Strip nF escapes so rustfmt output loses its B

rustfmt and `git diff` close every coloured run with `ESC ( B` (designate G0
as ASCII) and write it after the newline, ahead of the SGR reset. With no arm
for nF escapes the parser fell through to "two-char escape", ate `ESC (` and
left the `B` as text, so each diff line in the feed read `B+ added line`.

Intermediates 0x20-0x2f now run to a final 0x30-0x7e, and an unterminated
sequence stops where the CSI arm would.
This commit is contained in:
Jonas H
2026-08-27 10:33:18 +02:00
parent ba6b18e7d9
commit 8907766b5b
2 changed files with 42 additions and 5 deletions

View File

@@ -57,10 +57,12 @@ src/app.rs Arc<Mutex<App>> shared state; Tap = one in-flight tapped request,
out of the user prompt and turns each into a one-line
Kind::TaskNote
src/ansi.rs self-contained SGR parser (no dependency): CSI `…m` → ratatui
Style; every other escape (other CSI finals, OSC/DCS/APC,
two-char) is stripped. `ui::sanitize`/`sanitize_md` are thin
wrappers over `ansi::strip`/`strip_multiline`, so dropping the
ESC byte no longer leaves `[1m` behind as literal text
Style; every other escape (other CSI finals, OSC/DCS/APC, nF
charset designation, two-char) is stripped. `ui::sanitize`/
`sanitize_md` are thin wrappers over
`ansi::strip`/`strip_multiline`, so dropping the ESC byte no
longer leaves `[1m` behind as literal text — nor the `B` of the
`ESC ( B` that rustfmt and `git diff` write after every newline
src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
(FeedCache: per-entry rendered lines + wrapped heights, only
changed entries re-render; the viewport window of lines is

View File

@@ -172,7 +172,8 @@ fn runs(text: &str, keep_newlines: bool) -> Vec<(String, Sgr)> {
/// sequence's parameter string.
///
/// Everything else is stripped with no styling: other CSI finals (cursor moves,
/// erases), OSC/DCS/SOS/PM/APC strings, and two-char escapes. Damage from
/// erases), OSC/DCS/SOS/PM/APC strings, nF escapes (charset designation) and
/// two-char escapes. Damage from
/// malformed input is bounded — a CSI whose final byte never arrives consumes
/// only the parameter bytes it saw, and a string sequence missing its
/// terminator stops at a newline, so at most one line is lost rather than the
@@ -217,6 +218,23 @@ fn escape_at(text: &str, i: usize) -> (usize, Option<&str>) {
}
(b.len(), None)
}
// nF escape (`ESC I… F`): intermediate bytes 0x20-0x2f, then a final
// 0x30-0x7e. Charset designation lives here — `ESC ( B` (G0 = ASCII),
// which rustfmt and `git diff` emit after *every* colour reset
// (`\x1b(B\x1b[m`). Consumed as a two-char escape it left a literal `B`
// at the head of each coloured run.
Some(&c) if (0x20..=0x2f).contains(&c) => {
let mut j = i + 2;
while j < b.len() && (0x20..=0x2f).contains(&b[j]) {
j += 1;
}
match b.get(j) {
Some(&f) if (0x30..=0x7e).contains(&f) => (j + 1, None),
// Never terminated (end of text, or a UTF-8 lead byte): stop
// here, exactly as the CSI arm does.
_ => (j, None),
}
}
// Two-char escape (`ESC c`); in malformed input `c` may be multi-byte.
Some(_) => {
let n = text[i + 1..].chars().next().map_or(1, char::len_utf8);
@@ -408,6 +426,23 @@ mod tests {
assert_eq!(strip("a\u{1b}[2Kb\u{1b}[10;5Hc\u{1b}=d"), "abcd");
}
/// Real `cargo fmt` output: each coloured run is closed with `ESC ( B`
/// (G0 = ASCII) *before* the SGR reset. Consumed as a two-char escape that
/// left the `B` behind, so every diff line in the feed read `B+ added line`.
#[test]
fn charset_designation_leaves_no_stray_letter() {
let line = "\u{1b}[32m+ break;\u{1b}(B\u{1b}[m";
assert_eq!(strip(line), "+ break;");
let p = parts(line);
assert_eq!(p.len(), 1, "one green run, no stray letter: {p:?}");
assert_eq!(p[0].1, Some(Color::Green));
// Other nF escapes: line-drawing G1, `ESC # 8` (DECALN), `ESC % G`.
assert_eq!(strip("a\u{1b})0b\u{1b}#8c\u{1b}%Gd"), "abcd");
// Never terminated: the text after it still reaches the reader.
assert_eq!(strip("keep \u{1b}("), "keep ");
assert_eq!(strip("keep \u{1b}(\u{e6}"), "keep \u{e6}");
}
/// Malformed input must not panic and must not eat the visible text.
#[test]
fn malformed_escapes_keep_the_rest_of_the_line() {