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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * K-1 — dashboard keybinding cheatsheet overlay.
3
+ *
4
+ * Toggled by `?` (bound in keybinding-map.ts). Renders the dashboard's
5
+ * keybindings grouped by scope (general / navigation / panes / run actions /
6
+ * mailbox / health) directly from `DASHBOARD_KEYS`, so adding a key in one
7
+ * place is reflected here automatically.
8
+ *
9
+ * Uses the same `innerWidth = max(20, width - 4)` formula as `run-dashboard.ts`
10
+ * so the overlay's border column aligns with the dashboard's stable-height
11
+ * blank-padding rows (preserves the "stable overlay height" strength — the
12
+ * blank rows injected by the dashboard's pad/trim must land in the same
13
+ * column as this overlay's `│` borders).
14
+ *
15
+ * Template origin: `confirm-overlay.ts`.
16
+ */
17
+ import { Box, Text } from "../layout-primitives.ts";
18
+ import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
19
+ import { pad, truncate } from "../../utils/visual.ts";
20
+ import { DASHBOARD_KEYS } from "../keybinding-map.ts";
21
+
22
+ /** Translate a raw key sequence into a readable token for the cheatsheet. */
23
+ function keyToken(key: string): string {
24
+ switch (key) {
25
+ case "\u001b":
26
+ return "Esc";
27
+ case "\u001b[A":
28
+ return "↑";
29
+ case "\u001b[B":
30
+ return "↓";
31
+ case "\r":
32
+ case "\n":
33
+ return "Enter";
34
+ default:
35
+ return key;
36
+ }
37
+ }
38
+
39
+ function keyList(keys: readonly string[]): string {
40
+ return [...keys].map(keyToken).join("/");
41
+ }
42
+
43
+ interface HelpEntry {
44
+ readonly keys: string;
45
+ readonly label: string;
46
+ }
47
+
48
+ interface HelpGroup {
49
+ readonly title: string;
50
+ readonly entries: readonly HelpEntry[];
51
+ }
52
+
53
+ const ROOT_LABELS: Record<string, string> = {
54
+ summary: "summary",
55
+ artifacts: "artifacts",
56
+ api: "api",
57
+ agents: "agents",
58
+ mailbox: "mailbox",
59
+ events: "events",
60
+ output: "output",
61
+ transcript: "transcript",
62
+ liveConversation: "live conv",
63
+ reload: "reload",
64
+ progressToggle: "progress",
65
+ };
66
+
67
+ const MAILBOX_LABELS: Record<string, string> = {
68
+ ack: "ack",
69
+ nudge: "nudge",
70
+ compose: "compose",
71
+ preview: "preview",
72
+ ackAll: "ack all",
73
+ };
74
+
75
+ const HEALTH_LABELS: Record<string, string> = {
76
+ recovery: "recover",
77
+ killStale: "kill stale",
78
+ diagnosticExport: "diag export",
79
+ };
80
+
81
+ function buildHelpGroups(): HelpGroup[] {
82
+ const paneEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.pane).map(([name, keys]) => ({
83
+ keys: keyList(keys),
84
+ label: name,
85
+ }));
86
+ const rootEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.root).map(([name, keys]) => ({
87
+ keys: keyList(keys),
88
+ label: ROOT_LABELS[name] ?? name,
89
+ }));
90
+ const mailboxEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.mailbox)
91
+ .filter(([name]) => name !== "openDetail")
92
+ .map(([name, keys]) => ({ keys: keyList(keys), label: MAILBOX_LABELS[name] ?? name }));
93
+ const healthEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.health).map(([name, keys]) => ({
94
+ keys: keyList(keys),
95
+ label: HEALTH_LABELS[name] ?? name,
96
+ }));
97
+ return [
98
+ {
99
+ title: "General",
100
+ entries: [
101
+ { keys: keyList(DASHBOARD_KEYS.close), label: "close dashboard" },
102
+ { keys: keyList(DASHBOARD_KEYS.select), label: "open run status" },
103
+ { keys: "?", label: "toggle this help" },
104
+ ],
105
+ },
106
+ {
107
+ title: "Navigation",
108
+ entries: [
109
+ { keys: `${keyList(DASHBOARD_KEYS.navigation.up)}/${keyList(DASHBOARD_KEYS.navigation.down)}`, label: "move selection" },
110
+ ],
111
+ },
112
+ { title: "Panes", entries: paneEntries },
113
+ { title: "Run actions", entries: rootEntries },
114
+ { title: "Mailbox (pane 3)", entries: mailboxEntries },
115
+ {
116
+ title: "Health & notifications",
117
+ entries: [
118
+ ...healthEntries,
119
+ { keys: keyList(DASHBOARD_KEYS.notification.dismissAll), label: "dismiss notifs" },
120
+ ],
121
+ },
122
+ ];
123
+ }
124
+
125
+ export class HelpOverlay {
126
+ private readonly theme: CrewTheme;
127
+
128
+ constructor(theme: unknown = {}) {
129
+ this.theme = asCrewTheme(theme);
130
+ }
131
+
132
+ invalidate(): void {
133
+ // Stateless overlay.
134
+ }
135
+
136
+ render(width: number): string[] {
137
+ // MUST mirror run-dashboard.ts's innerWidth so the dashboard's
138
+ // stable-height blank-padding rows align with this overlay's borders.
139
+ const innerWidth = Math.max(20, width - 4);
140
+ const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
141
+ const row = (text: string) => `│ ${pad(truncate(text, innerWidth - 1), innerWidth - 1)}│`;
142
+ const bar = "─".repeat(innerWidth);
143
+ const top = fg("border", `╭${bar}╮`);
144
+ const mid = fg("border", `├${bar}┤`);
145
+ const bot = fg("border", `╰${bar}╯`);
146
+ const keyCol = 9;
147
+ const lines: string[] = [
148
+ top,
149
+ row(`${fg("accent", "pi-crew dashboard")} ${fg("dim", "— key reference (press ? to close)")}`),
150
+ mid,
151
+ ];
152
+ for (const group of buildHelpGroups()) {
153
+ lines.push(row(fg("accent", group.title)));
154
+ for (let i = 0; i < group.entries.length; i += 2) {
155
+ const pair = group.entries.slice(i, i + 2);
156
+ const cell = (entry: HelpEntry) =>
157
+ `${this.theme.bold(pad(entry.keys, keyCol))}${fg("dim", truncate(entry.label, 16))}`;
158
+ lines.push(row(pair.map(cell).join(" ")));
159
+ }
160
+ }
161
+ lines.push(bot);
162
+ const box = new Box(0, 0);
163
+ for (const line of lines) box.addChild(new Text(line));
164
+ return box.render(width);
165
+ }
166
+ }
@@ -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
+ }