Skip to content

fix(cli): coalesce a multi-line paste into one message#51

Closed
agjs wants to merge 1 commit into
mainfrom
fix/paste-multiline-coalesce
Closed

fix(cli): coalesce a multi-line paste into one message#51
agjs wants to merge 1 commit into
mainfrom
fix/paste-multiline-coalesce

Conversation

@agjs

@agjs agjs commented Jun 26, 2026

Copy link
Copy Markdown
Owner

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 line event 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") in cli.ts fired once per \n and submitted/queued each line immediately. No coalescing of the burst a paste produces.

Fix

createPasteBatcher — the burst of line events 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

  • 4-line burst incl. a blank line → one "a\nb\n\nd" message.
  • Two separate lines → stay two messages.
  • Full bun run validate green (1620 pass).

Known limitation (follow-up)

A paste with no trailing newline leaves its last line in the input buffer (readline only emits line on 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

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).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/cli.ts
Comment on lines +586 to +611
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);
});
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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;
}

Comment thread packages/core/src/cli.ts
Comment on lines +1741 to 1745
const onPastedLine = createPasteBatcher(submitLine);

rl.on("line", (raw) => {
onPastedLine(raw);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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();
    });

Comment on lines +338 to +354
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"]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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"]);
});

@agjs

agjs commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

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.

@agjs agjs closed this Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant