import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { keyHint } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { runCli } from "../cli.ts"; import { TOOLS } from "../schemas.ts"; import { parseTree, section, shortName } from "../tree.ts"; /** * `cbm_search_code`: same passthrough execute as the generic tools, but with a * bespoke TUI renderer. Since binary 0.10.x compact/full mode responses are * tree text (results + raw grep lines + dirs + stats) while `mode: "files"` * stays JSON. Execute normalizes both shapes into a summary in `details`; the * renderer collapses it to "found N results" / "not found" (expandable to a * short list). The LLM still sees the raw output in `content`. */ type Row = { label?: string; name: string; hits?: number; loc?: string }; type CodeSummary = { count: number; grepMatches?: number; rawMatches?: number; files?: string[]; // "files" mode rows: Row[]; }; /** Normalize files-mode JSON or compact/full tree text into a CodeSummary. */ function summarize(out: string): CodeSummary | null { // mode: "files" → {"files": [...], "directories": {...}, ...} try { const j = JSON.parse(out) as { files?: string[]; total_results?: number; total_grep_matches?: number; }; if (j && typeof j === "object" && Array.isArray(j.files)) { return { count: j.total_results ?? j.files.length, grepMatches: j.total_grep_matches, files: j.files, rows: [], }; } } catch { /* not JSON — try tree text */ } const doc = parseTree(out); if (!doc) return null; const results = section(doc, "results"); const rows: Row[] = []; for (let i = 0; i < (results?.raw.length ?? 0); i++) { const cells = results!.rows[i]; if (!cells) { rows.push({ name: results!.raw[i]! }); continue; } const qn = cells.qn ?? "?"; const lines = cells.lines?.split("-")[0]; rows.push({ label: cells.label, name: shortName(qn), hits: cells.matches ? cells.matches.split(";").filter(Boolean).length : undefined, loc: cells.file ? `${cells.file}${lines ? `:${lines}` : ""}` : undefined, }); } const num = (k: string) => (doc.scalars[k] != null ? Number(doc.scalars[k]) : undefined); return { count: num("total_results") ?? results?.count ?? rows.length, grepMatches: num("total_grep_matches"), rawMatches: num("raw_match_count"), rows, }; } export function registerSearchCode(pi: ExtensionAPI) { const def = TOOLS.find((t) => t.tool === "search_code")!; pi.registerTool({ name: "cbm_search_code", label: "cbm:search_code", description: def.description, parameters: def.parameters, async execute(_toolCallId, params, signal) { if (signal?.aborted) { return { content: [{ type: "text", text: "Cancelled" }], details: {} }; } const out = await runCli("search_code", params, signal ?? undefined); const summary = summarize(out); return { // LLM sees the binary's raw (token-compact) output. content: [{ type: "text", text: out || "{}" }], // Renderer reads the normalized summary from details. details: summary ?? { raw: out }, }; }, renderCall(args, theme) { const a = args as { pattern?: string; mode?: string }; let s = theme.fg("toolTitle", theme.bold("cbm:search_code ")); s += theme.fg("accent", a.pattern ?? "…"); if (a.mode && a.mode !== "compact") s += " " + theme.fg("muted", `[${a.mode}]`); return new Text(s, 0, 0); }, renderResult(result, { expanded, isPartial }, theme) { if (isPartial) { return new Text(theme.fg("warning", "⏳ Searching…"), 0, 0); } const d = result.details as CodeSummary & { raw?: string }; if (!d || d.raw !== undefined) { return new Text(theme.fg("error", `✗ ${d?.raw ?? "search failed"}`), 0, 0); } const isFilesMode = Array.isArray(d.files); const count = d.count ?? 0; // Not found → single dim line. if (count === 0) { return new Text(theme.fg("dim", "∅ not found"), 0, 0); } const grep = d.grepMatches != null ? " " + theme.fg("muted", `(${d.grepMatches} grep matches)`) : ""; const head = theme.fg("success", theme.bold(`✔ found ${count} result${count === 1 ? "" : "s"}`)) + grep; if (!expanded) { return new Text( head + " " + theme.fg("dim", `(${keyHint("app.tools.expand", "expand")})`), 0, 0, ); } const lines: string[] = [head]; if (isFilesMode) { for (const f of d.files!.slice(0, 12)) { lines.push(" " + theme.fg("muted", f)); } if (d.files!.length > 12) { lines.push(" " + theme.fg("dim", `… ${d.files!.length - 12} more`)); } } else { for (const r of d.rows.slice(0, 12)) { const label = r.label ? theme.fg("dim", `${r.label} `) : ""; const name = theme.fg("accent", r.name); const hits = r.hits ? " " + theme.fg("dim", `×${r.hits}`) : ""; const loc = r.loc ? " " + theme.fg("muted", r.loc) : ""; lines.push(" " + label + name + hits + loc); } if (d.rows.length > 12) { lines.push(" " + theme.fg("dim", `… ${d.rows.length - 12} more`)); } if (d.rawMatches) { lines.push(" " + theme.fg("dim", `+ ${d.rawMatches} raw grep-only matches`)); } } return new Text(lines.join("\n"), 0, 0); }, }); }