pi-mono/packages/tui/test/keys.test.ts
robopipi[bot] deb12d78eb
fix(tui): recognize kitty F1-F12 sequences and ignore F13+ keys (#11468)
With the kitty keyboard protocol active, these function keys were not
recognized:
- F1, F2 and F4 arrive as `CSI P`/`Q`/`S`, or as `CSI 1;<mod> P/Q/S`.
- Any F-key pressed with a modifier, e.g. `CSI 15;5~` for Ctrl+F5.

`matchesKey` returned false whenever a modifier was set and only
compared against the legacy sequences. `parseKittySequence` did not know
any F-key forms. So shortcuts bound to keys like `f1` or `ctrl+f5` never
fired.

Separately, F13–F35 (and other kitty functional keys) arrive as CSI-u
codepoints in the Private Use Area. `decodeKittyPrintable` returned them
as text, so the editor inserted invisible U+E020+ characters into the
prompt.

Changes in `packages/tui/src/keys.ts`:
- Add F1–F12 internal codepoints. Map `~` numbers 11–15, 17–21, 23 and
24 to them.
- Parse the `CSI [1;mod[:event]] P/Q/S` form. F3 is left out because
`CSI 1;<n>R` collides with cursor position reports, so kitty sends F3 as
`CSI 13~`.
- Let the `f1`…`f12` cases in `matchesKey` fall back to
`matchesKittySequence` so modifiers work. Name the keys in `parseKey`
output.
- Make `isKeyRelease` and `isKeyRepeat` recognize `:3`/`:2` events on
P/Q/S, so F1/F2/F4 releases don't trigger shortcuts a second time.
- `decodeKittyPrintable` now rejects the kitty functional-key PUA range
(57344–63743). Keypad keys are normalized to ASCII first, so they still
decode.

Testing:
- Added tests in `packages/tui/test/keys.test.ts`. They cover kitty
F1–F12 with and without modifiers, F3 not being confused with a cursor
position report, release and repeat detection, and F13+ not decoding as
text. Two of the new tests fail without the change; all pass with it.
- Ran `npm test` in `packages/tui` and `npm run check`; both pass.
- Before the change: `parseKey("\x1b[P")` and `parseKey("\x1b[1;5P")`
returned `undefined`, `matchesKey("\x1b[15;5~", "ctrl+f5")` returned
false, and `decodePrintableKey("\x1b[57376u")` returned `"\ue020"`.
- After the change: these give `f1`, `ctrl+f1`, true and `undefined`.

Fixes #11419
Fixes #11345

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
2026-10-27 09:16:36 +01:00

675 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests for keyboard input handling
*/
import assert from "node:assert";
import { describe, it } from "node:test";
import {
decodeKittyPrintable,
decodePrintableKey,
isKeyRelease,
isKeyRepeat,
Key,
matchesKey,
parseKey,
setKittyProtocolActive,
} from "../src/keys.ts";
function withEnv(name: string, value: string | undefined, fn: () => void): void {
const previous = process.env[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
try {
fn();
} finally {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
}
}
function withEnvVars(vars: Record<string, string | undefined>, fn: () => void): void {
const entries = Object.entries(vars);
const run = (index: number): void => {
if (index >= entries.length) {
fn();
return;
}
const [name, value] = entries[index]!;
withEnv(name, value, () => run(index + 1));
};
run(0);
}
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 match super-modified Kitty bindings, including combined modifiers", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[107;9u", "super+k"), true);
assert.strictEqual(matchesKey("\x1b[13;9u", "super+enter"), true);
assert.strictEqual(matchesKey("\x1b[107;13u", Key.ctrlSuper("k")), true);
assert.strictEqual(matchesKey("\x1b[107;13u", "ctrl+super+k"), true);
assert.strictEqual(matchesKey("\x1b[107;14u", "ctrl+shift+super+k"), true);
assert.strictEqual(matchesKey("\x1b[107;13u", "super+k"), false);
assert.strictEqual(parseKey("\x1b[107;9u"), "super+k");
assert.strictEqual(parseKey("\x1b[13;9u"), "super+enter");
assert.strictEqual(parseKey("\x1b[107;13u"), "ctrl+super+k");
assert.strictEqual(parseKey("\x1b[107;14u"), "shift+ctrl+super+k");
setKittyProtocolActive(false);
});
it("should match digit bindings via Kitty CSI-u", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[49u", "1"), true);
assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+1"), true);
assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+2"), false);
assert.strictEqual(parseKey("\x1b[49u"), "1");
assert.strictEqual(parseKey("\x1b[49;5u"), "ctrl+1");
setKittyProtocolActive(false);
});
it("should normalize Kitty keypad functional keys to logical digits, symbols, and navigation", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[57400u", "1"), true);
assert.strictEqual(matchesKey("\x1b[57410u", "/"), true);
assert.strictEqual(matchesKey("\x1b[57417u", "left"), true);
assert.strictEqual(matchesKey("\x1b[57426u", "delete"), true);
assert.strictEqual(parseKey("\x1b[57399u"), "0");
assert.strictEqual(parseKey("\x1b[57409u"), ".");
assert.strictEqual(parseKey("\x1b[57413u"), "+");
assert.strictEqual(parseKey("\x1b[57416u"), ",");
assert.strictEqual(parseKey("\x1b[57417u"), "left");
assert.strictEqual(parseKey("\x1b[57418u"), "right");
assert.strictEqual(parseKey("\x1b[57419u"), "up");
assert.strictEqual(parseKey("\x1b[57420u"), "down");
assert.strictEqual(parseKey("\x1b[57421u"), "pageUp");
assert.strictEqual(parseKey("\x1b[57422u"), "pageDown");
assert.strictEqual(parseKey("\x1b[57423u"), "home");
assert.strictEqual(parseKey("\x1b[57424u"), "end");
assert.strictEqual(parseKey("\x1b[57425u"), "insert");
assert.strictEqual(parseKey("\x1b[57426u"), "delete");
setKittyProtocolActive(false);
});
it("should match shifted symbols by their produced character", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[61:43;2u", "+"), true);
assert.strictEqual(matchesKey("\x1b[61:43;2u", "shift+="), true);
assert.strictEqual(parseKey("\x1b[61:43;2u"), "+");
assert.strictEqual(matchesKey("\x1b[61:43;6u", "ctrl++"), true);
assert.strictEqual(parseKey("\x1b[61:43;6u"), "ctrl++");
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 prefer codepoint for Latin letters even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
const dvorakCtrlK = "\x1b[107::118;5u";
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+k"), true);
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+v"), false);
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
const dvorakCtrlSlash = "\x1b[47::91;5u";
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+/"), true);
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+["), false);
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("modifyOtherKeys matching", () => {
it("should match xterm modifyOtherKeys Ctrl+c", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;99~", "ctrl+c"), true);
assert.strictEqual(parseKey("\x1b[27;5;99~"), "ctrl+c");
});
it("should match xterm modifyOtherKeys Ctrl+d", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;100~", "ctrl+d"), true);
assert.strictEqual(parseKey("\x1b[27;5;100~"), "ctrl+d");
});
it("should match xterm modifyOtherKeys Ctrl+z", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;122~", "ctrl+z"), true);
assert.strictEqual(parseKey("\x1b[27;5;122~"), "ctrl+z");
});
it("should match xterm modifyOtherKeys Enter variants", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;13~", "ctrl+enter"), true);
assert.strictEqual(matchesKey("\x1b[27;2;13~", "shift+enter"), true);
assert.strictEqual(matchesKey("\x1b[27;3;13~", "alt+enter"), true);
assert.strictEqual(parseKey("\x1b[27;5;13~"), "ctrl+enter");
assert.strictEqual(parseKey("\x1b[27;2;13~"), "shift+enter");
assert.strictEqual(parseKey("\x1b[27;3;13~"), "alt+enter");
});
it("should match xterm modifyOtherKeys Tab variants", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;2;9~", "shift+tab"), true);
assert.strictEqual(matchesKey("\x1b[27;5;9~", "ctrl+tab"), true);
assert.strictEqual(matchesKey("\x1b[27;3;9~", "alt+tab"), true);
assert.strictEqual(parseKey("\x1b[27;2;9~"), "shift+tab");
assert.strictEqual(parseKey("\x1b[27;5;9~"), "ctrl+tab");
assert.strictEqual(parseKey("\x1b[27;3;9~"), "alt+tab");
});
it("should match xterm modifyOtherKeys Backspace variants", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;1;127~", "backspace"), true);
assert.strictEqual(matchesKey("\x1b[27;5;127~", "ctrl+backspace"), true);
assert.strictEqual(matchesKey("\x1b[27;3;127~", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b[27;1;127~"), "backspace");
assert.strictEqual(parseKey("\x1b[27;5;127~"), "ctrl+backspace");
assert.strictEqual(parseKey("\x1b[27;3;127~"), "alt+backspace");
});
it("should match xterm modifyOtherKeys Escape", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;1;27~", "escape"), true);
assert.strictEqual(parseKey("\x1b[27;1;27~"), "escape");
});
it("should match xterm modifyOtherKeys Space variants", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;1;32~", "space"), true);
assert.strictEqual(matchesKey("\x1b[27;5;32~", "ctrl+space"), true);
assert.strictEqual(parseKey("\x1b[27;1;32~"), "space");
assert.strictEqual(parseKey("\x1b[27;5;32~"), "ctrl+space");
});
it("should match xterm modifyOtherKeys symbol combos", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/");
assert.strictEqual(matchesKey("\x1b[27;6;43~", "ctrl++"), true);
assert.strictEqual(parseKey("\x1b[27;6;43~"), "ctrl++");
});
it("should match xterm modifyOtherKeys digit combos", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;49~", "ctrl+1"), true);
assert.strictEqual(matchesKey("\x1b[27;2;49~", "shift+1"), true);
assert.strictEqual(parseKey("\x1b[27;5;49~"), "ctrl+1");
assert.strictEqual(parseKey("\x1b[27;2;49~"), "shift+1");
});
it("should match xterm modifyOtherKeys shifted uppercase letters", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;2;69~", "shift+e"), true);
assert.strictEqual(matchesKey("\x1b[27;6;69~", "ctrl+shift+e"), true);
assert.strictEqual(parseKey("\x1b[27;2;69~"), "shift+e");
assert.strictEqual(parseKey("\x1b[27;6;69~"), "shift+ctrl+e");
});
it("should match Ctrl+Alt+letter via CSI-u when kitty inactive", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[104;7u", "ctrl+alt+h"), true);
assert.strictEqual(parseKey("\x1b[104;7u"), "ctrl+alt+h");
});
it("should match Ctrl+Alt+letter via xterm modifyOtherKeys", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;7;104~", "ctrl+alt+h"), true);
assert.strictEqual(parseKey("\x1b[27;7;104~"), "ctrl+alt+h");
});
});
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 legacy linefeed as enter", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\n", "enter"), true);
assert.strictEqual(parseKey("\n"), "enter");
});
it("should treat linefeed as shift+enter when kitty active", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\n", "shift+enter"), true);
assert.strictEqual(matchesKey("\n", "enter"), false);
assert.strictEqual(parseKey("\n"), "shift+enter");
setKittyProtocolActive(false);
});
it("should parse ctrl+space", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x00", "ctrl+space"), true);
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 treat raw 0x08 as plain backspace outside Windows Terminal", () => {
setKittyProtocolActive(false);
withEnv("WT_SESSION", undefined, () => {
assert.strictEqual(matchesKey("\x7f", "backspace"), true);
assert.strictEqual(matchesKey("\x7f", "ctrl+backspace"), false);
assert.strictEqual(parseKey("\x7f"), "backspace");
assert.strictEqual(matchesKey("\x08", "backspace"), true);
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false);
assert.strictEqual(parseKey("\x08"), "backspace");
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
});
});
it("should treat raw 0x08 as ctrl+backspace in local Windows Terminal", () => {
setKittyProtocolActive(false);
withEnvVars(
{
WT_SESSION: "test-session",
SSH_CONNECTION: undefined,
SSH_CLIENT: undefined,
SSH_TTY: undefined,
},
() => {
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), true);
assert.strictEqual(matchesKey("\x08", "backspace"), false);
assert.strictEqual(parseKey("\x08"), "ctrl+backspace");
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
},
);
});
it("should treat raw 0x08 as plain backspace in Windows Terminal over SSH", () => {
setKittyProtocolActive(false);
withEnvVars(
{
WT_SESSION: "test-session",
SSH_CONNECTION: "1 2 3 4",
SSH_CLIENT: "1 2 3",
SSH_TTY: "/dev/pts/1",
},
() => {
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false);
assert.strictEqual(matchesKey("\x08", "backspace"), true);
assert.strictEqual(parseKey("\x08"), "backspace");
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
},
);
});
it("should parse legacy alt-prefixed sequences when kitty inactive", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b ", "alt+space"), true);
assert.strictEqual(parseKey("\x1b "), "alt+space");
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), true);
assert.strictEqual(parseKey("\x1b\x03"), "ctrl+alt+c");
assert.strictEqual(matchesKey("\x1bB", "alt+left"), true);
assert.strictEqual(parseKey("\x1bB"), "alt+left");
assert.strictEqual(matchesKey("\x1bF", "alt+right"), true);
assert.strictEqual(parseKey("\x1bF"), "alt+right");
assert.strictEqual(matchesKey("\x1ba", "alt+a"), true);
assert.strictEqual(parseKey("\x1ba"), "alt+a");
assert.strictEqual(matchesKey("\x1b1", "alt+1"), true);
assert.strictEqual(parseKey("\x1b1"), "alt+1");
assert.strictEqual(matchesKey("\x1b,", "alt+,"), true);
assert.strictEqual(parseKey("\x1b,"), "alt+,");
assert.strictEqual(matchesKey("\x1b.", "alt+."), true);
assert.strictEqual(parseKey("\x1b."), "alt+.");
assert.strictEqual(matchesKey("\x1by", "alt+y"), true);
assert.strictEqual(parseKey("\x1by"), "alt+y");
assert.strictEqual(matchesKey("\x1bz", "alt+z"), true);
assert.strictEqual(parseKey("\x1bz"), "alt+z");
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b ", "alt+space"), false);
assert.strictEqual(parseKey("\x1b "), undefined);
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), false);
assert.strictEqual(parseKey("\x1b\x03"), undefined);
assert.strictEqual(matchesKey("\x1bB", "alt+left"), false);
assert.strictEqual(parseKey("\x1bB"), undefined);
assert.strictEqual(matchesKey("\x1bF", "alt+right"), false);
assert.strictEqual(parseKey("\x1bF"), undefined);
assert.strictEqual(matchesKey("\x1ba", "alt+a"), false);
assert.strictEqual(parseKey("\x1ba"), undefined);
assert.strictEqual(matchesKey("\x1b1", "alt+1"), false);
assert.strictEqual(parseKey("\x1b1"), undefined);
assert.strictEqual(matchesKey("\x1b,", "alt+,"), false);
assert.strictEqual(parseKey("\x1b,"), undefined);
assert.strictEqual(matchesKey("\x1b.", "alt+."), false);
assert.strictEqual(parseKey("\x1b."), undefined);
assert.strictEqual(matchesKey("\x1by", "alt+y"), false);
assert.strictEqual(parseKey("\x1by"), undefined);
setKittyProtocolActive(false);
});
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);
});
it("should match SS3 arrows and home/end", () => {
assert.strictEqual(matchesKey("\x1bOA", "up"), true);
assert.strictEqual(matchesKey("\x1bOB", "down"), true);
assert.strictEqual(matchesKey("\x1bOC", "right"), true);
assert.strictEqual(matchesKey("\x1bOD", "left"), true);
assert.strictEqual(matchesKey("\x1bOH", "home"), true);
assert.strictEqual(matchesKey("\x1bOF", "end"), true);
});
it("should match xterm Ctrl-modified viewport navigation", () => {
assert.strictEqual(matchesKey("\x1b[1;5H", "ctrl+home"), true);
assert.strictEqual(matchesKey("\x1b[1;5F", "ctrl+end"), true);
assert.strictEqual(matchesKey("\x1b[5;5~", "ctrl+pageUp"), true);
assert.strictEqual(matchesKey("\x1b[6;5~", "ctrl+pageDown"), true);
assert.strictEqual(parseKey("\x1b[1;5H"), "ctrl+home");
assert.strictEqual(parseKey("\x1b[1;5F"), "ctrl+end");
assert.strictEqual(parseKey("\x1b[5;5~"), "ctrl+pageUp");
assert.strictEqual(parseKey("\x1b[6;5~"), "ctrl+pageDown");
});
it("should match legacy function keys and clear", () => {
assert.strictEqual(matchesKey("\x1bOP", "f1"), true);
assert.strictEqual(matchesKey("\x1b[24~", "f12"), true);
assert.strictEqual(matchesKey("\x1b[E", "clear"), true);
});
it("should match Kitty function keys F1-F12 with and without modifiers", () => {
assert.strictEqual(matchesKey("\x1b[P", "f1"), true);
assert.strictEqual(matchesKey("\x1b[Q", "f2"), true);
assert.strictEqual(matchesKey("\x1b[13~", "f3"), true);
assert.strictEqual(matchesKey("\x1b[S", "f4"), true);
assert.strictEqual(matchesKey("\x1b[1;5P", "ctrl+f1"), true);
assert.strictEqual(matchesKey("\x1b[1;5P", "f1"), false);
assert.strictEqual(matchesKey("\x1b[1;2S", "shift+f4"), true);
assert.strictEqual(matchesKey("\x1b[15;5~", "ctrl+f5"), true);
assert.strictEqual(matchesKey("\x1b[24;3~", "alt+f12"), true);
assert.strictEqual(matchesKey("\x1b[15;1:2~", "f5"), true);
assert.strictEqual(parseKey("\x1b[P"), "f1");
assert.strictEqual(parseKey("\x1b[1;5P"), "ctrl+f1");
assert.strictEqual(parseKey("\x1b[13~"), "f3");
assert.strictEqual(parseKey("\x1b[21;2~"), "shift+f10");
assert.strictEqual(parseKey("\x1b[1;5R"), undefined);
assert.strictEqual(isKeyRelease("\x1b[1;1:3P"), true);
assert.strictEqual(isKeyRepeat("\x1b[1;1:2S"), true);
});
it("should match alt+arrows", () => {
assert.strictEqual(matchesKey("\x1bp", "alt+up"), true);
assert.strictEqual(matchesKey("\x1bp", "up"), false);
});
it("should match rxvt modifier sequences", () => {
assert.strictEqual(matchesKey("\x1b[a", "shift+up"), true);
assert.strictEqual(matchesKey("\x1bOa", "ctrl+up"), true);
assert.strictEqual(matchesKey("\x1b[2$", "shift+insert"), true);
assert.strictEqual(matchesKey("\x1b[2^", "ctrl+insert"), true);
assert.strictEqual(matchesKey("\x1b[7$", "shift+home"), true);
});
});
});
describe("decodeKittyPrintable", () => {
it("ignores unmapped Kitty functional keys such as F13-F35", () => {
assert.strictEqual(decodeKittyPrintable("\x1b[57376u"), undefined);
assert.strictEqual(decodeKittyPrintable("\x1b[57398;2u"), undefined);
assert.strictEqual(decodePrintableKey("\x1b[57382u"), undefined);
});
it("should decode Kitty keypad functional keys to printable characters", () => {
assert.strictEqual(decodeKittyPrintable("\x1b[57399u"), "0");
assert.strictEqual(decodeKittyPrintable("\x1b[57400u"), "1");
assert.strictEqual(decodeKittyPrintable("\x1b[57409u"), ".");
assert.strictEqual(decodeKittyPrintable("\x1b[57410u"), "/");
assert.strictEqual(decodeKittyPrintable("\x1b[57411u"), "*");
assert.strictEqual(decodeKittyPrintable("\x1b[57412u"), "-");
assert.strictEqual(decodeKittyPrintable("\x1b[57413u"), "+");
assert.strictEqual(decodeKittyPrintable("\x1b[57415u"), "=");
assert.strictEqual(decodeKittyPrintable("\x1b[57416u"), ",");
assert.strictEqual(decodeKittyPrintable("\x1b[57417u"), undefined);
});
});
describe("decodePrintableKey", () => {
it("should decode printable xterm modifyOtherKeys sequences", () => {
assert.strictEqual(decodePrintableKey("\x1b[27;2;69~"), "E");
assert.strictEqual(decodePrintableKey("\x1b[27;2;196~"), "Ä");
assert.strictEqual(decodePrintableKey("\x1b[27;2;32~"), " ");
assert.strictEqual(decodePrintableKey("\x1b[27;2;13~"), undefined);
assert.strictEqual(decodePrintableKey("\x1b[27;6;69~"), undefined);
});
});
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 prefer codepoint for Latin letters when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
const dvorakCtrlK = "\x1b[107::118;5u";
assert.strictEqual(parseKey(dvorakCtrlK), "ctrl+k");
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
const dvorakCtrlSlash = "\x1b[47::91;5u";
assert.strictEqual(parseKey(dvorakCtrlSlash), "ctrl+/");
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);
});
it("should parse shifted uppercase CSI-u letters as shift+letter", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[69;2u", "shift+e"), true);
assert.strictEqual(parseKey("\x1b[69;2u"), "shift+e");
setKittyProtocolActive(false);
});
it("should ignore Kitty CSI-u with unsupported modifiers", () => {
setKittyProtocolActive(true);
assert.strictEqual(parseKey("\x1b[99;17u"), undefined);
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("\n"), "enter");
assert.strictEqual(parseKey("\x00"), "ctrl+space");
assert.strictEqual(parseKey(" "), "space");
assert.strictEqual(parseKey("1"), "1");
assert.strictEqual(matchesKey("1", "1"), true);
assert.strictEqual(parseKey("+"), "+");
assert.strictEqual(matchesKey("+", "+"), true);
});
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");
});
it("should parse SS3 arrows and home/end", () => {
assert.strictEqual(parseKey("\x1bOA"), "up");
assert.strictEqual(parseKey("\x1bOB"), "down");
assert.strictEqual(parseKey("\x1bOC"), "right");
assert.strictEqual(parseKey("\x1bOD"), "left");
assert.strictEqual(parseKey("\x1bOH"), "home");
assert.strictEqual(parseKey("\x1bOF"), "end");
});
it("should parse legacy function and modifier sequences", () => {
assert.strictEqual(parseKey("\x1bOP"), "f1");
assert.strictEqual(parseKey("\x1b[24~"), "f12");
assert.strictEqual(parseKey("\x1b[E"), "clear");
assert.strictEqual(parseKey("\x1b[2^"), "ctrl+insert");
assert.strictEqual(parseKey("\x1bp"), "alt+up");
});
it("should parse double bracket pageUp", () => {
assert.strictEqual(parseKey("\x1b[[5~"), "pageUp");
});
});
});