import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, type Component, } from "@earendil-works/pi-tui"; import { isValidContextContinuation } from "./continuation.ts"; import type { GateDecision, PrototypeCandidate, WorkflowRun, } from "./domain.ts"; import { executeHostDecision, type ContextTransitionRuntime, } from "./context-transition.ts"; import { readVerifiedPlanArtifact, readVerifiedReportArtifact, readVerifiedTranscriptArtifact, } from "./store.ts"; interface DashboardTheme { fg( color: | "accent" | "dim" | "error" | "muted" | "success" | "text" | "warning", text: string, ): string; bold(text: string): string; } function statusColor(status: WorkflowRun["status"]) { if (status === "completed") return "success" as const; if (status === "failed" || status === "aborted") return "error" as const; if (status === "waiting") return "warning" as const; return "accent" as const; } function activeStage(run: WorkflowRun) { return ( [...run.stages] .reverse() .find((stage) => stage.status === "running" || stage.status === "waiting" )?.kind ?? run.status ); } function selectedGate(run: WorkflowRun) { return [...run.gates].reverse().find((gate) => gate.status === "open"); } function stageLine(run: WorkflowRun, theme: DashboardTheme) { return run.stages .filter((stage) => ["discover", "design", "prototype", "implement", "verify", "review"].includes( stage.kind, ), ) .map((stage) => { const marker = stage.status === "completed" ? theme.fg("success", "✓") : stage.status === "failed" ? theme.fg("error", "×") : stage.status === "running" || stage.status === "waiting" ? theme.fg("accent", "●") : theme.fg("dim", "·"); return `${marker} ${stage.kind}`; }) .join(" "); } export class FocusedWorkflowDashboard implements Component { private readonly runs: WorkflowRun[]; private readonly theme: DashboardTheme; private readonly done: (result: string | null) => void; private selected = 0; private detail = false; constructor( runs: WorkflowRun[], theme: DashboardTheme, done: (result: string | null) => void, initialRunId?: string, ) { this.runs = runs; this.theme = theme; this.done = done; const initial = initialRunId ? runs.findIndex((run) => run.runId === initialRunId) : -1; if (initial >= 0) { this.selected = initial; this.detail = true; } } handleInput(data: string) { if (matchesKey(data, Key.escape)) { if (this.detail) this.detail = false; else this.done(null); return; } if (matchesKey(data, Key.up) && this.selected > 0) { this.selected--; return; } if (matchesKey(data, Key.down) && this.selected < this.runs.length - 1) { this.selected++; return; } if (matchesKey(data, Key.enter)) { const run = this.runs[this.selected]; if (!run) return; if (this.detail) this.done(run.runId); else this.detail = true; } } render(width: number) { const lines = [this.theme.fg("accent", this.theme.bold("pi-workflow")), ""]; if (this.runs.length === 0) { lines.push(this.theme.fg("muted", "No Workflow Runs are attached to this session.")); lines.push("", this.theme.fg("dim", "esc close")); return lines.map((line) => truncateToWidth(line, width)); } const windowSize = 10; const windowStart = Math.max( 0, Math.min( this.selected - Math.floor(windowSize / 2), Math.max(0, this.runs.length - windowSize), ), ); const visibleRuns = this.runs.slice(windowStart, windowStart + windowSize); for (const [visibleIndex, run] of visibleRuns.entries()) { const index = windowStart + visibleIndex; const prefix = index === this.selected ? "> " : " "; const name = run.featureName ?? run.request.slice(0, 24); const status = this.theme.fg( statusColor(run.status), run.status.toUpperCase().padEnd(9), ); lines.push( `${prefix}${name.padEnd(24)} ${status} ${String(activeStage(run)).padEnd(20)} r${run.revision}`, ); } if (this.runs.length > windowSize) { lines.push( this.theme.fg( "dim", `${windowStart + 1}-${windowStart + visibleRuns.length} of ${this.runs.length} runs`, ), ); } if (this.detail) { const run = this.runs[this.selected]!; const gate = selectedGate(run); lines.push("", this.theme.fg("dim", "─".repeat(Math.max(1, width)))); lines.push(this.theme.bold(run.featureName ?? run.request)); if (run.workspace.branch) lines.push(this.theme.fg("muted", run.workspace.branch)); lines.push("", `${this.theme.bold("Progress")} ${stageLine(run, this.theme)}`); if (gate) { lines.push( "", `${this.theme.bold("Gate")} ${gate.kind} · revision ${gate.revision}`, ); } if (run.planArtifact) { lines.push(`${this.theme.bold("Plan")} ${run.planArtifact.path}`); } if (run.reportArtifact) { lines.push(`${this.theme.bold("Report")} ${run.reportArtifact.path}`); } if (width >= 80 && run.workspace.root) { lines.push(`${this.theme.bold("Worktree")} ${run.workspace.root}`); } lines.push( "", this.theme.fg("dim", "enter actions esc runs"), ); } else { lines.push("", this.theme.fg("dim", "↑↓ run enter details esc close")); } return lines.map((line) => truncateToWidth(line, width)); } invalidate() {} } function runSummary(run: WorkflowRun) { const gate = selectedGate(run); return [ `${run.runId} · ${run.status} · revision ${run.revision}`, `Stage: ${activeStage(run)}`, ...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []), ...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []), ...(run.workspace.root ? [`Worktree: ${run.workspace.root}`] : []), ...(run.planArtifact ? [`Plan: ${run.planArtifact.path}`] : []), ...(run.reportArtifact ? [`Report: ${run.reportArtifact.path}`] : []), ].join("\n"); } function idempotencyKey(run: WorkflowRun, decision: GateDecision) { return `dashboard-${run.runId}-${run.revision}-${decision.kind}-${randomUUID()}`; } async function decide( pi: Pick, runtime: ContextTransitionRuntime, ctx: ExtensionContext, run: WorkflowRun, decision: GateDecision, ) { const gate = selectedGate(run); if (!gate) throw new Error("Workflow Run has no open Gate"); const engine = await runtime.engineForContext(ctx); return executeHostDecision(pi, engine, { action: "decide", runId: run.runId, gateId: gate.id, expectedRevision: run.revision, decision, idempotencyKey: idempotencyKey(run, decision), }); } async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) { const gate = selectedGate(run); const payload = gate?.payload; if (!payload || typeof payload !== "object") return undefined; const questions = (payload as { questions?: unknown }).questions; if (!Array.isArray(questions)) return undefined; const answers: Record = {}; for (const item of questions) { if (!item || typeof item !== "object") continue; const id = (item as { id?: unknown }).id; const prompt = (item as { prompt?: unknown }).prompt; if (typeof id !== "string" || typeof prompt !== "string") continue; const answer = await ctx.ui.input(prompt, "Required answer"); if (!answer?.trim()) return undefined; answers[id] = answer.trim(); } return { kind: "answer-questions" as const, answers }; } async function planDecision(ctx: ExtensionContext, run: WorkflowRun) { if (!run.planArtifact || !run.uiImpact) return undefined; const action = await ctx.ui.select("Plan Approval", [ "Inspect plan", `Approve plan · UI impact ${run.uiImpact}`, "Request revision", ]); if (action === "Inspect plan") { const plan = await readVerifiedPlanArtifact(run.planArtifact); await ctx.ui.editor("Approved plan (read-only copy)", plan.slice(0, 32 * 1024)); return undefined; } if (action === "Request revision") { const feedback = await ctx.ui.editor("Plan revision feedback", ""); return feedback?.trim() ? { kind: "revise-plan" as const, feedback: feedback.trim() } : undefined; } if (action?.startsWith("Approve plan")) { const approved = await ctx.ui.confirm( "Approve plan", `Approve plan revision ${run.planArtifact.revision} and explicitly accept UI impact ${run.uiImpact}?`, ); return approved ? { kind: "approve-plan" as const, approved: true as const, acceptedUiImpact: run.uiImpact, } : undefined; } return undefined; } async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) { const candidates = run.prototypeCandidates.slice(-3); const items = candidates.map( (candidate, index) => `${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`, ); const action = await ctx.ui.select("Prototype Acceptance", [ ...items, "Request refinement", "Abort run", ]); if (action === "Request refinement") { const feedback = await ctx.ui.editor("Prototype refinement feedback", ""); return feedback?.trim() ? { kind: "refine-prototypes" as const, feedback: feedback.trim() } : undefined; } if (action === "Abort run") return "abort" as const; const index = items.indexOf(action ?? ""); const candidate: PrototypeCandidate | undefined = candidates[index]; if (!candidate) return undefined; ctx.ui.notify( [ candidate.launchInstructions, `Route: ${candidate.route}`, `Evidence: ${candidate.retainedArtifacts.join(", ")}`, ].join("\n"), "info", ); const browserReviewed = await ctx.ui.confirm( "Accept prototype", "Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.", ); if (!browserReviewed) return undefined; const feedback = await ctx.ui.input("Acceptance feedback", "Optional"); return { kind: "accept-prototype" as const, candidateId: candidate.id, candidateRevision: candidate.revision, browserReviewed: true as const, ...(feedback?.trim() ? { feedback: feedback.trim() } : {}), }; } async function implementationContextDecision(ctx: ExtensionContext) { const strategy = await ctx.ui.select("Implementation Context", [ "Fresh plan-seeded session", "Compact current session", "Keep current context unchanged", ]); if (strategy === "Fresh plan-seeded session") { return { kind: "select-implementation-context" as const, strategy: "fresh-session" as const }; } if (strategy === "Compact current session") { return { kind: "select-implementation-context" as const, strategy: "smart-compact" as const }; } if (strategy === "Keep current context unchanged") { return { kind: "select-implementation-context" as const, strategy: "current-session" as const }; } return undefined; } async function recoveryDecision(ctx: ExtensionContext) { const resolution = await ctx.ui.select("Recovery", ["Retry", "Inspect", "Abort"]); if (!resolution) return undefined; return { kind: "recover" as const, resolution: resolution.toLowerCase() as "retry" | "inspect" | "abort", }; } async function gateDecision(ctx: ExtensionContext, run: WorkflowRun) { const gate = selectedGate(run); if (!gate) return undefined; if (gate.kind === "questions") return questionDecision(ctx, run); if (gate.kind === "plan-approval") return planDecision(ctx, run); if (gate.kind === "prototype-acceptance") return prototypeDecision(ctx, run); if (gate.kind === "implementation-context") { return implementationContextDecision(ctx); } return recoveryDecision(ctx); } function validContinuation(run: WorkflowRun, sessionId?: string) { return isValidContextContinuation( { ...run, openGate: selectedGate(run) }, sessionId, ); } function isActiveParent(run: WorkflowRun, sessionId?: string) { return Boolean( sessionId && run.lease?.activeParentSessionId === sessionId, ); } async function browsePrototypeArtifacts( ctx: ExtensionContext, run: WorkflowRun, ) { const candidates = run.prototypeCandidates; const labels = candidates.map( (candidate, index) => `${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`, ); const selected = await ctx.ui.select("Prototype evidence", labels); const candidate = candidates[labels.indexOf(selected ?? "")]; if (!candidate) return; ctx.ui.notify( `${candidate.launchInstructions}\nRoute: ${candidate.route}`, "info", ); const artifacts = [candidate.artifactPath, ...candidate.retainedArtifacts]; const artifact = await ctx.ui.select("Retained artifact", artifacts); if (!artifact) return; if (artifact.endsWith(".json")) { const contents = await readFile(artifact, "utf8"); await ctx.ui.editor("Retained prototype evidence", contents.slice(0, 32 * 1024)); } else { ctx.ui.notify(artifact, "info"); } } async function browseAssignmentTranscripts( ctx: ExtensionContext, run: WorkflowRun, ) { const assignments = run.assignments.filter( (assignment) => assignment.transcriptArtifact, ); const labels = assignments.map( (assignment, index) => `${index + 1}. ${assignment.role}/${assignment.profileName} · ${assignment.status}`, ); const selected = await ctx.ui.select("Assignment transcripts", labels); const assignment = assignments[labels.indexOf(selected ?? "")]; if (!assignment?.transcriptArtifact) return; const transcript = await readVerifiedTranscriptArtifact( assignment.transcriptArtifact, ); await ctx.ui.editor( `${assignment.role} transcript`, transcript.slice(0, 32 * 1024), ); } async function actionSheet( pi: ExtensionAPI, runtime: ContextTransitionRuntime, ctx: ExtensionCommandContext, run: WorkflowRun, ) { const actions = [ "Inspect run", ...(selectedGate(run) && isActiveParent(run, ctx.sessionManager.getSessionId()) ? ["Record Gate Decision"] : []), ...(validContinuation(run, ctx.sessionManager.getSessionId()) ? ["Continue context transition"] : []), ...(run.planArtifact ? ["Open plan"] : []), ...(run.reportArtifact ? ["Open report"] : []), ...(run.prototypeCandidates.length > 0 ? ["Browse prototype evidence"] : []), ...(run.assignments.some((assignment) => assignment.transcriptArtifact) ? ["Browse Assignment transcripts"] : []), ...(run.workspace.root ? ["Show worktree path"] : []), ]; const action = await ctx.ui.select(run.featureName ?? run.runId, actions); if (action === "Inspect run") ctx.ui.notify(runSummary(run), "info"); else if (action === "Open plan") { const plan = await readVerifiedPlanArtifact(run.planArtifact!); await ctx.ui.editor("Workflow plan", plan.slice(0, 32 * 1024)); } else if (action === "Open report") { const report = await readVerifiedReportArtifact(run.reportArtifact!); await ctx.ui.editor("Workflow report", report.slice(0, 32 * 1024)); } else if (action === "Browse prototype evidence") { await browsePrototypeArtifacts(ctx, run); } else if (action === "Browse Assignment transcripts") { await browseAssignmentTranscripts(ctx, run); } else if (action === "Show worktree path") { ctx.ui.notify(run.workspace.root!, "info"); } else if (action === "Continue context transition") { return { continueRunId: run.runId }; } else if (action === "Record Gate Decision") { const decision = await gateDecision(ctx, run); if (decision === "abort") { const engine = await runtime.engineForContext(ctx); await engine.execute({ action: "abort", runId: run.runId, expectedRevision: run.revision, idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`, }); } else if (decision) { const result = await decide(pi, runtime, ctx, run, decision); if ( decision.kind === "select-implementation-context" && decision.strategy === "smart-compact" ) { ctx.ui.notify( `Workflow ${run.runId} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`, "info", ); } else { ctx.ui.notify(`Workflow ${run.runId}: ${result.status}`, "info"); } } } return {}; } export interface DashboardResult { continueRunId?: string; } export async function openWorkflowDashboard( pi: ExtensionAPI, runtime: ContextTransitionRuntime, ctx: ExtensionCommandContext, initialRunId?: string, ): Promise { const sessionId = ctx.sessionManager.getSessionId(); const runs = (await runtime.store.listRuns()) .filter((run) => !sessionId || run.parentSessionIds.includes(sessionId)) .sort((left, right) => right.revision - left.revision); if (!ctx.hasUI || ctx.mode !== "tui") { const visibleRuns = runs.slice(0, 20); const omitted = runs.length - visibleRuns.length; ctx.ui.notify( runs.length > 0 ? `${visibleRuns.map(runSummary).join("\n\n")}${ omitted > 0 ? `\n\n[${omitted} older runs omitted]` : "" }` : "No Workflow Runs.", "info", ); return {}; } const selected = await ctx.ui.custom( (tui, theme, _keybindings, done) => { const dashboard = new FocusedWorkflowDashboard( runs, theme as DashboardTheme, done, initialRunId, ); return { render: (width) => dashboard.render(width), invalidate: () => dashboard.invalidate(), handleInput: (data) => { dashboard.handleInput(data); tui.requestRender(); }, }; }, ); if (!selected) return {}; const run = runs.find((candidate) => candidate.runId === selected); return run ? actionSheet(pi, runtime, ctx, run) : {}; }