Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom
This commit is contained in:
101
extensions/pi-crew/src/ui/crew-footer.ts
Normal file
101
extensions/pi-crew/src/ui/crew-footer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
111
extensions/pi-crew/src/ui/crew-select-list.ts
Normal file
111
extensions/pi-crew/src/ui/crew-select-list.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
356
extensions/pi-crew/src/ui/crew-widget.ts
Normal file
356
extensions/pi-crew/src/ui/crew-widget.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
extensions/pi-crew/src/ui/dashboard-panes/agents-pane.ts
Normal file
28
extensions/pi-crew/src/ui/dashboard-panes/agents-pane.ts
Normal 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(" · ")}`;
|
||||
}),
|
||||
];
|
||||
}
|
||||
30
extensions/pi-crew/src/ui/dashboard-panes/health-pane.ts
Normal file
30
extensions/pi-crew/src/ui/dashboard-panes/health-pane.ts
Normal 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;
|
||||
}
|
||||
11
extensions/pi-crew/src/ui/dashboard-panes/mailbox-pane.ts
Normal file
11
extensions/pi-crew/src/ui/dashboard-panes/mailbox-pane.ts
Normal 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.",
|
||||
];
|
||||
}
|
||||
34
extensions/pi-crew/src/ui/dashboard-panes/metrics-pane.ts
Normal file
34
extensions/pi-crew/src/ui/dashboard-panes/metrics-pane.ts
Normal 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;
|
||||
}
|
||||
19
extensions/pi-crew/src/ui/dashboard-panes/progress-pane.ts
Normal file
19
extensions/pi-crew/src/ui/dashboard-panes/progress-pane.ts
Normal 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"]),
|
||||
];
|
||||
}
|
||||
10
extensions/pi-crew/src/ui/dashboard-panes/transcript-pane.ts
Normal file
10
extensions/pi-crew/src/ui/dashboard-panes/transcript-pane.ts
Normal 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"]),
|
||||
];
|
||||
}
|
||||
25
extensions/pi-crew/src/ui/dynamic-border.ts
Normal file
25
extensions/pi-crew/src/ui/dynamic-border.ts
Normal 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 {}
|
||||
}
|
||||
63
extensions/pi-crew/src/ui/heartbeat-aggregator.ts
Normal file
63
extensions/pi-crew/src/ui/heartbeat-aggregator.ts
Normal 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;
|
||||
}
|
||||
94
extensions/pi-crew/src/ui/keybinding-map.ts
Normal file
94
extensions/pi-crew/src/ui/keybinding-map.ts
Normal 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;
|
||||
}
|
||||
106
extensions/pi-crew/src/ui/layout-primitives.ts
Normal file
106
extensions/pi-crew/src/ui/layout-primitives.ts
Normal 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 {}
|
||||
}
|
||||
176
extensions/pi-crew/src/ui/live-run-sidebar.ts
Normal file
176
extensions/pi-crew/src/ui/live-run-sidebar.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
158
extensions/pi-crew/src/ui/loaders.ts
Normal file
158
extensions/pi-crew/src/ui/loaders.ts
Normal 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);
|
||||
}
|
||||
442
extensions/pi-crew/src/ui/mascot.ts
Normal file
442
extensions/pi-crew/src/ui/mascot.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
57
extensions/pi-crew/src/ui/overlays/agent-picker-overlay.ts
Normal file
57
extensions/pi-crew/src/ui/overlays/agent-picker-overlay.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
extensions/pi-crew/src/ui/overlays/confirm-overlay.ts
Normal file
58
extensions/pi-crew/src/ui/overlays/confirm-overlay.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
144
extensions/pi-crew/src/ui/overlays/mailbox-compose-overlay.ts
Normal file
144
extensions/pi-crew/src/ui/overlays/mailbox-compose-overlay.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
122
extensions/pi-crew/src/ui/overlays/mailbox-detail-overlay.ts
Normal file
122
extensions/pi-crew/src/ui/overlays/mailbox-detail-overlay.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
57
extensions/pi-crew/src/ui/pi-ui-compat.ts
Normal file
57
extensions/pi-crew/src/ui/pi-ui-compat.ts
Normal 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);
|
||||
}
|
||||
129
extensions/pi-crew/src/ui/powerbar-publisher.ts
Normal file
129
extensions/pi-crew/src/ui/powerbar-publisher.ts
Normal 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);
|
||||
}
|
||||
119
extensions/pi-crew/src/ui/render-diff.ts
Normal file
119
extensions/pi-crew/src/ui/render-diff.ts
Normal 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");
|
||||
}
|
||||
143
extensions/pi-crew/src/ui/render-scheduler.ts
Normal file
143
extensions/pi-crew/src/ui/render-scheduler.ts
Normal 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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
108
extensions/pi-crew/src/ui/run-action-dispatcher.ts
Normal file
108
extensions/pi-crew/src/ui/run-action-dispatcher.ts
Normal 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;
|
||||
}
|
||||
460
extensions/pi-crew/src/ui/run-dashboard.ts
Normal file
460
extensions/pi-crew/src/ui/run-dashboard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
725
extensions/pi-crew/src/ui/run-snapshot-cache.ts
Normal file
725
extensions/pi-crew/src/ui/run-snapshot-cache.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
62
extensions/pi-crew/src/ui/snapshot-types.ts
Normal file
62
extensions/pi-crew/src/ui/snapshot-types.ts
Normal 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;
|
||||
}
|
||||
17
extensions/pi-crew/src/ui/spinner.ts
Normal file
17
extensions/pi-crew/src/ui/spinner.ts
Normal 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];
|
||||
}
|
||||
58
extensions/pi-crew/src/ui/status-colors.ts
Normal file
58
extensions/pi-crew/src/ui/status-colors.ts
Normal 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);
|
||||
}
|
||||
116
extensions/pi-crew/src/ui/syntax-highlight.ts
Normal file
116
extensions/pi-crew/src/ui/syntax-highlight.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
190
extensions/pi-crew/src/ui/theme-adapter.ts
Normal file
190
extensions/pi-crew/src/ui/theme-adapter.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
94
extensions/pi-crew/src/ui/transcript-cache.ts
Normal file
94
extensions/pi-crew/src/ui/transcript-cache.ts
Normal 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;
|
||||
335
extensions/pi-crew/src/ui/transcript-viewer.ts
Normal file
335
extensions/pi-crew/src/ui/transcript-viewer.ts
Normal 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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user