diff --git a/codex-rs/ansi-escape/src/lib.rs b/codex-rs/ansi-escape/src/lib.rs index 68ea5e9aa9..b47cf14f8e 100644 --- a/codex-rs/ansi-escape/src/lib.rs +++ b/codex-rs/ansi-escape/src/lib.rs @@ -3,11 +3,30 @@ use ansi_to_tui::IntoText; use ratatui::text::Line; use ratatui::text::Text; +// Expand tabs in a best-effort way for transcript rendering. +// Tabs can interact poorly with left-gutter prefixes in our TUI and CLI +// transcript views (e.g., `nl` separates line numbers from content with a tab). +// Replacing tabs with spaces avoids odd visual artifacts without changing +// semantics for our use cases. +fn expand_tabs(s: &str) -> std::borrow::Cow<'_, str> { + if s.contains('\t') { + // Keep it simple: replace each tab with 4 spaces. + // We do not try to align to tab stops since most usages (like `nl`) + // look acceptable with a fixed substitution and this avoids stateful math + // across spans. + std::borrow::Cow::Owned(s.replace('\t', " ")) + } else { + std::borrow::Cow::Borrowed(s) + } +} + /// This function should be used when the contents of `s` are expected to match /// a single line. If multiple lines are found, a warning is logged and only the /// first line is returned. pub fn ansi_escape_line(s: &str) -> Line<'static> { - let text = ansi_escape(s); + // Normalize tabs to spaces to avoid odd gutter collisions in transcript mode. + let s = expand_tabs(s); + let text = ansi_escape(&s); match text.lines.as_slice() { [] => "".into(), [only] => only.clone(), diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cb3dea5e60..6edc1a0ed3 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -421,10 +421,8 @@ impl App { kind: KeyEventKind::Press, .. } => { - // Enter alternate screen and set viewport to full size. - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); - tui.frame_requester().schedule_frame(); + // Open transcript overlay: flush explore stack, enter alt screen, and enable transcript mode. + self.open_transcript_overlay(tui); } // Esc primes/advances backtracking only in normal (not working) mode // with an empty composer. In any other state, forward Esc so the diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b5c1300b45..f6d4ab4a4b 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -109,6 +109,9 @@ impl App { /// Open transcript overlay (enters alternate screen and shows full transcript). pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { + // Flush any in‑progress explore stack so it lands in history first. + self.chat_widget.flush_active_cell(); + self.chat_widget.set_transcript_mode(true); let _ = tui.enter_alt_screen(); self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); tui.frame_requester().schedule_frame(); @@ -117,6 +120,8 @@ impl App { /// Close transcript overlay and restore normal UI. pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) { let _ = tui.leave_alt_screen(); + // Leave transcript mode so future tool calls return to the explore stack. + self.chat_widget.set_transcript_mode(false); let was_backtrack = self.backtrack.overlay_preview_active; if !self.deferred_history_lines.is_empty() { let lines = std::mem::take(&mut self.deferred_history_lines); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2237c678c7..65b7c4223d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -260,6 +260,9 @@ pub(crate) struct ChatWidget { needs_final_message_separator: bool, last_rendered_width: std::cell::Cell>, + + // When true, route tool calls directly into history (transcript mode). + transcript_mode_active: bool, } struct UserMessage { @@ -285,6 +288,12 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio } impl ChatWidget { + /// Enable or disable transcript mode. When enabled, tool calls are + /// recorded directly into history instead of the active explore stack. + pub(crate) fn set_transcript_mode(&mut self, active: bool) { + self.transcript_mode_active = active; + } + fn model_description_for(slug: &str) -> Option<&'static str> { if slug.starts_with("gpt-5-codex") { Some("Optimized for coding tasks with many tools.") @@ -706,6 +715,24 @@ impl ChatWidget { None => (vec![ev.call_id.clone()], Vec::new()), }; + // In transcript mode, avoid the active explore stack and emit a + // completed exec cell directly to history. + if self.transcript_mode_active { + let mut cell = new_active_exec_command(ev.call_id.clone(), command, parsed); + cell.complete_call( + &ev.call_id, + CommandOutput { + exit_code: ev.exit_code, + stdout: ev.stdout.clone(), + stderr: ev.stderr.clone(), + formatted_output: ev.formatted_output.clone(), + }, + ev.duration, + ); + self.add_boxed_history(Box::new(cell)); + return; + } + let needs_new = self .active_cell .as_ref() @@ -797,6 +824,11 @@ impl ChatWidget { parsed_cmd: ev.parsed_cmd.clone(), }, ); + // In transcript mode, skip creating/updating the active explore stack. + if self.transcript_mode_active { + self.request_redraw(); + return; + } if let Some(cell) = self .active_cell .as_mut() @@ -823,6 +855,12 @@ impl ChatWidget { pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); + if self.transcript_mode_active { + // In transcript mode, don't create an active cell; history will be + // emitted on the corresponding end event. + self.request_redraw(); + return; + } self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( ev.call_id, @@ -840,6 +878,17 @@ impl ChatWidget { result, } = ev; + // In transcript mode, bypass the active stack and emit a completed tool call. + if self.transcript_mode_active { + let mut cell = history_cell::new_active_mcp_tool_call(call_id, invocation); + let extra = cell.complete(duration, result); + self.add_boxed_history(Box::new(cell)); + if let Some(extra_cell) = extra { + self.add_boxed_history(extra_cell); + } + return; + } + let extra_cell = match self .active_cell .as_mut() @@ -938,6 +987,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + transcript_mode_active: false, } } @@ -1001,6 +1051,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + transcript_mode_active: false, } } @@ -1225,7 +1276,7 @@ impl ChatWidget { } } - fn flush_active_cell(&mut self) { + pub(crate) fn flush_active_cell(&mut self) { if let Some(active) = self.active_cell.take() { self.needs_final_message_separator = true; self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0ab31daa32..44bc69b2fc 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -287,6 +287,7 @@ fn make_chatwidget_manual() -> ( ghost_snapshots_disabled: false, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + transcript_mode_active: false, }; (widget, rx, op_rx) }