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_graph`: same passthrough execute as the generic tools, but with a * bespoke TUI renderer. Since binary 0.10.x the default response is tree text * (`format: "tree"`); `format: "json"` returns the same model as * `{total, cols, rows}`. Execute normalizes either shape 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; loc?: string }; type GraphSummary = { total: number; mode?: string; hasMore?: boolean; rows: Row[]; }; function rowFromCells(cells: Record): Row { const qn = cells.qn ?? "?"; const lines = cells.lines?.split("-")[0]; return { label: cells.label, name: shortName(qn), loc: cells.file ? `${cells.file}${lines ? `:${lines}` : ""}` : undefined, }; } /** Normalize tree text or `format: "json"` output into a GraphSummary. */ function summarize(out: string): GraphSummary | null { // format: "json" → {total, search_mode, cols, rows: any[][], has_more} try { const j = JSON.parse(out) as { total?: number; search_mode?: string; cols?: string[]; rows?: unknown[][]; has_more?: boolean; }; if (j && typeof j === "object" && (j.total != null || Array.isArray(j.rows))) { const cols = j.cols ?? []; const rows = (j.rows ?? []).map((r) => rowFromCells(Object.fromEntries(cols.map((c, i) => [c, String(r[i] ?? "")]))), ); return { total: j.total ?? rows.length, mode: j.search_mode, hasMore: j.has_more, 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]; rows.push(cells ? rowFromCells(cells) : { name: results!.raw[i]! }); } return { total: doc.scalars.total != null ? Number(doc.scalars.total) : rows.length, mode: doc.scalars.search_mode, hasMore: doc.scalars.has_more === "true", rows, }; } export function registerSearchGraph(pi: ExtensionAPI) { const def = TOOLS.find((t) => t.tool === "search_graph")!; pi.registerTool({ name: "cbm_search_graph", label: "cbm:search_graph", 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_graph", 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 { query?: string; name_pattern?: string; qn_pattern?: string; semantic_query?: string[]; }; const q = a.query ?? a.name_pattern ?? a.qn_pattern ?? a.semantic_query?.join(", ") ?? "…"; let s = theme.fg("toolTitle", theme.bold("cbm:search_graph ")); s += theme.fg("accent", q); 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 GraphSummary & { raw?: string }; if (!d || d.raw !== undefined) { return new Text(theme.fg("error", `✗ ${d?.raw ?? "search failed"}`), 0, 0); } const count = d.total ?? d.rows.length; // Not found → single dim line. if (count === 0) { return new Text(theme.fg("dim", "∅ not found"), 0, 0); } const mode = d.mode ? ` ${theme.fg("muted", `(${d.mode})`)}` : ""; const head = theme.fg("success", theme.bold(`✔ found ${count} result${count === 1 ? "" : "s"}`)) + mode + (d.hasMore ? " " + theme.fg("dim", "· more") : ""); if (!expanded) { return new Text( head + " " + theme.fg("dim", `(${keyHint("app.tools.expand", "expand")})`), 0, 0, ); } const lines: string[] = [head]; 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 loc = r.loc ? " " + theme.fg("muted", r.loc) : ""; lines.push(" " + label + name + loc); } if (d.rows.length > 12) { lines.push(" " + theme.fg("dim", `… ${d.rows.length - 12} more`)); } return new Text(lines.join("\n"), 0, 0); }, }); }