fix(render): disable OSC 8 default + strip ANSI from tool output

ratatui's buffer drops the bare ESC byte but happily paints every
other byte of an escape (`[`, `0`, `;`, `m`, OSC payloads, etc.) into
a buffer cell. That drifts columns by the escape-body length and
produces user-reported corruption like `526sOPEN` instead of
`526   OPEN` when shell tools (`gh`, `git` with color forced on, PTY
runs) emit ANSI in stdout.

Two changes:

- Default OSC 8 emission off on every platform until it can be emitted
  out-of-band of the ratatui buffer pipeline. macOS users with a
  conformant terminal can still opt in via `[ui] osc8_links = true`.
- Add `osc8::strip_ansi_into` (handles CSI, OSC, DCS/SOS/PM/APC, and
  standalone two-byte ESC) and apply it in `output_rows` so shell
  tool output is sanitized before it enters the transcript. Raw bytes
  remain available to spillover and the model.

Tests cover SGR stripping, OSC 8 wrappers, control-byte handling, and
preservation of `\n` / `\r` / `\t`.
This commit is contained in:
Hunter Bown
2026-05-03 13:48:07 -05:00
parent 1d315ec3d6
commit 4c7be1f90b
3 changed files with 114 additions and 8 deletions
+6 -3
View File
@@ -2322,15 +2322,18 @@ fn render_preserved_output_mode(
fn output_rows(output: &str, width: u16) -> Vec<OutputRow> {
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,
+100
View File
@@ -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 [ ... <final byte 0x40..=0x7E>
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
+8 -5
View File
@@ -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