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
@@ -5,9 +5,12 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
5
5
  import { formatTaskGraphLines, waitingReason } from "../runtime/task-display.ts";
6
6
  import { loadRunManifestById } from "../state/stores/state-store.ts";
7
7
  import type { TeamTaskState } from "../state/types.ts";
8
- import { aggregateUsage, formatUsage } from "../state/usage.ts";
8
+ import { aggregateUsage, formatTokens } from "../state/usage.ts";
9
9
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
10
- import { pad, truncate } from "../utils/visual.ts";
10
+ import { truncate } from "../utils/visual.ts";
11
+ import { formatCount, teamWorkflowLabel } from "./format-helpers.ts";
12
+ import { DASHBOARD_KEYS } from "./keybinding-map.ts";
13
+ import { ACTIVE, canopyLine, formatHint, RAIL, type RailSlot, railLine, sectionLine, shortId, statusSlot } from "./rail.ts";
11
14
  import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
12
15
  import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
13
16
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
@@ -21,13 +24,11 @@ const TASK_READ_TTL_MS = 200;
21
24
 
22
25
  type Done = (value: undefined) => void;
23
26
 
24
- function line(text: string, width: number): string {
25
- return `│ ${pad(truncate(text, width - 4), width - 4)} │`;
26
- }
27
-
28
- function border(left: string, fill: string, right: string, width: number): string {
29
- return `${left}${fill.repeat(Math.max(0, width - 2))}${right}`;
30
- }
27
+ /** The ONE footer hint of the sidebar: keys label pairs, close LAST (RAIL §1). */
28
+ const SIDEBAR_HINT: ReadonlyArray<readonly [string, string]> = [
29
+ ["/team-dashboard", "details"],
30
+ [DASHBOARD_KEYS.close[0] ?? "q", "close"],
31
+ ];
31
32
 
32
33
  function readTasks(path: string): TeamTaskState[] {
33
34
  const parse = () => {
@@ -43,7 +44,25 @@ function readTasks(path: string): TeamTaskState[] {
43
44
 
44
45
  function shortUsage(tasks: TeamTaskState[]): string {
45
46
  const usage = aggregateUsage(tasks);
46
- return usage ? formatUsage(usage) : "usage=(none)";
47
+ return usage ? compactUsage(usage) : "usage=(none)";
48
+ }
49
+
50
+ /**
51
+ * TUI form of a usage record: `↑2.8k ↓3.7k $0.000`.
52
+ *
53
+ * `formatUsage` (state/usage.ts) is the `key=value` form used by CLI/status
54
+ * output (`input=2780, output=3715, cacheRead=57216, cost=0.000000, turns=0`) —
55
+ * correct for a log line, but on a 118-column rail row it buries the numbers
56
+ * the eye actually wants. The card's usage row has always used this compact
57
+ * form; the sidebar now matches it.
58
+ */
59
+ function compactUsage(usage: ReturnType<typeof aggregateUsage>): string {
60
+ if (!usage) return "usage=(none)";
61
+ const parts: string[] = [];
62
+ if (usage.input !== undefined) parts.push(`↑${formatTokens(usage.input)}`);
63
+ if (usage.output !== undefined) parts.push(`↓${formatTokens(usage.output)}`);
64
+ if (usage.cost !== undefined && Number.isFinite(usage.cost) && usage.cost > 0) parts.push(`$${usage.cost.toFixed(3)}`);
65
+ return parts.length > 0 ? parts.join(" ") : "usage=(none)";
47
66
  }
48
67
 
49
68
  export class LiveRunSidebar {
@@ -147,13 +166,14 @@ export class LiveRunSidebar {
147
166
 
148
167
  render(width: number): string[] {
149
168
  const w = Math.max(36, width);
169
+ const budget = w - 2;
150
170
 
151
171
  // P0-6: render from snapshots only — never read disk on every render tick.
152
172
  // Production wires a snapshotCache (extension/registration/ui.ts). When
153
- // the cache hasn't populated yet we paint a single "(loading…)" line so
154
- // the pre-load frame is well-formed instead of an empty panel. When the
155
- // cache is undefined (tests/dev) we fall back to direct disk reads so
156
- // existing unit tests keep working.
173
+ // the cache hasn't populated yet we paint a well-formed two-row frame so
174
+ // the pre-load surface still wears the rail; when the cache is undefined
175
+ // (tests/dev) we fall back to direct disk reads so existing unit tests
176
+ // keep working.
157
177
  let run: import("../state/types.ts").TeamRunManifest;
158
178
  let tasks: TeamTaskState[];
159
179
  let rawAgents: ReturnType<typeof readCrewAgents>;
@@ -165,7 +185,16 @@ export class LiveRunSidebar {
165
185
  snapshot = undefined;
166
186
  }
167
187
  if (!snapshot) {
168
- return ["(loading…)"];
188
+ // A well-formed pre-load frame: same canopy/cap grammar as the loaded
189
+ // one, with a single dim status row.
190
+ return this.renderFrame(
191
+ [
192
+ canopyLine({ word: "LIVE", subject: shortId(this.runId), theme: this.theme, budget }),
193
+ railLine(RAIL.body, "border", this.theme.fg("dim", "loading…"), this.theme, budget),
194
+ ],
195
+ budget,
196
+ "border",
197
+ );
169
198
  }
170
199
  run = snapshot.manifest;
171
200
  tasks = snapshot.tasks;
@@ -173,14 +202,13 @@ export class LiveRunSidebar {
173
202
  } else {
174
203
  const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
175
204
  if (!loaded) {
176
- return renderLines(
205
+ return this.renderFrame(
177
206
  [
178
- border("╭", "─", "╮", w),
179
- line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
180
- line("run not found", w),
181
- border("╰", "─", "╯", w),
207
+ canopyLine({ word: "LIVE", subject: shortId(this.runId), theme: this.theme, budget }),
208
+ railLine(RAIL.body, "border", this.theme.fg("muted", "run not found"), this.theme, budget),
182
209
  ],
183
- w,
210
+ budget,
211
+ "border",
184
212
  );
185
213
  }
186
214
  run = loaded.manifest;
@@ -194,6 +222,10 @@ export class LiveRunSidebar {
194
222
  const waiting = tasks.filter((task) => task.status === "queued");
195
223
  const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot);
196
224
  if (signature !== this.cachedSignature || w !== this.cachedWidth) {
225
+ // RAIL (§2.E): the rounded `╭─╮│╰─╯` box is retired — the surface is a
226
+ // `┏ LIVE ▸ <run8>` canopy, `┃` body rows, `┣ SECTION` headers and a
227
+ // `┗ <hint>` cap. The rail colour carries the run's state.
228
+ const slot: RailSlot = statusSlot(run.status);
197
229
  // L-2: surface the cancellation/failure reason for terminal runs so the
198
230
  // user sees *why* a run ended without having to switch panes. The reason
199
231
  // is already computed on the consumed snapshot (cancellationReason).
@@ -203,59 +235,98 @@ export class LiveRunSidebar {
203
235
  ? ` · ${truncate(snapshot.cancellationReason, 40)}`
204
236
  : "";
205
237
  const lines: string[] = [
206
- border("╭", "─", "╮", w),
207
- line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
208
- line(`${run.runId.slice(-12)} · ${run.status}${reasonSuffix} · right default`, w),
209
- line(`${run.team}/${run.workflow ?? "none"} · ${shortUsage(tasks)}`, w),
210
- border("├", "─", "┤", w),
211
- line(`Active agents (${active.length})`, w),
238
+ canopyLine({ word: "LIVE", subject: shortId(run.runId), theme: this.theme, budget, slot }),
239
+ railLine(
240
+ RAIL.body,
241
+ "border",
242
+ this.theme.fg(
243
+ "muted",
244
+ `${shortId(run.runId)} · ${run.status ?? "?"}${reasonSuffix} · ${run.workspaceMode ?? "single"}`,
245
+ ),
246
+ this.theme,
247
+ budget,
248
+ ),
249
+ railLine(
250
+ RAIL.body,
251
+ "border",
252
+ this.theme.fg("muted", `${teamWorkflowLabel(run.team, run.workflow)} · ${shortUsage(tasks)}`),
253
+ this.theme,
254
+ budget,
255
+ ),
256
+ sectionLine({ name: "active", subject: formatCount(active.length, "agent"), theme: this.theme, budget, slot }),
212
257
  ];
213
258
  for (const agent of active.slice(0, 8)) {
214
259
  const status = iconForStatus(agent.status, {
215
260
  runningGlyph: spinnerFrame(agent.taskId),
216
261
  });
217
262
  const usage = agent.usage
218
- ? formatUsage(agent.usage)
263
+ ? compactUsage(agent.usage)
219
264
  : agent.progress?.tokens
220
265
  ? `tokens=${agent.progress.tokens}`
221
266
  : "usage=pending";
222
- lines.push(line(`${status} ${agent.taskId} ${agent.role}->${agent.agent}`, w));
267
+ // The record comes from unvalidated `agents.json` — every field is
268
+ // guarded (`?? "?"`) and the legacy `role->agent` separator is `▸`.
223
269
  lines.push(
224
- line(
225
- ` ${agent.routing ? `model ${agent.routing.requested ? `${agent.routing.requested} → ` : ""}${agent.routing.resolved}` : agent.model ? `model ${agent.model}` : "model pending"}`,
226
- w,
270
+ railLine(
271
+ RAIL.body,
272
+ "border",
273
+ `${status} ${agent.taskId ?? "?"} ${agent.role ?? "?"} ${ACTIVE} ${agent.agent ?? "?"}`,
274
+ this.theme,
275
+ budget,
227
276
  ),
228
277
  );
229
278
  lines.push(
230
- line(
231
- ` ${agent.progress?.currentTool ? `tool ${agent.progress.currentTool} · ` : ""}${agent.toolUses ?? 0} tools · ${usage}`,
232
- w,
279
+ railLine(
280
+ RAIL.body,
281
+ "border",
282
+ this.theme.fg(
283
+ "muted",
284
+ ` ${agent.routing ? `model ${agent.routing.requested ? `${agent.routing.requested} → ` : ""}${agent.routing.resolved ?? "pending"}` : agent.model ? `model ${agent.model}` : "model pending"}`,
285
+ ),
286
+ this.theme,
287
+ budget,
288
+ ),
289
+ );
290
+ lines.push(
291
+ railLine(
292
+ RAIL.body,
293
+ "border",
294
+ this.theme.fg(
295
+ "muted",
296
+ ` ${agent.progress?.currentTool ? `tool ${agent.progress.currentTool} · ` : ""}${formatCount(agent.toolUses ?? 0, "tool")} · ${usage}`,
297
+ ),
298
+ this.theme,
299
+ budget,
233
300
  ),
234
301
  );
235
302
  }
236
- if (!active.length) lines.push(line("- none", w));
237
- lines.push(border("├", "─", "┤", w), line(`Waiting tasks (${waiting.length})`, w));
303
+ if (!active.length) lines.push(this.bodyRow(this.theme.fg("dim", "none"), budget));
304
+ lines.push(sectionLine({ name: "waiting", subject: formatCount(waiting.length, "task"), theme: this.theme, budget, slot }));
238
305
  for (const task of waiting.slice(0, 8)) {
239
306
  const status = iconForStatus("queued");
240
- lines.push(line(`${status} ${task.id} ${waitingReason(task, tasks) ?? "waiting"}`, w));
307
+ lines.push(this.bodyRow(`${status} ${task.id ?? "?"} ${waitingReason(task, tasks) ?? "waiting"}`, budget));
241
308
  }
242
- if (waiting.length === 0) lines.push(line("- none", w));
243
- lines.push(border("├", "─", "┤", w), line(`Completed agents (${completed.length})`, w));
309
+ if (waiting.length === 0) lines.push(this.bodyRow(this.theme.fg("dim", "none"), budget));
310
+ lines.push(sectionLine({ name: "done", subject: formatCount(completed.length, "agent"), theme: this.theme, budget, slot }));
244
311
  for (const agent of completed) {
245
312
  const status = iconForStatus(agent.status === "running" ? "stopped" : agent.status);
246
313
  lines.push(
247
- line(
248
- `${status} ${agent.taskId} ${agent.model ? `· ${agent.model}` : ""}${agent.usage ? ` · ${formatUsage(agent.usage)}` : ""}`,
249
- w,
314
+ this.bodyRow(
315
+ `${status} ${agent.taskId ?? "?"} ${agent.model ? `· ${agent.model}` : ""}${agent.usage ? ` · ${compactUsage(agent.usage)}` : ""}`,
316
+ budget,
250
317
  ),
251
318
  );
252
319
  }
253
- if (completed.length === 0) lines.push(line("- none", w));
254
- lines.push(border("├", "─", "┤", w));
255
- for (const entry of formatTaskGraphLines(tasks).slice(0, 6)) lines.push(line(entry, w));
256
- lines.push(line("q close · /team-dashboard details", w));
257
- // F-6: compute the auto-close countdown BEFORE pushing the bottom border
258
- // so the countdown renders inside the bordered box rather than below it.
320
+ if (completed.length === 0) lines.push(this.bodyRow(this.theme.fg("dim", "none"), budget));
321
+ lines.push(sectionLine({ name: "tasks", subject: formatCount(tasks.length, "task"), theme: this.theme, budget, slot }));
322
+ for (const entry of formatTaskGraphLines(tasks).slice(0, 6)) {
323
+ // `formatTaskGraphLines` (src/runtime/task-display.ts) still emits the
324
+ // legacy `role->agent` separator; normalize it at the render boundary
325
+ // so no `->` can reach the TUI (§4 consistency fix).
326
+ lines.push(this.bodyRow(this.theme.fg("muted", entry.replace(/->/g, ACTIVE)), budget));
327
+ }
328
+ // F-6: compute the auto-close countdown BEFORE the cap so the countdown
329
+ // renders inside the frame rather than below it.
259
330
  // Auto-close logic: if run is terminal and no active agents, close after delay
260
331
  const isTerminal = ["completed", "failed", "cancelled", "blocked"].includes(run.status);
261
332
  const hasActiveAgents = agents.some((a) => a.status === "running");
@@ -268,7 +339,7 @@ export class LiveRunSidebar {
268
339
  this.done(undefined);
269
340
  }, autoCloseMs);
270
341
  this.autoCloseTimeout?.unref();
271
- lines.push(line(`auto-close in ${Math.round(autoCloseMs / 1000)}s…`, w));
342
+ lines.push(this.bodyRow(this.theme.fg("dim", `auto-close in ${Math.round(autoCloseMs / 1000)}s…`), budget));
272
343
  }
273
344
  }
274
345
  // Clear timeout if conditions change
@@ -276,17 +347,25 @@ export class LiveRunSidebar {
276
347
  clearTimeout(this.autoCloseTimeout);
277
348
  this.autoCloseTimeout = undefined;
278
349
  }
279
- lines.push(border("╰", "─", "╯", w));
280
- this.cachedLines = renderLines(
281
- lines.map((entry) => this.colorLine(entry)),
282
- w,
283
- );
350
+ this.cachedLines = renderLines(this.renderFrame(lines, budget, slot), w);
284
351
  this.cachedSignature = signature;
285
352
  this.cachedWidth = w;
286
353
  }
287
354
  return this.cachedLines;
288
355
  }
289
356
 
357
+ /** One `┃` body row (glyph + colour owned by the rail helper). */
358
+ private bodyRow(content: string, budget: number): string {
359
+ return railLine(RAIL.body, "border", content, this.theme, budget);
360
+ }
361
+
362
+ /** Append the `┗ <hint>` cap and colorize the glyphs (F-1 / V-3). */
363
+ private renderFrame(lines: string[], budget: number, slot: RailSlot): string[] {
364
+ const hint = this.theme.fg("dim", formatHint(SIDEBAR_HINT));
365
+ const framed = [...lines, railLine(RAIL.close, slot, hint, this.theme, budget)];
366
+ return framed.map((entry) => this.colorLine(entry));
367
+ }
368
+
290
369
  handleInput(data: string): void {
291
370
  if (data === "q" || data === "\u001b") this.done(undefined);
292
371
  }
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
  }