From ecbabf40e32f55a315543ca922c1771be882291e Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 26 Jun 2026 22:50:01 +0200 Subject: [PATCH] fix(cli): coalesce a multi-line paste into ONE message (not N) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/core/src/cli.ts | 50 +++++++++++++++++++++++++++++-- packages/core/tests/cli.test.ts | 53 ++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 46329dd..e250253 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -573,6 +573,43 @@ export function isPlanApproval(line: string): boolean { return /^(approve|approved|go|lgtm|implement)[.!]?$/i.test(line.trim()); } +/** + * Coalesce the burst of `line` events a multi-line PASTE produces into ONE + * message. A terminal delivers a pasted block as many newline-separated lines in + * a single tick, and readline emits a `line` event per newline — so without this + * each line was submitted separately: N messages, and mid-run, N "↳ queued (steers + * the next turn)" notices for a single paste. Buffering the lines and flushing them + * joined by newline on the next tick turns one paste into one message; a single + * human-typed line (one event, then an idle gap) flushes unchanged a tick later. + * `schedule` is injectable so the batching is deterministically testable. + */ +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); + }); + }; +} + // The /help body is generated from the command registry (src/cli/commands.ts) so // the help text and the interactive `/` palette can never drift. const HELP = formatHelp(); @@ -1665,8 +1702,11 @@ async function repl(args: ICliArgs): Promise { // Event-driven (not for-await) so stdin is read DURING a run: a line typed // mid-run is queued to steer the next turn (or, if "/exit", aborts). This is // what makes it feel like a real harness — you can redirect without waiting. - rl.on("line", (raw) => { - const line = raw.trim(); + // Submit ONE message (already paste-coalesced). A multi-line paste arrives as + // many `line` events in one tick; the batcher joins them so this runs once with + // the whole block, instead of once per line (which read as N steer messages). + const submitLine = (message: string): void => { + const line = message.trim(); if (line.length === 0) { if (!busy) { @@ -1696,6 +1736,12 @@ async function repl(args: ICliArgs): Promise { } void runLine(line); + }; + + const onPastedLine = createPasteBatcher(submitLine); + + rl.on("line", (raw) => { + onPastedLine(raw); }); rl.on("close", () => { diff --git a/packages/core/tests/cli.test.ts b/packages/core/tests/cli.test.ts index 40b9c96..f38684e 100644 --- a/packages/core/tests/cli.test.ts +++ b/packages/core/tests/cli.test.ts @@ -2,7 +2,13 @@ import { test, expect } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parseArgs, isOneShot, applyRecipe, runNotify } from "../src/cli"; +import { + parseArgs, + isOneShot, + applyRecipe, + runNotify, + createPasteBatcher, +} from "../src/cli"; import type { ITaskRecipe } from "../src/config/recipes"; // Regression: runNotify used to spawn `sh -c cmd` with a bare `await proc.exited` @@ -301,3 +307,48 @@ test("spinner exposes a live 'compacting' activity label and repaints via onTick spinner.stop(); expect(spinner.frameLabel()).toBe(""); // stopped → loader cleared }); + +// Regression: a multi-line PASTE fired one readline `line` event per newline, so +// each line submitted separately — N messages, or mid-run N "↳ queued (steers the +// next turn)" notices for ONE paste. The batcher coalesces a burst (same tick) +// into a single newline-joined message. +test("createPasteBatcher coalesces a multi-line paste into one message", () => { + const flushed: string[] = []; + // Synchronous scheduler: run the flush immediately so the test is deterministic + // (the burst is pushed before the scheduled flush, exactly as in one tick). + const pending: (() => void)[] = []; + const onLine = createPasteBatcher( + (m) => flushed.push(m), + (fn) => pending.push(fn) + ); + + // A paste: many lines (incl. a blank one) delivered before the tick settles. + for (const l of ["line one", "line two", "", "line four"]) { + onLine(l); + } + + // Nothing flushed until the scheduled tick runs. + expect(flushed).toEqual([]); + + pending.forEach((fn) => fn()); + + expect(flushed).toEqual(["line one\nline two\n\nline four"]); +}); + +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"]); +});