diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 6b64205f1..51f35095d 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -233,19 +233,22 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string) return preview.trimEnd(); } -function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string { +function formatStreamingDiff( + diff: string, + rawPath: string, + uiTheme: Theme, + expanded: boolean, + label = "streaming", +): string { if (!diff) return ""; - // "Cursor" tail window: pin the last EDIT_STREAMING_PREVIEW_LINES rows to the - // bottom of the diff so freshly streamed changes stay on screen, and accept - // the trailing rows "from the back" once the diff outgrows the window. The - // whole-file diff is recomputed on every streamed chunk and its Myers - // alignment is not monotonic in payload length, so a hunk-aware window that - // kept whole change segments gained and lost rows tick to tick — the box - // stuttered, and the earlier high-water fix traded that for a half-empty - // rectangle. A strict fixed-height window keeps the box steady and always - // full of real diff context instead of blank padding. + // Collapsed uses a "Cursor" tail window: pin the last + // EDIT_STREAMING_PREVIEW_LINES rows to the bottom so freshly streamed changes + // stay on screen. The whole-file diff is recomputed on every streamed chunk + // and its Myers alignment is not monotonic in payload length, so a hunk-aware + // window stutters as rows move between hunks. Expanded deliberately lifts that + // cap for the approval-time full view. const allLines = diff.replace(/\n+$/u, "").split("\n"); - const hiddenLines = Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES); + const hiddenLines = expanded ? 0 : Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES); const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines; let text = "\n\n"; if (hiddenLines > 0) { @@ -256,7 +259,7 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`; } text += renderDiffColored(visible.join("\n"), { filePath: rawPath }); - text += uiTheme.fg("dim", `\n(${label})`); + if (!expanded || label !== "preview") text += uiTheme.fg("dim", `\n(${label})`); return text; } @@ -268,7 +271,7 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin return uiTheme.fg("dim", `${icon}`); } -function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme): string { +function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme, expanded: boolean): string { const parts: string[] = []; for (const preview of previews) { if (!preview.diff && !preview.error) continue; @@ -278,7 +281,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T continue; } if (preview.diff) { - parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, "preview")}`); + parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview")}`); } } return parts.join(""); @@ -289,16 +292,17 @@ function getCallPreview( rawPath: string, uiTheme: Theme, renderContext: EditRenderContext | undefined, + expanded: boolean, ): string { const multi = renderContext?.perFileDiffPreview; if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) { - return formatMultiFileStreamingDiff(multi, uiTheme); + return formatMultiFileStreamingDiff(multi, uiTheme, expanded); } if (args.previewDiff) { - return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview"); + return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview"); } if (args.diff && args.op) { - return formatStreamingDiff(args.diff, rawPath, uiTheme); + return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded); } if (args.diff) { return renderPlainTextPreview(args.diff, uiTheme, rawPath); @@ -481,7 +485,7 @@ export const editToolRenderer = { if (fileCount > 1) { text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`); } - text += getCallPreview(editArgs, rawPath, uiTheme, renderContext); + text += getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded); if (applyPatchSummary?.error) { text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), CALL_TEXT_PREVIEW_WIDTH))}`; } diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index 79ca8a72c..57f9aef63 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -53,6 +53,36 @@ describe("editToolRenderer", () => { expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts"); }); + it("lifts the streaming diff tail window when expanded", async () => { + const uiTheme = await getUiTheme(); + const diff = Array.from({ length: 20 }, (_, index) => + index === 0 ? "-head-line-1" : `+tail-line-${index + 1}`, + ).join("\n"); + const renderPreview = (expanded: boolean): string => + Bun.stripANSI( + editToolRenderer + .renderCall( + { file_path: "/tmp/preview.ts", previewDiff: diff }, + { expanded, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "replace" } }, + uiTheme, + ) + .render(200) + .join("\n"), + ); + + const collapsed = renderPreview(false); + expect(collapsed).toContain("tail-line-20"); + expect(collapsed).not.toContain("head-line-1"); + expect(collapsed).toContain("more lines above"); + expect(collapsed).toContain("(preview)"); + + const expanded = renderPreview(true); + expect(expanded).toContain("head-line-1"); + expect(expanded).toContain("tail-line-20"); + expect(expanded).not.toContain("more lines above"); + expect(expanded).not.toContain("(preview)"); + }); + it("uses hashline input headers for streaming call path without apply_patch errors", async () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderCall(