diff --git a/src/index.ts b/src/index.ts index 9f2f012..83d5924 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from " import path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; -import { defineTool, type ExtensionAPI, type ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { defineTool, type ExtensionAPI, renderDiff, type ToolDefinition } from "@mariozechner/pi-coding-agent"; import { Box, Container, Spacer, Text } from "@mariozechner/pi-tui"; import * as Diff from "diff"; import { Type } from "typebox"; @@ -107,6 +107,14 @@ export class ApplyPatchError extends Error { } } +type ApplyPatchRenderState = { + cwd: string; + patchText: string; + callText: string; + collapsed: string; + expanded: string; +}; + type ApplyPatchThemeColor = | "accent" | "error" @@ -167,6 +175,39 @@ export async function __testWriteFileAtomic( } const GPT_APPLY_PATCH_PROVIDERS = new Set(["openai", "azure-openai-responses", "github-copilot"]); +export const PATCH_PREVIEW_MAX_LINES = 16; +export const PATCH_PREVIEW_MAX_CHARS = 4000; +const PATCH_PREVIEW_HEAD_LINES = 8; +const PATCH_PREVIEW_TAIL_LINES = 8; +const applyPatchRenderStates = new Map(); + +function countLines(text: string): number { + if (text.length === 0) { + return 0; + } + let lines = 1; + for (let index = 0; index < text.length; index++) { + if (text.charCodeAt(index) === 10) { + lines += 1; + } + } + return lines; +} + +export function truncatePreview(text: string): string { + if (text.length <= PATCH_PREVIEW_MAX_CHARS && countLines(text) <= PATCH_PREVIEW_MAX_LINES) { + return text; + } + + const lines = text.split("\n"); + const head = lines.slice(0, PATCH_PREVIEW_HEAD_LINES); + const tail = lines.slice(-PATCH_PREVIEW_TAIL_LINES); + let preview = [...head, "…", ...tail].join("\n"); + if (preview.length > PATCH_PREVIEW_MAX_CHARS) { + preview = `${preview.slice(0, PATCH_PREVIEW_MAX_CHARS).trimEnd()}\n…`; + } + return preview; +} function normalizeApplyPatchArguments(args: unknown): ApplyPatchParams { if (typeof args === "string") { @@ -347,8 +388,29 @@ function formatLineCountSummary(added: number, removed: number): string { return `(+${added} -${removed})`; } -function formatPatchFilePath(file: ApplyPatchPreviewFile): string { - return file.movePath ? `${file.filePath} → ${file.movePath}` : file.filePath; +export function displayPath(filePath: string, cwd: string): string { + if (!path.isAbsolute(filePath)) { + return filePath; + } + + const absoluteCwd = path.resolve(cwd); + const relativePath = path.relative(absoluteCwd, filePath); + if ( + relativePath === "" || + (!relativePath.startsWith(`..${path.sep}`) && relativePath !== ".." && !path.isAbsolute(relativePath)) + ) { + return relativePath || "."; + } + + return filePath; +} + +export function formatPatchFilePath(file: ApplyPatchPreviewFile, cwd: string = process.cwd()): string { + const filePath = displayPath(file.filePath, cwd); + if (!file.movePath) { + return filePath; + } + return `${filePath} → ${displayPath(file.movePath, cwd)}`; } function formatPatchOperation(operation: ApplyPatchOperation): string { @@ -361,16 +423,24 @@ function formatPatchOperation(operation: ApplyPatchOperation): string { return "Edited"; } -function formatPatchPreview(preview: ApplyPatchPreview): string { +export function formatPatchPreview( + preview: ApplyPatchPreview, + cwd: string = process.cwd(), + expanded: boolean = true, +): string { const lines: string[] = []; if (preview.files.length === 1) { const file = preview.files[0]; if (file) { lines.push( - `• ${formatPatchOperation(file.operation)} ${formatPatchFilePath(file)} ${formatLineCountSummary(file.added, file.removed)}`, + `• ${formatPatchOperation(file.operation)} ${formatPatchFilePath(file, cwd)} ${formatLineCountSummary(file.added, file.removed)}`, ); - if (file.diff) { - lines.push(...file.diff.split("\n").map((line) => ` ${line}`)); + if (expanded && file.diff) { + lines.push( + ...truncatePreview(file.diff) + .split("\n") + .map((line) => ` ${line}`), + ); } } return lines.join("\n"); @@ -379,16 +449,92 @@ function formatPatchPreview(preview: ApplyPatchPreview): string { const noun = preview.files.length === 1 ? "file" : "files"; lines.push(`• Edited ${preview.files.length} ${noun} ${formatLineCountSummary(preview.added, preview.removed)}`); for (const file of preview.files) { - lines.push(` └ ${formatPatchFilePath(file)} ${formatLineCountSummary(file.added, file.removed)}`); - if (file.diff) { - lines.push(...file.diff.split("\n").map((line) => ` ${line}`)); + lines.push(` └ ${formatPatchFilePath(file, cwd)} ${formatLineCountSummary(file.added, file.removed)}`); + if (expanded && file.diff) { + lines.push( + ...truncatePreview(file.diff) + .split("\n") + .map((line) => ` ${line}`), + ); } } return lines.join("\n"); } -function renderPatchPreview(preview: ApplyPatchPreview, theme: ApplyPatchTheme): string { - return formatPatchPreview(preview) +function getApplyPatchRenderState(toolCallId: string, cwd: string, patchText: string): ApplyPatchRenderState { + const existing = applyPatchRenderStates.get(toolCallId); + if (existing && existing.cwd === cwd && existing.patchText === patchText) { + return existing; + } + + const callText = formatInFlightCallText(patchText); + let collapsed = ""; + let expanded = ""; + try { + const hunks = parsePatch(patchText); + if (hunks.length > 0) { + const files = hunks.map((hunk) => ({ + filePath: hunk.filePath, + movePath: hunk.type === "update" ? hunk.movePath : undefined, + operation: hunk.type, + diff: "", + added: 0, + removed: 0, + })) satisfies ApplyPatchPreviewFile[]; + const preview: ApplyPatchPreview = { files, added: 0, removed: 0 }; + collapsed = formatPatchPreview(preview, cwd, false); + expanded = formatPatchPreview(preview, cwd, true); + } + } catch { + // leave summaries empty for partial/incomplete patch text + } + + const nextState: ApplyPatchRenderState = { cwd, patchText, callText, collapsed, expanded }; + applyPatchRenderStates.set(toolCallId, nextState); + return nextState; +} + +export function clearApplyPatchRenderState(): void { + applyPatchRenderStates.clear(); +} + +export function formatInFlightCallText(patchText: string): string { + const paths = extractPatchedPaths(patchText); + if (paths.length === 0) { + return "Patching"; + } + const noun = paths.length === 1 ? "file" : "files"; + const count = paths.length > 1 ? ` (${paths.length} ${noun})` : ""; + return `Patching${count}: ${paths.join(", ")}`; +} + +function renderPatchPreview( + preview: ApplyPatchPreview, + cwd: string, + theme: ApplyPatchTheme, + expanded: boolean, +): string { + if (expanded) { + try { + const renderedFiles = preview.files + .map((file) => { + const header = `• ${formatPatchOperation(file.operation)} ${formatPatchFilePath(file, cwd)} ${formatLineCountSummary(file.added, file.removed)}`; + if (!file.diff) { + return header; + } + const previewDiff = truncatePreview(file.diff); + return `${header}\n${renderDiff(previewDiff)}`; + }) + .join("\n"); + if (renderedFiles.length > 0) { + return renderedFiles; + } + } catch { + // fall back to manual themed line rendering + } + } + + return formatPatchPreview(preview, cwd, expanded) .split("\n") .map((line) => { const trimmed = line.trimStart(); @@ -966,10 +1112,17 @@ export function createApplyPatchTool(): ApplyPatchToolDefinition { details: { result }, }; }, - renderCall(_args, theme) { - return new Text(theme.fg("toolTitle", theme.bold("apply_patch")), 0, 0); + renderCall(args, theme, context) { + if (!context.argsComplete) { + return new Text(theme.fg("toolTitle", theme.bold("apply_patch: Patching")), 0, 0); + } + + const normalizedArgs = normalizeApplyPatchArguments(args); + const renderState = getApplyPatchRenderState(context.toolCallId, context.cwd, normalizedArgs.input); + const text = renderState.callText.length > 0 ? `apply_patch: ${renderState.callText}` : "apply_patch"; + return new Text(theme.fg("toolTitle", theme.bold(text)), 0, 0); }, - renderResult(result, options, theme) { + renderResult(result, options, theme, context) { const component = new Container(); const preview = result.details?.preview; if (preview) { @@ -977,7 +1130,8 @@ export function createApplyPatchTool(): ApplyPatchToolDefinition { const box = new Box(1, 1, (text: string) => theme.bg(bgName, text)); box.addChild(new Text(theme.fg("toolTitle", theme.bold("Applying patch")), 0, 0)); box.addChild(new Spacer(1)); - box.addChild(new Text(renderPatchPreview(preview, theme), 0, 0)); + const expanded = options.isPartial ? true : (options.expanded ?? true); + box.addChild(new Text(renderPatchPreview(preview, context.cwd, theme, expanded), 0, 0)); component.addChild(box); return component; } diff --git a/test/render.test.ts b/test/render.test.ts new file mode 100644 index 0000000..6ac79a4 --- /dev/null +++ b/test/render.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "vitest"; +import { + clearApplyPatchRenderState, + createApplyPatchTool, + displayPath, + formatInFlightCallText, + formatPatchPreview, + PATCH_PREVIEW_MAX_CHARS, + PATCH_PREVIEW_MAX_LINES, + truncatePreview, +} from "../src/index.js"; + +const identityTheme = { + fg: (_name: string, text: string) => text, + bg: (_name: string, text: string) => text, + bold: (text: string) => text, + inverse: (text: string) => text, +}; + +describe("render helpers", () => { + it("#given long diff #when truncating #then keeps head and tail", () => { + // given + const lines = Array.from({ length: PATCH_PREVIEW_MAX_LINES + 12 }, (_, index) => `line-${index + 1}`); + const diff = lines.join("\n"); + + // when + const preview = truncatePreview(diff); + + // then + expect(preview).toContain("line-1"); + expect(preview).toContain(`line-${lines.length}`); + expect(preview).toContain("…"); + }); + + it("#given huge payload #when truncating #then enforces max chars", () => { + // given + const diff = `${"x".repeat(PATCH_PREVIEW_MAX_CHARS + 500)}\nend`; + + // when + const preview = truncatePreview(diff); + + // then + expect(preview.length).toBeLessThanOrEqual(PATCH_PREVIEW_MAX_CHARS + 2); + expect(preview).toContain("…"); + }); + + it("#given absolute path under cwd #when displaying #then returns relative path", () => { + // given + const cwd = "/workspace/project"; + const absolute = "/workspace/project/src/index.ts"; + + // when + const rendered = displayPath(absolute, cwd); + + // then + expect(rendered).toBe("src/index.ts"); + }); + + it("#given absolute path outside cwd #when displaying #then keeps absolute path", () => { + // given + const cwd = "/workspace/project"; + const absolute = "/tmp/file.ts"; + + // when + const rendered = displayPath(absolute, cwd); + + // then + expect(rendered).toBe(absolute); + }); + + it("#given expanded false #when formatting preview #then renders headers only", () => { + // given + const preview = { + files: [ + { + filePath: "/workspace/project/src/foo.ts", + operation: "update" as const, + diff: "-1 old\n+1 new", + added: 1, + removed: 1, + }, + ], + added: 1, + removed: 1, + }; + + // when + const collapsed = formatPatchPreview(preview, "/workspace/project", false); + const expanded = formatPatchPreview(preview, "/workspace/project", true); + + // then + expect(collapsed).toContain("• Edited src/foo.ts (+1 -1)"); + expect(collapsed).not.toContain("+1 new"); + expect(expanded).toContain("+1 new"); + }); + + it("#given omitted optional args #when formatting preview #then keeps backward compatible defaults", () => { + // given + const preview = { + files: [ + { + filePath: "src/foo.ts", + operation: "update" as const, + diff: "-1 old\n+1 new", + added: 1, + removed: 1, + }, + ], + added: 1, + removed: 1, + }; + + // when + const rendered = formatPatchPreview(preview); + + // then + expect(rendered).toContain("• Edited src/foo.ts (+1 -1)"); + expect(rendered).toContain("+1 new"); + }); + + it("#given cached state #when clearing #then reset helper is callable", () => { + // given/when/then + expect(() => clearApplyPatchRenderState()).not.toThrow(); + }); + + it("#given parseable call text #when formatting in-flight label #then includes count and paths", () => { + // given + const patch = `*** Begin Patch +*** Update File: src/a.ts +*** Add File: src/b.ts +*** End Patch`; + + // when + const callText = formatInFlightCallText(patch); + + // then + expect(callText).toContain("(2 files)"); + expect(callText).toContain("src/a.ts"); + expect(callText).toContain("src/b.ts"); + }); + + it("#given partial args #when rendering call #then shows patching placeholder", () => { + // given + const tool = createApplyPatchTool(); + + // when + const component = tool.renderCall?.( + { input: "{" }, + identityTheme as never, + { + argsComplete: false, + cwd: "/workspace/project", + toolCallId: "call-1", + } as never, + ); + const rendered = component?.render(120).join("\n") ?? ""; + + // then + expect(rendered).toContain("apply_patch: Patching"); + }); + + it("#given patch args #when rendering call #then shows paths and count", () => { + // given + const tool = createApplyPatchTool(); + const args = { + input: `*** Begin Patch +*** Update File: src/a.ts +*** Add File: src/b.ts +*** End Patch`, + }; + + // when + const component = tool.renderCall?.( + args, + identityTheme as never, + { + argsComplete: true, + cwd: "/workspace/project", + toolCallId: "call-2", + } as never, + ); + const rendered = component?.render(200).join("\n") ?? ""; + + // then + expect(rendered).toContain("apply_patch: Patching (2 files): src/a.ts, src/b.ts"); + }); + + it("#given preview #when rendering result collapsed #then shows headers without diff lines", () => { + // given + const tool = createApplyPatchTool(); + const result = { + content: [{ type: "text" as const, text: "Applying patch" }], + details: { + preview: { + files: [ + { + filePath: "src/foo.ts", + operation: "update" as const, + diff: "-1 old\n+1 new", + added: 1, + removed: 1, + }, + ], + added: 1, + removed: 1, + }, + }, + }; + + // when + const component = tool.renderResult?.( + result, + { expanded: false, isPartial: false }, + identityTheme as never, + { cwd: "/workspace/project", toolCallId: "result-1", args: { input: "" } } as never, + ); + const rendered = component?.render(200).join("\n") ?? ""; + + // then + expect(rendered).toContain("• Edited src/foo.ts (+1 -1)"); + expect(rendered).not.toContain("+1 new"); + }); + + it("#given multi-file preview #when rendering result collapsed #then shows grouped summary", () => { + // given + const tool = createApplyPatchTool(); + const result = { + content: [{ type: "text" as const, text: "Applying patch" }], + details: { + preview: { + files: [ + { filePath: "src/a.ts", operation: "update" as const, diff: "+1 one", added: 1, removed: 0 }, + { filePath: "src/b.ts", operation: "update" as const, diff: "+1 two", added: 1, removed: 0 }, + ], + added: 2, + removed: 0, + }, + }, + }; + + // when + const component = tool.renderResult?.( + result, + { expanded: false, isPartial: false }, + identityTheme as never, + { cwd: "/workspace/project", toolCallId: "result-3", args: { input: "" } } as never, + ); + const rendered = component?.render(400).join("\n") ?? ""; + + // then + expect(rendered).toContain("• Edited 2 files (+2 -0)"); + expect(rendered).toContain("└ src/a.ts (+1 -0)"); + expect(rendered).toContain("└ src/b.ts (+1 -0)"); + expect(rendered).not.toContain("+1 one"); + }); + + it("#given large preview #when rendering result expanded #then shows truncation marker", () => { + // given + const tool = createApplyPatchTool(); + const diff = Array.from({ length: 50 }, (_, index) => `+${index + 1} line`).join("\n"); + const result = { + content: [{ type: "text" as const, text: "Applying patch" }], + details: { + preview: { + files: [{ filePath: "src/large.ts", operation: "update" as const, diff, added: 50, removed: 0 }], + added: 50, + removed: 0, + }, + }, + }; + + // when + const component = tool.renderResult?.( + result, + { expanded: true, isPartial: false }, + identityTheme as never, + { cwd: "/workspace/project", toolCallId: "result-large", args: { input: "" } } as never, + ); + const rendered = component?.render(400).join("\n") ?? ""; + + // then + expect(rendered).toContain("• Edited src/large.ts (+50 -0)"); + expect(rendered).toContain("…"); + }); +});