diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 7b1c5a1721..6ae489d3d3 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -451,10 +451,13 @@ describe('KeypressContext - Kitty Protocol', () => { '[DEBUG] Kitty buffer accumulating:', expect.stringContaining('\x1b[27u'), ); - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] Kitty sequence parsed successfully:', - expect.stringContaining('\x1b[27u'), + const parsedCall = consoleLogSpy.mock.calls.find( + (args) => + typeof args[0] === 'string' && + args[0].includes('[DEBUG] Kitty sequence parsed successfully'), ); + expect(parsedCall).toBeTruthy(); + expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u')); }); it('should log kitty buffer overflow when debugKeystrokeLogging is true', async () => { @@ -584,4 +587,137 @@ describe('KeypressContext - Kitty Protocol', () => { ); }); }); + + describe('Parameterized functional keys', () => { + it.each([ + // Parameterized + { sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } }, + { sequence: `\x1b[1;5F`, expected: { name: 'end', ctrl: true } }, + { sequence: `\x1b[1;1P`, expected: { name: 'f1' } }, + { sequence: `\x1b[1;3Q`, expected: { name: 'f2', meta: true } }, + { sequence: `\x1b[3~`, expected: { name: 'delete' } }, + { sequence: `\x1b[5~`, expected: { name: 'pageup' } }, + { sequence: `\x1b[6~`, expected: { name: 'pagedown' } }, + { sequence: `\x1b[1~`, expected: { name: 'home' } }, + { sequence: `\x1b[4~`, expected: { name: 'end' } }, + { sequence: `\x1b[2~`, expected: { name: 'insert' } }, + // Legacy Arrows + { + sequence: `\x1b[A`, + expected: { name: 'up', ctrl: false, meta: false, shift: false }, + }, + { + sequence: `\x1b[B`, + expected: { name: 'down', ctrl: false, meta: false, shift: false }, + }, + { + sequence: `\x1b[C`, + expected: { name: 'right', ctrl: false, meta: false, shift: false }, + }, + { + sequence: `\x1b[D`, + expected: { name: 'left', ctrl: false, meta: false, shift: false }, + }, + // Legacy Home/End + { + sequence: `\x1b[H`, + expected: { name: 'home', ctrl: false, meta: false, shift: false }, + }, + { + sequence: `\x1b[F`, + expected: { name: 'end', ctrl: false, meta: false, shift: false }, + }, + ])( + 'should recognize sequence "$sequence" as $expected.name', + ({ sequence, expected }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(sequence)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining(expected), + ); + }, + ); + }); + + describe('Shift+Tab forms', () => { + it.each([ + { sequence: `\x1b[Z`, description: 'legacy reverse Tab' }, + { sequence: `\x1b[1;2Z`, description: 'parameterized reverse Tab' }, + ])( + 'should recognize $description "$sequence" as Shift+Tab', + ({ sequence }) => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(sequence)); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'tab', shift: true }), + ); + }, + ); + }); + + describe('Double-tap and batching', () => { + it('should emit two delete events for double-tap CSI[3~', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[3~`)); + act(() => stdin.sendKittySequence(`\x1b[3~`)); + + expect(keyHandler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'delete' }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: 'delete' }), + ); + }); + + it('should parse two concatenated tilde-coded sequences in one chunk', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => stdin.sendKittySequence(`\x1b[3~\x1b[5~`)); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'delete' }), + ); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'pageup' }), + ); + }); + + it('should ignore incomplete CSI then parse the next complete sequence', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + // Incomplete ESC sequence then a complete Delete + act(() => { + // Provide an incomplete ESC sequence chunk with a real ESC character + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + sequence: '\x1b[1;', + }); + }); + act(() => stdin.sendKittySequence(`\x1b[3~`)); + + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'delete' }), + ); + }); + }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index a7af64a95e..73afe70c5b 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -29,6 +29,11 @@ import { KITTY_KEYCODE_NUMPAD_ENTER, KITTY_KEYCODE_TAB, MAX_KITTY_SEQUENCE_LENGTH, + KITTY_MODIFIER_BASE, + KITTY_MODIFIER_EVENT_TYPES_OFFSET, + MODIFIER_SHIFT_BIT, + MODIFIER_ALT_BIT, + MODIFIER_CTRL_BIT, } from '../utils/platformConstants.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; @@ -116,48 +121,244 @@ export function KeypressProvider({ let backslashTimeout: NodeJS.Timeout | null = null; let waitingForEnterAfterBackslash = false; - const parseKittySequence = (sequence: string): Key | null => { - const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`); - const match = sequence.match(kittyPattern); - if (!match) return null; - - const keyCode = parseInt(match[1], 10); - const modifiers = match[3] ? parseInt(match[3], 10) : 1; - const modifierBits = modifiers - 1; - const shift = (modifierBits & 1) === 1; - const alt = (modifierBits & 2) === 2; - const ctrl = (modifierBits & 4) === 4; - - const keyNameMap: Record = { - [CHAR_CODE_ESC]: 'escape', - [KITTY_KEYCODE_TAB]: 'tab', - [KITTY_KEYCODE_BACKSPACE]: 'backspace', - [KITTY_KEYCODE_ENTER]: 'return', - [KITTY_KEYCODE_NUMPAD_ENTER]: 'return', - }; - - if (keyCode in keyNameMap) { + // Parse a single complete kitty sequence from the start (prefix) of the + // buffer and return both the Key and the number of characters consumed. + // This lets us "peel off" one complete event when multiple sequences arrive + // in a single chunk, preventing buffer overflow and fragmentation. + // Parse a single complete kitty/parameterized/legacy sequence from the start + // of the buffer and return both the parsed Key and the number of characters + // consumed. This enables peel-and-continue parsing for batched input. + const parseKittyPrefix = ( + buffer: string, + ): { key: Key; length: number } | null => { + // In older terminals ESC [ Z was used as Cursor Backward Tabulation (CBT) + // In newer terminals the same functionality of key combination for moving + // backward through focusable elements is Shift+Tab, hence we will + // map ESC [ Z to Shift+Tab + // 0) Reverse Tab (legacy): ESC [ Z + // Treat as Shift+Tab for UI purposes. + // Regex parts: + // ^ - start of buffer + // ESC [ - CSI introducer + // Z - legacy reverse tab + const revTabLegacy = new RegExp(`^${ESC}\\[Z`); + let m = buffer.match(revTabLegacy); + if (m) { return { - name: keyNameMap[keyCode], - ctrl, - meta: alt, - shift, - paste: false, - sequence, - kittyProtocol: true, + key: { + name: 'tab', + ctrl: false, + meta: false, + shift: true, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, }; } - if (keyCode >= 97 && keyCode <= 122 && ctrl) { - const letter = String.fromCharCode(keyCode); + // 1) Reverse Tab (parameterized): ESC [ 1 ; Z + // Parameterized reverse Tab: ESC [ 1 ; Z + const revTabParam = new RegExp(`^${ESC}\\[1;(\\d+)Z`); + m = buffer.match(revTabParam); + if (m) { + let mods = parseInt(m[1], 10); + if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { + mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; + } + const bits = mods - KITTY_MODIFIER_BASE; + const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; + const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; return { - name: letter, - ctrl: true, - meta: alt, - shift, - paste: false, - sequence, - kittyProtocol: true, + key: { + name: 'tab', + ctrl, + meta: alt, + // Reverse tab implies Shift behavior; force shift regardless of mods + shift: true, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) + // 2) Parameterized functional: ESC [ 1 ; (A|B|C|D|H|F|P|Q|R|S) + // Arrows, Home/End, F1–F4 with modifiers encoded in . + const arrowPrefix = new RegExp(`^${ESC}\\[1;(\\d+)([ABCDHFPQSR])`); + m = buffer.match(arrowPrefix); + if (m) { + let mods = parseInt(m[1], 10); + if (mods >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { + mods -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; + } + const bits = mods - KITTY_MODIFIER_BASE; + const shift = (bits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; + const alt = (bits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; + const ctrl = (bits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; + const sym = m[2]; + const symbolToName: { [k: string]: string } = { + A: 'up', + B: 'down', + C: 'right', + D: 'left', + H: 'home', + F: 'end', + P: 'f1', + Q: 'f2', + R: 'f3', + S: 'f4', + }; + const name = symbolToName[sym] || ''; + if (!name) return null; + return { + key: { + name, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // 3) CSI-u form: ESC [ ; (u|~) + // 3) CSI-u and tilde-coded functional keys: ESC [ ; (u|~) + // 'u' terminator: Kitty CSI-u; '~' terminator: tilde-coded function keys. + const csiUPrefix = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])`); + m = buffer.match(csiUPrefix); + if (m) { + const keyCode = parseInt(m[1], 10); + let modifiers = m[3] ? parseInt(m[3], 10) : KITTY_MODIFIER_BASE; + if (modifiers >= KITTY_MODIFIER_EVENT_TYPES_OFFSET) { + modifiers -= KITTY_MODIFIER_EVENT_TYPES_OFFSET; + } + const modifierBits = modifiers - KITTY_MODIFIER_BASE; + const shift = + (modifierBits & MODIFIER_SHIFT_BIT) === MODIFIER_SHIFT_BIT; + const alt = (modifierBits & MODIFIER_ALT_BIT) === MODIFIER_ALT_BIT; + const ctrl = (modifierBits & MODIFIER_CTRL_BIT) === MODIFIER_CTRL_BIT; + const terminator = m[4]; + + // Tilde-coded functional keys (Delete, Insert, PageUp/Down, Home/End) + if (terminator === '~') { + let name: string | null = null; + switch (keyCode) { + case 1: + name = 'home'; + break; + case 2: + name = 'insert'; + break; + case 3: + name = 'delete'; + break; + case 4: + name = 'end'; + break; + case 5: + name = 'pageup'; + break; + case 6: + name = 'pagedown'; + break; + default: + break; + } + if (name) { + return { + key: { + name, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + } + + const kittyKeyCodeToName: { [key: number]: string } = { + [CHAR_CODE_ESC]: 'escape', + [KITTY_KEYCODE_TAB]: 'tab', + [KITTY_KEYCODE_BACKSPACE]: 'backspace', + [KITTY_KEYCODE_ENTER]: 'return', + [KITTY_KEYCODE_NUMPAD_ENTER]: 'return', + }; + + const name = kittyKeyCodeToName[keyCode]; + if (name) { + return { + key: { + name, + ctrl, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + + // Ctrl+letters + if ( + ctrl && + keyCode >= 'a'.charCodeAt(0) && + keyCode <= 'z'.charCodeAt(0) + ) { + const letter = String.fromCharCode(keyCode); + return { + key: { + name: letter, + ctrl: true, + meta: alt, + shift, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, + }; + } + } + + // 4) Legacy function keys (no parameters): ESC [ (A|B|C|D|H|F) + // Arrows + Home/End without modifiers. + const legacyFuncKey = new RegExp(`^${ESC}\\[([ABCDHF])`); + m = buffer.match(legacyFuncKey); + if (m) { + const sym = m[1]; + const nameMap: { [key: string]: string } = { + A: 'up', + B: 'down', + C: 'right', + D: 'left', + H: 'home', + F: 'end', + }; + const name = nameMap[sym]!; + return { + key: { + name, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: buffer.slice(0, m[0].length), + kittyProtocol: true, + }, + length: m[0].length, }; } @@ -285,18 +486,51 @@ export function KeypressProvider({ ); } - const kittyKey = parseKittySequence(kittySequenceBuffer); - if (kittyKey) { - if (debugKeystrokeLogging) { - console.log( - '[DEBUG] Kitty sequence parsed successfully:', - kittySequenceBuffer, - ); + // Try to peel off as many complete sequences as are available at the + // start of the buffer. This handles batched inputs cleanly. If the + // prefix is incomplete or invalid, skip to the next CSI introducer + // (ESC[) so that a following valid sequence can still be parsed. + let parsedAny = false; + while (kittySequenceBuffer) { + const parsed = parseKittyPrefix(kittySequenceBuffer); + if (!parsed) { + // Look for the next potential CSI start beyond index 0 + const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1); + if (nextStart > 0) { + if (debugKeystrokeLogging) { + console.log( + '[DEBUG] Skipping incomplete/invalid CSI prefix:', + kittySequenceBuffer.slice(0, nextStart), + ); + } + kittySequenceBuffer = kittySequenceBuffer.slice(nextStart); + continue; + } + break; } - kittySequenceBuffer = ''; - broadcast(kittyKey); - return; + if (debugKeystrokeLogging) { + const parsedSequence = kittySequenceBuffer.slice( + 0, + parsed.length, + ); + if (kittySequenceBuffer.length > parsed.length) { + console.log( + '[DEBUG] Kitty sequence parsed successfully (prefix):', + parsedSequence, + ); + } else { + console.log( + '[DEBUG] Kitty sequence parsed successfully:', + parsedSequence, + ); + } + } + // Consume the parsed prefix and broadcast it. + kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length); + broadcast(parsed.key); + parsedAny = true; } + if (parsedAny) return; if (config?.getDebugMode() || debugKeystrokeLogging) { const codes = Array.from(kittySequenceBuffer).map((ch) => diff --git a/packages/cli/src/ui/utils/platformConstants.ts b/packages/cli/src/ui/utils/platformConstants.ts index 976653103f..7993883d6c 100644 --- a/packages/cli/src/ui/utils/platformConstants.ts +++ b/packages/cli/src/ui/utils/platformConstants.ts @@ -25,6 +25,31 @@ export const KITTY_KEYCODE_NUMPAD_ENTER = 57414; export const KITTY_KEYCODE_TAB = 9; export const KITTY_KEYCODE_BACKSPACE = 127; +/** + * Kitty modifier decoding constants + * + * In Kitty/Ghostty, the modifier parameter is encoded as (1 + bitmask). + * Some terminals also set bit 7 (i.e., add 128) when reporting event types. + */ +export const KITTY_MODIFIER_BASE = 1; // Base value per spec before bitmask decode +export const KITTY_MODIFIER_EVENT_TYPES_OFFSET = 128; // Added when event types are included + +/** + * Modifier bit flags for Kitty/Xterm-style parameters. + * + * Per spec, the modifiers parameter encodes (1 + bitmask) where: + * - 1: no modifiers + * - bit 0 (1): Shift + * - bit 1 (2): Alt/Option (reported as "alt" in spec; we map to meta) + * - bit 2 (4): Ctrl + * + * Some terminals add 128 to the entire modifiers field when reporting event types. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#modifiers + */ +export const MODIFIER_SHIFT_BIT = 1; +export const MODIFIER_ALT_BIT = 2; +export const MODIFIER_CTRL_BIT = 4; + /** * Timing constants for terminal interactions */ @@ -49,7 +74,9 @@ export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5; * Longest reasonable: \x1b[127;15~ = 11 chars (Del with all modifiers) * We use 12 to provide a small buffer. */ -export const MAX_KITTY_SEQUENCE_LENGTH = 12; +// Increased to accommodate parameterized forms and occasional colon subfields +// while still being small enough to avoid pathological buffering. +export const MAX_KITTY_SEQUENCE_LENGTH = 32; /** * Character codes for common escape sequences