From 9db120e05d5bf98b0b6dcb7077e67412bac8341f Mon Sep 17 00:00:00 2001 From: Dave Beesley Date: Fri, 16 Jan 2026 20:19:52 -0700 Subject: [PATCH] commit --- .gitignore | 21 + Cargo.lock | 61 +++ Cargo.toml | 10 + README.md | 52 ++ crates/claude-chill/Cargo.toml | 27 + crates/claude-chill/src/analyzer.rs | 207 ++++++++ crates/claude-chill/src/bin.rs | 51 ++ crates/claude-chill/src/escape_parser.rs | 519 ++++++++++++++++++++ crates/claude-chill/src/escape_sequences.rs | 12 + crates/claude-chill/src/lib.rs | 6 + crates/claude-chill/src/line_buffer.rs | 214 ++++++++ crates/claude-chill/src/output_processor.rs | 197 ++++++++ crates/claude-chill/src/proxy.rs | 485 ++++++++++++++++++ crates/claude-chill/src/script_parser.rs | 79 +++ 14 files changed, 1941 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 crates/claude-chill/Cargo.toml create mode 100644 crates/claude-chill/src/analyzer.rs create mode 100644 crates/claude-chill/src/bin.rs create mode 100644 crates/claude-chill/src/escape_parser.rs create mode 100644 crates/claude-chill/src/escape_sequences.rs create mode 100644 crates/claude-chill/src/lib.rs create mode 100644 crates/claude-chill/src/line_buffer.rs create mode 100644 crates/claude-chill/src/output_processor.rs create mode 100644 crates/claude-chill/src/proxy.rs create mode 100644 crates/claude-chill/src/script_parser.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad67955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a728bc7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,61 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "claude-chill" +version = "0.1.0" +dependencies = [ + "anyhow", + "libc", + "memchr", + "nix", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8373cb4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] +resolver = "2" +members = [ + "crates/claude-chill", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +authors = ["David Beesley"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3c7db0 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# claude-chill + +A PTY proxy that reduces terminal flicker by truncating synchronized output blocks. + +## The Problem + +Claude Code (and similar tools) use synchronized output (`\x1b[?2026h` / `\x1b[?2026l`) to update the terminal atomically. When these blocks contain thousands of lines, some terminals struggle to render them smoothly, causing visible flicker. + +## The Solution + +claude-chill sits between your terminal and the child process, intercepting synchronized output blocks and truncating them to a configurable number of lines. This keeps atomic updates small enough for smooth rendering while preserving full history for lookback. + +## Installation + +```bash +cargo install --path crates/claude-chill +``` + +## Usage + +```bash +claude-chill [args...] + +# Example: wrap Claude Code +claude-chill claude + +# Example: wrap any command +claude-chill bash +``` + +### Environment Variables + +- `CHILL_MAX_LINES` - Max lines per sync block (default: 100) +- `CHILL_HISTORY` - Max history lines for lookback (default: 100000) + +### Lookback Mode + +Press `Ctrl+Shift+PgUp` to dump the full history buffer to the terminal. + +## How It Works + +1. Creates a PTY pair and spawns the child process +2. Intercepts all output from the child +3. Detects synchronized output blocks (between `?2026h` and `?2026l`) +4. For "full redraw" blocks (containing clear screen + cursor home): + - Stores full content in history buffer + - Outputs only the last N lines +5. Passes all other content through unchanged + +## License + +MIT diff --git a/crates/claude-chill/Cargo.toml b/crates/claude-chill/Cargo.toml new file mode 100644 index 0000000..3210057 --- /dev/null +++ b/crates/claude-chill/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "claude-chill" +version.workspace = true +edition.workspace = true +authors.workspace = true +default-run = "claude-chill" +description = "PTY proxy that reduces terminal flicker by truncating synchronized output" +license = "MIT" +repository = "https://github.com/davidbeesley/claude-chill" + +[lib] +name = "claude_chill" +path = "src/lib.rs" + +[[bin]] +name = "claude-chill" +path = "src/bin.rs" + +[[bin]] +name = "chill-analyzer" +path = "src/analyzer.rs" + +[dependencies] +anyhow = "1" +memchr = "2" +nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"] } +libc = "0.2" diff --git a/crates/claude-chill/src/analyzer.rs b/crates/claude-chill/src/analyzer.rs new file mode 100644 index 0000000..7be32a5 --- /dev/null +++ b/crates/claude-chill/src/analyzer.rs @@ -0,0 +1,207 @@ +use claude_chill::escape_sequences::{SYNC_END, SYNC_START}; +use claude_chill::script_parser::strip_script_wrapper; +use std::env; +use std::fs; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = env::args().collect(); + + if args.len() != 3 { + eprintln!("Usage: chill-analyzer "); + eprintln!(); + eprintln!("Compares two script recordings, stripping headers and showing diffs."); + return ExitCode::from(1); + } + + let original_path = &args[1]; + let pty_path = &args[2]; + + let original = match fs::read(original_path) { + Ok(data) => data, + Err(e) => { + eprintln!("Failed to read {}: {}", original_path, e); + return ExitCode::from(1); + } + }; + + let pty_output = match fs::read(pty_path) { + Ok(data) => data, + Err(e) => { + eprintln!("Failed to read {}: {}", pty_path, e); + return ExitCode::from(1); + } + }; + + let original_content = strip_script_wrapper(&original); + let pty_content = strip_script_wrapper(&pty_output); + + println!( + "Original: {} bytes -> {} bytes after stripping", + original.len(), + original_content.len() + ); + println!( + "PTY: {} bytes -> {} bytes after stripping", + pty_output.len(), + pty_content.len() + ); + println!(); + + compare_and_report(original_content, pty_content); + + ExitCode::SUCCESS +} + +fn compare_and_report(original: &[u8], pty: &[u8]) { + let mut first_diff: Option = None; + let common_len = original.len().min(pty.len()); + + for i in 0..common_len { + if original[i] != pty[i] { + first_diff = Some(i); + break; + } + } + + match first_diff { + Some(pos) => { + println!("FIRST DIFFERENCE at byte {}", pos); + println!(); + + let context_start = pos.saturating_sub(32); + let context_end = (pos + 64).min(original.len()).min(pty.len()); + + println!( + "=== ORIGINAL @ {} (diff at +{}) ===", + context_start, + pos - context_start + ); + print_hex_with_highlight( + &original[context_start..context_end.min(original.len())], + pos - context_start, + ); + + println!(); + println!( + "=== PTY @ {} (diff at +{}) ===", + context_start, + pos - context_start + ); + print_hex_with_highlight( + &pty[context_start..context_end.min(pty.len())], + pos - context_start, + ); + + println!(); + println!("Context around diff:"); + println!( + " Original byte: 0x{:02x} ({:?})", + original[pos], + char::from(original[pos]) + ); + println!( + " PTY byte: 0x{:02x} ({:?})", + pty[pos], + char::from(pty[pos]) + ); + + let sync_before = count_sync_markers(&original[..pos]); + println!(); + println!( + "Sync blocks before diff: {} starts, {} ends", + sync_before.0, sync_before.1 + ); + } + None => { + if original.len() == pty.len() { + println!("FILES ARE IDENTICAL"); + } else { + println!("Files match for {} bytes, then one ends early", common_len); + println!(" Original: {} bytes", original.len()); + println!(" PTY: {} bytes", pty.len()); + + if original.len() > pty.len() { + println!(); + println!("=== ORIGINAL CONTINUES WITH ==="); + print_hex_with_highlight( + &original[common_len..(common_len + 64).min(original.len())], + 0, + ); + } else { + println!(); + println!("=== PTY CONTINUES WITH ==="); + print_hex_with_highlight(&pty[common_len..(common_len + 64).min(pty.len())], 0); + } + } + } + } + + println!(); + println!("=== SYNC BLOCK SUMMARY ==="); + let orig_sync = count_sync_markers(original); + let pty_sync = count_sync_markers(pty); + println!( + "Original: {} sync starts, {} sync ends", + orig_sync.0, orig_sync.1 + ); + println!( + "PTY: {} sync starts, {} sync ends", + pty_sync.0, pty_sync.1 + ); +} + +fn count_sync_markers(data: &[u8]) -> (usize, usize) { + let mut starts = 0; + let mut ends = 0; + + for i in 0..data.len().saturating_sub(SYNC_START.len()) { + if &data[i..i + SYNC_START.len()] == SYNC_START { + starts += 1; + } + if &data[i..i + SYNC_END.len()] == SYNC_END { + ends += 1; + } + } + + (starts, ends) +} + +fn print_hex_with_highlight(data: &[u8], highlight_pos: usize) { + for (i, chunk) in data.chunks(16).enumerate() { + let offset = i * 16; + print!("{:08x}: ", offset); + + for (j, &byte) in chunk.iter().enumerate() { + let pos = offset + j; + if pos == highlight_pos { + print!("\x1b[41m{:02x}\x1b[0m ", byte); + } else { + print!("{:02x} ", byte); + } + if j == 7 { + print!(" "); + } + } + + for _ in chunk.len()..16 { + print!(" "); + } + + print!(" |"); + for (j, &byte) in chunk.iter().enumerate() { + let pos = offset + j; + let c = if byte.is_ascii_graphic() || byte == b' ' { + byte as char + } else { + '.' + }; + if pos == highlight_pos { + print!("\x1b[41m{}\x1b[0m", c); + } else { + print!("{}", c); + } + } + println!("|"); + } +} diff --git a/crates/claude-chill/src/bin.rs b/crates/claude-chill/src/bin.rs new file mode 100644 index 0000000..09093ca --- /dev/null +++ b/crates/claude-chill/src/bin.rs @@ -0,0 +1,51 @@ +use claude_chill::proxy::{Proxy, ProxyConfig}; +use std::env; +use std::process::ExitCode; +use std::str::FromStr; + +fn parse_env_var(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn main() -> ExitCode { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: claude-chill [args...]"); + eprintln!(); + eprintln!("PTY proxy that reduces terminal flicker by truncating synchronized output."); + eprintln!(); + eprintln!("Environment variables:"); + eprintln!(" CHILL_MAX_LINES Max lines per sync block (default: 100)"); + eprintln!(" CHILL_HISTORY Max history lines for lookback (default: 100000)"); + eprintln!(); + eprintln!("Lookback mode: Press Ctrl+Shift+PgUp to view full history"); + return ExitCode::from(1); + } + + let command = &args[1]; + let cmd_args: Vec<&str> = args[2..].iter().map(|s| s.as_str()).collect(); + + let config = ProxyConfig { + max_output_lines: parse_env_var("CHILL_MAX_LINES", 100), + max_history_lines: parse_env_var("CHILL_HISTORY", 100_000), + ..Default::default() + }; + + match Proxy::spawn(command, &cmd_args, config) { + Ok(mut proxy) => match proxy.run() { + Ok(exit_code) => ExitCode::from(exit_code as u8), + Err(e) => { + eprintln!("Proxy error: {}", e); + ExitCode::from(1) + } + }, + Err(e) => { + eprintln!("Failed to start proxy: {}", e); + ExitCode::from(1) + } + } +} diff --git a/crates/claude-chill/src/escape_parser.rs b/crates/claude-chill/src/escape_parser.rs new file mode 100644 index 0000000..ead76c5 --- /dev/null +++ b/crates/claude-chill/src/escape_parser.rs @@ -0,0 +1,519 @@ +#[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 new file mode 100644 index 0000000..6a401d6 --- /dev/null +++ b/crates/claude-chill/src/escape_sequences.rs @@ -0,0 +1,12 @@ +pub const SYNC_START: &[u8] = b"\x1b[?2026h"; +pub const SYNC_END: &[u8] = b"\x1b[?2026l"; +pub const CLEAR_SCREEN: &[u8] = b"\x1b[2J"; +pub const CLEAR_SCROLLBACK: &[u8] = b"\x1b[3J"; +pub const CURSOR_HOME: &[u8] = b"\x1b[H"; +pub const LOOKBACK_HEADER: &[u8] = b"\x1b[7m--- LOOKBACK MODE ---\x1b[0m\r\n"; + +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 new file mode 100644 index 0000000..b5ebe07 --- /dev/null +++ b/crates/claude-chill/src/lib.rs @@ -0,0 +1,6 @@ +pub mod escape_parser; +pub mod escape_sequences; +pub mod line_buffer; +pub mod output_processor; +pub mod proxy; +pub mod script_parser; diff --git a/crates/claude-chill/src/line_buffer.rs b/crates/claude-chill/src/line_buffer.rs new file mode 100644 index 0000000..8abe1c3 --- /dev/null +++ b/crates/claude-chill/src/line_buffer.rs @@ -0,0 +1,214 @@ +use std::collections::VecDeque; + +pub struct LineBuffer { + lines: VecDeque>, + current_line: Vec, + max_lines: usize, + cached_bytes: usize, +} + +impl LineBuffer { + pub fn new(max_lines: usize) -> Self { + Self { + lines: VecDeque::new(), + current_line: Vec::new(), + max_lines, + cached_bytes: 0, + } + } + + pub fn push_byte(&mut self, byte: u8) { + if byte == b'\n' { + let line = std::mem::take(&mut self.current_line); + self.cached_bytes += line.len() + 1; + self.lines.push_back(line); + if self.lines.len() > self.max_lines + && let Some(removed) = self.lines.pop_front() + { + self.cached_bytes -= removed.len() + 1; + } + } else { + self.current_line.push(byte); + } + } + + pub fn push_bytes(&mut self, bytes: &[u8]) { + for &byte in bytes { + self.push_byte(byte); + } + } + + pub fn clear(&mut self) { + self.lines.clear(); + self.current_line.clear(); + self.cached_bytes = 0; + } + + pub fn line_count(&self) -> usize { + self.lines.len() + if self.current_line.is_empty() { 0 } else { 1 } + } + + pub fn total_bytes(&self) -> usize { + self.cached_bytes + self.current_line.len() + } + + pub fn append_last_n_lines(&self, n: usize, output: &mut Vec) { + let total_lines = self.line_count(); + let lines_to_skip = total_lines.saturating_sub(n); + let completed_lines_to_skip = lines_to_skip.min(self.lines.len()); + + for line in self.lines.iter().skip(completed_lines_to_skip) { + output.extend_from_slice(line); + output.push(b'\n'); + } + if !self.current_line.is_empty() && lines_to_skip < total_lines { + output.extend_from_slice(&self.current_line); + } + } + + pub fn append_all(&self, output: &mut Vec) { + for line in &self.lines { + output.extend_from_slice(line); + output.push(b'\n'); + } + if !self.current_line.is_empty() { + output.extend_from_slice(&self.current_line); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_all(buf: &LineBuffer) -> Vec { + let mut result = Vec::new(); + buf.append_all(&mut result); + result + } + + fn get_last_n(buf: &LineBuffer, n: usize) -> Vec { + let mut result = Vec::new(); + buf.append_last_n_lines(n, &mut result); + result + } + + #[test] + fn test_empty_buffer() { + let buf = LineBuffer::new(10); + assert_eq!(buf.line_count(), 0); + assert_eq!(buf.total_bytes(), 0); + assert_eq!(get_all(&buf), Vec::::new()); + } + + #[test] + fn test_push_single_line() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"hello\n"); + assert_eq!(buf.line_count(), 1); + assert_eq!(buf.total_bytes(), 6); + assert_eq!(get_all(&buf), b"hello\n"); + } + + #[test] + fn test_push_partial_line() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"hello"); + assert_eq!(buf.line_count(), 1); + assert_eq!(buf.total_bytes(), 5); + assert_eq!(get_all(&buf), b"hello"); + } + + #[test] + fn test_push_multiple_lines() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"line1\nline2\nline3\n"); + assert_eq!(buf.line_count(), 3); + assert_eq!(get_all(&buf), b"line1\nline2\nline3\n"); + } + + #[test] + fn test_max_lines_eviction() { + let mut buf = LineBuffer::new(3); + buf.push_bytes(b"a\nb\nc\nd\ne\n"); + assert_eq!(buf.line_count(), 3); + assert_eq!(get_all(&buf), b"c\nd\ne\n"); + } + + #[test] + fn test_total_bytes_after_eviction() { + let mut buf = LineBuffer::new(2); + buf.push_bytes(b"long_line_1\nshort\nmedium_line\n"); + assert_eq!(buf.line_count(), 2); + assert_eq!(buf.total_bytes(), 6 + 12); + assert_eq!(get_all(&buf), b"short\nmedium_line\n"); + } + + #[test] + fn test_clear() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"line1\nline2\npartial"); + buf.clear(); + assert_eq!(buf.line_count(), 0); + assert_eq!(buf.total_bytes(), 0); + assert_eq!(get_all(&buf), Vec::::new()); + } + + #[test] + fn test_get_last_n_lines_all() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"a\nb\nc\n"); + assert_eq!(get_last_n(&buf, 10), b"a\nb\nc\n"); + } + + #[test] + fn test_get_last_n_lines_subset() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"a\nb\nc\nd\ne\n"); + assert_eq!(get_last_n(&buf, 2), b"d\ne\n"); + } + + #[test] + fn test_get_last_n_lines_with_partial() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"a\nb\nc\npartial"); + assert_eq!(buf.line_count(), 4); + assert_eq!(get_last_n(&buf, 2), b"c\npartial"); + } + + #[test] + fn test_get_last_n_lines_only_partial() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"a\nb\nc\npartial"); + assert_eq!(get_last_n(&buf, 1), b"partial"); + } + + #[test] + fn test_get_last_n_lines_zero() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"a\nb\nc\n"); + assert_eq!(get_last_n(&buf, 0), Vec::::new()); + } + + #[test] + fn test_crlf_preserved() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"line1\r\nline2\r\n"); + assert_eq!( + get_all(&buf), + b"line1\r\nline2\r\n", + "CRLF must be preserved" + ); + } + + #[test] + fn test_crlf_preserved_in_last_n() { + let mut buf = LineBuffer::new(10); + buf.push_bytes(b"line1\r\nline2\r\nline3\r\n"); + assert_eq!( + get_last_n(&buf, 2), + b"line2\r\nline3\r\n", + "CRLF must be preserved in last_n" + ); + } +} diff --git a/crates/claude-chill/src/output_processor.rs b/crates/claude-chill/src/output_processor.rs new file mode 100644 index 0000000..3e121d7 --- /dev/null +++ b/crates/claude-chill/src/output_processor.rs @@ -0,0 +1,197 @@ +use crate::escape_parser::{EscapeParser, ParsedEscape}; +use crate::escape_sequences::{ + PASSTHROUGH_BUFFER_CAPACITY, PENDING_ESCAPE_CAPACITY, SYNC_END, SYNC_START, +}; + +pub struct OutputProcessor { + parser: EscapeParser, + in_sync_block: bool, + sync_buffer: Vec, + passthrough_buffer: Vec, + pending_escape: Vec, +} + +impl Default for OutputProcessor { + fn default() -> Self { + Self::new() + } +} + +impl OutputProcessor { + pub fn new() -> Self { + Self { + parser: EscapeParser::new(), + in_sync_block: false, + sync_buffer: Vec::with_capacity(PASSTHROUGH_BUFFER_CAPACITY), + passthrough_buffer: Vec::with_capacity(PASSTHROUGH_BUFFER_CAPACITY), + pending_escape: Vec::with_capacity(PENDING_ESCAPE_CAPACITY), + } + } + + pub fn process(&mut self, data: &[u8]) -> Vec { + let mut output = Vec::new(); + self.passthrough_buffer.clear(); + + 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() { + output.extend_from_slice(&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); + output.extend_from_slice(&self.sync_buffer); + 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); + } else { + self.passthrough_buffer.push(byte); + } + } + + if !self.pending_escape.is_empty() { + self.flush_pending_escape(); + } + + if !self.passthrough_buffer.is_empty() { + output.extend_from_slice(&self.passthrough_buffer); + } + + output + } + + 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(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_passthrough_no_sync() { + let mut processor = OutputProcessor::new(); + let input = b"hello world\r\n"; + let output = processor.process(input); + assert_eq!(output, input); + } + + #[test] + fn test_single_sync_block() { + let mut processor = OutputProcessor::new(); + let input = b"\x1b[?2026hcontent\x1b[?2026l"; + let output = processor.process(input); + assert_eq!(output, input, "Sync block should pass through unchanged"); + } + + #[test] + fn test_sync_start_no_duplicate_byte() { + let mut processor = OutputProcessor::new(); + let input = b"\x1b[?2026hcontent\x1b[?2026l"; + let output = processor.process(input); + + let sync_start_count = output + .windows(SYNC_START.len()) + .filter(|w| *w == SYNC_START) + .count(); + assert_eq!( + sync_start_count, 1, + "Should have exactly one SYNC_START, got {}", + sync_start_count + ); + + let sync_end_count = output + .windows(SYNC_END.len()) + .filter(|w| *w == SYNC_END) + .count(); + assert_eq!( + sync_end_count, 1, + "Should have exactly one SYNC_END, got {}", + sync_end_count + ); + } + + #[test] + fn test_content_before_sync() { + let mut processor = OutputProcessor::new(); + let input = b"before\x1b[?2026hcontent\x1b[?2026lafter"; + let output = processor.process(input); + assert_eq!(output, input); + } + + #[test] + fn test_multiple_sync_blocks() { + let mut processor = OutputProcessor::new(); + let input = b"\x1b[?2026hblock1\x1b[?2026l\x1b[?2026hblock2\x1b[?2026l"; + let output = processor.process(input); + assert_eq!(output, input); + } + + #[test] + fn test_carriage_return_preserved() { + let mut processor = OutputProcessor::new(); + let input = b"line1\r\nline2\r\n"; + let output = processor.process(input); + assert_eq!(output, input, "Carriage returns must be preserved"); + } + + #[test] + fn test_carriage_return_after_sync_start() { + let mut processor = OutputProcessor::new(); + let input = b"\x1b[?2026h\r\ncontent\x1b[?2026l"; + let output = processor.process(input); + assert_eq!( + output, input, + "Carriage return after sync start must be preserved" + ); + } + + #[test] + fn test_carriage_return_in_sync_block() { + let mut processor = OutputProcessor::new(); + let input = b"\x1b[?2026hline1\r\nline2\r\n\x1b[?2026l"; + let output = processor.process(input); + assert_eq!( + output, input, + "Carriage returns inside sync block must be preserved" + ); + } +} diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs new file mode 100644 index 0000000..01e74c4 --- /dev/null +++ b/crates/claude-chill/src/proxy.rs @@ -0,0 +1,485 @@ +use crate::escape_parser::{EscapeParser, ParsedEscape}; +use crate::escape_sequences::{ + CLEAR_SCREEN, CLEAR_SCROLLBACK, CURSOR_HOME, INPUT_BUFFER_CAPACITY, LOOKBACK_HEADER, + OUTPUT_BUFFER_CAPACITY, PASSTHROUGH_BUFFER_CAPACITY, PENDING_ESCAPE_CAPACITY, + SYNC_BUFFER_CAPACITY, SYNC_END, SYNC_START, +}; +use crate::line_buffer::LineBuffer; +use anyhow::{Context, Result}; +use memchr::memmem; +use nix::fcntl::{FcntlArg, OFlag, fcntl}; +use nix::pty::{Winsize, openpty}; +use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction}; +use nix::sys::termios::{SetArg, Termios, cfmakeraw, tcgetattr, tcsetattr}; +use std::io; +use std::os::fd::{AsFd, AsRawFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, ExitStatus}; +use std::sync::atomic::{AtomicBool, Ordering}; + +static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false); +static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false); +static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false); + +extern "C" fn handle_sigwinch(_: libc::c_int) { + SIGWINCH_RECEIVED.store(true, Ordering::SeqCst); +} + +extern "C" fn handle_sigint(_: libc::c_int) { + SIGINT_RECEIVED.store(true, Ordering::SeqCst); +} + +extern "C" fn handle_sigterm(_: libc::c_int) { + SIGTERM_RECEIVED.store(true, Ordering::SeqCst); +} + +pub struct ProxyConfig { + pub max_output_lines: usize, + pub max_history_lines: usize, + pub lookback_sequence: Vec, +} + +impl Default for ProxyConfig { + fn default() -> Self { + Self { + max_output_lines: 100, + max_history_lines: 100_000, + lookback_sequence: b"\x1b[5;6~".to_vec(), + } + } +} + +pub struct Proxy { + config: ProxyConfig, + pty_master: OwnedFd, + child: Child, + original_termios: Option, + history: LineBuffer, + sync_buffer: Vec, + in_sync_block: bool, + parser: EscapeParser, + input_buffer: Vec, + passthrough_buffer: Vec, + output_buffer: Vec, + pending_escape: Vec, + clear_screen_finder: memmem::Finder<'static>, + cursor_home_finder: memmem::Finder<'static>, +} + +impl Proxy { + pub fn spawn(command: &str, args: &[&str], config: ProxyConfig) -> Result { + let winsize = get_terminal_size()?; + let pty = openpty(&winsize, None).context("openpty failed")?; + + let original_termios = setup_raw_mode()?; + setup_signal_handlers()?; + + let slave_fd = pty.slave.as_raw_fd(); + + let child = unsafe { + Command::new(command) + .args(args) + .pre_exec(move || { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + if libc::ioctl(slave_fd, libc::TIOCSCTTY, 0) == -1 { + return Err(io::Error::last_os_error()); + } + if libc::dup2(slave_fd, 0) == -1 { + return Err(io::Error::last_os_error()); + } + if libc::dup2(slave_fd, 1) == -1 { + return Err(io::Error::last_os_error()); + } + if libc::dup2(slave_fd, 2) == -1 { + return Err(io::Error::last_os_error()); + } + if slave_fd > 2 { + libc::close(slave_fd); + } + Ok(()) + }) + .spawn() + .context("spawn failed")? + }; + + drop(pty.slave); + set_nonblocking(&pty.master)?; + + Ok(Self { + history: LineBuffer::new(config.max_history_lines), + config, + pty_master: pty.master, + child, + 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), + clear_screen_finder: memmem::Finder::new(CLEAR_SCREEN), + cursor_home_finder: memmem::Finder::new(CURSOR_HOME), + }) + } + + pub fn run(&mut self) -> Result { + let stdin_raw = io::stdin().as_raw_fd(); + let stdout_raw = io::stdout().as_raw_fd(); + let master_raw = self.pty_master.as_raw_fd(); + + let mut buf = [0u8; 65536]; + + loop { + if SIGWINCH_RECEIVED.swap(false, Ordering::SeqCst) { + self.forward_winsize()?; + } + if SIGINT_RECEIVED.swap(false, Ordering::SeqCst) { + self.forward_signal(Signal::SIGINT); + } + if SIGTERM_RECEIVED.swap(false, Ordering::SeqCst) { + self.forward_signal(Signal::SIGTERM); + } + + let mut poll_fds = [ + libc::pollfd { + fd: master_raw, + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: stdin_raw, + events: libc::POLLIN, + revents: 0, + }, + ]; + + let poll_ret = unsafe { libc::poll(poll_fds.as_mut_ptr(), 2, 100) }; + if poll_ret == 0 { + continue; + } + if poll_ret < 0 { + let err = unsafe { *libc::__errno_location() }; + if err == libc::EINTR { + continue; + } + anyhow::bail!("poll failed: errno {}", err); + } + + if poll_fds[0].revents & libc::POLLIN != 0 { + match libc_read(master_raw, &mut buf) { + Ok(0) => break, + Ok(n) => self.process_output(&buf[..n], stdout_raw)?, + Err(e) if e == libc::EAGAIN => {} + Err(e) if e == libc::EIO => break, + Err(e) => anyhow::bail!("read from pty failed: errno {}", e), + } + } + if poll_fds[0].revents & libc::POLLHUP != 0 { + break; + } + + if poll_fds[1].revents & libc::POLLIN != 0 { + match libc_read(stdin_raw, &mut buf) { + Ok(0) => break, + Ok(n) => self.process_input(&buf[..n], stdout_raw)?, + Err(e) if e == libc::EAGAIN => {} + Err(e) => anyhow::bail!("read from stdin failed: errno {}", e), + } + } + } + + if !self.sync_buffer.is_empty() { + write_all_raw(stdout_raw, &self.sync_buffer)?; + } + + self.wait_child() + } + + fn process_output(&mut self, data: &[u8], stdout_fd: i32) -> Result<()> { + self.passthrough_buffer.clear(); + + 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(); + } + } + } + + 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 { + self.passthrough_buffer.push(byte); + } + } + + 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(); + let is_full_redraw = has_clear_screen && has_cursor_home; + + 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.create_truncated_output(); + write_all_raw(stdout_fd, &self.output_buffer)?; + } else { + write_all_raw(stdout_fd, &self.sync_buffer)?; + } + + Ok(()) + } + + fn create_truncated_output(&mut self) { + self.output_buffer.clear(); + self.output_buffer.extend_from_slice(SYNC_START); + self.output_buffer.extend_from_slice(CLEAR_SCREEN); + self.output_buffer.extend_from_slice(CLEAR_SCROLLBACK); + self.output_buffer.extend_from_slice(CURSOR_HOME); + self.history + .append_last_n_lines(self.config.max_output_lines, &mut self.output_buffer); + 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 { + self.input_buffer.push(byte); + + if self.input_buffer.len() > self.config.lookback_sequence.len() { + let excess = self.input_buffer.len() - self.config.lookback_sequence.len(); + write_all_raw(master_fd, &self.input_buffer[..excess])?; + self.input_buffer.drain(..excess); + } + + if self.input_buffer == self.config.lookback_sequence { + self.enter_lookback_mode(stdout_fd)?; + self.input_buffer.clear(); + } else if !self + .config + .lookback_sequence + .starts_with(&self.input_buffer) + { + write_all_raw(master_fd, &self.input_buffer)?; + self.input_buffer.clear(); + } + } + Ok(()) + } + + fn enter_lookback_mode(&mut self, stdout_fd: i32) -> Result<()> { + write_all_raw(stdout_fd, LOOKBACK_HEADER)?; + self.output_buffer.clear(); + self.history.append_all(&mut self.output_buffer); + write_all_raw(stdout_fd, &self.output_buffer)?; + Ok(()) + } + + fn forward_winsize(&self) -> Result<()> { + if let Ok(winsize) = get_terminal_size() { + unsafe { + libc::ioctl(self.pty_master.as_raw_fd(), libc::TIOCSWINSZ, &winsize); + } + } + Ok(()) + } + + fn forward_signal(&self, signal: Signal) { + let pid = self.child.id(); + unsafe { + libc::kill(pid as i32, signal as i32); + } + } + + fn wait_child(&mut self) -> Result { + match self.child.wait() { + Ok(status) => Ok(exit_code_from_status(status)), + Err(e) => anyhow::bail!("wait failed: {}", e), + } + } +} + +impl Drop for Proxy { + fn drop(&mut self) { + if let Some(ref termios) = self.original_termios { + let _ = tcsetattr(io::stdin(), SetArg::TCSANOW, termios); + } + } +} + +fn get_terminal_size() -> Result { + let mut ws: Winsize = unsafe { std::mem::zeroed() }; + let ret = unsafe { libc::ioctl(io::stdout().as_raw_fd(), libc::TIOCGWINSZ, &mut ws) }; + if ret == -1 { + ws.ws_row = 24; + ws.ws_col = 80; + } + Ok(ws) +} + +fn exit_code_from_status(status: ExitStatus) -> i32 { + use std::os::unix::process::ExitStatusExt; + if let Some(code) = status.code() { + code + } else if let Some(signal) = status.signal() { + 128 + signal + } else { + 1 + } +} + +fn setup_raw_mode() -> Result> { + let stdin = io::stdin(); + let is_tty = unsafe { libc::isatty(stdin.as_raw_fd()) == 1 }; + if !is_tty { + return Ok(None); + } + + let original = tcgetattr(&stdin).context("tcgetattr failed")?; + let mut raw = original.clone(); + cfmakeraw(&mut raw); + tcsetattr(&stdin, SetArg::TCSANOW, &raw).context("tcsetattr failed")?; + Ok(Some(original)) +} + +fn setup_signal_handler( + signal: Signal, + handler: extern "C" fn(libc::c_int), +) -> Result<()> { + let action = SigAction::new( + SigHandler::Handler(handler), + SaFlags::SA_RESTART, + SigSet::empty(), + ); + unsafe { sigaction(signal, &action) }.context(format!("sigaction {:?} failed", signal))?; + Ok(()) +} + +fn setup_signal_handlers() -> Result<()> { + setup_signal_handler(Signal::SIGWINCH, handle_sigwinch)?; + setup_signal_handler(Signal::SIGINT, handle_sigint)?; + setup_signal_handler(Signal::SIGTERM, handle_sigterm)?; + Ok(()) +} + +fn set_nonblocking(fd: &Fd) -> Result<()> { + let flags = fcntl(fd.as_fd(), FcntlArg::F_GETFL).context("fcntl F_GETFL failed")?; + let flags = OFlag::from_bits_truncate(flags); + fcntl(fd.as_fd(), FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)) + .context("fcntl F_SETFL failed")?; + Ok(()) +} + +fn write_all_raw(fd: i32, data: &[u8]) -> Result<()> { + let mut written = 0; + while written < data.len() { + match libc_write(fd, &data[written..]) { + Ok(n) => written += n, + Err(e) if e == libc::EAGAIN || e == libc::EINTR => continue, + Err(e) => anyhow::bail!("write failed: errno {}", e), + } + } + Ok(()) +} + +fn libc_read(fd: i32, buf: &mut [u8]) -> Result { + let ret = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + if ret < 0 { + Err(unsafe { *libc::__errno_location() }) + } else { + Ok(ret as usize) + } +} + +fn libc_write(fd: i32, buf: &[u8]) -> Result { + let ret = unsafe { libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()) }; + if ret < 0 { + Err(unsafe { *libc::__errno_location() }) + } else { + Ok(ret as usize) + } +} diff --git a/crates/claude-chill/src/script_parser.rs b/crates/claude-chill/src/script_parser.rs new file mode 100644 index 0000000..c50156f --- /dev/null +++ b/crates/claude-chill/src/script_parser.rs @@ -0,0 +1,79 @@ +pub fn find_script_header_end(data: &[u8]) -> usize { + if !data.starts_with(b"Script started on") { + return 0; + } + for (i, &byte) in data.iter().enumerate() { + if byte == b'\n' { + return i + 1; + } + } + 0 +} + +pub fn find_script_footer_start(data: &[u8]) -> usize { + let footer_marker = b"\nScript done on"; + for i in (0..data.len().saturating_sub(footer_marker.len())).rev() { + if &data[i..i + footer_marker.len()] == footer_marker { + return i; + } + } + data.len() +} + +pub fn strip_script_wrapper(data: &[u8]) -> &[u8] { + let mut result = data; + + loop { + let header_end = find_script_header_end(result); + if header_end == 0 { + break; + } + result = &result[header_end..]; + } + + loop { + let footer_start = find_script_footer_start(result); + if footer_start >= result.len() { + break; + } + result = &result[..footer_start]; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_header_end_no_header() { + let data = b"hello world"; + assert_eq!(find_script_header_end(data), 0); + } + + #[test] + fn test_find_header_end_with_header() { + let data = b"Script started on 2024-01-01\ncontent here"; + assert_eq!(find_script_header_end(data), 29); + } + + #[test] + fn test_find_footer_start_no_footer() { + let data = b"hello world"; + assert_eq!(find_script_footer_start(data), data.len()); + } + + #[test] + fn test_find_footer_start_with_footer() { + let data = b"content here\nScript done on 2024-01-01"; + assert_eq!(find_script_footer_start(data), 12); + } + + #[test] + fn test_strip_script_wrapper() { + let data = b"Script started on 2024-01-01\ncontent\nScript done on 2024-01-01"; + let stripped = strip_script_wrapper(data); + assert_eq!(stripped, b"content"); + } +}