From 35ecc80e2f6e5fca2ea31252b28e69422e303bbf Mon Sep 17 00:00:00 2001 From: David Beesley <37917398+davidbeesley@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:54:43 +1300 Subject: [PATCH] Fix: Remove terminal queries from history to prevent terminal responses on stdin (#10) --- crates/claude-chill/src/escape_filter.rs | 504 +++++++++++++++++++++++ crates/claude-chill/src/lib.rs | 1 + crates/claude-chill/src/proxy.rs | 20 +- tests/integration.exp | 10 +- 4 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 crates/claude-chill/src/escape_filter.rs diff --git a/crates/claude-chill/src/escape_filter.rs b/crates/claude-chill/src/escape_filter.rs new file mode 100644 index 0000000..4bfdec9 --- /dev/null +++ b/crates/claude-chill/src/escape_filter.rs @@ -0,0 +1,504 @@ +/// Filters out terminal query escape sequences that would cause the terminal +/// to respond via stdin when replayed. +/// +/// Query sequences include: +/// - CSI c, CSI 0c, CSI >c, CSI >0c, CSI =c (Device Attributes) +/// - CSI 5n, CSI 6n, CSI ?6n (Device Status / Cursor Position Reports) +/// - OSC N;? ST (color/property queries) + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum FilterState { + #[default] + Normal, + Escape, // Saw ESC + Csi, // Saw ESC [ + CsiParam, // Saw ESC [ followed by params + CsiGt, // Saw ESC [ > + CsiGtParam, // Saw ESC [ > followed by params + CsiEq, // Saw ESC [ = + CsiQuestion, // Saw ESC [ ? + CsiQuestionParam, // Saw ESC [ ? followed by params + Osc, // Saw ESC ] + OscParam, // Inside OSC, collecting param number + OscSemicolon, // Saw ; in OSC + OscQuery, // Saw ? after ; in OSC (query sequence) + OscQuerySt, // Saw ESC in OSC query, looking for \ +} + +/// Stateful filter for terminal query sequences. +/// Maintains state across multiple filter() calls to handle sequences +/// that are split across chunk boundaries. +#[derive(Debug, Default)] +pub struct TerminalQueryFilter { + state: FilterState, + pending: Vec, +} + +impl TerminalQueryFilter { + pub fn new() -> Self { + Self { + state: FilterState::Normal, + pending: Vec::with_capacity(32), + } + } + + /// Filter terminal query sequences from input bytes. + /// Returns filtered output. Maintains state for sequences split across calls. + pub fn filter(&mut self, input: &[u8]) -> Vec { + let mut output = Vec::with_capacity(input.len()); + + for &byte in input { + match self.state { + FilterState::Normal => { + if byte == 0x1B { + self.state = FilterState::Escape; + self.pending.clear(); + self.pending.push(byte); + } else { + output.push(byte); + } + } + + FilterState::Escape => { + self.pending.push(byte); + match byte { + b'[' => self.state = FilterState::Csi, + b']' => self.state = FilterState::Osc, + _ => { + // Not a sequence we care about, emit pending + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::Csi => { + self.pending.push(byte); + match byte { + b'>' => self.state = FilterState::CsiGt, + b'=' => self.state = FilterState::CsiEq, + b'?' => self.state = FilterState::CsiQuestion, + b'c' => { + // ESC [ c - Primary DA query, discard + self.pending.clear(); + self.state = FilterState::Normal; + } + b'0'..=b'9' => self.state = FilterState::CsiParam, + _ => { + // Unknown CSI sequence, emit + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::CsiParam => { + self.pending.push(byte); + match byte { + b'0'..=b'9' | b';' => {} // Continue collecting params + b'c' => { + // ESC [ 0 c or similar - Primary DA query, discard + self.pending.clear(); + self.state = FilterState::Normal; + } + b'n' => { + // Check if this is 5n or 6n (Device Status Reports) + if is_device_status_query(&self.pending) { + // Discard query + self.pending.clear(); + } else { + output.extend_from_slice(&self.pending); + self.pending.clear(); + } + self.state = FilterState::Normal; + } + _ => { + // End of CSI sequence, emit + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::CsiGt => { + self.pending.push(byte); + match byte { + b'c' => { + // ESC [ > c - Secondary DA query, discard + self.pending.clear(); + self.state = FilterState::Normal; + } + b'0'..=b'9' => self.state = FilterState::CsiGtParam, + _ => { + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::CsiGtParam => { + self.pending.push(byte); + match byte { + b'0'..=b'9' => {} // Continue + b'c' => { + // ESC [ > 0 c - Secondary DA query, discard + self.pending.clear(); + self.state = FilterState::Normal; + } + _ => { + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::CsiEq => { + self.pending.push(byte); + match byte { + b'c' => { + // ESC [ = c - Tertiary DA query, discard + self.pending.clear(); + self.state = FilterState::Normal; + } + _ => { + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::CsiQuestion => { + self.pending.push(byte); + match byte { + b'0'..=b'9' => self.state = FilterState::CsiQuestionParam, + _ => { + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::CsiQuestionParam => { + self.pending.push(byte); + match byte { + b'0'..=b'9' | b';' => {} // Continue + b'n' => { + // ESC [ ? N n - Extended cursor position query, discard + self.pending.clear(); + self.state = FilterState::Normal; + } + _ => { + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::Osc => { + self.pending.push(byte); + match byte { + b'0'..=b'9' => self.state = FilterState::OscParam, + _ => { + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::OscParam => { + self.pending.push(byte); + match byte { + b'0'..=b'9' => {} // Continue + b';' => self.state = FilterState::OscSemicolon, + 0x07 | 0x1B => { + // End of OSC (BEL or ST), not a query + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + _ => { + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::OscSemicolon => { + self.pending.push(byte); + match byte { + b'?' => self.state = FilterState::OscQuery, + 0x07 => { + // Not a query, emit + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + 0x1B => { + // Start of ST, not a query + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + _ => { + // Some other content, not a query + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::OscQuery => { + self.pending.push(byte); + match byte { + 0x07 => { + // ESC ] N ; ? BEL - Query sequence, discard + self.pending.clear(); + self.state = FilterState::Normal; + } + 0x1B => self.state = FilterState::OscQuerySt, + _ => { + // Not a simple query, might be content starting with ? + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::OscQuerySt => { + self.pending.push(byte); + match byte { + b'\\' => { + // ESC ] N ; ? ESC \ - Query sequence, discard + self.pending.clear(); + self.state = FilterState::Normal; + } + _ => { + // Not ST, emit what we have + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + } + } + + // Don't emit pending bytes - keep them for next call + // They'll be emitted when the sequence completes + + output + } + + /// Flush any pending bytes. Call this when done filtering to get + /// any incomplete sequences that should be emitted. + pub fn flush(&mut self) -> Vec { + let result = std::mem::take(&mut self.pending); + self.state = FilterState::Normal; + result + } +} + +/// Check if pending buffer is a device status query (5n or 6n) +fn is_device_status_query(pending: &[u8]) -> bool { + // pending should be like [ESC, '[', '5', 'n'] or [ESC, '[', '6', 'n'] + if pending.len() < 4 { + return false; + } + + // Check for patterns like ESC [ 5 n or ESC [ 6 n + // The number part could be just '5' or '6' + let param_start = 2; + let param_end = pending.len() - 1; // exclude the 'n' + let param_slice = &pending[param_start..param_end]; + + // Parse the parameter + if let Ok(param_str) = std::str::from_utf8(param_slice) + && let Ok(param) = param_str.parse::() + { + return param == 5 || param == 6; + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_no_filter_normal_text() { + let mut filter = TerminalQueryFilter::new(); + let input = b"Hello, World!"; + let output = filter.filter(input); + assert_eq!(output, input.to_vec()); + } + + #[test] + fn test_no_filter_normal_escape_sequences() { + let mut filter = TerminalQueryFilter::new(); + // Color codes should pass through + let input = b"\x1b[31mRed\x1b[0m"; + let output = filter.filter(input); + assert_eq!(output, input.to_vec()); + } + + #[test] + fn test_filter_primary_da_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ c + let input = b"before\x1b[cafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_primary_da_query_with_param() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 0 c + let input = b"before\x1b[0cafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_secondary_da_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ > c + let input = b"before\x1b[>cafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_secondary_da_query_with_param() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ > 0 c + let input = b"before\x1b[>0cafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_tertiary_da_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ = c + let input = b"before\x1b[=cafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_device_status_5n() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 5 n + let input = b"before\x1b[5nafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_device_status_6n() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 6 n + let input = b"before\x1b[6nafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_extended_cursor_position() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ ? 6 n + let input = b"before\x1b[?6nafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_osc_query_with_bel() { + let mut filter = TerminalQueryFilter::new(); + // ESC ] 11 ; ? BEL + let input = b"before\x1b]11;?\x07after"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_osc_query_with_st() { + let mut filter = TerminalQueryFilter::new(); + // ESC ] 11 ; ? ESC \ + let input = b"before\x1b]11;?\x1b\\after"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_no_filter_osc_set() { + let mut filter = TerminalQueryFilter::new(); + // ESC ] 11 ; color BEL - setting color, not query + let input = b"before\x1b]11;rgb:00/00/00\x07after"; + let output = filter.filter(input); + // This should pass through since it's not a query (doesn't start with ?) + assert_eq!(output, input.to_vec()); + } + + #[test] + fn test_filter_multiple_queries() { + let mut filter = TerminalQueryFilter::new(); + let input = b"start\x1b[c\x1b]11;?\x07\x1b[6nend"; + let output = filter.filter(input); + assert_eq!(output, b"startend".to_vec()); + } + + #[test] + fn test_preserve_cursor_movement() { + let mut filter = TerminalQueryFilter::new(); + // Cursor movement should not be filtered + let input = b"\x1b[H\x1b[2J"; + let output = filter.filter(input); + assert_eq!(output, input.to_vec()); + } + + #[test] + fn test_split_sequence_query_filtered() { + let mut filter = TerminalQueryFilter::new(); + // Split ESC [ c across two calls - should still be filtered + let output1 = filter.filter(b"before\x1b["); + let output2 = filter.filter(b"cafter"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_sequence_non_query_preserved() { + let mut filter = TerminalQueryFilter::new(); + // Split ESC [ 3 1 m across two calls - should be preserved + let output1 = filter.filter(b"before\x1b[31"); + let output2 = filter.filter(b"mafter"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"\x1b[31mafter".to_vec()); + } + + #[test] + fn test_flush_incomplete_sequence() { + let mut filter = TerminalQueryFilter::new(); + // Incomplete sequence at end + let output = filter.filter(b"text\x1b["); + assert_eq!(output, b"text".to_vec()); + // Flush should emit the incomplete sequence + let flushed = filter.flush(); + assert_eq!(flushed, b"\x1b[".to_vec()); + } +} diff --git a/crates/claude-chill/src/lib.rs b/crates/claude-chill/src/lib.rs index d071126..4db9869 100644 --- a/crates/claude-chill/src/lib.rs +++ b/crates/claude-chill/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod escape_filter; pub mod escape_sequences; pub mod key_parser; pub mod line_buffer; diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index 7e405f7..cc7b86c 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -1,3 +1,4 @@ +use crate::escape_filter::TerminalQueryFilter; use crate::escape_sequences::{ ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY, CLEAR_SCREEN, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY, @@ -94,6 +95,7 @@ pub struct Proxy { child: Child, original_termios: Option, history: LineBuffer, + history_filter: TerminalQueryFilter, vt_parser: vt100::Parser, vt_prev_screen: Option, last_output_time: Option, @@ -171,6 +173,7 @@ impl Proxy { Ok(Self { history, + history_filter: TerminalQueryFilter::new(), config, pty_master: pty.master, child, @@ -299,7 +302,7 @@ impl Proxy { // 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); + self.push_to_history(data); } return self.process_output_alt_screen(data, stdout_fd); } @@ -334,7 +337,7 @@ impl Proxy { self.flush_sync_block_to_history(); self.in_sync_block = false; } else { - self.history.push_bytes(remaining); + self.push_to_history(remaining); } self.in_alternate_screen = true; let seq_len = self.alt_screen_enter_len(&data[pos + alt_pos..]); @@ -359,7 +362,7 @@ impl Proxy { debug!("process_output: SYNC_START at pos={}", pos + idx); // Add any data before SYNC_START to history if idx > 0 { - self.history.push_bytes(&data[pos..pos + idx]); + self.push_to_history(&data[pos..pos + idx]); } self.in_sync_block = true; self.sync_buffer.clear(); @@ -367,7 +370,7 @@ impl Proxy { pos += idx + SYNC_START.len(); } else { // No sync block, just add to history - self.history.push_bytes(&data[pos..]); + self.push_to_history(&data[pos..]); break; } } @@ -477,10 +480,17 @@ impl Proxy { self.history.push_bytes(CLEAR_SCREEN); self.history.push_bytes(CURSOR_HOME); } - self.history.push_bytes(&self.sync_buffer); + self.push_to_history(&self.sync_buffer.clone()); self.sync_buffer.clear(); } + /// Push data to history, filtering out terminal query sequences that would + /// cause the terminal to respond when replayed. + fn push_to_history(&mut self, data: &[u8]) { + let filtered = self.history_filter.filter(data); + self.history.push_bytes(&filtered); + } + 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(()); diff --git a/tests/integration.exp b/tests/integration.exp index 3a66cb9..d0b2b96 100644 --- a/tests/integration.exp +++ b/tests/integration.exp @@ -206,7 +206,15 @@ if {$vi_cmd eq ""} { # Start vi/vim with no config to avoid user rc issues send "$vi_cmd -u NONE\r" - sleep 1 + + # Wait for alt screen sequence before sending input + # This ensures vim has fully started and alt screen is active + # Note: \033 is octal for ESC, \[ escapes the bracket in TCL + expect { + -re "\033\\\[\\?1049h" { } + timeout { fail "$vi_cmd did not enter alt screen" } + } + sleep 0.3 # In vi, Ctrl+^ switches between buffers - should NOT trigger lookback send "\x1e"