/** * Presentation layer for the subagents extension: the status widget and the * custom message renderers. Consumes structured data (widget rows, message * details) and never reaches into orchestration state or module internals — * callers project RunningSubagent/lifecycle state before calling in. */ import { keyHint } from "@earendil-works/pi-coding-agent"; import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { LifecycleProjection } from "./lifecycle.ts"; import type { NestedLifecycleSnapshot } from "./nested-lifecycle.ts"; import { formatNestedLifecycleDrainDetail } from "./status.ts"; import { formatElapsed, formatElapsedDuration, formatElapsedMMSS, } from "./format.ts"; export interface SubagentCustomMessage { content?: unknown; details?: Record; } export interface MessageRenderOptions { expanded?: boolean; } export interface MessageTheme { fg(color: string, text: string): string; bg(color: string, text: string): string; bold(text: string): string; } export interface RenderedMessage { render(width: number): string[]; } // ── Bordered box primitives ── export const ACTIVE_ACCENT = "\x1b[38;2;77;163;255m"; export const OPEN_ACCENT = "\x1b[38;2;214;158;46m"; const RST = "\x1b[0m"; /** * Build a bordered content line: │left right│ * Left content is truncated if needed, right is preserved, padded to fill width. */ export function borderLine(left: string, right: string, width: number, accent = ACTIVE_ACCENT): string { if (width <= 0) return ""; if (width === 1) return `${accent}│${RST}`; // width = total visible chars for the whole line including │ and │ const contentWidth = Math.max(0, width - 2); // space inside the two │ chars const rightVis = visibleWidth(right); // If the status chunk alone is too wide, prefer preserving it in compact form // rather than overflowing the terminal. if (rightVis >= contentWidth) { const truncRight = truncateToWidth(right, contentWidth); const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight)); return `${accent}│${RST}${truncRight}${" ".repeat(rightPad)}${accent}│${RST}`; } const maxLeft = Math.max(0, contentWidth - rightVis); const truncLeft = truncateToWidth(left, maxLeft); const leftVis = visibleWidth(truncLeft); const pad = Math.max(0, contentWidth - leftVis - rightVis); return `${accent}│${RST}${truncLeft}${" ".repeat(pad)}${right}${accent}│${RST}`; } /** * Build the bordered top line: ╭─ Title ──── info ─╮ * All chars are accounted for within `width`. */ export function borderTop(title: string, info: string, width: number, accent = ACTIVE_ACCENT): string { if (width <= 0) return ""; if (width === 1) return `${accent}╭${RST}`; // ╭─ Title ──...── info ─╮ // overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify const inner = Math.max(0, width - 2); // inside ╭ and ╮ const titlePart = `─ ${title} `; const infoPart = ` ${info} ─`; const fillLen = Math.max(0, inner - titlePart.length - infoPart.length); const fill = "─".repeat(fillLen); const content = `${titlePart}${fill}${infoPart}`.slice(0, inner).padEnd(inner, "─"); return `${accent}╭${content}╮${RST}`; } /** * Build the bordered bottom line: ╰──────────────────╯ */ export function borderBottom(width: number, accent = ACTIVE_ACCENT): string { if (width <= 0) return ""; if (width === 1) return `${accent}╰${RST}`; const inner = Math.max(0, width - 2); return `${accent}╰${"─".repeat(inner)}╯${RST}`; } // ── Subagent status widget ── function formatLifecycleWidgetLabel( projection: LifecycleProjection, now: number, ): string { const duration = projection.stateDurationSince == null ? "" : ` ${formatElapsedDuration(now - projection.stateDurationSince)}`; if (projection.kind === "active") return projection.label ? ` active · ${projection.label}${duration} ` : ` active${duration} `; if (projection.kind === "blocked") return ` blocked${duration} `; if (projection.kind === "running") return " running… "; if (projection.kind === "waiting") return ` waiting${duration} `; if (projection.kind === "interrupted") return ` interrupted${duration} `; if (projection.kind === "stalled") return ` stalled${duration} `; // The row is removed immediately after result delivery, so the only visible // terminal handoff label is finalizing. if ( projection.kind === "finalizing" || projection.kind === "completed" || projection.kind === "failed" ) { return " finalizing… "; } return " starting… "; } /** * One widget row: a running subagent reduced to exactly what the widget * renders. The caller projects lifecycle state; this module stays free of * RunningSubagent/registry knowledge. */ export interface SubagentWidgetRow { name: string; agent?: string; startTime: number; runtimePlan?: { modelId: string; thinking: string }; projection: LifecycleProjection; } function formatParentLifecycleStatus(snapshot: NestedLifecycleSnapshot): string | undefined { if (snapshot.state === "completed") return undefined; if (!snapshot.selfSettled && !snapshot.cancellationRequested && snapshot.state !== "draining") { return undefined; } const detail = formatNestedLifecycleDrainDetail(snapshot); if (snapshot.cancellationRequested) { return snapshot.state === "draining" ? `cancelling · draining${detail}` : "cancelled"; } if (snapshot.state === "draining") { return snapshot.selfSettled ? `self-settled · draining${detail}` : `draining${detail}`; } return "self-settled"; } export function renderSubagentWidgetLines( rows: SubagentWidgetRow[], width: number, options: { statusEnabled: boolean; now?: number; parentLifecycle?: NestedLifecycleSnapshot; }, ): string[] { const now = options.now ?? Date.now(); const activeCount = rows.filter(({ projection }) => projection.kind === "active" || projection.kind === "starting" || projection.kind === "running" || projection.kind === "blocked" ).length; const openCount = rows.length - activeCount; const parentStatus = options.statusEnabled && options.parentLifecycle ? formatParentLifecycleStatus(options.parentLifecycle) : undefined; const counts = activeCount > 0 ? openCount > 0 ? `${activeCount} active · ${openCount} open` : `${activeCount} active` : `${openCount} open`; const info = parentStatus ? `${counts} · ${parentStatus}` : counts; const accent = activeCount > 0 ? ACTIVE_ACCENT : OPEN_ACCENT; const lines: string[] = [borderTop("Subagents", info, width, accent)]; for (const { name, agent, startTime, runtimePlan, projection } of rows) { const elapsed = formatElapsedMMSS(startTime, projection.runtimeEndedAt ?? now); const agentTag = agent ? ` (${agent})` : ""; const left = ` ${elapsed} ${name}${agentTag} `; const runtimeTag = runtimePlan ? `${runtimePlan.modelId}|${runtimePlan.thinking} · ` : ""; const right = options.statusEnabled ? ` ${runtimeTag}${formatLifecycleWidgetLabel(projection, now).trim()} ` : ` ${runtimeTag}starting… `; lines.push(borderLine(left, right, width, accent)); } lines.push(borderBottom(width, accent)); return lines; } // ── Custom message renderers ── export function renderSubagentResultMessage( message: SubagentCustomMessage, options: MessageRenderOptions, theme: MessageTheme, ): RenderedMessage | undefined { const details = message.details as any; if (!details) return undefined; return { render(width: number): string[] { const name = details.name ?? "subagent"; const exitCode = details.exitCode ?? 0; const errorMessage = typeof details.errorMessage === "string" ? details.errorMessage : ""; const failed = exitCode !== 0 || !!errorMessage; const elapsed = details.elapsed != null ? formatElapsed(details.elapsed) : "?"; const bgFn = failed ? (text: string) => theme.bg("toolErrorBg", text) : (text: string) => theme.bg("toolSuccessBg", text); const icon = failed ? theme.fg("error", "✗") : theme.fg("success", "✓"); const status = details.status === "cancelled" ? "cancelled" : errorMessage ? "failed (provider/agent error)" : failed ? `failed (exit ${exitCode})` : "completed"; const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : ""; const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`; // Display body: the structured summary carried in details. Messages // without one fall back to the raw model-facing content as-is. const rawContent = typeof message.content === "string" ? message.content : ""; const summary = typeof details.summary === "string" ? details.summary : rawContent; // Build content for the box const contentLines = [header]; if (options.expanded) { // Full view: complete Completion summary if (summary) { for (const line of summary.split("\n")) { contentLines.push(line.slice(0, width - 6)); } } } else { // Collapsed: preview + expand hint if (summary) { const previewLines = summary.split("\n").slice(0, 5); for (const line of previewLines) { contentLines.push(theme.fg("dim", line.slice(0, width - 6))); } const totalLines = summary.split("\n").length; if (totalLines > 5) { contentLines.push(theme.fg("muted", `… ${totalLines - 5} more lines`)); } } contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand"))); } // Render via Box for background + padding, with blank line above for separation const box = new Box(1, 1, bgFn); box.addChild(new Text(contentLines.join("\n"), 0, 0)); return ["", ...box.render(width)]; }, }; } export function renderSubagentStatusMessage( message: SubagentCustomMessage, options: MessageRenderOptions, theme: MessageTheme, ): RenderedMessage | undefined { const details = message.details as any; const lines = Array.isArray(details?.lines) ? details.lines : []; const overflow = typeof details?.overflow === "number" ? details.overflow : 0; if (lines.length === 0 && overflow === 0) return undefined; return { render(width: number): string[] { const lineWidth = Math.max(0, width - 6); const contentLines = [ `${theme.fg("accent", "•")} ${theme.fg("toolTitle", theme.bold("Subagent status"))}`, ...lines.map((line: string) => theme.fg("dim", truncateToWidth(line, lineWidth))), ]; if (overflow > 0) { contentLines.push(theme.fg("muted", `+${overflow} more running.`)); } if (!options.expanded) { contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand"))); } const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text)); box.addChild(new Text(contentLines.join("\n"), 0, 0)); return ["", ...box.render(width)]; }, }; }