import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import type { AgentRecord } from "../agents/types"; import type { AgentManager } from "../runtime/agentManager"; import { createLiveTranscriptEmitter } from "./liveTranscriptEmitter"; import { buildLiveTranscriptPayload } from "./liveTranscriptPayload"; const STATUS_KEY = "pi-subagents"; const WIDGET_KEY = "pi-subagents-progress"; const LIVE_TRANSCRIPTS_WIDGET_KEY = "pi-subagents-live-transcripts"; const MAX_VISIBLE_ACTIVE_AGENTS = 5; const ACTIVE_AGENT_STATUSES = new Set(["queued", "starting", "running"]); const RUNNING_AGENT_STATUSES = new Set(["starting", "running"]); const PREPARING_MESSAGE = "preparing delegation"; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const SPINNER_INTERVAL_MS = 80; /** * Subscribes the current session UI to live agent-progress updates. * * @param mode - Current Pi run mode. Live transcript data (for GUI subagent-detail * tabs) is only emitted in "rpc" mode; in "tui" mode the raw JSON payload must * not appear as visible widget text in the terminal. */ export function bindAgentProgressUi( manager: AgentManager, ui: ExtensionUIContext, mode: ExtensionContext["mode"], ): () => void { let latestRecords: AgentRecord[] = []; let spinnerFrameIndex = 0; let spinnerInterval: ReturnType | undefined; let hadActiveAgents = false; // Live transcripts are time-throttled (latest-value-wins) and byte-budgeted // before serialization so high-frequency session changes cannot amplify into // unbounded parent-process allocations (2026-08-16 OOM incident). const liveTranscriptEmitter = mode === "rpc" ? createLiveTranscriptEmitter(ui) : undefined; const stopSpinner = () => { if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = undefined; } }; const syncSpinner = () => { const hasRunningAgents = latestRecords.some((record) => RUNNING_AGENT_STATUSES.has(record.status)); if (!hasRunningAgents) { stopSpinner(); spinnerFrameIndex = 0; return; } if (spinnerInterval) { return; } spinnerInterval = setInterval(() => { spinnerFrameIndex = (spinnerFrameIndex + 1) % SPINNER_FRAMES.length; renderAgentProgress(ui, latestRecords, spinnerFrameIndex); }, SPINNER_INTERVAL_MS); }; const unsubscribe = manager.subscribe((records) => { latestRecords = records; syncSpinner(); const hasActiveAgents = records.some((record) => ACTIVE_AGENT_STATUSES.has(record.status)); if (!hasActiveAgents) { clearAgentProgress(ui, { restoreWorkingVisibility: hadActiveAgents }); // Emitting only active children: a snapshot with no active child clears // the widget immediately (see liveTranscriptEmitter.push). liveTranscriptEmitter?.push(records); hadActiveAgents = false; return; } hadActiveAgents = true; renderAgentProgress(ui, latestRecords, spinnerFrameIndex); // Only emit the structured JSON payload in RPC mode where a GUI consumer // is present to parse it. In TUI mode the raw JSON would render as // visible widget text in the terminal, which is not useful. if (mode === "rpc") { liveTranscriptEmitter?.push(records); } }); return () => { stopSpinner(); unsubscribe(); liveTranscriptEmitter?.dispose(); clearAgentProgress(ui, { restoreWorkingVisibility: hadActiveAgents }); }; } /** * Renders live footer and widget updates for active sub-agents. */ export function renderAgentProgress(ui: ExtensionUIContext, records: AgentRecord[], spinnerFrameIndex = 0): void { const activeRecords = records.filter((record) => ACTIVE_AGENT_STATUSES.has(record.status)); if (activeRecords.length === 0) { clearAgentProgress(ui); return; } const runningCount = activeRecords.filter((record) => record.status === "running").length; const startingCount = activeRecords.filter((record) => record.status === "starting").length; const queuedCount = activeRecords.filter((record) => record.status === "queued").length; const statusParts = [ runningCount > 0 ? `${runningCount} running` : undefined, startingCount > 0 ? `${startingCount} starting` : undefined, queuedCount > 0 ? `${queuedCount} queued` : undefined, ].filter((value): value is string => Boolean(value)); const statusSummary = statusParts.join(" · "); ui.setStatus(STATUS_KEY, ui.theme.fg("accent", `🤖 ${statusSummary}`)); ui.setWidget(WIDGET_KEY, buildWidgetLines(ui, activeRecords, spinnerFrameIndex)); ui.setWorkingMessage(); ui.setWorkingVisible(false); } export function renderPendingDelegation(ui: ExtensionUIContext, agentNames: string[]): void { const lines = [ui.theme.fg("accent", "Sub-agents")]; for (const agentName of agentNames.slice(0, MAX_VISIBLE_ACTIVE_AGENTS)) { lines.push(`${ui.theme.fg("warning", "…")} ${agentName} · preparing`); } if (agentNames.length > MAX_VISIBLE_ACTIVE_AGENTS) { lines.push(ui.theme.fg("muted", `… ${agentNames.length - MAX_VISIBLE_ACTIVE_AGENTS} more`)); } ui.setStatus(STATUS_KEY, ui.theme.fg("warning", `🤖 ${PREPARING_MESSAGE}`)); ui.setWidget(WIDGET_KEY, lines); ui.setWorkingMessage(); ui.setWorkingVisible(false); } export function clearAgentProgress(ui: ExtensionUIContext, options: { restoreWorkingVisibility?: boolean } = {}): void { ui.setStatus(STATUS_KEY, undefined); ui.setWidget(WIDGET_KEY, undefined); ui.setWorkingMessage(); if (options.restoreWorkingVisibility ?? true) { ui.setWorkingVisible(true); } } /** * Publishes a bounded live-transcript snapshot through the dedicated widget * channel. Applies emit-side budgets (per-message/per-child/aggregate bytes) * before serialization; emits undefined when no active child remains. */ export function renderAgentLiveTranscripts(ui: ExtensionUIContext, records: AgentRecord[]): void { const { payload } = buildLiveTranscriptPayload(records); ui.setWidget(LIVE_TRANSCRIPTS_WIDGET_KEY, payload ? [JSON.stringify(payload)] : undefined); } function buildWidgetLines(ui: ExtensionUIContext, records: AgentRecord[], spinnerFrameIndex: number): string[] { const visibleRecords = records.slice(0, MAX_VISIBLE_ACTIVE_AGENTS); const hiddenCount = records.length - visibleRecords.length; const lines = [ui.theme.fg("accent", "Sub-agents")]; for (const record of visibleRecords) { lines.push(formatAgentLine(ui, record, spinnerFrameIndex)); } if (hiddenCount > 0) { lines.push(ui.theme.fg("muted", `… ${hiddenCount} more`)); } return lines; } function formatAgentLine(ui: ExtensionUIContext, record: AgentRecord, spinnerFrameIndex: number): string { const spinnerFrame = SPINNER_FRAMES[spinnerFrameIndex] ?? "⠋"; const statusSymbol = RUNNING_AGENT_STATUSES.has(record.status) ? ui.theme.fg("accent", spinnerFrame) : ui.theme.fg("muted", "…"); const modeLabel = record.mode === "background" ? "bg" : "fg"; const sessionSuffix = record.sessionId ? ` · ${record.sessionId}` : ""; return `${statusSymbol} ${record.agentName} · ${record.status} · ${modeLabel} · ${record.id}${sessionSuffix}`; }