/** * Shared helpers for the hashline-aware tool renderers. * * Mutation previews and the one TUI presentation of a report (report.ts): every * card renders its result's facts from the structured report in `details`, * never from the model text. Argument rejections are the exception Pi forces: * they are raised before execution and arrive as text only, so they are decoded * with the report codec. * * @module pi-hashline-edit/pi */ import { renderDiff, type Theme, type ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { Text, type Component } from "@earendil-works/pi-tui"; import type { MutationToolName, Report, ReportTool } from "./report-schema.ts"; import { renderReport, reportEntries, reportOf, detailsReport } from "./report.ts"; /** Max diff lines shown when a result is rendered collapsed. */ const MAX_COLLAPSED_DIFF_LINES = 24; /** Collapsed tool output and errors share a bounded preview. */ const MAX_COLLAPSED_OUTPUT_LINES = 15; /** * Render a pi-format diff (`+N`/`-N`/` N` content) for the TUI, reusing pi's * built-in renderer: semantic diff colors plus intra-line (word-level) change * highlighting on single-line modifications. When not expanded, collapse to the * first rendered lines with an overflow marker — truncating after rendering * keeps `-`/`+` pairs intact so the intra-line highlight never dangles. */ function renderDiffPreview(diff: string, expanded: boolean, theme: Theme): string { const rendered = renderDiff(diff); if (expanded) return rendered; const allLines = rendered.split("\n"); const more = allLines.length > MAX_COLLAPSED_DIFF_LINES ? `\n${theme.fg("dim", `… (${allLines.length - MAX_COLLAPSED_DIFF_LINES} more)`)}` : ""; return allLines.slice(0, MAX_COLLAPSED_DIFF_LINES).join("\n") + more; } /** Added/removed line counts of a pi-format diff string (`+N`/`-N` leading char). */ export interface DiffCounts { readonly added: number; readonly removed: number; } /** Row-local render state shared by the mutation call and result renderers. */ export interface MutationRenderState { diffCounts?: DiffCounts; callText?: Text; } /** Count added/removed lines in a pi-format diff (`+N content` / `-N content` / ` N content`). */ function countDiffLines(diff: string): DiffCounts { 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 }; } /** * The call header's path. Arguments stream in the provider's key order, so the * path may arrive after the operations; until then it shows Pi's `...` placeholder. */ export function formatHeaderPath(path: unknown, theme: Theme): string { return typeof path === "string" && path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); } /** Format `+N -N` with the theme's diff colors for the tool call header. */ export function formatDiffCounts(counts: DiffCounts, theme: Theme): string { return ` ${theme.fg("toolDiffAdded", `+${counts.added}`)} ${theme.fg("toolDiffRemoved", `-${counts.removed}`)}`; } /** * Stash per-call diff counts into the row-local render state and refresh the * call header component in place — pi's own edit-tool pattern (renderResult * mutates the component stashed by renderCall; it never re-runs the renderer). * * `updateDisplay` runs renderCall before renderResult in every pass, so later * passes (expand/collapse, result updates) rebuild the header from * `state.diffCounts`; the in-place refresh covers the first result render, * where renderCall ran before the counts existed. renderResult cannot find the * header via `lastComponent` — there it is the *result* component — so * renderCall stashes it (e.g. `state.callText`). * * MUST NOT call `context.invalidate()`: it re-enters `updateDisplay` * synchronously (not re-entrant) and the outer pass then re-adds the result * component after the nested one — the diff renders twice. */ function publishDiffCounts( diff: string | undefined, context: { state: MutationRenderState }, refreshHeader: (counts: DiffCounts) => void, ): void { if (!diff) return; const counts = countDiffLines(diff); const prev = context.state.diffCounts; context.state.diffCounts = counts; if (!prev || prev.added !== counts.added || prev.removed !== counts.removed) refreshHeader(counts); } /** Render a bounded preview; expanding reveals every supplied line. */ export function renderOutputPreview( lines: readonly string[], expanded: boolean, theme: Theme, ): Text { const shown = expanded ? lines : lines.slice(0, MAX_COLLAPSED_OUTPUT_LINES); const more = shown.length < lines.length ? `\n${theme.fg("muted", `… (${lines.length - shown.length} more lines)`)}` : ""; return new Text(shown.join("\n") + more, 0, 0); } /** A value on one line when it is a scalar; lists and objects continue on indented lines. */ function factLines(key: string, value: unknown, theme: Theme, indent = ""): string[] { const label = `${indent}${theme.fg("accent", key)}:`; if (value === null || typeof value !== "object") { const [first, ...rest] = String(value).split("\n"); return [`${label} ${first}`, ...rest.map((line) => `${indent} ${line}`)]; } if (!Array.isArray(value)) return [ label, ...Object.entries(value).flatMap(([k, v]) => factLines(k, v, theme, `${indent} `)), ]; return [ label, ...value.flatMap((item: unknown) => { if (item === null || typeof item !== "object") return [`${indent} - ${String(item)}`]; // Scalar fields share the item's line; nested lists follow it. const entries = Object.entries(item); const scalars = entries.filter(([, v]) => v === null || typeof v !== "object"); const nested = entries.filter(([, v]) => v !== null && typeof v === "object"); return [ `${indent} - ${scalars.map(([k, v]) => `${k}: ${String(v)}`).join(" · ")}`, ...nested.flatMap(([k, v]) => factLines(k, v, theme, `${indent} `)), ]; }), ]; } /** Highlight field issues. */ function argumentLines( report: Extract, theme: Theme, ): string[] { const lines = [theme.fg("error", theme.bold(`Invalid arguments · ${report.tool} not executed`))]; // Keep source omission notices ahead of the bounded collapsed preview. if (report.schemaLimited) lines.push( theme.fg("warning", "Schema diagnostic limit reached; additional issues may remain."), ); if (report.omittedIssues) lines.push(theme.fg("warning", `${report.omittedIssues} issues omitted from diagnostic.`)); for (const { field, reason, fix } of report.issues) { const [first, ...rest] = reason.split("\n"); lines.push(`${theme.fg("accent", theme.bold(field))}: ${theme.fg("error", first)}`); for (const line of rest) lines.push(` ${theme.fg("error", line)}`); if (fix) lines.push(` ${theme.fg("dim", fix)}`); } return lines; } /** Fields every card shows in its own header or call line. */ const HEADER_FIELDS = ["error", "tool", "path", "publication", "message", "next"]; /** * The one TUI presentation of a report: for a failure, a header with the code, * tool, and path, the publication line, and the message; then one `key: value` * row per fact in report order, and `next` last. `shown` names facts the card * already presents another way (a diff, a separate command card). */ export function reportLines( report: Report, theme: Theme, shown: ReadonlySet = new Set(), ): string[] { if (report.error === "INVALID_ARGUMENTS") return argumentLines(report, theme); const lines: string[] = []; if (report.error !== undefined) { const { path } = report; const target = path === undefined ? "" : ` ${Array.isArray(path) ? path.join(", ") : path}`; lines.push(theme.fg("error", theme.bold(`${report.error} · ${report.tool}${target}`))); if (report.publication !== undefined) lines.push( theme.fg(report.publication === "NOT_PUBLISHED" ? "dim" : "warning", report.publication), ); if (report.message !== undefined) for (const line of report.message.split("\n")) lines.push(theme.fg("error", line)); } for (const [key, value] of reportEntries(report)) { if (HEADER_FIELDS.includes(key) || shown.has(key)) continue; for (const line of factLines(key, value, theme)) lines.push(line); } if (report.next !== undefined) lines.push(`${theme.fg("dim", "next:")} ${report.next}`); return lines; } /** * A successful result's checked report and the content before its report block. Renderers * read reports only here or through renderToolError, so the facts a card shows and the * block it strips come from one check; content that is not our report stays. */ export function splitReport(result: Pick, "content" | "details">): { content: AgentToolResult["content"]; report: Report | undefined; } { const report = detailsReport(result.details); const last = result.content.at(-1); const ours = report !== undefined && last?.type === "text" && last.text === renderReport(report); return { content: ours ? result.content.slice(0, -1) : result.content, report }; } /** Render a failure from its report; text that is not ours keeps its original lines. */ export function renderToolError( result: Pick, "content"> & Partial, "details">>, tool: ReportTool, theme: Theme, expanded: boolean, ): Text { const report = reportOf(result, tool); const lines = report ? reportLines(report, theme) : result.content.flatMap((block) => block.type === "text" && block.text ? block.text.split("\n").map((line) => theme.fg("error", line)) : [], ); return renderOutputPreview(lines.length ? lines : [theme.fg("error", "Error")], expanded, theme); } /** Facts a mutation card presents another way: the command card owns then_run. */ export const MUTATION_CARD_SHOWN = new Set(["then_run", "progressError"]); /** The cards of a tool that shows a diff (edit, replace); each supplies only its header and pending text. */ export function diffCardRenderers( tool: MutationToolName, pending: string, header: (args: TArgs, theme: Theme, counts?: DiffCounts) => string, ) { return { /** Reuse the call component and retain it for the result's in-place count refresh. */ renderCall( args: TArgs, theme: Theme, context: { lastComponent?: Component; state: MutationRenderState }, ) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); context.state.callText = text; text.setText(header(args, theme, context.state.diffCounts)); return text; }, /** Render mutation status or a diff, refreshing the call header's counts in place. */ renderResult( result: AgentToolResult<{ diff?: string; report?: Report } | undefined>, { isPartial, expanded }: ToolRenderResultOptions, theme: Theme, context: { isError: boolean; args: TArgs; state: MutationRenderState }, ) { // withMutationStatus has already cleared isPartial once a fused mutation completed. if (isPartial) return new Text(theme.fg("warning", pending), 0, 0); if (context.isError) return renderToolError(result, tool, theme, expanded); const diff = result.details?.diff; // Refresh in place: invalidation inside a renderer re-enters updateDisplay. publishDiffCounts(diff, context, (counts) => { context.state.callText?.setText(header(context.args, theme, counts)); }); const { report } = splitReport(result); const facts = report ? reportLines(report, theme, MUTATION_CARD_SHOWN) : []; const emptyDiff = report?.publication === "NOT_PUBLISHED" ? "No net change" : report?.publication === "PUBLISHED" ? "Updated; no logical line diff" : "No diff available"; const body = diff ? renderDiffPreview(diff, expanded, theme) : theme.fg("success", emptyDiff); return new Text([body, ...facts].join("\n"), 0, 0); }, }; }