fix(tui): respected expanded edit previews

Passed options.expanded through the edit call preview renderer so approval previews can lift the streaming diff tail window and hide the preview label.

Added regression coverage for collapsed versus expanded edit preview rendering.

Fixes #1992
This commit is contained in:
roboomp
2026-06-06 15:17:32 +00:00
parent 512aacc517
commit 0ea0fe24ab
2 changed files with 52 additions and 18 deletions
+22 -18
View File
@@ -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))}`;
}
@@ -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(