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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user