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) => {