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
parent 172303bbfa
commit 5e0a4adbe5
39 changed files with 1141 additions and 58 deletions

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(),

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()