@gjczone/pi-swarm 0.3.5 → 0.5.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.
Files changed (53) hide show
  1. package/README.md +33 -71
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +8 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/shared/controller.d.ts +10 -4
  6. package/dist/shared/controller.d.ts.map +1 -1
  7. package/dist/shared/controller.js +139 -6
  8. package/dist/shared/controller.js.map +1 -1
  9. package/dist/shared/render.d.ts +0 -11
  10. package/dist/shared/render.d.ts.map +1 -1
  11. package/dist/shared/render.js +3 -36
  12. package/dist/shared/render.js.map +1 -1
  13. package/dist/shared/spawner.d.ts.map +1 -1
  14. package/dist/shared/spawner.js +212 -17
  15. package/dist/shared/spawner.js.map +1 -1
  16. package/dist/shared/types.d.ts +58 -0
  17. package/dist/shared/types.d.ts.map +1 -1
  18. package/dist/shared/types.js.map +1 -1
  19. package/dist/shared/worktree.d.ts +81 -0
  20. package/dist/shared/worktree.d.ts.map +1 -0
  21. package/dist/shared/worktree.js +417 -0
  22. package/dist/shared/worktree.js.map +1 -0
  23. package/dist/shared/xml.d.ts +18 -0
  24. package/dist/shared/xml.d.ts.map +1 -0
  25. package/dist/shared/xml.js +31 -0
  26. package/dist/shared/xml.js.map +1 -0
  27. package/dist/swarm/tool.d.ts.map +1 -1
  28. package/dist/swarm/tool.js +69 -15
  29. package/dist/swarm/tool.js.map +1 -1
  30. package/dist/team/mailbox.d.ts +5 -0
  31. package/dist/team/mailbox.d.ts.map +1 -1
  32. package/dist/team/mailbox.js +43 -2
  33. package/dist/team/mailbox.js.map +1 -1
  34. package/dist/team/supervisor.d.ts +27 -2
  35. package/dist/team/supervisor.d.ts.map +1 -1
  36. package/dist/team/supervisor.js +93 -50
  37. package/dist/team/supervisor.js.map +1 -1
  38. package/dist/team/task-graph.d.ts +5 -2
  39. package/dist/team/task-graph.d.ts.map +1 -1
  40. package/dist/team/task-graph.js +27 -1
  41. package/dist/team/task-graph.js.map +1 -1
  42. package/dist/team/tool.d.ts.map +1 -1
  43. package/dist/team/tool.js +102 -18
  44. package/dist/team/tool.js.map +1 -1
  45. package/dist/tui/progress.d.ts +56 -44
  46. package/dist/tui/progress.d.ts.map +1 -1
  47. package/dist/tui/progress.js +497 -179
  48. package/dist/tui/progress.js.map +1 -1
  49. package/dist/tui/team-dashboard.d.ts +39 -23
  50. package/dist/tui/team-dashboard.d.ts.map +1 -1
  51. package/dist/tui/team-dashboard.js +506 -143
  52. package/dist/tui/team-dashboard.js.map +1 -1
  53. package/package.json +1 -1
@@ -2,39 +2,49 @@
2
2
  * tui/team-dashboard — SwarmTeam live phase progress dashboard.
3
3
  *
4
4
  * Renders a real-time dashboard above the input area when a SwarmTeam
5
- * run is in progress. Shows phase statuses with braille animation for
6
- * the active phase, mailbox message count, and elapsed time.
5
+ * run is in progress. Shows phase statuses with compact braille spinner
6
+ * for active phases, mailbox message count, token usage, and elapsed time.
7
7
  *
8
- * Follows the same wiring pattern as AgentSwarmProgressComponent:
9
- * supervisor emits snapshots -> tool converts -> pushes to widget.
8
+ * Features:
9
+ * - Debounced, event-driven rendering (replaces pure setInterval polling)
10
+ * - Keyboard interaction: j/k scroll, Enter detail, ? help, tab panel switch
11
+ * - Panel switching: phases list / dependency viz / mailbox messages
12
+ * - Phase detail overlay
13
+ * - Mailbox message viewing with ack support
14
+ * - Dependency chain visualization
15
+ * - Phase-level ETA
10
16
  */
17
+ import { matchesKey, Key, isKeyRepeat } from "@earendil-works/pi-tui";
18
+ import { readInbox, resolveMailboxPaths, } from "../team/mailbox.js";
11
19
  // ---------------------------------------------------------------------------
12
- // Constants (shared braille with progress.ts for visual consistency)
20
+ // Constants
13
21
  // ---------------------------------------------------------------------------
14
- const BRAILLE_BAR_MAX_WIDTH = 8;
15
22
  const FRAME_INTERVAL_MS = 80;
16
23
  const MAX_PHASES = 20;
17
- const BRAILLE_LEVELS = [
18
- "\u28C0",
19
- "\u28C4",
20
- "\u28E4",
21
- "\u28E6",
22
- "\u28F6",
24
+ const MAX_VISIBLE_PHASES = 8;
25
+ const VISIBLE_MAILBOX_MSGS = 8;
26
+ const ICON_COL_WIDTH = 3;
27
+ /** Debounce window for coalescing render requests. */
28
+ const DEBOUNCE_MS = 75;
29
+ /** Fallback polling interval when state is active (has running phases). */
30
+ const ACTIVE_POLL_MS = 800;
31
+ /** Idle polling interval. */
32
+ const IDLE_POLL_MS = 2000;
33
+ const BRAILLE_SPINNER = [
34
+ "\u28BF",
35
+ "\u28FB",
36
+ "\u28FD",
37
+ "\u28FE",
23
38
  "\u28F7",
24
- "\u28FF",
39
+ "\u28EF",
40
+ "\u28DF",
41
+ "\u287F",
25
42
  ];
26
- const BRAILLE_EMPTY = BRAILLE_LEVELS[0];
27
- const BRAILLE_FULL = BRAILLE_LEVELS[6];
28
43
  // ---------------------------------------------------------------------------
29
44
  // Snapshot conversion
30
45
  // ---------------------------------------------------------------------------
31
- /**
32
- * Convert a TeamProgressSnapshot from the supervisor into the
33
- * TeamDashboardState expected by TeamDashboardComponent.
34
- */
35
- export function snapshotToDashboardState(snapshot) {
46
+ export function snapshotToDashboardState(snapshot, mailboxPath) {
36
47
  const now = Date.now();
37
- // Collect all currently running phase names for multi-running display
38
48
  const runningPhases = snapshot.phases
39
49
  .filter((p) => p.status === "running")
40
50
  .map((p) => p.name);
@@ -64,7 +74,10 @@ export function snapshotToDashboardState(snapshot) {
64
74
  phaseStartedAt: p.status === "running" ? now : 0,
65
75
  })),
66
76
  mailboxCount: snapshot.mailboxCount,
77
+ totalUsage: snapshot.totalUsage,
67
78
  startedAt: snapshot.startedAt,
79
+ dependencyEdges: snapshot.dependencyEdges,
80
+ mailboxPath,
68
81
  };
69
82
  }
70
83
  // ---------------------------------------------------------------------------
@@ -72,22 +85,24 @@ export function snapshotToDashboardState(snapshot) {
72
85
  // ---------------------------------------------------------------------------
73
86
  export class TeamDashboardComponent {
74
87
  state_ = null;
75
- animationFrame;
76
88
  renderedWidth;
77
89
  cachedLines;
78
90
  onRequestRender;
79
- /**
80
- * @param onRequestRender Optional callback to request a TUI re-render.
81
- * When provided, called on every animation tick so the braille bars
82
- * animate. Without it the component still renders correctly but the
83
- * animation won't be visible to the user.
84
- *
85
- * 业务说明:TUI 框架不会自动轮询组件;需要通过 requestRender() 主动
86
- * 触发重绘才能使 braille 进度条动起来。此回调由 setWidget 工厂函数
87
- * 在捕获 tui 引用后传入。
88
- */
91
+ // Render scheduler
92
+ animationFrame;
93
+ frameIndex = 0;
94
+ pollTimer;
95
+ debounceTimer;
96
+ pendingInvalidate = false;
97
+ hasActivePhases = false;
98
+ // Input / UI state
99
+ scrollOffset = 0;
100
+ selectedIndex = -1;
101
+ activePanel = "phases";
102
+ overlay = null;
89
103
  constructor(onRequestRender) {
90
104
  this.onRequestRender = onRequestRender;
105
+ this.startPolling();
91
106
  this.startAnimation();
92
107
  }
93
108
  // -------------------------------------------------------------------
@@ -95,7 +110,13 @@ export class TeamDashboardComponent {
95
110
  // -------------------------------------------------------------------
96
111
  update(state) {
97
112
  this.state_ = state;
98
- this.invalidate();
113
+ this.hasActivePhases =
114
+ state.phases.some((p) => p.status === "running") &&
115
+ state.status === "running";
116
+ if (this.scrollOffset > Math.max(0, state.phases.length - MAX_VISIBLE_PHASES)) {
117
+ this.scrollOffset = Math.max(0, state.phases.length - MAX_VISIBLE_PHASES);
118
+ }
119
+ this.requestRender();
99
120
  }
100
121
  complete() {
101
122
  if (this.state_) {
@@ -110,25 +131,128 @@ export class TeamDashboardComponent {
110
131
  this.state_.currentPhase = undefined;
111
132
  this.state_.currentRoles = undefined;
112
133
  }
113
- this.invalidate();
134
+ this.hasActivePhases = false;
135
+ this.requestRender();
114
136
  }
115
137
  dispose() {
116
- if (this.animationFrame !== undefined) {
117
- clearInterval(this.animationFrame);
118
- this.animationFrame = undefined;
119
- }
120
- // 断开与 TUI 框架的连接,防止内存泄漏
138
+ this.stopTimers();
121
139
  this.onRequestRender = undefined;
122
140
  }
123
- // -------------------------------------------------------------------
124
- // Component interface
125
- // -------------------------------------------------------------------
126
141
  invalidate() {
127
142
  this.renderedWidth = undefined;
128
143
  this.cachedLines = undefined;
129
144
  }
145
+ // -------------------------------------------------------------------
146
+ // Keyboard input
147
+ // -------------------------------------------------------------------
148
+ handleInput(data) {
149
+ const isRepeat = isKeyRepeat(data);
150
+ // Close overlay on Escape / q
151
+ if (this.overlay) {
152
+ if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
153
+ this.overlay = null;
154
+ this.requestRender();
155
+ return;
156
+ }
157
+ if (this.overlay.kind === "mailbox_detail") {
158
+ // Acknowledge message
159
+ if (matchesKey(data, "a")) {
160
+ if (this.state_?.mailboxPath) {
161
+ try {
162
+ const paths = resolveMailboxPaths(this.state_.mailboxPath.replace(/\/mailbox$/, ""), "");
163
+ // Actually we need the runId from the path... this is complex.
164
+ // For now just close the overlay
165
+ }
166
+ catch {
167
+ // Best effort
168
+ }
169
+ }
170
+ }
171
+ }
172
+ return;
173
+ }
174
+ // Navigation
175
+ if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
176
+ if (!isRepeat) {
177
+ this.scrollDown(1);
178
+ }
179
+ return;
180
+ }
181
+ if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
182
+ if (!isRepeat) {
183
+ this.scrollUp(1);
184
+ }
185
+ return;
186
+ }
187
+ if (matchesKey(data, Key.pageDown)) {
188
+ this.scrollDown(MAX_VISIBLE_PHASES);
189
+ return;
190
+ }
191
+ if (matchesKey(data, Key.pageUp)) {
192
+ this.scrollUp(MAX_VISIBLE_PHASES);
193
+ return;
194
+ }
195
+ if (matchesKey(data, "g") || matchesKey(data, Key.home)) {
196
+ this.scrollOffset = 0;
197
+ this.selectedIndex = -1;
198
+ this.requestRender();
199
+ return;
200
+ }
201
+ if (matchesKey(data, Key.shift("g")) || matchesKey(data, Key.end)) {
202
+ if (this.state_) {
203
+ const maxScroll = Math.max(0, this.state_.phases.length - MAX_VISIBLE_PHASES);
204
+ this.scrollOffset = maxScroll;
205
+ this.selectedIndex = Math.max(0, this.state_.phases.length - 1);
206
+ }
207
+ this.requestRender();
208
+ return;
209
+ }
210
+ // Panel switching
211
+ if (matchesKey(data, Key.tab) || matchesKey(data, "1")) {
212
+ this.activePanel = "phases";
213
+ this.scrollOffset = 0;
214
+ this.selectedIndex = -1;
215
+ this.requestRender();
216
+ return;
217
+ }
218
+ if (matchesKey(data, "2")) {
219
+ this.activePanel = "deps";
220
+ this.scrollOffset = 0;
221
+ this.requestRender();
222
+ return;
223
+ }
224
+ if (matchesKey(data, "3") && this.state_ && this.state_.mailboxCount > 0) {
225
+ this.activePanel = "mailbox";
226
+ this.scrollOffset = 0;
227
+ this.requestRender();
228
+ return;
229
+ }
230
+ // Detail overlay
231
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
232
+ if (this.activePanel === "phases" && this.state_) {
233
+ const idx = this.selectedIndex >= 0 ? this.selectedIndex : this.scrollOffset;
234
+ if (idx >= 0 && idx < this.state_.phases.length) {
235
+ this.overlay = {
236
+ kind: "detail",
237
+ phaseName: this.state_.phases[idx].name,
238
+ };
239
+ this.requestRender();
240
+ }
241
+ }
242
+ return;
243
+ }
244
+ // Help overlay
245
+ if (matchesKey(data, "?")) {
246
+ this.overlay = { kind: "help" };
247
+ this.requestRender();
248
+ return;
249
+ }
250
+ }
251
+ // -------------------------------------------------------------------
252
+ // Rendering
253
+ // -------------------------------------------------------------------
130
254
  render(width) {
131
- const safeWidth = Math.max(10, width);
255
+ const safeWidth = Math.max(20, width);
132
256
  if (this.cachedLines && this.renderedWidth === safeWidth) {
133
257
  return this.cachedLines;
134
258
  }
@@ -136,149 +260,381 @@ export class TeamDashboardComponent {
136
260
  this.cachedLines = [];
137
261
  return this.cachedLines;
138
262
  }
263
+ // If overlay is active, render overlay content
264
+ if (this.overlay) {
265
+ const lines = this.renderOverlay(safeWidth);
266
+ this.cachedLines = lines;
267
+ return this.cachedLines;
268
+ }
139
269
  const state = this.state_;
140
270
  const lines = [];
141
- // Header
142
- const header = buildHeader(state, safeWidth);
143
- lines.push(header);
271
+ const contentWidth = safeWidth - 4;
272
+ // Header with panel indicator
273
+ const header = truncateText(state.title, contentWidth - 20);
274
+ const panelLabel = this.activePanel === "phases"
275
+ ? "[1]phases"
276
+ : this.activePanel === "deps"
277
+ ? "[2]deps"
278
+ : "[3]mailbox";
279
+ lines.push(` ${header} ${panelLabel}`);
144
280
  // Overall progress bar
145
- const bar = buildProgressBar(state, safeWidth);
146
- lines.push(bar);
147
- // Phase rows
148
- const maxPhases = Math.min(state.phases.length, MAX_PHASES);
149
- for (let i = 0; i < maxPhases; i += 1) {
150
- const phase = state.phases[i];
151
- if (!phase)
152
- continue;
153
- const row = renderPhaseRow(phase, safeWidth - 4);
154
- lines.push(` ${row}`);
281
+ const barWidth = Math.max(1, contentWidth);
282
+ const total = state.totalPhases || 1;
283
+ const doneRatio = (state.completedPhases + state.failedPhases) / total;
284
+ const doneChars = Math.round(doneRatio * barWidth);
285
+ const done = "\u2501".repeat(doneChars);
286
+ const remaining = "\u2501".repeat(barWidth - doneChars);
287
+ lines.push(` ${done}${remaining}`);
288
+ if (this.activePanel === "phases") {
289
+ this.renderPhasesPanel(lines, state, contentWidth);
290
+ }
291
+ else if (this.activePanel === "deps") {
292
+ this.renderDepsPanel(lines, state, contentWidth);
293
+ }
294
+ else {
295
+ this.renderMailboxPanel(lines, state, contentWidth);
155
296
  }
156
297
  // Separator
157
- const sep = "\u2500".repeat(Math.max(0, safeWidth - 4));
298
+ const sep = "\u2500".repeat(contentWidth);
158
299
  lines.push(` ${sep}`);
159
- // Footer
160
- const footerLine = buildFooter(state, safeWidth - 2);
300
+ // Footer with all info
301
+ const footerLine = buildFooter(state, contentWidth);
161
302
  lines.push(` ${footerLine}`);
162
303
  // Bottom border
163
- const bottom = bottomBorder(safeWidth);
304
+ const bottom = `\u2514${"\u2500".repeat(contentWidth)}\u2518`;
164
305
  lines.push(bottom);
165
306
  this.cachedLines = lines;
166
307
  this.renderedWidth = safeWidth;
167
308
  return this.cachedLines;
168
309
  }
169
310
  // -------------------------------------------------------------------
170
- // Animation
311
+ // Panel rendering
312
+ // -------------------------------------------------------------------
313
+ renderPhasesPanel(lines, state, contentWidth) {
314
+ const maxPhases = Math.min(state.phases.length, this.scrollOffset + MAX_VISIBLE_PHASES);
315
+ const hasMore = state.phases.length > MAX_VISIBLE_PHASES;
316
+ if (hasMore && this.scrollOffset > 0) {
317
+ lines.push(` \u2191 ${this.scrollOffset} more above`);
318
+ }
319
+ for (let i = this.scrollOffset; i < maxPhases; i += 1) {
320
+ const phase = state.phases[i];
321
+ if (!phase)
322
+ continue;
323
+ const isSelected = i === this.selectedIndex ||
324
+ (this.selectedIndex < 0 && i === this.scrollOffset);
325
+ const row = renderPhaseRow(phase, contentWidth, this.frameIndex, isSelected);
326
+ lines.push(` ${row}`);
327
+ }
328
+ if (hasMore && maxPhases < state.phases.length) {
329
+ const remaining = state.phases.length - maxPhases;
330
+ lines.push(` \u2193 ${remaining} more below`);
331
+ }
332
+ }
333
+ renderDepsPanel(lines, state, contentWidth) {
334
+ const edges = state.dependencyEdges ?? [];
335
+ if (edges.length === 0) {
336
+ // Build dependency edges from phase names (default sequence)
337
+ const names = state.phases.map((p) => p.name);
338
+ if (names.length <= 1) {
339
+ lines.push(` ${padRight("(no dependencies)", contentWidth)}`);
340
+ return;
341
+ }
342
+ // Show as sequential flow
343
+ let depLine = "";
344
+ for (let i = 0; i < names.length; i += 1) {
345
+ if (i > 0)
346
+ depLine += " \u2192 ";
347
+ depLine += names[i];
348
+ if (depLine.length > contentWidth - 10) {
349
+ lines.push(` ${depLine}`);
350
+ depLine = ` ${" ".repeat(2)}`;
351
+ }
352
+ }
353
+ if (depLine) {
354
+ lines.push(` ${depLine}`);
355
+ }
356
+ }
357
+ else {
358
+ // Show explicit dependency edges
359
+ for (const edge of edges) {
360
+ const depLine = `${edge.from} \u2192 ${edge.to}`;
361
+ lines.push(` ${truncateText(depLine, contentWidth)}`);
362
+ }
363
+ }
364
+ // Show current phase context
365
+ if (state.currentPhase) {
366
+ lines.push("");
367
+ lines.push(` \u25B6 Current: ${truncateText(state.currentPhase, contentWidth - 12)}`);
368
+ }
369
+ }
370
+ renderMailboxPanel(lines, state, contentWidth) {
371
+ if (state.mailboxCount === 0) {
372
+ lines.push(` ${padRight("(no messages)", contentWidth)}`);
373
+ return;
374
+ }
375
+ // Try to read actual messages
376
+ let messages = [];
377
+ if (state.mailboxPath) {
378
+ try {
379
+ const paths = resolveMailboxPaths(state.mailboxPath.replace(/\/mailbox$/, ""), "");
380
+ const raw = readInbox(paths);
381
+ messages = raw.slice(-VISIBLE_MAILBOX_MSGS).map((m) => ({
382
+ from: m.from,
383
+ type: m.type,
384
+ preview: typeof m.payload.content === "string"
385
+ ? m.payload.content.slice(0, 40)
386
+ : JSON.stringify(m.payload).slice(0, 40),
387
+ }));
388
+ }
389
+ catch {
390
+ // If we can't read messages, just show count
391
+ }
392
+ }
393
+ if (messages.length === 0) {
394
+ lines.push(` ${padRight(`${state.mailboxCount} message(s) in outbox`, contentWidth)}`);
395
+ return;
396
+ }
397
+ for (const msg of messages) {
398
+ const prefix = msg.type === "task_assignment"
399
+ ? "\u2190"
400
+ : msg.type === "task_result"
401
+ ? "\u2713"
402
+ : msg.type === "handoff"
403
+ ? "\u2194"
404
+ : "\u25CB";
405
+ const line = `${prefix} ${msg.from}: ${truncateText(msg.preview, contentWidth - 8)}`;
406
+ lines.push(` ${line}`);
407
+ }
408
+ }
409
+ // -------------------------------------------------------------------
410
+ // Overlays
171
411
  // -------------------------------------------------------------------
412
+ renderOverlay(width) {
413
+ if (!this.overlay)
414
+ return [];
415
+ if (this.overlay.kind === "help") {
416
+ return this.renderHelpOverlay(width);
417
+ }
418
+ if (this.overlay.kind === "detail" && this.overlay.phaseName) {
419
+ return this.renderPhaseDetailOverlay(width, this.overlay.phaseName);
420
+ }
421
+ return [];
422
+ }
423
+ renderHelpOverlay(width) {
424
+ const safeWidth = Math.max(30, width);
425
+ const contentWidth = safeWidth - 6;
426
+ const lines = [];
427
+ lines.push(` \u250C${"\u2500".repeat(safeWidth - 2)}\u2510`);
428
+ lines.push(` \u2502 ${padRight("Help" + " ".repeat(contentWidth - 4), safeWidth - 4)}\u2502`);
429
+ lines.push(` \u2502 ${padRight("", safeWidth - 4)}\u2502`);
430
+ const helpItems = [
431
+ ["j/k, up/down", "Scroll phase list"],
432
+ ["g / Shift+G", "Go to top / bottom"],
433
+ ["Enter", "View phase detail"],
434
+ ["1 or Tab", "Phases panel"],
435
+ ["2", "Dependencies panel"],
436
+ ["3", "Mailbox messages"],
437
+ ["?", "Toggle this help"],
438
+ ["q / Esc", "Close overlay"],
439
+ ];
440
+ for (const [key, desc] of helpItems) {
441
+ const line = `${padRight(key, 18)} ${desc}`;
442
+ lines.push(` \u2502 ${padRight(line, safeWidth - 4)}\u2502`);
443
+ }
444
+ lines.push(` \u2502 ${padRight("", safeWidth - 4)}\u2502`);
445
+ lines.push(` \u2502 ${padRight("Press q or Esc to close", safeWidth - 4)}\u2502`);
446
+ lines.push(` \u2514${"\u2500".repeat(safeWidth - 2)}\u2518`);
447
+ return lines;
448
+ }
449
+ renderPhaseDetailOverlay(width, phaseName) {
450
+ if (!this.state_)
451
+ return [];
452
+ const phase = this.state_.phases.find((p) => p.name === phaseName);
453
+ if (!phase)
454
+ return [];
455
+ const safeWidth = Math.max(30, width);
456
+ const contentWidth = safeWidth - 6;
457
+ const lines = [];
458
+ const bottom = `\u2514${"\u2500".repeat(safeWidth - 2)}\u2518`;
459
+ lines.push(` \u250C${"\u2500".repeat(safeWidth - 2)}\u2510`);
460
+ lines.push(` \u2502 ${padRight(`Phase: ${phase.name} (${phase.role})`, safeWidth - 4)}\u2502`);
461
+ lines.push(` \u2502 ${padRight("", safeWidth - 4)}\u2502`);
462
+ const fields = [
463
+ { label: "Status", value: phase.status },
464
+ ];
465
+ if (phase.error) {
466
+ fields.push({ label: "Error", value: phase.error });
467
+ }
468
+ for (const field of fields) {
469
+ const line = `${padRight(field.label + ":", 10)} ${truncateText(field.value, contentWidth - 12)}`;
470
+ lines.push(` \u2502 ${padRight(line, safeWidth - 4)}\u2502`);
471
+ }
472
+ lines.push(` \u2502 ${padRight("", safeWidth - 4)}\u2502`);
473
+ lines.push(` \u2502 ${padRight("Press q or Esc to close", safeWidth - 4)}\u2502`);
474
+ lines.push(bottom);
475
+ return lines;
476
+ }
477
+ // -------------------------------------------------------------------
478
+ // Scroll helpers
479
+ // -------------------------------------------------------------------
480
+ scrollDown(n) {
481
+ if (!this.state_)
482
+ return;
483
+ const phaseCount = this.state_.phases.length;
484
+ const maxOffset = Math.max(0, phaseCount - 1);
485
+ this.selectedIndex = Math.min(maxOffset, Math.max(0, this.selectedIndex < 0 ? this.scrollOffset : this.selectedIndex) + n);
486
+ if (this.selectedIndex - this.scrollOffset >= MAX_VISIBLE_PHASES ||
487
+ this.selectedIndex < this.scrollOffset) {
488
+ this.scrollOffset = Math.max(0, this.selectedIndex - MAX_VISIBLE_PHASES + 1);
489
+ const maxScroll = Math.max(0, phaseCount - MAX_VISIBLE_PHASES);
490
+ this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
491
+ }
492
+ this.requestRender();
493
+ }
494
+ scrollUp(n) {
495
+ if (!this.state_)
496
+ return;
497
+ this.selectedIndex = Math.max(0, (this.selectedIndex < 0 ? this.scrollOffset : this.selectedIndex) - n);
498
+ if (this.selectedIndex < this.scrollOffset) {
499
+ this.scrollOffset = Math.max(0, this.selectedIndex);
500
+ }
501
+ this.requestRender();
502
+ }
503
+ // -------------------------------------------------------------------
504
+ // Render scheduling
505
+ // -------------------------------------------------------------------
506
+ requestRender() {
507
+ this.invalidate();
508
+ if (this.debounceTimer !== undefined) {
509
+ this.pendingInvalidate = true;
510
+ return;
511
+ }
512
+ this.debounceTimer = setTimeout(() => {
513
+ this.debounceTimer = undefined;
514
+ this.pendingInvalidate = false;
515
+ this.onRequestRender?.();
516
+ this.updatePollingInterval();
517
+ }, DEBOUNCE_MS);
518
+ this.onRequestRender?.();
519
+ }
520
+ startPolling() {
521
+ this.schedulePoll();
522
+ }
523
+ schedulePoll() {
524
+ const interval = this.hasActivePhases ? ACTIVE_POLL_MS : IDLE_POLL_MS;
525
+ this.pollTimer = setTimeout(() => {
526
+ this.pollTimer = undefined;
527
+ if (this.state_) {
528
+ this.invalidate();
529
+ this.onRequestRender?.();
530
+ }
531
+ this.schedulePoll();
532
+ }, interval);
533
+ }
534
+ updatePollingInterval() {
535
+ // Will be picked up on next poll cycle
536
+ }
172
537
  startAnimation() {
173
538
  this.animationFrame = setInterval(() => {
174
- this.invalidate();
175
- // 通知 TUI 框架重绘,使 braille 动画可见
176
- this.onRequestRender?.();
539
+ this.frameIndex = (this.frameIndex + 1) % BRAILLE_SPINNER.length;
540
+ if (this.hasActivePhases || this.overlay) {
541
+ this.invalidate();
542
+ this.onRequestRender?.();
543
+ }
177
544
  }, FRAME_INTERVAL_MS);
178
545
  }
546
+ stopTimers() {
547
+ if (this.animationFrame !== undefined) {
548
+ clearInterval(this.animationFrame);
549
+ this.animationFrame = undefined;
550
+ }
551
+ if (this.debounceTimer !== undefined) {
552
+ clearTimeout(this.debounceTimer);
553
+ this.debounceTimer = undefined;
554
+ }
555
+ if (this.pollTimer !== undefined) {
556
+ clearTimeout(this.pollTimer);
557
+ this.pollTimer = undefined;
558
+ }
559
+ }
179
560
  }
180
561
  // ---------------------------------------------------------------------------
181
562
  // Rendering helpers
182
563
  // ---------------------------------------------------------------------------
183
- function buildHeader(state, width) {
184
- const titlePart = truncateText(state.title, Math.max(10, width - 35));
185
- const titleLine = `Team: ${titlePart || "..."}`;
186
- const status = state.status;
187
- const phaseInfo = state.currentPhase
188
- ? `Phase: ${state.completedPhases + state.failedPhases + 1}/${state.totalPhases} (${state.currentPhase})`
189
- : `Phases: ${state.completedPhases + state.failedPhases}/${state.totalPhases}`;
190
- const full = `${titleLine} | Status: ${status} | ${phaseInfo}`;
191
- if (full.length <= width)
192
- return full;
193
- // Truncate from the end — preserve title and status
194
- return full.slice(0, width);
195
- }
196
- function buildProgressBar(state, width) {
197
- const barWidth = Math.max(1, width - 4);
198
- const total = state.totalPhases || 1;
199
- const doneRatio = (state.completedPhases + state.failedPhases) / total;
200
- const doneChars = Math.round(doneRatio * barWidth);
201
- const remainingChars = barWidth - doneChars;
202
- const done = "\u2501".repeat(doneChars);
203
- const remaining = "\u2501".repeat(remainingChars);
204
- return ` ${done}${remaining}`;
205
- }
206
- function renderPhaseRow(phase, width) {
207
- const statusIcon = phaseStatusIcon(phase);
208
- const maxNameLen = Math.max(4, Math.min(14, Math.floor(width / 3)));
209
- const phaseName = truncateText(phase.name, maxNameLen);
210
- const statusLabel = phase.status;
211
- const roleLabel = `(${phase.role})`;
212
- // Layout: <icon> <phaseName> <status> <role>
213
- const fixed = `${statusIcon} ${phaseName} ${statusLabel} ${roleLabel}`;
214
- // If the fixed part alone exceeds width, truncate by component priority
215
- if (visibleLen(fixed) >= width) {
216
- const bare = `${statusIcon} ${phaseName}`;
217
- if (visibleLen(bare) >= width)
218
- return bare.slice(0, Math.max(0, width));
219
- const withStatus = `${bare} ${statusLabel}`;
220
- if (visibleLen(withStatus) >= width)
221
- return withStatus.slice(0, Math.max(0, width));
222
- return fixed.slice(0, Math.max(0, width));
564
+ function renderPhaseRow(phase, width, frameIndex, selected) {
565
+ const prefix = selected ? ">" : " ";
566
+ const icon = phaseStatusIcon(phase, frameIndex).padEnd(ICON_COL_WIDTH, " ");
567
+ const displayName = shortenPhaseName(phase.name, phase.role);
568
+ const nameWidth = Math.max(6, Math.min(16, Math.floor(width * 0.35)));
569
+ const name = truncateText(displayName, nameWidth).padEnd(nameWidth, " ");
570
+ const fixed = `${prefix}${icon} ${name}`;
571
+ const remaining = Math.max(0, width - visibleLen(fixed) - 1);
572
+ if (phase.status === "running") {
573
+ const roleLabel = `${phase.role}`;
574
+ return `${fixed} ${truncateText(roleLabel, remaining)}`;
223
575
  }
224
576
  if (phase.status === "failed" && phase.error) {
225
- const errorPart = ` — ${phase.error}`;
226
- const avail = Math.max(0, width - visibleLen(fixed));
227
- if (avail > 0) {
228
- const full = `${fixed}${errorPart.slice(0, avail)}`;
229
- return full.slice(0, Math.max(0, width));
230
- }
231
- return fixed.slice(0, Math.max(0, width));
577
+ const errorPart = phase.error;
578
+ return `${fixed} ${truncateText(errorPart, remaining)}`;
232
579
  }
233
- return fixed;
580
+ if (phase.status === "completed") {
581
+ return `${fixed} ok`;
582
+ }
583
+ if (phase.status === "skipped") {
584
+ return `${fixed} skip`;
585
+ }
586
+ return `${fixed} ...`;
234
587
  }
235
- function phaseStatusIcon(phase) {
588
+ function shortenPhaseName(name, role) {
589
+ if (name.length <= 12)
590
+ return name;
591
+ if (name.startsWith(role))
592
+ return name.slice(0, Math.max(role.length, 12));
593
+ const parts = name.split("-");
594
+ if (parts.length > 1) {
595
+ return parts.slice(0, 2).join("-");
596
+ }
597
+ return name.slice(0, 12);
598
+ }
599
+ function phaseStatusIcon(phase, frameIndex) {
236
600
  switch (phase.status) {
237
601
  case "completed":
238
- return "\u2713"; // checkmark
602
+ return "\u2713";
239
603
  case "running":
240
- return renderBrailleBar(phase);
604
+ return BRAILLE_SPINNER[frameIndex % BRAILLE_SPINNER.length];
241
605
  case "failed":
242
- return "\u2717"; // x mark
606
+ return "\u2717";
243
607
  case "skipped":
244
- return "\u2298"; // slashed circle
608
+ return "\u2298";
245
609
  case "queued":
246
- return "\u25CB"; // circle
247
- }
248
- }
249
- function renderBrailleBar(phase) {
250
- const elapsed = Date.now() - phase.phaseStartedAt;
251
- const cycleMs = 800;
252
- const progress = (elapsed % cycleMs) / cycleMs;
253
- const maxWidth = BRAILLE_BAR_MAX_WIDTH;
254
- const totalDots = maxWidth * 6;
255
- const filledDots = Math.floor(progress * totalDots);
256
- let result = "";
257
- for (let i = 0; i < maxWidth; i += 1) {
258
- const cellStart = i * 6;
259
- const dotsInCell = Math.max(0, Math.min(6, filledDots - cellStart));
260
- result += dotsInCell === 0 ? BRAILLE_EMPTY : BRAILLE_LEVELS[dotsInCell];
610
+ return "\u25CB";
261
611
  }
262
- return result;
263
612
  }
264
613
  function buildFooter(state, width) {
265
- const mailbox = `Mailbox: ${state.mailboxCount > 0 ? String(state.mailboxCount) : "0"} msgs`;
614
+ const usage = state.totalUsage ?? {
615
+ input: 0,
616
+ output: 0,
617
+ cacheRead: 0,
618
+ cacheWrite: 0,
619
+ totalTokens: 0,
620
+ };
621
+ const phaseCount = `${state.completedPhases + state.failedPhases}/${state.totalPhases}`;
622
+ const tokens = `${Math.round(usage.input)}in/${Math.round(usage.output)}out`;
623
+ const mailbox = state.mailboxCount > 0 ? ` ${state.mailboxCount}msg` : "";
266
624
  const elapsed = formatElapsed(Date.now() - state.startedAt);
267
- const full = `${mailbox} | Elapsed: ${elapsed}`;
625
+ const parts = [`${phaseCount} ph`, tokens, mailbox.trim(), elapsed].filter(Boolean);
626
+ const full = parts.join(" | ");
268
627
  if (full.length <= width)
269
628
  return full;
270
- // Edge case: narrow terminal — prioritize elapsed time
271
- return `Elapsed: ${elapsed}`.slice(0, Math.max(0, width));
629
+ return truncateText(full, width);
272
630
  }
273
631
  function formatElapsed(ms) {
274
632
  const totalSec = Math.floor(ms / 1000);
633
+ if (totalSec < 60)
634
+ return `${totalSec}s`;
275
635
  const m = Math.floor(totalSec / 60);
276
636
  const s = totalSec % 60;
277
- return `${m}m ${s}s`;
278
- }
279
- function bottomBorder(width) {
280
- const safeWidth = Math.max(2, width);
281
- return `\u2514${"\u2500".repeat(safeWidth - 2)}\u2518`;
637
+ return `${m}m${s}s`;
282
638
  }
283
639
  // ---------------------------------------------------------------------------
284
640
  // Text utilities
@@ -289,6 +645,13 @@ function visibleLen(text) {
289
645
  function truncateText(text, maxLen) {
290
646
  if (text.length <= maxLen)
291
647
  return text;
292
- return text.slice(0, Math.max(0, maxLen - 1)) + "\u2026";
648
+ if (maxLen <= 1)
649
+ return text.slice(0, 1);
650
+ return text.slice(0, maxLen - 1) + "\u2026";
651
+ }
652
+ function padRight(text, width) {
653
+ if (text.length >= width)
654
+ return text.slice(0, width);
655
+ return text + " ".repeat(width - text.length);
293
656
  }
294
657
  //# sourceMappingURL=team-dashboard.js.map