/** * Which tools this package draws, and what their two lines say. * * Pure string transforms, no TUI imports: everything here is unit tested * without a terminal. The patch layer supplies the row, this decides whether * it is claimed and what it reads. * * A tool that is not in `ROWS` returns `undefined` all the way up and keeps * pi's native rendering. That is how an unknown third-party tool stays * untouched. `edit` is claimed, but with `settledOnly`: while it is running * (and while its async preview diff is still the point of the row) the row is * handed back to pi; only after execution settles does it become one line. */ import { countGroupedMatches, countLines, diffSummary, editBlocksReplaced, extractDiff, plural, summarizeCount, summarizeGrep, textOutput, } from "./format.ts"; /** The result as it reaches the renderer. */ export interface RowResult { content?: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean; } export interface RowSpec { /** Name in the call row, as in `⏺ Grep(…)`. */ readonly title: string; /** * Claim only once the row has settled. While it is still running, hand it * back to pi. `edit` needs this: its async diff preview is the point of the * running row, and it lives in pi's renderer, not in the result we can see. */ readonly settledOnly?: boolean; /** Argument list, already flattened to one line. */ detail(args: Record): string; /** The `⎿` line for a settled row. */ summary(result: RowResult): string; } /** Quote a value the way it reads in a call signature. */ export function quoted(value: unknown): string { return typeof value === "string" ? JSON.stringify(value) : String(value); } /** Join `key: value` pairs, skipping anything the model left out. */ export function args(pairs: Array<[string, unknown]>): string { return pairs .filter(([, value]) => value !== undefined && value !== null && value !== "") .map(([key, value]) => `${key}: ${quoted(value)}`) .join(", "); } /** Read a numeric field off a tool's `details`, when it published one. */ function detailCount(details: unknown, key: string): number | undefined { if (!details || typeof details !== "object") return undefined; const value = (details as Record)[key]; return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; } const text = (result: RowResult): string => textOutput(result.content); /** * `38 matches · 6 files`. * * fff publishes `totalMatched` / `totalFiles` on `details`, which beats * re-deriving them from formatted output — the numbers stay right even if it * changes how matches are printed. Parsing is only the fallback, and it * understands both shapes: pi's `path:line: text` and fff's file-grouped * blocks (`path` on its own line, then ` 42: text`). */ export function summarizeMatches(result: RowResult): string { const matches = detailCount(result.details, "totalMatched"); const files = detailCount(result.details, "totalFiles"); if (matches !== undefined) { const head = plural(matches, "match", "matches"); return files === undefined ? head : `${head} · ${plural(files, "file")}`; } const body = text(result); const grouped = countGroupedMatches(body); if (grouped) { return `${plural(grouped.matches, "match", "matches")} · ${plural(grouped.files, "file")}`; } return summarizeGrep(body); } /** `7 paths`, preferring fff's own count when it published one. */ export function summarizePaths(result: RowResult): string { const total = detailCount(result.details, "totalMatched"); if (total !== undefined) return plural(total, "path"); const body = text(result).trim(); if (body === "" || /^no matches found$/i.test(body)) return "no matches"; return summarizeCount(body, "path"); } /** `214 lines`, or `empty` when the tool returned nothing. */ function lines(result: RowResult): string { const body = text(result); return countLines(body) === 0 ? "empty" : summarizeCount(body, "line"); } const path = (a: Record): string => String(a.path ?? ""); /** * The claimed tools. * * Third-party names sit here beside the built-ins because the patch draws * rows by name, not by ownership: `ffgrep` is the fff package's tool, and this * only decides how its row reads. Both spellings of a search are titled the * same — which package answered the query is not what the row is about. */ export const ROWS: Record = { edit: { title: "Edit", // While it runs, pi shows a live diff preview that is the point of the // row; this only takes over once execution has settled. settledOnly: true, detail: (a) => String(a.path ?? ""), summary: (result) => { const stat = diffSummary(extractDiff(result.details)); const blocks = editBlocksReplaced(text(result)); const parts: string[] = []; if (stat) parts.push(stat); if (blocks !== undefined) parts.push(plural(blocks, "block", "blocks") + " replaced"); return parts.length > 0 ? parts.join(" · ") : ""; }, }, read: { title: "Read", detail: path, summary: lines, }, write: { title: "Write", detail: path, summary: lines, }, bash: { title: "Bash", detail: (a) => String(a.command ?? ""), summary: lines, }, ls: { title: "Ls", detail: (a) => String(a.path ?? "."), summary: (result) => { const body = text(result); return countLines(body) === 0 ? "empty" : summarizeCount(body, "entry", "entries"); }, }, grep: { title: "Grep", detail: (a) => args([ ["pattern", a.pattern], ["path", a.path], ["glob", a.glob], ]), summary: summarizeMatches, }, find: { title: "Find", detail: (a) => args([ ["pattern", a.pattern], ["path", a.path], ]), summary: summarizePaths, }, ffgrep: { title: "Grep", detail: (a) => args([ ["pattern", a.pattern], ["path", a.path], ["exclude", a.exclude], ]), summary: summarizeMatches, }, fffind: { title: "Find", detail: (a) => args([ ["pattern", a.pattern], ["path", a.path], ]), summary: summarizePaths, }, "fff-multi-grep": { title: "MultiGrep", detail: (a) => args([ ["patterns", Array.isArray(a.patterns) ? a.patterns.join(", ") : a.patterns], ["path", a.path], ]), summary: summarizeMatches, }, }; /** * Strip a namespace prefix so a tool served over MCP is recognised by its bare * name (`mcp__server__read` → `read`). Names without a prefix are returned as * they came. */ export function bareToolName(name: string): string { const parts = name.split("__"); return parts.length > 1 ? (parts[parts.length - 1] ?? name) : name; } /** The spec for a tool name, or `undefined` to leave the row to pi. */ export function specFor(name: string): RowSpec | undefined { return ROWS[name] ?? ROWS[bareToolName(name)]; }