pi-crew 0.11.0 → 0.11.1

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 (73) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +0 -1
  3. package/dist/index.mjs +29936 -29118
  4. package/package.json +1 -1
  5. package/schema.json +10 -0
  6. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  7. package/skills/real-test-pi-crew/SKILL.md +181 -1
  8. package/src/config/config-validation.ts +0 -2
  9. package/src/config/defaults.ts +0 -1
  10. package/src/config/types.ts +0 -5
  11. package/src/extension/crew-vibes/config.ts +0 -21
  12. package/src/extension/crew-vibes/index.ts +0 -2
  13. package/src/extension/crew-vibes/render.ts +1 -50
  14. package/src/extension/help.ts +0 -2
  15. package/src/extension/register.ts +7 -2
  16. package/src/extension/registration/commands/manage.ts +5 -4
  17. package/src/extension/registration/commands/shared.ts +0 -1
  18. package/src/extension/registration/subagent-tools.ts +16 -6
  19. package/src/extension/registration/team-tool.ts +10 -3
  20. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  21. package/src/extension/registration/viewers.ts +6 -0
  22. package/src/extension/team-tool/handle-settings.ts +14 -2
  23. package/src/runtime/task-display.ts +1 -1
  24. package/src/schema/config-schema.ts +4 -0
  25. package/src/ui/adaptive-card.ts +65 -0
  26. package/src/ui/agents-jobs-browser.ts +70 -64
  27. package/src/ui/card-colors.ts +36 -7
  28. package/src/ui/dashboard-panes/agents-pane.ts +45 -13
  29. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  30. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  31. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  32. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  33. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  34. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  35. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  36. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  37. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  38. package/src/ui/dwf-phase-display.ts +3 -20
  39. package/src/ui/format-helpers.ts +22 -0
  40. package/src/ui/inline-panel/crew-editor.ts +13 -3
  41. package/src/ui/inline-panel/index.ts +60 -4
  42. package/src/ui/keybinding-map.ts +230 -32
  43. package/src/ui/live-conversation-overlay.ts +176 -46
  44. package/src/ui/live-run-sidebar.ts +134 -55
  45. package/src/ui/mascot.ts +32 -16
  46. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  47. package/src/ui/overlays/confirm-overlay.ts +55 -29
  48. package/src/ui/overlays/help-overlay.ts +108 -53
  49. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  50. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  51. package/src/ui/powerbar-publisher.ts +0 -1
  52. package/src/ui/rail.ts +333 -0
  53. package/src/ui/run-dashboard.ts +95 -74
  54. package/src/ui/settings-overlay.ts +81 -39
  55. package/src/ui/terminal-status.ts +7 -1
  56. package/src/ui/theme-adapter.ts +0 -45
  57. package/src/ui/theme-discovery.ts +12 -6
  58. package/src/ui/tool-progress-formatter.ts +126 -8
  59. package/src/ui/tool-renderers/brief-mode.ts +8 -66
  60. package/src/ui/tool-renderers/index.ts +372 -521
  61. package/src/ui/transcript-viewer.ts +30 -12
  62. package/src/ui/widget/index.ts +32 -52
  63. package/src/ui/widget/task-list.ts +64 -32
  64. package/src/ui/widget/widget-formatters.ts +3 -402
  65. package/src/ui/widget/widget-model.ts +28 -7
  66. package/src/ui/widget/widget-renderer.ts +201 -128
  67. package/src/ui/widget/widget-types.ts +0 -2
  68. package/src/utils/visual.ts +0 -4
  69. package/src/extension/crew-vibes/figures.ts +0 -22
  70. package/src/extension/crew-vibes/font-detect.ts +0 -71
  71. package/src/ui/dynamic-border.ts +0 -35
  72. package/src/ui/loaders.ts +0 -6
  73. package/src/ui/overlay-stack.ts +0 -148
@@ -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
  }
@@ -1,6 +1,28 @@
1
+ /**
2
+ * Mailbox compose overlay — RAIL design language (M4/E1, 2026-09-16).
3
+ *
4
+ * ┏ COMPOSE ▸ mailbox
5
+ * ┃ › from: operator │ Preview
6
+ * ┃ › to: leader │ # Title
7
+ * ┃ › body: hello │ • item
8
+ * ┃ › taskId: │
9
+ * ┃ › [ ] Send to outbox │
10
+ * ┗ P close preview · Tab cycle · Enter submit · Esc discard
11
+ *
12
+ * Frame: canopy + `┃` body + `┗` close cap. When the preview is on, the body is
13
+ * a genuine two-column layout and `│` is the inner COLUMN separator (§2.D); the
14
+ * canopy/hint rows are NOT part of the split any more, so the hint keeps its
15
+ * full width instead of being cropped to 60% of the overlay.
16
+ *
17
+ * Keys/behaviour are unchanged (same `overlay:*` dispatch, same free-text
18
+ * passthrough, same validation errors, same discard confirmation). Hints come
19
+ * from `formatHint` (discard LAST, keys through `keyToken`).
20
+ */
21
+
1
22
  import type { MailboxDirection } from "../../state/coordination/mailbox.ts";
2
- import { pad, truncate } from "../../utils/visual.ts";
3
- import { matchesKey } from "../key-utils.ts";
23
+ import { pad, sanitizeLine, truncate } from "../../utils/visual.ts";
24
+ import { overlayActionForKey } from "../keybinding-map.ts";
25
+ import { CURSOR, canopyLine, formatHint, RAIL, railLine, railRaw } from "../rail.ts";
4
26
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
5
27
  import { ConfirmOverlay } from "./confirm-overlay.ts";
6
28
  import { renderComposePreview } from "./mailbox-compose-preview.ts";
@@ -49,36 +71,53 @@ export class MailboxComposeOverlay {
49
71
 
50
72
  render(width: number): string[] {
51
73
  if (this.confirm) return this.confirm.render(width);
52
- const inner = Math.max(24, width - 4);
53
- const formWidth = this.preview ? Math.max(24, Math.floor(inner * 0.6)) : inner;
54
- const lines = [
55
- this.theme.bold("Compose mailbox message"),
56
- this.preview
57
- ? "P close preview · Tab cycle · Enter submit · ESC discard"
58
- : "P preview · Tab cycle · Enter submit · ESC discard",
59
- ...(this.error ? [this.theme.fg("error", this.error)] : []),
60
- this.fieldLine("from", formWidth),
61
- this.fieldLine("to", formWidth),
62
- this.fieldLine("body", formWidth),
63
- this.fieldLine("taskId", formWidth),
64
- `${this.activeField === 4 ? "›" : " "} [${this.fields.direction === "outbox" ? "x" : " "}] Send to outbox`,
74
+ if (width < 10) return [];
75
+ const theme = this.theme;
76
+ const budget = width - 2;
77
+ const hint = formatHint([
78
+ ["P", this.preview ? "close preview" : "preview"],
79
+ ["tab", "cycle"],
80
+ ["enter", "submit"],
81
+ ["escape", "discard"],
82
+ ]);
83
+ const lines: string[] = [canopyLine({ word: "COMPOSE", subject: "mailbox", theme, budget })];
84
+ if (this.error) lines.push(railLine(RAIL.body, "error", truncate(this.error, budget), theme, budget));
85
+
86
+ const formBudget = this.preview ? Math.max(10, Math.floor(budget * 0.6)) : budget;
87
+ const previewBudget = Math.max(8, budget - formBudget - 3);
88
+ const formRows = [
89
+ this.fieldLine("from", formBudget),
90
+ this.fieldLine("to", formBudget),
91
+ this.fieldLine("body", formBudget),
92
+ this.fieldLine("taskId", formBudget),
93
+ `${this.activeField === 4 ? CURSOR : " "} [${this.fields.direction === "outbox" ? "x" : " "}] Send to outbox`,
65
94
  ];
66
- if (!this.preview) return lines.map((line) => pad(truncate(line, inner), inner));
67
- const previewLines = renderComposePreview(this.fields.body, Math.max(20, inner - formWidth - 3), this.theme);
68
- const max = Math.max(lines.length, previewLines.length);
69
- const split: string[] = [];
70
- for (let index = 0; index < max; index += 1) {
71
- split.push(
72
- `${pad(truncate(lines[index] ?? "", formWidth), formWidth)} │ ${truncate(previewLines[index] ?? "", inner - formWidth - 3)}`,
73
- );
95
+ if (!this.preview) {
96
+ for (const row of formRows) lines.push(railLine(RAIL.body, "border", truncate(row, budget), theme, budget));
97
+ } else {
98
+ const previewLines = renderComposePreview(this.fields.body, previewBudget, theme);
99
+ const max = Math.max(formRows.length, previewLines.length);
100
+ for (let index = 0; index < max; index += 1) {
101
+ const left = pad(truncate(formRows[index] ?? "", formBudget), formBudget);
102
+ const right = truncate(previewLines[index] ?? "", previewBudget);
103
+ // No separator when the preview column has no row at this height:
104
+ // a `│` with nothing to its right reads as a frame edge.
105
+ lines.push(railRaw(RAIL.body, "border", right ? `${left} ${theme.fg("dim", "│")} ${right}` : left, theme));
106
+ }
74
107
  }
75
- return split;
108
+ lines.push(railLine(RAIL.close, "border", theme.fg("dim", hint), theme, budget));
109
+ return lines;
76
110
  }
77
111
 
78
112
  private fieldLine(field: Exclude<FieldName, "direction">, width: number): string {
79
113
  const active = FIELD_ORDER[this.activeField] === field;
80
114
  const label = field === "taskId" ? "taskId" : field;
81
- return `${active ? "›" : " "} ${label}: ${truncate(this.fields[field] ?? "", Math.max(8, width - label.length - 5))}`;
115
+ // `sanitizeLine`: a pasted / pre-filled value can carry a raw newline or
116
+ // TAB — printing it verbatim would break the rail row into several lines
117
+ // (the field is a single-line cell; multi-line content belongs to the
118
+ // preview column).
119
+ const value = sanitizeLine(this.fields[field] ?? "");
120
+ return `${active ? CURSOR : " "} ${label}: ${truncate(value, Math.max(8, width - label.length - 5))}`;
82
121
  }
83
122
 
84
123
  private activeName(): FieldName {
@@ -151,32 +190,32 @@ export class MailboxComposeOverlay {
151
190
  this.confirm.handleInput(data);
152
191
  return;
153
192
  }
154
- if (matchesKey(data, "escape")) {
155
- this.cancel();
156
- return;
157
- }
158
- if (data === "P") {
159
- this.preview = !this.preview;
160
- return;
161
- }
162
- if (data === "\t") {
163
- this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
164
- return;
165
- }
166
- if (data === " ") {
167
- if (this.activeName() === "direction") this.fields.direction = this.fields.direction === "inbox" ? "outbox" : "inbox";
168
- else this.appendText(data);
169
- return;
170
- }
171
- if (matchesKey(data, "backspace")) {
172
- this.backspace();
173
- return;
174
- }
175
- if (matchesKey(data, "return")) {
176
- if (this.activeName() === "body" || this.fields.body.trim()) this.submit();
177
- else this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
178
- return;
193
+ // M2-1: keys come from the central `overlay:*` keyspace (remappable via
194
+ // `.crew/config.json` → keybindings["overlay:mailbox-compose:<action>"]).
195
+ switch (overlayActionForKey("mailbox-compose", data)) {
196
+ case "cancel":
197
+ this.cancel();
198
+ return;
199
+ case "preview":
200
+ this.preview = !this.preview;
201
+ return;
202
+ case "nextField":
203
+ this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
204
+ return;
205
+ case "space":
206
+ if (this.activeName() === "direction") this.fields.direction = this.fields.direction === "inbox" ? "outbox" : "inbox";
207
+ else this.appendText(data);
208
+ return;
209
+ case "backspace":
210
+ this.backspace();
211
+ return;
212
+ case "submit":
213
+ if (this.activeName() === "body" || this.fields.body.trim()) this.submit();
214
+ else this.activeField = (this.activeField + 1) % FIELD_ORDER.length;
215
+ return;
179
216
  }
217
+ // Free-text passthrough (unchanged): any other printable char is typed
218
+ // into the active field.
180
219
  if (data.length === 1 && data >= " ") this.appendText(data);
181
220
  }
182
221
  }
@@ -1,7 +1,32 @@
1
+ /**
2
+ * Mailbox detail overlay — RAIL design language (M4/E1, 2026-09-16).
3
+ *
4
+ * ┏ MAILBOX ▸ 1a2b3c4d
5
+ * ┃ Inbox │ Outbox
6
+ * ┃ ›✓ lead ▸ one: ping │ !
7
+ * ┃ ▼ 3 below
8
+ * ┣ MESSAGE
9
+ * ┃ lead ▸ one (one) · pending
10
+ * ┗ Tab side · ↑/↓ select · Enter expand · … ···· Esc close
11
+ *
12
+ * The `│` in the body is a genuine inner COLUMN separator of the two-column
13
+ * mailbox table (§2.D) — it is not a frame edge. The frame is canopy + `┃`
14
+ * body + `┗` close cap; the legacy title/hint header rows, the `───` inline
15
+ * rule before the expanded message and the hand-typed hint string are gone.
16
+ *
17
+ * Hints: built by `formatHint` (close LAST) and laid out with `railLeaders` so
18
+ * the `Esc close` segment stays visible on a narrow overlay instead of being
19
+ * truncated away with the long action list.
20
+ *
21
+ * Every disk-sourced field is guarded (`?? "?"`): mailbox JSON is not
22
+ * schema-validated at read time.
23
+ */
24
+
1
25
  import { type MailboxMessage, readDeliveryState, readMailbox } from "../../state/coordination/mailbox.ts";
2
26
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
3
27
  import { pad, truncate } from "../../utils/visual.ts";
4
- import { keyOf, matchesKey } from "../key-utils.ts";
28
+ import { overlayActionForKey } from "../keybinding-map.ts";
29
+ import { ACTIVE, CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLeaders, railLine, sectionLine, shortId } from "../rail.ts";
5
30
  import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
6
31
 
7
32
  export type MailboxAction =
@@ -11,6 +36,9 @@ export type MailboxAction =
11
36
  | { type: "ackAll" }
12
37
  | { type: "close" };
13
38
 
39
+ /** Rows shown per column before the overflow hint takes over. */
40
+ const MAX_ROWS = 12;
41
+
14
42
  export class MailboxDetailOverlay {
15
43
  private readonly runId: string;
16
44
  private readonly cwd: string;
@@ -84,72 +112,124 @@ export class MailboxDetailOverlay {
84
112
  this.refresh();
85
113
  this.needsRefresh = false;
86
114
  }
87
- const inner = Math.max(40, width - 4);
88
- const col = Math.max(18, Math.floor((inner - 3) / 2));
89
- const lines = [
90
- this.theme.bold(`Mailbox detail · ${this.runId}`),
91
- "Tab side · ↑/↓ select · Enter expand · A ack · N nudge · C compose · X ack all · ESC close",
92
- `${pad(this.theme.bold("Inbox"), col)} │ ${pad(this.theme.bold("Outbox"), col)}`,
93
- ];
94
- const max = Math.max(this.inbox.length, this.outbox.length, 1);
95
- for (let index = 0; index < Math.min(max, 12); index += 1) {
96
- lines.push(`${this.row(this.inbox[index], "inbox", index, col)} │ ${this.row(this.outbox[index], "outbox", index, col)}`);
115
+ if (width < 10) return [];
116
+ const theme = this.theme;
117
+ const budget = width - 2;
118
+ const lines: string[] = [canopyLine({ word: "MAILBOX", subject: shortId(this.runId), theme, budget })];
119
+
120
+ // Two-column body (inbox ↔ outbox): `│` is the inner column separator.
121
+ const col = Math.max(10, Math.floor((budget - 3) / 2));
122
+ const rightCol = Math.max(1, budget - col - 3);
123
+ const twoCol = (left: string, right: string) =>
124
+ `${pad(truncate(left, col), col)} ${theme.fg("dim", "│")} ${truncate(right, rightCol)}`;
125
+ lines.push(railLine(RAIL.body, "border", twoCol(theme.bold("Inbox"), theme.bold("Outbox")), theme, budget));
126
+ const total = Math.max(this.inbox.length, this.outbox.length);
127
+ const shown = Math.min(total, MAX_ROWS);
128
+ for (let index = 0; index < shown; index += 1) {
129
+ lines.push(
130
+ railLine(
131
+ RAIL.body,
132
+ "border",
133
+ twoCol(this.row(this.inbox[index], "inbox", index, col), this.row(this.outbox[index], "outbox", index, col)),
134
+ theme,
135
+ budget,
136
+ ),
137
+ );
138
+ }
139
+ if (total > shown) {
140
+ lines.push(railLine(RAIL.body, "border", overflowHint(0, total - shown, theme), theme, budget));
97
141
  }
142
+
98
143
  const selected = this.selectedMessage();
99
144
  if (this.expanded && selected) {
100
- lines.push("─".repeat(Math.min(inner, 72)));
101
- lines.push(`${selected.from} → ${selected.to}${selected.taskId ? ` (${selected.taskId})` : ""} · ${selected.status}`);
102
- lines.push(...selected.body.split(/\r?\n/).map((line) => truncate(line, inner)));
145
+ const from = selected.from ?? "?";
146
+ const to = selected.to ?? "?";
147
+ lines.push(sectionLine({ name: "Message", theme, budget }));
148
+ lines.push(
149
+ railLine(
150
+ RAIL.body,
151
+ "border",
152
+ truncate(
153
+ `${from} ${ACTIVE} ${to}${selected.taskId ? ` (${selected.taskId})` : ""} · ${selected.status ?? "?"}`,
154
+ budget,
155
+ ),
156
+ theme,
157
+ budget,
158
+ ),
159
+ );
160
+ for (const line of (selected.body ?? "").split(/\r?\n/)) {
161
+ lines.push(railLine(RAIL.body, "border", truncate(line, budget), theme, budget));
162
+ }
163
+ }
164
+
165
+ if (!this.inbox.length && !this.outbox.length) {
166
+ lines.push(railLine(RAIL.body, "border", theme.fg("dim", "Mailbox is empty."), theme, budget));
103
167
  }
104
- if (!this.inbox.length && !this.outbox.length) lines.push("Mailbox is empty.");
105
- return lines.map((line) => truncate(line, inner));
168
+
169
+ const actions = formatHint([
170
+ ["tab", "side"],
171
+ [["up", "down"], "select"],
172
+ ["enter", "expand"],
173
+ ["A", "ack"],
174
+ ["N", "nudge"],
175
+ ["C", "compose"],
176
+ ["X", "ack all"],
177
+ ]);
178
+ const close = formatHint([["escape", "close"]]);
179
+ lines.push(
180
+ railLine(RAIL.close, "border", railLeaders(theme.fg("dim", actions), theme.fg("dim", close), budget, theme), theme, budget),
181
+ );
182
+ return lines;
106
183
  }
107
184
 
108
185
  private row(message: MailboxMessage | undefined, side: "inbox" | "outbox", index: number, width: number): string {
109
- if (!message) return pad("", width);
110
- const marker = this.side === side && this.selected === index ? "›" : " ";
111
- const status = message.status === "acknowledged" ? "✓" : "!";
112
- return pad(truncate(`${marker}${status} ${message.from}->${message.to}: ${message.body.replace(/\s+/g, " ")}`, width), width);
186
+ if (!message) return "";
187
+ const marker = this.side === side && this.selected === index ? CURSOR : " ";
188
+ const acknowledged = message.status === "acknowledged";
189
+ const status = this.theme.fg(acknowledged ? "success" : "warning", acknowledged ? "✓" : "!");
190
+ const from = message.from ?? "?";
191
+ const to = message.to ?? "?";
192
+ const body = (message.body ?? "").replace(/\s+/g, " ");
193
+ return truncate(`${marker}${status} ${from} ${ACTIVE} ${to}: ${body}`, width);
113
194
  }
114
195
 
115
196
  handleInput(data: string): void {
116
- if (matchesKey(data, "escape") || data === "q") {
117
- this.done({ type: "close" });
118
- return;
119
- }
120
- if (data === "\t") {
121
- this.side = this.side === "inbox" ? "outbox" : "inbox";
122
- this.selected = Math.min(this.selected, Math.max(0, this.current().length - 1));
123
- return;
124
- }
125
- if (data === "k" || keyOf(data) === "up") {
126
- this.selected = Math.max(0, this.selected - 1);
127
- return;
128
- }
129
- if (data === "j" || keyOf(data) === "down") {
130
- this.selected = Math.min(Math.max(0, this.current().length - 1), this.selected + 1);
131
- return;
132
- }
133
- if (matchesKey(data, "return")) {
134
- this.expanded = !this.expanded;
135
- return;
136
- }
137
- if (data === "A") {
138
- const message = this.selectedMessage();
139
- if (message) this.done({ type: "ack", messageId: message.id });
140
- return;
141
- }
142
- if (data === "N") {
143
- this.done({
144
- type: "nudge",
145
- agentId: this.selectedMessage()?.taskId,
146
- });
147
- return;
148
- }
149
- if (data === "C") {
150
- this.done({ type: "compose" });
151
- return;
197
+ // M2-1: keys come from the central `overlay:*` keyspace (remappable via
198
+ // `.crew/config.json` → keybindings["overlay:mailbox-detail:<action>"]).
199
+ switch (overlayActionForKey("mailbox-detail", data)) {
200
+ case "close":
201
+ this.done({ type: "close" });
202
+ return;
203
+ case "toggleSide":
204
+ this.side = this.side === "inbox" ? "outbox" : "inbox";
205
+ this.selected = Math.min(this.selected, Math.max(0, this.current().length - 1));
206
+ return;
207
+ case "up":
208
+ this.selected = Math.max(0, this.selected - 1);
209
+ return;
210
+ case "down":
211
+ this.selected = Math.min(Math.max(0, this.current().length - 1), this.selected + 1);
212
+ return;
213
+ case "toggleDetail":
214
+ this.expanded = !this.expanded;
215
+ return;
216
+ case "ack": {
217
+ const message = this.selectedMessage();
218
+ if (message) this.done({ type: "ack", messageId: message.id });
219
+ return;
220
+ }
221
+ case "nudge":
222
+ this.done({
223
+ type: "nudge",
224
+ agentId: this.selectedMessage()?.taskId,
225
+ });
226
+ return;
227
+ case "compose":
228
+ this.done({ type: "compose" });
229
+ return;
230
+ case "ackAll":
231
+ this.done({ type: "ackAll" });
232
+ return;
152
233
  }
153
- if (data === "X") this.done({ type: "ackAll" });
154
234
  }
155
235
  }
@@ -271,7 +271,6 @@ class PowerbarPublisher {
271
271
  workspaceId?: string,
272
272
  ): void {
273
273
  if (config?.powerbar === false) return;
274
- const useStatusFallback = !hasPowerbarConsumer(events);
275
274
  // P3 (#10): prefer an explicit workspaceId arg; otherwise self-derive
276
275
  // from the ctx every caller already passes (ExtensionContext exposes
277
276
  // sessionManager). Back-compat: when neither is present, no filtering.