Fix: Filter Kitty keyboard and other terminal queries from history (#23)
This commit is contained in:
2
CLAUDE.md
Normal file
2
CLAUDE.md
Normal 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
2
Cargo.lock
generated
@@ -133,7 +133,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||
|
||||
[[package]]
|
||||
name = "claude-chill"
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
||||
@@ -5,6 +5,6 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
authors = ["David Beesley"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://github.com/davidbeesley/claude-chill/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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::<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)]
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user