mirror of
https://github.com/google-gemini/gemini-cli
synced 2026-05-24 09:38:34 +00:00
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:
parent
11a9edc808
commit
1703aa8cce
2 changed files with 111 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue