From 8a0189c32d8d2a39e2025eb106860e43478b5cfe Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 6 Mar 2026 06:38:43 +0000 Subject: [PATCH] fix(keyboard): handle kitty keypad private-use keycodes --- .../src/ui/contexts/KeypressContext.test.tsx | 99 +++++++++++++++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 73 +++++++++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index d69bada5b..edf25bead 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -1369,6 +1369,105 @@ describe('KeypressContext - Kitty Protocol', () => { }); }); + describe('Kitty keypad private-use keys', () => { + it.each([ + { keyCode: 57399, digit: '0' }, + { keyCode: 57400, digit: '1' }, + { keyCode: 57401, digit: '2' }, + { keyCode: 57402, digit: '3' }, + { keyCode: 57403, digit: '4' }, + { keyCode: 57404, digit: '5' }, + { keyCode: 57405, digit: '6' }, + { keyCode: 57406, digit: '7' }, + { keyCode: 57407, digit: '8' }, + { keyCode: 57408, digit: '9' }, + ])( + 'parses kitty keypad digit keyCode $keyCode as "$digit"', + ({ keyCode, digit }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: digit, + sequence: digit, + kittyProtocol: true, + }), + ); + }, + ); + + it.each([ + { keyCode: 57409, char: '.' }, + { keyCode: 57410, char: '/' }, + { keyCode: 57411, char: '*' }, + { keyCode: 57412, char: '-' }, + { keyCode: 57413, char: '+' }, + { keyCode: 57415, char: '=' }, + { keyCode: 57416, char: ',' }, + ])( + 'parses kitty keypad printable keyCode $keyCode as "$char"', + ({ keyCode, char }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: char, + sequence: char, + kittyProtocol: true, + }), + ); + }, + ); + + it.each([ + { keyCode: 57417, name: 'left' }, + { keyCode: 57418, name: 'right' }, + { keyCode: 57419, name: 'up' }, + { keyCode: 57420, name: 'down' }, + { keyCode: 57421, name: 'pageup' }, + { keyCode: 57422, name: 'pagedown' }, + { keyCode: 57423, name: 'home' }, + { keyCode: 57424, name: 'end' }, + { keyCode: 57425, name: 'insert' }, + { keyCode: 57426, name: 'delete' }, + ])( + 'parses kitty keypad functional keyCode $keyCode as $name', + ({ keyCode, name }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[${keyCode};5u`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name, + ctrl: true, + kittyProtocol: true, + }), + ); + }, + ); + + it('does not emit a placeholder for unmapped private-use keyCodes', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[57398u`)); + + expect(keyHandler).not.toHaveBeenCalled(); + }); + }); + describe('Shift+Tab forms', () => { it.each([ { sequence: `\x1b[Z`, description: 'legacy reverse Tab' }, diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 4496f5e1b..886da8441 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -47,6 +47,39 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m export const SINGLE_QUOTE = "'"; export const DOUBLE_QUOTE = '"'; +const KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR: Record = { + 57399: '0', + 57400: '1', + 57401: '2', + 57402: '3', + 57403: '4', + 57404: '5', + 57405: '6', + 57406: '7', + 57407: '8', + 57408: '9', + 57409: '.', + 57410: '/', + 57411: '*', + 57412: '-', + 57413: '+', + 57415: '=', + 57416: ',', +}; + +const KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME: Record = { + 57417: 'left', + 57418: 'right', + 57419: 'up', + 57420: 'down', + 57421: 'pageup', + 57422: 'pagedown', + 57423: 'home', + 57424: 'end', + 57425: 'insert', + 57426: 'delete', +}; + export interface Key { name: string; ctrl: boolean; @@ -332,14 +365,52 @@ export function KeypressProvider({ }; } + if (!ctrl) { + const keypadChar = KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR[keyCode]; + if (keypadChar) { + return { + key: { + name: keypadChar, + ctrl: false, + meta: alt, + shift, + paste: false, + sequence: keypadChar, + kittyProtocol: true, + }, + length: m[0].length, + }; + } + } + + const keypadName = KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME[keyCode]; + if (keypadName) { + return { + key: { + name: keypadName, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + // Printable CSI-u keys (including space) should behave like regular // character input so downstream text inputs receive the literal char. + // Kitty uses the Unicode private use area for some functional keys + // such as keypad events, so exclude that range from generic printable + // conversion and handle mapped keys explicitly above. if ( terminator === 'u' && !ctrl && keyCode >= 32 && keyCode !== 127 && - keyCode <= 0x10ffff + keyCode <= 0x10ffff && + !(keyCode >= 0xe000 && keyCode <= 0xf8ff) ) { const char = String.fromCodePoint(keyCode); const printableName =