diff --git a/src/workflow-tui.test.ts b/src/workflow-tui.test.ts index 59696588..820210c6 100644 --- a/src/workflow-tui.test.ts +++ b/src/workflow-tui.test.ts @@ -29,14 +29,25 @@ const project: WorkflowProjectView = { phases: [ { title: "Implementation", + status: "running", calls: [ { callIndex: 1, status: "running", provider: "codex", label: "Patch auth", + prompt: "Patch auth", + responseText: "Tests pass", isolation: "worktree", fromCache: false, + observations: [{ + seq: 1, + kind: "activity", + toolName: "bash", + toolStatus: "completed", + message: "Running tests", + createdAt: "2026-07-26T10:00:02.000Z", + }], updatedAt: "2026-07-26T10:00:02.000Z", }, ], @@ -66,6 +77,12 @@ assert.match(rendered, /Review auth · Implementation/); assert.match(rendered, /Patch auth codex · worktree/); assert.match(rendered, /Running tests/); assert.match(rendered, /refreshes automatically/); +const inspected = renderWorkflowTui(project, 0, 100, 30, { + ansi: false, + selection: { runIndex: 0, phaseIndex: 0, callIndex: 0, focus: "inspector" }, +}); +assert.match(inspected, /Call inspector · Patch auth/); +assert.match(inspected, /bash · completed · Running tests/); assert.equal(resolveWorkflowTuiWorkspaceRoot("./test-project").endsWith("test-project"), true); console.log("workflow-tui.test.ts: ok"); diff --git a/src/workflow-tui.ts b/src/workflow-tui.ts index 5cdc0e32..7e17318e 100644 --- a/src/workflow-tui.ts +++ b/src/workflow-tui.ts @@ -6,11 +6,20 @@ import { ACTIVE_WORKFLOW_STATUSES, loadWorkflowProjectView, type WorkflowCallView, + type WorkflowPhaseView, type WorkflowProjectView, type WorkflowRunView, } from "./workflow-view.js"; const REFRESH_MS = 750; +type TuiFocus = "runs" | "phases" | "calls" | "inspector"; + +export interface WorkflowTuiSelection { + runIndex: number; + phaseIndex: number; + callIndex: number; + focus: TuiFocus; +} export async function runWorkflowTui( args: string[], @@ -25,14 +34,15 @@ export async function runWorkflowTui( statuses: requestedRunId ? undefined : [...ACTIVE_WORKFLOW_STATUSES], limit: 50, eventLimit: 100, + observationLimit: 100, }); if (!process.stdin.isTTY || !process.stdout.isTTY) { try { const view = load(); - const selectedIndex = findInitialSelection(view, requestedRunId); + const selection = createSelection(view, findInitialSelection(view, requestedRunId)); process.stdout.write( - `${renderWorkflowTui(view, selectedIndex, 100, 40, { ansi: false })}\n`, + `${renderWorkflowTui(view, selection.runIndex, 100, 40, { ansi: false, selection })}\n`, ); return; } finally { @@ -41,7 +51,7 @@ export async function runWorkflowTui( } let project = load(); - let selectedIndex = findInitialSelection(project, requestedRunId); + let selection = createSelection(project, findInitialSelection(project, requestedRunId)); let closed = false; let rendering = false; @@ -50,14 +60,14 @@ export async function runWorkflowTui( rendering = true; try { project = load(); - selectedIndex = clampSelection(project, selectedIndex, requestedRunId); + selection = reconcileSelection(project, selection, requestedRunId); process.stdout.write( `\u001b[H\u001b[2J${renderWorkflowTui( project, - selectedIndex, + selection.runIndex, process.stdout.columns || 100, process.stdout.rows || 40, - { ansi: true }, + { ansi: true, selection }, )}`, ); } finally { @@ -84,17 +94,48 @@ export async function runWorkflowTui( const onKeypress = ( _input: string, - key: { name?: string; ctrl?: boolean }, + key: { name?: string; ctrl?: boolean; shift?: boolean }, ): void => { - if ((key.ctrl && key.name === "c") || key.name === "q" || key.name === "escape") { + if ((key.ctrl && key.name === "c") || key.name === "q") { finish(); return; } - if (key.name === "up") { - selectedIndex = Math.max(0, selectedIndex - 1); + if (key.name === "escape") { + if (selection.focus === "inspector") selection.focus = "calls"; + else if (selection.focus === "calls") selection.focus = "phases"; + else if (selection.focus === "phases") selection.focus = "runs"; + else finish(); + render(); + return; + } + if (key.name === "tab") { + selection.focus = nextFocus(selection.focus); + render(); + return; + } + if (key.name === "up" || key.name === "k") { + moveSelection(project, selection, -1); + render(); + } else if (key.name === "down" || key.name === "j") { + moveSelection(project, selection, 1); render(); - } else if (key.name === "down") { - selectedIndex = Math.min(Math.max(0, project.runs.length - 1), selectedIndex + 1); + } else if (key.name === "right" || key.name === "l" || key.name === "return") { + selection.focus = selection.focus === "runs" + ? "phases" + : selection.focus === "phases" + ? "calls" + : selection.focus === "calls" + ? "inspector" + : "inspector"; + render(); + } else if (key.name === "left" || key.name === "h") { + selection.focus = selection.focus === "inspector" + ? "calls" + : selection.focus === "calls" + ? "phases" + : selection.focus === "phases" + ? "runs" + : "runs"; render(); } }; @@ -120,11 +161,12 @@ export function renderWorkflowTui( selectedIndex: number, columns: number, rows: number, - options: { ansi?: boolean } = {}, + options: { ansi?: boolean; selection?: WorkflowTuiSelection } = {}, ): string { const ansi = options.ansi !== false; - const width = Math.max(48, columns); - const selected = project.runs[selectedIndex]; + const width = Math.max(56, columns); + const selection = options.selection ?? createSelection(project, selectedIndex); + const selected = project.runs[selection.runIndex]; const lines: string[] = []; lines.push(style(truncate(`DevSpace workflows · ${project.workspaceRoot}`, width), "bold", ansi)); @@ -137,14 +179,14 @@ export function renderWorkflowTui( return fitRows(lines, rows).join("\n"); } - const maxRunRows = Math.max(3, Math.min(8, Math.floor(rows / 4))); - lines.push(style("Active workflows", "heading", ansi)); + const maxRunRows = Math.max(3, Math.min(7, Math.floor(rows / 5))); + lines.push(style("Workflows", "heading", ansi)); for (const [index, run] of project.runs.slice(0, maxRunRows).entries()) { - const marker = index === selectedIndex ? "›" : " "; + const marker = index === selection.runIndex ? "›" : " "; const phase = run.currentPhase ? ` · ${run.currentPhase}` : ""; lines.push( truncate( - `${marker} ${statusGlyph(run.status)} ${run.name}${phase} · ${callSummary(run)}`, + `${marker} ${statusGlyph(run.status)} ${run.name}${phase} · ${callSummary(run)} · ${durationLabel(run.startedAt, run.completedAt)}`, width, ), ); @@ -154,97 +196,205 @@ export function renderWorkflowTui( } lines.push(rule(width)); - if (selected) renderRunDetails(lines, selected, width, rows, ansi); + if (selected) { + if (selection.focus === "inspector") { + renderCallInspector(lines, selected, selection, width, rows, ansi); + } else { + renderNavigator(lines, selected, selection, width, rows, ansi); + } + } lines.push(rule(width)); - lines.push(style("↑/↓ select · q/esc quit · refreshes automatically", "muted", ansi)); + lines.push(style("↑/↓ move · ←/→ or enter drill in · tab focus · esc back · q quit · refreshes automatically", "muted", ansi)); return fitRows(lines, rows).join("\n"); } -function renderRunDetails( +function renderNavigator( lines: string[], run: WorkflowRunView, + selection: WorkflowTuiSelection, width: number, rows: number, ansi: boolean, ): void { - lines.push(`${style(run.name, "bold", ansi)} ${statusGlyph(run.status)} ${run.status}`); lines.push( truncate( - `${run.currentPhase ? `Phase: ${run.currentPhase} · ` : ""}${callSummary(run)} · ${elapsedLabel(run)}`, + `${run.name} ${statusGlyph(run.status)} ${run.status} · ${callSummary(run)} · ${usageLabel(run.usage)} · ${durationLabel(run.startedAt, run.completedAt)}`, width, ), ); + if (run.error) lines.push(style(truncate(`${run.errorKind ?? "error"}: ${run.error}`, width), "error", ansi)); - const phaseBudget = Math.max(4, Math.floor(rows / 2)); - let renderedCalls = 0; - for (const phase of run.phases) { - if (renderedCalls >= phaseBudget) break; - lines.push(style(`\n${phase.title}`, "heading", ansi)); - for (const call of phase.calls) { - if (renderedCalls >= phaseBudget) break; - lines.push(truncate(formatCall(call), width)); - renderedCalls += 1; + const phase = selectedPhase(run, selection.phaseIndex); + lines.push(style("\nNavigator · phases", "heading", ansi)); + if (run.phases.length === 0) { + lines.push(style(" No phase markers yet.", "muted", ansi)); + } else { + for (const [index, item] of run.phases.entries()) { + const marker = index === selection.phaseIndex ? "›" : " "; + lines.push( + truncate( + `${marker} ${phaseGlyph(item.status)} ${item.title} · ${item.calls.length} calls · ${usageLabel(item.usage)} · ${durationLabel(item.startedAt, item.completedAt)}`, + width, + ), + ); } } - if (run.unphasedCalls.length > 0 && renderedCalls < phaseBudget) { - lines.push(style("\nOther calls", "heading", ansi)); - for (const call of run.unphasedCalls) { - if (renderedCalls >= phaseBudget) break; - lines.push(truncate(formatCall(call), width)); - renderedCalls += 1; + + if (phase) { + lines.push(style(`\n${phase.title} · ${phase.status}`, "heading", ansi)); + const calls = phase.calls; + if (calls.length === 0) lines.push(style(" No agent calls in this phase.", "muted", ansi)); + for (const [index, call] of calls.entries()) { + const marker = index === selection.callIndex && (selection.focus === "calls" || selection.focus === "phases") ? "›" : " "; + lines.push(truncate(formatCall(call, marker), width)); } + renderActivityPreview(lines, calls, selection.callIndex, width, ansi); + } else if (run.unphasedCalls.length > 0) { + lines.push(style("\nOther calls", "heading", ansi)); + for (const call of run.unphasedCalls) lines.push(truncate(formatCall(call, " "), width)); } - const activity = run.recentActivity.slice(-4); - if (activity.length > 0) { - lines.push(style("\nRecent activity", "heading", ansi)); - for (const event of activity) { - const time = new Date(event.createdAt).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + if (run.recentActivity.length > 0 && rows > 18) { + lines.push(style("\nRun activity", "heading", ansi)); + for (const event of run.recentActivity.slice(-3)) { const label = event.label ?? event.phase ?? event.type.replaceAll("_", " "); - const detail = event.detail ? `: ${event.detail}` : ""; - lines.push(truncate(`${time} ${label}${detail}`, width)); + lines.push(truncate(`${shortTime(event.createdAt)} ${label}${event.detail ? ` · ${event.detail}` : ""}`, width)); } } +} - if (run.error) { - lines.push(style(`\n${run.errorKind ?? "error"}: ${run.error}`, "error", ansi)); +function renderCallInspector( + lines: string[], + run: WorkflowRunView, + selection: WorkflowTuiSelection, + width: number, + rows: number, + ansi: boolean, +): void { + const call = selectedCall(run, selection.phaseIndex, selection.callIndex); + if (!call) { + lines.push(style("Call inspector · no call selected", "heading", ansi)); + return; + } + const label = call.label ?? `Agent #${call.callIndex}`; + const target = call.model ? `${call.provider}/${call.model}` : call.provider; + lines.push(style(`Call inspector · ${label}`, "heading", ansi)); + lines.push(truncate(`${statusGlyph(call.status)} ${target} · ${usageLabel(call.finalUsage ?? call.usage)} · ${durationLabel(call.startedAt, call.completedAt)}`, width)); + lines.push(truncate(`phase ${call.phase ?? "unphased"} · ${call.isolation}${call.profileName ? ` · profile ${call.profileName}` : ""}${call.providerSessionId ? ` · session ${call.providerSessionId}` : ""}`, width)); + lines.push(style("\nPrompt", "heading", ansi)); + lines.push(truncate(call.prompt, width)); + + lines.push(style("\nActivity", "heading", ansi)); + const activity = call.observations.filter((entry) => entry.kind === "activity"); + if (activity.length === 0) lines.push(style(" No provider activity reported yet.", "muted", ansi)); + for (const entry of activity.slice(-Math.max(3, rows - 17))) { + const tool = entry.toolName ? `${entry.toolName}${entry.toolStatus ? ` · ${entry.toolStatus}` : ""}` : "agent"; + const detail = entry.message ?? entry.detail; + lines.push(truncate(`${shortTime(entry.createdAt)} ${activityGlyph(entry.toolStatus)} ${tool}${detail ? ` · ${detail}` : ""}`, width)); + } + + if (call.error) lines.push(style(`\n${call.errorKind ?? "error"}: ${call.error}`, "error", ansi)); + if (call.responseText) { + lines.push(style("\nResult", "heading", ansi)); + for (const line of call.responseText.split(/\r?\n/).slice(0, Math.max(2, rows - lines.length - 3))) { + lines.push(truncate(line, width)); + } } } -function findInitialSelection( +function renderActivityPreview( + lines: string[], + calls: WorkflowCallView[], + selectedCallIndex: number, + width: number, + ansi: boolean, +): void { + const call = calls[selectedCallIndex]; + if (!call) return; + const activity = call.observations.filter((entry) => entry.kind === "activity").slice(-2); + if (activity.length === 0) return; + lines.push(style("\nSelected call activity", "heading", ansi)); + for (const entry of activity) { + lines.push(truncate(`${shortTime(entry.createdAt)} ${activityGlyph(entry.toolStatus)} ${entry.toolName ?? "agent"}${entry.message ? ` · ${entry.message}` : ""}`, width)); + } +} + +function createSelection(project: WorkflowProjectView, runIndex: number): WorkflowTuiSelection { + return { + runIndex, + phaseIndex: 0, + callIndex: 0, + focus: "runs", + }; +} + +function reconcileSelection( project: WorkflowProjectView, + selection: WorkflowTuiSelection, requestedRunId: string | undefined, -): number { +): WorkflowTuiSelection { + const runIndex = requestedRunId + ? findInitialSelection(project, requestedRunId) + : Math.min(Math.max(0, selection.runIndex), Math.max(0, project.runs.length - 1)); + const run = project.runs[runIndex]; + const phaseIndex = run + ? Math.min(Math.max(0, selection.phaseIndex), Math.max(0, run.phases.length - 1)) + : 0; + const phase = run?.phases[phaseIndex]; + const callIndex = phase + ? Math.min(Math.max(0, selection.callIndex), Math.max(0, phase.calls.length - 1)) + : 0; + return { ...selection, runIndex, phaseIndex, callIndex }; +} + +function moveSelection(project: WorkflowProjectView, selection: WorkflowTuiSelection, delta: number): void { + const run = project.runs[selection.runIndex]; + if (selection.focus === "runs") { + selection.runIndex = Math.min(Math.max(0, selection.runIndex + delta), Math.max(0, project.runs.length - 1)); + selection.phaseIndex = 0; + selection.callIndex = 0; + return; + } + if (selection.focus === "phases") { + selection.phaseIndex = Math.min(Math.max(0, selection.phaseIndex + delta), Math.max(0, (run?.phases.length ?? 1) - 1)); + selection.callIndex = 0; + return; + } + const phase = run?.phases[selection.phaseIndex]; + selection.callIndex = Math.min(Math.max(0, selection.callIndex + delta), Math.max(0, (phase?.calls.length ?? 1) - 1)); +} + +function nextFocus(focus: TuiFocus): TuiFocus { + if (focus === "runs") return "phases"; + if (focus === "phases") return "calls"; + if (focus === "calls") return "inspector"; + return "runs"; +} + +function findInitialSelection(project: WorkflowProjectView, requestedRunId: string | undefined): number { if (!requestedRunId) return 0; const index = project.runs.findIndex((run) => run.id === requestedRunId); if (index < 0) { - throw new Error( - `Workflow ${requestedRunId} does not belong to the current directory: ${project.workspaceRoot}`, - ); + throw new Error(`Workflow ${requestedRunId} does not belong to the current directory: ${project.workspaceRoot}`); } return index; } -function clampSelection( - project: WorkflowProjectView, - selectedIndex: number, - requestedRunId: string | undefined, -): number { - if (requestedRunId) return findInitialSelection(project, requestedRunId); - return Math.min(Math.max(0, selectedIndex), Math.max(0, project.runs.length - 1)); +function selectedPhase(run: WorkflowRunView, index: number): WorkflowPhaseView | undefined { + return run.phases[Math.min(Math.max(0, index), Math.max(0, run.phases.length - 1))]; +} + +function selectedCall(run: WorkflowRunView, phaseIndex: number, callIndex: number): WorkflowCallView | undefined { + return selectedPhase(run, phaseIndex)?.calls[callIndex]; } -function formatCall(call: WorkflowCallView): string { +function formatCall(call: WorkflowCallView, marker: string): string { const label = call.label ?? `Agent #${call.callIndex}`; const provider = call.model ? `${call.provider}/${call.model}` : call.provider; const worktree = call.isolation === "worktree" ? " · worktree" : ""; const replay = call.fromCache ? " · replayed" : ""; const error = call.error ? ` · ${call.errorKind ?? "error"}: ${call.error}` : ""; - return ` ${statusGlyph(call.status)} ${label} ${provider}${worktree}${replay}${error}`; + return `${marker} ${statusGlyph(call.status)} #${call.callIndex} ${label} ${provider}${worktree}${replay} · ${usageLabel(call.finalUsage ?? call.usage)} · ${durationLabel(call.startedAt, call.completedAt)}${error}`; } function callSummary(run: WorkflowRunView): string { @@ -255,20 +405,39 @@ function callSummary(run: WorkflowRunView): string { run.calls.failed ? `${run.calls.failed} failed` : undefined, run.calls.cancelled ? `${run.calls.cancelled} cancelled` : undefined, ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? parts.join(" · ") : "no agent calls yet"; + return parts.length > 0 ? parts.join(" · ") : "no agent calls"; +} + +function usageLabel(usage: WorkflowRunView["usage"] | WorkflowCallView["usage"]): string { + if (!usage) return "tokens n/a"; + if (usage.totalTokens !== undefined) return `${formatNumber(usage.totalTokens)} tok`; + const input = usage.inputTokens ?? 0; + const output = usage.outputTokens ?? 0; + return `${formatNumber(input + output)} tok`; } -function elapsedLabel(run: WorkflowRunView): string { - const start = Date.parse(run.startedAt ?? run.createdAt); - const end = run.completedAt ? Date.parse(run.completedAt) : Date.now(); - const seconds = Math.max(0, Math.floor((end - start) / 1_000)); +function durationLabel(startedAt: string | undefined, completedAt: string | undefined): string { + if (!startedAt) return "time n/a"; + const start = Date.parse(startedAt); + const end = completedAt ? Date.parse(completedAt) : Date.now(); + if (!Number.isFinite(start) || !Number.isFinite(end)) return "time n/a"; + let seconds = Math.max(0, Math.floor((end - start) / 1_000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); - const remaining = seconds % 60; - if (minutes < 60) return `${minutes}m ${remaining}s`; + seconds %= 60; + if (minutes < 60) return `${minutes}m ${seconds}s`; return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; } +function shortTime(createdAt: string): string { + const date = new Date(createdAt); + return Number.isNaN(date.valueOf()) ? "--:--:--" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function formatNumber(value: number): string { + return value >= 1_000_000 ? `${(value / 1_000_000).toFixed(1)}m` : value >= 1_000 ? `${(value / 1_000).toFixed(1)}k` : String(value); +} + function statusGlyph(status: WorkflowRunView["status"] | WorkflowCallView["status"]): string { if (status === "completed" || status === "from_cache") return "✓"; if (status === "failed") return "✕"; @@ -277,6 +446,21 @@ function statusGlyph(status: WorkflowRunView["status"] | WorkflowCallView["statu return "◌"; } +function phaseGlyph(status: WorkflowPhaseView["status"]): string { + if (status === "completed") return "✓"; + if (status === "failed") return "✕"; + if (status === "cancelled") return "−"; + if (status === "running") return "●"; + return "○"; +} + +function activityGlyph(status: WorkflowCallView["observations"][number]["toolStatus"]): string { + if (status === "completed") return "✓"; + if (status === "failed") return "✕"; + if (status === "started" || status === "updated") return "●"; + return "·"; +} + function rule(width: number): string { return "─".repeat(width); } @@ -291,11 +475,7 @@ function fitRows(lines: string[], rows: number): string[] { return lines.slice(0, Math.max(1, rows)); } -function style( - value: string, - tone: "bold" | "heading" | "muted" | "error", - ansi: boolean, -): string { +function style(value: string, tone: "bold" | "heading" | "muted" | "error", ansi: boolean): string { if (!ansi) return value; if (tone === "bold") return `\u001b[1m${value}\u001b[0m`; if (tone === "heading") return `\u001b[1;36m${value}\u001b[0m`; diff --git a/src/workflow-view.test.ts b/src/workflow-view.test.ts index a96ddd8b..7e5d4922 100644 --- a/src/workflow-view.test.ts +++ b/src/workflow-view.test.ts @@ -32,6 +32,7 @@ const calls: WorkflowAgentCallRecord[] = [ phase: "Planning", status: "completed", fromCache: false, + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, isolation: "shared", createdAt: "2026-07-26T10:00:02.000Z", startedAt: "2026-07-26T10:00:02.000Z", @@ -99,7 +100,27 @@ const events: WorkflowEventRecord[] = [ }, ]; -const view = buildWorkflowRunView(run, calls, events); +const observations = new Map([ + [ + 1, + [ + { + runId: run.id, + callIndex: 1, + seq: 1, + provider: "claude" as const, + kind: "activity" as const, + activityId: "tool-1", + toolName: "bash", + toolStatus: "started" as const, + message: "Running tests", + createdAt: "2026-07-26T10:00:04.500Z", + }, + ], + ], +]); + +const view = buildWorkflowRunView(run, calls, events, observations); assert.equal(view.currentPhase, "Implementation"); assert.equal(view.calls.completed, 1); assert.equal(view.calls.running, 1); @@ -108,6 +129,8 @@ assert.equal(view.calls.observed, 3); assert.deepEqual(view.phases.map((phase) => phase.title), ["Planning", "Implementation"]); assert.equal(view.phases[1]?.calls[0]?.worktreePath, "/tmp/worktree"); assert.equal(view.unphasedCalls[0]?.replayedFromRunId, "wfr_old"); +assert.equal(view.phases[1]?.calls[0]?.observations[0]?.toolName, "bash"); +assert.deepEqual(view.usage, { inputTokens: 10, outputTokens: 5, totalTokens: 15 }); assert.equal(view.recentActivity.at(-1)?.detail, "Running tests"); assert.equal(view.latestEventSeq, 3); diff --git a/src/workflow-view.ts b/src/workflow-view.ts index 919d5347..8171ffdd 100644 --- a/src/workflow-view.ts +++ b/src/workflow-view.ts @@ -2,8 +2,10 @@ import { resolve } from "node:path"; import { parseWorkflowEventPayload } from "./workflow-contracts.js"; import type { WorkflowStore } from "./workflow-store.js"; import type { + LocalAgentTokenUsage, WorkflowAgentCallRecord, WorkflowAgentCallStatus, + WorkflowAgentObservationRecord, WorkflowErrorKind, WorkflowEventRecord, WorkflowEventType, @@ -23,14 +25,32 @@ export interface WorkflowCallCounts { observed: number; } +export type WorkflowPhaseStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; + +export interface WorkflowObservationView { + seq: number; + kind: "activity" | "usage"; + activityId?: string; + message?: string; + toolName?: string; + toolStatus?: "started" | "updated" | "completed" | "failed"; + detail?: string; + usage?: LocalAgentTokenUsage; + createdAt: string; +} + export interface WorkflowCallView { callIndex: number; status: WorkflowAgentCallStatus; provider: string; model?: string; effort?: string; + profileName?: string; label?: string; phase?: string; + prompt: string; + responseText?: string; + providerSessionId?: string; isolation: "shared" | "worktree"; worktreePath?: string; dirty?: boolean; @@ -41,6 +61,10 @@ export interface WorkflowCallView { replayReason?: string; error?: string; errorKind?: WorkflowErrorKind; + usage?: LocalAgentTokenUsage; + finalUsage?: LocalAgentTokenUsage; + observations: WorkflowObservationView[]; + durationMs?: number; startedAt?: string; completedAt?: string; updatedAt: string; @@ -48,7 +72,12 @@ export interface WorkflowCallView { export interface WorkflowPhaseView { title: string; + status: WorkflowPhaseStatus; calls: WorkflowCallView[]; + usage?: LocalAgentTokenUsage; + startedAt?: string; + completedAt?: string; + durationMs?: number; } export interface WorkflowActivityView { @@ -71,6 +100,7 @@ export interface WorkflowRunView { resumedFromRunId?: string; currentPhase?: string; calls: WorkflowCallCounts; + usage?: LocalAgentTokenUsage; phases: WorkflowPhaseView[]; unphasedCalls: WorkflowCallView[]; recentActivity: WorkflowActivityView[]; @@ -97,6 +127,7 @@ export function loadWorkflowProjectView( statuses?: WorkflowRunStatus[]; limit?: number; eventLimit?: number; + observationLimit?: number; } = {}, ): WorkflowProjectView { const root = resolve(workspaceRoot); @@ -105,13 +136,21 @@ export function loadWorkflowProjectView( statuses: options.statuses, limit: options.limit, }) - .map((run) => - buildWorkflowRunView( + .map((run) => { + const calls = store.listAgentCalls(run.id); + const observations = new Map( + calls.map((call) => [ + call.callIndex, + store.listAgentObservations(run.id, call.callIndex, options.observationLimit ?? 100), + ]), + ); + return buildWorkflowRunView( run, - store.listAgentCalls(run.id), + calls, store.listEvents(run.id, options.eventLimit ?? 100), - ), - ); + observations, + ); + }); return { workspaceRoot: root, @@ -124,31 +163,61 @@ export function buildWorkflowRunView( run: WorkflowRunRecord, calls: WorkflowAgentCallRecord[], events: WorkflowEventRecord[], + observations = new Map(), ): WorkflowRunView { - const callViews = calls.map(toCallView); + const callViews = calls.map((call) => + toCallView(call, observations.get(call.callIndex) ?? []), + ); const phaseOrder: string[] = []; + const phaseMeta = new Map(); let currentPhase: string | undefined; for (const event of events) { if (event.type !== "phase_started") continue; const title = event.phase ?? parsePhaseTitle(event); if (!title) continue; + if (currentPhase && currentPhase !== title) { + const previous = phaseMeta.get(currentPhase); + if (previous?.status === "running") { + previous.status = "completed"; + previous.completedAt = event.createdAt; + } + } currentPhase = title; if (!phaseOrder.includes(title)) phaseOrder.push(title); + phaseMeta.set(title, { + startedAt: phaseMeta.get(title)?.startedAt ?? event.createdAt, + status: "running", + }); } for (const call of callViews) { if (call.phase && !phaseOrder.includes(call.phase)) phaseOrder.push(call.phase); } - const phases = phaseOrder.map((title) => ({ - title, - calls: callViews.filter((call) => call.phase === title), - })); + const phases = phaseOrder.map((title) => { + const phaseCalls = callViews.filter((call) => call.phase === title); + const meta = phaseMeta.get(title); + const status = phaseStatus(run.status, meta?.status, phaseCalls); + const completedAt = meta?.completedAt ?? (isTerminalRun(run.status) ? run.completedAt : undefined); + return { + title, + status, + calls: phaseCalls, + usage: sumUsage(phaseCalls.map((call) => call.finalUsage ?? call.usage)), + startedAt: meta?.startedAt ?? phaseCalls[0]?.startedAt, + completedAt, + durationMs: elapsedMs(meta?.startedAt ?? phaseCalls[0]?.startedAt, completedAt), + }; + }); + const latestEventSeq = events.at(-1)?.seq ?? 0; const latestCallUpdate = calls.reduce( (latest, call) => call.updatedAt > latest ? call.updatedAt : latest, run.updatedAt, ); + const latestObservation = callViews + .flatMap((call) => call.observations) + .at(-1)?.createdAt; return { id: run.id, @@ -161,11 +230,12 @@ export function buildWorkflowRunView( resumedFromRunId: run.resumedFromRunId, currentPhase, calls: countCalls(callViews), + usage: sumUsage(callViews.map((call) => call.finalUsage ?? call.usage)), phases, unphasedCalls: callViews.filter((call) => !call.phase), recentActivity: events.map(toActivityView), latestEventSeq, - version: `${run.updatedAt}:${latestCallUpdate}:${latestEventSeq}`, + version: `${run.updatedAt}:${latestCallUpdate}:${latestObservation ?? ""}:${latestEventSeq}`, error: run.error, errorKind: run.errorKind, createdAt: run.createdAt, @@ -175,15 +245,22 @@ export function buildWorkflowRunView( }; } -function toCallView(call: WorkflowAgentCallRecord): WorkflowCallView { +function toCallView( + call: WorkflowAgentCallRecord, + observations: WorkflowAgentObservationRecord[], +): WorkflowCallView { return { callIndex: call.callIndex, status: call.status, provider: call.provider, model: call.model, effort: call.effort, + profileName: call.profileName, label: call.label, phase: call.phase, + prompt: call.prompt, + responseText: call.responseText, + providerSessionId: call.providerSessionId, isolation: call.isolation, worktreePath: call.worktreePath, dirty: call.dirty, @@ -194,12 +271,30 @@ function toCallView(call: WorkflowAgentCallRecord): WorkflowCallView { replayReason: call.replayReason, error: call.error, errorKind: call.errorKind, + usage: call.usage, + finalUsage: call.finalUsage, + observations: observations.map(toObservationView), + durationMs: elapsedMs(call.startedAt, call.completedAt), startedAt: call.startedAt, completedAt: call.completedAt, updatedAt: call.updatedAt, }; } +function toObservationView(observation: WorkflowAgentObservationRecord): WorkflowObservationView { + return { + seq: observation.seq, + kind: observation.kind, + activityId: observation.activityId, + message: observation.message, + toolName: observation.toolName, + toolStatus: observation.toolStatus, + detail: observation.detail, + usage: observation.usage, + createdAt: observation.createdAt, + }; +} + function countCalls(calls: WorkflowCallView[]): WorkflowCallCounts { const counts: WorkflowCallCounts = { running: 0, @@ -230,6 +325,48 @@ function toActivityView(event: WorkflowEventRecord): WorkflowActivityView { }; } +function phaseStatus( + runStatus: WorkflowRunStatus, + inferred: WorkflowPhaseStatus | undefined, + calls: WorkflowCallView[], +): WorkflowPhaseStatus { + if (calls.some((call) => call.status === "failed")) return "failed"; + if (calls.some((call) => call.status === "cancelled")) return "cancelled"; + if (calls.some((call) => call.status === "running")) return "running"; + if (inferred === "running" && !isTerminalRun(runStatus)) return "running"; + if (calls.length > 0 && calls.every((call) => + call.status === "completed" || call.status === "from_cache" + )) return "completed"; + return inferred ?? "pending"; +} + +function isTerminalRun(status: WorkflowRunStatus): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} + +function sumUsage(usages: Array): LocalAgentTokenUsage | undefined { + const present = usages.filter((usage): usage is LocalAgentTokenUsage => Boolean(usage)); + if (present.length === 0) return undefined; + const sum = (key: keyof LocalAgentTokenUsage): number | undefined => { + const values = present.map((usage) => usage[key]).filter((value): value is number => value !== undefined); + return values.length ? values.reduce((total, value) => total + value, 0) : undefined; + }; + const result: LocalAgentTokenUsage = {}; + for (const key of ["inputTokens", "outputTokens", "totalTokens", "cacheReadTokens", "cacheWriteTokens"] as const) { + const value = sum(key); + if (value !== undefined) result[key] = value; + } + return result; +} + +function elapsedMs(startedAt: string | undefined, completedAt: string | undefined): number | undefined { + if (!startedAt) return undefined; + const end = completedAt ? Date.parse(completedAt) : Date.now(); + const start = Date.parse(startedAt); + if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined; + return Math.max(0, end - start); +} + function activityDetail(event: WorkflowEventRecord): string | undefined { try { if (event.type === "log") {