diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-23 00:36:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-27 21:37:05 +0300 |
| commit | a57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch) | |
| tree | 25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /src/input.rs | |
initial release
terminal workspace manager for AI coding agents.
single rust binary, runs inside your existing terminal.
workspaces, tiled panes, intelligent agent state detection.
https://herdr.dev
Diffstat (limited to 'src/input.rs')
| -rw-r--r-- | src/input.rs | 339 |
1 files changed, 339 insertions, 0 deletions
diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 00000000..dbbf65df --- /dev/null +++ b/src/input.rs @@ -0,0 +1,339 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Encode a key event for a PTY child. +/// If `kitty_mode` is true, modified keys use CSI u format. +pub fn encode_key(key: KeyEvent, kitty_mode: bool) -> Vec<u8> { + if kitty_mode { + if let Some(bytes) = try_encode_csi_u(&key) { + return bytes; + } + } + encode_legacy(key) +} + +/// CSI u encoding: \e[{codepoint};{modifiers}u +/// Used when the child has pushed Kitty keyboard enhancement. +/// Returns None if the key doesn't need CSI u (unmodified basic keys). +fn try_encode_csi_u(key: &KeyEvent) -> Option<Vec<u8>> { + let mods = key.modifiers; + + // Unmodified keys use legacy encoding (more compatible) + if mods.is_empty() { + return None; + } + + // Plain Ctrl+letter is well-represented in legacy (bytes 1-26) + if mods == KeyModifiers::CONTROL { + if let KeyCode::Char(c) = key.code { + if c.is_ascii_alphabetic() { + return None; // let legacy handle it + } + } + } + + // Special keys (arrows, F-keys, etc.) have well-established legacy + // xterm modified formats (\x1b[1;3A for Alt+Up, etc.) that are universally + // understood. Even Ghostty sends these in legacy format with kitty mode on. + // Only use CSI u for character keys and keys without legacy representations. + match key.code { + KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right + | KeyCode::Home | KeyCode::End | KeyCode::PageUp | KeyCode::PageDown + | KeyCode::Insert | KeyCode::Delete | KeyCode::F(_) => { + return None; // let legacy handle these + } + _ => {} + } + + let codepoint = match key.code { + KeyCode::Char(c) => c as u32, + KeyCode::Enter => 13, + KeyCode::Tab => 9, + KeyCode::Backspace => 127, + KeyCode::Esc => 27, + _ => return None, // fall back to legacy for unhandled keys + }; + + let modifier = kitty_modifier(mods); + + Some(format!("\x1b[{codepoint};{modifier}u").into_bytes()) +} + +/// Legacy terminal encoding (standard escape sequences). +fn encode_legacy(key: KeyEvent) -> Vec<u8> { + let mods = key.modifiers; + + // Modified special keys (arrows, home, end, etc.) use xterm format: + // \x1b[1;{modifier}A for arrows/home/end + // \x1b[{n};{modifier}~ for insert/delete/pgup/pgdn + // The ESC-prefix hack doesn't work for these since they're already escape sequences. + if !mods.is_empty() { + if let Some(bytes) = encode_modified_special(key.code, mods) { + return bytes; + } + } + + // Alt modifier on character keys: prefix with ESC + if mods.contains(KeyModifiers::ALT) { + let inner = KeyEvent::new(key.code, mods.difference(KeyModifiers::ALT)); + let mut bytes = vec![0x1b]; + bytes.extend(encode_legacy_inner(inner)); + return bytes; + } + encode_legacy_inner(key) +} + +/// xterm-style encoding for modified special keys. +/// Modifier value: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0) +fn encode_modified_special(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> { + let modifier = xterm_modifier(mods); + if modifier <= 1 { + return None; // no modifiers to encode + } + + match code { + // CSI 1;{mod}{letter} format + KeyCode::Up => Some(format!("\x1b[1;{modifier}A").into_bytes()), + KeyCode::Down => Some(format!("\x1b[1;{modifier}B").into_bytes()), + KeyCode::Right => Some(format!("\x1b[1;{modifier}C").into_bytes()), + KeyCode::Left => Some(format!("\x1b[1;{modifier}D").into_bytes()), + KeyCode::Home => Some(format!("\x1b[1;{modifier}H").into_bytes()), + KeyCode::End => Some(format!("\x1b[1;{modifier}F").into_bytes()), + // CSI {n};{mod}~ format + KeyCode::Insert => Some(format!("\x1b[2;{modifier}~").into_bytes()), + KeyCode::Delete => Some(format!("\x1b[3;{modifier}~").into_bytes()), + KeyCode::PageUp => Some(format!("\x1b[5;{modifier}~").into_bytes()), + KeyCode::PageDown => Some(format!("\x1b[6;{modifier}~").into_bytes()), + // F1-F4: CSI 1;{mod}{P-S} + KeyCode::F(1) => Some(format!("\x1b[1;{modifier}P").into_bytes()), + KeyCode::F(2) => Some(format!("\x1b[1;{modifier}Q").into_bytes()), + KeyCode::F(3) => Some(format!("\x1b[1;{modifier}R").into_bytes()), + KeyCode::F(4) => Some(format!("\x1b[1;{modifier}S").into_bytes()), + // F5-F12: CSI {n};{mod}~ + KeyCode::F(n @ 5..=12) => { + let code = match n { + 5 => 15, + 6 => 17, + 7 => 18, + 8 => 19, + 9 => 20, + 10 => 21, + 11 => 23, + 12 => 24, + _ => unreachable!(), + }; + Some(format!("\x1b[{code};{modifier}~").into_bytes()) + } + _ => None, + } +} + +/// xterm modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) +/// Used for legacy modified special keys (arrows, function keys, etc.) +fn xterm_modifier(mods: KeyModifiers) -> u32 { + let mut m = 1u32; + if mods.contains(KeyModifiers::SHIFT) { + m += 1; + } + if mods.contains(KeyModifiers::ALT) { + m += 2; + } + if mods.contains(KeyModifiers::CONTROL) { + m += 4; + } + m +} + +/// Kitty protocol modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) + super(8) + hyper(16) + meta(32) +/// Superset of xterm — adds Super/Hyper/Meta bits. +fn kitty_modifier(mods: KeyModifiers) -> u32 { + let mut m = xterm_modifier(mods); + if mods.contains(KeyModifiers::SUPER) { + m += 8; + } + if mods.contains(KeyModifiers::HYPER) { + m += 16; + } + if mods.contains(KeyModifiers::META) { + m += 32; + } + m +} + +fn encode_legacy_inner(key: KeyEvent) -> Vec<u8> { + match key.code { + KeyCode::Char(ch) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + let upper = ch.to_ascii_uppercase(); + match upper { + 'A'..='Z' => vec![upper as u8 - 64], + ' ' | '@' | '2' => vec![0], + '[' | '3' => vec![27], + '\\' | '4' => vec![28], + ']' | '5' => vec![29], + '^' | '6' => vec![30], + '_' | '7' | '-' => vec![31], + _ => vec![ch as u8], + } + } else { + let mut buf = [0u8; 4]; + ch.encode_utf8(&mut buf).as_bytes().to_vec() + } + } + KeyCode::Enter => vec![b'\r'], + KeyCode::Backspace => vec![127], + KeyCode::Tab => vec![9], + KeyCode::BackTab => vec![27, 91, 90], + KeyCode::Esc => vec![27], + KeyCode::Left => vec![27, 91, 68], + KeyCode::Right => vec![27, 91, 67], + KeyCode::Up => vec![27, 91, 65], + KeyCode::Down => vec![27, 91, 66], + KeyCode::Home => vec![27, 91, 72], + KeyCode::End => vec![27, 91, 70], + KeyCode::PageUp => vec![27, 91, 53, 126], + KeyCode::PageDown => vec![27, 91, 54, 126], + KeyCode::Delete => vec![27, 91, 51, 126], + KeyCode::Insert => vec![27, 91, 50, 126], + KeyCode::F(n) => encode_f_key(n), + _ => vec![], + } +} + +fn encode_f_key(n: u8) -> Vec<u8> { + match n { + 1 => vec![27, 79, 80], + 2 => vec![27, 79, 81], + 3 => vec![27, 79, 82], + 4 => vec![27, 79, 83], + 5 => vec![27, 91, 49, 53, 126], + 6 => vec![27, 91, 49, 55, 126], + 7 => vec![27, 91, 49, 56, 126], + 8 => vec![27, 91, 49, 57, 126], + 9 => vec![27, 91, 50, 48, 126], + 10 => vec![27, 91, 50, 49, 126], + 11 => vec![27, 91, 50, 51, 126], + 12 => vec![27, 91, 50, 52, 126], + _ => vec![], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); + assert_eq!(encode_key(key, false), vec![b'\r']); + } + + #[test] + fn legacy_ctrl_c() { + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(encode_key(key, false), vec![3]); + } + + #[test] + fn legacy_shift_enter_is_just_cr() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + // Enter/Tab/Backspace/Esc aren't special keys with xterm modifier encoding, + // so Shift+Enter falls through to legacy which just sends CR + assert_eq!(encode_key(key, false), vec![b'\r']); + } + + #[test] + fn legacy_alt_up() { + let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT); + // xterm modified key format: CSI 1;3A (3 = 1 + Alt) + assert_eq!(encode_key(key, false), b"\x1b[1;3A"); + } + + #[test] + fn legacy_shift_right() { + let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT); + assert_eq!(encode_key(key, false), b"\x1b[1;2C"); + } + + #[test] + fn legacy_ctrl_left() { + let key = KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL); + assert_eq!(encode_key(key, false), b"\x1b[1;5D"); + } + + #[test] + fn legacy_ctrl_shift_end() { + let key = KeyEvent::new( + KeyCode::End, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!(encode_key(key, false), b"\x1b[1;6F"); + } + + #[test] + fn legacy_alt_delete() { + let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT); + assert_eq!(encode_key(key, false), b"\x1b[3;3~"); + } + + #[test] + fn legacy_shift_f5() { + let key = KeyEvent::new(KeyCode::F(5), KeyModifiers::SHIFT); + assert_eq!(encode_key(key, false), b"\x1b[15;2~"); + } + + #[test] + fn legacy_alt_char_still_esc_prefix() { + // Alt+a on character keys still uses ESC prefix (not xterm modified) + let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT); + assert_eq!(encode_key(key, false), b"\x1ba"); + } + + #[test] + fn kitty_shift_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + assert_eq!(encode_key(key, true), b"\x1b[13;2u"); + } + + #[test] + fn kitty_ctrl_shift_a() { + let key = KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!(encode_key(key, true), b"\x1b[97;6u"); + } + + #[test] + fn kitty_alt_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT); + assert_eq!(encode_key(key, true), b"\x1b[13;3u"); + } + + #[test] + fn kitty_plain_ctrl_c_uses_legacy() { + // Plain Ctrl+letter is well-represented in legacy + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(encode_key(key, true), vec![3]); + } + + #[test] + fn kitty_unmodified_uses_legacy() { + let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()); + assert_eq!(encode_key(key, true), b"a"); + } + + #[test] + fn kitty_shift_tab() { + let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); + assert_eq!(encode_key(key, true), b"\x1b[9;2u"); + } + + #[test] + fn kitty_ctrl_shift_enter() { + let key = KeyEvent::new( + KeyCode::Enter, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!(encode_key(key, true), b"\x1b[13;6u"); + } +} |
