mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
fix: keyboard shortcuts on non-Latin keyboard layouts (#718)
This commit is contained in:
parent
7f2d2f106e
commit
15a9670db5
4 changed files with 228 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[<num>u or \x1b[<num>;<mod>u or \x1b[<num>;<mod>:<event>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[<codepoint>u
|
||||
// \x1b[<codepoint>;<mod>u
|
||||
// \x1b[<codepoint>;<mod>:<event>u
|
||||
// \x1b[<codepoint>:<shifted>;<mod>u
|
||||
// \x1b[<codepoint>:<shifted>:<base>;<mod>u
|
||||
// \x1b[<codepoint>::<base>;<mod>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;<mod>A/B/C/D or \x1b[1;<mod>:<event>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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
168
packages/tui/test/keys.test.ts
Normal file
168
packages/tui/test/keys.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue