feat(tui): add legacy terminal support for Ctrl+symbol keys

Ctrl+\ sends ASCII 28 (File Separator) in legacy terminals. This is
commonly used as SIGQUIT in Unix.

Ctrl+] sends ASCII 29 (Group Separator) in legacy terminals. This is
commonly used as the telnet escape character.

Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals. On US
keyboards, - and _ are on the same physical key, so this also functions
as an alias for Ctrl+-.
This commit is contained in:
Sviatoslav Abakumov 2026-01-18 23:14:39 +04:00 committed by Mario Zechner
parent 6bde679a5f
commit fb1242829d
2 changed files with 66 additions and 8 deletions

View file

@ -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";

View file

@ -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);