pi-crew 0.9.11 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.9.12] — TUI UI/UX polish (21 findings) (2026-06-27)
4
+
5
+ Comprehensive TUI UX review of pi-crew's UI layer (`src/ui/` + `src/utils/visual.ts`). 21 findings (5×P1, 10×P2, 6×P3), all addressed. Full review with evidence-backed file:line citations: `research-findings/pi-crew-uiux-review.md`.
6
+
7
+ ### Bug fixes
8
+
9
+ - **F-3 (P1)** — `src/ui/live-conversation-overlay.ts`: local `pad` used `s.length` (counted ANSI escape bytes) and `content.slice` (UTF-16 units) → border drift on every colored or CJK line. Replaced with `pad`/`truncate` from `utils/visual.ts` (reference impl: `transcript-viewer.ts`).
10
+ - **F-1 / F-2 / V-3 (P1/P2)** — `src/ui/status-colors.ts` (new shared `colorizeStatusGlyphs()`): unified status-glyph colorization covers ⏳ (waiting), ⚠ (needs_attention), and the braille spinner range ⠁–⣿ — the two most attention-demanding states were previously uncolored. Replaces duplicated per-module glyph maps/regexes in `widget-renderer.ts`, `live-run-sidebar.ts`, and `run-dashboard.ts`.
11
+ - **L-1 (P1)** — `src/ui/run-dashboard.ts`: windowed run list with `scrollOffset`; selection can no longer escape the rendered 8-row window. ↓ past row 7 previously hid the highlight and Enter acted on an invisible run. Brute-force verified for 0–30 runs.
12
+ - **L-2 (P1)** — cancellation/failure reason now shown in default detail row (`run-dashboard.ts`) and `live-run-sidebar.ts`. Previously rendered only in `progress-pane` (pane `2`). `cancellation-pane.ts` wired in via `summarizeTerminalReason()` (D-1).
13
+ - **V-1 (P2)** — tabular-aligned numeric metrics across `run-dashboard` footer, `dashboard-panes/agents-pane.ts`, and `widget/widget-formatters.ts` via width-aware `alignMetric` (visibleWidth-based). Eliminates per-tick column jitter on transitions like 9.9s→10.0s and 950→1.0k.
14
+ - **F-5 (P2)** — `src/runtime/process-status.ts`: `ERROR_VISIBILITY_GRACE_MS = 10 * 60_000` (was the 8s `COMPLETED_VISIBILITY_GRACE_MS` shared with completed runs). Failed/cancelled runs now linger 10 min in the crew widget — the run-level header (✗ team/workflow · X/Y agents) is the "one-line trace". Successful completions still vanish in 8s.
15
+ - **K-1 (P2)** — new `src/ui/overlays/help-overlay.ts`: `?` opens the HelpOverlay rendering `BINDINGS[]` grouped by scope. ~16/20 keybindings were previously undiscoverable (no `?` cheatsheet existed). Header hint updated.
16
+ - **F-6 (P2)** — `src/ui/live-run-sidebar.ts`: auto-close countdown moved inside the bordered box (was rendered below the bottom border).
17
+ - **V-2 (P2)** — `src/ui/crew-footer.ts`, `src/ui/crew-select-list.ts`: dropped ASCII `'...'` 3rd arg from `truncate(...)` so they use the default `'…'` (U+2026) consistently with the rest of the UI. Test updated.
18
+ - **L-3 / L-4 / L-5 (P2/P3)** — width-aware `runLabel` keeps the goal visible (the most meaningful field); widget prioritizes running > queued > waiting with `finishedSlots` so finished rows only fill leftover budget and never push a live agent's activity line off-screen; run-list separator unified to ` · `.
19
+ - **F-4 / F-7 (P2/P3)** — stale-snapshot hint in the default dashboard view when manifest reads are flaky; actionable empty/error states (no more "Dashboard error — see logs").
20
+ - **T-1 (P3)** — `src/utils/visual.ts`: ZWJ (`U+200D`) removed from `WIDE_RANGES` (now correctly width-0; was inflating compound-emoji width and over-truncating).
21
+ - **T-2 (P3)** — `src/ui/widget/index.ts`: debounced (~120ms) SIGWINCH + stdout-resize listener busts the render cache and requests a repaint. Guarded against double-registration across widget reinstalls.
22
+ - **D-1 (P3)** — `src/ui/dashboard-panes/cancellation-pane.ts` wired in via `summarizeTerminalReason()` (was dead-imported by `test/unit/cancellation-pane.test.ts`, so kept and given a real consumer instead of deleted).
23
+
24
+ ### Shortcut collision fix
25
+
26
+ - **Extension-load warning**: `alt+d` collided with `tui.editor.deleteWordForward` (verified in pi-tui `TUI_KEYBINDINGS`). Pi resolved in favor of the extension, *stripping* the editor's delete-word-forward binding. Moved dashboard shortcut to **`alt+c`** (mnemonic: **C**rew — verified free against the full built-in `alt+` keymap: occupied letters are `b, d, f, v, y`).
27
+ - **Test guard hardened**: `test/unit/crew-shortcuts.test.ts` collision set now includes the editor keys (`alt+b/d/f/y`, `alt+backspace`, `alt+delete`). The previous set was incomplete (`alt+v, alt+enter, alt+arrows` only), which is why the bug slipped past tests. This class of regression is now caught at test time.
28
+
29
+ ### Decisions (deviations from the initial review)
30
+
31
+ - **K-3 `KEY_RESERVED`**: NOT dead code — consumed by `test/unit/keybinding-map.parity.test.ts:29,185-203` and `test/manual/l2-keybinding-dispatch-smoke.mjs`. Corrected the misleading "dead code" doc instead of deleting (deleting would have broken the parity test).
32
+ - **K-2 `alt+m` / `alt+t`**: blocked — mailbox overlay is run-scoped (requires a runId; reached via the dashboard); `team-status` is a text command (`handleTeamTool({action:"status"})`), not an overlay opener. `alt+d → dashboard` is the only wired shortcut.
33
+ - **F-5 location**: fixed at the correct layer (`process-status.ts` run-level grace) rather than the report's suggested `widget-renderer.ts` agent-row linger; the run-level header line is the right "one-line trace" per the finding.
34
+
35
+ ### Verification
36
+
37
+ - `npx tsc --noEmit` + `npm run typecheck`: 0 errors (incl. strip-types import smoke).
38
+ - Focused UI cluster: **74/74 pass** across 8 suites — `crew-shortcuts` (incl. strengthened collision assertion), `crew-footer` (incl. renamed V-2 test), `keybinding-map parity`, `cancellation-pane`, `process-status ×3`, `agents-pane-cost`.
39
+ - Full suite baseline (before this batch): 5642 / 5639 pass / 0 fail. Two post-change full runs hit `ETIMEDOUT` on `spawnSync` inside integration child-spawn tests (`worktree-run`, `cleanup-full-flow`) under load (0 assertion failures — environmental flake). To be reconfirmed against CI.
40
+
3
41
  ## [v0.9.11] — Per-run lock path for background-runner (parallel-spawn race) (2026-06-27)
4
42
 
5
43
  Bug caught by an E2E parallel-spawn test in this session, NOT by unit tests (which cannot spawn multiple real processes). Independent of the F1-F5/redaction batches.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -6,9 +6,25 @@
6
6
  * built-in keymap (see analysis of pi-tui core/keybindings defaults):
7
7
  *
8
8
  * alt+s → open the pi-crew settings overlay (config + theme picker)
9
+ * alt+c → open the pi-crew run dashboard overlay (mnemonic: **C**rew)
9
10
  *
10
- * `alt+<letter>` combos are safe: Pi only binds `alt+v`, `alt+enter`, and the
11
- * alt+arrow navigation keys. `alt+s` is mnemonic (settings) and free.
11
+ * OCCUPIED alt+ keys in the built-in keymap (must NOT reuse verified against
12
+ * pi-tui TUI_KEYBINDINGS + pi core keybindings.js):
13
+ * alt+b (cursor word left) alt+f (cursor word right)
14
+ * alt+d (delete word forward) alt+y (yank pop)
15
+ * alt+v (paste) alt+s (crew settings — this module)
16
+ * alt+enter / alt+up/down/left/right / alt+backspace / alt+delete
17
+ * Free alt+<letter> keys include: a, c, e, g, h, i, j, k, l, m, n, o, p, q,
18
+ * r, t, u, w, x, z.
19
+ *
20
+ * NOTE: an earlier revision used `alt+d` for the dashboard; that collided
21
+ * with `tui.editor.deleteWordForward` and Pi's conflict detector stripped the
22
+ * editor binding. `alt+c` is free AND mnemonic.
23
+ *
24
+ * NOTE: alt+m (mailbox) and alt+t (status) were considered but are NOT wired
25
+ * — the mailbox overlay is run-scoped (requires a runId; reached via the
26
+ * dashboard) and there is no standalone status overlay (status is a text
27
+ * command). See the K-2 note accompanying openTeamDashboard in commands.ts.
12
28
  *
13
29
  * Shortcuts are guarded by `hasUI` so they never fire in print/RPC mode, and
14
30
  * by the optional `registerShortcut` API so older Pi versions degrade
@@ -39,6 +55,17 @@ const CREW_SHORTCUTS: ReadonlyArray<ShortcutRegistration> = [
39
55
  await openTeamSettingsOverlay(ctx);
40
56
  },
41
57
  },
58
+ {
59
+ key: "alt+c",
60
+ description: "pi-crew: open run dashboard (Crew)",
61
+ // Lazy-import so the heavy UI module chain (RunDashboard etc.) is only
62
+ // loaded on first use, not at extension load.
63
+ handler: async (ctx) => {
64
+ // LAZY: defer dynamic import of ./registration/commands.ts to its call site.
65
+ const { openTeamDashboard } = await import("./registration/commands.ts");
66
+ await openTeamDashboard(ctx);
67
+ },
68
+ },
42
69
  ];
43
70
 
44
71
  /**
@@ -260,6 +260,66 @@ async function handleHealthDashboardAction(ctx: ExtensionCommandContext, selecti
260
260
 
261
261
  let depsRef: RegisterTeamCommandsDeps | undefined;
262
262
 
263
+ /**
264
+ * Open the pi-crew run dashboard overlay and run its action loop.
265
+ *
266
+ * Extracted verbatim from the `team-dashboard` command so it is reusable from
267
+ * a keyboard shortcut (alt+c, see crew-shortcuts.ts). Takes the base
268
+ * `ExtensionContext` (the shortcut handler's context) — uses only `hasUI`,
269
+ * `cwd`, `ui`, and `sessionManager` fields, so both `ExtensionContext` and
270
+ * `ExtensionCommandContext` satisfy it. Reads run caches via the module-level
271
+ * `depsRef` (set by `registerTeamCommands`), so it is a no-op if commands
272
+ * have not been registered yet. `deps` is captured into a local const so the
273
+ * non-undefined narrowing survives the awaited overlay call.
274
+ *
275
+ * The dashboard action helpers (handleMailboxDashboardAction, the viewers,
276
+ * notifyCommandResult, teamCommandContext/handleTeamTool) are declared for
277
+ * `ExtensionCommandContext` but only ever read base `ExtensionContext` fields
278
+ * (verified). The keyboard-shortcut path supplies an `ExtensionContext`, so we
279
+ * bridge those over-typed helpers with a single cast rather than relaxing
280
+ * signatures across several modules (some outside this file's scope).
281
+ */
282
+ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
283
+ if (!ctx.hasUI) return;
284
+ const deps = depsRef;
285
+ if (!deps) return;
286
+ const cmdCtx = ctx as ExtensionCommandContext;
287
+ for (;;) {
288
+ // Extract sessionId for workspace-scoped filtering
289
+ const sessionId = cmdCtx.sessionManager?.getSessionId?.();
290
+ const runs = deps.getManifestCache(cmdCtx.cwd).list(50);
291
+ const uiConfig = loadConfig(cmdCtx.cwd).config.ui;
292
+ const rightPanel = (uiConfig?.dashboardPlacement ?? DEFAULT_UI.dashboardPlacement) === "right";
293
+ const width = rightPanel ? Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? DEFAULT_UI.dashboardWidth)) : "90%";
294
+ const { RunDashboard } = await ui();
295
+ const selection = await cmdCtx.ui.custom<RunDashboardSelection | undefined>((tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme, { placement: rightPanel ? "right" : "center", showModel: uiConfig?.showModel, showTokens: uiConfig?.showTokens, showTools: uiConfig?.showTools, snapshotCache: deps.getRunSnapshotCache?.(cmdCtx.cwd), runProvider: () => deps.getManifestCache(cmdCtx.cwd).list(50), registry: deps.getMetricRegistry?.(), workspaceId: sessionId, requestRender: () => requestRenderTarget(tui) }), { overlay: true, overlayOptions: rightPanel ? { width, minWidth: 40, maxHeight: "100%", anchor: "top-right", offsetX: 0, offsetY: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 } } : { width, maxHeight: "90%", anchor: "center", margin: 2 } });
296
+ if (!selection) return;
297
+ if (selection.action === "reload") continue;
298
+ if (selection.action === "notifications-dismiss") {
299
+ deps.dismissNotifications?.();
300
+ cmdCtx.ui.notify("pi-crew notifications dismissed.", "info");
301
+ continue;
302
+ }
303
+ if (selection.action === "mailbox-detail") {
304
+ await handleMailboxDashboardAction(cmdCtx, selection.runId);
305
+ deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
306
+ continue;
307
+ }
308
+ if (selection.action === "health-recovery" || selection.action === "health-kill-stale" || selection.action === "health-diagnostic-export") {
309
+ await handleHealthDashboardAction(cmdCtx, selection);
310
+ deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
311
+ continue;
312
+ }
313
+ if (selection.action === "agent-transcript" && await openTranscriptViewer(cmdCtx, selection.runId)) continue;
314
+ if (selection.action === "agent-live" && await openLiveConversation(cmdCtx, selection.runId)) continue;
315
+ if (selection.action === "agent-live") { await notifyCommandResult(cmdCtx, commandText({ content: [{ type: "text", text: "No live agent found for this run." }] })); continue; }
316
+ const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(cmdCtx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(cmdCtx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(cmdCtx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(cmdCtx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(cmdCtx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(cmdCtx)) : // eslint-disable-next-line @typescript-eslint/no-explicit-any
317
+ await handleTeamTool({ action: selection.action as any, runId: selection.runId }, teamCommandContext(cmdCtx));
318
+ await notifyCommandResult(cmdCtx, commandText(result));
319
+ return;
320
+ }
321
+ }
322
+
263
323
  export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommandsDeps): void {
264
324
  depsRef = deps;
265
325
  pi.registerCommand("teams", {
@@ -497,40 +557,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
497
557
  } });
498
558
 
499
559
  pi.registerCommand("team-dashboard", { description: "Open a pi-crew run dashboard overlay", handler: async (_args: string, ctx: ExtensionCommandContext) => {
500
- for (;;) {
501
- // Extract sessionId for workspace-scoped filtering
502
- const sessionId = ctx.sessionManager?.getSessionId?.();
503
- const runs = deps.getManifestCache(ctx.cwd).list(50);
504
- const uiConfig = loadConfig(ctx.cwd).config.ui;
505
- const rightPanel = (uiConfig?.dashboardPlacement ?? DEFAULT_UI.dashboardPlacement) === "right";
506
- const width = rightPanel ? Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? DEFAULT_UI.dashboardWidth)) : "90%";
507
- const { RunDashboard } = await ui();
508
- const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme, { placement: rightPanel ? "right" : "center", showModel: uiConfig?.showModel, showTokens: uiConfig?.showTokens, showTools: uiConfig?.showTools, snapshotCache: deps.getRunSnapshotCache?.(ctx.cwd), runProvider: () => deps.getManifestCache(ctx.cwd).list(50), registry: deps.getMetricRegistry?.(), workspaceId: sessionId, requestRender: () => requestRenderTarget(tui) }), { overlay: true, overlayOptions: rightPanel ? { width, minWidth: 40, maxHeight: "100%", anchor: "top-right", offsetX: 0, offsetY: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 } } : { width, maxHeight: "90%", anchor: "center", margin: 2 } });
509
- if (!selection) return;
510
- if (selection.action === "reload") continue;
511
- if (selection.action === "notifications-dismiss") {
512
- deps.dismissNotifications?.();
513
- ctx.ui.notify("pi-crew notifications dismissed.", "info");
514
- continue;
515
- }
516
- if (selection.action === "mailbox-detail") {
517
- await handleMailboxDashboardAction(ctx, selection.runId);
518
- deps.getRunSnapshotCache?.(ctx.cwd).invalidate(selection.runId);
519
- continue;
520
- }
521
- if (selection.action === "health-recovery" || selection.action === "health-kill-stale" || selection.action === "health-diagnostic-export") {
522
- await handleHealthDashboardAction(ctx, selection);
523
- deps.getRunSnapshotCache?.(ctx.cwd).invalidate(selection.runId);
524
- continue;
525
- }
526
- if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
527
- if (selection.action === "agent-live" && await openLiveConversation(ctx, selection.runId)) continue;
528
- if (selection.action === "agent-live") { await notifyCommandResult(ctx, commandText({ content: [{ type: "text", text: "No live agent found for this run." }] })); continue; }
529
- const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(ctx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(ctx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(ctx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(ctx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(ctx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(ctx)) : // eslint-disable-next-line @typescript-eslint/no-explicit-any
530
- await handleTeamTool({ action: selection.action as any, runId: selection.runId }, teamCommandContext(ctx));
531
- await notifyCommandResult(ctx, commandText(result));
532
- return;
533
- }
560
+ await openTeamDashboard(ctx);
534
561
  } });
535
562
 
536
563
  pi.registerCommand("team-mascot", { description: "Show an animated mascot splash", handler: async (args: string, ctx: ExtensionCommandContext) => {
@@ -16,7 +16,11 @@ export interface ProcessLiveness {
16
16
  */
17
17
  const ORPHANED_ACTIVE_RUN_MS = 2 * 60 * 1000;
18
18
  /** How long a completed run stays visible in the widget after completion. */
19
- const COMPLETED_VISIBILITY_GRACE_MS = 8000;
19
+ const COMPLETED_VISIBILITY_GRACE_MS = 8_000;
20
+ /** Errors (failed/cancelled) linger far longer so a failed run leaves a visible
21
+ * trace in the crew widget for ~10 min (F-5). Successful completions vanish
22
+ * quickly to keep the widget quiet. */
23
+ const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
20
24
  /** Maximum age (ms) for an active run before it's considered stale.
21
25
  * After this time, PID-only liveness is unreliable due to PID recycling. */
22
26
  const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
@@ -120,12 +124,13 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
120
124
  }
121
125
  // Grace period: show completed runs for a few seconds so users see the result.
122
126
  if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
127
+ const grace = run.status === "completed" ? COMPLETED_VISIBILITY_GRACE_MS : ERROR_VISIBILITY_GRACE_MS;
123
128
  const lastAgentActivity = agents.reduce<number>((max, agent) => {
124
129
  const ts = agent.completedAt ?? agent.startedAt;
125
130
  const parsed = ts ? new Date(ts).getTime() : 0;
126
131
  return Number.isFinite(parsed) && parsed > max ? parsed : max;
127
132
  }, new Date(run.updatedAt).getTime());
128
- if (Number.isFinite(lastAgentActivity) && now - lastAgentActivity < COMPLETED_VISIBILITY_GRACE_MS) return true;
133
+ if (Number.isFinite(lastAgentActivity) && now - lastAgentActivity < grace) return true;
129
134
  return false;
130
135
  }
131
136
  if (!isActiveRunStatus(run.status)) return false;
@@ -90,9 +90,9 @@ export class CrewFooter {
90
90
  ].join(" • ");
91
91
  const badges = this.data.badges?.length ? this.data.badges.map((badge) => `[${badge}]`).join(" ") : "";
92
92
  this.cacheLines = [
93
- this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth, "..."), lineWidth)),
94
- this.theme.fg("dim", pad(truncate(usageLine, lineWidth, "..."), lineWidth)),
95
- this.theme.fg("dim", pad(truncate(badges, lineWidth, "..."), lineWidth)),
93
+ this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth), lineWidth)),
94
+ this.theme.fg("dim", pad(truncate(usageLine, lineWidth), lineWidth)),
95
+ this.theme.fg("dim", pad(truncate(badges, lineWidth), lineWidth)),
96
96
  ];
97
97
  this.cacheKey = key;
98
98
  this.cacheWidth = width;
@@ -86,7 +86,7 @@ export class CrewSelectList<T = string> {
86
86
  const suffix = item.description ? this.theme.fg("dim", ` — ${item.description}`) : "";
87
87
  const raw = `${prefix}${item.label}${suffix}`;
88
88
  const line = index === this.selectedIndex ? this.theme.inverse?.(raw) ?? raw : raw;
89
- lines.push(pad(truncate(line, width, "..."), Math.max(1, width)));
89
+ lines.push(pad(truncate(line, width), Math.max(1, width)));
90
90
  }
91
91
  if (hasBottom) lines.push(this.theme.fg("muted", `↓ ${this.items.length - (this.scrollOffset + slots)} more`));
92
92
  return lines.slice(0, maxHeight);
@@ -6,6 +6,28 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
6
6
  import { formatCost } from "../../state/usage.ts";
7
7
  import { listLiveAgents, listLiveAgentsByWorkspace, type LiveAgentHandle } from "../../runtime/live-agent-manager.ts";
8
8
  import { computeLiveDurationMs } from "../live-duration.ts";
9
+ import { visibleWidth } from "../../utils/visual.ts";
10
+
11
+ /**
12
+ * Fixed visible widths for the per-agent numeric metrics (finding V-1).
13
+ * Right-aligning each field to a constant width stops the right edge of the
14
+ * stats block from jittering every tick as values change width
15
+ * (e.g. `9.9s`→`10.0s`, `950`→`1.0k`, `$0.9`→`$1.0`).
16
+ */
17
+ const TOKENS_METRIC_WIDTH = 5; // `1.2k`, `12.3k`, `123`
18
+ const COST_METRIC_WIDTH = 7; // `$0.0500`, `$1.50`
19
+ const DURATION_METRIC_WIDTH = 6; // `3.0s`, `59.9s`, `120.0s`
20
+
21
+ /**
22
+ * Width-aware right-align (padStart) for a numeric metric so its column stays
23
+ * stable across ticks. Uses `visibleWidth` so wide/CJK glyphs never misalign
24
+ * the padding. Values wider than `width` are returned verbatim (no truncation)
25
+ * so outliers overflow gracefully instead of crashing the render.
26
+ */
27
+ function alignMetric(value: string, width: number): string {
28
+ const gap = width - visibleWidth(value);
29
+ return gap > 0 ? " ".repeat(gap) + value : value;
30
+ }
9
31
 
10
32
  /**
11
33
  * Returns true if this agent did real work (LLM call, tool use, or non-trivial duration).
@@ -89,12 +111,12 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
89
111
  const tokenTotal = (agent.usage?.input ?? 0) + (agent.usage?.output ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0);
90
112
  if (tokenTotal > 0) {
91
113
  const tok = tokenTotal >= 1000 ? `${(tokenTotal / 1000).toFixed(1)}k` : `${tokenTotal}`;
92
- stats.push(tok);
114
+ stats.push(alignMetric(tok, TOKENS_METRIC_WIDTH));
93
115
  }
94
116
  // Per-agent cost (Round 17 BS-1): the data is already on task.usage.cost;
95
117
  // surface it live so the user sees $ burn per agent during a run.
96
118
  if (agent.usage?.cost && agent.usage.cost > 0) {
97
- stats.push(formatCost(agent.usage.cost));
119
+ stats.push(alignMetric(formatCost(agent.usage.cost), COST_METRIC_WIDTH));
98
120
  }
99
121
  if (liveHandle) {
100
122
  // Round 23 (BUG 1): the duration math here was naive —
@@ -104,13 +126,13 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
104
126
  // fired for EVERY running live agent in the dashboard. Use the shared,
105
127
  // validated computeLiveDurationMs (mirrors widget-formatters.ts).
106
128
  const ms = computeLiveDurationMs(liveHandle.activity);
107
- stats.push(`${(ms / 1000).toFixed(1)}s`);
129
+ stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
108
130
  if (options.showModel !== false && liveHandle.modelName && liveHandle.modelName !== "default") {
109
131
  stats.push(liveHandle.modelName);
110
132
  }
111
133
  } else if (agent.startedAt) {
112
134
  const ms = Date.now() - new Date(agent.startedAt).getTime();
113
- if (Number.isFinite(ms)) stats.push(`${(ms / 1000).toFixed(1)}s`);
135
+ if (Number.isFinite(ms)) stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
114
136
  }
115
137
 
116
138
  const statsStr = stats.length ? ` · ${stats.join(" ")}` : "";
@@ -40,4 +40,27 @@ export function renderCancellationPane(manifest: TeamRunManifest, tasks: TeamTas
40
40
  }
41
41
 
42
42
  return lines;
43
+ }
44
+
45
+ /**
46
+ * D-1 / L-2 — a one-line terminal-run reason for the dashboard detail row.
47
+ *
48
+ * Surfaces *why* a failed/cancelled/stopped run ended without forcing the
49
+ * user to switch panes. This also gives this module a real consumer (it was
50
+ * previously zero-importer dead code per the UI/UX review), wiring it in as
51
+ * the natural home for cancellation/failure reason display.
52
+ *
53
+ * Resolution order: structured `run.cancelled` event reason → first failed
54
+ * task's error → first policy-decision message.
55
+ */
56
+ export function summarizeTerminalReason(
57
+ manifest: TeamRunManifest,
58
+ tasks: TeamTaskState[],
59
+ cancellationReason?: string,
60
+ ): string | undefined {
61
+ if (cancellationReason) return cancellationReason;
62
+ const failed = tasks.find((task) => task.status === "failed" && task.error);
63
+ if (failed?.error) return failed.error;
64
+ if (manifest.policyDecisions?.length) return manifest.policyDecisions[0]?.message;
65
+ return undefined;
43
66
  }
@@ -26,6 +26,7 @@
26
26
  export const DASHBOARD_KEYS = {
27
27
  close: ["q", "\u001b"],
28
28
  select: ["\r", "\n", "s"],
29
+ help: ["?"],
29
30
  root: {
30
31
  summary: ["u"],
31
32
  artifacts: ["a"],
@@ -67,6 +68,7 @@ export interface KeyBinding {
67
68
 
68
69
  export type DashboardKeyAction =
69
70
  | "close"
71
+ | "help"
70
72
  | "select"
71
73
  | "summary"
72
74
  | "artifacts"
@@ -112,6 +114,7 @@ export type DashboardKeyAction =
112
114
  */
113
115
  const BINDINGS: readonly KeyBinding[] = [
114
116
  { keys: DASHBOARD_KEYS.close, action: "close" },
117
+ { keys: DASHBOARD_KEYS.help, action: "help" },
115
118
  { keys: DASHBOARD_KEYS.mailbox.openDetail, action: "mailbox-detail", pane: "mailbox" },
116
119
  { keys: DASHBOARD_KEYS.health.recovery, action: "health-recovery", pane: "health" },
117
120
  { keys: DASHBOARD_KEYS.health.killStale, action: "health-kill-stale", pane: "health" },
@@ -145,13 +148,14 @@ const BINDINGS: readonly KeyBinding[] = [
145
148
  * overlays. Derived from `DASHBOARD_KEYS` (the full key set) rather than from
146
149
  * `BINDINGS` (the dispatched subset) so overlay-handled keys stay reserved.
147
150
  *
148
- * @internal Currently unused outside this module but retained to document
149
- * intent and support future callers that need to know which keys the
150
- * dashboard ecosystem owns.
151
+ * @internal Consumed by `test/unit/keybinding-map.parity.test.ts` (asserts
152
+ * reserved-key membership) and the L2 dispatch smoke script. It is the
153
+ * canonical "keys the dashboard ecosystem owns" set — NOT dead code.
151
154
  */
152
155
  const KEY_RESERVED = new Set<string>([
153
156
  ...DASHBOARD_KEYS.close,
154
157
  ...DASHBOARD_KEYS.select,
158
+ ...DASHBOARD_KEYS.help,
155
159
  ...Object.values(DASHBOARD_KEYS.root).flat(),
156
160
  ...Object.values(DASHBOARD_KEYS.pane).flat(),
157
161
  ...Object.values(DASHBOARD_KEYS.navigation).flat(),
@@ -8,6 +8,7 @@ import type { LiveAgentHandle } from "../runtime/live-agent-manager.ts";
8
8
  import { iconForStatus } from "./status-colors.ts";
9
9
  import { spinnerFrame } from "./spinner.ts";
10
10
  import type { CrewTheme } from "./theme-adapter.ts";
11
+ import { pad, truncate } from "../utils/visual.ts";
11
12
 
12
13
  const CHROME_LINES = 6;
13
14
  const MIN_VIEWPORT = 3;
@@ -119,9 +120,8 @@ export class LiveConversationOverlay {
119
120
  if (w < 6) return [];
120
121
  const th = this.theme;
121
122
  const innerW = w - 4;
122
- const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - s.length));
123
123
  const row = (content: string) =>
124
- th.fg("border", "│") + " " + pad(content.slice(0, innerW), innerW) + " " + th.fg("border", "│");
124
+ th.fg("border", "│") + " " + pad(truncate(content, innerW), innerW) + " " + th.fg("border", "│");
125
125
  const hrTop = th.fg("border", `╭${"─".repeat(w - 2)}╮`);
126
126
  const hrBot = th.fg("border", `╰${"─".repeat(w - 2)}╯`);
127
127
  const hrMid = row(th.fg("dim", "─".repeat(innerW)));
@@ -8,7 +8,7 @@ import { aggregateUsage, formatUsage } from "../state/usage.ts";
8
8
  import type { TeamTaskState } from "../state/types.ts";
9
9
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
10
10
  import { pad, truncate } from "../utils/visual.ts";
11
- import { iconForStatus } from "./status-colors.ts";
11
+ import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
12
12
  import type { CrewTheme } from "./theme-adapter.ts";
13
13
  import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
14
14
  import { Box, Text } from "./layout-primitives.ts";
@@ -93,13 +93,11 @@ export class LiveRunSidebar {
93
93
  }
94
94
 
95
95
  private colorLine(line: string): string {
96
- const iconColor = (icon: string): Parameters<CrewTheme["fg"]>[0] => {
97
- if (icon === "✓") return "success";
98
- if (icon === "✗") return "error";
99
- if (icon === "■" || icon === "⏸") return "warning";
100
- return "accent";
101
- };
102
- return line.replace(/[✓✗■⏸◦·▶]/g, (icon) => this.theme.fg(iconColor(icon), icon));
96
+ // F-1 / V-3: delegate to the shared glyph colorizer so ⏳ (waiting),
97
+ // (needs_attention) and the braille spinner frames are colored
98
+ // consistently with the rest of the UI. The previous local map/regex
99
+ // omitted all three, leaving the most attention-demanding states uncolored.
100
+ return colorizeStatusGlyphs(line, this.theme);
103
101
  }
104
102
 
105
103
  invalidate(): void {
@@ -150,10 +148,17 @@ export class LiveRunSidebar {
150
148
  const waiting = tasks.filter((task) => task.status === "queued");
151
149
  const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot);
152
150
  if (signature !== this.cachedSignature || w !== this.cachedWidth) {
151
+ // L-2: surface the cancellation/failure reason for terminal runs so the
152
+ // user sees *why* a run ended without having to switch panes. The reason
153
+ // is already computed on the consumed snapshot (cancellationReason).
154
+ const TERMINAL_WITH_REASON = ["failed", "cancelled", "stopped"];
155
+ const reasonSuffix = TERMINAL_WITH_REASON.includes(run.status) && snapshot?.cancellationReason
156
+ ? ` · ${truncate(snapshot.cancellationReason, 40)}`
157
+ : "";
153
158
  const lines: string[] = [
154
159
  border("╭", "─", "╮", w),
155
160
  line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
156
- line(`${run.runId.slice(-12)} · ${run.status} · right default`, w),
161
+ line(`${run.runId.slice(-12)} · ${run.status}${reasonSuffix} · right default`, w),
157
162
  line(`${run.team}/${run.workflow ?? "none"} · ${shortUsage(tasks)}`, w),
158
163
  border("├", "─", "┤", w),
159
164
  line(`Active agents (${active.length})`, w),
@@ -180,7 +185,9 @@ export class LiveRunSidebar {
180
185
  if (completed.length === 0) lines.push(line("- none", w));
181
186
  lines.push(border("├", "─", "┤", w));
182
187
  for (const entry of formatTaskGraphLines(tasks).slice(0, 6)) lines.push(line(entry, w));
183
- lines.push(line("q close · /team-dashboard details", w), border("╰", "─", "╯", w));
188
+ lines.push(line("q close · /team-dashboard details", w));
189
+ // F-6: compute the auto-close countdown BEFORE pushing the bottom border
190
+ // so the countdown renders inside the bordered box rather than below it.
184
191
  // Auto-close logic: if run is terminal and no active agents, close after delay
185
192
  const isTerminal = ["completed", "failed", "cancelled", "blocked"].includes(run.status);
186
193
  const hasActiveAgents = agents.some((a) => a.status === "running");
@@ -199,6 +206,7 @@ export class LiveRunSidebar {
199
206
  clearTimeout(this.autoCloseTimeout);
200
207
  this.autoCloseTimeout = undefined;
201
208
  }
209
+ lines.push(border("╰", "─", "╯", w));
202
210
  this.cachedLines = renderLines(lines.map((entry) => this.colorLine(entry)), w);
203
211
  this.cachedSignature = signature;
204
212
  this.cachedWidth = w;