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:
10
CLAUDE.md
10
CLAUDE.md
@@ -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
|
||||
|
||||
37
src/ansi.rs
37
src/ansi.rs
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user