Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions packages/cli/src/tests/terminal-paste.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
124 changes: 55 additions & 69 deletions packages/cli/src/ui/hooks/useTerminalInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand All @@ -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<typeof setTimeout> | 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]);
}
Loading