From d54702185be9f05ff7740f4d70a2277ce664cde8 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Fri, 20 Feb 2026 10:09:10 -0800 Subject: [PATCH] feat(cli): add support for numpad SS3 sequences (#19659) --- .../src/ui/contexts/KeypressContext.test.tsx | 74 +++++++++++++++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 48 +++++++++--- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 1635fd3c14..e25ff57642 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -758,6 +758,80 @@ describe('KeypressContext', () => { ); }); + describe('Numpad support', () => { + it.each([ + { + sequence: '\x1bOj', + expected: { name: '*', sequence: '*', insertable: true }, + }, + { + sequence: '\x1bOk', + expected: { name: '+', sequence: '+', insertable: true }, + }, + { + sequence: '\x1bOm', + expected: { name: '-', sequence: '-', insertable: true }, + }, + { + sequence: '\x1bOo', + expected: { name: '/', sequence: '/', insertable: true }, + }, + { + sequence: '\x1bOp', + expected: { name: '0', sequence: '0', insertable: true }, + }, + { + sequence: '\x1bOq', + expected: { name: '1', sequence: '1', insertable: true }, + }, + { + sequence: '\x1bOr', + expected: { name: '2', sequence: '2', insertable: true }, + }, + { + sequence: '\x1bOs', + expected: { name: '3', sequence: '3', insertable: true }, + }, + { + sequence: '\x1bOt', + expected: { name: '4', sequence: '4', insertable: true }, + }, + { + sequence: '\x1bOu', + expected: { name: '5', sequence: '5', insertable: true }, + }, + { + sequence: '\x1bOv', + expected: { name: '6', sequence: '6', insertable: true }, + }, + { + sequence: '\x1bOw', + expected: { name: '7', sequence: '7', insertable: true }, + }, + { + sequence: '\x1bOx', + expected: { name: '8', sequence: '8', insertable: true }, + }, + { + sequence: '\x1bOy', + expected: { name: '9', sequence: '9', insertable: true }, + }, + { + sequence: '\x1bOn', + expected: { name: '.', sequence: '.', insertable: true }, + }, + ])( + 'should recognize numpad sequence "$sequence" as $expected.name', + ({ sequence, expected }) => { + const { keyHandler } = setupKeypressTest(); + act(() => stdin.write(sequence)); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining(expected), + ); + }, + ); + }); + describe('Double-tap and batching', () => { it('should emit two delete events for double-tap CSI[3~', async () => { const { keyHandler } = setupKeypressTest(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 217f5182bb..7d1881644d 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -122,6 +122,25 @@ const KEY_INFO_MAP: Record< '[8^': { name: 'end', ctrl: true }, }; +// Numpad keys in Application Keypad Mode (SS3 sequences) +const NUMPAD_MAP: Record = { + Oj: '*', + Ok: '+', + Om: '-', + Oo: '/', + Op: '0', + Oq: '1', + Or: '2', + Os: '3', + Ot: '4', + Ou: '5', + Ov: '6', + Ow: '7', + Ox: '8', + Oy: '9', + On: '.', +}; + const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 function charLengthAt(str: string, i: number): number { if (str.length <= i) { @@ -538,18 +557,27 @@ function* emitKeys( insertable = true; } } else { - name = 'undefined'; - if ( - (ctrl || cmd || alt) && - (code.endsWith('u') || code.endsWith('~')) - ) { - // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) - const codeNumber = parseInt(code.slice(1, -1), 10); + const numpadChar = NUMPAD_MAP[code]; + if (numpadChar) { + name = numpadChar; + if (!ctrl && !cmd && !alt) { + sequence = numpadChar; + insertable = true; + } + } else { + name = 'undefined'; if ( - codeNumber >= 'a'.charCodeAt(0) && - codeNumber <= 'z'.charCodeAt(0) + (ctrl || cmd || alt) && + (code.endsWith('u') || code.endsWith('~')) ) { - name = String.fromCharCode(codeNumber); + // 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); + } } } }