@fradser/pi-agent-teams 0.5.2 → 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,241 +1,259 @@
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
- import type { ExtensionUIContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
6
- import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionUIContext } 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 above the editor is DISPLAY-ONLY. ALL interaction happens in the
23
- // full-screen Team Console (/teammate), which owns input via ctx.ui.custom.
24
-
25
- const TEAM_COLORS = ["accent", "success", "warning", "error", "toolTitle", "mdLink"] as const;
26
- const PANEL_IDLE_COLLAPSE_MS = 30_000;
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";
27
23
 
28
- let panelRequestRender: (() => void) | undefined;
24
+ const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
29
25
  let spinnerTimer: ReturnType<typeof setInterval> | undefined;
30
26
  let spinnerFrame = 0;
31
- let panelLastActivity = 0;
32
- let panelCollapseTimer: ReturnType<typeof setTimeout> | undefined;
33
- let widgetRegistered = false;
34
27
 
35
- /** Stable per-node color (independent of row order). */
36
28
  function hashName(name: string): number {
37
29
  let h = 0;
38
- 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;
39
31
  return Math.abs(h);
40
32
  }
41
33
 
42
- interface PanelRow {
43
- key: string;
44
- }
45
-
46
- /** Console/widget rows contain node status and leader-report access. */
47
- function buildPanelRows(): PanelRow[] {
48
- return listNodes().map((node) => ({ key: node.workerKey }));
49
- }
50
-
51
- function runningTeammateLabel(node: Node): string {
52
- const frame = PI_SPINNER_FRAMES[spinnerFrame];
53
- const tool = node.spawn?.activeTool;
54
- const thinking = (node.spawn?.liveThinking ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
55
- const live = (node.spawn?.liveText ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
56
- // Like the memory package's activity line: show what the worker is doing
57
- // right now — current tool when one is executing, else its live reasoning,
58
- // else the latest assistant text.
59
- const detail = tool
60
- ? ` ${tool}`
61
- : thinking
62
- ? ` ${truncateToWidth(thinking, 48)}`
63
- : live
64
- ? ` ${truncateToWidth(live, 48)}`
65
- : " working...";
66
- return `${frame}${detail}`;
34
+ function colorFor(name: string): (typeof TEAM_COLORS)[number] {
35
+ return TEAM_COLORS[hashName(name) % TEAM_COLORS.length];
67
36
  }
68
37
 
69
38
  function ensureSpinner(): void {
70
- const running = listNodes().some((node) => node.status === "running");
71
- if (running && !spinnerTimer) {
39
+ const working = livingTeammates().some((t) => t.status === "working" || t.status === "starting");
40
+ if (working && !spinnerTimer) {
72
41
  spinnerTimer = setInterval(() => {
73
42
  spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
74
- panelRequestRender?.();
75
43
  }, PI_SPINNER_INTERVAL_MS);
76
44
  spinnerTimer.unref?.();
77
- } else if (!running && spinnerTimer) {
45
+ } else if (!working && spinnerTimer) {
78
46
  clearInterval(spinnerTimer);
79
47
  spinnerTimer = undefined;
80
48
  }
81
49
  }
82
50
 
83
- function isPanelCollapsed(): boolean {
84
- return Date.now() - panelLastActivity > PANEL_IDLE_COLLAPSE_MS;
85
- }
86
-
87
- function scheduleIdleCollapse(): void {
88
- if (panelCollapseTimer) clearTimeout(panelCollapseTimer);
89
- panelCollapseTimer = setTimeout(() => panelRequestRender?.(), PANEL_IDLE_COLLAPSE_MS);
90
- panelCollapseTimer.unref?.();
91
- }
92
-
93
51
  export function stopUiTimers(): void {
94
52
  if (spinnerTimer) {
95
53
  clearInterval(spinnerTimer);
96
54
  spinnerTimer = undefined;
97
55
  }
98
- if (panelCollapseTimer) {
99
- clearTimeout(panelCollapseTimer);
100
- panelCollapseTimer = undefined;
101
- }
102
56
  }
103
57
 
104
58
  export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
105
- if (widgetRegistered || !ctx?.ui?.setWidget) return;
59
+ if (!ctx?.ui?.setWidget) return;
106
60
  if (ctx.mode && ctx.mode !== "tui") return;
107
- try {
108
- ctx.ui.setWidget("teammate", (tui, theme) => {
109
- panelRequestRender = () => tui.requestRender();
110
- ensureSpinner();
111
- return {
112
- render: (width) => panelRows(theme, width),
113
- invalidate: () => {},
114
- dispose: () => {
115
- stopUiTimers();
116
- panelRequestRender = undefined;
117
- widgetRegistered = false;
118
- },
119
- };
120
- });
121
- widgetRegistered = true;
122
- } catch {
123
- // Best effort if widget registration is unavailable in the current mode.
61
+
62
+ if (livingTeammates().filter(isWorking).length === 0) {
63
+ ctx.ui.setWidget("teammate", undefined);
64
+ return;
124
65
  }
66
+
67
+ ctx.ui.setWidget("teammate", (tui, theme) => {
68
+ const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
69
+ timer.unref?.();
70
+ const style = createPiThemeStyle(theme);
71
+ return {
72
+ placement: "belowEditor",
73
+ render: (width: number) => {
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 [];
78
+ const lines: string[] = [];
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
+ ));
87
+ }
88
+ return lines;
89
+ },
90
+ invalidate: () => {},
91
+ dispose: () => clearInterval(timer),
92
+ };
93
+ });
94
+ }
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
+ : "";
125
107
  }
126
108
 
127
109
  export function refreshTeamUI(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
128
110
  ensureTeamWidget(ctx);
129
- panelLastActivity = Date.now();
130
- scheduleIdleCollapse();
131
111
  ensureLivePoll();
132
112
  ensureSpinner();
133
- panelRequestRender?.();
134
113
  }
135
114
 
136
- /** Passive widget rows (display only — no selection, no key handling). */
137
- function panelRows(theme: Theme, width?: number): string[] {
138
- const fg = (color: ThemeColor, s: string): string => theme.fg(color, s);
139
- const bold = (s: string): string => theme.bold(s);
140
- const fit = (line: string): string =>
141
- typeof width === "number" && width > 0 ? truncateToWidth(line, Math.max(10, width - 1)) : line;
142
- const running = listNodes().filter((node) => node.status === "running");
143
- // Stay out of the way until a teammate is actually working.
144
- if (running.length === 0) return [];
145
- if (isPanelCollapsed()) {
146
- return [` ${fit(fg("dim", `${running.length} teammate${running.length === 1 ? "" : "s"} working — /teammate`))}`];
147
- }
148
- const lines: string[] = [];
149
- for (const node of running) {
150
- const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
151
- lines.push(` ${fit(`${bold(fg(color, node.id))} ${fg("muted", `(${node.agent})`)} ${fg("warning", runningTeammateLabel(node))}`)}`);
152
- }
153
- lines.push(` ${fit(fg("dim", "/teammate — open console"))}`);
154
- return lines;
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]`;
155
118
  }
156
119
 
157
- /** Full content of a node as shown on its detail page: spawn lifecycle, live
158
- * worker text, captured output, result and error. */
159
- function buildNodeSection(node: Node): string[] {
160
- const lines: string[] = [`- [${node.id}] ${node.status}: ${node.agent}`];
161
- if (node.prompt) lines.push(` Task: ${cap(node.prompt, 2000)}`);
162
- lines.push(` Access: ${node.access} | Paths: ${node.paths.join(", ")}`);
163
- if (node.dependsOn.length > 0) lines.push(` Depends on: ${node.dependsOn.join(", ")}`);
164
-
165
- const spawn = node.spawn;
166
- if (spawn) {
167
- const stateLabel = spawn.status === "running" ? `running (pid ${spawn.pid})` : spawn.status;
168
- lines.push(` Spawn: ${stateLabel} | Isolation: ${spawn.isolation ?? "none"}`);
169
- if (spawn.startedAt) lines.push(` Started: ${new Date(spawn.startedAt).toLocaleString()}`);
170
- if (spawn.finishedAt) lines.push(` Finished: ${new Date(spawn.finishedAt).toLocaleString()}`);
171
- if (spawn.exitCode !== undefined) lines.push(` Exit code: ${spawn.exitCode}`);
172
- if (spawn.timedOut) lines.push(" Timed out: yes");
173
- if (spawn.usage) {
174
- const u = spawn.usage;
175
- lines.push(` Usage: ${u.totalTokens} tokens (in ${u.input} / out ${u.output}) | cost $${u.cost}`);
176
- }
177
- if (spawn.turns) lines.push(` Worker turns: ${spawn.turns}`);
178
- if (spawn.activeTool) lines.push(` Current tool: ${spawn.activeTool}`);
179
- if (spawn.error) lines.push(` Spawn error: ${spawn.error}`);
180
- if (spawn.status === "running") {
181
- lines.push(" --- Live worker activity ---");
182
- const activity = [
183
- ...(spawn.liveThinking?.trim() ? spawn.liveThinking.split("\n").map((l) => ` ${l}`) : []),
184
- ...(spawn.liveText?.trim() ? spawn.liveText.split("\n").map((l) => ` ${l}`) : []),
185
- ];
186
- lines.push(...(activity.length > 0 ? activity : [" Waiting for the worker's first response…"]));
187
- }
188
- if (spawn.stdout) {
189
- lines.push(" --- Worker output ---");
190
- 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;
191
148
  }
192
- if (spawn.stderr) {
193
- lines.push(" --- Worker stderr ---");
194
- 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
+ }
195
165
  }
196
166
  }
197
-
198
- if (node.result && node.result !== spawn?.stdout) lines.push(` Result: ${node.result}`);
199
- if (node.errorMessage) lines.push(` Error: ${node.errorMessage}`);
200
- return lines;
167
+ return lines.sort((a, b) => a.timestamp - b.timestamp);
201
168
  }
202
169
 
203
- /** Node detail: node section + reports this node sent to team-leader. */
204
- function buildNodeDetail(workerKey: string): string[] {
205
- const entry = getNodeByWorkerKey(workerKey);
206
- if (!entry) return ["(removed)"];
207
- const { run, node } = entry;
208
- const outgoing = getState().leaderMailbox.filter((message) => message.from === workerKey);
170
+ function indent(text: string): string[] {
171
+ return text.split("\n").map((line) => ` ${line}`);
172
+ }
209
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);
210
178
  const lines: string[] = [
211
- `${node.workerKey} (${node.agent}) [${node.status}] — run ${run.status}`,
212
- "",
213
- "== node ==",
214
- ...buildNodeSection(node),
179
+ `@${teammate.name} (${teammate.agent}) [${teammate.status}]`,
215
180
  "",
216
- `== reports to team-leader (${outgoing.length}) ==`,
217
- ...(outgoing.length === 0
218
- ? ["(none)"]
219
- : outgoing.flatMap((m) => {
220
- const time = new Date(m.timestamp).toLocaleString();
221
- return [
222
- `→ [${m.id}] ${m.subject} | ${time}`,
223
- m.runId ? ` run ${m.runId}` : "",
224
- m.body,
225
- "",
226
- ];
227
- })),
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()}`,
228
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
+ }
229
209
  return lines;
230
210
  }
231
211
 
232
- /** Full-screen Team Console — owns input via ctx.ui.custom, so ↑/↓ and Enter
233
- * 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. */
234
250
  export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void> {
235
251
  return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
252
+ let page: ConsolePage = "roster";
236
253
  let mode: "list" | "detail" = "list";
237
- let selected = 0;
238
- let detailKey = "";
254
+ let selectedRoster = 0;
255
+ let selectedBoard = 0;
256
+ let detailTitle = "";
239
257
  let offset = 0;
240
258
  let closed = false;
241
259
  let renderTimer: ReturnType<typeof setInterval> | undefined;
@@ -254,9 +272,14 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
254
272
  };
255
273
  startLiveRefresh();
256
274
 
257
- // btw-style callbacks (same accent/muted/dim/border/success/error language as pi-btw-fradser).
258
275
  const style = createPiThemeStyle(theme);
259
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
+
260
283
  const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
261
284
  const wrapped = wrapConsoleDetail(full, width);
262
285
  const viewport = maxConsoleBody(tui.terminal.rows);
@@ -267,44 +290,66 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
267
290
  };
268
291
  };
269
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
+
270
299
  const renderList = (width: number): string[] => {
271
- const rows = buildPanelRows();
272
- if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
300
+ const rows = currentRows();
273
301
  const border = style.border("─".repeat(Math.max(1, width)));
274
- const lines: string[] = [
275
- border,
276
- style.accent(truncateToWidth(`teammate ${listNodes().filter((n) => n.status === "running").length} working / ${listNodes().length} teammate(s)`, width)),
277
- "",
278
- ];
279
- for (let i = 0; i < rows.length; i++) {
280
- const row = rows[i];
281
- const marker = i === selected ? style.accent("❯ ") : " ";
282
- const entry = getNodeByWorkerKey(row.key);
283
- if (!entry) continue;
284
- const { node } = entry;
285
- const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
286
- const name = theme.bold(theme.fg(color, node.id));
287
- const role = style.muted(`(${node.agent})`);
288
- const status = node.status === "running"
289
- ? theme.fg("warning", runningTeammateLabel(node))
290
- : node.status === "completed"
291
- ? style.success("✓ completed")
292
- : node.status === "failed"
293
- ? style.error("✗ failed")
294
- : style.dim(`○ ${node.status}`);
295
- 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."));
296
307
  }
297
- 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);
298
337
  return lines.map((l) => truncateToWidth(l, Math.max(10, width - 1)));
299
338
  };
300
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
+
301
345
  const renderDetail = (width: number): string[] => {
302
346
  const border = style.border("─".repeat(Math.max(1, width)));
303
- const detail = windowLines(buildNodeDetail(detailKey), width);
347
+ const source = detailSource();
348
+ const detail = windowLines(source, width);
304
349
  const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
305
350
  const lines = [
306
351
  border,
307
- style.accent(truncateToWidth(`agent-teams ${detailKey}`, width)),
352
+ style.accent(truncateToWidth(`agent-teams ${detailTitle}`, width)),
308
353
  "",
309
354
  ...detail.lines.map((line) => ` ${line}`),
310
355
  "",
@@ -314,90 +359,104 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
314
359
  return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
315
360
  };
316
361
 
362
+ const detailSource = (): string[] =>
363
+ detailTitle.startsWith("[")
364
+ ? buildTaskDetail(detailTitle.slice(1, detailTitle.indexOf("]")))
365
+ : buildTeammateDetail(detailTitle.replace(/^@/, ""));
366
+
317
367
  return {
318
- render: (width) =>
319
- mode === "list" ? renderList(width) : renderDetail(width),
368
+ render: (width) => (mode === "list" ? renderList(width) : renderDetail(width)),
320
369
  handleInput: (data: string) => {
321
- // Detail mode: Esc returns to the list, q closes.
322
370
  if (mode !== "list") {
323
- if (matchesKey(data, Key.escape)) {
324
- mode = "list";
325
- offset = 0;
326
- return;
327
- }
328
- if (data === "q" || data === "Q") {
329
- closed = true;
330
- stopLiveRefresh();
331
- done();
332
- return;
333
- }
334
- const detail = wrapConsoleDetail(buildNodeDetail(detailKey), tui.terminal.columns);
335
- const viewport = maxConsoleBody(tui.terminal.rows);
336
- // Mouse-wheel scrolling (SGR wheel events, same as pi-btw-fradser).
337
- const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
338
- if (sgrWheel) {
339
- const button = Number.parseInt(sgrWheel[1], 10);
340
- if ((button & 64) !== 0) {
341
- const direction = button & 3;
342
- if (direction === 0) offset = scrollConsoleDetail(offset, -3, detail.length, viewport);
343
- else if (direction === 1) offset = scrollConsoleDetail(offset, 3, detail.length, viewport);
344
- }
345
- return;
346
- }
347
- if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, detail.length, viewport);
348
- else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, detail.length, viewport);
349
- else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), detail.length, viewport);
350
- else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), detail.length, viewport);
351
- else if (matchesKey(data, Key.home)) offset = 0;
352
- else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, detail.length, viewport);
371
+ handleDetailInput(data);
353
372
  return;
354
373
  }
355
-
356
- // List mode — the console owns input, so ↑/↓/Enter are safe here.
357
- const rows = buildPanelRows();
358
- if (matchesKey(data, Key.down)) selected = Math.min(selected + 1, rows.length - 1);
359
- 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);
360
381
  if (matchesKey(data, Key.enter)) {
361
- const row = rows[Math.min(selected, rows.length - 1)];
362
- if (row?.key) {
382
+ const selection = currentPageSelection(rows.length);
383
+ const row = rows[selection];
384
+ if (row) {
363
385
  mode = "detail";
364
- detailKey = row.key;
365
386
  offset = 0;
387
+ detailTitle = row.kind === "teammate" ? `@${row.key}` : `[${row.key}]`;
366
388
  }
367
389
  return;
368
390
  }
369
- if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
370
- closed = true;
371
- stopLiveRefresh();
372
- 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);
373
395
  return;
374
396
  }
375
- if (data === "x" || data === "X") {
376
- // Same node-cancel path as teammate_cancel nodeId: SIGTERM→SIGKILL,
377
- // dependents cancelled, the rest of the run continues.
378
- const row = rows[Math.min(selected, rows.length - 1)];
379
- const entry = row?.key ? getNodeByWorkerKey(row.key) : undefined;
380
- if (entry && (entry.node.status === "running" || entry.node.status === "pending")) {
381
- const runId = entry.run.id;
382
- const nodeId = entry.node.id;
383
- void cancelNodeAndTerminate(runId, nodeId, { ui: ctx.ui })
384
- .then((result) => {
385
- if (result.ok) ctx.ui.notify(`Cancelled node [${runId}/${nodeId}] — the rest of the run continues.`, "info");
386
- else ctx.ui.notify(result.error, "error");
387
- })
388
- .catch(() => false);
389
- }
397
+ if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
398
+ closeConsole(done);
390
399
  return;
391
400
  }
392
401
  },
393
- invalidate: () => {
394
- requestRender();
395
- },
402
+ invalidate: () => requestRender(),
396
403
  dispose: () => {
397
404
  closed = true;
398
405
  stopLiveRefresh();
399
406
  },
400
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
+ }
401
451
  });
402
452
  }
403
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
+ }