feat(cli): support literal character keybindings and extended Kitty protocol keys (#21972)

This commit is contained in:
Tommaso Sciortino
2026-03-11 04:49:20 +00:00
committed by GitHub
parent f8ad3a200a
commit 075e0b1a81
4 changed files with 97 additions and 28 deletions

View File

@@ -637,6 +637,9 @@ describe('KeypressContext', () => {
describe('Parameterized functional keys', () => {
it.each([
// CSI-u numeric keys
{ sequence: `\x1b[53;5u`, expected: { name: '5', ctrl: true } },
{ sequence: `\x1b[51;2u`, expected: { name: '3', shift: true } },
// ModifyOtherKeys
{ sequence: `\x1b[27;2;13~`, expected: { name: 'enter', shift: true } },
{ sequence: `\x1b[27;5;13~`, expected: { name: 'enter', ctrl: true } },
@@ -665,6 +668,14 @@ describe('KeypressContext', () => {
{ sequence: `\x1b[17~`, expected: { name: 'f6' } },
{ sequence: `\x1b[23~`, expected: { name: 'f11' } },
{ sequence: `\x1b[24~`, expected: { name: 'f12' } },
{ sequence: `\x1b[25~`, expected: { name: 'f13' } },
{ sequence: `\x1b[34~`, expected: { name: 'f20' } },
// Kitty Extended Function Keys (F13-F35)
{ sequence: `\x1b[302u`, expected: { name: 'f13' } },
{ sequence: `\x1b[324u`, expected: { name: 'f35' } },
// Modifier / Special Keys (Kitty Protocol)
{ sequence: `\x1b[57358u`, expected: { name: 'capslock' } },
{ sequence: `\x1b[57362u`, expected: { name: 'pausebreak' } },
// Reverse tabs
{ sequence: `\x1b[Z`, expected: { name: 'tab', shift: true } },
{ sequence: `\x1b[1;2Z`, expected: { name: 'tab', shift: true } },
@@ -820,6 +831,20 @@ describe('KeypressContext', () => {
sequence: '\x1bOn',
expected: { name: '.', sequence: '.', insertable: true },
},
// Kitty Numpad Support (CSI-u)
{
sequence: '\x1b[57404u',
expected: { name: 'numpad5', sequence: '5', insertable: true },
},
{
modifier: 'Ctrl',
sequence: '\x1b[57404;5u',
expected: { name: 'numpad5', ctrl: true, insertable: false },
},
{
sequence: '\x1b[57411u',
expected: { name: 'numpad_multiply', sequence: '*', insertable: true },
},
])(
'should recognize numpad sequence "$sequence" as $expected.name',
({ sequence, expected }) => {

View File

@@ -66,6 +66,14 @@ const KEY_INFO_MAP: Record<
'[21~': { name: 'f10' },
'[23~': { name: 'f11' },
'[24~': { name: 'f12' },
'[25~': { name: 'f13' },
'[26~': { name: 'f14' },
'[28~': { name: 'f15' },
'[29~': { name: 'f16' },
'[31~': { name: 'f17' },
'[32~': { name: 'f18' },
'[33~': { name: 'f19' },
'[34~': { name: 'f20' },
'[A': { name: 'up' },
'[B': { name: 'down' },
'[C': { name: 'right' },
@@ -91,12 +99,6 @@ const KEY_INFO_MAP: Record<
OZ: { name: 'tab', shift: true }, // SS3 Shift+Tab variant for Windows terminals
'[[5~': { name: 'pageup' },
'[[6~': { name: 'pagedown' },
'[9u': { name: 'tab' },
'[13u': { name: 'enter' },
'[27u': { name: 'escape' },
'[32u': { name: 'space' },
'[127u': { name: 'backspace' },
'[57414u': { name: 'enter' }, // Numpad Enter
'[a': { name: 'up', shift: true },
'[b': { name: 'down', shift: true },
'[c': { name: 'right', shift: true },
@@ -122,6 +124,46 @@ const KEY_INFO_MAP: Record<
'[8^': { name: 'end', ctrl: true },
};
// Kitty Keyboard Protocol (CSI u) code mappings
const KITTY_CODE_MAP: Record<number, { name: string; sequence?: string }> = {
2: { name: 'insert' },
3: { name: 'delete' },
5: { name: 'pageup' },
6: { name: 'pagedown' },
9: { name: 'tab' },
13: { name: 'enter' },
14: { name: 'up' },
15: { name: 'down' },
16: { name: 'right' },
17: { name: 'left' },
27: { name: 'escape' },
32: { name: 'space', sequence: ' ' },
127: { name: 'backspace' },
57358: { name: 'capslock' },
57359: { name: 'scrolllock' },
57360: { name: 'numlock' },
57361: { name: 'printscreen' },
57362: { name: 'pausebreak' },
57409: { name: 'numpad_decimal', sequence: '.' },
57410: { name: 'numpad_divide', sequence: '/' },
57411: { name: 'numpad_multiply', sequence: '*' },
57412: { name: 'numpad_subtract', sequence: '-' },
57413: { name: 'numpad_add', sequence: '+' },
57414: { name: 'enter' },
57416: { name: 'numpad_separator', sequence: ',' },
// Function keys F13-F35, not standard, but supported by Kitty
...Object.fromEntries(
Array.from({ length: 23 }, (_, i) => [302 + i, { name: `f${13 + i}` }]),
),
// Numpad keys in Numeric Keypad Mode (CSI u codes 57399-57408)
...Object.fromEntries(
Array.from({ length: 10 }, (_, i) => [
57399 + i,
{ name: `numpad${i}`, sequence: String(i) },
]),
),
};
// Numpad keys in Application Keypad Mode (SS3 sequences)
const NUMPAD_MAP: Record<string, string> = {
Oj: '*',
@@ -565,17 +607,24 @@ function* emitKeys(
}
} else {
name = 'undefined';
if (
(ctrl || cmd || alt) &&
(code.endsWith('u') || code.endsWith('~'))
) {
if (code.endsWith('u') || code.endsWith('~')) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (
codeNumber >= 'a'.charCodeAt(0) &&
codeNumber <= 'z'.charCodeAt(0)
) {
name = String.fromCharCode(codeNumber);
if (codeNumber >= 33 && codeNumber <= 126) {
const char = String.fromCharCode(codeNumber);
name = char.toLowerCase();
if (char >= 'A' && char <= 'Z') {
shift = true;
}
} else {
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
}
}
}
}

View File

@@ -97,13 +97,6 @@ describe('KeyBinding', () => {
'Invalid keybinding key: "ctlr+a" in "ctlr+a"',
);
});
it('should throw an error for literal "+" as key (must use "=")', () => {
// VS Code style peeling logic results in "+" as the remains
expect(() => new KeyBinding('alt++')).toThrow(
'Invalid keybinding key: "+" in "alt++"',
);
});
});
});

View File

@@ -110,10 +110,8 @@ export enum Command {
* Data-driven key binding structure for user configuration
*/
export class KeyBinding {
private static readonly VALID_KEYS = new Set([
...'abcdefghijklmnopqrstuvwxyz0123456789', // Letters & Numbers
..."`-=[]\\;',./", // Punctuation
...Array.from({ length: 19 }, (_, i) => `f${i + 1}`), // Function Keys
private static readonly VALID_LONG_KEYS = new Set([
...Array.from({ length: 35 }, (_, i) => `f${i + 1}`), // Function Keys
...Array.from({ length: 10 }, (_, i) => `numpad${i}`), // Numpad Numbers
// Navigation & Actions
'left',
@@ -130,6 +128,7 @@ export class KeyBinding {
'space',
'backspace',
'delete',
'clear',
'pausebreak',
'capslock',
'insert',
@@ -193,8 +192,11 @@ export class KeyBinding {
const key = remains;
if (!KeyBinding.VALID_KEYS.has(key)) {
throw new Error(`Invalid keybinding key: "${key}" in "${pattern}"`);
if ([...key].length !== 1 && !KeyBinding.VALID_LONG_KEYS.has(key)) {
throw new Error(
`Invalid keybinding key: "${key}" in "${pattern}".` +
` Must be a single character or one of: ${[...KeyBinding.VALID_LONG_KEYS].join(', ')}`,
);
}
this.key = key;