From ce80cb8b94534bb5282ea9133bd5de7ce80e4ba0 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 1 Feb 2026 12:35:57 +0100 Subject: [PATCH] feat(keys): migrated keyboard parsing logic from JavaScript to native Rust implementation - Migrated keyboard parsing and matching logic from JavaScript to native Rust implementation for improved performance. - Exported new native functions parseKey, parseKittySequence, matchesLegacySequence, and matchesKey from @oh-my-pi/pi-natives package. - Exported KeyEventType enum and ParsedKittyResult type for Kitty keyboard protocol support. - Removed Key helper object from public API of @oh-my-pi/pi-tui package; users should now pass key identifiers as strings (e.g., 'ctrl+c' instead of Key.ctrl('c')). - Added comprehensive keyboard parsing support in native layer including Kitty protocol, legacy terminal sequences, function keys, and modifier combinations. - Added phf dependency for efficient perfect hash map lookups of legacy terminal escape sequences. --- Cargo.lock | 74 +- crates/pi-natives/Cargo.toml | 1 + crates/pi-natives/src/keys.rs | 1093 +++++++++++++++++++++++--- packages/natives/CHANGELOG.md | 10 + packages/natives/src/index.ts | 12 +- packages/natives/src/keys/index.ts | 56 +- packages/natives/src/native.ts | 29 + packages/tui/CHANGELOG.md | 9 + packages/tui/bench/_jskey.ts | 1150 ++++++++++++++++++++++++++++ packages/tui/bench/parse-key.ts | 116 +++ packages/tui/src/index.ts | 2 - packages/tui/src/keys.ts | 955 ++--------------------- packages/tui/test/keys.test.ts | 4 +- 13 files changed, 2499 insertions(+), 1012 deletions(-) create mode 100644 packages/tui/bench/_jskey.ts create mode 100644 packages/tui/bench/parse-key.ts diff --git a/Cargo.lock b/Cargo.lock index e7e54cf20..f7b790a93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -749,13 +749,23 @@ dependencies = [ "windows-link", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_shared", + "phf_shared 0.13.1", "serde", ] @@ -765,8 +775,18 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", ] [[package]] @@ -776,7 +796,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", ] [[package]] @@ -803,6 +845,7 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "phf 0.11.3", "rayon", "syntect", "unicode-segmentation", @@ -879,6 +922,21 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rayon" version = "1.11.0" @@ -1025,7 +1083,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -1035,8 +1093,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -1171,7 +1229,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" dependencies = [ - "phf", + "phf 0.13.1", "phf_codegen", "string_cache", "string_cache_codegen", diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 012272726..ccb3bdbe3 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -36,6 +36,7 @@ syntect = { version = "5.3", default-features = false, features = [ "regex-fancy", ] } html-to-markdown-rs = { version = "2.24", default-features = false } +phf = { version = "0.11", features = ["macros"] } [build-dependencies] napi-build = "2" diff --git a/crates/pi-natives/src/keys.rs b/crates/pi-natives/src/keys.rs index dc5a99c8c..fcc221ea1 100644 --- a/crates/pi-natives/src/keys.rs +++ b/crates/pi-natives/src/keys.rs @@ -6,13 +6,18 @@ //! //! # Example //! ```ignore -//! // JS: native.matchesKittySequence("\x1b[65;5u", 65, 4) +//! // JS: native.matchesKittySequence("\x1b[65;5u", 65, 4) -> true +//! // JS: native.parseKey("\x1b[65;5u", false) -> "ctrl+a" //! ``` +use std::borrow::Cow; + use napi_derive::napi; +use phf::phf_map; const LOCK_MASK: u32 = 64 + 128; +// Internal sentinel codes for CSI 1;mod forms: const ARROW_UP: i32 = -1; const ARROW_DOWN: i32 = -2; const ARROW_RIGHT: i32 = -3; @@ -24,24 +29,158 @@ const FUNC_PAGE_UP: i32 = -12; const FUNC_PAGE_DOWN: i32 = -13; const FUNC_HOME: i32 = -14; const FUNC_END: i32 = -15; +const FUNC_CLEAR: i32 = -16; +const FUNC_F1: i32 = -20; +const FUNC_F2: i32 = -21; +const FUNC_F3: i32 = -22; +const FUNC_F4: i32 = -23; +const FUNC_F5: i32 = -24; +const FUNC_F6: i32 = -25; +const FUNC_F7: i32 = -26; +const FUNC_F8: i32 = -27; +const FUNC_F9: i32 = -28; +const FUNC_F10: i32 = -29; +const FUNC_F11: i32 = -30; +const FUNC_F12: i32 = -31; + +const CP_ESCAPE: i32 = 27; +const CP_TAB: i32 = 9; +const CP_ENTER: i32 = 13; +const CP_SPACE: i32 = 32; +const CP_BACKSPACE: i32 = 127; +const CP_KP_ENTER: i32 = 57414; + +const MOD_SHIFT: u32 = 1; +const MOD_ALT: u32 = 2; +const MOD_CTRL: u32 = 4; + +/// Parsed Kitty keyboard protocol sequence (subset we care about). struct ParsedKittySequence { codepoint: i32, + shifted_key: Option, base_layout_key: Option, modifier: u32, + event_type: Option, } +/// Parsed Kitty keyboard protocol sequence result. +#[napi(object)] +pub struct ParsedKittyResult { + pub codepoint: i32, + pub shifted_key: Option, + pub base_layout_key: Option, + pub modifier: u32, + /// 1 = press, 2 = repeat, 3 = release + pub event_type: Option, +} + +/// Perfect hash map for legacy sequences - O(1) lookup +static LEGACY_SEQUENCES: phf::Map<&'static [u8], &'static str> = phf_map! { + // Arrow keys (SS3 and CSI) + b"\x1bOA" => "up", b"\x1bOB" => "down", b"\x1bOC" => "right", b"\x1bOD" => "left", + b"\x1b[A" => "up", b"\x1b[B" => "down", b"\x1b[C" => "right", b"\x1b[D" => "left", + // Home/End (multiple terminal variants) + b"\x1bOH" => "home", b"\x1bOF" => "end", + b"\x1b[H" => "home", b"\x1b[F" => "end", + b"\x1b[1~" => "home", b"\x1b[7~" => "home", + b"\x1b[4~" => "end", b"\x1b[8~" => "end", + // Clear + b"\x1b[E" => "clear", b"\x1bOE" => "clear", b"\x1bOe" => "ctrl+clear", b"\x1b[e" => "shift+clear", + // Insert/Delete + b"\x1b[2~" => "insert", b"\x1b[2$" => "shift+insert", b"\x1b[2^" => "ctrl+insert", + b"\x1b[3~" => "delete", b"\x1b[3$" => "shift+delete", b"\x1b[3^" => "ctrl+delete", + // Page Up/Down + b"\x1b[5~" => "pageUp", b"\x1b[6~" => "pageDown", + b"\x1b[[5~" => "pageUp", b"\x1b[[6~" => "pageDown", + // Shift+arrow + b"\x1b[a" => "shift+up", b"\x1b[b" => "shift+down", b"\x1b[c" => "shift+right", b"\x1b[d" => "shift+left", + // Ctrl+arrow + b"\x1bOa" => "ctrl+up", b"\x1bOb" => "ctrl+down", b"\x1bOc" => "ctrl+right", b"\x1bOd" => "ctrl+left", + // Shift+page/home/end + b"\x1b[5$" => "shift+pageUp", b"\x1b[6$" => "shift+pageDown", + b"\x1b[7$" => "shift+home", b"\x1b[8$" => "shift+end", + // Ctrl+page/home/end + b"\x1b[5^" => "ctrl+pageUp", b"\x1b[6^" => "ctrl+pageDown", + b"\x1b[7^" => "ctrl+home", b"\x1b[8^" => "ctrl+end", + // Function keys (SS3, CSI tilde, Linux console) + b"\x1bOP" => "f1", b"\x1bOQ" => "f2", b"\x1bOR" => "f3", b"\x1bOS" => "f4", + b"\x1b[11~" => "f1", b"\x1b[12~" => "f2", b"\x1b[13~" => "f3", b"\x1b[14~" => "f4", + b"\x1b[[A" => "f1", b"\x1b[[B" => "f2", b"\x1b[[C" => "f3", b"\x1b[[D" => "f4", b"\x1b[[E" => "f5", + b"\x1b[15~" => "f5", b"\x1b[17~" => "f6", b"\x1b[18~" => "f7", b"\x1b[19~" => "f8", + b"\x1b[20~" => "f9", b"\x1b[21~" => "f10", b"\x1b[23~" => "f11", b"\x1b[24~" => "f12", + // Alt+arrow (legacy) + b"\x1bb" => "alt+left", b"\x1bf" => "alt+right", b"\x1bp" => "alt+up", b"\x1bn" => "alt+down", +}; + +/// Pre-allocated single ASCII printable characters (33-126) +static ASCII_PRINTABLE: [&str; 94] = [ + "!", "\"", "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/", "0", "1", "2", "3", + "4", "5", "6", "7", "8", "9", ":", ";", "<", "=", ">", "?", "@", "A", "B", "C", "D", "E", "F", + "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", + "Z", "[", "\\", "]", "^", "_", "`", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", + "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "{", "|", "}", "~", +]; + +/// Pre-allocated modifier+letter combinations +static CTRL_LETTERS: [&str; 26] = [ + "ctrl+a", "ctrl+b", "ctrl+c", "ctrl+d", "ctrl+e", "ctrl+f", "ctrl+g", "ctrl+h", "ctrl+i", + "ctrl+j", "ctrl+k", "ctrl+l", "ctrl+m", "ctrl+n", "ctrl+o", "ctrl+p", "ctrl+q", "ctrl+r", + "ctrl+s", "ctrl+t", "ctrl+u", "ctrl+v", "ctrl+w", "ctrl+x", "ctrl+y", "ctrl+z", +]; + +static ALT_LETTERS: [&str; 26] = [ + "alt+a", "alt+b", "alt+c", "alt+d", "alt+e", "alt+f", "alt+g", "alt+h", "alt+i", "alt+j", + "alt+k", "alt+l", "alt+m", "alt+n", "alt+o", "alt+p", "alt+q", "alt+r", "alt+s", "alt+t", + "alt+u", "alt+v", "alt+w", "alt+x", "alt+y", "alt+z", +]; + +static CTRL_ALT_LETTERS: [&str; 26] = [ + "ctrl+alt+a", + "ctrl+alt+b", + "ctrl+alt+c", + "ctrl+alt+d", + "ctrl+alt+e", + "ctrl+alt+f", + "ctrl+alt+g", + "ctrl+alt+h", + "ctrl+alt+i", + "ctrl+alt+j", + "ctrl+alt+k", + "ctrl+alt+l", + "ctrl+alt+m", + "ctrl+alt+n", + "ctrl+alt+o", + "ctrl+alt+p", + "ctrl+alt+q", + "ctrl+alt+r", + "ctrl+alt+s", + "ctrl+alt+t", + "ctrl+alt+u", + "ctrl+alt+v", + "ctrl+alt+w", + "ctrl+alt+x", + "ctrl+alt+y", + "ctrl+alt+z", +]; + +static LETTERS: [&str; 26] = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", + "t", "u", "v", "w", "x", "y", "z", +]; + +// ============================================================================= +// Public API +// ============================================================================= + /// Matches Kitty protocol keyboard sequences against a codepoint and modifier. -/// -/// # Errors -/// Returns `false` when the input is not a Kitty sequence or does not match. #[napi(js_name = "matchesKittySequence")] pub fn matches_kitty_sequence( data: String, expected_codepoint: i32, expected_modifier: u32, ) -> bool { - let Some(parsed) = parse_kitty_sequence(&data) else { + let Some(parsed) = parse_kitty_sequence(data.as_bytes()) else { return false; }; @@ -51,73 +190,773 @@ pub fn matches_kitty_sequence( return false; } - if parsed.codepoint == expected_codepoint { - return true; + parsed.codepoint == expected_codepoint || parsed.base_layout_key == Some(expected_codepoint) +} + +/// Parse terminal input and return a normalized key identifier. +#[napi(js_name = "parseKey")] +pub fn parse_key(data: String, kitty_protocol_active: bool) -> Option { + parse_key_inner(data.as_bytes(), kitty_protocol_active).map(|s| s.into_owned()) +} + +/// Check if input matches a legacy escape sequence. +#[napi(js_name = "matchesLegacySequence")] +pub fn matches_legacy_sequence(data: String, key_name: String) -> bool { + LEGACY_SEQUENCES + .get(data.as_bytes()) + .is_some_and(|&id| id == key_name) +} + +/// Match input data against a key identifier string. +#[napi(js_name = "matchesKey")] +pub fn matches_key(data: String, key_id: String, kitty_protocol_active: bool) -> bool { + matches_key_inner(data.as_bytes(), &key_id, kitty_protocol_active) +} + +/// Parse a Kitty keyboard protocol sequence. +#[napi(js_name = "parseKittySequence")] +pub fn parse_kitty_sequence_napi(data: String) -> Option { + parse_kitty_sequence(data.as_bytes()).map(|p| ParsedKittyResult { + codepoint: p.codepoint, + shifted_key: p.shifted_key, + base_layout_key: p.base_layout_key, + modifier: p.modifier, + event_type: p.event_type, + }) +} + +// ============================================================================= +// Key Matching +// ============================================================================= + +struct ParsedKeyId<'a> { + key: &'a str, + ctrl: bool, + shift: bool, + alt: bool, +} + +fn parse_key_id(key_id: &str) -> Option> { + let s = key_id.trim(); + if s.is_empty() { + return None; } - if parsed.base_layout_key == Some(expected_codepoint) { - return true; + // Support plus key as "++" or "ctrl++" etc. + // In this case the trailing "++" means: delimiter '+' + key '+' + let (prefix, forced_key_plus): (&str, bool) = if s == "+" { + ("", true) + } else if s.ends_with("++") { + (&s[..s.len() - 2], true) + } else { + (s, false) + }; + + let mut ctrl = false; + let mut shift = false; + let mut alt = false; + let mut key: Option<&str> = if forced_key_plus { Some("+") } else { None }; + + for part in prefix.split('+') { + let p = part.trim(); + if p.is_empty() { + continue; + } + if p.eq_ignore_ascii_case("ctrl") || p.eq_ignore_ascii_case("control") { + ctrl = true; + continue; + } + if p.eq_ignore_ascii_case("shift") { + shift = true; + continue; + } + if p.eq_ignore_ascii_case("alt") || p.eq_ignore_ascii_case("option") { + alt = true; + continue; + } + + // Treat this as the key token (last non-modifier wins) + key = Some(p); + } + + let mut key = key?; + // Optional aliases + if key.eq_ignore_ascii_case("plus") { + key = "+"; + } else if key.eq_ignore_ascii_case("esc") { + key = "esc"; + } + + Some(ParsedKeyId { key, ctrl, shift, alt }) +} + +#[inline] +fn raw_ctrl_char(letter: u8) -> u8 { + (letter.to_ascii_lowercase() - b'a') + 1 +} + +/// CTRL+symbol legacy mappings +fn ctrl_symbol_to_byte(symbol: u8) -> Option { + match symbol { + b'@' => Some(0x00), + b'[' => Some(0x1b), + b'\\' => Some(0x1c), + b']' => Some(0x1d), + b'^' => Some(0x1e), + b'_' | b'-' => Some(0x1f), + _ => None, + } +} + +/// Parse xterm "modifyOtherKeys" format: +/// CSI 27 ; modifiers ; keycode ~ +/// Some implementations omit the trailing '~': +/// CSI 27 ; modifiers ; keycode +#[inline] +fn parse_modify_other_keys(bytes: &[u8]) -> Option<(u32, i32)> { + if bytes.len() < 7 || !bytes.starts_with(b"\x1b[27;") { + return None; + } + + let mut end = bytes.len(); + if bytes.last() == Some(&b'~') { + end -= 1; + } + if end <= 5 { + return None; + } + + let mut idx = 5; // after "\x1b[27;" + let (mod_value, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + + if idx >= end || bytes[idx] != b';' { + return None; + } + idx += 1; + + let (keycode_u32, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + + if idx != end || mod_value == 0 { + return None; + } + + let modifier = mod_value - 1; + let keycode = i32::try_from(keycode_u32).ok()?; + Some((modifier, keycode)) +} + +#[inline] +fn matches_modify_other_keys(bytes: &[u8], expected_keycode: i32, expected_modifier: u32) -> bool { + parse_modify_other_keys(bytes) + .is_some_and(|(m, k)| k == expected_keycode && m == expected_modifier) +} + +fn matches_key_inner(bytes: &[u8], key_id: &str, kitty_protocol_active: bool) -> bool { + let Some(parsed_id) = parse_key_id(key_id) else { + return false; + }; + + let ctrl = parsed_id.ctrl; + let shift = parsed_id.shift; + let alt = parsed_id.alt; + let key = parsed_id.key; + + let mut modifier: u32 = 0; + if shift { + modifier |= MOD_SHIFT; + } + if alt { + modifier |= MOD_ALT; + } + if ctrl { + modifier |= MOD_CTRL; + } + + // Parse Kitty once (avoid repeated parsing in branches). + let kitty_parsed = parse_kitty_sequence(bytes); + let kitty_matches = |codepoint: i32, m: u32| -> bool { + let Some(p) = kitty_parsed.as_ref() else { + return false; + }; + let actual_mod = p.modifier & !LOCK_MASK; + let expected_mod = m & !LOCK_MASK; + if actual_mod != expected_mod { + return false; + } + p.codepoint == codepoint || p.base_layout_key == Some(codepoint) + }; + + // Parse modifyOtherKeys once. + let mok = parse_modify_other_keys(bytes); + let mok_matches = + |keycode: i32, m: u32| -> bool { mok.is_some_and(|(mm, kk)| kk == keycode && mm == m) }; + + // Named keys (case-insensitive) + if key.eq_ignore_ascii_case("escape") || key.eq_ignore_ascii_case("esc") { + if modifier != 0 { + return false; + } + return bytes == b"\x1b" || kitty_matches(CP_ESCAPE, 0); + } + + if key.eq_ignore_ascii_case("space") { + // legacy ctrl+space + if !alt && ctrl && !shift && bytes == b"\x00" { + return true; + } + // legacy alt+space (only reliable when not disambiguated) + if !ctrl && alt && !shift && !kitty_protocol_active && bytes == b"\x1b " { + return true; + } + + if modifier == 0 { + return bytes == b" " || kitty_matches(CP_SPACE, 0); + } + return kitty_matches(CP_SPACE, modifier) || mok_matches(CP_SPACE, modifier); + } + + if key.eq_ignore_ascii_case("tab") { + // shift+tab classic + if shift && !ctrl && !alt { + return bytes == b"\x1b[Z" + || kitty_matches(CP_TAB, MOD_SHIFT) + || mok_matches(CP_TAB, MOD_SHIFT); + } + + // alt+tab stays ESC+TAB in many legacy/kitty-disambiguate scenarios (Tab is an + // exception). + if alt && !ctrl && !shift && bytes == b"\x1b\t" { + return true; + } + + // plain tab (treat LF/CR elsewhere) + if modifier == 0 { + return bytes == b"\t" || kitty_matches(CP_TAB, 0); + } + + // ctrl+tab etc are only distinguishable in enhanced modes (CSI-u / + // modifyOtherKeys) + return kitty_matches(CP_TAB, modifier) || mok_matches(CP_TAB, modifier); + } + + if key.eq_ignore_ascii_case("enter") || key.eq_ignore_ascii_case("return") { + // alt+enter is commonly ESC + CR even when kitty disambiguation is on (Enter is + // an exception). + if alt && !ctrl && !shift && bytes == b"\x1b\r" { + return true; + } + + // unmodified enter + if modifier == 0 { + return bytes == b"\r" + || bytes == b"\n" + || bytes == b"\x1bOM" + || kitty_matches(CP_ENTER, 0) + || kitty_matches(CP_KP_ENTER, 0); + } + + // modified enter is only reliably representable when encoded (CSI-u / + // modifyOtherKeys) + return kitty_matches(CP_ENTER, modifier) + || kitty_matches(CP_KP_ENTER, modifier) + || mok_matches(CP_ENTER, modifier) + || mok_matches(CP_KP_ENTER, modifier); + } + + if key.eq_ignore_ascii_case("backspace") { + // alt+backspace is commonly ESC + (DEL or BS) even in kitty disambiguate mode + // (Backspace is an exception). + if alt && !ctrl && !shift { + return bytes == b"\x1b\x7f" + || bytes == b"\x1b\x08" + || kitty_matches(CP_BACKSPACE, MOD_ALT) + || mok_matches(CP_BACKSPACE, MOD_ALT); + } + + if modifier == 0 { + return bytes == b"\x7f" || bytes == b"\x08" || kitty_matches(CP_BACKSPACE, 0); + } + + return kitty_matches(CP_BACKSPACE, modifier) || mok_matches(CP_BACKSPACE, modifier); + } + + if key.eq_ignore_ascii_case("insert") { + if modifier == 0 { + return matches_legacy_key(bytes, "insert") || kitty_matches(FUNC_INSERT, 0); + } + return matches_legacy_modifier_sequence(bytes, "insert", modifier) + || kitty_matches(FUNC_INSERT, modifier); + } + + if key.eq_ignore_ascii_case("delete") { + if modifier == 0 { + return matches_legacy_key(bytes, "delete") || kitty_matches(FUNC_DELETE, 0); + } + return matches_legacy_modifier_sequence(bytes, "delete", modifier) + || kitty_matches(FUNC_DELETE, modifier); + } + + if key.eq_ignore_ascii_case("clear") { + if modifier == 0 { + return matches_legacy_key(bytes, "clear") || kitty_matches(FUNC_CLEAR, 0); + } + return matches_legacy_modifier_sequence(bytes, "clear", modifier) + || kitty_matches(FUNC_CLEAR, modifier); + } + + if key.eq_ignore_ascii_case("home") { + if modifier == 0 { + return matches_legacy_key(bytes, "home") || kitty_matches(FUNC_HOME, 0); + } + return matches_legacy_modifier_sequence(bytes, "home", modifier) + || kitty_matches(FUNC_HOME, modifier); + } + + if key.eq_ignore_ascii_case("end") { + if modifier == 0 { + return matches_legacy_key(bytes, "end") || kitty_matches(FUNC_END, 0); + } + return matches_legacy_modifier_sequence(bytes, "end", modifier) + || kitty_matches(FUNC_END, modifier); + } + + if key.eq_ignore_ascii_case("pageup") { + if modifier == 0 { + return matches_legacy_key(bytes, "pageUp") || kitty_matches(FUNC_PAGE_UP, 0); + } + return matches_legacy_modifier_sequence(bytes, "pageUp", modifier) + || kitty_matches(FUNC_PAGE_UP, modifier); + } + + if key.eq_ignore_ascii_case("pagedown") { + if modifier == 0 { + return matches_legacy_key(bytes, "pageDown") || kitty_matches(FUNC_PAGE_DOWN, 0); + } + return matches_legacy_modifier_sequence(bytes, "pageDown", modifier) + || kitty_matches(FUNC_PAGE_DOWN, modifier); + } + + if key.eq_ignore_ascii_case("up") { + if alt && !ctrl && !shift { + return bytes == b"\x1bp" || kitty_matches(ARROW_UP, MOD_ALT); + } + if modifier == 0 { + return matches_legacy_key(bytes, "up") || kitty_matches(ARROW_UP, 0); + } + return matches_legacy_modifier_sequence(bytes, "up", modifier) + || kitty_matches(ARROW_UP, modifier); + } + + if key.eq_ignore_ascii_case("down") { + if alt && !ctrl && !shift { + return bytes == b"\x1bn" || kitty_matches(ARROW_DOWN, MOD_ALT); + } + if modifier == 0 { + return matches_legacy_key(bytes, "down") || kitty_matches(ARROW_DOWN, 0); + } + return matches_legacy_modifier_sequence(bytes, "down", modifier) + || kitty_matches(ARROW_DOWN, modifier); + } + + if key.eq_ignore_ascii_case("left") { + if alt && !ctrl && !shift { + return bytes == b"\x1b[1;3D" + || (!kitty_protocol_active && bytes == b"\x1bB") + || bytes == b"\x1bb" + || kitty_matches(ARROW_LEFT, MOD_ALT); + } + if ctrl && !alt && !shift { + return bytes == b"\x1b[1;5D" + || matches_legacy_modifier_sequence(bytes, "left", MOD_CTRL) + || kitty_matches(ARROW_LEFT, MOD_CTRL); + } + if modifier == 0 { + return matches_legacy_key(bytes, "left") || kitty_matches(ARROW_LEFT, 0); + } + return matches_legacy_modifier_sequence(bytes, "left", modifier) + || kitty_matches(ARROW_LEFT, modifier); + } + + if key.eq_ignore_ascii_case("right") { + if alt && !ctrl && !shift { + return bytes == b"\x1b[1;3C" + || (!kitty_protocol_active && bytes == b"\x1bF") + || bytes == b"\x1bf" + || kitty_matches(ARROW_RIGHT, MOD_ALT); + } + if ctrl && !alt && !shift { + return bytes == b"\x1b[1;5C" + || matches_legacy_modifier_sequence(bytes, "right", MOD_CTRL) + || kitty_matches(ARROW_RIGHT, MOD_CTRL); + } + if modifier == 0 { + return matches_legacy_key(bytes, "right") || kitty_matches(ARROW_RIGHT, 0); + } + return matches_legacy_modifier_sequence(bytes, "right", modifier) + || kitty_matches(ARROW_RIGHT, modifier); + } + + // Function keys (now allow modifiers via CSI forms too) + let f_code = if key.eq_ignore_ascii_case("f1") { + Some(FUNC_F1) + } else if key.eq_ignore_ascii_case("f2") { + Some(FUNC_F2) + } else if key.eq_ignore_ascii_case("f3") { + Some(FUNC_F3) + } else if key.eq_ignore_ascii_case("f4") { + Some(FUNC_F4) + } else if key.eq_ignore_ascii_case("f5") { + Some(FUNC_F5) + } else if key.eq_ignore_ascii_case("f6") { + Some(FUNC_F6) + } else if key.eq_ignore_ascii_case("f7") { + Some(FUNC_F7) + } else if key.eq_ignore_ascii_case("f8") { + Some(FUNC_F8) + } else if key.eq_ignore_ascii_case("f9") { + Some(FUNC_F9) + } else if key.eq_ignore_ascii_case("f10") { + Some(FUNC_F10) + } else if key.eq_ignore_ascii_case("f11") { + Some(FUNC_F11) + } else if key.eq_ignore_ascii_case("f12") { + Some(FUNC_F12) + } else { + None + }; + + if let Some(cp) = f_code { + if modifier == 0 { + return matches_legacy_key(bytes, key); + } + return kitty_matches(cp, modifier); + } + + // Single-character keys: accept any ASCII graphic char (0x21..=0x7E). + if key.len() == 1 { + let mut ch = key.as_bytes()[0]; + if ch.is_ascii_uppercase() { + ch = ch.to_ascii_lowercase(); + } + + if !ch.is_ascii_graphic() { + return false; + } + + let codepoint = ch as i32; + let is_letter = ch.is_ascii_lowercase(); + + // ctrl+alt+letter in legacy mode + if ctrl && alt && !shift && !kitty_protocol_active && is_letter { + let ctrl_char = raw_ctrl_char(ch); + return bytes.len() == 2 && bytes[0] == 0x1b && bytes[1] == ctrl_char; + } + + // alt+letter in legacy mode + if alt && !ctrl && !shift && !kitty_protocol_active && is_letter { + return bytes.len() == 2 && bytes[0] == 0x1b && bytes[1] == ch; + } + + // ctrl+key + if ctrl && !shift && !alt { + if is_letter { + let raw = raw_ctrl_char(ch); + if bytes.len() == 1 && bytes[0] == raw { + return true; + } + return mok_matches(codepoint, MOD_CTRL) || kitty_matches(codepoint, MOD_CTRL); + } + + // ctrl+symbol legacy mapping (layout dependent) + if let Some(legacy_ctrl) = ctrl_symbol_to_byte(ch) { + if bytes.len() == 1 && bytes[0] == legacy_ctrl { + return true; + } + } + + return mok_matches(codepoint, MOD_CTRL) || kitty_matches(codepoint, MOD_CTRL); + } + + // ctrl+shift + if ctrl && shift && !alt { + return kitty_matches(codepoint, MOD_SHIFT + MOD_CTRL) + || mok_matches(codepoint, MOD_SHIFT + MOD_CTRL); + } + + // shift+key (letters can match uppercase in plain legacy mode) + if shift && !ctrl && !alt { + if is_letter && bytes.len() == 1 && bytes[0] == ch.to_ascii_uppercase() { + return true; + } + return kitty_matches(codepoint, MOD_SHIFT) || mok_matches(codepoint, MOD_SHIFT); + } + + // other modifier combinations + if modifier != 0 { + return kitty_matches(codepoint, modifier) || mok_matches(codepoint, modifier); + } + + // plain key + return (bytes.len() == 1 && bytes[0] == ch) || kitty_matches(codepoint, 0); } false } -fn parse_kitty_sequence(data: &str) -> Option { - parse_csi_u(data) - .or_else(|| parse_arrow_sequence(data)) - .or_else(|| parse_functional_sequence(data)) - .or_else(|| parse_home_end_sequence(data)) +/// Check if bytes match a legacy key sequence +fn matches_legacy_key(bytes: &[u8], key: &str) -> bool { + LEGACY_SEQUENCES.get(bytes).is_some_and(|&id| id == key) } -fn parse_csi_u(data: &str) -> Option { - let bytes = data.as_bytes(); - if bytes.len() < 4 || !bytes.starts_with(b"\x1b[") || *bytes.last()? != b'u' { +/// Check if bytes match a legacy modifier sequence (shift/ctrl variants) +fn matches_legacy_modifier_sequence(bytes: &[u8], key: &str, modifier: u32) -> bool { + if modifier == MOD_SHIFT { + let expected = match key { + "up" => Some("shift+up"), + "down" => Some("shift+down"), + "right" => Some("shift+right"), + "left" => Some("shift+left"), + "clear" => Some("shift+clear"), + "insert" => Some("shift+insert"), + "delete" => Some("shift+delete"), + "pageUp" => Some("shift+pageUp"), + "pageDown" => Some("shift+pageDown"), + "home" => Some("shift+home"), + "end" => Some("shift+end"), + _ => None, + }; + if let Some(expected_key) = expected { + return LEGACY_SEQUENCES + .get(bytes) + .is_some_and(|&id| id == expected_key); + } + } else if modifier == MOD_CTRL { + let expected = match key { + "up" => Some("ctrl+up"), + "down" => Some("ctrl+down"), + "right" => Some("ctrl+right"), + "left" => Some("ctrl+left"), + "clear" => Some("ctrl+clear"), + "insert" => Some("ctrl+insert"), + "delete" => Some("ctrl+delete"), + "pageUp" => Some("ctrl+pageUp"), + "pageDown" => Some("ctrl+pageDown"), + "home" => Some("ctrl+home"), + "end" => Some("ctrl+end"), + _ => None, + }; + if let Some(expected_key) = expected { + return LEGACY_SEQUENCES + .get(bytes) + .is_some_and(|&id| id == expected_key); + } + } + false +} + +// ============================================================================= +// Core Parsing +// ============================================================================= + +#[inline] +fn parse_key_inner(bytes: &[u8], kitty_protocol_active: bool) -> Option> { + // Fast path: single byte (most common for typing) + if bytes.len() == 1 { + return parse_single_byte(bytes[0]); + } + + // All escape sequences start with ESC + if bytes.first() != Some(&0x1b) { return None; } - let end = bytes.len() - 1; + // O(1) lookup in perfect hash map for legacy sequences + if let Some(&key_id) = LEGACY_SEQUENCES.get(bytes) { + return Some(Cow::Borrowed(key_id)); + } + + // xterm modifyOtherKeys (CSI 27;...;...~) + if let Some((mods, keycode)) = parse_modify_other_keys(bytes) { + let key_name = format_key_name(keycode)?; + if mods == 0 { + return Some(Cow::Borrowed(key_name)); + } + return Some(Cow::Owned(format_with_mods(mods & !LOCK_MASK, key_name))); + } + + // Try Kitty protocol sequences (including enhanced CSI-u with optional text + // field) + if let Some(parsed) = parse_kitty_sequence(bytes) { + return format_kitty_key(&parsed); + } + + // Two-byte ESC sequences (legacy ALT prefix, with exceptions even in kitty + // mode) + if bytes.len() == 2 { + return parse_esc_pair(bytes[1], kitty_protocol_active); + } + + // Fixed CSI / SS3 sequences not covered by LEGACY_SEQUENCES + match bytes { + b"\x1b[Z" => Some(Cow::Borrowed("shift+tab")), + b"\x1bOM" => Some(Cow::Borrowed("enter")), // keypad enter (SS3 M) + _ => None, + } +} + +#[inline] +fn parse_single_byte(code: u8) -> Option> { + match code { + 0x1b => Some(Cow::Borrowed("escape")), + b'\t' => Some(Cow::Borrowed("tab")), + b'\r' | b'\n' => Some(Cow::Borrowed("enter")), + 0x00 => Some(Cow::Borrowed("ctrl+space")), + b' ' => Some(Cow::Borrowed("space")), + 0x7f | 0x08 => Some(Cow::Borrowed("backspace")), + 28 => Some(Cow::Borrowed("ctrl+\\")), + 29 => Some(Cow::Borrowed("ctrl+]")), + 30 => Some(Cow::Borrowed("ctrl+^")), + 31 => Some(Cow::Borrowed("ctrl+_")), + 1..=26 => Some(Cow::Borrowed(CTRL_LETTERS[(code - 1) as usize])), + b'a'..=b'z' => Some(Cow::Borrowed(LETTERS[(code - b'a') as usize])), + 33..=126 => Some(Cow::Borrowed(ASCII_PRINTABLE[(code - 33) as usize])), + _ => None, + } +} + +#[inline] +fn parse_esc_pair(code: u8, kitty_protocol_active: bool) -> Option> { + // These remain ESC-prefixed even in kitty "disambiguate" mode in many + // terminals. + match code { + 0x7f | 0x08 => return Some(Cow::Borrowed("alt+backspace")), + b'\r' => return Some(Cow::Borrowed("alt+enter")), + b'\t' => return Some(Cow::Borrowed("alt+tab")), + _ => {}, + } + + // Legacy ALT-prefix parsing only when kitty protocol isn't expected to + // disambiguate. + if !kitty_protocol_active { + match code { + b' ' => return Some(Cow::Borrowed("alt+space")), + b'B' => return Some(Cow::Borrowed("alt+left")), + b'F' => return Some(Cow::Borrowed("alt+right")), + 1..=26 => return Some(Cow::Borrowed(CTRL_ALT_LETTERS[(code - 1) as usize])), + b'a'..=b'z' => return Some(Cow::Borrowed(ALT_LETTERS[(code - b'a') as usize])), + _ => {}, + } + } + + None +} + +// ============================================================================= +// Kitty Protocol Parsing +// ============================================================================= + +fn parse_kitty_sequence(bytes: &[u8]) -> Option { + if bytes.len() < 4 || bytes[0] != 0x1b || bytes[1] != b'[' { + return None; + } + + match *bytes.last()? { + b'u' => parse_csi_u(bytes), + b'~' => parse_functional(bytes), + // CSI 1;mod + b'A' | b'B' | b'C' | b'D' | b'E' | b'F' | b'H' | b'P' | b'Q' | b'R' | b'S' => { + parse_csi_1_letter(bytes) + }, + _ => None, + } +} + +fn parse_csi_u(bytes: &[u8]) -> Option { + let end = bytes.len() - 1; // index of 'u' let mut idx = 2; - let (codepoint, next_idx) = parse_digits(bytes, idx, end)?; - let codepoint = to_i32(codepoint)?; + + // unicode-key-code + let (codepoint_u32, next_idx) = parse_digits(bytes, idx, end)?; + let codepoint = i32::try_from(codepoint_u32).ok()?; idx = next_idx; + // :alternate-key-codes (shifted[:base_layout]) + let mut shifted_key = None; let mut base_layout_key = None; if idx < end && bytes[idx] == b':' { idx += 1; - let (_, next_idx) = parse_optional_digits(bytes, idx, end); + + let (shifted_value, next_idx) = parse_optional_digits(bytes, idx, end); + shifted_key = shifted_value.and_then(|v| i32::try_from(v).ok()); idx = next_idx; + if idx < end && bytes[idx] == b':' { idx += 1; let (base_value, next_idx) = parse_digits(bytes, idx, end)?; - base_layout_key = Some(to_i32(base_value)?); + base_layout_key = Some(i32::try_from(base_value).ok()?); idx = next_idx; } } - let mod_value = if idx < end && bytes[idx] == b';' { - idx += 1; - let (mod_value, next_idx) = parse_digits(bytes, idx, end)?; - idx = next_idx; - mod_value - } else { - 1 - }; + // ;modifiers:event-type (modifiers field may be omitted OR empty if followed + // by ;text) + let mut mod_value: u32 = 1; + let mut event_type: Option = None; - if idx < end && bytes[idx] == b':' { + if idx < end && bytes[idx] == b';' { idx += 1; - let (_, next_idx) = parse_digits(bytes, idx, end)?; - idx = next_idx; + + // modifiers digits may be absent (e.g. CSI 0;;229u) + if idx < end && bytes[idx].is_ascii_digit() { + let (v, next_idx) = parse_digits(bytes, idx, end)?; + mod_value = v; + idx = next_idx; + } else { + mod_value = 1; + } + + // :event-type (allow even if modifiers were empty -> treat as modifiers=1) + if idx < end && bytes[idx] == b':' { + idx += 1; + let (ev, next_idx) = parse_digits(bytes, idx, end)?; + event_type = Some(ev); + idx = next_idx; + } + } + + // ;text-as-codepoints (optional, may be empty) + if idx < end && bytes[idx] == b';' { + idx += 1; + // validate "digits(:digits)*" but allow empty and ignore values + while idx < end { + if bytes[idx] == b':' { + idx += 1; + continue; + } + let (_cp, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + if idx < end && bytes[idx] == b':' { + idx += 1; + } + } } if idx != end || mod_value == 0 { return None; } - Some(ParsedKittySequence { codepoint, base_layout_key, modifier: mod_value - 1 }) + Some(ParsedKittySequence { + codepoint, + shifted_key, + base_layout_key, + modifier: mod_value - 1, + event_type, + }) } -fn parse_arrow_sequence(data: &str) -> Option { - let bytes = data.as_bytes(); +fn parse_csi_1_letter(bytes: &[u8]) -> Option { if !bytes.starts_with(b"\x1b[1;") { return None; } @@ -127,9 +966,11 @@ fn parse_arrow_sequence(data: &str) -> Option { let (mod_value, next_idx) = parse_digits(bytes, idx, end)?; idx = next_idx; + let mut event_type = None; if idx < end && bytes[idx] == b':' { idx += 1; - let (_, next_idx) = parse_digits(bytes, idx, end)?; + let (ev, next_idx) = parse_digits(bytes, idx, end)?; + event_type = Some(ev); idx = next_idx; } @@ -142,35 +983,45 @@ fn parse_arrow_sequence(data: &str) -> Option { b'B' => ARROW_DOWN, b'C' => ARROW_RIGHT, b'D' => ARROW_LEFT, + b'H' => FUNC_HOME, + b'F' => FUNC_END, + b'E' => FUNC_CLEAR, + b'P' => FUNC_F1, + b'Q' => FUNC_F2, + b'R' => FUNC_F3, + b'S' => FUNC_F4, _ => return None, }; - Some(ParsedKittySequence { codepoint, base_layout_key: None, modifier: mod_value - 1 }) + Some(ParsedKittySequence { + codepoint, + shifted_key: None, + base_layout_key: None, + modifier: mod_value - 1, + event_type, + }) } -fn parse_functional_sequence(data: &str) -> Option { - let bytes = data.as_bytes(); - if bytes.len() < 4 || !bytes.starts_with(b"\x1b[") || *bytes.last()? != b'~' { - return None; - } - - let end = bytes.len() - 1; +fn parse_functional(bytes: &[u8]) -> Option { + let end = bytes.len() - 1; // index of '~' let mut idx = 2; let (key_num, next_idx) = parse_digits(bytes, idx, end)?; idx = next_idx; let mod_value = if idx < end && bytes[idx] == b';' { idx += 1; - let (mod_value, next_idx) = parse_digits(bytes, idx, end)?; + let (v, next_idx) = parse_digits(bytes, idx, end)?; idx = next_idx; - mod_value + v } else { 1 }; + let mut event_type = None; if idx < end && bytes[idx] == b':' { idx += 1; - let (_, next_idx) = parse_digits(bytes, idx, end)?; + let (ev, next_idx) = parse_digits(bytes, idx, end)?; + event_type = Some(ev); idx = next_idx; } @@ -179,48 +1030,125 @@ fn parse_functional_sequence(data: &str) -> Option { } let codepoint = match key_num { + // Common functional keys 2 => FUNC_INSERT, 3 => FUNC_DELETE, 5 => FUNC_PAGE_UP, 6 => FUNC_PAGE_DOWN, - 7 => FUNC_HOME, - 8 => FUNC_END, + + // Home/End variants + 1 | 7 => FUNC_HOME, + 4 | 8 => FUNC_END, + + // Function keys (terminfo-style) + 11 => FUNC_F1, + 12 => FUNC_F2, + 13 => FUNC_F3, + 14 => FUNC_F4, + 15 => FUNC_F5, + 17 => FUNC_F6, + 18 => FUNC_F7, + 19 => FUNC_F8, + 20 => FUNC_F9, + 21 => FUNC_F10, + 23 => FUNC_F11, + 24 => FUNC_F12, + _ => return None, }; - Some(ParsedKittySequence { codepoint, base_layout_key: None, modifier: mod_value - 1 }) + Some(ParsedKittySequence { + codepoint, + shifted_key: None, + base_layout_key: None, + modifier: mod_value - 1, + event_type, + }) } -fn parse_home_end_sequence(data: &str) -> Option { - let bytes = data.as_bytes(); - if !bytes.starts_with(b"\x1b[1;") { - return None; +// ============================================================================= +// Formatting +// ============================================================================= + +fn format_kitty_key(parsed: &ParsedKittySequence) -> Option> { + let effective_mod = parsed.modifier & !LOCK_MASK; + let effective_codepoint = parsed.base_layout_key.unwrap_or(parsed.codepoint); + + // No modifiers - return static string + if effective_mod == 0 { + return format_key_name(effective_codepoint).map(Cow::Borrowed); } - let end = bytes.len(); - let mut idx = 4; - let (mod_value, next_idx) = parse_digits(bytes, idx, end)?; - idx = next_idx; - - if idx < end && bytes[idx] == b':' { - idx += 1; - let (_, next_idx) = parse_digits(bytes, idx, end)?; - idx = next_idx; - } - - if idx + 1 != end || mod_value == 0 { - return None; - } - - let codepoint = match bytes[idx] { - b'H' => FUNC_HOME, - b'F' => FUNC_END, - _ => return None, - }; - - Some(ParsedKittySequence { codepoint, base_layout_key: None, modifier: mod_value - 1 }) + let key_name = format_key_name(effective_codepoint)?; + Some(Cow::Owned(format_with_mods(effective_mod, key_name))) } +#[inline] +fn format_key_name(codepoint: i32) -> Option<&'static str> { + match codepoint { + CP_ESCAPE => Some("escape"), + CP_TAB => Some("tab"), + CP_ENTER | CP_KP_ENTER => Some("enter"), + CP_SPACE => Some("space"), + CP_BACKSPACE => Some("backspace"), + + FUNC_DELETE => Some("delete"), + FUNC_INSERT => Some("insert"), + FUNC_HOME => Some("home"), + FUNC_END => Some("end"), + FUNC_PAGE_UP => Some("pageUp"), + FUNC_PAGE_DOWN => Some("pageDown"), + FUNC_CLEAR => Some("clear"), + + ARROW_UP => Some("up"), + ARROW_DOWN => Some("down"), + ARROW_LEFT => Some("left"), + ARROW_RIGHT => Some("right"), + + FUNC_F1 => Some("f1"), + FUNC_F2 => Some("f2"), + FUNC_F3 => Some("f3"), + FUNC_F4 => Some("f4"), + FUNC_F5 => Some("f5"), + FUNC_F6 => Some("f6"), + FUNC_F7 => Some("f7"), + FUNC_F8 => Some("f8"), + FUNC_F9 => Some("f9"), + FUNC_F10 => Some("f10"), + FUNC_F11 => Some("f11"), + FUNC_F12 => Some("f12"), + + // Any printable ASCII can be represented without allocation via the static table. + 33..=126 => Some(ASCII_PRINTABLE[(codepoint - 33) as usize]), + + // Keep lowercase letters as a fast/consistent path (optional): + 97..=122 => Some(LETTERS[(codepoint - 97) as usize]), + + _ => None, + } +} + +#[inline] +fn format_with_mods(mods: u32, key_name: &str) -> String { + let mut result = String::with_capacity(16); + if mods & MOD_SHIFT != 0 { + result.push_str("shift+"); + } + if mods & MOD_CTRL != 0 { + result.push_str("ctrl+"); + } + if mods & MOD_ALT != 0 { + result.push_str("alt+"); + } + result.push_str(key_name); + result +} + +// ============================================================================= +// Digit Parsing Helpers +// ============================================================================= + +#[inline] fn parse_digits(bytes: &[u8], mut idx: usize, end: usize) -> Option<(u32, usize)> { if idx >= end || !bytes[idx].is_ascii_digit() { return None; @@ -237,17 +1165,10 @@ fn parse_digits(bytes: &[u8], mut idx: usize, end: usize) -> Option<(u32, usize) Some((value, idx)) } +#[inline] fn parse_optional_digits(bytes: &[u8], idx: usize, end: usize) -> (Option, usize) { if idx >= end || !bytes[idx].is_ascii_digit() { return (None, idx); } - - let Some((value, next_idx)) = parse_digits(bytes, idx, end) else { - return (None, idx); - }; - (Some(value), next_idx) -} - -fn to_i32(value: u32) -> Option { - i32::try_from(value).ok() + parse_digits(bytes, idx, end).map_or((None, idx), |(v, i)| (Some(v), i)) } diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index cebd01386..c9d7cf2b3 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog ## [Unreleased] + +### Added + +- Exported keyboard parsing and matching functions: `parseKey`, `parseKittySequence`, `matchesLegacySequence`, and `matchesKey` for terminal input handling +- Exported `KeyEventType` enum and `ParsedKittyResult` type for Kitty keyboard protocol support +- Added `parseKey` function to parse terminal input and return normalized key identifiers (e.g., "ctrl+c", "shift+tab") +- Added `parseKittySequence` function to parse Kitty keyboard protocol sequences with codepoint, modifier, and event type information +- Added `matchesLegacySequence` function to match legacy escape sequences for specific keys +- Added `matchesKey` function to match input against key identifiers with support for modifiers and Kitty protocol + ### Changed - Improved native binary installation to use atomic rename operations and better fallback handling for Windows DLLs diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts index e92dcd40d..f41733a8b 100644 --- a/packages/natives/src/index.ts +++ b/packages/natives/src/index.ts @@ -97,7 +97,15 @@ export { // Keyboard sequence helpers // ============================================================================= -export { matchesKittySequence } from "./keys/index"; +export { + type KeyEventType, + matchesKey, + matchesKittySequence, + matchesLegacySequence, + type ParsedKittyResult, + parseKey, + parseKittySequence, +} from "./keys/index"; // ============================================================================= // HTML to Markdown @@ -107,5 +115,3 @@ export { type HtmlToMarkdownOptions, htmlToMarkdown, } from "./html/index"; - -export type { RequestOptions } from "./request-options"; diff --git a/packages/natives/src/keys/index.ts b/packages/natives/src/keys/index.ts index 41db43814..8d99fe080 100644 --- a/packages/natives/src/keys/index.ts +++ b/packages/natives/src/keys/index.ts @@ -2,9 +2,63 @@ * Keyboard sequence utilities powered by native bindings. */ -import { native } from "../native"; +import { type KeyEventType, native, type ParsedKittyResult } from "../native"; + +export type { KeyEventType, ParsedKittyResult }; /** Match Kitty protocol sequences for codepoint and modifier. */ export function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean { return native.matchesKittySequence(data, expectedCodepoint, expectedModifier); } + +/** + * Parse a Kitty keyboard protocol sequence. + * + * @param data - Raw escape sequence from terminal + * @returns Parsed sequence with codepoint, modifier, and event type, or undefined if not a valid Kitty sequence + */ +export function parseKittySequence(data: string): ParsedKittyResult | undefined { + return native.parseKittySequence(data) ?? undefined; +} + +/** + * Parse terminal input and return a normalized key identifier. + * + * Returns key names like "escape", "ctrl+c", "shift+tab", "alt+enter". + * Returns undefined if the input is not a recognized key sequence. + * + * @param data - Raw input data from terminal + * @param kittyProtocolActive - Whether Kitty keyboard protocol is active + */ +export function parseKey(data: string, kittyProtocolActive: boolean): string | undefined { + return native.parseKey(data, kittyProtocolActive) ?? undefined; +} + +/** + * Check if input matches a legacy escape sequence for a specific key. + * + * @param data - Raw input data from terminal + * @param keyName - Key name to match (e.g., "up", "f1", "ctrl+up") + */ +export function matchesLegacySequence(data: string, keyName: string): boolean { + return native.matchesLegacySequence(data, keyName); +} + +/** + * Match input data against a key identifier string. + * + * Supported key identifiers: + * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space" + * - Arrow keys: "up", "down", "left", "right" + * - Ctrl combinations: "ctrl+c", "ctrl+z", etc. + * - Shift combinations: "shift+tab", "shift+enter" + * - Alt combinations: "alt+enter", "alt+backspace" + * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x" + * + * @param data - Raw input data from terminal + * @param keyId - Key identifier (e.g., "ctrl+c", "escape") + * @param kittyProtocolActive - Whether Kitty keyboard protocol is active + */ +export function matchesKey(data: string, keyId: string, kittyProtocolActive: boolean): boolean { + return native.matchesKey(data, keyId, kittyProtocolActive); +} diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index 89f4b8f86..4f8d7fd62 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -13,6 +13,26 @@ import type { HighlightColors } from "./highlight/index"; import type { HtmlToMarkdownOptions } from "./html/types"; import type { ExtractSegmentsResult, SliceWithWidthResult } from "./text/index"; +export type { RequestOptions } from "./request-options"; + +/** + * Event types from Kitty keyboard protocol (flag 2) + * 1 = key press, 2 = key repeat, 3 = key release + */ +export const enum KeyEventType { + Press = 1, + Repeat = 2, + Release = 3, +} +/** Parsed Kitty keyboard protocol sequence result. */ +export interface ParsedKittyResult { + codepoint: number; + shiftedKey?: number; + baseLayoutKey?: number; + modifier: number; + eventType?: KeyEventType; +} + export interface NativePhotonImage { getWidth(): number; getHeight(): number; @@ -66,6 +86,10 @@ export interface NativeBindings { strictAfter: boolean, ): ExtractSegmentsResult; matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean; + parseKey(data: string, kittyProtocolActive: boolean): string | null; + matchesLegacySequence(data: string, keyName: string): boolean; + parseKittySequence(data: string): ParsedKittyResult | null; + matchesKey(data: string, keyId: string, kittyProtocolActive: boolean): boolean; } const require = createRequire(import.meta.url); @@ -141,6 +165,11 @@ function validateNative(bindings: NativeBindings, source: string): void { checkFn("sliceWithWidth"); checkFn("extractSegments"); checkFn("matchesKittySequence"); + checkFn("parseKey"); + checkFn("matchesLegacySequence"); + checkFn("parseKittySequence"); + checkFn("matchesKey"); + checkFn("visibleWidth"); if (missing.length) { throw new Error( diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4de6c1617..ace448287 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Removed `Key` helper object from public API; use string literals like `"ctrl+c"` instead of `Key.ctrl("c")` +- Removed `KeyEventType` export from public API + +### Changed + +- Migrated key parsing and matching logic to native implementation for improved performance +- Simplified `isKeyRelease()` and `isKeyRepeat()` to use regex pattern matching instead of string inclusion checks ## [9.6.2] - 2026-02-01 ### Changed diff --git a/packages/tui/bench/_jskey.ts b/packages/tui/bench/_jskey.ts new file mode 100644 index 000000000..2905c8c95 --- /dev/null +++ b/packages/tui/bench/_jskey.ts @@ -0,0 +1,1150 @@ +/** + * Keyboard input handling for terminal applications. + * + * Supports both legacy terminal sequences and Kitty keyboard protocol. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + * Reference: https://github.com/sst/opentui/blob/7da92b4088aebfe27b9f691c04163a48821e49fd/packages/core/src/lib/parse.keypress.ts + * + * Symbol keys are also supported, however some ctrl+symbol combos + * overlap with ASCII codes, e.g. ctrl+[ = ESC. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys + * Those can still be * used for ctrl+shift combos + * + * API: + * - matchesKey(data, keyId) - Check if input matches a key identifier + * - parseKey(data) - Parse input and return the key identifier + * - Key - Helper object for creating typed key identifiers + * - setKittyProtocolActive(active) - Set global Kitty protocol state + * - isKittyProtocolActive() - Query global Kitty protocol state + */ + +// ============================================================================= +// Global Kitty Protocol State +// ============================================================================= + +let _kittyProtocolActive = false; + +/** + * Set the global Kitty keyboard protocol state. + * Called by ProcessTerminal after detecting protocol support. + */ +export function setKittyProtocolActive(active: boolean): void { + _kittyProtocolActive = active; +} + +/** + * Query whether Kitty keyboard protocol is currently active. + */ +export function isKittyProtocolActive(): boolean { + return _kittyProtocolActive; +} + +// ============================================================================= +// Type-Safe Key Identifiers +// ============================================================================= + +type Letter = + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z"; + +type SymbolKey = + | "`" + | "-" + | "=" + | "[" + | "]" + | "\\" + | ";" + | "'" + | "," + | "." + | "/" + | "!" + | "@" + | "#" + | "$" + | "%" + | "^" + | "&" + | "*" + | "(" + | ")" + | "_" + | "+" + | "|" + | "~" + | "{" + | "}" + | ":" + | "<" + | ">" + | "?"; + +type SpecialKey = + | "escape" + | "esc" + | "enter" + | "return" + | "tab" + | "space" + | "backspace" + | "delete" + | "insert" + | "clear" + | "home" + | "end" + | "pageUp" + | "pageDown" + | "up" + | "down" + | "left" + | "right" + | "f1" + | "f2" + | "f3" + | "f4" + | "f5" + | "f6" + | "f7" + | "f8" + | "f9" + | "f10" + | "f11" + | "f12"; + +type BaseKey = Letter | SymbolKey | SpecialKey; + +/** + * Union type of all valid key identifiers. + * Provides autocomplete and catches typos at compile time. + */ +export type KeyId = + | BaseKey + | `ctrl+${BaseKey}` + | `shift+${BaseKey}` + | `alt+${BaseKey}` + | `ctrl+shift+${BaseKey}` + | `shift+ctrl+${BaseKey}` + | `ctrl+alt+${BaseKey}` + | `alt+ctrl+${BaseKey}` + | `shift+alt+${BaseKey}` + | `alt+shift+${BaseKey}` + | `ctrl+shift+alt+${BaseKey}` + | `ctrl+alt+shift+${BaseKey}` + | `shift+ctrl+alt+${BaseKey}` + | `shift+alt+ctrl+${BaseKey}` + | `alt+ctrl+shift+${BaseKey}` + | `alt+shift+ctrl+${BaseKey}`; + +/** + * Helper object for creating typed key identifiers with autocomplete. + * + * Usage: + * - Key.escape, Key.enter, Key.tab, etc. for special keys + * - Key.backtick, Key.comma, Key.period, etc. for symbol keys + * - Key.ctrl("c"), Key.alt("x") for single modifier + * - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers + */ +export const Key = { + // Special keys + escape: "escape" as const, + esc: "esc" as const, + enter: "enter" as const, + return: "return" as const, + tab: "tab" as const, + space: "space" as const, + backspace: "backspace" as const, + delete: "delete" as const, + insert: "insert" as const, + clear: "clear" as const, + home: "home" as const, + end: "end" as const, + pageUp: "pageUp" as const, + pageDown: "pageDown" as const, + up: "up" as const, + down: "down" as const, + left: "left" as const, + right: "right" as const, + f1: "f1" as const, + f2: "f2" as const, + f3: "f3" as const, + f4: "f4" as const, + f5: "f5" as const, + f6: "f6" as const, + f7: "f7" as const, + f8: "f8" as const, + f9: "f9" as const, + f10: "f10" as const, + f11: "f11" as const, + f12: "f12" as const, + + // Symbol keys + backtick: "`" as const, + hyphen: "-" as const, + equals: "=" as const, + leftbracket: "[" as const, + rightbracket: "]" as const, + backslash: "\\" as const, + semicolon: ";" as const, + quote: "'" as const, + comma: "," as const, + period: "." as const, + slash: "/" as const, + exclamation: "!" as const, + at: "@" as const, + hash: "#" as const, + dollar: "$" as const, + percent: "%" as const, + caret: "^" as const, + ampersand: "&" as const, + asterisk: "*" as const, + leftparen: "(" as const, + rightparen: ")" as const, + underscore: "_" as const, + plus: "+" as const, + pipe: "|" as const, + tilde: "~" as const, + leftbrace: "{" as const, + rightbrace: "}" as const, + colon: ":" as const, + lessthan: "<" as const, + greaterthan: ">" as const, + question: "?" as const, + + // Single modifiers + ctrl: (key: K): `ctrl+${K}` => `ctrl+${key}`, + shift: (key: K): `shift+${K}` => `shift+${key}`, + alt: (key: K): `alt+${K}` => `alt+${key}`, + + // Combined modifiers + ctrlShift: (key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`, + shiftCtrl: (key: K): `shift+ctrl+${K}` => `shift+ctrl+${key}`, + ctrlAlt: (key: K): `ctrl+alt+${K}` => `ctrl+alt+${key}`, + altCtrl: (key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`, + shiftAlt: (key: K): `shift+alt+${K}` => `shift+alt+${key}`, + altShift: (key: K): `alt+shift+${K}` => `alt+shift+${key}`, + + // Triple modifiers + ctrlShiftAlt: (key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`, +} as const; + +// ============================================================================= +// Constants +// ============================================================================= + +const SYMBOL_KEYS = new Set([ + "`", + "-", + "=", + "[", + "]", + "\\", + ";", + "'", + ",", + ".", + "/", + "!", + "@", + "#", + "$", + "%", + "^", + "&", + "*", + "(", + ")", + "_", + "+", + "|", + "~", + "{", + "}", + ":", + "<", + ">", + "?", +]); + +const CTRL_SYMBOL_MAP: Record = { + "@": "\x00", + "[": "\x1b", + "\\": "\x1c", + "]": "\x1d", + "^": "\x1e", + _: "\x1f", + "-": "\x1f", +} as const; + +const CTRL_SYMBOL_CODES: Record = { + 28: "ctrl+\\", + 29: "ctrl+]", + 30: "ctrl+^", + 31: "ctrl+_", +} as const; + +const MODIFIERS = { + shift: 1, + alt: 2, + ctrl: 4, +} as const; + +const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock + +const CODEPOINTS = { + escape: 27, + tab: 9, + enter: 13, + space: 32, + backspace: 127, + kpEnter: 57414, // Numpad Enter (Kitty protocol) +} as const; + +const ARROW_CODEPOINTS = { + up: -1, + down: -2, + right: -3, + left: -4, +} as const; + +const FUNCTIONAL_CODEPOINTS = { + delete: -10, + insert: -11, + pageUp: -12, + pageDown: -13, + home: -14, + end: -15, +} as const; + +const LEGACY_KEY_SEQUENCES = { + up: ["\x1b[A", "\x1bOA"], + down: ["\x1b[B", "\x1bOB"], + right: ["\x1b[C", "\x1bOC"], + left: ["\x1b[D", "\x1bOD"], + home: ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"], + end: ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"], + insert: ["\x1b[2~"], + delete: ["\x1b[3~"], + pageUp: ["\x1b[5~", "\x1b[[5~"], + pageDown: ["\x1b[6~", "\x1b[[6~"], + clear: ["\x1b[E", "\x1bOE"], + f1: ["\x1bOP", "\x1b[11~", "\x1b[[A"], + f2: ["\x1bOQ", "\x1b[12~", "\x1b[[B"], + f3: ["\x1bOR", "\x1b[13~", "\x1b[[C"], + f4: ["\x1bOS", "\x1b[14~", "\x1b[[D"], + f5: ["\x1b[15~", "\x1b[[E"], + f6: ["\x1b[17~"], + f7: ["\x1b[18~"], + f8: ["\x1b[19~"], + f9: ["\x1b[20~"], + f10: ["\x1b[21~"], + f11: ["\x1b[23~"], + f12: ["\x1b[24~"], +} as const; + +const LEGACY_SHIFT_SEQUENCES = { + up: ["\x1b[a"], + down: ["\x1b[b"], + right: ["\x1b[c"], + left: ["\x1b[d"], + clear: ["\x1b[e"], + insert: ["\x1b[2$"], + delete: ["\x1b[3$"], + pageUp: ["\x1b[5$"], + pageDown: ["\x1b[6$"], + home: ["\x1b[7$"], + end: ["\x1b[8$"], +} as const; + +const LEGACY_CTRL_SEQUENCES = { + up: ["\x1bOa"], + down: ["\x1bOb"], + right: ["\x1bOc"], + left: ["\x1bOd"], + clear: ["\x1bOe"], + insert: ["\x1b[2^"], + delete: ["\x1b[3^"], + pageUp: ["\x1b[5^"], + pageDown: ["\x1b[6^"], + home: ["\x1b[7^"], + end: ["\x1b[8^"], +} as const; + +const LEGACY_SEQUENCE_KEY_IDS: Record = { + "\x1bOA": "up", + "\x1bOB": "down", + "\x1bOC": "right", + "\x1bOD": "left", + "\x1bOH": "home", + "\x1bOF": "end", + "\x1b[E": "clear", + "\x1bOE": "clear", + "\x1bOe": "ctrl+clear", + "\x1b[e": "shift+clear", + "\x1b[2~": "insert", + "\x1b[2$": "shift+insert", + "\x1b[2^": "ctrl+insert", + "\x1b[3$": "shift+delete", + "\x1b[3^": "ctrl+delete", + "\x1b[[5~": "pageUp", + "\x1b[[6~": "pageDown", + "\x1b[a": "shift+up", + "\x1b[b": "shift+down", + "\x1b[c": "shift+right", + "\x1b[d": "shift+left", + "\x1bOa": "ctrl+up", + "\x1bOb": "ctrl+down", + "\x1bOc": "ctrl+right", + "\x1bOd": "ctrl+left", + "\x1b[5$": "shift+pageUp", + "\x1b[6$": "shift+pageDown", + "\x1b[7$": "shift+home", + "\x1b[8$": "shift+end", + "\x1b[5^": "ctrl+pageUp", + "\x1b[6^": "ctrl+pageDown", + "\x1b[7^": "ctrl+home", + "\x1b[8^": "ctrl+end", + "\x1bOP": "f1", + "\x1bOQ": "f2", + "\x1bOR": "f3", + "\x1bOS": "f4", + "\x1b[11~": "f1", + "\x1b[12~": "f2", + "\x1b[13~": "f3", + "\x1b[14~": "f4", + "\x1b[[A": "f1", + "\x1b[[B": "f2", + "\x1b[[C": "f3", + "\x1b[[D": "f4", + "\x1b[[E": "f5", + "\x1b[15~": "f5", + "\x1b[17~": "f6", + "\x1b[18~": "f7", + "\x1b[19~": "f8", + "\x1b[20~": "f9", + "\x1b[21~": "f10", + "\x1b[23~": "f11", + "\x1b[24~": "f12", + "\x1bb": "alt+left", + "\x1bf": "alt+right", + "\x1bp": "alt+up", + "\x1bn": "alt+down", +} as const; + +type LegacyModifierKey = keyof typeof LEGACY_SHIFT_SEQUENCES; + +const matchesLegacySequence = (data: string, sequences: readonly string[]): boolean => sequences.includes(data); + +const matchesLegacyModifierSequence = (data: string, key: LegacyModifierKey, modifier: number): boolean => { + if (modifier === MODIFIERS.shift) { + return matchesLegacySequence(data, LEGACY_SHIFT_SEQUENCES[key]); + } + if (modifier === MODIFIERS.ctrl) { + return matchesLegacySequence(data, LEGACY_CTRL_SEQUENCES[key]); + } + return false; +}; + +// ============================================================================= +// Kitty Protocol Parsing +// ============================================================================= + +/** + * Event types from Kitty keyboard protocol (flag 2) + * 1 = key press, 2 = key repeat, 3 = key release + */ +export type KeyEventType = "press" | "repeat" | "release"; + +interface ParsedKittySequence { + codepoint: number; + shiftedKey?: number; // Shifted version of the key (when shift is pressed) + baseLayoutKey?: number; // Key in standard PC-101 layout (for non-Latin layouts) + modifier: number; + eventType: KeyEventType; +} + +// Store the last parsed event type for isKeyRelease() to query +let _lastEventType: KeyEventType = "press"; + +/** + * Check if the last parsed key event was a key release. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRelease(data: string): boolean { + // Don't treat bracketed paste content as key release, even if it contains + // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5"). + // Terminal.ts re-wraps paste content with bracketed paste markers before + // passing to TUI, so pasted data will always contain \x1b[200~. + if (data.includes("\x1b[200~")) { + return false; + } + + // Quick check: release events with flag 2 contain ":3" + // Format: \x1b[;:3u + if ( + data.includes(":3u") || + data.includes(":3~") || + data.includes(":3A") || + data.includes(":3B") || + data.includes(":3C") || + data.includes(":3D") || + data.includes(":3H") || + data.includes(":3F") + ) { + return true; + } + return false; +} + +/** + * Check if the last parsed key event was a key repeat. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRepeat(data: string): boolean { + // Don't treat bracketed paste content as key repeat, even if it contains + // patterns like ":2F". See isKeyRelease() for details. + if (data.includes("\x1b[200~")) { + return false; + } + + if ( + data.includes(":2u") || + data.includes(":2~") || + data.includes(":2A") || + data.includes(":2B") || + data.includes(":2C") || + data.includes(":2D") || + data.includes(":2H") || + data.includes(":2F") + ) { + return true; + } + return false; +} + +function parseEventType(eventTypeStr: string | undefined): KeyEventType { + if (!eventTypeStr) return "press"; + const eventType = parseInt(eventTypeStr, 10); + if (eventType === 2) return "repeat"; + if (eventType === 3) return "release"; + return "press"; +} + +export function parseKittySequence(data: string): ParsedKittySequence | null { + // CSI u format with alternate keys (flag 4): + // \x1b[u + // \x1b[;u + // \x1b[;:u + // \x1b[:;u + // \x1b[::;u + // \x1b[::;u (no shifted key, only base) + // + // With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release + // With flag 4, alternate keys are appended after codepoint with colons + const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/); + if (csiUMatch) { + const codepoint = parseInt(csiUMatch[1]!, 10); + const shiftedKey = csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined; + const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined; + const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1; + const eventType = parseEventType(csiUMatch[5]); + _lastEventType = eventType; + return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType }; + } + + // Arrow keys with modifier: \x1b[1;A/B/C/D or \x1b[1;:A/B/C/D + const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/); + if (arrowMatch) { + const modValue = parseInt(arrowMatch[1]!, 10); + const eventType = parseEventType(arrowMatch[2]); + const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; + _lastEventType = eventType; + return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }; + } + + // Functional keys: \x1b[~ or \x1b[;~ or \x1b[;:~ + const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/); + if (funcMatch) { + const keyNum = parseInt(funcMatch[1]!, 10); + const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1; + const eventType = parseEventType(funcMatch[3]); + const funcCodes: Record = { + 2: FUNCTIONAL_CODEPOINTS.insert, + 3: FUNCTIONAL_CODEPOINTS.delete, + 5: FUNCTIONAL_CODEPOINTS.pageUp, + 6: FUNCTIONAL_CODEPOINTS.pageDown, + 7: FUNCTIONAL_CODEPOINTS.home, + 8: FUNCTIONAL_CODEPOINTS.end, + }; + const codepoint = funcCodes[keyNum]; + if (codepoint !== undefined) { + _lastEventType = eventType; + return { codepoint, modifier: modValue - 1, eventType }; + } + } + + // Home/End with modifier: \x1b[1;H/F or \x1b[1;:H/F + const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/); + if (homeEndMatch) { + const modValue = parseInt(homeEndMatch[1]!, 10); + const eventType = parseEventType(homeEndMatch[2]); + const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; + _lastEventType = eventType; + return { codepoint, modifier: modValue - 1, eventType }; + } + + return null; +} + +function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean { + const parsed = parseKittySequence(data); + if (!parsed) return false; + const actualMod = parsed.modifier & ~LOCK_MASK; + const expectedMod = expectedModifier & ~LOCK_MASK; + + // Check if modifiers match + if (actualMod !== expectedMod) return false; + + // Primary match: codepoint matches directly + if (parsed.codepoint === expectedCodepoint) return true; + + // Alternate match: use base layout key for non-Latin keyboard layouts + // This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports + // the base layout key (the key in standard PC-101 layout) + if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) return true; + + return false; +} + +/** + * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~ + * This is used by terminals when Kitty protocol is not enabled. + * Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc. + */ +function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { + const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/); + if (!match) return false; + const modValue = parseInt(match[1]!, 10); + const keycode = parseInt(match[2]!, 10); + // Convert from 1-indexed xterm format to our 0-indexed format + const actualMod = modValue - 1; + return keycode === expectedKeycode && actualMod === expectedModifier; +} + +// ============================================================================= +// Generic Key Matching +// ============================================================================= + +function rawCtrlChar(letter: string): string { + const code = letter.toLowerCase().charCodeAt(0) - 96; + return String.fromCharCode(code); +} + +type ParsedKeyId = { key: string; ctrl: boolean; shift: boolean; alt: boolean }; + +const PARSED_KEY_ID_CACHE = new Map(); + +function parseKeyId(keyId: string): ParsedKeyId | null { + const normalizedKeyId = keyId.toLowerCase(); + const cached = PARSED_KEY_ID_CACHE.get(normalizedKeyId); + if (cached) return cached; + + const parts = normalizedKeyId.split("+"); + const key = parts[parts.length - 1]; + if (!key) return null; + const parsed = { + key, + ctrl: parts.includes("ctrl"), + shift: parts.includes("shift"), + alt: parts.includes("alt"), + }; + PARSED_KEY_ID_CACHE.set(normalizedKeyId, parsed); + return parsed; +} + +/** + * Match input data against a key identifier string. + * + * Supported key identifiers: + * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space" + * - Arrow keys: "up", "down", "left", "right" + * - Ctrl combinations: "ctrl+c", "ctrl+z", etc. + * - Shift combinations: "shift+tab", "shift+enter" + * - Alt combinations: "alt+enter", "alt+backspace" + * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x" + * + * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p") + * + * @param data - Raw input data from terminal + * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) + */ +export function matchesKey(data: string, keyId: KeyId): boolean { + const parsed = parseKeyId(keyId); + if (!parsed) return false; + + const { key, ctrl, shift, alt } = parsed; + let modifier = 0; + if (shift) modifier |= MODIFIERS.shift; + if (alt) modifier |= MODIFIERS.alt; + if (ctrl) modifier |= MODIFIERS.ctrl; + + switch (key) { + case "escape": + case "esc": + if (modifier !== 0) return false; + return data === "\x1b" || matchesKittySequence(data, CODEPOINTS.escape, 0); + + case "space": + if (!_kittyProtocolActive) { + if (ctrl && !alt && !shift && data === "\x00") { + return true; + } + if (alt && !ctrl && !shift && data === "\x1b ") { + return true; + } + } + if (modifier === 0) { + return data === " " || matchesKittySequence(data, CODEPOINTS.space, 0); + } + return matchesKittySequence(data, CODEPOINTS.space, modifier); + + case "tab": + if (shift && !ctrl && !alt) { + return data === "\x1b[Z" || matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift); + } + if (modifier === 0) { + return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0); + } + return matchesKittySequence(data, CODEPOINTS.tab, modifier); + + case "enter": + case "return": + if (shift && !ctrl && !alt) { + // CSI u sequences (standard Kitty protocol) + if ( + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift) + ) { + return true; + } + // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) + if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) { + return true; + } + // When Kitty protocol is active, legacy sequences are custom terminal mappings + // \x1b\r = Kitty's "map shift+enter send_text all \e\r" + // \n = Ghostty's "keybind = shift+enter=text:\n" + if (_kittyProtocolActive) { + return data === "\x1b\r" || data === "\n"; + } + return false; + } + if (alt && !ctrl && !shift) { + // CSI u sequences (standard Kitty protocol) + if ( + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt) + ) { + return true; + } + // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) + if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.alt)) { + return true; + } + // \x1b\r is alt+enter only in legacy mode (no Kitty protocol) + // When Kitty protocol is active, alt+enter comes as CSI u sequence + if (!_kittyProtocolActive) { + return data === "\x1b\r"; + } + return false; + } + if (modifier === 0) { + return ( + data === "\r" || + (!_kittyProtocolActive && data === "\n") || + data === "\x1bOM" || // SS3 M (numpad enter in some terminals) + matchesKittySequence(data, CODEPOINTS.enter, 0) || + matchesKittySequence(data, CODEPOINTS.kpEnter, 0) + ); + } + return ( + matchesKittySequence(data, CODEPOINTS.enter, modifier) || + matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) + ); + + case "backspace": + if (alt && !ctrl && !shift) { + if (data === "\x1b\x7f" || data === "\x1b\b") { + return true; + } + return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt); + } + if (modifier === 0) { + return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0); + } + return ( + matchesKittySequence(data, CODEPOINTS.backspace, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, modifier) + ); + + case "insert": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.insert) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, 0) + ); + } + if (matchesLegacyModifierSequence(data, "insert", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, modifier); + + case "delete": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.delete) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0) + ); + } + if (matchesLegacyModifierSequence(data, "delete", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, modifier); + + case "clear": + if (modifier === 0) { + return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.clear); + } + return matchesLegacyModifierSequence(data, "clear", modifier); + + case "home": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.home) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) + ); + } + if (matchesLegacyModifierSequence(data, "home", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, modifier); + + case "end": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.end) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) + ); + } + if (matchesLegacyModifierSequence(data, "end", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier); + + case "pageup": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageUp) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0) + ); + } + if (matchesLegacyModifierSequence(data, "pageUp", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, modifier); + + case "pagedown": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageDown) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0) + ); + } + if (matchesLegacyModifierSequence(data, "pageDown", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier); + + case "up": + if (alt && !ctrl && !shift) { + return data === "\x1bp" || matchesKittySequence(data, ARROW_CODEPOINTS.up, MODIFIERS.alt); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.up) || + matchesKittySequence(data, ARROW_CODEPOINTS.up, 0) + ); + } + if (matchesLegacyModifierSequence(data, "up", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier); + + case "down": + if (alt && !ctrl && !shift) { + return data === "\x1bn" || matchesKittySequence(data, ARROW_CODEPOINTS.down, MODIFIERS.alt); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.down) || + matchesKittySequence(data, ARROW_CODEPOINTS.down, 0) + ); + } + if (matchesLegacyModifierSequence(data, "down", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier); + + case "left": + if (alt && !ctrl && !shift) { + return ( + data === "\x1b[1;3D" || + (!_kittyProtocolActive && data === "\x1bB") || + data === "\x1bb" || + matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt) + ); + } + if (ctrl && !alt && !shift) { + return ( + data === "\x1b[1;5D" || + matchesLegacyModifierSequence(data, "left", MODIFIERS.ctrl) || + matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl) + ); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.left) || + matchesKittySequence(data, ARROW_CODEPOINTS.left, 0) + ); + } + if (matchesLegacyModifierSequence(data, "left", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier); + + case "right": + if (alt && !ctrl && !shift) { + return ( + data === "\x1b[1;3C" || + (!_kittyProtocolActive && data === "\x1bF") || + data === "\x1bf" || + matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt) + ); + } + if (ctrl && !alt && !shift) { + return ( + data === "\x1b[1;5C" || + matchesLegacyModifierSequence(data, "right", MODIFIERS.ctrl) || + matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl) + ); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.right) || + matchesKittySequence(data, ARROW_CODEPOINTS.right, 0) + ); + } + if (matchesLegacyModifierSequence(data, "right", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.right, modifier); + + case "f1": + case "f2": + case "f3": + case "f4": + case "f5": + case "f6": + case "f7": + case "f8": + case "f9": + case "f10": + case "f11": + case "f12": { + if (modifier !== 0) { + return false; + } + const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES; + return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey]); + } + } + + // Handle single letter keys (a-z) and some symbols + if (key.length === 1 && ((key >= "a" && key <= "z") || SYMBOL_KEYS.has(key))) { + const codepoint = key.charCodeAt(0); + const isLetterKey = key >= "a" && key <= "z"; + + if (ctrl && alt && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { + return data === `\x1b${rawCtrlChar(key)}`; + } + + if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { + // Legacy: alt+letter is ESC followed by the letter + if (data === `\x1b${key}`) return true; + } + + if (ctrl && !shift && !alt) { + if (!isLetterKey) { + const legacyCtrl = CTRL_SYMBOL_MAP[key]; + if (legacyCtrl && data === legacyCtrl) return true; + if (matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)) return true; + return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); + } + const raw = rawCtrlChar(key); + if (data === raw) return true; + if (data.length > 0 && data.charCodeAt(0) === raw.charCodeAt(0)) return true; + if (matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)) return true; + return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); + } + + if (ctrl && shift && !alt) { + return matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl); + } + + if (shift && !ctrl && !alt) { + // Legacy: shift+letter produces uppercase + if (data === key.toUpperCase()) return true; + return matchesKittySequence(data, codepoint, MODIFIERS.shift); + } + + if (modifier !== 0) { + return matchesKittySequence(data, codepoint, modifier); + } + + // Check both raw char and Kitty sequence (needed for release events) + return data === key || matchesKittySequence(data, codepoint, 0); + } + + return false; +} + +/** + * Parse input data and return the key identifier if recognized. + * + * @param data - Raw input data from terminal + * @returns Key identifier string (e.g., "ctrl+c") or undefined + */ +export function parseKey(data: string): string | undefined { + const kitty = parseKittySequence(data); + if (kitty) { + const { codepoint, baseLayoutKey, modifier } = kitty; + const mods: string[] = []; + const effectiveMod = modifier & ~LOCK_MASK; + if (effectiveMod & MODIFIERS.shift) mods.push("shift"); + if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); + if (effectiveMod & MODIFIERS.alt) mods.push("alt"); + + // Prefer base layout key for consistent shortcut naming across keyboard layouts + // This ensures Ctrl+С (Cyrillic) is reported as "ctrl+c" (Latin) + const effectiveCodepoint = baseLayoutKey ?? codepoint; + + let keyName: string | undefined; + if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape"; + else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab"; + else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter"; + else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space"; + else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right"; + else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint); + else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint))) + keyName = String.fromCharCode(effectiveCodepoint); + + if (keyName) { + return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; + } + } + + // Mode-aware legacy sequences + // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings: + // - \x1b\r = shift+enter (Kitty mapping), not alt+enter + // - \n = shift+enter (Ghostty mapping) + if (_kittyProtocolActive) { + if (data === "\x1b\r" || data === "\n") return "shift+enter"; + } + + const legacySequenceKeyId = LEGACY_SEQUENCE_KEY_IDS[data]; + if (legacySequenceKeyId) return legacySequenceKeyId; + + // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences) + if (data === "\x1b") return "escape"; + if (data === "\t") return "tab"; + if (data === "\r" || (!_kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter"; + if (data === "\x00") return "ctrl+space"; + if (data === " ") return "space"; + if (data === "\x7f" || data === "\x08") return "backspace"; + if (data === "\x1b[Z") return "shift+tab"; + if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter"; + if (!_kittyProtocolActive && data === "\x1b ") return "alt+space"; + if (data === "\x1b\x7f" || data === "\x1b\b") return "alt+backspace"; + if (!_kittyProtocolActive && data === "\x1bB") return "alt+left"; + if (!_kittyProtocolActive && data === "\x1bF") return "alt+right"; + if (!_kittyProtocolActive && data.length === 2 && data[0] === "\x1b") { + const code = data.charCodeAt(1); + if (code >= 1 && code <= 26) { + return `ctrl+alt+${String.fromCharCode(code + 96)}`; + } + // Legacy alt+letter (ESC followed by letter a-z) + if (code >= 97 && code <= 122) { + return `alt+${String.fromCharCode(code)}`; + } + } + if (data === "\x1b[A") return "up"; + if (data === "\x1b[B") return "down"; + if (data === "\x1b[C") return "right"; + if (data === "\x1b[D") return "left"; + if (data === "\x1b[H" || data === "\x1bOH") return "home"; + if (data === "\x1b[F" || data === "\x1bOF") return "end"; + if (data === "\x1b[3~") return "delete"; + if (data === "\x1b[5~") return "pageUp"; + if (data === "\x1b[6~") return "pageDown"; + + // Raw Ctrl+letter + if (data.length === 1) { + const code = data.charCodeAt(0); + const ctrlSymbol = CTRL_SYMBOL_CODES[code]; + if (ctrlSymbol) { + return ctrlSymbol; + } + if (code >= 1 && code <= 26) { + return `ctrl+${String.fromCharCode(code + 96)}`; + } + if (code >= 32 && code <= 126) { + return data; + } + } + + return undefined; +} diff --git a/packages/tui/bench/parse-key.ts b/packages/tui/bench/parse-key.ts new file mode 100644 index 000000000..455ec1642 --- /dev/null +++ b/packages/tui/bench/parse-key.ts @@ -0,0 +1,116 @@ +import { parseKey as nativeParseKey } from "@oh-my-pi/pi-natives"; +import * as native from "../src/keys"; +import * as js from "./_jskey"; + +const ITERATIONS = 2000; + +// Test cases covering various input types +const samples = [ + // Kitty protocol sequences + { name: "kitty ctrl+a", data: "\x1b[97;5u", expected: "ctrl+a" }, + { name: "kitty shift+tab", data: "\x1b[9;2u", expected: "shift+tab" }, + { name: "kitty alt+enter", data: "\x1b[13;3u", expected: "alt+enter" }, + { name: "kitty ctrl+right", data: "\x1b[1;5C", expected: "ctrl+right" }, + { name: "kitty shift+delete", data: "\x1b[3;2~", expected: "shift+delete" }, + { name: "kitty base-layout", data: "\x1b[108::97;5u", expected: "ctrl+a" }, + + // Legacy sequences + { name: "legacy escape", data: "\x1b", expected: "escape" }, + { name: "legacy tab", data: "\t", expected: "tab" }, + { name: "legacy enter", data: "\r", expected: "enter" }, + { name: "legacy space", data: " ", expected: "space" }, + { name: "legacy backspace", data: "\x7f", expected: "backspace" }, + { name: "legacy shift+tab", data: "\x1b[Z", expected: "shift+tab" }, + { name: "legacy up", data: "\x1b[A", expected: "up" }, + { name: "legacy down", data: "\x1b[B", expected: "down" }, + { name: "legacy left", data: "\x1b[D", expected: "left" }, + { name: "legacy right", data: "\x1b[C", expected: "right" }, + { name: "legacy home", data: "\x1b[H", expected: "home" }, + { name: "legacy end", data: "\x1b[F", expected: "end" }, + { name: "legacy delete", data: "\x1b[3~", expected: "delete" }, + { name: "legacy pageUp", data: "\x1b[5~", expected: "pageUp" }, + { name: "legacy pageDown", data: "\x1b[6~", expected: "pageDown" }, + + // Function keys + { name: "legacy f1", data: "\x1bOP", expected: "f1" }, + { name: "legacy f5", data: "\x1b[15~", expected: "f5" }, + { name: "legacy f12", data: "\x1b[24~", expected: "f12" }, + + // Ctrl sequences + { name: "ctrl+c", data: "\x03", expected: "ctrl+c" }, + { name: "ctrl+z", data: "\x1a", expected: "ctrl+z" }, + { name: "ctrl+space", data: "\x00", expected: "ctrl+space" }, + + // Alt sequences (legacy mode) + { name: "alt+backspace", data: "\x1b\x7f", expected: "alt+backspace" }, + { name: "alt+left", data: "\x1bb", expected: "alt+left" }, + { name: "alt+right", data: "\x1bf", expected: "alt+right" }, + + // Arrow with modifiers (legacy) + { name: "shift+up", data: "\x1b[a", expected: "shift+up" }, + { name: "ctrl+up", data: "\x1bOa", expected: "ctrl+up" }, + + // Printable characters + { name: "letter a", data: "a", expected: "a" }, + { name: "letter z", data: "z", expected: "z" }, + { name: "symbol /", data: "/", expected: "/" }, +]; + +function bench(name: string, fn: () => void): number { + const start = performance.now(); + for (let i = 0; i < ITERATIONS; i++) { + fn(); + } + const elapsed = performance.now() - start; + const perOp = (elapsed / ITERATIONS).toFixed(4); + console.log(`${name}: ${elapsed.toFixed(2)}ms total (${perOp}ms/op)`); + return elapsed; +} + +// Set to legacy mode for consistent comparison +js.setKittyProtocolActive(true); +native.setKittyProtocolActive(true); + +console.log(`parseKey benchmark (${ITERATIONS} iterations, ${samples.length} samples each)\n`); + +// Verify correctness first +let mismatches = 0; +for (const sample of samples) { + const jsResult = js.parseKey(sample.data); + const nativeResult = nativeParseKey(sample.data, false); + if (jsResult !== nativeResult) { + console.log(`MISMATCH ${sample.name}: js="${jsResult}" native="${nativeResult}" expected="${sample.expected}"`); + mismatches++; + } +} +if (mismatches > 0) { + console.log(`\n${mismatches} mismatches found!\n`); +} else { + console.log("All results match.\n"); +} + +const jsTime = bench("js/parseKey", () => { + for (const sample of samples) { + js.parseKey(sample.data); + } +}); + +const nativeTime = bench("native/parseKey", () => { + for (const sample of samples) { + native.parseKey(sample.data); + } +}); + +console.log(`\nSpeedup: ${(jsTime / nativeTime).toFixed(2)}x`); + +bench("js/parse+match", () => { + for (const sample of samples) { + js.matchesKey(sample.data, sample.expected as any); + } +}); + +bench("native/match", () => { + for (const sample of samples) { + native.matchesKey(sample.data, sample.expected as any); + } +}); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 95618c177..90a5a72e7 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -39,8 +39,6 @@ export { isKeyRelease, isKeyRepeat, isKittyProtocolActive, - Key, - type KeyEventType, type KeyId, matchesKey, parseKey, diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 3a520eec9..5c430a979 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -18,7 +18,12 @@ * - isKittyProtocolActive() - Query global Kitty protocol state */ -import { matchesKittySequence } from "@oh-my-pi/pi-natives"; +import { + type KeyEventType, + matchesKey as matchesKeyNative, + parseKey as parseKeyNative, + parseKittySequence as parseKittySequenceNative, +} from "@oh-my-pi/pi-natives"; // ============================================================================= // Global Kitty Protocol State @@ -162,515 +167,76 @@ export type KeyId = | `alt+ctrl+shift+${BaseKey}` | `alt+shift+ctrl+${BaseKey}`; -/** - * Helper object for creating typed key identifiers with autocomplete. - * - * Usage: - * - Key.escape, Key.enter, Key.tab, etc. for special keys - * - Key.backtick, Key.comma, Key.period, etc. for symbol keys - * - Key.ctrl("c"), Key.alt("x") for single modifier - * - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers - */ -export const Key = { - // Special keys - escape: "escape" as const, - esc: "esc" as const, - enter: "enter" as const, - return: "return" as const, - tab: "tab" as const, - space: "space" as const, - backspace: "backspace" as const, - delete: "delete" as const, - insert: "insert" as const, - clear: "clear" as const, - home: "home" as const, - end: "end" as const, - pageUp: "pageUp" as const, - pageDown: "pageDown" as const, - up: "up" as const, - down: "down" as const, - left: "left" as const, - right: "right" as const, - f1: "f1" as const, - f2: "f2" as const, - f3: "f3" as const, - f4: "f4" as const, - f5: "f5" as const, - f6: "f6" as const, - f7: "f7" as const, - f8: "f8" as const, - f9: "f9" as const, - f10: "f10" as const, - f11: "f11" as const, - f12: "f12" as const, - - // Symbol keys - backtick: "`" as const, - hyphen: "-" as const, - equals: "=" as const, - leftbracket: "[" as const, - rightbracket: "]" as const, - backslash: "\\" as const, - semicolon: ";" as const, - quote: "'" as const, - comma: "," as const, - period: "." as const, - slash: "/" as const, - exclamation: "!" as const, - at: "@" as const, - hash: "#" as const, - dollar: "$" as const, - percent: "%" as const, - caret: "^" as const, - ampersand: "&" as const, - asterisk: "*" as const, - leftparen: "(" as const, - rightparen: ")" as const, - underscore: "_" as const, - plus: "+" as const, - pipe: "|" as const, - tilde: "~" as const, - leftbrace: "{" as const, - rightbrace: "}" as const, - colon: ":" as const, - lessthan: "<" as const, - greaterthan: ">" as const, - question: "?" as const, - - // Single modifiers - ctrl: (key: K): `ctrl+${K}` => `ctrl+${key}`, - shift: (key: K): `shift+${K}` => `shift+${key}`, - alt: (key: K): `alt+${K}` => `alt+${key}`, - - // Combined modifiers - ctrlShift: (key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`, - shiftCtrl: (key: K): `shift+ctrl+${K}` => `shift+ctrl+${key}`, - ctrlAlt: (key: K): `ctrl+alt+${K}` => `ctrl+alt+${key}`, - altCtrl: (key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`, - shiftAlt: (key: K): `shift+alt+${K}` => `shift+alt+${key}`, - altShift: (key: K): `alt+shift+${K}` => `alt+shift+${key}`, - - // Triple modifiers - ctrlShiftAlt: (key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`, -} as const; - -// ============================================================================= -// Constants -// ============================================================================= - -const SYMBOL_KEYS = new Set([ - "`", - "-", - "=", - "[", - "]", - "\\", - ";", - "'", - ",", - ".", - "/", - "!", - "@", - "#", - "$", - "%", - "^", - "&", - "*", - "(", - ")", - "_", - "+", - "|", - "~", - "{", - "}", - ":", - "<", - ">", - "?", -]); - -const CTRL_SYMBOL_MAP: Record = { - "@": "\x00", - "[": "\x1b", - "\\": "\x1c", - "]": "\x1d", - "^": "\x1e", - _: "\x1f", - "-": "\x1f", -} as const; - -const CTRL_SYMBOL_CODES: Record = { - 28: "ctrl+\\", - 29: "ctrl+]", - 30: "ctrl+^", - 31: "ctrl+_", -} as const; - -const MODIFIERS = { - shift: 1, - alt: 2, - ctrl: 4, -} as const; - -const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock - -const CODEPOINTS = { - escape: 27, - tab: 9, - enter: 13, - space: 32, - backspace: 127, - kpEnter: 57414, // Numpad Enter (Kitty protocol) -} as const; - -const ARROW_CODEPOINTS = { - up: -1, - down: -2, - right: -3, - left: -4, -} as const; - -const FUNCTIONAL_CODEPOINTS = { - delete: -10, - insert: -11, - pageUp: -12, - pageDown: -13, - home: -14, - end: -15, -} as const; - -const LEGACY_KEY_SEQUENCES = { - up: ["\x1b[A", "\x1bOA"], - down: ["\x1b[B", "\x1bOB"], - right: ["\x1b[C", "\x1bOC"], - left: ["\x1b[D", "\x1bOD"], - home: ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"], - end: ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"], - insert: ["\x1b[2~"], - delete: ["\x1b[3~"], - pageUp: ["\x1b[5~", "\x1b[[5~"], - pageDown: ["\x1b[6~", "\x1b[[6~"], - clear: ["\x1b[E", "\x1bOE"], - f1: ["\x1bOP", "\x1b[11~", "\x1b[[A"], - f2: ["\x1bOQ", "\x1b[12~", "\x1b[[B"], - f3: ["\x1bOR", "\x1b[13~", "\x1b[[C"], - f4: ["\x1bOS", "\x1b[14~", "\x1b[[D"], - f5: ["\x1b[15~", "\x1b[[E"], - f6: ["\x1b[17~"], - f7: ["\x1b[18~"], - f8: ["\x1b[19~"], - f9: ["\x1b[20~"], - f10: ["\x1b[21~"], - f11: ["\x1b[23~"], - f12: ["\x1b[24~"], -} as const; - -const LEGACY_SHIFT_SEQUENCES = { - up: ["\x1b[a"], - down: ["\x1b[b"], - right: ["\x1b[c"], - left: ["\x1b[d"], - clear: ["\x1b[e"], - insert: ["\x1b[2$"], - delete: ["\x1b[3$"], - pageUp: ["\x1b[5$"], - pageDown: ["\x1b[6$"], - home: ["\x1b[7$"], - end: ["\x1b[8$"], -} as const; - -const LEGACY_CTRL_SEQUENCES = { - up: ["\x1bOa"], - down: ["\x1bOb"], - right: ["\x1bOc"], - left: ["\x1bOd"], - clear: ["\x1bOe"], - insert: ["\x1b[2^"], - delete: ["\x1b[3^"], - pageUp: ["\x1b[5^"], - pageDown: ["\x1b[6^"], - home: ["\x1b[7^"], - end: ["\x1b[8^"], -} as const; - -const LEGACY_SEQUENCE_KEY_IDS: Record = { - "\x1bOA": "up", - "\x1bOB": "down", - "\x1bOC": "right", - "\x1bOD": "left", - "\x1bOH": "home", - "\x1bOF": "end", - "\x1b[E": "clear", - "\x1bOE": "clear", - "\x1bOe": "ctrl+clear", - "\x1b[e": "shift+clear", - "\x1b[2~": "insert", - "\x1b[2$": "shift+insert", - "\x1b[2^": "ctrl+insert", - "\x1b[3$": "shift+delete", - "\x1b[3^": "ctrl+delete", - "\x1b[[5~": "pageUp", - "\x1b[[6~": "pageDown", - "\x1b[a": "shift+up", - "\x1b[b": "shift+down", - "\x1b[c": "shift+right", - "\x1b[d": "shift+left", - "\x1bOa": "ctrl+up", - "\x1bOb": "ctrl+down", - "\x1bOc": "ctrl+right", - "\x1bOd": "ctrl+left", - "\x1b[5$": "shift+pageUp", - "\x1b[6$": "shift+pageDown", - "\x1b[7$": "shift+home", - "\x1b[8$": "shift+end", - "\x1b[5^": "ctrl+pageUp", - "\x1b[6^": "ctrl+pageDown", - "\x1b[7^": "ctrl+home", - "\x1b[8^": "ctrl+end", - "\x1bOP": "f1", - "\x1bOQ": "f2", - "\x1bOR": "f3", - "\x1bOS": "f4", - "\x1b[11~": "f1", - "\x1b[12~": "f2", - "\x1b[13~": "f3", - "\x1b[14~": "f4", - "\x1b[[A": "f1", - "\x1b[[B": "f2", - "\x1b[[C": "f3", - "\x1b[[D": "f4", - "\x1b[[E": "f5", - "\x1b[15~": "f5", - "\x1b[17~": "f6", - "\x1b[18~": "f7", - "\x1b[19~": "f8", - "\x1b[20~": "f9", - "\x1b[21~": "f10", - "\x1b[23~": "f11", - "\x1b[24~": "f12", - "\x1bb": "alt+left", - "\x1bf": "alt+right", - "\x1bp": "alt+up", - "\x1bn": "alt+down", -} as const; - -type LegacyModifierKey = keyof typeof LEGACY_SHIFT_SEQUENCES; - -const matchesLegacySequence = (data: string, sequences: readonly string[]): boolean => sequences.includes(data); - -const matchesLegacyModifierSequence = (data: string, key: LegacyModifierKey, modifier: number): boolean => { - if (modifier === MODIFIERS.shift) { - return matchesLegacySequence(data, LEGACY_SHIFT_SEQUENCES[key]); - } - if (modifier === MODIFIERS.ctrl) { - return matchesLegacySequence(data, LEGACY_CTRL_SEQUENCES[key]); - } - return false; -}; - // ============================================================================= // Kitty Protocol Parsing // ============================================================================= -/** - * Event types from Kitty keyboard protocol (flag 2) - * 1 = key press, 2 = key repeat, 3 = key release - */ -export type KeyEventType = "press" | "repeat" | "release"; - interface ParsedKittySequence { codepoint: number; shiftedKey?: number; // Shifted version of the key (when shift is pressed) baseLayoutKey?: number; // Key in standard PC-101 layout (for non-Latin layouts) modifier: number; - eventType: KeyEventType; + eventType?: KeyEventType; } -// Store the last parsed event type for isKeyRelease() to query -let _lastEventType: KeyEventType = "press"; +// Regex for Kitty protocol event type detection +// Matches CSI sequences with :2 (repeat) or :3 (release) event type +// Format: \x1b[...;modifier:event_type where terminator is u, ~, or A-F/H +const KITTY_RELEASE_PATTERN = /^\x1b\[[\d:;]*:3[u~ABCDHF]$/; +const KITTY_REPEAT_PATTERN = /^\x1b\[[\d:;]*:2[u~ABCDHF]$/; /** - * Check if the last parsed key event was a key release. + * Check if the input is a key release event. * Only meaningful when Kitty keyboard protocol with flag 2 is active. + * Returns false if Kitty protocol is not active. */ export function isKeyRelease(data: string): boolean { - // Don't treat bracketed paste content as key release, even if it contains - // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5"). - // Terminal.ts re-wraps paste content with bracketed paste markers before - // passing to TUI, so pasted data will always contain \x1b[200~. + // Only detect release events when Kitty protocol is active + if (!_kittyProtocolActive) { + return false; + } + + // Don't treat bracketed paste content as key release if (data.includes("\x1b[200~")) { return false; } - // Quick check: release events with flag 2 contain ":3" - // Format: \x1b[;:3u - if ( - data.includes(":3u") || - data.includes(":3~") || - data.includes(":3A") || - data.includes(":3B") || - data.includes(":3C") || - data.includes(":3D") || - data.includes(":3H") || - data.includes(":3F") - ) { - return true; - } - return false; + // Match the full CSI sequence pattern for release events + return KITTY_RELEASE_PATTERN.test(data); } /** - * Check if the last parsed key event was a key repeat. + * Check if the input is a key repeat event. * Only meaningful when Kitty keyboard protocol with flag 2 is active. + * Returns false if Kitty protocol is not active. */ export function isKeyRepeat(data: string): boolean { - // Don't treat bracketed paste content as key repeat, even if it contains - // patterns like ":2F". See isKeyRelease() for details. + // Only detect repeat events when Kitty protocol is active + if (!_kittyProtocolActive) { + return false; + } + + // Don't treat bracketed paste content as key repeat if (data.includes("\x1b[200~")) { return false; } - if ( - data.includes(":2u") || - data.includes(":2~") || - data.includes(":2A") || - data.includes(":2B") || - data.includes(":2C") || - data.includes(":2D") || - data.includes(":2H") || - data.includes(":2F") - ) { - return true; - } - return false; -} - -function parseEventType(eventTypeStr: string | undefined): KeyEventType { - if (!eventTypeStr) return "press"; - const eventType = parseInt(eventTypeStr, 10); - if (eventType === 2) return "repeat"; - if (eventType === 3) return "release"; - return "press"; + // Match the full CSI sequence pattern for repeat events + return KITTY_REPEAT_PATTERN.test(data); } export function parseKittySequence(data: string): ParsedKittySequence | null { - // CSI u format with alternate keys (flag 4): - // \x1b[u - // \x1b[;u - // \x1b[;:u - // \x1b[:;u - // \x1b[::;u - // \x1b[::;u (no shifted key, only base) - // - // With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release - // With flag 4, alternate keys are appended after codepoint with colons - const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/); - if (csiUMatch) { - const codepoint = parseInt(csiUMatch[1]!, 10); - const shiftedKey = csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined; - const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined; - const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1; - const eventType = parseEventType(csiUMatch[5]); - _lastEventType = eventType; - return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType }; - } - - // Arrow keys with modifier: \x1b[1;A/B/C/D or \x1b[1;:A/B/C/D - const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/); - if (arrowMatch) { - const modValue = parseInt(arrowMatch[1]!, 10); - const eventType = parseEventType(arrowMatch[2]); - const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; - _lastEventType = eventType; - return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }; - } - - // Functional keys: \x1b[~ or \x1b[;~ or \x1b[;:~ - const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/); - if (funcMatch) { - const keyNum = parseInt(funcMatch[1]!, 10); - const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1; - const eventType = parseEventType(funcMatch[3]); - const funcCodes: Record = { - 2: FUNCTIONAL_CODEPOINTS.insert, - 3: FUNCTIONAL_CODEPOINTS.delete, - 5: FUNCTIONAL_CODEPOINTS.pageUp, - 6: FUNCTIONAL_CODEPOINTS.pageDown, - 7: FUNCTIONAL_CODEPOINTS.home, - 8: FUNCTIONAL_CODEPOINTS.end, - }; - const codepoint = funcCodes[keyNum]; - if (codepoint !== undefined) { - _lastEventType = eventType; - return { codepoint, modifier: modValue - 1, eventType }; - } - } - - // Home/End with modifier: \x1b[1;H/F or \x1b[1;:H/F - const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/); - if (homeEndMatch) { - const modValue = parseInt(homeEndMatch[1]!, 10); - const eventType = parseEventType(homeEndMatch[2]); - const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; - _lastEventType = eventType; - return { codepoint, modifier: modValue - 1, eventType }; - } - - return null; -} - -/** - * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~ - * This is used by terminals when Kitty protocol is not enabled. - * Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc. - */ -function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { - const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/); - if (!match) return false; - const modValue = parseInt(match[1]!, 10); - const keycode = parseInt(match[2]!, 10); - // Convert from 1-indexed xterm format to our 0-indexed format - const actualMod = modValue - 1; - return keycode === expectedKeycode && actualMod === expectedModifier; -} - -// ============================================================================= -// Generic Key Matching -// ============================================================================= - -function rawCtrlChar(letter: string): string { - const code = letter.toLowerCase().charCodeAt(0) - 96; - return String.fromCharCode(code); -} - -type ParsedKeyId = { key: string; ctrl: boolean; shift: boolean; alt: boolean }; - -const PARSED_KEY_ID_CACHE = new Map(); - -function parseKeyIdSlow(keyId: string): ParsedKeyId | null { - const normalizedKeyId = keyId.toLowerCase(); - const parts = normalizedKeyId.split("+"); - const key = parts[parts.length - 1]; - if (!key) return null; + const result = parseKittySequenceNative(data); + if (!result) return null; return { - key, - ctrl: parts.includes("ctrl"), - shift: parts.includes("shift"), - alt: parts.includes("alt"), + codepoint: result.codepoint, + shiftedKey: result.shiftedKey ?? undefined, + baseLayoutKey: result.baseLayoutKey ?? undefined, + modifier: result.modifier, + eventType: result.eventType, }; } -function parseKeyId(keyId: string): ParsedKeyId | null { - const cached = PARSED_KEY_ID_CACHE.get(keyId); - if (cached !== undefined) return cached; - const parsed = parseKeyIdSlow(keyId); - PARSED_KEY_ID_CACHE.set(keyId, parsed); - return parsed; -} - /** * Match input data against a key identifier string. * @@ -688,448 +254,17 @@ function parseKeyId(keyId: string): ParsedKeyId | null { * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) */ export function matchesKey(data: string, keyId: KeyId): boolean { - const parsed = parseKeyId(keyId); - if (!parsed) return false; - - const { key, ctrl, shift, alt } = parsed; - let modifier = 0; - if (shift) modifier |= MODIFIERS.shift; - if (alt) modifier |= MODIFIERS.alt; - if (ctrl) modifier |= MODIFIERS.ctrl; - - switch (key) { - case "escape": - case "esc": - if (modifier !== 0) return false; - return data === "\x1b" || matchesKittySequence(data, CODEPOINTS.escape, 0); - - case "space": - if (!_kittyProtocolActive) { - if (ctrl && !alt && !shift && data === "\x00") { - return true; - } - if (alt && !ctrl && !shift && data === "\x1b ") { - return true; - } - } - if (modifier === 0) { - return data === " " || matchesKittySequence(data, CODEPOINTS.space, 0); - } - return matchesKittySequence(data, CODEPOINTS.space, modifier); - - case "tab": - if (shift && !ctrl && !alt) { - return data === "\x1b[Z" || matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift); - } - if (modifier === 0) { - return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0); - } - return matchesKittySequence(data, CODEPOINTS.tab, modifier); - - case "enter": - case "return": - if (shift && !ctrl && !alt) { - // CSI u sequences (standard Kitty protocol) - if ( - matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || - matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift) - ) { - return true; - } - // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) - if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) { - return true; - } - // When Kitty protocol is active, legacy sequences are custom terminal mappings - // \x1b\r = Kitty's "map shift+enter send_text all \e\r" - // \n = Ghostty's "keybind = shift+enter=text:\n" - if (_kittyProtocolActive) { - return data === "\x1b\r" || data === "\n"; - } - return false; - } - if (alt && !ctrl && !shift) { - // CSI u sequences (standard Kitty protocol) - if ( - matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) || - matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt) - ) { - return true; - } - // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) - if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.alt)) { - return true; - } - // \x1b\r is alt+enter only in legacy mode (no Kitty protocol) - // When Kitty protocol is active, alt+enter comes as CSI u sequence - if (!_kittyProtocolActive) { - return data === "\x1b\r"; - } - return false; - } - if (modifier === 0) { - return ( - data === "\r" || - (!_kittyProtocolActive && data === "\n") || - data === "\x1bOM" || // SS3 M (numpad enter in some terminals) - matchesKittySequence(data, CODEPOINTS.enter, 0) || - matchesKittySequence(data, CODEPOINTS.kpEnter, 0) - ); - } - return ( - matchesKittySequence(data, CODEPOINTS.enter, modifier) || - matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) - ); - - case "backspace": - if (alt && !ctrl && !shift) { - if (data === "\x1b\x7f" || data === "\x1b\b") { - return true; - } - return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt); - } - if (modifier === 0) { - return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0); - } - return ( - matchesKittySequence(data, CODEPOINTS.backspace, modifier) || - matchesModifyOtherKeys(data, CODEPOINTS.backspace, modifier) - ); - - case "insert": - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.insert) || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, 0) - ); - } - if (matchesLegacyModifierSequence(data, "insert", modifier)) { - return true; - } - return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, modifier); - - case "delete": - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.delete) || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0) - ); - } - if (matchesLegacyModifierSequence(data, "delete", modifier)) { - return true; - } - return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, modifier); - - case "clear": - if (modifier === 0) { - return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.clear); - } - return matchesLegacyModifierSequence(data, "clear", modifier); - - case "home": - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.home) || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) - ); - } - if (matchesLegacyModifierSequence(data, "home", modifier)) { - return true; - } - return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, modifier); - - case "end": - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.end) || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) - ); - } - if (matchesLegacyModifierSequence(data, "end", modifier)) { - return true; - } - return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier); - - case "pageup": - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageUp) || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0) - ); - } - if (matchesLegacyModifierSequence(data, "pageUp", modifier)) { - return true; - } - return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, modifier); - - case "pagedown": - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageDown) || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0) - ); - } - if (matchesLegacyModifierSequence(data, "pageDown", modifier)) { - return true; - } - return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier); - - case "up": - if (alt && !ctrl && !shift) { - return data === "\x1bp" || matchesKittySequence(data, ARROW_CODEPOINTS.up, MODIFIERS.alt); - } - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.up) || - matchesKittySequence(data, ARROW_CODEPOINTS.up, 0) - ); - } - if (matchesLegacyModifierSequence(data, "up", modifier)) { - return true; - } - return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier); - - case "down": - if (alt && !ctrl && !shift) { - return data === "\x1bn" || matchesKittySequence(data, ARROW_CODEPOINTS.down, MODIFIERS.alt); - } - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.down) || - matchesKittySequence(data, ARROW_CODEPOINTS.down, 0) - ); - } - if (matchesLegacyModifierSequence(data, "down", modifier)) { - return true; - } - return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier); - - case "left": - if (alt && !ctrl && !shift) { - return ( - data === "\x1b[1;3D" || - (!_kittyProtocolActive && data === "\x1bB") || - data === "\x1bb" || - matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt) - ); - } - if (ctrl && !alt && !shift) { - return ( - data === "\x1b[1;5D" || - matchesLegacyModifierSequence(data, "left", MODIFIERS.ctrl) || - matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl) - ); - } - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.left) || - matchesKittySequence(data, ARROW_CODEPOINTS.left, 0) - ); - } - if (matchesLegacyModifierSequence(data, "left", modifier)) { - return true; - } - return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier); - - case "right": - if (alt && !ctrl && !shift) { - return ( - data === "\x1b[1;3C" || - (!_kittyProtocolActive && data === "\x1bF") || - data === "\x1bf" || - matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt) - ); - } - if (ctrl && !alt && !shift) { - return ( - data === "\x1b[1;5C" || - matchesLegacyModifierSequence(data, "right", MODIFIERS.ctrl) || - matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl) - ); - } - if (modifier === 0) { - return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.right) || - matchesKittySequence(data, ARROW_CODEPOINTS.right, 0) - ); - } - if (matchesLegacyModifierSequence(data, "right", modifier)) { - return true; - } - return matchesKittySequence(data, ARROW_CODEPOINTS.right, modifier); - - case "f1": - case "f2": - case "f3": - case "f4": - case "f5": - case "f6": - case "f7": - case "f8": - case "f9": - case "f10": - case "f11": - case "f12": { - if (modifier !== 0) { - return false; - } - const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES; - return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey]); - } - } - - // Handle single letter keys (a-z) and some symbols - if (key.length === 1 && ((key >= "a" && key <= "z") || SYMBOL_KEYS.has(key))) { - const codepoint = key.charCodeAt(0); - const isLetterKey = key >= "a" && key <= "z"; - - if (ctrl && alt && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { - return data === `\x1b${rawCtrlChar(key)}`; - } - - if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { - // Legacy: alt+letter is ESC followed by the letter - if (data === `\x1b${key}`) return true; - } - - if (ctrl && !shift && !alt) { - if (!isLetterKey) { - const legacyCtrl = CTRL_SYMBOL_MAP[key]; - if (legacyCtrl && data === legacyCtrl) return true; - if (matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)) return true; - return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); - } - const raw = rawCtrlChar(key); - if (data === raw) return true; - if (data.length > 0 && data.charCodeAt(0) === raw.charCodeAt(0)) return true; - if (matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)) return true; - return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); - } - - if (ctrl && shift && !alt) { - return matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl); - } - - if (shift && !ctrl && !alt) { - // Legacy: shift+letter produces uppercase - if (data === key.toUpperCase()) return true; - return matchesKittySequence(data, codepoint, MODIFIERS.shift); - } - - if (modifier !== 0) { - return matchesKittySequence(data, codepoint, modifier); - } - - // Check both raw char and Kitty sequence (needed for release events) - return data === key || matchesKittySequence(data, codepoint, 0); - } - - return false; + return matchesKeyNative(data, keyId, _kittyProtocolActive); } /** - * Parse input data and return the key identifier if recognized. + * Parse terminal input and return a normalized key identifier. + * + * Returns key names like "escape", "ctrl+c", "shift+tab", "alt+enter". + * Returns undefined if the input is not a recognized key sequence. * * @param data - Raw input data from terminal - * @returns Key identifier string (e.g., "ctrl+c") or undefined */ export function parseKey(data: string): string | undefined { - const kitty = parseKittySequence(data); - if (kitty) { - const { codepoint, baseLayoutKey, modifier } = kitty; - const mods: string[] = []; - const effectiveMod = modifier & ~LOCK_MASK; - if (effectiveMod & MODIFIERS.shift) mods.push("shift"); - if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); - if (effectiveMod & MODIFIERS.alt) mods.push("alt"); - - // Prefer base layout key for consistent shortcut naming across keyboard layouts - // This ensures Ctrl+С (Cyrillic) is reported as "ctrl+c" (Latin) - const effectiveCodepoint = baseLayoutKey ?? codepoint; - - let keyName: string | undefined; - if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape"; - else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab"; - else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter"; - else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space"; - else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown"; - else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up"; - else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down"; - else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left"; - else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right"; - else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint); - else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint))) - keyName = String.fromCharCode(effectiveCodepoint); - - if (keyName) { - return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; - } - } - - // Mode-aware legacy sequences - // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings: - // - \x1b\r = shift+enter (Kitty mapping), not alt+enter - // - \n = shift+enter (Ghostty mapping) - if (_kittyProtocolActive) { - if (data === "\x1b\r" || data === "\n") return "shift+enter"; - } - - const legacySequenceKeyId = LEGACY_SEQUENCE_KEY_IDS[data]; - if (legacySequenceKeyId) return legacySequenceKeyId; - - // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences) - if (data === "\x1b") return "escape"; - if (data === "\t") return "tab"; - if (data === "\r" || (!_kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter"; - if (data === "\x00") return "ctrl+space"; - if (data === " ") return "space"; - if (data === "\x7f" || data === "\x08") return "backspace"; - if (data === "\x1b[Z") return "shift+tab"; - if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter"; - if (!_kittyProtocolActive && data === "\x1b ") return "alt+space"; - if (data === "\x1b\x7f" || data === "\x1b\b") return "alt+backspace"; - if (!_kittyProtocolActive && data === "\x1bB") return "alt+left"; - if (!_kittyProtocolActive && data === "\x1bF") return "alt+right"; - if (!_kittyProtocolActive && data.length === 2 && data[0] === "\x1b") { - const code = data.charCodeAt(1); - if (code >= 1 && code <= 26) { - return `ctrl+alt+${String.fromCharCode(code + 96)}`; - } - // Legacy alt+letter (ESC followed by letter a-z) - if (code >= 97 && code <= 122) { - return `alt+${String.fromCharCode(code)}`; - } - } - if (data === "\x1b[A") return "up"; - if (data === "\x1b[B") return "down"; - if (data === "\x1b[C") return "right"; - if (data === "\x1b[D") return "left"; - if (data === "\x1b[H" || data === "\x1bOH") return "home"; - if (data === "\x1b[F" || data === "\x1bOF") return "end"; - if (data === "\x1b[3~") return "delete"; - if (data === "\x1b[5~") return "pageUp"; - if (data === "\x1b[6~") return "pageDown"; - - // Raw Ctrl+letter - if (data.length === 1) { - const code = data.charCodeAt(0); - const ctrlSymbol = CTRL_SYMBOL_CODES[code]; - if (ctrlSymbol) { - return ctrlSymbol; - } - if (code >= 1 && code <= 26) { - return `ctrl+${String.fromCharCode(code + 96)}`; - } - if (code >= 32 && code <= 126) { - return data; - } - } - - return undefined; + return parseKeyNative(data, _kittyProtocolActive) ?? undefined; } diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index b85c061cd..113535d76 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { Key, matchesKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; +import { matchesKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; describe("matchesKey", () => { it("matches ctrl+letter sequences", () => { setKittyProtocolActive(false); const ctrlC = String.fromCharCode(3); - expect(matchesKey(ctrlC, Key.ctrl("c"))).toBe(true); + expect(matchesKey(ctrlC, "ctrl+c")).toBe(true); }); it("matches shifted tab", () => {