From fff36aece21d6eb06592cb7bd7f7c4fd4e598356 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 26 Oct 2026 18:15:37 +0800 Subject: [PATCH] fix(cli): handle bracketed paste markers across input chunks --- packages/cli/src/tests/terminal-paste.test.ts | 170 ++++++++++++++++++ packages/cli/src/ui/hooks/useTerminalInput.ts | 124 ++++++------- 2 files changed, 225 insertions(+), 69 deletions(-) create mode 100644 packages/cli/src/tests/terminal-paste.test.ts diff --git a/packages/cli/src/tests/terminal-paste.test.ts b/packages/cli/src/tests/terminal-paste.test.ts new file mode 100644 index 00000000..dc16112f --- /dev/null +++ b/packages/cli/src/tests/terminal-paste.test.ts @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { PassThrough, Writable } from "node:stream"; +import { test, type TestContext } from "node:test"; +import { setTimeout } from "node:timers/promises"; +import React, { useState } from "react"; +import { render, Text } from "ink"; +import { EMPTY_BUFFER, expandPasteMarkers, insertText } from "../ui/core/prompt-buffer"; +import { usePasteHandling } from "../ui/hooks/usePasteHandling"; +import { useTerminalInput, type InputKey } from "../ui/hooks/useTerminalInput"; + +const PASTE_START = "\u001B[200~"; +const PASTE_END = "\u001B[201~"; + +async function harness(t: TestContext) { + const events: { input: string; key: InputKey }[] = []; + const submissions: string[] = []; + let text = ""; + + function Probe({ active = true }: { active?: boolean }) { + const [buffer, setBuffer] = useState(EMPTY_BUFFER); + const { handlePaste, pastesRef } = usePasteHandling(buffer, setBuffer, () => {}); + text = buffer.text; + useTerminalInput( + (input, key) => { + events.push({ input, key }); + if (key.paste) handlePaste(input); + else if (key.return) submissions.push(expandPasteMarkers(buffer.text, pastesRef.current)); + else if (input && !key.ctrl && !key.meta) setBuffer((state) => insertText(state, input)); + }, + { isActive: active } + ); + return React.createElement(Text, null, buffer.text || "ready"); + } + + const stdin = Object.assign(new PassThrough(), { isTTY: true, setRawMode() {}, ref() {}, unref() {} }); + const stdout = Object.assign( + new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }), + { isTTY: true, columns: 80, rows: 24 } + ); + const app = render(React.createElement(Probe), { + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream, + stderr: stdout as unknown as NodeJS.WriteStream, + debug: true, + patchConsole: false, + exitOnCtrlC: false, + }); + t.after(() => { + app.unmount(); + app.cleanup(); + }); + async function flush() { + await setTimeout(0); + await app.waitUntilRenderFlush(); + } + await flush(); + return { + events, + submissions, + text: () => text, + send: (chunk: string | Buffer) => stdin.emit("data", chunk), + flush, + async setActive(active: boolean) { + app.rerender(React.createElement(Probe, { active })); + await flush(); + }, + unmount: () => app.unmount(), + }; +} + +test("paste markers can split at every start/end boundary", async (t) => { + const h = await harness(t); + for (let start = 1; start < PASTE_START.length; start++) { + for (let end = 1; end < PASTE_END.length; end++) { + const payload = `payload-${start}-${end}\n你好`; + h.events.length = 0; + h.send(PASTE_START.slice(0, start)); + h.send(PASTE_START.slice(start) + payload + PASTE_END.slice(0, end)); + h.send(PASTE_END.slice(end)); + h.send("x"); + h.send("\r"); + assert.equal(h.events.length, 3, `start=${start}, end=${end}`); + assert.equal(h.events[0]?.input, payload); + assert.equal(h.events[0]?.key.paste, true); + assert.equal(h.events[1]?.input, "x"); + assert.equal(h.events[2]?.key.return, true); + } + } +}); + +test("a 1014-byte payload split at 1024 bytes collapses and remains editable and submittable", async (t) => { + const h = await harness(t); + const payload = JSON.stringify({ ciphertext: "a".repeat(997) }); + assert.equal(Buffer.byteLength(payload), 1014); + const packet = Buffer.from(PASTE_START + payload + PASTE_END); + h.send(packet.subarray(0, 1024)); + assert.equal(h.events.length, 0); + h.send(packet.subarray(1024)); + await h.flush(); + assert.equal(h.text(), "[paste #1 1014 chars]"); + h.send("x"); + await h.flush(); + h.send("\r"); + assert.deepEqual(h.submissions, [payload + "x"]); +}); + +test("one-character chunks preserve pasted control characters and marker-like content", async (t) => { + const h = await harness(t); + const payload = `abc\r\n\t\u007F\u0003${PASTE_START}literal\u001B[20x201~`; + for (const char of PASTE_START + payload + PASTE_END) h.send(char); + assert.equal(h.events.length, 1); + assert.equal(h.events[0]?.input, payload); + assert.equal(h.events[0]?.key.paste, true); +}); + +test("coalesced input preserves text before a paste, consecutive pastes, and trailing Enter", async (t) => { + const h = await harness(t); + h.send(`before${PASTE_START}first${PASTE_END}${PASTE_START}${PASTE_END}${PASTE_START}second${PASTE_END}\r`); + assert.deepEqual( + h.events.map(({ input }) => input), + ["before", "first", "second", "\r"] + ); + assert.equal(h.events[1]?.key.paste, true); + assert.equal(h.events[2]?.key.paste, true); + assert.equal(h.events[3]?.key.return, true); +}); + +test("a delayed paste end stays buffered while standalone Esc remains responsive", async (t) => { + const h = await harness(t); + h.send(PASTE_START + "abc" + PASTE_END.slice(0, 4)); + await setTimeout(50); + assert.equal(h.events.length, 0); + h.send(PASTE_END.slice(4)); + assert.equal(h.events[0]?.input, "abc"); + h.send("\u001B"); + await setTimeout(50); + assert.equal(h.events.length, 2); + assert.equal(h.events[1]?.key.escape, true); + h.send("\u001B[A"); + h.send("\u001Bb"); + assert.equal(h.events[2]?.key.upArrow, true); + assert.equal(h.events[3]?.key.leftArrow, true); + assert.equal(h.events[3]?.key.meta, true); +}); + +test("deactivating input discards an unfinished paste and cancels pending Esc", async (t) => { + const h = await harness(t); + h.send(PASTE_START + "unfinished"); + await h.setActive(false); + await h.setActive(true); + h.send("x"); + assert.equal(h.events[0]?.input, "x"); + h.events.length = 0; + h.send("\u001B"); + await h.setActive(false); + await setTimeout(50); + assert.equal(h.events.length, 0); +}); + +test("unmounting input cancels pending Esc", async (t) => { + const h = await harness(t); + h.send("\u001B"); + h.unmount(); + await setTimeout(50); + assert.equal(h.events.length, 0); +}); diff --git a/packages/cli/src/ui/hooks/useTerminalInput.ts b/packages/cli/src/ui/hooks/useTerminalInput.ts index f0b3603e..bcc26681 100644 --- a/packages/cli/src/ui/hooks/useTerminalInput.ts +++ b/packages/cli/src/ui/hooks/useTerminalInput.ts @@ -47,7 +47,15 @@ const TERMINAL_FOCUS_OUT = "\u001B[O"; // ESC[200~ ...pasted content... ESC[201~ const PASTE_START = "\u001B[200~"; const PASTE_END = "\u001B[201~"; -const PASTE_END_LENGTH = 6; // length of PASTE_END +const ESCAPE_SEQUENCE_TIMEOUT_MS = 20; + +/** Length of a trailing, incomplete marker that must wait for the next data event. */ +function partialMarkerLength(input: string, marker: string): number { + for (let length = Math.min(input.length, marker.length - 1); length > 0; length--) { + if (input.endsWith(marker.slice(0, length))) return length; + } + return 0; +} // Ctrl+- (minus) sequences in modifyOtherKeys mode. // \u001B[45;5u — standard format: keycode=45 ('-'), modifier=5 (Ctrl) @@ -255,15 +263,8 @@ export function useTerminalInput( const handlerRef = useRef(inputHandler); handlerRef.current = inputHandler; - // Mutable paste-bracketing state shared across data events. - // Uses an array of chunks instead of string concatenation to avoid - // O(n²) copying when the terminal splits a large paste across many events. - const pasteRef = useRef({ active: false, chunks: [] as string[] }); - useLayoutEffect(() => { if (!isActive) { - pasteRef.current.active = false; - pasteRef.current.chunks = []; return; } setRawMode(true); @@ -277,80 +278,65 @@ export function useTerminalInput( return; } - const handleData = (data: Buffer | string) => { - const raw = String(data); - - // ----- Bracketed paste handling ----- - // Most terminals send the start/end markers in the same chunk as - // the content. We handle both inline and multi-chunk scenarios. - - if (raw.includes(PASTE_START)) { - pasteRef.current.active = true; - pasteRef.current.chunks = []; - - // Extract content after the start marker. - const startIdx = raw.indexOf(PASTE_START); - const afterStart = raw.slice(startIdx + PASTE_START.length); + let pasteActive = false; + const pasteChunks: string[] = []; + let pending = ""; + let escapeTimer: ReturnType | undefined; - // Check if the end marker is also in this same chunk. - const endIdx = afterStart.indexOf(PASTE_END); - if (endIdx !== -1) { - // Both markers in one chunk — process immediately. - const pasteContent = afterStart.slice(0, endIdx); - pasteRef.current.active = false; - const remaining = afterStart.slice(endIdx + PASTE_END_LENGTH); - - if (pasteContent.length > 0) { - handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true }); + const handleData = (data: Buffer | string) => { + clearTimeout(escapeTimer); + escapeTimer = undefined; + let raw = pending + String(data); + pending = ""; + + while (raw.length > 0) { + const marker = pasteActive ? PASTE_END : PASTE_START; + const markerIndex = raw.indexOf(marker); + + if (markerIndex === -1) { + // Only carry a possible marker prefix (at most five characters). + // Keep the body in chunks and join once, when the paste finishes. + const pendingLength = partialMarkerLength(raw, marker); + const content = raw.slice(0, raw.length - pendingLength); + pending = raw.slice(raw.length - pendingLength); + if (content) { + if (pasteActive) pasteChunks.push(content); + else dispatchTerminalInput(content, handlerRef.current); } - if (remaining.length > 0) { - dispatchTerminalInput(remaining, handlerRef.current); + + // Esc and Alt+[ are also prefixes of the paste start marker. Give + // the next data event a short window before treating them as keys. + // Never time out an in-progress paste or an unambiguous CSI prefix. + if (!pasteActive && (pending === "\u001B" || pending === "\u001B[")) { + escapeTimer = setTimeout(() => { + escapeTimer = undefined; + const input = pending; + pending = ""; + dispatchTerminalInput(input, handlerRef.current); + }, ESCAPE_SEQUENCE_TIMEOUT_MS); } return; } - // Only start marker — buffer as first chunk. - if (afterStart) { - pasteRef.current.chunks.push(afterStart); + const content = raw.slice(0, markerIndex); + raw = raw.slice(markerIndex + marker.length); + if (pasteActive) { + pasteChunks.push(content); + const pasteContent = pasteChunks.join(""); + pasteChunks.length = 0; + pasteActive = false; + if (pasteContent) handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true }); + } else { + if (content) dispatchTerminalInput(content, handlerRef.current); + pasteActive = true; } - return; } - - if (pasteRef.current.active) { - pasteRef.current.chunks.push(raw); - // Only join+search when this chunk might contain the end marker. - if (raw.includes("201~")) { - const combined = pasteRef.current.chunks.join(""); - const endIdx = combined.indexOf(PASTE_END); - if (endIdx !== -1) { - const pasteContent = combined.slice(0, endIdx); - pasteRef.current.active = false; - const remaining = combined.slice(endIdx + PASTE_END_LENGTH); - pasteRef.current.chunks = []; - - // Dispatch the pasted text as a single event. - if (pasteContent.length > 0) { - handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true }); - } - - // Handle any remaining input after the paste end marker. - if (remaining.length > 0) { - dispatchTerminalInput(remaining, handlerRef.current); - } - return; - } - return; - } - return; - } - - // ----- Normal (non-paste) input ----- - dispatchTerminalInput(data, handlerRef.current); }; stdin?.on("data", handleData); return () => { stdin?.off("data", handleData); + clearTimeout(escapeTimer); }; }, [isActive, stdin]); }