import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, type Component, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import { boundedPreview, projectJobStatus, type JobStatus } from "./job-status.js"; import { LiveSubagentsWidget } from "./live-widget.js"; import { SubagentManager, type SubagentSessionSnapshot } from "./subagent-manager.js"; import type { WorkState } from "./types.js"; type DashboardTheme = { fg(color: "accent" | "dim" | "error" | "muted" | "success" | "warning", text: string): string; bold(text: string): string; }; type DashboardMode = "list" | "compact" | "full"; type ReturnMode = Exclude; type SessionRecord = { kind: "session"; id: string; session: SubagentSessionSnapshot; status: JobStatus; }; type ListRecord = { kind: "heading"; label: string } | SessionRecord; const renderFingerprint = (sessions: readonly SubagentSessionSnapshot[]): string => JSON.stringify(sessions.map((session) => [projectJobStatus(session, 0), session.cancellable])); const durationText = (durationMs: number): string => { const seconds = Math.floor(Math.max(0, durationMs) / 1_000); return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; }; const workText = (status: JobStatus): string => { const work = status.workState ?? "unknown"; const duration = work === "queued" ? status.queueDurationMs : status.runDurationMs ?? status.queueDurationMs; return `${work}${duration === undefined ? "" : ` ${durationText(duration)}`}`; }; const isActiveWork = (state: WorkState | undefined): boolean => state === "queued" || state === "running" || state === "cancelling"; const canCancel = (session: Readonly): boolean => session.cancellable; const sectionLabel = (record: SessionRecord): string => { if (record.session.state === "opening" || record.session.state === "closing" || isActiveWork(record.status.workState)) return "ACTIVE"; if (record.status.workState === "waiting_for_parent") return "WAITING"; if (record.status.resultReady || (record.status.unreadReportCount ?? 0) > 0 || (record.status.omittedReports ?? 0) > 0 || record.status.queuedFollowUp) return "ATTENTION"; if (record.session.state === "open") return "IDLE OPEN"; return "CLOSED"; }; export interface SubagentsDashboardOptions { sessions: readonly SubagentSessionSnapshot[]; manager: SubagentManager; theme: DashboardTheme; terminalRows(): number; requestRender(): void; notify(message: string): void; close(): void; now?: () => number; } export class SubagentsDashboard implements Component { private sessions: readonly SubagentSessionSnapshot[]; private selected = 0; private mode: DashboardMode = "list"; private listOffset = 0; private fullReturnMode: ReturnMode = "list"; private fullOffset = 0; private cachedWidth: number | undefined; private cachedRows: number | undefined; private cachedLines: string[] | undefined; private renderFingerprint: string; private disposed = false; private readonly now: () => number; constructor(private readonly options: SubagentsDashboardOptions) { this.sessions = options.sessions; this.renderFingerprint = renderFingerprint(options.sessions); this.now = options.now ?? Date.now; } setSessions(sessions: readonly SubagentSessionSnapshot[]): void { if (this.disposed) return; const nextFingerprint = renderFingerprint(sessions); const fingerprintChanged = nextFingerprint !== this.renderFingerprint; const priorSessions = this.visibleSessions(); const priorIndex = this.selected; const selectedId = priorSessions[priorIndex]?.id; const priorMode = this.mode; this.sessions = sessions; this.renderFingerprint = nextFingerprint; const visible = this.visibleSessions(); const preservedIndex = selectedId === undefined ? -1 : visible.findIndex((session) => session.id === selectedId); this.selected = preservedIndex >= 0 ? preservedIndex : Math.min(priorIndex, Math.max(0, visible.length - 1)); if (this.mode === "full" && preservedIndex < 0) { this.mode = "list"; this.fullOffset = 0; } if (fingerprintChanged || selectedId !== visible[this.selected]?.id || priorMode !== this.mode) this.changed(); } handleInput(data: string): void { if (this.disposed) return; if (this.mode === "full") { this.handleFullInput(data); return; } const sessions = this.visibleSessions(); if (matchesKey(data, Key.escape)) { this.dispose(); this.options.close(); return; } if (matchesKey(data, Key.up) && this.selected > 0) this.selected -= 1; else if (matchesKey(data, Key.down) && this.selected < sessions.length - 1) this.selected += 1; else if (matchesKey(data, Key.enter) && sessions[this.selected]) this.mode = this.mode === "list" ? "compact" : "list"; else if (matchesKey(data, "v") && sessions[this.selected]) { this.fullReturnMode = this.mode; this.mode = "full"; this.fullOffset = 0; } else { const selected = sessions[this.selected]; if (!selected || !matchesKey(data, "c") || !canCancel(selected.session)) return; try { void this.options.manager.cancel(selected.id).catch(() => this.actionFailed()); } catch { this.actionFailed(); } return; } this.changed(); } render(width: number): string[] { const safeWidth = Math.max(0, Math.floor(width)); const rows = Math.max(0, Math.floor(this.options.terminalRows())); if (this.cachedWidth === safeWidth && this.cachedRows === rows && this.cachedLines) return this.cachedLines; const line = (value: string): string => truncateToWidth(value, safeWidth); const records = this.records(); const sessions = records.filter((record): record is SessionRecord => record.kind === "session"); this.selected = Math.min(this.selected, Math.max(0, sessions.length - 1)); if (this.mode === "full") { const selected = sessions[this.selected]; if (selected) return this.cache(safeWidth, rows, this.fullFrame(selected, safeWidth, rows)); this.mode = "list"; this.fullOffset = 0; } if (rows === 0) return this.cache(safeWidth, rows, []); const title = line(this.options.theme.bold("Subagents")); if (rows === 1) return this.cache(safeWidth, rows, [title]); const bodyRows = Math.max(0, rows - 2); const selected = sessions[this.selected]; const body = this.mode === "compact" ? this.compactBody(selected, safeWidth, bodyRows) : this.listBody(records, selected?.id, safeWidth, bodyRows); const actions = ["↑↓ select", "enter inspect", "v full", selected && canCancel(selected.session) ? "c cancel" : undefined, "esc close"] .filter((action): action is string => action !== undefined) .join(" · "); return this.cache(safeWidth, rows, [title, ...body.map(line), line(actions)]); } invalidate(): void { if (this.disposed) return; this.cachedWidth = undefined; this.cachedRows = undefined; this.cachedLines = undefined; } dispose(): void { if (this.disposed) return; this.disposed = true; } private cache(width: number, rows: number, lines: string[]): string[] { this.cachedWidth = width; this.cachedRows = rows; this.cachedLines = lines; return lines; } private visibleSessions(): SessionRecord[] { return this.records().filter((record): record is SessionRecord => record.kind === "session"); } private records(): ListRecord[] { const now = this.now(); const sessions: SessionRecord[] = this.sessions.map((session) => ({ kind: "session", id: session.id, session, status: projectJobStatus(session, now), })); const labels = ["ACTIVE", "WAITING", "ATTENTION", "IDLE OPEN", "CLOSED"] as const; return labels.flatMap((label) => { const matching = sessions.filter((record) => sectionLabel(record) === label); return matching.length === 0 ? [] : [{ kind: "heading" as const, label }, ...matching]; }); } private handleFullInput(data: string): void { if (matchesKey(data, Key.up)) this.fullOffset = Math.max(0, this.fullOffset - 1); else if (matchesKey(data, Key.down)) this.fullOffset += 1; else if (matchesKey(data, Key.pageUp)) this.fullOffset = Math.max(0, this.fullOffset - Math.max(1, this.options.terminalRows() - 2)); else if (matchesKey(data, Key.pageDown)) this.fullOffset += Math.max(1, this.options.terminalRows() - 2); else if (matchesKey(data, Key.home)) this.fullOffset = 0; else if (matchesKey(data, Key.end)) this.fullOffset = Number.MAX_SAFE_INTEGER; else if (matchesKey(data, "v") || matchesKey(data, Key.escape)) { this.mode = this.fullReturnMode; this.fullOffset = 0; } else return; this.changed(); } private fullFrame(selected: SessionRecord, width: number, rows: number): string[] { const line = (value: string): string => truncateToWidth(value, width); const title = line(this.options.theme.bold(`Subagent ${selected.status.id} · full view`)); if (rows === 0) return []; if (rows === 1) return [title]; const content = this.fullDetail(selected.status, width); const bodyRows = Math.max(0, rows - 2); const maxOffset = Math.max(0, content.length - bodyRows); this.fullOffset = Math.min(Math.max(0, this.fullOffset), maxOffset); const body = content.slice(this.fullOffset, this.fullOffset + bodyRows); const start = bodyRows === 0 || content.length === 0 ? 0 : this.fullOffset + 1; const end = bodyRows === 0 ? 0 : Math.min(content.length, this.fullOffset + body.length); const footer = `lines ${start}–${end} of ${content.length} · ↑↓ line · PgUp/PgDn page · Home/End · v/esc back`; return [title, ...body.map(line), line(footer)]; } private fullDetail(status: JobStatus, width: number): string[] { const labeledField = (label: string, value: string): string[] => { const prefix = `${label}: `; const wrapped = value.split("\n").flatMap((segment) => wrapTextWithAnsi(segment, Math.max(1, width - prefix.length))); if (prefix.length >= width) return [...wrapTextWithAnsi(prefix, Math.max(1, width)), ...wrapped].map((line) => truncateToWidth(line, width)); const continuation = " ".repeat(prefix.length); return wrapped.map((line, index) => truncateToWidth(`${index === 0 ? prefix : continuation}${line}`, width)); }; return this.detailFields(status).flatMap(([label, value]) => labeledField(label, value)); } private detailFields(status: JobStatus): Array<[string, string]> { const timestamp = (value: number | undefined, absent: string): string => value === undefined ? absent : new Date(value).toISOString(); const duration = (value: number | undefined): string => value === undefined ? "Not recorded" : durationText(value); const activity = status.recentActivity.length === 0 ? "No activity reported yet" : status.recentActivity.map((item) => `${durationText(this.now() - item.timestamp)} ago ${item.summary}`).join("\n"); return [ ["Status", status.id], ["Session", status.sessionState ?? "unknown"], ["Generation", `${status.generationNumber ?? "unknown"} · Work: ${status.workState ?? "unknown"}`], ["Result", status.resultReady ? "ready" : "not ready"], ...(status.resultPreview ? [["Result preview", status.resultPreview] as [string, string]] : []), ["Queue", `queued ${status.queued ? "yes" : "no"} · Follow-up: ${status.queuedFollowUp ? "queued" : "none"} · Barrier: ${status.blockedByResult ? "blocked" : "open"}`], ["Unread reports", `${status.unreadReportCount ?? 0} · Omitted reports: ${status.omittedReports ?? 0}`], ["Pending help", status.pendingHelpQuestion ?? "none"], ["Agent", status.agent], ["Access", status.access], ["Launch model", status.launchModel ?? "model or Pi default"], ["Launch thinking", status.launchThinking ?? "model or Pi default"], ["Reported model", status.reportedModel ?? "Not reported"], ["Created", timestamp(status.createdAt, "Not recorded")], ["Started", timestamp(status.startedAt, "Not started")], ["Finished", timestamp(status.finishedAt, "Not finished")], ["Queue duration", duration(status.queueDurationMs)], ["Run duration", duration(status.runDurationMs)], ["Usage", `input ${status.usage.input}, output ${status.usage.output}, cache read ${status.usage.cacheRead}, cache write ${status.usage.cacheWrite}, cost ${status.usage.cost}, turns ${status.usage.turns}`], ["Recent activity", activity], ]; } private listBody(records: ListRecord[], selectedId: string | undefined, width: number, bodyRows: number): string[] { const selectedRecord = records.findIndex((record) => record.kind === "session" && record.id === selectedId); const maxOffset = Math.max(0, records.length - bodyRows); if (selectedRecord >= 0 && selectedRecord < this.listOffset) this.listOffset = selectedRecord; else if (selectedRecord >= this.listOffset + bodyRows) this.listOffset = selectedRecord - bodyRows + 1; this.listOffset = Math.min(Math.max(0, this.listOffset), maxOffset); const body = records.slice(this.listOffset, this.listOffset + bodyRows).map((record) => record.kind === "heading" ? this.options.theme.fg("dim", record.label) : this.row(record.status, record.id === selectedId, width)); if (body.length === 0 && records.length === 0 && bodyRows > 0) body.push(this.options.theme.fg("dim", "No subagent sessions.")); return body; } private compactBody(selected: SessionRecord | undefined, width: number, bodyRows: number): string[] { if (!selected) return bodyRows > 0 ? [this.options.theme.fg("dim", "No subagent sessions.")] : []; const lines = [ this.row(selected.status, true, width), ...this.detailFields(selected.status).flatMap(([label, value]) => value.split("\n").flatMap((segment, index) => wrapTextWithAnsi(`${index === 0 ? `${label}: ` : " "}${segment}`, Math.max(1, width))), ), ]; if (lines.length <= bodyRows) return lines; if (bodyRows === 0) return []; return [...lines.slice(0, bodyRows - 1), `… ${lines.length - bodyRows + 1} compact lines omitted`]; } private row(status: JobStatus, selected: boolean, width: number): string { const marker = selected ? this.options.theme.fg("accent", "> ") : " "; const writeMarker = status.access === "write" ? "W " : ""; const flags = [ status.resultReady ? "result ready" : undefined, status.queuedFollowUp ? "follow-up queued" : undefined, status.blockedByResult ? "blocked" : undefined, (status.unreadReportCount ?? 0) > 0 ? `unread ${status.unreadReportCount}` : undefined, (status.omittedReports ?? 0) > 0 ? `omitted ${status.omittedReports}` : undefined, ].filter((value): value is string => value !== undefined); const base = `${marker}${status.id} ${writeMarker}${status.sessionState ?? "unknown"} · gen ${status.generationNumber ?? "?"} ${workText(status)}`; const withFlags = flags.length === 0 ? base : `${base} · ${flags.join(" · ")}`; const activity = status.recentActivity.at(-1)?.summary; const withActivity = activity && visibleWidth(`${withFlags} · ${boundedPreview(activity)}`) <= width ? `${withFlags} · ${boundedPreview(activity)}` : withFlags; return truncateToWidth(withActivity, width); } private actionFailed(): void { if (this.disposed) return; this.options.notify("Could not cancel subagent session."); this.setSessions(this.options.manager.list()); } private changed(): void { if (this.disposed) return; this.invalidate(); this.options.requestRender(); } } export function registerSubagentsUi(pi: ExtensionAPI, manager: SubagentManager): () => void { let liveWidget: LiveSubagentsWidget | undefined; let removeWidgetSubscription: (() => void) | undefined; let activeDashboard: { close(): void } | undefined; let cleanedUp = false; const clearWidget = (): void => { removeWidgetSubscription?.(); removeWidgetSubscription = undefined; liveWidget?.dispose(); liveWidget = undefined; }; pi.registerCommand("subagents", { description: "Open the subagent sessions dashboard", handler: async (_args, ctx) => { if (cleanedUp) return; if (ctx.mode !== "tui") { ctx.ui.notify("The subagents dashboard requires interactive mode.", "warning"); return; } activeDashboard?.close(); let unsubscribe: (() => void) | undefined; let component: SubagentsDashboard | undefined; let doneCustom: (() => void) | undefined; let closed = false; const opening = { close: (): void => { if (closed) return; closed = true; component?.dispose(); unsubscribe?.(); unsubscribe = undefined; if (activeDashboard === opening) activeDashboard = undefined; doneCustom?.(); }, }; activeDashboard = opening; try { await ctx.ui.custom((tui, theme, _keybindings, done) => { doneCustom = done; component = new SubagentsDashboard({ sessions: manager.list(), manager, theme, terminalRows: () => tui.terminal.rows, requestRender: () => { if (!closed) tui.requestRender(); }, notify: (message) => { if (!closed) ctx.ui.notify(message, "error"); }, close: opening.close, }); unsubscribe = manager.subscribe((sessions) => { if (!closed) component?.setSessions(sessions); }); return component; }); } finally { opening.close(); } }, }); pi.on("session_start", (_event, ctx) => { activeDashboard?.close(); activeDashboard = undefined; clearWidget(); if (cleanedUp || ctx.mode !== "tui") return; const widget = new LiveSubagentsWidget(); liveWidget = widget; widget.setUi(ctx.ui); let widgetClosed = false; const unsubscribe = manager.subscribe((sessions) => { if (!widgetClosed) widget.setSessions(sessions); }); removeWidgetSubscription = () => { if (widgetClosed) return; widgetClosed = true; unsubscribe(); }; }); return () => { if (cleanedUp) return; cleanedUp = true; activeDashboard?.close(); activeDashboard = undefined; clearWidget(); }; }