/** * TUI rendering helpers for the web-research tools (interactive mode only). * * pi-tui is loaded LAZILY via `loadTui()` (dynamic import, failure-tolerant): if * it can't be resolved we simply don't attach renderers and the tools fall back * to pi's default rendering — the extension never breaks over a UI dependency. * All pi-tui / pi-coding-agent imports here are TYPE-ONLY (erased at runtime), so * this module itself loads even where the packages aren't resolvable. */ import type { Component } from "@earendil-works/pi-tui"; import type { Theme } from "@earendil-works/pi-coding-agent"; type Tui = typeof import("@earendil-works/pi-tui"); let _tui: Tui | undefined; let _tried = false; /** Resolve pi-tui once; returns undefined (cached) if it can't be loaded. */ export async function loadTui(): Promise { if (!_tried) { _tried = true; try { _tui = await import("@earendil-works/pi-tui"); } catch { _tui = undefined; } } return _tui; } // Structured detail payloads attached to tool results, consumed by renderResult. export interface WebSearchDetails { provider?: string; query?: string; questions?: number; recency?: string; read?: number; attempted?: number; followed?: number; summarized?: boolean; error?: string; mode?: "comprehensive" | "concise"; dedup?: "query" | "urls"; // short-circuited as redundant with a prior search results?: { url: string; title: string }[]; } export interface WebFetchDetails { url?: string; title?: string; mode?: "full" | "concise" | "thorough" | "research" | "raw"; error?: string; } interface ToolResultLike { content?: { type?: string; text?: string }[]; details?: unknown; } interface RenderOpts { expanded: boolean; isPartial: boolean; } function contentText(result: ToolResultLike): string { const first = result?.content?.find?.((c) => c?.type === "text"); return first?.text ?? ""; } function truncate(s: string, n: number): string { return s.length > n ? `${s.slice(0, n - 1)}…` : s; } /** Dim affordance shown on a collapsed result so the full output is discoverable. */ function expandHint(theme: Theme, expanded: boolean): string { return expanded ? "" : theme.fg("dim", " · ctrl+o to expand"); } // How long a freshly-completed result stays expanded before auto-collapsing. export const AUTO_COLLAPSE_MS = 3000; /** Persistent per-row render state (lives in ToolRenderContext.state). */ interface AutoCollapseState { autoStarted?: boolean; autoCollapsed?: boolean; } /** The slice of ToolRenderContext we use (kept loose to avoid a runtime SDK import). */ export interface RenderCtx { state: AutoCollapseState; invalidate: () => void; } /** * Decide whether to show the full output. Behavior: * - The global tool-output toggle (ctrl+o → opts.expanded) always forces full. * - Otherwise a freshly-completed result shows full for AUTO_COLLAPSE_MS, then * auto-collapses to its header. The one-shot timer is tracked in the row's * persistent context.state, and fires context.invalidate() to re-render. * - With no context (e.g. unit tests), falls back to opts.expanded. */ function resolveExpanded(opts: RenderOpts, ctx?: RenderCtx): boolean { if (opts.expanded) return true; if (!ctx) return false; const st = ctx.state; if (st.autoCollapsed) return false; if (!st.autoStarted) { st.autoStarted = true; const invalidate = ctx.invalidate; setTimeout(() => { st.autoCollapsed = true; invalidate(); }, AUTO_COLLAPSE_MS).unref?.(); } return true; } /** Build a MarkdownTheme from the host Theme so briefings render with formatting. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function makeMarkdownTheme(theme: Theme): any { const f = (color: string) => (t: string) => theme.fg(color as never, t); return { heading: f("mdHeading"), link: f("mdLink"), linkUrl: f("mdLinkUrl"), code: f("mdCode"), codeBlock: f("mdCodeBlock"), codeBlockBorder: f("mdCodeBlockBorder"), quote: f("mdQuote"), quoteBorder: f("mdQuoteBorder"), hr: f("mdHr"), listBullet: f("mdListBullet"), bold: (t: string) => theme.bold(t), italic: (t: string) => theme.italic(t), strikethrough: (t: string) => theme.strikethrough(t), underline: (t: string) => theme.underline(t), }; } // --------------------------------------------------------------------------- // web_search // --------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any export function renderSearchCall(tui: Tui, theme: Theme, args: any): Component { let s = theme.fg("toolTitle", theme.bold("web_search ")) + theme.fg("text", `"${truncate(String(args?.query ?? ""), 80)}"`); const qn = Array.isArray(args?.questions) ? args.questions.length : 0; if (qn) s += theme.fg("dim", ` · ${qn} question${qn > 1 ? "s" : ""}`); if (args?.recency) s += theme.fg("dim", ` · last ${args.recency}`); return new tui.Text(s, 0, 0); } export function renderSearchResult(tui: Tui, theme: Theme, result: ToolResultLike, opts: RenderOpts, ctx?: RenderCtx): Component { if (opts.isPartial) { return new tui.Text(contentText(result) || theme.fg("muted", "Working…"), 0, 0); } const d = (result.details ?? {}) as WebSearchDetails; if (d.error) return new tui.Text(theme.fg("error", `✖ ${d.error}`), 0, 0); // Full on arrival, then auto-collapses to a compact header after 3s; ctrl+o // re-expands. (See resolveExpanded.) const show = resolveExpanded(opts, ctx); const container = new tui.Container(); if (d.dedup) { const why = d.dedup === "query" ? "duplicate query" : "results already researched"; container.addChild(new tui.Text(theme.fg("muted", `↺ reused prior research · ${why}`) + expandHint(theme, show), 0, 0)); if (show) container.addChild(new tui.Markdown(contentText(result), 0, 0, makeMarkdownTheme(theme))); return container; } const sources = (d.results?.length ?? 0) + (d.followed ?? 0); let header = theme.fg("toolTitle", theme.bold("web_search")); if (d.provider) header += theme.fg("muted", ` · ${d.provider}`); if (d.mode) header += theme.fg("muted", ` · ${d.mode}`); if (d.read != null && d.attempted != null) header += theme.fg("muted", ` · read ${d.read}/${d.attempted}`); if (d.followed) header += theme.fg("accent", ` · ${d.followed} followed`); if (sources) header += theme.fg("muted", ` · ${sources} source${sources > 1 ? "s" : ""}`); container.addChild(new tui.Text(header + expandHint(theme, show), 0, 0)); if (show) container.addChild(new tui.Markdown(contentText(result), 0, 0, makeMarkdownTheme(theme))); return container; } // --------------------------------------------------------------------------- // web_fetch // --------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any export function renderFetchCall(tui: Tui, theme: Theme, args: any): Component { let s = theme.fg("toolTitle", theme.bold("web_fetch ")) + theme.fg("text", truncate(String(args?.url ?? ""), 80)); if (args?.prompt) s += theme.fg("dim", ` · "${truncate(String(args.prompt), 60)}"`); else if (args?.mode) s += theme.fg("dim", ` · ${args.mode}`); return new tui.Text(s, 0, 0); } export function renderFetchResult(tui: Tui, theme: Theme, result: ToolResultLike, opts: RenderOpts, ctx?: RenderCtx): Component { if (opts.isPartial) { return new tui.Text(contentText(result) || theme.fg("muted", "Working…"), 0, 0); } const d = (result.details ?? {}) as WebFetchDetails; if (d.error) return new tui.Text(theme.fg("error", `✖ ${d.error}`), 0, 0); const target = d.title ?? d.url ?? ""; const label = d.mode === "research" ? `💬 answered from ${target}` : d.mode === "concise" || d.mode === "thorough" ? `📝 ${d.mode} summary of ${target}` : `📄 ${target}`; // Full on arrival, then auto-collapses to the label after 3s; ctrl+o re-expands. const show = resolveExpanded(opts, ctx); const container = new tui.Container(); container.addChild(new tui.Text(theme.fg("muted", label) + expandHint(theme, show), 0, 0)); if (show) container.addChild(new tui.Markdown(contentText(result), 0, 0, makeMarkdownTheme(theme))); return container; }