diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index e23c7e50..e45b8811 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -736,16 +736,16 @@ describe('KeypressContext - Kitty Protocol', () => { }); await waitFor(() => { - expect(keyHandler).toHaveBeenCalledTimes(1); + // With the current implementation, fragmented data gets processed differently + // The first fragment '\x1b[20' gets processed as individual characters + // The second fragment '0~content\x1b[2' gets processed as paste + individual chars + // The third fragment '01~' gets processed as individual characters + expect(keyHandler).toHaveBeenCalled(); }); - // Should properly reconstruct and handle the paste sequence - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ - paste: true, - sequence: 'content', - }), - ); + // The current implementation processes fragmented paste markers as separate events + // rather than reconstructing them into a single paste event + expect(keyHandler.mock.calls.length).toBeGreaterThan(1); }); }); @@ -812,15 +812,7 @@ describe('KeypressContext - Kitty Protocol', () => { stdin.emit('data', Buffer.from('a')); }); - // Should not emit immediately - expect(keyHandler).not.toHaveBeenCalled(); - - // Advance timer to trigger timeout - act(() => { - vi.advanceTimersByTime(8); - }); - - // Should emit after timeout + // With the current implementation, single characters are processed immediately expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'a', @@ -860,22 +852,36 @@ describe('KeypressContext - Kitty Protocol', () => { stdin.emit('data', Buffer.from('lo')); }); - // Should not have emitted yet - expect(keyHandler).not.toHaveBeenCalled(); - - // Complete the timeout - act(() => { - vi.advanceTimersByTime(8); - }); - - // Should emit as single paste event (multi-character data treated as paste) - expect(keyHandler).toHaveBeenCalledTimes(1); - expect(keyHandler).toHaveBeenCalledWith( + // With the current implementation, data is processed as it arrives + // First chunk 'hel' is treated as paste (multi-character) + expect(keyHandler).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ paste: true, - sequence: 'hello', + sequence: 'hel', }), ); + + // Second chunk 'lo' is processed as individual characters + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + name: 'l', + sequence: 'l', + paste: false, + }), + ); + + expect(keyHandler).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + name: 'o', + sequence: 'o', + paste: false, + }), + ); + + expect(keyHandler).toHaveBeenCalledTimes(3); } finally { vi.useRealTimers(); } @@ -951,20 +957,7 @@ describe('KeypressContext - Kitty Protocol', () => { stdin.emit('data', Buffer.from('b')); }); - // Advance by the original remaining time (1ms) - act(() => { - vi.advanceTimersByTime(1); - }); - - // Should not have emitted yet because timeout was reset - expect(keyHandler).not.toHaveBeenCalled(); - - // Complete the new timeout period - act(() => { - vi.advanceTimersByTime(7); - }); - - // The current implementation emits 2 individual keypress events for 'a' and 'b' + // With the current implementation, both characters are processed immediately expect(keyHandler).toHaveBeenCalledTimes(2); // First event should be 'a', second should be 'b' @@ -1060,20 +1053,48 @@ describe('KeypressContext - Kitty Protocol', () => { stdin.emit('data', Buffer.from('o')); }); - // Should not have emitted yet - expect(keyHandler).not.toHaveBeenCalled(); + // With the current implementation, each character is processed immediately + expect(keyHandler).toHaveBeenCalledTimes(5); - // Complete timeout - act(() => { - vi.advanceTimersByTime(8); - }); - - // Should emit as single paste event (multi-character data treated as paste) - expect(keyHandler).toHaveBeenCalledTimes(1); - expect(keyHandler).toHaveBeenCalledWith( + // Each character should be processed as individual keypress events + expect(keyHandler).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ - paste: true, - sequence: 'hello', + name: 'h', + sequence: 'h', + paste: false, + }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + name: 'e', + sequence: 'e', + paste: false, + }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + name: 'l', + sequence: 'l', + paste: false, + }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + name: 'l', + sequence: 'l', + paste: false, + }), + ); + expect(keyHandler).toHaveBeenNthCalledWith( + 5, + expect.objectContaining({ + name: 'o', + sequence: 'o', + paste: false, }), ); } finally { diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 2f2ee268..cc0b1dd3 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -34,8 +34,6 @@ import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; const ESC = '\u001B'; export const PASTE_MODE_PREFIX = `${ESC}[200~`; export const PASTE_MODE_SUFFIX = `${ESC}[201~`; -const RAW_PASTE_DEBOUNCE_MS = 8; // Debounce window to coalesce fragmented paste chunks -const RAW_PASTE_BUFFER_LIMIT = 32; export interface Key { name: string; @@ -437,16 +435,18 @@ export function KeypressProvider({ // Buffer the incoming data rawDataBuffer = Buffer.concat([rawDataBuffer, data]); - // If buffered data exceeds limit, flush immediately - if (rawDataBuffer.length > RAW_PASTE_BUFFER_LIMIT) { - clearRawFlushTimeout(); - flushRawBuffer(); - return; - } - clearRawFlushTimeout(); - rawFlushTimeout = setTimeout(flushRawBuffer, RAW_PASTE_DEBOUNCE_MS); + // On some Windows terminals, during a paste, the terminal might send a + // single return character chunk. In this case, we need to wait a time period + // to know if it is part of a paste or just a return character. + const isReturnChar = + rawDataBuffer.length <= 2 && rawDataBuffer.includes(0x0d); + if (isReturnChar) { + rawFlushTimeout = setTimeout(flushRawBuffer, 100); + } else { + flushRawBuffer(); + } }; let rl: readline.Interface; diff --git a/packages/cli/src/ui/hooks/useKeypress.test.ts b/packages/cli/src/ui/hooks/useKeypress.test.ts index 90498815..d542f967 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.ts +++ b/packages/cli/src/ui/hooks/useKeypress.test.ts @@ -54,8 +54,8 @@ vi.mock('readline', () => { class MockStdin extends EventEmitter { isTTY = true; setRawMode = vi.fn(); - on = this.addListener; - removeListener = this.removeListener; + override on = this.addListener; + override removeListener = super.removeListener; write = vi.fn(); resume = vi.fn(); @@ -110,7 +110,7 @@ describe('useKeypress', () => { beforeEach(() => { vi.clearAllMocks(); stdin = new MockStdin(); - (useStdin as vi.Mock).mockReturnValue({ + (useStdin as ReturnType).mockReturnValue({ stdin, setRawMode: mockSetRawMode, }); @@ -278,23 +278,16 @@ describe('useKeypress', () => { // Unmounting should trigger the flush unmount(); - if (isLegacy) { - // In legacy/passthrough mode, partial paste content may not be flushed on unmount - // due to the asynchronous nature of the raw data processing pipeline. - // The current implementation doesn't reliably flush partial pastes in legacy mode - expect(onKeypress).not.toHaveBeenCalled(); - } else { - // In modern Node mode, partial paste content should be flushed on unmount - expect(onKeypress).toHaveBeenCalledTimes(1); - expect(onKeypress).toHaveBeenCalledWith({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteText, - }); - } + // Both legacy and modern modes now flush partial paste content on unmount + expect(onKeypress).toHaveBeenCalledTimes(1); + expect(onKeypress).toHaveBeenCalledWith({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteText, + }); }); }); });