From 075e0b1a8123f3f48038fd9ced3d9397577d9a38 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Wed, 11 Mar 2026 04:49:20 +0000 Subject: [PATCH] feat(cli): support literal character keybindings and extended Kitty protocol keys (#21972) --- .../src/ui/contexts/KeypressContext.test.tsx | 25 ++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 79 +++++++++++++++---- packages/cli/src/ui/key/keyBindings.test.ts | 7 -- packages/cli/src/ui/key/keyBindings.ts | 14 ++-- 4 files changed, 97 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 1024488cfb..7cd17106f5 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -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 }) => { diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 7791872865..63e8a07a94 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -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 = { + 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 = { 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 [ ; (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; + } + } } } } diff --git a/packages/cli/src/ui/key/keyBindings.test.ts b/packages/cli/src/ui/key/keyBindings.test.ts index fb342e7513..c8a1c8787e 100644 --- a/packages/cli/src/ui/key/keyBindings.test.ts +++ b/packages/cli/src/ui/key/keyBindings.test.ts @@ -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++"', - ); - }); }); }); diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index fcf38d476a..cd234022fc 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -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;