Skip to content

Commit fff36ae

Browse files
committed
fix(cli): handle bracketed paste markers across input chunks
1 parent 49d2782 commit fff36ae

2 files changed

Lines changed: 225 additions & 69 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import assert from "node:assert/strict";
2+
import { PassThrough, Writable } from "node:stream";
3+
import { test, type TestContext } from "node:test";
4+
import { setTimeout } from "node:timers/promises";
5+
import React, { useState } from "react";
6+
import { render, Text } from "ink";
7+
import { EMPTY_BUFFER, expandPasteMarkers, insertText } from "../ui/core/prompt-buffer";
8+
import { usePasteHandling } from "../ui/hooks/usePasteHandling";
9+
import { useTerminalInput, type InputKey } from "../ui/hooks/useTerminalInput";
10+
11+
const PASTE_START = "\u001B[200~";
12+
const PASTE_END = "\u001B[201~";
13+
14+
async function harness(t: TestContext) {
15+
const events: { input: string; key: InputKey }[] = [];
16+
const submissions: string[] = [];
17+
let text = "";
18+
19+
function Probe({ active = true }: { active?: boolean }) {
20+
const [buffer, setBuffer] = useState(EMPTY_BUFFER);
21+
const { handlePaste, pastesRef } = usePasteHandling(buffer, setBuffer, () => {});
22+
text = buffer.text;
23+
useTerminalInput(
24+
(input, key) => {
25+
events.push({ input, key });
26+
if (key.paste) handlePaste(input);
27+
else if (key.return) submissions.push(expandPasteMarkers(buffer.text, pastesRef.current));
28+
else if (input && !key.ctrl && !key.meta) setBuffer((state) => insertText(state, input));
29+
},
30+
{ isActive: active }
31+
);
32+
return React.createElement(Text, null, buffer.text || "ready");
33+
}
34+
35+
const stdin = Object.assign(new PassThrough(), { isTTY: true, setRawMode() {}, ref() {}, unref() {} });
36+
const stdout = Object.assign(
37+
new Writable({
38+
write(_chunk, _encoding, callback) {
39+
callback();
40+
},
41+
}),
42+
{ isTTY: true, columns: 80, rows: 24 }
43+
);
44+
const app = render(React.createElement(Probe), {
45+
stdin: stdin as unknown as NodeJS.ReadStream,
46+
stdout: stdout as unknown as NodeJS.WriteStream,
47+
stderr: stdout as unknown as NodeJS.WriteStream,
48+
debug: true,
49+
patchConsole: false,
50+
exitOnCtrlC: false,
51+
});
52+
t.after(() => {
53+
app.unmount();
54+
app.cleanup();
55+
});
56+
async function flush() {
57+
await setTimeout(0);
58+
await app.waitUntilRenderFlush();
59+
}
60+
await flush();
61+
return {
62+
events,
63+
submissions,
64+
text: () => text,
65+
send: (chunk: string | Buffer) => stdin.emit("data", chunk),
66+
flush,
67+
async setActive(active: boolean) {
68+
app.rerender(React.createElement(Probe, { active }));
69+
await flush();
70+
},
71+
unmount: () => app.unmount(),
72+
};
73+
}
74+
75+
test("paste markers can split at every start/end boundary", async (t) => {
76+
const h = await harness(t);
77+
for (let start = 1; start < PASTE_START.length; start++) {
78+
for (let end = 1; end < PASTE_END.length; end++) {
79+
const payload = `payload-${start}-${end}\n你好`;
80+
h.events.length = 0;
81+
h.send(PASTE_START.slice(0, start));
82+
h.send(PASTE_START.slice(start) + payload + PASTE_END.slice(0, end));
83+
h.send(PASTE_END.slice(end));
84+
h.send("x");
85+
h.send("\r");
86+
assert.equal(h.events.length, 3, `start=${start}, end=${end}`);
87+
assert.equal(h.events[0]?.input, payload);
88+
assert.equal(h.events[0]?.key.paste, true);
89+
assert.equal(h.events[1]?.input, "x");
90+
assert.equal(h.events[2]?.key.return, true);
91+
}
92+
}
93+
});
94+
95+
test("a 1014-byte payload split at 1024 bytes collapses and remains editable and submittable", async (t) => {
96+
const h = await harness(t);
97+
const payload = JSON.stringify({ ciphertext: "a".repeat(997) });
98+
assert.equal(Buffer.byteLength(payload), 1014);
99+
const packet = Buffer.from(PASTE_START + payload + PASTE_END);
100+
h.send(packet.subarray(0, 1024));
101+
assert.equal(h.events.length, 0);
102+
h.send(packet.subarray(1024));
103+
await h.flush();
104+
assert.equal(h.text(), "[paste #1 1014 chars]");
105+
h.send("x");
106+
await h.flush();
107+
h.send("\r");
108+
assert.deepEqual(h.submissions, [payload + "x"]);
109+
});
110+
111+
test("one-character chunks preserve pasted control characters and marker-like content", async (t) => {
112+
const h = await harness(t);
113+
const payload = `abc\r\n\t\u007F\u0003${PASTE_START}literal\u001B[20x201~`;
114+
for (const char of PASTE_START + payload + PASTE_END) h.send(char);
115+
assert.equal(h.events.length, 1);
116+
assert.equal(h.events[0]?.input, payload);
117+
assert.equal(h.events[0]?.key.paste, true);
118+
});
119+
120+
test("coalesced input preserves text before a paste, consecutive pastes, and trailing Enter", async (t) => {
121+
const h = await harness(t);
122+
h.send(`before${PASTE_START}first${PASTE_END}${PASTE_START}${PASTE_END}${PASTE_START}second${PASTE_END}\r`);
123+
assert.deepEqual(
124+
h.events.map(({ input }) => input),
125+
["before", "first", "second", "\r"]
126+
);
127+
assert.equal(h.events[1]?.key.paste, true);
128+
assert.equal(h.events[2]?.key.paste, true);
129+
assert.equal(h.events[3]?.key.return, true);
130+
});
131+
132+
test("a delayed paste end stays buffered while standalone Esc remains responsive", async (t) => {
133+
const h = await harness(t);
134+
h.send(PASTE_START + "abc" + PASTE_END.slice(0, 4));
135+
await setTimeout(50);
136+
assert.equal(h.events.length, 0);
137+
h.send(PASTE_END.slice(4));
138+
assert.equal(h.events[0]?.input, "abc");
139+
h.send("\u001B");
140+
await setTimeout(50);
141+
assert.equal(h.events.length, 2);
142+
assert.equal(h.events[1]?.key.escape, true);
143+
h.send("\u001B[A");
144+
h.send("\u001Bb");
145+
assert.equal(h.events[2]?.key.upArrow, true);
146+
assert.equal(h.events[3]?.key.leftArrow, true);
147+
assert.equal(h.events[3]?.key.meta, true);
148+
});
149+
150+
test("deactivating input discards an unfinished paste and cancels pending Esc", async (t) => {
151+
const h = await harness(t);
152+
h.send(PASTE_START + "unfinished");
153+
await h.setActive(false);
154+
await h.setActive(true);
155+
h.send("x");
156+
assert.equal(h.events[0]?.input, "x");
157+
h.events.length = 0;
158+
h.send("\u001B");
159+
await h.setActive(false);
160+
await setTimeout(50);
161+
assert.equal(h.events.length, 0);
162+
});
163+
164+
test("unmounting input cancels pending Esc", async (t) => {
165+
const h = await harness(t);
166+
h.send("\u001B");
167+
h.unmount();
168+
await setTimeout(50);
169+
assert.equal(h.events.length, 0);
170+
});

‎packages/cli/src/ui/hooks/useTerminalInput.ts‎

Lines changed: 55 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,15 @@ const TERMINAL_FOCUS_OUT = "\u001B[O";
4747
// ESC[200~ ...pasted content... ESC[201~
4848
const PASTE_START = "\u001B[200~";
4949
const PASTE_END = "\u001B[201~";
50-
const PASTE_END_LENGTH = 6; // length of PASTE_END
50+
const ESCAPE_SEQUENCE_TIMEOUT_MS = 20;
51+
52+
/** Length of a trailing, incomplete marker that must wait for the next data event. */
53+
function partialMarkerLength(input: string, marker: string): number {
54+
for (let length = Math.min(input.length, marker.length - 1); length > 0; length--) {
55+
if (input.endsWith(marker.slice(0, length))) return length;
56+
}
57+
return 0;
58+
}
5159

5260
// Ctrl+- (minus) sequences in modifyOtherKeys mode.
5361
// \u001B[45;5u — standard format: keycode=45 ('-'), modifier=5 (Ctrl)
@@ -255,15 +263,8 @@ export function useTerminalInput(
255263
const handlerRef = useRef(inputHandler);
256264
handlerRef.current = inputHandler;
257265

258-
// Mutable paste-bracketing state shared across data events.
259-
// Uses an array of chunks instead of string concatenation to avoid
260-
// O(n²) copying when the terminal splits a large paste across many events.
261-
const pasteRef = useRef({ active: false, chunks: [] as string[] });
262-
263266
useLayoutEffect(() => {
264267
if (!isActive) {
265-
pasteRef.current.active = false;
266-
pasteRef.current.chunks = [];
267268
return;
268269
}
269270
setRawMode(true);
@@ -277,80 +278,65 @@ export function useTerminalInput(
277278
return;
278279
}
279280

280-
const handleData = (data: Buffer | string) => {
281-
const raw = String(data);
282-
283-
// ----- Bracketed paste handling -----
284-
// Most terminals send the start/end markers in the same chunk as
285-
// the content. We handle both inline and multi-chunk scenarios.
286-
287-
if (raw.includes(PASTE_START)) {
288-
pasteRef.current.active = true;
289-
pasteRef.current.chunks = [];
290-
291-
// Extract content after the start marker.
292-
const startIdx = raw.indexOf(PASTE_START);
293-
const afterStart = raw.slice(startIdx + PASTE_START.length);
281+
let pasteActive = false;
282+
const pasteChunks: string[] = [];
283+
let pending = "";
284+
let escapeTimer: ReturnType<typeof setTimeout> | undefined;
294285

295-
// Check if the end marker is also in this same chunk.
296-
const endIdx = afterStart.indexOf(PASTE_END);
297-
if (endIdx !== -1) {
298-
// Both markers in one chunk — process immediately.
299-
const pasteContent = afterStart.slice(0, endIdx);
300-
pasteRef.current.active = false;
301-
const remaining = afterStart.slice(endIdx + PASTE_END_LENGTH);
302-
303-
if (pasteContent.length > 0) {
304-
handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true });
286+
const handleData = (data: Buffer | string) => {
287+
clearTimeout(escapeTimer);
288+
escapeTimer = undefined;
289+
let raw = pending + String(data);
290+
pending = "";
291+
292+
while (raw.length > 0) {
293+
const marker = pasteActive ? PASTE_END : PASTE_START;
294+
const markerIndex = raw.indexOf(marker);
295+
296+
if (markerIndex === -1) {
297+
// Only carry a possible marker prefix (at most five characters).
298+
// Keep the body in chunks and join once, when the paste finishes.
299+
const pendingLength = partialMarkerLength(raw, marker);
300+
const content = raw.slice(0, raw.length - pendingLength);
301+
pending = raw.slice(raw.length - pendingLength);
302+
if (content) {
303+
if (pasteActive) pasteChunks.push(content);
304+
else dispatchTerminalInput(content, handlerRef.current);
305305
}
306-
if (remaining.length > 0) {
307-
dispatchTerminalInput(remaining, handlerRef.current);
306+
307+
// Esc and Alt+[ are also prefixes of the paste start marker. Give
308+
// the next data event a short window before treating them as keys.
309+
// Never time out an in-progress paste or an unambiguous CSI prefix.
310+
if (!pasteActive && (pending === "\u001B" || pending === "\u001B[")) {
311+
escapeTimer = setTimeout(() => {
312+
escapeTimer = undefined;
313+
const input = pending;
314+
pending = "";
315+
dispatchTerminalInput(input, handlerRef.current);
316+
}, ESCAPE_SEQUENCE_TIMEOUT_MS);
308317
}
309318
return;
310319
}
311320

312-
// Only start marker — buffer as first chunk.
313-
if (afterStart) {
314-
pasteRef.current.chunks.push(afterStart);
321+
const content = raw.slice(0, markerIndex);
322+
raw = raw.slice(markerIndex + marker.length);
323+
if (pasteActive) {
324+
pasteChunks.push(content);
325+
const pasteContent = pasteChunks.join("");
326+
pasteChunks.length = 0;
327+
pasteActive = false;
328+
if (pasteContent) handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true });
329+
} else {
330+
if (content) dispatchTerminalInput(content, handlerRef.current);
331+
pasteActive = true;
315332
}
316-
return;
317333
}
318-
319-
if (pasteRef.current.active) {
320-
pasteRef.current.chunks.push(raw);
321-
// Only join+search when this chunk might contain the end marker.
322-
if (raw.includes("201~")) {
323-
const combined = pasteRef.current.chunks.join("");
324-
const endIdx = combined.indexOf(PASTE_END);
325-
if (endIdx !== -1) {
326-
const pasteContent = combined.slice(0, endIdx);
327-
pasteRef.current.active = false;
328-
const remaining = combined.slice(endIdx + PASTE_END_LENGTH);
329-
pasteRef.current.chunks = [];
330-
331-
// Dispatch the pasted text as a single event.
332-
if (pasteContent.length > 0) {
333-
handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true });
334-
}
335-
336-
// Handle any remaining input after the paste end marker.
337-
if (remaining.length > 0) {
338-
dispatchTerminalInput(remaining, handlerRef.current);
339-
}
340-
return;
341-
}
342-
return;
343-
}
344-
return;
345-
}
346-
347-
// ----- Normal (non-paste) input -----
348-
dispatchTerminalInput(data, handlerRef.current);
349334
};
350335

351336
stdin?.on("data", handleData);
352337
return () => {
353338
stdin?.off("data", handleData);
339+
clearTimeout(escapeTimer);
354340
};
355341
}, [isActive, stdin]);
356342
}

0 commit comments

Comments
 (0)