@fradser/pi-agent-teams 0.5.3 → 0.6.0

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/src/ui.ts CHANGED
@@ -1,69 +1,48 @@
1
- /** Passive teammate widget and interactive full-screen console. */
1
+ /** Passive team widget and interactive full-screen console (roster + board). */
2
2
 
3
3
  import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
4
4
  import { createPiThemeStyle, PI_SPINNER_FRAMES, PI_SPINNER_INTERVAL_MS } from "@fradser/pi-kit";
5
5
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
6
- import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
7
6
  import {
8
7
  clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
9
- } from "./console-viewport";
10
- import { getNodeByWorkerKey, getState, listNodes } from "./state";
11
- import { cancelNodeAndTerminate, ensureLivePoll } from "./run-machine";
12
- import type { Node } from "./types";
13
-
14
- function cap(text: string | undefined, maxBytes = DEFAULT_MAX_BYTES): string {
15
- if (!text) return "";
16
- if (text.length <= maxBytes) return text;
17
- const t = truncateTail(text, { maxLines: DEFAULT_MAX_LINES, maxBytes });
18
- return `${t.content}\n…[truncated ${text.length - t.content.length} chars]`;
19
- }
20
-
21
- // ── Team UI: passive widget + full-screen console ──────────────────
22
- // The widget below the editor is DISPLAY-ONLY. ALL interaction happens in the
23
- // full-screen Team Console (/teammate), which owns input via ctx.ui.custom.
8
+ } from "./console-viewport.ts";
9
+ import { fitTeammateRow, formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
10
+ import { getState, getTeammate, listTasks, listTeammates, livingTeammates } from "./state.ts";
11
+ import {
12
+ ensureLivePoll,
13
+ formatSilenceDuration,
14
+ runtimeDirPath,
15
+ shutdownTeammate,
16
+ STALL_NOTICE_MS,
17
+ stallSilenceMs,
18
+ } from "./team-machine.ts";
19
+ import type { Teammate } from "./types.ts";
20
+ import { inboxPath } from "./statefile.ts";
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
24
23
 
25
24
  const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
26
25
  let spinnerTimer: ReturnType<typeof setInterval> | undefined;
27
26
  let spinnerFrame = 0;
28
27
 
29
- /** Stable per-node color (independent of row order). */
30
28
  function hashName(name: string): number {
31
29
  let h = 0;
32
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
30
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(0) + i * 7) | 0;
33
31
  return Math.abs(h);
34
32
  }
35
33
 
36
- interface PanelRow {
37
- key: string;
38
- }
39
-
40
- /** Console/widget rows contain node status and leader-report access. */
41
- function buildPanelRows(): PanelRow[] {
42
- return listNodes().map((node) => ({ key: node.workerKey }));
43
- }
44
-
45
- function runningTeammateLabel(node: Node): string {
46
- const frame = PI_SPINNER_FRAMES[spinnerFrame];
47
- const tool = node.spawn?.activeTool;
48
- const thinking = (node.spawn?.liveThinking ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
49
- const live = (node.spawn?.liveText ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
50
- // Like the memory package's activity line: show what the worker is doing
51
- // right now — current tool when one is executing, else its live reasoning,
52
- // else the latest assistant text.
53
- const activity = tool
54
- ?? (thinking ? truncateToWidth(thinking, 48) : undefined)
55
- ?? (live ? truncateToWidth(live, 48) : "Working...");
56
- return `${frame} ${activity}`;
34
+ function colorFor(name: string): (typeof TEAM_COLORS)[number] {
35
+ return TEAM_COLORS[hashName(name) % TEAM_COLORS.length];
57
36
  }
58
37
 
59
38
  function ensureSpinner(): void {
60
- const running = listNodes().some((node) => node.status === "running");
61
- if (running && !spinnerTimer) {
39
+ const working = livingTeammates().some((t) => t.status === "working" || t.status === "starting");
40
+ if (working && !spinnerTimer) {
62
41
  spinnerTimer = setInterval(() => {
63
42
  spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
64
43
  }, PI_SPINNER_INTERVAL_MS);
65
44
  spinnerTimer.unref?.();
66
- } else if (!running && spinnerTimer) {
45
+ } else if (!working && spinnerTimer) {
67
46
  clearInterval(spinnerTimer);
68
47
  spinnerTimer = undefined;
69
48
  }
@@ -80,8 +59,7 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
80
59
  if (!ctx?.ui?.setWidget) return;
81
60
  if (ctx.mode && ctx.mode !== "tui") return;
82
61
 
83
- const running = listNodes().filter((node) => node.status === "running");
84
- if (running.length === 0) {
62
+ if (livingTeammates().filter(isWorking).length === 0) {
85
63
  ctx.ui.setWidget("teammate", undefined);
86
64
  return;
87
65
  }
@@ -90,37 +68,22 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
90
68
  const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
91
69
  timer.unref?.();
92
70
  const style = createPiThemeStyle(theme);
93
- const assignedColors = new Map<string, (typeof TEAM_COLORS)[number]>();
94
71
  return {
95
72
  placement: "belowEditor",
96
73
  render: (width: number) => {
97
- const running = listNodes().filter((n) => n.status === "running");
98
- if (running.length === 0) return [];
99
- const runningKeys = new Set(running.map((node) => node.workerKey));
100
- for (const key of assignedColors.keys()) {
101
- if (!runningKeys.has(key)) assignedColors.delete(key);
102
- }
103
- const usedColors = new Set(assignedColors.values());
104
- for (const node of running) {
105
- if (!assignedColors.has(node.workerKey)) {
106
- const start = hashName(node.workerKey) % TEAM_COLORS.length;
107
- const color = TEAM_COLORS.find((_, offset) => !usedColors.has(TEAM_COLORS[(start + offset) % TEAM_COLORS.length]));
108
- assignedColors.set(node.workerKey, color ?? TEAM_COLORS[start]);
109
- usedColors.add(color ?? TEAM_COLORS[start]);
110
- }
111
- }
74
+ // Only WORKING teammates appear above the input box; idle and
75
+ // stopped teammates stay in the /teammate console instead.
76
+ const working = livingTeammates().filter(isWorking);
77
+ if (working.length === 0) return [];
112
78
  const lines: string[] = [];
113
- for (const node of running) {
114
- const label = runningTeammateLabel(node);
115
- const color = assignedColors.get(node.workerKey) ?? TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
116
- const name = style.fg(color, node.id);
117
- const role = style.dim(`(${node.agent})`);
118
- const separator = label.indexOf(" ");
119
- const spinner = separator === -1 ? label : label.slice(0, separator);
120
- const activityText = separator === -1 ? "Working..." : label.slice(separator + 1).trim();
121
- const activity = theme.bold(style.fg("accent", activityText));
122
- const line = ` ${spinner} ${name} ${role} · ${activity}`;
123
- lines.push(truncateToWidth(line, Math.max(10, width - 1)));
79
+ for (const teammate of working) {
80
+ lines.push(fitTeammateRow(
81
+ PI_SPINNER_FRAMES[spinnerFrame],
82
+ style.fg(colorFor(teammate.name), teammate.name),
83
+ runningTeammateActivity(teammate) + stallSuffix(teammate),
84
+ width,
85
+ (activity) => theme.bold(style.fg("accent", activity)),
86
+ ));
124
87
  }
125
88
  return lines;
126
89
  },
@@ -130,95 +93,167 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
130
93
  });
131
94
  }
132
95
 
96
+ function isWorking(teammate: { status: string }): boolean {
97
+ return teammate.status === "working" || teammate.status === "starting";
98
+ }
99
+
100
+ /** Silence marker appended once a working teammate passes the stall notice threshold. */
101
+ function stallSuffix(teammate: Teammate): string {
102
+ if (!isWorking(teammate)) return "";
103
+ const silence = stallSilenceMs(teammate);
104
+ return silence !== undefined && STALL_NOTICE_MS > 0 && silence >= STALL_NOTICE_MS
105
+ ? ` · stalled for ${formatSilenceDuration(silence)}`
106
+ : "";
107
+ }
108
+
133
109
  export function refreshTeamUI(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
134
110
  ensureTeamWidget(ctx);
135
111
  ensureLivePoll();
136
112
  ensureSpinner();
137
113
  }
138
114
 
115
+ function cap(text: string | undefined, maxBytes = 2000): string {
116
+ if (!text) return "";
117
+ return text.length <= maxBytes ? text : `${text.slice(0, maxBytes)}\n…[truncated ${text.length - maxBytes} chars]`;
118
+ }
139
119
 
140
- /** Full content of a node as shown on its detail page: spawn lifecycle, live
141
- * worker text, captured output, result and error. */
142
- function buildNodeSection(node: Node): string[] {
143
- const lines: string[] = [`- [${node.id}] ${node.status}: ${node.agent}`];
144
- if (node.prompt) lines.push(` Task: ${cap(node.prompt, 2000)}`);
145
- lines.push(` Access: ${node.access} | Paths: ${node.paths.join(", ")}`);
146
- if (node.dependsOn.length > 0) lines.push(` Depends on: ${node.dependsOn.join(", ")}`);
147
-
148
- const spawn = node.spawn;
149
- if (spawn) {
150
- const stateLabel = spawn.status === "running" ? `running (pid ${spawn.pid})` : spawn.status;
151
- lines.push(` Spawn: ${stateLabel} | Isolation: ${spawn.isolation ?? "none"}`);
152
- if (spawn.startedAt) lines.push(` Started: ${new Date(spawn.startedAt).toLocaleString()}`);
153
- if (spawn.finishedAt) lines.push(` Finished: ${new Date(spawn.finishedAt).toLocaleString()}`);
154
- if (spawn.exitCode !== undefined) lines.push(` Exit code: ${spawn.exitCode}`);
155
- if (spawn.timedOut) lines.push(" Timed out: yes");
156
- if (spawn.usage) {
157
- const u = spawn.usage;
158
- lines.push(` Usage: ${u.totalTokens} tokens (in ${u.input} / out ${u.output}) | cost $${u.cost}`);
159
- }
160
- if (spawn.turns) lines.push(` Worker turns: ${spawn.turns}`);
161
- if (spawn.activeTool) lines.push(` Current tool: ${spawn.activeTool}`);
162
- if (spawn.error) lines.push(` Spawn error: ${spawn.error}`);
163
- if (spawn.status === "running") {
164
- lines.push(" --- Live worker activity ---");
165
- const activity = [
166
- ...(spawn.liveThinking?.trim() ? spawn.liveThinking.split("\n").map((l) => ` ${l}`) : []),
167
- ...(spawn.liveText?.trim() ? spawn.liveText.split("\n").map((l) => ` ${l}`) : []),
168
- ];
169
- lines.push(...(activity.length > 0 ? activity : [" Waiting for the worker's first response…"]));
170
- }
171
- if (spawn.stdout) {
172
- lines.push(" --- Worker output ---");
173
- lines.push(...spawn.stdout.split("\n"));
120
+ // ── Detail builders ───────────────────────────────────────────────
121
+
122
+ interface PeerMailLine {
123
+ direction: "sent" | "received";
124
+ counterpart: string;
125
+ subject: string;
126
+ body: string;
127
+ timestamp: number;
128
+ }
129
+
130
+ function readPeerMail(teammateName: string): PeerMailLine[] {
131
+ const runtimeDir = runtimeDirPath();
132
+ if (!runtimeDir) return [];
133
+ const mailDirPath = path.dirname(inboxPath(path.join(runtimeDir, "state.json"), teammateName));
134
+ let entries: string[] = [];
135
+ try {
136
+ entries = fs.readdirSync(mailDirPath).filter((name) => name.startsWith("inbox-") && name.endsWith(".jsonl"));
137
+ } catch {
138
+ return [];
139
+ }
140
+ const lines: PeerMailLine[] = [];
141
+ for (const entry of entries) {
142
+ const recipient = decodeURIComponent(entry.slice("inbox-".length, -".jsonl".length));
143
+ let raw = "";
144
+ try {
145
+ raw = fs.readFileSync(path.join(mailDirPath, entry), "utf-8");
146
+ } catch {
147
+ continue;
174
148
  }
175
- if (spawn.stderr) {
176
- lines.push(" --- Worker stderr ---");
177
- lines.push(...spawn.stderr.split("\n"));
149
+ for (const line of raw.split("\n")) {
150
+ if (!line.trim()) continue;
151
+ try {
152
+ const message = JSON.parse(line) as { id?: string; from?: string; subject?: string; body?: string; timestamp?: number };
153
+ if (!message.id || !message.from || !message.subject) continue;
154
+ if (message.from !== teammateName && recipient !== teammateName) continue;
155
+ lines.push({
156
+ direction: message.from === teammateName ? "sent" : "received",
157
+ counterpart: message.from === teammateName ? recipient : message.from,
158
+ subject: message.subject,
159
+ body: message.body ?? "",
160
+ timestamp: message.timestamp ?? 0,
161
+ });
162
+ } catch {
163
+ continue;
164
+ }
178
165
  }
179
166
  }
180
-
181
- if (node.result && node.result !== spawn?.stdout) lines.push(` Result: ${node.result}`);
182
- if (node.errorMessage) lines.push(` Error: ${node.errorMessage}`);
183
- return lines;
167
+ return lines.sort((a, b) => a.timestamp - b.timestamp);
184
168
  }
185
169
 
186
- /** Node detail: node section + reports this node sent to team-leader. */
187
- function buildNodeDetail(workerKey: string): string[] {
188
- const entry = getNodeByWorkerKey(workerKey);
189
- if (!entry) return ["(removed)"];
190
- const { run, node } = entry;
191
- const outgoing = getState().leaderMailbox.filter((message) => message.from === workerKey);
170
+ function indent(text: string): string[] {
171
+ return text.split("\n").map((line) => ` ${line}`);
172
+ }
192
173
 
174
+ function buildTeammateDetail(name: string): string[] {
175
+ const teammate = getTeammate(name);
176
+ if (!teammate) return ["(teammate removed from the roster)"];
177
+ const reports = getState().leaderMailbox.filter((message) => message.from === name);
193
178
  const lines: string[] = [
194
- `${node.workerKey} (${node.agent}) [${node.status}] — run ${run.status}`,
195
- "",
196
- "== node ==",
197
- ...buildNodeSection(node),
179
+ `@${teammate.name} (${teammate.agent}) [${teammate.status}]`,
198
180
  "",
199
- `== reports to team-leader (${outgoing.length}) ==`,
200
- ...(outgoing.length === 0
201
- ? ["(none)"]
202
- : outgoing.flatMap((m) => {
203
- const time = new Date(m.timestamp).toLocaleString();
204
- return [
205
- `→ [${m.id}] ${m.subject} | ${time}`,
206
- m.runId ? ` run ${m.runId}` : "",
207
- m.body,
208
- "",
209
- ];
210
- })),
181
+ "== teammate ==",
182
+ ` Status: ${teammate.status}${teammate.currentTaskId ? ` | Task: ${teammate.currentTaskId}` : ""}`,
183
+ ` Spawn: ${teammate.pid > 0 ? `pid ${teammate.pid}` : "pid unknown"} | Isolation: ${teammate.isolation}`,
184
+ ` Created: ${new Date(teammate.createdAt).toLocaleString()}`,
211
185
  ];
186
+ if (teammate.stoppedAt) lines.push(` Stopped: ${new Date(teammate.stoppedAt).toLocaleString()}`);
187
+ if (isWorking(teammate) && teammate.lastOutputAt !== undefined) {
188
+ const silence = stallSilenceMs(teammate);
189
+ if (silence !== undefined) {
190
+ lines.push(` Last output: ${new Date(teammate.lastOutputAt).toLocaleString()} (${formatSilenceDuration(silence)} ago${stallSuffix(teammate) ? " — stalled" : ""})`);
191
+ }
192
+ }
193
+ if (teammate.usage) lines.push(` Usage: ${teammate.usage.totalTokens} tokens | $${teammate.usage.cost.toFixed(4)}`);
194
+ if (teammate.error) lines.push(` Error: ${teammate.error}`);
195
+
196
+ lines.push("", `== reports to leader (${reports.length}) ==`);
197
+ if (reports.length === 0) lines.push(" (none)");
198
+ for (const report of reports) {
199
+ lines.push(` -> [${report.subject}] ${new Date(report.timestamp).toLocaleString()}`, ...indent(cap(report.body)), "");
200
+ }
201
+
202
+ const peer = readPeerMail(name);
203
+ lines.push(`== peer mail (${peer.length}) ==`);
204
+ if (peer.length === 0) lines.push(" (none)");
205
+ for (const mail of peer) {
206
+ const arrow = mail.direction === "sent" ? `to @${mail.counterpart}` : `from @${mail.counterpart}`;
207
+ lines.push(` ${arrow} [${mail.subject}] ${new Date(mail.timestamp).toLocaleString()}`, ...indent(cap(mail.body)), "");
208
+ }
212
209
  return lines;
213
210
  }
214
211
 
215
- /** Full-screen Team Console — owns input via ctx.ui.custom, so ↑/↓ and Enter
216
- * are safe in here and nothing is intercepted globally. Modes: list / detail. */
212
+ function buildTaskDetail(taskId: string): string[] {
213
+ const tasks = listTasks();
214
+ const task = tasks.find((candidate) => candidate.id === taskId);
215
+ if (!task) return ["(task not on the board)"];
216
+ const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
217
+ return [
218
+ `[${task.id}] ${task.subject}`,
219
+ "",
220
+ "== task ==",
221
+ ` Status: ${task.status}${task.claimedBy ? ` (@${task.claimedBy})` : ""}`,
222
+ ...(task.description ? [` Description:`, ...indent(cap(task.description))] : []),
223
+ ...(task.dependsOn.length > 0
224
+ ? [` Depends on: ${task.dependsOn.join(", ")} (${task.dependsOn.every((dep) => byId.get(dep)?.status === "completed") ? "met" : "unmet"})`]
225
+ : []),
226
+ ` Verify: ${task.verify ?? "(none)"}`,
227
+ ...(task.result ? [" Result:", ...indent(cap(task.result))] : []),
228
+ ...(task.errorMessage ? [" Error:", ...indent(cap(task.errorMessage))] : []),
229
+ ` Created: ${new Date(task.createdAt).toLocaleString()}`,
230
+ ...(task.completedAt ? [` Completed: ${new Date(task.completedAt).toLocaleString()}`] : []),
231
+ ];
232
+ }
233
+
234
+ // ── Full-screen console ───────────────────────────────────────────
235
+
236
+ type ConsolePage = "roster" | "board";
237
+
238
+ interface RosterRow {
239
+ key: string;
240
+ kind: "teammate";
241
+ }
242
+ interface BoardRow {
243
+ key: string;
244
+ kind: "task";
245
+ }
246
+ type ConsoleRow = RosterRow | BoardRow;
247
+
248
+ /** Full-screen Team Console — owns input via ctx.ui.custom. Pages: roster /
249
+ * board; each row opens a scrolling detail view. */
217
250
  export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void> {
218
251
  return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
252
+ let page: ConsolePage = "roster";
219
253
  let mode: "list" | "detail" = "list";
220
- let selected = 0;
221
- let detailKey = "";
254
+ let selectedRoster = 0;
255
+ let selectedBoard = 0;
256
+ let detailTitle = "";
222
257
  let offset = 0;
223
258
  let closed = false;
224
259
  let renderTimer: ReturnType<typeof setInterval> | undefined;
@@ -237,9 +272,14 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
237
272
  };
238
273
  startLiveRefresh();
239
274
 
240
- // btw-style callbacks (same accent/muted/dim/border/success/error language as pi-btw-fradser).
241
275
  const style = createPiThemeStyle(theme);
242
276
 
277
+ const currentRows = (): ConsoleRow[] =>
278
+ page === "roster"
279
+ // The console is the full roster: idle and stopped stay visible here.
280
+ ? listTeammates().map((t) => ({ key: t.name, kind: "teammate" as const }))
281
+ : listTasks().map((task) => ({ key: task.id, kind: "task" as const }));
282
+
243
283
  const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
244
284
  const wrapped = wrapConsoleDetail(full, width);
245
285
  const viewport = maxConsoleBody(tui.terminal.rows);
@@ -250,44 +290,66 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
250
290
  };
251
291
  };
252
292
 
293
+ const headerLine = (): string => {
294
+ const alive = livingTeammates();
295
+ const tasks = listTasks();
296
+ return `team ${alive.length} alive · board ${tasks.filter((task) => task.status === "pending").length}p/${tasks.filter((task) => task.status === "claimed").length}c/${tasks.filter((task) => task.status === "completed").length}d · ${page}`;
297
+ };
298
+
253
299
  const renderList = (width: number): string[] => {
254
- const rows = buildPanelRows();
255
- if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
300
+ const rows = currentRows();
256
301
  const border = style.border("─".repeat(Math.max(1, width)));
257
- const lines: string[] = [
258
- border,
259
- style.accent(truncateToWidth(`teammate ${listNodes().filter((n) => n.status === "running").length} working / ${listNodes().length} teammate(s)`, width)),
260
- "",
261
- ];
262
- for (let i = 0; i < rows.length; i++) {
263
- const row = rows[i];
264
- const marker = i === selected ? style.accent("❯ ") : " ";
265
- const entry = getNodeByWorkerKey(row.key);
266
- if (!entry) continue;
267
- const { node } = entry;
268
- const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
269
- const name = theme.bold(theme.fg(color, node.id));
270
- const role = style.muted(`(${node.agent})`);
271
- const status = node.status === "running"
272
- ? theme.fg("warning", runningTeammateLabel(node))
273
- : node.status === "completed"
274
- ? style.success("✓ completed")
275
- : node.status === "failed"
276
- ? style.error("✗ failed")
277
- : style.dim(`○ ${node.status}`);
278
- lines.push(`${marker}${name} ${role} ${status}`);
302
+ const lines = [border, style.accent(truncateToWidth(headerLine(), width)), ""];
303
+ if (rows.length === 0) {
304
+ lines.push(style.dim(page === "roster"
305
+ ? "No living teammates. Spawn one with teammate_spawn."
306
+ : "The task board is empty. Create tasks with task_create."));
279
307
  }
280
- lines.push("", style.dim("↑↓ select · enter open · esc/q close · x cancel"), border);
308
+ rows.forEach((row, index) => {
309
+ const marker = index === currentPageSelection(rows.length) ? style.accent("❯ ") : " ";
310
+ if (row.kind === "teammate") {
311
+ const teammate = getTeammate(row.key)!;
312
+ const name = theme.bold(theme.fg(colorFor(teammate.name), `@${teammate.name}`));
313
+ let status: string;
314
+ if (teammate.status === "working" || teammate.status === "starting") {
315
+ const prefix = `${marker}${name} `;
316
+ const available = Math.max(0, width - prefix.length - PI_SPINNER_FRAMES[spinnerFrame].length - 2);
317
+ status = theme.fg("warning", formatTeammateLabel(PI_SPINNER_FRAMES[spinnerFrame], runningTeammateActivity(teammate) + stallSuffix(teammate), available));
318
+ } else if (teammate.status === "idle") {
319
+ status = style.dim(`○ idle${teammate.currentTaskId ? ` · ${teammate.currentTaskId}` : ""}`);
320
+ } else {
321
+ status = theme.fg("warning", "■ stopped");
322
+ }
323
+ lines.push(`${marker}${name} ${status}`);
324
+ } else {
325
+ const task = listTasks().find((candidate) => candidate.id === row.key)!;
326
+ const label = theme.fg(colorFor(task.id), `[${task.id}]`);
327
+ const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
328
+ const statusText = task.status === "completed"
329
+ ? style.success("✓")
330
+ : task.status === "claimed"
331
+ ? theme.fg("warning", "◐ claimed")
332
+ : style.dim("○ pending");
333
+ lines.push(truncateToWidth(`${marker}${label} ${statusText} ${theme.fg("customMessageText", task.subject)}${holder}`, Math.max(10, width - 1)));
334
+ }
335
+ });
336
+ lines.push("", style.dim("↑↓ select · enter open · tab page · x shutdown · esc/q close"), border);
281
337
  return lines.map((l) => truncateToWidth(l, Math.max(10, width - 1)));
282
338
  };
283
339
 
340
+ const currentPageSelection = (rowCount: number): number => {
341
+ const selection = page === "roster" ? selectedRoster : selectedBoard;
342
+ return rowCount === 0 ? 0 : Math.min(selection, rowCount - 1);
343
+ };
344
+
284
345
  const renderDetail = (width: number): string[] => {
285
346
  const border = style.border("─".repeat(Math.max(1, width)));
286
- const detail = windowLines(buildNodeDetail(detailKey), width);
347
+ const source = detailSource();
348
+ const detail = windowLines(source, width);
287
349
  const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
288
350
  const lines = [
289
351
  border,
290
- style.accent(truncateToWidth(`agent-teams ${detailKey}`, width)),
352
+ style.accent(truncateToWidth(`agent-teams ${detailTitle}`, width)),
291
353
  "",
292
354
  ...detail.lines.map((line) => ` ${line}`),
293
355
  "",
@@ -297,90 +359,104 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
297
359
  return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
298
360
  };
299
361
 
362
+ const detailSource = (): string[] =>
363
+ detailTitle.startsWith("[")
364
+ ? buildTaskDetail(detailTitle.slice(1, detailTitle.indexOf("]")))
365
+ : buildTeammateDetail(detailTitle.replace(/^@/, ""));
366
+
300
367
  return {
301
- render: (width) =>
302
- mode === "list" ? renderList(width) : renderDetail(width),
368
+ render: (width) => (mode === "list" ? renderList(width) : renderDetail(width)),
303
369
  handleInput: (data: string) => {
304
- // Detail mode: Esc returns to the list, q closes.
305
370
  if (mode !== "list") {
306
- if (matchesKey(data, Key.escape)) {
307
- mode = "list";
308
- offset = 0;
309
- return;
310
- }
311
- if (data === "q" || data === "Q") {
312
- closed = true;
313
- stopLiveRefresh();
314
- done();
315
- return;
316
- }
317
- const detail = wrapConsoleDetail(buildNodeDetail(detailKey), tui.terminal.columns);
318
- const viewport = maxConsoleBody(tui.terminal.rows);
319
- // Mouse-wheel scrolling (SGR wheel events, same as pi-btw-fradser).
320
- const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
321
- if (sgrWheel) {
322
- const button = Number.parseInt(sgrWheel[1], 10);
323
- if ((button & 64) !== 0) {
324
- const direction = button & 3;
325
- if (direction === 0) offset = scrollConsoleDetail(offset, -3, detail.length, viewport);
326
- else if (direction === 1) offset = scrollConsoleDetail(offset, 3, detail.length, viewport);
327
- }
328
- return;
329
- }
330
- if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, detail.length, viewport);
331
- else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, detail.length, viewport);
332
- else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), detail.length, viewport);
333
- else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), detail.length, viewport);
334
- else if (matchesKey(data, Key.home)) offset = 0;
335
- else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, detail.length, viewport);
371
+ handleDetailInput(data);
336
372
  return;
337
373
  }
338
-
339
- // List mode — the console owns input, so ↑/↓/Enter are safe here.
340
- const rows = buildPanelRows();
341
- if (matchesKey(data, Key.down)) selected = Math.min(selected + 1, rows.length - 1);
342
- if (matchesKey(data, Key.up)) selected = Math.max(selected - 1, 0);
374
+ const rows = currentRows();
375
+ if (matchesKey(data, Key.tab)) {
376
+ page = page === "roster" ? "board" : "roster";
377
+ return;
378
+ }
379
+ if (matchesKey(data, Key.down)) bumpSelection(1, rows.length);
380
+ if (matchesKey(data, Key.up)) bumpSelection(-1, rows.length);
343
381
  if (matchesKey(data, Key.enter)) {
344
- const row = rows[Math.min(selected, rows.length - 1)];
345
- if (row?.key) {
382
+ const selection = currentPageSelection(rows.length);
383
+ const row = rows[selection];
384
+ if (row) {
346
385
  mode = "detail";
347
- detailKey = row.key;
348
386
  offset = 0;
387
+ detailTitle = row.kind === "teammate" ? `@${row.key}` : `[${row.key}]`;
349
388
  }
350
389
  return;
351
390
  }
352
- if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
353
- closed = true;
354
- stopLiveRefresh();
355
- done();
391
+ if (data === "x" || data === "X") {
392
+ const selection = currentPageSelection(rows.length);
393
+ const row = rows[selection];
394
+ if (page === "roster" && row?.kind === "teammate") void shutdownFromConsole(ctx, row.key);
356
395
  return;
357
396
  }
358
- if (data === "x" || data === "X") {
359
- // Same node-cancel path as teammate_cancel nodeId: SIGTERM→SIGKILL,
360
- // dependents cancelled, the rest of the run continues.
361
- const row = rows[Math.min(selected, rows.length - 1)];
362
- const entry = row?.key ? getNodeByWorkerKey(row.key) : undefined;
363
- if (entry && (entry.node.status === "running" || entry.node.status === "pending")) {
364
- const runId = entry.run.id;
365
- const nodeId = entry.node.id;
366
- void cancelNodeAndTerminate(runId, nodeId, { ui: ctx.ui })
367
- .then((result) => {
368
- if (result.ok) ctx.ui.notify(`Cancelled node [${runId}/${nodeId}] — the rest of the run continues.`, "info");
369
- else ctx.ui.notify(result.error, "error");
370
- })
371
- .catch(() => false);
372
- }
397
+ if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
398
+ closeConsole(done);
373
399
  return;
374
400
  }
375
401
  },
376
- invalidate: () => {
377
- requestRender();
378
- },
402
+ invalidate: () => requestRender(),
379
403
  dispose: () => {
380
404
  closed = true;
381
405
  stopLiveRefresh();
382
406
  },
383
407
  };
408
+
409
+ function closeConsole(doneFn: () => void): void {
410
+ closed = true;
411
+ stopLiveRefresh();
412
+ doneFn();
413
+ }
414
+
415
+ function bumpSelection(delta: number, rowCount: number): void {
416
+ if (rowCount === 0) return;
417
+ if (page === "roster") selectedRoster = clampIndex(selectedRoster + delta, rowCount);
418
+ else selectedBoard = clampIndex(selectedBoard + delta, rowCount);
419
+ }
420
+
421
+ function handleDetailInput(data: string): void {
422
+ const source = detailSource();
423
+ const viewport = maxConsoleBody(tui.terminal.rows);
424
+ if (matchesKey(data, Key.escape)) {
425
+ mode = "list";
426
+ offset = 0;
427
+ return;
428
+ }
429
+ if (data === "q" || data === "Q") {
430
+ closeConsole(done);
431
+ return;
432
+ }
433
+ const total = wrapConsoleDetail(source, tui.terminal.columns).length;
434
+ const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
435
+ if (sgrWheel) {
436
+ const button = Number.parseInt(sgrWheel[1], 10);
437
+ if ((button & 64) !== 0) {
438
+ const direction = button & 3;
439
+ if (direction === 0) offset = scrollConsoleDetail(offset, -3, total, viewport);
440
+ else if (direction === 1) offset = scrollConsoleDetail(offset, 3, total, viewport);
441
+ }
442
+ return;
443
+ }
444
+ if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, total, viewport);
445
+ else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, total, viewport);
446
+ else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), total, viewport);
447
+ else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), total, viewport);
448
+ else if (matchesKey(data, Key.home)) offset = 0;
449
+ else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, total, viewport);
450
+ }
384
451
  });
385
452
  }
386
453
 
454
+ async function shutdownFromConsole(ctx: { ui: ExtensionUIContext }, name: string): Promise<void> {
455
+ const result = await shutdownTeammate(name);
456
+ if (result.ok) ctx.ui.notify(result.body, "info");
457
+ else ctx.ui.notify(result.error, "error");
458
+ }
459
+
460
+ function clampIndex(index: number, rowCount: number): number {
461
+ return Math.max(0, Math.min(rowCount - 1, index));
462
+ }