Skip to content

Commit 40a3635

Browse files
authored
🐛 Preserve bracketed terminal paste as one editable value (#1063)
1 parent 7304eed commit 40a3635

6 files changed

Lines changed: 273 additions & 22 deletions

File tree

‎architecture.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7338,8 +7338,13 @@ Text is its own member of that normalized union, not a key with a payload: a
73387338
named key — submit, dismiss, traverse, move, erase — is a command whoever
73397339
handles it recognizes, and text is content that goes wherever content goes.
73407340
Text carries what the terminal decoded, so shifted, accented and multi-byte
7341-
characters arrive already assembled, and a newline inside a paste arrives as
7342-
one. A chord carries a letter in its physical key code, and reading that code
7341+
characters arrive already assembled. The screen owns bracketed-paste mode with
7342+
its other terminal settings. The decoder consumes framing across reads and
7343+
publishes a completed paste as text, normalizing CR, LF and CRLF to LF. Payload
7344+
bytes never become submission keys; deliberate keys outside the frame retain
7345+
their meaning. Incomplete paste state belongs to that decoder's scope.
7346+
7347+
A chord carries a letter in its physical key code, and reading that code
73437348
would type the letter somebody held Control with, so a chord is dropped whole —
73447349
payload included. Arrows, Home, End, the Page keys and Alt+Up/Down are named as
73457350
pressed rather than dropped: they carry no text.

‎packages/cli/src/repl/input.ts‎

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/**
22
* Turning terminal bytes into the events the tree already understands.
33
*
4-
* The tty scanner does the decoding; this decides which decoded events the
4+
* Framed paste is text, including bytes that otherwise name keyboard commands.
5+
* Its payload waits for the closing marker so UTF-8 and line separators can
6+
* cross reads without becoming partial text or submission keys.
7+
*
8+
* The tty scanner decodes ordinary input; this decides which decoded events the
59
* composition layer has a meaning for and hands them over in its own closed
610
* shape. Nothing here knows an action name: a normalized event says what
711
* happened and where, and what it means is decided by whichever mounted node
@@ -103,9 +107,9 @@ const ALT_KEYS: ReadonlyMap<string, ReplKey> = new Map([
103107
]);
104108

105109
/**
106-
* The physical key a pasted newline arrives as.
110+
* The physical key an unframed LF arrives as.
107111
*
108-
* A terminal sends `LF` inside a paste, and the decoder reports `LF` as
112+
* An unframed newline arrives as `LF`, and the decoder reports `LF` as
109113
* Control-J with no text of its own. Pasting a multi-line document is the whole
110114
* point of an XMD draft, so this one chord is text — and it is the only chord
111115
* that is.
@@ -278,27 +282,96 @@ export interface ReplDecoder {
278282
export function useReplDecoder(
279283
options: { readonly escLatency?: number } = {},
280284
): Operation<ReplDecoder> {
281-
const settings = options.escLatency === undefined ? {} : { escLatency: options.escLatency };
285+
const settings = { escLatency: options.escLatency ?? 25 };
282286
return resource<ReplDecoder>(function* (provide) {
283287
let input: Input = yield* until(createInput(settings));
288+
let prefix: number[] = [];
289+
let paste: number[] | undefined;
290+
const opening = new TextEncoder().encode("\x1b[200~");
291+
const closing = new TextEncoder().encode("\x1b[201~");
292+
293+
function* keys(bytes?: Uint8Array): Operation<ReplInputScan> {
294+
const drained = drain(input, bytes);
295+
const settling =
296+
bytes === undefined && drained.pendingFor !== undefined && drained.ordered.length === 0;
297+
if (!settling) {
298+
return drained;
299+
}
300+
// The spent ESC otherwise joins the next keystroke as an Alt modifier.
301+
input = yield* until(createInput(settings));
302+
return Object.freeze({
303+
ordered: Object.freeze([{ kind: "input", event: ESCAPE } satisfies ReplPhysicalInput]),
304+
events: Object.freeze([ESCAPE]),
305+
pointers: Object.freeze([]),
306+
resized: drained.resized,
307+
pendingFor: undefined,
308+
});
309+
}
310+
284311
yield* provide({
285312
*scan(bytes?: Uint8Array): Operation<ReplInputScan> {
286-
const drained = drain(input, bytes);
287-
const settling =
288-
bytes === undefined && drained.pendingFor !== undefined && drained.ordered.length === 0;
289-
if (!settling) {
290-
return drained;
313+
const passes: ReplInputScan[] = [];
314+
let ordinary: number[] = [];
315+
if (bytes === undefined) {
316+
if (paste === undefined && prefix.length > 0) {
317+
passes.push(yield* keys(Uint8Array.from(prefix)));
318+
prefix = [];
319+
}
320+
passes.push(yield* keys());
321+
} else {
322+
for (const byte of bytes) {
323+
if (paste !== undefined) {
324+
paste.push(byte);
325+
if (
326+
paste.length >= closing.length &&
327+
closing.every(
328+
(one, index) => paste?.[paste.length - closing.length + index] === one,
329+
)
330+
) {
331+
const text = new TextDecoder()
332+
.decode(Uint8Array.from(paste.slice(0, -closing.length)))
333+
.replace(/\r\n|\r/g, "\n");
334+
const event: ReplInputEvent = Object.freeze({ kind: "text", text });
335+
passes.push({
336+
ordered: [{ kind: "input", event }],
337+
events: [event],
338+
pointers: [],
339+
resized: false,
340+
pendingFor: undefined,
341+
});
342+
paste = undefined;
343+
}
344+
continue;
345+
}
346+
if (prefix.length > 0 && byte !== opening[prefix.length]) {
347+
ordinary.push(...prefix);
348+
prefix = [];
349+
}
350+
if (byte === opening[prefix.length]) {
351+
prefix.push(byte);
352+
if (prefix.length === opening.length) {
353+
passes.push(yield* keys(Uint8Array.from(ordinary)));
354+
ordinary = [];
355+
prefix = [];
356+
paste = [];
357+
}
358+
} else {
359+
ordinary.push(byte);
360+
}
361+
}
362+
passes.push(yield* keys(Uint8Array.from(ordinary)));
291363
}
292-
// Nothing followed the held ESC, so it was the key. A fresh scanner,
293-
// because the spent byte is still in this one's buffer and would
294-
// otherwise become the first byte of whatever is typed next.
295-
input = yield* until(createInput(settings));
296364
return Object.freeze({
297-
ordered: Object.freeze([{ kind: "input", event: ESCAPE } satisfies ReplPhysicalInput]),
298-
events: Object.freeze([ESCAPE]),
299-
pointers: Object.freeze([]),
300-
resized: drained.resized,
301-
pendingFor: undefined,
365+
ordered: Object.freeze(passes.flatMap((pass) => pass.ordered)),
366+
events: Object.freeze(passes.flatMap((pass) => pass.events)),
367+
pointers: Object.freeze(passes.flatMap((pass) => pass.pointers)),
368+
resized: passes.some((pass) => pass.resized),
369+
pendingFor:
370+
prefix.length === 1
371+
? settings.escLatency
372+
: prefix.length > 1 || paste !== undefined
373+
? undefined
374+
: passes.at(-1)?.pendingFor,
302375
});
303376
},
304377
});

‎packages/cli/src/repl/screen.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export function replModes(): Setting {
8181
alternateBuffer({ clear: true }),
8282
cursor(false),
8383
mouseTracking(),
84+
{
85+
apply: new TextEncoder().encode("\x1b[?2004h"),
86+
revert: new TextEncoder().encode("\x1b[?2004l"),
87+
},
8488
// Level 1: disambiguated escape codes, which is what makes a lone Escape
8589
// decidable at all on terminals that support it.
8690
progressiveInput(1),

‎packages/cli/tests/repl-sidekick-dispatch.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { records, useHarness } from "./fixtures/repl/issue-883/sidekick-harness.
1414
import type { Harness } from "./fixtures/repl/issue-883/sidekick-harness.ts";
1515
import { awaitedFact } from "./support/mounted-repl.ts";
1616
import { settledReading } from "./support/mounted-history.ts";
17+
import { useReplDecoder } from "../src/repl/input.ts";
1718
import { useReplPage } from "./support/repl-page.ts";
1819
import {
1920
asked,
@@ -24,6 +25,7 @@ import {
2425
COMPOSER_VALUE,
2526
draftText,
2627
DRAFT_VALUE,
28+
sidekickIdle,
2729
} from "./support/screen-journeys.ts";
2830

2931
function service(
@@ -84,6 +86,66 @@ function fills(source: string): string {
8486
}
8587

8688
describe("shared Sidekick dispatch", () => {
89+
for (const busy of [false, true]) {
90+
it(`keeps one pasted Message at its caret while ${busy ? "busy" : "idle"}`, function* () {
91+
const page = yield* useReplPage();
92+
if (busy) {
93+
page.repl.fake.script({ reply: "<Output>original joined</Output>", held: true });
94+
yield* sendMessage(page, "original request");
95+
yield* page.repl.fake.startedTurns(1);
96+
}
97+
yield* intoComposer(page);
98+
yield* typed(page, "beforeafter");
99+
for (let at = 0; at < "after".length; at += 1) {
100+
const before = page.screen().index;
101+
yield* page.key("ArrowLeft");
102+
yield* page.until("middle caret", (one) => one.index > before);
103+
}
104+
const payload = "first\n\n\t é界🙂\nlast";
105+
const raw = new TextEncoder().encode(`\x1b[200~${payload.replaceAll("\n", "\r\n")}\x1b[201~`);
106+
const decoder = yield* useReplDecoder();
107+
expect((yield* decoder.scan(raw)).events).toEqual([{ kind: "text", text: payload }]);
108+
const initial = yield* settledReading(page.repl);
109+
page.repl.terminal.bytes(raw);
110+
yield* page.until(
111+
"pasted tail at the middle caret",
112+
(one) => one.found("lastafter", COMPOSER_VALUE) > 0,
113+
);
114+
expect(page.screen().shows("QUEUE 0/16")).toBe(true);
115+
expect(page.repl.fake.prompts).toHaveLength(busy ? 1 : 0);
116+
const pasted = yield* settledReading(page.repl);
117+
expect(pasted.chat.filter((one) => one.kind === "message")).toEqual(
118+
initial.chat.filter((one) => one.kind === "message"),
119+
);
120+
expect(pasted.model.entries).toEqual(initial.model.entries);
121+
if (busy) {
122+
page.repl.fake.releaseTurn();
123+
yield* page.until(
124+
"joined idle without queued paste",
125+
(one) => sidekickIdle(one) && one.shows("original joined"),
126+
);
127+
expect(page.screen().found("lastafter", COMPOSER_VALUE)).toBe(1);
128+
expect(page.screen().shows("QUEUE 0/16")).toBe(true);
129+
expect(page.repl.fake.prompts).toHaveLength(1);
130+
expect(
131+
(yield* settledReading(page.repl)).chat.filter((one) => one.kind === "message"),
132+
).toHaveLength(1);
133+
}
134+
page.repl.fake.script({ reply: "<Output>paste submitted once</Output>" });
135+
yield* page.key("Enter");
136+
yield* page.until(
137+
"one deliberate full submission",
138+
(one) => sidekickIdle(one) && one.shows("paste submitted once"),
139+
);
140+
const after = yield* settledReading(page.repl);
141+
const messages = after.chat.filter((one) => one.kind === "message");
142+
expect(messages).toHaveLength(busy ? 2 : 1);
143+
expect(messages.at(-1)?.text).toBe(`before${payload}after`);
144+
expect(page.repl.fake.prompts).toHaveLength(busy ? 2 : 1);
145+
expect(after.model.entries).toEqual(initial.model.entries);
146+
});
147+
}
148+
87149
it("keeps the mounted UI's real controls, generated entry and final answer", function* () {
88150
const page = yield* useReplPage();
89151
const repl = page.repl;

0 commit comments

Comments
 (0)