import { isAbsolute } from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { EditToolDetails } from "../../../core/tools/edit.js"; import { generateDiffString } from "../../../core/tools/edit-diff.js"; import type { IpythonToolDetails } from "../../../core/tools/ipython.js"; import { resolveToCwd } from "../../../core/tools/path-utils.js"; import { canonicalizePath, formatPathRelativeToCwdOrAbsolute } from "../../../utils/paths.js"; import { theme } from "../theme/theme.js"; export interface FileChangeSummary { path: string; added: number; removed: number; } export function countChangedLines(diff: string): { added: number; removed: number } { let added = 0; let removed = 0; for (const line of diff.split("\n")) { if (line.startsWith("+")) added++; else if (line.startsWith("-")) removed++; } return { added, removed }; } function mergeFileChange(target: Map, change: FileChangeSummary, cwd: string): void { if (change.added === 0 && change.removed === 0) return; const key = canonicalizePath(resolveToCwd(change.path, cwd)); const existing = target.get(key); if (existing) { existing.added += change.added; existing.removed += change.removed; } else { target.set(key, { ...change }); } } export function getToolFileChanges( toolName: string, args: unknown, result: { details?: unknown; isError: boolean }, cwd: string, ): FileChangeSummary[] { const changes = new Map(); if (toolName === "ipython") { for (const display of (result.details as IpythonToolDetails | undefined)?.diffs ?? []) { const { diff } = generateDiffString(display.oldStr, display.newStr, 4, display.startLine ?? 1); mergeFileChange(changes, { path: display.path, ...countChangedLines(diff) }, cwd); } } else if (toolName === "edit" && !result.isError) { const editArgs = args as { path?: unknown; file_path?: unknown } | undefined; const path = typeof editArgs?.path === "string" ? editArgs.path : editArgs?.file_path; const diff = (result.details as EditToolDetails | undefined)?.diff; if (typeof path === "string" && diff) { mergeFileChange(changes, { path, ...countChangedLines(diff) }, cwd); } } return [...changes.values()]; } export function mergeTurnFileChanges( target: Map, message: AgentMessage, toolResults: readonly ToolResultMessage[], cwd: string, ): void { if (message.role !== "assistant") return; const calls = new Map( message.content.filter((content) => content.type === "toolCall").map((content) => [content.id, content] as const), ); for (const result of toolResults) { const call = calls.get(result.toolCallId); if (!call) continue; for (const change of getToolFileChanges(call.name, call.arguments, result, cwd)) { mergeFileChange(target, change, cwd); } } } /** Dim gutter that anchors every per-file change summary line. */ const FILE_CHANGE_SUMMARY_PREFIX = " ╰─ "; /** Standard content inset for full-width diff rows. */ export const FILE_CHANGE_DIFF_INDENT = " "; function formatChangeCounts(change: Pick): string { return `${theme.fg("toolDiffAdded", `+${change.added}`)} ${theme.fg("toolDiffRemoved", `-${change.removed}`)}`; } function formatFileChangePath(path: string, cwd: string): string { const resolvedPath = resolveToCwd(path, cwd); const lexicalPath = formatPathRelativeToCwdOrAbsolute(resolvedPath, cwd); if (!isAbsolute(lexicalPath)) return lexicalPath; return formatPathRelativeToCwdOrAbsolute(canonicalizePath(resolvedPath), canonicalizePath(cwd)); } /** * One ` ╰─ +N -M` row, truncated to width; the path renders relative * to cwd where possible. */ export function formatFileChangeSummaryLine( rawPath: string, cwd: string | undefined, change: Pick, width: number, ): string { const prefix = theme.fg("dim", FILE_CHANGE_SUMMARY_PREFIX); const counts = `${theme.fg("dim", " ")}${formatChangeCounts(change)}`; const safeWidth = Math.max(1, width); const available = Math.max(1, safeWidth - visibleWidth(prefix) - visibleWidth(counts)); const displayPath = cwd === undefined ? rawPath : formatFileChangePath(rawPath, cwd); const path = truncateToWidth(displayPath, available, "…"); return truncateToWidth(`${prefix}${theme.fg("muted", path)}${counts}`, safeWidth, ""); } export function formatTotalChangeSummary(changes: readonly FileChangeSummary[]): string { const totals = changes.reduce( (sum, change) => ({ added: sum.added + change.added, removed: sum.removed + change.removed }), { added: 0, removed: 0 }, ); const files = `${changes.length} file${changes.length === 1 ? "" : "s"} changed`; return `${theme.fg("muted", files)}${theme.fg("dim", " | ")}${formatChangeCounts(totals)}`; }