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
This commit is contained in:
roboomp
2026-07-17 13:58:45 +00:00
parent 0f9fceeea4
commit e5f65fcd5f
3 changed files with 56 additions and 1 deletions
+1
View File
@@ -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.
@@ -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<OutputSummary> {
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
@@ -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";