pi-crew 0.9.11 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +1 -135
  3. package/package.json +1 -1
  4. package/src/extension/crew-shortcuts.ts +29 -2
  5. package/src/extension/knowledge-injection.ts +236 -15
  6. package/src/extension/registration/commands.ts +61 -34
  7. package/src/extension/team-tool/chain-dispatch.ts +103 -0
  8. package/src/extension/team-tool/chain-executor.ts +400 -0
  9. package/src/extension/team-tool/run.ts +9 -0
  10. package/src/runtime/agent-memory.ts +5 -2
  11. package/src/runtime/background-runner.ts +49 -0
  12. package/src/runtime/chain-runner.ts +47 -8
  13. package/src/runtime/child-pi.ts +33 -0
  14. package/src/runtime/handoff-manager.ts +7 -0
  15. package/src/runtime/live-session-runtime.ts +10 -4
  16. package/src/runtime/pi-json-output.ts +5 -1
  17. package/src/runtime/process-status.ts +7 -2
  18. package/src/runtime/task-output-context.ts +36 -7
  19. package/src/runtime/task-runner/prompt-builder.ts +5 -1
  20. package/src/runtime/task-runner.ts +7 -0
  21. package/src/schema/team-tool-schema.ts +9 -0
  22. package/src/ui/crew-footer.ts +3 -3
  23. package/src/ui/crew-select-list.ts +1 -1
  24. package/src/ui/dashboard-panes/agents-pane.ts +26 -4
  25. package/src/ui/dashboard-panes/cancellation-pane.ts +23 -0
  26. package/src/ui/keybinding-map.ts +7 -3
  27. package/src/ui/live-conversation-overlay.ts +2 -2
  28. package/src/ui/live-run-sidebar.ts +18 -10
  29. package/src/ui/overlays/help-overlay.ts +166 -0
  30. package/src/ui/run-dashboard.ts +210 -70
  31. package/src/ui/status-colors.ts +45 -0
  32. package/src/ui/widget/index.ts +46 -3
  33. package/src/ui/widget/widget-formatters.ts +22 -7
  34. package/src/ui/widget/widget-renderer.ts +31 -27
  35. package/src/utils/visual.ts +3 -1
@@ -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;
@@ -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);