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/rail.ts ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * RAIL design system — the shared visual language for EVERY pi-crew surface.
3
+ *
4
+ * Extracted from the tool-card renderer (R3, 2026-09-16) so the widget, the
5
+ * dashboard, the agents & jobs browser, the settings overlay, the mailbox /
6
+ * help / confirm / live-conversation overlays and the dashboard panes all
7
+ * speak one grammar instead of each inventing its own frame.
8
+ *
9
+ * ── Grammar ───────────────────────────────────────────────────────────
10
+ *
11
+ * ┏ ┣ ┃ ┗ one vertical rail down the LEFT edge marks a surface.
12
+ * `┏` opens it (identity), `┣` starts a section, `┃` continues
13
+ * it, `┗` closes it (outcome + end cap). The rail colour
14
+ * carries state: neutral idle, accent active, green success,
15
+ * red failure, warning attention.
16
+ *
17
+ * `NAME ▸ SUBJECT` the identity canopy: `┏ CREW ▸ implementation`,
18
+ * `┏ AGENTS ▸ a3f9c1d2`, `┏ HELP ▸ dashboard`.
19
+ *
20
+ * `······` dot leaders join a left and a right segment
21
+ * (`left ······ right`). They absorb all slack and
22
+ * collapse to a two-space gap on a narrow column, so
23
+ * the right segment (elapsed / key hint) never moves
24
+ * and never wraps.
25
+ *
26
+ * `▕████▎░░▏` gauge with EIGHTH-BLOCK sub-cell precision.
27
+ *
28
+ * `›` cursor · `▸` active/section marker · `▲ n above` / `▼ m below`
29
+ * the single selection, active and overflow dialects.
30
+ *
31
+ * ── Width contract ────────────────────────────────────────────────────
32
+ *
33
+ * Every helper takes an explicit `budget` (content width = render width
34
+ * minus the 2 columns of `glyph + space`) and NEVER emits a line wider than
35
+ * it. Callers that render into a live TUI must defer width through
36
+ * `AdaptiveCard` (see src/ui/adaptive-card.ts) rather than baking a width at
37
+ * build time — that is the R2 lesson: a frame built for 116 columns tears
38
+ * apart at 100.
39
+ */
40
+
41
+ import { visibleWidth } from "@earendil-works/pi-tui";
42
+ import { truncateToWidth } from "../utils/visual.ts";
43
+ import type { CrewTheme, CrewThemeColor } from "./theme-adapter.ts";
44
+
45
+ // ── Glyph vocabulary ───────────────────────────────────────────────────
46
+
47
+ /** Rail glyphs: open, section, continue, close. */
48
+ export const RAIL = { open: "┏", section: "┣", body: "┃", close: "┗" } as const;
49
+
50
+ /**
51
+ * Pi's own chord for collapsing/expanding a tool output block — the only key
52
+ * that ACTUALLY expands a pi-crew card, so it is what the card caps advertise.
53
+ *
54
+ * Verified against pi 0.85.1 `docs/keybindings.md`: `app.tools.expand` = `ctrl+o`.
55
+ * The cards used to print `⌘E`, which pi binds to `tui.editor.cursorLineEnd`
56
+ * (move the EDITOR cursor to end of line) — a phantom affordance: pressing it
57
+ * moved the cursor and left the card collapsed (live 2026-09-16).
58
+ */
59
+ export const PI_EXPAND_CHORD = "ctrl+o";
60
+
61
+ /** Selection cursor (lists) and active/step marker (sections, goals). */
62
+ export const CURSOR = "›";
63
+ export const ACTIVE = "▸";
64
+
65
+ /** Canonical overflow dialect — the ONLY one; the old `↑ 3 more above`
66
+ * / `… 3 above` / `↓2` variants are retired. */
67
+ export const OVERFLOW = { above: "▲", below: "▼" } as const;
68
+
69
+ // ── ANSI-aware width helpers ───────────────────────────────────────────
70
+
71
+ /** Pad a string (which may contain ANSI codes) to a target VISUAL width. */
72
+ export function padVisual(str: string, targetWidth: number): string {
73
+ const vw = visibleWidth(str);
74
+ if (vw >= targetWidth) return str;
75
+ return str + " ".repeat(targetWidth - vw);
76
+ }
77
+
78
+ /** Truncate a string (which may contain ANSI codes) to a target VISUAL width. */
79
+ export function truncVisual(str: string, maxWidth: number): string {
80
+ if (visibleWidth(str) <= maxWidth) return str;
81
+ // Round 23 (BUG 3): String.slice counts UTF-16 code units — for CJK that
82
+ // overflows by up to 2x and for emoji it splits a surrogate pair. Use the
83
+ // grapheme/ANSI-aware truncateToWidth with empty ellipsis (the caller
84
+ // appends its own '…').
85
+ return truncateToWidth(str, maxWidth, "");
86
+ }
87
+
88
+ /** Short id (last 8 chars — the disambiguating suffix). */
89
+ export function shortId(id: string | undefined): string {
90
+ return id ? id.slice(-8) : "????????";
91
+ }
92
+
93
+ // ── Rail slots ─────────────────────────────────────────────────────────
94
+
95
+ export type RailSlot = "border" | "borderAccent" | "success" | "error" | "warning";
96
+
97
+ /** Status → rail colour. One mapping for every surface (was: per-surface maps). */
98
+ export function statusSlot(status: string | undefined): RailSlot {
99
+ switch (status) {
100
+ case "completed":
101
+ case "done":
102
+ case "succeeded":
103
+ case "passed":
104
+ case "healthy":
105
+ return "success";
106
+ case "failed":
107
+ case "cancelled":
108
+ case "error":
109
+ case "stopped":
110
+ case "dead":
111
+ return "error";
112
+ case "running":
113
+ case "in_progress":
114
+ case "streaming":
115
+ return "borderAccent";
116
+ case "waiting":
117
+ case "needs_attention":
118
+ case "stale":
119
+ case "pending":
120
+ return "warning";
121
+ default:
122
+ return "border";
123
+ }
124
+ }
125
+
126
+ /** One rail line: `┃ <content padded to budget>`. The glyph+space separator is
127
+ * owned HERE so callers pass bare content and no line can spill past the rail. */
128
+ export function railLine(glyph: string, slot: RailSlot, content: string, theme: CrewTheme, budget: number): string {
129
+ return theme.fg(slot, glyph) + " " + padVisual(truncVisual(content, budget), budget);
130
+ }
131
+
132
+ /** Content-only rail line (no padding) — for callers that pad themselves. */
133
+ export function railRaw(glyph: string, slot: RailSlot, content: string, theme: CrewTheme): string {
134
+ return `${theme.fg(slot, glyph)} ${content}`;
135
+ }
136
+
137
+ /**
138
+ * Left/right segments joined by dot leaders: `left ······· right`.
139
+ * Leaders absorb all slack; on a tight column the left side is truncated so
140
+ * the right segment (elapsed / key hint) always stays visible.
141
+ */
142
+ export function railLeaders(left: string, right: string, budget: number, theme: CrewTheme): string {
143
+ const slack = budget - visibleWidth(left) - visibleWidth(right) - 2;
144
+ if (slack >= 3) {
145
+ const dots = theme.fg("dim", "·".repeat(Math.min(slack, 120)));
146
+ return `${left} ${dots} ${right}`;
147
+ }
148
+ if (slack > 0) return `${left} ${right}`;
149
+ const rightW = visibleWidth(right);
150
+ const leftBudget = budget - rightW - 2;
151
+ // When the left segment has to be cut, mark the elision with `…` — a hard
152
+ // cut mid-word reads as a rendering bug (`Esc/Q clos auto-scroll`).
153
+ if (leftBudget > 4) return `${truncVisual(left, leftBudget - 1)}… ${right}`;
154
+ return truncVisual(`${left} ${right}`, budget);
155
+ }
156
+
157
+ /** `┏ NAME ▸ SUBJECT` — the identity canopy, shared by every surface. */
158
+ export function canopyLine(args: {
159
+ word: string;
160
+ subject?: string;
161
+ theme: CrewTheme;
162
+ budget: number;
163
+ slot?: RailSlot;
164
+ glyph?: string;
165
+ /** Trailing right-aligned segment (e.g. `1-8 pane · ? help`), dot-led. */
166
+ right?: string;
167
+ }): string {
168
+ const { word, subject, theme, budget, slot = "border", glyph = RAIL.open, right } = args;
169
+ const tail = subject ? ` ${theme.fg("dim", ACTIVE)} ${theme.fg("toolTitle", theme.bold(subject))}` : "";
170
+ const label = `${theme.fg("accent", theme.bold(word))}${tail}`;
171
+ if (right) return railLine(glyph, slot, railLeaders(label, right, budget, theme), theme, budget);
172
+ return railLine(glyph, slot, label, theme, budget);
173
+ }
174
+
175
+ /** `┣ SECTION ▸ subject` — replaces the legacy `── label ──` inline rule. */
176
+ export function sectionLine(args: {
177
+ name: string;
178
+ subject?: string;
179
+ theme: CrewTheme;
180
+ budget: number;
181
+ slot?: RailSlot;
182
+ right?: string;
183
+ }): string {
184
+ const { name, subject, theme, budget, slot = "border", right } = args;
185
+ const tail = subject ? ` ${theme.fg("dim", ACTIVE)} ${theme.fg("muted", subject)}` : "";
186
+ const label = `${theme.fg("border", theme.bold(name.toUpperCase()))}${tail}`;
187
+ if (right) return railLine(RAIL.section, slot, railLeaders(label, right, budget, theme), theme, budget);
188
+ return railLine(RAIL.section, slot, label, theme, budget);
189
+ }
190
+
191
+ /** `▕████▎░░▏` — eighth-block sub-cell precision, truthful on narrow columns. */
192
+ const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"] as const;
193
+
194
+ export function gaugeBar(ratio: number, barWidth: number, theme: CrewTheme, fill: CrewThemeColor = "success"): string {
195
+ const clamped = Math.max(0, Math.min(1, ratio));
196
+ const cells = clamped * barWidth;
197
+ const full = Math.floor(cells);
198
+ const partial = EIGHTHS[Math.round((cells - full) * 8)] ?? "";
199
+ const filledCells = full + (partial ? 1 : 0);
200
+ const empty = Math.max(0, barWidth - filledCells);
201
+ const body = theme.fg(fill, "█".repeat(full) + partial);
202
+ const rest = theme.fg("dim", "░".repeat(empty));
203
+ return `${theme.fg("dim", "▕")}${body}${rest}${theme.fg("dim", "▏")}`;
204
+ }
205
+
206
+ /** Animated scanning gauge for indeterminate progress. */
207
+ export function scanGauge(barWidth: number, elapsedMs: number, theme: CrewTheme): string {
208
+ const pos = Math.floor((elapsedMs / 400) % (barWidth + 6)) - 3;
209
+ const segW = Math.max(3, Math.floor(barWidth * 0.3));
210
+ let bar = "";
211
+ for (let i = 0; i < barWidth; i++) {
212
+ bar += i >= pos && i < pos + segW ? theme.fg("accent", "█") : theme.fg("dim", "░");
213
+ }
214
+ return `${theme.fg("dim", "▕")}${bar}${theme.fg("dim", "▏")}`;
215
+ }
216
+
217
+ /**
218
+ * Producer emits `role/agent` (e.g. `verifier/verifier`). Collapse the
219
+ * redundant half so a live row reads `verifier` — real run data showed the
220
+ * duplication on every single-agent run.
221
+ */
222
+ export function dedupeAgentLabel(label: string): string {
223
+ const slash = label.indexOf("/");
224
+ if (slash <= 0) return label;
225
+ const role = label.slice(0, slash);
226
+ const rest = label.slice(slash + 1); // "agent · tool" hoặc "agent"
227
+ const agent = rest.split(" ")[0] ?? "";
228
+ if (agent && role === agent) return `${role}${rest.slice(agent.length)}`;
229
+ return label;
230
+ }
231
+
232
+ // ── Status glyphs ──────────────────────────────────────────────────────
233
+
234
+ /** Badge glyph, colour-coded (`●` done, `✖` failed, `◉` running, `○` other). */
235
+ export function statusBadge(status: string, theme: CrewTheme): string {
236
+ switch (statusSlot(status)) {
237
+ case "success":
238
+ return theme.fg("success", "●");
239
+ case "error":
240
+ return theme.fg("error", "✖");
241
+ case "borderAccent":
242
+ return theme.fg("warning", "◉");
243
+ default:
244
+ return theme.fg("dim", "○");
245
+ }
246
+ }
247
+
248
+ /** Compact status icon (`✓` done, `✗` failed, `⟳` running, `○` other). */
249
+ export function statusIcon(status: string, theme: CrewTheme): string {
250
+ switch (statusSlot(status)) {
251
+ case "success":
252
+ return theme.fg("success", "✓");
253
+ case "error":
254
+ return theme.fg("error", "✗");
255
+ case "borderAccent":
256
+ return theme.fg("warning", "⟳");
257
+ default:
258
+ return theme.fg("dim", "○");
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Canonical overflow hint. Replaces four dialects (`↑ N more above`,
264
+ * `▲ N more above`, `… N above · M below`, `↓N`).
265
+ */
266
+ export function overflowHint(above: number, below: number, theme: CrewTheme): string {
267
+ const parts: string[] = [];
268
+ if (above > 0) parts.push(`${OVERFLOW.above} ${above} above`);
269
+ if (below > 0) parts.push(`${OVERFLOW.below} ${below} below`);
270
+ return parts.length ? theme.fg("dim", parts.join(" · ")) : "";
271
+ }
272
+
273
+ // ── Hint text (one format for every surface) ───────────────────────────
274
+
275
+ /** `Esc` not `ESC`/`esc`; `Enter` not `⏎`; letter keys bare uppercase. */
276
+ export function keyToken(key: string): string {
277
+ switch (key) {
278
+ case "escape":
279
+ case "esc":
280
+ case "\u001b":
281
+ return "Esc";
282
+ case "return":
283
+ case "enter":
284
+ case "\r":
285
+ case "\n":
286
+ return "Enter";
287
+ case "\t":
288
+ case "tab":
289
+ return "Tab";
290
+ case "up":
291
+ case "down":
292
+ return "↑/↓";
293
+ case "pageup":
294
+ return "PgUp";
295
+ case "pagedown":
296
+ return "PgDn";
297
+ case " ":
298
+ case "space":
299
+ return "Space";
300
+ default:
301
+ return key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * `↑/↓ move · Enter select · Esc cancel` — the single hint format.
307
+ *
308
+ * Contract: `keys label` pairs joined by ` · `, the close/cancel action LAST,
309
+ * keys rendered through `keyToken` (so `esc`/`ESC`/`q`-as-close collapse to one
310
+ * spelling). Callers should feed keys from `keybinding-map.ts` so a remap
311
+ * cannot silently desync the footer. Pass `{ exactKeys: true }` for a
312
+ * case-sensitive keyspace (plan approval: `A` vs `n`).
313
+ */
314
+ export function formatHint(
315
+ pairs: ReadonlyArray<readonly [string | readonly string[], string]>,
316
+ options: { exactKeys?: boolean } = {},
317
+ ): string {
318
+ return pairs
319
+ .map(([keys, label]) => {
320
+ const list = Array.isArray(keys) ? keys : [keys as string];
321
+ // Dedupe AFTER mapping: `["up","down"]` both map to `↑/↓` and
322
+ // `["enter","\r"]` both map to `Enter`, so the joined token must
323
+ // collapse instead of printing `↑/↓/↑/↓`.
324
+ //
325
+ // `exactKeys` is for the case-SENSITIVE keyspaces (plan approval
326
+ // binds `A` = approve vs `n` = deny — see keybinding-map.ts:75, and
327
+ // the keyspace deliberately relies on that case distinction).
328
+ // Uppercasing there would advertise a key that does not work.
329
+ const tokens = [...new Set(options.exactKeys ? list : list.map(keyToken))];
330
+ return `${tokens.join("/")} ${label}`;
331
+ })
332
+ .join(" · ");
333
+ }