Files
harness-tools/extensions/workflows/dashboard.ts
T

1064 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* /workflows dashboard: a full-screen overlay with a run list and a per-run
* detail view (phases sidebar + agents panel), modeled after:
*
* name 5/5 agents · 31m18s · done
* description
* ╭ Phases ────────────╮ ╭ Gather · 3 agents ──────────────────────────────╮
* │ ❯ ■ Gather 3/3 │ │ ■ CodeRabbit feedback gpt-5 · 7%/372k 5m37s│
* │ ■ Verify 1/1 │ │ ■ Other bot feedback gpt-5 · 9%/372k 4m43s│
* ╰────────────────────╯ ╰─────────────────────────────────────────────────╯
* up/down select · esc back · s save report
*/
import * as fs from "node:fs";
import * as path from "node:path";
import {
getAgentDir,
type ExtensionContext,
type KeybindingsManager,
} from "@earendil-works/pi-coding-agent";
import {
Key,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type TUI,
} from "@earendil-works/pi-tui";
import {
agentContext,
countStates,
formatElapsed,
formatUsage,
aggregateUsage,
phaseGroups,
resultJson,
shortenHome,
stateSquare,
statusColor,
statusWord,
SQUARE,
type Theme,
type AgentRecord,
type PhaseGroup,
type TranscriptEntry,
type WorkflowDetails,
} from "./model.ts";
const NOTICE_TTL_MS = 4000;
const MIN_HEIGHT = 10;
const TRANSCRIPT_SCROLL_STEP = 20;
function wrapSelection(index: number, delta: number, length: number): number {
if (length === 0) return 0;
return (index + delta + length) % length;
}
export interface RunEntry {
runId: string;
details: WorkflowDetails;
live: boolean;
}
function runsDir(): string {
return path.join(getAgentDir(), "workflows");
}
function normalizeTranscript(value: unknown): TranscriptEntry[] {
if (!Array.isArray(value)) return [];
const transcript: TranscriptEntry[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const entry = item as Record<string, unknown>;
if (
entry.role !== "user" &&
entry.role !== "assistant" &&
entry.role !== "thinking" &&
entry.role !== "tool" &&
entry.role !== "toolResult"
) {
continue;
}
if (typeof entry.text !== "string") continue;
transcript.push({
role: entry.role,
text: entry.text,
name: typeof entry.name === "string" ? entry.name : undefined,
isError: entry.isError === true,
timestamp:
typeof entry.timestamp === "number" ? entry.timestamp : undefined,
});
}
return transcript;
}
/** Leniently normalize a workflow.json (including runs from older tooling). */
function normalizeDetails(
runId: string,
raw: unknown,
): WorkflowDetails | undefined {
if (!raw || typeof raw !== "object") return undefined;
const record = raw as Record<string, unknown>;
const meta = (record.meta ?? {}) as Record<string, unknown>;
const rawAgents = Array.isArray(record.agents) ? record.agents : [];
const startedAt = typeof record.startedAt === "number" ? record.startedAt : 0;
const agents: AgentRecord[] = [];
for (const item of rawAgents) {
if (!item || typeof item !== "object") continue;
const a = item as Record<string, unknown>;
const state =
a.state === "error" || a.state === "failed"
? "error"
: a.state === "running"
? "running"
: "done";
agents.push({
index: typeof a.index === "number" ? a.index : agents.length + 1,
label:
typeof a.label === "string" ? a.label : `agent-${agents.length + 1}`,
phase: typeof a.phase === "string" ? a.phase : undefined,
state,
model: typeof a.model === "string" ? a.model : undefined,
contextWindow:
typeof a.contextWindow === "number" &&
Number.isFinite(a.contextWindow) &&
a.contextWindow > 0
? a.contextWindow
: undefined,
startedAt: typeof a.startedAt === "number" ? a.startedAt : startedAt,
finishedAt: typeof a.finishedAt === "number" ? a.finishedAt : undefined,
error:
typeof a.error === "string" && a.error !== "[undefined]"
? a.error
: undefined,
preview: typeof a.preview === "string" ? a.preview : "",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: 0,
turns: 0,
...(a.usage && typeof a.usage === "object" ? (a.usage as object) : {}),
},
transcript: normalizeTranscript(a.transcript),
});
}
const rawPhases = Array.isArray(record.phases)
? record.phases
: Array.isArray(meta.phases)
? meta.phases
: [];
const phases: WorkflowDetails["phases"] = [];
for (const item of rawPhases) {
if (!item || typeof item !== "object") continue;
const p = item as Record<string, unknown>;
if (typeof p.title !== "string") continue;
phases.push({
title: p.title,
...(typeof p.detail === "string" ? { detail: p.detail } : {}),
});
}
const status =
record.status === "running" ||
record.status === "failed" ||
record.status === "aborted"
? record.status
: "completed";
return {
runId,
sessionId:
typeof record.sessionId === "string" ? record.sessionId : undefined,
name:
typeof record.name === "string"
? record.name
: typeof meta.name === "string"
? meta.name
: undefined,
description:
typeof record.description === "string"
? record.description
: typeof meta.description === "string"
? meta.description
: undefined,
background: record.background === true,
status,
startedAt,
finishedAt:
typeof record.finishedAt === "number" ? record.finishedAt : undefined,
phases,
currentPhase:
typeof record.currentPhase === "string" ? record.currentPhase : undefined,
agents,
result: record.result,
resultArtifact:
typeof record.resultArtifact === "string"
? record.resultArtifact
: undefined,
transcriptArtifact:
typeof record.transcriptArtifact === "string"
? record.transcriptArtifact
: undefined,
error: typeof record.error === "string" ? record.error : undefined,
};
}
export function sessionWorkflowRunIds(ctx: ExtensionContext): Set<string> {
const runIds = new Set<string>();
for (const entry of ctx.sessionManager.getEntries()) {
if (
entry.type !== "message" ||
entry.message.role !== "toolResult" ||
entry.message.toolName !== "workflow"
) {
continue;
}
const details = entry.message.details;
if (!details || typeof details !== "object") continue;
const runId = (details as Record<string, unknown>).runId;
if (typeof runId === "string") runIds.add(runId);
}
return runIds;
}
export function loadRunEntries(
active: Map<string, WorkflowDetails>,
sessionId: string,
referencedRunIds: ReadonlySet<string>,
): RunEntry[] {
let names: string[] = [];
try {
names = fs.readdirSync(runsDir()).filter((name) => name.startsWith("wf_"));
} catch {
// No runs yet.
}
const entries: RunEntry[] = [];
for (const runId of names) {
const live = active.get(runId);
if (live) {
entries.push({ runId, details: live, live: true });
continue;
}
try {
const raw = JSON.parse(
fs.readFileSync(path.join(runsDir(), runId, "workflow.json"), "utf8"),
);
const details = normalizeDetails(runId, raw);
if (
details &&
(details.sessionId === sessionId || referencedRunIds.has(runId))
) {
const runDir = path.join(runsDir(), runId);
if (details.resultArtifact) {
try {
details.result = JSON.parse(
fs.readFileSync(
path.join(runDir, path.basename(details.resultArtifact)),
"utf8",
),
);
} catch {
// Keep the compact compatibility marker from workflow.json.
}
}
if (details.transcriptArtifact) {
try {
const transcripts = JSON.parse(
fs.readFileSync(
path.join(runDir, path.basename(details.transcriptArtifact)),
"utf8",
),
) as Record<string, unknown>;
for (const agent of details.agents) {
agent.transcript = normalizeTranscript(
transcripts[String(agent.index)],
);
}
} catch {
// Older or partially written artifacts simply lack transcripts.
}
}
if (details.status === "running") {
details.status = "aborted";
details.finishedAt = details.finishedAt ?? Date.now();
details.error =
details.error ?? "Recovered stale run that was not active";
for (const agent of details.agents) {
if (agent.state !== "running") continue;
agent.state = "error";
agent.error = agent.error ?? "Run ended before this agent settled";
agent.finishedAt = details.finishedAt;
}
}
entries.push({ runId, details, live: false });
}
} catch {
// Skip unreadable runs.
}
}
return entries.sort((a, b) => b.details.startedAt - a.details.startedAt);
}
function buildReport(details: WorkflowDetails): string {
const { done, failed } = countStates(details);
const lines: string[] = [
`# Workflow ${details.name ?? details.runId}`,
"",
`- Run: ${details.runId}`,
`- Status: ${statusWord(details.status)}`,
`- Agents: ${done}/${details.agents.length} ok${failed ? `, ${failed} failed` : ""}`,
`- Elapsed: ${formatElapsed(details.startedAt, details.finishedAt)}`,
];
const totals = formatUsage(aggregateUsage(details.agents));
if (totals) lines.push(`- Usage: ${totals}`);
if (details.description) lines.push("", details.description);
if (details.error) lines.push("", `**Error:** ${details.error}`);
for (const group of phaseGroups(details, true)) {
lines.push("", `## ${group.title}`, "");
if (group.agents.length === 0) {
lines.push("_no agents_");
continue;
}
for (const agent of group.agents) {
const status =
agent.state === "done"
? "ok"
: agent.state === "error"
? "FAILED"
: "running";
const stats = [
agent.model,
agentContext(agent),
formatElapsed(agent.startedAt, agent.finishedAt),
]
.filter(Boolean)
.join(" · ");
lines.push(
`- **${agent.label}** — ${status}${stats ? ` (${stats})` : ""}`,
);
if (agent.error) lines.push(` - error: ${agent.error}`);
}
}
if (details.result !== undefined) {
lines.push(
"",
"## Result",
"",
"```json",
resultJson(details.result),
"```",
);
}
lines.push("");
return lines.join("\n");
}
type View = "list" | "detail" | "transcript";
type DetailFocus = "phases" | "agents";
export class WorkflowDashboard {
private view: View = "list";
private entries: RunEntry[] = [];
private listIndex = 0;
private phaseIndex = 0;
private agentIndex = 0;
private detailFocus: DetailFocus = "phases";
private transcriptScroll = 0;
private transcriptRowCount = 0;
private transcriptViewportSize = 1;
private current?: RunEntry;
private notice?: string;
private noticeAt = 0;
private disposed = false;
private timer: ReturnType<typeof setInterval>;
private tui: TUI;
private theme: Theme;
private keybindings: KeybindingsManager;
private getActive: () => Map<string, WorkflowDetails>;
private sessionId: string;
private referencedRunIds: ReadonlySet<string>;
private close: () => void;
constructor(
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
getActive: () => Map<string, WorkflowDetails>,
sessionId: string,
referencedRunIds: ReadonlySet<string>,
close: () => void,
initialRunId?: string,
) {
this.tui = tui;
this.theme = theme;
this.keybindings = keybindings;
this.getActive = getActive;
this.sessionId = sessionId;
this.referencedRunIds = referencedRunIds;
this.close = close;
this.refresh();
if (initialRunId) {
const entry = this.entries.find(
(e) => e.runId === initialRunId || e.runId.endsWith(initialRunId),
);
if (entry) {
this.current = entry;
this.listIndex = this.entries.indexOf(entry);
this.view = "detail";
}
}
this.timer = setInterval(() => {
if (
this.entries.some((e) => e.live) ||
this.current?.live ||
this.notice
) {
this.refresh();
this.tui.requestRender();
}
}, 500);
}
dispose() {
if (this.disposed) return;
this.disposed = true;
clearInterval(this.timer);
}
invalidate() {}
private refresh() {
const selected = this.entries[this.listIndex]?.runId;
this.entries = loadRunEntries(
this.getActive(),
this.sessionId,
this.referencedRunIds,
);
if (selected) {
const index = this.entries.findIndex((e) => e.runId === selected);
if (index >= 0) this.listIndex = index;
}
this.listIndex = Math.min(
this.listIndex,
Math.max(0, this.entries.length - 1),
);
if (this.current) {
const refreshed = this.entries.find(
(e) => e.runId === this.current?.runId,
);
if (refreshed) this.current = refreshed;
}
if (this.notice && Date.now() - this.noticeAt > NOTICE_TTL_MS)
this.notice = undefined;
}
private groups(): PhaseGroup[] {
if (!this.current) return [];
return phaseGroups(this.current.details, true);
}
private selectedGroup(): PhaseGroup | undefined {
return this.groups()[this.phaseIndex];
}
private selectedAgent(): AgentRecord | undefined {
return this.selectedGroup()?.agents[this.agentIndex];
}
private clampAgentIndex() {
const agents = this.selectedGroup()?.agents ?? [];
this.agentIndex = Math.min(this.agentIndex, Math.max(0, agents.length - 1));
}
private saveReport() {
const entry = this.current;
if (!entry) return;
const target = path.join(runsDir(), entry.runId, "report.md");
try {
fs.writeFileSync(target, buildReport(entry.details), "utf8");
this.notice = `saved ${shortenHome(target)}`;
} catch (error) {
this.notice = `save failed: ${error instanceof Error ? error.message : String(error)}`;
}
this.noticeAt = Date.now();
}
handleInput(data: string) {
const up = this.keybindings.matches(data, "tui.select.up") || data === "k";
const down =
this.keybindings.matches(data, "tui.select.down") || data === "j";
const left =
this.keybindings.matches(data, "tui.editor.cursorLeft") || data === "h";
const right =
this.keybindings.matches(data, "tui.editor.cursorRight") || data === "l";
const confirm = this.keybindings.matches(data, "tui.select.confirm");
const cancel = this.keybindings.matches(data, "tui.select.cancel");
if (this.view === "list") {
if (up) {
this.listIndex = wrapSelection(this.listIndex, -1, this.entries.length);
} else if (down) {
this.listIndex = wrapSelection(this.listIndex, 1, this.entries.length);
} else if (data === "g") {
this.listIndex = 0;
} else if (data === "G") {
this.listIndex = Math.max(0, this.entries.length - 1);
} else if (confirm) {
const entry = this.entries[this.listIndex];
if (entry) {
this.current = entry;
this.phaseIndex = 0;
this.agentIndex = 0;
this.detailFocus = "phases";
this.view = "detail";
}
} else if (cancel) {
this.close();
return;
}
} else if (this.view === "detail") {
if (this.detailFocus === "phases") {
if (up) {
this.phaseIndex = wrapSelection(
this.phaseIndex,
-1,
this.groups().length,
);
this.agentIndex = 0;
} else if (down) {
this.phaseIndex = wrapSelection(
this.phaseIndex,
1,
this.groups().length,
);
this.agentIndex = 0;
} else if (data === "g") {
this.phaseIndex = 0;
this.agentIndex = 0;
} else if (data === "G") {
this.phaseIndex = Math.max(0, this.groups().length - 1);
this.agentIndex = 0;
} else if (
right ||
(confirm && (this.selectedGroup()?.agents.length ?? 0) > 0)
) {
if ((this.selectedGroup()?.agents.length ?? 0) > 0) {
this.detailFocus = "agents";
this.clampAgentIndex();
}
} else if (cancel) {
this.view = "list";
this.refresh();
}
} else {
const agents = this.selectedGroup()?.agents ?? [];
if (up) {
this.agentIndex = wrapSelection(this.agentIndex, -1, agents.length);
} else if (down) {
this.agentIndex = wrapSelection(this.agentIndex, 1, agents.length);
} else if (data === "g") {
this.agentIndex = 0;
} else if (data === "G") {
this.agentIndex = Math.max(0, agents.length - 1);
} else if (left || cancel) {
this.detailFocus = "phases";
} else if (confirm && this.selectedAgent()) {
this.transcriptScroll = 0;
this.view = "transcript";
}
}
if (data === "s") this.saveReport();
} else {
const maxScroll = Math.max(
0,
this.transcriptRowCount - this.transcriptViewportSize,
);
const scrollStep =
data === "j" || data === "k" ? TRANSCRIPT_SCROLL_STEP : 1;
const pageStep = Math.max(1, this.transcriptViewportSize - 2);
if (up) {
this.transcriptScroll = Math.max(0, this.transcriptScroll - scrollStep);
} else if (down) {
this.transcriptScroll = Math.min(
maxScroll,
this.transcriptScroll + scrollStep,
);
} else if (matchesKey(data, Key.ctrl("u"))) {
this.transcriptScroll = Math.max(0, this.transcriptScroll - pageStep);
} else if (matchesKey(data, Key.ctrl("d"))) {
this.transcriptScroll = Math.min(
maxScroll,
this.transcriptScroll + pageStep,
);
} else if (data === "g") {
this.transcriptScroll = 0;
} else if (data === "G") {
this.transcriptScroll = maxScroll;
} else if (cancel || left) {
this.view = "detail";
this.detailFocus = "agents";
}
}
this.tui.requestRender();
}
render(width: number): string[] {
const height = Math.max(MIN_HEIGHT, this.tui.terminal.rows - 1);
let lines: string[];
if (this.view === "transcript" && this.current && this.selectedAgent()) {
lines = this.renderTranscript(
this.current.details,
this.selectedAgent()!,
width,
height,
);
} else if (this.view === "detail" && this.current) {
lines = this.renderDetail(this.current.details, width, height);
} else {
lines = this.renderList(width, height);
}
return lines.map((line) => truncateToWidth(line, width, ""));
}
/** Compose `left ... right` within `width`, truncating left when needed. */
private split(left: string, right: string, width: number): string {
const rightWidth = visibleWidth(right);
let text = left;
if (visibleWidth(text) + rightWidth + 1 > width) {
text = truncateToWidth(text, Math.max(0, width - rightWidth - 2), "…");
}
const pad = Math.max(1, width - visibleWidth(text) - rightWidth);
return text + " ".repeat(pad) + right;
}
/** Bordered panel with a title in the top border, padded to exact height. */
private panel(
title: string,
rows: string[],
width: number,
height: number,
): string[] {
const theme = this.theme;
const inner = Math.max(0, width - 2);
const border = (s: string) => theme.fg("borderMuted", s);
const titleText = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
const dashes = Math.max(0, inner - visibleWidth(titleText) - 1);
const lines: string[] = [
border("╭─") + titleText + border("─".repeat(dashes) + "╮"),
];
const bodyHeight = Math.max(0, height - 2);
for (let i = 0; i < bodyHeight; i++) {
const row = rows[i] ?? "";
const clipped = truncateToWidth(row, inner, "…");
const pad = Math.max(0, inner - visibleWidth(clipped));
lines.push(border("│") + clipped + " ".repeat(pad) + border("│"));
}
lines.push(border("╰" + "─".repeat(inner) + "╯"));
return lines;
}
/** Scroll window keeping `selected` visible. */
private windowed<T>(
items: T[],
selected: number,
size: number,
): { items: T[]; offset: number } {
if (items.length <= size) return { items, offset: 0 };
const offset = Math.max(
0,
Math.min(selected - Math.floor(size / 2), items.length - size),
);
return { items: items.slice(offset, offset + size), offset };
}
private keys(binding: Parameters<KeybindingsManager["getKeys"]>[0]) {
return this.keybindings.getKeys(binding).join("/") || "unbound";
}
private hintLine(hint: string, width: number): string {
const theme = this.theme;
if (this.notice)
return truncateToWidth(theme.fg("accent", ` ${this.notice}`), width);
return truncateToWidth(theme.fg("dim", ` ${hint}`), width);
}
private renderList(width: number, height: number): string[] {
const theme = this.theme;
const lines: string[] = [];
const header = this.split(
" " + theme.bold(theme.fg("accent", "Workflows")),
theme.fg(
"dim",
`${this.entries.length} run${this.entries.length === 1 ? "" : "s"} `,
),
width,
);
lines.push(header);
const panelHeight = height - 2;
const bodyHeight = Math.max(0, panelHeight - 2);
if (this.entries.length === 0) {
lines.push(
...this.panel(
"Runs",
[theme.fg("dim", " no workflow runs yet")],
width,
panelHeight,
),
);
lines.push(
this.hintLine(`${this.keys("tui.select.cancel")} close`, width),
);
return lines;
}
const { items, offset } = this.windowed(
this.entries,
this.listIndex,
bodyHeight,
);
const rows = items.map((entry, i) => {
const index = offset + i;
const selected = index === this.listIndex;
const d = entry.details;
const marker = selected ? theme.fg("accent", "❯") : " ";
const name = d.name ?? d.runId;
const label = selected
? theme.fg("accent", name)
: theme.fg("text", name);
const { done, failed } = countStates(d);
const settled = done + failed;
const right =
theme.fg(
"dim",
`${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
) +
theme.fg(statusColor(d.status), statusWord(d.status)) +
" ";
const left = ` ${marker} ${statusSquareFor(d, theme)} ${label} ${theme.fg("dim", d.runId)}`;
return this.split(left, right, width - 2);
});
lines.push(...this.panel("Runs", rows, width, panelHeight));
lines.push(
this.hintLine(
`${this.keys("tui.select.up")}/${this.keys("tui.select.down")} select · ${this.keys("tui.select.confirm")} open · ${this.keys("tui.select.cancel")} close`,
width,
),
);
return lines;
}
private renderDetail(
d: WorkflowDetails,
width: number,
height: number,
): string[] {
const theme = this.theme;
const lines: string[] = [];
const { done, failed } = countStates(d);
const settled = done + failed;
const right =
theme.fg(
"dim",
`${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
) +
theme.fg(statusColor(d.status), statusWord(d.status)) +
" ";
lines.push(
this.split(
" " + theme.bold(theme.fg("accent", d.name ?? d.runId)),
right,
width,
),
);
const totals = formatUsage(aggregateUsage(d.agents));
const subLeft = " " + theme.fg("muted", d.description ?? d.runId);
lines.push(
this.split(subLeft, totals ? theme.fg("dim", `${totals} `) : " ", width),
);
const groups = this.groups();
this.phaseIndex = Math.min(this.phaseIndex, Math.max(0, groups.length - 1));
const selectedGroup = groups[this.phaseIndex];
this.clampAgentIndex();
const panelHeight = height - 3;
const bodyHeight = Math.max(0, panelHeight - 2);
// Left: phases sidebar.
const maxTitle = Math.max(8, ...groups.map((g) => g.title.length));
const sidebarWidth = Math.min(
Math.max(maxTitle + 12, 20),
Math.floor(width / 3),
);
const sidebarInner = sidebarWidth - 2;
const phaseWindow = this.windowed(groups, this.phaseIndex, bodyHeight);
const phaseRows = phaseWindow.items.map((group, i) => {
const index = phaseWindow.offset + i;
const selected = index === this.phaseIndex;
const marker = selected
? theme.fg(this.detailFocus === "phases" ? "accent" : "muted", "❯")
: " ";
const groupDone = group.agents.filter(
(a) => a.state !== "running",
).length;
const square = groupSquare(group, theme);
const title =
selected && this.detailFocus === "phases"
? theme.fg("accent", group.title)
: theme.fg("text", group.title);
const counts =
group.agents.length > 0
? theme.fg("dim", `${groupDone}/${group.agents.length} `)
: theme.fg("dim", "- ");
return this.split(` ${marker} ${square} ${title}`, counts, sidebarInner);
});
// Right: agents in the selected phase.
const agentsWidth = width - sidebarWidth - 1;
const agentsInner = agentsWidth - 2;
const agentRows: string[] = [];
if (selectedGroup) {
const maxLabel = Math.max(
0,
...selectedGroup.agents.map((a) => a.label.length),
);
const agentWindow = this.windowed(
selectedGroup.agents,
this.agentIndex,
bodyHeight,
);
for (const [visibleIndex, agent] of agentWindow.items.entries()) {
const index = agentWindow.offset + visibleIndex;
const selected = index === this.agentIndex;
const marker =
selected && this.detailFocus === "agents"
? theme.fg("accent", "❯")
: " ";
const stats = [agent.model, agentContext(agent)]
.filter(Boolean)
.join(" · ");
const label =
selected && this.detailFocus === "agents"
? theme.fg("accent", agent.label.padEnd(Math.min(maxLabel, 40)))
: theme.fg("text", agent.label.padEnd(Math.min(maxLabel, 40)));
const left = ` ${marker} ${stateSquare(agent.state, theme)} ${label} ${theme.fg("dim", stats)}`;
const right = theme.fg(
"dim",
`${formatElapsed(agent.startedAt, agent.finishedAt)} `,
);
agentRows.push(this.split(left, right, agentsInner));
if (agent.error) {
agentRows.push(
truncateToWidth(
` ${theme.fg("error", agent.error)}`,
agentsInner,
"…",
),
);
}
}
if (selectedGroup.agents.length === 0) {
agentRows.push(theme.fg("dim", " no agents in this phase yet"));
}
}
if (d.error) {
agentRows.push("");
agentRows.push(
truncateToWidth(
` ${theme.fg("error", `workflow error: ${d.error}`)}`,
agentsInner,
"…",
),
);
}
const agentCount = selectedGroup?.agents.length ?? 0;
const agentsTitle = selectedGroup
? `${selectedGroup.title} · ${agentCount} agent${agentCount === 1 ? "" : "s"}`
: "Agents";
const leftPanel = this.panel(
"Phases",
phaseRows,
sidebarWidth,
panelHeight,
);
const rightPanel = this.panel(
agentsTitle,
agentRows,
agentsWidth,
panelHeight,
);
for (let i = 0; i < panelHeight; i++) {
lines.push(`${leftPanel[i] ?? ""} ${rightPanel[i] ?? ""}`);
}
const hint =
this.detailFocus === "phases"
? `j/k select phase · l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")} agents · ${this.keys("tui.select.cancel")} back · s save report`
: `j/k select agent · h/${this.keys("tui.editor.cursorLeft")}/${this.keys("tui.select.cancel")} phases · ${this.keys("tui.select.confirm")} transcript · s save report`;
lines.push(this.hintLine(hint, width));
return lines;
}
private transcriptRows(agent: AgentRecord, width: number): string[] {
const theme = this.theme;
const rows: string[] = [];
if (agent.transcript.length === 0) {
return [
theme.fg(
"dim",
" transcript unavailable (this run predates transcript capture)",
),
];
}
for (const entry of agent.transcript) {
const label = transcriptLabel(entry);
const color = transcriptColor(entry);
rows.push(
` ${theme.fg(color, SQUARE)} ${theme.bold(theme.fg(color, label))}`,
);
const contentWidth = Math.max(8, width - 4);
const styled = theme.fg(
entry.role === "thinking" ? "dim" : entry.isError ? "error" : "text",
entry.text,
);
for (const line of wrapTextWithAnsi(styled, contentWidth)) {
rows.push(` ${line}`);
}
rows.push("");
}
return rows;
}
private renderTranscript(
details: WorkflowDetails,
agent: AgentRecord,
width: number,
height: number,
): string[] {
const theme = this.theme;
const lines: string[] = [];
const right = theme.fg(
"dim",
[
agent.model,
agentContext(agent),
formatElapsed(agent.startedAt, agent.finishedAt),
]
.filter(Boolean)
.join(" · ") + " ",
);
lines.push(
this.split(
` ${stateSquare(agent.state, theme)} ${theme.bold(theme.fg("accent", agent.label))}`,
right,
width,
),
);
lines.push(
this.split(
` ${theme.fg("muted", `${details.name ?? details.runId} · ${agent.phase ?? "unphased"}`)}`,
theme.fg("dim", `${agent.transcript.length} entries `),
width,
),
);
const panelHeight = height - 3;
const bodyHeight = Math.max(1, panelHeight - 2);
const rows = this.transcriptRows(agent, width - 2);
this.transcriptRowCount = rows.length;
this.transcriptViewportSize = bodyHeight;
const maxScroll = Math.max(0, rows.length - bodyHeight);
this.transcriptScroll = Math.min(this.transcriptScroll, maxScroll);
const visible = rows.slice(
this.transcriptScroll,
this.transcriptScroll + bodyHeight,
);
const position =
rows.length > bodyHeight
? `Transcript · ${this.transcriptScroll + 1}-${Math.min(rows.length, this.transcriptScroll + bodyHeight)}/${rows.length}`
: "Transcript";
lines.push(...this.panel(position, visible, width, panelHeight));
lines.push(
this.hintLine(
"j/k scroll · ctrl-u/d page · g/G top/bottom · h/left/esc back",
width,
),
);
return lines;
}
}
function transcriptLabel(entry: TranscriptEntry): string {
if (entry.role === "user") return "USER";
if (entry.role === "assistant") return "ASSISTANT";
if (entry.role === "thinking") return "THINKING";
if (entry.role === "tool") return `TOOL ${entry.name ?? "unknown"}`;
return `RESULT ${entry.name ?? "unknown"}`;
}
function transcriptColor(
entry: TranscriptEntry,
): "accent" | "success" | "dim" | "warning" | "error" | "muted" {
if (entry.isError) return "error";
if (entry.role === "user") return "accent";
if (entry.role === "assistant") return "success";
if (entry.role === "thinking") return "dim";
if (entry.role === "tool") return "warning";
return "muted";
}
function statusSquareFor(details: WorkflowDetails, theme: Theme): string {
return theme.fg(statusColor(details.status), SQUARE);
}
function groupSquare(group: PhaseGroup, theme: Theme): string {
if (group.agents.length === 0) return theme.fg("dim", SQUARE);
if (group.agents.some((a) => a.state === "running"))
return theme.fg("warning", SQUARE);
if (group.agents.some((a) => a.state === "error"))
return theme.fg("error", SQUARE);
return theme.fg("success", SQUARE);
}
/** Open the dashboard as a full-screen overlay. */
export async function showWorkflowDashboard(
ctx: ExtensionContext,
getActive: () => Map<string, WorkflowDetails>,
initialRunId?: string,
): Promise<void> {
await ctx.ui.custom<void>(
(tui, theme, keybindings, done) => {
const dashboard: WorkflowDashboard = new WorkflowDashboard(
tui,
theme,
keybindings,
getActive,
ctx.sessionManager.getSessionId(),
sessionWorkflowRunIds(ctx),
() => {
dashboard.dispose();
done(undefined);
},
initialRunId,
);
return dashboard;
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
},
);
}