ctrl-t opens a 'transcript' in less

This commit is contained in:
Jeremy Rose
2025-08-14 21:32:21 -07:00
parent 70c24de906
commit b47251cd9d
12 changed files with 247 additions and 134 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -924,6 +924,7 @@ dependencies = [
"strum 0.27.2",
"strum_macros 0.27.2",
"supports-color",
"tempfile",
"textwrap 0.16.2",
"tokio",
"tracing",

View File

@@ -66,6 +66,7 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
tempfile = "3"
tracing = { version = "0.1.41", features = ["log"] }
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View File

@@ -3,6 +3,7 @@ use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::insert_history::write_lines;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::OnboardingScreen;
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
@@ -22,7 +23,9 @@ use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Offset;
use ratatui::prelude::Backend;
use ratatui::text::Line;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
@@ -31,6 +34,7 @@ use std::sync::mpsc::channel;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use tempfile::NamedTempFile;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
@@ -61,6 +65,7 @@ pub(crate) struct App<'a> {
file_search: FileSearchManager,
pending_history_lines: Vec<Line<'static>>,
transcript: Vec<Line<'static>>,
enhanced_keys_supported: bool,
@@ -70,6 +75,7 @@ pub(crate) struct App<'a> {
/// Channel to schedule one-shot animation frames; coalesced by a single
/// scheduler thread.
frame_schedule_tx: std::sync::mpsc::Sender<Instant>,
event_reader_enabled: Arc<AtomicBool>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -93,6 +99,7 @@ impl App<'_> {
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let event_reader_enabled = Arc::new(AtomicBool::new(true));
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
@@ -100,8 +107,13 @@ impl App<'_> {
// re-publishing the events as AppEvents, as appropriate.
{
let app_event_tx = app_event_tx.clone();
let events_enabled = event_reader_enabled.clone();
std::thread::spawn(move || {
loop {
if !events_enabled.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(50));
continue;
}
// This timeout is necessary to avoid holding the event lock
// that crossterm::event::read() acquires. In particular,
// reading the cursor position (crossterm::cursor::position())
@@ -215,6 +227,7 @@ impl App<'_> {
server: conversation_manager,
app_event_tx,
pending_history_lines: Vec::new(),
transcript: Vec::new(),
app_event_rx,
app_state,
config,
@@ -222,6 +235,7 @@ impl App<'_> {
enhanced_keys_supported,
commit_anim_running: Arc::new(AtomicBool::new(false)),
frame_schedule_tx: frame_tx,
event_reader_enabled,
}
}
@@ -235,10 +249,16 @@ impl App<'_> {
while let Ok(event) = self.app_event_rx.recv() {
match event {
AppEvent::InsertHistory(lines) => {
AppEvent::InsertHistoryLines(lines) => {
self.transcript.extend(lines.clone());
self.pending_history_lines.extend(lines);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::InsertHistoryCell(cell) => {
self.transcript.extend(cell.transcript_lines());
self.pending_history_lines.extend(cell.display_lines());
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RequestRedraw => {
self.schedule_frame_in(REDRAW_DEBOUNCE);
}
@@ -299,6 +319,28 @@ impl App<'_> {
}
// No-op on non-Unix platforms.
}
KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.event_reader_enabled.store(false, Ordering::Relaxed);
let mut tmp = NamedTempFile::new()?;
write_lines(tmp.as_file_mut(), self.transcript.clone());
tmp.flush().ok();
let path = tmp.into_temp_path();
tui::restore_modes()?;
let _ = Command::new("less")
.arg("-R")
.arg("+G")
.arg(path.as_os_str())
.status();
let _ = path.close();
tui::set_modes()?;
self.event_reader_enabled.store(true, Ordering::Relaxed);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
@@ -499,7 +541,7 @@ impl App<'_> {
#[cfg(unix)]
fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
tui::restore()?;
tui::restore_modes()?;
// SAFETY: Unix-only code path. We intentionally send SIGTSTP to the
// current process group (pid 0) to trigger standard job-control
// suspension semantics. This FFI does not involve any raw pointers,

View File

@@ -5,6 +5,7 @@ use ratatui::text::Line;
use std::time::Duration;
use crate::app::ChatWidgetArgs;
use crate::history_cell::HistoryCell;
use crate::slash_command::SlashCommand;
#[allow(clippy::large_enum_variant)]
@@ -51,7 +52,8 @@ pub(crate) enum AppEvent {
matches: Vec<FileMatch>,
},
InsertHistory(Vec<Line<'static>>),
InsertHistoryLines(Vec<Line<'static>>),
InsertHistoryCell(Box<dyn HistoryCell>),
StartCommitAnimation,
StopCommitAnimation,

View File

@@ -86,6 +86,7 @@ pub(crate) struct ChatWidget<'a> {
needs_redraw: bool,
// Accumulates the current reasoning block text to extract a header
reasoning_buffer: String,
full_reasoning_buffer: String,
session_id: Option<Uuid>,
}
@@ -125,7 +126,7 @@ impl ChatWidget<'_> {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.session_id = Some(event.session_id);
self.add_to_history(&history_cell::new_session_info(&self.config, event, true));
self.add_to_history(history_cell::new_session_info(&self.config, event, true));
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
@@ -160,12 +161,22 @@ impl ChatWidget<'_> {
fn on_agent_reasoning_final(&mut self) {
// Clear the reasoning buffer at the end of a reasoning block.
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
if !self.full_reasoning_buffer.is_empty() {
self.add_to_history(history_cell::new_reasoning_block(
self.full_reasoning_buffer.clone(),
&self.config,
));
}
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.mark_needs_redraw();
}
fn on_reasoning_section_break(&mut self) {
// Start a new reasoning block for header extraction.
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
self.full_reasoning_buffer.push_str("\n\n");
self.reasoning_buffer.clear();
}
@@ -175,6 +186,7 @@ impl ChatWidget<'_> {
self.bottom_pane.clear_ctrl_c_quit_hint();
self.bottom_pane.set_task_running(true);
self.stream.reset_headers_for_new_turn();
self.full_reasoning_buffer.clear();
self.reasoning_buffer.clear();
self.mark_needs_redraw();
}
@@ -203,7 +215,7 @@ impl ChatWidget<'_> {
}
fn on_error(&mut self, message: String) {
self.add_to_history(&history_cell::new_error_event(message));
self.add_to_history(history_cell::new_error_event(message));
self.bottom_pane.set_task_running(false);
self.running_commands.clear();
self.stream.clear_all();
@@ -211,7 +223,7 @@ impl ChatWidget<'_> {
}
fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
self.add_to_history(&history_cell::new_plan_update(update));
self.add_to_history(history_cell::new_plan_update(update));
}
fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) {
@@ -246,7 +258,7 @@ impl ChatWidget<'_> {
}
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
self.add_to_history(&history_cell::new_patch_event(
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApplyBegin {
auto_approved: event.auto_approved,
},
@@ -373,7 +385,7 @@ impl ChatWidget<'_> {
self.active_exec_cell = None;
let pending = std::mem::take(&mut self.pending_exec_completions);
for (command, parsed, output) in pending {
self.add_to_history(&history_cell::new_completed_exec_command(
self.add_to_history(history_cell::new_completed_exec_command(
command, parsed, output,
));
}
@@ -385,9 +397,9 @@ impl ChatWidget<'_> {
event: codex_core::protocol::PatchApplyEndEvent,
) {
if event.success {
self.add_to_history(&history_cell::new_patch_apply_success(event.stdout));
self.add_to_history(history_cell::new_patch_apply_success(event.stdout));
} else {
self.add_to_history(&history_cell::new_patch_apply_failure(event.stderr));
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
}
}
@@ -409,7 +421,7 @@ impl ChatWidget<'_> {
ev: ApplyPatchApprovalRequestEvent,
) {
self.flush_answer_stream_with_separator();
self.add_to_history(&history_cell::new_patch_event(
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApprovalRequest,
ev.changes.clone(),
));
@@ -451,20 +463,26 @@ impl ChatWidget<'_> {
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
self.flush_answer_stream_with_separator();
self.add_to_history(&history_cell::new_active_mcp_tool_call(ev.invocation));
self.add_to_history(history_cell::new_active_mcp_tool_call(ev.invocation));
}
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
self.flush_answer_stream_with_separator();
self.add_to_history(&*history_cell::new_completed_mcp_tool_call(
80,
ev.invocation,
ev.duration,
ev.result
.as_ref()
.map(|r| !r.is_error.unwrap_or(false))
.unwrap_or(false),
ev.result,
));
if let Some(cell) =
history_cell::try_new_completed_mcp_tool_call_with_image_output(&ev.result)
{
self.add_to_history(cell);
} else {
self.add_to_history(history_cell::new_completed_mcp_tool_call(
80,
ev.invocation,
ev.duration,
ev.result
.as_ref()
.map(|r| !r.is_error.unwrap_or(false))
.unwrap_or(false),
ev.result,
));
}
}
fn interrupt_running_task(&mut self) {
if self.bottom_pane.is_task_running() {
@@ -522,6 +540,7 @@ impl ChatWidget<'_> {
interrupts: InterruptManager::new(),
needs_redraw: false,
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
}
}
@@ -554,14 +573,14 @@ impl ChatWidget<'_> {
fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() {
self.app_event_tx
.send(AppEvent::InsertHistory(active.display_lines()));
.send(AppEvent::InsertHistoryCell(Box::new(active)));
}
}
fn add_to_history(&mut self, cell: &dyn HistoryCell) {
fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
self.flush_active_exec_cell();
self.app_event_tx
.send(AppEvent::InsertHistory(cell.display_lines()));
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
}
fn submit_user_message(&mut self, user_message: UserMessage) {
@@ -597,7 +616,7 @@ impl ChatWidget<'_> {
// Only show the text portion in conversation history.
if !text.is_empty() {
self.add_to_history(&history_cell::new_user_prompt(text.clone()));
self.add_to_history(history_cell::new_user_prompt(text.clone()));
}
}
@@ -663,11 +682,11 @@ impl ChatWidget<'_> {
}
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
self.add_to_history(&history_cell::new_diff_output(diff_output.clone()));
self.add_to_history(history_cell::new_diff_output(diff_output.clone()));
}
pub(crate) fn add_status_output(&mut self) {
self.add_to_history(&history_cell::new_status_output(
self.add_to_history(history_cell::new_status_output(
&self.config,
&self.total_token_usage,
&self.session_id,
@@ -675,7 +694,7 @@ impl ChatWidget<'_> {
}
pub(crate) fn add_prompts_output(&mut self) {
self.add_to_history(&history_cell::new_prompts_output());
self.add_to_history(history_cell::new_prompts_output());
}
/// Forward file-search results to the bottom pane.

View File

@@ -2,8 +2,11 @@ use crate::colors::LIGHT_BLUE;
use crate::diff_render::create_diff_summary;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::slash_command::SlashCommand;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::user_approval_widget::ApprovalRequest;
use crate::user_approval_widget::to_command_display;
use base64::Engine;
use codex_ansi_escape::ansi_escape_line;
use codex_common::create_config_summary_entries;
@@ -15,6 +18,7 @@ use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::ReviewDecision;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::TokenUsage;
@@ -39,7 +43,7 @@ use std::time::Instant;
use tracing::error;
use uuid::Uuid;
#[derive(Clone)]
#[derive(Clone, Debug)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
@@ -54,9 +58,13 @@ pub(crate) enum PatchEventType {
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
pub(crate) trait HistoryCell {
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync {
fn display_lines(&self) -> Vec<Line<'static>>;
fn transcript_lines(&self) -> Vec<Line<'static>> {
self.display_lines()
}
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.display_lines()))
.wrap(Wrap { trim: false })
@@ -66,6 +74,7 @@ pub(crate) trait HistoryCell {
}
}
#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
lines: Vec<Line<'static>>,
}
@@ -76,6 +85,22 @@ impl HistoryCell for PlainHistoryCell {
}
}
#[derive(Debug)]
pub(crate) struct TranscriptOnlyHistoryCell {
lines: Vec<Line<'static>>,
}
impl HistoryCell for TranscriptOnlyHistoryCell {
fn display_lines(&self) -> Vec<Line<'static>> {
vec![]
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
}
}
#[derive(Debug)]
pub(crate) struct ExecCell {
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
@@ -101,7 +126,8 @@ impl WidgetRef for &ExecCell {
}
}
struct CompletedMcpToolCallWithImageOutput {
#[derive(Debug)]
pub(crate) struct CompletedMcpToolCallWithImageOutput {
_image: DynamicImage,
}
impl HistoryCell for CompletedMcpToolCallWithImageOutput {
@@ -343,7 +369,7 @@ pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistor
/// If the first content is an image, return a new cell with the image.
/// TODO(rgwood-dd): Handle images properly even if they're not the first result.
fn try_new_completed_mcp_tool_call_with_image_output(
pub fn try_new_completed_mcp_tool_call_with_image_output(
result: &Result<mcp_types::CallToolResult, String>,
) -> Option<CompletedMcpToolCallWithImageOutput> {
match result {
@@ -387,11 +413,7 @@ pub(crate) fn new_completed_mcp_tool_call(
duration: Duration,
success: bool,
result: Result<mcp_types::CallToolResult, String>,
) -> Box<dyn HistoryCell> {
if let Some(cell) = try_new_completed_mcp_tool_call_with_image_output(&result) {
return Box::new(cell);
}
) -> PlainHistoryCell {
let duration = format_duration(duration);
let status_str = if success { "success" } else { "failed" };
let title_line = Line::from(vec![
@@ -459,7 +481,7 @@ pub(crate) fn new_completed_mcp_tool_call(
}
};
Box::new(PlainHistoryCell { lines })
PlainHistoryCell { lines }
}
pub(crate) fn new_diff_output(message: String) -> PlainHistoryCell {
@@ -910,3 +932,91 @@ mod tests {
assert_eq!(lines[2].spans[0].content, " ");
}
}
pub(crate) fn new_exec_approval_decision(
approval_request: &ApprovalRequest,
decision: codex_core::protocol::ReviewDecision,
feedback: String,
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
match approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
let mut cmd_span: Span = cmd.clone().into();
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
// Result line based on decision.
match decision {
ReviewDecision::Approved => {
lines.extend(to_command_display(
vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
],
cmd,
vec![" this time".bold()],
));
}
ReviewDecision::ApprovedForSession => {
lines.extend(to_command_display(
vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
"codex to run ".into(),
],
cmd,
vec![" every time this session".bold()],
));
}
ReviewDecision::Denied => {
lines.extend(to_command_display(
vec![
"".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
],
cmd,
vec![],
));
}
ReviewDecision::Abort => {
lines.extend(to_command_display(
vec![
"".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
],
cmd,
vec![],
));
}
}
}
ApprovalRequest::ApplyPatch { .. } => {
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
}
}
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {
lines.push(Line::from(l.to_string()));
}
}
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String,
config: &Config,
) -> TranscriptOnlyHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("thinking".magenta().italic()));
append_markdown(&full_reasoning_buffer, &mut lines, config);
lines.push(Line::from(""));
TranscriptOnlyHistoryCell { lines }
}

View File

@@ -109,6 +109,13 @@ pub fn insert_history_lines_to_writer<B, W>(
}
}
pub fn write_lines(writer: &mut impl Write, lines: Vec<Line>) {
for line in lines {
queue!(writer, Print("\r\n")).ok();
write_spans(writer, line.iter()).ok();
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetScrollRegion(pub std::ops::Range<u16>);

View File

@@ -288,7 +288,7 @@ fn run_ratatui_app(
reason = "TUI should no longer be displayed, so we can write to stderr."
)]
fn restore() {
if let Err(err) = tui::restore() {
if let Err(err) = tui::restore_modes() {
eprintln!(
"failed to restore terminal. Run `reset` or restart your terminal to recover: {err}"
);

View File

@@ -160,12 +160,12 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
LOGGER.write_json_line(value);
}
// Internal UI events; still log for fidelity, but avoid heavy payloads.
AppEvent::InsertHistory(lines) => {
AppEvent::InsertHistoryCell(cell) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "insert_history",
"lines": lines.len(),
"lines": cell.transcript_lines().len(),
});
LOGGER.write_json_line(value);
}

View File

@@ -17,7 +17,7 @@ pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEve
impl HistorySink for AppEventHistorySink {
fn insert_history(&self, lines: Vec<Line<'static>>) {
self.0
.send(crate::app_event::AppEvent::InsertHistory(lines))
.send(crate::app_event::AppEvent::InsertHistoryLines(lines))
}
fn start_commit_animation(&self) {
self.0

View File

@@ -23,23 +23,7 @@ pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
pub fn init(_config: &Config) -> Result<Tui> {
execute!(stdout(), EnableBracketedPaste)?;
enable_raw_mode()?;
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
// chat_composer.rs is using a keyboard event listener to enter for any modified keys
// to create a new line that require this.
// Some terminals (notably legacy Windows consoles) do not support
// keyboard enhancement flags. Attempt to enable them, but continue
// gracefully if unsupported.
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
set_modes()?;
set_panic_hook();
// Clear screen and move cursor to top-left before drawing UI
@@ -53,13 +37,27 @@ pub fn init(_config: &Config) -> Result<Tui> {
fn set_panic_hook() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = restore(); // ignore any errors as we are already failing
let _ = restore_modes(); // ignore any errors as we are already failing
hook(panic_info);
}));
}
pub fn set_modes() -> Result<()> {
execute!(stdout(), EnableBracketedPaste)?;
enable_raw_mode()?;
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
Ok(())
}
/// Restore the terminal to its original state
pub fn restore() -> Result<()> {
pub fn restore_modes() -> Result<()> {
// Pop may fail on platforms that didn't support the push; ignore errors.
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
execute!(stdout(), DisableBracketedPaste)?;

View File

@@ -29,6 +29,7 @@ use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
@@ -109,7 +110,7 @@ pub(crate) struct UserApprovalWidget<'a> {
done: bool,
}
fn to_command_display<'a>(
pub fn to_command_display<'a>(
first_line: Vec<Span<'a>>,
cmd: String,
last_line: Vec<Span<'a>>,
@@ -258,77 +259,9 @@ impl UserApprovalWidget<'_> {
}
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
let mut lines: Vec<Line<'static>> = Vec::new();
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
let mut cmd_span: Span = cmd.clone().into();
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
// Result line based on decision.
match decision {
ReviewDecision::Approved => {
lines.extend(to_command_display(
vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
],
cmd,
vec![" this time".bold()],
));
}
ReviewDecision::ApprovedForSession => {
lines.extend(to_command_display(
vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
"codex to run ".into(),
],
cmd,
vec![" every time this session".bold()],
));
}
ReviewDecision::Denied => {
lines.extend(to_command_display(
vec![
"".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
],
cmd,
vec![],
));
}
ReviewDecision::Abort => {
lines.extend(to_command_display(
vec![
"".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
],
cmd,
vec![],
));
}
}
}
ApprovalRequest::ApplyPatch { .. } => {
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
}
}
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {
lines.push(Line::from(l.to_string()));
}
}
lines.push(Line::from(""));
self.app_event_tx.send(AppEvent::InsertHistory(lines));
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_exec_approval_decision(&self.approval_request, decision, feedback),
)));
let op = match &self.approval_request {
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {