From 91e0516fe93114ee92b3704dd613f00b933e1e60 Mon Sep 17 00:00:00 2001 From: David Beesley <37917398+davidbeesley@users.noreply.github.com> Date: Sun, 18 Jan 2026 06:17:14 +1300 Subject: [PATCH] Fix/key sequences (#2) * Change default hotkey to Ctrl+6 and fix ctrl+punctuation handling * Disable lookback mode when alternate screen is active --- README.md | 8 +- crates/claude-chill/src/config.rs | 6 +- crates/claude-chill/src/escape_sequences.rs | 5 ++ crates/claude-chill/src/key_parser.rs | 72 +++++++++++++---- crates/claude-chill/src/proxy.rs | 88 ++++++++++++++++++++- 5 files changed, 158 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f3277ae..ee3539a 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ claude-chill -l 50 -- claude # Set max lines to 50 ## Lookback Mode -Press `Ctrl+Shift+J` (or your configured key) to enter lookback mode: +Press `Ctrl+6` (or your configured key) to enter lookback mode: 1. **Claude pauses** - Output from Claude is cached, input is blocked 2. **History dumps** - The full history buffer is written to your terminal @@ -68,7 +68,7 @@ Create `~/.config/claude-chill.toml`: ```toml max_lines = 100 # Lines shown per sync block history_lines = 100000 # Lines stored for lookback -lookback_key = "[ctrl][shift][j]" +lookback_key = "[ctrl][6]" ``` ### Key Format @@ -79,6 +79,10 @@ Modifiers: `[ctrl]`, `[shift]`, `[alt]` Keys: `[a]`-`[z]`, `[f1]`-`[f12]`, `[pageup]`, `[pagedown]`, `[home]`, `[end]`, `[enter]`, `[tab]`, `[space]`, `[esc]` +### Why Ctrl+6? + +`Ctrl+6` sends 0x1E, a control character not frequently used by terminals, signals, or shells. Avoid `Ctrl+letter` hotkeys - terminals can't distinguish `Ctrl+J` from `Ctrl+Shift+J`. + ## Disclaimer This tool was developed for personal convenience on Debian Linux. It works for me, but it hasn't been extensively tested across different terminals, operating systems, or edge cases. Don't use it to send anyone to space, perform surgery, or run critical infrastructure. If it breaks, you get to keep both pieces. diff --git a/crates/claude-chill/src/config.rs b/crates/claude-chill/src/config.rs index 8128776..4a711ed 100644 --- a/crates/claude-chill/src/config.rs +++ b/crates/claude-chill/src/config.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use std::fs; use std::path::PathBuf; -const DEFAULT_LOOKBACK_KEY: &str = "[ctrl][shift][j]"; +const DEFAULT_LOOKBACK_KEY: &str = "[ctrl][6]"; #[derive(Debug, Clone, Deserialize)] #[serde(default)] @@ -89,12 +89,12 @@ mod tests { let config = Config::default(); assert_eq!(config.max_lines, 100); assert_eq!(config.history_lines, 100_000); - assert_eq!(config.lookback_key, "[ctrl][shift][j]"); + assert_eq!(config.lookback_key, "[ctrl][6]"); } #[test] fn test_default_lookback_sequence() { let config = Config::default(); - assert_eq!(config.lookback_sequence(), vec![0x0A]); + assert_eq!(config.lookback_sequence(), vec![0x1E]); } } diff --git a/crates/claude-chill/src/escape_sequences.rs b/crates/claude-chill/src/escape_sequences.rs index 0061010..f6121ec 100644 --- a/crates/claude-chill/src/escape_sequences.rs +++ b/crates/claude-chill/src/escape_sequences.rs @@ -4,6 +4,11 @@ pub const CLEAR_SCREEN: &[u8] = b"\x1b[2J"; pub const CLEAR_SCROLLBACK: &[u8] = b"\x1b[3J"; pub const CURSOR_HOME: &[u8] = b"\x1b[H"; +pub const ALT_SCREEN_ENTER: &[u8] = b"\x1b[?1049h"; +pub const ALT_SCREEN_EXIT: &[u8] = b"\x1b[?1049l"; +pub const ALT_SCREEN_ENTER_LEGACY: &[u8] = b"\x1b[?47h"; +pub const ALT_SCREEN_EXIT_LEGACY: &[u8] = b"\x1b[?47l"; + pub const SYNC_BUFFER_CAPACITY: usize = 1024 * 1024; pub const OUTPUT_BUFFER_CAPACITY: usize = 32768; pub const INPUT_BUFFER_CAPACITY: usize = 64; diff --git a/crates/claude-chill/src/key_parser.rs b/crates/claude-chill/src/key_parser.rs index 92d5f29..dcb8703 100644 --- a/crates/claude-chill/src/key_parser.rs +++ b/crates/claude-chill/src/key_parser.rs @@ -241,23 +241,43 @@ fn key_to_escape_sequence(code: &KeyCode, modifiers: &Modifiers) -> Vec { b" ".to_vec() } } - KeyCode::Char(c) => { - if modifiers.ctrl && c.is_ascii_alphabetic() { - let ctrl_char = (c.to_ascii_uppercase() as u8) - b'A' + 1; - if modifiers.alt { - vec![0x1b, ctrl_char] - } else { - vec![ctrl_char] - } - } else if modifiers.alt { - vec![0x1b, *c as u8] - } else if modifiers.shift { - vec![c.to_ascii_uppercase() as u8] + KeyCode::Char(c) => char_to_escape_sequence(*c, modifiers), + } +} + +fn char_to_escape_sequence(c: char, modifiers: &Modifiers) -> Vec { + if modifiers.ctrl { + let ctrl_byte = match c { + 'a'..='z' => Some((c.to_ascii_uppercase() as u8) - b'A' + 1), + 'A'..='Z' => Some((c as u8) - b'A' + 1), + '@' => Some(0x00), + '[' => Some(0x1B), + '\\' => Some(0x1C), + ']' => Some(0x1D), + '^' | '6' => Some(0x1E), + '_' | '7' => Some(0x1F), + '2' => Some(0x00), + '3' => Some(0x1B), + '4' => Some(0x1C), + '5' => Some(0x1D), + '8' => Some(0x7F), + _ => None, + }; + if let Some(byte) = ctrl_byte { + return if modifiers.alt { + vec![0x1b, byte] } else { - vec![*c as u8] - } + vec![byte] + }; } } + if modifiers.alt { + vec![0x1b, c as u8] + } else if modifiers.shift { + vec![c.to_ascii_uppercase() as u8] + } else { + vec![c as u8] + } } fn modified_key(base: &[u8], modifier: u8) -> Vec { @@ -383,4 +403,28 @@ mod tests { let result = parse("[ctrl][shift]"); assert!(result.is_err()); } + + #[test] + fn test_ctrl_caret() { + let key = parse("[ctrl][^]").unwrap(); + assert_eq!(key.to_escape_sequence(), vec![0x1E]); + } + + #[test] + fn test_ctrl_6_same_as_ctrl_caret() { + let key = parse("[ctrl][6]").unwrap(); + assert_eq!(key.to_escape_sequence(), vec![0x1E]); + } + + #[test] + fn test_ctrl_bracket() { + let key = parse("[ctrl][[]").unwrap(); + assert_eq!(key.to_escape_sequence(), vec![0x1B]); + } + + #[test] + fn test_ctrl_backslash() { + let key = parse("[ctrl][\\]").unwrap(); + assert_eq!(key.to_escape_sequence(), vec![0x1C]); + } } diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index d59de21..c1736e1 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -1,4 +1,5 @@ use crate::escape_sequences::{ + ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY, CLEAR_SCREEN, CLEAR_SCROLLBACK, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY, SYNC_END, SYNC_START, }; @@ -43,8 +44,8 @@ impl Default for ProxyConfig { Self { max_output_lines: 100, max_history_lines: 100_000, - lookback_key: "[ctrl][shift][j]".to_string(), - lookback_sequence: vec![0x0A], + lookback_key: "[ctrl][6]".to_string(), + lookback_sequence: vec![0x1E], } } } @@ -58,6 +59,7 @@ pub struct Proxy { sync_buffer: Vec, in_sync_block: bool, in_lookback_mode: bool, + in_alternate_screen: bool, lookback_cache: Vec, input_buffer: Vec, output_buffer: Vec, @@ -65,6 +67,10 @@ pub struct Proxy { sync_end_finder: memmem::Finder<'static>, clear_screen_finder: memmem::Finder<'static>, cursor_home_finder: memmem::Finder<'static>, + alt_screen_enter_finder: memmem::Finder<'static>, + alt_screen_exit_finder: memmem::Finder<'static>, + alt_screen_enter_legacy_finder: memmem::Finder<'static>, + alt_screen_exit_legacy_finder: memmem::Finder<'static>, } impl Proxy { @@ -117,6 +123,7 @@ impl Proxy { sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY), in_sync_block: false, in_lookback_mode: false, + in_alternate_screen: false, lookback_cache: Vec::new(), input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY), output_buffer: Vec::with_capacity(OUTPUT_BUFFER_CAPACITY), @@ -124,6 +131,10 @@ impl Proxy { sync_end_finder: memmem::Finder::new(SYNC_END), clear_screen_finder: memmem::Finder::new(CLEAR_SCREEN), cursor_home_finder: memmem::Finder::new(CURSOR_HOME), + alt_screen_enter_finder: memmem::Finder::new(ALT_SCREEN_ENTER), + alt_screen_exit_finder: memmem::Finder::new(ALT_SCREEN_EXIT), + alt_screen_enter_legacy_finder: memmem::Finder::new(ALT_SCREEN_ENTER_LEGACY), + alt_screen_exit_legacy_finder: memmem::Finder::new(ALT_SCREEN_EXIT_LEGACY), }) } @@ -201,6 +212,10 @@ impl Proxy { } fn process_output(&mut self, data: &[u8], stdout_fd: i32) -> Result<()> { + if self.in_alternate_screen { + return self.process_output_alt_screen(data, stdout_fd); + } + if self.in_lookback_mode { self.lookback_cache.extend_from_slice(data); return Ok(()); @@ -209,6 +224,22 @@ impl Proxy { let mut pos = 0; while pos < data.len() { + if let Some(alt_pos) = self.find_alt_screen_enter(&data[pos..]) { + if self.in_sync_block { + self.sync_buffer + .extend_from_slice(&data[pos..pos + alt_pos]); + write_all_raw(stdout_fd, &self.sync_buffer)?; + self.sync_buffer.clear(); + self.in_sync_block = false; + } else if alt_pos > 0 { + write_all_raw(stdout_fd, &data[pos..pos + alt_pos])?; + } + self.in_alternate_screen = true; + let seq_len = self.alt_screen_enter_len(&data[pos + alt_pos..]); + write_all_raw(stdout_fd, &data[pos + alt_pos..pos + alt_pos + seq_len])?; + return self.process_output_alt_screen(&data[pos + alt_pos + seq_len..], stdout_fd); + } + if self.in_sync_block { if let Some(idx) = self.sync_end_finder.find(&data[pos..]) { self.sync_buffer.extend_from_slice(&data[pos..pos + idx]); @@ -237,6 +268,55 @@ impl Proxy { Ok(()) } + fn process_output_alt_screen(&mut self, data: &[u8], stdout_fd: i32) -> Result<()> { + if let Some(exit_pos) = self.find_alt_screen_exit(data) { + write_all_raw(stdout_fd, &data[..exit_pos])?; + let seq_len = self.alt_screen_exit_len(&data[exit_pos..]); + write_all_raw(stdout_fd, &data[exit_pos..exit_pos + seq_len])?; + self.in_alternate_screen = false; + return self.process_output(&data[exit_pos + seq_len..], stdout_fd); + } + write_all_raw(stdout_fd, data) + } + + fn find_alt_screen_enter(&self, data: &[u8]) -> Option { + let pos1 = self.alt_screen_enter_finder.find(data); + let pos2 = self.alt_screen_enter_legacy_finder.find(data); + match (pos1, pos2) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + } + } + + fn find_alt_screen_exit(&self, data: &[u8]) -> Option { + let pos1 = self.alt_screen_exit_finder.find(data); + let pos2 = self.alt_screen_exit_legacy_finder.find(data); + match (pos1, pos2) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + } + } + + fn alt_screen_enter_len(&self, data: &[u8]) -> usize { + if data.starts_with(ALT_SCREEN_ENTER) { + ALT_SCREEN_ENTER.len() + } else { + ALT_SCREEN_ENTER_LEGACY.len() + } + } + + fn alt_screen_exit_len(&self, data: &[u8]) -> usize { + if data.starts_with(ALT_SCREEN_EXIT) { + ALT_SCREEN_EXIT.len() + } else { + ALT_SCREEN_EXIT_LEGACY.len() + } + } + fn flush_sync_block(&mut self, stdout_fd: i32) -> Result<()> { 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(); @@ -271,6 +351,10 @@ impl Proxy { fn process_input(&mut self, data: &[u8], stdout_fd: i32) -> Result<()> { let master_fd = self.pty_master.as_raw_fd(); + if self.in_alternate_screen { + return write_all_raw(master_fd, data); + } + for &byte in data { if self.in_lookback_mode && byte == 0x03 { self.input_buffer.clear();