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