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
This commit is contained in:
Deevi Eswar 2026-05-12 12:30:30 +05:30
parent 11a9edc808
commit 1703aa8cce
2 changed files with 111 additions and 0 deletions

View file

@ -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);
}

View file

@ -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 }),
);
});
});
});