From 1703aa8ccea3fc12eef208cb798995f14e9cdac2 Mon Sep 17 00:00:00 2001 From: Deevi Eswar Date: Tue, 12 May 2026 12:30:30 +0530 Subject: [PATCH] fix(cli): synthesize bracketed-paste markers for unbracketed multi-line input Pastes on Windows Terminal / PowerShell and WSL2 via ConPTY can arrive without bracketed-paste sequences, causing the embedded \r in \r\n to be interpreted as Enter and submit a partial command before the rest of the paste arrives. The existing 30ms bufferFastReturn fallback is unreliable when stdin chunks land with wider gaps. Detect stdin chunks that look like unbracketed paste content (containing \r\n, or \n in a chunk of length >= 4) and synthesize the bracketed-paste sequences before feeding them to emitKeys. The chunk then flows through the existing bufferPaste pipeline, producing a single paste keypress event and activating recentUnsafePasteTime protection in InputPrompt. State is tracked across chunks so the middle of a real bracketed paste is never re-wrapped. The length threshold excludes single-keystroke events that legitimately produce \n (Alt+Enter \x1b\n, Ctrl+J bare \n). Fixes #26114 --- .../cli/src/ui/contexts/KeypressContext.tsx | 44 ++++++++++++ .../cli/src/ui/hooks/useKeypress.test.tsx | 67 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 4744ecc348..3f9fbffc26 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -29,6 +29,34 @@ export const ESC_TIMEOUT = 50; export const PASTE_TIMEOUT = 30_000; export const FAST_RETURN_TIMEOUT = 30; +const BRACKETED_PASTE_START = '\x1b[200~'; +const BRACKETED_PASTE_END = '\x1b[201~'; + +/** + * Heuristic for stdin chunks that look like pastes from terminals that + * didn't honor bracketed-paste mode (commonly Windows Terminal/PowerShell + * and WSL2 via ConPTY). Wrapping such chunks in the bracketed-paste + * sequences lets the existing paste-buffering logic treat their contents + * as a single paste event with embedded newlines instead of a string of + * keypresses that can submit prematurely on the first '\r'. + * + * - '\r\n' is a smoking gun: never produced by a single keystroke in raw + * mode (Enter is bare '\r'), so any chunk containing it is paste content. + * - A bare '\n' in a chunk of length >= 4 is also treated as paste content. + * This length threshold avoids false positives on single-keystroke events + * that legitimately produce '\n' (Ctrl+J as bare '\n', Alt+Enter as + * '\x1b\n'). + */ +export function looksLikeUnbracketedPaste(data: string): boolean { + if (data.includes(BRACKETED_PASTE_START)) { + return false; + } + if (data.includes('\r\n')) { + return true; + } + return data.length >= 4 && data.includes('\n'); +} + export enum KeypressPriority { Low = -100, Normal = 0, @@ -361,8 +389,24 @@ function createDataListener(keypressHandler: KeypressHandler) { parser.next(); // prime the generator so it starts listening. let timeoutId: NodeJS.Timeout; + // Track unmatched bracketed-paste-start across stdin chunks so we don't + // re-wrap content that's the middle of a legitimate bracketed paste. + let insideBracketedPaste = false; + return (data: string) => { clearTimeout(timeoutId); + + const startIdx = data.lastIndexOf(BRACKETED_PASTE_START); + const endIdx = data.lastIndexOf(BRACKETED_PASTE_END); + if (startIdx !== -1 && (endIdx === -1 || endIdx < startIdx)) { + insideBracketedPaste = true; + } else if (endIdx !== -1 && (startIdx === -1 || startIdx < endIdx)) { + insideBracketedPaste = false; + } + + if (!insideBracketedPaste && looksLikeUnbracketedPaste(data)) { + data = BRACKETED_PASTE_START + data + BRACKETED_PASTE_END; + } for (const char of data) { parser.next(char); } diff --git a/packages/cli/src/ui/hooks/useKeypress.test.tsx b/packages/cli/src/ui/hooks/useKeypress.test.tsx index bff6f88f75..160572cc67 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.tsx +++ b/packages/cli/src/ui/hooks/useKeypress.test.tsx @@ -260,4 +260,71 @@ describe(`useKeypress`, () => { expect(onKeypress).toHaveBeenCalledTimes(3); }); }); + + describe('unbracketed paste from terminals not honoring bracketed-paste mode', () => { + it('should treat a chunk containing CRLF as a paste (Windows-style line ending)', async () => { + await renderKeypressHook(true); + const pasteText = 'first line\r\nsecond line'; + act(() => stdin.write(pasteText)); + + expect(onKeypress).toHaveBeenCalledTimes(1); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ name: 'paste', sequence: pasteText }), + ); + }); + + it('should treat a chunk containing bare LF as a paste (Unix-style line ending)', async () => { + await renderKeypressHook(true); + const pasteText = 'first line\nsecond line'; + act(() => stdin.write(pasteText)); + + expect(onKeypress).toHaveBeenCalledTimes(1); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ name: 'paste', sequence: pasteText }), + ); + }); + + it('should NOT wrap a single CR keystroke (real Enter)', async () => { + await renderKeypressHook(true); + act(() => stdin.write('\r')); + + expect(onKeypress).toHaveBeenCalledTimes(1); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ name: 'enter' }), + ); + }); + + it('should NOT wrap Alt+Enter (ESC + LF)', async () => { + await renderKeypressHook(true); + act(() => stdin.write('\x1b\n')); + + expect(onKeypress).toHaveBeenCalledTimes(1); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ name: 'enter', alt: true }), + ); + }); + + it('should NOT wrap a bare LF keystroke (Ctrl+J)', async () => { + await renderKeypressHook(true); + act(() => stdin.write('\n')); + + // Ctrl+J in raw mode arrives as bare '\n'. We don't care what name it + // gets — only that it is NOT delivered as a paste event. + expect(onKeypress).toHaveBeenCalledTimes(1); + expect(onKeypress).not.toHaveBeenCalledWith( + expect.objectContaining({ name: 'paste' }), + ); + }); + + it('should NOT double-wrap an already-bracketed paste', async () => { + await renderKeypressHook(true); + const pasteText = 'line a\r\nline b'; + act(() => stdin.write(PASTE_START + pasteText + PASTE_END)); + + expect(onKeypress).toHaveBeenCalledTimes(1); + expect(onKeypress).toHaveBeenCalledWith( + expect.objectContaining({ name: 'paste', sequence: pasteText }), + ); + }); + }); });