diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5597d222b..feb931256 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index e0b1422a4..571ad1751 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -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, diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index d17b0e118..45b404c01 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -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), }; } diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index de7f2d361..ad15b57a4 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -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[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( diff --git a/packages/coding-agent/test/tools/render-utils.test.ts b/packages/coding-agent/test/tools/render-utils.test.ts index 62545d5f1..c4e6cfdaf 100644 --- a/packages/coding-agent/test/tools/render-utils.test.ts +++ b/packages/coding-agent/test/tools/render-utils.test.ts @@ -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)", () => {