461 lines
22 KiB
TypeScript
461 lines
22 KiB
TypeScript
import * as fs from "node:fs";
|
||
import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
|
||
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
||
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
||
import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
|
||
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
||
import type { CrewTheme } from "./theme-adapter.ts";
|
||
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
||
import { applyStatusColor, iconForStatus, type RunStatus } from "./status-colors.ts";
|
||
import { pad, truncate } from "../utils/visual.ts";
|
||
import { Box, Text } from "./layout-primitives.ts";
|
||
import { DynamicCrewBorder } from "./dynamic-border.ts";
|
||
import { CrewFooter } from "./crew-footer.ts";
|
||
import { aggregateUsage } from "../state/usage.ts";
|
||
import { logInternalError } from "../utils/internal-error.ts";
|
||
import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
|
||
import { renderMailboxPane } from "./dashboard-panes/mailbox-pane.ts";
|
||
import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
|
||
import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
|
||
import { renderHealthPane } from "./dashboard-panes/health-pane.ts";
|
||
import { renderMetricsPane } from "./dashboard-panes/metrics-pane.ts";
|
||
import { dashboardActionForKey } from "./keybinding-map.ts";
|
||
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
||
import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
||
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
||
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
||
|
||
interface DashboardComponent {
|
||
invalidate(): void;
|
||
render(width: number): string[];
|
||
handleInput(data: string): void;
|
||
}
|
||
|
||
export interface RunDashboardOptions {
|
||
placement?: "center" | "right";
|
||
showModel?: boolean;
|
||
showTokens?: boolean;
|
||
showTools?: boolean;
|
||
snapshotCache?: RunSnapshotCache;
|
||
runProvider?: () => TeamRunManifest[];
|
||
registry?: MetricRegistry;
|
||
}
|
||
|
||
export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "mailbox" | "reload" | "mailbox-detail" | "health-recovery" | "health-kill-stale" | "health-diagnostic-export" | "notifications-dismiss";
|
||
export interface RunDashboardSelection {
|
||
runId: string;
|
||
action: RunDashboardAction;
|
||
}
|
||
|
||
const TASK_READ_TTL_MS = 200;
|
||
|
||
function formatAge(iso: string | undefined): string | undefined {
|
||
if (!iso) return undefined;
|
||
const ms = Math.max(0, Date.now() - new Date(iso).getTime());
|
||
if (!Number.isFinite(ms)) return undefined;
|
||
if (ms < 1000) return "now";
|
||
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
||
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
|
||
return `${Math.floor(ms / 3_600_000)}h`;
|
||
}
|
||
|
||
function renderLines(lines: string[], width: number): string[] {
|
||
const box = new Box(0, 0);
|
||
for (const line of lines) {
|
||
box.addChild(new Text(line));
|
||
}
|
||
return box.render(width);
|
||
}
|
||
|
||
function readProgressPreview(run: TeamRunManifest, maxLines = 5): string[] {
|
||
const progress = [...run.artifacts].reverse().find((artifact) => artifact.kind === "progress");
|
||
if (!progress) return ["Progress: (none)"];
|
||
try {
|
||
const progressPath = resolveRealContainedPath(run.artifactsRoot, progress.path);
|
||
if (!fs.existsSync(progressPath)) return ["Progress: (none)"];
|
||
return ["Progress:", ...fs.readFileSync(progressPath, "utf-8").split(/\r?\n/).filter(Boolean).slice(0, maxLines)];
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
return [`Progress: failed to read (${message})`];
|
||
}
|
||
}
|
||
|
||
function formatTokens(usage: UsageState | undefined): string | undefined {
|
||
if (!usage) return undefined;
|
||
const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||
if (!total) return undefined;
|
||
const compact = total >= 1000 ? `${(total / 1000).toFixed(total >= 10_000 ? 0 : 1)}k` : `${total}`;
|
||
const parts = [`tok=${compact}`];
|
||
if (usage.input) parts.push(`in=${usage.input}`);
|
||
if (usage.output) parts.push(`out=${usage.output}`);
|
||
if (usage.cacheRead) parts.push(`cache=${usage.cacheRead}`);
|
||
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
||
return parts.join("/");
|
||
}
|
||
|
||
function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): RunUiSnapshot | undefined {
|
||
try {
|
||
return snapshotCache?.refreshIfStale(run.runId);
|
||
} catch {
|
||
return snapshotCache?.get(run.runId);
|
||
}
|
||
}
|
||
|
||
function readRunTasks(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): TeamTaskState[] {
|
||
const snapshot = snapshotFor(run, snapshotCache);
|
||
if (snapshot) return snapshot.tasks;
|
||
const parse = () => {
|
||
if (!fs.existsSync(run.tasksPath)) return [];
|
||
const parsed = JSON.parse(fs.readFileSync(run.tasksPath, "utf-8"));
|
||
return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
|
||
};
|
||
try {
|
||
return readJsonFileCoalesced(run.tasksPath, TASK_READ_TTL_MS, parse);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function taskForAgent(tasks: TeamTaskState[], agent: CrewAgentRecord): TeamTaskState | undefined {
|
||
return tasks.find((task) => task.id === agent.taskId);
|
||
}
|
||
|
||
function modelForTask(task: TeamTaskState | undefined): string | undefined {
|
||
const attempts = task?.modelAttempts;
|
||
if (!attempts?.length) return undefined;
|
||
return attempts.find((attempt) => attempt.success)?.model ?? attempts.at(-1)?.model;
|
||
}
|
||
|
||
function modelForAgent(agent: CrewAgentRecord, task: TeamTaskState | undefined): string | undefined {
|
||
return modelForTask(task) ?? agent.model;
|
||
}
|
||
|
||
function usageForAgent(agent: CrewAgentRecord, task: TeamTaskState | undefined): UsageState | undefined {
|
||
return task?.usage ?? agent.usage;
|
||
}
|
||
|
||
function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefined, options: RunDashboardOptions): string {
|
||
const stats = [
|
||
agent.progress?.activityState,
|
||
options.showModel !== false && modelForAgent(agent, task) ? `model=${modelForAgent(agent, task)}` : undefined,
|
||
options.showTokens !== false
|
||
? formatTokens(usageForAgent(agent, task)) ?? (agent.progress?.tokens !== undefined ? `tok=${agent.progress.tokens}` : undefined)
|
||
: undefined,
|
||
options.showTools !== false && agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
|
||
options.showTools !== false && agent.toolUses !== undefined ? `${agent.toolUses} tools` : undefined,
|
||
agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
|
||
agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
|
||
agent.startedAt ? `age=${formatAge(agent.completedAt ?? agent.startedAt)}` : undefined,
|
||
].filter((part): part is string => Boolean(part));
|
||
const recent = agent.progress?.recentOutput?.at(-1);
|
||
const icon = iconForStatus(agent.status, { runningGlyph: spinnerFrame(agent.taskId) });
|
||
return `Agent: ${icon} ${agent.taskId} ${agent.role}->${agent.agent}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`;
|
||
}
|
||
|
||
function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashboardOptions = {}): string[] {
|
||
try {
|
||
const snapshot = snapshotFor(run, options.snapshotCache);
|
||
const agents = snapshot?.agents ?? readCrewAgents(run);
|
||
const tasks = snapshot?.tasks ?? readRunTasks(run, options.snapshotCache);
|
||
if (!agents.length) return ["Agents: (none)"];
|
||
const totals = tasks.reduce((acc, task) => {
|
||
acc.input += task.usage?.input ?? 0;
|
||
acc.output += task.usage?.output ?? 0;
|
||
acc.cacheRead += task.usage?.cacheRead ?? 0;
|
||
acc.cacheWrite += task.usage?.cacheWrite ?? 0;
|
||
acc.cost += task.usage?.cost ?? 0;
|
||
return acc;
|
||
}, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 } as { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number });
|
||
const header = formatTokens(totals) ? `Agents: ${formatTokens(totals)}` : "Agents:";
|
||
return [
|
||
header,
|
||
...agents
|
||
.slice(0, maxLines)
|
||
.map((agent) => agentPreviewLine(agent, taskForAgent(tasks, agent), options)),
|
||
...(agents.length > maxLines ? [`Agents: +${agents.length - maxLines} more`] : []),
|
||
];
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
return [`Agents: failed to read (${message})`];
|
||
}
|
||
}
|
||
|
||
function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): CrewAgentRecord[] {
|
||
const snapshot = snapshotFor(run, snapshotCache);
|
||
if (snapshot) return snapshot.agents;
|
||
try {
|
||
return readCrewAgents(run);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSnapshotCache): string {
|
||
const agents = agentsFor(run, snapshotCache);
|
||
const stale = isLikelyOrphanedActiveRun(run, agents);
|
||
const running = agents.find((agent) => agent.status === "running");
|
||
const queued = agents.find((agent) => agent.status === "queued");
|
||
const step = stale ? "orphaned queued run" : running ? `step ${running.taskId.replace(/[\x00-\x1f\x7f-\x9f]/g, "")}` : queued ? `queued ${queued.taskId.replace(/[\x00-\x1f\x7f-\x9f]/g, "")}` : `agents ${agents.length}`;
|
||
const status: RunStatus = stale ? "stale" : (run.status as RunStatus);
|
||
const marker = selected ? "›" : " ";
|
||
return `${marker} ${iconForStatus(status, { runningGlyph: spinnerFrame(run.runId) })} ${run.runId.slice(-8)} ${status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
|
||
}
|
||
|
||
interface ResolvedRun {
|
||
manifest: TeamRunManifest;
|
||
snapshot: RunUiSnapshot | undefined;
|
||
agents: CrewAgentRecord[];
|
||
status: RunStatus;
|
||
}
|
||
|
||
function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): Map<string, ResolvedRun> {
|
||
const map = new Map<string, ResolvedRun>();
|
||
for (const run of runs) {
|
||
const snapshot = snapshotFor(run, snapshotCache);
|
||
const agents = snapshot?.agents ?? agentsFor(run, snapshotCache);
|
||
const displayRun = snapshot?.manifest ?? run;
|
||
const status: RunStatus = isLikelyOrphanedActiveRun(displayRun, agents) ? "stale" : (displayRun.status as RunStatus);
|
||
map.set(run.runId, { manifest: run, snapshot, agents, status });
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function groupedRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): Array<{ label: string; run?: TeamRunManifest }> {
|
||
const resolved = resolveRuns(runs, snapshotCache);
|
||
const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
|
||
const active = runs.filter((run) => isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []));
|
||
const rest = runs.filter((run) => !isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []));
|
||
if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
|
||
if (rest.length) rows.push({ label: "Recent" }, ...rest.map((run) => ({ label: run.runId, run })));
|
||
return rows;
|
||
}
|
||
|
||
function selectedRunFromGrouped(runs: TeamRunManifest[], selected: number, snapshotCache?: RunSnapshotCache): TeamRunManifest | undefined {
|
||
return groupedRuns(runs, snapshotCache).filter((row) => row.run)[selected]?.run;
|
||
}
|
||
|
||
function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache): string {
|
||
const resolved = resolveRuns(runs, snapshotCache);
|
||
const counts = new Map<RunStatus, number>();
|
||
for (const r of resolved.values()) counts.set(r.status, (counts.get(r.status) ?? 0) + 1);
|
||
return [...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none";
|
||
}
|
||
|
||
export class RunDashboard implements DashboardComponent {
|
||
private selected = 0;
|
||
private showFullProgress = false;
|
||
private activePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" = "agents";
|
||
private runs: TeamRunManifest[];
|
||
private readonly done: (selection: RunDashboardSelection | undefined) => void;
|
||
private readonly theme: CrewTheme;
|
||
private readonly options: RunDashboardOptions;
|
||
private cachedWidth = 0;
|
||
private cachedVersion = "";
|
||
private cachedLines: string[] = [];
|
||
private readonly unsubscribeTheme: () => void;
|
||
|
||
constructor(
|
||
runs: TeamRunManifest[],
|
||
done: (selection: RunDashboardSelection | undefined) => void,
|
||
theme: unknown = {},
|
||
options: RunDashboardOptions = {},
|
||
) {
|
||
this.runs = runs;
|
||
this.done = done;
|
||
this.theme = asCrewTheme(theme);
|
||
this.options = options;
|
||
this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidate());
|
||
}
|
||
|
||
private refreshRuns(): void {
|
||
if (!this.options.runProvider) return;
|
||
const selectedRunId = this.selectedRunId();
|
||
const next = this.options.runProvider();
|
||
this.runs = Array.isArray(next) ? next : this.runs;
|
||
if (selectedRunId) {
|
||
const nextIndex = groupedRuns(this.runs, this.options.snapshotCache).filter((row) => row.run).findIndex((row) => row.run?.runId === selectedRunId);
|
||
if (nextIndex >= 0) this.selected = nextIndex;
|
||
else this.selected = 0;
|
||
}
|
||
}
|
||
|
||
private buildSignature(): string {
|
||
let hasRunning = false;
|
||
const statuses = this.runs.map((run) => {
|
||
const snapshot = snapshotFor(run, this.options.snapshotCache);
|
||
const displayRun = snapshot?.manifest ?? run;
|
||
const agents = snapshot?.agents ?? agentsFor(run, this.options.snapshotCache);
|
||
const stale = isLikelyOrphanedActiveRun(displayRun, agents);
|
||
const status: RunStatus = stale ? "stale" : (displayRun.status as RunStatus);
|
||
if (status === "running" || agents.some((agent) => agent.status === "running")) hasRunning = true;
|
||
return snapshot?.signature ?? `${displayRun.runId}:${displayRun.status}:${displayRun.updatedAt}:${status}`;
|
||
}).join("|");
|
||
const metricsSig = this.activePane === "metrics" ? `:metrics=${this.options.registry?.snapshot().length ?? 0}:${spinnerBucket()}` : "";
|
||
return `${this.selected}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}`;
|
||
}
|
||
|
||
invalidate(): void {
|
||
this.cachedVersion = "";
|
||
this.cachedLines = [];
|
||
}
|
||
|
||
dispose(): void {
|
||
this.unsubscribeTheme();
|
||
}
|
||
|
||
private selectedRunId(): string | undefined {
|
||
return selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache)?.runId;
|
||
}
|
||
|
||
render(width: number): string[] {
|
||
try {
|
||
return this.renderUnsafe(width);
|
||
} catch (error) {
|
||
logInternalError("run-dashboard.render", error);
|
||
return renderLines(["Dashboard error — see logs for details."], width);
|
||
}
|
||
}
|
||
|
||
private renderUnsafe(width: number): string[] {
|
||
this.refreshRuns();
|
||
const signature = this.buildSignature();
|
||
if (signature !== this.cachedVersion || this.cachedWidth !== width) {
|
||
const innerWidth = Math.max(20, width - 4);
|
||
const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
|
||
const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
|
||
const borderFill = (count: number) => new DynamicCrewBorder(this.theme).render(count)[0];
|
||
const border = (left: string, right: string) => `${fg("border", left)}${borderFill(borderWidth)}${fg("border", right)}`;
|
||
|
||
const lines = [
|
||
border("╭", "╮"),
|
||
`│ ${pad(truncate(`${fg("accent", "▐")} ${this.theme.bold(this.options.placement === "right" ? "pi-crew right sidebar (anchored top-right)" : "pi-crew dashboard")}`, innerWidth - 1), innerWidth - 1)}│`,
|
||
`│ ${pad(truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs, this.options.snapshotCache)}`, innerWidth - 1), innerWidth - 1)}│`,
|
||
`│ ${pad(truncate(`↑/↓ select • 1 agents 2 progress 3 mailbox 4 output 5 health 6 metrics • s/u/a/i actions • R/K/D health • H hush`, innerWidth - 1), innerWidth - 1)}│`,
|
||
border("├", "┤"),
|
||
];
|
||
if (this.runs.length === 0) {
|
||
lines.push(`│ ${pad(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
|
||
} else {
|
||
const rows = groupedRuns(this.runs, this.options.snapshotCache).slice(0, 16);
|
||
const selectableRuns = rows.filter((row) => row.run);
|
||
for (const row of rows) {
|
||
if (!row.run) {
|
||
lines.push(`│ ${pad(truncate(fg("accent", row.label), innerWidth - 1), innerWidth - 1)}│`);
|
||
continue;
|
||
}
|
||
const index = selectableRuns.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
|
||
const rowSnapshot = snapshotFor(row.run, this.options.snapshotCache);
|
||
const rowRun = rowSnapshot?.manifest ?? row.run;
|
||
const rowAgents = rowSnapshot?.agents ?? agentsFor(row.run, this.options.snapshotCache);
|
||
const rowStatus = isLikelyOrphanedActiveRun(rowRun, rowAgents) ? "stale" : (rowRun.status as RunStatus);
|
||
const label = runLabel(rowRun, index === this.selected, this.options.snapshotCache);
|
||
lines.push(`│ ${pad(applyStatusColor(this.theme, rowStatus, label), innerWidth - 1)}│`);
|
||
}
|
||
const selectedRun = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache);
|
||
if (selectedRun) {
|
||
const selectedSnapshot = snapshotFor(selectedRun, this.options.snapshotCache);
|
||
const selectedDisplayRun = selectedSnapshot?.manifest ?? selectedRun;
|
||
const selectedAgents = selectedSnapshot?.agents ?? agentsFor(selectedRun, this.options.snapshotCache);
|
||
lines.push(border("├", "┤"));
|
||
const details = [
|
||
`Selected: ${selectedDisplayRun.runId}`,
|
||
`Status: ${isLikelyOrphanedActiveRun(selectedDisplayRun, selectedAgents) ? "stale" : selectedDisplayRun.status} | Team: ${selectedDisplayRun.team} | Workflow: ${selectedDisplayRun.workflow ?? "none"}`,
|
||
`Created: ${selectedDisplayRun.createdAt}`,
|
||
`Updated: ${selectedDisplayRun.updatedAt}`,
|
||
`Artifacts: ${selectedDisplayRun.artifacts.length} | Workspace: ${selectedDisplayRun.workspaceMode}`,
|
||
selectedDisplayRun.async ? `Async: pid=${selectedDisplayRun.async.pid ?? "unknown"} log=${selectedDisplayRun.async.logPath}` : "Async: no",
|
||
`Goal: ${selectedDisplayRun.goal}`,
|
||
];
|
||
const paneLines = selectedSnapshot
|
||
? this.activePane === "agents"
|
||
? renderAgentsPane(selectedSnapshot, this.options)
|
||
: this.activePane === "progress"
|
||
? renderProgressPane(selectedSnapshot)
|
||
: this.activePane === "mailbox"
|
||
? renderMailboxPane(selectedSnapshot)
|
||
: this.activePane === "health"
|
||
? renderHealthPane(selectedSnapshot, { isForeground: selectedDisplayRun.async ? false : true })
|
||
: this.activePane === "metrics"
|
||
? renderMetricsPane(selectedSnapshot, { registry: this.options.registry })
|
||
: renderTranscriptPane(selectedSnapshot)
|
||
: [
|
||
...readAgentPreview(selectedDisplayRun, this.showFullProgress ? 20 : 8, this.options),
|
||
...readProgressPreview(selectedDisplayRun, this.showFullProgress ? 20 : 5),
|
||
];
|
||
for (const detail of [
|
||
...details,
|
||
`Pane: ${this.activePane}`,
|
||
...paneLines,
|
||
...(this.showFullProgress ? readProgressPreview(selectedDisplayRun, 20) : []),
|
||
]) {
|
||
lines.push(`│ ${pad(truncate(detail, innerWidth - 1), innerWidth - 1)}│`);
|
||
}
|
||
const selectedTasks = selectedSnapshot?.tasks ?? readRunTasks(selectedDisplayRun, this.options.snapshotCache);
|
||
const footer = new CrewFooter({
|
||
pwd: selectedDisplayRun.cwd,
|
||
runId: selectedDisplayRun.runId,
|
||
status: isLikelyOrphanedActiveRun(selectedDisplayRun, selectedAgents) ? "stale" : selectedDisplayRun.status,
|
||
usage: aggregateUsage(selectedTasks),
|
||
badges: [`team ${selectedDisplayRun.team}`, `workflow ${selectedDisplayRun.workflow ?? "none"}`, `${selectedDisplayRun.artifacts.length} artifacts`, selectedDisplayRun.workspaceMode],
|
||
}, this.theme);
|
||
lines.push(border("├", "┤"));
|
||
for (const footerLine of footer.render(innerWidth - 1)) {
|
||
lines.push(`│ ${pad(truncate(footerLine, innerWidth - 1), innerWidth - 1)}│`);
|
||
}
|
||
}
|
||
}
|
||
lines.push(border("╰", "╯"));
|
||
this.cachedLines = renderLines(lines.map((line) => truncate(line, width)), width);
|
||
this.cachedVersion = signature;
|
||
this.cachedWidth = width;
|
||
}
|
||
return this.cachedLines;
|
||
}
|
||
|
||
handleInput(data: string): void {
|
||
const action = dashboardActionForKey(data, this.activePane);
|
||
const selectedRunId = this.selectedRunId();
|
||
if (action === "close") {
|
||
this.done(undefined);
|
||
return;
|
||
}
|
||
if (action === "select") {
|
||
this.done(selectedRunId ? { runId: selectedRunId, action: "status" } : undefined);
|
||
return;
|
||
}
|
||
if (action === "summary" || action === "artifacts" || action === "api" || action === "agents" || action === "mailbox" || action === "reload" || action === "mailbox-detail" || action === "health-recovery" || action === "health-kill-stale" || action === "health-diagnostic-export" || action === "notifications-dismiss") {
|
||
this.done(selectedRunId ? { runId: selectedRunId, action } : action === "reload" ? { runId: "", action } : undefined);
|
||
return;
|
||
}
|
||
if (action === "events") {
|
||
this.done(selectedRunId ? { runId: selectedRunId, action: "agent-events" } : undefined);
|
||
return;
|
||
}
|
||
if (action === "output") {
|
||
this.done(selectedRunId ? { runId: selectedRunId, action: "agent-output" } : undefined);
|
||
return;
|
||
}
|
||
if (action === "transcript") {
|
||
this.done(selectedRunId ? { runId: selectedRunId, action: "agent-transcript" } : undefined);
|
||
return;
|
||
}
|
||
if (action === "progressToggle") {
|
||
this.showFullProgress = !this.showFullProgress;
|
||
this.invalidate();
|
||
return;
|
||
}
|
||
if (action === "pane-agents") this.activePane = "agents";
|
||
else if (action === "pane-progress") this.activePane = "progress";
|
||
else if (action === "pane-mailbox") this.activePane = "mailbox";
|
||
else if (action === "pane-output") this.activePane = "output";
|
||
else if (action === "pane-health") this.activePane = "health";
|
||
else if (action === "pane-metrics") this.activePane = "metrics";
|
||
else if (action === "up") this.selected = Math.max(0, this.selected - 1);
|
||
else if (action === "down") {
|
||
const selectableCount = groupedRuns(this.runs, this.options.snapshotCache).filter((row) => row.run).length;
|
||
this.selected = Math.min(Math.max(0, selectableCount - 1), this.selected + 1);
|
||
}
|
||
if (action) this.invalidate();
|
||
}
|
||
}
|