diff --git a/.gitattributes b/.gitattributes index ceb10cdb3b..9da598bc8b 100755 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,6 @@ rust/src/agent_sessions/fixtures/** -text # Bash scripts must remain LF for Linux and WSL execution. *.sh text eol=lf + +# Upstream Claude PTY captures are byte-exact copies (they contain CR bytes); never EOL-convert them. +rust/src/providers/fixtures/claude/*.ansi -text diff --git a/rust/src/providers/claude/cli_screen.rs b/rust/src/providers/claude/cli_screen.rs new file mode 100644 index 0000000000..4adac081ea --- /dev/null +++ b/rust/src/providers/claude/cli_screen.rs @@ -0,0 +1,697 @@ +//! Virtual screen replay for Claude CLI `/usage` captures. +//! +//! Claude's terminal UI redraws panels differentially: it moves the cursor and +//! rewrites only the cells that changed. Stripping escape sequences from such a +//! capture glues unrelated fragments together (`51%us` + jump + `d` becomes +//! `51%usd`). Replaying the cursor and erase operations onto a bounded screen +//! that matches the PTY geometry keeps the unchanged cells of earlier frames. +//! +//! Ported from upstream `ClaudeCLIScreen.swift` (v0.65.0). The screen has no +//! scrollback: erased or scrolled-off content is never reused. + +/// Screen width, equal to the PTY columns in `cli::tty_runner`. +pub(super) const COLUMNS: usize = 160; +/// Screen height, equal to the PTY rows in `cli::tty_runner`. +pub(super) const ROWS: usize = 50; + +/// Replay `text` onto a 160x50 screen and return the visible rows. +/// +/// With `preserve_plain_reports`, a capture that never moves the cursor or +/// erases cells (and has no backspace) is returned as its original text with +/// escape sequences removed: color-only or CR-delimited reports are neither +/// clipped nor wrapped to the PTY geometry. Replay takes over at the first +/// cursor or erase sequence, or backspace. +pub(super) fn render(text: &str, preserve_plain_reports: bool) -> String { + let mut screen = Screen::new(); + let mut replay = !preserve_plain_reports; + let mut plain = String::new(); + for token in tokens(text) { + match token { + Token::Csi(sequence) => { + if screen.apply_csi(sequence) { + replay = true; + } + } + Token::Text(run) => { + if !replay { + plain.push_str(run); + replay = run.contains('\u{8}'); + } + for cluster in Clusters::new(run) { + screen.write(&cluster); + } + } + } + } + if replay { screen.into_text() } else { plain } +} + +enum Token<'a> { + /// Parameter, intermediate and final bytes after `ESC [`. + Csi(&'a str), + /// A run of non-escape characters. + Text(&'a str), +} + +/// Split a capture into CSI sequences and text runs. OSC strings and other +/// escape sequences are consumed whole and produce no token. +/// +/// Mirrors upstream's pattern +/// `ESC(?:\[[0-?]*[ -/]*[@-~]?|\][\s\S]*?(?:BEL|ESC\\|$)|.)|[^ESC]+`, where +/// `.` does not match a line terminator and `$` also matches before a final one. +fn tokens(text: &str) -> impl Iterator> { + let mut rest = text; + std::iter::from_fn(move || { + loop { + if rest.is_empty() { + return None; + } + let Some(after_escape) = rest.strip_prefix('\u{1b}') else { + let end = rest.find('\u{1b}').unwrap_or(rest.len()); + let (run, tail) = rest.split_at(end); + rest = tail; + return Some(Token::Text(run)); + }; + if let Some(body) = after_escape.strip_prefix('[') { + let end = csi_length(body); + let (sequence, tail) = body.split_at(end); + rest = tail; + return Some(Token::Csi(sequence)); + } + if let Some(body) = after_escape.strip_prefix(']') { + rest = &body[osc_length(body)..]; + continue; + } + match after_escape.chars().next() { + Some(next) if !is_line_terminator(next) => { + rest = &after_escape[next.len_utf8()..]; + } + // A lone escape before a line terminator or at the end is dropped. + _ => rest = after_escape, + } + } + }) +} + +/// Byte length of `[0-?]*[ -/]*[@-~]?` at the start of `body`. +fn csi_length(body: &str) -> usize { + let bytes = body.as_bytes(); + let mut end = 0; + while end < bytes.len() && (0x30..=0x3F).contains(&bytes[end]) { + end += 1; + } + while end < bytes.len() && (0x20..=0x2F).contains(&bytes[end]) { + end += 1; + } + if end < bytes.len() && (0x40..=0x7E).contains(&bytes[end]) { + end += 1; + } + end +} + +/// Byte length of an OSC string body including its terminator (BEL or `ESC \`). +/// An unterminated string runs to the end of the input, or to a final line +/// terminator, which stays visible text. +fn osc_length(body: &str) -> usize { + for (index, c) in body.char_indices() { + match c { + '\u{7}' => return index + 1, + '\u{1b}' if body[index + 1..].starts_with('\\') => return index + 2, + _ => {} + } + } + let trimmed = body + .strip_suffix("\r\n") + .or_else(|| body.strip_suffix(is_line_terminator)) + .unwrap_or(body); + trimmed.len() +} + +fn is_line_terminator(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}') +} + +#[derive(Clone, PartialEq, Eq)] +enum Cell { + Blank, + Glyph(String), + /// Right half of a double-width glyph. + Trail, +} + +struct Screen { + lines: Vec>, + row: usize, + column: usize, +} + +impl Screen { + fn new() -> Self { + Self { + lines: vec![vec![Cell::Blank; COLUMNS]; ROWS], + row: 0, + column: 0, + } + } + + fn into_text(self) -> String { + let mut rows: Vec = self + .lines + .into_iter() + .map(|mut line| { + while line.last() == Some(&Cell::Blank) { + line.pop(); + } + line.iter() + .map(|cell| match cell { + Cell::Blank => " ", + Cell::Glyph(glyph) => glyph, + Cell::Trail => "", + }) + .collect() + }) + .collect(); + while rows.last().is_some_and(String::is_empty) { + rows.pop(); + } + rows.join("\n") + } + + fn newline(&mut self) { + self.column = 0; + self.row += 1; + if self.row == ROWS { + self.lines.remove(0); + self.lines.push(vec![Cell::Blank; COLUMNS]); + self.row -= 1; + } + } + + fn write(&mut self, cluster: &str) { + match cluster { + "\r" => self.column = 0, + "\n" | "\r\n" => self.newline(), + "\u{8}" => self.column = self.column.min(COLUMNS - 1).saturating_sub(1), + "\t" => self.column = (COLUMNS - 1).min((self.column / 8 + 1) * 8), + _ => self.write_glyph(cluster), + } + } + + fn write_glyph(&mut self, cluster: &str) { + if !cluster.chars().all(|c| c >= ' ' && c != '\u{7f}') { + return; + } + let width = cell_width(cluster); + if width == 0 { + self.attach_combining(cluster); + return; + } + if self.column + width > COLUMNS { + self.newline(); + } + for cell in self.column..self.column + width { + self.clear_cell(self.row, cell); + } + let glyph = if cluster == " " { + Cell::Blank + } else { + Cell::Glyph(cluster.to_string()) + }; + self.lines[self.row][self.column] = glyph; + if width == 2 { + self.lines[self.row][self.column + 1] = Cell::Trail; + } + self.column += width; + } + + /// A zero-width cluster joins the previous cell when it is a combining + /// mark or joiner; other zero-width characters are dropped. + fn attach_combining(&mut self, cluster: &str) { + let Some(first) = cluster.chars().next() else { + return; + }; + if !is_extend(first) { + return; + } + let Some(mut previous) = self.column.min(COLUMNS).checked_sub(1) else { + return; + }; + if self.lines[self.row][previous] == Cell::Trail { + let Some(lead) = previous.checked_sub(1) else { + return; + }; + previous = lead; + } + let joined = match &self.lines[self.row][previous] { + Cell::Blank => format!(" {cluster}"), + Cell::Glyph(glyph) => format!("{glyph}{cluster}"), + Cell::Trail => return, + }; + self.lines[self.row][previous] = Cell::Glyph(joined); + } + + /// Apply a cursor or erase sequence. Returns false when the sequence is + /// not one the replay handles (colors, modes, private sequences, ...). + fn apply_csi(&mut self, sequence: &str) -> bool { + let Some(command) = sequence.chars().next_back() else { + return false; + }; + if !"ABCDGHfKJ".contains(command) { + return false; + } + let parameters = &sequence[..sequence.len() - command.len_utf8()]; + if !parameters.bytes().all(|b| b.is_ascii_digit() || b == b';') { + return false; + } + // At most two parameters, each clamped to the screen width before any + // arithmetic, even for overflowing decimal input. + let values: Vec = parameters + .splitn(3, ';') + .take(2) + .map(|value| match value { + "" => 0, + digits => digits.parse().map_or(COLUMNS, |n: usize| n.min(COLUMNS)), + }) + .collect(); + let mode = values.first().copied().unwrap_or(0); + let amount = mode.max(1); + if matches!(command, 'K' | 'J') && mode > 2 { + return false; + } + self.column = self.column.min(COLUMNS - 1); + match command { + 'A' => self.row = self.row.saturating_sub(amount), + 'B' => self.row = (ROWS - 1).min(self.row + amount), + 'C' => self.column = (COLUMNS - 1).min(self.column + amount), + 'D' => self.column = self.column.saturating_sub(amount), + 'G' => self.column = (COLUMNS - 1).min(amount - 1), + 'H' | 'f' => { + self.row = (ROWS - 1).min(amount - 1); + let column = values.get(1).copied().unwrap_or(0).max(1); + self.column = (COLUMNS - 1).min(column - 1); + } + _ => self.erase(command == 'K', mode), + } + true + } + + /// `EL` (`K`, line) or `ED` (`J`, screen) with mode 0 (cursor to end), + /// 1 (start to cursor) or 2 (all). + fn erase(&mut self, line_only: bool, mode: usize) { + let cursor = self.row * COLUMNS + self.column; + let (start, end) = if line_only { + (self.row * COLUMNS, (self.row + 1) * COLUMNS) + } else { + (0, ROWS * COLUMNS) + }; + let first = if mode == 0 { cursor } else { start }; + let last = if mode == 1 { cursor + 1 } else { end }; + for cell in first..last { + self.clear_cell(cell / COLUMNS, cell % COLUMNS); + } + } + + /// Blank a cell, also blanking the other half of a wide glyph it overlaps. + fn clear_cell(&mut self, row: usize, column: usize) { + let line = &mut self.lines[row]; + if column > 0 && line[column] == Cell::Trail { + line[column - 1] = Cell::Blank; + } + if column + 1 < COLUMNS && line[column + 1] == Cell::Trail { + line[column + 1] = Cell::Blank; + } + line[column] = Cell::Blank; + } +} + +/// Grapheme-like clusters of a text run: a base character with its combining +/// marks, joiner sequences, emoji modifiers and regional-indicator pairs, plus +/// `CR LF` as one unit. The crate has no grapheme segmentation dependency, so +/// this covers the sequences terminal panels emit rather than full UAX #29. +struct Clusters<'a> { + chars: std::iter::Peekable>, +} + +impl<'a> Clusters<'a> { + fn new(text: &'a str) -> Self { + Self { + chars: text.chars().peekable(), + } + } +} + +impl Iterator for Clusters<'_> { + type Item = String; + + fn next(&mut self) -> Option { + let first = self.chars.next()?; + let mut cluster = String::from(first); + if first == '\r' { + if self.chars.next_if_eq(&'\n').is_some() { + cluster.push('\n'); + } + return Some(cluster); + } + if first.is_control() { + return Some(cluster); + } + let mut last = first; + if is_regional_indicator(first) + && let Some(second) = self.chars.next_if(|c| is_regional_indicator(*c)) + { + cluster.push(second); + last = second; + } + while let Some(&next) = self.chars.peek() { + let joins = is_extend(next) + || is_emoji_modifier(next) + || (last == '\u{200D}' && is_pictographic(next)); + if !joins { + break; + } + cluster.push(next); + last = next; + self.chars.next(); + } + Some(cluster) + } +} + +/// Number of cells a cluster occupies: 0 for bare combining marks, 2 for wide +/// and emoji-presentation glyphs, otherwise 1 (usage-bar blocks stay 1). +fn cell_width(cluster: &str) -> usize { + let mut chars = cluster.chars(); + if let (Some(only), None) = (chars.next(), chars.next()) + && only.is_ascii() + { + return 1; + } + let Some(base) = cluster.chars().find(|c| !is_zero_width(*c)) else { + return 0; + }; + let emoji_presentation = cluster.chars().any(|c| in_ranges(c, EMOJI_PRESENTATION)); + let emoji_with_selector = in_ranges(base, EMOJI) && cluster.contains('\u{FE0F}'); + if emoji_presentation || emoji_with_selector || in_ranges(base, WIDE) { + 2 + } else { + 1 + } +} + +type Ranges = &'static [(u32, u32)]; + +fn in_ranges(c: char, ranges: Ranges) -> bool { + let value = c as u32; + ranges + .binary_search_by(|&(low, high)| { + if value < low { + std::cmp::Ordering::Greater + } else if value > high { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() +} + +/// Nonspacing and enclosing marks plus the joiners and selectors that extend +/// the preceding character (Unicode `Grapheme_Extend`, approximated by block). +fn is_extend(c: char) -> bool { + in_ranges(c, EXTEND) +} + +/// Marks, joiners and other format characters (general category Mn, Me, Cf). +fn is_zero_width(c: char) -> bool { + is_extend(c) || in_ranges(c, FORMAT) +} + +fn is_emoji_modifier(c: char) -> bool { + ('\u{1F3FB}'..='\u{1F3FF}').contains(&c) +} + +fn is_regional_indicator(c: char) -> bool { + ('\u{1F1E6}'..='\u{1F1FF}').contains(&c) +} + +/// Characters that may follow a zero-width joiner inside an emoji sequence. +fn is_pictographic(c: char) -> bool { + matches!(c as u32, 0x2190..=0x2BFF | 0x1F000..=0x1FAFF) +} + +// Sorted, non-overlapping code point ranges for `in_ranges`. + +const EXTEND: Ranges = &[ + (0x0300, 0x036F), + (0x0483, 0x0489), + (0x0591, 0x05BD), + (0x05BF, 0x05BF), + (0x05C1, 0x05C2), + (0x05C4, 0x05C5), + (0x05C7, 0x05C7), + (0x0610, 0x061A), + (0x064B, 0x065F), + (0x0670, 0x0670), + (0x06D6, 0x06DC), + (0x06DF, 0x06E4), + (0x06E7, 0x06E8), + (0x06EA, 0x06ED), + (0x0E31, 0x0E31), + (0x0E34, 0x0E3A), + (0x0E47, 0x0E4E), + (0x1AB0, 0x1AFF), + (0x1DC0, 0x1DFF), + (0x200C, 0x200D), + (0x20D0, 0x20F0), + (0x302A, 0x302F), + (0x3099, 0x309A), + (0xFE00, 0xFE0F), + (0xFE20, 0xFE2F), + (0xE0020, 0xE007F), + (0xE0100, 0xE01EF), +]; + +const FORMAT: Ranges = &[ + (0x00AD, 0x00AD), + (0x0600, 0x0605), + (0x061C, 0x061C), + (0x06DD, 0x06DD), + (0x070F, 0x070F), + (0x200B, 0x200B), + (0x200E, 0x200F), + (0x202A, 0x202E), + (0x2060, 0x2064), + (0x2066, 0x206F), + (0xFEFF, 0xFEFF), + (0xE0001, 0xE0001), +]; + +/// Wide and fullwidth blocks, as listed upstream (block characters used by +/// usage bars are deliberately absent and stay one cell). +const WIDE: Ranges = &[ + (0x1100, 0x115F), + (0x2E80, 0x303E), + (0x3041, 0x33FF), + (0x3400, 0x4DBF), + (0x4E00, 0x9FFF), + (0xA000, 0xA4CF), + (0xA960, 0xA97F), + (0xAC00, 0xD7A3), + (0xF900, 0xFAFF), + (0xFE10, 0xFE19), + (0xFE30, 0xFE6F), + (0xFF00, 0xFF60), + (0xFFE0, 0xFFE6), + (0x16FE0, 0x16FE4), + (0x17000, 0x18AFF), + (0x1B000, 0x1B2FF), + (0x1F200, 0x1F251), + (0x20000, 0x2FFFD), + (0x30000, 0x3FFFD), +]; + +/// Unicode `Emoji_Presentation`: rendered as a two-cell emoji by default. +const EMOJI_PRESENTATION: Ranges = &[ + (0x231A, 0x231B), + (0x23E9, 0x23EC), + (0x23F0, 0x23F0), + (0x23F3, 0x23F3), + (0x25FD, 0x25FE), + (0x2614, 0x2615), + (0x2648, 0x2653), + (0x267F, 0x267F), + (0x2693, 0x2693), + (0x26A1, 0x26A1), + (0x26AA, 0x26AB), + (0x26BD, 0x26BE), + (0x26C4, 0x26C5), + (0x26CE, 0x26CE), + (0x26D4, 0x26D4), + (0x26EA, 0x26EA), + (0x26F2, 0x26F3), + (0x26F5, 0x26F5), + (0x26FA, 0x26FA), + (0x26FD, 0x26FD), + (0x2705, 0x2705), + (0x270A, 0x270B), + (0x2728, 0x2728), + (0x274C, 0x274C), + (0x274E, 0x274E), + (0x2753, 0x2755), + (0x2757, 0x2757), + (0x2795, 0x2797), + (0x27B0, 0x27B0), + (0x27BF, 0x27BF), + (0x2B1B, 0x2B1C), + (0x2B50, 0x2B50), + (0x2B55, 0x2B55), + (0x1F004, 0x1F004), + (0x1F0CF, 0x1F0CF), + (0x1F18E, 0x1F18E), + (0x1F191, 0x1F19A), + (0x1F1E6, 0x1F1FF), + (0x1F201, 0x1F201), + (0x1F21A, 0x1F21A), + (0x1F22F, 0x1F22F), + (0x1F232, 0x1F236), + (0x1F238, 0x1F23A), + (0x1F250, 0x1F251), + (0x1F300, 0x1F320), + (0x1F32D, 0x1F335), + (0x1F337, 0x1F37C), + (0x1F37E, 0x1F393), + (0x1F3A0, 0x1F3CA), + (0x1F3CF, 0x1F3D3), + (0x1F3E0, 0x1F3F0), + (0x1F3F4, 0x1F3F4), + (0x1F3F8, 0x1F43E), + (0x1F440, 0x1F440), + (0x1F442, 0x1F4FC), + (0x1F4FF, 0x1F53D), + (0x1F54B, 0x1F54E), + (0x1F550, 0x1F567), + (0x1F57A, 0x1F57A), + (0x1F595, 0x1F596), + (0x1F5A4, 0x1F5A4), + (0x1F5FB, 0x1F64F), + (0x1F680, 0x1F6C5), + (0x1F6CC, 0x1F6CC), + (0x1F6D0, 0x1F6D2), + (0x1F6D5, 0x1F6D7), + (0x1F6DC, 0x1F6DF), + (0x1F6EB, 0x1F6EC), + (0x1F6F4, 0x1F6FC), + (0x1F7E0, 0x1F7EB), + (0x1F7F0, 0x1F7F0), + (0x1F90C, 0x1F93A), + (0x1F93C, 0x1F945), + (0x1F947, 0x1F9FF), + (0x1FA70, 0x1FA7C), + (0x1FA80, 0x1FA89), + (0x1FA8F, 0x1FAC6), + (0x1FACE, 0x1FADC), + (0x1FADF, 0x1FAE9), + (0x1FAF0, 0x1FAF8), +]; + +/// Unicode `Emoji`: characters that become two-cell emoji when followed by +/// U+FE0F (text-presentation symbols such as the warning sign). +const EMOJI: Ranges = &[ + (0x0023, 0x0023), + (0x002A, 0x002A), + (0x0030, 0x0039), + (0x00A9, 0x00A9), + (0x00AE, 0x00AE), + (0x203C, 0x203C), + (0x2049, 0x2049), + (0x2122, 0x2122), + (0x2139, 0x2139), + (0x2194, 0x2199), + (0x21A9, 0x21AA), + (0x231A, 0x231B), + (0x2328, 0x2328), + (0x23CF, 0x23CF), + (0x23E9, 0x23F3), + (0x23F8, 0x23FA), + (0x24C2, 0x24C2), + (0x25AA, 0x25AB), + (0x25B6, 0x25B6), + (0x25C0, 0x25C0), + (0x25FB, 0x25FE), + (0x2600, 0x2604), + (0x260E, 0x260E), + (0x2611, 0x2611), + (0x2614, 0x2615), + (0x2618, 0x2618), + (0x261D, 0x261D), + (0x2620, 0x2620), + (0x2622, 0x2623), + (0x2626, 0x2626), + (0x262A, 0x262A), + (0x262E, 0x262F), + (0x2638, 0x263A), + (0x2640, 0x2640), + (0x2642, 0x2642), + (0x2648, 0x2653), + (0x265F, 0x2660), + (0x2663, 0x2663), + (0x2665, 0x2666), + (0x2668, 0x2668), + (0x267B, 0x267B), + (0x267E, 0x267F), + (0x2692, 0x2697), + (0x2699, 0x2699), + (0x269B, 0x269C), + (0x26A0, 0x26A1), + (0x26A7, 0x26A7), + (0x26AA, 0x26AB), + (0x26B0, 0x26B1), + (0x26BD, 0x26BE), + (0x26C4, 0x26C5), + (0x26C8, 0x26C8), + (0x26CE, 0x26CF), + (0x26D1, 0x26D1), + (0x26D3, 0x26D4), + (0x26E9, 0x26EA), + (0x26F0, 0x26F5), + (0x26F7, 0x26FA), + (0x26FD, 0x26FD), + (0x2702, 0x2702), + (0x2705, 0x2705), + (0x2708, 0x270D), + (0x270F, 0x270F), + (0x2712, 0x2712), + (0x2714, 0x2714), + (0x2716, 0x2716), + (0x271D, 0x271D), + (0x2721, 0x2721), + (0x2728, 0x2728), + (0x2733, 0x2734), + (0x2744, 0x2744), + (0x2747, 0x2747), + (0x274C, 0x274C), + (0x274E, 0x274E), + (0x2753, 0x2755), + (0x2757, 0x2757), + (0x2763, 0x2764), + (0x2795, 0x2797), + (0x27A1, 0x27A1), + (0x27B0, 0x27B0), + (0x27BF, 0x27BF), + (0x2934, 0x2935), + (0x2B05, 0x2B07), + (0x2B1B, 0x2B1C), + (0x2B50, 0x2B50), + (0x2B55, 0x2B55), + (0x3030, 0x3030), + (0x303D, 0x303D), + (0x3297, 0x3297), + (0x3299, 0x3299), + (0x1F004, 0x1F004), + (0x1F0CF, 0x1F0CF), + (0x1F170, 0x1F251), + (0x1F300, 0x1FAFF), +]; + +#[cfg(test)] +mod tests; diff --git a/rust/src/providers/claude/cli_screen/tests.rs b/rust/src/providers/claude/cli_screen/tests.rs new file mode 100644 index 0000000000..ccf2d74b3e --- /dev/null +++ b/rust/src/providers/claude/cli_screen/tests.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::providers::claude::ClaudeProvider; + +/// Byte-exact upstream fixtures (`Tests/CodexBarTests/Fixtures/Providers/Claude` +/// at v0.65.0); `.gitattributes` keeps their CR bytes. +const USAGE_FIXTURE: &str = + include_str!("../../fixtures/claude/usage-pty-differential-redraw.ansi"); +const STATUS_FIXTURE: &str = + include_str!("../../fixtures/claude/status-pty-differential-redraw.ansi"); + +fn replay(text: &str) -> String { + render(text, false) +} + +#[test] +fn cursor_and_erase_operations_preserve_visible_cells() { + let vectors = [ + ("abc\rZ", "Zbc"), + ("one\r\ntwo\nthree", "one\ntwo\nthree"), + ("abc\u{8}Z", "abZ"), + ("abc\r\u{1b}[2CZ", "abZ"), + ("abc\u{1b}[2DZ", "aZc"), + ("a\u{1b}[2Cb", "a b"), + ("abc\r\u{1b}[CZ", "aZc"), + ("abc\u{1b}[DZ", "abZ"), + ("one\r\ntwo\u{1b}[1A\u{1b}[1GX", "Xne\ntwo"), + ("one\r\u{1b}[2Btwo", "one\n\ntwo"), + ("abc\u{1b}[2GZ", "aZc"), + ("abc\u{1b}[2;2HZ", "abc\n Z"), + ("abc\u{1b}[2;2fZ", "abc\n Z"), + ("abc\u{1b}[0;0HZ", "Zbc"), + ("one\r\ntwo\u{1b}[HZ", "Zne\ntwo"), + ("abc\r\u{1b}[2G\u{1b}[K", "a"), + ("abc\u{1b}[2G\u{1b}[1K", " c"), + ("abc\u{1b}[2K", ""), + ("abc\nxyz\u{1b}[1;2H\u{1b}[J", "a"), + ("abc\nxyz\u{1b}[2;2H\u{1b}[1J", "\n z"), + ("abc\nxyz\u{1b}[2JQ", "\n Q"), + ("a\u{1b}[38;5;42mb\u{1b}[0m\u{1b}[?25lc", "abc"), + ("a\u{1b}]0;hidden\u{7}b\u{1b}]8;;hidden\u{1b}\\c", "abc"), + ("a\u{1b}]0;hidden", "a"), + ("a\u{1b}[123;", "a"), + ("a\u{1b}[?2J\u{1b}[99K\u{1b}[99Jb", "ab"), + ("Org: δΈ­ζ–‡\u{1b}[10G Labs", "Org: δΈ­ζ–‡ Labs"), + ("πŸŽ‰ ok\u{1b}[4GX", "πŸŽ‰ Xk"), + ("⚠️ ok\u{1b}[4GX", "⚠️ Xk"), + ("β–ˆβ–ˆβ–Œ\u{1b}[5G51%", "β–ˆβ–ˆβ–Œ 51%"), + ("δΈ­ζ–‡\u{1b}[2Gab", " ab"), + ("δΈ­ζ–‡x\u{1b}[4G\u{1b}[K", "δΈ­"), + ("e\u{1b}[0m\u{301}x", "e\u{301}x"), + ]; + for (stream, expected) in vectors { + assert_eq!(replay(stream), expected, "stream {stream:?}"); + } +} + +#[test] +fn joiner_sequences_and_flags_occupy_one_wide_cell() { + assert_eq!(replay("πŸ‘¨\u{200D}πŸ‘© ok\u{1b}[4GX"), "πŸ‘¨\u{200D}πŸ‘© Xk"); + assert_eq!(replay("πŸ‘\u{1F3FD} ok\u{1b}[4GX"), "πŸ‘\u{1F3FD} Xk"); + assert_eq!(replay("πŸ‡ΈπŸ‡ͺ ok\u{1b}[4GX"), "πŸ‡ΈπŸ‡ͺ Xk"); +} + +#[test] +fn unsupported_zero_width_characters_are_dropped() { + assert_eq!(replay("a\u{200B}b\u{7}c\u{7f}d"), "abcd"); +} + +#[test] +fn unterminated_osc_leaves_a_final_line_break_visible() { + assert_eq!(replay("a\u{1b}]0;title\nb"), "a"); + assert_eq!(replay("a\u{1b}]0;title\r\n"), "a"); + assert_eq!(replay("a\u{1b}\nb"), "a\nb"); + assert_eq!(replay("a\u{1b}"), "a"); +} + +#[test] +fn synthetic_differential_frame_matches_the_visible_panel_golden() { + let frame = "\u{1b}[HCurrent session\n3% used\nCurrent week (all models)\n27% used\n\ + Current week (Fable)\nabc xye jkl\u{1b}[6;1H51%\u{1b}[5Gus\u{1b}[8Gd\u{1b}[9G\u{1b}[K"; + let golden = "Current session\n3% used\nCurrent week (all models)\n27% used\n\ + Current week (Fable)\n51% used"; + assert_eq!(replay(frame), golden); + assert_eq!(render(frame, true), golden); + + let provider = ClaudeProvider::new(); + let actual = provider.parse_cli_output(frame).unwrap().usage; + let expected = provider.parse_cli_output(golden).unwrap().usage; + assert_eq!(actual.primary.used_percent, expected.primary.used_percent); + assert_eq!( + actual.secondary.map(|w| w.used_percent), + expected.secondary.map(|w| w.used_percent) + ); + assert_eq!( + actual.extra_rate_windows.len(), + expected.extra_rate_windows.len() + ); +} + +#[test] +fn backspace_edits_are_replayed_without_a_csi_sequence() { + let usage = ClaudeProvider::new() + .parse_cli_output("Current session\n39\u{8}5% used") + .unwrap() + .usage; + assert_eq!(usage.primary.used_percent, 35.0); +} + +#[test] +fn clearing_the_screen_cannot_resurrect_an_earlier_quota() { + let frame = "\u{1b}[HCurrent session\n3% used\u{1b}[2J"; + assert!(ClaudeProvider::new().parse_cli_output(frame).is_err()); +} + +#[test] +fn positions_and_overflowing_parameters_stay_within_the_pty_geometry() { + let huge = "9".repeat(100); + let frame = format!("\u{1b}[{huge};{huge}HQ\u{1b}[{huge}AZ\u{1b}[{huge}DX"); + let output = replay(&frame); + let lines: Vec<&str> = output.split('\n').collect(); + assert_eq!(lines.len(), ROWS); + assert!(lines.iter().all(|line| line.chars().count() <= COLUMNS)); + assert_eq!(lines[0], format!("X{}Z", " ".repeat(COLUMNS - 2))); + assert_eq!(lines[ROWS - 1], format!("{}Q", " ".repeat(COLUMNS - 1))); +} + +#[test] +fn wrap_and_line_feed_retain_only_the_visible_screen() { + let full = "x".repeat(COLUMNS); + assert_eq!(replay(&format!("{full}Z")), format!("{full}\nZ")); + assert_eq!(replay(&format!("{full}\u{1b}[31mZ")), format!("{full}\nZ")); + assert_eq!(replay(&format!("{full}\u{1b}[99KZ")), format!("{full}\nZ")); + let narrow = "x".repeat(COLUMNS - 1); + assert_eq!(replay(&format!("{narrow}δΈ­Z")), format!("{narrow}\nδΈ­Z")); + let rows: Vec = (0..=ROWS).map(|n| n.to_string()).collect(); + assert_eq!(replay(&rows.join("\r\n")), rows[1..].join("\n")); +} + +#[test] +fn every_range_table_is_sorted_and_disjoint() { + for table in [EXTEND, FORMAT, WIDE, EMOJI_PRESENTATION, EMOJI] { + for pair in table.windows(2) { + assert!(pair[0].0 <= pair[0].1 && pair[0].1 < pair[1].0, "{pair:x?}"); + } + } +} + +#[test] +fn plain_reports_keep_cr_delimiters_and_ignore_osc_contents() { + let title = "\u{1b}]0;\u{1b}[HCurrent session 99% used\u{7}"; + let text = format!("{title}\u{1b}[35mCurrent session\u{1b}[0m\r3% used\r"); + assert_eq!(render(&text, true), "Current session\r3% used\r"); + let usage = ClaudeProvider::new().parse_cli_output(&text).unwrap().usage; + assert_eq!(usage.primary.used_percent, 3.0); +} + +#[test] +fn plain_reports_are_not_clipped_or_wrapped_to_the_pty_geometry() { + let padding = "report detail\n".repeat(55); + let organization = "Example".repeat(30); + let text = format!( + "\u{1b}[32mCurrent session\n3% used\n{padding}Org: {organization}\nEmail: fixture@example.com\u{1b}[0m" + ); + let rendered = render(&text, true); + assert!(rendered.contains(&format!("Org: {organization}\n"))); + let usage = ClaudeProvider::new().parse_cli_output(&text).unwrap().usage; + assert_eq!(usage.primary.used_percent, 3.0); + assert_eq!(usage.account_email.as_deref(), Some("fixture@example.com")); +} + +#[test] +fn a_cursor_sequence_after_plain_text_switches_to_replay() { + assert_eq!(render("abc\u{1b}[1D\u{1b}[K", true), "ab"); + assert_eq!(render("color \u{1b}[31monly\u{1b}[0m", true), "color only"); +} + +#[test] +fn differential_redraw_fixture_keeps_the_scoped_weekly_quota() { + // Plain escape stripping fuses the redraw fragments, which is the bug. + let stripped = crate::providers::claude::strip_ansi(USAGE_FIXTURE); + assert!(stripped.contains("51%usd"), "{stripped}"); + + let provider = ClaudeProvider::new(); + let result = provider.parse_cli_output(USAGE_FIXTURE).unwrap(); + let usage = result.usage; + assert_eq!(usage.primary.used_percent, 3.0, "97% left"); + let weekly = usage.secondary.expect("weekly window"); + assert_eq!(weekly.used_percent, 27.0, "73% left"); + assert_eq!( + weekly.reset_description.as_deref(), + Some("Resets Sep 23 at 3pm (Europe/Stockholm)") + ); + let fable = usage + .extra_rate_windows + .iter() + .find(|window| window.id == "claude-weekly-scoped-fable") + .expect("scoped Fable window"); + assert_eq!(fable.title, "Fable"); // label is taken verbatim from "Current week (Fable)" + assert_eq!(fable.window.used_percent, 51.0); +} + +#[test] +fn rendering_a_rendered_capture_changes_nothing() { + for fixture in [USAGE_FIXTURE, STATUS_FIXTURE] { + let once = render(fixture, true); + assert_eq!(render(&once, true), once); + } +} + +#[test] +fn redrawn_identity_uses_the_final_frame_with_cursor_positioned_spaces() { + assert_eq!( + render(STATUS_FIXTURE, true), + "Email: fixture@example.com\nOrg: Example Org\nLogin method: Claude Max Account" + ); + let text = format!("{STATUS_FIXTURE}\nCurrent session\n3% used"); + let usage = ClaudeProvider::new().parse_cli_output(&text).unwrap().usage; + assert_eq!(usage.account_email.as_deref(), Some("fixture@example.com")); + assert_eq!(usage.login_method.as_deref(), Some("Claude Max Account")); +} diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 22da9fe951..3913a2b11b 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -4,6 +4,7 @@ pub mod accounts; mod admin_api; pub mod claude_swap; mod cli_reset; +mod cli_screen; mod oauth; pub mod quota_history; pub mod reset_observations; @@ -319,7 +320,7 @@ async fn rerun_claude_usage_after_trust_prompt( probe_dir: std::path::PathBuf, combined: String, ) -> Result { - if !is_workspace_trust_prompt(&strip_ansi(&combined).to_lowercase()) { + if !is_workspace_trust_prompt(&cli_screen::render(&combined, true).to_lowercase()) { return Ok(combined); } @@ -672,12 +673,14 @@ impl ClaudeProvider { let claude_path = resolve_claude_cli_path()?; let combined = fetch_claude_cli_usage_text(claude_path).await?; + // Replay cursor redraws once; rendering is idempotent on rendered text. + let visible = cli_screen::render(&combined, true); - if let Some(error) = claude_cli_error_from_output(&combined) { + if let Some(error) = claude_cli_error_from_output(&visible) { return Err(error); } - let mut result = self.parse_cli_output(&combined)?; + let mut result = self.parse_cli_output(&visible)?; if let Some(identity) = auto_resume_identity() { result = result.with_account_identity(identity); } @@ -686,7 +689,7 @@ impl ClaudeProvider { /// Parse Claude CLI /usage output fn parse_cli_output(&self, output: &str) -> Result { - let clean = strip_ansi(output); + let clean = cli_screen::render(output, true); let clean_lower = clean.to_lowercase(); if clean.trim().is_empty() { @@ -701,7 +704,7 @@ impl ClaudeProvider { )); } - if is_cli_activity_stats_response(&clean_lower) { + if is_cli_activity_stats_response(&clean_lower) && !has_plan_limit_section(&clean_lower) { return Err(ProviderError::Other( "Claude CLI /usage opened, but this Claude version returned local activity stats instead of plan limit percentages. Use Auto, OAuth, or Web mode for Claude limits.".to_string(), )); @@ -1031,6 +1034,12 @@ fn is_workspace_trust_prompt(text: &str) -> bool { && text.contains("yes, i trust this folder") } +/// Current `/usage` panels show a local session summary above the plan limits, +/// so the presence of a limit section outranks the activity-stats markers. +fn has_plan_limit_section(text: &str) -> bool { + text.contains("current session") || text.contains("current week") +} + fn is_cli_activity_stats_response(text: &str) -> bool { let has_activity_overview = text.contains("favorite model:") || text.contains("total tokens:"); let has_session_cost_summary = diff --git a/rust/src/providers/fixtures/claude/status-pty-differential-redraw.ansi b/rust/src/providers/fixtures/claude/status-pty-differential-redraw.ansi new file mode 100644 index 0000000000..1a8dce7204 --- /dev/null +++ b/rust/src/providers/fixtures/claude/status-pty-differential-redraw.ansi @@ -0,0 +1,3 @@ +Email: old@example.com +Org: OldData Previous +Login method: Claude Pro AccountEmail:fixture@example.comOrg:ExampleOrgLoginmethod:ClaudeMaxAccount \ No newline at end of file diff --git a/rust/src/providers/fixtures/claude/usage-pty-differential-redraw.ansi b/rust/src/providers/fixtures/claude/usage-pty-differential-redraw.ansi new file mode 100644 index 0000000000..ab90fdd850 --- /dev/null +++ b/rust/src/providers/fixtures/claude/usage-pty-differential-redraw.ansi @@ -0,0 +1 @@ +[?25l▝▝▝▝example workspace synthetic/ClaudeProbe  β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”  Settings Status Config Usage Stats Session Totalcost:$0.0000 Totalduration(API):0s Totalduration(wall):2s Totalcodechanges:0linesadded,0linesremoved Usage:0input,0output,0cacheread,0cachewrite Currentsession β–ˆβ–Œ3%used Resets9am(Europe/Stockholm) Currentweek(allmodels) β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–Œ27%used ResetsSep23at3pm(Europe/Stockholm) What'scontributingtoyourlimitsusage? Approximate,basedonlocalsessionsonthismachineβ€”doesnotincludeotherdevicesorclaude.ai Scanninglocalsessions… Refreshing… Esctocancel        Last 24hΒ· these are independentcharacteristicsofyourusage,notabreakdown MCP servers%ofusage fixture_tools1%  dtodayΒ·wtoweek Refreshing… Esctocancel Current week (Fable) β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–Œ     51%usd ResetsSep23at3pm(Europe/Stockholm)  What'scontributingtoyourlimitsusage? Approximate,basedonlocal sesionsonthismachineβ€”doesnotincludeotherdevicesorclaude.ai  Last24hΒ·theseareindependentcharacteristicsofyourusage,notabreakdown  MCPservers%ofusage fixture_tools1% d to day Β· w toweek Usagecredits UsagecreditsareoffΒ·/usage-creditstoturnthemon Esctocancel \ No newline at end of file