pi-crew 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,8 @@ import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/proces
7
7
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
8
8
  import type { CrewTheme } from "./theme-adapter.ts";
9
9
  import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
10
- import { applyStatusColor, iconForStatus, type RunStatus } from "./status-colors.ts";
11
- import { pad, truncate, sanitizeLine } from "../utils/visual.ts";
10
+ import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus } from "./status-colors.ts";
11
+ import { pad, truncate, sanitizeLine, visibleWidth } from "../utils/visual.ts";
12
12
  import { Box, Text } from "./layout-primitives.ts";
13
13
  import { DynamicCrewBorder } from "./dynamic-border.ts";
14
14
  import { aggregateUsage } from "../state/usage.ts";
@@ -19,6 +19,8 @@ import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
19
19
  import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
20
20
  import { renderHealthPane } from "./dashboard-panes/health-pane.ts";
21
21
  import { renderMetricsPane } from "./dashboard-panes/metrics-pane.ts";
22
+ import { summarizeTerminalReason } from "./dashboard-panes/cancellation-pane.ts";
23
+ import { HelpOverlay } from "./overlays/help-overlay.ts";
22
24
  import { dashboardActionForKey } from "./keybinding-map.ts";
23
25
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
24
26
  import { spinnerBucket, spinnerFrame } from "./spinner.ts";
@@ -72,6 +74,41 @@ export interface RunDashboardSelection {
72
74
 
73
75
  const TASK_READ_TTL_MS = 1000;
74
76
 
77
+ /** Max run rows rendered in the dashboard run-list block (L-1 window budget). */
78
+ const RUN_LIST_MAX = 8;
79
+
80
+ /**
81
+ * F-4 — a live run's snapshot is considered "possibly stale" once its
82
+ * `fetchedAt` is this old. A progressing run rebuilds on every stamp change,
83
+ * so an old `fetchedAt` means the manifest read has been repeatedly flaky
84
+ * (the cache silently returned the previous entry).
85
+ */
86
+ const STALE_SNAPSHOT_MS = 15_000;
87
+
88
+ /** Left-pad `value` to a fixed VISIBLE width (ANSI-aware). Used by V-1. */
89
+ function padVis(value: string, width: number): string {
90
+ const current = visibleWidth(value);
91
+ return current >= width ? value : `${" ".repeat(width - current)}${value}`;
92
+ }
93
+
94
+ /**
95
+ * L-1 — compute the run-list window (visible slots + which scroll indicators
96
+ * render) for a given offset. Shared by `ensureRunListWindow()` and the
97
+ * render loop so they ALWAYS agree and the selection can never land off-screen.
98
+ */
99
+ interface RunListWindow {
100
+ slots: number;
101
+ hasTop: boolean;
102
+ hasBottom: boolean;
103
+ }
104
+ function runListWindow(scrollOffset: number, count: number): RunListWindow {
105
+ const hasTop = scrollOffset > 0;
106
+ const availNoBottom = Math.max(1, RUN_LIST_MAX - (hasTop ? 1 : 0));
107
+ const hasBottom = scrollOffset + availNoBottom < count;
108
+ const slots = Math.max(1, RUN_LIST_MAX - (hasTop ? 1 : 0) - (hasBottom ? 1 : 0));
109
+ return { slots, hasTop, hasBottom };
110
+ }
111
+
75
112
  function formatAge(iso: string | undefined): string | undefined {
76
113
  if (!iso) return undefined;
77
114
  const ms = Math.max(0, Date.now() - new Date(iso).getTime());
@@ -213,7 +250,7 @@ function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): Crew
213
250
  }
214
251
  }
215
252
 
216
- function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSnapshotCache): string {
253
+ function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSnapshotCache, maxW?: number): string {
217
254
  const agents = agentsFor(run, snapshotCache);
218
255
  const stale = isLikelyOrphanedActiveRun(run, agents);
219
256
  const running = agents.find((agent) => agent.status === "running");
@@ -221,7 +258,28 @@ function runLabel(run: TeamRunManifest, selected: boolean, snapshotCache?: RunSn
221
258
  const step = stale ? "orphaned queued run" : running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
222
259
  const status: RunStatus = stale ? "stale" : (run.status as RunStatus);
223
260
  const marker = selected ? "›" : " ";
224
- return sanitizeLine(`${marker} ${iconForStatus(status, { runningGlyph: spinnerFrame(run.runId) })} ${run.runId.slice(-8)} ${status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`);
261
+ const icon = iconForStatus(status, { runningGlyph: spinnerFrame(run.runId) });
262
+ // L-5: unified " · " separator (was "|").
263
+ // L-3: keep the GOAL (the human identifier) visible on narrow terminals —
264
+ // when the full line overflows, sacrifice the meta prefix (runId/status
265
+ // survive; team/workflow/step clip) rather than the goal. Optional `maxW`
266
+ // enables the goal-aware truncation; legacy 3-arg callers (dev patch
267
+ // scripts) get the untruncated full label.
268
+ const head = `${marker} ${icon} ${run.runId.slice(-8)} ${status}`;
269
+ const meta = `${run.team}/${run.workflow ?? "none"} · ${step}`;
270
+ const goal = sanitizeLine(run.goal ?? "");
271
+ if (maxW === undefined) return sanitizeLine(`${head} · ${meta} · ${goal}`);
272
+ const sepW = 3; // " · "
273
+ if (visibleWidth(head) + sepW + visibleWidth(meta) + sepW + visibleWidth(goal) <= maxW) {
274
+ return sanitizeLine(`${head} · ${meta} · ${goal}`);
275
+ }
276
+ // Not enough room: keep head + goal; clip the goal only if head+goal alone
277
+ // cannot fit, then shrink the prefix (meta clips first) to match.
278
+ const goalFits = visibleWidth(head) + sepW + visibleWidth(goal) <= maxW;
279
+ const goalRender = goalFits ? goal : truncate(goal, Math.max(8, maxW - visibleWidth(head) - sepW));
280
+ const prefixBudget = Math.max(0, maxW - visibleWidth(goalRender) - sepW);
281
+ const prefixRender = truncate(`${head} · ${meta}`, prefixBudget);
282
+ return sanitizeLine(`${prefixRender} · ${goalRender}`);
225
283
  }
226
284
 
227
285
  interface ResolvedRun {
@@ -266,7 +324,9 @@ function countByStatus(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache
266
324
 
267
325
  export class RunDashboard implements DashboardComponent {
268
326
  private selected = 0;
327
+ private runScrollOffset = 0;
269
328
  private showFullProgress = false;
329
+ private showHelp = false;
270
330
  private activePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" = lastActivePane;
271
331
  private runs: TeamRunManifest[];
272
332
  private readonly done: (selection: RunDashboardSelection | undefined) => void;
@@ -342,6 +402,30 @@ export class RunDashboard implements DashboardComponent {
342
402
  }
343
403
  }
344
404
 
405
+ /**
406
+ * L-1 — keep the run-list selection inside the rendered window so the `›`
407
+ * marker can never scroll off-screen (and Enter can never act on an
408
+ * invisible run). Uses the SAME `runListWindow()` the render loop uses and
409
+ * iterates to a fixed point, because the slot count depends on whether the
410
+ * ↑/↓ indicators render (which depends on the offset). This makes the
411
+ * window correct on the SAME render that processed the keypress — there is
412
+ * no one-frame lag where the marker is off-screen.
413
+ */
414
+ private ensureRunListWindow(selectableCount: number): void {
415
+ if (selectableCount <= RUN_LIST_MAX) {
416
+ this.runScrollOffset = 0;
417
+ return;
418
+ }
419
+ for (let i = 0; i < 4; i++) {
420
+ const { slots } = runListWindow(this.runScrollOffset, selectableCount);
421
+ const prev = this.runScrollOffset;
422
+ if (this.selected < this.runScrollOffset) this.runScrollOffset = this.selected;
423
+ else if (this.selected >= this.runScrollOffset + slots) this.runScrollOffset = this.selected - slots + 1;
424
+ this.runScrollOffset = Math.max(0, Math.min(this.runScrollOffset, Math.max(0, selectableCount - 1)));
425
+ if (this.runScrollOffset === prev) break;
426
+ }
427
+ }
428
+
345
429
  private buildSignature(): string {
346
430
  let hasRunning = false;
347
431
  const statuses = this.runs.map((run) => {
@@ -354,7 +438,7 @@ export class RunDashboard implements DashboardComponent {
354
438
  return snapshot?.signature ?? `${displayRun.runId}:${displayRun.status}:${displayRun.updatedAt}:${status}`;
355
439
  }).join("|");
356
440
  const metricsSig = this.activePane === "metrics" ? `:metrics=${this.options.registry?.snapshot().length ?? 0}:${spinnerBucket()}` : "";
357
- return `${this.selected}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}`;
441
+ return `${this.selected}:${this.showHelp ? 1 : 0}:${this.showFullProgress ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}`;
358
442
  }
359
443
 
360
444
  invalidate(): void {
@@ -376,7 +460,10 @@ export class RunDashboard implements DashboardComponent {
376
460
  return this.renderUnsafe(width);
377
461
  } catch (error) {
378
462
  logInternalError("run-dashboard.render", error);
379
- return renderLines(["Dashboard error — see logs for details."], width);
463
+ return renderLines([
464
+ "Dashboard error — see logs for details.",
465
+ "Press r to reload · Esc to close.",
466
+ ], width);
380
467
  }
381
468
  }
382
469
 
@@ -392,78 +479,119 @@ export class RunDashboard implements DashboardComponent {
392
479
  const row = (text: string) => `│ ${pad(truncate(text, innerWidth - 1), innerWidth - 1)}│`;
393
480
  const sep = () => border("├", "┤");
394
481
 
395
- const lines: string[] = [
396
- border("╭", "╮"),
397
- row(`${fg("accent", "▐")} ${this.theme.bold("pi-crew")} · ${this.runs.length} runs ${fg("dim", "1-6 pane · ↑↓ · Enter · Esc")}`),
398
- sep(),
399
- ];
400
-
401
- if (this.runs.length === 0) {
402
- lines.push(row(fg("dim", "No runs.")));
482
+ const lines: string[] = [];
483
+ if (this.showHelp) {
484
+ // K-1: help overlay replaces the dashboard body until dismissed.
485
+ lines.push(...new HelpOverlay(this.theme).render(width));
403
486
  } else {
404
- // Run list (max 8 lines)
405
- const rows = groupedRuns(this.runs, this.options.snapshotCache).slice(0, 8);
406
- const selectableRuns = rows.filter((r) => r.run);
407
- for (const r of rows) {
408
- if (!r.run) { lines.push(row(fg("dim", `── ${r.label} ──`))); continue; }
409
- const idx = selectableRuns.findIndex((c) => c.run?.runId === r.run?.runId);
410
- const snap = snapshotFor(r.run, this.options.snapshotCache);
411
- const run = snap?.manifest ?? r.run;
412
- const agents = snap?.agents ?? agentsFor(r.run, this.options.snapshotCache);
413
- const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
414
- const label = runLabel(run, idx === this.selected, this.options.snapshotCache);
415
- lines.push(row(applyStatusColor(this.theme, status, label)));
416
- }
417
-
418
- // Selected run detail — compact
419
- const selectedRun = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache);
420
- if (selectedRun) {
421
- const snap = snapshotFor(selectedRun, this.options.snapshotCache);
422
- const r = snap?.manifest ?? selectedRun;
423
- const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache);
424
- const statusStr = isLikelyOrphanedActiveRun(r, agents) ? "stale" : r.status;
425
- lines.push(sep());
426
- lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeLine(r.goal), innerWidth - 6)}`));
427
- lines.push(row(fg("dim", sanitizeLine(` ${r.team}/${r.workflow ?? "default"} · ${statusStr} · ${r.runId.slice(-10)}`))));
428
-
429
- // Pane content (max 8 lines)
430
- const paneLines = snap
431
- ? this.activePane === "agents" ? renderAgentsPane(snap, this.options)
432
- : this.activePane === "progress" ? renderProgressPane(snap)
433
- : this.activePane === "mailbox" ? renderMailboxPane(snap)
434
- : this.activePane === "health" ? renderHealthPane(snap, { isForeground: !r.async })
435
- : this.activePane === "metrics" ? renderMetricsPane(snap, { registry: this.options.registry })
436
- : renderTranscriptPane(snap)
437
- : [
438
- ...readAgentPreview(r, 4, this.options),
439
- ...readProgressPreview(r, 2),
440
- ];
441
- const filteredPane = paneLines.filter(l => l && !l.includes("(none)") && l.trim() !== "");
442
- if (filteredPane.length > 0) {
443
- lines.push(row(fg("dim", `── ${this.activePane} ──`)));
444
- for (const line of filteredPane.slice(0, 8)) {
445
- lines.push(row(truncate(sanitizeLine(line), innerWidth - 2)));
487
+ lines.push(
488
+ border("╭", "╮"),
489
+ row(`${fg("accent", "▐")} ${this.theme.bold("pi-crew")} · ${this.runs.length} runs ${fg("dim", "1-6 pane · ↑↓ · Enter · ? help · Esc")}`),
490
+ sep(),
491
+ );
492
+
493
+ if (this.runs.length === 0) {
494
+ // F-7: actionable empty state instead of a bare "No runs.".
495
+ lines.push(row(fg("dim", "No runs yet.")));
496
+ lines.push(row(fg("dim", "Start one: team action='run' · r reload · Esc close")));
497
+ } else {
498
+ // L-1: windowed run list so the selection can never scroll off-screen.
499
+ const allGrouped = groupedRuns(this.runs, this.options.snapshotCache);
500
+ const selectable = allGrouped.filter((rowItem) => rowItem.run);
501
+ const selectableCount = selectable.length;
502
+ if (this.selected > selectableCount - 1) this.selected = Math.max(0, selectableCount - 1);
503
+ this.ensureRunListWindow(selectableCount);
504
+ if (selectableCount <= RUN_LIST_MAX) {
505
+ // Common case (≤8 runs): keep the Active/Recent group headers.
506
+ for (const rowItem of allGrouped) {
507
+ if (!rowItem.run) { lines.push(row(fg("dim", `── ${rowItem.label} ──`))); continue; }
508
+ const idx = selectable.findIndex((c) => c.run?.runId === rowItem.run?.runId);
509
+ const snap = snapshotFor(rowItem.run, this.options.snapshotCache);
510
+ const run = snap?.manifest ?? rowItem.run;
511
+ const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache);
512
+ const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
513
+ const label = runLabel(run, idx === this.selected, this.options.snapshotCache, innerWidth - 2);
514
+ lines.push(row(applyStatusColor(this.theme, status, label)));
446
515
  }
516
+ } else {
517
+ // >8 runs: windowed list (group headers omitted to maximise run rows).
518
+ const win = runListWindow(this.runScrollOffset, selectableCount);
519
+ if (win.hasTop) lines.push(row(fg("dim", `↑ ${this.runScrollOffset} more above`)));
520
+ for (let gi = this.runScrollOffset; gi < Math.min(this.runScrollOffset + win.slots, selectableCount); gi++) {
521
+ const rowItem = selectable[gi];
522
+ if (!rowItem?.run) continue;
523
+ const snap = snapshotFor(rowItem.run, this.options.snapshotCache);
524
+ const run = snap?.manifest ?? rowItem.run;
525
+ const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache);
526
+ const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus);
527
+ const label = runLabel(run, gi === this.selected, this.options.snapshotCache, innerWidth - 2);
528
+ lines.push(row(applyStatusColor(this.theme, status, label)));
529
+ }
530
+ if (win.hasBottom) lines.push(row(fg("dim", `↓ ${selectableCount - (this.runScrollOffset + win.slots)} more below`)));
447
531
  }
448
532
 
449
- // One-line footer
450
- const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache);
451
- const usage = aggregateUsage(selectedTasks);
452
- const u = usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
453
- const tok = (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
454
- const tokStr = tok > 0 ? (tok >= 1000 ? `${(tok/1000).toFixed(1)}k tok` : `${tok} tok`) : "";
455
- let ctxPct: number | undefined;
456
- for (const agent of agents) {
457
- if (agent.status === "running" && agent.runtime === "live-session") {
458
- const pct = getLiveAgentContextPercent(agent.taskId);
459
- if (pct != null) { ctxPct = pct; break; }
533
+ // Selected run detail — compact
534
+ const selectedRun = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache);
535
+ if (selectedRun) {
536
+ const snap = snapshotFor(selectedRun, this.options.snapshotCache);
537
+ const r = snap?.manifest ?? selectedRun;
538
+ const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache);
539
+ const statusStr: RunStatus = isLikelyOrphanedActiveRun(r, agents) ? "stale" : (r.status as RunStatus);
540
+ const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache);
541
+ lines.push(sep());
542
+ lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeLine(r.goal), innerWidth - 6)}`));
543
+ // L-2: surface the failure/cancellation reason inline for terminal runs.
544
+ const isTerminal = statusStr === "failed" || statusStr === "cancelled" || statusStr === "stopped";
545
+ const reason = isTerminal ? summarizeTerminalReason(r, selectedTasks, snap?.cancellationReason) : undefined;
546
+ const reasonSuffix = reason ? ` · ${truncate(sanitizeLine(reason), 40)}` : "";
547
+ lines.push(row(fg("dim", sanitizeLine(` ${r.team}/${r.workflow ?? "default"} · ${statusStr} · ${r.runId.slice(-10)}${reasonSuffix}`))));
548
+
549
+ // Pane content (max 8 lines) — F-2: colorize embedded status glyphs.
550
+ const paneLines = snap
551
+ ? this.activePane === "agents" ? renderAgentsPane(snap, this.options)
552
+ : this.activePane === "progress" ? renderProgressPane(snap)
553
+ : this.activePane === "mailbox" ? renderMailboxPane(snap)
554
+ : this.activePane === "health" ? renderHealthPane(snap, { isForeground: !r.async })
555
+ : this.activePane === "metrics" ? renderMetricsPane(snap, { registry: this.options.registry })
556
+ : renderTranscriptPane(snap)
557
+ : [
558
+ ...readAgentPreview(r, 4, this.options),
559
+ ...readProgressPreview(r, 2),
560
+ ];
561
+ const filteredPane = paneLines.filter(l => l && !l.includes("(none)") && l.trim() !== "");
562
+ if (filteredPane.length > 0) {
563
+ lines.push(row(fg("dim", `── ${this.activePane} ──`)));
564
+ for (const line of filteredPane.slice(0, 8)) {
565
+ lines.push(colorizeStatusGlyphs(row(truncate(sanitizeLine(line), innerWidth - 2)), this.theme));
566
+ }
460
567
  }
568
+
569
+ // F-4: warn when a live run's snapshot is likely stale (flaky read).
570
+ const isActiveRun = statusStr === "running" || statusStr === "queued" || statusStr === "waiting";
571
+ const staleData = isActiveRun && (snap ? Date.now() - snap.fetchedAt > STALE_SNAPSHOT_MS : true);
572
+ if (staleData) lines.push(row(fg("warning", "⚠ data may be stale (manifest read flaky)")));
573
+
574
+ // One-line footer — V-1: width-padded so the right edge doesn't jitter.
575
+ const usage = aggregateUsage(selectedTasks);
576
+ const u = usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
577
+ const tok = (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
578
+ const tokStr = tok > 0 ? (tok >= 1000 ? `${(tok / 1000).toFixed(1)}k tok` : `${tok} tok`) : "";
579
+ let ctxPct: number | undefined;
580
+ for (const agent of agents) {
581
+ if (agent.status === "running" && agent.runtime === "live-session") {
582
+ const pct = getLiveAgentContextPercent(agent.taskId);
583
+ if (pct != null) { ctxPct = pct; break; }
584
+ }
585
+ }
586
+ const ctxStr = ctxPct != null ? `${Math.round(ctxPct)}% ctx` : "";
587
+ const footerFields: string[] = [];
588
+ if (tokStr) footerFields.push(padVis(tokStr, 10));
589
+ if (ctxStr) footerFields.push(padVis(ctxStr, 9));
590
+ if (footerFields.length) lines.push(row(fg("dim", footerFields.join(" · "))));
461
591
  }
462
- const ctxStr = ctxPct != null ? ` · ${Math.round(ctxPct)}% ctx` : "";
463
- if (tokStr || ctxStr) lines.push(row(fg("dim", `${tokStr}${ctxStr}`)));
464
592
  }
593
+ lines.push(border("╰", "╯"));
465
594
  }
466
- lines.push(border("╰", "╯"));
467
595
 
468
596
  const target = this.targetHeight();
469
597
  if (lines.length < target) {
@@ -488,6 +616,18 @@ export class RunDashboard implements DashboardComponent {
488
616
 
489
617
  handleInput(data: string): void {
490
618
  const action = dashboardActionForKey(data, this.activePane);
619
+ // K-1: "?" toggles the help overlay; while it is shown, any other key
620
+ // (including Esc) just dismisses it first instead of acting.
621
+ if (action === "help") {
622
+ this.showHelp = !this.showHelp;
623
+ this.invalidateAndRender();
624
+ return;
625
+ }
626
+ if (this.showHelp) {
627
+ this.showHelp = false;
628
+ this.invalidateAndRender();
629
+ return;
630
+ }
491
631
  const selectedRunId = this.selectedRunId();
492
632
  if (action === "close") {
493
633
  this.done(undefined);
@@ -61,3 +61,48 @@ function colorForActivity(activityState: string | undefined): CrewThemeColor {
61
61
  export function applyStatusColor(theme: CrewTheme, status: RunStatus, text: string): string {
62
62
  return theme.fg(colorForStatus(status), text);
63
63
  }
64
+
65
+ /**
66
+ * Shared glyph→color map for status glyphs embedded in rendered lines.
67
+ * Consolidates the duplicated `statusGlyphColor` (widget-renderer.ts) and
68
+ * `iconColor` (live-run-sidebar.ts) maps into a single source of truth.
69
+ *
70
+ * Covers ALL status glyphs, including the two previously-missing ones:
71
+ * ⏳ (waiting) → muted, ⚠ (needs_attention) → warning
72
+ * and the braille spinner range U+2800–U+28FF (⠁–⣿) → accent (V-3).
73
+ */
74
+ function glyphColor(glyph: string): CrewThemeColor {
75
+ // Braille Patterns block (U+2800–U+28FF) — running animation frames.
76
+ const code = glyph.codePointAt(0) ?? 0;
77
+ if (code >= 0x2800 && code <= 0x28ff) return "accent";
78
+ switch (glyph) {
79
+ case "✓":
80
+ return "success";
81
+ case "✗":
82
+ return "error";
83
+ case "■":
84
+ case "⏸":
85
+ case "⚠":
86
+ return "warning";
87
+ case "⏳":
88
+ return "muted";
89
+ case "◦":
90
+ case "·":
91
+ return "dim";
92
+ case "▶":
93
+ default:
94
+ return "accent";
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Colorize status glyphs embedded in a rendered line. Wraps every status glyph
100
+ * (✓ ✗ ■ ⏸ ⏳ ⚠ ◦ · ▶ and braille spinner frames ⠁–⣿) in the appropriate theme
101
+ * color, leaving surrounding text untouched.
102
+ *
103
+ * Replaces the duplicated per-module glyph colorizers in widget-renderer.ts
104
+ * and live-run-sidebar.ts with a single shared helper (F-1, F-2, V-3).
105
+ */
106
+ export function colorizeStatusGlyphs(line: string, theme: CrewTheme): string {
107
+ return line.replace(/[✓✗■⏸◦·▶⏳⚠]|[\u2800-\u28FF]/g, (glyph) => theme.fg(glyphColor(glyph), glyph));
108
+ }
@@ -13,7 +13,7 @@ import type { RunSnapshotCache, RunUiSnapshot } from "../snapshot-types.ts";
13
13
  import type { CrewTheme } from "../theme-adapter.ts";
14
14
  import { asCrewTheme, subscribeThemeChange } from "../theme-adapter.ts";
15
15
  import { truncate } from "../../utils/visual.ts";
16
- import { requestRender, setExtensionWidget } from "../pi-ui-compat.ts";
16
+ import { requestRender, requestRenderTarget, setExtensionWidget } from "../pi-ui-compat.ts";
17
17
  import { spinnerBucket, spinnerFrame } from "../spinner.ts";
18
18
  import { runEventBus } from "../run-event-bus.ts";
19
19
  import { DEFAULT_UI } from "../../config/defaults.ts";
@@ -51,6 +51,35 @@ const LEGACY_WIDGET_KEY = "pi-crew";
51
51
  const WIDGET_KEY = "pi-crew-active";
52
52
  const STATUS_KEY = "pi-crew";
53
53
 
54
+ // ── Terminal resize handling (T-2) ────────────────────────────────────
55
+ // On a terminal resize the widget's cached render width goes stale until the
56
+ // next invalidate; a mid-run resize could briefly paint a frame at the old
57
+ // width. We register ONE debounced process-level listener (guarded so it never
58
+ // accumulates across widget reinstalls) that busts the active widget's cache
59
+ // and pokes Pi to repaint at the new width. The listener references a
60
+ // module-level `activeResizeTarget` (the most-recently-mounted widget) rather
61
+ // than a specific instance, so replaced widgets do not leak listeners.
62
+ let resizeListenerInstalled = false;
63
+ let activeResizeTarget: { invalidate(): void; requestRepaint(): void } | undefined;
64
+
65
+ function installResizeListener(): void {
66
+ if (resizeListenerInstalled) return;
67
+ resizeListenerInstalled = true;
68
+ let timer: ReturnType<typeof setTimeout> | undefined;
69
+ const onResize = (): void => {
70
+ if (timer) clearTimeout(timer);
71
+ // Debounce (~120ms) so a drag-resize doesn't thrash renders.
72
+ timer = setTimeout(() => {
73
+ timer = undefined;
74
+ activeResizeTarget?.invalidate();
75
+ activeResizeTarget?.requestRepaint();
76
+ }, 120);
77
+ };
78
+ process.on("SIGWINCH", onResize);
79
+ // Windows has no SIGWINCH; Node emits "resize" on stdout instead.
80
+ if (typeof process.stdout?.on === "function") process.stdout.on("resize", onResize);
81
+ }
82
+
54
83
  // ── Widget Component ──────────────────────────────────────────────────
55
84
 
56
85
  interface WidgetComponent {
@@ -66,13 +95,21 @@ class CrewWidgetComponent implements WidgetComponent {
66
95
  private cachedLines: string[] = [];
67
96
  private cachedBaseLines: string[] = [];
68
97
  private cachedTheme: CrewTheme;
98
+ private readonly tui: unknown;
69
99
  private readonly unsubscribeTheme: () => void;
70
100
  private readonly unsubscribeEventBus: () => void;
71
101
 
72
- constructor(model: CrewWidgetModel, themeLike: unknown) {
102
+ constructor(model: CrewWidgetModel, themeLike: unknown, tui?: unknown) {
73
103
  this.model = model;
74
104
  this.theme = asCrewTheme(themeLike);
75
105
  this.cachedTheme = this.theme;
106
+ this.tui = tui;
107
+ // Register as the active resize target and ensure the single, guarded
108
+ // terminal-resize listener is installed. On a resize the cached width
109
+ // goes stale; busting the cache + requesting a repaint refreshes the
110
+ // widget at the new width without waiting for the next event tick (T-2).
111
+ activeResizeTarget = this;
112
+ installResizeListener();
76
113
  this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
77
114
  this.unsubscribeEventBus = (() => {
78
115
  const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
@@ -114,9 +151,15 @@ class CrewWidgetComponent implements WidgetComponent {
114
151
  this.cachedLines = [];
115
152
  }
116
153
 
154
+ /** Poke the host TUI to repaint immediately (defensive: no-op if unavailable). */
155
+ requestRepaint(): void {
156
+ requestRenderTarget(this.tui);
157
+ }
158
+
117
159
  dispose(): void {
118
160
  this.unsubscribeTheme();
119
161
  this.unsubscribeEventBus();
162
+ if (activeResizeTarget === this) activeResizeTarget = undefined;
120
163
  }
121
164
 
122
165
  render(width: number): string[] {
@@ -215,7 +258,7 @@ export function updateCrewWidget(
215
258
  setExtensionWidget(
216
259
  ctx,
217
260
  WIDGET_KEY,
218
- ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme)) as never,
261
+ ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme, _tui)) as never,
219
262
  { placement, persist: true },
220
263
  );
221
264
  state.lastVisibility = "visible";
@@ -8,9 +8,24 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
8
8
  import type { LiveAgentHandle } from "../../runtime/live-agent-manager.ts";
9
9
  import { getTaskUsage } from "../../runtime/usage-tracker.ts";
10
10
  import { computeLiveDurationMs } from "../live-duration.ts";
11
+ import { visibleWidth } from "../../utils/visual.ts";
11
12
 
12
13
  // ── Token formatting ──────────────────────────────────────────────────
13
14
 
15
+ // V-1: fixed visible widths for per-agent numeric metrics so columns don't
16
+ // jitter every tick as values change width (e.g. 9.9s→10.0s, 950→1.0k).
17
+ // alignMetric right-aligns to the width; values wider than the width overflow
18
+ // verbatim (no truncation/crash) — these are rare one-off states, not jitter.
19
+ const TOOLS_METRIC_WIDTH = 8; // "127 tools"
20
+ const TOKENS_METRIC_WIDTH = 10; // "1.2k tok", "12.3M tok"
21
+ const CTX_METRIC_WIDTH = 7; // "100% ctx"
22
+ const DURATION_METRIC_WIDTH = 6; // "120.0s"
23
+
24
+ function alignMetric(value: string, width: number): string {
25
+ const pad = Math.max(0, width - visibleWidth(value));
26
+ return " ".repeat(pad) + value;
27
+ }
28
+
14
29
  export function formatTokensCompact(count: number): string {
15
30
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M tok`;
16
31
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k tok`;
@@ -107,22 +122,22 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
107
122
  const parts: string[] = [];
108
123
  if (liveHandle) {
109
124
  const act = liveHandle.activity;
110
- if (act.toolUses > 0) parts.push(`${act.toolUses} tools`);
125
+ if (act.toolUses > 0) parts.push(alignMetric(`${act.toolUses} tools`, TOOLS_METRIC_WIDTH));
111
126
  const usage = getTaskUsage(liveHandle.taskId);
112
127
  const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
113
- if (total > 0) parts.push(formatTokensCompact(total));
128
+ if (total > 0) parts.push(alignMetric(formatTokensCompact(total), TOKENS_METRIC_WIDTH));
114
129
  try {
115
130
  const stats = liveHandle.session.getSessionStats?.();
116
131
  const ctxPct = stats?.contextUsage?.percent;
117
- if (ctxPct != null) parts.push(`${Math.round(ctxPct)}% ctx`);
132
+ if (ctxPct != null) parts.push(alignMetric(`${Math.round(ctxPct)}% ctx`, CTX_METRIC_WIDTH));
118
133
  } catch { /* ignore */ }
119
134
  const ms = computeLiveDurationMs(act);
120
- parts.push(`${(ms / 1000).toFixed(1)}s`);
135
+ parts.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
121
136
  } else {
122
- if (agent.toolUses) parts.push(`${agent.toolUses} tools`);
123
- if (agent.progress?.tokens) parts.push(formatTokensCompact(agent.progress.tokens));
137
+ if (agent.toolUses) parts.push(alignMetric(`${agent.toolUses} tools`, TOOLS_METRIC_WIDTH));
138
+ if (agent.progress?.tokens) parts.push(alignMetric(formatTokensCompact(agent.progress.tokens), TOKENS_METRIC_WIDTH));
124
139
  const age = elapsed(agent.completedAt ?? agent.startedAt);
125
- if (age) parts.push(age);
140
+ if (age) parts.push(alignMetric(age, DURATION_METRIC_WIDTH));
126
141
  }
127
142
  return parts.join(" · ");
128
143
  }