diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a58070c0..2585c6c2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -140,10 +140,17 @@ impl App { fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { - AppEvent::InsertHistory(lines) => { + AppEvent::InsertHistoryLines(lines) => { self.transcript_lines.extend(lines.clone()); tui.insert_history_lines(lines); } + AppEvent::InsertHistoryCell(cell) => { + self.transcript_lines.extend(cell.transcript_lines()); + let display = cell.display_lines(); + if !display.is_empty() { + tui.insert_history_lines(display); + } + } AppEvent::StartCommitAnimation => { if self .commit_anim_running diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 325b2d5b..31ac032b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -2,6 +2,8 @@ use codex_core::protocol::Event; use codex_file_search::FileMatch; use ratatui::text::Line; +use crate::history_cell::HistoryCell; + use crate::slash_command::SlashCommand; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; @@ -39,7 +41,8 @@ pub(crate) enum AppEvent { /// Result of computing a `/diff` command. DiffResult(String), - 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 ca166d7b..b39cf7aa 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -98,6 +98,8 @@ pub(crate) struct ChatWidget { needs_redraw: bool, // Accumulates the current reasoning block text to extract a header reasoning_buffer: String, + // Accumulates full reasoning content for transcript-only recording + full_reasoning_buffer: String, session_id: Option, frame_requester: FrameRequester, } @@ -138,7 +140,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); } @@ -172,13 +174,23 @@ impl ChatWidget { } fn on_agent_reasoning_final(&mut self) { - // Clear the reasoning buffer at the end of a reasoning block. + // At the end of a reasoning block, record transcript-only content. + 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. + // Start a new reasoning block for header extraction and accumulate transcript. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + self.full_reasoning_buffer.push_str("\n\n"); self.reasoning_buffer.clear(); } @@ -188,6 +200,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(); } @@ -216,7 +229,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(); @@ -224,7 +237,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) { @@ -259,7 +272,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, }, @@ -386,7 +399,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, )); } @@ -398,9 +411,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)); } } @@ -422,7 +435,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(), )); @@ -464,11 +477,11 @@ 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( + self.add_boxed_history(history_cell::new_completed_mcp_tool_call( 80, ev.invocation, ev.duration, @@ -541,6 +554,7 @@ impl ChatWidget { interrupts: InterruptManager::new(), needs_redraw: false, reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), session_id: None, } } @@ -573,14 +587,19 @@ 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 add_boxed_history(&mut self, cell: Box) { + self.flush_active_exec_cell(); + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } fn submit_user_message(&mut self, user_message: UserMessage) { @@ -616,7 +635,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())); } } @@ -692,12 +711,12 @@ impl ChatWidget { pub(crate) fn add_diff_output(&mut self, diff_output: String) { self.bottom_pane.set_task_running(false); - self.add_to_history(&history_cell::new_diff_output(diff_output)); + self.add_to_history(history_cell::new_diff_output(diff_output)); self.mark_needs_redraw(); } 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, @@ -808,7 +827,7 @@ impl ChatWidget { pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { - self.add_to_history(&history_cell::empty_mcp_output()); + self.add_to_history(history_cell::empty_mcp_output()); } else { self.submit_op(Op::ListMcpTools); } @@ -856,7 +875,7 @@ impl ChatWidget { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(&history_cell::new_mcp_tools_output(&self.config, ev.tools)); + self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools)); } /// Programmatically submit a user text message as if typed in the diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a7959b1f..42dcddeb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -150,6 +150,7 @@ fn make_chatwidget_manual() -> ( interrupts: InterruptManager::new(), needs_redraw: false, reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), session_id: None, frame_requester: crate::tui::FrameRequester::test_dummy(), }; @@ -161,8 +162,10 @@ fn drain_insert_history( ) -> Vec>> { let mut out = Vec::new(); while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { - out.push(lines); + match ev { + AppEvent::InsertHistoryLines(lines) => out.push(lines), + AppEvent::InsertHistoryCell(cell) => out.push(cell.display_lines()), + _ => {} } } out @@ -336,13 +339,25 @@ async fn binary_size_transcript_matches_ideal_fixture() { let ev: Event = serde_json::from_value(payload.clone()).expect("parse"); chat.handle_codex_event(ev); while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = app_ev { - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + match app_ev { + AppEvent::InsertHistoryLines(lines) => { + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + AppEvent::InsertHistoryCell(cell) => { + let lines = cell.display_lines(); + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + _ => {} } } } @@ -353,13 +368,25 @@ async fn binary_size_transcript_matches_ideal_fixture() { { chat.on_commit_tick(); while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = app_ev { - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + match app_ev { + AppEvent::InsertHistoryLines(lines) => { + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + AppEvent::InsertHistoryCell(cell) => { + let lines = cell.display_lines(); + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + _ => {} } } } @@ -809,7 +836,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { }); let mut saw_codex_pre = false; while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { + if let AppEvent::InsertHistoryLines(lines) = ev { let s = lines .iter() .flat_map(|l| l.spans.iter()) @@ -837,7 +864,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { chat.on_commit_tick(); let mut saw_codex_post = false; while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { + if let AppEvent::InsertHistoryLines(lines) = ev { let s = lines .iter() .flat_map(|l| l.spans.iter()) @@ -865,7 +892,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { }); let mut saw_thinking = false; while let Ok(ev) = rx2.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { + if let AppEvent::InsertHistoryLines(lines) = ev { let s = lines .iter() .flat_map(|l| l.spans.iter()) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 40623935..8e3a5fb9 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,6 +1,7 @@ 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 base64::Engine; @@ -39,7 +40,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 +55,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 +71,7 @@ pub(crate) trait HistoryCell { } } +#[derive(Debug)] pub(crate) struct PlainHistoryCell { lines: Vec>, } @@ -76,6 +82,22 @@ impl HistoryCell for PlainHistoryCell { } } +#[derive(Debug)] +pub(crate) struct TranscriptOnlyHistoryCell { + lines: Vec>, +} + +impl HistoryCell for TranscriptOnlyHistoryCell { + fn display_lines(&self) -> Vec> { + Vec::new() + } + + fn transcript_lines(&self) -> Vec> { + self.lines.clone() + } +} + +#[derive(Debug)] pub(crate) struct ExecCell { pub(crate) command: Vec, pub(crate) parsed: Vec, @@ -101,6 +123,7 @@ impl WidgetRef for &ExecCell { } } +#[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, } @@ -930,6 +953,17 @@ pub(crate) fn new_patch_apply_success(stdout: String) -> PlainHistoryCell { 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 } +} + fn output_lines( output: Option<&CommandOutput>, only_err: bool, diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 03334f7f..d1dc4532 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -143,7 +143,7 @@ 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::InsertHistoryLines(lines) => { let value = json!({ "ts": now_ts(), "dir": "to_tui", @@ -152,6 +152,15 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } + AppEvent::InsertHistoryCell(cell) => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "insert_history_cell", + "lines": cell.transcript_lines().len(), + }); + LOGGER.write_json_line(value); + } AppEvent::StartFileSearch(query) => { let value = json!({ "ts": now_ts(), diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 050e6154..5fb60e78 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/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index f55ebba7..deb2c8ff 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -328,7 +328,7 @@ impl UserApprovalWidget { } } lines.push(Line::from("")); - self.app_event_tx.send(AppEvent::InsertHistory(lines)); + self.app_event_tx.send(AppEvent::InsertHistoryLines(lines)); let op = match &self.approval_request { ApprovalRequest::Exec { id, .. } => Op::ExecApproval {