From e5f65fcd5fd7f16ce1bba8cd345c2d01f9971ffa Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 17 Jul 2026 13:58:23 +0000 Subject: [PATCH] fix(cli): preserved carriage-return progress boundaries Normalized lone carriage returns before terminal output is buffered while retaining CRLF as a single line boundary across chunk splits. Fixes #5845 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/session/streaming-output.ts | 42 ++++++++++++++++++- .../test/streaming-output.test.ts | 14 +++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 493bd5332..68430a943 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -26,6 +26,7 @@ ### Fixed +- Fixed local `!` command output concatenating carriage-return progress updates by preserving them as readable line boundaries ([#5845](https://github.com/can1357/oh-my-pi/issues/5845)). - Fixed loading issues for linked legacy extensions importing `DefaultPackageManager` or `linkedom`. - Fixed the advisor retrying terminal, non-retriable provider failures (e.g., blocked prompts), ensuring they fail immediately while transient failures still retry. - Fixed an issue where reassigning the `plan` role model mid-planning did not take effect until the next plan-mode entry; it now applies at the next turn boundary. diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index d0dddc6c9..72d486029 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -22,6 +22,7 @@ export const ARTIFACT_DEFAULT_MAX_BYTES = 0; export const ARTIFACT_DEFAULT_HEAD_BYTES = 3 * 1024 * 1024; // 3 MiB const NL = "\n"; +const CR = "\r"; const ELLIPSIS = "…"; // ============================================================================= @@ -737,6 +738,7 @@ export class OutputSink { #truncated = false; #lastChunkTime = 0; #pendingChunk = ""; + #pendingCarriageReturn = false; #pendingChunkTimer: Timer | undefined; // Per-line column cap streaming state (persists across `push` calls so a @@ -802,12 +804,45 @@ export class OutputSink { this.#artifactTailBudget = Math.max(0, this.#artifactMaxBytes - this.#artifactHeadBudget); } + /** + * Converts carriage-return progress updates into line boundaries while + * collapsing CRLF to one newline. A trailing CR is held until the next + * chunk so split CRLF sequences do not create blank lines. + */ + #normalizeCarriageReturns(text: string): string { + if (text.length === 0 || (!this.#pendingCarriageReturn && !text.includes(CR))) return text; + + let cursor = 0; + let normalized = ""; + if (this.#pendingCarriageReturn) { + this.#pendingCarriageReturn = false; + normalized = NL; + if (text.startsWith(NL)) cursor = 1; + } + + while (cursor < text.length) { + const carriageReturn = text.indexOf(CR, cursor); + if (carriageReturn === -1) { + normalized += text.substring(cursor); + break; + } + normalized += text.substring(cursor, carriageReturn); + if (carriageReturn === text.length - 1) { + this.#pendingCarriageReturn = true; + break; + } + normalized += NL; + cursor = text.startsWith(NL, carriageReturn + 1) ? carriageReturn + 2 : carriageReturn + 1; + } + return normalized; + } + /** * Push a chunk of output. The buffer management and onChunk callback run * synchronously. File sink writes are deferred and serialized internally. */ push(chunk: string): void { - chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText); + chunk = sanitizeWithOptionalSixelPassthrough(chunk, text => sanitizeText(this.#normalizeCarriageReturns(text))); // Throttled onChunk: coalesce chunks arriving inside the throttle window. // A timer flushes quiet tails at the throttle boundary; dump() catches a @@ -1137,6 +1172,7 @@ export class OutputSink { this.#columnDroppedBytes = 0; this.#columnTruncatedLines = 0; this.#pendingChunk = ""; + this.#pendingCarriageReturn = false; } #clearPendingChunkTimer(): void { @@ -1208,6 +1244,10 @@ export class OutputSink { } async dump(notice?: string): Promise { + if (this.#pendingCarriageReturn) { + this.#pendingCarriageReturn = false; + this.push(NL); + } const noticeLine = notice ? `[${notice}]\n` : ""; // Flush any chunk still held back by the throttle so the live preview diff --git a/packages/coding-agent/test/streaming-output.test.ts b/packages/coding-agent/test/streaming-output.test.ts index 1c668501d..8b1f529c2 100644 --- a/packages/coding-agent/test/streaming-output.test.ts +++ b/packages/coding-agent/test/streaming-output.test.ts @@ -227,6 +227,20 @@ describe("OutputSink", () => { expect(chunks).toEqual(["abc", "def"]); }); + test("normalizes carriage-return progress frames across chunk boundaries", async () => { + const chunks: string[] = []; + const sink = new OutputSink({ onChunk: chunk => chunks.push(chunk) }); + + sink.push("start\r"); + sink.push("one\r"); + sink.push("two\r"); + sink.push("\n"); + const dumped = await sink.dump(); + + expect(chunks.join("")).toBe("start\none\ntwo\n"); + expect(dumped.output).toBe("start\none\ntwo\n"); + }); + test("preserves SIXEL chunks when passthrough gates are enabled", async () => { const sixel = "\x1bPqabc\x1b\\"; Bun.env.PI_FORCE_IMAGE_PROTOCOL = "sixel";