From 5e0a4adbe564cff56edc6d3a844181ce1df7794b Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 5 May 2026 15:17:47 -0300 Subject: [PATCH] feat(tui): add raw scrollback mode (#20819) ## Why Granular copy is particularly difficult with the current output. Part of it was solved with the introduction of the `/copy` command but when you only need to copy parts of a response, you still encounter some issues: - When you copy a paragraph, the result is a sequence of separate lines instead of one correctly joined paragraph. - When a word wraps, part of it stays on the original line and the rest appears at the start of the next line. - When you copy a long command, extra line breaks are often inserted, and command arguments can be split across multiple lines. https://github.com/user-attachments/assets/0ef85c84-9363-4aad-b43a-15fce062a443 ## Solution Now that we own the scrollback and we re-create it when we resize, we have the opportunity of toggling between the raw text and the rich text we see today. - Add TUI raw scrollback mode with `tui.raw_output_mode`, `/raw [on|off]`, and the configurable `tui.keymap.global.toggle_raw_output` action. - Render transcript cells through rich/raw-aware paths so raw mode preserves source text and lets the terminal soft-wrap selection-friendly output. - Bind raw-mode toggle to `alt-r` by default, with the keybinding path toggling silently while `/raw` continues to emit confirmation messages. ## Related Issues Likely addressed by raw mode: - #12200: clean copy for multiline and soft-wrapped output. Raw mode removes Codex-inserted wrapping/indentation and lets the terminal soft-wrap logical lines. - #9252: command suggestions gain unwanted leading spaces when copied. Raw mode renders transcript text without the rich-mode left padding/gutter. - #8258: prompt output is hard to copy because of leading indentation. Raw mode renders user/source-backed transcript text without that decorative indentation. Partially or conditionally addressed: - #2880: copy/export message as Markdown. Raw mode exposes raw Markdown for terminal selection, but this PR does not add a dedicated export/copy-message command. - #19820: mouse drag selection + copy in the TUI. Raw mode improves terminal-native selection of output/history text, but this PR does not implement in-TUI mouse selection, highlighting, auto-copy, or composer selection. - #18979: copied content is divided into two parts. This should improve cases caused by Codex-inserted wraps/padding in rendered output; if the report is about pasting into the composer/input path, that remains outside this PR. ## Validation - `just write-config-schema` - `just fmt` - `cargo test -p codex-config` - `cargo test -p codex-tui` - `just fix -p codex-tui` - `just argument-comment-lint` - `cargo test -p codex-tui raw_output_mode_can_change_without_inserting_notice -- --nocapture` - `cargo test -p codex-tui raw_slash_command_toggles_and_accepts_on_off_args -- --nocapture` - `cargo test -p codex-tui raw_output_toggle -- --nocapture` - `git diff --check` - `cargo insta pending-snapshots` --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/config/src/types.rs | 5 + codex-rs/core/config.schema.json | 15 + codex-rs/core/src/config/config_tests.rs | 53 ++ codex-rs/core/src/config/mod.rs | 8 + codex-rs/thread-manager-sample/src/main.rs | 1 + codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app/input.rs | 26 + codex-rs/tui/src/app/resize_reflow.rs | 29 +- codex-rs/tui/src/app_backtrack.rs | 7 +- codex-rs/tui/src/app_event.rs | 5 + .../tui/src/bottom_pane/slash_commands.rs | 1 + .../tui/src/bottom_pane/status_line_setup.rs | 5 + .../tui/src/bottom_pane/status_line_style.rs | 2 +- .../src/bottom_pane/status_surface_preview.rs | 3 + codex-rs/tui/src/chatwidget.rs | 52 ++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 22 + .../tui/src/chatwidget/status_surfaces.rs | 2 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../src/chatwidget/tests/slash_commands.rs | 51 ++ .../src/chatwidget/tests/status_and_layout.rs | 41 ++ codex-rs/tui/src/exec_cell/render.rs | 5 + codex-rs/tui/src/history_cell.rs | 539 +++++++++++++++++- codex-rs/tui/src/history_cell/hook_cell.rs | 5 + codex-rs/tui/src/insert_history.rs | 63 +- codex-rs/tui/src/keymap.rs | 34 ++ codex-rs/tui/src/keymap_setup/actions.rs | 3 + codex-rs/tui/src/pager_overlay.rs | 4 + codex-rs/tui/src/slash_command.rs | 8 + ...ll__tests__raw_mode_toggle_transcript.snap | 58 ++ ...__tests__keymap_picker_all_tab_search.snap | 2 +- ...ap_setup__tests__keymap_picker_custom.snap | 4 +- ...ests__keymap_picker_fast_mode_enabled.snap | 4 +- ...p__tests__keymap_picker_first_actions.snap | 6 +- ...ap_setup__tests__keymap_picker_narrow.snap | 4 +- ...ymap_setup__tests__keymap_picker_wide.snap | 4 +- codex-rs/tui/src/status/card.rs | 5 + codex-rs/tui/src/streaming/controller.rs | 74 ++- codex-rs/tui/src/tui.rs | 43 +- 39 files changed, 1141 insertions(+), 58 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index fcce1fa8ab..3e8be83d6a 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -106,6 +106,8 @@ pub struct TuiGlobalKeymap { pub toggle_vim_mode: Option, /// Toggle Fast mode. pub toggle_fast_mode: Option, + /// Toggle raw scrollback mode for copy-friendly transcript selection. + pub toggle_raw_output: Option, } /// Chat context keybindings. diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index b856367a66..62ba242148 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -617,6 +617,11 @@ pub struct Tui { #[serde(default)] pub vim_mode_default: bool, + /// Start the TUI in raw scrollback mode for copy-friendly transcript output. + /// Defaults to `false`. + #[serde(default)] + pub raw_output_mode: bool, + /// Controls whether the TUI uses the terminal's alternate screen buffer. /// /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index acc64f14a3..a736b7a1d5 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2460,6 +2460,7 @@ "queue": null, "submit": null, "toggle_fast_mode": null, + "toggle_raw_output": null, "toggle_shortcuts": null, "toggle_vim_mode": null }, @@ -2558,6 +2559,11 @@ "default": true, "description": "Enable desktop notifications from the TUI. Defaults to `true`." }, + "raw_output_mode": { + "default": false, + "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", + "type": "boolean" + }, "show_tooltips": { "default": true, "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", @@ -2956,6 +2962,14 @@ ], "description": "Toggle Fast mode." }, + "toggle_raw_output": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle raw scrollback mode for copy-friendly transcript selection." + }, "toggle_shortcuts": { "allOf": [ { @@ -3062,6 +3076,7 @@ "queue": null, "submit": null, "toggle_fast_mode": null, + "toggle_raw_output": null, "toggle_shortcuts": null, "toggle_vim_mode": null } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b48cf020f2..dfd0fc36d5 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -550,6 +550,7 @@ fn config_toml_deserializes_model_availability_nux() { animations: true, show_tooltips: true, vim_mode_default: false, + raw_output_mode: false, alternate_screen: AltScreenMode::default(), status_line: None, status_line_use_colors: true, @@ -660,6 +661,53 @@ fn test_tui_vim_mode_default_true() { ); } +#[test] +fn test_tui_raw_output_mode_defaults_to_false() { + let toml = r#" + [tui] + "#; + let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table"); + assert!( + !parsed + .tui + .expect("config should include tui section") + .raw_output_mode + ); +} + +#[test] +fn test_tui_raw_output_mode_true() { + let toml = r#" + [tui] + raw_output_mode = true + "#; + let parsed: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true"); + assert!( + parsed + .tui + .expect("config should include tui section") + .raw_output_mode + ); +} + +#[tokio::test] +async fn runtime_config_uses_tui_raw_output_mode() { + let toml = r#" + [tui] + raw_output_mode = true + "#; + let cfg_toml: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true"); + let cfg = Config::load_from_base_config_with_overrides( + cfg_toml, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load config"); + + assert!(cfg.tui_raw_output_mode); +} + #[test] fn config_toml_deserializes_permission_profiles() { let toml = r#" @@ -2125,6 +2173,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { animations: true, show_tooltips: true, vim_mode_default: false, + raw_output_mode: false, alternate_screen: AltScreenMode::Auto, status_line: None, status_line_use_colors: true, @@ -6450,6 +6499,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6652,6 +6702,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6808,6 +6859,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6949,6 +7001,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3692ded0f9..7dcc625c0d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -517,6 +517,9 @@ pub struct Config { /// Start the composer in Vim mode (`Normal`) by default. pub tui_vim_mode_default: bool, + /// Start the TUI in raw scrollback mode for copy-friendly transcript output. + pub tui_raw_output_mode: bool, + /// Start the TUI in the specified collaboration mode (plan/default). /// Controls whether the TUI uses the terminal's alternate screen buffer. @@ -3147,6 +3150,11 @@ impl Config { .as_ref() .map(|t| t.vim_mode_default) .unwrap_or(false), + tui_raw_output_mode: cfg + .tui + .as_ref() + .map(|t| t.raw_output_mode) + .unwrap_or(false), tui_alternate_screen: cfg .tui .as_ref() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 7064d9e4ce..f350bce2cd 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -197,6 +197,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_raw_output_mode: false, terminal_resize_reflow: TerminalResizeReflowConfig::default(), tui_keymap: TuiKeymap::default(), tui_vim_mode_default: false, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index aefab7a1b3..4536688a9a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -35,6 +35,9 @@ impl App { ) .await; } + AppEvent::RawOutputModeChanged { enabled } => { + self.apply_raw_output_mode(tui, enabled, /*notify*/ false); + } AppEvent::ClearUiAndSubmitUserMessage { text } => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index f223db9bb3..905f62f86f 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -69,6 +69,25 @@ impl App { tui.frame_requester().schedule_frame(); } + pub(super) fn apply_raw_output_mode( + &mut self, + tui: &mut tui::Tui, + enabled: bool, + notify: bool, + ) { + if notify { + self.chat_widget.set_raw_output_mode_and_notify(enabled); + } else { + self.chat_widget.set_raw_output_mode(enabled); + } + if let Err(err) = self.reflow_transcript_now(tui) { + tracing::warn!(error = %err, "failed to reflow transcript after raw output mode toggle"); + self.chat_widget + .add_error_message(format!("Failed to redraw transcript: {err}")); + } + tui.frame_requester().schedule_frame(); + } + pub(super) async fn handle_key_event( &mut self, tui: &mut tui::Tui, @@ -137,6 +156,13 @@ impl App { return; } + if app_keymap_shortcuts_available && self.keymap.app.toggle_raw_output.is_pressed(key_event) + { + let enabled = !self.chat_widget.raw_output_mode(); + self.apply_raw_output_mode(tui, enabled, /*notify*/ false); + return; + } + if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 58b2e21dff..7775aed71b 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -26,6 +26,7 @@ use super::App; use super::InitialHistoryReplayBuffer; use crate::history_cell; use crate::history_cell::HistoryCell; +use crate::insert_history::HistoryLineWrapPolicy; use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE; use crate::tui; @@ -75,7 +76,8 @@ impl App { cell: &dyn HistoryCell, width: u16, ) -> Vec> { - let mut display = cell.display_lines(width); + let mut display = + cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); if !display.is_empty() && !cell.is_stream_continuation() { if self.has_emitted_history_lines { display.insert(0, Line::from("")); @@ -99,7 +101,7 @@ impl App { if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines(display); + tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); } } @@ -158,7 +160,7 @@ impl App { } let retained_lines = buffer.retained_lines.into_iter().collect::>(); - tui.insert_history_lines(retained_lines); + tui.insert_history_lines_with_wrap_policy(retained_lines, self.history_line_wrap_policy()); } pub(super) fn insert_history_cell_lines_with_initial_replay_buffer( @@ -188,11 +190,19 @@ impl App { } else if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines(display); + tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); } } } + pub(crate) fn history_line_wrap_policy(&self) -> HistoryLineWrapPolicy { + if self.chat_widget.raw_output_mode() { + HistoryLineWrapPolicy::Terminal + } else { + HistoryLineWrapPolicy::PreWrap + } + } + /// Retain only the newest rendered rows for initial resume replay. /// /// The oldest rows are dropped first because terminal scrollback caps preserve the tail of the @@ -408,7 +418,7 @@ impl App { Ok(()) } - fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result { + pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result { let width = tui.terminal.size()?.width; if self.transcript_cells.is_empty() { // Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells. @@ -426,7 +436,10 @@ impl App { self.deferred_history_lines.clear(); if !reflowed_lines.is_empty() { - tui.insert_history_lines(reflowed_lines); + tui.insert_history_lines_with_wrap_policy( + reflowed_lines, + self.history_line_wrap_policy(), + ); } Ok(width) @@ -448,7 +461,7 @@ impl App { while start > 0 { start -= 1; let cell = self.transcript_cells[start].clone(); - let lines = cell.display_lines(width); + let lines = cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); rendered_rows += lines.len(); cell_displays.push_front(ReflowCellDisplay { lines, @@ -468,7 +481,7 @@ impl App { start -= 1; let cell = self.transcript_cells[start].clone(); cell_displays.push_front(ReflowCellDisplay { - lines: cell.display_lines(width), + lines: cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), is_stream_continuation: cell.is_stream_continuation(), }); } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 231e5e9cb0..307fb8559a 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -245,7 +245,7 @@ impl App { 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); - tui.insert_history_lines(lines); + tui.insert_history_lines_with_wrap_policy(lines, self.history_line_wrap_policy()); } self.overlay = None; self.backtrack.overlay_preview_active = false; @@ -261,7 +261,10 @@ impl App { if !self.transcript_cells.is_empty() { let width = tui.terminal.last_known_screen_size.width; for cell in &self.transcript_cells { - tui.insert_history_lines(cell.display_lines(width)); + tui.insert_history_lines_with_wrap_policy( + cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + self.history_line_wrap_policy(), + ); } } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b9e4380003..89b19a49e8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -157,6 +157,11 @@ pub(crate) enum AppEvent { /// previous chat resumable. ClearUi, + /// Re-render the transcript using the selected scrollback rendering mode. + RawOutputModeChanged { + enabled: bool, + }, + /// Clear the current context, start a fresh session, and submit an initial user message. /// /// This is the Plan Mode handoff path: the previous thread remains resumable, but the model diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 9f2c33fbec..c253d49b04 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -167,6 +167,7 @@ mod tests { vec![ SlashCommand::Ide, SlashCommand::Copy, + SlashCommand::Raw, SlashCommand::Diff, SlashCommand::Mention, SlashCommand::Status, diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 5d0ba8718e..db789a11ff 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -117,6 +117,9 @@ pub(crate) enum StatusLineItem { /// Whether Fast mode is currently active. FastMode, + /// Whether raw scrollback mode is currently active. + RawOutput, + /// Current thread title (if set by user). ThreadTitle, @@ -163,6 +166,7 @@ impl StatusLineItem { "Current session identifier (omitted until session starts)" } StatusLineItem::FastMode => "Whether Fast mode is currently active", + StatusLineItem::RawOutput => "Whether raw scrollback mode is active", StatusLineItem::ThreadTitle => "Current thread title (omitted when unavailable)", StatusLineItem::TaskProgress => { "Latest task progress from update_plan (omitted until available)" @@ -191,6 +195,7 @@ impl StatusLineItem { StatusLineItem::TotalOutputTokens => StatusSurfacePreviewItem::TotalOutputTokens, StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId, StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, + StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, } diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index dddd02db09..07018bff0e 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -44,7 +44,7 @@ impl StatusLineAccent { | StatusLineItem::TotalOutputTokens => Self::Usage, StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => Self::Limit, StatusLineItem::CodexVersion | StatusLineItem::SessionId => Self::Metadata, - StatusLineItem::FastMode => Self::Mode, + StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, StatusLineItem::ThreadTitle => Self::Thread, StatusLineItem::TaskProgress => Self::Progress, } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 581d424aaf..1f23742a55 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -27,6 +27,7 @@ pub(crate) enum StatusSurfacePreviewItem { TotalOutputTokens, SessionId, FastMode, + RawOutput, Model, ModelWithReasoning, TaskProgress, @@ -55,6 +56,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::TotalOutputTokens => "0 out", StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", StatusSurfacePreviewItem::FastMode => "Fast on", + StatusSurfacePreviewItem::RawOutput => "raw output", StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::TaskProgress => "Tasks 0/0", @@ -83,6 +85,7 @@ impl StatusSurfacePreviewItem { Self::TotalOutputTokens, Self::SessionId, Self::FastMode, + Self::RawOutput, Self::Model, Self::ModelWithReasoning, Self::TaskProgress, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5908ceb376..9e18284a65 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -293,6 +293,7 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::HistoryCell; +use crate::history_cell::HistoryRenderMode; use crate::history_cell::HookCell; use crate::history_cell::McpInvocation; use crate::history_cell::McpToolCallCell; @@ -756,6 +757,7 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, + raw_output_mode: bool, /// Runtime value resolved by core. `config.service_tier` remains the explicit user choice. effective_service_tier: Option, /// The unmasked collaboration mode settings (always Default mode). @@ -2329,6 +2331,7 @@ impl ChatWidget { self.plan_stream_controller = Some(PlanStreamController::new( self.current_stream_width(/*reserved_cols*/ 4), &self.config.cwd, + self.history_render_mode(), )); } if let Some(controller) = self.plan_stream_controller.as_mut() @@ -4331,6 +4334,7 @@ impl ChatWidget { self.stream_controller = Some(StreamController::new( self.current_stream_width(/*reserved_cols*/ 2), &self.config.cwd, + self.history_render_mode(), )); } if let Some(controller) = self.stream_controller.as_mut() @@ -4890,6 +4894,7 @@ impl ChatWidget { }), active_cell, active_cell_revision: 0, + raw_output_mode: config.tui_raw_output_mode, config, effective_service_tier, skills_all: Vec::new(), @@ -10276,6 +10281,53 @@ impl ChatWidget { }) } + pub(crate) fn raw_output_mode(&self) -> bool { + self.raw_output_mode + } + + pub(crate) fn history_render_mode(&self) -> HistoryRenderMode { + if self.raw_output_mode { + HistoryRenderMode::Raw + } else { + HistoryRenderMode::Rich + } + } + + pub(crate) fn set_raw_output_mode(&mut self, enabled: bool) { + self.raw_output_mode = enabled; + self.config.tui_raw_output_mode = enabled; + let render_mode = self.history_render_mode(); + if let Some(controller) = self.stream_controller.as_mut() { + controller.set_render_mode(render_mode); + } + if let Some(controller) = self.plan_stream_controller.as_mut() { + controller.set_render_mode(render_mode); + } + self.refresh_status_surfaces(); + } + + pub(crate) fn raw_output_mode_notice(enabled: bool) -> &'static str { + if enabled { + "Raw output mode on: transcript text is shown for clean terminal selection." + } else { + "Raw output mode off: rich transcript rendering restored." + } + } + + pub(crate) fn set_raw_output_mode_and_notify(&mut self, enabled: bool) { + self.set_raw_output_mode(enabled); + self.add_info_message( + Self::raw_output_mode_notice(enabled).to_string(), + /*hint*/ None, + ); + } + + pub(crate) fn toggle_raw_output_mode_and_notify(&mut self) -> bool { + let enabled = !self.raw_output_mode; + self.set_raw_output_mode_and_notify(enabled); + enabled + } + /// Update resize-sensitive chat widget state after the terminal width changes. /// /// The app calls this even when terminal resize reflow is disabled so live stream wrapping diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 1293b37674..30169904d7 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -32,6 +32,7 @@ const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str = const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Esc to return to the main thread first."; const GOAL_USAGE: &str = "Usage: /goal "; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; +const RAW_USAGE: &str = "Usage: /raw [on|off]"; impl ChatWidget { /// Dispatch a bare slash command and record its staged local-history entry. @@ -104,6 +105,11 @@ impl ChatWidget { self.request_side_conversation(parent_thread_id, /*user_message*/ None); } + fn emit_raw_output_mode_changed(&self, enabled: bool) { + self.app_event_tx + .send(AppEvent::RawOutputModeChanged { enabled }); + } + pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) { if !self.ensure_slash_command_allowed_in_side_conversation(cmd) { return; @@ -315,6 +321,10 @@ impl ChatWidget { SlashCommand::Copy => { self.copy_last_agent_markdown(); } + SlashCommand::Raw => { + let enabled = self.toggle_raw_output_mode_and_notify(); + self.emit_raw_output_mode_changed(enabled); + } SlashCommand::Diff => { self.add_diff_in_progress(); let tx = self.app_event_tx.clone(); @@ -595,6 +605,17 @@ impl ChatWidget { } _ => self.add_error_message("Usage: /keymap [debug]".to_string()), }, + SlashCommand::Raw => match trimmed.to_ascii_lowercase().as_str() { + "on" => { + self.set_raw_output_mode_and_notify(/*enabled*/ true); + self.emit_raw_output_mode_changed(/*enabled*/ true); + } + "off" => { + self.set_raw_output_mode_and_notify(/*enabled*/ false); + self.emit_raw_output_mode_changed(/*enabled*/ false); + } + _ => self.add_error_message(RAW_USAGE.to_string()), + }, SlashCommand::Rename if !trimmed.is_empty() => { if !self.ensure_thread_rename_allowed() { return; @@ -878,6 +899,7 @@ impl ChatWidget { | SlashCommand::Plugins | SlashCommand::Rollout | SlashCommand::Copy + | SlashCommand::Raw | SlashCommand::Vim | SlashCommand::Diff | SlashCommand::Rename diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 699b45e053..3095556fd0 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -647,6 +647,7 @@ impl ChatWidget { "Fast off".to_string() }, ), + StatusLineItem::RawOutput => self.raw_output_mode().then(|| "raw output".to_string()), StatusLineItem::ThreadTitle => self.thread_name.as_ref().and_then(|name| { let trimmed = name.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) @@ -688,6 +689,7 @@ impl ChatWidget { StatusSurfacePreviewItem::TotalOutputTokens => StatusLineItem::TotalOutputTokens, StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, + StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, }; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 8f3d37e76c..a7474e5d47 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -189,6 +189,7 @@ pub(super) async fn make_chatwidget_manual( bottom_pane: bottom, active_cell: None, active_cell_revision: 0, + raw_output_mode: cfg.tui_raw_output_mode, config: cfg, effective_service_tier, current_collaboration_mode, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e493c83d06..3b6b0e7ff2 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1975,6 +1975,57 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() { } } +#[tokio::test] +async fn raw_slash_command_toggles_and_accepts_on_off_args() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Raw); + assert!(chat.raw_output_mode()); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: true })) + ); + + chat.dispatch_command_with_args(SlashCommand::Raw, "off".to_string(), Vec::new()); + assert!(!chat.raw_output_mode()); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: false })) + ); + + chat.dispatch_command_with_args(SlashCommand::Raw, "on".to_string(), Vec::new()); + assert!(chat.raw_output_mode()); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: true })) + ); +} + +#[tokio::test] +async fn raw_slash_command_reports_usage_for_invalid_arg() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Raw, "status".to_string(), Vec::new()); + + assert!(!chat.raw_output_mode()); + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Usage: /raw [on|off]"), + "expected raw usage error, got {rendered:?}" + ); +} + #[tokio::test] async fn compact_queues_user_messages_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 440b7f9ab8..96b2b681b6 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -156,6 +156,23 @@ async fn status_line_git_summary_items_render_values() { ); } +#[tokio::test] +async fn raw_output_status_line_value_only_shows_when_enabled() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::RawOutput), + None + ); + + chat.set_raw_output_mode(/*enabled*/ true); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::RawOutput), + Some("raw output".to_string()) + ); +} + #[tokio::test] async fn status_line_branch_changes_render_no_changes() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; @@ -196,6 +213,30 @@ async fn stale_status_line_git_summary_update_is_ignored() { assert!(chat.status_line_git_summary.is_none()); assert!(!chat.status_line_git_summary_pending); } + +#[tokio::test] +async fn raw_output_mode_can_change_without_inserting_notice() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.set_raw_output_mode(/*enabled*/ true); + + assert!(chat.raw_output_mode()); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.set_raw_output_mode_and_notify(/*enabled*/ false); + + assert!(!chat.raw_output_mode()); + let history = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + history.contains("Raw output mode off: rich transcript rendering restored."), + "expected raw output notice, got {history:?}" + ); +} + #[tokio::test] async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index f780e3d3e3..882683ad28 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -5,6 +5,7 @@ use super::model::ExecCall; use super::model::ExecCell; use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell::HistoryCell; +use crate::history_cell::plain_lines; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; @@ -243,6 +244,10 @@ impl HistoryCell for ExecCell { } lines } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.transcript_lines(u16::MAX)) + } } impl ExecCell { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5ed2cee9ed..a9dd59e794 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -99,12 +99,51 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use url::Url; +const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000; +const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000; + mod hook_cell; pub(crate) use hook_cell::HookCell; pub(crate) use hook_cell::new_active_hook_cell; pub(crate) use hook_cell::new_completed_hook_cell; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HistoryRenderMode { + Rich, + Raw, +} + +pub(crate) fn raw_lines_from_source(source: &str) -> Vec> { + if source.is_empty() { + return Vec::new(); + } + + let mut parts = source.split('\n').collect::>(); + if source.ends_with('\n') { + parts.pop(); + } + + parts + .into_iter() + .map(|line| Line::from(line.to_string())) + .collect() +} + +pub(crate) fn plain_lines(lines: impl IntoIterator>) -> Vec> { + lines + .into_iter() + .map(|line| { + let text = line + .spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::(); + Line::from(text) + }) + .collect() +} + /// A single renderable unit of conversation history. /// /// Each cell produces logical `Line`s and reports how many viewport @@ -118,6 +157,16 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { /// Returns the logical lines for the main chat viewport. fn display_lines(&self, width: u16) -> Vec>; + /// Returns copy-friendly plain logical lines for raw scrollback mode. + fn raw_lines(&self) -> Vec>; + + fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { + match mode { + HistoryRenderMode::Rich => self.display_lines(width), + HistoryRenderMode::Raw => self.raw_lines(), + } + } + /// Returns the number of viewport rows needed to render this cell. /// /// The default delegates to `Paragraph::line_count` with @@ -126,7 +175,11 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { /// for lines containing URL-like tokens that are wider than the /// terminal — the logical line count would undercount. fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(Text::from(self.display_lines(width))) + self.desired_height_for_mode(width, HistoryRenderMode::Rich) + } + + fn desired_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 { + Paragraph::new(Text::from(self.display_lines_for_mode(width, mode))) .wrap(Wrap { trim: false }) .line_count(width) .try_into() @@ -391,6 +444,22 @@ impl HistoryCell for UserHistoryCell { lines.push(Line::from("").style(style)); lines } + + fn raw_lines(&self) -> Vec> { + let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n'])); + if !self.remote_image_urls.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + lines.extend( + self.remote_image_urls + .iter() + .enumerate() + .map(|(idx, _url)| Line::from(local_image_label_text(idx.saturating_add(1)))), + ); + } + lines + } } #[derive(Debug)] @@ -456,6 +525,14 @@ impl HistoryCell for ReasoningSummaryCell { fn transcript_lines(&self, width: u16) -> Vec> { self.lines(width) } + + fn raw_lines(&self) -> Vec> { + if self.transcript_only { + Vec::new() + } else { + raw_lines_from_source(self.content.trim()) + } + } } #[derive(Debug)] @@ -487,6 +564,10 @@ impl HistoryCell for AgentMessageCell { ) } + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } + fn is_stream_continuation(&self) -> bool { !self.is_first_line } @@ -541,6 +622,10 @@ impl HistoryCell for AgentMarkdownCell { ); prefix_lines(lines, "• ".dim(), " ".into()) } + + fn raw_lines(&self) -> Vec> { + raw_lines_from_source(&self.markdown_source) + } } #[derive(Debug)] @@ -558,6 +643,10 @@ impl HistoryCell for PlainHistoryCell { fn display_lines(&self, _width: u16) -> Vec> { self.lines.clone() } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } } #[cfg_attr(debug_assertions, allow(dead_code))] @@ -612,6 +701,22 @@ impl HistoryCell for UpdateAvailableHistoryCell { .max(1); with_border_with_inner_width(content.lines, inner_width) } + + fn raw_lines(&self) -> Vec> { + let update_instruction = if let Some(update_action) = self.update_action { + format!("Run {} to update.", update_action.command_str()) + } else { + "See https://github.com/openai/codex for installation options.".to_string() + }; + vec![ + Line::from("Update available!"), + Line::from(format!("{CODEX_CLI_VERSION} -> {}", self.latest_version)), + Line::from(update_instruction), + Line::from(""), + Line::from("See full release notes:"), + Line::from("https://github.com/openai/codex/releases/latest"), + ] + } } #[derive(Debug)] @@ -645,6 +750,10 @@ impl HistoryCell for PrefixedWrappedHistoryCell { .subsequent_indent(self.subsequent_prefix.clone()); adaptive_wrap_lines(&self.text, opts) } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.text.clone().lines) + } } #[derive(Debug)] @@ -706,6 +815,38 @@ impl HistoryCell for UnifiedExecInteractionCell { out.extend(input_wrapped); out } + + fn raw_lines(&self) -> Vec> { + let mut out = Vec::new(); + if self.stdin.is_empty() { + if let Some(command) = self + .command_display + .as_ref() + .filter(|command| !command.is_empty()) + { + out.push(Line::from(format!( + "Waited for background terminal: {command}" + ))); + } else { + out.push(Line::from("Waited for background terminal")); + } + return out; + } + + if let Some(command) = self + .command_display + .as_ref() + .filter(|command| !command.is_empty()) + { + out.push(Line::from(format!( + "Interacted with background terminal: {command}" + ))); + } else { + out.push(Line::from("Interacted with background terminal")); + } + out.extend(raw_lines_from_source(&self.stdin)); + out + } } pub(crate) fn new_unified_exec_interaction( @@ -837,6 +978,10 @@ impl HistoryCell for UnifiedExecProcessesCell { out } + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } + fn desired_height(&self, width: u16) -> u16 { self.display_lines(width).len() as u16 } @@ -1108,6 +1253,14 @@ impl HistoryCell for PatchHistoryCell { fn display_lines(&self, width: u16) -> Vec> { create_diff_summary(&self.changes, &self.cwd, width as usize) } + + fn raw_lines(&self) -> Vec> { + plain_lines(create_diff_summary( + &self.changes, + &self.cwd, + RAW_DIFF_SUMMARY_WIDTH, + )) + } } #[derive(Debug)] @@ -1118,6 +1271,10 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput { fn display_lines(&self, _width: u16) -> Vec> { vec!["tool result (image output)".into()] } + + fn raw_lines(&self) -> Vec> { + vec![Line::from("tool result (image output)")] + } } pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value @@ -1228,6 +1385,10 @@ impl HistoryCell for TooltipHistoryCell { prefix_lines(lines, indent.into(), indent.into()) } + + fn raw_lines(&self) -> Vec> { + vec![Line::from(format!("Tip: {}", self.tip))] + } } #[derive(Debug)] @@ -1245,6 +1406,10 @@ impl HistoryCell for SessionInfoCell { fn transcript_lines(&self, width: u16) -> Vec> { self.0.transcript_lines(width) } + + fn raw_lines(&self) -> Vec> { + self.0.raw_lines() + } } pub(crate) fn new_session_info( @@ -1539,6 +1704,27 @@ impl HistoryCell for SessionHeaderHistoryCell { with_border(lines) } + + fn raw_lines(&self) -> Vec> { + let mut lines = vec![ + Line::from(format!("OpenAI Codex (v{})", self.version)), + Line::from(format!( + "model: {}{}", + self.model, + self.reasoning_label() + .map(|reasoning| format!(" {reasoning}")) + .unwrap_or_default() + )), + Line::from(format!( + "directory: {}", + self.format_directory(/*max_width*/ None) + )), + ]; + if self.yolo_mode { + lines.push(Line::from("permissions: YOLO mode")); + } + lines + } } #[derive(Debug)] @@ -1568,6 +1754,22 @@ impl HistoryCell for CompositeHistoryCell { } out } + + fn raw_lines(&self) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.raw_lines(); + if !lines.is_empty() { + if !first { + out.push(Line::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } } #[derive(Debug)] @@ -1759,6 +1961,32 @@ impl HistoryCell for McpToolCallCell { lines } + fn raw_lines(&self) -> Vec> { + let header_text = if self.success().is_some() { + "Called" + } else { + "Calling" + }; + let mut lines = vec![Line::from(format!( + "{header_text} {}", + format_mcp_invocation(self.invocation.clone()) + ))]; + + if let Some(result) = &self.result { + match result { + Ok(codex_protocol::mcp::CallToolResult { content, .. }) => { + for block in content { + let text = Self::render_content_block(block, RAW_TOOL_OUTPUT_WIDTH); + lines.extend(raw_lines_from_source(&text)); + } + } + Err(err) => lines.push(Line::from(format!("Error: {err}"))), + } + } + + lines + } + fn transcript_animation_tick(&self) -> Option { if !self.animations_enabled || self.result.is_some() { return None; @@ -1881,6 +2109,16 @@ impl HistoryCell for WebSearchCell { }; PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width) } + + fn raw_lines(&self) -> Vec> { + let header = web_search_header(self.completed); + let detail = web_search_detail(self.action.as_ref(), &self.query); + if detail.is_empty() { + vec![Line::from(header)] + } else { + vec![Line::from(format!("{header} {detail}"))] + } + } } pub(crate) fn new_active_web_search_call( @@ -2016,6 +2254,16 @@ impl HistoryCell for CyberPolicyNoticeCell { lines } + + fn raw_lines(&self) -> Vec> { + vec![ + Line::from("This chat was flagged for possible cybersecurity risk"), + Line::from( + "If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program.", + ), + Line::from(TRUSTED_ACCESS_FOR_CYBER_URL), + ] + } } #[derive(Debug)] @@ -2046,6 +2294,14 @@ impl HistoryCell for DeprecationNoticeCell { lines } + + fn raw_lines(&self) -> Vec> { + let mut lines = vec![Line::from(self.summary.clone())]; + if let Some(details) = &self.details { + lines.extend(raw_lines_from_source(details)); + } + lines + } } /// Render a summary of configured MCP servers from the current `Config`. @@ -2494,6 +2750,10 @@ impl HistoryCell for McpInventoryLoadingCell { ] } + fn raw_lines(&self) -> Vec> { + vec![Line::from("Loading MCP inventory...")] + } + fn transcript_animation_tick(&self) -> Option { if !self.animations_enabled { return None; @@ -2612,6 +2872,48 @@ impl HistoryCell for RequestUserInputResultCell { lines } + + fn raw_lines(&self) -> Vec> { + let total = self.questions.len(); + let answered = self + .questions + .iter() + .filter(|question| { + self.answers + .get(&question.id) + .is_some_and(|answer| !answer.answers.is_empty()) + }) + .count(); + let mut lines = vec![Line::from(format!("Questions {answered}/{total} answered"))]; + if self.interrupted { + lines.push(Line::from("(interrupted)")); + } + for question in &self.questions { + lines.push(Line::from(question.question.clone())); + if let Some(answer) = self + .answers + .get(&question.id) + .filter(|answer| !answer.answers.is_empty()) + { + if question.is_secret { + lines.push(Line::from("answer: ******")); + } else { + let (options, note) = split_request_user_input_answer(answer); + lines.extend( + options + .into_iter() + .map(|option| Line::from(format!("answer: {option}"))), + ); + if let Some(note) = note { + lines.push(Line::from(format!("note: {note}"))); + } + } + } else { + lines.push(Line::from("(unanswered)")); + } + } + lines + } } /// Wrap a plain string with textwrap and prefix each line, while applying a style to the content. @@ -2728,6 +3030,10 @@ impl HistoryCell for ProposedPlanCell { lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style))); lines } + + fn raw_lines(&self) -> Vec> { + raw_lines_from_source(&self.plan_markdown) + } } impl HistoryCell for ProposedPlanStreamCell { @@ -2735,6 +3041,10 @@ impl HistoryCell for ProposedPlanStreamCell { self.lines.clone() } + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } + fn is_stream_continuation(&self) -> bool { self.is_stream_continuation } @@ -2798,6 +3108,26 @@ impl HistoryCell for PlanUpdateCell { lines } + + fn raw_lines(&self) -> Vec> { + let mut lines = vec![Line::from("Updated Plan")]; + if let Some(explanation) = self + .explanation + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + lines.extend(raw_lines_from_source(explanation)); + } + if self.plan.is_empty() { + lines.push(Line::from("(no steps provided)")); + } else { + for PlanItemArg { step, status } in &self.plan { + lines.push(Line::from(format!("{status:?}: {step}"))); + } + } + lines + } } /// Create a new `PendingPatch` cell that lists the file‑level summary of @@ -2959,6 +3289,25 @@ impl HistoryCell for FinalMessageSeparator { .dim(), ] } + + fn raw_lines(&self) -> Vec> { + let mut label_parts = Vec::new(); + if let Some(elapsed_seconds) = self + .elapsed_seconds + .filter(|seconds| *seconds > 60) + .map(super::status_indicator_widget::fmt_elapsed_compact) + { + label_parts.push(format!("Worked for {elapsed_seconds}")); + } + if let Some(metrics_label) = self.runtime_metrics.and_then(runtime_metrics_label) { + label_parts.push(metrics_label); + } + if label_parts.is_empty() { + Vec::new() + } else { + vec![Line::from(label_parts.join(" • "))] + } + } } pub(crate) fn runtime_metrics_label(summary: RuntimeMetricsSummary) -> Option { @@ -3225,6 +3574,15 @@ mod tests { render_lines(&cell.transcript_lines(u16::MAX)) } + fn assert_unstyled_lines(lines: &[Line<'static>]) { + for line in lines { + assert_eq!(line.style, Style::default()); + for span in &line.spans { + assert_eq!(span.style, Style::default()); + } + } + } + fn image_block(data: &str) -> serde_json::Value { serde_json::to_value(Content::image(data.to_string(), "image/png")) .expect("image content should serialize") @@ -3253,6 +3611,185 @@ mod tests { .expect("resource link content should serialize") } + #[test] + fn raw_lines_from_source_preserves_explicit_blank_lines() { + let lines = raw_lines_from_source("alpha\n\nbeta\n"); + + assert_eq!( + render_lines(&lines), + vec!["alpha".to_string(), String::new(), "beta".to_string()] + ); + assert_unstyled_lines(&lines); + } + + #[test] + fn raw_lines_from_source_preserves_trailing_blank_but_not_trailing_newline() { + assert_eq!( + render_lines(&raw_lines_from_source("alpha\n\n")), + vec!["alpha".to_string(), String::new()] + ); + assert_eq!(raw_lines_from_source(""), Vec::>::new()); + } + + #[test] + fn source_backed_cells_render_raw_source_without_prefix_or_style() { + let user = new_user_prompt( + "hello\n\nworld\n".to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let assistant = AgentMarkdownCell::new( + "- item\n\n| A | B |\n| - | - |\n| x | y |\n".to_string(), + &test_cwd(), + ); + let reasoning = ReasoningSummaryCell::new( + "thinking".to_string(), + "first thought\n\nsecond thought".to_string(), + &test_cwd(), + /*transcript_only*/ false, + ); + let plan = new_proposed_plan( + "1. Inspect\n\n```sh\ncargo test\n```".to_string(), + &test_cwd(), + ); + + let user_lines = user.raw_lines(); + assert_eq!( + render_lines(&user_lines), + vec!["hello".to_string(), String::new(), "world".to_string()] + ); + assert_unstyled_lines(&user_lines); + + let assistant_lines = assistant.raw_lines(); + assert_eq!( + render_lines(&assistant_lines), + vec![ + "- item".to_string(), + String::new(), + "| A | B |".to_string(), + "| - | - |".to_string(), + "| x | y |".to_string(), + ] + ); + assert_unstyled_lines(&assistant_lines); + + let reasoning_lines = reasoning.raw_lines(); + assert_eq!( + render_lines(&reasoning_lines), + vec![ + "first thought".to_string(), + String::new(), + "second thought".to_string(), + ] + ); + assert_unstyled_lines(&reasoning_lines); + + let plan_lines = plan.raw_lines(); + assert_eq!( + render_lines(&plan_lines), + vec![ + "1. Inspect".to_string(), + String::new(), + "```sh".to_string(), + "cargo test".to_string(), + "```".to_string(), + ] + ); + assert_unstyled_lines(&plan_lines); + } + + #[test] + fn structured_tool_cell_renders_raw_plain_text_without_prefix_or_style() { + let invocation = McpInvocation { + server: "search".into(), + tool: "find_docs".into(), + arguments: Some(json!({"query": "raw mode"})), + }; + let result = CallToolResult { + content: vec![text_block("alpha\nbeta")], + is_error: None, + structured_content: None, + meta: None, + }; + let mut cell = new_active_mcp_tool_call( + "call-raw".to_string(), + invocation, + /*animations_enabled*/ false, + ); + assert!( + cell.complete(Duration::from_millis(1), Ok(result)) + .is_none() + ); + + let lines = cell.raw_lines(); + let rendered = render_lines(&lines); + assert!(rendered[0].starts_with("Called search.find_docs(")); + assert_eq!(rendered[1..], ["alpha".to_string(), "beta".to_string()]); + assert_unstyled_lines(&lines); + } + + #[test] + fn raw_mode_toggle_transcript_snapshot() { + let mut tool_cell = new_active_mcp_tool_call( + "call-snapshot".to_string(), + McpInvocation { + server: "workspace".to_string(), + tool: "inspect".to_string(), + arguments: Some(json!({"path": "README.md"})), + }, + /*animations_enabled*/ false, + ); + assert!( + tool_cell + .complete( + Duration::from_millis(5), + Ok(CallToolResult { + content: vec![text_block("structured output\nsecond line")], + is_error: None, + structured_content: None, + meta: None, + }), + ) + .is_none() + ); + let cells: Vec> = vec![ + Box::new(new_user_prompt( + "Please format this\nfor copying".to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + )), + Box::new(AgentMarkdownCell::new( + "- first item\n- second item\n\n| Col | Value |\n| --- | --- |\n| code | `x = 1` |\n\n```text\ncopy me\n```".to_string(), + &test_cwd(), + )), + Box::new(tool_cell), + ]; + + let render = |mode| { + cells + .iter() + .flat_map(|cell| cell.display_lines_for_mode(/*width*/ 40, mode)) + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() + }) + .collect::>() + .join("\n") + }; + let rendered = format!( + "rich before:\n{}\n\nraw on:\n{}\n\nrich after:\n{}", + render(HistoryRenderMode::Rich), + render(HistoryRenderMode::Raw), + render(HistoryRenderMode::Rich) + ); + + insta::assert_snapshot!("raw_mode_toggle_transcript", rendered); + } + #[test] fn image_generation_call_renders_saved_path() { let saved_path = test_path_buf("/tmp/generated-image.png").abs(); diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index ec5f1ca18e..3b87eff6d3 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -11,6 +11,7 @@ //! first drawn. //! 4. Completed runs only persist when they have output or a non-success status. use super::HistoryCell; +use super::plain_lines; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; @@ -340,6 +341,10 @@ impl HistoryCell for HookCell { self.display_lines(width) } + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } + /// Produces a coarse cache key for transcript overlays while hook animations are active. fn transcript_animation_tick(&self) -> Option { if !self.animations_enabled { diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 4f3ea981bd..2543b56bac 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -57,6 +57,12 @@ impl InsertHistoryMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryLineWrapPolicy { + PreWrap, + Terminal, +} + /// Insert `lines` above the viewport using the terminal's backend writer /// (avoids direct stdout references). pub fn insert_history_lines( @@ -83,6 +89,23 @@ pub fn insert_history_lines_with_mode( lines: Vec, mode: InsertHistoryMode, ) -> io::Result<()> +where + B: Backend + Write, +{ + insert_history_lines_with_mode_and_wrap_policy( + terminal, + lines, + mode, + HistoryLineWrapPolicy::PreWrap, + ) +} + +pub fn insert_history_lines_with_mode_and_wrap_policy( + terminal: &mut crate::custom_terminal::Terminal, + lines: Vec, + mode: InsertHistoryMode, + wrap_policy: HistoryLineWrapPolicy, +) -> io::Result<()> where B: Backend + Write, { @@ -109,12 +132,15 @@ where let mut wrapped_rows = 0usize; for line in &lines { - let line_wrapped = - if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) { + let line_wrapped = match wrap_policy { + HistoryLineWrapPolicy::Terminal => vec![line.clone()], + HistoryLineWrapPolicy::PreWrap + if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) => + { vec![line.clone()] - } else { - adaptive_wrap_line(line, RtOptions::new(wrap_width)) - }; + } + HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line(line, RtOptions::new(wrap_width)), + }; wrapped_rows += line_wrapped .iter() .map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width)) @@ -738,6 +764,33 @@ mod tests { ); } + #[test] + fn vt100_terminal_wrap_policy_does_not_pre_wrap_long_paragraph() { + let width: u16 = 20; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let line = Line::from("alpha beta gamma delta epsilon zeta"); + + insert_history_lines_with_mode_and_wrap_policy( + &mut term, + vec![line], + InsertHistoryMode::Standard, + HistoryLineWrapPolicy::Terminal, + ) + .expect("insert raw history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + assert!( + rows.iter() + .any(|row| row.trim_end() == "alpha beta gamma del"), + "expected terminal soft-wrap instead of Codex word pre-wrap, rows: {rows:?}" + ); + } + #[test] fn vt100_unwrapped_url_like_clears_continuation_rows() { let width: u16 = 20; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 5e88423b22..c12d24ca22 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -65,6 +65,8 @@ pub(crate) struct AppKeymap { pub(crate) toggle_vim_mode: Vec, /// Toggle Fast mode. pub(crate) toggle_fast_mode: Vec, + /// Toggle raw scrollback mode for copy-friendly transcript selection. + pub(crate) toggle_raw_output: Vec, } /// Chat-level keybindings evaluated at the app event layer. @@ -377,6 +379,11 @@ impl RuntimeKeymap { &defaults.app.toggle_fast_mode, "tui.keymap.global.toggle_fast_mode", )?, + toggle_raw_output: resolve_bindings( + keymap.global.toggle_raw_output.as_ref(), + &defaults.app.toggle_raw_output, + "tui.keymap.global.toggle_raw_output", + )?, }; let chat = ChatKeymap { @@ -546,6 +553,7 @@ impl RuntimeKeymap { clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))], toggle_vim_mode: default_bindings![], toggle_fast_mode: default_bindings![], + toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))], }, chat: ChatKeymap { decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], @@ -754,6 +762,7 @@ impl RuntimeKeymap { ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), + ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -795,6 +804,7 @@ impl RuntimeKeymap { ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), + ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -837,6 +847,7 @@ impl RuntimeKeymap { ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), + ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ], [ ("list.move_up", self.list.move_up.as_slice()), @@ -886,6 +897,7 @@ impl RuntimeKeymap { ("composer.submit", self.composer.submit.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), + ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ( "composer.history_search_previous", self.composer.history_search_previous.as_slice(), @@ -1908,6 +1920,28 @@ mod tests { assert!(runtime.composer.toggle_shortcuts.is_empty()); } + #[test] + fn raw_output_toggle_defaults_to_alt_r() { + let runtime = RuntimeKeymap::defaults(); + assert_eq!( + runtime.app.toggle_raw_output, + vec![key_hint::alt(KeyCode::Char('r'))] + ); + } + + #[test] + fn raw_output_toggle_can_be_remapped() { + let mut keymap = TuiKeymap::default(); + keymap.global.toggle_raw_output = Some(one("f12")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.app.toggle_raw_output, + vec![key_hint::plain(KeyCode::F(12))] + ); + } + #[test] fn default_editor_insert_newline_includes_current_aliases() { let runtime = RuntimeKeymap::defaults(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index b6f9660bdd..8e11c55377 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -92,6 +92,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "clear_terminal", "Clear the terminal UI."), action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode), + action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."), @@ -213,6 +214,7 @@ pub(super) fn binding_slot<'a>( ("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal), ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), ("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode), + ("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output), ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), @@ -316,6 +318,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()), ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), ("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()), + ("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()), ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 68798ecc0e..be8629542e 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -941,6 +941,10 @@ mod tests { self.lines.clone() } + fn raw_lines(&self) -> Vec> { + self.lines.clone() + } + fn transcript_lines(&self, _width: u16) -> Vec> { self.lines.clone() } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e05edac6b5..d5e923f0e3 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -41,6 +41,7 @@ pub enum SlashCommand { Agent, Side, Copy, + Raw, Diff, Mention, Status, @@ -88,6 +89,7 @@ impl SlashCommand { SlashCommand::Fork => "fork the current chat", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Copy => "copy last response as markdown", + SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", @@ -153,6 +155,7 @@ impl SlashCommand { | SlashCommand::Ide | SlashCommand::Keymap | SlashCommand::Mcp + | SlashCommand::Raw | SlashCommand::Side | SlashCommand::Resume | SlashCommand::SandboxReadRoot @@ -164,6 +167,7 @@ impl SlashCommand { matches!( self, SlashCommand::Copy + | SlashCommand::Raw | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status @@ -197,6 +201,7 @@ impl SlashCommand { | SlashCommand::MemoryUpdate => false, SlashCommand::Diff | SlashCommand::Copy + | SlashCommand::Raw | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills @@ -268,6 +273,9 @@ mod tests { assert!(SlashCommand::Ide.available_during_task()); assert!(SlashCommand::Title.available_during_task()); assert!(SlashCommand::Statusline.available_during_task()); + assert!(SlashCommand::Raw.available_during_task()); + assert!(SlashCommand::Raw.available_in_side_conversation()); + assert!(SlashCommand::Raw.supports_inline_args()); } #[test] diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap new file mode 100644 index 0000000000..5f18eb209d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap @@ -0,0 +1,58 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +rich before: + +› Please format this + for copying + +• - first item + - second item + + | Col | Value | + | --- | --- | + | code | x = 1 | + + copy me +• Called + └ workspace.inspect({"path":"README.md + "}) + structured output + second line + +raw on: +Please format this +for copying +- first item +- second item + +| Col | Value | +| --- | --- | +| code | `x = 1` | + +```text +copy me +``` +Called workspace.inspect({"path":"README.md"}) +structured output +second line + +rich after: + +› Please format this + for copying + +• - first item + - second item + + | Col | Value | + | --- | --- | + | code | x = 1 | + + copy me +• Called + └ workspace.inspect({"path":"README.md + "}) + structured output + second line diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index 0633d837cc..4e388179a3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -7,10 +7,10 @@ Open External Editor | ctrl-g | Global open_external_editor Open External Editor Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default +Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default -History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index ab398780e4..f58386a8d5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 86 actions, 1 customized, 2 unbound. + 87 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -15,8 +15,8 @@ expression: "render_picker(params, 120)" Global Copy ctrl-o Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r Chat Decrease Reasoning Effort alt-, Chat Increase Reasoning Effort alt-. - Chat Edit Queued Message alt-up, shift-left left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 7538c21500..1c247951fd 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 87 actions, 0 customized, 3 unbound. + 88 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug @@ -16,7 +16,7 @@ expression: "render_picker(params, 120)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global - Toggle Fast Mode unbound + Global Toggle Raw Output alt-r Chat Decrease Reasoning Effort alt-, - Chat Increase Reasoning Effort alt-. left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 24b8d81b77..e094edad97 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,11 +2,11 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (86 selectable) +tab: All (87 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) -tab: App (8 selectable) +tab: App (9 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (34 selectable) @@ -18,10 +18,10 @@ Open External Editor | ctrl-g | Global open_external_editor Open External Editor Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default +Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default -History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 9132b3403d..f146c7eed1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 86 actions, 0 customized, 2 unbound. + 87 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -16,8 +16,8 @@ expression: "render_picker(params, 78)" Global Copy ctrl-o Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r Chat Decrease Reasoning Effort alt-, Chat Increase Reasoning Effort alt-. - Chat Edit Queued Message alt-up, shift-left left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index b6cc5dfa2d..c9b8be80fd 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 86 actions, 0 customized, 2 unbound. + 87 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -15,8 +15,8 @@ expression: "render_picker(params, 120)" Global Copy ctrl-o Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r Chat Decrease Reasoning Effort alt-, Chat Increase Reasoning Effort alt-. - Chat Edit Queued Message alt-up, shift-left left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 596c10aa75..aa98a20bc4 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -1,6 +1,7 @@ use crate::history_cell::CompositeHistoryCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; +use crate::history_cell::plain_lines; use crate::history_cell::with_border_with_inner_width; use crate::legacy_core::config::Config; use crate::token_usage::TokenUsage; @@ -788,6 +789,10 @@ impl HistoryCell for StatusHistoryCell { with_border_with_inner_width(truncated_lines, inner_width) } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } } fn format_model_provider(config: &Config, runtime_base_url: Option<&str>) -> Option { diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 2def4ae8bb..5d903c91cc 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -11,6 +11,8 @@ //! scrollback from finalized cells. use crate::history_cell::HistoryCell; +use crate::history_cell::HistoryRenderMode; +use crate::history_cell::raw_lines_from_source; use crate::history_cell::{self}; use crate::markdown::append_markdown; use crate::render::line_utils::prefix_lines; @@ -39,10 +41,11 @@ struct StreamCore { enqueued_len: usize, emitted_len: usize, cwd: PathBuf, + render_mode: HistoryRenderMode, } impl StreamCore { - fn new(width: Option, cwd: &Path) -> Self { + fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { Self { state: StreamState::new(width, cwd), width, @@ -51,6 +54,7 @@ impl StreamCore { enqueued_len: 0, emitted_len: 0, cwd: cwd.to_path_buf(), + render_mode, } } @@ -77,13 +81,7 @@ impl StreamCore { self.raw_source.push_str(&remainder_source); } - let mut rendered = Vec::new(); - append_markdown( - &self.raw_source, - self.width, - Some(self.cwd.as_path()), - &mut rendered, - ); + let rendered = self.render_source(&self.raw_source); if self.emitted_len >= rendered.len() { Vec::new() } else { @@ -150,6 +148,27 @@ impl StreamCore { self.rebuild_queue_from_render(); } + fn set_render_mode(&mut self, render_mode: HistoryRenderMode) { + if self.render_mode == render_mode { + return; + } + + let had_pending_queue = self.state.queued_len() > 0; + self.render_mode = render_mode; + if self.raw_source.is_empty() { + return; + } + + self.recompute_render(); + self.emitted_len = self.emitted_len.min(self.rendered_lines.len()); + self.state.clear_queue(); + if self.emitted_len > 0 && !had_pending_queue { + self.enqueued_len = self.rendered_lines.len(); + return; + } + self.rebuild_queue_from_render(); + } + fn clear_queue(&mut self) { self.state.clear_queue(); self.enqueued_len = self.emitted_len; @@ -164,13 +183,18 @@ impl StreamCore { } fn recompute_render(&mut self) { - self.rendered_lines.clear(); - append_markdown( - &self.raw_source, - self.width, - Some(self.cwd.as_path()), - &mut self.rendered_lines, - ); + self.rendered_lines = self.render_source(&self.raw_source); + } + + fn render_source(&self, source: &str) -> Vec> { + match self.render_mode { + HistoryRenderMode::Rich => { + let mut rendered = Vec::new(); + append_markdown(source, self.width, Some(self.cwd.as_path()), &mut rendered); + rendered + } + HistoryRenderMode::Raw => raw_lines_from_source(source), + } } /// Append newly rendered lines to the live queue without replaying already queued rows. @@ -227,9 +251,9 @@ impl StreamController { /// `width` is the content width available to markdown rendering, not necessarily the full /// terminal width. Passing a stale width after resize will keep queued live output wrapped for /// the old viewport until app-level reflow repairs the finalized transcript. - pub(crate) fn new(width: Option, cwd: &Path) -> Self { + pub(crate) fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { Self { - core: StreamCore::new(width, cwd), + core: StreamCore::new(width, cwd, render_mode), header_emitted: false, } } @@ -289,6 +313,10 @@ impl StreamController { self.core.set_width(width); } + pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) { + self.core.set_render_mode(render_mode); + } + fn emit(&mut self, lines: Vec>) -> Option> { if lines.is_empty() { return None; @@ -317,9 +345,9 @@ impl PlanStreamController { /// /// The width has the same meaning as in `StreamController`: it is the markdown body width, and /// callers must update it when the terminal width changes. - pub(crate) fn new(width: Option, cwd: &Path) -> Self { + pub(crate) fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { Self { - core: StreamCore::new(width, cwd), + core: StreamCore::new(width, cwd, render_mode), header_emitted: false, top_padding_emitted: false, } @@ -385,6 +413,10 @@ impl PlanStreamController { self.core.set_width(width); } + pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) { + self.core.set_render_mode(render_mode); + } + fn emit( &mut self, lines: Vec>, @@ -436,11 +468,11 @@ mod tests { } fn stream_controller(width: Option) -> StreamController { - StreamController::new(width, &test_cwd()) + StreamController::new(width, &test_cwd(), HistoryRenderMode::Rich) } fn plan_stream_controller(width: Option) -> PlanStreamController { - PlanStreamController::new(width, &test_cwd()) + PlanStreamController::new(width, &test_cwd(), HistoryRenderMode::Rich) } fn lines_to_plain_strings(lines: &[Line<'_>]) -> Vec { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index d7f14c8a3a..24f6e6f946 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -40,6 +40,7 @@ use tokio_stream::Stream; pub use self::frame_requester::FrameRequester; use crate::custom_terminal; use crate::custom_terminal::Terminal as CustomTerminal; +use crate::insert_history::HistoryLineWrapPolicy; use crate::notifications::DesktopNotificationBackend; use crate::notifications::detect_backend; use crate::tui::event_stream::EventBroker; @@ -369,7 +370,7 @@ pub struct Tui { draw_tx: broadcast::Sender<()>, event_broker: Arc, pub(crate) terminal: Terminal, - pending_history_lines: Vec>, + pending_history_lines: Vec, alt_saved_viewport: Option, #[cfg(unix)] suspend_context: SuspendContext, @@ -385,6 +386,11 @@ pub struct Tui { alt_screen_enabled: bool, } +struct PendingHistoryLines { + lines: Vec>, + wrap_policy: HistoryLineWrapPolicy, +} + impl Tui { pub fn new(terminal: Terminal) -> Self { let (draw_tx, _) = broadcast::channel(1); @@ -582,7 +588,25 @@ impl Tui { } pub fn insert_history_lines(&mut self, lines: Vec>) { - self.pending_history_lines.extend(lines); + self.insert_history_lines_with_wrap_policy(lines, HistoryLineWrapPolicy::PreWrap); + } + + pub fn insert_history_lines_with_wrap_policy( + &mut self, + lines: Vec>, + wrap_policy: HistoryLineWrapPolicy, + ) { + if lines.is_empty() { + return; + } + if let Some(last) = self.pending_history_lines.last_mut() + && last.wrap_policy == wrap_policy + { + last.lines.extend(lines); + } else { + self.pending_history_lines + .push(PendingHistoryLines { lines, wrap_policy }); + } self.frame_requester().schedule_frame(); } @@ -698,18 +722,21 @@ impl Tui { /// invalidate the diff buffer for a full repaint. fn flush_pending_history_lines( terminal: &mut Terminal, - pending_history_lines: &mut Vec>, + pending_history_lines: &mut Vec, is_zellij: bool, ) -> Result { if pending_history_lines.is_empty() { return Ok(false); } - crate::insert_history::insert_history_lines_with_mode( - terminal, - pending_history_lines.clone(), - crate::insert_history::InsertHistoryMode::new(is_zellij), - )?; + for batch in pending_history_lines.iter() { + crate::insert_history::insert_history_lines_with_mode_and_wrap_policy( + terminal, + batch.lines.clone(), + crate::insert_history::InsertHistoryMode::new(is_zellij), + batch.wrap_policy, + )?; + } pending_history_lines.clear(); Ok(is_zellij) }