diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5fa753..1c44ef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,21 @@ env: CARGO_TERM_COLOR: always jobs: + version-badge: + name: Version Badge + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check version badge matches Cargo.toml + run: | + CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + BADGE_VERSION=$(grep -oP 'badge/version-\K[0-9]+\.[0-9]+\.[0-9]+' README.md | head -1) + if [ "$CARGO_VERSION" != "$BADGE_VERSION" ]; then + echo "Version mismatch: Cargo.toml=$CARGO_VERSION README badge=$BADGE_VERSION" + exit 1 + fi + echo "Version badge matches: $CARGO_VERSION" + check: name: Check runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 469ea13..f672853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,6 @@ members = [ ] [workspace.package] -version = "0.1.1" +version = "0.1.2" edition = "2024" authors = ["David Beesley"] diff --git a/README.md b/README.md index f211687..6c68390 100644 --- a/README.md +++ b/README.md @@ -2,6 +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) ![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) @@ -116,6 +117,12 @@ auto_lookback_timeout_ms = 15000 # Auto-lookback after 15s idle (0 to disable) Note: History is cleared on full screen redraws, so lookback shows output since Claude's last full render. +### Kitty Keyboard Protocol + +Modern terminals like Kitty, Ghostty, and WezTerm support the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) which encodes keys differently than legacy terminals. + +claude-chill automatically tracks Kitty protocol state by monitoring the escape sequences passing through the proxy. When Claude Code enables Kitty mode, claude-chill switches to expecting Kitty-encoded key sequences for the lookback key. When Claude Code disables it, claude-chill switches back to legacy mode. This happens transparently with no configuration needed. + ### Key Format `[modifier][key]` - Examples: `[f12]`, `[ctrl][g]`, `[ctrl][shift][j]` diff --git a/crates/claude-chill/src/bin.rs b/crates/claude-chill/src/bin.rs index 8a6c405..234759d 100644 --- a/crates/claude-chill/src/bin.rs +++ b/crates/claude-chill/src/bin.rs @@ -4,6 +4,7 @@ use clap::Parser; use claude_chill::config::Config; use claude_chill::key_parser; use claude_chill::proxy::{Proxy, ProxyConfig}; +use log::debug; use std::process::ExitCode; fn main() -> ExitCode { @@ -33,15 +34,25 @@ fn main() -> ExitCode { .clone() .unwrap_or_else(|| config.lookback_key.clone()); - let lookback_sequence = match key_parser::parse(&lookback_key) { - Ok(key) => key.to_escape_sequence(), + let (lookback_sequence_legacy, lookback_sequence_kitty) = match key_parser::parse(&lookback_key) + { + Ok(key) => { + let legacy = key.to_escape_sequence(); + let kitty = key.to_kitty_sequence().unwrap_or_else(|| legacy.clone()); + (legacy, kitty) + } Err(e) => { eprintln!("Invalid lookback key '{}': {}", lookback_key, e); eprintln!("Using default: [ctrl][6]"); - config.lookback_sequence() + (vec![0x1E], b"\x1b[54;5u".to_vec()) } }; + debug!( + "Lookback sequences: legacy={:?} kitty={:?}", + lookback_sequence_legacy, lookback_sequence_kitty + ); + let auto_lookback_timeout_ms = cli .auto_lookback_timeout .unwrap_or(config.auto_lookback_timeout_ms); @@ -49,7 +60,8 @@ fn main() -> ExitCode { let proxy_config = ProxyConfig { max_history_lines: history_lines, lookback_key, - lookback_sequence, + lookback_sequence_legacy, + lookback_sequence_kitty, auto_lookback_timeout_ms, }; diff --git a/crates/claude-chill/src/key_parser.rs b/crates/claude-chill/src/key_parser.rs index dcb8703..8ae1090 100644 --- a/crates/claude-chill/src/key_parser.rs +++ b/crates/claude-chill/src/key_parser.rs @@ -61,6 +61,29 @@ impl KeyCombination { pub fn to_escape_sequence(&self) -> Vec { key_to_escape_sequence(&self.code, &self.modifiers) } + + pub fn to_kitty_sequence(&self) -> Option> { + let codepoint = match &self.code { + KeyCode::Char(c) => *c as u32, + KeyCode::Esc => 27, + KeyCode::Enter => 13, + KeyCode::Tab => 9, + KeyCode::Backspace => 127, + KeyCode::Space => 32, + _ => return None, + }; + + let modifier = 1 + + if self.modifiers.shift { 1 } else { 0 } + + if self.modifiers.alt { 2 } else { 0 } + + if self.modifiers.ctrl { 4 } else { 0 }; + + if modifier == 1 { + Some(format!("\x1b[{}u", codepoint).into_bytes()) + } else { + Some(format!("\x1b[{};{}u", codepoint, modifier).into_bytes()) + } + } } impl fmt::Display for KeyCombination { diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index b5ec3ca..6bb3b03 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -48,7 +48,8 @@ extern "C" fn handle_sigterm(_: libc::c_int) { pub struct ProxyConfig { pub max_history_lines: usize, pub lookback_key: String, - pub lookback_sequence: Vec, + pub lookback_sequence_legacy: Vec, + pub lookback_sequence_kitty: Vec, pub auto_lookback_timeout_ms: u64, } @@ -57,7 +58,8 @@ impl Default for ProxyConfig { Self { max_history_lines: 100_000, lookback_key: "[ctrl][6]".to_string(), - lookback_sequence: vec![0x1E], + lookback_sequence_legacy: vec![0x1E], + lookback_sequence_kitty: b"\x1b[54;5u".to_vec(), auto_lookback_timeout_ms: 15000, } } @@ -107,6 +109,10 @@ pub struct Proxy { in_sync_block: bool, in_lookback_mode: bool, in_alternate_screen: bool, + kitty_mode_supported: bool, + kitty_mode_enabled: bool, + kitty_input_buffer: Vec, + kitty_output_buffer: Vec, vt_render_pending: bool, lookback_cache: Vec, lookback_input_buffer: Vec, @@ -121,6 +127,72 @@ pub struct Proxy { alt_screen_exit_legacy_finder: memmem::Finder<'static>, } +const MAX_KITTY_SEQ_LEN: usize = 16; + +fn is_complete_kitty_output_seq(data: &[u8]) -> bool { + if data.len() < 4 || data[0] != 0x1b || data[1] != b'[' { + return false; + } + if data[2] == b'<' && data[3] == b'u' { + return true; + } + if data[2] == b'>' { + let mut j = 3; + while j < data.len() && data[j].is_ascii_digit() { + j += 1; + } + if j > 3 && j < data.len() && data[j] == b'u' { + return true; + } + } + false +} + +fn is_complete_kitty_input_seq(data: &[u8]) -> bool { + if data.len() < 5 || data[0] != 0x1b || data[1] != b'[' || data[2] != b'?' { + return false; + } + let mut j = 3; + while j < data.len() && data[j].is_ascii_digit() { + j += 1; + } + j > 3 && j < data.len() && data[j] == b'u' +} + +fn kitty_output_split_point(buffer: &[u8]) -> usize { + let last_esc = buffer.iter().rposition(|&b| b == 0x1b); + + match last_esc { + None => buffer.len(), + Some(pos) if pos + MAX_KITTY_SEQ_LEN <= buffer.len() => buffer.len(), + Some(pos) => { + let tail = &buffer[pos..]; + if is_complete_kitty_output_seq(tail) { + buffer.len() + } else { + pos + } + } + } +} + +fn kitty_input_split_point(buffer: &[u8]) -> usize { + let last_esc = buffer.iter().rposition(|&b| b == 0x1b); + + match last_esc { + None => buffer.len(), + Some(pos) if pos + MAX_KITTY_SEQ_LEN <= buffer.len() => buffer.len(), + Some(pos) => { + let tail = &buffer[pos..]; + if is_complete_kitty_input_seq(tail) { + buffer.len() + } else { + pos + } + } + } +} + impl Proxy { pub fn spawn(command: &str, args: &[&str], config: ProxyConfig) -> Result { let winsize = get_terminal_size()?; @@ -191,6 +263,10 @@ impl Proxy { in_sync_block: false, in_lookback_mode: false, in_alternate_screen: false, + kitty_mode_supported: false, + kitty_mode_enabled: false, + kitty_input_buffer: Vec::with_capacity(16), + kitty_output_buffer: Vec::with_capacity(16), vt_render_pending: false, lookback_cache: Vec::new(), lookback_input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY), @@ -302,6 +378,9 @@ impl Proxy { feed_vt ); + // Track Kitty keyboard protocol state from output + self.update_kitty_mode_from_output(data); + if self.in_alternate_screen { // Still feed VT and history while in alt screen so they stay in sync if feed_vt { @@ -466,6 +545,93 @@ impl Proxy { } } + fn update_kitty_mode_from_output(&mut self, data: &[u8]) { + self.kitty_output_buffer.extend_from_slice(data); + + let split_point = kitty_output_split_point(&self.kitty_output_buffer); + + if split_point > 0 { + let to_parse: Vec = self.kitty_output_buffer.drain(..split_point).collect(); + self.parse_kitty_output(&to_parse); + } + + // Safety cap on carryover buffer + if self.kitty_output_buffer.len() > 64 { + let overflow: Vec = self.kitty_output_buffer.drain(..).collect(); + self.parse_kitty_output(&overflow); + } + } + + fn parse_kitty_output(&mut self, data: &[u8]) { + let mut i = 0; + while i < data.len() { + if data[i] == 0x1b && i + 3 < data.len() && data[i + 1] == b'[' { + if data[i + 2] == b'<' && data[i + 3] == b'u' { + debug!("Kitty keyboard protocol disabled"); + self.kitty_mode_enabled = false; + i += 4; + continue; + } + if data[i + 2] == b'>' && self.kitty_mode_supported { + let mut j = i + 3; + while j < data.len() && data[j].is_ascii_digit() { + j += 1; + } + if j > i + 3 && j < data.len() && data[j] == b'u' { + debug!("Kitty keyboard protocol enabled"); + self.kitty_mode_enabled = true; + i = j + 1; + continue; + } + } + } + i += 1; + } + } + + fn update_kitty_support_from_input(&mut self, data: &[u8]) { + if self.kitty_mode_supported { + return; + } + + self.kitty_input_buffer.extend_from_slice(data); + + let split_point = kitty_input_split_point(&self.kitty_input_buffer); + + if split_point > 0 { + let to_parse: Vec = self.kitty_input_buffer.drain(..split_point).collect(); + self.parse_kitty_input(&to_parse); + } + + // Safety cap on carryover buffer + if self.kitty_input_buffer.len() > 64 { + let overflow: Vec = self.kitty_input_buffer.drain(..).collect(); + self.parse_kitty_input(&overflow); + } + } + + fn parse_kitty_input(&mut self, data: &[u8]) { + if self.kitty_mode_supported { + return; + } + + let mut i = 0; + while i < data.len() { + if data[i] == 0x1b && i + 4 < data.len() && data[i + 1] == b'[' && data[i + 2] == b'?' { + let mut j = i + 3; + while j < data.len() && data[j].is_ascii_digit() { + j += 1; + } + if j > i + 3 && j < data.len() && data[j] == b'u' { + debug!("Kitty keyboard protocol supported (saw query response)"); + self.kitty_mode_supported = true; + return; + } + } + i += 1; + } + } + 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(); @@ -641,10 +807,18 @@ impl Proxy { fn process_input(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> { self.last_stdin_time = Some(Instant::now()); + self.update_kitty_support_from_input(data); + if self.in_alternate_screen { return write_all(&self.pty_master, data); } + let lookback_sequence = if self.kitty_mode_enabled { + self.config.lookback_sequence_kitty.clone() + } else { + self.config.lookback_sequence_legacy.clone() + }; + for &byte in data { if self.in_lookback_mode && byte == 0x03 { self.lookback_input_buffer.clear(); @@ -655,13 +829,13 @@ impl Proxy { let lookback_action = self.check_sequence_match( byte, &mut self.lookback_input_buffer.clone(), - &self.config.lookback_sequence.clone(), + &lookback_sequence, ); 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(); + if self.lookback_input_buffer.len() > lookback_sequence.len() { + let excess = self.lookback_input_buffer.len() - lookback_sequence.len(); self.lookback_input_buffer.drain(..excess); } @@ -677,11 +851,7 @@ impl Proxy { } SequenceMatch::Partial => {} SequenceMatch::None => { - if !self - .config - .lookback_sequence - .starts_with(&self.lookback_input_buffer) - { + if !lookback_sequence.starts_with(&self.lookback_input_buffer) { self.lookback_input_buffer.clear(); } } @@ -901,3 +1071,217 @@ fn write_all(fd: &F, data: &[u8]) -> Result<()> { fn nix_read(fd: &F, buf: &mut [u8]) -> Result { read(fd.as_fd(), buf) } + +#[cfg(test)] +mod tests { + use super::*; + + // Tests for is_complete_kitty_output_seq + + #[test] + fn test_output_seq_disable_complete() { + assert!(is_complete_kitty_output_seq(b"\x1b[1u")); + } + + #[test] + fn test_output_seq_enable_multi_digit() { + assert!(is_complete_kitty_output_seq(b"\x1b[>31u")); + } + + #[test] + fn test_output_seq_enable_with_trailing() { + assert!(is_complete_kitty_output_seq(b"\x1b[>1umore")); + } + + #[test] + fn test_output_seq_incomplete_esc_only() { + assert!(!is_complete_kitty_output_seq(b"\x1b")); + } + + #[test] + fn test_output_seq_incomplete_esc_bracket() { + assert!(!is_complete_kitty_output_seq(b"\x1b[")); + } + + #[test] + fn test_output_seq_incomplete_disable() { + assert!(!is_complete_kitty_output_seq(b"\x1b[<")); + } + + #[test] + fn test_output_seq_incomplete_enable_no_digit() { + assert!(!is_complete_kitty_output_seq(b"\x1b[>")); + } + + #[test] + fn test_output_seq_incomplete_enable_digit_no_u() { + assert!(!is_complete_kitty_output_seq(b"\x1b[>1")); + } + + #[test] + fn test_output_seq_not_kitty() { + assert!(!is_complete_kitty_output_seq(b"\x1b[H")); + assert!(!is_complete_kitty_output_seq(b"\x1b[2J")); + } + + #[test] + fn test_output_seq_empty() { + assert!(!is_complete_kitty_output_seq(b"")); + } + + #[test] + fn test_output_seq_no_esc() { + assert!(!is_complete_kitty_output_seq(b"hello")); + } + + // Tests for is_complete_kitty_input_seq + + #[test] + fn test_input_seq_query_response_complete() { + assert!(is_complete_kitty_input_seq(b"\x1b[?1u")); + } + + #[test] + fn test_input_seq_query_response_multi_digit() { + assert!(is_complete_kitty_input_seq(b"\x1b[?31u")); + } + + #[test] + fn test_input_seq_query_response_with_trailing() { + assert!(is_complete_kitty_input_seq(b"\x1b[?1umore")); + } + + #[test] + fn test_input_seq_incomplete_esc_only() { + assert!(!is_complete_kitty_input_seq(b"\x1b")); + } + + #[test] + fn test_input_seq_incomplete_esc_bracket() { + assert!(!is_complete_kitty_input_seq(b"\x1b[")); + } + + #[test] + fn test_input_seq_incomplete_esc_bracket_question() { + assert!(!is_complete_kitty_input_seq(b"\x1b[?")); + } + + #[test] + fn test_input_seq_incomplete_no_u() { + assert!(!is_complete_kitty_input_seq(b"\x1b[?1")); + } + + #[test] + fn test_input_seq_not_kitty() { + assert!(!is_complete_kitty_input_seq(b"\x1b[H")); + assert!(!is_complete_kitty_input_seq(b"\x1b[?25h")); + } + + #[test] + fn test_input_seq_empty() { + assert!(!is_complete_kitty_input_seq(b"")); + } + + // Tests for kitty_output_split_point + + #[test] + fn test_output_split_no_esc() { + assert_eq!(kitty_output_split_point(b"hello world"), 11); + } + + #[test] + fn test_output_split_empty() { + assert_eq!(kitty_output_split_point(b""), 0); + } + + #[test] + fn test_output_split_complete_disable() { + // Complete sequence - split at end (process all) + assert_eq!(kitty_output_split_point(b"\x1b[1u"), 5); + } + + #[test] + fn test_output_split_incomplete_esc_only() { + // Just ESC - incomplete, split at 0 (keep all) + assert_eq!(kitty_output_split_point(b"\x1b"), 0); + } + + #[test] + fn test_output_split_incomplete_enable() { + // Incomplete enable - split at ESC position + assert_eq!(kitty_output_split_point(b"\x1b[>"), 0); + assert_eq!(kitty_output_split_point(b"\x1b[>1"), 0); + } + + #[test] + fn test_output_split_data_then_incomplete() { + // Data followed by incomplete - split before ESC + let data = b"hello\x1b[>"; + assert_eq!(kitty_output_split_point(data), 5); + } + + #[test] + fn test_output_split_complete_then_incomplete() { + // Complete disable followed by incomplete enable + let data = b"\x1b["; + assert_eq!(kitty_output_split_point(data), 4); + } + + #[test] + fn test_output_split_esc_far_from_end() { + // ESC more than 16 bytes from end - process all + let mut data = b"\x1b[