Fix/key sequences (#2)

* Change default hotkey to Ctrl+6 and fix ctrl+punctuation handling
* Disable lookback mode when alternate screen is active
This commit is contained in:
David Beesley
2026-01-18 06:17:14 +13:00
committed by GitHub
parent 76b65add1d
commit 91e0516fe9
5 changed files with 158 additions and 21 deletions

View File

@@ -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.

View File

@@ -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]);
}
}

View File

@@ -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;

View File

@@ -241,23 +241,43 @@ fn key_to_escape_sequence(code: &KeyCode, modifiers: &Modifiers) -> Vec<u8> {
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<u8> {
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<u8> {
@@ -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]);
}
}

View File

@@ -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<u8>,
in_sync_block: bool,
in_lookback_mode: bool,
in_alternate_screen: bool,
lookback_cache: Vec<u8>,
input_buffer: Vec<u8>,
output_buffer: Vec<u8>,
@@ -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<usize> {
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<usize> {
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();