/** * Agent view orchestration — live/finished agent detail UI (showLiveAgentView), workflow * definition/graph tab formatters, and live polling via AgentLiveViewComponent. * * Markdown formatting helpers live in agent-view-format.ts. Deferred cycles: showLiveAgentView * constructs AgentLiveViewComponent (agent-live-view.js) while that component reads * liveAgentHeaderStatus from here; event-parser.js reads extractMarkdownSection from here * inside a body. index.ts re-exports liveAgentHeaderStatus for the usability test. */ import * as fs from "node:fs/promises"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { truncate } from "../lib/format.js"; import { notify } from "../lib/notify.js"; import { readRunEvents } from "../observe/event-parser.js"; import { computeCodeHash } from "../runtime/index.js"; import type { AgentMonitorModel, WorkflowRunRecord } from "../types.js"; import { AgentLiveViewComponent } from "./agent-live-view.js"; import { type AgentViewParts, buildAgentViewParts, extractMarkdownSection, fencedBlock, formatAgentView, resolveAgentArtifactPath, } from "./agent-view-format.js"; import { formatRunView, pickAndOpenRunArtifact } from "./run-view.js"; export type { AgentViewParts }; export { buildAgentViewParts, extractMarkdownSection, resolveAgentArtifactPath }; const TERMINAL_AGENT_STATES: ReadonlySet = new Set(["completed", "failed", "cached"]); function isTerminalAgentState(state: string | undefined): boolean { return state !== undefined && TERMINAL_AGENT_STATES.has(state); } // Header status label for the live agent viewer: keep advertising the 1s poll // only while the agent can still change; show a stable "final" marker once it // reaches a terminal state (and the poll is stopped). export function liveAgentHeaderStatus(state: string | undefined): string { return isTerminalAgentState(state) ? `final (${state})` : "refresh 1s"; } interface WorkflowGraphViewCache { sourceKey: string; recordedCodeHash: string | undefined; graph: string; content: string; } // La pestaña Definition: la fuente del workflow que ejecutó esta ejecución, como un bloque de código cercado, // con una advertencia de caché de reanudación cuando el archivo cambió desde la ejecución (igual verificación de hash que // hace la vista de ejecución). async function formatWorkflowDefinition(run: WorkflowRunRecord): Promise { const header = [`# Workflow definition: ${run.workflow}`, "", `File: ${run.file ?? "unknown"}`]; if (!run.file) return [...header, "", "No workflow file was recorded for this run."].join("\n"); let code: string; try { code = await fs.readFile(run.file, "utf8"); } catch (err) { return [...header, "", `Cannot read workflow file: ${err instanceof Error ? err.message : String(err)}`].join( "\n", ); } const codeChanged = run.codeHash !== undefined && computeCodeHash(code) !== run.codeHash; return [ ...header, ...(codeChanged ? [ "", "⚠ Warning: this file changed since the run started. On resume, calls whose arguments changed will re-execute (cache miss); unchanged calls stay cached.", ] : []), "", fencedBlock(truncate(code, 60_000), "js"), ].join("\n"); } // La pestaña Graph: una representación Markdown/texto del mismo gráfico de workflow estático // usado por /workflow graph. Mantenlo como text+Mermaid dentro del visor Markdown con pestañas; // la acción de gráfico independiente posee el componente más rico capaz de PNG. async function formatWorkflowGraphView( ctx: ExtensionContext, run: WorkflowRunRecord, cache: WorkflowGraphViewCache | undefined, ): Promise { const header = [`# Workflow graph: ${run.workflow}`, "", `Run: ${run.runId}`]; const { resolveWorkflowForRun } = await import("../surface/index.js"); const workflow = await resolveWorkflowForRun(ctx, run); if (!workflow) { const sourceKey = `missing:${run.file ?? run.workflow}`; if (cache?.sourceKey === sourceKey && cache.recordedCodeHash === run.codeHash) return cache; return { sourceKey, recordedCodeHash: run.codeHash, graph: "", content: [...header, "", "Cannot open graph: workflow file not found."].join("\n"), }; } const code = await fs.readFile(workflow.path, "utf8"); const sourceHash = computeCodeHash(code); const sourceKey = `${workflow.path}:${sourceHash}`; if (cache?.sourceKey === sourceKey && cache.recordedCodeHash === run.codeHash) return cache; let graph = cache?.sourceKey === sourceKey ? cache.graph : undefined; if (graph === undefined) { const { makeWorkflowGraphForContext } = await import("./graph/index.js"); graph = await makeWorkflowGraphForContext(ctx, workflow, code); } const codeChanged = run.codeHash !== undefined && sourceHash !== run.codeHash; return { sourceKey, recordedCodeHash: run.codeHash, graph, content: [ ...header, `File: ${workflow.path}`, ...(codeChanged ? [ "", "⚠ Warning: this file changed since the run started, so the graph reflects the current workflow file.", ] : []), "", graph, ].join("\n"), }; } async function latestAgentForRun(run: WorkflowRunRecord, agent: AgentMonitorModel): Promise { const { agents } = await readRunEvents(run.runDir); return agents.find((candidate) => candidate.id === agent.id) ?? agent; } export async function showLiveAgentView( ctx: ExtensionContext, run: WorkflowRunRecord, agent: AgentMonitorModel, ): Promise { if (ctx.mode === "print") { console.log(await formatAgentView(run, await latestAgentForRun(run, agent))); return; } if (ctx.mode === "tui") { // loop open→action→reopen: `f` deja al usuario abrir uno de los artifacts de la ejecución en // el visor correcto (.md → Markdown, de lo contrario texto), luego regresa a la vista de agente en vivo — // la misma capacidad que tiene la vista de ejecución, para que la pantalla del agente "encaje" con ella. // La pantalla de detalle es un visor SUB-TABULADO (Card / Prompt / Graph / Output / Definition / Run) // para que el usuario pueda moverse entre la tarjeta del agente, su prompt, el gráfico del workflow, su salida, // la fuente del workflow, y la vista de ejecución completa sin rebotar al panel. let definitionCache: string | undefined; // estático por ejecución: carga una vez, reutiliza entre pestañas/actualizaciones let graphCache: WorkflowGraphViewCache | undefined; for (;;) { let timer: NodeJS.Timeout | undefined; let refreshing = false; let component: AgentLiveViewComponent | undefined; let intent: "openFiles" | undefined; try { intent = await ctx.ui.custom<"openFiles" | undefined>((tui, theme, _keybindings, done) => { let pending = false; const refresh = async () => { if (!component) return; if (refreshing) { // Un cambio de pestaña durante una actualización en vuelo no debe ser descartado: el // nuevo tab estaría en "Cargando…" por siempre una vez que la encuesta de estado terminal se detiene. pending = true; return; } refreshing = true; try { const latest = await latestAgentForRun(run, agent); const active = component.getActiveTab(); if (active === "card" || active === "prompt" || active === "output") { // Una lectura de artifact produce las tres secciones del agente; rellénalas juntas. const parts = await buildAgentViewParts(run, latest); component.setTabContents([ ["card", parts.card], ["prompt", parts.prompt], ["output", parts.output], ]); } else if (active === "definition") { definitionCache ??= await formatWorkflowDefinition(run); component.setTabContent("definition", definitionCache); } else if (active === "run") { component.setTabContent("run", await formatRunView(run)); } else if (active === "graph") { const nextGraph = await formatWorkflowGraphView(ctx, run, graphCache); if (nextGraph !== graphCache) component.setTabContent("graph", nextGraph.content); graphCache = nextGraph; } component.setState(latest.state); component.markRefreshOk(); tui.requestRender(); // Detén el sondeo una vez que el agente es terminal; la salida final permanece // en pantalla hasta que el usuario cierre la vista. Los cambios de pestaña aún se actualizan // bajo demanda a través de onTabChange abajo. if (timer && isTerminalAgentState(latest.state)) { clearInterval(timer); timer = undefined; } } catch (err) { component.markRefreshError(err instanceof Error ? err.message : String(err)); tui.requestRender(); } finally { refreshing = false; if (pending) { pending = false; void refresh(); } } }; component = new AgentLiveViewComponent( theme, () => tui.terminal.rows, done, () => tui.requestRender(), true, [ { key: "card", label: "Card" }, { key: "prompt", label: "Prompt" }, { key: "graph", label: "Graph" }, { key: "output", label: "Output" }, { key: "definition", label: "Definition" }, { key: "run", label: "Run" }, ], () => void refresh(), // load the newly-focused tab immediately ); timer = setInterval(() => void refresh(), 1000); void refresh(); return component; }); } finally { if (timer) clearInterval(timer); } if (intent !== "openFiles") return; await pickAndOpenRunArtifact(ctx, run); } } if (ctx.hasUI) { await ctx.ui.editor( `Workflow agent: ${agent.name}`, await formatAgentView(run, await latestAgentForRun(run, agent)), ); return; } notify(ctx, await formatAgentView(run, await latestAgentForRun(run, agent)), "info"); }