diff --git a/README.md b/README.md index abc6746..4db32dc 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,18 @@ Claude Code uses synchronized output to update the terminal atomically. It wraps The problem: Claude Code sends *entire* screen redraws in these sync blocks - often thousands of lines. Your terminal receives a 5000-line atomic update when only 20 lines are visible. This causes lag, flicker, and makes scrollback useless since each update clears history. +Analysis of terminal recordings confirms Claude Code wraps 100% of its output in sync blocks - every byte of visible output goes through synchronized updates. + +Sync blocks start with one of three patterns (from a 3.5GB recording sample): + +| Pattern | Count | Frequency | Avg Size | +|---------|-------|-----------|----------| +| Line clearing (`2K` + `1A` repeated) | 3,544 | 55% | 2.7 KB | +| Full screen clear (`2J` + `3J` + `H`) | 2,891 | 45% | 94.5 KB | +| CRLF + color codes | 1 | <1% | 2.0 KB | + +The full screen clears are 35x larger than incremental line clears - these are the real problem. + ## The Solution claude-chill sits between your terminal and Claude Code: diff --git a/crates/claude-chill/src/escape_parser.rs b/crates/claude-chill/src/escape_parser.rs deleted file mode 100644 index ead76c5..0000000 --- a/crates/claude-chill/src/escape_parser.rs +++ /dev/null @@ -1,519 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ParsedEscape { - SyncStart, - SyncEnd, - ClearScreen, - ClearScrollback, - CursorHome, - CursorUp(u16), - CursorCol(u16), - ClearLine, - Newline, - CarriageReturn, - Sgr(SgrCode), - Other, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct SgrCode { - pub reset: bool, - pub fg: Option, - pub bg: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Color { - Default, - Indexed(u8), - Rgb(u8, u8, u8), -} - -pub struct EscapeParser { - state: ParserState, - params: Vec, - intermediate: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ParserState { - Ground, - Escape, - CsiEntry, - CsiParam, - CsiIntermediate, - OscString, - DcsString, -} - -impl Default for EscapeParser { - fn default() -> Self { - Self::new() - } -} - -impl EscapeParser { - pub fn new() -> Self { - Self { - state: ParserState::Ground, - params: Vec::with_capacity(16), - intermediate: Vec::with_capacity(4), - } - } - - pub fn in_escape_sequence(&self) -> bool { - self.state != ParserState::Ground - } - - pub fn feed(&mut self, byte: u8) -> Option { - match self.state { - ParserState::Ground => self.ground(byte), - ParserState::Escape => self.escape(byte), - ParserState::CsiEntry => self.csi_entry(byte), - ParserState::CsiParam => self.csi_param(byte), - ParserState::CsiIntermediate => self.csi_intermediate(byte), - ParserState::OscString => self.osc_string(byte), - ParserState::DcsString => self.dcs_string(byte), - } - } - - fn ground(&mut self, byte: u8) -> Option { - match byte { - 0x1b => { - self.state = ParserState::Escape; - None - } - b'\n' => Some(ParsedEscape::Newline), - b'\r' => Some(ParsedEscape::CarriageReturn), - _ => None, - } - } - - fn escape(&mut self, byte: u8) -> Option { - match byte { - b'[' => { - self.state = ParserState::CsiEntry; - self.params.clear(); - self.intermediate.clear(); - None - } - b']' => { - self.state = ParserState::OscString; - None - } - b'P' | b'^' | b'_' => { - self.state = ParserState::DcsString; - None - } - _ => { - self.state = ParserState::Ground; - Some(ParsedEscape::Other) - } - } - } - - fn osc_string(&mut self, byte: u8) -> Option { - match byte { - 0x07 => { - self.state = ParserState::Ground; - Some(ParsedEscape::Other) - } - 0x1b => { - self.state = ParserState::Escape; - None - } - _ => None, - } - } - - fn dcs_string(&mut self, byte: u8) -> Option { - match byte { - 0x1b => { - self.state = ParserState::Escape; - None - } - 0x9c => { - self.state = ParserState::Ground; - Some(ParsedEscape::Other) - } - _ => None, - } - } - - fn csi_entry(&mut self, byte: u8) -> Option { - match byte { - b'0'..=b'9' => { - self.params.push((byte - b'0') as u16); - self.state = ParserState::CsiParam; - None - } - b';' => { - self.params.push(0); - self.state = ParserState::CsiParam; - None - } - b'?' => { - self.intermediate.push(byte); - self.state = ParserState::CsiIntermediate; - None - } - b'@'..=b'~' => { - self.state = ParserState::Ground; - self.dispatch_csi(byte) - } - _ => { - self.state = ParserState::Ground; - Some(ParsedEscape::Other) - } - } - } - - fn csi_param(&mut self, byte: u8) -> Option { - match byte { - b'0'..=b'9' => { - if let Some(last) = self.params.last_mut() { - *last = last.saturating_mul(10).saturating_add((byte - b'0') as u16); - } - None - } - b';' => { - self.params.push(0); - None - } - b'@'..=b'~' => { - self.state = ParserState::Ground; - self.dispatch_csi(byte) - } - _ => { - self.state = ParserState::Ground; - Some(ParsedEscape::Other) - } - } - } - - fn csi_intermediate(&mut self, byte: u8) -> Option { - match byte { - b'0'..=b'9' => { - if self.params.is_empty() { - self.params.push((byte - b'0') as u16); - } else if let Some(last) = self.params.last_mut() { - *last = last.saturating_mul(10).saturating_add((byte - b'0') as u16); - } - None - } - b';' => { - self.params.push(0); - None - } - b'@'..=b'~' => { - self.state = ParserState::Ground; - self.dispatch_private_csi(byte) - } - _ => { - self.intermediate.push(byte); - None - } - } - } - - fn dispatch_csi(&mut self, byte: u8) -> Option { - match byte { - b'H' => { - if self.params.is_empty() - || (self.params.len() == 2 && self.params[0] <= 1 && self.params[1] <= 1) - { - Some(ParsedEscape::CursorHome) - } else { - Some(ParsedEscape::Other) - } - } - b'J' => { - let param = self.params.first().copied().unwrap_or(0); - match param { - 2 => Some(ParsedEscape::ClearScreen), - 3 => Some(ParsedEscape::ClearScrollback), - _ => Some(ParsedEscape::Other), - } - } - b'A' => { - let n = self.params.first().copied().unwrap_or(1).max(1); - Some(ParsedEscape::CursorUp(n)) - } - b'G' => { - let col = self.params.first().copied().unwrap_or(1); - Some(ParsedEscape::CursorCol(col)) - } - b'K' => Some(ParsedEscape::ClearLine), - b'm' => Some(ParsedEscape::Sgr(self.parse_sgr())), - _ => Some(ParsedEscape::Other), - } - } - - fn dispatch_private_csi(&mut self, byte: u8) -> Option { - if self.intermediate.first() == Some(&b'?') { - let param = self.params.first().copied().unwrap_or(0); - match (param, byte) { - (2026, b'h') => Some(ParsedEscape::SyncStart), - (2026, b'l') => Some(ParsedEscape::SyncEnd), - _ => Some(ParsedEscape::Other), - } - } else { - Some(ParsedEscape::Other) - } - } - - fn parse_sgr(&self) -> SgrCode { - let mut sgr = SgrCode::default(); - let mut i = 0; - while i < self.params.len() { - match self.params[i] { - 0 => sgr.reset = true, - 38 => { - if i + 1 < self.params.len() && self.params[i + 1] == 2 { - if i + 4 < self.params.len() { - let r = self.params[i + 2] as u8; - let g = self.params[i + 3] as u8; - let b = self.params[i + 4] as u8; - sgr.fg = Some(Color::Rgb(r, g, b)); - i += 4; - } - } else if i + 1 < self.params.len() - && self.params[i + 1] == 5 - && i + 2 < self.params.len() - { - sgr.fg = Some(Color::Indexed(self.params[i + 2] as u8)); - i += 2; - } - } - 48 => { - if i + 1 < self.params.len() && self.params[i + 1] == 2 { - if i + 4 < self.params.len() { - let r = self.params[i + 2] as u8; - let g = self.params[i + 3] as u8; - let b = self.params[i + 4] as u8; - sgr.bg = Some(Color::Rgb(r, g, b)); - i += 4; - } - } else if i + 1 < self.params.len() - && self.params[i + 1] == 5 - && i + 2 < self.params.len() - { - sgr.bg = Some(Color::Indexed(self.params[i + 2] as u8)); - i += 2; - } - } - 39 => sgr.fg = Some(Color::Default), - 49 => sgr.bg = Some(Color::Default), - _ => {} - } - i += 1; - } - sgr - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse_sequence(bytes: &[u8]) -> Vec { - let mut parser = EscapeParser::new(); - bytes.iter().filter_map(|&b| parser.feed(b)).collect() - } - - fn parse_last(bytes: &[u8]) -> Option { - parse_sequence(bytes).into_iter().last() - } - - #[test] - fn test_newline() { - assert_eq!(parse_last(b"\n"), Some(ParsedEscape::Newline)); - } - - #[test] - fn test_carriage_return() { - assert_eq!(parse_last(b"\r"), Some(ParsedEscape::CarriageReturn)); - } - - #[test] - fn test_clear_screen() { - assert_eq!(parse_last(b"\x1b[2J"), Some(ParsedEscape::ClearScreen)); - } - - #[test] - fn test_clear_scrollback() { - assert_eq!(parse_last(b"\x1b[3J"), Some(ParsedEscape::ClearScrollback)); - } - - #[test] - fn test_cursor_home() { - assert_eq!(parse_last(b"\x1b[H"), Some(ParsedEscape::CursorHome)); - assert_eq!(parse_last(b"\x1b[1;1H"), Some(ParsedEscape::CursorHome)); - } - - #[test] - fn test_cursor_home_with_position() { - assert_eq!(parse_last(b"\x1b[5;10H"), Some(ParsedEscape::Other)); - } - - #[test] - fn test_cursor_up() { - assert_eq!(parse_last(b"\x1b[A"), Some(ParsedEscape::CursorUp(1))); - assert_eq!(parse_last(b"\x1b[5A"), Some(ParsedEscape::CursorUp(5))); - } - - #[test] - fn test_cursor_col() { - assert_eq!(parse_last(b"\x1b[G"), Some(ParsedEscape::CursorCol(1))); - assert_eq!(parse_last(b"\x1b[15G"), Some(ParsedEscape::CursorCol(15))); - } - - #[test] - fn test_clear_line() { - assert_eq!(parse_last(b"\x1b[K"), Some(ParsedEscape::ClearLine)); - } - - #[test] - fn test_sync_start() { - assert_eq!(parse_last(b"\x1b[?2026h"), Some(ParsedEscape::SyncStart)); - } - - #[test] - fn test_sync_end() { - assert_eq!(parse_last(b"\x1b[?2026l"), Some(ParsedEscape::SyncEnd)); - } - - #[test] - fn test_sgr_reset() { - let result = parse_last(b"\x1b[0m"); - assert_eq!( - result, - Some(ParsedEscape::Sgr(SgrCode { - reset: true, - fg: None, - bg: None, - })) - ); - } - - #[test] - fn test_sgr_default_colors() { - let result = parse_last(b"\x1b[39;49m"); - assert_eq!( - result, - Some(ParsedEscape::Sgr(SgrCode { - reset: false, - fg: Some(Color::Default), - bg: Some(Color::Default), - })) - ); - } - - #[test] - fn test_sgr_indexed_fg() { - let result = parse_last(b"\x1b[38;5;196m"); - assert_eq!( - result, - Some(ParsedEscape::Sgr(SgrCode { - reset: false, - fg: Some(Color::Indexed(196)), - bg: None, - })) - ); - } - - #[test] - fn test_sgr_indexed_bg() { - let result = parse_last(b"\x1b[48;5;21m"); - assert_eq!( - result, - Some(ParsedEscape::Sgr(SgrCode { - reset: false, - fg: None, - bg: Some(Color::Indexed(21)), - })) - ); - } - - #[test] - fn test_sgr_rgb_fg() { - let result = parse_last(b"\x1b[38;2;255;128;0m"); - assert_eq!( - result, - Some(ParsedEscape::Sgr(SgrCode { - reset: false, - fg: Some(Color::Rgb(255, 128, 0)), - bg: None, - })) - ); - } - - #[test] - fn test_sgr_rgb_bg() { - let result = parse_last(b"\x1b[48;2;0;128;255m"); - assert_eq!( - result, - Some(ParsedEscape::Sgr(SgrCode { - reset: false, - fg: None, - bg: Some(Color::Rgb(0, 128, 255)), - })) - ); - } - - #[test] - fn test_osc_title_bel_terminated() { - let events = parse_sequence(b"\x1b]0;My Title\x07"); - assert_eq!(events, vec![ParsedEscape::Other]); - } - - #[test] - fn test_osc_title_st_terminated() { - let events = parse_sequence(b"\x1b]0;My Title\x1b\\"); - assert_eq!(events, vec![ParsedEscape::Other]); - } - - #[test] - fn test_dcs_sequence() { - let events = parse_sequence(b"\x1bPsome data\x1b\\"); - assert_eq!(events, vec![ParsedEscape::Other]); - } - - #[test] - fn test_apc_sequence() { - let events = parse_sequence(b"\x1b_application data\x1b\\"); - assert_eq!(events, vec![ParsedEscape::Other]); - } - - #[test] - fn test_pm_sequence() { - let events = parse_sequence(b"\x1b^private message\x1b\\"); - assert_eq!(events, vec![ParsedEscape::Other]); - } - - #[test] - fn test_mixed_content() { - let events = parse_sequence(b"hello\nworld\r\n"); - assert_eq!( - events, - vec![ - ParsedEscape::Newline, - ParsedEscape::CarriageReturn, - ParsedEscape::Newline - ] - ); - } - - #[test] - fn test_escape_followed_by_text() { - let events = parse_sequence(b"\x1b[2Jhello\n"); - assert_eq!( - events, - vec![ParsedEscape::ClearScreen, ParsedEscape::Newline] - ); - } - - #[test] - fn test_unknown_csi() { - assert_eq!(parse_last(b"\x1b[999z"), Some(ParsedEscape::Other)); - } -} diff --git a/crates/claude-chill/src/escape_sequences.rs b/crates/claude-chill/src/escape_sequences.rs index 53c3eaf..0061010 100644 --- a/crates/claude-chill/src/escape_sequences.rs +++ b/crates/claude-chill/src/escape_sequences.rs @@ -5,7 +5,5 @@ pub const CLEAR_SCROLLBACK: &[u8] = b"\x1b[3J"; pub const CURSOR_HOME: &[u8] = b"\x1b[H"; pub const SYNC_BUFFER_CAPACITY: usize = 1024 * 1024; -pub const PASSTHROUGH_BUFFER_CAPACITY: usize = 65536; pub const OUTPUT_BUFFER_CAPACITY: usize = 32768; -pub const PENDING_ESCAPE_CAPACITY: usize = 32; pub const INPUT_BUFFER_CAPACITY: usize = 64; diff --git a/crates/claude-chill/src/lib.rs b/crates/claude-chill/src/lib.rs index a7e14e8..c3be1b5 100644 --- a/crates/claude-chill/src/lib.rs +++ b/crates/claude-chill/src/lib.rs @@ -1,5 +1,4 @@ pub mod config; -pub mod escape_parser; pub mod escape_sequences; pub mod key_parser; pub mod line_buffer; diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index 5414b9c..1ee06b4 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -1,8 +1,6 @@ -use crate::escape_parser::{EscapeParser, ParsedEscape}; use crate::escape_sequences::{ CLEAR_SCREEN, CLEAR_SCROLLBACK, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, - PASSTHROUGH_BUFFER_CAPACITY, PENDING_ESCAPE_CAPACITY, SYNC_BUFFER_CAPACITY, SYNC_END, - SYNC_START, + SYNC_BUFFER_CAPACITY, SYNC_END, SYNC_START, }; use crate::line_buffer::LineBuffer; use anyhow::{Context, Result}; @@ -59,11 +57,10 @@ pub struct Proxy { history: LineBuffer, sync_buffer: Vec, in_sync_block: bool, - parser: EscapeParser, input_buffer: Vec, - passthrough_buffer: Vec, output_buffer: Vec, - pending_escape: Vec, + sync_start_finder: memmem::Finder<'static>, + sync_end_finder: memmem::Finder<'static>, clear_screen_finder: memmem::Finder<'static>, cursor_home_finder: memmem::Finder<'static>, } @@ -117,11 +114,10 @@ impl Proxy { original_termios, sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY), in_sync_block: false, - parser: EscapeParser::new(), input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY), - passthrough_buffer: Vec::with_capacity(PASSTHROUGH_BUFFER_CAPACITY), output_buffer: Vec::with_capacity(OUTPUT_BUFFER_CAPACITY), - pending_escape: Vec::with_capacity(PENDING_ESCAPE_CAPACITY), + sync_start_finder: memmem::Finder::new(SYNC_START), + sync_end_finder: memmem::Finder::new(SYNC_END), clear_screen_finder: memmem::Finder::new(CLEAR_SCREEN), cursor_home_finder: memmem::Finder::new(CURSOR_HOME), }) @@ -201,76 +197,37 @@ impl Proxy { } fn process_output(&mut self, data: &[u8], stdout_fd: i32) -> Result<()> { - self.passthrough_buffer.clear(); + let mut pos = 0; - for &byte in data { - let in_escape = self.parser.in_escape_sequence(); - - if in_escape && self.pending_escape.is_empty() { - self.pending_escape.push(0x1b); - } - - if let Some(event) = self.parser.feed(byte) { - match event { - ParsedEscape::SyncStart => { - if !self.passthrough_buffer.is_empty() { - write_all_raw(stdout_fd, &self.passthrough_buffer)?; - self.passthrough_buffer.clear(); - } - self.pending_escape.clear(); - self.in_sync_block = true; - self.sync_buffer.clear(); - self.sync_buffer.extend_from_slice(SYNC_START); - continue; - } - ParsedEscape::SyncEnd => { - self.pending_escape.clear(); - if self.in_sync_block { - self.sync_buffer.extend_from_slice(SYNC_END); - self.flush_sync_block(stdout_fd)?; - self.in_sync_block = false; - } - continue; - } - _ => { - self.flush_pending_escape(); - } + while pos < data.len() { + if self.in_sync_block { + if let Some(idx) = self.sync_end_finder.find(&data[pos..]) { + self.sync_buffer.extend_from_slice(&data[pos..pos + idx]); + self.sync_buffer.extend_from_slice(SYNC_END); + self.flush_sync_block(stdout_fd)?; + self.in_sync_block = false; + pos += idx + SYNC_END.len(); + } else { + self.sync_buffer.extend_from_slice(&data[pos..]); + break; } - } - - if !self.parser.in_escape_sequence() && !self.pending_escape.is_empty() { - self.flush_pending_escape(); - } - - if self.parser.in_escape_sequence() { - self.pending_escape.push(byte); - } else if self.in_sync_block { - self.sync_buffer.push(byte); + } else if let Some(idx) = self.sync_start_finder.find(&data[pos..]) { + if idx > 0 { + write_all_raw(stdout_fd, &data[pos..pos + idx])?; + } + self.in_sync_block = true; + self.sync_buffer.clear(); + self.sync_buffer.extend_from_slice(SYNC_START); + pos += idx + SYNC_START.len(); } else { - self.passthrough_buffer.push(byte); + write_all_raw(stdout_fd, &data[pos..])?; + break; } } - if !self.pending_escape.is_empty() && !self.parser.in_escape_sequence() { - self.flush_pending_escape(); - } - - if !self.passthrough_buffer.is_empty() { - write_all_raw(stdout_fd, &self.passthrough_buffer)?; - } Ok(()) } - fn flush_pending_escape(&mut self) { - if self.in_sync_block { - self.sync_buffer.extend_from_slice(&self.pending_escape); - } else { - self.passthrough_buffer - .extend_from_slice(&self.pending_escape); - } - self.pending_escape.clear(); - } - fn flush_sync_block(&mut self, stdout_fd: i32) -> Result<()> { let has_clear_screen = self.clear_screen_finder.find(&self.sync_buffer).is_some(); let has_cursor_home = self.cursor_home_finder.find(&self.sync_buffer).is_some(); @@ -278,13 +235,10 @@ impl Proxy { if is_full_redraw { self.history.clear(); - let content_start = self.find_content_start(); - let content_end = self.sync_buffer.len().saturating_sub(SYNC_END.len()); - if content_start < content_end { - self.history - .push_bytes(&self.sync_buffer[content_start..content_end]); - } + } + self.history.push_bytes(&self.sync_buffer); + if is_full_redraw { self.create_truncated_output(); write_all_raw(stdout_fd, &self.output_buffer)?; } else { @@ -305,28 +259,6 @@ impl Proxy { self.output_buffer.extend_from_slice(SYNC_END); } - fn find_content_start(&self) -> usize { - let sync_start_finder = memmem::Finder::new(SYNC_START); - let clear_screen_finder = memmem::Finder::new(CLEAR_SCREEN); - let clear_scrollback_finder = memmem::Finder::new(CLEAR_SCROLLBACK); - let cursor_home_finder = memmem::Finder::new(CURSOR_HOME); - - let mut pos = 0; - if let Some(idx) = sync_start_finder.find(&self.sync_buffer[pos..]) { - pos += idx + SYNC_START.len(); - } - if let Some(idx) = clear_screen_finder.find(&self.sync_buffer[pos..]) { - pos += idx + CLEAR_SCREEN.len(); - } - if let Some(idx) = clear_scrollback_finder.find(&self.sync_buffer[pos..]) { - pos += idx + CLEAR_SCROLLBACK.len(); - } - if let Some(idx) = cursor_home_finder.find(&self.sync_buffer[pos..]) { - pos += idx + CURSOR_HOME.len(); - } - pos - } - fn process_input(&mut self, data: &[u8], stdout_fd: i32) -> Result<()> { let master_fd = self.pty_master.as_raw_fd(); for &byte in data {