diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index c26d0c17..6d4d14e1 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -642,9 +642,26 @@ function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedM // Generic Key Matching // ============================================================================= -function rawCtrlChar(letter: string): string { - const code = letter.toLowerCase().charCodeAt(0) - 96; - return String.fromCharCode(code); +/** + * Get the control character for a key. + * Uses the universal formula: code & 0x1f (mask to lower 5 bits) + * + * Works for: + * - Letters a-z → 1-26 + * - Symbols [\]_ → 27, 28, 29, 31 + * - Also maps - to same as _ (same physical key on US keyboards) + */ +function rawCtrlChar(key: string): string | null { + const char = key.toLowerCase(); + const code = char.charCodeAt(0); + if ((code >= 97 && code <= 122) || char === "[" || char === "\\" || char === "]" || char === "_") { + return String.fromCharCode(code & 0x1f); + } + // Handle - as _ (same physical key on US keyboards) + if (char === "-") { + return String.fromCharCode(31); // Same as Ctrl+_ + } + return null; } function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null { @@ -966,9 +983,11 @@ export function matchesKey(data: string, keyId: KeyId): boolean { // 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 rawCtrl = rawCtrlChar(key); - if (ctrl && alt && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { - return data === `\x1b${rawCtrlChar(key)}`; + if (ctrl && alt && !shift && !_kittyProtocolActive && rawCtrl) { + // Legacy: ctrl+alt+key is ESC followed by the control character + return data === `\x1b${rawCtrl}`; } if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { @@ -977,9 +996,8 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } if (ctrl && !shift && !alt) { - const raw = rawCtrlChar(key); - if (data === raw) return true; - if (data.length > 0 && data.charCodeAt(0) === raw.charCodeAt(0)) return true; + // Legacy: ctrl+key sends the control character + if (rawCtrl && data === rawCtrl) return true; return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); } @@ -1062,6 +1080,13 @@ export function parseKey(data: string): string | undefined { // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences) if (data === "\x1b") return "escape"; + if (data === "\x1c") return "ctrl+\\"; + if (data === "\x1d") return "ctrl+]"; + if (data === "\x1f") return "ctrl+-"; + if (data === "\x1b\x1b") return "ctrl+alt+["; + if (data === "\x1b\x1c") return "ctrl+alt+\\"; + if (data === "\x1b\x1d") return "ctrl+alt+]"; + if (data === "\x1b\x1f") return "ctrl+alt+-"; if (data === "\t") return "tab"; if (data === "\r" || (!_kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter"; if (data === "\x00") return "ctrl+space"; diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index ec7ce1d9..276cba7b 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -137,6 +137,39 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x00"), "ctrl+space"); }); + it("should match legacy Ctrl+symbol", () => { + setKittyProtocolActive(false); + // Ctrl+\ sends ASCII 28 (File Separator) in legacy terminals + assert.strictEqual(matchesKey("\x1c", "ctrl+\\"), true); + assert.strictEqual(parseKey("\x1c"), "ctrl+\\"); + // Ctrl+] sends ASCII 29 (Group Separator) in legacy terminals + assert.strictEqual(matchesKey("\x1d", "ctrl+]"), true); + assert.strictEqual(parseKey("\x1d"), "ctrl+]"); + // Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals + // Ctrl+- is on the same physical key on US keyboards + assert.strictEqual(matchesKey("\x1f", "ctrl+_"), true); + assert.strictEqual(matchesKey("\x1f", "ctrl+-"), true); + assert.strictEqual(parseKey("\x1f"), "ctrl+-"); + }); + + it("should match legacy Ctrl+Alt+symbol", () => { + setKittyProtocolActive(false); + // Ctrl+Alt+[ sends ESC followed by ESC (Ctrl+[ = ESC) + assert.strictEqual(matchesKey("\x1b\x1b", "ctrl+alt+["), true); + assert.strictEqual(parseKey("\x1b\x1b"), "ctrl+alt+["); + // Ctrl+Alt+\ sends ESC followed by ASCII 28 + assert.strictEqual(matchesKey("\x1b\x1c", "ctrl+alt+\\"), true); + assert.strictEqual(parseKey("\x1b\x1c"), "ctrl+alt+\\"); + // Ctrl+Alt+] sends ESC followed by ASCII 29 + assert.strictEqual(matchesKey("\x1b\x1d", "ctrl+alt+]"), true); + assert.strictEqual(parseKey("\x1b\x1d"), "ctrl+alt+]"); + // Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals + // Ctrl+- is on the same physical key on US keyboards + assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+_"), true); + assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+-"), true); + assert.strictEqual(parseKey("\x1b\x1f"), "ctrl+alt+-"); + }); + it("should parse legacy alt-prefixed sequences when kitty inactive", () => { setKittyProtocolActive(false); assert.strictEqual(matchesKey("\x1b ", "alt+space"), true);