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 +38 -0
- package/package.json +1 -1
- package/src/extension/crew-shortcuts.ts +29 -2
- package/src/extension/registration/commands.ts +61 -34
- package/src/runtime/process-status.ts +7 -2
- package/src/ui/crew-footer.ts +3 -3
- package/src/ui/crew-select-list.ts +1 -1
- package/src/ui/dashboard-panes/agents-pane.ts +26 -4
- package/src/ui/dashboard-panes/cancellation-pane.ts +23 -0
- package/src/ui/keybinding-map.ts +7 -3
- package/src/ui/live-conversation-overlay.ts +2 -2
- package/src/ui/live-run-sidebar.ts +18 -10
- package/src/ui/overlays/help-overlay.ts +166 -0
- package/src/ui/run-dashboard.ts +210 -70
- package/src/ui/status-colors.ts +45 -0
- package/src/ui/widget/index.ts +46 -3
- package/src/ui/widget/widget-formatters.ts +22 -7
- package/src/ui/widget/widget-renderer.ts +31 -27
- package/src/utils/visual.ts +3 -1
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
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 <
|
|
133
|
+
if (Number.isFinite(lastAgentActivity) && now - lastAgentActivity < grace) return true;
|
|
129
134
|
return false;
|
|
130
135
|
}
|
|
131
136
|
if (!isActiveRunStatus(run.status)) return false;
|
package/src/ui/crew-footer.ts
CHANGED
|
@@ -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
|
|
94
|
-
this.theme.fg("dim", pad(truncate(usageLine, lineWidth
|
|
95
|
-
this.theme.fg("dim", pad(truncate(badges, 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
|
|
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
|
}
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -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
|
|
149
|
-
*
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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)
|
|
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;
|