From deb12d78ebbd2b65a4a884334cad2f26f3847636 Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:16:36 +0100 Subject: [PATCH] fix(tui): recognize kitty F1-F12 sequences and ignore F13+ keys (#11468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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; 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;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 --- packages/tui/src/keys.ts | 68 +++++++++++++++++++++++++++++++--- packages/tui/test/keys.test.ts | 28 ++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 734fbfae5..a3ce39726 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -325,8 +325,25 @@ const FUNCTIONAL_CODEPOINTS = { pageDown: -13, home: -14, end: -15, + f1: -21, + f2: -22, + f3: -23, + f4: -24, + f5: -25, + f6: -26, + f7: -27, + f8: -28, + f9: -29, + f10: -30, + f11: -31, + f12: -32, } as const; +// Kitty reserves this Private Use Area range for functional keys (F13-F35, +// media keys, modifier keys, ...). CSI-u codepoints in it are never text. +const KITTY_FUNCTIONAL_PUA_START = 57344; +const KITTY_FUNCTIONAL_PUA_END = 63743; + const KITTY_FUNCTIONAL_KEY_EQUIVALENTS = new Map([ [57399, 48], // KP_0 -> 0 [57400, 49], // KP_1 -> 1 @@ -558,7 +575,10 @@ export function isKeyRelease(data: string): boolean { data.includes(":3C") || data.includes(":3D") || data.includes(":3H") || - data.includes(":3F") + data.includes(":3F") || + data.includes(":3P") || + data.includes(":3Q") || + data.includes(":3S") ) { return true; } @@ -584,7 +604,10 @@ export function isKeyRepeat(data: string): boolean { data.includes(":2C") || data.includes(":2D") || data.includes(":2H") || - data.includes(":2F") + data.includes(":2F") || + data.includes(":2P") || + data.includes(":2Q") || + data.includes(":2S") ) { return true; } @@ -644,6 +667,18 @@ function parseKittySequence(data: string): ParsedKittySequence | null { 6: FUNCTIONAL_CODEPOINTS.pageDown, 7: FUNCTIONAL_CODEPOINTS.home, 8: FUNCTIONAL_CODEPOINTS.end, + 11: FUNCTIONAL_CODEPOINTS.f1, + 12: FUNCTIONAL_CODEPOINTS.f2, + 13: FUNCTIONAL_CODEPOINTS.f3, + 14: FUNCTIONAL_CODEPOINTS.f4, + 15: FUNCTIONAL_CODEPOINTS.f5, + 17: FUNCTIONAL_CODEPOINTS.f6, + 18: FUNCTIONAL_CODEPOINTS.f7, + 19: FUNCTIONAL_CODEPOINTS.f8, + 20: FUNCTIONAL_CODEPOINTS.f9, + 21: FUNCTIONAL_CODEPOINTS.f10, + 23: FUNCTIONAL_CODEPOINTS.f11, + 24: FUNCTIONAL_CODEPOINTS.f12, }; const codepoint = funcCodes[keyNum]; if (codepoint !== undefined) { @@ -662,6 +697,21 @@ function parseKittySequence(data: string): ParsedKittySequence | null { return { codepoint, modifier: modValue - 1, eventType }; } + // F1, F2, F4: \x1b[P/Q/S or \x1b[1;P/Q/S or \x1b[1;:P/Q/S + // F3 is sent as \x1b[13~ because \x1b[1;R collides with cursor position reports. + const fKeyMatch = data.match(/^\x1b\[(?:1;(\d+)(?::(\d+))?)?([PQS])$/); + if (fKeyMatch) { + const modValue = fKeyMatch[1] ? parseInt(fKeyMatch[1], 10) : 1; + const eventType = parseEventType(fKeyMatch[2]); + const fKeyCodes: Record = { + P: FUNCTIONAL_CODEPOINTS.f1, + Q: FUNCTIONAL_CODEPOINTS.f2, + S: FUNCTIONAL_CODEPOINTS.f4, + }; + _lastEventType = eventType; + return { codepoint: fKeyCodes[fKeyMatch[3]!]!, modifier: modValue - 1, eventType }; + } + return null; } @@ -1176,11 +1226,11 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "f10": case "f11": case "f12": { - if (modifier !== 0) { - return false; + const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES & keyof typeof FUNCTIONAL_CODEPOINTS; + if (modifier === 0 && matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey])) { + return true; } - const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES; - return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey]); + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS[functionKey], modifier); } } @@ -1277,6 +1327,8 @@ function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: nu 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 <= FUNCTIONAL_CODEPOINTS.f1 && effectiveCodepoint >= FUNCTIONAL_CODEPOINTS.f12) + keyName = `f${FUNCTIONAL_CODEPOINTS.f1 - effectiveCodepoint + 1}`; else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up"; else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down"; else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left"; @@ -1428,6 +1480,10 @@ export function decodeKittyPrintable(data: string): string | undefined { effectiveCodepoint = normalizeKittyFunctionalCodepoint(effectiveCodepoint); // Drop control characters or invalid codepoints. if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined; + // Drop unmapped Kitty functional keys (F13-F35, media keys, ...). + if (effectiveCodepoint >= KITTY_FUNCTIONAL_PUA_START && effectiveCodepoint <= KITTY_FUNCTIONAL_PUA_END) { + return undefined; + } try { return String.fromCodePoint(effectiveCodepoint); diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index 0045610bc..4494508e1 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -7,6 +7,8 @@ import { describe, it } from "node:test"; import { decodeKittyPrintable, decodePrintableKey, + isKeyRelease, + isKeyRepeat, Key, matchesKey, parseKey, @@ -509,6 +511,26 @@ describe("matchesKey", () => { 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); @@ -525,6 +547,12 @@ describe("matchesKey", () => { }); 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");