pi-crew 0.1.10 → 0.1.12

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.
@@ -1,8 +1,9 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { CrewUiConfig } from "../config/config.ts";
2
3
  import { listRuns } from "../extension/run-index.ts";
3
- import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
4
4
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
5
5
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
6
+ import { isDisplayActiveRun } from "../runtime/process-status.ts";
6
7
  import type { TeamRunManifest } from "../state/types.ts";
7
8
 
8
9
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -15,12 +16,48 @@ const TOOL_LABELS: Record<string, string> = {
15
16
  find: "finding files",
16
17
  ls: "listing",
17
18
  };
19
+ const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
20
+
21
+ type ThemeLike = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
22
+ type WidgetComponent = { render(width: number): string[]; invalidate(): void };
18
23
 
19
24
  export interface CrewWidgetState {
20
25
  frame: number;
21
26
  interval?: ReturnType<typeof setInterval>;
22
27
  }
23
28
 
29
+ interface WidgetRun {
30
+ run: TeamRunManifest;
31
+ agents: CrewAgentRecord[];
32
+ }
33
+
34
+ function visibleWidth(value: string): number {
35
+ return value.replace(ANSI_PATTERN, "").length;
36
+ }
37
+
38
+ function truncate(value: string, width: number): string {
39
+ if (width <= 0) return "";
40
+ if (visibleWidth(value) <= width) return value;
41
+ if (width <= 1) return "…";
42
+ let output = "";
43
+ let visible = 0;
44
+ for (let index = 0; index < value.length;) {
45
+ const slice = value.slice(index);
46
+ const ansi = slice.match(/^\u001b\[[0-?]*[ -/]*[@-~]/);
47
+ if (ansi?.[0]) {
48
+ output += ansi[0];
49
+ index += ansi[0].length;
50
+ continue;
51
+ }
52
+ const char = value[index]!;
53
+ if (visible >= width - 1) break;
54
+ output += char;
55
+ visible += 1;
56
+ index += char.length;
57
+ }
58
+ return `${output}\u001b[0m…`;
59
+ }
60
+
24
61
  function elapsed(iso: string | undefined, now = Date.now()): string | undefined {
25
62
  if (!iso) return undefined;
26
63
  const ms = Math.max(0, now - new Date(iso).getTime());
@@ -45,7 +82,7 @@ function agentActivity(agent: CrewAgentRecord): string {
45
82
  const recent = agent.progress?.recentOutput?.at(-1);
46
83
  if (recent) return recent.replace(/\s+/g, " ").trim();
47
84
  if (agent.progress?.activityState === "needs_attention") return "needs attention";
48
- if (agent.status === "queued") return "queued";
85
+ if (agent.status === "queued") return "queued";
49
86
  if (agent.status === "running") return "thinking…";
50
87
  if (agent.status === "failed") return agent.error ?? "failed";
51
88
  return "done";
@@ -61,59 +98,86 @@ function agentStats(agent: CrewAgentRecord): string {
61
98
  return parts.join(" · ");
62
99
  }
63
100
 
64
- function runStep(run: TeamRunManifest, agents: CrewAgentRecord[]): string {
65
- if (isLikelyOrphanedActiveRun(run, agents)) return "stale";
66
- const running = agents.find((agent) => agent.status === "running");
67
- if (running) return running.taskId;
68
- const queued = agents.find((agent) => agent.status === "queued");
69
- if (queued) return queued.taskId;
70
- return run.status;
71
- }
72
-
73
101
  function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
74
102
  try { return readCrewAgents(run); } catch { return []; }
75
103
  }
76
104
 
77
- export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
105
+ function activeWidgetRuns(cwd: string): WidgetRun[] {
78
106
  const runs = listRuns(cwd).slice(0, 20);
79
- const runAgents = new Map(runs.map((run) => [run.runId, agentsFor(run)]));
80
- const activeRuns = runs.filter((run) => isDisplayActiveRun(run, runAgents.get(run.runId) ?? []));
81
- const recentRuns = runs.filter((run) => !isDisplayActiveRun(run, runAgents.get(run.runId) ?? [])).slice(0, 3);
82
- const shownRuns = [...activeRuns, ...recentRuns];
83
- if (!shownRuns.length) return [];
107
+ return runs.map((run) => ({ run, agents: agentsFor(run) })).filter((item) => isDisplayActiveRun(item.run, item.agents));
108
+ }
109
+
110
+ function statusSummary(runs: WidgetRun[]): string {
111
+ const runningAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "running").length;
112
+ const queuedAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "queued").length;
113
+ return `● pi-crew · runs=${runs.length} agents=${runningAgents} running${queuedAgents ? ` · ${queuedAgents} queued` : ""} · /team-dashboard`;
114
+ }
115
+
116
+ export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
117
+ const runs = activeWidgetRuns(cwd);
118
+ if (!runs.length) return [];
84
119
  const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
85
- const lines: string[] = [];
86
- const activeCount = activeRuns.length;
87
- lines.push(`${activeCount ? "●" : ""} pi-crew · active=${activeCount} recent=${recentRuns.length} · /team-dashboard`);
88
- for (const run of shownRuns) {
89
- const agents = runAgents.get(run.runId) ?? [];
90
- const stale = isLikelyOrphanedActiveRun(run, agents);
91
- const counts = new Map<string, number>();
92
- for (const agent of agents) counts.set(agent.status, (counts.get(agent.status) ?? 0) + 1);
93
- const countText = stale ? "stale queued run" : [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(" ") || run.status;
94
- lines.push(`${glyph(stale ? "failed" : run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${runStep(run, agents)} · ${countText}`);
95
- for (const agent of (stale ? [] : agents.filter((item) => item.status === "running" || item.status === "queued").slice(0, 2))) {
120
+ const lines: string[] = [statusSummary(runs)];
121
+ for (const { run, agents } of runs) {
122
+ const running = agents.find((agent) => agent.status === "running");
123
+ const queued = agents.find((agent) => agent.status === "queued");
124
+ const step = running?.taskId ?? queued?.taskId ?? run.status;
125
+ const completed = agents.filter((agent) => agent.status === "completed").length;
126
+ lines.push(`${glyph(run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${step} · ${completed}/${agents.length} done`);
127
+ const activeAgents = agents.filter((item) => item.status === "running" || item.status === "queued");
128
+ for (const agent of activeAgents.slice(0, 3)) {
96
129
  const stats = agentStats(agent);
97
130
  lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
98
131
  }
132
+ if (activeAgents.length > 3) lines.push(` … +${activeAgents.length - 3} more agents`);
99
133
  if (lines.length >= maxLines) break;
100
134
  }
101
135
  return lines.slice(0, maxLines);
102
136
  }
103
137
 
104
- export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState): void {
138
+ class CrewWidgetComponent implements WidgetComponent {
139
+ private cwd: string;
140
+ private frame: number;
141
+ private maxLines: number;
142
+ private theme: ThemeLike;
143
+
144
+ constructor(cwd: string, frame: number, maxLines: number, theme: ThemeLike) {
145
+ this.cwd = cwd;
146
+ this.frame = frame;
147
+ this.maxLines = maxLines;
148
+ this.theme = theme;
149
+ }
150
+ invalidate(): void {}
151
+ render(width: number): string[] {
152
+ const fg = this.theme.fg?.bind(this.theme) ?? ((_color: string, text: string) => text);
153
+ const bold = this.theme.bold?.bind(this.theme) ?? ((text: string) => text);
154
+ return buildCrewWidgetLines(this.cwd, this.frame, this.maxLines).map((line, index) => {
155
+ const colored = index === 0
156
+ ? line.replace("● pi-crew", `${fg("accent", "●")} ${bold("pi-crew")}`)
157
+ : line.replace(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏▶◦✓✗■·])/, (match, icon: string) => match.replace(icon, fg(icon === "✓" ? "success" : icon === "✗" ? "error" : icon === "◦" ? "dim" : "accent", icon)));
158
+ return truncate(colored, width);
159
+ });
160
+ }
161
+ }
162
+
163
+ export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState, config?: CrewUiConfig): void {
105
164
  if (!ctx.hasUI) return;
106
165
  state.frame += 1;
107
- const lines = buildCrewWidgetLines(ctx.cwd, state.frame);
166
+ const maxLines = config?.widgetMaxLines ?? 8;
167
+ const lines = buildCrewWidgetLines(ctx.cwd, state.frame, maxLines);
108
168
  ctx.ui.setStatus("pi-crew", lines.length ? lines[0] : undefined);
109
- ctx.ui.setWidget("pi-crew", lines.length ? lines : undefined, { placement: "aboveEditor" });
169
+ if (!lines.length) {
170
+ ctx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
171
+ return;
172
+ }
173
+ ctx.ui.setWidget("pi-crew", ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(ctx.cwd, state.frame, maxLines, theme as ThemeLike)) as never, { placement: config?.widgetPlacement ?? "aboveEditor" });
110
174
  }
111
175
 
112
- export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState): void {
176
+ export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState, config?: CrewUiConfig): void {
113
177
  if (state.interval) clearInterval(state.interval);
114
178
  state.interval = undefined;
115
179
  if (ctx?.hasUI) {
116
180
  ctx.ui.setStatus("pi-crew", undefined);
117
- ctx.ui.setWidget("pi-crew", undefined, { placement: "aboveEditor" });
181
+ ctx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
118
182
  }
119
183
  }
@@ -0,0 +1,55 @@
1
+ import type { CrewUiConfig } from "../config/config.ts";
2
+ import { listRuns } from "../extension/run-index.ts";
3
+ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
4
+ import { isDisplayActiveRun } from "../runtime/process-status.ts";
5
+
6
+ type EventBus = { emit?: (event: string, data: unknown) => void } | undefined;
7
+
8
+ function safeEmit(events: EventBus, event: string, data: unknown): void {
9
+ try { events?.emit?.(event, data); } catch {}
10
+ }
11
+
12
+ export function registerPiCrewPowerbarSegments(events: EventBus, config?: CrewUiConfig): void {
13
+ if (config?.powerbar === false) return;
14
+ safeEmit(events, "powerbar:register-segment", { id: "pi-crew-active", label: "pi-crew active agents" });
15
+ safeEmit(events, "powerbar:register-segment", { id: "pi-crew-progress", label: "pi-crew run progress" });
16
+ }
17
+
18
+ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: CrewUiConfig): void {
19
+ if (config?.powerbar === false) return;
20
+ const active = listRuns(cwd).slice(0, 20).map((run) => {
21
+ let agents = [] as ReturnType<typeof readCrewAgents>;
22
+ try { agents = readCrewAgents(run); } catch {}
23
+ return { run, agents };
24
+ }).filter((item) => isDisplayActiveRun(item.run, item.agents));
25
+ if (!active.length) {
26
+ safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
27
+ safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
28
+ return;
29
+ }
30
+ const agents = active.flatMap((item) => item.agents);
31
+ const running = agents.filter((agent) => agent.status === "running").length;
32
+ const queued = agents.filter((agent) => agent.status === "queued").length;
33
+ const completed = agents.filter((agent) => agent.status === "completed").length;
34
+ const total = Math.max(1, agents.length);
35
+ safeEmit(events, "powerbar:update", {
36
+ id: "pi-crew-active",
37
+ icon: "⚙",
38
+ text: `crew ${running || active.length}`,
39
+ suffix: queued ? `${queued}q` : undefined,
40
+ color: running ? "accent" : "warning",
41
+ });
42
+ safeEmit(events, "powerbar:update", {
43
+ id: "pi-crew-progress",
44
+ text: active[0]?.run.team ?? "crew",
45
+ bar: Math.round((completed / total) * 100),
46
+ suffix: `${completed}/${total}`,
47
+ color: completed === total ? "success" : "accent",
48
+ barSegments: 8,
49
+ });
50
+ }
51
+
52
+ export function clearPiCrewPowerbar(events: EventBus): void {
53
+ safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
54
+ safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
55
+ }
@@ -10,6 +10,8 @@ interface DashboardComponent {
10
10
  handleInput(data: string): void;
11
11
  }
12
12
 
13
+ type DashboardTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
14
+
13
15
  export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "reload";
14
16
  export interface RunDashboardSelection {
15
17
  runId: string;
@@ -49,6 +51,14 @@ function padVisible(value: string, width: number): string {
49
51
  return `${value}${" ".repeat(Math.max(0, width - visibleLength(value)))}`;
50
52
  }
51
53
 
54
+ function colorForStatus(status: string): string {
55
+ if (status === "completed") return "success";
56
+ if (status === "failed" || status === "stale") return "error";
57
+ if (status === "cancelled" || status === "blocked") return "warning";
58
+ if (status === "running") return "accent";
59
+ return "dim";
60
+ }
61
+
52
62
  function statusIcon(status: string): string {
53
63
  if (status === "completed") return "✓";
54
64
  if (status === "failed" || status === "stale") return "✗";
@@ -143,23 +153,28 @@ export class RunDashboard implements DashboardComponent {
143
153
  private showFullProgress = false;
144
154
  private readonly runs: TeamRunManifest[];
145
155
  private readonly done: (selection: RunDashboardSelection | undefined) => void;
156
+ private readonly theme: DashboardTheme;
146
157
 
147
- constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void) {
158
+ constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void, theme: unknown = {}) {
148
159
  this.runs = runs;
149
160
  this.done = done;
161
+ this.theme = theme as DashboardTheme;
150
162
  }
151
163
 
152
164
  invalidate(): void {}
153
165
 
154
166
  render(width: number): string[] {
167
+ const fg = this.theme.fg?.bind(this.theme) ?? ((_color: string, text: string) => text);
168
+ const bold = this.theme.bold?.bind(this.theme) ?? ((text: string) => text);
155
169
  const innerWidth = Math.max(20, width - 4);
156
170
  const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
171
+ const border = (text: string) => fg("border", text);
157
172
  const lines = [
158
- `╭${"─".repeat(borderWidth)}╮`,
159
- `│ ${padVisible(truncate("pi-crew dashboard", innerWidth - 1), innerWidth - 1)}│`,
160
- `│ ${padVisible(truncate("↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close", innerWidth - 1), innerWidth - 1)}│`,
173
+ border(`╭${"─".repeat(borderWidth)}╮`),
174
+ `│ ${padVisible(truncate(`${fg("accent", "●")} ${bold("pi-crew dashboard")}`, innerWidth - 1), innerWidth - 1)}│`,
175
+ `│ ${padVisible(truncate(fg("dim", "↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close"), innerWidth - 1), innerWidth - 1)}│`,
161
176
  `│ ${padVisible(truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1), innerWidth - 1)}│`,
162
- `├${"─".repeat(borderWidth)}┤`,
177
+ border(`├${"─".repeat(borderWidth)}┤`),
163
178
  ];
164
179
  if (this.runs.length === 0) {
165
180
  lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
@@ -168,15 +183,17 @@ export class RunDashboard implements DashboardComponent {
168
183
  const runRows = rows.filter((row) => row.run);
169
184
  for (const row of rows) {
170
185
  if (!row.run) {
171
- lines.push(`│ ${padVisible(truncate(row.label, innerWidth - 1), innerWidth - 1)}│`);
186
+ lines.push(`│ ${padVisible(truncate(fg("accent", row.label), innerWidth - 1), innerWidth - 1)}│`);
172
187
  continue;
173
188
  }
174
189
  const index = runRows.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
175
- lines.push(`│ ${padVisible(truncate(runLabel(row.run, index === this.selected), innerWidth - 1), innerWidth - 1)}│`);
190
+ const label = runLabel(row.run, index === this.selected);
191
+ const status = isLikelyOrphanedActiveRun(row.run, agentsFor(row.run)) ? "stale" : row.run.status;
192
+ lines.push(`│ ${padVisible(truncate(fg(colorForStatus(status), label), innerWidth - 1), innerWidth - 1)}│`);
176
193
  }
177
194
  const selectedRun = selectedRunFromGrouped(this.runs, this.selected);
178
195
  if (selectedRun) {
179
- lines.push(`├${"─".repeat(borderWidth)}┤`);
196
+ lines.push(border(`├${"─".repeat(borderWidth)}┤`));
180
197
  const details = [
181
198
  `Selected: ${selectedRun.runId}`,
182
199
  `Status: ${selectedRun.status} | Team: ${selectedRun.team} | Workflow: ${selectedRun.workflow ?? "none"}`,
@@ -191,7 +208,7 @@ export class RunDashboard implements DashboardComponent {
191
208
  }
192
209
  }
193
210
  }
194
- lines.push(`╰${"─".repeat(borderWidth)}╯`);
211
+ lines.push(border(`╰${"─".repeat(borderWidth)}╯`));
195
212
  return lines.map((line) => truncate(line, width));
196
213
  }
197
214