Fix: Filter Kitty keyboard and other terminal queries from history (#23)

This commit is contained in:
David Beesley
2026-01-24 20:57:49 -07:00
committed by GitHub
parent 420da759a7
commit e2b20dce1c
5 changed files with 382 additions and 19 deletions

2
CLAUDE.md Normal file
View File

@@ -0,0 +1,2 @@
# Version Bumping
1. Don't forget to update the version number in Cargo.toml AND in the README.md.

2
Cargo.lock generated
View File

@@ -133,7 +133,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]] [[package]]
name = "claude-chill" name = "claude-chill"
version = "0.1.1" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",

View File

@@ -5,6 +5,6 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.2" version = "0.1.3"
edition = "2024" edition = "2024"
authors = ["David Beesley"] authors = ["David Beesley"]

View File

@@ -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) [![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) [![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) ![Linux](https://img.shields.io/badge/Linux-supported-green)
![macOS](https://img.shields.io/badge/macOS-supported-green) ![macOS](https://img.shields.io/badge/macOS-supported-green)
![Windows](https://img.shields.io/badge/Windows-unsupported-red) ![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. 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 ## The Solution

View File

@@ -2,8 +2,14 @@
/// to respond via stdin when replayed. /// to respond via stdin when replayed.
/// ///
/// Query sequences include: /// Query sequences include:
/// - CSI c, CSI 0c, CSI >c, CSI >0c, CSI =c (Device Attributes) /// - CSI c, CSI 0c, CSI >c, CSI >0c, CSI =c (Device Attributes DA1/DA2/DA3)
/// - CSI 5n, CSI 6n, CSI ?6n (Device Status / Cursor Position Reports) /// - 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) /// - OSC N;? ST (color/property queries)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -13,16 +19,21 @@ enum FilterState {
Escape, // Saw ESC Escape, // Saw ESC
Csi, // Saw ESC [ Csi, // Saw ESC [
CsiParam, // Saw ESC [ followed by params CsiParam, // Saw ESC [ followed by params
CsiParamDollar, // Saw ESC [ N... $ (DECRQM prefix)
CsiGt, // Saw ESC [ > CsiGt, // Saw ESC [ >
CsiGtParam, // Saw ESC [ > followed by params CsiGtParam, // Saw ESC [ > followed by params
CsiEq, // Saw ESC [ = CsiEq, // Saw ESC [ =
CsiQuestion, // Saw ESC [ ? CsiQuestion, // Saw ESC [ ?
CsiQuestionParam, // Saw ESC [ ? followed by params CsiQuestionParam, // Saw ESC [ ? followed by params
CsiQuestionParamDollar, // Saw ESC [ ? N... $ (DECRQM private prefix)
Osc, // Saw ESC ] Osc, // Saw ESC ]
OscParam, // Inside OSC, collecting param number OscParam, // Inside OSC, collecting param number
OscSemicolon, // Saw ; in OSC OscSemicolon, // Saw ; in OSC
OscQuery, // Saw ? after ; in OSC (query sequence) OscQuery, // Saw ? after ; in OSC (query sequence)
OscQuerySt, // Saw ESC in OSC query, looking for \ 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. /// Stateful filter for terminal query sequences.
@@ -64,6 +75,7 @@ impl TerminalQueryFilter {
match byte { match byte {
b'[' => self.state = FilterState::Csi, b'[' => self.state = FilterState::Csi,
b']' => self.state = FilterState::Osc, b']' => self.state = FilterState::Osc,
b'P' => self.state = FilterState::Dcs,
_ => { _ => {
// Not a sequence we care about, emit pending // Not a sequence we care about, emit pending
output.extend_from_slice(&self.pending); output.extend_from_slice(&self.pending);
@@ -114,6 +126,20 @@ impl TerminalQueryFilter {
} }
self.state = FilterState::Normal; 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 // End of CSI sequence, emit
output.extend_from_slice(&self.pending); output.extend_from_slice(&self.pending);
@@ -131,6 +157,11 @@ impl TerminalQueryFilter {
self.pending.clear(); self.pending.clear();
self.state = FilterState::Normal; 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, b'0'..=b'9' => self.state = FilterState::CsiGtParam,
_ => { _ => {
output.extend_from_slice(&self.pending); output.extend_from_slice(&self.pending);
@@ -176,6 +207,11 @@ impl TerminalQueryFilter {
FilterState::CsiQuestion => { FilterState::CsiQuestion => {
self.pending.push(byte); self.pending.push(byte);
match 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, b'0'..=b'9' => self.state = FilterState::CsiQuestionParam,
_ => { _ => {
output.extend_from_slice(&self.pending); output.extend_from_slice(&self.pending);
@@ -194,6 +230,15 @@ impl TerminalQueryFilter {
self.pending.clear(); self.pending.clear();
self.state = FilterState::Normal; 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); output.extend_from_slice(&self.pending);
self.pending.clear(); 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 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::<u32>() {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -501,4 +672,194 @@ mod tests {
let flushed = filter.flush(); let flushed = filter.flush();
assert_eq!(flushed, b"\x1b[".to_vec()); 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());
}
} }