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`
This commit is contained in:
Felipe Coury
2026-05-05 15:17:47 -03:00
committed by GitHub
Unverified
parent 172303bbfa
commit 5e0a4adbe5
39 changed files with 1141 additions and 58 deletions
+2
View File
@@ -106,6 +106,8 @@ pub struct TuiGlobalKeymap {
pub toggle_vim_mode: Option<KeybindingsSpec>,
/// Toggle Fast mode.
pub toggle_fast_mode: Option<KeybindingsSpec>,
/// Toggle raw scrollback mode for copy-friendly transcript selection.
pub toggle_raw_output: Option<KeybindingsSpec>,
}
/// Chat context keybindings.
+5
View File
@@ -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.
+15
View File
@@ -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
}
+53
View File
@@ -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(),
+8
View File
@@ -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()
@@ -197,6 +197,7 @@ fn new_config(model: Option<String>, 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,
+3
View File
@@ -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();
+26
View File
@@ -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();
+21 -8
View File
@@ -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<Line<'static>> {
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::<Vec<_>>();
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<u16> {
pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<u16> {
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(),
});
}
+5 -2
View File
@@ -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(),
);
}
}
}
+5
View File
@@ -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
@@ -167,6 +167,7 @@ mod tests {
vec![
SlashCommand::Ide,
SlashCommand::Copy,
SlashCommand::Raw,
SlashCommand::Diff,
SlashCommand::Mention,
SlashCommand::Status,
@@ -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,
}
@@ -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,
}
@@ -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,
+52
View File
@@ -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<ServiceTier>,
/// 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
@@ -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 <objective>";
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
@@ -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,
};
@@ -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,
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>()
.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;
@@ -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::<Vec<_>>()
.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::<AppEvent>();
+5
View File
@@ -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<Line<'static>> {
plain_lines(self.transcript_lines(u16::MAX))
}
}
impl ExecCell {
+538 -1
View File
@@ -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<Line<'static>> {
if source.is_empty() {
return Vec::new();
}
let mut parts = source.split('\n').collect::<Vec<_>>();
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<Item = Line<'static>>) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|line| {
let text = line
.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>();
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<Line<'static>>;
/// Returns copy-friendly plain logical lines for raw scrollback mode.
fn raw_lines(&self) -> Vec<Line<'static>>;
fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
self.lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
raw_lines_from_source(&self.markdown_source)
}
}
#[derive(Debug)]
@@ -558,6 +643,10 @@ impl HistoryCell for PlainHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
create_diff_summary(&self.changes, &self.cwd, width as usize)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
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<Line<'static>> {
vec!["tool result (image output)".into()]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
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<Line<'static>> {
vec![Line::from(format!("Tip: {}", self.tip))]
}
}
#[derive(Debug)]
@@ -1245,6 +1406,10 @@ impl HistoryCell for SessionInfoCell {
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.0.transcript_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
let mut out: Vec<Line<'static>> = 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<Line<'static>> {
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<u64> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
vec![Line::from("Loading MCP inventory...")]
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled {
return None;
@@ -2612,6 +2872,48 @@ impl HistoryCell for RequestUserInputResultCell {
lines
}
fn raw_lines(&self) -> Vec<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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 filelevel summary of
@@ -2959,6 +3289,25 @@ impl HistoryCell for FinalMessageSeparator {
.dim(),
]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
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<String> {
@@ -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::<Line<'static>>::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<Box<dyn HistoryCell>> = 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::<String>()
})
.collect::<Vec<_>>()
.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();
@@ -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<Line<'static>> {
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<u64> {
if !self.animations_enabled {
+58 -5
View File
@@ -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<B>(
@@ -83,6 +89,23 @@ pub fn insert_history_lines_with_mode<B>(
lines: Vec<Line>,
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<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
lines: Vec<Line>,
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<String> = 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;
+34
View File
@@ -65,6 +65,8 @@ pub(crate) struct AppKeymap {
pub(crate) toggle_vim_mode: Vec<KeyBinding>,
/// Toggle Fast mode.
pub(crate) toggle_fast_mode: Vec<KeyBinding>,
/// Toggle raw scrollback mode for copy-friendly transcript selection.
pub(crate) toggle_raw_output: Vec<KeyBinding>,
}
/// 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();
+3
View File
@@ -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()),
+4
View File
@@ -941,6 +941,10 @@ mod tests {
self.lines.clone()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
}
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
+8
View File
@@ -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]
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+5
View File
@@ -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<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
}
fn format_model_provider(config: &Config, runtime_base_url: Option<&str>) -> Option<String> {
+53 -21
View File
@@ -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<usize>, cwd: &Path) -> Self {
fn new(width: Option<usize>, 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<Line<'static>> {
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<usize>, cwd: &Path) -> Self {
pub(crate) fn new(width: Option<usize>, 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<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
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<usize>, cwd: &Path) -> Self {
pub(crate) fn new(width: Option<usize>, 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<Line<'static>>,
@@ -436,11 +468,11 @@ mod tests {
}
fn stream_controller(width: Option<usize>) -> StreamController {
StreamController::new(width, &test_cwd())
StreamController::new(width, &test_cwd(), HistoryRenderMode::Rich)
}
fn plan_stream_controller(width: Option<usize>) -> PlanStreamController {
PlanStreamController::new(width, &test_cwd())
PlanStreamController::new(width, &test_cwd(), HistoryRenderMode::Rich)
}
fn lines_to_plain_strings(lines: &[Line<'_>]) -> Vec<String> {
+35 -8
View File
@@ -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<EventBroker>,
pub(crate) terminal: Terminal,
pending_history_lines: Vec<Line<'static>>,
pending_history_lines: Vec<PendingHistoryLines>,
alt_saved_viewport: Option<ratatui::layout::Rect>,
#[cfg(unix)]
suspend_context: SuspendContext,
@@ -385,6 +386,11 @@ pub struct Tui {
alt_screen_enabled: bool,
}
struct PendingHistoryLines {
lines: Vec<Line<'static>>,
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<Line<'static>>) {
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<Line<'static>>,
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<Line<'static>>,
pending_history_lines: &mut Vec<PendingHistoryLines>,
is_zellij: bool,
) -> Result<bool> {
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)
}