/** * Pure summary helpers. * * These turn a tool's raw text output into the single line that sits on the * `⎿` row. No TUI imports here on purpose: everything in this file is a plain * string transform so it can be unit tested without a terminal. */ /** A tool result's content entries, as seen at runtime. */ type ContentEntry = { readonly type?: unknown; readonly text?: unknown }; /** Join the text parts of a tool result. Non-text parts (images) are skipped. */ export function textOutput(content: readonly unknown[] | undefined): string { if (!content) return ""; const parts: string[] = []; for (const entry of content) { if (!entry || typeof entry !== "object") continue; const { type, text } = entry as ContentEntry; if (type === "text" && typeof text === "string") parts.push(text); } return parts.join("\n"); } /** `1 file` / `2 files`. Falls back to `s` when no plural is given. */ export function plural(count: number, singular: string, many?: string): string { return `${count} ${count === 1 ? singular : (many ?? `${singular}s`)}`; } /** Number of lines, ignoring trailing blanks. */ export function countLines(output: string): number { const trimmed = output.replace(/\s+$/, ""); if (trimmed === "") return 0; return trimmed.split("\n").length; } /** `12 lines`, `1 entry`, … for tools whose output is one item per line. */ export function summarizeCount( output: string, singular: string, many?: string, ): string { return plural(countLines(output), singular, many); } /** * Grep prints `path:line: text` per match. Count the matches and the distinct * files they came from. Trailing notices ("… limit reached") never match the * shape, so they drop out on their own. * * Context mode (`path-line- text`) and any future format change fall back to a * plain line count rather than reporting a wrong number. */ export function summarizeGrep(output: string): string { const trimmed = output.trim(); if (trimmed === "" || /^no matches found$/i.test(trimmed)) return "no matches"; const files = new Set(); let matches = 0; for (const line of trimmed.split("\n")) { const hit = /^(.+?):(\d+): /.exec(line); if (!hit?.[1]) continue; matches++; files.add(hit[1]); } if (matches === 0) return summarizeCount(trimmed, "line"); return `${plural(matches, "match", "matches")} · ${plural(files.size, "file")}`; } /** * Count added and removed lines in pi's display diff, where changed lines are * prefixed `+`/`-` and context lines start with a space. */ export function diffStats(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 }; } /** `1 addition, 1 removal`, dropping either half when it is zero. */ export function summarizeEdit(diff: string): string { const { added, removed } = diffStats(diff); const parts: string[] = []; if (added > 0) parts.push(plural(added, "addition")); if (removed > 0) parts.push(plural(removed, "removal")); if (parts.length === 0) return "no change"; return parts.join(", "); } /** `+12 −3` — compact diff stat for a call row. Empty when nothing changed. */ export function diffSummary(diff: string): string { const { added, removed } = diffStats(diff); const parts: string[] = []; if (added > 0) parts.push(`+${added}`); if (removed > 0) parts.push(`−${removed}`); return parts.join(" "); } /** `details.diff` as a string, when a tool published one on its details. */ export function extractDiff(details: unknown): string { if (!details || typeof details !== "object") return ""; const diff = (details as Record).diff; return typeof diff === "string" ? diff : ""; } /** * The number of replacement blocks an edit reports on its result line * (`"Successfully replaced N block(s) in …"`), or `undefined` when the text * is not in that shape. */ export function editBlocksReplaced(content: string): number | undefined { const match = /Successfully replaced (\d+) block/.exec(content); if (!match?.[1]) return undefined; const count = Number.parseInt(match[1], 10); return Number.isFinite(count) ? count : undefined; } /** * Count matches in file-grouped output, the shape fff prints: * * ``` * src/client.ts * 42: timeout: 3000, * 87- const previous = … ← context, not a match * * src/pool.ts * 15: timeout?: number, * ``` * * A file heading starts at column zero; a match line is indented and reads * `: `. Context lines use `-` instead of `:` and are not counted, which * is the whole reason this cannot be a line count. * * Returns `undefined` when the text does not look like this at all, so the * caller can fall through to another reading rather than report a zero. */ export function countGroupedMatches( output: string, ): { matches: number; files: number } | undefined { const files = new Set(); let matches = 0; let heading = ""; for (const line of output.split("\n")) { if (line.trim() === "") continue; if (/^\s+\d+: /.test(line)) { matches++; if (heading !== "") files.add(heading); continue; } if (/^\s+\d+- /.test(line)) continue; // context line if (!/^\s/.test(line) && !line.startsWith("[")) { // A heading may carry an annotation; the path is what comes first. heading = line.split(/\s+/)[0] ?? line; } } return matches === 0 ? undefined : { matches, files: files.size }; }