/** * Agent Dashboard Component * * Full-window detailed viewer for subagent results. * `/agents` opens the latest run in the main window (non-overlay). * Ctrl+Shift+Left / Ctrl+Shift+Right navigates runs. * Read-only, no text input. */ import * as fs from "node:fs"; import * as path from "node:path"; import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui"; import type { CustomMessageEntry, SessionEntry } from "@earendil-works/pi-coding-agent"; import { type Details, type SingleResult, type SubagentState, type ToolCallSummary, type AsyncJobState, SLASH_RESULT_TYPE, } from "../shared/types.ts"; import { getSlashRenderableSnapshot, type SlashMessageDetails } from "../slash/slash-live-state.ts"; import { formatDuration, formatTokens, formatModelThinking, shortenPath } from "../shared/formatters.ts"; import { extractTextFromContent, getSingleResultOutput } from "../shared/utils.ts"; import { parentSessionIndexPath } from "../runs/background/durable-store.ts"; // --------------------------------------------------------------------------- // Dashboard data model // --------------------------------------------------------------------------- export type DashboardStatus = "running" | "complete" | "failed" | "paused" | "queued"; export interface DashboardTimelineEvent { timestamp?: number; text: string; } export type TimelineLogKind = "tool_start" | "tool_end" | "phase" | "speech" | "bash_output" | "error" | "marker"; export interface TimelineLogEntry { timestamp: number; kind: TimelineLogKind; tool?: string; label?: string; text?: string; args?: string; path?: string; status?: "success" | "error"; isLive?: boolean; } export interface DashboardTouchedFile { action: string; path: string; } export interface DashboardChild { agent: string; task?: string; status: DashboardStatus; summary?: string; error?: string; output?: string; outputPath?: string; sessionFile?: string; toolCalls?: ToolCallSummary[]; durationMs?: number; startedAt?: number; endedAt?: number; tokens?: number; cost?: number; phase?: string; objectiveLines?: string[]; timelineLog?: TimelineLogEntry[]; timeline?: DashboardTimelineEvent[]; touchedFiles?: DashboardTouchedFile[]; commandOutput?: string[]; currentTool?: string; currentToolArgs?: string; currentToolStartedAt?: number; currentPath?: string; activityState?: string; lastActivityAt?: number; turnCount?: number; toolCount?: number; recentTools?: Array<{ tool: string; args: string; endMs: number }>; recentOutput?: string[]; } export interface DashboardRun { id: string; timestamp: number; mode: "single" | "parallel" | "chain"; status: DashboardStatus; title: string; summary: string; details?: Details; children: DashboardChild[]; outputPath?: string; sessionFile?: string; artifactPath?: string; durationMs?: number; totalTools?: number; totalTokens?: number; model?: string; context?: "fresh" | "fork"; } // --------------------------------------------------------------------------- // Type guards for session entries // --------------------------------------------------------------------------- function isCustomMessageEntry( entry: SessionEntry, ): entry is CustomMessageEntry { return (entry as { type?: string }).type === "custom_message"; } function isSlashResultEntry( entry: SessionEntry, ): entry is CustomMessageEntry & { customType: typeof SLASH_RESULT_TYPE } { return ( isCustomMessageEntry(entry) && entry.customType === SLASH_RESULT_TYPE && entry.details !== null && typeof entry.details === "object" && typeof (entry.details as SlashMessageDetails).requestId === "string" && (entry.details as SlashMessageDetails).result !== undefined ); } // --------------------------------------------------------------------------- // Run extraction // --------------------------------------------------------------------------- function resultStatus(result: SingleResult): DashboardStatus { if (result.progress?.status === "pending") return "queued"; if (result.progress?.status === "running") return "running"; if (result.interrupted || result.detached) return "paused"; if (result.progress?.status === "failed" || result.exitCode !== 0) return "failed"; return "complete"; } function singleRunStatus(results: SingleResult[]): DashboardStatus { if (results.length === 0) return "complete"; if (results.some((r) => r.progress?.status === "running")) return "running"; if (results.some((r) => r.interrupted || r.detached)) return "paused"; if (results.every((r) => r.exitCode === 0)) return "complete"; return "failed"; } function extractChild(r: SingleResult): DashboardChild { const child: DashboardChild = { agent: r.agent, task: r.task, status: resultStatus(r), error: r.error, outputPath: r.savedOutputPath ?? r.artifactPaths?.outputPath, sessionFile: r.sessionFile, toolCalls: r.toolCalls, durationMs: r.progress?.durationMs ?? r.progressSummary?.durationMs, tokens: r.progress?.tokens ?? r.progressSummary?.tokens ?? r.usage?.input ?? 0, cost: r.usage?.cost, currentTool: r.progress?.currentTool, currentToolArgs: r.progress?.currentToolArgs, currentToolStartedAt: r.progress?.currentToolStartedAt, currentPath: r.progress?.currentPath, activityState: r.progress?.activityState, lastActivityAt: r.progress?.lastActivityAt, turnCount: r.progress?.turnCount, toolCount: r.progress?.toolCount, recentTools: r.progress?.recentTools, recentOutput: r.progress?.recentOutput, }; const output = r.finalOutput ?? getSingleResultOutput(r); if (output) child.output = output; child.objectiveLines = extractObjectiveLines(r.task); child.phase = inferPhase(child); // Summary - first meaningful line of output or error if (r.error) { child.summary = r.error.slice(0, 200); } else if (output) { const firstLine = output.split("\n").find((l) => l.trim())?.trim() ?? ""; child.summary = firstLine.slice(0, 200); } else if (resultStatus(r) === "complete") { child.summary = "(no text output)"; } // Build timelineLog from toolCalls for non-async runs if (r.toolCalls?.length) { const baseTime = r.progress?.lastActivityAt ?? Date.now(); child.timelineLog = r.toolCalls.map((tc, i) => ({ timestamp: baseTime - (r.toolCalls!.length - i) * 1000, kind: "tool_end" as TimelineLogKind, tool: tc.text.split(/\s+/)[0] ?? "tool", status: "success" as const, text: tc.text, })); } return child; } function aggregateTokens(results: SingleResult[]): number { let total = 0; for (const r of results) { total += r.progress?.tokens ?? r.progressSummary?.tokens ?? r.usage?.input ?? 0; } return total; } function aggregateTools(results: SingleResult[]): number { let total = 0; for (const r of results) { total += r.progress?.toolCount ?? r.progressSummary?.toolCount ?? 0; } return total; } function aggregateDuration( results: SingleResult[], mode: "single" | "parallel" | "chain", ): number { if (mode === "parallel") { let max = 0; for (const r of results) { max = Math.max(max, r.progress?.durationMs ?? r.progressSummary?.durationMs ?? 0); } return max; } let total = 0; for (const r of results) { total += r.progress?.durationMs ?? r.progressSummary?.durationMs ?? 0; } return total; } function extractRunFromSlashEntry( entry: CustomMessageEntry, ): DashboardRun | null { const details = entry.details as SlashMessageDetails | undefined; if (!details?.result?.details) return null; const snapshot = getSlashRenderableSnapshot(details); if (!snapshot.result.details) return null; return extractRunFromDetails(snapshot.result.details, details.requestId, new Date(entry.timestamp).getTime()); } function extractRunFromToolResultEntry(entry: SessionEntry): DashboardRun | null { const e = entry as { timestamp?: string | number; message?: { role?: string; toolName?: string; content?: unknown; details?: Details & { runId?: string; asyncId?: string; asyncDir?: string }; }; }; const message = e.message; if (message?.role !== "toolResult" || message.toolName !== "subagent") return null; const d = message.details; if (!d || d.mode === "management") return null; const timestamp = typeof e.timestamp === "string" ? new Date(e.timestamp).getTime() : typeof e.timestamp === "number" ? e.timestamp : Date.now(); if (d.results?.length) { return extractRunFromDetails(d, d.runId ?? d.asyncId ?? `tool-${timestamp}`, timestamp); } const text = extractTextFromContent(message.content); const runMatch = text.match(/^Run:\s*(.+)$/m); const stateMatch = text.match(/^State:\s*(queued|running|complete|failed|paused)$/m); const modeMatch = text.match(/^Mode:\s*(single|parallel|chain)$/m); const progressMatch = text.match(/^Progress:\s*(.+)$/m); const outputMatch = text.match(/^Output:\s*(.+)$/m); const dirMatch = text.match(/^Dir:\s*(.+)$/m); const asyncLaunch = text.match(/^Async(?:\s+(parallel|chain))?:\s*(.+?)\s*\[([0-9a-f-]{8,})\]/m); const id = d.runId ?? d.asyncId ?? runMatch?.[1]?.trim() ?? asyncLaunch?.[3]?.trim(); if (!id) return null; const mode = (modeMatch?.[1] ?? asyncLaunch?.[1] ?? d.mode) as "single" | "parallel" | "chain"; const status = (stateMatch?.[1] ?? (text.startsWith("Async") ? "running" : "complete")) as DashboardStatus; const title = asyncLaunch?.[2]?.trim() || mode; const children = Array.from(text.matchAll(/^(?:Agent \d+\/\d+|Step \d+(?:\/\d+)?):\s*([^\s]+)\s+(running|complete|completed|failed|paused|queued|pending)\b([^\n]*)/gm)).map((match) => ({ agent: match[1]!, status: (match[2] === "completed" ? "complete" : match[2] === "pending" ? "queued" : match[2]) as DashboardStatus, summary: match[3]?.trim(), error: match[3]?.includes("error:") ? match[3].split("error:").slice(1).join("error:").trim() : undefined, })); if (children.length === 0 && asyncLaunch?.[2]) { const agents = splitAgentList(asyncLaunch[2]); for (const [index, agent] of agents.entries()) { children.push({ agent, status: index === 0 ? status : "queued" }); } } const asyncDir = d.asyncDir ?? dirMatch?.[1]?.trim(); const asyncRun = asyncDir ? extractRunFromAsyncDir(id, asyncDir, timestamp, mode, title, status, d.context) : null; if (asyncRun) return asyncRun; return { id, timestamp, mode, status, title, summary: progressMatch?.[1]?.trim() || text.split("\n").find((line) => line.trim())?.trim() || title, details: d, children: children.length ? children : [{ agent: title, status, summary: progressMatch?.[1]?.trim() }], outputPath: outputMatch?.[1]?.trim(), }; } function extractRunFromDetails(d: Details, id: string, timestamp: number): DashboardRun | null { const results = d.results ?? []; const mode = d.mode === "management" ? "single" : d.mode; if (d.mode === "management") return null; if (mode === "single" && results.length === 1) { const r = results[0]!; const child = extractChild(r); return { id, timestamp, mode: "single", status: singleRunStatus(results), title: r.agent, summary: r.task ?? child.summary ?? "", details: d, children: [child], outputPath: child.outputPath, sessionFile: child.sessionFile, artifactPath: r.artifactPaths?.outputPath, durationMs: child.durationMs, totalTools: r.progress?.toolCount ?? r.progressSummary?.toolCount, totalTokens: child.tokens, model: r.model, context: d.context, }; } const children = results.map(extractChild); const isRunning = children.some((c) => c.status === "running"); return { id, timestamp, mode, status: isRunning ? "running" : singleRunStatus(results), title: mode, summary: `${mode}: ${results.map((r) => r.agent).join("+")}`, details: d, children, durationMs: aggregateDuration(results, mode), totalTools: aggregateTools(results), totalTokens: aggregateTokens(results), model: results[0]?.model, context: d.context, }; } function readTextFileIfExists(filePath: string | undefined, maxBytes = 200_000): string | undefined { if (!filePath) return undefined; try { if (!fs.existsSync(filePath)) return undefined; const stat = fs.statSync(filePath); const start = stat.size > maxBytes ? stat.size - maxBytes : 0; const fd = fs.openSync(filePath, "r"); try { const buffer = Buffer.alloc(Math.min(maxBytes, stat.size)); fs.readSync(fd, buffer, 0, buffer.length, start); const prefix = start > 0 ? "… output truncated to last 200KB …\n" : ""; return prefix + buffer.toString("utf-8"); } finally { fs.closeSync(fd); } } catch { return undefined; } } function extractFinalReportFromLog(log: string): string { const marker = "\n---\n"; const markerIndex = log.lastIndexOf(marker); const text = markerIndex >= 0 ? log.slice(markerIndex + marker.length) : log; return text.trim(); } function safeJsonParse(line: string): Record | undefined { try { const parsed = JSON.parse(line) as unknown; return parsed && typeof parsed === "object" ? parsed as Record : undefined; } catch { return undefined; } } const jsonlCache = new Map[] }>(); function readRecentJsonl(filePath: string | undefined, maxBytes = 12_000_000): Record[] { if (!filePath) return []; try { const stat = fs.statSync(filePath); const cached = jsonlCache.get(filePath); if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.records; const raw = readTextFileIfExists(filePath, maxBytes); if (!raw) return []; const records = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map(safeJsonParse).filter((v): v is Record => Boolean(v)); jsonlCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, records }); if (jsonlCache.size > 12) { const firstKey = jsonlCache.keys().next().value; if (firstKey) jsonlCache.delete(firstKey); } return records; } catch { return []; } } function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? value as Record : undefined; } function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function asNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function formatClock(timestamp?: number): string { if (!timestamp) return ""; const d = new Date(timestamp); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`; } function compactWhitespace(text: string): string { return text.replace(/\s+/g, " ").trim(); } function extractToolPath(tool: string, args: Record | undefined): string | undefined { const pathValue = asString(args?.path) ?? asString(args?.filePath) ?? asString(args?.cwd); if (pathValue) return pathValue; if (tool === "bash") { const command = asString(args?.command); const cdMatch = command?.match(/(?:^|&&|;)\s*cd\s+([^;&|]+)/); return cdMatch?.[1]?.replace(/^['"]|['"]$/g, ""); } return undefined; } function summarizeToolArgs(tool: string, args: Record | undefined): string { if (tool === "bash") return truncLine(compactWhitespace(asString(args?.command) ?? ""), 120); const file = extractToolPath(tool, args); if (file) return shortenPath(file); return truncLine(JSON.stringify(args ?? {}), 120); } function extractToolResultText(result: Record | undefined): string { const content = result?.content; if (!Array.isArray(content)) return ""; return content.map((item) => asString(asRecord(item)?.text) ?? "").filter(Boolean).join("\n").trim(); } function isToolResultError(result: Record | undefined): boolean { if (!result) return false; if (result.isError === true) return true; const exitCode = asNumber(result.exitCode); return typeof exitCode === "number" && exitCode !== 0; } function tailTextLines(text: string, maxLines: number): string[] { return text.split(/\r?\n/).filter((line) => line.trim()).slice(-maxLines); } function addTouchedFile(files: DashboardTouchedFile[], seen: Set, action: string, filePath?: string): void { if (!filePath) return; const key = `${action}\0${filePath}`; if (seen.has(key)) return; seen.add(key); files.push({ action, path: filePath }); } function actionForTool(tool: string): string | undefined { if (["read", "fetch_content"].includes(tool)) return "read"; if (["write"].includes(tool)) return "write"; if (["edit"].includes(tool)) return "edit"; if (["ls", "rg", "find"].includes(tool)) return "inspect"; if (tool === "bash") return "bash"; return undefined; } function extractLiveDetailsFromEvents(asyncDir: string, index: number): Pick { const events = readRecentJsonl(path.join(asyncDir, "events.jsonl")); const timeline: DashboardTimelineEvent[] = []; const timelineLog: TimelineLogEntry[] = []; const touchedFiles: DashboardTouchedFile[] = []; const seenFiles = new Set(); let latestBashOutput: string[] | undefined; let cost = 0; let currentPhase: string | undefined; for (const event of events) { if (asNumber(event.subagentStepIndex) !== index) continue; const type = asString(event.type); const timestamp = asNumber(event.observedAt) ?? asNumber(asRecord(event.message)?.timestamp) ?? Date.now(); if (type === "tool_execution_start") { const tool = asString(event.toolName) ?? "tool"; const args = asRecord(event.args); const action = actionForTool(tool); const filePath = extractToolPath(tool, args); if (action) addTouchedFile(touchedFiles, seenFiles, action, filePath); // Phase detection const newPhase = phaseForTool(tool, currentPhase); if (newPhase && newPhase !== currentPhase) { currentPhase = newPhase; timelineLog.push({ timestamp, kind: "phase", label: newPhase }); } timeline.push({ timestamp, text: `→ ${tool} ${summarizeToolArgs(tool, args)}`.trim() }); timelineLog.push({ timestamp, kind: "tool_start", tool, args: summarizeToolArgs(tool, args), path: filePath }); } else if (type === "tool_execution_end") { const tool = asString(event.toolName) ?? "tool"; const result = asRecord(event.result); const resultText = extractToolResultText(result); const firstLine = resultText.split(/\r?\n/).find((line) => line.trim())?.trim(); const isError = isToolResultError(result); timeline.push({ timestamp, text: `${isError ? "✗" : "✓"} ${tool}${firstLine ? ` — ${truncLine(firstLine, 90)}` : ""}` }); timelineLog.push({ timestamp, kind: "tool_end", tool, status: isError ? "error" : "success", text: firstLine ? truncLine(firstLine, 90) : undefined, }); // Inline bash output if (tool === "bash" && resultText) { latestBashOutput = tailTextLines(resultText, 8); const shortOut = tailTextLines(resultText, 2).join("\n"); if (shortOut) { timelineLog.push({ timestamp, kind: "bash_output", text: shortOut }); } } } else if (type === "message_update") { const update = asRecord(event.assistantMessageEvent); if (asString(update?.type) === "text_end") { const content = compactWhitespace(asString(update?.content) ?? ""); if (content) { timeline.push({ timestamp, text: `said: ${truncLine(content, 120)}` }); timelineLog.push({ timestamp, kind: "speech", text: content }); } } } else if (type === "message_end") { const message = asRecord(event.message); if (asString(message?.role) === "assistant") { const usage = asRecord(message?.usage); const usageCost = asNumber(asRecord(usage?.cost)?.total) ?? asNumber(usage?.cost); if (usageCost) cost += usageCost; } } } return { timelineLog, timeline: timeline.slice(-20), touchedFiles: touchedFiles.slice(-30), commandOutput: latestBashOutput, cost: cost > 0 ? cost : undefined, }; } function extractTimelineFromSession(sessionFile: string): TimelineLogEntry[] { const raw = readTextFileIfExists(sessionFile, 5_000_000); if (!raw) return []; const lines = raw.split(/\r?\n/); const timeline: TimelineLogEntry[] = []; let currentPhase: string | undefined; for (const line of lines) { if (!line.trim()) continue; let entry: Record; try { entry = JSON.parse(line) as Record; } catch { continue; } if (asString(entry.type) !== "message") continue; const msg = asRecord(entry.message); if (!msg) continue; const ts = asNumber(msg.timestamp) ?? asNumber(entry.timestamp) ?? 0; const role = asString(msg.role); const content = msg.content as Array> | undefined; if (!Array.isArray(content)) continue; if (role === "assistant") { for (const part of content) { const pType = asString(part.type); if (pType === "toolCall") { const tool = asString(part.name) ?? "tool"; const args = asRecord(part.arguments); const filePath = extractToolPath(tool, args); const newPhase = phaseForTool(tool, currentPhase); if (newPhase && newPhase !== currentPhase) { currentPhase = newPhase; timeline.push({ timestamp: ts, kind: "phase", label: newPhase }); } timeline.push({ timestamp: ts, kind: "tool_start", tool, args: summarizeToolArgs(tool, args), path: filePath }); } else if (pType === "text") { const text = compactWhitespace(asString(part.text) ?? ""); if (text) timeline.push({ timestamp: ts, kind: "speech", text }); } } } else if (role === "toolResult") { const tool = asString(msg.toolName) ?? "tool"; const isError = isToolResultError(msg); const resultText = extractToolResultText(msg); const firstLine = resultText.split(/\r?\n/).find((l) => l.trim())?.trim(); timeline.push({ timestamp: ts, kind: "tool_end", tool, status: isError ? "error" : "success", text: firstLine ? truncLine(firstLine, 90) : undefined, }); if (tool === "bash" && resultText) { const short = tailTextLines(resultText, 2).join("\n"); if (short) timeline.push({ timestamp: ts, kind: "bash_output", text: short }); } } } return timeline; } function extractObjectiveLines(task?: string): string[] | undefined { if (!task) return undefined; const cleaned = task.replace(/^Task:\s*/i, "").trim(); const markers = cleaned.match(/(?:Requirements?|Tasks?):\s*([^]+?)(?:\b(?:Run|Return|Keep edits|Project):|$)/i)?.[1]; const source = markers?.trim() || cleaned; const lines = source .split(/(?:\n+|;\s+|\.\s+|\b(?=Read\b|Implement\b|Add\b|Run\b|Return\b|Keep\b))/) .map((line) => line.replace(/^[-*]\s*/, "").trim()) .filter((line) => line.length > 0 && line.length < 180) .slice(0, 6); return lines.length ? lines : undefined; } function phaseForTool(tool: string, prevPhase?: string): string | undefined { if (["read", "fetch_content", "ls", "rg", "find"].includes(tool)) return "exploring"; if (tool === "edit" || tool === "write") return "editing"; if (tool === "bash") return "testing"; return prevPhase; } function inferPhase(child: Pick): string | undefined { if (child.status === "queued") return "queued"; if (child.status === "complete") return "complete"; if (child.status === "failed") return "failed"; const tools = [child.currentTool, ...(child.recentTools ?? []).slice(-5).map((t) => t.tool), ...(child.toolCalls ?? []).slice(-5).map((t) => t.text.split(/\s+/)[0])].filter(Boolean) as string[]; const joined = tools.join(" ").toLowerCase(); if (/bash|npm|test|lint|build|pytest|cargo|go test/.test(joined)) return "testing"; if (/edit|write|apply_patch/.test(joined)) return "editing"; if (/read|ls|rg|grep|find|fetch/.test(joined)) return "exploring"; if (child.output) return "finalizing"; return child.status === "running" ? "working" : undefined; } function readAsyncStepOutput(asyncDir: string, index: number): { output?: string; outputPath?: string } { const finalPath = path.join(asyncDir, `final-${index}.md`); const final = readTextFileIfExists(finalPath); if (final) return { output: final.trim(), outputPath: finalPath }; const outputPath = path.join(asyncDir, `output-${index}.log`); const raw = readTextFileIfExists(outputPath); if (!raw) return {}; const output = extractFinalReportFromLog(raw); return { output: output || raw.trim(), outputPath }; } function normalizeDashboardStatus(status: unknown, fallback: DashboardStatus): DashboardStatus { if (status === "completed") return "complete"; if (status === "pending") return "queued"; if (status === "running" || status === "complete" || status === "failed" || status === "paused" || status === "queued") return status; return fallback; } function extractRunFromAsyncDir( id: string, asyncDir: string, fallbackTimestamp: number, fallbackMode: "single" | "parallel" | "chain", fallbackTitle: string, fallbackStatus: DashboardStatus, context?: "fresh" | "fork", ): DashboardRun | null { const statusRaw = readTextFileIfExists(path.join(asyncDir, "status.json")); if (!statusRaw) return null; let statusData: Record; try { statusData = JSON.parse(statusRaw) as Record; } catch { return null; } const steps = Array.isArray(statusData.steps) ? statusData.steps as Array> : []; if (steps.length === 0) return null; const mode = (statusData.mode === "single" || statusData.mode === "parallel" || statusData.mode === "chain") ? statusData.mode : fallbackMode; const status = normalizeDashboardStatus(statusData.state, fallbackStatus); const children = steps.map((step, fallbackIndex) => { const stepIndex = typeof step.index === "number" ? step.index : fallbackIndex; const childStatus = normalizeDashboardStatus(step.status, status); const completed = childStatus === "complete" || childStatus === "failed" || childStatus === "paused"; const saved = completed ? readAsyncStepOutput(asyncDir, stepIndex) : {}; const liveDetails = extractLiveDetailsFromEvents(asyncDir, stepIndex); const output = saved.output ?? (typeof step.output === "string" ? step.output : undefined); const child: DashboardChild = { agent: typeof step.agent === "string" ? step.agent : "unknown", task: typeof step.task === "string" ? step.task : undefined, status: childStatus, error: typeof step.error === "string" ? step.error : undefined, output, outputPath: saved.outputPath, currentTool: typeof step.currentTool === "string" ? step.currentTool : undefined, currentToolArgs: typeof step.currentToolArgs === "string" ? step.currentToolArgs : undefined, currentToolStartedAt: typeof step.currentToolStartedAt === "number" ? step.currentToolStartedAt : undefined, currentPath: typeof step.currentPath === "string" ? step.currentPath : undefined, activityState: typeof step.activityState === "string" ? step.activityState : undefined, lastActivityAt: typeof step.lastActivityAt === "number" ? step.lastActivityAt : undefined, turnCount: typeof step.turnCount === "number" ? step.turnCount : undefined, toolCount: typeof step.toolCount === "number" ? step.toolCount : undefined, recentTools: Array.isArray(step.recentTools) ? step.recentTools as DashboardChild["recentTools"] : undefined, recentOutput: Array.isArray(step.recentOutput) ? step.recentOutput.filter((line): line is string => typeof line === "string") : undefined, durationMs: typeof step.durationMs === "number" ? step.durationMs : undefined, startedAt: typeof step.startedAt === "number" ? step.startedAt : undefined, endedAt: typeof step.endedAt === "number" ? step.endedAt : undefined, sessionFile: typeof step.sessionFile === "string" ? step.sessionFile : undefined, objectiveLines: extractObjectiveLines(typeof step.task === "string" ? step.task : undefined), timelineLog: liveDetails.timelineLog?.length ? liveDetails.timelineLog : (typeof step.sessionFile === "string" ? extractTimelineFromSession(step.sessionFile) : []), timeline: liveDetails.timeline, touchedFiles: liveDetails.touchedFiles, commandOutput: liveDetails.commandOutput, cost: liveDetails.cost, }; const tokens = step.tokens as { total?: unknown } | undefined; if (tokens && typeof tokens.total === "number") child.tokens = tokens.total; const firstLine = output?.split("\n").find((line) => line.trim())?.trim(); if (firstLine) child.summary = firstLine.slice(0, 200); child.phase = inferPhase(child); return child; }); const startedAt = typeof statusData.startedAt === "number" ? statusData.startedAt : fallbackTimestamp; const endedAt = typeof statusData.endedAt === "number" ? statusData.endedAt : typeof statusData.lastUpdate === "number" ? statusData.lastUpdate : undefined; return { id, timestamp: startedAt, mode, status, title: fallbackTitle || mode, summary: `${mode}: ${children.map((child) => child.agent).join("+")}`, children, durationMs: endedAt ? endedAt - startedAt : undefined, totalTokens: children.reduce((sum, child) => sum + (child.tokens ?? 0), 0) || undefined, totalTools: children.reduce((sum, child) => sum + (child.toolCount ?? 0), 0) || undefined, sessionFile: typeof statusData.sessionFile === "string" ? statusData.sessionFile : undefined, context, }; } function splitAgentList(raw: string): string[] { return raw .split(/\s*(?:->|[+,>])\s*/) .map((s) => s.trim()) .filter(Boolean); } function deriveNotifyAgents(title: string): string[] { const colon = title.indexOf(":"); if (colon >= 0) return splitAgentList(title.slice(colon + 1)); return title ? [title] : []; } function trimBlankLines(lines: string[]): string[] { let start = 0; let end = lines.length; while (start < end && !lines[start]?.trim()) start++; while (end > start && !lines[end - 1]?.trim()) end--; return lines.slice(start, end); } function isNotifyMetadataLine(line: string): boolean { return /^(?:Session file|Session|Output saved to):\s*/.test(line.trim()); } function isNotifySessionMetadataLine(line: string): boolean { return /^(?:Session file|Session):\s*/.test(line.trim()); } function extractNotifySavedOutputPath(line: string): string | undefined { const match = line.trim().match(/^Output saved to:\s*(.+?)(?:\s+\(|\s*$)/); return match?.[1]?.trim(); } function extractNotifyChildren(text: string, title: string, status: DashboardStatus, sessionFile?: string): DashboardChild[] { const lines = text.split(/\r?\n/); const derivedAgents = deriveNotifyAgents(title); const discoveredAgents = Array.from(text.matchAll(/^([A-Za-z0-9_.-]+):\s*$/gm)) .map((match) => match[1]!) .filter((name) => !["Session", "Output"].includes(name)); const derivedHits = derivedAgents.filter((agent) => lines.some((line, index) => index > 0 && line.trim() === `${agent}:`)); const agents = derivedHits.length > 0 ? derivedAgents : discoveredAgents; const children: DashboardChild[] = []; for (const agent of agents) { const headingIndex = lines.findIndex((line, index) => index > 0 && line.trim() === `${agent}:`); if (headingIndex < 0) continue; let endIndex = lines.length; for (let i = headingIndex + 1; i < lines.length; i++) { const trimmed = lines[i]!.trim(); if (isNotifySessionMetadataLine(trimmed)) { endIndex = i; break; } if (agents.some((other) => other !== agent && trimmed === `${other}:`)) { endIndex = i; break; } } const sectionLines = lines.slice(headingIndex + 1, endIndex); const outputPath = sectionLines.map(extractNotifySavedOutputPath).find(Boolean); const savedOutput = readTextFileIfExists(outputPath); const inlineOutputLines = trimBlankLines(sectionLines.filter((line) => !extractNotifySavedOutputPath(line))); const inlineOutput = inlineOutputLines.join("\n").trim(); const output = savedOutput?.trim() || inlineOutput; const firstLine = output.split("\n").find((line) => line.trim())?.trim() ?? inlineOutputLines.find((line) => line.trim())?.trim(); const child: DashboardChild = { agent, status, output: output || undefined, outputPath, summary: firstLine?.slice(0, 200), sessionFile, }; child.objectiveLines = undefined; child.phase = inferPhase(child); children.push(child); } if (children.length > 0) return children; return discoveredAgents.length ? discoveredAgents.map((agent) => ({ agent, status, sessionFile })) : [{ agent: title, status, sessionFile }]; } function extractRunFromNotifyEntry(entry: SessionEntry): DashboardRun | null { const e = entry as { type?: string; customType?: string; content?: unknown; timestamp?: string | number; id?: string }; if (e.type !== "custom_message" || e.customType !== "subagent-notify") return null; const text = typeof e.content === "string" ? e.content : extractTextFromContent(e.content); const header = text.match(/^Background task (completed|failed|paused): \*\*(.+?)\*\*/m); if (!header) return null; const status = header[1] === "completed" ? "complete" : header[1] as DashboardStatus; const title = header[2]!.trim(); const timestamp = typeof e.timestamp === "string" ? new Date(e.timestamp).getTime() : typeof e.timestamp === "number" ? e.timestamp : Date.now(); const sessionMatch = text.match(/^Session file:\s*(.+)$/m) ?? text.match(/^Session:\s*(.+)$/m); const sessionFile = sessionMatch?.[1]?.trim(); const outputMatch = text.match(/Output saved to:\s*(.+?)\s*\(/m); const children = extractNotifyChildren(text, title, status, sessionFile); const bodyLines = text.split("\n").slice(2).filter((line) => line.trim() && !isNotifyMetadataLine(line)); return { id: e.id ?? `notify-${timestamp}`, timestamp, mode: title.startsWith("parallel:") ? "parallel" : title.startsWith("chain") ? "chain" : "single", status, title, summary: children.find((child) => child.summary)?.summary ?? bodyLines.find((line) => !line.endsWith(":"))?.trim() ?? title, children, outputPath: outputMatch?.[1]?.trim(), sessionFile, }; } function sameAgentSequence(a: DashboardRun, b: DashboardRun): boolean { if (a.children.length === 0 || a.children.length !== b.children.length) return false; return a.children.every((child, index) => child.agent === b.children[index]?.agent); } function childHasRecoveredDetails(child: DashboardChild): boolean { return Boolean(child.output || child.timeline?.length || child.touchedFiles?.length || child.commandOutput?.length || child.recentOutput?.length); } function mergeChildDetails(target: DashboardChild, source: DashboardChild): void { target.output ??= source.output; if (!target.outputPath || !fs.existsSync(target.outputPath)) target.outputPath = source.outputPath ?? target.outputPath; target.summary ??= source.summary; target.error ??= source.error; target.task ??= source.task; target.toolCalls ??= source.toolCalls; target.durationMs ??= source.durationMs; target.startedAt ??= source.startedAt; target.endedAt ??= source.endedAt; target.tokens ??= source.tokens; target.cost ??= source.cost; target.phase ??= source.phase; target.objectiveLines ??= source.objectiveLines; target.timelineLog ??= source.timelineLog; target.timeline ??= source.timeline; target.touchedFiles ??= source.touchedFiles; target.commandOutput ??= source.commandOutput; target.currentTool ??= source.currentTool; target.currentToolArgs ??= source.currentToolArgs; target.currentToolStartedAt ??= source.currentToolStartedAt; target.currentPath ??= source.currentPath; target.activityState ??= source.activityState; target.lastActivityAt ??= source.lastActivityAt; target.turnCount ??= source.turnCount; target.toolCount ??= source.toolCount; target.recentTools ??= source.recentTools; target.recentOutput ??= source.recentOutput; target.sessionFile ??= source.sessionFile; } function mergeRunDetails(target: DashboardRun, source: DashboardRun): void { for (const [index, child] of target.children.entries()) { const sourceChild = source.children[index]; if (sourceChild) mergeChildDetails(child, sourceChild); } target.durationMs ??= source.durationMs; target.totalTools ??= source.totalTools; target.totalTokens ??= source.totalTokens; target.model ??= source.model; target.context ??= source.context; target.sessionFile ??= source.sessionFile; target.outputPath ??= source.outputPath; if (source.status === "complete" || source.status === "failed" || source.status === "paused") target.status = source.status; } function enrichRunsFromMatchingAsyncLogs(runs: DashboardRun[]): void { for (const run of runs) { if (run.children.every(childHasRecoveredDetails)) continue; const candidate = runs .filter((other) => other !== run && sameAgentSequence(run, other) && other.children.some(childHasRecoveredDetails)) .sort((a, b) => b.timestamp - a.timestamp)[0]; if (!candidate) continue; mergeRunDetails(run, candidate); } } function runDetailScore(run: DashboardRun): number { let score = 0; if (run.status === "complete" || run.status === "failed" || run.status === "paused") score += 100; for (const child of run.children) { if (child.output) score += 20; if (child.outputPath) score += 6; if (child.timeline?.length) score += 4; if (child.touchedFiles?.length) score += 4; if (child.recentOutput?.length) score += 2; if (child.recentTools?.length) score += 2; } return score; } function chooseRunForId(existing: DashboardRun | undefined, incoming: DashboardRun): DashboardRun { if (!existing) return incoming; const existingScore = runDetailScore(existing); const incomingScore = runDetailScore(incoming); if (incomingScore > existingScore) { mergeRunDetails(incoming, existing); return incoming; } mergeRunDetails(existing, incoming); if (incoming.timestamp > existing.timestamp && incomingScore === existingScore) return incoming; return existing; } function looksLikeNotifyRun(run: DashboardRun): boolean { return /^(?:chain|parallel):/.test(run.title) || /^notify-/.test(run.id); } function collapseSequenceDuplicates(runs: DashboardRun[]): DashboardRun[] { const keep = new Set(runs); for (const run of runs) { if (!keep.has(run) || !looksLikeNotifyRun(run)) continue; const target = runs .filter((other) => other !== run && keep.has(other) && sameAgentSequence(run, other) && !looksLikeNotifyRun(other)) .sort((a, b) => runDetailScore(b) - runDetailScore(a) || b.timestamp - a.timestamp)[0]; if (!target) continue; mergeRunDetails(target, run); keep.delete(run); } return runs.filter((run) => keep.has(run)); } function extractRunFromAsyncJob(job: AsyncJobState): DashboardRun { const children: DashboardChild[] = []; for (const [fallbackIndex, step] of (job.steps ?? []).entries()) { const stepIndex = step.index ?? fallbackIndex; const completed = step.status === "complete" || step.status === "completed" || step.status === "failed" || step.status === "paused"; const saved = completed ? readAsyncStepOutput(job.asyncDir, stepIndex) : {}; const liveDetails = extractLiveDetailsFromEvents(job.asyncDir, stepIndex); const child: DashboardChild = { agent: step.agent ?? job.agents?.[step.index ?? 0] ?? "unknown", status: (step.status === "completed" ? "complete" : step.status) as DashboardStatus, error: step.error, output: saved.output, outputPath: saved.outputPath, currentTool: step.currentTool, currentToolArgs: step.currentToolArgs, currentToolStartedAt: step.currentToolStartedAt, currentPath: step.currentPath, activityState: step.activityState, lastActivityAt: step.lastActivityAt, turnCount: step.turnCount, toolCount: step.toolCount, recentTools: step.recentTools, recentOutput: step.recentOutput, durationMs: step.durationMs, startedAt: step.startedAt, endedAt: step.endedAt, tokens: step.tokens?.total, cost: liveDetails.cost, phase: undefined, objectiveLines: extractObjectiveLines(step.task), timelineLog: liveDetails.timelineLog?.length ? liveDetails.timelineLog : (step.sessionFile ? extractTimelineFromSession(step.sessionFile) : []), timeline: liveDetails.timeline, touchedFiles: liveDetails.touchedFiles, commandOutput: (step.currentTool === "bash" && step.recentOutput?.length) ? step.recentOutput.slice(-8) : liveDetails.commandOutput, sessionFile: step.sessionFile, }; child.phase = inferPhase(child); children.push(child); } const endTime = job.status === "complete" || job.status === "failed" || job.status === "paused" ? job.updatedAt ?? Date.now() : Date.now(); const duration = job.startedAt ? endTime - job.startedAt : undefined; return { id: job.asyncId, timestamp: job.startedAt ?? Date.now(), mode: (job.mode as "single" | "parallel" | "chain") ?? "single", status: job.status as DashboardStatus, title: job.mode ?? "async", summary: job.mode ? `${job.mode}: ${(job.agents ?? []).join("+") || "subagent"}` : "async subagent", children, durationMs: duration, totalTools: job.toolCount, totalTokens: job.totalTokens?.total, sessionFile: job.sessionFile, outputPath: job.outputFile, }; } function extractRunsFromParentSessionIndex(ctx: ExtensionContext): DashboardRun[] { let sessionFile: string | undefined; try { sessionFile = ctx.sessionManager.getSessionFile() ?? undefined; } catch { return []; } if (!sessionFile) return []; const raw = readTextFileIfExists(parentSessionIndexPath(sessionFile)); if (!raw) return []; try { const entries = JSON.parse(raw) as Array<{ runId?: unknown; asyncDir?: unknown; startedAt?: unknown; mode?: unknown; agents?: unknown }>; if (!Array.isArray(entries)) return []; return entries.flatMap((entry) => { if (typeof entry.runId !== "string" || typeof entry.asyncDir !== "string") return []; const mode = entry.mode === "single" || entry.mode === "parallel" || entry.mode === "chain" ? entry.mode : "single"; const title = Array.isArray(entry.agents) ? entry.agents.filter((agent): agent is string => typeof agent === "string").join(" -> ") : mode; const run = extractRunFromAsyncDir(entry.runId, entry.asyncDir, typeof entry.startedAt === "number" ? entry.startedAt : Date.now(), mode, title || mode, "running"); return run ? [run] : []; }); } catch { return []; } } export function extractDashboardRuns( ctx: ExtensionContext, state: SubagentState, ): DashboardRun[] { const runs: DashboardRun[] = []; // Extract from session entries (slash results, direct tool results, async completion notices, and the durable session index) const entries = ctx.sessionManager.getEntries(); for (const entry of entries) { const run = isSlashResultEntry(entry) ? extractRunFromSlashEntry(entry) : extractRunFromToolResultEntry(entry) ?? extractRunFromNotifyEntry(entry); if (run) runs.push(run); } runs.push(...extractRunsFromParentSessionIndex(ctx)); // Extract from async jobs (running/completed background jobs) for (const job of state.asyncJobs.values()) { runs.push(extractRunFromAsyncJob(job)); } // Deduplicate by id. Prefer durable/recovered detail over stale status snapshots. const byId = new Map(); for (const run of runs) { byId.set(run.id, chooseRunForId(byId.get(run.id), run)); } const deduped = Array.from(byId.values()); enrichRunsFromMatchingAsyncLogs(deduped); const collapsed = collapseSequenceDuplicates(deduped); // Sort oldest → newest collapsed.sort((a, b) => a.timestamp - b.timestamp); return collapsed; } // --------------------------------------------------------------------------- // Rendering helpers // --------------------------------------------------------------------------- function statusGlyph(status: DashboardStatus, theme: Theme): string { switch (status) { case "running": return theme.fg("accent", "⠋"); case "complete": return theme.fg("success", "✓"); case "failed": return theme.fg("error", "✗"); case "paused": return theme.fg("warning", "■"); case "queued": return theme.fg("muted", "◦"); } } function statJoin(theme: Theme, parts: string[]): string { return parts.filter(Boolean).map((p) => theme.fg("dim", p)).join(` ${theme.fg("dim", "·")} `); } function truncLine(text: string, maxWidth: number): string { if (visibleWidth(text) <= maxWidth) return text; const target = maxWidth - 1; const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); let result = ""; let currentWidth = 0; const activeStyles: string[] = []; let i = 0; while (i < text.length) { const ansiMatch = text.slice(i).match(/^\x1b\[[0-9;]*m/); if (ansiMatch) { const code = ansiMatch[0]; result += code; if (code === "\x1b[0m" || code === "\x1b[m") { activeStyles.length = 0; } else { activeStyles.push(code); } i += code.length; continue; } let end = i; while (end < text.length && !text.slice(end).match(/^\x1b\[[0-9;]*m/)) { end++; } const portion = text.slice(i, end); for (const seg of segmenter.segment(portion)) { const w = visibleWidth(seg.segment); if (currentWidth + w > target) return result + activeStyles.join("") + "…"; result += seg.segment; currentWidth += w; } i = end; } return result + activeStyles.join("") + "…"; } function pad(s: string, len: number): string { const vis = visibleWidth(s); return s + " ".repeat(Math.max(0, len - vis)); } function renderHeader(text: string, width: number, theme: Theme): string { const innerW = width - 2; const padLen = Math.max(0, innerW - visibleWidth(text)); const padLeft = Math.floor(padLen / 2); const padRight = padLen - padLeft; return ( theme.fg("border", "╭" + "─".repeat(padLeft)) + theme.fg("accent", text) + theme.fg("border", "─".repeat(padRight) + "╮") ); } function renderFooter(text: string, width: number, theme: Theme): string { const innerW = width - 2; const padLen = Math.max(0, innerW - visibleWidth(text)); const padLeft = Math.floor(padLen / 2); const padRight = padLen - padLeft; return ( theme.fg("border", "╰" + "─".repeat(padLeft)) + theme.fg("dim", text) + theme.fg("border", "─".repeat(padRight) + "╯") ); } function renderRow(content: string, width: number, theme: Theme): string { const innerW = width - 2; const singleLine = content.replace(/[\r\n]+/g, " ").replace(/\t/g, " "); const clipped = truncLine(singleLine, innerW); return theme.fg("border", "│") + pad(clipped, innerW) + theme.fg("border", "│"); } // --------------------------------------------------------------------------- // SubagentDashboardComponent (agent viewer) // --------------------------------------------------------------------------- interface DashboardPage { id: string; run: DashboardRun; child: DashboardChild; childIndex: number; childCount: number; } function buildPages(runs: DashboardRun[]): DashboardPage[] { const pages: DashboardPage[] = []; for (const run of runs) { const children = run.children.length > 0 ? run.children : [{ agent: run.title, status: run.status, summary: run.summary } satisfies DashboardChild]; children.forEach((child, childIndex) => { pages.push({ id: `${run.id}:${childIndex}`, run, child, childIndex, childCount: children.length, }); }); } return pages; } function formatFooterHint(parts: Array): string { return parts.map((p) => p?.trim()).filter((p): p is string => !!p).join(" · "); } function formatAgo(timestamp?: number): string | undefined { if (!timestamp) return undefined; const delta = Math.max(0, Date.now() - timestamp); if (delta < 1000) return "now"; if (delta < 60_000) return `${Math.floor(delta / 1000)}s ago`; if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago`; return `${Math.floor(delta / 3_600_000)}h ago`; } function childDurationMs(child: DashboardChild): number | undefined { if (child.durationMs !== undefined) return child.durationMs; if (child.startedAt !== undefined) return Math.max(0, (child.endedAt ?? Date.now()) - child.startedAt); return undefined; } function liveActivitySummary(child: DashboardChild): string | undefined { const parts = [ child.phase ? `phase ${child.phase}` : undefined, child.activityState, child.turnCount ? `${child.turnCount} turns` : undefined, child.toolCount ? `${child.toolCount} tools` : undefined, formatAgo(child.lastActivityAt) ? `active ${formatAgo(child.lastActivityAt)}` : undefined, ]; return formatFooterHint(parts); } function formatCost(cost?: number): string | undefined { if (!cost || cost <= 0) return undefined; return cost < 0.01 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`; } function parseMouseWheel(data: string): number { // SGR mouse: ESC [ < button ; x ; y M/m. Wheel up/down are buttons 64/65 // (with modifier bits optionally added), so mask to the low two button bits. const sgr = data.match(/^\x1b\[<(\d+);\d+;\d+[mM]$/); if (sgr) { const code = Number(sgr[1]); if ((code & 64) === 64) { const button = code & 3; if (button === 0) return -1; if (button === 1) return 1; } return 0; } // X10 mouse: ESC [ M Cb Cx Cy. Wheel up/down are Cb 96/97 after subtracting 32. if (data.startsWith("\x1b[M") && data.length >= 6) { const code = data.charCodeAt(3) - 32; if ((code & 64) === 64) { const button = code & 3; if (button === 0) return -1; if (button === 1) return 1; } } return 0; } export class SubagentDashboardComponent implements Component { private readonly tui: TUI; private readonly theme: Theme; private runs: DashboardRun[]; private readonly getRuns?: () => DashboardRun[]; private readonly done: (result: void) => void; private index: number; private scrollOffset = 0; private userScrolledUp = false; private filterQuery = ""; private filterResults: number[] = []; private filterMode = false; private refreshTimer?: ReturnType; private mouseTrackingEnabled = false; private alternateScreenEnabled = false; constructor( tui: TUI, theme: Theme, runs: DashboardRun[], options: { initialIndex?: number; done: (result: void) => void; getRuns?: () => DashboardRun[]; useAlternateScreen?: boolean }, ) { this.tui = tui; this.theme = theme; this.runs = options.getRuns ? options.getRuns() : runs; if (options.useAlternateScreen) this.enableAlternateScreen(); this.getRuns = options.getRuns; this.done = options.done; const pages = buildPages(this.runs); this.index = options.initialIndex !== undefined && options.initialIndex >= 0 && options.initialIndex < pages.length ? options.initialIndex : Math.max(0, pages.length - 1); this.enableMouseTracking(); if (this.alternateScreenEnabled) { setTimeout(() => this.tui.requestRender(true), 0); } this.refreshTimer = setInterval(() => { this.refreshRuns(); this.tui.requestRender(); }, 1000); } invalidate(): void {} private enableAlternateScreen(): void { if (this.alternateScreenEnabled) return; this.alternateScreenEnabled = true; process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H"); } private disableAlternateScreen(): void { if (!this.alternateScreenEnabled) return; this.alternateScreenEnabled = false; process.stdout.write("\x1b[?1049l"); } private enableMouseTracking(): void { if (this.mouseTrackingEnabled) return; this.mouseTrackingEnabled = true; process.stdout.write("\x1b[?1000h\x1b[?1006h"); } private disableMouseTracking(): void { if (!this.mouseTrackingEnabled) return; this.mouseTrackingEnabled = false; process.stdout.write("\x1b[?1000l\x1b[?1006l"); } dispose(): void { if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = undefined; } this.disableMouseTracking(); this.disableAlternateScreen(); } private close(): void { this.dispose(); this.done(undefined); setTimeout(() => this.tui.requestRender(true), 0); } private pages(): DashboardPage[] { return buildPages(this.runs); } private refreshRuns(preferredPageId?: string): DashboardPage[] { const beforePages = this.pages(); const wasAtLatest = beforePages.length === 0 || this.index >= beforePages.length - 1; const currentId = preferredPageId ?? beforePages[this.index]?.id; if (this.getRuns) this.runs = this.getRuns(); const pages = this.pages(); if (pages.length === 0) { this.index = 0; this.scrollOffset = 0; this.userScrolledUp = false; return pages; } const sameIndex = currentId ? pages.findIndex((page) => page.id === currentId) : -1; if (sameIndex >= 0) { this.index = sameIndex; // Auto-scroll to bottom on live runs unless user scrolled up const page = pages[this.index]; if (page && (page.child.status === "running" || page.child.status === "queued") && !this.userScrolledUp) { this.scrollOffset = this.maxScrollOffset(page); } } else if (wasAtLatest) { this.index = pages.length - 1; this.scrollOffset = 0; this.userScrolledUp = false; } else { this.index = Math.min(this.index, pages.length - 1); } return pages; } handleInput(data: string): void { let pages = this.refreshRuns(); const wheelDelta = parseMouseWheel(data); if (wheelDelta !== 0) { const page = pages[this.index]; if (!page) return; const step = 3; this.scrollOffset = wheelDelta < 0 ? Math.max(0, this.scrollOffset - step) : Math.min(this.maxScrollOffset(page), this.scrollOffset + step); if (this.scrollOffset >= this.maxScrollOffset(page)) this.userScrolledUp = false; else this.userScrolledUp = true; this.tui.requestRender(); return; } // Filter mode takes priority if (this.filterMode) { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.filterMode = false; this.filterQuery = ""; this.filterResults = []; this.tui.requestRender(); return; } if (matchesKey(data, "return")) { if (this.filterResults.length > 0) { this.index = this.filterResults[0]!; } this.filterMode = false; this.filterQuery = ""; this.filterResults = []; this.scrollOffset = 0; this.tui.requestRender(); return; } if (matchesKey(data, "backspace")) { this.filterQuery = this.filterQuery.slice(0, -1); this.refreshFilter(); this.tui.requestRender(); return; } if (data.length === 1 && data.charCodeAt(0) >= 32) { this.filterQuery += data; this.refreshFilter(); this.tui.requestRender(); return; } return; } if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; } if (matchesKey(data, "ctrl+shift+left")) { if (pages.length === 0) return; this.index = this.index <= 0 ? pages.length - 1 : this.index - 1; this.userScrolledUp = false; this.tui.requestRender(); return; } if (matchesKey(data, "ctrl+shift+right")) { if (pages.length === 0) return; this.index = this.index >= pages.length - 1 ? 0 : this.index + 1; this.userScrolledUp = false; this.tui.requestRender(); return; } const page = pages[this.index]; if (!page) return; if (matchesKey(data, "home")) { if (pages.length > 0) { this.index = 0; this.userScrolledUp = false; this.tui.requestRender(); } return; } if (matchesKey(data, "end")) { if (pages.length > 0) { this.index = pages.length - 1; this.userScrolledUp = false; this.tui.requestRender(); } return; } if (data === "/") { this.filterMode = true; this.filterQuery = ""; this.refreshFilter(); this.tui.requestRender(); return; } if (matchesKey(data, "up") || data === "k") { this.scrollOffset = Math.max(0, this.scrollOffset - 1); this.userScrolledUp = true; this.tui.requestRender(); return; } if (matchesKey(data, "down") || data === "j") { this.scrollOffset = Math.min(this.maxScrollOffset(page), this.scrollOffset + 1); if (this.scrollOffset >= this.maxScrollOffset(page)) this.userScrolledUp = false; this.tui.requestRender(); return; } } private refreshFilter(): void { const q = this.filterQuery.toLowerCase(); const pages = this.refreshRuns(); if (!q) { this.filterResults = pages.map((_, i) => i); return; } this.filterResults = []; for (let i = 0; i < pages.length; i++) { const page = pages[i]!; const run = page.run; const child = page.child; if ( run.title.toLowerCase().includes(q) || run.summary.toLowerCase().includes(q) || run.mode.toLowerCase().includes(q) || child.agent.toLowerCase().includes(q) || (child.task ?? "").toLowerCase().includes(q) || (child.output ?? "").toLowerCase().includes(q) ) { this.filterResults.push(i); } } } private contentLineBudget(): number { const rows = process.stdout.rows || 30; return Math.max(3, Math.floor(rows * 0.9) - 8); } private collectOutputLines(page: DashboardPage): string[] { const theme = this.theme; const { child } = page; const outputLines: string[] = []; if (child.status === "queued") { outputLines.push(`${theme.fg("accent", "Live:")} queued, waiting to start`); outputLines.push(""); } else if (child.currentTool || child.currentToolArgs || child.currentPath) { const args = child.currentToolArgs ? ` ${truncLine(child.currentToolArgs, 120)}` : ""; const pathInfo = child.currentPath ? ` ${theme.fg("dim", "@")} ${shortenPath(child.currentPath)}` : ""; const elapsed = child.currentToolStartedAt ? ` ${theme.fg("dim", `(${formatDuration(Date.now() - child.currentToolStartedAt)})`)}` : ""; outputLines.push(`${theme.fg("accent", "Now:")} ${child.currentTool ?? "working"}${args}${pathInfo}${elapsed}`); outputLines.push(""); } else if (child.status === "running") { outputLines.push(`${theme.fg("accent", "Live:")} running, waiting for next progress update…`); outputLines.push(""); } if (child.error) { outputLines.push(`${theme.fg("error", "Error:")} ${child.error}`); outputLines.push(""); } // Unified sequential timeline log const timelineLog = child.timelineLog ?? []; if (timelineLog.length > 0) { outputLines.push(theme.fg("accent", "── Activity Log ──")); outputLines.push(""); for (const entry of timelineLog) { const clock = formatClock(entry.timestamp) || "--:--:--"; const liveMarker = entry.isLive ? theme.fg("accent", "▸ ") : " "; outputLines.push(this.renderTimelineEntry(entry, clock, liveMarker, theme)); } outputLines.push(""); } // Final output section if available and not already in log const recentOutput = child.recentOutput ?? []; if (child.output && !child.timelineLog?.length) { outputLines.push(theme.fg("accent", "Output:")); for (const line of child.output.split("\n")) { outputLines.push(` ${line}`); } outputLines.push(""); } else if (recentOutput.length > 0 && !child.timelineLog?.length) { outputLines.push(theme.fg("accent", "Recent output:")); for (const line of recentOutput.slice(-20)) { outputLines.push(` ${line}`); } outputLines.push(""); } if (child.outputPath) { outputLines.push(`${theme.fg("dim", "Saved output:")} ${shortenPath(child.outputPath)}`); } if (child.sessionFile) { outputLines.push(`${theme.fg("dim", "Session:")} ${shortenPath(child.sessionFile)}`); } if (page.run.sessionFile && !child.sessionFile) { outputLines.push(`${theme.fg("dim", "Session:")} ${shortenPath(page.run.sessionFile)}`); } return outputLines; } private renderTimelineEntry(entry: TimelineLogEntry, clock: string, liveMarker: string, theme: Theme): string { const timeCol = theme.fg("dim", clock); const prefix = `${liveMarker}${timeCol} `; switch (entry.kind) { case "phase": return `${prefix}${theme.fg("accent", `[phase] ${entry.label}`)}`; case "tool_start": { const toolCol = theme.fg("toolTitle", entry.tool ?? "tool"); const detail = entry.path ? ` ${theme.fg("dim", shortenPath(entry.path))}` : ""; const args = entry.args && !entry.path ? ` ${theme.fg("dim", entry.args)}` : ""; return `${prefix} ${toolCol}${detail}${args}`; } case "tool_end": { const icon = entry.status === "error" ? theme.fg("error", "✗") : theme.fg("success", "✓"); const detail = entry.text ? ` ${theme.fg("dim", entry.text.length > 100 ? entry.text.slice(0, 100) + "…" : entry.text)}` : ""; return `${prefix} ${icon} ${theme.fg("dim", entry.tool ?? "tool")}${detail}`; } case "speech": return `${prefix} ${theme.fg("dim", "said:")} ${entry.text}`; case "bash_output": { const lines = (entry.text ?? "").split("\n"); const first = lines[0] ? ` ${theme.fg("dim", truncLine(lines[0], 80))}` : ""; return `${prefix} ${theme.fg("dim", "└─")}${first}`; } case "error": return `${prefix} ${theme.fg("error", entry.text ?? "error")}`; default: return `${prefix} ${entry.text ?? ""}`; } } private maxScrollOffset(page: DashboardPage): number { return Math.max(0, this.collectOutputLines(page).length - this.contentLineBudget()); } // ----------------------------------------------------------------------- // Rendering // ----------------------------------------------------------------------- render(width: number): string[] { const w = Math.max(40, width); const pages = this.refreshRuns(); if (pages.length === 0) { return this.renderEmpty(w); } if (this.filterMode) { return this.renderFilter(w, pages); } const page = pages[this.index]; if (!page) { return this.renderEmpty(w); } return this.renderPage(page, pages, w); } private renderEmpty(w: number): string[] { const theme = this.theme; const lines: string[] = []; const headerText = " Agent Viewer "; lines.push(renderHeader(headerText, w, theme)); lines.push(renderRow("", w, theme)); lines.push(renderRow(theme.fg("dim", " No agent runs found in this session."), w, theme)); lines.push(renderRow("", w, theme)); lines.push(renderRow(theme.fg("dim", " Run agents via /run, /chain, /parallel or the subagent() tool to populate this view."), w, theme)); lines.push(renderRow("", w, theme)); const footerText = " Esc close "; lines.push(renderFooter(footerText, w, theme)); return lines; } private renderFilter(w: number, pages: DashboardPage[]): string[] { const theme = this.theme; const innerW = w - 2; const lines: string[] = []; lines.push(renderHeader(" Filter agents ", w, theme)); lines.push(renderRow(` ${theme.fg("accent", "/ " + this.filterQuery)}`, w, theme)); lines.push(renderRow("", w, theme)); const maxItems = Math.min(12, this.filterResults.length); for (let i = 0; i < maxItems; i++) { const idx = this.filterResults[i]!; const page = pages[idx]!; const glyph = statusGlyph(page.child.status, theme); const childSuffix = page.childCount > 1 ? theme.fg("dim", ` ${page.run.mode} ${page.childIndex + 1}/${page.childCount}`) : theme.fg("dim", ` ${page.run.mode}`); const title = truncLine(`${page.child.agent}${childSuffix}`, innerW - 12); lines.push(renderRow( ` ${glyph} ${title} ${theme.fg("dim", `#${idx + 1}`)}`, w, theme, )); } if (this.filterResults.length > maxItems) { lines.push(renderRow( theme.fg("dim", ` … and ${this.filterResults.length - maxItems} more matches`), w, theme, )); } if (this.filterResults.length === 0) { lines.push(renderRow(theme.fg("dim", " No matching agents"), w, theme)); } lines.push(renderRow("", w, theme)); lines.push(renderFooter(" Esc cancel · Enter select ", w, theme)); return lines; } private renderPage(page: DashboardPage, pages: DashboardPage[], w: number): string[] { const theme = this.theme; const innerW = w - 2; const lines: string[] = []; const { run, child } = page; const progressLabel = `${this.index + 1}/${pages.length}${ this.index === pages.length - 1 ? " — latest" : "" }`; lines.push(renderHeader(` Agent ${progressLabel} `, w, theme)); const duration = childDurationMs(child) ?? run.durationMs; const stats = statJoin(theme, [ duration ? `${child.status === "running" ? "elapsed " : ""}${formatDuration(duration)}` : "", child.toolCalls?.length ? `${child.toolCalls.length} tools` : run.totalTools ? `${run.totalTools} tools` : "", child.tokens ? `${formatTokens(child.tokens)} tok` : run.totalTokens ? `${formatTokens(run.totalTokens)} tok` : "", ]); const statusLine = `${statusGlyph(child.status, theme)} ${theme.fg("toolTitle", theme.bold?.(child.agent) ?? child.agent)} · ${theme.fg("dim", child.status)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`; lines.push(renderRow(statusLine, w, theme)); const resourceParts = [ child.phase ? `Phase: ${child.phase}` : undefined, childDurationMs(child) !== undefined ? `${child.status === "running" ? "Elapsed" : "Duration"}: ${formatDuration(childDurationMs(child)!)}` : undefined, child.turnCount ? `${child.turnCount} turns` : undefined, child.toolCount ? `${child.toolCount} tools` : undefined, child.tokens ? `${formatTokens(child.tokens)}` : undefined, formatCost(child.cost) ? `${formatCost(child.cost)}` : undefined, ].filter(Boolean) as string[]; const modelLabel = formatModelThinking(run.model); const metaParts = [ modelLabel ? `Model: ${modelLabel}` : undefined, child.status === "running" || child.status === "queued" ? `Live` : undefined, run.context === "fork" ? "Context: fork" : undefined, page.childCount > 1 ? `Run: ${run.mode} ${page.childIndex + 1}/${page.childCount} · overall ${run.status}` : undefined, ]; const combinedMeta = formatFooterHint([...resourceParts.map((p) => theme.fg("dim", p)), ...metaParts.map((p) => theme.fg("dim", p))]); if (combinedMeta) lines.push(renderRow(` ${combinedMeta}`, w, theme)); const task = child.task || run.summary; if (task) { lines.push(renderRow(theme.fg("border", "─".repeat(innerW)), w, theme)); lines.push(renderRow(` ${theme.fg("dim", "Task:")} ${truncLine(task, innerW - 8)}`, w, theme)); } lines.push(renderRow(theme.fg("border", "─".repeat(innerW)), w, theme)); const outputLines = this.collectOutputLines(page); const maxContentLines = this.contentLineBudget(); const maxOffset = Math.max(0, outputLines.length - maxContentLines); this.scrollOffset = Math.min(this.scrollOffset, maxOffset); const totalLines = outputLines.length; const visibleLines = outputLines.slice( this.scrollOffset, this.scrollOffset + maxContentLines, ); for (const line of visibleLines) { if (visibleWidth(line) > innerW - 2) { lines.push(renderRow(` ${truncLine(line, innerW - 4)}`, w, theme)); } else { lines.push(renderRow(` ${line}`, w, theme)); } } for (let i = visibleLines.length; i < maxContentLines; i++) { lines.push(renderRow("", w, theme)); } const scrollInfo = totalLines > maxContentLines ? `line ${this.scrollOffset + 1}/${totalLines}` : undefined; const liveInfo = child.status === "running" || child.status === "queued" ? "live" : undefined; const footerText = ` ${formatFooterHint([ scrollInfo, liveInfo, "Ctrl+Shift+← older", "Ctrl+Shift+→ newer", "↑↓/jk/wheel scroll", "/ filter", "Esc close", ])} `; lines.push(renderFooter(footerText, w, theme)); return lines; } }