Merge PR #8662: perf(coding-agent): bound streaming diff previews (@lemonleks)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user