/** * agent-widget.ts — Persistent widget showing running/completed agents above the editor. * * Displays a tree of agents with animated spinners, live stats, and activity descriptions. * Uses the callback form of setWidget for themed rendering. */ import { type TUI, truncateToWidth } from "@earendil-works/pi-tui"; import type { AgentManager } from "../agent-manager.js"; import { getConfig } from "../agent-types.js"; import type { SubagentType, ThinkingLevel } from "../types.js"; // ---- Constants ---- /** Maximum number of rendered lines before overflow collapse kicks in. */ const MAX_WIDGET_LINES = 12; /** Braille spinner frames for animated running indicator. */ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */ export const ERROR_STATUSES = new Set([ "error", "aborted", "steered", "stopped", ]); /** Tool name → human-readable action for activity descriptions. */ const TOOL_DISPLAY: Record = { read: "reading", bash: "running command", edit: "editing", write: "writing", grep: "searching", find: "finding files", ls: "listing", }; // ---- Types ---- export interface Theme { fg(color: string, text: string): string; bold(text: string): string; } interface WidgetRenderer { render(): string[]; invalidate(): void; } export interface UICtx { setStatus(key: string, text: string | undefined): void; setWidget( key: string, content: undefined | ((tui: TUI, theme: Theme) => WidgetRenderer), options?: { placement?: "aboveEditor" | "belowEditor" } ): void; } /** Per-agent live activity state. */ export interface AgentActivity { activeTools: Map; toolUses: number; tokens: string; responseText: string; session?: { getSessionStats(): { tokens: { total: number } } }; /** Current turn count. */ turnCount: number; /** Effective max turns for this agent (undefined = unlimited). */ maxTurns?: number; } /** Metadata attached to Agent tool results for custom rendering. */ export interface AgentDetails { displayName: string; description: string; subagentType: string; toolUses: number; tokens: string; durationMs: number; status: | "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background"; /** Human-readable description of what the agent is currently doing. */ activity?: string; /** Current spinner frame index (for animated running indicator). */ spinnerFrame?: number; /** Short resolved model name for display (e.g. "haiku", "sonnet"). */ modelName?: string; /** Effective explicit thinking level for display. */ thinkingLevel?: ThinkingLevel; /** Notable config tags (e.g. ["isolated"]). */ tags?: string[]; /** Non-fatal warnings produced while preparing or running the agent. */ warnings?: string[]; /** Current turn count. */ turnCount?: number; /** Effective max turns (undefined = unlimited). */ maxTurns?: number; agentId?: string; error?: string; } // ---- Formatting helpers ---- /** Format a token count compactly: "33.8k token", "1.2M token". */ export function formatTokens(count: number): string { if (count >= 1_000_000) { return `${(count / 1_000_000).toFixed(1)}M token`; } if (count >= 1000) { return `${(count / 1000).toFixed(1)}k token`; } return `${count} token`; } /** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */ export function formatTurns( turnCount: number, maxTurns?: number | null ): string { return maxTurns == null ? `⟳${turnCount}` : `⟳${turnCount}≤${maxTurns}`; } /** Format milliseconds as human-readable duration. */ export function formatMs(ms: number): string { return `${(ms / 1000).toFixed(1)}s`; } /** Format duration from start/completed timestamps. */ export function formatDuration( startedAt: number, completedAt?: number ): string { if (completedAt) { return formatMs(completedAt - startedAt); } return `${formatMs(Date.now() - startedAt)} (running)`; } export function formatAgentConfigTag( modelName?: string, thinkingLevel?: ThinkingLevel ): string | undefined { if (modelName && thinkingLevel) { return `${modelName}:${thinkingLevel}`; } if (modelName) { return modelName; } if (thinkingLevel) { return `thinking:${thinkingLevel}`; } return undefined; } /** Get display name for any agent type (built-in or custom). */ export function getDisplayName(type: SubagentType): string { return getConfig(type).displayName; } /** Short label for prompt mode: "twin" for append, nothing for replace (the default). */ export function getPromptModeLabel(type: SubagentType): string | undefined { const config = getConfig(type); return config.promptMode === "append" ? "twin" : undefined; } /** Truncate text to a single line, max `len` chars. */ function truncateLine(text: string, len = 60): string { const line = text .split("\n") .find((l) => l.trim()) ?.trim() ?? ""; if (line.length <= len) { return line; } return `${line.slice(0, len)}…`; } /** Build a human-readable activity string from currently-running tools or response text. */ export function describeActivity( activeTools: Map, responseText?: string ): string { if (activeTools.size > 0) { const groups = new Map(); for (const toolName of activeTools.values()) { const action = TOOL_DISPLAY[toolName] ?? toolName; groups.set(action, (groups.get(action) ?? 0) + 1); } const parts: string[] = []; for (const [action, count] of groups) { if (count > 1) { parts.push( `${action} ${count} ${action === "searching" ? "patterns" : "files"}` ); } else { parts.push(action); } } return `${parts.join(", ")}…`; } // No tools active — show truncated response text if available if (responseText && responseText.trim().length > 0) { return truncateLine(responseText); } return "thinking…"; } // ---- Widget manager ---- export class AgentWidget { private uiCtx: UICtx | undefined; private widgetFrame = 0; private widgetInterval: ReturnType | undefined; /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */ private readonly finishedTurnAge = new Map(); /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */ private static readonly ERROR_LINGER_TURNS = 2; /** Whether the widget callback is currently registered with the TUI. */ private widgetRegistered = false; /** Cached TUI reference from widget factory callback, used for requestRender(). */ private tui: TUI | undefined; /** Last status bar text, used to avoid redundant setStatus calls. */ private lastStatusText: string | undefined; private readonly manager: AgentManager; private readonly agentActivity: Map; constructor( manager: AgentManager, agentActivity: Map ) { this.manager = manager; this.agentActivity = agentActivity; } /** Set the UI context (grabbed from first tool execution). */ setUICtx(ctx: UICtx) { if (ctx !== this.uiCtx) { // UICtx changed — the widget registered on the old context is gone. // Force re-registration on next update(). this.uiCtx = ctx; this.widgetRegistered = false; this.tui = undefined; this.lastStatusText = undefined; } } /** * Called on each new turn (tool_execution_start). * Ages finished agents and clears those that have lingered long enough. */ onTurnStart() { // Age all finished agents for (const [id, age] of this.finishedTurnAge) { this.finishedTurnAge.set(id, age + 1); } // Trigger a widget refresh (will filter out expired agents) this.update(); } /** Ensure the widget update timer is running. */ ensureTimer() { if (!this.widgetInterval) { this.widgetInterval = setInterval(() => this.update(), 80); } } /** Check if a finished agent should still be shown in the widget. */ private shouldShowFinished(agentId: string, status: string): boolean { const age = this.finishedTurnAge.get(agentId) ?? 0; const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1; return age < maxAge; } /** Record an agent as finished (call when agent completes). */ markFinished(agentId: string) { if (!this.finishedTurnAge.has(agentId)) { this.finishedTurnAge.set(agentId, 0); } } /** Render a finished agent line. */ private renderFinishedLine( a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; tags?: string[]; }, theme: Theme ): string { const name = getDisplayName(a.type); const modeLabel = getPromptModeLabel(a.type); const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt); let icon: string; let statusText: string; if (a.status === "completed") { icon = theme.fg("success", "✓"); statusText = ""; } else if (a.status === "steered") { icon = theme.fg("warning", "✓"); statusText = theme.fg("warning", " (turn limit)"); } else if (a.status === "stopped") { icon = theme.fg("dim", "■"); statusText = theme.fg("dim", " stopped"); } else if (a.status === "error") { icon = theme.fg("error", "✗"); const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : ""; statusText = theme.fg("error", ` error${errMsg}`); } else { // aborted icon = theme.fg("error", "✗"); statusText = theme.fg("warning", " aborted"); } const parts: string[] = []; const activity = this.agentActivity.get(a.id); if (a.tags) { parts.push(...a.tags); } if (activity) { parts.push(formatTurns(activity.turnCount, activity.maxTurns)); } if (a.toolUses > 0) { parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`); } parts.push(duration); const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : ""; return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`; } /** * Render the widget content. Called from the registered widget's render() callback, * reading live state each time instead of capturing it in a closure. */ private renderWidget(tui: TUI, theme: Theme): string[] { const allAgents = this.manager.listAgents(); const running = allAgents.filter((a) => a.status === "running"); const queued = allAgents.filter((a) => a.status === "queued"); const finished = allAgents.filter( (a) => a.status !== "running" && a.status !== "queued" && a.completedAt && this.shouldShowFinished(a.id, a.status) ); const hasActive = running.length > 0 || queued.length > 0; const hasFinished = finished.length > 0; // Nothing to show — return empty (widget will be unregistered by update()) if (!(hasActive || hasFinished)) { return []; } const w = tui.terminal.columns; const truncate = (line: string) => truncateToWidth(line, w); const headingColor = hasActive ? "accent" : "dim"; const headingIcon = hasActive ? "●" : "○"; const frame = SPINNER[this.widgetFrame % SPINNER.length]; // Build sections separately for overflow-aware assembly. // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line. const finishedLines: string[] = []; for (const a of finished) { finishedLines.push( truncate( `${theme.fg("dim", "├─")} ${this.renderFinishedLine(a, theme)}` ) ); } const runningLines: string[][] = []; // each entry is [header, activity] for (const a of running) { const name = getDisplayName(a.type); const modeLabel = getPromptModeLabel(a.type); const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : ""; const elapsed = formatMs(Date.now() - a.startedAt); const bg = this.agentActivity.get(a.id); const toolUses = bg?.toolUses ?? a.toolUses; let tokenText = ""; if (bg?.session) { try { tokenText = formatTokens(bg.session.getSessionStats().tokens.total); } catch { /* */ } } const parts: string[] = []; const configTag = formatAgentConfigTag(a.modelName, a.thinkingLevel); if (configTag) { parts.push(configTag); } if (a.tags) { parts.push(...a.tags); } if (bg) { parts.push(formatTurns(bg.turnCount, bg.maxTurns)); } if (toolUses > 0) { parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`); } if (tokenText) { parts.push(tokenText); } parts.push(elapsed); const statsText = parts.join(" · "); const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…"; runningLines.push([ truncate( theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}` ), truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)), ]); } const queuedLine = queued.length > 0 ? truncate( theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}` ) : undefined; // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines). const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0); const lines: string[] = [ truncate( theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents") ), ]; if (totalBody <= maxBody) { // Everything fits — add all lines and fix up connectors for the last item. lines.push(...finishedLines); for (const pair of runningLines) { lines.push(...pair); } if (queuedLine) { lines.push(queuedLine); } // Fix last connector: swap ├─ → └─ and │ → space for activity lines. if (lines.length > 1) { const last = lines.length - 1; lines[last] = lines[last].replace("├─", "└─"); // If last item is a running agent activity line, fix indent of that line // and fix the header line above it. if (runningLines.length > 0 && !queuedLine && last >= 2) { // The last two lines are the last running agent's header + activity. lines[last - 1] = lines[last - 1].replace("├─", "└─"); lines[last] = lines[last].replace("│ ", " "); } } } else { // Overflow — prioritize: running > queued > finished. // Reserve 1 line for overflow indicator. let budget = maxBody - 1; let hiddenRunning = 0; let hiddenFinished = 0; // 1. Running agents (2 lines each) for (const pair of runningLines) { if (budget >= 2) { lines.push(...pair); budget -= 2; } else { hiddenRunning++; } } // 2. Queued line if (queuedLine && budget >= 1) { lines.push(queuedLine); budget--; } // 3. Finished agents for (const fl of finishedLines) { if (budget >= 1) { lines.push(fl); budget--; } else { hiddenFinished++; } } // Overflow summary const overflowParts: string[] = []; if (hiddenRunning > 0) { overflowParts.push(`${hiddenRunning} running`); } if (hiddenFinished > 0) { overflowParts.push(`${hiddenFinished} finished`); } const overflowText = overflowParts.join(", "); lines.push( truncate( theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}` ) ); } return lines; } /** Force an immediate widget update. */ update() { if (!this.uiCtx) { return; } const allAgents = this.manager.listAgents(); // Lightweight existence checks — full categorization happens in renderWidget() let runningCount = 0; let queuedCount = 0; let hasFinished = false; for (const a of allAgents) { if (a.status === "running") { runningCount++; } else if (a.status === "queued") { queuedCount++; } else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; } } const hasActive = runningCount > 0 || queuedCount > 0; if (hasActive) { this.ensureTimer(); } // Nothing to show — clear widget if (!(hasActive || hasFinished)) { if (this.widgetRegistered) { this.uiCtx.setWidget("agents", undefined); this.widgetRegistered = false; this.tui = undefined; } if (this.lastStatusText !== undefined) { this.uiCtx.setStatus("subagents", undefined); this.lastStatusText = undefined; } if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; } // Clean up stale entries for (const [id] of this.finishedTurnAge) { if (!allAgents.some((a) => a.id === id)) { this.finishedTurnAge.delete(id); } } return; } // Status bar — only call setStatus when the text actually changes let newStatusText: string | undefined; if (hasActive) { const statusParts: string[] = []; if (runningCount > 0) { statusParts.push(`${runningCount} running`); } if (queuedCount > 0) { statusParts.push(`${queuedCount} queued`); } const total = runningCount + queuedCount; newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`; if (runningCount === 1) { const runningAgent = allAgents.find((a) => a.status === "running"); const configTag = runningAgent ? formatAgentConfigTag( runningAgent.modelName, runningAgent.thinkingLevel ) : undefined; if (configTag) { newStatusText += ` · ${configTag}`; } } } if (newStatusText !== this.lastStatusText) { this.uiCtx.setStatus("subagents", newStatusText); this.lastStatusText = newStatusText; } this.widgetFrame++; // Register widget callback once; subsequent updates use requestRender() // which re-invokes render() without replacing the component (avoids layout thrashing). if (this.widgetRegistered) { // Widget already registered — just request a re-render of existing components. this.tui?.requestRender(); } else { this.uiCtx.setWidget( "agents", (tui, theme) => { this.tui = tui; return { render: () => this.renderWidget(tui, theme), invalidate: () => { // Theme changed — force re-registration so factory captures fresh theme. this.widgetRegistered = false; this.tui = undefined; }, }; }, { placement: "aboveEditor" } ); this.widgetRegistered = true; } } dispose() { if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; } if (this.uiCtx) { this.uiCtx.setWidget("agents", undefined); this.uiCtx.setStatus("subagents", undefined); } this.widgetRegistered = false; this.tui = undefined; this.lastStatusText = undefined; } }