Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom

This commit is contained in:
2026-05-08 15:59:25 +10:00
parent d0d1d9b045
commit 31b4110c87
457 changed files with 85157 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
import type { UsageState } from "../state/types.ts";
import { pad, truncate } from "../utils/visual.ts";
import type { RunStatus } from "./status-colors.ts";
import type { CrewTheme } from "./theme-adapter.ts";
export interface CrewFooterData {
pwd: string;
branch?: string;
runId?: string;
status?: RunStatus;
usage?: UsageState;
contextWindow?: number;
contextPercent?: number;
badges?: string[];
}
function formatCount(value: number | undefined): string {
if (value === undefined || !Number.isFinite(value)) return "?";
if (Math.abs(value) >= 1000) return `${(value / 1000).toFixed(Math.abs(value) >= 10_000 ? 0 : 1)}k`;
return `${value}`;
}
function formatCost(value: number | undefined): string {
return value === undefined || !Number.isFinite(value) ? "$0.0000" : `$${value.toFixed(4)}`;
}
function displayPwd(pwd: string): string {
const home = process.env.HOME || process.env.USERPROFILE;
if (home && pwd.startsWith(home)) return `~${pwd.slice(home.length) || "/"}`;
return pwd || ".";
}
function contextText(data: CrewFooterData): string {
const windowText = data.contextWindow && Number.isFinite(data.contextWindow) ? formatCount(data.contextWindow) : "window";
const percent = data.contextPercent;
if (percent === undefined || !Number.isFinite(percent)) return `?/${windowText}`;
return `${percent.toFixed(1)}%/${windowText}`;
}
export class CrewFooter {
private data: CrewFooterData;
private readonly theme: CrewTheme;
private cacheKey = "";
private cacheWidth = 0;
private cacheLines: string[] = [];
constructor(data: CrewFooterData, theme: CrewTheme) {
this.data = data;
this.theme = theme;
}
setData(data: CrewFooterData): void {
this.data = data;
this.invalidate();
}
invalidate(): void {
this.cacheKey = "";
this.cacheLines = [];
}
render(width: number): string[] {
const key = JSON.stringify(this.data);
if (this.cacheKey === key && this.cacheWidth === width && this.cacheLines.length) return this.cacheLines;
const lineWidth = Math.max(1, width);
const firstParts = [
displayPwd(this.data.pwd),
this.data.branch ? `(${this.data.branch})` : undefined,
this.data.runId,
this.data.status,
].filter((part): part is string => Boolean(part));
const usage = this.data.usage;
const context = contextText(this.data);
const contextPercent = this.data.contextPercent;
const contextColor = contextPercent !== undefined && Number.isFinite(contextPercent)
? contextPercent > 90
? "error"
: contextPercent > 70
? "warning"
: undefined
: undefined;
const contextRendered = contextColor ? this.theme.fg(contextColor, context) : context;
const usageLine = [
`${formatCount(usage?.input)}`,
`${formatCount(usage?.output)}`,
`R ${formatCount(usage?.cacheRead)} cache`,
`W ${formatCount(usage?.cacheWrite)} cache`,
formatCost(usage?.cost),
contextRendered,
].join(" • ");
const badges = this.data.badges?.length ? this.data.badges.map((badge) => `[${badge}]`).join(" ") : "";
this.cacheLines = [
this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth, "..."), lineWidth)),
this.theme.fg("dim", pad(truncate(usageLine, lineWidth, "..."), lineWidth)),
this.theme.fg("dim", pad(truncate(badges, lineWidth, "..."), lineWidth)),
];
this.cacheKey = key;
this.cacheWidth = width;
return this.cacheLines;
}
}

View File

@@ -0,0 +1,111 @@
import { pad, truncate } from "../utils/visual.ts";
import type { CrewTheme } from "./theme-adapter.ts";
export interface CrewSelectItem<T = string> {
value: T;
label: string;
description?: string;
}
export interface CrewSelectListOptions<T = string> {
onSelect: (item: CrewSelectItem<T>) => void;
onCancel: () => void;
onPreview?: (item: CrewSelectItem<T>) => void;
maxHeight?: number;
}
export class CrewSelectList<T = string> {
private readonly items: CrewSelectItem<T>[];
private readonly theme: CrewTheme;
private readonly options: CrewSelectListOptions<T>;
private selectedIndex = 0;
private scrollOffset = 0;
constructor(items: CrewSelectItem<T>[], theme: CrewTheme, options: CrewSelectListOptions<T>) {
this.items = [...items];
this.theme = theme;
this.options = options;
this.selectedIndex = this.items.length ? 0 : -1;
}
invalidate(): void {}
getSelected(): CrewSelectItem<T> | undefined {
return this.selectedIndex >= 0 ? this.items[this.selectedIndex] : undefined;
}
setSelectedIndex(index: number): void {
if (!this.items.length) {
this.selectedIndex = -1;
this.scrollOffset = 0;
return;
}
const next = Math.min(this.items.length - 1, Math.max(0, index));
const changed = next !== this.selectedIndex;
this.selectedIndex = next;
this.ensureVisible();
if (changed) {
const selected = this.getSelected();
if (selected) this.options.onPreview?.(selected);
}
}
handleInput(data: string): void {
if (data === "q" || data === "\u001b") {
this.options.onCancel();
return;
}
if (data === "j" || data === "\u001b[B") {
this.setSelectedIndex(this.selectedIndex + 1);
return;
}
if (data === "k" || data === "\u001b[A") {
this.setSelectedIndex(this.selectedIndex - 1);
return;
}
if (data === "\r" || data === "\n") {
const selected = this.getSelected();
if (selected) this.options.onSelect(selected);
}
}
render(width: number): string[] {
if (!this.items.length) return [this.theme.fg("muted", "(no items)")];
const maxHeight = Math.max(1, Math.floor(this.options.maxHeight ?? this.items.length));
this.ensureVisible();
const hasTop = this.scrollOffset > 0;
const availableWithoutBottom = Math.max(1, maxHeight - (hasTop ? 1 : 0));
const hasBottom = this.scrollOffset + availableWithoutBottom < this.items.length;
const slots = this.visibleItemSlots(maxHeight, hasTop, hasBottom);
const visibleItems = this.items.slice(this.scrollOffset, this.scrollOffset + slots);
const lines: string[] = [];
if (hasTop) lines.push(this.theme.fg("muted", `${this.scrollOffset} more`));
for (const [offset, item] of visibleItems.entries()) {
const index = this.scrollOffset + offset;
const prefix = index === this.selectedIndex ? " → " : " ";
const suffix = item.description ? this.theme.fg("dim", `${item.description}`) : "";
const raw = `${prefix}${item.label}${suffix}`;
const line = index === this.selectedIndex ? this.theme.inverse?.(raw) ?? raw : raw;
lines.push(pad(truncate(line, width, "..."), Math.max(1, width)));
}
if (hasBottom) lines.push(this.theme.fg("muted", `${this.items.length - (this.scrollOffset + slots)} more`));
return lines.slice(0, maxHeight);
}
private visibleItemSlots(maxHeight: number, hasTop: boolean, hasBottom: boolean): number {
return Math.max(1, maxHeight - (hasTop ? 1 : 0) - (hasBottom ? 1 : 0));
}
private ensureVisible(): void {
if (this.selectedIndex < 0) return;
const maxHeight = Math.max(1, Math.floor(this.options.maxHeight ?? this.items.length));
const reservedTop = this.scrollOffset > 0 ? 1 : 0;
const visibleSlots = Math.max(1, maxHeight - reservedTop - 1);
if (this.selectedIndex < this.scrollOffset) {
this.scrollOffset = this.selectedIndex;
} else if (this.selectedIndex >= this.scrollOffset + visibleSlots) {
this.scrollOffset = Math.max(0, this.selectedIndex - visibleSlots + 1);
}
this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, this.items.length - 1));
}
}

View File

@@ -0,0 +1,356 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { CrewUiConfig } from "../config/config.ts";
import { listRecentRuns } from "../extension/run-index.ts";
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
import { isDisplayActiveRun } from "../runtime/process-status.ts";
import type { TeamRunManifest } from "../state/types.ts";
import type { ManifestCache } from "../runtime/manifest-cache.ts";
import { colorForStatus, iconForStatus, type RunStatus } from "./status-colors.ts";
import { pad, truncate } from "../utils/visual.ts";
import type { CrewTheme } from "./theme-adapter.ts";
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
import { Box, Text } from "./layout-primitives.ts";
import { requestRender, setExtensionWidget } from "./pi-ui-compat.ts";
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
import { DEFAULT_UI } from "../config/defaults.ts";
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const TOOL_LABELS: Record<string, string> = {
read: "reading",
bash: "running command",
edit: "editing",
write: "writing",
grep: "searching",
find: "finding files",
ls: "listing",
};
const LEGACY_WIDGET_KEY = "pi-crew";
const WIDGET_KEY = "pi-crew-active";
const STATUS_KEY = "pi-crew";
const MAX_LINES_DEFAULT = DEFAULT_UI.widgetMaxLines;
const MAX_AGENTS_DISPLAY = 3;
type WidgetComponent = { render(width: number): string[]; invalidate(): void };
interface CrewWidgetModel {
cwd: string;
frame: number;
maxLines: number;
notificationCount?: number;
manifestCache?: ManifestCache;
snapshotCache?: RunSnapshotCache;
}
export interface CrewWidgetState {
frame: number;
interval?: ReturnType<typeof setInterval>;
lastPlacement?: string;
lastVisibility?: "hidden" | "visible";
lastKey?: string;
lastMaxLines?: number;
lastCwd?: string;
legacyCleared?: boolean;
model?: CrewWidgetModel;
notificationCount?: number;
}
interface WidgetRun {
run: TeamRunManifest;
agents: CrewAgentRecord[];
snapshot?: RunUiSnapshot;
}
function elapsed(iso: string | undefined, now = Date.now()): string | undefined {
if (!iso) return undefined;
const ms = Math.max(0, 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 agentActivity(agent: CrewAgentRecord): string {
if (agent.progress?.currentTool) return `${TOOL_LABELS[agent.progress.currentTool] ?? agent.progress.currentTool}`;
const recent = agent.progress?.recentOutput?.at(-1);
if (recent) return recent.replace(/\s+/g, " ").trim();
if (agent.progress?.activityState === "needs_attention") return "needs attention";
if (agent.status === "queued") return "queued";
if (agent.status === "running") return "thinking…";
if (agent.status === "failed") return agent.error ?? "failed";
return "done";
}
function agentStats(agent: CrewAgentRecord): string {
const parts: string[] = [];
if (agent.toolUses) parts.push(`${agent.toolUses} tools`);
if (agent.progress?.tokens) parts.push(`${agent.progress.tokens} tok`);
if (agent.progress?.turns) parts.push(`${agent.progress.turns}`);
const age = elapsed(agent.completedAt ?? agent.startedAt);
if (age) parts.push(agent.completedAt ? age : `${age} ago`);
return parts.join(" · ");
}
function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
try {
return readCrewAgents(run);
} catch {
return [];
}
}
export function activeWidgetRuns(cwd: string, manifestCache?: ManifestCache, snapshotCache?: RunSnapshotCache, preloadedManifests?: TeamRunManifest[]): WidgetRun[] {
const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
return runs
.map((run) => {
try {
const snapshot = snapshotCache?.get(run.runId) ?? snapshotCache?.refreshIfStale(run.runId);
return snapshot ? { run: snapshot.manifest, agents: snapshot.agents, snapshot } : { run, agents: agentsFor(run) };
} catch {
return { run, agents: agentsFor(run) };
}
})
.filter((item) => isDisplayActiveRun(item.run, item.agents));
}
function statusSummary(runs: WidgetRun[]): string {
const agents = runs.flatMap((item) => item.agents);
const runningAgents = agents.filter((agent) => agent.status === "running").length;
const queuedAgents = agents.filter((agent) => agent.status === "queued").length;
const waitingAgents = agents.filter((agent) => agent.status === "waiting").length;
const completedAgents = agents.filter((agent) => agent.status === "completed").length;
const parts = [`${runningAgents} running`];
if (queuedAgents) parts.push(`${queuedAgents} queued`);
if (waitingAgents) parts.push(`${waitingAgents} waiting`);
if (completedAgents) parts.push(`${completedAgents}/${agents.length} done`);
return `Crew: ${parts.join(", ")}`;
}
export function notificationBadge(count: number | undefined, env: NodeJS.ProcessEnv = process.env): string {
if (!count || count <= 0) return "";
const term = `${env.TERM ?? ""} ${env.WT_SESSION ?? ""} ${env.TERM_PROGRAM ?? ""}`.toLowerCase();
const supportsEmoji = !term.includes("dumb") && env.NO_COLOR !== "1";
return supportsEmoji ? ` 🔔${count}` : ` [!${count}]`;
}
export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines = 20, notificationCount = 0): string {
const agents = runs.flatMap((item) => item.agents);
const runningAgents = agents.filter((agent) => agent.status === "running").length;
const queuedAgents = agents.filter((agent) => agent.status === "queued").length;
const waitingAgents = agents.filter((agent) => agent.status === "waiting").length;
const completedAgents = agents.filter((agent) => agent.status === "completed").length;
const parts = [`${runningAgents} running`];
if (queuedAgents) parts.push(`${queuedAgents} queued`);
if (waitingAgents) parts.push(`${waitingAgents} waiting`);
if (completedAgents) parts.push(`${completedAgents}/${agents.length} done`);
return `${runningGlyph} Crew agents${notificationBadge(notificationCount)} · ${parts.join(" · ")} · /team-dashboard`;
}
function shortRunLabel(run: TeamRunManifest): string {
return `${run.team}/${run.workflow ?? "none"}`;
}
export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0): string[] {
const runs = providedRuns ?? activeWidgetRuns(cwd);
if (!runs.length) return [];
const runningGlyph = SPINNER[frame % SPINNER.length] ?? SPINNER[0];
const lines: string[] = [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
for (const { run, agents } of runs) {
const activeAgents = agents.filter((item) => item.status === "running" || item.status === "queued" || item.status === "waiting");
const completed = agents.filter((agent) => agent.status === "completed").length;
const runGlyph = iconForStatus(run.status, { runningGlyph });
lines.push(`├─ ${runGlyph} ${shortRunLabel(run)} · ${completed}/${agents.length} done · ${run.runId.slice(-8)}`);
const visibleAgents = activeAgents.slice(0, MAX_AGENTS_DISPLAY);
for (const [index, agent] of visibleAgents.entries()) {
const last = index === visibleAgents.length - 1 && activeAgents.length <= MAX_AGENTS_DISPLAY;
const branch = last ? "└─" : "├─";
const agentGlyph = iconForStatus(agent.status, { runningGlyph });
const stats = agentStats(agent);
lines.push(`${branch} ${agentGlyph} ${agent.agent} · ${agent.role}`);
lines.push(`│ ⎿ ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
}
if (activeAgents.length > MAX_AGENTS_DISPLAY) lines.push(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`);
if (lines.length >= maxLines) break;
}
return lines.slice(0, maxLines);
}
function statusGlyphColor(icon: string): Parameters<CrewTheme["fg"]>[0] {
const mapping: Record<string, Parameters<CrewTheme["fg"]>[0]> = {
"✓": "success",
"✗": "error",
"■": "warning",
"⏸": "warning",
"◦": "dim",
"·": "dim",
"▶": "accent",
};
return mapping[icon] ?? "accent";
}
function colorWidgetLine(line: string, index: number, theme: CrewTheme): string {
let result = line;
if (index === 0) {
result = result.replace("Crew agents", theme.bold(theme.fg("accent", "Crew agents")));
}
result = result.replace(/[✓✗■⏸◦·▶]/g, (icon) => theme.fg(statusGlyphColor(icon), icon));
if (index === 0) {
result = theme.fg("accent", result);
}
return result;
}
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);
}
class CrewWidgetComponent implements WidgetComponent {
private readonly model: CrewWidgetModel;
private theme: CrewTheme;
private cacheSignature: string;
private cachedWidth = 0;
private cachedLines: string[] = [];
private cachedBaseLines: string[] = [];
private cachedTheme: CrewTheme;
private readonly unsubscribeTheme: () => void;
constructor(model: CrewWidgetModel, themeLike: unknown) {
this.model = model;
this.theme = asCrewTheme(themeLike);
this.cachedTheme = this.theme;
this.cacheSignature = "";
this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
}
private buildSignature(runs: WidgetRun[]): string {
return runs
.map((entry) => entry.snapshot?.signature ?? `${entry.run.runId}:${entry.run.status}:${entry.run.updatedAt}:` + entry.agents.map((agent) => {
const recentOutput = agent.progress?.recentOutput.at(-1) ?? "";
const progress = [agent.progress?.currentTool ?? "", agent.progress?.toolCount ?? 0, agent.progress?.tokens ?? 0, agent.progress?.turns ?? 0, agent.progress?.lastActivityAt ?? "", recentOutput].join(":");
return `${agent.status}:${agent.startedAt}:${agent.completedAt ?? ""}:${agent.toolUses ?? 0}:${progress}`;
}).join(","))
.join("|");
}
private colorize(lines: string[], width: number): string[] {
return renderLines(lines.map((line, index) => colorWidgetLine(line, index, this.theme)), width);
}
invalidate(): void {
this.cacheSignature = "";
this.cachedBaseLines = [];
this.cachedLines = [];
}
dispose(): void {
this.unsubscribeTheme();
}
render(width: number): string[] {
const runs = activeWidgetRuns(this.model.cwd, this.model.manifestCache, this.model.snapshotCache);
const signature = `${this.buildSignature(runs)}:${this.model.notificationCount ?? 0}`;
const runningGlyph = SPINNER[this.model.frame % SPINNER.length] ?? SPINNER[0];
const headerGlyph = runs.length ? SPINNER[0] : " ";
if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
this.cachedBaseLines = buildCrewWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0).map((line, index) => {
if (index === 0 && line.length > 0) return `${headerGlyph}${line.slice(1)}`;
return line;
});
this.cachedLines = this.colorize(this.cachedBaseLines, width);
this.cachedWidth = width;
this.cachedTheme = this.theme;
this.cacheSignature = signature;
}
if (runs.length === 0) return [];
// Update only spinner and command icon on header line to avoid full re-color for every frame.
const updatedHeader = `${runningGlyph}${this.cachedBaseLines[0]?.slice(1) ?? ""}`;
this.cachedLines[0] = truncate(colorWidgetLine(updatedHeader, 0, this.theme), width);
return this.cachedLines;
}
}
export function updateCrewWidget(
ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">,
state: CrewWidgetState,
config?: CrewUiConfig,
manifestCache?: ManifestCache,
snapshotCache?: RunSnapshotCache,
preloadedManifests?: TeamRunManifest[],
): void {
if (!ctx.hasUI) return;
state.frame += 1;
const maxLines = config?.widgetMaxLines ?? MAX_LINES_DEFAULT;
const runs = activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests);
const lines = buildCrewWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0);
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
const shouldClearLegacy = state.legacyCleared !== true || state.lastPlacement !== placement;
if (shouldClearLegacy) {
setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement });
state.legacyCleared = true;
}
if (!lines.length) {
if (state.lastVisibility !== "hidden" || state.lastPlacement !== placement) {
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement });
state.lastVisibility = "hidden";
state.lastPlacement = placement;
state.lastKey = WIDGET_KEY;
state.lastMaxLines = maxLines;
state.lastCwd = ctx.cwd;
state.model = undefined;
}
requestRender(ctx);
return;
}
const needsWidgetInstall = state.lastVisibility !== "visible" || state.lastPlacement !== placement || state.lastKey !== WIDGET_KEY || state.lastMaxLines !== maxLines || state.lastCwd !== ctx.cwd || !state.model;
if (!state.model) state.model = { cwd: ctx.cwd, frame: state.frame, maxLines, notificationCount: state.notificationCount ?? 0, manifestCache, snapshotCache };
else {
state.model.cwd = ctx.cwd;
state.model.frame = state.frame;
state.model.maxLines = maxLines;
state.model.notificationCount = state.notificationCount ?? 0;
state.model.manifestCache = manifestCache;
state.model.snapshotCache = snapshotCache;
}
if (needsWidgetInstall) {
const model = state.model;
setExtensionWidget(
ctx,
WIDGET_KEY,
((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme)) as never,
{ placement, persist: true },
);
state.lastVisibility = "visible";
state.lastPlacement = placement;
state.lastKey = WIDGET_KEY;
state.lastMaxLines = maxLines;
state.lastCwd = ctx.cwd;
}
requestRender(ctx);
}
export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState, config?: CrewUiConfig): void {
if (state.interval) clearInterval(state.interval);
state.interval = undefined;
if (ctx?.hasUI) {
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
ctx.ui.setStatus(STATUS_KEY, undefined);
setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement });
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement });
state.lastVisibility = "hidden";
state.lastPlacement = placement;
state.lastKey = WIDGET_KEY;
state.model = undefined;
state.legacyCleared = true;
requestRender(ctx);
}
}

View File

@@ -0,0 +1,28 @@
import type { RunDashboardOptions } from "../run-dashboard.ts";
import { iconForStatus } from "../status-colors.ts";
import type { RunUiSnapshot } from "../snapshot-types.ts";
import { spinnerFrame } from "../spinner.ts";
function tokens(agent: RunUiSnapshot["agents"][number]): string {
const total = (agent.usage?.input ?? 0) + (agent.usage?.output ?? agent.progress?.tokens ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0);
return total ? `${total} tok` : "tok pending";
}
export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: RunDashboardOptions = {}): string[] {
if (!snapshot) return ["Agents pane: snapshot unavailable"];
if (!snapshot.agents.length) return ["Agents pane: no agents"];
return [
`Agents pane: ${snapshot.agents.length} agents · ${snapshot.progress.completed}/${snapshot.progress.total} tasks done`,
...snapshot.agents.slice(0, 12).map((agent) => {
const parts = [
agent.status,
options.showTools !== false && agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
options.showTools !== false ? `${agent.toolUses ?? agent.progress?.toolCount ?? 0} tools` : undefined,
options.showTokens !== false ? tokens(agent) : undefined,
options.showModel !== false ? (agent.model ? `model=${agent.model}` : undefined) : undefined,
].filter((part): part is string => Boolean(part));
const icon = iconForStatus(agent.status, { runningGlyph: spinnerFrame(agent.taskId) });
return `${icon} ${agent.taskId} ${agent.role}->${agent.agent} · ${parts.join(" · ")}`;
}),
];
}

View File

@@ -0,0 +1,30 @@
import { summarizeHeartbeats } from "../heartbeat-aggregator.ts";
import type { RunUiSnapshot } from "../snapshot-types.ts";
export interface HealthPaneOptions {
staleMs?: number;
deadMs?: number;
isForeground?: boolean;
now?: number | Date;
}
function seconds(ms: number): string {
return `${Math.round(ms / 1000)}s`;
}
export function renderHealthPane(snapshot: RunUiSnapshot | undefined, opts: HealthPaneOptions = {}): string[] {
if (!snapshot) return ["Health pane: snapshot unavailable"];
const summary = summarizeHeartbeats(snapshot, opts);
const lines = [
`Health pane: ${summary.healthy}/${summary.totalTasks} healthy · stale=${summary.stale} · dead=${summary.dead} · missing=${summary.missing}`,
];
if (summary.worstStaleMs > 0) lines.push(`Worst stale: ${seconds(summary.worstStaleMs)} ago`);
const hints: string[] = [];
const foreground = opts.isForeground !== false;
if ((summary.dead > 0 || summary.missing > 0) && foreground) hints.push("R recovery");
if ((summary.dead > 0 || summary.stale > 0) && foreground) hints.push("K kill stale");
hints.push("D diagnostic export");
lines.push(`Actions: ${hints.join(" · ")}`);
if (!foreground && (summary.dead > 0 || summary.missing > 0 || summary.stale > 0)) lines.push("Async run: R/K disabled — inspect process manually or use /team-api.");
return lines;
}

View File

@@ -0,0 +1,11 @@
import type { RunUiSnapshot } from "../snapshot-types.ts";
export function renderMailboxPane(snapshot: RunUiSnapshot | undefined): string[] {
if (!snapshot) return ["Mailbox pane: snapshot unavailable"];
const mailbox = snapshot.mailbox;
const approx = mailbox.approximate ? " · approximate (tail)" : "";
return [
`Mailbox pane: inbox unread=${mailbox.inboxUnread} · outbox pending=${mailbox.outboxPending} · attention=${mailbox.needsAttention}${approx}`,
mailbox.needsAttention > 0 ? "Needs attention: press Enter for detail · A ack · N nudge · C compose · X ack all." : "No mailbox items need attention. Press Enter for detail or C compose.",
];
}

View File

@@ -0,0 +1,34 @@
import type { MetricRegistry } from "../../observability/metric-registry.ts";
import type { HistogramPoint, MetricLabels, MetricPoint } from "../../observability/metrics-primitives.ts";
import type { RunUiSnapshot } from "../snapshot-types.ts";
export interface MetricsPaneOptions {
registry?: MetricRegistry;
maxCounters?: number;
}
function labelsText(labels: MetricLabels): string {
const entries = Object.entries(labels);
return entries.length ? `{${entries.map(([key, value]) => `${key}=${value}`).join(",")}}` : "";
}
function isHistogramPoint(point: MetricPoint | HistogramPoint): point is HistogramPoint {
return "quantiles" in point;
}
export function renderMetricsPane(_snapshot: RunUiSnapshot | undefined, opts: MetricsPaneOptions = {}): string[] {
if (!opts.registry) return ["Metrics pane: registry unavailable"];
const snapshots = opts.registry.snapshot();
if (!snapshots.length) return ["Metrics pane: no metrics recorded"];
const lines = ["Metrics pane: top metrics"];
for (const snapshot of snapshots.slice(0, opts.maxCounters ?? 10)) {
const first = snapshot.values[0];
if (!first) {
lines.push(`${snapshot.name}: empty`);
continue;
}
if (isHistogramPoint(first)) lines.push(`${snapshot.name}${labelsText(first.labels)} count=${first.count} p95=${Number.isFinite(first.quantiles.p95) ? Math.round(first.quantiles.p95) : "n/a"}`);
else lines.push(`${snapshot.name}${labelsText(first.labels)} ${first.value}`);
}
return lines;
}

View File

@@ -0,0 +1,19 @@
import type { RunUiSnapshot } from "../snapshot-types.ts";
export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[] {
if (!snapshot) return ["Progress pane: snapshot unavailable"];
const progress = snapshot.progress;
const groupJoins = snapshot.groupJoins ?? [];
const groupJoinLines = groupJoins.length ? groupJoins.map((item) => `group join ${item.partial ? "partial" : "completed"}: ${item.requestId} ack=${item.ack}`) : ["group joins: none"];
const cancellationLine = snapshot.cancellationReason ? [`cancelled: reason=${snapshot.cancellationReason}`] : [];
return [
`Progress pane: ${progress.completed}/${progress.total} completed · running=${progress.running} queued=${progress.queued} failed=${progress.failed}`,
...cancellationLine,
...groupJoinLines,
...snapshot.recentEvents.slice(-10).map((event) => {
const seq = event.metadata?.seq !== undefined ? `#${event.metadata.seq}` : "#?";
return `${seq} ${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? ` · ${event.message}` : ""}`;
}),
...(snapshot.recentEvents.length ? [] : ["No recent events"]),
];
}

View File

@@ -0,0 +1,10 @@
import type { RunUiSnapshot } from "../snapshot-types.ts";
export function renderTranscriptPane(snapshot: RunUiSnapshot | undefined): string[] {
if (!snapshot) return ["Output pane: snapshot unavailable"];
return [
`Output pane: ${snapshot.recentOutputLines.length} recent lines · press v for transcript viewer · o for raw output`,
...snapshot.recentOutputLines.slice(-12).map((line) => `${line}`),
...(snapshot.recentOutputLines.length ? [] : ["No recent output"]),
];
}

View File

@@ -0,0 +1,25 @@
import type { CrewTheme } from "./theme-adapter.ts";
export interface DynamicCrewBorderOptions {
color?: (value: string) => string;
char?: string;
}
export class DynamicCrewBorder {
private readonly theme: CrewTheme;
private readonly color?: (value: string) => string;
private readonly char: string;
constructor(theme: CrewTheme, options: DynamicCrewBorderOptions = {}) {
this.theme = theme;
this.color = options.color;
this.char = options.char && options.char.length > 0 ? options.char : "─";
}
render(width: number): string[] {
const line = this.char.repeat(Math.max(0, width));
return [this.color ? this.color(line) : this.theme.fg("border", line)];
}
invalidate(): void {}
}

View File

@@ -0,0 +1,63 @@
import type { TeamTaskState } from "../state/types.ts";
import { classifyHeartbeat, heartbeatAgeMs } from "../runtime/heartbeat-gradient.ts";
import type { MetricRegistry } from "../observability/metric-registry.ts";
import type { RunUiSnapshot } from "./snapshot-types.ts";
export interface HeartbeatSummary {
runId: string;
totalTasks: number;
healthy: number;
stale: number;
dead: number;
missing: number;
worstStaleMs: number;
gradient: { healthy: number; warn: number; stale: number; dead: number };
}
export interface HeartbeatSummaryOptions {
staleMs?: number;
deadMs?: number;
now?: number | Date;
registry?: MetricRegistry;
}
function nowMs(now: number | Date | undefined): number {
if (typeof now === "number") return now;
if (now instanceof Date) return now.getTime();
return Date.now();
}
function isActiveTask(task: TeamTaskState): boolean {
return task.status === "running";
}
export function summarizeHeartbeats(snapshot: RunUiSnapshot, opts: HeartbeatSummaryOptions = {}): HeartbeatSummary {
const staleMs = opts.staleMs ?? 60_000;
const deadMs = opts.deadMs ?? 5 * 60_000;
const current = nowMs(opts.now);
const summary: HeartbeatSummary = { runId: snapshot.runId, totalTasks: snapshot.tasks.length, healthy: 0, stale: 0, dead: 0, missing: 0, worstStaleMs: 0, gradient: { healthy: 0, warn: 0, stale: 0, dead: 0 } };
for (const task of snapshot.tasks) {
if (!isActiveTask(task)) continue;
const heartbeat = task.heartbeat;
if (!heartbeat) {
summary.missing += 1;
summary.gradient.dead += 1;
continue;
}
const age = heartbeatAgeMs(heartbeat, current);
if (!Number.isFinite(age)) {
summary.missing += 1;
summary.gradient.dead += 1;
continue;
}
summary.worstStaleMs = Math.max(summary.worstStaleMs, age);
const level = classifyHeartbeat(heartbeat, { warnMs: Math.max(1, Math.floor(staleMs / 2)), staleMs, deadMs }, current);
summary.gradient[level] += 1;
opts.registry?.gauge("crew.heartbeat.staleness_ms", "Heartbeat elapsed since last seen, milliseconds").set({ runId: snapshot.runId, taskId: task.id }, age);
opts.registry?.counter("crew.heartbeat.level_total", "Heartbeat classifications by level").inc({ runId: snapshot.runId, level });
if (level === "dead") summary.dead += 1;
else if (level === "stale") summary.stale += 1;
else summary.healthy += 1;
}
return summary;
}

View File

@@ -0,0 +1,94 @@
export const DASHBOARD_KEYS = {
close: ["q", "\u001b"],
select: ["\r", "\n", "s"],
root: {
summary: ["u"],
artifacts: ["a"],
api: ["i"],
agents: ["d"],
mailbox: ["m"],
events: ["e"],
output: ["o"],
transcript: ["v"],
reload: ["r"],
progressToggle: ["p"],
},
pane: { agents: ["1"], progress: ["2"], mailbox: ["3"], output: ["4"], health: ["5"], metrics: ["6"] },
navigation: { up: ["k", "\u001b[A"], down: ["j", "\u001b[B"] },
mailbox: { ack: ["A"], nudge: ["N"], compose: ["C"], preview: ["P"], ackAll: ["X"], openDetail: ["\r", "\n"] },
health: { recovery: ["R"], killStale: ["K"], diagnosticExport: ["D"] },
notification: { dismissAll: ["H"] },
} as const;
export const KEY_RESERVED = new Set<string>([
...DASHBOARD_KEYS.close,
...DASHBOARD_KEYS.select,
...Object.values(DASHBOARD_KEYS.root).flat(),
...Object.values(DASHBOARD_KEYS.pane).flat(),
...Object.values(DASHBOARD_KEYS.navigation).flat(),
...Object.values(DASHBOARD_KEYS.mailbox).flat(),
...Object.values(DASHBOARD_KEYS.health).flat(),
...Object.values(DASHBOARD_KEYS.notification).flat(),
]);
function includes(values: readonly string[], data: string): boolean {
return values.includes(data);
}
export type DashboardKeyAction =
| "close"
| "select"
| "summary"
| "artifacts"
| "api"
| "agents"
| "mailbox"
| "events"
| "output"
| "transcript"
| "reload"
| "progressToggle"
| "pane-agents"
| "pane-progress"
| "pane-mailbox"
| "pane-output"
| "pane-health"
| "pane-metrics"
| "up"
| "down"
| "mailbox-detail"
| "health-recovery"
| "health-kill-stale"
| "health-diagnostic-export"
| "notifications-dismiss";
export function dashboardActionForKey(data: string, activePane?: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics"): DashboardKeyAction | undefined {
if (includes(DASHBOARD_KEYS.close, data)) return "close";
if (activePane === "mailbox" && includes(DASHBOARD_KEYS.mailbox.openDetail, data)) return "mailbox-detail";
if (activePane === "health") {
if (includes(DASHBOARD_KEYS.health.recovery, data)) return "health-recovery";
if (includes(DASHBOARD_KEYS.health.killStale, data)) return "health-kill-stale";
if (includes(DASHBOARD_KEYS.health.diagnosticExport, data)) return "health-diagnostic-export";
}
if (includes(DASHBOARD_KEYS.notification.dismissAll, data)) return "notifications-dismiss";
if (includes(DASHBOARD_KEYS.select, data)) return "select";
if (includes(DASHBOARD_KEYS.root.summary, data)) return "summary";
if (includes(DASHBOARD_KEYS.root.artifacts, data)) return "artifacts";
if (includes(DASHBOARD_KEYS.root.api, data)) return "api";
if (includes(DASHBOARD_KEYS.root.agents, data)) return "agents";
if (includes(DASHBOARD_KEYS.root.mailbox, data)) return "mailbox";
if (includes(DASHBOARD_KEYS.root.events, data)) return "events";
if (includes(DASHBOARD_KEYS.root.output, data)) return "output";
if (includes(DASHBOARD_KEYS.root.transcript, data)) return "transcript";
if (includes(DASHBOARD_KEYS.root.reload, data)) return "reload";
if (includes(DASHBOARD_KEYS.root.progressToggle, data)) return "progressToggle";
if (includes(DASHBOARD_KEYS.pane.agents, data)) return "pane-agents";
if (includes(DASHBOARD_KEYS.pane.progress, data)) return "pane-progress";
if (includes(DASHBOARD_KEYS.pane.mailbox, data)) return "pane-mailbox";
if (includes(DASHBOARD_KEYS.pane.output, data)) return "pane-output";
if (includes(DASHBOARD_KEYS.pane.health, data)) return "pane-health";
if (includes(DASHBOARD_KEYS.pane.metrics, data)) return "pane-metrics";
if (includes(DASHBOARD_KEYS.navigation.up, data)) return "up";
if (includes(DASHBOARD_KEYS.navigation.down, data)) return "down";
return undefined;
}

View File

@@ -0,0 +1,106 @@
import { pad, wrapHard } from "../utils/visual.ts";
export interface RenderableComponent {
invalidate(): void;
render(width: number): string[];
}
export class Container implements RenderableComponent {
private children: RenderableComponent[] = [];
addChild(child: RenderableComponent): void {
this.children.push(child);
}
clear(): void {
this.children = [];
}
invalidate(): void {
for (const child of this.children) {
child.invalidate();
}
}
render(width: number): string[] {
const lines: string[] = [];
for (const child of this.children) {
lines.push(...child.render(width));
}
return lines;
}
}
export class Box extends Container {
private readonly paddingX: number;
private readonly paddingY: number;
constructor(paddingX = 0, paddingY = 0) {
super();
this.paddingX = paddingX;
this.paddingY = paddingY;
}
render(width: number): string[] {
const innerWidth = Math.max(1, width - this.paddingX * 2);
const rows = super.render(innerWidth);
const paddedRows: string[] = [];
const left = " ".repeat(this.paddingX);
const right = " ".repeat(this.paddingX);
for (const row of rows) {
paddedRows.push(pad(`${left}${row}${right}`, width));
}
const emptyRow = pad("", width);
if (this.paddingY <= 0) return paddedRows;
if (this.paddingY > 0) {
const topAndBottom = Array.from({ length: this.paddingY }, () => emptyRow);
return [...topAndBottom, ...paddedRows, ...topAndBottom];
}
return paddedRows;
}
}
export class Text implements RenderableComponent {
private text: string;
private cachedWidth = 0;
private cachedResult: string[] = [];
constructor(text = "") {
this.text = text;
}
setText(text: string): void {
if (text === this.text) return;
this.text = text;
this.invalidate();
}
invalidate(): void {
this.cachedWidth = 0;
this.cachedResult = [];
}
render(width: number): string[] {
if (this.cachedWidth === width) return this.cachedResult;
const wrapped = wrapHard(this.text, Math.max(1, width));
const lines = wrapped.length ? wrapped : [""];
this.cachedWidth = width;
this.cachedResult = lines.map((line) => pad(line, width));
return this.cachedResult;
}
}
export class Spacer implements RenderableComponent {
private readonly rows: number;
constructor(rows = 0) {
this.rows = rows;
}
render(width: number): string[] {
if (this.rows <= 0) return [];
return Array.from({ length: Math.max(0, this.rows) }, () => pad("", width));
}
invalidate(): void {}
}

View File

@@ -0,0 +1,176 @@
import * as fs from "node:fs";
import type { CrewUiConfig } from "../config/config.ts";
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
import { applyAttentionState, resolveCrewControlConfig } from "../runtime/agent-control.ts";
import { formatTaskGraphLines, waitingReason } from "../runtime/task-display.ts";
import { loadRunManifestById } from "../state/state-store.ts";
import { aggregateUsage, formatUsage } from "../state/usage.ts";
import type { TeamTaskState } from "../state/types.ts";
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
import { pad, truncate } from "../utils/visual.ts";
import { iconForStatus } from "./status-colors.ts";
import type { CrewTheme } from "./theme-adapter.ts";
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
import { Box, Text } from "./layout-primitives.ts";
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
import { spinnerBucket, spinnerFrame } from "./spinner.ts";
const TASK_READ_TTL_MS = 200;
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);
}
type Done = (value: undefined) => void;
function line(text: string, width: number): string {
return `${pad(truncate(text, width - 4), width - 4)}`;
}
function border(left: string, fill: string, right: string, width: number): string {
return `${left}${fill.repeat(Math.max(0, width - 2))}${right}`;
}
function readTasks(path: string): TeamTaskState[] {
const parse = () => {
const parsed = JSON.parse(fs.readFileSync(path, "utf-8"));
return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
};
try {
return readJsonFileCoalesced(path, TASK_READ_TTL_MS, parse);
} catch {
return [];
}
}
function shortUsage(tasks: TeamTaskState[]): string {
const usage = aggregateUsage(tasks);
return usage ? formatUsage(usage) : "usage=(none)";
}
export class LiveRunSidebar {
private readonly cwd: string;
private readonly runId: string;
private readonly done: Done;
private readonly theme: CrewTheme;
private readonly config: CrewUiConfig;
private readonly unsubscribeTheme: () => void;
private readonly snapshotCache?: RunSnapshotCache;
private cachedLines: string[] = [];
private cachedWidth = 0;
private cachedSignature = "";
constructor(input: { cwd: string; runId: string; done: Done; theme?: unknown; config?: CrewUiConfig; snapshotCache?: RunSnapshotCache }) {
this.cwd = input.cwd;
this.runId = input.runId;
this.done = input.done;
this.theme = asCrewTheme(input.theme);
this.config = input.config ?? {};
this.snapshotCache = input.snapshotCache;
this.unsubscribeTheme = subscribeThemeChange(input.theme, () => this.invalidate());
}
private buildSignature(manifestStatus: string, tasks: TeamTaskState[], agents: ReturnType<typeof readCrewAgents>, waitingCount: number, snapshot?: RunUiSnapshot): string {
const animation = agents.some((agent) => agent.status === "running") ? `:spin=${spinnerBucket()}` : "";
if (snapshot) return `${snapshot.signature}:${waitingCount}${animation}`;
const taskSig = tasks.map((task) => `${task.id}:${task.status}:${task.startedAt ?? ""}:${task.finishedAt ?? ""}:${task.agentProgress?.currentTool ?? ""}:${task.agentProgress?.toolCount ?? 0}:${task.agentProgress?.tokens ?? 0}:${task.usage ? JSON.stringify(task.usage) : ""}`).join("|");
const agentSig = agents.map((agent) => [agent.id, agent.status, agent.startedAt, agent.completedAt ?? "", agent.progress?.currentTool ?? "", agent.progress?.toolCount ?? 0, agent.progress?.tokens ?? 0, agent.progress?.turns ?? 0, agent.progress?.lastActivityAt ?? "", agent.progress?.recentOutput?.at(-1) ?? "", agent.toolUses ?? 0].join(":")).join("|");
return `${manifestStatus}|${agents.length}|${waitingCount}|${taskSig}|${agentSig}${animation}`;
}
private colorLine(line: string): string {
const iconColor = (icon: string): Parameters<CrewTheme["fg"]>[0] => {
if (icon === "✓") return "success";
if (icon === "✗") return "error";
if (icon === "■" || icon === "⏸") return "warning";
return "accent";
};
return line.replace(/[✓✗■⏸◦·▶]/g, (icon) => this.theme.fg(iconColor(icon), icon));
}
invalidate(): void {
this.cachedLines = [];
this.cachedSignature = "";
}
dispose(): void {
this.unsubscribeTheme();
}
render(width: number): string[] {
const w = Math.max(36, width);
const loaded = loadRunManifestById(this.cwd, this.runId);
if (!loaded) {
return renderLines(
[
border("╭", "─", "╮", w),
line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
line("run not found", w),
border("╰", "─", "╯", w),
],
w,
);
}
let snapshot: RunUiSnapshot | undefined;
try {
snapshot = this.snapshotCache?.refreshIfStale(this.runId);
} catch {
snapshot = undefined;
}
const run = snapshot?.manifest ?? loaded.manifest;
const tasks = snapshot?.tasks ?? readTasks(run.tasksPath);
const controlConfig = resolveCrewControlConfig({ ui: this.config });
const rawAgents = snapshot?.agents ?? readCrewAgents(run);
const agents = rawAgents.map((agent) => applyAttentionState(run, agent, controlConfig));
const active = agents.filter((agent) => agent.status === "running");
const completed = agents.filter((agent) => agent.status !== "running").slice(-5);
const waiting = tasks.filter((task) => task.status === "queued");
const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot);
if (signature !== this.cachedSignature || w !== this.cachedWidth) {
const lines: string[] = [
border("╭", "─", "╮", w),
line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
line(`${run.runId.slice(-12)} · ${run.status} · right default`, w),
line(`${run.team}/${run.workflow ?? "none"} · ${shortUsage(tasks)}`, w),
border("├", "─", "┤", w),
line(`Active agents (${active.length})`, w),
];
for (const agent of active.slice(0, 8)) {
const status = iconForStatus(agent.status, { runningGlyph: spinnerFrame(agent.taskId) });
const usage = agent.usage ? formatUsage(agent.usage) : agent.progress?.tokens ? `tokens=${agent.progress.tokens}` : "usage=pending";
lines.push(line(`${status} ${agent.taskId} ${agent.role}->${agent.agent}`, w));
lines.push(line(` ${agent.routing ? `model ${agent.routing.requested ? `${agent.routing.requested}` : ""}${agent.routing.resolved}` : agent.model ? `model ${agent.model}` : "model pending"}`, w));
lines.push(line(` ${agent.progress?.currentTool ? `tool ${agent.progress.currentTool} · ` : ""}${agent.toolUses ?? 0} tools · ${usage}`, w));
}
if (!active.length) lines.push(line("- none", w));
lines.push(border("├", "─", "┤", w), line(`Waiting tasks (${waiting.length})`, w));
for (const task of waiting.slice(0, 8)) {
const status = iconForStatus("queued");
lines.push(line(`${status} ${task.id} ${waitingReason(task, tasks) ?? "waiting"}`, w));
}
if (waiting.length === 0) lines.push(line("- none", w));
lines.push(border("├", "─", "┤", w), line(`Completed agents (${completed.length})`, w));
for (const agent of completed) {
const status = iconForStatus(agent.status === "running" ? "stopped" : agent.status);
lines.push(line(`${status} ${agent.taskId} ${agent.model ? `· ${agent.model}` : ""}${agent.usage ? ` · ${formatUsage(agent.usage)}` : ""}`, w));
}
if (completed.length === 0) lines.push(line("- none", w));
lines.push(border("├", "─", "┤", w));
for (const entry of formatTaskGraphLines(tasks).slice(0, 6)) lines.push(line(entry, w));
lines.push(line("q close · /team-dashboard details", w), border("╰", "─", "╯", w));
this.cachedLines = renderLines(lines.map((entry) => this.colorLine(entry)), w);
this.cachedSignature = signature;
this.cachedWidth = w;
}
return this.cachedLines;
}
handleInput(data: string): void {
if (data === "q" || data === "\u001b") this.done(undefined);
}
}

View File

@@ -0,0 +1,158 @@
import { pad, truncate } from "../utils/visual.ts";
import type { CrewTheme } from "./theme-adapter.ts";
import { asCrewTheme } from "./theme-adapter.ts";
import { DynamicCrewBorder } from "./dynamic-border.ts";
export interface BorderedLoaderOptions {
message: string;
cancellable?: boolean;
frames?: string[];
intervalMs?: number;
minWidth?: number;
onAbort?: () => void;
}
const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export class CrewBorderedLoader {
private readonly abortController = new AbortController();
private readonly frameOptions: string[];
private readonly intervalMs: number;
private readonly minWidth: number;
private readonly onAbort?: () => void;
private theme: CrewTheme;
private message: string;
private lineCache = "";
private width = 0;
private startedAt = Date.now();
constructor(_ui: unknown, themeLike: unknown, options: BorderedLoaderOptions) {
const theme = asCrewTheme(themeLike);
this.theme = theme;
this.message = options.message;
this.minWidth = Math.max(12, options.minWidth ?? 24);
this.onAbort = options.onAbort;
this.frameOptions = options.frames ?? DEFAULT_FRAMES;
this.intervalMs = Math.max(40, options.intervalMs ?? 120);
}
private spinnerFrame(): string {
if (this.frameOptions.length === 0) return "•";
const elapsed = Date.now() - this.startedAt;
const index = Math.floor(elapsed / this.intervalMs) % this.frameOptions.length;
return this.frameOptions[Math.max(0, index)];
}
setMessage(message: string): void {
this.message = message;
}
get signal(): AbortSignal {
return this.abortController.signal;
}
handleInput(data: string): void {
if (!this.onAbort || this.abortController.signal.aborted) return;
if (data === "c" || data === "q" || data === "\u001b" || data === "\u0003") {
this.abortController.abort();
this.onAbort();
}
}
render(width: number): string[] {
if (width === this.width && this.lineCache) {
return this.lineCache.split("\n");
}
const innerWidth = Math.max(this.minWidth - 4, 1);
const contentWidth = Math.max(1, Math.min(width - 4, innerWidth));
const frame = this.spinnerFrame();
const loaderLine = ` ${frame} ${truncate(this.message, Math.max(1, contentWidth - 4))} `;
const body = ` ${truncate(loaderLine, contentWidth - 2)} `;
const inner = ` ${pad(body, contentWidth - 1)} `;
const padWidth = Math.max(0, width - (contentWidth + 4));
const leftRightPad = " ".repeat(Math.floor(padWidth / 2));
const widthAwareInner = contentWidth + padWidth;
const border = new DynamicCrewBorder(this.theme).render(widthAwareInner + 2)[0];
const top = `${leftRightPad}${this.theme.fg("border", "┌")}${border}${this.theme.fg("border", "┐")}`;
const line = `${leftRightPad}${this.theme.fg("border", "│")} ${truncate(inner, widthAwareInner)} ${this.theme.fg("border", "│")}`;
const hint = `${leftRightPad}${this.theme.fg("border", "│")}${" ".repeat(widthAwareInner + 2)}${this.theme.fg("border", "│")}`;
const bottom = `${leftRightPad}${this.theme.fg("border", "└")}${border}${this.theme.fg("border", "┘")}`;
const lineWithHint = optionsHint(this.theme, this.message, widthAwareInner);
this.width = width;
const lines = [
top,
line,
`${leftRightPad}${pad(lineWithHint, widthAwareInner)}`,
hint,
bottom,
];
this.lineCache = lines.join("\n");
return lines;
}
invalidate(): void {
this.lineCache = "";
this.width = 0;
}
dispose(): void {
this.abortController.abort();
}
}
export interface CountdownTimerOptions {
timeoutMs: number;
onTick: (seconds: number) => void;
onExpire: () => void;
}
export class CountdownTimer {
private readonly onExpire: () => void;
private readonly onTick: (seconds: number) => void;
private readonly startedAt: number;
private readonly timeoutMs: number;
private timer: ReturnType<typeof setTimeout> | undefined;
private expired = false;
constructor(options: CountdownTimerOptions) {
this.timeoutMs = Math.max(0, options.timeoutMs);
this.onTick = options.onTick;
this.onExpire = options.onExpire;
this.startedAt = Date.now();
this.onTick(this.secondsLeft());
if (this.timeoutMs === 0) {
this.emitExpire();
return;
}
this.timer = setInterval(() => {
const seconds = this.secondsLeft();
this.onTick(seconds);
if (seconds <= 0) {
this.emitExpire();
}
}, 1000);
}
private emitExpire(): void {
if (this.expired) return;
this.expired = true;
this.dispose();
this.onExpire();
}
private secondsLeft(): number {
const remainingMs = this.startedAt + this.timeoutMs - Date.now();
return Math.max(0, Math.ceil(remainingMs / 1000));
}
dispose(): void {
if (this.timer === undefined) return;
clearInterval(this.timer);
this.timer = undefined;
}
}
function optionsHint(theme: CrewTheme, message: string, width: number): string {
if (!message) return "";
return truncate(theme.fg("muted", message), width);
}

View File

@@ -0,0 +1,442 @@
import type { CrewTheme } from "./theme-adapter.ts";
import { asCrewTheme } from "./theme-adapter.ts";
import { pad } from "../utils/visual.ts";
import { DynamicCrewBorder } from "./dynamic-border.ts";
export type MascotStyle = "cat" | "armin";
export type MascotEffect =
| "random"
| "none"
| "typewriter"
| "scanline"
| "rain"
| "fade"
| "crt"
| "glitch"
| "dissolve";
interface AnimatedMascotOptions {
frameIntervalMs?: number;
autoCloseMs?: number;
requestRender?: () => void;
style?: MascotStyle;
effect?: MascotEffect;
}
const BS = String.fromCharCode(92);
const CAT_FRAMES: readonly (readonly string[])[] = [
[` /${BS}_/${BS} `, "(='.'=)", "( _ )", ` ${BS}_/ `],
[` /${BS}_/${BS} `, "(='o'=)", "( w )", ` ${BS}_/ `],
[` /${BS}_/${BS} `, "(=^.^=)", "( _ )", ` ${BS}_/ `],
[` /${BS}_/${BS} `, "(=*.*=)", "( v )", ` ${BS}_/ `],
] as const;
// Armin XBM: 31x36 px, LSB first, 1=background, 0=foreground (ported from pi-mono coding-agent)
const ARMIN_WIDTH = 31;
const ARMIN_HEIGHT = 36;
const ARMIN_BITS: readonly number[] = [
0xff, 0xff, 0xff, 0x7f, 0xff, 0xf0, 0xff, 0x7f, 0xff, 0xed, 0xff, 0x7f, 0xff, 0xdb, 0xff, 0x7f, 0xff, 0xb7, 0xff,
0x7f, 0xff, 0x77, 0xfe, 0x7f, 0x3f, 0xf8, 0xfe, 0x7f, 0xdf, 0xff, 0xfe, 0x7f, 0xdf, 0x3f, 0xfc, 0x7f, 0x9f, 0xc3,
0xfb, 0x7f, 0x6f, 0xfc, 0xf4, 0x7f, 0xf7, 0x0f, 0xf7, 0x7f, 0xf7, 0xff, 0xf7, 0x7f, 0xf7, 0xff, 0xe3, 0x7f, 0xf7,
0x07, 0xe8, 0x7f, 0xef, 0xf8, 0x67, 0x70, 0x0f, 0xff, 0xbb, 0x6f, 0xf1, 0x00, 0xd0, 0x5b, 0xfd, 0x3f, 0xec, 0x53,
0xc1, 0xff, 0xef, 0x57, 0x9f, 0xfd, 0xee, 0x5f, 0x9f, 0xfc, 0xae, 0x5f, 0x1f, 0x78, 0xac, 0x5f, 0x3f, 0x00, 0x50,
0x6c, 0x7f, 0x00, 0xdc, 0x77, 0xff, 0xc0, 0x3f, 0x78, 0xff, 0x01, 0xf8, 0x7f, 0xff, 0x03, 0x9c, 0x78, 0xff, 0x07,
0x8c, 0x7c, 0xff, 0x0f, 0xce, 0x78, 0xff, 0xff, 0xcf, 0x7f, 0xff, 0xff, 0xcf, 0x78, 0xff, 0xff, 0xdf, 0x78, 0xff,
0xff, 0xdf, 0x7d, 0xff, 0xff, 0x3f, 0x7e, 0xff, 0xff, 0xff, 0x7f,
];
const ARMIN_BYTES_PER_ROW = Math.ceil(ARMIN_WIDTH / 8);
const ARMIN_DISPLAY_HEIGHT = Math.ceil(ARMIN_HEIGHT / 2);
const NON_NONE_EFFECTS: MascotEffect[] = [
"typewriter",
"scanline",
"rain",
"fade",
"crt",
"glitch",
"dissolve",
];
const CAT_FRIENDLY_EFFECTS: MascotEffect[] = ["scanline", "glitch", "crt"];
function getArminPixel(x: number, y: number): boolean {
if (y >= ARMIN_HEIGHT) return false;
const byteIndex = y * ARMIN_BYTES_PER_ROW + Math.floor(x / 8);
const bitIndex = x % 8;
return ((ARMIN_BITS[byteIndex] >> bitIndex) & 1) === 0;
}
function getArminChar(x: number, row: number): string {
const upper = getArminPixel(x, row * 2);
const lower = getArminPixel(x, row * 2 + 1);
if (upper && lower) return "█";
if (upper) return "▀";
if (lower) return "▄";
return " ";
}
function buildArminGrid(): string[][] {
const grid: string[][] = [];
for (let row = 0; row < ARMIN_DISPLAY_HEIGHT; row++) {
const line: string[] = [];
for (let x = 0; x < ARMIN_WIDTH; x++) line.push(getArminChar(x, row));
grid.push(line);
}
return grid;
}
function emptyArminGrid(): string[][] {
return Array.from({ length: ARMIN_DISPLAY_HEIGHT }, () => Array(ARMIN_WIDTH).fill(" "));
}
interface EffectState {
pos?: number;
row?: number;
expansion?: number;
phase?: number;
glitchFrames?: number;
positions?: [number, number][];
idx?: number;
drops?: { y: number; settled: number }[];
done?: boolean;
}
export class AnimatedMascot {
private readonly theme: CrewTheme;
private readonly frameIntervalMs: number;
private readonly autoCloseMs: number;
private readonly onDone: () => void;
private readonly requestRender: (() => void) | undefined;
private readonly doneGuard: { called: boolean } = { called: false };
private readonly interval: ReturnType<typeof setInterval> | undefined;
private readonly timeout: ReturnType<typeof setTimeout> | undefined;
private readonly style: MascotStyle;
private readonly effect: MascotEffect;
private readonly finalArminGrid: string[][];
private currentArminGrid: string[][];
private effectState: EffectState = {};
private effectDone = false;
private frame = 0;
private effectPhase = 0;
private gridVersion = 0;
private cachedWidth = 0;
private cachedVersion = -1;
private cachedLines: string[] = [];
constructor(themeLike: unknown, onDone: () => void, options: AnimatedMascotOptions = {}) {
this.theme = asCrewTheme(themeLike);
this.onDone = onDone;
this.frameIntervalMs = Math.max(16, Math.floor(options.frameIntervalMs ?? 180));
this.autoCloseMs = Math.max(0, Math.floor(options.autoCloseMs ?? 7_000));
this.requestRender = options.requestRender;
this.style = options.style === "armin" ? "armin" : "cat";
this.effect = this.resolveEffect(options.effect);
this.finalArminGrid = buildArminGrid();
this.currentArminGrid = this.style === "armin" ? this.initialArminGrid() : emptyArminGrid();
this.initEffect();
this.interval = setInterval(() => this.tick(), this.frameIntervalMs);
this.interval.unref();
this.timeout = this.autoCloseMs > 0 ? setTimeout(() => this.close(), this.autoCloseMs) : undefined;
this.timeout?.unref();
}
private resolveEffect(requested: MascotEffect | undefined): MascotEffect {
if (!requested || requested === "random") {
const pool = this.style === "armin" ? NON_NONE_EFFECTS : CAT_FRIENDLY_EFFECTS;
return pool[Math.floor(Math.random() * pool.length)];
}
return requested;
}
private initialArminGrid(): string[][] {
if (this.effect === "dissolve") {
const chars = [" ", "░", "▒", "▓", "█", "▀", "▄"];
return Array.from({ length: ARMIN_DISPLAY_HEIGHT }, () =>
Array.from({ length: ARMIN_WIDTH }, () => chars[Math.floor(Math.random() * chars.length)]),
);
}
return emptyArminGrid();
}
private initEffect(): void {
this.effectState = {};
this.effectDone = false;
switch (this.effect) {
case "typewriter":
this.effectState = { pos: 0 };
break;
case "scanline":
this.effectState = { row: 0 };
break;
case "rain":
this.effectState = {
drops: Array.from({ length: ARMIN_WIDTH }, () => ({
y: -Math.floor(Math.random() * ARMIN_DISPLAY_HEIGHT * 2),
settled: 0,
})),
};
break;
case "fade":
case "dissolve": {
const positions: [number, number][] = [];
for (let row = 0; row < ARMIN_DISPLAY_HEIGHT; row++) {
for (let x = 0; x < ARMIN_WIDTH; x++) positions.push([row, x]);
}
for (let i = positions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[positions[i], positions[j]] = [positions[j], positions[i]];
}
this.effectState = { positions, idx: 0 };
break;
}
case "crt":
this.effectState = { expansion: 0 };
break;
case "glitch":
this.effectState = { phase: 0, glitchFrames: 8 };
break;
case "none":
this.currentArminGrid = this.finalArminGrid.map((row) => [...row]);
this.effectDone = true;
break;
}
}
invalidate(): void {
this.cachedWidth = 0;
this.cachedLines = [];
}
private tick(): void {
this.effectPhase++;
this.frame = (this.frame + 1) % CAT_FRAMES.length;
if (!this.effectDone && this.style === "armin") {
this.effectDone = this.tickArminEffect();
this.gridVersion++;
}
this.invalidate();
this.requestRender?.();
}
private tickArminEffect(): boolean {
switch (this.effect) {
case "typewriter":
return this.tickTypewriter();
case "scanline":
return this.tickScanline();
case "rain":
return this.tickRain();
case "fade":
return this.tickFade();
case "crt":
return this.tickCrt();
case "glitch":
return this.tickGlitch();
case "dissolve":
return this.tickDissolve();
default:
return true;
}
}
private tickTypewriter(): boolean {
const state = this.effectState;
if (state.pos === undefined) return true;
for (let i = 0; i < 6; i++) {
const row = Math.floor(state.pos / ARMIN_WIDTH);
const x = state.pos % ARMIN_WIDTH;
if (row >= ARMIN_DISPLAY_HEIGHT) return true;
this.currentArminGrid[row][x] = this.finalArminGrid[row][x];
state.pos++;
}
return false;
}
private tickScanline(): boolean {
const state = this.effectState;
if (state.row === undefined) return true;
if (state.row >= ARMIN_DISPLAY_HEIGHT) return true;
for (let x = 0; x < ARMIN_WIDTH; x++) this.currentArminGrid[state.row][x] = this.finalArminGrid[state.row][x];
state.row++;
return false;
}
private tickRain(): boolean {
const drops = this.effectState.drops;
if (!drops) return true;
let allSettled = true;
this.currentArminGrid = emptyArminGrid();
for (let x = 0; x < ARMIN_WIDTH; x++) {
const drop = drops[x];
for (let row = ARMIN_DISPLAY_HEIGHT - 1; row >= ARMIN_DISPLAY_HEIGHT - drop.settled; row--) {
if (row >= 0) this.currentArminGrid[row][x] = this.finalArminGrid[row][x];
}
if (drop.settled >= ARMIN_DISPLAY_HEIGHT) continue;
allSettled = false;
let targetRow = -1;
for (let row = ARMIN_DISPLAY_HEIGHT - 1 - drop.settled; row >= 0; row--) {
if (this.finalArminGrid[row][x] !== " ") {
targetRow = row;
break;
}
}
drop.y++;
if (drop.y >= 0 && drop.y < ARMIN_DISPLAY_HEIGHT) {
if (targetRow >= 0 && drop.y >= targetRow) {
drop.settled = ARMIN_DISPLAY_HEIGHT - targetRow;
drop.y = -Math.floor(Math.random() * 5) - 1;
} else {
this.currentArminGrid[drop.y][x] = "▓";
}
}
}
return allSettled;
}
private tickFade(): boolean {
const state = this.effectState;
if (!state.positions || state.idx === undefined) return true;
for (let i = 0; i < 18; i++) {
if (state.idx >= state.positions.length) return true;
const [row, x] = state.positions[state.idx];
this.currentArminGrid[row][x] = this.finalArminGrid[row][x];
state.idx++;
}
return false;
}
private tickCrt(): boolean {
const state = this.effectState;
if (state.expansion === undefined) return true;
const midRow = Math.floor(ARMIN_DISPLAY_HEIGHT / 2);
this.currentArminGrid = emptyArminGrid();
const top = midRow - state.expansion;
const bottom = midRow + state.expansion;
for (let row = Math.max(0, top); row <= Math.min(ARMIN_DISPLAY_HEIGHT - 1, bottom); row++) {
for (let x = 0; x < ARMIN_WIDTH; x++) this.currentArminGrid[row][x] = this.finalArminGrid[row][x];
}
state.expansion++;
return state.expansion > ARMIN_DISPLAY_HEIGHT;
}
private tickGlitch(): boolean {
const state = this.effectState;
if (state.phase === undefined || state.glitchFrames === undefined) return true;
if (state.phase < state.glitchFrames) {
this.currentArminGrid = this.finalArminGrid.map((row) => {
const offset = Math.floor(Math.random() * 7) - 3;
const glitchRow = [...row];
if (Math.random() < 0.3) {
const shifted = glitchRow.slice(offset).concat(glitchRow.slice(0, offset));
return shifted.slice(0, ARMIN_WIDTH);
}
if (Math.random() < 0.2) {
const swapRow = Math.floor(Math.random() * ARMIN_DISPLAY_HEIGHT);
return [...this.finalArminGrid[swapRow]];
}
return glitchRow;
});
state.phase++;
return false;
}
this.currentArminGrid = this.finalArminGrid.map((row) => [...row]);
return true;
}
private tickDissolve(): boolean {
const state = this.effectState;
if (!state.positions || state.idx === undefined) return true;
for (let i = 0; i < 22; i++) {
if (state.idx >= state.positions.length) return true;
const [row, x] = state.positions[state.idx];
this.currentArminGrid[row][x] = this.finalArminGrid[row][x];
state.idx++;
}
return false;
}
private close(): void {
if (this.doneGuard.called) return;
this.doneGuard.called = true;
if (this.interval) clearInterval(this.interval);
if (this.timeout) clearTimeout(this.timeout);
this.onDone();
}
private formatLine(line: string, width: number, color: Parameters<CrewTheme["fg"]>[0] = "accent"): string {
const contentWidth = Math.max(0, width - 4);
const themed = this.theme.fg(color, line);
return `${pad(themed, contentWidth)}`;
}
private currentCatFrame(): readonly string[] {
return CAT_FRAMES[this.frame];
}
private applyCatEffect(lines: readonly string[]): string[] {
if (this.effect === "none") return [...lines];
if (this.effect === "scanline") {
const scanRow = this.effectPhase % (lines.length + 4);
return lines.map((ln, i) =>
i === scanRow ? this.theme.bold(this.theme.fg("accent", ln)) : ln,
);
}
if (this.effect === "glitch") {
if (this.effectPhase % 9 !== 0) return [...lines];
return lines.map((ln) => {
if (Math.random() > 0.4) return ln;
const offset = 1 + Math.floor(Math.random() * 2);
return ln.length > offset ? ln.slice(offset) + ln.slice(0, offset) : ln;
});
}
if (this.effect === "crt") {
const flickerOn = Math.floor(this.effectPhase / 4) % 2 === 0;
return lines.map((ln) => (flickerOn ? this.theme.bold(ln) : ln));
}
return [...lines];
}
render(width: number): string[] {
if (width === this.cachedWidth && this.cachedVersion === this.gridVersion && this.cachedLines.length) {
return this.cachedLines;
}
const safeWidth = Math.max(20, width);
const horizontal = new DynamicCrewBorder(this.theme).render(Math.max(0, safeWidth - 2))[0];
const result: string[] = [
`${this.theme.fg("border", "╭")}${horizontal}${this.theme.fg("border", "╮")}`,
this.formatLine(this.theme.bold(" ARMIN SAYS HI "), safeWidth),
this.formatLine("", safeWidth),
];
if (this.style === "armin") {
for (const row of this.currentArminGrid) {
const text = row.join("");
result.push(this.formatLine(text, safeWidth));
}
} else {
const frameLines = this.applyCatEffect(this.currentCatFrame());
for (const line of frameLines) result.push(this.formatLine(line, safeWidth));
}
const hint = this.style === "armin"
? `Press q or Esc to close · effect: ${this.effect}`
: "Press q or Esc to close · animated preview";
result.push(this.formatLine(hint, safeWidth, "muted"));
result.push(`${this.theme.fg("border", "╰")}${horizontal}${this.theme.fg("border", "╯")}`);
this.cachedWidth = safeWidth;
this.cachedVersion = this.gridVersion;
this.cachedLines = result;
return result;
}
handleInput(data: string): void {
if (data === "q" || data === "\u001b" || data === "\u0003") {
this.close();
}
}
dispose(): void {
this.doneGuard.called = true;
if (this.interval) clearInterval(this.interval);
if (this.timeout) clearTimeout(this.timeout);
}
}

View File

@@ -0,0 +1,57 @@
import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
import { loadRunManifestById } from "../../state/state-store.ts";
import { pad, truncate } from "../../utils/visual.ts";
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
export interface AgentPickerSelection {
agentId: string;
}
export class AgentPickerOverlay {
private readonly agents: CrewAgentRecord[];
private readonly done: (selection: AgentPickerSelection | undefined) => void;
private readonly theme: CrewTheme;
private selected = 0;
constructor(opts: { cwd: string; runId: string; done: (selection: AgentPickerSelection | undefined) => void; theme?: unknown }) {
const loaded = loadRunManifestById(opts.cwd, opts.runId);
this.agents = loaded ? readCrewAgents(loaded.manifest) : [];
this.done = opts.done;
this.theme = asCrewTheme(opts.theme ?? {});
}
invalidate(): void {
// Agent list is captured at open time.
}
render(width: number): string[] {
const inner = Math.max(24, width - 4);
const lines = [
this.theme.bold("Select agent"),
"↑/↓ move · Enter select · ESC cancel",
...this.agents.map((agent, index) => `${index === this.selected ? "" : " "} ${agent.taskId} · ${agent.status} · ${agent.role}->${agent.agent}`),
];
if (!this.agents.length) lines.push("No agents found.");
return lines.map((line) => pad(truncate(line, inner), inner));
}
handleInput(data: string): void {
if (data === "\u001b" || data === "q") {
this.done(undefined);
return;
}
if (data === "k" || data === "\u001b[A") {
this.selected = Math.max(0, this.selected - 1);
return;
}
if (data === "j" || data === "\u001b[B") {
this.selected = Math.min(Math.max(0, this.agents.length - 1), this.selected + 1);
return;
}
if (data === "\r" || data === "\n") {
const agent = this.agents[this.selected];
this.done(agent ? { agentId: agent.taskId } : undefined);
}
}
}

View File

@@ -0,0 +1,58 @@
import { Box, Text } from "../layout-primitives.ts";
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
import { pad, truncate } from "../../utils/visual.ts";
export interface ConfirmOptions {
title: string;
body?: string;
dangerLevel?: "low" | "medium" | "high";
defaultAction?: "confirm" | "cancel";
}
export class ConfirmOverlay {
private readonly opts: ConfirmOptions;
private readonly done: (confirmed: boolean) => void;
private readonly theme: CrewTheme;
constructor(opts: ConfirmOptions, done: (confirmed: boolean) => void, theme: unknown = {}) {
this.opts = opts;
this.done = done;
this.theme = asCrewTheme(theme);
}
invalidate(): void {
// Stateless overlay.
}
render(width: number): string[] {
const innerWidth = Math.max(24, Math.min(width - 4, 72));
const color = this.opts.dangerLevel === "high" ? "error" : this.opts.dangerLevel === "medium" ? "warning" : "accent";
const title = this.theme.bold(this.theme.fg(color, this.opts.title));
const hint = this.opts.defaultAction === "confirm" ? "Enter/Y confirm · N/ESC cancel" : "Y confirm · Enter/N/ESC cancel";
const bodyLines = (this.opts.body ?? "").split(/\r?\n/).filter(Boolean);
const lines = [
`${"─".repeat(innerWidth)}`,
`${pad(truncate(title, innerWidth - 1), innerWidth - 1)}`,
`${"─".repeat(innerWidth)}`,
...(bodyLines.length ? bodyLines : ["Are you sure?"]).map((line) => `${pad(truncate(line, innerWidth - 1), innerWidth - 1)}`),
`${"─".repeat(innerWidth)}`,
`${pad(truncate(this.theme.fg("dim", hint), innerWidth - 1), innerWidth - 1)}`,
`${"─".repeat(innerWidth)}`,
];
const box = new Box(0, 0);
for (const line of lines) box.addChild(new Text(line));
return box.render(width);
}
handleInput(data: string): void {
if (data === "y" || data === "Y") {
this.done(true);
return;
}
if ((data === "\r" || data === "\n") && this.opts.defaultAction === "confirm") {
this.done(true);
return;
}
if (data === "n" || data === "N" || data === "\u001b" || data === "q" || data === "\r" || data === "\n") this.done(false);
}
}

View File

@@ -0,0 +1,144 @@
import type { MailboxDirection } from "../../state/mailbox.ts";
import { pad, truncate } from "../../utils/visual.ts";
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
import { ConfirmOverlay } from "./confirm-overlay.ts";
import { renderComposePreview } from "./mailbox-compose-preview.ts";
export interface MailboxComposePayload {
from: string;
to: string;
body: string;
taskId?: string;
direction: MailboxDirection;
}
export type MailboxComposeResult = { type: "submit"; payload: MailboxComposePayload } | { type: "cancel" };
type FieldName = "from" | "to" | "body" | "taskId" | "direction";
const FIELD_ORDER: FieldName[] = ["from", "to", "body", "taskId", "direction"];
export class MailboxComposeOverlay {
private readonly done: (result: MailboxComposeResult) => void;
private readonly theme: CrewTheme;
private fields: MailboxComposePayload = { from: "operator", to: "leader", body: "", direction: "inbox" };
private activeField = 1;
private error: string | undefined;
private preview = false;
private confirm: ConfirmOverlay | undefined;
constructor(opts: { done: (result: MailboxComposeResult) => void; theme?: unknown; initial?: Partial<MailboxComposePayload> }) {
this.done = opts.done;
this.theme = asCrewTheme(opts.theme ?? {});
this.fields = { ...this.fields, ...opts.initial };
}
invalidate(): void {
// State is updated synchronously from input.
}
render(width: number): string[] {
if (this.confirm) return this.confirm.render(width);
const inner = Math.max(24, width - 4);
const formWidth = this.preview ? Math.max(24, Math.floor(inner * 0.6)) : inner;
const lines = [
this.theme.bold("Compose mailbox message"),
this.preview ? "P close preview · Tab cycle · Enter submit · ESC discard" : "P preview · Tab cycle · Enter submit · ESC discard",
...(this.error ? [this.theme.fg("error", this.error)] : []),
this.fieldLine("from", formWidth),
this.fieldLine("to", formWidth),
this.fieldLine("body", formWidth),
this.fieldLine("taskId", formWidth),
`${this.activeField === 4 ? "" : " "} [${this.fields.direction === "outbox" ? "x" : " "}] Send to outbox`,
];
if (!this.preview) return lines.map((line) => pad(truncate(line, inner), inner));
const previewLines = renderComposePreview(this.fields.body, Math.max(20, inner - formWidth - 3), this.theme);
const max = Math.max(lines.length, previewLines.length);
const split: string[] = [];
for (let index = 0; index < max; index += 1) {
split.push(`${pad(truncate(lines[index] ?? "", formWidth), formWidth)}${truncate(previewLines[index] ?? "", inner - formWidth - 3)}`);
}
return split;
}
private fieldLine(field: Exclude<FieldName, "direction">, width: number): string {
const active = FIELD_ORDER[this.activeField] === field;
const label = field === "taskId" ? "taskId" : field;
return `${active ? "" : " "} ${label}: ${truncate(this.fields[field] ?? "", Math.max(8, width - label.length - 5))}`;
}
private activeName(): FieldName {
return FIELD_ORDER[this.activeField] ?? "body";
}
private appendText(data: string): void {
const field = this.activeName();
if (field === "direction") return;
this.fields = { ...this.fields, [field]: `${this.fields[field] ?? ""}${data}` };
this.error = undefined;
}
private backspace(): void {
const field = this.activeName();
if (field === "direction") return;
this.fields = { ...this.fields, [field]: (this.fields[field] ?? "").slice(0, -1) };
}
private submit(): void {
const body = this.fields.body.trim();
if (!body) {
this.error = "Body is required.";
return;
}
if (!this.fields.to.trim()) {
this.error = "Recipient is required.";
return;
}
this.done({ type: "submit", payload: { ...this.fields, from: this.fields.from.trim() || "operator", to: this.fields.to.trim(), body, taskId: this.fields.taskId?.trim() || undefined } });
}
private cancel(): void {
if (this.fields.body.length <= 50) {
this.done({ type: "cancel" });
return;
}
this.confirm = new ConfirmOverlay({ title: "Discard draft?", body: `Body has ${this.fields.body.length} chars. Y=discard, N=continue editing`, dangerLevel: "medium", defaultAction: "cancel" }, (confirmed) => {
this.confirm = undefined;
if (confirmed) this.done({ type: "cancel" });
}, this.theme);
}
handleInput(data: string): void {
if (this.confirm) {
this.confirm.handleInput(data);
return;
}
if (data === "\u001b") {
this.cancel();
return;
}
if (data === "P") {
this.preview = !this.preview;
return;
}
if (data === "\t") {
this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
return;
}
if (data === " ") {
if (this.activeName() === "direction") this.fields.direction = this.fields.direction === "inbox" ? "outbox" : "inbox";
else this.appendText(data);
return;
}
if (data === "\b" || data === "\u007f") {
this.backspace();
return;
}
if (data === "\r" || data === "\n") {
if (this.activeName() === "body" || this.fields.body.trim()) this.submit();
else this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
return;
}
if (data.length === 1 && data >= " ") this.appendText(data);
}
}

View File

@@ -0,0 +1,63 @@
import type { CrewTheme } from "../theme-adapter.ts";
import { asCrewTheme } from "../theme-adapter.ts";
import { truncate } from "../../utils/visual.ts";
export type MarkdownToken = { type: "heading" | "code-block" | "list-item" | "paragraph"; level?: number; text: string };
function stripInlineMarkdown(text: string): string {
return text
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1");
}
export function tokenizeMarkdown(body: string): MarkdownToken[] {
const tokens: MarkdownToken[] = [];
const lines = body.split(/\r?\n/);
let inCode = false;
let codeLines: string[] = [];
for (const line of lines) {
if (line.trim().startsWith("```")) {
if (inCode) {
tokens.push({ type: "code-block", text: codeLines.join("\n") });
codeLines = [];
inCode = false;
} else inCode = true;
continue;
}
if (inCode) {
codeLines.push(line);
continue;
}
const heading = /^(#{1,3})\s+(.+)$/.exec(line);
if (heading) {
tokens.push({ type: "heading", level: heading[1]!.length, text: stripInlineMarkdown(heading[2]!) });
continue;
}
const list = /^\s*(?:[-*]|\d+\.)\s+(.+)$/.exec(line);
if (list) {
tokens.push({ type: "list-item", text: stripInlineMarkdown(list[1]!) });
continue;
}
if (line.trim()) tokens.push({ type: "paragraph", text: stripInlineMarkdown(line.trim()) });
}
if (inCode && codeLines.length) tokens.push({ type: "code-block", text: codeLines.join("\n") });
return tokens;
}
function renderToken(token: MarkdownToken, width: number, theme: CrewTheme): string[] {
const safeWidth = Math.max(10, width);
if (token.type === "heading") return [truncate(theme.bold(`${"#".repeat(token.level ?? 1)} ${token.text}`), safeWidth)];
if (token.type === "list-item") return [truncate(`${token.text}`, safeWidth)];
if (token.type === "code-block") return ["```", ...token.text.split(/\r?\n/).map((line) => truncate(` ${line}`, safeWidth)), "```"];
return [truncate(token.text, safeWidth)];
}
export function renderComposePreview(body: string, width: number, themeLike: unknown = {}): string[] {
const theme = asCrewTheme(themeLike);
const tokens = tokenizeMarkdown(body);
if (!tokens.length) return [theme.fg("dim", "Preview: (empty)")];
return [theme.bold("Preview"), ...tokens.flatMap((token) => renderToken(token, width, theme))];
}

View File

@@ -0,0 +1,122 @@
import { readDeliveryState, readMailbox, type MailboxMessage } from "../../state/mailbox.ts";
import { loadRunManifestById } from "../../state/state-store.ts";
import { pad, truncate } from "../../utils/visual.ts";
import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
export type MailboxAction =
| { type: "ack"; messageId: string }
| { type: "nudge"; agentId?: string }
| { type: "compose" }
| { type: "ackAll" }
| { type: "close" };
export class MailboxDetailOverlay {
private readonly runId: string;
private readonly cwd: string;
private readonly done: (action: MailboxAction | undefined) => void;
private readonly theme: CrewTheme;
private inbox: MailboxMessage[] = [];
private outbox: MailboxMessage[] = [];
private side: "inbox" | "outbox" = "inbox";
private selected = 0;
private expanded = false;
constructor(opts: { runId: string; cwd: string; done: (action: MailboxAction | undefined) => void; theme?: unknown }) {
this.runId = opts.runId;
this.cwd = opts.cwd;
this.done = opts.done;
this.theme = asCrewTheme(opts.theme ?? {});
this.refresh();
}
private refresh(): void {
const loaded = loadRunManifestById(this.cwd, this.runId);
if (!loaded) return;
const delivery = readDeliveryState(loaded.manifest).messages;
const applyDelivery = (message: MailboxMessage): MailboxMessage => ({ ...message, status: delivery[message.id] ?? message.status });
const taskIds = loaded.tasks.map((task) => task.id);
this.inbox = [...readMailbox(loaded.manifest, "inbox"), ...taskIds.flatMap((taskId) => readMailbox(loaded.manifest, "inbox", taskId))].map(applyDelivery).reverse();
this.outbox = [...readMailbox(loaded.manifest, "outbox"), ...taskIds.flatMap((taskId) => readMailbox(loaded.manifest, "outbox", taskId))].map(applyDelivery).reverse();
this.selected = Math.min(this.selected, Math.max(0, this.current().length - 1));
}
private current(): MailboxMessage[] {
return this.side === "inbox" ? this.inbox : this.outbox;
}
private selectedMessage(): MailboxMessage | undefined {
return this.current()[this.selected];
}
invalidate(): void {
this.refresh();
}
render(width: number): string[] {
this.refresh();
const inner = Math.max(40, width - 4);
const col = Math.max(18, Math.floor((inner - 3) / 2));
const lines = [
this.theme.bold(`Mailbox detail · ${this.runId}`),
"Tab side · ↑/↓ select · Enter expand · A ack · N nudge · C compose · X ack all · ESC close",
`${pad(this.theme.bold("Inbox"), col)}${pad(this.theme.bold("Outbox"), col)}`,
];
const max = Math.max(this.inbox.length, this.outbox.length, 1);
for (let index = 0; index < Math.min(max, 12); index += 1) {
lines.push(`${this.row(this.inbox[index], "inbox", index, col)}${this.row(this.outbox[index], "outbox", index, col)}`);
}
const selected = this.selectedMessage();
if (this.expanded && selected) {
lines.push("─".repeat(Math.min(inner, 72)));
lines.push(`${selected.from}${selected.to}${selected.taskId ? ` (${selected.taskId})` : ""} · ${selected.status}`);
lines.push(...selected.body.split(/\r?\n/).map((line) => truncate(line, inner)));
}
if (!this.inbox.length && !this.outbox.length) lines.push("Mailbox is empty.");
return lines.map((line) => truncate(line, inner));
}
private row(message: MailboxMessage | undefined, side: "inbox" | "outbox", index: number, width: number): string {
if (!message) return pad("", width);
const marker = this.side === side && this.selected === index ? "" : " ";
const status = message.status === "acknowledged" ? "✓" : "!";
return pad(truncate(`${marker}${status} ${message.from}->${message.to}: ${message.body.replace(/\s+/g, " ")}`, width), width);
}
handleInput(data: string): void {
if (data === "\u001b" || data === "q") {
this.done({ type: "close" });
return;
}
if (data === "\t") {
this.side = this.side === "inbox" ? "outbox" : "inbox";
this.selected = Math.min(this.selected, Math.max(0, this.current().length - 1));
return;
}
if (data === "k" || data === "\u001b[A") {
this.selected = Math.max(0, this.selected - 1);
return;
}
if (data === "j" || data === "\u001b[B") {
this.selected = Math.min(Math.max(0, this.current().length - 1), this.selected + 1);
return;
}
if (data === "\r" || data === "\n") {
this.expanded = !this.expanded;
return;
}
if (data === "A") {
const message = this.selectedMessage();
if (message) this.done({ type: "ack", messageId: message.id });
return;
}
if (data === "N") {
this.done({ type: "nudge", agentId: this.selectedMessage()?.taskId });
return;
}
if (data === "C") {
this.done({ type: "compose" });
return;
}
if (data === "X") this.done({ type: "ackAll" });
}
}

View File

@@ -0,0 +1,57 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
export interface WorkingIndicatorOptions {
frames?: string[];
intervalMs?: number;
}
type UiContext = Pick<ExtensionContext, "ui">;
type ExtensionUi = ExtensionContext["ui"];
type WidgetContent = string[] | ((tui: unknown, theme: unknown) => unknown);
type WidgetOptions = Parameters<ExtensionUi["setWidget"]>[2];
type WidgetOptionsWithPersist = WidgetOptions & { persist?: boolean };
type CustomOptions = Parameters<ExtensionUi["custom"]>[1];
type CustomFactory<T> = (
tui: unknown,
theme: unknown,
keybindings: unknown,
done: (result: T) => void,
) => unknown;
type GenericCustom = <T>(factory: CustomFactory<T>, options?: CustomOptions) => Promise<T>;
function maybeRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
export function requestRender(ctx: UiContext): void {
requestRenderTarget(ctx.ui);
}
export function requestRenderTarget(target: unknown): void {
const record = maybeRecord(target);
const fn = record?.requestRender;
if (typeof fn === "function") fn.call(target);
}
export function setWorkingIndicator(ctx: UiContext, options?: WorkingIndicatorOptions): void {
const record = maybeRecord(ctx.ui);
const fn = record?.setWorkingIndicator;
if (typeof fn === "function") fn.call(ctx.ui, options);
}
export function setExtensionWidget(ctx: UiContext, key: string, content: WidgetContent | undefined, options?: WidgetOptionsWithPersist): void {
const { persist: _persist, ...widgetOptions } = options ?? {};
ctx.ui.setWidget(key, content as never, widgetOptions as WidgetOptions);
}
export function showCustom<T>(ctx: UiContext, factory: CustomFactory<T>, options?: CustomOptions): Promise<T> {
const custom = ctx.ui.custom as unknown as GenericCustom;
return custom<T>(factory, options);
}
export function setStatusFallback(ctx: UiContext, key: string, lines: string | readonly string[] | undefined, segment?: string): void {
const text = typeof lines === "string" ? lines : lines ? [...lines].join("\n") : undefined;
ctx.ui.setStatus(segment ? `${key}:${segment}` : key, text);
}

View File

@@ -0,0 +1,129 @@
import * as fs from "node:fs";
import { listRecentRuns } from "../extension/run-index.ts";
import type { CrewUiConfig } from "../config/config.ts";
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
import type { TeamTaskState, TeamRunManifest } from "../state/types.ts";
import { aggregateUsage } from "../state/usage.ts";
import { isDisplayActiveRun } from "../runtime/process-status.ts";
import { logInternalError } from "../utils/internal-error.ts";
import type { ManifestCache } from "../runtime/manifest-cache.ts";
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
import { notificationBadge } from "./crew-widget.ts";
type EventBus = { emit?: (event: string, data: unknown) => void; listenerCount?: (event: string) => number } | undefined;
type StatusContext = { hasUI?: boolean; ui?: { setStatus?: (key: string, text: string | undefined) => void } } | undefined;
const TASK_READ_TTL_MS = 200;
function hasPowerbarConsumer(events: EventBus): boolean {
try {
return (events?.listenerCount?.("powerbar:register-segment") ?? 0) > 0 || (events?.listenerCount?.("powerbar:update") ?? 0) > 0;
} catch {
return false;
}
}
function setStatusFallback(ctx: StatusContext, text: string | undefined): void {
try {
if (ctx?.hasUI) ctx.ui?.setStatus?.("pi-crew", text);
} catch (error) {
logInternalError("powerbar.statusFallback", error);
}
}
function safeEmit(events: EventBus, event: string, data: unknown): void {
try {
events?.emit?.(event, data);
} catch (error) {
logInternalError("powerbar.safeEmit", error, `event=${event}`);
}
}
function readTasks(tasksPath: string): TeamTaskState[] {
try {
const parse = () => {
const parsed = JSON.parse(fs.readFileSync(tasksPath, "utf-8"));
return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
};
return readJsonFileCoalesced(tasksPath, TASK_READ_TTL_MS, parse);
} catch (error) {
logInternalError("powerbar.readTasks", error, tasksPath);
return [];
}
}
export function compactTokens(total: number): string {
return total >= 1000 ? `${Math.round(total / 1000)}k` : `${total}`;
}
export function registerPiCrewPowerbarSegments(events: EventBus, config?: CrewUiConfig): void {
if (config?.powerbar === false) return;
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-active", label: "pi-crew active agents" });
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-progress", label: "pi-crew run progress" });
}
export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: CrewUiConfig, manifestCache?: ManifestCache, snapshotCache?: RunSnapshotCache, ctx?: StatusContext, notificationCount = 0, preloadedManifests?: TeamRunManifest[]): void {
if (config?.powerbar === false) return;
const useStatusFallback = !hasPowerbarConsumer(events);
const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
const active = runs.map((run) => {
let snapshot: RunUiSnapshot | undefined;
try {
snapshot = snapshotCache?.get(run.runId) ?? snapshotCache?.refreshIfStale(run.runId);
} catch (error) {
logInternalError("powerbar.snapshot", error, run.runId);
}
if (snapshot) return { run: snapshot.manifest, agents: snapshot.agents, tasks: snapshot.tasks, snapshot };
let agents: ReturnType<typeof readCrewAgents> = [];
try {
agents = readCrewAgents(run);
} catch (error) {
logInternalError("powerbar.readCrewAgents", error, run.runId);
}
return { run, agents, tasks: readTasks(run.tasksPath), snapshot };
}).filter((item) => isDisplayActiveRun(item.run, item.agents));
if (!active.length) {
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
if (useStatusFallback) setStatusFallback(ctx, undefined);
return;
}
const agents = active.flatMap((item) => item.agents);
const tasks = active.flatMap((item) => item.tasks);
const running = agents.filter((agent) => agent.status === "running").length;
const waiting = active.reduce((sum, item) => sum + (item.snapshot ? item.snapshot.progress.queued + (item.snapshot.progress.waiting ?? 0) : item.tasks.reduce((s, t) => s + (t.status === "queued" || t.status === "waiting" ? 1 : 0), 0)), 0);
const completed = active.reduce((sum, item) => sum + (item.snapshot?.progress.completed ?? item.tasks.reduce((s, t) => s + (t.status === "completed" ? 1 : 0), 0)), 0);
const total = Math.max(1, active.reduce((sum, item) => sum + (item.snapshot?.progress.total ?? item.tasks.length), 0) || agents.length);
const usage = aggregateUsage(tasks);
const snapshotTokens = active.reduce((sum, item) => sum + (item.snapshot ? item.snapshot.usage.tokensIn + item.snapshot.usage.tokensOut : 0), 0);
const hasUsage = usage && ((usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)) > 0;
const tokenTotal = hasUsage ? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) : snapshotTokens;
const model = config?.showModel === false ? undefined : agents.find((agent) => agent.model)?.model?.split("/").at(-1);
const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
const activeText = `crew ${running}a/${waiting}w${notificationBadge(notificationCount)}`;
const activeSuffix = [model, tokenText].filter(Boolean).join(" · ") || undefined;
const progressSuffix = `${completed}/${total}${tokenText ? ` · ${tokenText}` : ""}`;
safeEmit(events, "powerbar:update", {
id: "pi-crew-active",
icon: "⚙",
text: activeText,
suffix: activeSuffix,
color: running ? "accent" : "warning",
});
safeEmit(events, "powerbar:update", {
id: "pi-crew-progress",
text: (active[0]?.run as TeamRunManifest)?.team ?? "crew",
bar: Math.round((completed / total) * 100),
suffix: progressSuffix,
color: completed === total ? "success" : "accent",
barSegments: 8,
});
if (useStatusFallback) setStatusFallback(ctx, `${activeText}${activeSuffix ? ` · ${activeSuffix}` : ""} · ${progressSuffix}`);
}
export function clearPiCrewPowerbar(events: EventBus, ctx?: StatusContext): void {
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
setStatusFallback(ctx, undefined);
}

View File

@@ -0,0 +1,119 @@
import * as Diff from "diff";
import type { CrewTheme } from "./theme-adapter.ts";
import { asCrewTheme } from "./theme-adapter.ts";
interface ParsedDiffLine {
prefix: string;
lineNum: string; content: string;
}
interface DiffLineContent {
lineNum: string;
content: string;
}
function parseDiffLine(line: string): ParsedDiffLine | null {
const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/);
if (!match) return null;
return { prefix: match[1], lineNum: match[2], content: match[3] };
}
function replaceTabs(text: string): string {
return text.replace(/\t/g, " ");
}
function renderIntraLineDiff(theme: CrewTheme, oldContent: string, newContent: string): { removedLine: string; addedLine: string } {
const wordDiff = Diff.diffWords(oldContent, newContent);
let removedLine = "";
let addedLine = "";
let isFirstRemoved = true;
let isFirstAdded = true;
for (const part of wordDiff) {
if (part.removed) {
let value = part.value;
if (isFirstRemoved) {
const leadingWs = value.match(/^(\s*)/)?.[1] ?? "";
value = value.slice(leadingWs.length);
removedLine += leadingWs;
isFirstRemoved = false;
}
if (value) removedLine += theme.inverse?.(value) ?? value;
} else if (part.added) {
let value = part.value;
if (isFirstAdded) {
const leadingWs = value.match(/^(\s*)/)?.[1] ?? "";
value = value.slice(leadingWs.length);
addedLine += leadingWs;
isFirstAdded = false;
}
if (value) addedLine += theme.inverse?.(value) ?? value;
} else {
removedLine += part.value;
addedLine += part.value;
}
}
return { removedLine, addedLine };
}
export interface RenderDiffOptions {
filePath?: string;
theme?: unknown;
}
export function renderDiff(diffText: string, options: RenderDiffOptions = {}): string {
const theme = asCrewTheme(options.theme);
const lines = diffText.split("\n");
const result: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? "";
const parsed = parseDiffLine(line);
if (!parsed) {
result.push(theme.fg("toolDiffContext", line));
i++;
continue;
}
if (parsed.prefix === "-") {
const removedLines: DiffLineContent[] = [];
while (i < lines.length) {
const nextParsed = parseDiffLine(lines[i] ?? "");
if (!nextParsed || nextParsed.prefix !== "-") break;
removedLines.push({ lineNum: nextParsed.lineNum, content: nextParsed.content });
i++;
}
const addedLines: DiffLineContent[] = [];
while (i < lines.length) {
const nextParsed = parseDiffLine(lines[i] ?? "");
if (!nextParsed || nextParsed.prefix !== "+") break;
addedLines.push({ lineNum: nextParsed.lineNum, content: nextParsed.content });
i++;
}
if (removedLines.length === 1 && addedLines.length === 1) {
const { removedLine, addedLine } = renderIntraLineDiff(theme, replaceTabs(removedLines[0]!.content), replaceTabs(addedLines[0]!.content));
result.push(theme.fg("toolDiffRemoved", `-${removedLines[0]!.lineNum} ${removedLine}`));
result.push(theme.fg("toolDiffAdded", `+${addedLines[0]!.lineNum} ${addedLine}`));
} else {
for (const removed of removedLines) {
result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`));
}
for (const added of addedLines) {
result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`));
}
}
} else if (parsed.prefix === "+") {
result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`));
i++;
} else {
result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`));
i++;
}
}
return result.join("\n");
}

View File

@@ -0,0 +1,143 @@
import { logInternalError } from "../utils/internal-error.ts";
export interface RenderSchedulerEventBus {
on?: (event: string, handler: (payload: unknown) => void) => (() => void) | void;
}
export interface RenderSchedulerOptions {
debounceMs?: number;
fallbackMs?: number;
events?: string[];
onInvalidate?: (payload: unknown) => void;
}
const DEFAULT_EVENTS = [
"crew.run.created",
"crew.run.completed",
"crew.run.failed",
"crew.run.cancelled",
"crew.subagent.completed",
"crew.subagent.failed",
"crew.mailbox.updated",
"crew.mailbox.message",
];
/**
* Coordinates UI renders with debounce + fallback polling.
*
* Critical: uses recursive setTimeout instead of setInterval + a rendering
* guard (`rendering` / `pendingRender`) so that when render() takes longer
* than the fallback interval, callbacks do NOT pile up and storm the event
* loop. Instead, overlapping schedules are collapsed into a single deferred
* re-render.
*/
export class RenderScheduler {
private readonly render: () => void;
private readonly onInvalidate?: (payload: unknown) => void;
private readonly debounceMs: number;
private readonly fallbackMs: number;
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
private fallbackTimer: ReturnType<typeof setTimeout> | undefined;
private disposed = false;
private lastEventAt = 0;
private rendering = false;
private pendingRender = false;
private readonly unsubs: Array<() => void> = [];
constructor(events: RenderSchedulerEventBus | undefined, render: () => void, options: RenderSchedulerOptions = {}) {
this.render = render;
this.onInvalidate = options.onInvalidate;
this.debounceMs = options.debounceMs ?? 75;
this.fallbackMs = options.fallbackMs ?? 750;
for (const event of options.events ?? DEFAULT_EVENTS) this.subscribe(events, event);
this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.fallbackMs);
this.fallbackTimer.unref();
}
private subscribe(events: RenderSchedulerEventBus | undefined, event: string): void {
if (!events?.on) return;
const handler = (payload: unknown): void => this.schedule(payload);
try {
const unsub = events.on(event, handler);
if (typeof unsub === "function") this.unsubs.push(unsub);
} catch (error) {
logInternalError("render-scheduler.subscribe", error, event);
}
}
/** Recursive setTimeout — avoids setInterval timer storms. */
private fallbackLoop(): void {
if (this.disposed) return;
if (Date.now() - this.lastEventAt < this.fallbackMs) {
if (this.disposed) return;
this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.fallbackMs);
this.fallbackTimer.unref();
return;
}
this.schedule();
if (this.disposed) return;
this.fallbackTimer = setTimeout(() => this.fallbackLoop(), this.fallbackMs);
this.fallbackTimer.unref();
}
schedule(payload?: unknown): void {
if (this.disposed) return;
this.lastEventAt = Date.now();
try {
this.onInvalidate?.(payload);
} catch (error) {
logInternalError("render-scheduler.invalidate", error);
}
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = undefined;
this.flush();
}, this.debounceMs);
this.debounceTimer.unref();
}
/**
* Flush a render. If a render is already in progress the request is
* collapsed: `pendingRender` is set and the caller that holds
* `rendering==true` will loop one more time after finishing.
*/
flush(): void {
if (this.disposed) return;
if (this.rendering) {
this.pendingRender = true;
return;
}
this.rendering = true;
this.pendingRender = false;
let iterations = 0;
try {
do {
this.pendingRender = false;
this.render();
iterations += 1;
// Safety valve: 5 re-renders max per flush to prevent infinite loops
// if render() itself calls flush() synchronously.
} while (this.pendingRender && !this.disposed && iterations < 5);
} catch (error) {
logInternalError("render-scheduler.render", error);
} finally {
this.rendering = false;
// If we hit the iteration cap, schedule one more render to drain.
if (iterations >= 5 && this.pendingRender && !this.disposed) {
this.schedule();
}
}
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
if (this.debounceTimer) clearTimeout(this.debounceTimer);
if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
this.debounceTimer = undefined;
this.fallbackTimer = undefined;
for (const unsub of this.unsubs.splice(0)) {
try { unsub(); } catch (error) { logInternalError("render-scheduler.unsubscribe", error); }
}
}
}

View File

@@ -0,0 +1,108 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { MetricRegistry } from "../observability/metric-registry.ts";
import { handleTeamTool } from "../extension/team-tool.ts";
import { isToolError, textFromToolResult } from "../extension/tool-result.ts";
import { loadRunManifestById, saveRunTasks } from "../state/state-store.ts";
import { appendEvent } from "../state/event-log.ts";
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
import { exportDiagnostic } from "../runtime/diagnostic-export.ts";
import type { MailboxDirection, MailboxMessage } from "../state/mailbox.ts";
export interface RunActionResult {
ok: boolean;
message: string;
data?: unknown;
}
function okFromTool(result: Awaited<ReturnType<typeof handleTeamTool>>): RunActionResult {
return { ok: !isToolError(result), message: textFromToolResult(result), data: result };
}
function err(error: unknown): RunActionResult {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
}
async function dispatchApi(ctx: ExtensionContext, runId: string, config: Record<string, unknown>): Promise<RunActionResult> {
try {
return okFromTool(await handleTeamTool({ action: "api", runId, config }, ctx));
} catch (error) {
return err(error);
}
}
function parseMailboxMessages(text: string): MailboxMessage[] {
try {
const parsed = JSON.parse(text) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((item): item is MailboxMessage => Boolean(item) && typeof item === "object" && !Array.isArray(item) && typeof (item as { id?: unknown }).id === "string");
} catch {
return [];
}
}
export function dispatchMailboxAck(ctx: ExtensionContext, runId: string, messageId: string): Promise<RunActionResult> {
return dispatchApi(ctx, runId, { operation: "ack-message", messageId });
}
export function dispatchMailboxNudge(ctx: ExtensionContext, runId: string, agentId: string, message: string): Promise<RunActionResult> {
return dispatchApi(ctx, runId, { operation: "nudge-agent", agentId, message });
}
export function dispatchMailboxCompose(ctx: ExtensionContext, runId: string, payload: { from: string; to: string; body: string; taskId?: string; direction: MailboxDirection }): Promise<RunActionResult> {
return dispatchApi(ctx, runId, { operation: "send-message", ...payload });
}
export async function dispatchMailboxAckAll(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
const listed = await dispatchApi(ctx, runId, { operation: "read-mailbox", direction: "inbox" });
if (!listed.ok) return listed;
const messages = parseMailboxMessages(listed.message).filter((message) => message.status !== "acknowledged");
let count = 0;
for (const message of messages) {
const acked = await dispatchMailboxAck(ctx, runId, message.id);
if (!acked.ok) return { ok: false, message: `Acknowledged ${count}/${messages.length}; failed ${message.id}: ${acked.message}` };
count += 1;
}
return { ok: true, message: `Acknowledged ${count} messages.`, data: { count } };
}
export function dispatchHealthRecovery(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
return dispatchApi(ctx, runId, { operation: "foreground-interrupt", reason: "operator health recovery" });
}
export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
try {
const loaded = loadRunManifestById(ctx.cwd, runId);
if (!loaded) return { ok: false, message: `Run '${runId}' not found.` };
const currentMs = Date.now();
const staleMs = 60_000;
const now = new Date(currentMs).toISOString();
let count = 0;
const tasks = loaded.tasks.map((task) => {
if ((task.status !== "running" && task.status !== "queued") || !task.heartbeat || task.heartbeat.alive === false) return task;
const lastSeenMs = Date.parse(task.heartbeat.lastSeenAt);
if (!Number.isFinite(lastSeenMs) || currentMs - lastSeenMs <= staleMs) return task;
count += 1;
return { ...task, heartbeat: { ...task.heartbeat, alive: false, lastSeenAt: now } };
});
saveRunTasks(loaded.manifest, tasks);
appendEvent(loaded.manifest.eventsPath, { type: "worker.kill_stale", runId, message: `Marked ${count} stale worker heartbeat(s) dead.`, data: { count } });
return { ok: true, message: `Marked ${count} stale worker heartbeat(s) dead.`, data: { count } };
} catch (error) {
return err(error);
}
}
export async function dispatchDiagnosticExport(ctx: ExtensionContext, runId: string, options: { registry?: MetricRegistry } = {}): Promise<RunActionResult> {
try {
const exported = await exportDiagnostic(ctx, runId, options);
return { ok: true, message: `Diagnostic exported to ${exported.path}`, data: exported.path };
} catch (error) {
return err(error);
}
}
export function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
const loaded = loadRunManifestById(ctx.cwd, runId);
if (!loaded) return undefined;
return readCrewAgents(loaded.manifest).find((agent) => agent.status === "running" || agent.status === "queued")?.taskId;
}

View File

@@ -0,0 +1,460 @@
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();
}
}

View File

@@ -0,0 +1,725 @@
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { readCrewAgents, readCrewAgentsAsync, agentsPath, agentOutputPath } from "../runtime/crew-agent-records.ts";
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
import { isActiveRunStatus } from "../runtime/process-status.ts";
import type { TeamEvent } from "../state/event-log.ts";
import type { MailboxMessageStatus } from "../state/mailbox.ts";
import { loadRunManifestById, loadRunManifestByIdAsync } from "../state/state-store.ts";
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
import type { RunSnapshotCache as RunSnapshotCacheBase, RunUiGroupJoin, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
export interface RunSnapshotCache extends RunSnapshotCacheBase {
preloadStale(runId: string): Promise<RunUiSnapshot | undefined>;
preloadAllStale(runIds: string[]): Promise<void>;
}
const DEFAULT_TTL_MS = 500;
const DEFAULT_MAX_ENTRIES = 24;
const DEFAULT_RECENT_EVENTS = 20;
const DEFAULT_RECENT_OUTPUT_LINES = 20;
const MAX_TAIL_BYTES = 32 * 1024;
/** Max JSONL lines to tail when reading growing files (events, mailbox). */
const MAX_TAIL_LINES = 500;
interface FileStamp {
mtimeMs: number;
size: number;
}
interface SnapshotStamps {
manifest: FileStamp;
tasks: FileStamp;
agents: FileStamp;
events: FileStamp;
mailbox: FileStamp;
output: FileStamp;
}
interface CacheEntry {
snapshot: RunUiSnapshot;
stamps: SnapshotStamps;
loadedAtMs: number;
lastAccessMs: number;
}
export interface RunSnapshotCacheOptions {
ttlMs?: number;
maxEntries?: number;
recentEvents?: number;
recentOutputLines?: number;
}
function zeroStamp(): FileStamp {
return { mtimeMs: 0, size: 0 };
}
function stampFile(filePath: string | undefined): FileStamp {
if (!filePath) return zeroStamp();
try {
const stat = fs.statSync(filePath);
return { mtimeMs: stat.mtimeMs, size: stat.size };
} catch {
return zeroStamp();
}
}
async function stampFileAsync(filePath: string | undefined): Promise<FileStamp> {
if (!filePath) return zeroStamp();
try {
const stat = await fs.promises.stat(filePath);
return { mtimeMs: stat.mtimeMs, size: stat.size };
} catch {
return zeroStamp();
}
}
function combineStamps(stamps: FileStamp[]): FileStamp {
return stamps.reduce((acc, stamp) => ({ mtimeMs: Math.max(acc.mtimeMs, stamp.mtimeMs), size: acc.size + stamp.size }), zeroStamp());
}
function mailboxStamp(manifest: TeamRunManifest): FileStamp {
const root = path.join(manifest.stateRoot, "mailbox");
const stamps: FileStamp[] = [
stampFile(path.join(root, "inbox.jsonl")),
stampFile(path.join(root, "outbox.jsonl")),
stampFile(path.join(root, "delivery.json")),
];
const tasksRoot = path.join(root, "tasks");
try {
for (const entry of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
stamps.push(stampFile(path.join(tasksRoot, entry.name, "inbox.jsonl")));
stamps.push(stampFile(path.join(tasksRoot, entry.name, "outbox.jsonl")));
}
} catch {
// No task mailbox yet.
}
return combineStamps(stamps);
}
async function mailboxStampAsync(manifest: TeamRunManifest): Promise<FileStamp> {
const root = path.join(manifest.stateRoot, "mailbox");
const stamps: FileStamp[] = [
await stampFileAsync(path.join(root, "inbox.jsonl")),
await stampFileAsync(path.join(root, "outbox.jsonl")),
await stampFileAsync(path.join(root, "delivery.json")),
];
const tasksRoot = path.join(root, "tasks");
try {
for (const entry of await fs.promises.readdir(tasksRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
stamps.push(await stampFileAsync(path.join(tasksRoot, entry.name, "inbox.jsonl")));
stamps.push(await stampFileAsync(path.join(tasksRoot, entry.name, "outbox.jsonl")));
}
} catch {
// No task mailbox yet.
}
return combineStamps(stamps);
}
function safeAgentOutputPath(manifest: TeamRunManifest, agent: CrewAgentRecord): string | undefined {
try {
return agentOutputPath(manifest, agent.taskId);
} catch {
return undefined;
}
}
function outputStamp(manifest: TeamRunManifest, agents: CrewAgentRecord[]): FileStamp {
return combineStamps(agents.map((agent) => stampFile(safeAgentOutputPath(manifest, agent))));
}
async function outputStampAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[]): Promise<FileStamp> {
return combineStamps(await Promise.all(agents.map((agent) => stampFileAsync(safeAgentOutputPath(manifest, agent)))));
}
function sameStamp(a: FileStamp, b: FileStamp): boolean {
return a.mtimeMs === b.mtimeMs && a.size === b.size;
}
function sameStamps(a: SnapshotStamps, b: SnapshotStamps): boolean {
return sameStamp(a.manifest, b.manifest)
&& sameStamp(a.tasks, b.tasks)
&& sameStamp(a.agents, b.agents)
&& sameStamp(a.events, b.events)
&& sameStamp(a.mailbox, b.mailbox)
&& sameStamp(a.output, b.output);
}
function readTasks(tasksPath: string): TeamTaskState[] {
try {
const parsed = JSON.parse(fs.readFileSync(tasksPath, "utf-8")) as unknown;
return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
} catch {
throw new Error(`Failed to parse tasks at ${tasksPath}`);
}
}
/** Tail-read JSONL lines from a file, returning parsed objects (limited). */
function tailJsonlLines<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): T[] {
if (limit <= 0) return [];
try {
const stat = fs.statSync(filePath);
const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(bytesToRead);
fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
const lines = buffer.toString("utf-8").split(/\r?\n/).filter(Boolean);
return lines.flatMap((line) => {
const item = parse(line);
return item ? [item] : [];
}).slice(-limit);
} finally {
fs.closeSync(fd);
}
} catch {
return [];
}
}
/** Async tail-read JSONL lines from a file, returning parsed objects (limited). */
async function tailJsonlLinesAsync<T>(filePath: string, limit: number, parse: (line: string) => T | undefined): Promise<T[]> {
if (limit <= 0) return [];
try {
const stat = await fs.promises.stat(filePath);
const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
const handle = await fs.promises.open(filePath, "r");
try {
const buffer = Buffer.alloc(bytesToRead);
await handle.read(buffer, 0, bytesToRead, stat.size - bytesToRead);
const lines = buffer.toString("utf-8").split(/\r?\n/).filter(Boolean);
return lines.flatMap((line) => {
const item = parse(line);
return item ? [item] : [];
}).slice(-limit);
} finally {
await handle.close();
}
} catch {
return [];
}
}
function safeRecentEvents(eventsPath: string, limit: number): TeamEvent[] {
return tailJsonlLines(eventsPath, limit, (line) => {
try {
const parsed = JSON.parse(line) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as TeamEvent) : undefined;
} catch {
return undefined;
}
});
}
async function safeRecentEventsAsync(eventsPath: string, limit: number): Promise<TeamEvent[]> {
return tailJsonlLinesAsync(eventsPath, limit, (line) => {
try {
const parsed = JSON.parse(line) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as TeamEvent) : undefined;
} catch {
return undefined;
}
});
}
function tailLines(filePath: string, limit: number): string[] {
if (limit <= 0) return [];
try {
const stat = fs.statSync(filePath);
const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(bytesToRead);
fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
return buffer.toString("utf-8").split(/\r?\n/).filter(Boolean).slice(-limit);
} finally {
fs.closeSync(fd);
}
} catch {
return [];
}
}
async function tailLinesAsync(filePath: string, limit: number): Promise<string[]> {
if (limit <= 0) return [];
try {
const stat = await fs.promises.stat(filePath);
const bytesToRead = Math.min(stat.size, MAX_TAIL_BYTES);
const handle = await fs.promises.open(filePath, "r");
try {
const buffer = Buffer.alloc(bytesToRead);
await handle.read(buffer, 0, bytesToRead, stat.size - bytesToRead);
return buffer.toString("utf-8").split(/\r?\n/).filter(Boolean).slice(-limit);
} finally {
await handle.close();
}
} catch {
return [];
}
}
function recentOutputLines(manifest: TeamRunManifest, agents: CrewAgentRecord[], limit: number): string[] {
const fromProgress = agents.flatMap((agent) => agent.progress?.recentOutput ?? []);
const fromFiles = agents.flatMap((agent) => {
const outputPath = safeAgentOutputPath(manifest, agent);
return outputPath ? tailLines(outputPath, limit) : [];
});
return [...fromProgress, ...fromFiles].map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).slice(-limit);
}
async function recentOutputLinesAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[], limit: number): Promise<string[]> {
const fromProgress = agents.flatMap((agent) => agent.progress?.recentOutput ?? []);
const fromFilesArrays = await Promise.all(agents.map((agent) => {
const outputPath = safeAgentOutputPath(manifest, agent);
return outputPath ? tailLinesAsync(outputPath, limit) : Promise.resolve([]);
}));
const fromFiles = fromFilesArrays.flat();
return [...fromProgress, ...fromFiles].map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).slice(-limit);
}
function progressFromTasks(tasks: TeamTaskState[]): RunUiProgress {
const progress: RunUiProgress = { total: tasks.length, completed: 0, running: 0, failed: 0, queued: 0, waiting: 0, cancelled: 0, skipped: 0 };
for (const task of tasks) {
if (task.status === "completed") progress.completed += 1;
else if (task.status === "running") progress.running += 1;
else if (task.status === "failed") progress.failed += 1;
else if (task.status === "queued") progress.queued += 1;
else if (task.status === "waiting") progress.waiting = (progress.waiting ?? 0) + 1;
else if (task.status === "cancelled") progress.cancelled = (progress.cancelled ?? 0) + 1;
else if (task.status === "skipped") progress.skipped = (progress.skipped ?? 0) + 1;
}
return progress;
}
function usageFrom(tasks: TeamTaskState[], agents: CrewAgentRecord[]): RunUiUsage {
const taskUsage = tasks.reduce((acc, task) => {
acc.tokensIn += task.usage?.input ?? 0;
acc.tokensOut += task.usage?.output ?? 0;
acc.toolUses += task.agentProgress?.toolCount ?? 0;
return acc;
}, { tokensIn: 0, tokensOut: 0, toolUses: 0 });
if (taskUsage.tokensIn || taskUsage.tokensOut || taskUsage.toolUses) return taskUsage;
return agents.reduce((acc, agent) => {
acc.tokensIn += agent.usage?.input ?? 0;
acc.tokensOut += agent.usage?.output ?? agent.progress?.tokens ?? 0;
acc.toolUses += agent.toolUses ?? agent.progress?.toolCount ?? 0;
return acc;
}, { tokensIn: 0, tokensOut: 0, toolUses: 0 });
}
function isMailboxStatus(value: unknown): value is MailboxMessageStatus {
return value === "queued" || value === "delivered" || value === "acknowledged";
}
function readDeliveryMessages(filePath: string): Record<string, MailboxMessageStatus> {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const messages = (parsed as { messages?: unknown }).messages;
if (!messages || typeof messages !== "object" || Array.isArray(messages)) return {};
const output: Record<string, MailboxMessageStatus> = {};
for (const [id, status] of Object.entries(messages)) if (isMailboxStatus(status)) output[id] = status;
return output;
} catch {
return {};
}
}
async function readDeliveryMessagesAsync(filePath: string): Promise<Record<string, MailboxMessageStatus>> {
try {
const content = await fs.promises.readFile(filePath, "utf-8");
const parsed = JSON.parse(content) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const messages = (parsed as { messages?: unknown }).messages;
if (!messages || typeof messages !== "object" || Array.isArray(messages)) return {};
const output: Record<string, MailboxMessageStatus> = {};
for (const [id, status] of Object.entries(messages)) if (isMailboxStatus(status)) output[id] = status;
return output;
} catch {
return {};
}
}
function readGroupJoinMailbox(filePath: string, delivery: Record<string, MailboxMessageStatus>): RunUiGroupJoin[] {
return tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
try {
const parsed = JSON.parse(line) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
const message = parsed as { id?: unknown; data?: unknown };
const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return undefined;
return { requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const };
} catch {
return undefined;
}
});
}
async function readGroupJoinMailboxAsync(filePath: string, delivery: Record<string, MailboxMessageStatus>): Promise<RunUiGroupJoin[]> {
return tailJsonlLinesAsync(filePath, MAX_TAIL_LINES, (line) => {
try {
const parsed = JSON.parse(line) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
const message = parsed as { id?: unknown; data?: unknown };
const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return undefined;
return { requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const };
} catch {
return undefined;
}
});
}
interface MailboxCount {
count: number;
approximate: boolean;
}
function tailApproximate(filePath: string): boolean {
try {
return fs.statSync(filePath).size > MAX_TAIL_BYTES;
} catch {
return false;
}
}
async function tailApproximateAsync(filePath: string): Promise<boolean> {
try {
return (await fs.promises.stat(filePath)).size > MAX_TAIL_BYTES;
} catch {
return false;
}
}
function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): MailboxCount {
const items = tailJsonlLines(filePath, MAX_TAIL_LINES, (line) => {
try {
const parsed = JSON.parse(line) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
const message = parsed as { id?: unknown; status?: unknown };
if (typeof message.id !== "string" || !isMailboxStatus(message.status)) return 0;
return message.status !== "acknowledged" && delivery[message.id] !== "acknowledged" ? 1 : 0;
} catch {
return 0;
}
}) as number[];
return { count: items.reduce((sum, val) => sum + val, 0), approximate: tailApproximate(filePath) };
}
async function readMailboxCountsAsync(filePath: string, delivery: Record<string, MailboxMessageStatus>): Promise<MailboxCount> {
const items = await tailJsonlLinesAsync(filePath, MAX_TAIL_LINES, (line) => {
try {
const parsed = JSON.parse(line) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
const message = parsed as { id?: unknown; status?: unknown };
if (typeof message.id !== "string" || !isMailboxStatus(message.status)) return 0;
return message.status !== "acknowledged" && delivery[message.id] !== "acknowledged" ? 1 : 0;
} catch {
return 0;
}
}) as number[];
return { count: items.reduce((sum, val) => sum + val, 0), approximate: await tailApproximateAsync(filePath) };
}
function groupJoinsFrom(manifest: TeamRunManifest): RunUiGroupJoin[] {
const root = path.join(manifest.stateRoot, "mailbox");
const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
return readGroupJoinMailbox(path.join(root, "outbox.jsonl"), delivery).slice(-5);
}
async function groupJoinsFromAsync(manifest: TeamRunManifest): Promise<RunUiGroupJoin[]> {
const root = path.join(manifest.stateRoot, "mailbox");
const delivery = await readDeliveryMessagesAsync(path.join(root, "delivery.json"));
return (await readGroupJoinMailboxAsync(path.join(root, "outbox.jsonl"), delivery)).slice(-5);
}
function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunUiMailbox {
const root = path.join(manifest.stateRoot, "mailbox");
const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
let inbox = readMailboxCounts(path.join(root, "inbox.jsonl"), delivery);
let outbox = readMailboxCounts(path.join(root, "outbox.jsonl"), delivery);
const tasksRoot = path.join(root, "tasks");
try {
for (const entry of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const taskInbox = readMailboxCounts(path.join(tasksRoot, entry.name, "inbox.jsonl"), delivery);
const taskOutbox = readMailboxCounts(path.join(tasksRoot, entry.name, "outbox.jsonl"), delivery);
inbox = { count: inbox.count + taskInbox.count, approximate: inbox.approximate || taskInbox.approximate };
outbox = { count: outbox.count + taskOutbox.count, approximate: outbox.approximate || taskOutbox.approximate };
}
} catch {
// No task mailboxes yet.
}
const attentionAgents = agents.filter((agent) => agent.progress?.activityState === "needs_attention").length;
return { inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate };
}
async function mailboxFromAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[]): Promise<RunUiMailbox> {
const root = path.join(manifest.stateRoot, "mailbox");
const delivery = await readDeliveryMessagesAsync(path.join(root, "delivery.json"));
let inbox = await readMailboxCountsAsync(path.join(root, "inbox.jsonl"), delivery);
let outbox = await readMailboxCountsAsync(path.join(root, "outbox.jsonl"), delivery);
const tasksRoot = path.join(root, "tasks");
try {
for (const entry of await fs.promises.readdir(tasksRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const taskInbox = await readMailboxCountsAsync(path.join(tasksRoot, entry.name, "inbox.jsonl"), delivery);
const taskOutbox = await readMailboxCountsAsync(path.join(tasksRoot, entry.name, "outbox.jsonl"), delivery);
inbox = { count: inbox.count + taskInbox.count, approximate: inbox.approximate || taskInbox.approximate };
outbox = { count: outbox.count + taskOutbox.count, approximate: outbox.approximate || taskOutbox.approximate };
}
} catch {
// No task mailboxes yet.
}
const attentionAgents = agents.filter((agent) => agent.progress?.activityState === "needs_attention").length;
return { inboxUnread: inbox.count, outboxPending: outbox.count, needsAttention: inbox.count + attentionAgents, approximate: inbox.approximate || outbox.approximate };
}
function cancellationReasonFromEvents(events: TeamEvent[]): string | undefined {
return [...events].reverse().find((event) => event.type === "run.cancelled" && typeof event.data?.reason === "string")?.data?.reason as string | undefined;
}
function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt">, stamps: SnapshotStamps): string {
try {
const digest = createHash("sha256");
digest.update(JSON.stringify({
run: [input.manifest.runId, input.manifest.status, input.manifest.updatedAt, input.manifest.artifacts.length],
tasks: input.tasks.map((task) => [task.id, task.status, task.startedAt, task.finishedAt, task.agentProgress, task.usage]),
agents: input.agents.map((agent) => [agent.id, agent.status, agent.startedAt, agent.completedAt, agent.toolUses, agent.progress, agent.usage, agent.model]),
progress: input.progress,
usage: input.usage,
mailbox: input.mailbox,
groupJoins: input.groupJoins,
events: input.recentEvents.map((event) => [event.metadata?.seq, event.time, event.type, event.taskId, event.message, event.data?.reason]),
cancellationReason: input.cancellationReason,
output: input.recentOutputLines,
stamps,
}));
return digest.digest("hex").slice(0, 16);
} catch {
// Circular reference or non-serializable data — fall back to timestamp.
return String(Date.now());
}
}
function stampsFor(manifest: TeamRunManifest, agents: CrewAgentRecord[]): SnapshotStamps {
return {
manifest: stampFile(path.join(manifest.stateRoot, "manifest.json")),
tasks: stampFile(manifest.tasksPath),
agents: stampFile(agentsPath(manifest)),
events: stampFile(manifest.eventsPath),
mailbox: mailboxStamp(manifest),
output: outputStamp(manifest, agents),
};
}
async function stampsForAsync(manifest: TeamRunManifest, agents: CrewAgentRecord[]): Promise<SnapshotStamps> {
const [manifestStamp, tasksStamp, agentsStamp, eventsStamp, mailbox, output] = await Promise.all([
stampFileAsync(path.join(manifest.stateRoot, "manifest.json")),
stampFileAsync(manifest.tasksPath),
stampFileAsync(agentsPath(manifest)),
stampFileAsync(manifest.eventsPath),
mailboxStampAsync(manifest),
outputStampAsync(manifest, agents),
]);
return { manifest: manifestStamp, tasks: tasksStamp, agents: agentsStamp, events: eventsStamp, mailbox, output };
}
export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOptions = {}): RunSnapshotCache {
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
const recentEventsLimit = options.recentEvents ?? DEFAULT_RECENT_EVENTS;
const recentOutputLimit = options.recentOutputLines ?? DEFAULT_RECENT_OUTPUT_LINES;
const entries = new Map<string, CacheEntry>();
function touch(runId: string, entry: CacheEntry): RunUiSnapshot {
entry.lastAccessMs = Date.now();
if (entries.has(runId)) {
entries.delete(runId);
entries.set(runId, entry);
}
return entry.snapshot;
}
function evictIfNeeded(): void {
while (entries.size > maxEntries) {
const oldestInactive = [...entries.entries()].find(([, entry]) => !isActiveRunStatus(entry.snapshot.manifest.status));
const key = oldestInactive?.[0] ?? entries.keys().next().value;
if (!key) break;
entries.delete(key);
}
}
function build(runId: string, previous?: CacheEntry): CacheEntry {
let loaded: ReturnType<typeof loadRunManifestById>;
try {
loaded = loadRunManifestById(cwd, runId);
} catch {
if (previous) return previous;
throw new Error(`Run '${runId}' could not be parsed.`);
}
if (!loaded) {
if (previous) return previous;
throw new Error(`Run '${runId}' not found.`);
}
let tasks: TeamTaskState[];
let agents: CrewAgentRecord[];
try {
tasks = readTasks(loaded.manifest.tasksPath);
agents = readCrewAgents(loaded.manifest);
} catch {
if (previous) return previous;
throw new Error(`Run '${runId}' could not be parsed.`);
}
const mailbox = mailboxFrom(loaded.manifest, agents);
const groupJoins = groupJoinsFrom(loaded.manifest);
const recentEvents = safeRecentEvents(loaded.manifest.eventsPath, recentEventsLimit);
const base = {
runId: loaded.manifest.runId,
cwd: loaded.manifest.cwd,
manifest: loaded.manifest,
tasks,
agents,
progress: progressFromTasks(tasks),
usage: usageFrom(tasks, agents),
mailbox,
groupJoins,
cancellationReason: cancellationReasonFromEvents(recentEvents),
recentEvents,
recentOutputLines: recentOutputLines(loaded.manifest, agents, recentOutputLimit),
};
const stamps = stampsFor(loaded.manifest, agents);
const snapshot: RunUiSnapshot = { ...base, fetchedAt: Date.now(), signature: signatureFor(base, stamps) };
return { snapshot, stamps, loadedAtMs: snapshot.fetchedAt, lastAccessMs: snapshot.fetchedAt };
}
async function buildAsync(runId: string, previous?: CacheEntry): Promise<CacheEntry> {
let loaded: Awaited<ReturnType<typeof loadRunManifestByIdAsync>>;
try {
loaded = await loadRunManifestByIdAsync(cwd, runId);
} catch {
if (previous) return previous;
throw new Error(`Run '${runId}' could not be parsed.`);
}
if (!loaded) {
if (previous) return previous;
throw new Error(`Run '${runId}' not found.`);
}
let tasks: TeamTaskState[];
let agents: CrewAgentRecord[];
try {
tasks = loaded.tasks;
agents = await readCrewAgentsAsync(loaded.manifest);
} catch {
if (previous) return previous;
throw new Error(`Run '${runId}' could not be parsed.`);
}
const [mailbox, groupJoins, recentEvents, recentOutput] = await Promise.all([
mailboxFromAsync(loaded.manifest, agents),
groupJoinsFromAsync(loaded.manifest),
safeRecentEventsAsync(loaded.manifest.eventsPath, recentEventsLimit),
recentOutputLinesAsync(loaded.manifest, agents, recentOutputLimit),
]);
const base = {
runId: loaded.manifest.runId,
cwd: loaded.manifest.cwd,
manifest: loaded.manifest,
tasks,
agents,
progress: progressFromTasks(tasks),
usage: usageFrom(tasks, agents),
mailbox,
groupJoins,
cancellationReason: cancellationReasonFromEvents(recentEvents),
recentEvents,
recentOutputLines: recentOutput,
};
const stamps = await stampsForAsync(loaded.manifest, agents);
const snapshot: RunUiSnapshot = { ...base, fetchedAt: Date.now(), signature: signatureFor(base, stamps) };
return { snapshot, stamps, loadedAtMs: snapshot.fetchedAt, lastAccessMs: snapshot.fetchedAt };
}
function currentStamps(previous: CacheEntry): SnapshotStamps {
const manifest = previous.snapshot.manifest;
return {
manifest: stampFile(path.join(manifest.stateRoot, "manifest.json")),
tasks: stampFile(manifest.tasksPath),
agents: stampFile(agentsPath(manifest)),
events: stampFile(manifest.eventsPath),
mailbox: mailboxStamp(manifest),
output: outputStamp(previous.snapshot.manifest, previous.snapshot.agents),
};
}
async function currentStampsAsync(previous: CacheEntry): Promise<SnapshotStamps> {
return stampsForAsync(previous.snapshot.manifest, previous.snapshot.agents);
}
async function preloadStale(runId: string): Promise<RunUiSnapshot | undefined> {
const previous = entries.get(runId);
const now = Date.now();
// Fresh enough? Return immediately
if (previous && now - previous.loadedAtMs < ttlMs) {
return touch(runId, previous);
}
// Check stamps async
if (previous) {
const stamps = await currentStampsAsync(previous);
if (sameStamps(stamps, previous.stamps)) {
previous.loadedAtMs = now;
return touch(runId, previous);
}
}
// Full async build
const entry = await buildAsync(runId, previous);
entries.set(runId, entry);
evictIfNeeded();
return entry.snapshot;
}
async function preloadAllStale(runIds: string[]): Promise<void> {
const batchSize = 4;
for (let i = 0; i < runIds.length; i += batchSize) {
const batch = runIds.slice(i, i + batchSize);
await Promise.all(batch.map((id) => preloadStale(id)));
}
}
return {
get(runId: string): RunUiSnapshot | undefined {
const entry = entries.get(runId);
return entry ? touch(runId, entry) : undefined;
},
refresh(runId: string): RunUiSnapshot {
const previous = entries.get(runId);
const entry = build(runId, previous);
entries.set(runId, entry);
evictIfNeeded();
return entry.snapshot;
},
refreshIfStale(runId: string): RunUiSnapshot {
const previous = entries.get(runId);
if (!previous) return this.refresh(runId);
const now = Date.now();
if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
const stamps = currentStamps(previous);
if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
return this.refresh(runId);
},
preloadStale,
preloadAllStale,
invalidate(runId?: string): void {
if (runId) entries.delete(runId);
else entries.clear();
},
snapshotsByKey(): Map<string, RunUiSnapshot> {
return new Map([...entries.entries()].map(([key, entry]) => [key, entry.snapshot]));
},
dispose(): void {
entries.clear();
},
};
}

View File

@@ -0,0 +1,62 @@
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
import type { TeamEvent } from "../state/event-log.ts";
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
export interface RunUiProgress {
total: number;
completed: number;
running: number;
failed: number;
queued: number;
waiting?: number;
cancelled?: number;
skipped?: number;
}
export interface RunUiUsage {
tokensIn: number;
tokensOut: number;
toolUses: number;
}
export interface RunUiMailbox {
inboxUnread: number;
outboxPending: number;
needsAttention: number;
/** True when counts come from bounded tail reads and older messages may be omitted. */
approximate?: boolean;
}
export interface RunUiGroupJoin {
requestId: string;
messageId: string;
partial: boolean;
ack: "pending" | "acknowledged";
}
export interface RunUiSnapshot {
runId: string;
cwd: string;
fetchedAt: number;
signature: string;
manifest: TeamRunManifest;
tasks: TeamTaskState[];
agents: CrewAgentRecord[];
progress: RunUiProgress;
usage: RunUiUsage;
mailbox: RunUiMailbox;
groupJoins?: RunUiGroupJoin[];
/** Structured cancellation reason from run.cancelled event data, when available. */
cancellationReason?: string;
recentEvents: TeamEvent[];
recentOutputLines: string[];
}
export interface RunSnapshotCache {
get(runId: string): RunUiSnapshot | undefined;
refresh(runId: string): RunUiSnapshot;
refreshIfStale(runId: string): RunUiSnapshot;
invalidate(runId?: string): void;
snapshotsByKey(): Map<string, RunUiSnapshot>;
dispose?(): void;
}

View File

@@ -0,0 +1,17 @@
export const SUBAGENT_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
export const SUBAGENT_SPINNER_FRAME_MS = 160;
export function spinnerBucket(now = Date.now(), frameMs = SUBAGENT_SPINNER_FRAME_MS): number {
return Math.floor(now / Math.max(1, frameMs));
}
function hashKey(key: string): number {
let hash = 0;
for (let index = 0; index < key.length; index += 1) hash = (hash * 31 + key.charCodeAt(index)) >>> 0;
return hash;
}
export function spinnerFrame(key = "", now = Date.now()): string {
const offset = key ? hashKey(key) % SUBAGENT_SPINNER_FRAMES.length : 0;
return SUBAGENT_SPINNER_FRAMES[(spinnerBucket(now) + offset) % SUBAGENT_SPINNER_FRAMES.length] ?? SUBAGENT_SPINNER_FRAMES[0];
}

View File

@@ -0,0 +1,58 @@
import type { CrewTheme, CrewThemeColor } from "./theme-adapter.ts";
export type RunStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "stopped" | "blocked" | (string & {});
export function colorForStatus(status: RunStatus): CrewThemeColor {
switch (status) {
case "running":
return "accent";
case "waiting":
return "muted";
case "completed":
return "success";
case "failed":
case "stale":
return "error";
case "cancelled":
case "blocked":
case "stopped":
return "warning";
case "queued":
default:
return "dim";
}
}
export function iconForStatus(status: RunStatus, options?: { runningGlyph?: string }): string {
const glyph = options?.runningGlyph ?? "▶";
switch (status) {
case "completed":
return "✓";
case "failed":
case "stale":
return "✗";
case "cancelled":
case "stopped":
return "■";
case "running":
return glyph;
case "waiting":
return "⏳";
case "queued":
return "◦";
case "blocked":
return "⏸";
default:
return "·";
}
}
export function colorForActivity(activityState: string | undefined): CrewThemeColor {
if (activityState === "needs_attention") return "warning";
if (activityState === "stale") return "error";
return "dim";
}
export function applyStatusColor(theme: CrewTheme, status: RunStatus, text: string): string {
return theme.fg(colorForStatus(status), text);
}

View File

@@ -0,0 +1,116 @@
import { supportsLanguage, highlight } from "cli-highlight";
import type { CrewTheme } from "./theme-adapter.ts";
import { asCrewTheme } from "./theme-adapter.ts";
function buildCliTheme(theme: CrewTheme): Record<string, (text: string) => string> {
return {
keyword: (text) => theme.fg("syntaxKeyword", text),
built_in: (text) => theme.fg("syntaxType", text),
literal: (text) => theme.fg("syntaxNumber", text),
number: (text) => theme.fg("syntaxNumber", text),
string: (text) => theme.fg("syntaxString", text),
comment: (text) => theme.fg("syntaxComment", text),
function: (text) => theme.fg("syntaxFunction", text),
title: (text) => theme.fg("syntaxFunction", text),
class: (text) => theme.fg("syntaxType", text),
type: (text) => theme.fg("syntaxType", text),
attr: (text) => theme.fg("syntaxVariable", text),
variable: (text) => theme.fg("syntaxVariable", text),
params: (text) => theme.fg("syntaxVariable", text),
operator: (text) => theme.fg("syntaxOperator", text),
punctuation: (text) => theme.fg("syntaxPunctuation", text),
};
}
export function detectLanguageFromPath(filePath: string): string | undefined {
const ext = filePath.split(".").pop()?.toLowerCase();
if (!ext) return undefined;
return languageMap[ext];
}
export const languageMap: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
py: "python",
md: "markdown",
markdown: "markdown",
json: "json",
yml: "yaml",
yaml: "yaml",
toml: "yaml",
html: "html",
htm: "html",
css: "css",
scss: "scss",
sass: "sass",
bash: "bash",
sh: "bash",
zsh: "bash",
fish: "bash",
ps1: "powershell",
sql: "sql",
rust: "rust",
rb: "ruby",
go: "go",
java: "java",
kt: "kotlin",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
c: "c",
h: "c",
cs: "csharp",
php: "php",
};
export function highlightCode(code: string, language: string | undefined, themeLike: unknown = undefined): string {
const theme = asCrewTheme(themeLike);
const validLanguage = language && supportsLanguage(language) ? language : undefined;
if (!validLanguage) {
return code
.split("\n")
.map((line) => theme.fg("mdCodeBlock", line))
.join("\n");
}
try {
return highlight(code, {
language: validLanguage,
ignoreIllegals: true,
theme: buildCliTheme(theme),
}).trimEnd();
} catch {
return code
.split("\n")
.map((line) => theme.fg("mdCodeBlock", line))
.join("\n");
}
}
export function highlightJson(payload: string, themeLike: unknown = undefined): string {
const theme = asCrewTheme(themeLike);
try {
return highlight(payload, {
language: "json",
ignoreIllegals: true,
theme: buildCliTheme(theme),
}).trimEnd();
} catch {
try {
const parsed = JSON.parse(payload);
return JSON.stringify(parsed, null, 2)
.split("\n")
.map((line) => theme.fg("mdCodeBlock", line))
.join("\n");
} catch {
return payload
.split("\n")
.map((line) => theme.fg("mdCodeBlock", line))
.join("\n");
}
}
}

View File

@@ -0,0 +1,190 @@
export type CrewThemeColor =
| "accent"
| "border"
| "borderAccent"
| "borderMuted"
| "success"
| "error"
| "warning"
| "muted"
| "dim"
| "text"
| "toolDiffAdded"
| "toolDiffRemoved"
| "toolDiffContext"
| "syntaxKeyword"
| "syntaxString"
| "syntaxNumber"
| "syntaxComment"
| "syntaxFunction"
| "syntaxVariable"
| "syntaxType"
| "syntaxOperator"
| "syntaxPunctuation"
| "mdCodeBlock";
export type CrewThemeBg =
| "selectedBg"
| "userMessageBg"
| "toolPendingBg"
| "toolSuccessBg"
| "toolErrorBg";
export interface CrewTheme {
fg(color: CrewThemeColor, text: string): string;
bg?(color: CrewThemeBg, text: string): string;
bold(text: string): string;
italic?(text: string): string;
underline?(text: string): string;
inverse?(text: string): string;
}
function inverseAnsi(text: string): string {
return `\u001b[7m${text}\u001b[27m`;
}
function safeNoopTheme(): CrewTheme {
return {
fg: (_color, text) => text,
bold: (text) => text,
inverse: inverseAnsi,
};
}
function asStringFn(value: unknown, owner?: object): ((color: CrewThemeColor | CrewThemeBg, text: string) => string) | undefined {
if (typeof value !== "function") return undefined;
return (color: CrewThemeColor | CrewThemeBg, text: string) => {
try {
const fn = value as (this: object | undefined, color: CrewThemeColor | CrewThemeBg, text: string) => unknown;
const result = fn.call(owner, color, text);
return typeof result === "string" ? result : text;
} catch {
return text;
}
};
}
function asUnaryFn(value: unknown, owner?: object): ((text: string) => string) | undefined {
if (typeof value !== "function") return undefined;
return (text: string) => {
try {
const fn = value as (this: object | undefined, text: string) => unknown;
const result = fn.call(owner, text);
return typeof result === "string" ? result : text;
} catch {
return text;
}
};
}
function asInverse(value: unknown, owner?: object): (text: string) => string {
return asUnaryFn(value, owner) ?? inverseAnsi;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
function callMaybeString(fn: unknown): string | undefined {
if (typeof fn !== "function") return undefined;
try {
const result = (fn as () => unknown)();
return typeof result === "string" || typeof result === "number" || typeof result === "boolean" ? String(result) : undefined;
} catch {
return undefined;
}
}
function themeSignature(theme: object): string {
const record = theme as Record<string, unknown>;
const primitiveEntries = Object.entries(record)
.filter(([_key, value]) => value === undefined || value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean")
.map(([key, value]) => `${key}:${String(value)}`)
.sort();
const colorMode = callMaybeString(record.getColorMode);
return [colorMode ? `mode:${colorMode}` : undefined, ...primitiveEntries].filter((item): item is string => Boolean(item)).join("|");
}
type Unsubscribe = () => void;
interface ThemeSourceSubscription {
callbacks: Set<() => void>;
unsubscribeSource?: Unsubscribe;
pollTimer?: ReturnType<typeof setInterval>;
lastSignature: string;
}
const themeSubscriptions = new WeakMap<object, ThemeSourceSubscription>();
function asUnsubscribe(value: unknown): Unsubscribe | undefined {
if (typeof value === "function") return value as Unsubscribe;
const record = asRecord(value);
if (!record) return undefined;
if (typeof record.unsubscribe === "function") return () => (record.unsubscribe as () => void)();
if (typeof record.dispose === "function") return () => (record.dispose as () => void)();
return undefined;
}
function startThemeSourceSubscription(theme: object, subscription: ThemeSourceSubscription): void {
const record = theme as Record<string, unknown>;
const emit = () => {
for (const callback of [...subscription.callbacks]) callback();
};
if (typeof record.onThemeChange === "function") {
const result = (record.onThemeChange as (callback: () => void) => unknown)(emit);
subscription.unsubscribeSource = asUnsubscribe(result);
return;
}
if (typeof record.addEventListener === "function") {
(record.addEventListener as (type: string, callback: () => void) => void)("change", emit);
if (typeof record.removeEventListener === "function") {
subscription.unsubscribeSource = () => (record.removeEventListener as (type: string, callback: () => void) => void)("change", emit);
}
return;
}
subscription.pollTimer = setInterval(() => {
const nextSignature = themeSignature(theme);
if (nextSignature === subscription.lastSignature) return;
subscription.lastSignature = nextSignature;
emit();
}, 1000);
subscription.pollTimer.unref();
}
export function subscribeThemeChange(theme: unknown, callback: () => void): () => void {
if (!theme || typeof theme !== "object") return () => {};
const key = theme;
let subscription = themeSubscriptions.get(key);
if (!subscription) {
subscription = { callbacks: new Set(), lastSignature: themeSignature(key) };
themeSubscriptions.set(key, subscription);
startThemeSourceSubscription(key, subscription);
}
subscription.callbacks.add(callback);
return () => {
const current = themeSubscriptions.get(key);
if (!current) return;
current.callbacks.delete(callback);
if (current.callbacks.size > 0) return;
if (current.pollTimer) clearInterval(current.pollTimer);
current.unsubscribeSource?.();
themeSubscriptions.delete(key);
};
}
export function asCrewTheme(raw: unknown): CrewTheme {
const fallback = safeNoopTheme();
if (!raw || typeof raw !== "object") return fallback;
const record = raw as Record<string, unknown>;
const fg = asStringFn(record.fg, raw);
const bold = asUnaryFn(record.bold, raw);
if (!fg || !bold) return fallback;
return {
fg,
bg: asStringFn(record.bg, raw),
bold,
italic: asUnaryFn(record.italic, raw),
underline: asUnaryFn(record.underline, raw),
inverse: asInverse(record.inverse, raw),
};
}

View File

@@ -0,0 +1,94 @@
import * as fs from "node:fs";
export interface TranscriptCacheEntry {
path: string;
mtimeMs: number;
size: number;
lines: string[];
parsedAt: number;
readCount: number;
mode: "tail" | "full";
bytesRead: number;
truncated: boolean;
}
export interface TranscriptReadOptions {
maxTailBytes?: number;
full?: boolean;
}
const TRANSCRIPT_CACHE_TTL_MS = 500;
const DEFAULT_TAIL_BYTES = 256 * 1024;
const transcriptCache = new Map<string, TranscriptCacheEntry>();
function cacheKey(path: string, options: Required<Pick<TranscriptReadOptions, "full">> & { maxTailBytes: number }): string {
return `${path}:${options.full ? "full" : `tail:${options.maxTailBytes}`}`;
}
export function clearTranscriptCache(path?: string): void {
if (!path) {
transcriptCache.clear();
return;
}
for (const key of [...transcriptCache.keys()]) if (key === path || key.startsWith(`${path}:`)) transcriptCache.delete(key);
}
export function getTranscriptCacheEntry(path: string, options: TranscriptReadOptions = {}): TranscriptCacheEntry | undefined {
const normalized = { full: options.full === true, maxTailBytes: options.maxTailBytes ?? DEFAULT_TAIL_BYTES };
return transcriptCache.get(cacheKey(path, normalized)) ?? transcriptCache.get(path);
}
function readTranscriptText(path: string, stat: fs.Stats, options: Required<Pick<TranscriptReadOptions, "full">> & { maxTailBytes: number }): { text: string; bytesRead: number; truncated: boolean } {
if (options.full || stat.size <= options.maxTailBytes) {
return { text: fs.readFileSync(path, "utf-8"), bytesRead: stat.size, truncated: false };
}
const bytesToRead = Math.min(stat.size, options.maxTailBytes);
const fd = fs.openSync(path, "r");
try {
const buffer = Buffer.alloc(bytesToRead);
fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
let text = buffer.toString("utf-8");
const firstNewline = text.search(/\r?\n/);
if (firstNewline >= 0) text = text.slice(firstNewline + (text[firstNewline] === "\r" && text[firstNewline + 1] === "\n" ? 2 : 1));
return { text, bytesRead: bytesToRead, truncated: true };
} finally {
fs.closeSync(fd);
}
}
export function readTranscriptLinesCached(path: string, parse: (text: string) => string[], now = Date.now(), options: TranscriptReadOptions = {}): string[] {
const normalized = { full: options.full === true, maxTailBytes: Math.max(1024, options.maxTailBytes ?? DEFAULT_TAIL_BYTES) };
const key = cacheKey(path, normalized);
const previous = transcriptCache.get(key);
let stat: fs.Stats;
try {
stat = fs.statSync(path);
} catch {
return previous?.lines ?? [];
}
if (previous && previous.mtimeMs === stat.mtimeMs && previous.size === stat.size) {
if (now - previous.parsedAt >= TRANSCRIPT_CACHE_TTL_MS) previous.parsedAt = now;
return previous.lines;
}
try {
const read = readTranscriptText(path, stat, normalized);
const lines = parse(read.text);
const entry: TranscriptCacheEntry = {
path,
mtimeMs: stat.mtimeMs,
size: stat.size,
lines,
parsedAt: now,
readCount: (previous?.readCount ?? 0) + 1,
mode: normalized.full ? "full" : "tail",
bytesRead: read.bytesRead,
truncated: read.truncated,
};
transcriptCache.set(key, entry);
return lines;
} catch {
return previous?.lines ?? [];
}
}
export const DEFAULT_TRANSCRIPT_TAIL_BYTES = DEFAULT_TAIL_BYTES;

View File

@@ -0,0 +1,335 @@
import * as fs from "node:fs";
import type { TeamRunManifest } from "../state/types.ts";
import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
import type { CrewTheme } from "./theme-adapter.ts";
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
import { renderDiff } from "./render-diff.ts";
import { highlightCode, highlightJson } from "./syntax-highlight.ts";
import { pad, truncate, truncateToVisualLines } from "../utils/visual.ts";
import { colorForStatus, iconForStatus, type RunStatus } from "./status-colors.ts";
import { DEFAULT_TRANSCRIPT_TAIL_BYTES, getTranscriptCacheEntry, readTranscriptLinesCached } from "./transcript-cache.ts";
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
type Component = { invalidate(): void; render(width: number): string[]; handleInput(data: string): void };
type TranscriptTheme = CrewTheme;
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function textFromContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) => {
const obj = asRecord(part);
if (!obj) return "";
if (typeof obj.text === "string") return obj.text;
if (typeof obj.content === "string") return obj.content;
if (typeof obj.name === "string") return `[tool:${obj.name}]`;
return "";
})
.filter(Boolean)
.join("\n");
}
function isLikelyDiff(text: string): boolean {
const lines = text.split(/\r?\n/);
const matched = lines.filter((line) => /^[-+\s]\d+\s/.test(line)).length;
return matched >= 2 && (text.includes("-") || text.includes("+"));
}
function highlightCodeBlocks(input: string, theme: TranscriptTheme): string[] {
const codeBlockRegex = /```(\S+)?\n([\s\S]*?)```/g;
const lines: string[] = [];
let index = 0;
let match: RegExpExecArray | null;
while ((match = codeBlockRegex.exec(input)) !== null) {
if (match.index > index) lines.push(...input.slice(index, match.index).split(/\r?\n/));
const lang = match[1]?.trim();
const block = match[2] ?? "";
const highlighted = highlightCode(block, lang, theme);
if (highlighted) {
lines.push(...highlighted.split(/\r?\n/));
}
index = match.index + match[0].length;
}
if (index < input.length) lines.push(...input.slice(index).split(/\r?\n/));
return lines.filter((line) => line.length > 0);
}
export function formatTranscriptEvent(event: unknown, themeLike: unknown = undefined): string[] {
const theme = asCrewTheme(themeLike);
const obj = asRecord(event);
if (!obj) return [String(event)];
const type = typeof obj.type === "string" ? obj.type : undefined;
const toolName = typeof obj.toolName === "string" ? obj.toolName : typeof obj.name === "string" ? obj.name : undefined;
const content = textFromContent(obj.content);
if (type && /tool/i.test(type)) {
const result = asRecord(obj.result);
const isError = obj.isError === true || result?.isError === true;
const isPartial = obj.isPartial === true;
const status: RunStatus = isError ? "failed" : isPartial ? "running" : "completed";
const header = theme.fg(colorForStatus(status), `${iconForStatus(status, { runningGlyph: "⋯" })} [Tool${toolName ? `: ${toolName}` : ""}] ${type}`);
const text = (content || (typeof obj.text === "string" ? obj.text : typeof obj.result === "string" ? obj.result : "")).trim();
if (!text) return [header, "(no output)"];
if (isLikelyDiff(text)) {
return [header, renderDiff(text, { theme })];
}
if (text.startsWith("{") && text.endsWith("}")) {
return [header, ...highlightJson(text, theme).split(/\r?\n/).filter(Boolean)];
}
if (text.includes("```") && text.includes("```")) {
return [header, ...highlightCodeBlocks(text, theme)];
}
return [header, ...text.split(/\r?\n/).filter(Boolean).map((line) => theme.fg("muted", line))];
}
const message = asRecord(obj.message);
if (message) {
const role = typeof message.role === "string" ? message.role : "message";
const text = textFromContent(message.content);
if (text.trim()) {
const label = role === "assistant" ? "Assistant" : role === "user" ? "User" : role;
const header = `[${label}]:`;
const lines = text.split(/\r?\n/);
if (text.includes("```") && text.includes("```")) {
return [theme.fg("accent", header), ...highlightCodeBlocks(text, theme)];
}
if (lines.length > 1) {
const block = lines
.map((line) => (role === "assistant" ? theme.bold(line) : line))
.join("\n");
return [theme.fg("accent", header), ...block.split(/\r?\n/).filter(Boolean)];
}
return [theme.fg("accent", header), ...lines.filter(Boolean)];
}
}
if (type) {
const text = content || (typeof obj.text === "string" ? obj.text : "");
return text.trim() ? [theme.fg("muted", `[${type}]: ${text.trim()}`)] : [`[${type}]`];
}
return [JSON.stringify(event)];
}
export function formatTranscriptText(text: string, themeLike: unknown = undefined): string[] {
const lines: string[] = [];
for (const raw of text.split(/\r?\n/).filter(Boolean)) {
try {
const parsed = JSON.parse(raw);
lines.push(...formatTranscriptEvent(parsed, themeLike));
} catch {
lines.push(raw);
}
}
return lines.length ? lines : ["(no transcript content)"];
}
export function readRunTranscript(manifest: TeamRunManifest, taskId?: string, options: { full?: boolean; maxTailBytes?: number } = {}): { title: string; path: string; lines: string[]; bytesRead: number; size: number; truncated: boolean } {
const agents = readCrewAgents(manifest);
const agent = taskId ? agents.find((item) => item.taskId === taskId || item.id === taskId) : agents.find((item) => item.transcriptPath) ?? agents[0];
const selectedTaskId = agent?.taskId ?? taskId ?? "unknown";
let transcriptPath = "";
try {
transcriptPath = agentOutputPath(manifest, selectedTaskId);
} catch {
try {
transcriptPath = agentOutputPath(manifest, "unknown");
} catch {
// Both fallbacks failed — transcript will be empty.
transcriptPath = "";
}
}
if (agent?.transcriptPath) {
try {
const safeTranscriptPath = resolveRealContainedPath(manifest.artifactsRoot, agent.transcriptPath);
if (fs.existsSync(safeTranscriptPath)) transcriptPath = safeTranscriptPath;
} catch {
// Ignore untrusted transcript paths from mutable agent state and fall back to durable agent output.
}
}
const readOptions = { full: options.full === true, maxTailBytes: options.maxTailBytes ?? DEFAULT_TRANSCRIPT_TAIL_BYTES };
const lines = readTranscriptLinesCached(transcriptPath, (text) => formatTranscriptText(text), Date.now(), readOptions);
const entry = getTranscriptCacheEntry(transcriptPath, readOptions);
return { title: `${manifest.runId}:${selectedTaskId}`, path: transcriptPath, lines: lines.length ? lines : ["(no transcript content)"], bytesRead: entry?.bytesRead ?? 0, size: entry?.size ?? 0, truncated: entry?.truncated ?? false };
}
interface ViewerState {
theme: TranscriptTheme;
autoScroll: boolean;
lastHeight: number;
scroll: number;
}
function renderViewerBase(
state: ViewerState,
width: number,
lines: string[],
title: string,
subtitle: string,
): string[] {
const inner = Math.max(20, width - 4);
const bodyText = lines.join("\n");
const { visualLines, skippedCount } = truncateToVisualLines(bodyText, state.lastHeight, inner);
const maxScroll = Math.max(0, visualLines.length - state.lastHeight);
if (state.autoScroll) state.scroll = maxScroll;
state.scroll = Math.min(state.scroll, maxScroll);
const visible = visualLines.slice(state.scroll, state.scroll + state.lastHeight);
const statusLine = `${visualLines.length} lines · ${visualLines.length ? Math.round(((state.scroll + visible.length) / visualLines.length) * 100) : 100}% · auto-scroll ${state.autoScroll ? "on" : "off"}`;
const fg = (color: Parameters<TranscriptTheme["fg"]>[0], text: string) => state.theme.fg(color, text);
const row = (text: string) => `${fg("border", "│")} ${pad(truncate(text, inner), inner)} ${fg("border", "│")}`;
const linesOut: string[] = [
fg("border", `${"─".repeat(inner + 2)}`),
row(`${fg("accent", title)} ${fg("dim", subtitle)}`),
row(fg("dim", "j/k scroll · PgUp/PgDn · g/G top/bottom · a auto · f full/tail · q close")),
fg("border", `${"─".repeat(inner + 2)}`),
...visible.map(row),
fg("border", `${"─".repeat(inner + 2)}`),
row(fg("dim", statusLine)),
fg("border", `${"─".repeat(inner + 2)}`),
];
if (skippedCount > 0) {
linesOut.splice(linesOut.length - 1, 0, row(fg("muted", `… (${skippedCount} lines truncated above`)));
}
return linesOut.map((line) => truncate(line, width));
}
export class DurableTextViewer implements Component {
private scroll = 0;
private lastHeight = 16;
private autoScroll = true;
private title: string;
private subtitle: string;
private lines: string[];
private theme: TranscriptTheme;
private done: (result: undefined) => void;
private readonly unsubscribeTheme: () => void;
constructor(title: string, subtitle: string, lines: string[], theme: unknown, done: (result: undefined) => void) {
this.title = title;
this.subtitle = subtitle;
this.lines = lines.length ? lines : ["(empty)"];
this.theme = asCrewTheme(theme);
this.done = done;
this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidate());
}
invalidate(): void {}
dispose(): void {
this.unsubscribeTheme();
}
handleInput(data: string): void {
if (data === "q" || data === "\u001b") {
this.done(undefined);
return;
}
const maxScroll = Math.max(0, this.lines.length - this.lastHeight);
if (data === "k" || data === "\u001b[A") {
this.scroll = Math.max(0, this.scroll - 1);
this.autoScroll = false;
} else if (data === "j" || data === "\u001b[B") {
this.scroll = Math.min(maxScroll, this.scroll + 1);
this.autoScroll = this.scroll >= maxScroll;
} else if (data === "\u001b[5~") {
this.scroll = Math.max(0, this.scroll - this.lastHeight);
this.autoScroll = false;
} else if (data === "\u001b[6~") {
this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight);
this.autoScroll = this.scroll >= maxScroll;
} else if (data === "g" || data === "\u001b[H") {
this.scroll = 0;
this.autoScroll = false;
} else if (data === "G" || data === "\u001b[F") {
this.scroll = maxScroll;
this.autoScroll = true;
} else if (data === "a") {
this.autoScroll = !this.autoScroll;
}
}
render(width: number): string[] {
return renderViewerBase(
{ theme: this.theme, autoScroll: this.autoScroll, lastHeight: this.lastHeight, scroll: this.scroll },
width,
this.lines,
this.title,
this.subtitle,
);
}
}
export class DurableTranscriptViewer implements Component {
private scroll = 0;
private lastHeight = 16;
private autoScroll = true;
private manifest: TeamRunManifest;
private theme: TranscriptTheme;
private done: (result: undefined) => void;
private taskId?: string;
private fullTranscript = false;
private maxTailBytes: number;
private readonly unsubscribeTheme: () => void;
constructor(manifest: TeamRunManifest, theme: unknown, done: (result: undefined) => void, taskId?: string, options: { maxTailBytes?: number } = {}) {
this.manifest = manifest;
this.theme = asCrewTheme(theme);
this.done = done;
this.taskId = taskId;
this.maxTailBytes = options.maxTailBytes ?? DEFAULT_TRANSCRIPT_TAIL_BYTES;
this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidate());
}
invalidate(): void {}
dispose(): void {
this.unsubscribeTheme();
}
handleInput(data: string): void {
if (data === "q" || data === "\u001b") {
this.done(undefined);
return;
}
const content = readRunTranscript(this.manifest, this.taskId, { full: this.fullTranscript, maxTailBytes: this.maxTailBytes }).lines;
const maxScroll = Math.max(0, content.length - this.lastHeight);
if (data === "k" || data === "\u001b[A") {
this.scroll = Math.max(0, this.scroll - 1);
this.autoScroll = false;
} else if (data === "j" || data === "\u001b[B") {
this.scroll = Math.min(maxScroll, this.scroll + 1);
this.autoScroll = this.scroll >= maxScroll;
} else if (data === "\u001b[5~") {
this.scroll = Math.max(0, this.scroll - this.lastHeight);
this.autoScroll = false;
} else if (data === "\u001b[6~") {
this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight);
this.autoScroll = this.scroll >= maxScroll;
} else if (data === "g" || data === "\u001b[H") {
this.scroll = 0;
this.autoScroll = false;
} else if (data === "G" || data === "\u001b[F") {
this.scroll = maxScroll;
this.autoScroll = true;
} else if (data === "a") {
this.autoScroll = !this.autoScroll;
} else if (data === "f") {
this.fullTranscript = !this.fullTranscript;
this.scroll = 0;
this.autoScroll = !this.fullTranscript;
}
}
render(width: number): string[] {
const data = readRunTranscript(this.manifest, this.taskId, { full: this.fullTranscript, maxTailBytes: this.maxTailBytes });
return renderViewerBase(
{ theme: this.theme, autoScroll: this.autoScroll, lastHeight: this.lastHeight, scroll: this.scroll },
width,
data.lines,
"pi-crew transcript",
`${data.title} · ${data.truncated ? `tail ${Math.round(data.bytesRead / 1024)}KB/${Math.round(data.size / 1024)}KB` : `full ${Math.round(data.size / 1024)}KB`} · f ${this.fullTranscript ? "tail" : "full"}`,
);
}
}