fix(cli): coalesce a multi-line paste into one message#51
Conversation
Pasting a multi-line block into the interactive REPL submitted each line separately: readline emits one line event per newline, so a ~40-line paste became ~40 messages — and mid-run, ~40 steer notices for one paste. createPasteBatcher buffers the burst (all arrive in one tick) and flushes them joined by newline on the next tick — one paste, one message (blank lines kept); a single typed line still flushes unchanged. Scheduler injectable for tests. validate green (1620 pass).
There was a problem hiding this comment.
Code Review
This pull request introduces createPasteBatcher to coalesce multi-line paste events in the REPL into a single newline-joined message, along with corresponding unit tests. The reviewer identified a critical issue where pending input could be lost upon stream closure (EOF) because setImmediate callbacks run after the synchronous close event. To resolve this, the reviewer suggested exposing a synchronous flush method on the batcher, invoking it during the REPL's close event, and adding a unit test to verify its behavior.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| export function createPasteBatcher( | ||
| flush: (message: string) => void, | ||
| schedule: (fn: () => void) => void = (fn) => { | ||
| setImmediate(fn); | ||
| } | ||
| ): (raw: string) => void { | ||
| let batch: string[] = []; | ||
| let scheduled = false; | ||
|
|
||
| return (raw: string): void => { | ||
| batch.push(raw); | ||
|
|
||
| if (scheduled) { | ||
| return; | ||
| } | ||
|
|
||
| scheduled = true; | ||
| schedule(() => { | ||
| const message = batch.join("\n"); | ||
|
|
||
| batch = []; | ||
| scheduled = false; | ||
| flush(message); | ||
| }); | ||
| }; | ||
| } |
There was a problem hiding this comment.
Because createPasteBatcher uses setImmediate to schedule the flush, any pasted lines or piped input remaining in the buffer when the stream closes (EOF) will be lost. This is because the close event is emitted synchronously during the poll phase, which immediately triggers the REPL's teardown and exits the process before the setImmediate callback has a chance to run in the check phase.
To fix this, we should expose a synchronous flush method on the batcher so that any pending lines can be flushed immediately when the stream closes.
export interface PasteBatcher {
(raw: string): void;
flush(): void;
}
export function createPasteBatcher(
flush: (message: string) => void,
schedule: (fn: () => void) => void = (fn) => {
setImmediate(fn);
}
): PasteBatcher {
let batch: string[] = [];
let scheduled = false;
const triggerFlush = (): void => {
if (batch.length === 0) {
return;
}
const message = batch.join("\n");
batch = [];
scheduled = false;
flush(message);
};
const batcher = (raw: string): void => {
batch.push(raw);
if (scheduled) {
return;
}
scheduled = true;
schedule(() => {
triggerFlush();
});
};
batcher.flush = triggerFlush;
return batcher;
}| const onPastedLine = createPasteBatcher(submitLine); | ||
|
|
||
| rl.on("line", (raw) => { | ||
| onPastedLine(raw); | ||
| }); |
There was a problem hiding this comment.
To prevent losing pending input when the stream closes (such as piped input or a fast paste followed by EOF), we must synchronously flush the paste batcher when the close event is emitted. By registering a close listener before the original one, we ensure the batcher is flushed first, which synchronously sets busy = true and prevents the REPL from exiting prematurely.
const onPastedLine = createPasteBatcher(submitLine);
rl.on("line", (raw) => {
onPastedLine(raw);
});
rl.on("close", () => {
onPastedLine.flush();
});| test("createPasteBatcher submits a single typed line unchanged, once", () => { | ||
| const flushed: string[] = []; | ||
| const pending: (() => void)[] = []; | ||
| const onLine = createPasteBatcher( | ||
| (m) => flushed.push(m), | ||
| (fn) => pending.push(fn) | ||
| ); | ||
|
|
||
| onLine("just one line"); | ||
| pending.forEach((fn) => fn()); | ||
|
|
||
| // A later, separate line (new tick) flushes on its own — not merged with the first. | ||
| onLine("a second, separate message"); | ||
| pending.slice(1).forEach((fn) => fn()); | ||
|
|
||
| expect(flushed).toEqual(["just one line", "a second, separate message"]); | ||
| }); |
There was a problem hiding this comment.
Add a unit test to verify that the new flush() method on createPasteBatcher correctly flushes any pending buffered lines immediately and synchronously.
test("createPasteBatcher submits a single typed line unchanged, once", () => {
const flushed: string[] = [];
const pending: (() => void)[] = [];
const onLine = createPasteBatcher(
(m) => flushed.push(m),
(fn) => pending.push(fn)
);
onLine("just one line");
pending.forEach((fn) => fn());
// A later, separate line (new tick) flushes on its own — not merged with the first.
onLine("a second, separate message");
pending.slice(1).forEach((fn) => fn());
expect(flushed).toEqual(["just one line", "a second, separate message"]);
});
test("createPasteBatcher flush() flushes immediately and synchronously", () => {
const flushed: string[] = [];
const pending: (() => void)[] = [];
const onLine = createPasteBatcher(
(m) => flushed.push(m),
(fn) => pending.push(fn)
);
onLine("line one");
onLine("line two");
expect(flushed).toEqual([]);
onLine.flush();
expect(flushed).toEqual(["line one\nline two"]);
});|
Closing — wrong approach. This coalesces the paste but still auto-submits it, which is exactly the behavior we do NOT want: a paste must land in the input buffer and wait for the user to press Enter (so they can add context first), never submit on its own. Reworking with proper bracketed-paste handling (insert into buffer, manual submit) instead. |
Bug
Pasting a multi-line block (e.g. a prompt with blank lines between sections) into the interactive REPL was treated as many separate prompts. readline emits one
lineevent per newline, so a ~40-line paste became ~40 submissions — and because a run is usually in flight, each became a↳ queued (steers the next turn)notice (the "shit tons of steer message" report).Root cause
rl.on("line")incli.tsfired once per\nand submitted/queued each line immediately. No coalescing of the burst a paste produces.Fix
createPasteBatcher— the burst oflineevents from one paste all arrive in a single tick, so buffer them and flush joined by newline on the next tick: one paste → one message (blank lines preserved). A single human-typed line (one event, then an idle gap) flushes unchanged a tick later. The scheduler is injectable so the coalescing is unit-tested deterministically (no PTY needed).Tests
"a\nb\n\nd"message.bun run validategreen (1620 pass).Known limitation (follow-up)
A paste with no trailing newline leaves its last line in the input buffer (readline only emits
lineon a newline) — so it's 1 message + that straggler the user sends with Enter, vs N. Full bracketed-paste handling (capturing the straggler + in-buffer multi-line editing) is a deliberate follow-up; this PR kills the N-messages bug with minimal, well-tested, runtime-safe change.🤖 Generated with Claude Code