From b47251cd9d43b2eb8bec992274372cd58f539783 Mon Sep 17 00:00:00 2001 From: Jeremy Rose Date: Thu, 14 Aug 2025 21:32:21 -0700 Subject: [PATCH] ctrl-t opens a 'transcript' in less --- codex-rs/Cargo.lock | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 46 +++++++- codex-rs/tui/src/app_event.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 71 ++++++++----- codex-rs/tui/src/history_cell.rs | 130 +++++++++++++++++++++-- codex-rs/tui/src/insert_history.rs | 7 ++ codex-rs/tui/src/lib.rs | 2 +- codex-rs/tui/src/session_log.rs | 4 +- codex-rs/tui/src/streaming/controller.rs | 2 +- codex-rs/tui/src/tui.rs | 36 +++---- codex-rs/tui/src/user_approval_widget.rs | 77 +------------- 12 files changed, 247 insertions(+), 134 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ef07a36daf..7727741e51 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -924,6 +924,7 @@ dependencies = [ "strum 0.27.2", "strum_macros 0.27.2", "supports-color", + "tempfile", "textwrap 0.16.2", "tokio", "tracing", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 182667a060..6a342fe328 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -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"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ee054e037a..7fd53be880 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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>, + transcript: Vec>, 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, + event_reader_enabled: Arc, } /// 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, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6b9999848c..a080ef02b3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, }, - InsertHistory(Vec>), + InsertHistoryLines(Vec>), + InsertHistoryCell(Box), StartCommitAnimation, StopCommitAnimation, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 56b26e471e..9fd3f6b057 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, } @@ -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. diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2a17ff1644..2e8e189841 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -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>` 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>; + fn transcript_lines(&self) -> Vec> { + 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>, } @@ -76,6 +85,22 @@ impl HistoryCell for PlainHistoryCell { } } +#[derive(Debug)] +pub(crate) struct TranscriptOnlyHistoryCell { + lines: Vec>, +} + +impl HistoryCell for TranscriptOnlyHistoryCell { + fn display_lines(&self) -> Vec> { + vec![] + } + + fn transcript_lines(&self) -> Vec> { + self.lines.clone() + } +} + +#[derive(Debug)] pub(crate) struct ExecCell { pub(crate) command: Vec, pub(crate) parsed: Vec, @@ -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, ) -> Option { match result { @@ -387,11 +413,7 @@ pub(crate) fn new_completed_mcp_tool_call( duration: Duration, success: bool, result: Result, -) -> Box { - 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> = 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> = Vec::new(); + lines.push(Line::from("thinking".magenta().italic())); + append_markdown(&full_reasoning_buffer, &mut lines, config); + lines.push(Line::from("")); + TranscriptOnlyHistoryCell { lines } +} diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index ced667af8d..86da535a43 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -109,6 +109,13 @@ pub fn insert_history_lines_to_writer( } } +pub fn write_lines(writer: &mut impl Write, lines: Vec) { + 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); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f1a7d99b79..2815dd2c1b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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}" ); diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index e94f58436f..9058016fe1 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -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); } diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 050e6154b9..5fb60e78a5 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -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>) { self.0 - .send(crate::app_event::AppEvent::InsertHistory(lines)) + .send(crate::app_event::AppEvent::InsertHistoryLines(lines)) } fn start_commit_animation(&self) { self.0 diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 0447e32ae9..8acc0befc6 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -23,23 +23,7 @@ pub type Tui = Terminal>; /// Initialize the terminal (inline viewport; history stays in normal scrollback) pub fn init(_config: &Config) -> Result { - 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 { 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)?; diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index 7a4e3d9008..ad29d6f512 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -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>, cmd: String, last_line: Vec>, @@ -258,77 +259,9 @@ impl UserApprovalWidget<'_> { } fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) { - let mut lines: Vec> = 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 {