pi-crew 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -18,6 +18,8 @@ export function piTeamsHelp(): string {
18
18
  "- /team-worktrees <runId>",
19
19
  "- /team-api <runId> <operation> [taskId=<taskId>] [body=<message>]",
20
20
  "- /team-dashboard",
21
+ "- /team-transcript <runId> [taskId]",
22
+ "- /team-result <runId> [taskId]",
21
23
  "- /team-manager",
22
24
  "",
23
25
  "Maintenance:",
@@ -10,6 +10,10 @@ import { handleTeamTool, type TeamToolDetails } from "./team-tool.ts";
10
10
  import { listRuns } from "./run-index.ts";
11
11
  import { RunDashboard, type RunDashboardSelection } from "../ui/run-dashboard.ts";
12
12
  import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.ts";
13
+ import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
14
+ import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
15
+ import { loadRunManifestById } from "../state/state-store.ts";
16
+ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
13
17
 
14
18
  function parseRunArgs(args: string): TeamToolParamsValue {
15
19
  const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
@@ -45,6 +49,36 @@ function parseScalar(raw: string): unknown {
45
49
  return raw;
46
50
  }
47
51
 
52
+ async function selectAgentTask(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<{ runId: string; taskId?: string } | undefined> {
53
+ if (!runId) return undefined;
54
+ if (taskId) return { runId, taskId };
55
+ const loaded = loadRunManifestById(ctx.cwd, runId);
56
+ if (!loaded) return { runId };
57
+ const agents = readCrewAgents(loaded.manifest);
58
+ if (ctx.hasUI && agents.length > 1) {
59
+ const choice = await ctx.ui.select("Select pi-crew agent", agents.map((agent) => `${agent.taskId} ${agent.role}→${agent.agent} [${agent.status}]`));
60
+ return { runId, taskId: choice?.split(" ")[0] };
61
+ }
62
+ return { runId, taskId: agents[0]?.taskId };
63
+ }
64
+
65
+ async function openTranscriptViewer(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<boolean> {
66
+ const selected = await selectAgentTask(ctx, runId, taskId);
67
+ if (!selected) return false;
68
+ // eslint-disable-next-line no-param-reassign
69
+ runId = selected.runId;
70
+ // eslint-disable-next-line no-param-reassign
71
+ taskId = selected.taskId;
72
+ if (!runId || !ctx.hasUI) return false;
73
+ const loaded = loadRunManifestById(ctx.cwd, runId);
74
+ if (!loaded) return false;
75
+ await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTranscriptViewer(loaded.manifest, theme, done, taskId), {
76
+ overlay: true,
77
+ overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" },
78
+ });
79
+ return true;
80
+ }
81
+
48
82
  function pushUnset(config: Record<string, unknown>, key: string): void {
49
83
  const current = Array.isArray(config.unset) ? config.unset : [];
50
84
  current.push(key);
@@ -67,6 +101,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
67
101
  const notifierState: AsyncNotifierState = { seenFinishedRunIds: new Set() };
68
102
  let currentCtx: ExtensionContext | undefined;
69
103
  let rpcHandle: PiCrewRpcHandle | undefined;
104
+ const widgetState: CrewWidgetState = { frame: 0 };
70
105
  registerAutonomousPolicy(pi);
71
106
  rpcHandle = registerPiCrewRpc((pi as unknown as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events, () => currentCtx);
72
107
 
@@ -75,9 +110,13 @@ export function registerPiTeams(pi: ExtensionAPI): void {
75
110
  notifyActiveRuns(ctx);
76
111
  const loadedConfig = loadConfig(ctx.cwd);
77
112
  startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? 5000);
113
+ updateCrewWidget(ctx, widgetState);
114
+ widgetState.interval = setInterval(() => { if (currentCtx) updateCrewWidget(currentCtx, widgetState); }, 1000);
115
+ widgetState.interval.unref?.();
78
116
  });
79
117
  pi.on("session_shutdown", () => {
80
118
  stopAsyncRunNotifier(notifierState);
119
+ stopCrewWidget(currentCtx, widgetState);
81
120
  currentCtx = undefined;
82
121
  rpcHandle?.unsubscribe();
83
122
  rpcHandle = undefined;
@@ -90,7 +129,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
90
129
  promptSnippet: "Use the team tool proactively for coordinated multi-agent work. If unsure, call { action: 'recommend', goal } first, then run or plan with the suggested team/workflow.",
91
130
  parameters: TeamToolParams as never,
92
131
  async execute(_id, params, _signal, _onUpdate, ctx) {
93
- return await handleTeamTool(params as TeamToolParamsValue, ctx);
132
+ const output = await handleTeamTool(params as TeamToolParamsValue, ctx);
133
+ updateCrewWidget(ctx, widgetState);
134
+ return output;
94
135
  },
95
136
  };
96
137
 
@@ -250,6 +291,33 @@ export function registerPiTeams(pi: ExtensionAPI): void {
250
291
  handler: handleTeamManagerCommand,
251
292
  });
252
293
 
294
+ pi.registerCommand("team-result", {
295
+ description: "Open a pi-crew agent result viewer: <runId> [taskId]",
296
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
297
+ const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
298
+ const selected = await selectAgentTask(ctx, runId, rawTaskId);
299
+ const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined;
300
+ if (ctx.hasUI && loaded) {
301
+ const agent = readCrewAgents(loaded.manifest).find((item) => item.taskId === selected?.taskId || item.id === selected?.taskId) ?? readCrewAgents(loaded.manifest)[0];
302
+ const text = agent?.resultArtifactPath ? commandText(await handleTeamTool({ action: "api", runId: selected!.runId, config: { operation: "read-agent-output", agentId: agent.taskId, maxBytes: 64_000 } }, ctx)) : "(no result)";
303
+ await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTextViewer("pi-crew result", `${selected!.runId}:${agent?.taskId ?? "unknown"}`, text.split(/\r?\n/), theme, done), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
304
+ return;
305
+ }
306
+ const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-output", agentId: rawTaskId, maxBytes: 64_000 } }, ctx);
307
+ await notifyCommandResult(ctx, commandText(result));
308
+ },
309
+ });
310
+
311
+ pi.registerCommand("team-transcript", {
312
+ description: "Open a pi-crew transcript viewer: <runId> [taskId]",
313
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
314
+ const [runId, taskId] = args.trim().split(/\s+/).filter(Boolean);
315
+ if (await openTranscriptViewer(ctx, runId, taskId)) return;
316
+ const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-transcript", agentId: taskId } }, ctx);
317
+ await notifyCommandResult(ctx, commandText(result));
318
+ },
319
+ });
320
+
253
321
  pi.registerCommand("team-dashboard", {
254
322
  description: "Open a pi-crew run dashboard overlay",
255
323
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
@@ -261,6 +329,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
261
329
  });
262
330
  if (!selection) return;
263
331
  if (selection.action === "reload") continue;
332
+ if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
264
333
  const result = selection.action === "api"
265
334
  ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, ctx)
266
335
  : selection.action === "agents"
@@ -3,6 +3,16 @@ import type { TeamRunManifest } from "../state/types.ts";
3
3
  import { agentOutputPath, readCrewAgents } from "./crew-agent-records.ts";
4
4
  import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
5
5
 
6
+ const TOOL_LABELS: Record<string, string> = {
7
+ read: "reading",
8
+ bash: "running command",
9
+ edit: "editing",
10
+ write: "writing",
11
+ grep: "searching",
12
+ find: "finding files",
13
+ ls: "listing",
14
+ };
15
+
6
16
  export interface TextTailResult {
7
17
  path: string;
8
18
  text: string;
@@ -24,14 +34,29 @@ export function readTextTail(filePath: string, maxBytes = 64_000): TextTailResul
24
34
  }
25
35
  }
26
36
 
37
+ function compactDuration(ms: number | undefined): string | undefined {
38
+ if (ms === undefined || !Number.isFinite(ms)) return undefined;
39
+ if (ms < 1000) return `${Math.round(ms)}ms`;
40
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
41
+ return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
42
+ }
43
+
44
+ function ageBetween(start: string | undefined, end: string | undefined): string | undefined {
45
+ if (!start) return undefined;
46
+ const stop = end ? new Date(end).getTime() : Date.now();
47
+ const ms = Math.max(0, stop - new Date(start).getTime());
48
+ return compactDuration(ms);
49
+ }
50
+
27
51
  function activityText(agent: CrewAgentRecord): string {
28
52
  const parts: string[] = [];
29
53
  if (agent.progress?.activityState) parts.push(agent.progress.activityState);
30
- if (agent.progress?.currentTool) parts.push(`tool=${agent.progress.currentTool}`);
54
+ if (agent.progress?.currentTool) parts.push(TOOL_LABELS[agent.progress.currentTool] ?? `tool=${agent.progress.currentTool}`);
31
55
  if (agent.toolUses !== undefined) parts.push(`tools=${agent.toolUses}`);
32
56
  if (agent.progress?.tokens !== undefined) parts.push(`tokens=${agent.progress.tokens}`);
33
57
  if (agent.progress?.turns !== undefined) parts.push(`turns=${agent.progress.turns}`);
34
- if (agent.progress?.durationMs !== undefined) parts.push(`durationMs=${agent.progress.durationMs}`);
58
+ const duration = compactDuration(agent.progress?.durationMs) ?? ageBetween(agent.startedAt, agent.completedAt);
59
+ if (duration) parts.push(duration);
35
60
  if (agent.progress?.failedTool) parts.push(`failedTool=${agent.progress.failedTool}`);
36
61
  if (agent.progress?.recentOutput?.length) parts.push(`last=${agent.progress.recentOutput.at(-1)}`);
37
62
  return parts.join(" ") || "idle";
@@ -56,7 +81,7 @@ function outputWarning(agent: CrewAgentRecord): string {
56
81
  }
57
82
 
58
83
  function agentLine(agent: CrewAgentRecord): string {
59
- return `- ${statusGlyph(agent.status)} ${agent.taskId} [${agent.status}] ${agent.role}->${agent.agent} runtime=${agent.runtime} ${activityText(agent)}${outputWarning(agent)}${agent.error ? ` error=${agent.error}` : ""}`;
84
+ return `- ${statusGlyph(agent.status)} ${agent.taskId} ${agent.role} ${agent.agent} · ${agent.status} · ${agent.runtime} · ${activityText(agent)}${outputWarning(agent)}${agent.error ? ` · error=${agent.error}` : ""}`;
60
85
  }
61
86
 
62
87
  export function buildAgentDashboard(manifest: TeamRunManifest): { text: string; groups: Record<string, CrewAgentRecord[]> } {
@@ -68,7 +93,7 @@ export function buildAgentDashboard(manifest: TeamRunManifest): { text: string;
68
93
  };
69
94
  const lines = [
70
95
  `Crew agents for ${manifest.runId}`,
71
- `Run status: ${manifest.status}`,
96
+ `Run: ${manifest.status} · ${manifest.team}/${manifest.workflow ?? "none"} · agents=${agents.length}`,
72
97
  `Counts: running=${groups.running.length}, queued=${groups.queued.length}, recent=${groups.recent.length}`,
73
98
  "",
74
99
  "## Running",
@@ -0,0 +1,113 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { listRuns } from "../extension/run-index.ts";
3
+ import { isActiveRunStatus } from "../runtime/process-status.ts";
4
+ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
5
+ import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
6
+ import type { TeamRunManifest } from "../state/types.ts";
7
+
8
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+ const TOOL_LABELS: Record<string, string> = {
10
+ read: "reading",
11
+ bash: "running command",
12
+ edit: "editing",
13
+ write: "writing",
14
+ grep: "searching",
15
+ find: "finding files",
16
+ ls: "listing",
17
+ };
18
+
19
+ export interface CrewWidgetState {
20
+ frame: number;
21
+ interval?: ReturnType<typeof setInterval>;
22
+ }
23
+
24
+ function elapsed(iso: string | undefined, now = Date.now()): string | undefined {
25
+ if (!iso) return undefined;
26
+ const ms = Math.max(0, now - new Date(iso).getTime());
27
+ if (!Number.isFinite(ms)) return undefined;
28
+ if (ms < 1000) return "now";
29
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
30
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
31
+ return `${Math.floor(ms / 3_600_000)}h`;
32
+ }
33
+
34
+ function glyph(status: string, runningGlyph: string): string {
35
+ if (status === "running") return runningGlyph;
36
+ if (status === "queued") return "◦";
37
+ if (status === "completed") return "✓";
38
+ if (status === "failed") return "✗";
39
+ if (status === "cancelled" || status === "stopped") return "■";
40
+ return "·";
41
+ }
42
+
43
+ function agentActivity(agent: CrewAgentRecord): string {
44
+ if (agent.progress?.currentTool) return `${TOOL_LABELS[agent.progress.currentTool] ?? agent.progress.currentTool}…`;
45
+ const recent = agent.progress?.recentOutput?.at(-1);
46
+ if (recent) return recent.replace(/\s+/g, " ").trim();
47
+ if (agent.progress?.activityState === "needs_attention") return "needs attention";
48
+ if (agent.status === "queued") return "queued…";
49
+ if (agent.status === "running") return "thinking…";
50
+ if (agent.status === "failed") return agent.error ?? "failed";
51
+ return "done";
52
+ }
53
+
54
+ function agentStats(agent: CrewAgentRecord): string {
55
+ const parts: string[] = [];
56
+ if (agent.toolUses) parts.push(`${agent.toolUses} tools`);
57
+ if (agent.progress?.tokens) parts.push(`${agent.progress.tokens} tok`);
58
+ if (agent.progress?.turns) parts.push(`⟳${agent.progress.turns}`);
59
+ const age = elapsed(agent.completedAt ?? agent.startedAt);
60
+ if (age) parts.push(agent.completedAt ? age : `${age} ago`);
61
+ return parts.join(" · ");
62
+ }
63
+
64
+ function runStep(run: TeamRunManifest, agents: CrewAgentRecord[]): string {
65
+ const running = agents.find((agent) => agent.status === "running");
66
+ if (running) return running.taskId;
67
+ const queued = agents.find((agent) => agent.status === "queued");
68
+ if (queued) return queued.taskId;
69
+ return run.status;
70
+ }
71
+
72
+ export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
73
+ const runs = listRuns(cwd).slice(0, 20);
74
+ const activeRuns = runs.filter((run) => isActiveRunStatus(run.status));
75
+ const recentRuns = runs.filter((run) => !isActiveRunStatus(run.status)).slice(0, 3);
76
+ const shownRuns = [...activeRuns, ...recentRuns];
77
+ if (!shownRuns.length) return [];
78
+ const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
79
+ const lines: string[] = [];
80
+ const activeCount = activeRuns.length;
81
+ lines.push(`${activeCount ? "●" : "○"} pi-crew · active=${activeCount} recent=${recentRuns.length} · /team-dashboard`);
82
+ for (const run of shownRuns) {
83
+ let agents: CrewAgentRecord[] = [];
84
+ try { agents = readCrewAgents(run); } catch { agents = []; }
85
+ const counts = new Map<string, number>();
86
+ for (const agent of agents) counts.set(agent.status, (counts.get(agent.status) ?? 0) + 1);
87
+ const countText = [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(" ") || run.status;
88
+ lines.push(`${glyph(run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${runStep(run, agents)} · ${countText}`);
89
+ for (const agent of agents.filter((item) => item.status === "running" || item.status === "queued").slice(0, 2)) {
90
+ const stats = agentStats(agent);
91
+ lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
92
+ }
93
+ if (lines.length >= maxLines) break;
94
+ }
95
+ return lines.slice(0, maxLines);
96
+ }
97
+
98
+ export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState): void {
99
+ if (!ctx.hasUI) return;
100
+ state.frame += 1;
101
+ const lines = buildCrewWidgetLines(ctx.cwd, state.frame);
102
+ ctx.ui.setStatus("pi-crew", lines.length ? lines[0] : undefined);
103
+ ctx.ui.setWidget("pi-crew", lines.length ? lines : undefined, { placement: "aboveEditor" });
104
+ }
105
+
106
+ export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState): void {
107
+ if (state.interval) clearInterval(state.interval);
108
+ state.interval = undefined;
109
+ if (ctx?.hasUI) {
110
+ ctx.ui.setStatus("pi-crew", undefined);
111
+ ctx.ui.setWidget("pi-crew", undefined, { placement: "aboveEditor" });
112
+ }
113
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import type { TeamRunManifest } from "../state/types.ts";
3
3
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
4
4
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
5
+ import { isActiveRunStatus } from "../runtime/process-status.ts";
5
6
 
6
7
  interface DashboardComponent {
7
8
  invalidate(): void;
@@ -68,6 +69,16 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5): string[] {
68
69
  }
69
70
  }
70
71
 
72
+ function formatAge(iso: string | undefined): string | undefined {
73
+ if (!iso) return undefined;
74
+ const ms = Math.max(0, Date.now() - new Date(iso).getTime());
75
+ if (!Number.isFinite(ms)) return undefined;
76
+ if (ms < 1000) return "now";
77
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
78
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
79
+ return `${Math.floor(ms / 3_600_000)}h`;
80
+ }
81
+
71
82
  function agentPreviewLine(agent: CrewAgentRecord): string {
72
83
  const stats = [
73
84
  agent.progress?.activityState,
@@ -76,6 +87,7 @@ function agentPreviewLine(agent: CrewAgentRecord): string {
76
87
  agent.progress?.tokens !== undefined ? `${agent.progress.tokens} tok` : undefined,
77
88
  agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
78
89
  agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
90
+ agent.startedAt ? `age=${formatAge(agent.completedAt ?? agent.startedAt)}` : undefined,
79
91
  ].filter((part): part is string => Boolean(part));
80
92
  const recent = agent.progress?.recentOutput?.at(-1);
81
93
  return `Agent: ${statusIcon(agent.status)} ${agent.taskId} ${agent.role}->${agent.agent}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`;
@@ -92,6 +104,29 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5): string[] {
92
104
  }
93
105
  }
94
106
 
107
+ function runLabel(run: TeamRunManifest, selected: boolean): string {
108
+ let agents: CrewAgentRecord[] = [];
109
+ try { agents = readCrewAgents(run); } catch { agents = []; }
110
+ const running = agents.find((agent) => agent.status === "running");
111
+ const queued = agents.find((agent) => agent.status === "queued");
112
+ const step = running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
113
+ const marker = selected ? "›" : " ";
114
+ return `${marker} ${statusIcon(run.status)} ${run.runId.slice(-8)} ${run.status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
115
+ }
116
+
117
+ function groupedRuns(runs: TeamRunManifest[]): Array<{ label: string; run?: TeamRunManifest }> {
118
+ const active = runs.filter((run) => isActiveRunStatus(run.status));
119
+ const recent = runs.filter((run) => !isActiveRunStatus(run.status));
120
+ const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
121
+ if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
122
+ if (recent.length) rows.push({ label: "Recent" }, ...recent.map((run) => ({ label: run.runId, run })));
123
+ return rows;
124
+ }
125
+
126
+ function selectedRunFromGrouped(runs: TeamRunManifest[], selected: number): TeamRunManifest | undefined {
127
+ return groupedRuns(runs).filter((row) => row.run)[selected]?.run;
128
+ }
129
+
95
130
  function countByStatus(runs: TeamRunManifest[]): string {
96
131
  const counts = new Map<string, number>();
97
132
  for (const run of runs) counts.set(run.status, (counts.get(run.status) ?? 0) + 1);
@@ -124,13 +159,17 @@ export class RunDashboard implements DashboardComponent {
124
159
  if (this.runs.length === 0) {
125
160
  lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
126
161
  } else {
127
- for (let i = 0; i < Math.min(this.runs.length, 10); i++) {
128
- const run = this.runs[i]!;
129
- const marker = i === this.selected ? "›" : " ";
130
- const text = `${marker} ${statusIcon(run.status)} ${run.runId} ${run.status} ${run.team}/${run.workflow ?? "none"} ${run.goal}`;
131
- lines.push(`│ ${padVisible(truncate(text, innerWidth - 1), innerWidth - 1)}│`);
162
+ const rows = groupedRuns(this.runs).slice(0, 16);
163
+ const runRows = rows.filter((row) => row.run);
164
+ for (const row of rows) {
165
+ if (!row.run) {
166
+ lines.push(`│ ${padVisible(truncate(row.label, innerWidth - 1), innerWidth - 1)}│`);
167
+ continue;
168
+ }
169
+ const index = runRows.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
170
+ lines.push(`│ ${padVisible(truncate(runLabel(row.run, index === this.selected), innerWidth - 1), innerWidth - 1)}│`);
132
171
  }
133
- const selectedRun = this.runs[this.selected];
172
+ const selectedRun = selectedRunFromGrouped(this.runs, this.selected);
134
173
  if (selectedRun) {
135
174
  lines.push(`├${"─".repeat(borderWidth)}┤`);
136
175
  const details = [
@@ -157,47 +196,47 @@ export class RunDashboard implements DashboardComponent {
157
196
  return;
158
197
  }
159
198
  if (data === "\r" || data === "\n" || data === "s") {
160
- const runId = this.runs[this.selected]?.runId;
199
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
161
200
  this.done(runId ? { runId, action: "status" } : undefined);
162
201
  return;
163
202
  }
164
203
  if (data === "u") {
165
- const runId = this.runs[this.selected]?.runId;
204
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
166
205
  this.done(runId ? { runId, action: "summary" } : undefined);
167
206
  return;
168
207
  }
169
208
  if (data === "a") {
170
- const runId = this.runs[this.selected]?.runId;
209
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
171
210
  this.done(runId ? { runId, action: "artifacts" } : undefined);
172
211
  return;
173
212
  }
174
213
  if (data === "i") {
175
- const runId = this.runs[this.selected]?.runId;
214
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
176
215
  this.done(runId ? { runId, action: "api" } : undefined);
177
216
  return;
178
217
  }
179
218
  if (data === "d") {
180
- const runId = this.runs[this.selected]?.runId;
219
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
181
220
  this.done(runId ? { runId, action: "agents" } : undefined);
182
221
  return;
183
222
  }
184
223
  if (data === "e") {
185
- const runId = this.runs[this.selected]?.runId;
224
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
186
225
  this.done(runId ? { runId, action: "agent-events" } : undefined);
187
226
  return;
188
227
  }
189
228
  if (data === "o") {
190
- const runId = this.runs[this.selected]?.runId;
229
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
191
230
  this.done(runId ? { runId, action: "agent-output" } : undefined);
192
231
  return;
193
232
  }
194
233
  if (data === "v") {
195
- const runId = this.runs[this.selected]?.runId;
234
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
196
235
  this.done(runId ? { runId, action: "agent-transcript" } : undefined);
197
236
  return;
198
237
  }
199
238
  if (data === "r") {
200
- this.done({ runId: this.runs[this.selected]?.runId ?? "", action: "reload" });
239
+ this.done({ runId: selectedRunFromGrouped(this.runs, this.selected)?.runId ?? "", action: "reload" });
201
240
  return;
202
241
  }
203
242
  if (data === "p") {
@@ -209,7 +248,8 @@ export class RunDashboard implements DashboardComponent {
209
248
  return;
210
249
  }
211
250
  if (data === "j" || data === "\u001b[B") {
212
- this.selected = Math.min(Math.max(0, this.runs.length - 1), this.selected + 1);
251
+ const selectableCount = groupedRuns(this.runs).filter((row) => row.run).length;
252
+ this.selected = Math.min(Math.max(0, selectableCount - 1), this.selected + 1);
213
253
  }
214
254
  }
215
255
  }
@@ -0,0 +1,204 @@
1
+ import * as fs from "node:fs";
2
+ type Component = { invalidate(): void; render(width: number): string[]; handleInput(data: string): void };
3
+ type TranscriptTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
4
+ import type { TeamRunManifest } from "../state/types.ts";
5
+ import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
6
+
7
+ function visibleWidth(text: string): number {
8
+ return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").length;
9
+ }
10
+
11
+ function truncate(text: string, width: number): string {
12
+ if (width <= 0) return "";
13
+ if (visibleWidth(text) <= width) return text;
14
+ return width <= 1 ? "…" : `${text.slice(0, Math.max(0, width - 1))}…`;
15
+ }
16
+
17
+ function wrap(text: string, width: number): string[] {
18
+ const source = text.split(/\r?\n/);
19
+ const lines: string[] = [];
20
+ for (const raw of source) {
21
+ const line = raw || " ";
22
+ if (line.length <= width) {
23
+ lines.push(line);
24
+ continue;
25
+ }
26
+ for (let index = 0; index < line.length; index += width) lines.push(line.slice(index, index + width));
27
+ }
28
+ return lines;
29
+ }
30
+
31
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
32
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
33
+ }
34
+
35
+ function textFromContent(content: unknown): string {
36
+ if (typeof content === "string") return content;
37
+ if (!Array.isArray(content)) return "";
38
+ return content.map((part) => {
39
+ const obj = asRecord(part);
40
+ if (!obj) return "";
41
+ if (typeof obj.text === "string") return obj.text;
42
+ if (typeof obj.content === "string") return obj.content;
43
+ if (typeof obj.name === "string") return `[tool:${obj.name}]`;
44
+ return "";
45
+ }).filter(Boolean).join("\n");
46
+ }
47
+
48
+ export function formatTranscriptEvent(event: unknown): string[] {
49
+ const obj = asRecord(event);
50
+ if (!obj) return [String(event)];
51
+ const type = typeof obj.type === "string" ? obj.type : undefined;
52
+ const toolName = typeof obj.toolName === "string" ? obj.toolName : typeof obj.name === "string" ? obj.name : undefined;
53
+ if (type && /tool/i.test(type)) {
54
+ const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : typeof obj.result === "string" ? obj.result : "");
55
+ return [`[tool${toolName ? `:${toolName}` : ""} ${type}]: ${text.trim() || "(no output)"}`];
56
+ }
57
+ const message = asRecord(obj.message);
58
+ if (message) {
59
+ const role = typeof message.role === "string" ? message.role : "message";
60
+ const text = textFromContent(message.content);
61
+ if (text.trim()) return [`[${role}]: ${text.trim()}`];
62
+ }
63
+ if (type) {
64
+ const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : "");
65
+ return text.trim() ? [`[${type}]: ${text.trim()}`] : [`[${type}]`];
66
+ }
67
+ return [JSON.stringify(event)];
68
+ }
69
+
70
+ export function formatTranscriptText(text: string): string[] {
71
+ const lines: string[] = [];
72
+ for (const raw of text.split(/\r?\n/).filter(Boolean)) {
73
+ try {
74
+ lines.push(...formatTranscriptEvent(JSON.parse(raw)));
75
+ } catch {
76
+ lines.push(raw);
77
+ }
78
+ }
79
+ return lines.length ? lines : ["(no transcript content)"];
80
+ }
81
+
82
+ export function readRunTranscript(manifest: TeamRunManifest, taskId?: string): { title: string; path: string; lines: string[] } {
83
+ const agents = readCrewAgents(manifest);
84
+ const agent = taskId ? agents.find((item) => item.taskId === taskId || item.id === taskId) : agents.find((item) => item.transcriptPath) ?? agents[0];
85
+ const selectedTaskId = agent?.taskId ?? taskId ?? "unknown";
86
+ const transcriptPath = agent?.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(manifest, selectedTaskId);
87
+ const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
88
+ return { title: `${manifest.runId}:${selectedTaskId}`, path: transcriptPath, lines: formatTranscriptText(text) };
89
+ }
90
+
91
+ export class DurableTextViewer implements Component {
92
+ private scroll = 0;
93
+ private lastHeight = 10;
94
+ private title: string;
95
+ private subtitle: string;
96
+ private lines: string[];
97
+ private theme: unknown;
98
+ private done: (result: undefined) => void;
99
+
100
+ constructor(title: string, subtitle: string, lines: string[], theme: unknown, done: (result: undefined) => void) {
101
+ this.title = title;
102
+ this.subtitle = subtitle;
103
+ this.lines = lines.length ? lines : ["(empty)"];
104
+ this.theme = theme;
105
+ this.done = done;
106
+ }
107
+
108
+ invalidate(): void {}
109
+
110
+ handleInput(data: string): void {
111
+ if (data === "q" || data === "\u001b") {
112
+ this.done(undefined);
113
+ return;
114
+ }
115
+ const maxScroll = Math.max(0, this.lines.length - this.lastHeight);
116
+ if (data === "k" || data === "\u001b[A") this.scroll = Math.max(0, this.scroll - 1);
117
+ else if (data === "j" || data === "\u001b[B") this.scroll = Math.min(maxScroll, this.scroll + 1);
118
+ else if (data === "g") this.scroll = 0;
119
+ else if (data === "G") this.scroll = maxScroll;
120
+ }
121
+
122
+ render(width: number): string[] {
123
+ const th = this.theme as TranscriptTheme;
124
+ const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
125
+ const bold = th.bold?.bind(th) ?? ((text: string) => text);
126
+ const inner = Math.max(20, width - 4);
127
+ this.lastHeight = 16;
128
+ const body = this.lines.flatMap((line) => wrap(line, inner));
129
+ const maxScroll = Math.max(0, body.length - this.lastHeight);
130
+ this.scroll = Math.min(this.scroll, maxScroll);
131
+ const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
132
+ const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
133
+ const row = (text: string) => `${fg("border", "│")} ${truncate(pad(text), inner)} ${fg("border", "│")}`;
134
+ return [
135
+ fg("border", `╭${"─".repeat(inner + 2)}╮`),
136
+ row(`${bold(this.title)} ${fg("dim", this.subtitle)}`),
137
+ row(fg("dim", "j/k scroll · g/G top/bottom · q close")),
138
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
139
+ ...visible.map(row),
140
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
141
+ row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}%`)),
142
+ fg("border", `╰${"─".repeat(inner + 2)}╯`),
143
+ ];
144
+ }
145
+ }
146
+
147
+ export class DurableTranscriptViewer implements Component {
148
+ private scroll = 0;
149
+ private lastHeight = 10;
150
+ private manifest: TeamRunManifest;
151
+ private theme: unknown;
152
+ private done: (result: undefined) => void;
153
+ private taskId?: string;
154
+
155
+ constructor(manifest: TeamRunManifest, theme: unknown, done: (result: undefined) => void, taskId?: string) {
156
+ this.manifest = manifest;
157
+ this.theme = theme;
158
+ this.done = done;
159
+ this.taskId = taskId;
160
+ }
161
+
162
+ invalidate(): void {}
163
+
164
+ handleInput(data: string): void {
165
+ if (data === "q" || data === "\u001b") {
166
+ this.done(undefined);
167
+ return;
168
+ }
169
+ const content = readRunTranscript(this.manifest, this.taskId).lines;
170
+ const maxScroll = Math.max(0, content.length - this.lastHeight);
171
+ if (data === "k" || data === "\u001b[A") this.scroll = Math.max(0, this.scroll - 1);
172
+ else if (data === "j" || data === "\u001b[B") this.scroll = Math.min(maxScroll, this.scroll + 1);
173
+ else if (data === "g") this.scroll = 0;
174
+ else if (data === "G") this.scroll = maxScroll;
175
+ }
176
+
177
+ render(width: number): string[] {
178
+ const th = this.theme as TranscriptTheme;
179
+ const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
180
+ const bold = th.bold?.bind(th) ?? ((text: string) => text);
181
+ const inner = Math.max(20, width - 4);
182
+ const data = readRunTranscript(this.manifest, this.taskId);
183
+ const body = data.lines.flatMap((line) => wrap(line, inner));
184
+ this.lastHeight = 16;
185
+ const maxScroll = Math.max(0, body.length - this.lastHeight);
186
+ this.scroll = Math.min(this.scroll, maxScroll);
187
+ const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
188
+ const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
189
+ const row = (text: string) => `${fg("border", "│")} ${truncate(pad(text), inner)} ${fg("border", "│")}`;
190
+ const lines = [
191
+ fg("border", `╭${"─".repeat(inner + 2)}╮`),
192
+ row(`${bold("pi-crew transcript")} ${fg("dim", data.title)}`),
193
+ row(fg("dim", data.path)),
194
+ row(fg("dim", "j/k scroll · g/G top/bottom · q close")),
195
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
196
+ ...visible.map(row),
197
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
198
+ row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}%`)),
199
+ fg("border", `╰${"─".repeat(inner + 2)}╯`),
200
+ ];
201
+ return lines;
202
+ }
203
+ }
204
+