diff --git a/CLAUDE.md b/CLAUDE.md index 2539a28..8a26184 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,10 +57,12 @@ src/app.rs Arc> 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 diff --git a/src/ansi.rs b/src/ansi.rs index 48bf2af..b5c3cc9 100644 --- a/src/ansi.rs +++ b/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() {