diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index a4aaf6e9..11a0eb48 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1211,6 +1211,43 @@ describe('InputPrompt', () => { }); }); + describe('multiline paste', () => { + it.each([ + { + description: 'with \n newlines', + pastedText: 'This \n is \n a \n multiline \n paste.', + }, + { + description: 'with extra slashes before \n newlines', + pastedText: 'This \\\n is \\\n a \\\n multiline \\\n paste.', + }, + { + description: 'with \r\n newlines', + pastedText: 'This\r\nis\r\na\r\nmultiline\r\npaste.', + }, + ])('should handle multiline paste $description', async ({ pastedText }) => { + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Simulate a bracketed paste event from the terminal + stdin.write(`\x1b[200~${pastedText}\x1b[201~`); + await wait(); + + // Verify that the buffer's handleInput was called once with the full text + expect(props.buffer.handleInput).toHaveBeenCalledTimes(1); + expect(props.buffer.handleInput).toHaveBeenCalledWith( + expect.objectContaining({ + paste: true, + sequence: pastedText, + }), + ); + + unmount(); + }); + }); + describe('enhanced input UX - double ESC clear functionality', () => { it('should clear buffer on second ESC press', async () => { const onEscapePromptChange = vi.fn(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index dcfdace3..99a59c34 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -239,6 +239,12 @@ export const InputPrompt: React.FC = ({ return; } + if (key.paste) { + // Ensure we never accidentally interpret paste as regular input. + buffer.handleInput(key); + return; + } + if (vimHandleInput && vimHandleInput(key)) { return; } diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 84bbdc9b..93f6e360 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1833,6 +1833,13 @@ export function useTextBuffer({ }): void => { const { sequence: input } = key; + if (key.paste) { + // Do not do any other processing on pastes so ensure we handle them + // before all other cases. + insert(input, { paste: key.paste }); + return; + } + if ( key.name === 'return' || input === '\r' || diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 01630229..caed50be 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { renderHook, act } from '@testing-library/react'; +import { renderHook, act, waitFor } from '@testing-library/react'; import { vi, Mock } from 'vitest'; import { KeypressProvider, @@ -28,18 +28,6 @@ vi.mock('ink', async (importOriginal) => { }; }); -// Mock the 'readline' module -vi.mock('readline', () => { - const mockedReadline = { - createInterface: vi.fn().mockReturnValue({ close: vi.fn() }), - emitKeypressEvents: vi.fn(), - }; - return { - ...mockedReadline, - default: mockedReadline, - }; -}); - class MockStdin extends EventEmitter { isTTY = true; setRawMode = vi.fn(); @@ -47,6 +35,7 @@ class MockStdin extends EventEmitter { override removeListener = super.removeListener; write = vi.fn(); resume = vi.fn(); + pause = vi.fn(); // Helper to simulate a keypress event pressKey(key: Partial) { @@ -55,32 +44,16 @@ class MockStdin extends EventEmitter { // Helper to simulate a kitty protocol sequence sendKittySequence(sequence: string) { - // Kitty sequences come in multiple parts - // For example, ESC[13u comes as: ESC[ then 1 then 3 then u - // ESC[57414;2u comes as: ESC[ then 5 then 7 then 4 then 1 then 4 then ; then 2 then u - const escIndex = sequence.indexOf('\x1b['); - if (escIndex !== -1) { - // Send ESC[ - this.emit('keypress', null, { - name: undefined, - sequence: '\x1b[', - ctrl: false, - meta: false, - shift: false, - }); + this.emit('data', Buffer.from(sequence)); + } - // Send the rest character by character - const rest = sequence.substring(escIndex + 2); - for (const char of rest) { - this.emit('keypress', null, { - name: /[a-zA-Z0-9]/.test(char) ? char : undefined, - sequence: char, - ctrl: false, - meta: false, - shift: false, - }); - } - } + // Helper to simulate a paste event + sendPaste(text: string) { + const PASTE_MODE_PREFIX = `\x1b[200~`; + const PASTE_MODE_SUFFIX = `\x1b[201~`; + this.emit('data', Buffer.from(PASTE_MODE_PREFIX)); + this.emit('data', Buffer.from(text)); + this.emit('data', Buffer.from(PASTE_MODE_SUFFIX)); } } @@ -304,4 +277,37 @@ describe('KeypressContext - Kitty Protocol', () => { ); }); }); + + describe('paste mode', () => { + it('should handle multiline paste as a single event', async () => { + const keyHandler = vi.fn(); + const pastedText = 'This \nis \na \nmultiline \npaste.'; + + const { result } = renderHook(() => useKeypressContext(), { + wrapper, + }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Simulate a bracketed paste event + act(() => { + stdin.sendPaste(pastedText); + }); + + await waitFor(() => { + // Expect the handler to be called exactly once for the entire paste + expect(keyHandler).toHaveBeenCalledTimes(1); + }); + + // Verify the single event contains the full pasted text + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + paste: true, + sequence: pastedText, + }), + ); + }); + }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index b1e70aed..eaf1819b 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -172,6 +172,29 @@ export function KeypressProvider({ }; const handleKeypress = (_: unknown, key: Key) => { + if (key.name === 'paste-start') { + isPaste = true; + return; + } + if (key.name === 'paste-end') { + isPaste = false; + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + pasteBuffer = Buffer.alloc(0); + return; + } + + if (isPaste) { + pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); + return; + } + if (key.name === 'return' && waitingForEnterAfterBackslash) { if (backslashTimeout) { clearTimeout(backslashTimeout); @@ -278,29 +301,10 @@ export function KeypressProvider({ } } - if (key.name === 'paste-start') { - isPaste = true; - } else if (key.name === 'paste-end') { - isPaste = false; - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); - pasteBuffer = Buffer.alloc(0); - } else { - if (isPaste) { - pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); - } else { - if (key.name === 'return' && key.sequence === `${ESC}\r`) { - key.meta = true; - } - broadcast({ ...key, paste: isPaste }); - } + if (key.name === 'return' && key.sequence === `${ESC}\r`) { + key.meta = true; } + broadcast({ ...key, paste: isPaste }); }; const handleRawKeypress = (data: Buffer) => {