/** * TUI rendering for pi-subagent. * * Renders sub-agent results in collapsed and expanded views. * Collapsed: status icon, agent name, last few items, usage stats. * Expanded (Ctrl+O): full task text, all tool calls, final markdown output. */ import * as os from "node:os"; import { getMarkdownTheme } from "@earendil-works/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; import type { Message } from "@earendil-works/pi-ai"; import { type SubAgentResult, isFailedResult, getResultOutput } from "./runner.ts"; // --------------------------------------------------------------------------- // Safe type guards // --------------------------------------------------------------------------- export function asString(value: unknown, fallback = "..."): string { return typeof value === "string" ? value : fallback; } export function asNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } export function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } // --------------------------------------------------------------------------- // Display helpers // --------------------------------------------------------------------------- function formatTokens(count: number): string { if (count < 1000) return count.toString(); if (count < 10000) return `${(count / 1000).toFixed(1)}k`; if (count < 1000000) return `${Math.round(count / 1000)}k`; return `${(count / 1000000).toFixed(1)}M`; } export function formatUsageStats( usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens?: number; turns?: number }, model?: string, ): string { const parts: string[] = []; if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`); if (usage.input) parts.push(`↑${formatTokens(usage.input)}`); if (usage.output) parts.push(`↓${formatTokens(usage.output)}`); if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`); if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`); if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`); if (usage.contextTokens && usage.contextTokens > 0) { parts.push(`ctx:${formatTokens(usage.contextTokens)}`); } if (model) parts.push(model); return parts.join(" "); } export function formatToolCall( toolName: string, args: Record, themeFg: (color: string, text: string) => string, ): string { const shortenPath = (p: string) => { const home = os.homedir(); return p.startsWith(home) ? `~${p.slice(home.length)}` : p; }; switch (toolName) { case "bash": { const command = asString(args.command); const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command; return themeFg("muted", "$ ") + themeFg("toolOutput", preview); } case "read": { const rawPath = asString(args.file_path ?? args.path); const filePath = shortenPath(rawPath); const offset = asNumber(args.offset); const limit = asNumber(args.limit); let text = themeFg("accent", filePath); if (offset !== undefined || limit !== undefined) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ""; text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); } return themeFg("muted", "read ") + text; } case "write": { const rawPath = asString(args.file_path ?? args.path); const content = asString(args.content, ""); const lines = content.split("\n").length; let text = themeFg("muted", "write ") + themeFg("accent", shortenPath(rawPath)); if (lines > 1) text += themeFg("dim", ` (${lines} lines)`); return text; } case "edit": { const rawPath = asString(args.file_path ?? args.path); return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath)); } case "ls": { const rawPath = asString(args.path, "."); return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath)); } case "find": { const pattern = asString(args.pattern, "*"); const rawPath = asString(args.path, "."); return ( themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`) ); } case "grep": { const pattern = asString(args.pattern); const rawPath = asString(args.path, "."); return ( themeFg("muted", "grep ") + themeFg("accent", `/${pattern}/`) + themeFg("dim", ` in ${shortenPath(rawPath)}`) ); } default: { const argsStr = JSON.stringify(args); const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr; return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`); } } } export type DisplayItem = | { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record }; export function getDisplayItems(messages: Message[]): DisplayItem[] { const items: DisplayItem[] = []; for (const msg of messages) { if (msg.role === "assistant") { for (const part of msg.content) { if (part.type === "text" && part.text.trim()) { items.push({ type: "text", text: part.text }); } else if (part.type === "toolCall") { items.push({ type: "toolCall", name: part.name, args: asRecord(part.arguments), }); } } } } return items; } // --------------------------------------------------------------------------- // Collapsed renderer // --------------------------------------------------------------------------- const COLLAPSED_ITEM_COUNT = 10; function renderDisplayItems( items: DisplayItem[], theme: { fg: (c: string, t: string) => string }, limit?: number, ): string { const toShow = limit ? items.slice(-limit) : items; const skipped = limit && items.length > limit ? items.length - limit : 0; let text = ""; if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`); for (const item of toShow) { if (item.type === "text") { const preview = item.text.split("\n").slice(0, 3).join("\n"); text += `${theme.fg("toolOutput", preview)}\n`; } else { text += `${theme.fg("muted", "→ ")}${formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`; } } return text.trimEnd(); } // --------------------------------------------------------------------------- // Single agent result // --------------------------------------------------------------------------- export function renderSingleResult( result: SubAgentResult, expanded: boolean, theme: { fg: (c: any, t: string) => string; bold: (t: string) => string }, agentColor?: string, ): Container | Text { const isError = isFailedResult(result); const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); const displayItems = getDisplayItems(result.messages); const finalOutput = getResultOutput(result); if (expanded) { const mdTheme = getMarkdownTheme(); const container = new Container(); let header = `${icon} ${theme.fg(agentColor ?? "toolTitle", theme.bold(result.agent))}`; if (isError && result.stopReason) { const reasonColor = result.stopReason === "timeout" ? "warning" : "error"; header += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`; } container.addChild(new Text(header, 0, 0)); if (isError && result.errorMessage) { const messageColor = result.stopReason === "timeout" ? "warning" : "error"; container.addChild(new Text(theme.fg(messageColor, `Error: ${result.errorMessage}`), 0, 0)); } container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0)); container.addChild(new Text(theme.fg("dim", result.task), 0, 0)); container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0)); if (displayItems.length === 0 && !finalOutput) { container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0)); } else { for (const item of displayItems) { if (item.type === "toolCall") { container.addChild( new Text( theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0, ), ); } } if (finalOutput) { container.addChild(new Spacer(1)); container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); } } const usageStr = formatUsageStats(result.usage, result.model); if (usageStr) { container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("dim", usageStr), 0, 0)); } return container; } // Collapsed let text = `${icon} ${theme.fg(agentColor ?? "toolTitle", theme.bold(result.agent))}`; if (isError && result.stopReason) { const reasonColor = result.stopReason === "timeout" ? "warning" : "error"; text += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`; } if (isError && result.errorMessage) { const messageColor = result.stopReason === "timeout" ? "warning" : "error"; text += `\n${theme.fg(messageColor, `Error: ${result.errorMessage}`)}`; } else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`; else { text += `\n${renderDisplayItems(displayItems, theme, COLLAPSED_ITEM_COUNT)}`; if (displayItems.length > COLLAPSED_ITEM_COUNT) { text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; } } const usageStr = formatUsageStats(result.usage, result.model); if (usageStr) text += `\n${theme.fg("dim", usageStr)}`; return new Text(text, 0, 0); } // --------------------------------------------------------------------------- // Aggregate helpers // --------------------------------------------------------------------------- export function aggregateUsage(results: SubAgentResult[]) { const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }; for (const r of results) { total.input += r.usage.input; total.output += r.usage.output; total.cacheRead += r.usage.cacheRead; total.cacheWrite += r.usage.cacheWrite; total.cost += r.usage.cost; total.turns += r.usage.turns; } return total; }