diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 93e7742f8..1130f8352 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -229,6 +229,33 @@ describe('KeypressContext - Kitty Protocol', () => { }), ); }); + + it('should not process kitty sequences when kitty protocol is disabled', async () => { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, kittyProtocolEnabled: false }), + }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send kitty protocol sequence for numpad enter + act(() => { + stdin.sendKittySequence(`\x1b[57414u`); + }); + + // When kitty protocol is disabled, the sequence should be passed through + // as individual keypresses, not recognized as a single enter key + expect(keyHandler).not.toHaveBeenCalledWith( + expect.objectContaining({ + name: 'return', + kittyProtocol: true, + }), + ); + }); }); describe('Escape key handling', () => { @@ -1229,13 +1256,13 @@ describe('KeypressContext - Kitty Protocol', () => { }); expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] CSI buffer accumulating:', + '[DEBUG] Kitty buffer accumulating:', expect.stringContaining('\x1b[27u'), ); const parsedCall = consoleLogSpy.mock.calls.find( (args) => typeof args[0] === 'string' && - args[0].includes('[DEBUG] CSI sequence parsed successfully'), + args[0].includes('[DEBUG] Kitty sequence parsed successfully'), ); expect(parsedCall).toBeTruthy(); expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u')); @@ -1266,7 +1293,7 @@ describe('KeypressContext - Kitty Protocol', () => { }); expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] CSI buffer overflow, clearing:', + '[DEBUG] Kitty buffer overflow, clearing:', expect.any(String), ); }); @@ -1357,13 +1384,13 @@ describe('KeypressContext - Kitty Protocol', () => { // Verify debug logging for accumulation expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEBUG] CSI buffer accumulating:', + '[DEBUG] Kitty buffer accumulating:', sequence, ); // Verify warning for char codes expect(consoleWarnSpy).toHaveBeenCalledWith( - 'CSI sequence buffer has char codes:', + 'Kitty sequence buffer has char codes:', [27, 91, 49, 50], ); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 8df81a9d8..dbdbf3e55 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -35,6 +35,7 @@ import { MODIFIER_ALT_BIT, MODIFIER_CTRL_BIT, } from '../utils/platformConstants.js'; +import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; @@ -53,6 +54,7 @@ export interface Key { paste: boolean; sequence: string; kittyProtocol?: boolean; + pasteImage?: boolean; } export type KeypressHandler = (key: Key) => void; @@ -387,7 +389,7 @@ export function KeypressProvider({ } }; - const handleKeypress = (_: unknown, key: Key) => { + const handleKeypress = async (_: unknown, key: Key) => { if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { return; } @@ -397,14 +399,28 @@ export function KeypressProvider({ } if (key.name === 'paste-end') { isPaste = false; - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); + if (pasteBuffer.toString().length > 0) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + } else { + const hasImage = await clipboardHasImage(); + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + pasteImage: hasImage, + sequence: pasteBuffer.toString(), + }); + } + pasteBuffer = Buffer.alloc(0); return; } @@ -508,97 +524,95 @@ export function KeypressProvider({ return; } - // Parse CSI sequences for both Kitty protocol and legacy terminals - // This ensures Shift+Tab and other special keys work correctly even when - // Kitty protocol is not available (e.g., Windows PowerShell) - if ( - kittySequenceBuffer || - (key.sequence && - key.sequence.startsWith(`${ESC}[`) && - !key.sequence.startsWith(PASTE_MODE_PREFIX) && - !key.sequence.startsWith(PASTE_MODE_SUFFIX) && - !key.sequence.startsWith(FOCUS_IN) && - !key.sequence.startsWith(FOCUS_OUT)) - ) { - kittySequenceBuffer += key.sequence; - - if (debugKeystrokeLogging) { - console.log('[DEBUG] CSI buffer accumulating:', 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; - } - if (debugKeystrokeLogging) { - const parsedSequence = kittySequenceBuffer.slice(0, parsed.length); - if (kittySequenceBuffer.length > parsed.length) { - console.log( - '[DEBUG] CSI sequence parsed successfully (prefix):', - parsedSequence, - ); - } else { - console.log( - '[DEBUG] CSI 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) => - ch.charCodeAt(0), - ); - console.warn('CSI sequence buffer has char codes:', codes); - } - + if (kittyProtocolEnabled) { if ( - kittyProtocolEnabled && - kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH + kittySequenceBuffer || + (key.sequence.startsWith(`${ESC}[`) && + !key.sequence.startsWith(PASTE_MODE_PREFIX) && + !key.sequence.startsWith(PASTE_MODE_SUFFIX) && + !key.sequence.startsWith(FOCUS_IN) && + !key.sequence.startsWith(FOCUS_OUT)) ) { + kittySequenceBuffer += key.sequence; + if (debugKeystrokeLogging) { console.log( - '[DEBUG] CSI buffer overflow, clearing:', + '[DEBUG] Kitty buffer accumulating:', kittySequenceBuffer, ); } - if (config) { - const event = new KittySequenceOverflowEvent( - kittySequenceBuffer.length, - kittySequenceBuffer, - ); - logKittySequenceOverflow(config, event); + + // 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; + } + 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) => + ch.charCodeAt(0), + ); + console.warn('Kitty sequence buffer has char codes:', codes); + } + + if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) { + if (debugKeystrokeLogging) { + console.log( + '[DEBUG] Kitty buffer overflow, clearing:', + kittySequenceBuffer, + ); + } + if (config) { + const event = new KittySequenceOverflowEvent( + kittySequenceBuffer.length, + kittySequenceBuffer, + ); + logKittySequenceOverflow(config, event); + } + kittySequenceBuffer = ''; + } else { + return; } - kittySequenceBuffer = ''; - } else if (!kittyProtocolEnabled) { - // For non-Kitty terminals, clear the buffer to avoid accumulation - kittySequenceBuffer = ''; - } else { - return; } } @@ -721,6 +735,7 @@ export function KeypressProvider({ }; let rl: readline.Interface; + if (usePassthrough) { rl = readline.createInterface({ input: keypressStream,