pi-crew 0.11.0 → 0.11.2

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 (174) hide show
  1. package/CHANGELOG.md +155 -9
  2. package/README.md +161 -1037
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -90644
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +39 -0
  11. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  12. package/skills/real-test-pi-crew/SKILL.md +371 -34
  13. package/src/agents/agent-config.ts +1 -1
  14. package/src/agents/discover-agents.ts +1 -1
  15. package/src/config/config-validation.ts +15 -2
  16. package/src/config/config.ts +47 -13
  17. package/src/config/defaults.ts +0 -1
  18. package/src/config/env-vars.ts +35 -0
  19. package/src/config/types.ts +19 -5
  20. package/src/errors.ts +2 -2
  21. package/src/extension/async-notifier.ts +23 -0
  22. package/src/extension/crew-vibes/config.ts +0 -21
  23. package/src/extension/crew-vibes/index.ts +0 -2
  24. package/src/extension/crew-vibes/render.ts +1 -50
  25. package/src/extension/help.ts +21 -12
  26. package/src/extension/knowledge-injection.ts +2 -1
  27. package/src/extension/management.ts +8 -3
  28. package/src/extension/notification-sink.ts +17 -0
  29. package/src/extension/register.ts +7 -2
  30. package/src/extension/registration/command-utils.ts +28 -2
  31. package/src/extension/registration/commands/dashboard.ts +11 -1
  32. package/src/extension/registration/commands/manage.ts +36 -19
  33. package/src/extension/registration/commands/run.ts +24 -2
  34. package/src/extension/registration/commands/shared.ts +23 -1
  35. package/src/extension/registration/commands/status.ts +25 -2
  36. package/src/extension/registration/context-builder.ts +8 -2
  37. package/src/extension/registration/health-notify-policy.ts +100 -0
  38. package/src/extension/registration/lazy-configurers.ts +35 -0
  39. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  40. package/src/extension/registration/lifecycle.ts +75 -10
  41. package/src/extension/registration/observability.ts +98 -35
  42. package/src/extension/registration/registration-types.ts +7 -5
  43. package/src/extension/registration/runtime-cleanup.ts +9 -3
  44. package/src/extension/registration/subagent-helpers.ts +38 -0
  45. package/src/extension/registration/subagent-tools.ts +16 -6
  46. package/src/extension/registration/team-tool.ts +10 -3
  47. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  48. package/src/extension/registration/viewers.ts +6 -0
  49. package/src/extension/registration/wire-cross-extension.ts +28 -0
  50. package/src/extension/run-compare.ts +220 -0
  51. package/src/extension/run-export.ts +37 -5
  52. package/src/extension/run-maintenance.ts +155 -5
  53. package/src/extension/team-tool/dispatch/index.ts +3 -2
  54. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  55. package/src/extension/team-tool/goal.ts +4 -1
  56. package/src/extension/team-tool/handle-settings.ts +33 -4
  57. package/src/extension/team-tool/health-monitor.ts +21 -7
  58. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  59. package/src/extension/team-tool/plan.ts +10 -0
  60. package/src/extension/team-tool/routing-hint.ts +63 -0
  61. package/src/extension/team-tool/status.ts +4 -0
  62. package/src/extension/team-tool.ts +52 -6
  63. package/src/extension/webhook-notify.ts +382 -0
  64. package/src/observability/metric-sink.ts +12 -2
  65. package/src/prompt/prompt-runtime.ts +82 -31
  66. package/src/prompt/worker-events-channel.ts +12 -0
  67. package/src/runtime/README.md +1 -1
  68. package/src/runtime/async-runner.ts +87 -1
  69. package/src/runtime/background-runner.ts +313 -234
  70. package/src/runtime/broker/crew-broker.ts +17 -11
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/wait-status-cache.ts +1 -1
  73. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  74. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  75. package/src/runtime/crew-agent-records.ts +337 -45
  76. package/src/runtime/deadletter.ts +43 -1
  77. package/src/runtime/delegate-spawn.ts +5 -1
  78. package/src/runtime/dispatch-batch.ts +72 -5
  79. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  80. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  81. package/src/runtime/model/model-fallback.ts +21 -1
  82. package/src/runtime/model/pi-args.ts +8 -10
  83. package/src/runtime/recovery/crash-recovery.ts +25 -1
  84. package/src/runtime/run-worker.ts +12 -1
  85. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  86. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  87. package/src/runtime/scheduling/scheduler.ts +49 -13
  88. package/src/runtime/scheduling/semaphore.ts +148 -20
  89. package/src/runtime/scratchpad/README.md +1 -1
  90. package/src/runtime/scratchpad/protocol.ts +1 -1
  91. package/src/runtime/settings-store.ts +1 -1
  92. package/src/runtime/skill-instructions.ts +22 -0
  93. package/src/runtime/stale-reconciler.ts +85 -13
  94. package/src/runtime/task-display.ts +1 -1
  95. package/src/runtime/task-runner/pre-execution.ts +26 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  97. package/src/runtime/task-runner.ts +21 -1
  98. package/src/runtime/team-runner.ts +38 -1
  99. package/src/runtime/workspace-lock.ts +4 -1
  100. package/src/schema/config-schema.ts +18 -0
  101. package/src/schema/team-tool-schema.ts +17 -0
  102. package/src/state/atomic-write.ts +53 -0
  103. package/src/state/contracts.ts +109 -0
  104. package/src/state/coordination/locks.ts +191 -33
  105. package/src/state/coordination/mailbox.ts +140 -15
  106. package/src/state/crew-init.ts +87 -12
  107. package/src/state/event-log/cursor.ts +37 -1
  108. package/src/state/event-log/event-log-rotation.ts +72 -7
  109. package/src/state/stores/active-run-registry.ts +13 -1
  110. package/src/state/stores/state-store.ts +112 -22
  111. package/src/state/types.ts +4 -0
  112. package/src/ui/adaptive-card.ts +65 -0
  113. package/src/ui/agents-jobs-browser.ts +70 -64
  114. package/src/ui/card-colors.ts +36 -7
  115. package/src/ui/dashboard-panes/agents-pane.ts +55 -14
  116. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  117. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  118. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  119. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  120. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  121. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  122. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  123. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  124. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  125. package/src/ui/dwf-phase-display.ts +3 -20
  126. package/src/ui/format-helpers.ts +22 -0
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/crew-editor.ts +13 -3
  129. package/src/ui/inline-panel/index.ts +60 -4
  130. package/src/ui/keybinding-map.ts +251 -35
  131. package/src/ui/live-conversation-overlay.ts +180 -47
  132. package/src/ui/live-run-sidebar.ts +134 -55
  133. package/src/ui/mascot.ts +32 -16
  134. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  135. package/src/ui/overlays/confirm-overlay.ts +55 -29
  136. package/src/ui/overlays/help-overlay.ts +108 -53
  137. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  138. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  139. package/src/ui/powerbar-publisher.ts +0 -1
  140. package/src/ui/rail.ts +333 -0
  141. package/src/ui/run-dashboard.ts +193 -79
  142. package/src/ui/run-snapshot-cache.ts +18 -1
  143. package/src/ui/settings-overlay.ts +81 -39
  144. package/src/ui/spinner.ts +26 -2
  145. package/src/ui/terminal-status.ts +7 -1
  146. package/src/ui/theme-adapter.ts +0 -45
  147. package/src/ui/theme-discovery.ts +12 -6
  148. package/src/ui/tool-progress-formatter.ts +128 -9
  149. package/src/ui/tool-renderers/brief-mode.ts +10 -67
  150. package/src/ui/tool-renderers/index.ts +374 -523
  151. package/src/ui/transcript-viewer.ts +30 -12
  152. package/src/ui/widget/index.ts +32 -52
  153. package/src/ui/widget/task-list.ts +64 -32
  154. package/src/ui/widget/widget-formatters.ts +3 -402
  155. package/src/ui/widget/widget-model.ts +28 -7
  156. package/src/ui/widget/widget-renderer.ts +201 -128
  157. package/src/ui/widget/widget-types.ts +0 -2
  158. package/src/utils/incremental-reader.ts +11 -3
  159. package/src/utils/paths.ts +94 -12
  160. package/src/utils/project-markers.ts +40 -0
  161. package/src/utils/visual.ts +0 -4
  162. package/src/worktree/worktree-manager.ts +206 -26
  163. package/workflows/distill.workflow.md +3 -3
  164. package/workflows/fast-fix.workflow.md +1 -1
  165. package/workflows/plan-execute.workflow.md +1 -1
  166. package/workflows/review.workflow.md +1 -1
  167. package/workflows/strict-fast-fix.workflow.md +1 -1
  168. package/docs/migration-v0.4-v0.5.md +0 -208
  169. package/docs/runtime-flow.md +0 -148
  170. package/src/extension/crew-vibes/figures.ts +0 -22
  171. package/src/extension/crew-vibes/font-detect.ts +0 -71
  172. package/src/ui/dynamic-border.ts +0 -35
  173. package/src/ui/loaders.ts +0 -6
  174. package/src/ui/overlay-stack.ts +0 -148
package/src/ui/mascot.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { matchesKey } from "@earendil-works/pi-tui";
2
- import { pad } from "../utils/visual.ts";
3
- import { DynamicCrewBorder } from "./dynamic-border.ts";
2
+ import { canopyLine, formatHint, RAIL, railLeaders, railLine } from "./rail.ts";
4
3
  import type { CrewTheme } from "./theme-adapter.ts";
5
4
  import { asCrewTheme } from "./theme-adapter.ts";
6
5
 
@@ -356,10 +355,8 @@ export class AnimatedMascot {
356
355
  this.onDone();
357
356
  }
358
357
 
359
- private formatLine(line: string, width: number, color: Parameters<CrewTheme["fg"]>[0] = "accent"): string {
360
- const contentWidth = Math.max(0, width - 4);
361
- const themed = this.theme.fg(color, line);
362
- return `│ ${pad(themed, contentWidth)} │`;
358
+ private formatLine(line: string, budget: number, color: Parameters<CrewTheme["fg"]>[0] = "accent"): string {
359
+ return railLine(RAIL.body, "border", this.theme.fg(color, line), this.theme, budget);
363
360
  }
364
361
 
365
362
  private currentCatFrame(): readonly string[] {
@@ -397,25 +394,44 @@ export class AnimatedMascot {
397
394
  return this.cachedLines;
398
395
  }
399
396
  const safeWidth = Math.max(20, width);
400
- const horizontal = new DynamicCrewBorder(this.theme).render(Math.max(0, safeWidth - 2))[0];
397
+ // M4/RAIL (2026-09-16): the rounded box + DynamicCrewBorder were retired;
398
+ // this surface paints the same rail frame as every other pi-crew surface.
399
+ const budget = Math.max(4, safeWidth - 2);
401
400
  const result: string[] = [
402
- `${this.theme.fg("border", "╭")}${horizontal}${this.theme.fg("border", "╮")}`,
403
- this.formatLine(this.theme.bold(" ARMIN SAYS HI "), safeWidth),
404
- this.formatLine("", safeWidth),
401
+ canopyLine({ word: "MASCOT", subject: this.style, theme: this.theme, budget }),
402
+ this.formatLine(this.theme.bold("ARMIN SAYS HI"), budget),
403
+ this.formatLine("", budget),
405
404
  ];
406
405
  if (this.style === "armin") {
407
406
  for (const row of this.currentArminGrid) {
408
407
  const text = row.join("");
409
- result.push(this.formatLine(text, safeWidth));
408
+ result.push(this.formatLine(text, budget));
410
409
  }
411
410
  } else {
412
411
  const frameLines = this.applyCatEffect(this.currentCatFrame());
413
- for (const line of frameLines) result.push(this.formatLine(line, safeWidth));
412
+ for (const line of frameLines) result.push(this.formatLine(line, budget));
414
413
  }
415
- const hint =
416
- this.style === "armin" ? `Press q or Esc to close · effect: ${this.effect}` : "Press q or Esc to close · animated preview";
417
- result.push(this.formatLine(hint, safeWidth, "muted"));
418
- result.push(`${this.theme.fg("border", "╰")}${horizontal}${this.theme.fg("border", "╯")}`);
414
+ const state = this.style === "armin" ? `effect: ${this.effect}` : "animated preview";
415
+ result.push(
416
+ railLine(
417
+ RAIL.close,
418
+ "border",
419
+ railLeaders(
420
+ this.theme.fg(
421
+ "dim",
422
+ formatHint([
423
+ ["esc", "close"],
424
+ ["q", "close"],
425
+ ]),
426
+ ),
427
+ this.theme.fg("dim", state),
428
+ budget,
429
+ this.theme,
430
+ ),
431
+ this.theme,
432
+ budget,
433
+ ),
434
+ );
419
435
  this.cachedWidth = safeWidth;
420
436
  this.cachedVersion = this.gridVersion;
421
437
  this.cachedFrame = this.frame;
@@ -1,8 +1,30 @@
1
+ /**
2
+ * Agent picker overlay — RAIL design language (M4/E1, 2026-09-16).
3
+ *
4
+ * ┏ AGENTS ▸ <runId8>
5
+ * ┃ › explorer · running · executor ▸ worker
6
+ * ┗ ↑/↓ move · Enter select · Esc cancel
7
+ *
8
+ * Grammar: `canopyLine` opens the surface, every body row is a `┃` line built
9
+ * by `railLine` (which owns the width budget), the close cap carries the hint
10
+ * built by `formatHint` (close LAST, keys through `keyToken`). The rounded
11
+ * `╭─╮│╰─╯` frame and the hand-typed "↑/↓ move · Enter select · ESC cancel"
12
+ * string are retired.
13
+ *
14
+ * Keys/behaviour are unchanged: same `overlay:*` keyspace dispatch, same
15
+ * selection and clamping.
16
+ *
17
+ * File-sourced risk (audit §4): `agents.json` is NOT schema-validated at read
18
+ * time, so every interpolated record field is guarded (`?? "?"`) before it can
19
+ * reach a line — a missing field must never print as `undefined`.
20
+ */
21
+
1
22
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
2
23
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
3
24
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
4
- import { pad, truncate } from "../../utils/visual.ts";
5
- import { keyOf, matchesKey } from "../key-utils.ts";
25
+ import { truncate } from "../../utils/visual.ts";
26
+ import { overlayActionForKey } from "../keybinding-map.ts";
27
+ import { ACTIVE, CURSOR, canopyLine, formatHint, RAIL, railLine, shortId } from "../rail.ts";
6
28
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
7
29
 
8
30
  export interface AgentPickerSelection {
@@ -13,6 +35,7 @@ export class AgentPickerOverlay {
13
35
  private readonly agents: CrewAgentRecord[];
14
36
  private readonly done: (selection: AgentPickerSelection | undefined) => void;
15
37
  private readonly theme: CrewTheme;
38
+ private readonly runId: string;
16
39
  private selected = 0;
17
40
 
18
41
  constructor(opts: {
@@ -25,6 +48,7 @@ export class AgentPickerOverlay {
25
48
  this.agents = loaded ? readCrewAgents(loaded.manifest) : [];
26
49
  this.done = opts.done;
27
50
  this.theme = asCrewTheme(opts.theme ?? {});
51
+ this.runId = opts.runId;
28
52
  }
29
53
 
30
54
  invalidate(): void {
@@ -32,35 +56,66 @@ export class AgentPickerOverlay {
32
56
  }
33
57
 
34
58
  render(width: number): string[] {
35
- const inner = Math.max(24, width - 4);
36
- const lines = [
37
- this.theme.bold("Select agent"),
38
- "↑/↓ move · Enter select · ESC cancel",
39
- ...this.agents.map(
40
- (agent, index) =>
41
- `${index === this.selected ? "›" : " "} ${agent.taskId} · ${agent.status} · ${agent.role}->${agent.agent}`,
59
+ if (width < 6) return [];
60
+ const theme = this.theme;
61
+ const budget = width - 2;
62
+ const lines: string[] = [
63
+ canopyLine({ word: "AGENTS", subject: shortId(this.runId), theme, budget }),
64
+ ...this.agents.map((agent, index) =>
65
+ railLine(
66
+ RAIL.body,
67
+ "border",
68
+ truncate(
69
+ `${index === this.selected ? CURSOR : " "} ${agent.taskId ?? "?"} · ${agent.status ?? "?"} · ${
70
+ agent.role ?? "?"
71
+ } ${ACTIVE} ${agent.agent ?? "?"}`,
72
+ budget,
73
+ ),
74
+ theme,
75
+ budget,
76
+ ),
42
77
  ),
43
78
  ];
44
- if (!this.agents.length) lines.push("No agents found.");
45
- return lines.map((line) => pad(truncate(line, inner), inner));
79
+ if (!this.agents.length) {
80
+ lines.push(railLine(RAIL.body, "border", theme.fg("dim", "No agents found."), theme, budget));
81
+ }
82
+ lines.push(
83
+ railLine(
84
+ RAIL.close,
85
+ "border",
86
+ theme.fg(
87
+ "dim",
88
+ formatHint([
89
+ [["up", "down"], "move"],
90
+ ["enter", "select"],
91
+ ["escape", "cancel"],
92
+ ]),
93
+ ),
94
+ theme,
95
+ budget,
96
+ ),
97
+ );
98
+ return lines;
46
99
  }
47
100
 
48
101
  handleInput(data: string): void {
49
- if (matchesKey(data, "escape") || data === "q") {
50
- this.done(undefined);
51
- return;
52
- }
53
- if (data === "k" || keyOf(data) === "up") {
54
- this.selected = Math.max(0, this.selected - 1);
55
- return;
56
- }
57
- if (data === "j" || keyOf(data) === "down") {
58
- this.selected = Math.min(Math.max(0, this.agents.length - 1), this.selected + 1);
59
- return;
60
- }
61
- if (matchesKey(data, "return")) {
62
- const agent = this.agents[this.selected];
63
- this.done(agent ? { agentId: agent.taskId } : undefined);
102
+ // M2-1: keys come from the central `overlay:*` keyspace (remappable via
103
+ // `.crew/config.json` → keybindings["overlay:agent-picker:<action>"]).
104
+ switch (overlayActionForKey("agent-picker", data)) {
105
+ case "close":
106
+ this.done(undefined);
107
+ return;
108
+ case "up":
109
+ this.selected = Math.max(0, this.selected - 1);
110
+ return;
111
+ case "down":
112
+ this.selected = Math.min(Math.max(0, this.agents.length - 1), this.selected + 1);
113
+ return;
114
+ case "select": {
115
+ const agent = this.agents[this.selected];
116
+ this.done(agent ? { agentId: agent.taskId } : undefined);
117
+ return;
118
+ }
64
119
  }
65
120
  }
66
121
  }
@@ -1,6 +1,23 @@
1
- import { pad, truncate } from "../../utils/visual.ts";
2
- import { matchesKey } from "../key-utils.ts";
3
- import { Box, Text } from "../layout-primitives.ts";
1
+ /**
2
+ * Confirm dialog overlay — RAIL design language (M4/E1, 2026-09-16).
3
+ *
4
+ * ┏ CONFIRM ▸ Delete?
5
+ * ┃ Danger
6
+ * ┗ Y confirm · N/Esc cancel
7
+ *
8
+ * The rounded `╭─╮├─┤│╰─╯` box (and its inline `├───┤` rules) is retired; the
9
+ * surface is now canopy / `┃` body / `┗` close cap. The rail colour carries the
10
+ * danger level (high → error, medium → warning, else neutral border) instead of
11
+ * colouring only the title, and the hint is built by `formatHint` (cancel LAST,
12
+ * keys through `keyToken` → `Esc`, never `ESC`).
13
+ *
14
+ * Keys/behaviour are unchanged: same `overlay:*` keyspace dispatch, Enter stays
15
+ * dual-role exactly as before.
16
+ */
17
+
18
+ import { truncate } from "../../utils/visual.ts";
19
+ import { overlayActionForKey } from "../keybinding-map.ts";
20
+ import { canopyLine, formatHint, RAIL, type RailSlot, railLine } from "../rail.ts";
4
21
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
5
22
 
6
23
  export interface ConfirmOptions {
@@ -26,36 +43,45 @@ export class ConfirmOverlay {
26
43
  }
27
44
 
28
45
  render(width: number): string[] {
29
- const innerWidth = Math.max(24, Math.min(width - 4, 72));
30
- const color = this.opts.dangerLevel === "high" ? "error" : this.opts.dangerLevel === "medium" ? "warning" : "accent";
31
- const title = this.theme.bold(this.theme.fg(color, this.opts.title));
32
- const hint = this.opts.defaultAction === "confirm" ? "Enter/Y confirm · N/ESC cancel" : "Y confirm · Enter/N/ESC cancel";
46
+ if (width < 6) return [];
47
+ const theme = this.theme;
48
+ const budget = width - 2;
49
+ // Rail colour = state (§1): the danger level is the state of a confirm.
50
+ const slot: RailSlot = this.opts.dangerLevel === "high" ? "error" : this.opts.dangerLevel === "medium" ? "warning" : "border";
51
+ const hint =
52
+ this.opts.defaultAction === "confirm"
53
+ ? formatHint([
54
+ [["enter", "y"], "confirm"],
55
+ [["n", "escape"], "cancel"],
56
+ ])
57
+ : formatHint([
58
+ ["y", "confirm"],
59
+ [["enter", "n", "escape"], "cancel"],
60
+ ]);
33
61
  const bodyLines = (this.opts.body ?? "").split(/\r?\n/).filter(Boolean);
34
- const lines = [
35
- `╭${"─".repeat(innerWidth)}╮`,
36
- `│ ${pad(truncate(title, innerWidth - 1), innerWidth - 1)}│`,
37
- `├${"─".repeat(innerWidth)}┤`,
38
- ...(bodyLines.length ? bodyLines : ["Are you sure?"]).map(
39
- (line) => `│ ${pad(truncate(line, innerWidth - 1), innerWidth - 1)}│`,
40
- ),
41
- `├${"─".repeat(innerWidth)}┤`,
42
- `│ ${pad(truncate(this.theme.fg("dim", hint), innerWidth - 1), innerWidth - 1)}│`,
43
- `╰${"─".repeat(innerWidth)}╯`,
44
- ];
45
- const box = new Box(0, 0);
46
- for (const line of lines) box.addChild(new Text(line));
47
- return box.render(width);
62
+ const lines: string[] = [canopyLine({ word: "CONFIRM", subject: this.opts.title ?? "?", theme, budget, slot })];
63
+ for (const line of bodyLines.length ? bodyLines : ["Are you sure?"]) {
64
+ lines.push(railLine(RAIL.body, slot, truncate(line, budget), theme, budget));
65
+ }
66
+ lines.push(railLine(RAIL.close, slot, theme.fg("dim", hint), theme, budget));
67
+ return lines;
48
68
  }
49
69
 
50
70
  handleInput(data: string): void {
51
- if (data === "y" || data === "Y") {
52
- this.done(true);
53
- return;
54
- }
55
- if (matchesKey(data, "return") && this.opts.defaultAction === "confirm") {
56
- this.done(true);
57
- return;
71
+ // M2-1: keys come from the central `overlay:*` keyspace (remappable via
72
+ // `.crew/config.json` → keybindings["overlay:confirm:<action>"]).
73
+ switch (overlayActionForKey("confirm", data)) {
74
+ case "confirm":
75
+ this.done(true);
76
+ return;
77
+ // Enter is dual-role: it confirms when the overlay defaults to confirm
78
+ // and cancels otherwise (byte-identical to the pre-M2-1 chain).
79
+ case "submit":
80
+ this.done(this.opts.defaultAction === "confirm");
81
+ return;
82
+ case "cancel":
83
+ this.done(false);
84
+ return;
58
85
  }
59
- if (data === "n" || data === "N" || matchesKey(data, "escape") || data === "q" || matchesKey(data, "return")) this.done(false);
60
86
  }
61
87
  }
@@ -1,52 +1,51 @@
1
1
  /**
2
- * K-1 — dashboard keybinding cheatsheet overlay.
2
+ * K-1 — dashboard keybinding cheatsheet overlay (RAIL design language, M4/E1).
3
3
  *
4
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.
5
+ * keybindings grouped by scope (general / navigation / panes / schedules /
6
+ * plan / run actions / mailbox / health) directly from `DASHBOARD_KEYS`, so
7
+ * adding a key in one place is reflected here automatically.
8
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).
9
+ * Grammar (docs/UI-DESIGN-SYSTEM.md §2.E):
14
10
  *
15
- * Template origin: `confirm-overlay.ts`.
11
+ * ┏ HELP ▸ dashboard
12
+ * ┣ GENERAL
13
+ * ┃ Q/Esc close dashboard Enter/S/Tab/Space open run status
14
+ * ┗ ? toggle · Esc dismiss
15
+ *
16
+ * The legacy rounded box (`╭─╮│╰─╯├─┤`) is retired: the overlay is a canopy +
17
+ * `┣ SECTION` group headers + `┃` rows. Each group title is a RAIL section
18
+ * (replacing the old accent-only row), and the always-visible column key/token
19
+ * pair now goes through the SHARED `keyToken()` from `src/ui/rail.ts`.
20
+ *
21
+ * Why that mattered: the old file-local `keyToken` had no `case "\t"` branch, so
22
+ * `DASHBOARD_KEYS.select`'s raw tab byte was rendered verbatim into the table
23
+ * and knocked the row's alignment out by one column (audit §4). The shared
24
+ * tokenizer maps it to `Tab` (and `Esc`/`↑/↓`/`PgUp`/`Space` consistently).
25
+ *
26
+ * Width contract: `render(width)` — every line is built through `railLine`,
27
+ * which owns the `glyph + space` separator and never emits a line wider than
28
+ * the render width. Nothing is baked at build time.
16
29
  */
17
30
 
18
- import { pad, truncate } from "../../utils/visual.ts";
31
+ import { pad, truncate, visibleWidth } from "../../utils/visual.ts";
19
32
  import { DASHBOARD_KEYS } from "../keybinding-map.ts";
20
- import { Box, Text } from "../layout-primitives.ts";
33
+ import { canopyLine, formatHint, keyToken, RAIL, railLine, sectionLine } from "../rail.ts";
21
34
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
22
35
 
23
- /** Translate a raw key sequence into a readable token for the cheatsheet. */
24
- function keyToken(key: string): string {
25
- switch (key) {
26
- case "\u001b":
27
- return "Esc";
28
- case "\u001b[A":
29
- return "↑";
30
- case "\u001b[B":
31
- return "↓";
32
- case "\r":
33
- case "\n":
34
- return "Enter";
35
- default:
36
- return key;
37
- }
38
- }
39
-
36
+ /** Translate a raw key sequence into a readable token for the cheatsheet.
37
+ * Delegates to the SHARED `keyToken` (rail.ts) so the cheatsheet, the footer
38
+ * hints and the tool cards can never drift apart. */
40
39
  function keyList(keys: readonly string[]): string {
41
- return [...keys].map(keyToken).join("/");
40
+ return [...new Set(keys.map((key) => keyToken(key)))].join("/");
42
41
  }
43
42
 
44
- interface HelpEntry {
43
+ export interface HelpEntry {
45
44
  readonly keys: string;
46
45
  readonly label: string;
47
46
  }
48
47
 
49
- interface HelpGroup {
48
+ export interface HelpGroup {
50
49
  readonly title: string;
51
50
  readonly entries: readonly HelpEntry[];
52
51
  }
@@ -62,7 +61,24 @@ const ROOT_LABELS: Record<string, string> = {
62
61
  transcript: "transcript",
63
62
  liveConversation: "live conv",
64
63
  reload: "reload",
65
- progressToggle: "progress",
64
+ // NOTE: `progressToggle` deliberately has no label here — the `p` binding
65
+ // had no visible effect (P1-5, phantom key) and is being removed from
66
+ // keybinding-map.ts. Entries derive from Object.entries(DASHBOARD_KEYS.root),
67
+ // so the row disappears entirely once that key is gone.
68
+ };
69
+
70
+ const SCHEDULES_LABELS: Record<string, string> = {
71
+ toggle: "toggle",
72
+ runNow: "run now",
73
+ details: "details",
74
+ delete: "delete",
75
+ refresh: "refresh",
76
+ };
77
+
78
+ const PLAN_LABELS: Record<string, string> = {
79
+ approve: "approve",
80
+ deny: "deny",
81
+ diff: "diff",
66
82
  };
67
83
 
68
84
  const MAILBOX_LABELS: Record<string, string> = {
@@ -88,6 +104,16 @@ function buildHelpGroups(): HelpGroup[] {
88
104
  keys: keyList(keys),
89
105
  label: ROOT_LABELS[name] ?? name,
90
106
  }));
107
+ // P1-14: pane 8 (schedules) and pane 7 (plan) have pane-scoped action keys
108
+ // that were missing from this cheatsheet entirely.
109
+ const scheduleEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.schedules).map(([name, keys]) => ({
110
+ keys: keyList(keys),
111
+ label: SCHEDULES_LABELS[name] ?? name,
112
+ }));
113
+ const planEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.plan).map(([name, keys]) => ({
114
+ keys: keyList(keys),
115
+ label: PLAN_LABELS[name] ?? name,
116
+ }));
91
117
  const mailboxEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.mailbox)
92
118
  .filter(([name]) => name !== "openDetail")
93
119
  .map(([name, keys]) => ({
@@ -117,12 +143,18 @@ function buildHelpGroups(): HelpGroup[] {
117
143
  title: "Navigation",
118
144
  entries: [
119
145
  {
120
- keys: `${keyList(DASHBOARD_KEYS.navigation.up)}/${keyList(DASHBOARD_KEYS.navigation.down)}`,
146
+ // Union of the up/down aliases, deduped through the shared
147
+ // `keyToken` (`k`/`up` and `j`/`down` collapse to one `↑/↓`).
148
+ keys: [
149
+ ...new Set([...DASHBOARD_KEYS.navigation.up, ...DASHBOARD_KEYS.navigation.down].map((key) => keyToken(key))),
150
+ ].join("/"),
121
151
  label: "move selection",
122
152
  },
123
153
  ],
124
154
  },
125
155
  { title: "Panes", entries: paneEntries },
156
+ { title: "Schedules (pane 8)", entries: scheduleEntries },
157
+ { title: "Plan (pane 7)", entries: planEntries },
126
158
  { title: "Run actions", entries: rootEntries },
127
159
  { title: "Mailbox (pane 3)", entries: mailboxEntries },
128
160
  {
@@ -138,6 +170,21 @@ function buildHelpGroups(): HelpGroup[] {
138
170
  ];
139
171
  }
140
172
 
173
+ /** Test seam (mirrors `__test__openPane` / `__resetInlinePanelForTest`): the
174
+ * generated groups, so tests can assert keys/labels without parsing the
175
+ * rendered surface. The overlay itself renders through `buildHelpGroups()`. */
176
+ export function __test__buildHelpGroups(): HelpGroup[] {
177
+ return buildHelpGroups();
178
+ }
179
+
180
+ /** Width of the key column: the widest rendered token list, capped at 20 so a
181
+ * future key set cannot push the labels off the row. Derived from the data —
182
+ * never a hand-tuned constant that silently misaligns when a key is added. */
183
+ function keyColumnWidth(groups: readonly HelpGroup[]): number {
184
+ const widths = groups.flatMap((group) => group.entries.map((entry) => visibleWidth(entry.keys)));
185
+ return Math.min(20, Math.max(1, ...widths));
186
+ }
187
+
141
188
  export class HelpOverlay {
142
189
  private readonly theme: CrewTheme;
143
190
 
@@ -150,28 +197,36 @@ export class HelpOverlay {
150
197
  }
151
198
 
152
199
  render(width: number): string[] {
153
- // MUST mirror run-dashboard.ts's innerWidth so the dashboard's
154
- // stable-height blank-padding rows align with this overlay's borders.
155
- const innerWidth = Math.max(20, width - 4);
156
- const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
157
- const row = (text: string) => `│ ${pad(truncate(text, innerWidth - 1), innerWidth - 1)}│`;
158
- const bar = "─".repeat(innerWidth);
159
- const top = fg("border", `╭${bar}╮`);
160
- const mid = fg("border", `├${bar}┤`);
161
- const bot = fg("border", `╰${bar}╯`);
162
- const keyCol = 9;
163
- const lines: string[] = [top, row(`${fg("accent", "pi-crew dashboard")} ${fg("dim", "— key reference (press ? to close)")}`), mid];
164
- for (const group of buildHelpGroups()) {
165
- lines.push(row(fg("accent", group.title)));
200
+ if (width < 6) return [];
201
+ const theme = this.theme;
202
+ const budget = width - 2;
203
+ const groups = buildHelpGroups();
204
+ const keyCol = keyColumnWidth(groups);
205
+ const lines: string[] = [canopyLine({ word: "HELP", subject: "dashboard", theme, budget })];
206
+ for (const group of groups) {
207
+ lines.push(sectionLine({ name: group.title, theme, budget }));
166
208
  for (let i = 0; i < group.entries.length; i += 2) {
167
209
  const pair = group.entries.slice(i, i + 2);
168
- const cell = (entry: HelpEntry) => `${this.theme.bold(pad(entry.keys, keyCol))}${fg("dim", truncate(entry.label, 16))}`;
169
- lines.push(row(pair.map(cell).join(" ")));
210
+ const cell = (entry: HelpEntry) =>
211
+ `${theme.bold(pad(truncate(entry.keys, keyCol), keyCol))} ${theme.fg("dim", truncate(entry.label, 16))}`;
212
+ lines.push(railLine(RAIL.body, "border", truncate(pair.map(cell).join(" "), budget), theme, budget));
170
213
  }
171
214
  }
172
- lines.push(bot);
173
- const box = new Box(0, 0);
174
- for (const line of lines) box.addChild(new Text(line));
175
- return box.render(width);
215
+ lines.push(
216
+ railLine(
217
+ RAIL.close,
218
+ "border",
219
+ theme.fg(
220
+ "dim",
221
+ formatHint([
222
+ ["?", "toggle"],
223
+ ["escape", "dismiss"],
224
+ ]),
225
+ ),
226
+ theme,
227
+ budget,
228
+ ),
229
+ );
230
+ return lines;
176
231
  }
177
232
  }