diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6524bcfa..0a865350 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Keyboard shortcuts (Ctrl+C, Ctrl+D, etc.) now work on non-Latin keyboard layouts (Russian, Ukrainian, Bulgarian, etc.) in terminals supporting Kitty keyboard protocol with alternate key reporting + ## [0.45.7] - 2026-01-13 ## [0.45.6] - 2026-01-13 diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index f6c9c7e8..c6bfee55 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -306,6 +306,8 @@ 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; } @@ -378,15 +380,25 @@ function parseEventType(eventTypeStr: string | undefined): KeyEventType { } function parseKittySequence(data: string): ParsedKittySequence | null { - // CSI u format: \x1b[u or \x1b[;u or \x1b[;:u - // With flag 2, event type is appended after colon: 1=press, 2=repeat, 3=release - const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u$/); + // 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 modValue = csiUMatch[2] ? parseInt(csiUMatch[2], 10) : 1; - const eventType = parseEventType(csiUMatch[3]); + 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, modifier: modValue - 1, 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 @@ -438,7 +450,19 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM if (!parsed) return false; const actualMod = parsed.modifier & ~LOCK_MASK; const expectedMod = expectedModifier & ~LOCK_MASK; - return parsed.codepoint === expectedCodepoint && actualMod === expectedMod; + + // 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; } /** @@ -713,30 +737,35 @@ export function matchesKey(data: string, keyId: KeyId): boolean { export function parseKey(data: string): string | undefined { const kitty = parseKittySequence(data); if (kitty) { - const { codepoint, modifier } = 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 (codepoint === CODEPOINTS.escape) keyName = "escape"; - else if (codepoint === CODEPOINTS.tab) keyName = "tab"; - else if (codepoint === CODEPOINTS.enter || codepoint === CODEPOINTS.kpEnter) keyName = "enter"; - else if (codepoint === CODEPOINTS.space) keyName = "space"; - else if (codepoint === CODEPOINTS.backspace) keyName = "backspace"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp"; - else if (codepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown"; - else if (codepoint === ARROW_CODEPOINTS.up) keyName = "up"; - else if (codepoint === ARROW_CODEPOINTS.down) keyName = "down"; - else if (codepoint === ARROW_CODEPOINTS.left) keyName = "left"; - else if (codepoint === ARROW_CODEPOINTS.right) keyName = "right"; - else if (codepoint >= 97 && codepoint <= 122) keyName = String.fromCharCode(codepoint); - else if (SYMBOL_KEYS.has(String.fromCharCode(codepoint))) keyName = String.fromCharCode(codepoint); + 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.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; diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 7727832e..34e22bca 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -108,7 +108,9 @@ export class ProcessTerminal implements Terminal { // Enable Kitty keyboard protocol (push flags) // Flag 1 = disambiguate escape codes // Flag 2 = report event types (press/repeat/release) - process.stdout.write("\x1b[>3u"); + // Flag 4 = report alternate keys (shifted key, base layout key) + // Base layout key enables shortcuts to work with non-Latin keyboard layouts + process.stdout.write("\x1b[>7u"); return; // Don't forward protocol response to TUI } } diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts new file mode 100644 index 00000000..af153126 --- /dev/null +++ b/packages/tui/test/keys.test.ts @@ -0,0 +1,168 @@ +/** + * Tests for keyboard input handling + */ + +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js"; + +describe("matchesKey", () => { + describe("Kitty protocol with alternate keys (non-Latin layouts)", () => { + // Kitty protocol flag 4 (Report alternate keys) sends: + // CSI codepoint:shifted:base ; modifier:event u + // Where base is the key in standard PC-101 layout + + it("should match Ctrl+c when pressing Ctrl+С (Cyrillic) with base layout key", () => { + setKittyProtocolActive(true); + // Cyrillic 'с' = codepoint 1089, Latin 'c' = codepoint 99 + // Format: CSI 1089::99;5u (codepoint::base;modifier with ctrl=4, +1=5) + const cyrillicCtrlC = "\x1b[1089::99;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+c"), true); + setKittyProtocolActive(false); + }); + + it("should match Ctrl+d when pressing Ctrl+В (Cyrillic) with base layout key", () => { + setKittyProtocolActive(true); + // Cyrillic 'в' = codepoint 1074, Latin 'd' = codepoint 100 + const cyrillicCtrlD = "\x1b[1074::100;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlD, "ctrl+d"), true); + setKittyProtocolActive(false); + }); + + it("should match Ctrl+z when pressing Ctrl+Я (Cyrillic) with base layout key", () => { + setKittyProtocolActive(true); + // Cyrillic 'я' = codepoint 1103, Latin 'z' = codepoint 122 + const cyrillicCtrlZ = "\x1b[1103::122;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlZ, "ctrl+z"), true); + setKittyProtocolActive(false); + }); + + it("should match Ctrl+Shift+p with base layout key", () => { + setKittyProtocolActive(true); + // Cyrillic 'з' = codepoint 1079, Latin 'p' = codepoint 112 + // ctrl=4, shift=1, +1 = 6 + const cyrillicCtrlShiftP = "\x1b[1079::112;6u"; + assert.strictEqual(matchesKey(cyrillicCtrlShiftP, "ctrl+shift+p"), true); + setKittyProtocolActive(false); + }); + + it("should still match direct codepoint when no base layout key", () => { + setKittyProtocolActive(true); + // Latin ctrl+c without base layout key (terminal doesn't support flag 4) + const latinCtrlC = "\x1b[99;5u"; + assert.strictEqual(matchesKey(latinCtrlC, "ctrl+c"), true); + setKittyProtocolActive(false); + }); + + it("should handle shifted key in format", () => { + setKittyProtocolActive(true); + // Format with shifted key: CSI codepoint:shifted:base;modifier u + // Latin 'c' with shifted 'C' (67) and base 'c' (99) + const shiftedKey = "\x1b[99:67:99;2u"; // shift modifier = 1, +1 = 2 + assert.strictEqual(matchesKey(shiftedKey, "shift+c"), true); + setKittyProtocolActive(false); + }); + + it("should handle event type in format", () => { + setKittyProtocolActive(true); + // Format with event type: CSI codepoint::base;modifier:event u + // Cyrillic ctrl+c release event (event type 3) + const releaseEvent = "\x1b[1089::99;5:3u"; + assert.strictEqual(matchesKey(releaseEvent, "ctrl+c"), true); + setKittyProtocolActive(false); + }); + + it("should handle full format with shifted key, base key, and event type", () => { + setKittyProtocolActive(true); + // Full format: CSI codepoint:shifted:base;modifier:event u + // Cyrillic 'С' (shifted) with base 'c', Ctrl+Shift pressed, repeat event + // Cyrillic 'с' = 1089, Cyrillic 'С' = 1057, Latin 'c' = 99 + // ctrl=4, shift=1, +1 = 6, repeat event = 2 + const fullFormat = "\x1b[1089:1057:99;6:2u"; + assert.strictEqual(matchesKey(fullFormat, "ctrl+shift+c"), true); + setKittyProtocolActive(false); + }); + + it("should not match wrong key even with base layout", () => { + setKittyProtocolActive(true); + // Cyrillic ctrl+с with base 'c' should NOT match ctrl+d + const cyrillicCtrlC = "\x1b[1089::99;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+d"), false); + setKittyProtocolActive(false); + }); + + it("should not match wrong modifiers even with base layout", () => { + setKittyProtocolActive(true); + // Cyrillic ctrl+с should NOT match ctrl+shift+c + const cyrillicCtrlC = "\x1b[1089::99;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+shift+c"), false); + setKittyProtocolActive(false); + }); + }); + + describe("Legacy key matching", () => { + it("should match legacy Ctrl+c", () => { + setKittyProtocolActive(false); + // Ctrl+c sends ASCII 3 (ETX) + assert.strictEqual(matchesKey("\x03", "ctrl+c"), true); + }); + + it("should match legacy Ctrl+d", () => { + setKittyProtocolActive(false); + // Ctrl+d sends ASCII 4 (EOT) + assert.strictEqual(matchesKey("\x04", "ctrl+d"), true); + }); + + it("should match escape key", () => { + assert.strictEqual(matchesKey("\x1b", "escape"), true); + }); + + it("should match arrow keys", () => { + assert.strictEqual(matchesKey("\x1b[A", "up"), true); + assert.strictEqual(matchesKey("\x1b[B", "down"), true); + assert.strictEqual(matchesKey("\x1b[C", "right"), true); + assert.strictEqual(matchesKey("\x1b[D", "left"), true); + }); + }); +}); + +describe("parseKey", () => { + describe("Kitty protocol with alternate keys", () => { + it("should return Latin key name when base layout key is present", () => { + setKittyProtocolActive(true); + // Cyrillic ctrl+с with base layout 'c' + const cyrillicCtrlC = "\x1b[1089::99;5u"; + assert.strictEqual(parseKey(cyrillicCtrlC), "ctrl+c"); + setKittyProtocolActive(false); + }); + + it("should return key name from codepoint when no base layout", () => { + setKittyProtocolActive(true); + const latinCtrlC = "\x1b[99;5u"; + assert.strictEqual(parseKey(latinCtrlC), "ctrl+c"); + setKittyProtocolActive(false); + }); + }); + + describe("Legacy key parsing", () => { + it("should parse legacy Ctrl+letter", () => { + setKittyProtocolActive(false); + assert.strictEqual(parseKey("\x03"), "ctrl+c"); + assert.strictEqual(parseKey("\x04"), "ctrl+d"); + }); + + it("should parse special keys", () => { + assert.strictEqual(parseKey("\x1b"), "escape"); + assert.strictEqual(parseKey("\t"), "tab"); + assert.strictEqual(parseKey("\r"), "enter"); + assert.strictEqual(parseKey(" "), "space"); + }); + + it("should parse arrow keys", () => { + assert.strictEqual(parseKey("\x1b[A"), "up"); + assert.strictEqual(parseKey("\x1b[B"), "down"); + assert.strictEqual(parseKey("\x1b[C"), "right"); + assert.strictEqual(parseKey("\x1b[D"), "left"); + }); + }); +});