diff --git a/Cargo.lock b/Cargo.lock index a7c24ef..7919874 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -58,6 +67,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "bitflags" version = "2.10.0" @@ -123,11 +138,14 @@ dependencies = [ "anyhow", "clap", "dirs", + "env_logger", "libc", + "log", "memchr", "nix", "serde", "toml", + "vt100", ] [[package]] @@ -157,6 +175,29 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -202,6 +243,36 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "libc" version = "0.2.177" @@ -218,6 +289,12 @@ dependencies = [ "libc", ] +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "memchr" version = "2.7.6" @@ -248,6 +325,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -277,6 +369,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "serde" version = "1.0.228" @@ -400,12 +521,39 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/README.md b/README.md index ce66812..fee42bb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ![Windows](https://img.shields.io/badge/Windows-unsupported-red) ![Rust](https://img.shields.io/badge/rust-2024-orange) -A PTY proxy that tames Claude Code's massive terminal updates. +A PTY proxy that tames Claude Code's massive terminal updates using VT-based rendering. ## The Problem @@ -15,25 +15,13 @@ 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: 1. **Intercepts sync blocks** - Catches those massive atomic updates -2. **Truncates full screen clears** - Only the 45% that are full redraws (94.5 KB avg) get truncated to the last N lines (default: 100). The 55% incremental updates (2.7 KB avg) pass through unchanged. -3. **Preserves history** - Accumulates content in a buffer. Clears on full screen clear, accumulates otherwise. +2. **VT-based rendering** - Uses a VT100 emulator to track screen state and renders only the differences +3. **Preserves history** - Accumulates content in a buffer for lookback 4. **Enables lookback** - Press a key to pause Claude and view the full history buffer ## Installation @@ -47,7 +35,6 @@ cargo install --path crates/claude-chill ```bash claude-chill claude claude-chill -- claude --verbose # Use -- for command flags -claude-chill -l 50 -- claude # Set max lines to 50 ``` ## Lookback Mode @@ -66,11 +53,12 @@ When you exit lookback mode, any cached output is processed and the current stat Create `~/.config/claude-chill.toml`: ```toml -max_lines = 100 # Lines shown per sync block -history_lines = 100000 # Lines stored for lookback +history_lines = 100000 # Max lines stored for lookback lookback_key = "[ctrl][6]" ``` +Note: History is cleared on full screen redraws, so lookback shows output since Claude's last full render. + ### Key Format `[modifier][key]` - Examples: `[f12]`, `[ctrl][g]`, `[ctrl][shift][j]` @@ -97,9 +85,9 @@ claude-chill creates a pseudo-terminal (PTY) and spawns Claude Code as a child p 1. **Input handling**: Keystrokes pass through to Claude, except for the lookback key which toggles lookback mode 2. **Output processing**: Scans output for sync block markers. Non-sync output passes through directly -3. **Sync block buffering**: Accumulates sync block content until the end marker arrives -4. **Truncation decision**: If the sync block contains a full screen clear (`ESC[2J` + `ESC[H`), truncates to the last N lines. Otherwise passes through unchanged -5. **History tracking**: Maintains a rolling buffer of output for lookback mode +3. **VT emulation**: Feeds output through a VT100 emulator to track the virtual screen state +4. **Differential rendering**: Compares current screen to previous and emits only the changes +5. **History tracking**: Maintains a buffer of output for lookback mode since the last full redraw 6. **Signal forwarding**: Window resize (SIGWINCH), interrupt (SIGINT), and terminate (SIGTERM) signals are forwarded to Claude ## Disclaimer diff --git a/crates/claude-chill/Cargo.toml b/crates/claude-chill/Cargo.toml index 8b64a8c..0519d36 100644 --- a/crates/claude-chill/Cargo.toml +++ b/crates/claude-chill/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true authors.workspace = true default-run = "claude-chill" -description = "PTY proxy that reduces terminal flicker by truncating synchronized output" +description = "PTY proxy that reduces terminal flicker using VT-based rendering" license = "MIT" repository = "https://github.com/davidbeesley/claude-chill" @@ -25,3 +25,6 @@ nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"] libc = "0.2" serde = { version = "1", features = ["derive"] } toml = "0.8" +vt100 = "0.16" +log = "0.4" +env_logger = "0.11" diff --git a/crates/claude-chill/src/bin.rs b/crates/claude-chill/src/bin.rs index 446d37c..02b82e4 100644 --- a/crates/claude-chill/src/bin.rs +++ b/crates/claude-chill/src/bin.rs @@ -7,10 +7,25 @@ use claude_chill::proxy::{Proxy, ProxyConfig}; use std::process::ExitCode; fn main() -> ExitCode { + // Only enable logging if CLAUDE_CHILL_LOG_FILE is set + if let Ok(log_file) = std::env::var("CLAUDE_CHILL_LOG_FILE") { + use std::fs::OpenOptions; + if let Ok(file) = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&log_file) + { + env_logger::Builder::new() + .filter_level(log::LevelFilter::Debug) + .target(env_logger::Target::Pipe(Box::new(file))) + .init(); + } + } + let cli = cli::Cli::parse(); let config = Config::load(); - let max_lines = cli.max_lines.unwrap_or(config.max_lines); let history_lines = cli.history_lines.unwrap_or(config.history_lines); let lookback_key = cli @@ -22,13 +37,12 @@ fn main() -> ExitCode { Ok(key) => key.to_escape_sequence(), Err(e) => { eprintln!("Invalid lookback key '{}': {}", lookback_key, e); - eprintln!("Using default: [ctrl][shift][j]"); + eprintln!("Using default: [ctrl][6]"); config.lookback_sequence() } }; let proxy_config = ProxyConfig { - max_output_lines: max_lines, max_history_lines: history_lines, lookback_key, lookback_sequence, diff --git a/crates/claude-chill/src/cli.rs b/crates/claude-chill/src/cli.rs index de531f1..9615ece 100644 --- a/crates/claude-chill/src/cli.rs +++ b/crates/claude-chill/src/cli.rs @@ -1,59 +1,16 @@ use clap::Parser; #[derive(Parser, Debug)] -#[command( - name = "claude-chill", - about = "PTY proxy that reduces terminal flicker by truncating synchronized output", - long_about = "claude-chill sits between your terminal and a child process, intercepting \ - synchronized output blocks and truncating them to reduce flicker.\n\n\ - Full history is preserved. Press the lookback key (default: Ctrl+Shift+J) \ - to dump history to terminal, then scroll up to view it.", - version, - after_help = "USAGE EXAMPLES:\n \ - claude-chill claude\n \ - claude-chill -- claude --verbose # Use -- for command flags\n \ - claude-chill -l 50 -- claude # Set max lines to 50\n\n\ - CONFIGURATION:\n \ - Create ~/.config/claude-chill.toml:\n\n \ - max_lines = 100 # Lines shown per sync block\n \ - history_lines = 100000 # Lines stored for lookback\n \ - lookback_key = \"[ctrl][shift][j]\"\n\n\ - KEY FORMAT: [modifier][key]\n \ - Modifiers: [ctrl], [shift], [alt]\n \ - Keys: [a]-[z], [f1]-[f12], [pageup], [enter], [space], etc." -)] +#[command(name = "claude-chill", version)] pub struct Cli { - #[arg(help = "Command to run", required = true, value_name = "COMMAND")] pub command: String, - #[arg( - help = "Arguments passed to command (use -- before command flags)", - value_name = "ARGS", - trailing_var_arg = true - )] + #[arg(trailing_var_arg = true)] pub args: Vec, - #[arg( - short = 'l', - long = "max-lines", - help = "Maximum lines per sync block", - value_name = "N" - )] - pub max_lines: Option, - - #[arg( - short = 'H', - long = "history", - help = "Maximum history lines for lookback", - value_name = "N" - )] + #[arg(short = 'H', long = "history")] pub history_lines: Option, - #[arg( - short = 'k', - long = "lookback-key", - help = "Key to trigger lookback (e.g., [ctrl][shift][j])", - value_name = "KEY" - )] + #[arg(short = 'k', long = "lookback-key")] pub lookback_key: Option, } diff --git a/crates/claude-chill/src/config.rs b/crates/claude-chill/src/config.rs index 4a711ed..1955b0b 100644 --- a/crates/claude-chill/src/config.rs +++ b/crates/claude-chill/src/config.rs @@ -4,21 +4,22 @@ use std::fs; use std::path::PathBuf; const DEFAULT_LOOKBACK_KEY: &str = "[ctrl][6]"; +const DEFAULT_REFRESH_RATE: u64 = 20; #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct Config { - pub max_lines: usize, pub history_lines: usize, pub lookback_key: String, + pub refresh_rate: u64, } impl Default for Config { fn default() -> Self { Self { - max_lines: 100, history_lines: 100_000, lookback_key: DEFAULT_LOOKBACK_KEY.to_string(), + refresh_rate: DEFAULT_REFRESH_RATE, } } } @@ -78,6 +79,11 @@ impl Config { .unwrap_or_else(|_| b"\x1b[5;6~".to_vec()) }) } + + pub fn redraw_throttle_ms(&self) -> u64 { + let rate = self.refresh_rate.max(1); + 1000 / rate + } } #[cfg(test)] @@ -87,9 +93,10 @@ mod tests { #[test] fn test_default_config() { let config = Config::default(); - assert_eq!(config.max_lines, 100); assert_eq!(config.history_lines, 100_000); assert_eq!(config.lookback_key, "[ctrl][6]"); + assert_eq!(config.refresh_rate, 20); + assert_eq!(config.redraw_throttle_ms(), 50); } #[test] diff --git a/crates/claude-chill/src/lib.rs b/crates/claude-chill/src/lib.rs index c3be1b5..d071126 100644 --- a/crates/claude-chill/src/lib.rs +++ b/crates/claude-chill/src/lib.rs @@ -3,3 +3,4 @@ pub mod escape_sequences; pub mod key_parser; pub mod line_buffer; pub mod proxy; +pub mod redraw_throttler; diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index 182565f..8cecc92 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -1,10 +1,11 @@ use crate::escape_sequences::{ ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY, - CLEAR_SCREEN, CLEAR_SCROLLBACK, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, - SYNC_BUFFER_CAPACITY, SYNC_END, SYNC_START, + CLEAR_SCREEN, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY, + SYNC_END, SYNC_START, }; use crate::line_buffer::LineBuffer; use anyhow::{Context, Result}; +use log::debug; use memchr::memmem; use nix::errno::Errno; use nix::fcntl::{FcntlArg, OFlag, fcntl}; @@ -18,11 +19,19 @@ use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; use std::os::unix::process::CommandExt; use std::process::{Child, Command, ExitStatus}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false); static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false); static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SequenceMatch { + Complete, + Partial, + None, +} + extern "C" fn handle_sigwinch(_: libc::c_int) { SIGWINCH_RECEIVED.store(true, Ordering::SeqCst); } @@ -36,7 +45,6 @@ extern "C" fn handle_sigterm(_: libc::c_int) { } pub struct ProxyConfig { - pub max_output_lines: usize, pub max_history_lines: usize, pub lookback_key: String, pub lookback_sequence: Vec, @@ -45,7 +53,6 @@ pub struct ProxyConfig { impl Default for ProxyConfig { fn default() -> Self { Self { - max_output_lines: 100, max_history_lines: 100_000, lookback_key: "[ctrl][6]".to_string(), lookback_sequence: vec![0x1E], @@ -76,18 +83,25 @@ impl Drop for TerminalGuard { } } +const RENDER_DELAY_MS: u64 = 5; +const SYNC_BLOCK_DELAY_MS: u64 = 50; + pub struct Proxy { config: ProxyConfig, pty_master: OwnedFd, child: Child, original_termios: Option, history: LineBuffer, + vt_parser: vt100::Parser, + vt_prev_screen: Option, + last_output_time: Option, sync_buffer: Vec, in_sync_block: bool, in_lookback_mode: bool, in_alternate_screen: bool, + vt_render_pending: bool, lookback_cache: Vec, - input_buffer: Vec, + lookback_input_buffer: Vec, output_buffer: Vec, sync_start_finder: memmem::Finder<'static>, sync_end_finder: memmem::Finder<'static>, @@ -140,18 +154,31 @@ impl Proxy { drop(pty.slave); set_nonblocking(&pty.master)?; + let vt_parser = vt100::Parser::new(winsize.ws_row, winsize.ws_col, 0); + + // Seed history with clear screen so replay starts fresh + let mut history = LineBuffer::new(config.max_history_lines); + history.push_bytes(CLEAR_SCREEN); + history.push_bytes(CURSOR_HOME); + + debug!("Proxy::spawn: command={} args={:?}", command, args); + Ok(Self { - history: LineBuffer::new(config.max_history_lines), + history, config, pty_master: pty.master, child, original_termios: terminal_guard.take(), + vt_parser, + vt_prev_screen: None, + last_output_time: None, sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY), in_sync_block: false, in_lookback_mode: false, in_alternate_screen: false, + vt_render_pending: false, lookback_cache: Vec::new(), - input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY), + lookback_input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY), output_buffer: Vec::with_capacity(OUTPUT_BUFFER_CAPACITY), sync_start_finder: memmem::Finder::new(SYNC_START), sync_end_finder: memmem::Finder::new(SYNC_END), @@ -189,13 +216,23 @@ impl Proxy { PollFd::new(stdin_borrowed, PollFlags::POLLIN), ]; - match poll(&mut poll_fds, PollTimeout::from(100u16)) { - Ok(0) => continue, + let poll_timeout_ms = self + .time_until_render() + .map(|d| d.as_millis().min(100) as u16) + .unwrap_or(100); + + match poll(&mut poll_fds, PollTimeout::from(poll_timeout_ms)) { + Ok(0) => { + self.flush_pending_vt_render(&stdout_fd)?; + continue; + } Ok(_) => {} Err(Errno::EINTR) => continue, Err(e) => anyhow::bail!("poll failed: {}", e), } + self.flush_pending_vt_render(&stdout_fd)?; + if let Some(revents) = poll_fds[0].revents() { if revents.contains(PollFlags::POLLIN) { match nix_read(&self.pty_master, &mut buf) { @@ -223,47 +260,86 @@ impl Proxy { } } - if !self.sync_buffer.is_empty() { - write_all(&stdout_fd, &self.sync_buffer)?; + // Final render before exit + if self.vt_render_pending { + self.render_vt_screen(&stdout_fd)?; } self.wait_child() } fn process_output(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { + self.process_output_inner(data, stdout_fd, true) + } + + fn process_output_inner( + &mut self, + data: &[u8], + stdout_fd: &F, + feed_vt: bool, + ) -> Result<()> { + debug!( + "process_output: len={} in_alt={} in_lookback={} feed_vt={}", + data.len(), + self.in_alternate_screen, + self.in_lookback_mode, + feed_vt + ); + if self.in_alternate_screen { + // Still feed VT and history while in alt screen so they stay in sync + if feed_vt { + self.vt_parser.process(data); + self.history.push_bytes(data); + } return self.process_output_alt_screen(data, stdout_fd); } if self.in_lookback_mode { + debug!("process_output: caching {} bytes for lookback", data.len()); self.lookback_cache.extend_from_slice(data); return Ok(()); } - let mut pos = 0; + // Feed data to VT emulator (unless already fed by caller) + if feed_vt { + self.vt_parser.process(data); + } + self.vt_render_pending = true; + self.last_output_time = Some(Instant::now()); + // Process sync blocks for history management + let mut pos = 0; while pos < data.len() { + // Check for alt screen enter if let Some(alt_pos) = self.find_alt_screen_enter(&data[pos..]) { + debug!( + "process_output: ALT_SCREEN_ENTER detected at pos={}", + pos + alt_pos + ); + // Add ALL remaining data to history (including alt screen enter and content) + // This ensures history matches VT exactly + let remaining = &data[pos..]; if self.in_sync_block { - self.sync_buffer - .extend_from_slice(&data[pos..pos + alt_pos]); - write_all(stdout_fd, &self.sync_buffer)?; - self.sync_buffer.clear(); + self.sync_buffer.extend_from_slice(remaining); + self.flush_sync_block_to_history(); self.in_sync_block = false; - } else if alt_pos > 0 { - write_all(stdout_fd, &data[pos..pos + alt_pos])?; + } else { + self.history.push_bytes(remaining); } self.in_alternate_screen = true; let seq_len = self.alt_screen_enter_len(&data[pos + alt_pos..]); + // Write alt screen enter directly write_all(stdout_fd, &data[pos + alt_pos..pos + alt_pos + seq_len])?; return self.process_output_alt_screen(&data[pos + alt_pos + seq_len..], stdout_fd); } if self.in_sync_block { if let Some(idx) = self.sync_end_finder.find(&data[pos..]) { + debug!("process_output: SYNC_END at pos={}", pos + idx); 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.flush_sync_block_to_history(); self.in_sync_block = false; pos += idx + SYNC_END.len(); } else { @@ -271,15 +347,18 @@ impl Proxy { break; } } else if let Some(idx) = self.sync_start_finder.find(&data[pos..]) { + debug!("process_output: SYNC_START at pos={}", pos + idx); + // Add any data before SYNC_START to history if idx > 0 { - write_all(stdout_fd, &data[pos..pos + idx])?; + self.history.push_bytes(&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(stdout_fd, &data[pos..])?; + // No sync block, just add to history + self.history.push_bytes(&data[pos..]); break; } } @@ -289,15 +368,50 @@ impl Proxy { fn process_output_alt_screen(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { if let Some(exit_pos) = self.find_alt_screen_exit(data) { + debug!( + "process_output_alt_screen: ALT_SCREEN_EXIT detected at pos={}", + exit_pos + ); write_all(stdout_fd, &data[..exit_pos])?; let seq_len = self.alt_screen_exit_len(&data[exit_pos..]); write_all(stdout_fd, &data[exit_pos..exit_pos + seq_len])?; self.in_alternate_screen = false; - return self.process_output(&data[exit_pos + seq_len..], stdout_fd); + + // Force full VT render to restore main screen content + debug!("process_output_alt_screen: rendering VT screen after alt exit"); + self.vt_prev_screen = None; + self.render_vt_screen(stdout_fd)?; + + // Data after ALT_EXIT was already fed to VT and history when we processed + // the alt screen chunk, so we just need to check for more alt screen transitions + let remaining = &data[exit_pos + seq_len..]; + if !remaining.is_empty() { + // Check if there's another alt screen enter in the remaining data + if self.find_alt_screen_enter(remaining).is_some() { + // Need to process for alt screen detection, but skip VT/history feed + return self.process_output_check_alt_only(remaining, stdout_fd); + } + } + return Ok(()); } write_all(stdout_fd, data) } + /// Check for alt screen transitions without re-feeding VT/history + fn process_output_check_alt_only(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { + if let Some(alt_pos) = self.find_alt_screen_enter(data) { + debug!( + "process_output_check_alt_only: ALT_SCREEN_ENTER at pos={}", + alt_pos + ); + self.in_alternate_screen = true; + let seq_len = self.alt_screen_enter_len(&data[alt_pos..]); + write_all(stdout_fd, &data[alt_pos..alt_pos + seq_len])?; + return self.process_output_alt_screen(&data[alt_pos + seq_len..], stdout_fd); + } + Ok(()) + } + fn find_alt_screen_enter(&self, data: &[u8]) -> Option { let pos1 = self.alt_screen_enter_finder.find(data); let pos2 = self.alt_screen_enter_legacy_finder.find(data); @@ -336,36 +450,108 @@ impl Proxy { } } - fn flush_sync_block(&mut self, stdout_fd: &F) -> Result<()> { + fn flush_sync_block_to_history(&mut self) { 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; + debug!( + "flush_sync_block: len={} full_redraw={}", + self.sync_buffer.len(), + is_full_redraw + ); + if is_full_redraw { + debug!("CLEARING HISTORY"); self.history.clear(); + // Re-seed with clear screen after clearing + self.history.push_bytes(CLEAR_SCREEN); + self.history.push_bytes(CURSOR_HOME); } self.history.push_bytes(&self.sync_buffer); + self.sync_buffer.clear(); + } - if is_full_redraw { - self.create_truncated_output(); - write_all(stdout_fd, &self.output_buffer)?; - } else { - write_all(stdout_fd, &self.sync_buffer)?; + fn flush_pending_vt_render(&mut self, stdout_fd: &F) -> Result<()> { + if !self.vt_render_pending || self.in_lookback_mode || self.in_alternate_screen { + return Ok(()); + } + + let elapsed = self + .last_output_time + .map(|t| t.elapsed()) + .unwrap_or(Duration::MAX); + + // Wait longer if in sync block (more data likely coming) + let delay = if self.in_sync_block { + Duration::from_millis(SYNC_BLOCK_DELAY_MS) + } else { + Duration::from_millis(RENDER_DELAY_MS) + }; + + if elapsed >= delay { + self.render_vt_screen(stdout_fd)?; } - self.sync_buffer.clear(); Ok(()) } - fn create_truncated_output(&mut self) { + fn time_until_render(&self) -> Option { + if !self.vt_render_pending || self.in_lookback_mode || self.in_alternate_screen { + return None; + } + + let elapsed = self + .last_output_time + .map(|t| t.elapsed()) + .unwrap_or(Duration::MAX); + + let delay = if self.in_sync_block { + Duration::from_millis(SYNC_BLOCK_DELAY_MS) + } else { + Duration::from_millis(RENDER_DELAY_MS) + }; + + if elapsed >= delay { + Some(Duration::ZERO) + } else { + Some(delay - elapsed) + } + } + + fn render_vt_screen(&mut self, stdout_fd: &F) -> Result<()> { + let is_diff = self.vt_prev_screen.is_some(); 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); + + match &self.vt_prev_screen { + Some(prev) => { + // Diff-based render: only send changes + self.output_buffer + .extend_from_slice(&self.vt_parser.screen().contents_diff(prev)); + } + None => { + // First render: full screen + self.output_buffer + .extend_from_slice(&self.vt_parser.screen().contents_formatted()); + } + } + + self.output_buffer + .extend_from_slice(&self.vt_parser.screen().cursor_state_formatted()); self.output_buffer.extend_from_slice(SYNC_END); + + debug!( + "render_vt_screen: diff={} output_len={}\n", + is_diff, + self.output_buffer.len() + ); + write_all(stdout_fd, &self.output_buffer)?; + + // Store current screen for next diff + self.vt_prev_screen = Some(self.vt_parser.screen().clone()); + self.vt_render_pending = false; + Ok(()) } fn process_input(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { @@ -375,48 +561,89 @@ impl Proxy { for &byte in data { if self.in_lookback_mode && byte == 0x03 { - self.input_buffer.clear(); + self.lookback_input_buffer.clear(); self.exit_lookback_mode(stdout_fd)?; continue; } - self.input_buffer.push(byte); + let lookback_action = self.check_sequence_match( + byte, + &mut self.lookback_input_buffer.clone(), + &self.config.lookback_sequence.clone(), + ); - if self.input_buffer.len() > self.config.lookback_sequence.len() { - let excess = self.input_buffer.len() - self.config.lookback_sequence.len(); - if !self.in_lookback_mode { - write_all(&self.pty_master, &self.input_buffer[..excess])?; - } - self.input_buffer.drain(..excess); + self.lookback_input_buffer.push(byte); + + if self.lookback_input_buffer.len() > self.config.lookback_sequence.len() { + let excess = self.lookback_input_buffer.len() - self.config.lookback_sequence.len(); + self.lookback_input_buffer.drain(..excess); } - if self.input_buffer == self.config.lookback_sequence { - self.input_buffer.clear(); - if self.in_lookback_mode { - self.exit_lookback_mode(stdout_fd)?; - } else { - self.enter_lookback_mode()?; + match lookback_action { + SequenceMatch::Complete => { + self.lookback_input_buffer.clear(); + if self.in_lookback_mode { + self.exit_lookback_mode(stdout_fd)?; + } else { + self.enter_lookback_mode()?; + } + continue; } - } else if !self - .config - .lookback_sequence - .starts_with(&self.input_buffer) - { - if !self.in_lookback_mode { - write_all(&self.pty_master, &self.input_buffer)?; + SequenceMatch::Partial => {} + SequenceMatch::None => { + if !self + .config + .lookback_sequence + .starts_with(&self.lookback_input_buffer) + { + self.lookback_input_buffer.clear(); + } } - self.input_buffer.clear(); + } + + if lookback_action == SequenceMatch::None && !self.in_lookback_mode { + write_all(&self.pty_master, &[byte])?; } } Ok(()) } + fn check_sequence_match( + &self, + byte: u8, + buffer: &mut Vec, + sequence: &[u8], + ) -> SequenceMatch { + buffer.push(byte); + if buffer.len() > sequence.len() { + let excess = buffer.len() - sequence.len(); + buffer.drain(..excess); + } + if buffer.as_slice() == sequence { + SequenceMatch::Complete + } else if sequence.starts_with(buffer) { + SequenceMatch::Partial + } else { + SequenceMatch::None + } + } + fn enter_lookback_mode(&mut self) -> Result<()> { + debug!( + "enter_lookback_mode: history_bytes={} lines={}", + self.history.total_bytes(), + self.history.line_count() + ); self.in_lookback_mode = true; self.lookback_cache.clear(); + self.vt_render_pending = false; self.output_buffer.clear(); self.history.append_all(&mut self.output_buffer); + debug!( + "enter_lookback_mode: output_buffer_len={}", + self.output_buffer.len() + ); let stdout_fd = io::stdout(); write_all(&stdout_fd, CLEAR_SCREEN)?; @@ -433,25 +660,49 @@ impl Proxy { } fn exit_lookback_mode(&mut self, stdout_fd: &F) -> Result<()> { + debug!( + "exit_lookback_mode: cached_len={}", + self.lookback_cache.len() + ); self.in_lookback_mode = false; + // Process cached output through VT to update screen state let cached = std::mem::take(&mut self.lookback_cache); if !cached.is_empty() { + debug!( + "exit_lookback_mode: processing {} cached bytes", + cached.len() + ); self.process_output(&cached, stdout_fd)?; } - self.output_buffer.clear(); - self.history.append_all(&mut self.output_buffer); + // Reset sync block state + self.in_sync_block = false; + self.sync_buffer.clear(); - write_all(stdout_fd, CLEAR_SCREEN)?; - write_all(stdout_fd, CURSOR_HOME)?; - write_all(stdout_fd, &self.output_buffer)?; + self.forward_winsize()?; + + // Force full render since terminal was showing history + debug!("exit_lookback_mode: rendering VT screen"); + self.vt_prev_screen = None; + self.render_vt_screen(stdout_fd)?; Ok(()) } - fn forward_winsize(&self) -> Result<()> { + fn forward_winsize(&mut self) -> Result<()> { if let Ok(winsize) = get_terminal_size() { + debug!( + "forward_winsize: rows={} cols={}", + winsize.ws_row, winsize.ws_col + ); + // Resize VT emulator + self.vt_parser + .screen_mut() + .set_size(winsize.ws_row, winsize.ws_col); + // Force full render on next frame since size changed + self.vt_prev_screen = None; + // Forward to child process unsafe { libc::ioctl( self.pty_master.as_raw_fd(), @@ -493,7 +744,7 @@ fn get_terminal_size() -> Result { &mut ws, ) }; - if ret == -1 { + if ret == -1 || ws.ws_row == 0 || ws.ws_col == 0 { ws.ws_row = 24; ws.ws_col = 80; } diff --git a/crates/claude-chill/src/redraw_throttler.rs b/crates/claude-chill/src/redraw_throttler.rs new file mode 100644 index 0000000..7a84baa --- /dev/null +++ b/crates/claude-chill/src/redraw_throttler.rs @@ -0,0 +1,86 @@ +use std::time::{Duration, Instant}; + +pub struct RedrawThrottler { + min_interval: Duration, + last_flush: Option, + pending_redraw: Option>, +} + +impl RedrawThrottler { + pub fn new(min_interval_ms: u64) -> Self { + Self { + min_interval: Duration::from_millis(min_interval_ms), + last_flush: None, + pending_redraw: None, + } + } + + pub fn submit(&mut self, data: Vec) { + self.pending_redraw = Some(data); + } + + pub fn should_flush(&self) -> bool { + if self.pending_redraw.is_none() { + return false; + } + + match self.last_flush { + None => true, + Some(last) => last.elapsed() >= self.min_interval, + } + } + + pub fn take_pending(&mut self) -> Option> { + if self.should_flush() { + self.last_flush = Some(Instant::now()); + self.pending_redraw.take() + } else { + None + } + } + + pub fn time_until_next_flush(&self) -> Option { + self.pending_redraw.as_ref()?; + + match self.last_flush { + None => Some(Duration::ZERO), + Some(last) => { + let elapsed = last.elapsed(); + if elapsed >= self.min_interval { + Some(Duration::ZERO) + } else { + Some(self.min_interval - elapsed) + } + } + } + } + + pub fn has_pending(&self) -> bool { + self.pending_redraw.is_some() + } + + pub fn can_render(&self) -> bool { + match self.last_flush { + None => true, + Some(last) => last.elapsed() >= self.min_interval, + } + } + + pub fn mark_rendered(&mut self) { + self.last_flush = Some(Instant::now()); + } + + pub fn time_until_can_render(&self) -> Option { + match self.last_flush { + None => Some(Duration::ZERO), + Some(last) => { + let elapsed = last.elapsed(); + if elapsed >= self.min_interval { + Some(Duration::ZERO) + } else { + Some(self.min_interval - elapsed) + } + } + } + } +}