From e2b20dce1cce8b7301215bd2d8a7db943c00e7a4 Mon Sep 17 00:00:00 2001 From: David Beesley <37917398+davidbeesley@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:57:49 -0700 Subject: [PATCH] Fix: Filter Kitty keyboard and other terminal queries from history (#23) --- CLAUDE.md | 2 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- crates/claude-chill/src/escape_filter.rs | 391 ++++++++++++++++++++++- 5 files changed, 382 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c821d9e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +# Version Bumping +1. Don't forget to update the version number in Cargo.toml AND in the README.md. diff --git a/Cargo.lock b/Cargo.lock index 491e512..742600d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,7 +133,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "claude-chill" -version = "0.1.1" +version = "0.1.3" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index f672853..1f1ea40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,6 @@ members = [ ] [workspace.package] -version = "0.1.2" +version = "0.1.3" edition = "2024" authors = ["David Beesley"] diff --git a/README.md b/README.md index 11cd510..36ce0eb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/davidbeesley/claude-chill/actions/workflows/ci.yml/badge.svg)](https://github.com/davidbeesley/claude-chill/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -![Version](https://img.shields.io/badge/version-0.1.2-blue) +![Version](https://img.shields.io/badge/version-0.1.3-blue) ![Linux](https://img.shields.io/badge/Linux-supported-green) ![macOS](https://img.shields.io/badge/macOS-supported-green) ![Windows](https://img.shields.io/badge/Windows-unsupported-red) @@ -14,7 +14,7 @@ A PTY proxy that tames Claude Code's massive terminal updates using VT-based ren Claude Code uses synchronized output to update the terminal atomically. It wraps output in sync markers (`\x1b[?2026h` ... `\x1b[?2026l`) so the terminal renders everything at once without flicker. -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. +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, or jitters in the terminal, making for a poor user experience. ## The Solution diff --git a/crates/claude-chill/src/escape_filter.rs b/crates/claude-chill/src/escape_filter.rs index 4bfdec9..6321956 100644 --- a/crates/claude-chill/src/escape_filter.rs +++ b/crates/claude-chill/src/escape_filter.rs @@ -2,27 +2,38 @@ /// 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) +/// - CSI c, CSI 0c, CSI >c, CSI >0c, CSI =c (Device Attributes DA1/DA2/DA3) +/// - CSI 5n, CSI 6n, CSI ?6n (Device Status Reports / Cursor Position) +/// - CSI ?u, CSI ?Nu (Kitty keyboard protocol queries) +/// - CSI >q (XTVERSION query) +/// - CSI Ps $p, CSI ?Ps $p (DECRQM - Request Mode) +/// - CSI 14t, CSI 16t, CSI 18t, etc. (XTWINOPS window queries) +/// - DCS $ q ... ST (DECRQSS - Request Selection or Setting) +/// - DCS + q ... ST (XTGETTCAP - Request Termcap/Terminfo) /// - 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 \ + Escape, // Saw ESC + Csi, // Saw ESC [ + CsiParam, // Saw ESC [ followed by params + CsiParamDollar, // Saw ESC [ N... $ (DECRQM prefix) + CsiGt, // Saw ESC [ > + CsiGtParam, // Saw ESC [ > followed by params + CsiEq, // Saw ESC [ = + CsiQuestion, // Saw ESC [ ? + CsiQuestionParam, // Saw ESC [ ? followed by params + CsiQuestionParamDollar, // Saw ESC [ ? N... $ (DECRQM private prefix) + 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 \ + Dcs, // Saw ESC P (Device Control String) + DcsCollect, // Collecting DCS content + DcsEscape, // Saw ESC in DCS, looking for \ } /// Stateful filter for terminal query sequences. @@ -64,6 +75,7 @@ impl TerminalQueryFilter { match byte { b'[' => self.state = FilterState::Csi, b']' => self.state = FilterState::Osc, + b'P' => self.state = FilterState::Dcs, _ => { // Not a sequence we care about, emit pending output.extend_from_slice(&self.pending); @@ -114,6 +126,20 @@ impl TerminalQueryFilter { } self.state = FilterState::Normal; } + b't' => { + // Check if this is an XTWINOPS query (14t, 16t, 18t, etc.) + if is_window_query(&self.pending) { + self.pending.clear(); + } else { + output.extend_from_slice(&self.pending); + self.pending.clear(); + } + self.state = FilterState::Normal; + } + b'$' => { + // Possible DECRQM prefix + self.state = FilterState::CsiParamDollar; + } _ => { // End of CSI sequence, emit output.extend_from_slice(&self.pending); @@ -131,6 +157,11 @@ impl TerminalQueryFilter { self.pending.clear(); self.state = FilterState::Normal; } + b'q' => { + // ESC [ > q - XTVERSION query, discard + self.pending.clear(); + self.state = FilterState::Normal; + } b'0'..=b'9' => self.state = FilterState::CsiGtParam, _ => { output.extend_from_slice(&self.pending); @@ -176,6 +207,11 @@ impl TerminalQueryFilter { FilterState::CsiQuestion => { self.pending.push(byte); match byte { + b'u' => { + // ESC [ ? u - Kitty keyboard protocol query, discard + self.pending.clear(); + self.state = FilterState::Normal; + } b'0'..=b'9' => self.state = FilterState::CsiQuestionParam, _ => { output.extend_from_slice(&self.pending); @@ -194,6 +230,15 @@ impl TerminalQueryFilter { self.pending.clear(); self.state = FilterState::Normal; } + b'u' => { + // ESC [ ? N u - Kitty keyboard protocol query, discard + self.pending.clear(); + self.state = FilterState::Normal; + } + b'$' => { + // Possible DECRQM private mode prefix + self.state = FilterState::CsiQuestionParamDollar; + } _ => { output.extend_from_slice(&self.pending); self.pending.clear(); @@ -292,6 +337,86 @@ impl TerminalQueryFilter { } } } + + FilterState::CsiParamDollar => { + self.pending.push(byte); + match byte { + b'p' => { + // ESC [ Ps $ p - DECRQM (Request Mode), discard + self.pending.clear(); + self.state = FilterState::Normal; + } + _ => { + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::CsiQuestionParamDollar => { + self.pending.push(byte); + match byte { + b'p' => { + // ESC [ ? Ps $ p - DECRQM private (Request Mode), discard + self.pending.clear(); + self.state = FilterState::Normal; + } + _ => { + output.extend_from_slice(&self.pending); + self.pending.clear(); + self.state = FilterState::Normal; + } + } + } + + FilterState::Dcs => { + self.pending.push(byte); + match byte { + b'$' | b'+' => { + // ESC P $ or ESC P + - start of query (DECRQSS or XTGETTCAP) + self.state = FilterState::DcsCollect; + } + 0x1B => { + // ESC in DCS - might be ST + self.state = FilterState::DcsEscape; + } + _ => { + // Other DCS content, collect but we'll discard queries + self.state = FilterState::DcsCollect; + } + } + } + + FilterState::DcsCollect => { + self.pending.push(byte); + if byte == 0x1B { + // ESC in DCS - looking for ST + self.state = FilterState::DcsEscape; + } + } + + FilterState::DcsEscape => { + self.pending.push(byte); + match byte { + b'\\' => { + // ESC \ - ST, end of DCS + // Check if this was a query (starts with $ or +) + if is_dcs_query(&self.pending) { + // Discard query + self.pending.clear(); + } else { + output.extend_from_slice(&self.pending); + self.pending.clear(); + } + self.state = FilterState::Normal; + } + _ => { + // Not ST, continue collecting + self.state = FilterState::DcsCollect; + } + } + } } } @@ -333,6 +458,52 @@ fn is_device_status_query(pending: &[u8]) -> bool { false } +fn is_window_query(pending: &[u8]) -> bool { + // XTWINOPS queries: ESC [ Ps t where Ps is a query operation + // Query operations that cause terminal to respond: + // 11 - Report window state (iconified/normal) + // 13 - Report window position + // 14 - Report window size in pixels + // 15 - Report screen size in pixels + // 16 - Report character cell size in pixels + // 18 - Report window size in characters + // 19 - Report screen size in characters + // 20 - Report icon label + // 21 - Report window title + if pending.len() < 4 { + return false; + } + + let param_start = 2; + let param_end = pending.len() - 1; // exclude the 't' + let param_slice = &pending[param_start..param_end]; + + if let Ok(param_str) = std::str::from_utf8(param_slice) { + // Handle single parameter or first parameter before semicolon + let first_param = param_str.split(';').next().unwrap_or(""); + if let Ok(param) = first_param.parse::() { + return matches!(param, 11 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 21); + } + } + + false +} + +fn is_dcs_query(pending: &[u8]) -> bool { + // DCS queries start with ESC P followed by $ or + + // ESC P $ q ... ESC \ - DECRQSS (Request Selection or Setting) + // ESC P + q ... ESC \ - XTGETTCAP (Request Termcap/Terminfo) + if pending.len() < 4 { + return false; + } + + // Check for ESC P $ or ESC P + + pending.len() >= 3 + && pending[0] == 0x1B + && pending[1] == b'P' + && (pending[2] == b'$' || pending[2] == b'+') +} + #[cfg(test)] mod tests { use super::*; @@ -501,4 +672,194 @@ mod tests { let flushed = filter.flush(); assert_eq!(flushed, b"\x1b[".to_vec()); } + + #[test] + fn test_filter_kitty_keyboard_query_simple() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ ? u - Kitty keyboard protocol query + let input = b"before\x1b[?uafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_kitty_keyboard_query_with_param() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ ? 1 u - Kitty keyboard query with flags + let input = b"before\x1b[?1uafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_xtversion_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ > q - XTVERSION query + let input = b"before\x1b[>qafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_decrqm_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 4 $ p - DECRQM (Request Mode) + let input = b"before\x1b[4$pafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_decrqm_private_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ ? 1 $ p - DECRQM private (Request Mode) + let input = b"before\x1b[?1$pafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_xtwinops_window_size_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 14 t - Report window size in pixels + let input = b"before\x1b[14tafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_xtwinops_title_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 21 t - Report window title + let input = b"before\x1b[21tafter"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_no_filter_xtwinops_non_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 1 t - De-iconify window (not a query) + let input = b"before\x1b[1tafter"; + let output = filter.filter(input); + assert_eq!(output, input.to_vec()); + } + + #[test] + fn test_filter_dcs_decrqss() { + let mut filter = TerminalQueryFilter::new(); + // ESC P $ q m ESC \ - DECRQSS query for SGR + let input = b"before\x1bP$qm\x1b\\after"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_filter_dcs_xtgettcap() { + let mut filter = TerminalQueryFilter::new(); + // ESC P + q 544e ESC \ - XTGETTCAP query + let input = b"before\x1bP+q544e\x1b\\after"; + let output = filter.filter(input); + assert_eq!(output, b"beforeafter".to_vec()); + } + + #[test] + fn test_split_kitty_keyboard_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ ? u split across reads + let output1 = filter.filter(b"before\x1b["); + let output2 = filter.filter(b"?uafter"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_kitty_keyboard_query_with_param() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ ? 1 u split after the digit + let output1 = filter.filter(b"before\x1b[?1"); + let output2 = filter.filter(b"uafter"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_xtversion_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ > q split after > + let output1 = filter.filter(b"before\x1b[>"); + let output2 = filter.filter(b"qafter"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_decrqm_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 4 $ p split after $ + let output1 = filter.filter(b"before\x1b[4$"); + let output2 = filter.filter(b"pafter"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_decrqm_private_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ ? 1 $ p split after $ + let output1 = filter.filter(b"before\x1b[?1$"); + let output2 = filter.filter(b"pafter"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_xtwinops_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 14 t split after 1 + let output1 = filter.filter(b"before\x1b[1"); + let output2 = filter.filter(b"4tafter"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_dcs_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC P $ q m ESC \ split after ESC P + let output1 = filter.filter(b"before\x1bP"); + let output2 = filter.filter(b"$qm\x1b\\after"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_dcs_query_at_st() { + let mut filter = TerminalQueryFilter::new(); + // ESC P $ q m ESC \ split before ST + let output1 = filter.filter(b"before\x1bP$qm\x1b"); + let output2 = filter.filter(b"\\after"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_osc_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC ] 11 ; ? BEL split after ; + let output1 = filter.filter(b"before\x1b]11;"); + let output2 = filter.filter(b"?\x07after"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } + + #[test] + fn test_split_device_status_query() { + let mut filter = TerminalQueryFilter::new(); + // ESC [ 6 n split after 6 + let output1 = filter.filter(b"before\x1b[6"); + let output2 = filter.filter(b"nafter"); + assert_eq!(output1, b"before".to_vec()); + assert_eq!(output2, b"after".to_vec()); + } }