core: encode embedded terminal Kitty keypad, lock, and modifier keys (#1648)

When the host terminal speaks the Kitty keyboard protocol, the embedded
terminal sent no bytes for keypad Enter, the
keypad navigation keys (keypad arrows, Home, End, Page Up and Down,
Insert, Delete), Caps Lock, Scroll Lock, Num
Lock, Print Screen, Pause, and the left and right Shift, Ctrl, Alt and
Super keys. The printable keypad keys reached
the child only as text, so a Kitty child could not tell them from the
main keys, and Ctrl+keypad 0 arrived as a
bare `0`.

The parser reports these keys with Kitty-only names (`kpleft`,
`capslock`, `leftshift`, ...), and `physicalKey` had
no W3C code for them, so Ghostty's encoder got no key. It now maps the
41 names that Ghostty can encode. Ghostty then
encodes each key for the child's modes: keypad Left is `CSI D` to a
legacy child and `CSI 57417 u` to a Kitty child,
keypad 0 is still `0` to a legacy child and `CSI 57399 ; ; 48 u` to a
child that asks for key text, and a child in
application keypad mode with mode 1035 reset gets `ESC O p`, as in
xterm.

Some keys still give no bytes because Ghostty has no key or no Kitty
encoding for them: the media and volume keys,
Hyper, Kitty's Meta, the ISO level shifts, F26 to F35, and Menu.

A sweep sends all 111 Kitty functional keys through the parser into a
Kitty child, parses the child's bytes back,
and checks that only those 30 keys fail to come back with the same name.
Rows in the `preserves` table give exact
bytes for modified keypad keys, keypad Enter and navigation in legacy
mode, and lock and modifier keys in legacy and
Kitty modes. Deleting the keypad digit line, moving either end of its
range, and each of about twenty tried
deletions, swaps or wrong codes in the table makes at least one test
fail.

Fixes #1645
This commit is contained in:
Simon Klee authored and GitHub committed 2026-10-12 06:35:48 +02:00
1 parent 055e2093f1
commit 4eca5d5ada
2 files changed
+57

No files matched your search

@@ -275,6 +275,17 @@ describe("EmbeddedTerminalRenderable", () => {
expect(terminal.encodeKey(keyEvent({ name, sequence: "" })).length > 0).toBe(name !== "menu")
})
// Ghostty has no key or no Kitty encoding for 30 of the 111 Kitty functional keys: menu, F26-F35, the 10 media
// keys, the 3 volume keys, hyper, Kitty's meta (Ghostty's meta is Kitty's super), and the ISO level shifts.
test("round-trips every Kitty functional key that Ghostty can encode", () => {
const terminal = new EmbeddedTerminalRenderable(setup.renderer, { width: 20, height: 4 })
terminal.write("\x1b[>31u")
const keys = Array.from({ length: 111 }, (_, i) => parseKeypress(`\x1b[${57344 + i}u`, { useKittyKeyboard: true })!)
const sent = keys.map((key) => new TextDecoder().decode(terminal.encodeKey(new KeyEvent(key))))
const lost = keys.filter((key, i) => parseKeypress(sent[i]!, { useKittyKeyboard: true })?.name !== key.name)
expect(lost.map((key) => key.name)).toHaveLength(30)
})
test.each([
["Kitty Ctrl+c", 3, "\x1b[99;5u", "\x1b[99;5u"],
["Kitty Escape", 3, "\x1b[27u", "\x1b[27u"],
@@ -293,6 +304,20 @@ describe("EmbeddedTerminalRenderable", () => {
["Kitty keypad Begin", 1, "\x1b[57427u", "\x1b[57427u"],
["Kitty Ctrl+keypad Begin", 1, "\x1b[57427;5u", "\x1b[57427;5u"],
["SS3 Up into a DECCKM child", "\x1b[?1h", "\x1bOA", "\x1bOA"],
["Kitty keypad 9", 31, "\x1b[57408u", "\x1b[57408;;57u"],
["Kitty Ctrl+keypad 0", 1, "\x1b[57399;5u", "\x1b[57399;5u"],
["Kitty Ctrl+keypad 0 in legacy mode", 0, "\x1b[57399;5u", "0"],
["Kitty keypad 0 into a DECKPAM child without mode 1035", "\x1b[?1035l\x1b=", "\x1b[57399u", "\x1bOp"],
["Kitty Ctrl+keypad minus", 1, "\x1b[57412;5u", "\x1b[57412;5u"],
["Kitty keypad plus in legacy mode", 0, "\x1b[57413u", "+"],
["Kitty keypad Enter", 1, "\x1b[57414u", "\x1b[57414u"],
["Kitty keypad Enter in legacy mode", 0, "\x1b[57414u", "\r"],
["Kitty Ctrl+keypad Left", 1, "\x1b[57417;5u", "\x1b[57417;5u"],
["Kitty Ctrl+keypad Left in legacy mode", 0, "\x1b[57417;5u", "\x1b[1;5D"],
["Kitty keypad Page Down in legacy mode", 0, "\x1b[57422u", "\x1b[6~"],
["Kitty Caps Lock in legacy mode", 0, "\x1b[57358u", ""],
["Kitty Shift+Left Shift", 31, "\x1b[57441;2u", "\x1b[57441;2u"],
["Kitty Left Shift in legacy mode", 0, "\x1b[57441u", ""],
["plain Dvorak u", 1, "\x1b[117::102;1u", "u"],
["plain Dvorak d", 1, "\x1b[100::104;1u", "d"],
["Dvorak Ctrl+U", 1, "\x1b[117::102;5u", "\x1b[117;5u"],
@@ -432,10 +432,42 @@ function physicalKey(key: KeyEvent) {
if (/^[a-z]$/i.test(name)) return `Key${name.toUpperCase()}`
if (/^[0-9]$/.test(name)) return `Digit${name}`
if (/^f([1-9]|1[0-9]|2[0-5])$/i.test(key.name)) return key.name.toUpperCase()
if (/^kp[0-9]$/i.test(key.name)) return `Numpad${key.name.slice(2)}`
return (
{
backspace: "Backspace",
clear: "NumpadBegin",
kpdecimal: "NumpadDecimal",
kpdivide: "NumpadDivide",
kpmultiply: "NumpadMultiply",
kpminus: "NumpadSubtract",
kpplus: "NumpadAdd",
kpenter: "NumpadEnter",
kpequal: "NumpadEqual",
kpseparator: "NumpadSeparator",
kpleft: "NumpadLeft",
kpright: "NumpadRight",
kpup: "NumpadUp",
kpdown: "NumpadDown",
kppageup: "NumpadPageUp",
kppagedown: "NumpadPageDown",
kphome: "NumpadHome",
kpend: "NumpadEnd",
kpinsert: "NumpadInsert",
kpdelete: "NumpadDelete",
capslock: "CapsLock",
scrolllock: "ScrollLock",
numlock: "NumLock",
printscreen: "PrintScreen",
pause: "Pause",
leftshift: "ShiftLeft",
leftctrl: "ControlLeft",
leftalt: "AltLeft",
leftsuper: "MetaLeft",
rightshift: "ShiftRight",
rightctrl: "ControlRight",
rightalt: "AltRight",
rightsuper: "MetaRight",
enter: "Enter",
return: "Enter",
space: "Space",