diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index cfc5cdbf..0e7d107d 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -2322,15 +2322,18 @@ fn render_preserved_output_mode( fn output_rows(output: &str, width: u16) -> Vec { let wrap_width = width.saturating_sub(4).max(1) as usize; let mut rows = Vec::new(); + let mut sanitized = String::with_capacity(output.len()); for line in output.lines() { - let intact = is_path_or_url_like(line); + sanitized.clear(); + crate::tui::osc8::strip_ansi_into(line, &mut sanitized); + let intact = is_path_or_url_like(&sanitized); if intact { rows.push(OutputRow { - text: line.to_string(), + text: sanitized.clone(), intact: true, }); } else { - for wrapped in wrap_text(line, wrap_width) { + for wrapped in wrap_text(&sanitized, wrap_width) { rows.push(OutputRow { text: wrapped, intact: false, diff --git a/crates/tui/src/tui/osc8.rs b/crates/tui/src/tui/osc8.rs index 57426e52..1d27a172 100644 --- a/crates/tui/src/tui/osc8.rs +++ b/crates/tui/src/tui/osc8.rs @@ -55,6 +55,74 @@ pub fn wrap_link(target: &str, label: &str) -> String { out } +/// Strip every ANSI escape sequence from `s` into `out`, preserving only the +/// visible characters. ratatui's buffer drops the leading `ESC` byte but +/// happily paints every other byte of an escape (`[`, `0`, `;`, `m`, OSC +/// payloads, etc.) into a buffer cell, drifting columns. Tool stdout that +/// includes ANSI (e.g. `gh`/`git` with color forced on, anything run through +/// a PTY) must be sanitized before it enters the transcript. +/// +/// Handles CSI (`ESC [ … final`), OSC (`ESC ] … BEL` or `ESC \`), DCS, SOS, +/// PM, APC, and standalone two-byte ESC sequences. OSC 8 hyperlink wrappers +/// (`ESC ] 8 ; … BEL` / `ESC \`) are stripped along with the rest. +pub fn strip_ansi_into(s: &str, out: &mut String) { + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == 0x1b && i + 1 < bytes.len() { + let next = bytes[i + 1]; + match next { + // CSI: ESC [ ... + b'[' => { + let mut j = i + 2; + while j < bytes.len() { + let b = bytes[j]; + if (0x40..=0x7e).contains(&b) { + j += 1; + break; + } + j += 1; + } + i = j; + continue; + } + // OSC / DCS / SOS / PM / APC: ESC ] | P | X | ^ | _ ... ST(ESC \) or BEL + b']' | b'P' | b'X' | b'^' | b'_' => { + let mut j = i + 2; + while j < bytes.len() { + if bytes[j] == 0x07 { + j += 1; + break; + } + if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { + j += 2; + break; + } + j += 1; + } + i = j; + continue; + } + // Standalone two-byte ESC sequence (RIS, charset selection, etc.) + _ => { + i += 2; + continue; + } + } + } + // Strip lone control bytes that ratatui would otherwise drop (and which + // mean nothing in transcript output) but keep \n, \r, \t as legitimate + // formatting. + let b = bytes[i]; + if b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t' { + i += 1; + continue; + } + out.push(b as char); + i += 1; + } +} + /// Strip OSC 8 escape sequences from `s` into `out`, preserving the visible /// label text. Other escapes (color, style) pass through untouched. The /// implementation handles both the standard `ESC \` and the lone `BEL` @@ -143,6 +211,38 @@ mod tests { assert_eq!(strip(&mixed), "\x1b[31mred\x1b[0m click"); } + fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + strip_ansi_into(s, &mut out); + out + } + + #[test] + fn strip_ansi_removes_csi_sgr_and_keeps_text() { + let coloured = "526 \x1b[1;32mOPEN\x1b[0m bug fix"; + assert_eq!(strip_ansi(coloured), "526 OPEN bug fix"); + } + + #[test] + fn strip_ansi_removes_osc_8_wrapper() { + let wrapped = wrap_link("https://example.com", "click"); + assert_eq!(strip_ansi(&wrapped), "click"); + } + + #[test] + fn strip_ansi_preserves_newlines_tabs_and_cr() { + let s = "a\nb\tc\rd"; + assert_eq!(strip_ansi(s), "a\nb\tc\rd"); + } + + #[test] + fn strip_ansi_drops_lone_control_bytes() { + // Bare BEL or other C0 control bytes that aren't \n/\r/\t are dropped + // so they can't paint as visible cells. + let s = "a\x07b\x01c"; + assert_eq!(strip_ansi(s), "abc"); + } + #[test] fn enabled_is_true_by_default_when_untouched() { // Hold the flag guard so we observe the initial state, not a value diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 240ce5d5..58b8f380 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -157,11 +157,14 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { // duplicate the composer panel during scroll. Reported on a // Windows session (issue forthcoming, screenshot showed // "eepseek-v4-flash" with the leading `d` consumed and three - // overlapping composer panels). Mac/Linux still default-on; users - // on a Windows console that *does* support OSC 8 (Windows - // Terminal, Alacritty, WezTerm) can opt back in via - // `[ui] osc8_links = true`. - let osc8_default_on = !cfg!(windows); + // overlapping composer panels). v0.8.8 also surfaced macOS + // corruption ("526sOPEN" instead of "526 OPEN") because OSC 8 + // wrappers are emitted inside ratatui `Span` content; ratatui's + // grapheme filter drops the bare ESC byte but paints every other + // byte of the wrapper into a buffer cell, drifting columns. Until + // OSC 8 is emitted out-of-band of the buffer pipeline, default off + // on every platform; opt back in via `[ui] osc8_links = true`. + let osc8_default_on = false; crate::tui::osc8::set_enabled( config .tui