perf(coding-agent): bound completed diff rendering
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Kept completed edit previews within their collapsed line limit for oversized single hunks and cached header change statistics across terminal repaints.
|
||||
|
||||
## [17.3.4] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -864,6 +864,7 @@ function renderSingleFileResult(
|
||||
|
||||
let diffSectionRenderDiffFn: ((t: string, o?: { filePath?: string }) => string) | undefined;
|
||||
const diffSectionCache = createRenderedStringCache();
|
||||
const statsSuffixCache = createRenderedStringCache();
|
||||
|
||||
return framedBlock(uiTheme, width => {
|
||||
const { expanded, renderContext } = options;
|
||||
@@ -887,7 +888,11 @@ function renderSingleFileResult(
|
||||
// Change stats ride inline on the header bar next to the path.
|
||||
const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
|
||||
const headerDiff = isError ? undefined : details?.diff || previewDiff;
|
||||
const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
|
||||
const statsSuffix = headerDiff
|
||||
? cachedRenderedString(statsSuffixCache, uiTheme, false, "", headerDiff, () =>
|
||||
formatDiffStatsSuffix(headerDiff, uiTheme),
|
||||
)
|
||||
: "";
|
||||
const header = renderEditHeader(width, uiTheme, {
|
||||
icon: isError ? "error" : "success",
|
||||
iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined,
|
||||
|
||||
@@ -599,79 +599,111 @@ export function truncateDiffByHunk(
|
||||
let keptHunks = 0;
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.isChange) {
|
||||
keptHunks++;
|
||||
if (keptHunks > maxHunks) break;
|
||||
}
|
||||
kept.push(...seg.lines);
|
||||
if (kept.length >= maxLines) break;
|
||||
if (seg.isChange) {
|
||||
if (keptHunks >= maxHunks) break;
|
||||
keptHunks++;
|
||||
}
|
||||
const take = Math.min(seg.lines.length, maxLines - kept.length);
|
||||
for (let i = 0; i < take; i++) {
|
||||
kept.push(seg.lines[i]!);
|
||||
}
|
||||
}
|
||||
|
||||
const keptStats = getDiffStats(kept.join("\n"));
|
||||
return {
|
||||
text: kept.join("\n"),
|
||||
hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
|
||||
hiddenHunks: Math.max(0, totalStats.hunks - keptHunks),
|
||||
hiddenLines: Math.max(0, lines.length - kept.length),
|
||||
};
|
||||
}
|
||||
|
||||
const contextBudget = maxLines - changeLineCount;
|
||||
const contextSegments = segments.filter(s => !s.isChange && !s.isEllipsis);
|
||||
const contextSegments = segments.filter(s => !s.isChange);
|
||||
const totalContextLines = contextSegments.reduce((sum, s) => sum + s.lines.length, 0);
|
||||
|
||||
const kept: string[] = [];
|
||||
let keptHunks = 0;
|
||||
let keptSourceLines = 0;
|
||||
|
||||
if (totalContextLines <= contextBudget) {
|
||||
for (const seg of segments) {
|
||||
if (seg.isChange) {
|
||||
if (keptHunks >= maxHunks) break;
|
||||
keptHunks++;
|
||||
if (keptHunks > maxHunks) break;
|
||||
}
|
||||
kept.push(...seg.lines);
|
||||
keptSourceLines += seg.lines.length;
|
||||
}
|
||||
} else {
|
||||
const contextRatio = contextSegments.length > 0 ? contextBudget / totalContextLines : 0;
|
||||
const contextRatio = totalContextLines > 0 ? contextBudget / totalContextLines : 0;
|
||||
let remainingContextBudget = contextBudget;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
|
||||
if (seg.isChange) {
|
||||
if (keptHunks >= maxHunks) break;
|
||||
keptHunks++;
|
||||
if (keptHunks > maxHunks) break;
|
||||
kept.push(...seg.lines);
|
||||
} else if (seg.isEllipsis) {
|
||||
kept.push(...seg.lines);
|
||||
keptSourceLines += seg.lines.length;
|
||||
continue;
|
||||
}
|
||||
if (remainingContextBudget <= 0) continue;
|
||||
|
||||
const allowedLines = Math.min(
|
||||
remainingContextBudget,
|
||||
Math.max(1, Math.floor(seg.lines.length * contextRatio)),
|
||||
);
|
||||
const outputStart = kept.length;
|
||||
let sourceLinesAdded = 0;
|
||||
|
||||
if (seg.isEllipsis || seg.lines.length <= allowedLines) {
|
||||
for (let j = 0; j < allowedLines; j++) {
|
||||
kept.push(seg.lines[j]!);
|
||||
}
|
||||
sourceLinesAdded = allowedLines;
|
||||
} else {
|
||||
const allowedLines = Math.max(1, Math.floor(seg.lines.length * contextRatio));
|
||||
const isBeforeChange = segments[i + 1]?.isChange;
|
||||
const isAfterChange = segments[i - 1]?.isChange;
|
||||
|
||||
if (isBeforeChange && isAfterChange) {
|
||||
const half = Math.ceil(allowedLines / 2);
|
||||
if (seg.lines.length > allowedLines) {
|
||||
kept.push(...seg.lines.slice(0, half));
|
||||
if (allowedLines >= 3) {
|
||||
const sourceBudget = allowedLines - 1;
|
||||
const firstCount = Math.ceil(sourceBudget / 2);
|
||||
const lastCount = sourceBudget - firstCount;
|
||||
kept.push(...seg.lines.slice(0, firstCount));
|
||||
kept.push("");
|
||||
kept.push(...seg.lines.slice(-half));
|
||||
if (lastCount > 0) kept.push(...seg.lines.slice(-lastCount));
|
||||
sourceLinesAdded = sourceBudget;
|
||||
} else {
|
||||
kept.push(...seg.lines);
|
||||
const firstCount = Math.ceil(allowedLines / 2);
|
||||
const lastCount = allowedLines - firstCount;
|
||||
kept.push(...seg.lines.slice(0, firstCount));
|
||||
if (lastCount > 0) kept.push(...seg.lines.slice(-lastCount));
|
||||
sourceLinesAdded = allowedLines;
|
||||
}
|
||||
} else if (isBeforeChange) {
|
||||
kept.push(...seg.lines.slice(-allowedLines));
|
||||
sourceLinesAdded = allowedLines;
|
||||
} else if (isAfterChange) {
|
||||
kept.push(...seg.lines.slice(0, allowedLines));
|
||||
sourceLinesAdded = allowedLines;
|
||||
} else {
|
||||
kept.push(...seg.lines.slice(0, Math.min(allowedLines, 2)));
|
||||
const take = Math.min(allowedLines, 2);
|
||||
kept.push(...seg.lines.slice(0, take));
|
||||
sourceLinesAdded = take;
|
||||
}
|
||||
}
|
||||
|
||||
keptSourceLines += sourceLinesAdded;
|
||||
remainingContextBudget -= kept.length - outputStart;
|
||||
}
|
||||
}
|
||||
|
||||
const keptStats = getDiffStats(kept.join("\n"));
|
||||
return {
|
||||
text: kept.join("\n"),
|
||||
hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
|
||||
hiddenLines: Math.max(0, lines.length - kept.length),
|
||||
hiddenHunks: Math.max(0, totalStats.hunks - keptHunks),
|
||||
hiddenLines: Math.max(0, lines.length - keptSourceLines),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,19 @@ describe("editToolRenderer", () => {
|
||||
it("caches completed diff rendering across stable frame renders", async () => {
|
||||
const uiTheme = await getUiTheme();
|
||||
let renderDiffCalls = 0;
|
||||
let statsColorCalls = 0;
|
||||
const countingTheme = new Proxy(uiTheme, {
|
||||
get(target, property) {
|
||||
if (property === "fg") {
|
||||
return (color: Parameters<themeModule.Theme["fg"]>[0], text: string): string => {
|
||||
if (color === "toolDiffAdded" && text === "+1") statsColorCalls++;
|
||||
return target.fg(color, text);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target) as unknown;
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
const options = {
|
||||
expanded: false,
|
||||
isPartial: false,
|
||||
@@ -275,17 +288,19 @@ describe("editToolRenderer", () => {
|
||||
},
|
||||
},
|
||||
options,
|
||||
uiTheme,
|
||||
countingTheme,
|
||||
{ file_path: "src/example.ts" },
|
||||
);
|
||||
|
||||
component.render(160);
|
||||
component.render(120);
|
||||
expect(renderDiffCalls).toBe(1);
|
||||
expect(statsColorCalls).toBe(1);
|
||||
|
||||
options.expanded = true;
|
||||
component.render(120);
|
||||
expect(renderDiffCalls).toBe(2);
|
||||
expect(statsColorCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("computes the hashline preview diff once a single-line edit finishes streaming", async () => {
|
||||
@@ -420,6 +435,27 @@ describe("editToolRenderer", () => {
|
||||
expect(lines.filter(line => line.includes("+2/-1"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("bounds a completed diff that contains one oversized change hunk", async () => {
|
||||
const uiTheme = await getUiTheme();
|
||||
const diff = Array.from({ length: 1_000 }, (_, i) => `+${i + 1}│line ${i}`).join("\n");
|
||||
const component = editToolRenderer.renderResult(
|
||||
{
|
||||
content: [{ type: "text", text: "Updated demo.ts" }],
|
||||
details: { diff, op: "update" },
|
||||
},
|
||||
{ expanded: false, isPartial: false, renderContext: { editMode: "hashline" } },
|
||||
uiTheme,
|
||||
{ file_path: "demo.ts" },
|
||||
);
|
||||
|
||||
const lines = component.render(160).map(line => Bun.stripANSI(line));
|
||||
const rendered = lines.join("\n");
|
||||
expect(lines.filter(line => line.includes("│line "))).toHaveLength(40);
|
||||
expect(rendered).toContain("+40│line 39");
|
||||
expect(rendered).not.toContain("+41│line 40");
|
||||
expect(rendered).toContain("960 more lines");
|
||||
});
|
||||
|
||||
it("renders completed edit gutters without inherited frame padding", async () => {
|
||||
const uiTheme = await getUiTheme();
|
||||
const component = editToolRenderer.renderResult(
|
||||
|
||||
@@ -286,6 +286,75 @@ describe("truncateDiffByHunk", () => {
|
||||
expect(idxOld).toBeLessThan(idxNew);
|
||||
expect(idxNew).toBeLessThan(idxTrailing);
|
||||
});
|
||||
it("caps one oversized change hunk at the line budget", () => {
|
||||
const diff = makeHunk("+", 0, 1_000).join("\n");
|
||||
const head = truncateDiffByHunk(diff, 4, 32);
|
||||
const tail = truncateDiffByHunk(diff, 4, 32, { fromTail: true });
|
||||
|
||||
expect(head.text.split("\n")).toHaveLength(32);
|
||||
expect(head.text).toStartWith("+ new 0\n");
|
||||
expect(head.text).toEndWith("+ new 31");
|
||||
expect(head.hiddenLines).toBe(968);
|
||||
expect(head.hiddenHunks).toBe(0);
|
||||
|
||||
expect(tail.text.split("\n")).toHaveLength(32);
|
||||
expect(tail.text).toStartWith("+ new 968\n");
|
||||
expect(tail.text).toEndWith("+ new 999");
|
||||
expect(tail.hiddenLines).toBe(968);
|
||||
expect(tail.hiddenHunks).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps an exact-size change hunk unchanged", () => {
|
||||
const diff = makeHunk("-", 0, 32).join("\n");
|
||||
expect(truncateDiffByHunk(diff, 4, 32)).toEqual({
|
||||
text: diff,
|
||||
hiddenHunks: 0,
|
||||
hiddenLines: 0,
|
||||
});
|
||||
});
|
||||
it("drops surrounding context when changes exactly fill the line budget", () => {
|
||||
const diff = [" leading context", ...makeHunk("+", 0, 32), " trailing context"].join("\n");
|
||||
|
||||
for (const result of [truncateDiffByHunk(diff, 4, 32), truncateDiffByHunk(diff, 4, 32, { fromTail: true })]) {
|
||||
expect(result.text.split("\n")).toHaveLength(32);
|
||||
expect(result.text).not.toContain("context");
|
||||
expect(result.hiddenLines).toBe(2);
|
||||
expect(result.hiddenHunks).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not exceed the line budget when context rounding spans multiple hunks", () => {
|
||||
const diff = [
|
||||
" leading context",
|
||||
...makeHunk("+", 0, 15),
|
||||
" middle context a",
|
||||
" middle context b",
|
||||
...makeHunk("-", 100, 15),
|
||||
" trailing context",
|
||||
].join("\n");
|
||||
|
||||
for (const result of [truncateDiffByHunk(diff, 4, 32), truncateDiffByHunk(diff, 4, 32, { fromTail: true })]) {
|
||||
expect(result.text.split("\n")).toHaveLength(32);
|
||||
expect(result.hiddenLines).toBe(2);
|
||||
expect(result.hiddenHunks).toBe(0);
|
||||
}
|
||||
});
|
||||
it("does not count a removed separator as a hidden hunk", () => {
|
||||
const diff = [...makeHunk("+", 0, 16), " hunk separator", ...makeHunk("-", 100, 16)].join("\n");
|
||||
|
||||
for (const result of [truncateDiffByHunk(diff, 4, 32), truncateDiffByHunk(diff, 4, 32, { fromTail: true })]) {
|
||||
expect(result.text.split("\n")).toHaveLength(32);
|
||||
expect(result.hiddenLines).toBe(1);
|
||||
expect(result.hiddenHunks).toBe(0);
|
||||
}
|
||||
});
|
||||
it("reports every hunk excluded by the hunk limit", () => {
|
||||
const diff = buildDiff(6, 1);
|
||||
|
||||
for (const result of [truncateDiffByHunk(diff, 2, 100), truncateDiffByHunk(diff, 2, 100, { fromTail: true })]) {
|
||||
expect(result.hiddenHunks).toBe(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatErrorMessage (F4 sanitization)", () => {
|
||||
|
||||
Reference in New Issue
Block a user