fix(keyboard): handle kitty keypad private-use keycodes

This commit is contained in:
zach 2026-03-06 06:38:43 +00:00
parent fcebd14a90
commit 8a0189c32d
2 changed files with 171 additions and 1 deletions

View file

@ -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' },

View file

@ -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<number, string> = {
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<number, string> = {
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 =