Merge PR #8662: perf(coding-agent): bound streaming diff previews (@lemonleks)

This commit is contained in:
can1357
2026-08-16 02:13:37 +02:00
3 changed files with 92 additions and 21 deletions
+1
View File
@@ -28,6 +28,7 @@
- Fixed mid-run auto-compaction waiting on `auto_compaction_end` / `session_compact` extension handlers before the next provider call, which could hang the live loop after a snapcompact or context-full pass. Mid-run those handlers now run concurrently with the next turn; `auto_compaction_start` is still awaited.
- Reduced peak memory for persisted subagent revival probes by streaming large file-backed session journals instead of loading the complete journal ([#8117](https://github.com/can1357/oh-my-pi/issues/8117)).
- Kept streaming edit previews responsive for large diffs by selecting and highlighting only the visible tail instead of scanning the discarded prefix on every update.
## [17.3.4] - 2026-08-14
+42 -18
View File
@@ -412,6 +412,42 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string
}
return preview.trimEnd();
}
interface StreamingDiffTail {
content: string;
hidden: boolean;
}
/**
* Select the trailing physical lines that fit the live preview budget.
*
* Walk backward from the end instead of splitting the complete diff. This keeps
* allocation and scanning proportional to the visible suffix. One physical line
* may exceed the budget, but it is still kept so the newest change is visible.
*/
function sliceStreamingDiffTail(diff: string, innerWidth: number, budget: number): StreamingDiffTail {
let end = diff.length;
while (end > 0 && diff.charCodeAt(end - 1) === 10) end--;
if (end === 0) return { content: "", hidden: false };
const rowLimit = Math.max(1, budget);
let start = end;
let cursor = end;
let visualRows = 0;
while (cursor >= 0) {
const newline = cursor > 0 ? diff.lastIndexOf("\n", cursor - 1) : -1;
const lineStart = newline + 1;
const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(diff.slice(lineStart, cursor)), innerWidth).length);
if (visualRows > 0 && visualRows + lineRows > rowLimit) break;
visualRows += lineRows;
start = lineStart;
if (newline < 0) break;
cursor = newline;
}
return { content: diff.slice(start, end), hidden: start > 0 };
}
function formatStreamingDiff(
diff: string,
rawPath: string,
@@ -442,26 +478,14 @@ function formatStreamingDiff(
// its Myers alignment is not monotonic in payload length, so a hunk-aware
// window stutters as rows move between hunks. Expanded widens the window
// to the viewport; the full diff appears once the result finalizes.
const allLines = diff.replace(/\n+$/u, "").split("\n");
let visualUsed = 0;
let cut = allLines.length;
for (let i = allLines.length - 1; i >= 0; i--) {
const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(allLines[i]!), innerWidth).length);
if (visualUsed + lineRows > budget && visualUsed > 0) break;
visualUsed += lineRows;
cut = i;
}
const hiddenLines = cut;
const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
const tail = sliceStreamingDiffTail(diff, innerWidth, budget);
let rendered = "\n\n";
if (hiddenLines > 0) {
const hiddenHunks = getDiffStats(allLines.slice(0, hiddenLines).join("\n")).hunks;
const remainder: string[] = [];
if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
remainder.push(`${hiddenLines} more lines`);
rendered += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
if (tail.hidden) {
// Exact hidden line/hunk counts require scanning the discarded prefix,
// which would make every streaming update scale with the complete diff.
rendered += `${uiTheme.fg("dim", "… (content above)")}\n`;
}
rendered += renderDiffColored(visible.join("\n"), { filePath: rawPath });
rendered += renderDiffColored(tail.content, { filePath: rawPath });
return rendered;
});
// The animated glyph rides this trailing line — inside the transcript's
@@ -84,14 +84,14 @@ describe("editToolRenderer", () => {
const collapsed = renderPreview(makeDiff(20), false);
expect(collapsed).toContain("tail-line-20");
expect(collapsed).not.toContain("head-line-1");
expect(collapsed).toContain("more lines above");
expect(collapsed).toContain("content above");
expect(collapsed).toContain("(preview)");
// Within the viewport window, expanded shows the whole diff.
const expanded = renderPreview(makeDiff(20), true);
expect(expanded).toContain("head-line-1");
expect(expanded).toContain("tail-line-20");
expect(expanded).not.toContain("more lines above");
expect(expanded).not.toContain("content above");
expect(expanded).not.toContain("(preview)");
// Beyond it, expanded stays a viewport-sized tail window: an unbounded
@@ -100,7 +100,7 @@ describe("editToolRenderer", () => {
const expandedTall = renderPreview(makeDiff(40), true);
expect(expandedTall).toContain("tail-line-40");
expect(expandedTall).not.toContain("head-line-1");
expect(expandedTall).toContain("more lines above");
expect(expandedTall).toContain("content above");
} finally {
if (originalRowsDescriptor) {
Object.defineProperty(process.stdout, "rows", originalRowsDescriptor);
@@ -110,6 +110,52 @@ describe("editToolRenderer", () => {
}
});
it("does not report a leading blank line as hidden content", async () => {
const uiTheme = await getUiTheme();
const rendered = Bun.stripANSI(
editToolRenderer
.renderCall(
{ file_path: "/tmp/leading-blank.ts", previewDiff: "\n+1|first-added\n+2|second-added" },
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "replace" } },
uiTheme,
)
.render(200)
.join("\n"),
);
expect(rendered).toContain("first-added");
expect(rendered).toContain("second-added");
expect(rendered).not.toContain("content above");
});
it("uses a count-free marker for a discarded streaming prefix", async () => {
const uiTheme = await getUiTheme();
const diff = [
"@@ -1,10000 +1,12 @@",
...Array.from({ length: 10_000 }, (_, index) => `-hidden-line-${index + 1}`),
...Array.from({ length: 12 }, (_, index) => `+visible-tail-${index + 1}`),
"",
"",
].join("\n");
const rendered = Bun.stripANSI(
editToolRenderer
.renderCall(
{ file_path: "/tmp/large-preview.ts", previewDiff: diff },
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "replace" } },
uiTheme,
)
.render(200)
.join("\n"),
);
expect(rendered).toContain("content above");
expect(rendered).toContain("visible-tail-12");
expect(rendered).not.toContain("hidden-line-10000");
expect(rendered).not.toContain("more hunks");
expect(rendered).not.toContain("more lines above");
});
it("uses hashline input headers for streaming call path without apply_patch errors", async () => {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderCall(