diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 59874bfceb..d07f6663cb 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -73,12 +73,14 @@ export const renderWithProviders = ( settings = mockSettings, uiState: providedUiState, width, + kittyProtocolEnabled = true, config = configProxy as unknown as Config, }: { shellFocus?: boolean; settings?: LoadedSettings; uiState?: Partial; width?: number; + kittyProtocolEnabled?: boolean; config?: Config; } = {}, ): ReturnType => { @@ -115,7 +117,7 @@ export const renderWithProviders = ( - + {component} diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 2f09b0ecd6..11676cf2f6 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -5,7 +5,7 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; -import { waitFor } from '@testing-library/react'; +import { waitFor, act } from '@testing-library/react'; import { vi } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; import * as processUtils from '../../utils/processUtils.js'; @@ -50,7 +50,9 @@ describe('FolderTrustDialog', () => { , ); - stdin.write('\x1b'); // escape key + act(() => { + stdin.write('\u001b[27u'); // Press kitty escape key + }); await waitFor(() => { expect(lastFrame()).toContain( @@ -87,7 +89,9 @@ describe('FolderTrustDialog', () => { , ); - stdin.write('r'); + act(() => { + stdin.write('r'); + }); await waitFor(() => { expect(mockedExit).not.toHaveBeenCalled(); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 5198dff2f5..c52ebf7d3c 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1616,6 +1616,7 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , + { kittyProtocolEnabled: true }, ); await vi.runAllTimersAsync(); @@ -1661,6 +1662,7 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , + { kittyProtocolEnabled: false }, ); await wait(); @@ -1682,6 +1684,7 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , + { kittyProtocolEnabled: false }, ); stdin.write('\x1B'); @@ -1703,6 +1706,7 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , + { kittyProtocolEnabled: false }, ); await wait(); @@ -1722,6 +1726,7 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , + { kittyProtocolEnabled: false }, ); await wait(); @@ -1733,23 +1738,27 @@ describe('InputPrompt', () => { }); it('should not call onEscapePromptChange when not provided', async () => { + vi.useFakeTimers(); props.onEscapePromptChange = undefined; props.buffer.setText('some text'); const { stdin, unmount } = renderWithProviders( , + { kittyProtocolEnabled: false }, ); - await wait(); + await vi.runAllTimersAsync(); stdin.write('\x1B'); - await wait(); + await vi.runAllTimersAsync(); + vi.useRealTimers(); unmount(); }); it('should not interfere with existing keyboard shortcuts', async () => { const { stdin, unmount } = renderWithProviders( , + { kittyProtocolEnabled: false }, ); await wait(); @@ -1821,6 +1830,7 @@ describe('InputPrompt', () => { stdin.write('\x12'); await wait(); stdin.write('\x1B'); + stdin.write('\u001b[27u'); // Press kitty escape key await waitFor(() => { expect(stdout.lastFrame()).not.toContain('(r:)'); @@ -1922,7 +1932,7 @@ describe('InputPrompt', () => { stdin.write('\x12'); await wait(); expect(stdout.lastFrame()).toContain('(r:)'); - stdin.write('\x1B'); + stdin.write('\u001b[27u'); // Press kitty escape key await waitFor(() => { expect(stdout.lastFrame()).not.toContain('(r:)'); diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx index e3008782fa..a88f533820 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx @@ -141,7 +141,7 @@ describe('PermissionsModifyTrustDialog', () => { await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); act(() => { - stdin.write('\x1b'); // escape key + stdin.write('\u001b[27u'); // Kitty escape key }); await waitFor(() => { @@ -201,7 +201,7 @@ describe('PermissionsModifyTrustDialog', () => { await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); - act(() => stdin.write('\x1b')); // Press escape + act(() => stdin.write('\u001b[27u')); // Press kitty escape key await waitFor(() => { expect(mockCommitTrustLevelChange).not.toHaveBeenCalled(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 56d924015a..b4f3b17074 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -13,6 +13,7 @@ import { KeypressProvider, useKeypressContext, DRAG_COMPLETION_TIMEOUT_MS, + KITTY_SEQUENCE_TIMEOUT_MS, // CSI_END_O, // SS3_END, SINGLE_QUOTE, @@ -70,7 +71,7 @@ describe('KeypressContext - Kitty Protocol', () => { children: React.ReactNode; kittyProtocolEnabled?: boolean; }) => ( - + {children} ); @@ -476,7 +477,7 @@ describe('KeypressContext - Kitty Protocol', () => { expect(consoleLogSpy).toHaveBeenCalledWith( '[DEBUG] Kitty buffer accumulating:', - expect.stringContaining('\x1b[27u'), + expect.stringContaining('"\\u001b[27u"'), ); const parsedCall = consoleLogSpy.mock.calls.find( (args) => @@ -484,7 +485,7 @@ describe('KeypressContext - Kitty Protocol', () => { args[0].includes('[DEBUG] Kitty sequence parsed successfully'), ); expect(parsedCall).toBeTruthy(); - expect(parsedCall?.[1]).toEqual(expect.stringContaining('\x1b[27u')); + expect(parsedCall?.[1]).toEqual(expect.stringContaining('\\u001b[27u')); }); it('should log kitty buffer overflow when debugKeystrokeLogging is true', async () => { @@ -505,10 +506,10 @@ describe('KeypressContext - Kitty Protocol', () => { result.current.subscribe(keyHandler); }); - // Send an invalid long sequence to trigger overflow - const longInvalidSequence = '\x1b[' + 'x'.repeat(100); + // Send a long sequence starting with a valid kitty prefix to trigger overflow + const longSequence = '\x1b[1;' + '1'.repeat(100); act(() => { - stdin.sendKittySequence(longInvalidSequence); + stdin.sendKittySequence(longSequence); }); expect(consoleLogSpy).toHaveBeenCalledWith( @@ -604,13 +605,13 @@ describe('KeypressContext - Kitty Protocol', () => { // Verify debug logging for accumulation expect(consoleLogSpy).toHaveBeenCalledWith( '[DEBUG] Kitty buffer accumulating:', - sequence, + JSON.stringify(sequence), ); // Verify warning for char codes expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Kitty sequence buffer has char codes:', - [27, 91, 49, 50], + 'Kitty sequence buffer has content:', + JSON.stringify(sequence), ); }); }); @@ -753,8 +754,16 @@ describe('Drag and Drop Handling', () => { let stdin: MockStdin; const mockSetRawMode = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + const wrapper = ({ + children, + kittyProtocolEnabled = true, + }: { + children: React.ReactNode; + kittyProtocolEnabled?: boolean; + }) => ( + + {children} + ); beforeEach(() => { @@ -957,16 +966,25 @@ describe('Drag and Drop Handling', () => { }); }); -describe('Terminal-specific Alt+key combinations', () => { +describe('Kitty Sequence Parsing', () => { let stdin: MockStdin; const mockSetRawMode = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + const wrapper = ({ + children, + kittyProtocolEnabled = true, + }: { + children: React.ReactNode; + kittyProtocolEnabled?: boolean; + }) => ( + + {children} + ); beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); stdin = new MockStdin(); (useStdin as Mock).mockReturnValue({ stdin, @@ -974,6 +992,10 @@ describe('Terminal-specific Alt+key combinations', () => { }); }); + afterEach(() => { + vi.useRealTimers(); + }); + // Terminals to test const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal']; @@ -1009,6 +1031,7 @@ describe('Terminal-specific Alt+key combinations', () => { return { terminal, key, + kitty: false, input: { sequence: `\x1b${key}`, name: key, @@ -1059,13 +1082,22 @@ describe('Terminal-specific Alt+key combinations', () => { kittySequence, input, expected, + kitty = true, }: { kittySequence?: string; input?: Partial; expected: Partial; + kitty?: boolean; }) => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const testWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useKeypressContext(), { + wrapper: testWrapper, + }); act(() => result.current.subscribe(keyHandler)); if (kittySequence) { @@ -1118,4 +1150,502 @@ describe('Terminal-specific Alt+key combinations', () => { ); }); }); + + it('should timeout and flush incomplete kitty sequences after 50ms', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send incomplete kitty sequence + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[1;', + }); + }); + + // Should not broadcast immediately + expect(keyHandler).not.toHaveBeenCalled(); + + // Advance time just before timeout + act(() => { + vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS - 5); + }); + + // Still shouldn't broadcast + expect(keyHandler).not.toHaveBeenCalled(); + + // Advance past timeout + act(() => { + vi.advanceTimersByTime(10); + }); + + // Should now broadcast the incomplete sequence as regular input + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + sequence: '\x1b[1;', + paste: false, + }), + ); + }); + + it('should immediately flush non-kitty CSI sequences', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send a CSI sequence that doesn't match kitty patterns + // ESC[m is SGR reset, not a kitty sequence + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[m', + }); + }); + + // Should broadcast immediately as it's not a valid kitty pattern + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + sequence: '\x1b[m', + paste: false, + }), + ); + }); + + it('should parse valid kitty sequences immediately when complete', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send complete kitty sequence for Ctrl+A + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[97;5u', + }); + }); + + // Should parse and broadcast immediately + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'a', + ctrl: true, + kittyProtocol: true, + }), + ); + }); + + it('should handle batched kitty sequences correctly', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send multiple kitty sequences at once + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[97;5u\x1b[98;5u', // Ctrl+a followed by Ctrl+b + }); + }); + + // Should parse both sequences + expect(keyHandler).toHaveBeenCalledTimes(2); + expect(keyHandler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: 'a', + ctrl: true, + kittyProtocol: true, + }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + name: 'b', + ctrl: true, + kittyProtocol: true, + }), + ); + }); + + it('should clear kitty buffer and timeout on Ctrl+C', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send incomplete kitty sequence + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[1;', + }); + }); + + // Press Ctrl+C + act(() => { + stdin.pressKey({ + name: 'c', + ctrl: true, + meta: false, + shift: false, + paste: false, + sequence: '\x03', + }); + }); + + // Advance past timeout + act(() => { + vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10); + }); + + // Should only have received Ctrl+C, not the incomplete sequence + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'c', + ctrl: true, + }), + ); + }); + + it('should handle mixed valid and invalid sequences', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send valid kitty sequence followed by invalid CSI + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[13u\x1b[!', // Valid enter, then invalid sequence + }); + }); + + // Should parse valid sequence and flush invalid immediately + expect(keyHandler).toHaveBeenCalledTimes(2); + expect(keyHandler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: 'return', + kittyProtocol: true, + }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + name: '', + sequence: '\x1b[!', + }), + ); + }); + + it('should not buffer 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 what would be a kitty sequence + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[13u', + }); + }); + + // Should pass through without parsing + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + sequence: '\x1b[13u', + }), + ); + expect(keyHandler).not.toHaveBeenCalledWith( + expect.objectContaining({ + name: 'return', + kittyProtocol: true, + }), + ); + }); + + it('should handle sequences arriving character by character', async () => { + vi.useRealTimers(); // Required for correct buffering timing. + + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send kitty sequence character by character + const sequence = '\x1b[27u'; // Escape key + for (const char of sequence) { + act(() => { + stdin.emit('data', Buffer.from(char)); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + // Should parse once complete + await waitFor(() => { + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'escape', + kittyProtocol: true, + }), + ); + }); + }); + + it('should reset timeout when new input arrives', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Start incomplete sequence + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[1', + }); + }); + + // Advance time partway + act(() => { + vi.advanceTimersByTime(30); + }); + + // Add more to sequence + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '3', + }); + }); + + // Advance time from the first timeout point + act(() => { + vi.advanceTimersByTime(25); + }); + + // Should not have timed out yet (timeout restarted) + expect(keyHandler).not.toHaveBeenCalled(); + + // Complete the sequence + act(() => { + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'u', + }); + }); + + // Should now parse as complete enter key + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'return', + kittyProtocol: true, + }), + ); + }); + + it('should flush incomplete kitty sequence on FOCUS_IN event', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send incomplete kitty sequence + act(() => { + stdin.pressKey({ + sequence: '\x1b[1;', + }); + }); + + // Incomplete sequence should be buffered, not broadcast + expect(keyHandler).not.toHaveBeenCalled(); + + // Send FOCUS_IN event + const FOCUS_IN = '\x1b[I'; + act(() => { + stdin.pressKey({ + sequence: FOCUS_IN, + }); + }); + + // The buffered sequence should be flushed + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + sequence: '\x1b[1;', + paste: false, + }), + ); + }); + + it('should flush incomplete kitty sequence on FOCUS_OUT event', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send incomplete kitty sequence + act(() => { + stdin.pressKey({ + sequence: '\x1b[1;', + }); + }); + + // Incomplete sequence should be buffered, not broadcast + expect(keyHandler).not.toHaveBeenCalled(); + + // Send FOCUS_OUT event + const FOCUS_OUT = '\x1b[O'; + act(() => { + stdin.pressKey({ + sequence: FOCUS_OUT, + }); + }); + + // The buffered sequence should be flushed + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + sequence: '\x1b[1;', + paste: false, + }), + ); + }); + + it('should flush incomplete kitty sequence on paste event', async () => { + vi.useFakeTimers(); + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Send incomplete kitty sequence + act(() => { + stdin.pressKey({ + sequence: '\x1b[1;', + }); + }); + + // Incomplete sequence should be buffered, not broadcast + expect(keyHandler).not.toHaveBeenCalled(); + + // Send paste start sequence + const PASTE_MODE_PREFIX = `\x1b[200~`; + act(() => { + stdin.emit('data', Buffer.from(PASTE_MODE_PREFIX)); + }); + + // The buffered sequence should be flushed + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + sequence: '\x1b[1;', + paste: false, + }), + ); + + // Now send some paste content and end paste to make sure paste still works + const pastedText = 'hello'; + const PASTE_MODE_SUFFIX = `\x1b[201~`; + act(() => { + stdin.emit('data', Buffer.from(pastedText)); + stdin.emit('data', Buffer.from(PASTE_MODE_SUFFIX)); + }); + + act(() => { + vi.runAllTimers(); + }); + + // The paste event should be broadcast + expect(keyHandler).toHaveBeenCalledTimes(2); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + paste: true, + sequence: pastedText, + }), + ); + vi.useRealTimers(); + }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 027eea8d56..78e31aa27e 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -42,6 +42,7 @@ const ESC = '\u001B'; export const PASTE_MODE_PREFIX = `${ESC}[200~`; export const PASTE_MODE_SUFFIX = `${ESC}[201~`; export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input +export const KITTY_SEQUENCE_TIMEOUT_MS = 50; // Flush incomplete kitty sequences after 50ms export const SINGLE_QUOTE = "'"; export const DOUBLE_QUOTE = '"'; @@ -163,9 +164,39 @@ export function KeypressProvider({ let isPaste = false; let pasteBuffer = Buffer.alloc(0); let kittySequenceBuffer = ''; + let kittySequenceTimeout: NodeJS.Timeout | null = null; let backslashTimeout: NodeJS.Timeout | null = null; let waitingForEnterAfterBackslash = false; + // Check if a buffer could potentially be a valid kitty sequence or its prefix + const couldBeKittySequence = (buffer: string): boolean => { + // Kitty sequences always start with ESC[. + if (buffer.length === 0) return true; + if (buffer === ESC || buffer === `${ESC}[`) return true; + + if (!buffer.startsWith(`${ESC}[`)) return false; + + // Check for known kitty sequence patterns: + // 1. ESC[ - could be CSI-u or tilde-coded + // 2. ESC[1; - parameterized functional + // 3. ESC[ - legacy functional keys + // 4. ESC[Z - reverse tab + const afterCSI = buffer.slice(2); + + // Check if it starts with a digit (could be CSI-u or parameterized) + if (/^\d/.test(afterCSI)) return true; + + // Check for known single-letter sequences + if (/^[ABCDHFPQRSZ]/.test(afterCSI)) return true; + + // Check for 1; pattern (parameterized sequences) + if (/^1;\d/.test(afterCSI)) return true; + + // Anything else starting with ESC[ that doesn't match our patterns + // is likely not a kitty sequence we handle + return false; + }; + // 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 @@ -416,11 +447,37 @@ export function KeypressProvider({ } }; + const flushKittyBufferOnInterrupt = (reason: string) => { + if (kittySequenceBuffer) { + if (debugKeystrokeLogging) { + console.log( + `[DEBUG] Kitty sequence flushed due to ${reason}:`, + JSON.stringify(kittySequenceBuffer), + ); + } + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: kittySequenceBuffer, + }); + kittySequenceBuffer = ''; + } + if (kittySequenceTimeout) { + clearTimeout(kittySequenceTimeout); + kittySequenceTimeout = null; + } + }; + const handleKeypress = (_: unknown, key: Key) => { if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { + flushKittyBufferOnInterrupt('focus event'); return; } if (key.name === 'paste-start') { + flushKittyBufferOnInterrupt('paste start'); isPaste = true; return; } @@ -534,6 +591,10 @@ export function KeypressProvider({ ); } kittySequenceBuffer = ''; + if (kittySequenceTimeout) { + clearTimeout(kittySequenceTimeout); + kittySequenceTimeout = null; + } if (key.sequence === `${ESC}${KITTY_CTRL_C}`) { broadcast({ name: 'c', @@ -551,94 +612,151 @@ export function KeypressProvider({ } if (kittyProtocolEnabled) { - if ( - 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)) - ) { + // Clear any pending timeout when new input arrives + if (kittySequenceTimeout) { + clearTimeout(kittySequenceTimeout); + kittySequenceTimeout = null; + } + + // Check if this could start a kitty sequence + const startsWithEsc = key.sequence.startsWith(ESC); + const isExcluded = [ + PASTE_MODE_PREFIX, + PASTE_MODE_SUFFIX, + FOCUS_IN, + FOCUS_OUT, + ].some((prefix) => key.sequence.startsWith(prefix)); + + if (kittySequenceBuffer || (startsWithEsc && !isExcluded)) { kittySequenceBuffer += key.sequence; if (debugKeystrokeLogging) { console.log( '[DEBUG] Kitty buffer accumulating:', - kittySequenceBuffer, + JSON.stringify(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. + // Try immediate parsing + let remainingBuffer = kittySequenceBuffer; 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 { + + while (remainingBuffer) { + const parsed = parseKittyPrefix(remainingBuffer); + + if (parsed) { + if (debugKeystrokeLogging) { + const parsedSequence = remainingBuffer.slice(0, parsed.length); console.log( '[DEBUG] Kitty sequence parsed successfully:', - parsedSequence, + JSON.stringify(parsedSequence), ); } - } - // Consume the parsed prefix and broadcast it. - kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length); - broadcast(parsed.key); - parsedAny = true; - } - if (parsedAny) return; + broadcast(parsed.key); + remainingBuffer = remainingBuffer.slice(parsed.length); + parsedAny = true; + } else { + // If we can't parse a sequence at the start, check if there's + // another ESC later in the buffer. If so, the data before it + // is garbage/incomplete and should be dropped so we can + // process the next sequence. + const nextEscIndex = remainingBuffer.indexOf(ESC, 1); + if (nextEscIndex !== -1) { + const garbage = remainingBuffer.slice(0, nextEscIndex); + if (debugKeystrokeLogging) { + console.log( + '[DEBUG] Dropping incomplete sequence before next ESC:', + JSON.stringify(garbage), + ); + } + // Drop garbage and continue parsing from next ESC + remainingBuffer = remainingBuffer.slice(nextEscIndex); + // We made progress, so we can continue the loop to parse the next sequence + continue; + } - if (config?.getDebugMode() || debugKeystrokeLogging) { - const codes = Array.from(kittySequenceBuffer).map((ch) => - ch.charCodeAt(0), - ); - console.warn('Kitty sequence buffer has char codes:', codes); + // Check if buffer could become a valid kitty sequence + const couldBeValid = couldBeKittySequence(remainingBuffer); + + if (!couldBeValid) { + // Not a kitty sequence - flush as regular input immediately + if (debugKeystrokeLogging) { + console.log( + '[DEBUG] Not a kitty sequence, flushing:', + JSON.stringify(remainingBuffer), + ); + } + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: remainingBuffer, + }); + remainingBuffer = ''; + parsedAny = true; + } else if (remainingBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) { + // Buffer overflow - log and clear + if (debugKeystrokeLogging) { + console.log( + '[DEBUG] Kitty buffer overflow, clearing:', + JSON.stringify(remainingBuffer), + ); + } + if (config) { + const event = new KittySequenceOverflowEvent( + remainingBuffer.length, + remainingBuffer, + ); + logKittySequenceOverflow(config, event); + } + // Flush as regular input + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: remainingBuffer, + }); + remainingBuffer = ''; + parsedAny = true; + } else { + if (config?.getDebugMode() || debugKeystrokeLogging) { + console.warn( + 'Kitty sequence buffer has content:', + JSON.stringify(kittySequenceBuffer), + ); + } + // Could be valid but incomplete - set timeout + kittySequenceTimeout = setTimeout(() => { + if (kittySequenceBuffer) { + if (debugKeystrokeLogging) { + console.log( + '[DEBUG] Kitty sequence timeout, flushing:', + JSON.stringify(kittySequenceBuffer), + ); + } + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: kittySequenceBuffer, + }); + kittySequenceBuffer = ''; + } + kittySequenceTimeout = null; + }, KITTY_SEQUENCE_TIMEOUT_MS); + break; + } + } } - 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 = remainingBuffer; + if (parsedAny || kittySequenceBuffer) return; } } @@ -734,6 +852,24 @@ export function KeypressProvider({ backslashTimeout = null; } + if (kittySequenceTimeout) { + clearTimeout(kittySequenceTimeout); + kittySequenceTimeout = null; + } + + // Flush any pending kitty sequence data to avoid data loss on exit. + if (kittySequenceBuffer) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: kittySequenceBuffer, + }); + kittySequenceBuffer = ''; + } + // Flush any pending paste data to avoid data loss on exit. if (isPaste) { broadcast({