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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user