This commit is contained in:
Dave Beesley
2026-01-16 22:50:11 -07:00
parent 961fc90313
commit 58f448e05e
5 changed files with 42 additions and 620 deletions

View File

@@ -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:

View File

@@ -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<Color>,
pub bg: Option<Color>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Color {
Default,
Indexed(u8),
Rgb(u8, u8, u8),
}
pub struct EscapeParser {
state: ParserState,
params: Vec<u16>,
intermediate: Vec<u8>,
}
#[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<ParsedEscape> {
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<ParsedEscape> {
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<ParsedEscape> {
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<ParsedEscape> {
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<ParsedEscape> {
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<ParsedEscape> {
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<ParsedEscape> {
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<ParsedEscape> {
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<ParsedEscape> {
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<ParsedEscape> {
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<ParsedEscape> {
let mut parser = EscapeParser::new();
bytes.iter().filter_map(|&b| parser.feed(b)).collect()
}
fn parse_last(bytes: &[u8]) -> Option<ParsedEscape> {
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));
}
}

View File

@@ -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;

View File

@@ -1,5 +1,4 @@
pub mod config;
pub mod escape_parser;
pub mod escape_sequences;
pub mod key_parser;
pub mod line_buffer;

View File

@@ -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<u8>,
in_sync_block: bool,
parser: EscapeParser,
input_buffer: Vec<u8>,
passthrough_buffer: Vec<u8>,
output_buffer: Vec<u8>,
pending_escape: Vec<u8>,
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();
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;
}
continue;
}
_ => {
self.flush_pending_escape();
}
}
}
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);
pos += idx + SYNC_END.len();
} else {
self.passthrough_buffer.push(byte);
self.sync_buffer.extend_from_slice(&data[pos..]);
break;
}
} 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 {
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 {