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
@@ -18,6 +18,12 @@
18
18
  * for zero benefit. The input-guard half of L2 is therefore intentionally
19
19
  * skipped; only the DRY/data-driven dispatch refactor landed.
20
20
  *
21
+ * M2-1 (P1-9) adds the sibling `overlay:*` keyspace below. The migrated
22
+ * overlays dispatch through `overlayActionForKey("<name>", data)` — the same
23
+ * data-driven + override pipeline as the dashboard, but a SEPARATE namespace:
24
+ * an overlay never consults `dashboardActionForKey`, so the no-leak argument
25
+ * above is unchanged.
26
+ *
21
27
  * Origin pattern: deer-flow `frontend/src/components/workspace/command-palette.tsx:39-50`
22
28
  * drives shortcuts from a single data array consumed by one loop in
23
29
  * `use-global-shortcuts.ts:38-61`.
@@ -44,7 +50,6 @@ export const DASHBOARD_KEYS = {
44
50
  transcript: ["v"],
45
51
  liveConversation: ["V"],
46
52
  reload: ["r"],
47
- progressToggle: ["p"],
48
53
  browser: ["b"],
49
54
  },
50
55
  pane: {
@@ -106,7 +111,6 @@ export type DashboardKeyAction =
106
111
  | "transcript"
107
112
  | "live-conversation"
108
113
  | "reload"
109
- | "progressToggle"
110
114
  | "browser"
111
115
  | "pane-agents"
112
116
  | "pane-progress"
@@ -152,11 +156,13 @@ export type DashboardKeyAction =
152
156
  *
153
157
  * NOTE: mailbox action keys A/N/C/P/X (ack/nudge/compose/preview/ackAll) are
154
158
  * intentionally NOT dispatched for the mailbox pane by this table. They live
155
- * in `DASHBOARD_KEYS.mailbox` for reservation but are handled by the mailbox
156
- * overlay's own `handleInput`, not by the dashboard dispatch. The `plan`
157
- * group reuses uppercase "A" (approve) pane-scoped to "progress" — it never
158
- * fires while the mailbox pane (or the mailbox-detail overlay) owns input,
159
- * so `mailbox.ack` behavior is unchanged.
159
+ * in `DASHBOARD_KEYS.mailbox` for reservation and are resolved by the mailbox
160
+ * overlays' own input handling — since M2-1 through the sibling `overlay:*`
161
+ * keyspace (`OVERLAY_KEYS["mailbox-detail"]` / `OVERLAY_KEYS["mailbox-compose"]`),
162
+ * never by `dashboardActionForKey`. The `plan` group reuses uppercase "A"
163
+ * (approve) pane-scoped to "progress" — it never fires while the mailbox pane
164
+ * (or the mailbox-detail overlay) owns input, so `mailbox.ack` behavior is
165
+ * unchanged.
160
166
  */
161
167
  const DEFAULT_BINDINGS: readonly KeyBinding[] = [
162
168
  { keys: DASHBOARD_KEYS.close, action: "close" },
@@ -245,7 +251,6 @@ const DEFAULT_BINDINGS: readonly KeyBinding[] = [
245
251
  { keys: DASHBOARD_KEYS.root.transcript, action: "transcript" },
246
252
  { keys: DASHBOARD_KEYS.root.liveConversation, action: "live-conversation" },
247
253
  { keys: DASHBOARD_KEYS.root.reload, action: "reload" },
248
- { keys: DASHBOARD_KEYS.root.progressToggle, action: "progressToggle" },
249
254
  // Agents & Jobs browser (one-keypress overlay, mirrors live-conversation).
250
255
  // Collision analysis: "b" is unbound everywhere else — root-unscoped is
251
256
  // safe; inside the browser overlay itself "p" is free because overlays
@@ -289,6 +294,103 @@ const KEY_RESERVED = new Set<string>([
289
294
 
290
295
  export { KEY_RESERVED };
291
296
 
297
+ // ─── Overlay keybindings (P1-9 / M2-1) ─────────────────────────────────────
298
+ //
299
+ // Overlays used to hardcode their own key chains — the SAME
300
+ // "↑/↓/Enter/Esc" concept re-implemented in every overlay file, none of them
301
+ // remappable. M2-1 centralises them into an `overlay:<name>:<action>`
302
+ // keyspace that rides the SAME override pipeline as the dashboard table
303
+ // above: `.crew/config.json` → `keybindings` and `PI_CREW_KEYBINDINGS` both
304
+ // accept `"overlay:<name>:<action>": ["<key>", …]`, a colliding override is
305
+ // reverted to its default, and the revert is reported by
306
+ // `getKeybindingOverrideWarnings()`.
307
+ //
308
+ // Overlay bindings are validated INDEPENDENTLY of dashboard bindings: the two
309
+ // namespaces are never live at the same time (the host hands input to exactly
310
+ // one component — an open overlay or the dashboard), so a shared key such as
311
+ // `q`/`escape`/`A` is not a real ambiguity. Within one overlay a shared key IS
312
+ // ambiguous and is treated as a collision.
313
+
314
+ export const OVERLAY_KEYS = {
315
+ "agent-picker": {
316
+ close: ["escape", "q"],
317
+ up: ["k", "up"],
318
+ down: ["j", "down"],
319
+ select: ["return"],
320
+ },
321
+ confirm: {
322
+ confirm: ["y", "Y"],
323
+ /** Enter is dual-role: the overlay resolves it with `defaultAction`. */
324
+ submit: ["return"],
325
+ cancel: ["n", "N", "escape", "q"],
326
+ },
327
+ "mailbox-detail": {
328
+ close: ["escape", "q"],
329
+ toggleSide: ["tab", "\t"],
330
+ up: ["k", "up"],
331
+ down: ["j", "down"],
332
+ toggleDetail: ["return"],
333
+ ack: ["A"],
334
+ nudge: ["N"],
335
+ compose: ["C"],
336
+ ackAll: ["X"],
337
+ },
338
+ "mailbox-compose": {
339
+ cancel: ["escape"],
340
+ preview: ["P"],
341
+ nextField: ["tab", "\t"],
342
+ space: [" "],
343
+ backspace: ["backspace"],
344
+ submit: ["return"],
345
+ },
346
+ } as const;
347
+
348
+ type OverlayDefs = typeof OVERLAY_KEYS;
349
+
350
+ /** Overlay namespaces that opt into the central keybinding map. */
351
+ export type OverlayName = keyof OverlayDefs;
352
+
353
+ /** Every action across every overlay (union). */
354
+ export type OverlayAction = { [N in OverlayName]: keyof OverlayDefs[N] & string }[OverlayName];
355
+
356
+ /** Actions of ONE overlay — the precise return type of `overlayActionForKey`. */
357
+ export type OverlayActionOf<N extends OverlayName> = keyof OverlayDefs[N] & string;
358
+
359
+ /** Override key as written in config/env: `overlay:<name>:<action>`. */
360
+ export type OverlayBindingKey = { [N in OverlayName]: `overlay:${N & string}:${keyof OverlayDefs[N] & string}` }[OverlayName];
361
+
362
+ /** A resolved overlay binding (one action of one overlay). */
363
+ export interface OverlayBinding {
364
+ readonly overlay: OverlayName;
365
+ readonly action: OverlayAction;
366
+ readonly keys: readonly string[];
367
+ }
368
+
369
+ /** Compose the config/env override key for one overlay action. */
370
+ function overlayBindingKey(overlay: OverlayName, action: OverlayAction): OverlayBindingKey {
371
+ return `overlay:${overlay}:${action}` as OverlayBindingKey;
372
+ }
373
+
374
+ // `Object.entries` widens the const-typed tables; this cast restores the exact
375
+ // shape (no `any`; the `OverlayBinding` annotation below still checks it).
376
+ type OverlayTableEntry = readonly [OverlayName, Readonly<Record<string, readonly string[]>>];
377
+
378
+ const OVERLAY_TABLE = Object.entries(OVERLAY_KEYS) as readonly OverlayTableEntry[];
379
+
380
+ /** Every valid `overlay:<name>:<action>` override key. */
381
+ const OVERLAY_BINDING_KEYS: ReadonlySet<string> = new Set(
382
+ OVERLAY_TABLE.flatMap(([overlay, actions]) =>
383
+ Object.keys(actions).map((action) => overlayBindingKey(overlay, action as OverlayAction)),
384
+ ),
385
+ );
386
+
387
+ const DEFAULT_OVERLAY_BINDINGS: ReadonlyMap<OverlayName, readonly OverlayBinding[]> = new Map<OverlayName, readonly OverlayBinding[]>(
388
+ OVERLAY_TABLE.map(([overlay, actions]) => [
389
+ overlay,
390
+ Object.entries(actions).map(([action, keys]): OverlayBinding => ({ overlay, action: action as OverlayAction, keys })),
391
+ ]),
392
+ );
393
+
292
394
  // ─── Keybinding overrides (UI-2) ───────────────────────────────────────────
293
395
  //
294
396
  // The hardcoded DEFAULT_BINDINGS above can be overridden per-action via two
@@ -309,13 +411,14 @@ export { KEY_RESERVED };
309
411
  // first-match-wins dispatch unambiguous — a shadowed override never silently
310
412
  // changes behaviour.
311
413
 
312
- /** Override map: action → replacement keys. `Partial` ⇒ only listed actions. */
313
- export type KeybindingOverride = Partial<Record<DashboardKeyAction, readonly string[]>>;
414
+ /** Override map: action → replacement keys. `Partial` ⇒ only listed actions.
415
+ * Keys are dashboard actions plus `overlay:<name>:<action>` binding keys. */
416
+ export type KeybindingOverride = Partial<Record<DashboardKeyAction | OverlayBindingKey, readonly string[]>>;
314
417
 
315
418
  const KEYBINDINGS_ENV = "PI_CREW_KEYBINDINGS";
316
419
 
317
- /** Every dispatched action is a valid override target. */
318
- const VALID_OVERRIDE_ACTIONS: ReadonlySet<string> = new Set(DEFAULT_BINDINGS.map((b) => b.action));
420
+ /** Every dispatched action + every `overlay:*` binding is a valid override target. */
421
+ const VALID_OVERRIDE_ACTIONS: ReadonlySet<string> = new Set<string>([...DEFAULT_BINDINGS.map((b) => b.action), ...OVERLAY_BINDING_KEYS]);
319
422
 
320
423
  /** Coerce an unknown parsed value into a safe {@link KeybindingOverride}. */
321
424
  function parseKeybindingOverride(raw: unknown): KeybindingOverride {
@@ -355,42 +458,87 @@ function paneScopesCompatible(a: PaneScope | undefined, b: PaneScope | undefined
355
458
 
356
459
  interface EffectiveBindingsResult {
357
460
  readonly bindings: readonly KeyBinding[];
358
- /** Actions whose override was rejected due to a collision. */
359
- readonly reverted: readonly DashboardKeyAction[];
461
+ /** Resolved bindings per overlay (defaults + non-colliding overrides). */
462
+ readonly overlayBindings: ReadonlyMap<OverlayName, readonly OverlayBinding[]>;
463
+ /** Override targets whose override was rejected due to a collision. */
464
+ readonly reverted: readonly string[];
360
465
  }
361
466
 
362
467
  /**
363
- * Apply `overrides` onto {@link DEFAULT_BINDINGS} (replace keys per action,
364
- * preserving each action's pane scope) and detect collisions. A colliding
365
- * override is reverted to its default so the dispatch stays unambiguous.
468
+ * Does `candidate` share a key with any `other` binding that could be live at
469
+ * the same time? Shared by the dashboard and the overlay namespaces so the
470
+ * collision rule (and its revert semantics) has ONE implementation.
471
+ */
472
+ function collidesWithOthers<B extends { readonly keys: readonly string[]; readonly action: string }>(
473
+ candidate: B,
474
+ others: readonly B[],
475
+ compatible: (a: B, b: B) => boolean,
476
+ ): boolean {
477
+ for (const other of others) {
478
+ if (other.action === candidate.action) continue;
479
+ if (!compatible(candidate, other)) continue;
480
+ if (candidate.keys.some((k) => other.keys.includes(k))) return true;
481
+ }
482
+ return false;
483
+ }
484
+
485
+ /**
486
+ * Apply `overrides` onto {@link DEFAULT_BINDINGS} + {@link DEFAULT_OVERLAY_BINDINGS}
487
+ * (replace keys per target, preserving each binding's pane scope / overlay) and
488
+ * detect collisions. A colliding override is reverted to its default so the
489
+ * dispatch stays unambiguous. Dashboard and overlay namespaces are validated
490
+ * separately (they are never live simultaneously).
366
491
  */
367
492
  function computeEffectiveBindings(overrides: KeybindingOverride): EffectiveBindingsResult {
493
+ // ── dashboard namespace (semantics unchanged since UI-2) ──
368
494
  const applied = new Map<DashboardKeyAction, KeyBinding>();
369
495
  for (const def of DEFAULT_BINDINGS) {
370
496
  const ov = overrides[def.action];
371
497
  applied.set(def.action, ov && ov.length > 0 ? { keys: [...ov], action: def.action, pane: def.pane } : def);
372
498
  }
373
499
  const effective = [...applied.values()];
374
- const reverted = new Set<DashboardKeyAction>();
500
+ const reverted = new Set<string>();
375
501
  for (const def of DEFAULT_BINDINGS) {
376
502
  const ov = overrides[def.action];
377
503
  if (!ov || ov.length === 0) continue; // not overridden
378
504
  const ob = applied.get(def.action);
379
505
  if (!ob) continue;
380
- for (const other of effective) {
381
- if (other.action === def.action) continue;
382
- if (!paneScopesCompatible(ob.pane, other.pane)) continue;
383
- if (ob.keys.some((k) => other.keys.includes(k))) {
384
- reverted.add(def.action);
385
- break;
386
- }
387
- }
506
+ if (collidesWithOthers(ob, effective, (a, b) => paneScopesCompatible(a.pane, b.pane))) reverted.add(def.action);
388
507
  }
389
508
  const bindings =
390
509
  reverted.size > 0
391
510
  ? effective.map((b) => (reverted.has(b.action) ? (DEFAULT_BINDINGS.find((d) => d.action === b.action) ?? b) : b))
392
511
  : effective;
393
- return { bindings, reverted: [...reverted] };
512
+
513
+ // ── overlay namespace (M2-1) ──
514
+ const overlayBindings = new Map<OverlayName, readonly OverlayBinding[]>();
515
+ for (const [overlay, defaults] of DEFAULT_OVERLAY_BINDINGS) {
516
+ const perOverlay: OverlayBinding[] = defaults.map((def) => {
517
+ const ov = overrides[overlayBindingKey(overlay, def.action)];
518
+ return ov && ov.length > 0 ? { overlay, action: def.action, keys: [...ov] } : def;
519
+ });
520
+ for (const def of defaults) {
521
+ const key = overlayBindingKey(overlay, def.action);
522
+ const ov = overrides[key];
523
+ if (!ov || ov.length === 0) continue; // not overridden
524
+ const ob = perOverlay.find((b) => b.action === def.action);
525
+ if (!ob) continue;
526
+ // Same overlay ⇒ same input lineage ⇒ any shared key is ambiguous.
527
+ if (collidesWithOthers(ob, perOverlay, () => true)) reverted.add(key);
528
+ }
529
+ if (reverted.size === 0) {
530
+ overlayBindings.set(overlay, perOverlay);
531
+ continue;
532
+ }
533
+ overlayBindings.set(
534
+ overlay,
535
+ perOverlay.map((b) =>
536
+ reverted.has(overlayBindingKey(overlay, b.action)) ? (defaults.find((d) => d.action === b.action) ?? b) : b,
537
+ ),
538
+ );
539
+ }
540
+
541
+ return { bindings, overlayBindings, reverted: [...reverted] };
394
542
  }
395
543
 
396
544
  /** Read the `keybindings` section from `<cwd>/.crew/config.json`. */
@@ -428,15 +576,17 @@ interface EffectiveCache {
428
576
  readonly configMtime: number | undefined;
429
577
  readonly cwd: string;
430
578
  readonly bindings: readonly KeyBinding[];
579
+ readonly overlayBindings: ReadonlyMap<OverlayName, readonly OverlayBinding[]>;
431
580
  }
432
581
 
433
582
  let _effectiveCache: EffectiveCache | null = null;
434
583
  let _overrideWarnings: readonly string[] = [];
435
584
 
436
585
  /**
437
- * Resolve the effective dispatch table: {@link DEFAULT_BINDINGS} with config +
438
- * env overrides applied (env wins per action). Memoised on (env value, config
439
- * mtime, cwd); a single `statSync` per call detects on-disk config changes.
586
+ * Resolve the effective dispatch tables: {@link DEFAULT_BINDINGS} and
587
+ * {@link DEFAULT_OVERLAY_BINDINGS} with config + env overrides applied (env
588
+ * wins per target). Memoised on (env value, config mtime, cwd); a single
589
+ * `statSync` per call detects on-disk config changes.
440
590
  */
441
591
  function getEffectiveBindings(cwd: string = process.cwd()): readonly KeyBinding[] {
442
592
  const envRaw = getCrewEnv(KEYBINDINGS_ENV);
@@ -445,12 +595,18 @@ function getEffectiveBindings(cwd: string = process.cwd()): readonly KeyBinding[
445
595
  return _effectiveCache.bindings;
446
596
  }
447
597
  const merged: KeybindingOverride = { ...readConfigKeybindings(cwd), ...readEnvKeybindings() };
448
- const { bindings, reverted } = computeEffectiveBindings(merged);
598
+ const { bindings, overlayBindings, reverted } = computeEffectiveBindings(merged);
449
599
  _overrideWarnings = reverted.map((a) => `keybinding override for '${a}' collides with another binding — reverting to default`);
450
- _effectiveCache = { env: envRaw, configMtime, cwd, bindings };
600
+ _effectiveCache = { env: envRaw, configMtime, cwd, bindings, overlayBindings };
451
601
  return bindings;
452
602
  }
453
603
 
604
+ /** Resolved overlay bindings for one overlay (defaults + non-colliding overrides). */
605
+ function getEffectiveOverlayBindings(overlay: OverlayName): readonly OverlayBinding[] {
606
+ getEffectiveBindings(); // ensures the memo is warm + warnings are populated
607
+ return _effectiveCache?.overlayBindings.get(overlay) ?? DEFAULT_OVERLAY_BINDINGS.get(overlay) ?? [];
608
+ }
609
+
454
610
  /** Warnings from the most recent override resolution (e.g. collisions). */
455
611
  export function getKeybindingOverrideWarnings(): readonly string[] {
456
612
  // Ensure a resolution has run so warnings are populated.
@@ -514,3 +670,45 @@ export function dashboardActionForKey(data: string, activePane?: ActivePane): Da
514
670
  }
515
671
  return undefined;
516
672
  }
673
+
674
+ /**
675
+ * Resolve a raw input `data` string to an OVERLAY action (M2-1 / P1-9).
676
+ *
677
+ * Mirror image of {@link dashboardActionForKey} over the overlay keyspace:
678
+ * same two-pass dispatch (case-sensitive exact match first, then the
679
+ * terminal-aware `matchesKey` normalization), same override pipeline
680
+ * (`.crew/config.json` → `keybindings["overlay:<name>:<action>"]`, then the
681
+ * `PI_CREW_KEYBINDINGS` env var, collision → revert to default).
682
+ *
683
+ * With no override configured the result is identical to the key chains the
684
+ * overlays used to hardcode (asserted by
685
+ * `test/unit/ui/overlay-keybindings.test.ts`).
686
+ *
687
+ * @param overlay Overlay namespace, e.g. `"confirm"`.
688
+ * @param data Raw key input (single char or escape sequence).
689
+ */
690
+ export function overlayActionForKey<N extends OverlayName>(overlay: N, data: string): OverlayActionOf<N> | undefined {
691
+ const bindings = getEffectiveOverlayBindings(overlay);
692
+ // Pass 1 — exact, case-sensitive match (keeps "y"/"Y" and "A"/"a" distinct).
693
+ for (const binding of bindings) {
694
+ if (binding.keys.includes(data)) return binding.action as OverlayActionOf<N>;
695
+ }
696
+ // Pass 2 — escape-sequence / canonical KeyId normalisation.
697
+ //
698
+ // Single-character candidates are deliberately EXCLUDED here: pi-tui's
699
+ // `matchesKey()` is case-INSENSITIVE for plain ASCII
700
+ // (`matchesKey("a", "A") === true`), which would collapse the deliberate
701
+ // case distinctions this keyspace relies on ("A"=ack vs lowercase "a"=free
702
+ // text in mailbox-compose; "y"/"Y" in confirm). Pass 1 already matched
703
+ // single chars exactly and case-sensitively, mirroring the pre-M2-1 chains
704
+ // (`data === "A"`, `data === "P"`, …).
705
+ const key = keyOf(data);
706
+ for (const binding of bindings) {
707
+ for (const candidate of binding.keys) {
708
+ if (candidate.length === 1) continue;
709
+ if (key === candidate) return binding.action as OverlayActionOf<N>;
710
+ if (matchesKey(data, candidate as KeyId)) return binding.action as OverlayActionOf<N>;
711
+ }
712
+ }
713
+ return undefined;
714
+ }
@@ -3,16 +3,47 @@
3
3
  *
4
4
  * R8: Subscribes to session events for real-time streaming updates.
5
5
  * Falls back to polling LiveAgentHandle.activity when subscribe is unavailable.
6
+ *
7
+ * Frame (RAIL design language, M4/E1 2026-09-16):
8
+ *
9
+ * ┏ LIVE ▸ explorer
10
+ * ┃ ◉ building feature · turn 2/10 · 3 tools · 5.1s · ▕██░░░░░░▏ 24% ctx · sonnet
11
+ * ┃ <streamed transcript rows…>
12
+ * ┗ ↑/↓/PgUp/PgDn/G scroll · A pause · Esc/Q close ···· auto-scroll
13
+ *
14
+ * The rounded box (`╭─╮│╰─╯` + its two `│ ──── │` rules) is retired: the canopy
15
+ * opens the surface, every body row is a `┃` rail line, the `┗` close cap
16
+ * carries the hint (`formatHint`, close LAST) on the left and the scroll STATE
17
+ * (`auto-scroll` / `manual 12-30/31`) on the dot-led right segment, so the state
18
+ * is never the part that gets truncated. Chrome therefore drops from 6 rows to 3
19
+ * (canopy + meta + cap) and the viewport gains the three freed rows — keys,
20
+ * scroll rules and clamping are unchanged.
21
+ *
22
+ * Keyboard (P1-3: the overlay captures focus while open — see
23
+ * `handleInput` below for the full key set):
24
+ * esc / q close, back to the main conversation
25
+ * pgup / pgdn / ↑ ↓ / j k / g G scroll the transcript
26
+ * a toggle auto-scroll
6
27
  */
28
+ import { matchesKey } from "@earendil-works/pi-tui";
7
29
  import type { LiveAgentHandle } from "../runtime/live-session/live-agent-manager.ts";
8
- import { pad, truncate } from "../utils/visual.ts";
30
+ import { truncate } from "../utils/visual.ts";
31
+ import { formatCount } from "./format-helpers.ts";
9
32
  import { computeLiveDurationMs } from "./live-duration.ts";
33
+ import { canopyLine, formatHint, gaugeBar, RAIL, railLeaders, railLine, statusSlot } from "./rail.ts";
10
34
  import { spinnerFrame } from "./spinner.ts";
11
35
  import { iconForStatus } from "./status-colors.ts";
12
- import type { CrewTheme } from "./theme-adapter.ts";
36
+ import type { CrewTheme, CrewThemeColor } from "./theme-adapter.ts";
13
37
 
14
- const CHROME_LINES = 6;
38
+ /** Chrome rows: canopy + meta row + close cap. Every other row is transcript. */
39
+ const CHROME_LINES = 3;
15
40
  const MIN_VIEWPORT = 3;
41
+ /** Lines moved by one ↑/k or ↓/j press. */
42
+ const SCROLL_LINE_STEP = 1;
43
+ /** Lines moved by one PgUp/PgDn press (same page size as the agent view overlay). */
44
+ const SCROLL_PAGE_STEP = 10;
45
+ /** Bar width of the `% ctx` gauge in the meta row. */
46
+ const CTX_GAUGE_WIDTH = 8;
16
47
 
17
48
  export class LiveConversationOverlay {
18
49
  private scrollOffset = 0;
@@ -46,7 +77,7 @@ export class LiveConversationOverlay {
46
77
  const text = typeof obj.text === "string" ? obj.text : typeof obj.content === "string" ? obj.content : "";
47
78
  if (text.trim()) {
48
79
  this.pushLine(text);
49
- if (this.autoScroll) this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
80
+ if (this.autoScroll) this.scrollOffset = this.maxScrollOffset();
50
81
  }
51
82
  });
52
83
  } catch {
@@ -86,83 +117,182 @@ export class LiveConversationOverlay {
86
117
 
87
118
  private refreshSummary(): void {
88
119
  const act = this.handle.activity;
89
- const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${act.turnCount} turns · ${act.toolUses} tools · ${(computeLiveDurationMs(act) / 1000).toFixed(1)}s]`;
120
+ const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${formatCount(act.turnCount ?? 0, "turn")} · ${formatCount(act.toolUses ?? 0, "tool")} · ${(computeLiveDurationMs(act) / 1000).toFixed(1)}s]`;
90
121
  const lastLine = this.cachedLines[this.cachedLines.length - 1];
91
122
  if (lastLine?.startsWith(LiveConversationOverlay.SUMMARY_PREFIX)) {
92
123
  this.cachedLines[this.cachedLines.length - 1] = summary;
93
124
  } else {
94
125
  this.pushLine(summary);
95
126
  }
96
- if (this.autoScroll) this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
127
+ if (this.autoScroll) this.scrollOffset = this.maxScrollOffset();
97
128
  }
98
129
 
99
130
  private viewportHeight(): number {
100
131
  return Math.max(MIN_VIEWPORT, this.rows - CHROME_LINES);
101
132
  }
102
133
 
134
+ /** Largest offset that still shows the newest line in the viewport. */
135
+ private maxScrollOffset(): number {
136
+ return Math.max(0, this.cachedLines.length - this.viewportHeight());
137
+ }
138
+
139
+ /**
140
+ * autoScroll rule (M1-6):
141
+ * - a manual scroll always recomputes autoScroll from the position — the
142
+ * viewport tails the stream only while it sits at the newest line;
143
+ * - scrolling DOWN to (or past) the newest line therefore restores
144
+ * autoScroll, and scrolling UP away from it pauses it so incoming lines
145
+ * cannot yank the viewport back;
146
+ * - `a` toggles explicitly: turning it on jumps to the newest line,
147
+ * turning it off pauses wherever the viewport currently is.
148
+ */
149
+ private scrollBy(delta: number): void {
150
+ this.scrollOffset = Math.max(0, Math.min(this.maxScrollOffset(), this.scrollOffset + delta));
151
+ this.autoScroll = this.scrollOffset >= this.maxScrollOffset();
152
+ }
153
+
154
+ /** Jump to the oldest buffered line. */
155
+ private scrollHome(): void {
156
+ this.scrollBy(-this.cachedLines.length);
157
+ }
158
+
159
+ /** Jump to the newest line (re-enables auto-scroll). */
160
+ private scrollEnd(): void {
161
+ this.scrollBy(this.cachedLines.length);
162
+ }
163
+
164
+ /** Toggle auto-scroll; turning it on jumps to the newest line. */
165
+ private toggleAutoScroll(): void {
166
+ if (this.autoScroll) this.autoScroll = false;
167
+ else this.scrollEnd();
168
+ }
169
+
170
+ /**
171
+ * Handle a keypress while the overlay has focus. Mirrors the key set of
172
+ * src/ui/inline-panel/agent-view-overlay.ts:160-182:
173
+ *
174
+ * esc / q close the overlay
175
+ * ↓ / j scroll down one line
176
+ * ↑ / k scroll up one line
177
+ * PgDn / PgUp scroll one page
178
+ * g (home) jump to the oldest buffered line
179
+ * G (end) jump to the newest line (re-enables auto-scroll)
180
+ * a toggle auto-scroll
181
+ *
182
+ * The host component in src/extension/registration/viewers.ts intercepts
183
+ * esc/q itself (it owns the `done()` callback) and forwards every other key
184
+ * here; the esc/q branch below keeps this class self-contained for direct
185
+ * callers and for the host contract. Unrecognised keys are ignored — the
186
+ * overlay never leaks keys into the editor underneath.
187
+ */
188
+ handleInput(data: string): void {
189
+ if (this.closed) return;
190
+ if (matchesKey(data, "escape") || data === "q") {
191
+ this.close();
192
+ return;
193
+ }
194
+ if (matchesKey(data, "down") || data === "j") {
195
+ this.scrollBy(SCROLL_LINE_STEP);
196
+ return;
197
+ }
198
+ if (matchesKey(data, "up") || data === "k") {
199
+ this.scrollBy(-SCROLL_LINE_STEP);
200
+ return;
201
+ }
202
+ if (matchesKey(data, "pageDown")) {
203
+ this.scrollBy(SCROLL_PAGE_STEP);
204
+ return;
205
+ }
206
+ if (matchesKey(data, "pageUp")) {
207
+ this.scrollBy(-SCROLL_PAGE_STEP);
208
+ return;
209
+ }
210
+ if (matchesKey(data, "home") || data === "g") {
211
+ this.scrollHome();
212
+ return;
213
+ }
214
+ if (matchesKey(data, "end") || data === "G") {
215
+ this.scrollEnd();
216
+ return;
217
+ }
218
+ if (data === "a") {
219
+ this.toggleAutoScroll();
220
+ }
221
+ }
222
+
223
+ /** Left half of the close cap: the key legend, through `formatHint`. */
224
+ private hintText(): string {
225
+ return formatHint([
226
+ [["up", "down", "pageup", "pagedown", "g", "G"], "scroll"],
227
+ ["a", this.autoScroll ? "pause" : "resume"],
228
+ [["escape", "q"], "close"],
229
+ ]);
230
+ }
231
+
232
+ /**
233
+ * Right half of the close cap: the scroll STATE (dot-led so it can never be
234
+ * the segment that gets truncated). Every key advertised is handled by
235
+ * `handleInput` in BOTH states (P1-3: the old `↑/k ↓/j G/g` hint lived in an
236
+ * autoScroll === false branch that could never be reached, because nothing
237
+ * ever turned autoScroll off).
238
+ */
239
+ private stateText(): string {
240
+ if (this.autoScroll) return "auto-scroll";
241
+ const total = this.cachedLines.length;
242
+ const from = total === 0 ? 0 : this.scrollOffset + 1;
243
+ const to = Math.min(total, this.scrollOffset + this.viewportHeight());
244
+ return `manual ${from}-${to}/${total}`;
245
+ }
246
+
103
247
  render(width?: number): string[] {
104
248
  const w = width ?? this.columns;
105
249
  if (w < 6) return [];
106
250
  const th = this.theme;
107
- const innerW = w - 4;
108
- const row = (content: string) => th.fg("border", "│") + " " + pad(truncate(content, innerW), innerW) + " " + th.fg("border", "│");
109
- const hrTop = th.fg("border", `╭${"─".repeat(w - 2)}╮`);
110
- const hrBot = th.fg("border", `╰${"─".repeat(w - 2)}╯`);
111
- const hrMid = row(th.fg("dim", "─".repeat(innerW)));
251
+ const budget = w - 2;
252
+ const act = this.handle.activity;
253
+ const slot = statusSlot(this.handle.status);
112
254
 
113
255
  const lines: string[] = [];
114
- lines.push(hrTop);
256
+ // Canopy: ┏ LIVE ▸ <agent> (every identity field guarded — the handle is
257
+ // assembled at runtime and a missing name must never print `undefined`).
258
+ lines.push(canopyLine({ word: "LIVE", subject: this.handle.agent ?? this.handle.taskId ?? "?", theme: th, budget }));
115
259
 
116
- // Header
260
+ // Meta row: status glyph, description, then the counters.
117
261
  const statusIcon =
118
262
  this.handle.status === "running"
119
- ? th.fg("accent", spinnerFrame(this.handle.taskId ?? this.handle.agentId))
263
+ ? th.fg("accent", spinnerFrame(this.handle.taskId ?? this.handle.agentId ?? ""))
120
264
  : iconForStatus(this.handle.status);
121
- const name = this.handle.agent ?? this.handle.taskId;
122
- const act = this.handle.activity;
123
- const elapsed = `${(computeLiveDurationMs(act) / 1000).toFixed(1)}s`;
124
- const headerParts: string[] = [];
125
- if (act.maxTurns != null) headerParts.push(`turn ${act.turnCount}/${act.maxTurns}`);
126
- else if (act.turnCount > 0) headerParts.push(`turn ${act.turnCount}`);
127
- if (act.toolUses > 0) headerParts.push(`${act.toolUses} tools`);
128
- headerParts.push(elapsed);
129
-
130
- // Context % + compaction
265
+ const desc = this.handle.description ?? this.handle.role ?? "";
266
+ const parts: string[] = [];
267
+ if (act.maxTurns != null) parts.push(`turn ${act.turnCount ?? 0}/${act.maxTurns}`);
268
+ else if ((act.turnCount ?? 0) > 0) parts.push(`turn ${act.turnCount}`);
269
+ if ((act.toolUses ?? 0) > 0) parts.push(formatCount(act.toolUses ?? 0, "tool"));
270
+ parts.push(`${(computeLiveDurationMs(act) / 1000).toFixed(1)}s`);
131
271
  try {
132
272
  const ctxPct = this.handle.session.getSessionStats?.()?.contextUsage?.percent;
133
273
  if (ctxPct != null) {
134
- const color = ctxPct >= 85 ? "error" : ctxPct >= 70 ? "warning" : "dim";
135
- headerParts.push(th.fg(color, `${Math.round(ctxPct)}% ctx`));
274
+ const color: CrewThemeColor = ctxPct >= 85 ? "error" : ctxPct >= 70 ? "warning" : "dim";
275
+ parts.push(th.fg(color, `${gaugeBar(ctxPct / 100, CTX_GAUGE_WIDTH, th, color)} ${Math.round(ctxPct)}% ctx`));
136
276
  }
137
277
  } catch {
138
278
  /* ignore */
139
279
  }
140
- if (act.compactionCount > 0) headerParts.push(th.fg("dim", `↻${act.compactionCount}`));
141
- // Model name
142
- if (this.handle.modelName) headerParts.push(th.fg("muted", this.handle.modelName));
280
+ if ((act.compactionCount ?? 0) > 0) parts.push(th.fg("dim", `↻${act.compactionCount}`));
281
+ if (this.handle.modelName) parts.push(th.fg("muted", this.handle.modelName));
282
+ const describe = desc ? `${th.fg("muted", desc)} ${th.fg("dim", "·")} ` : "";
283
+ lines.push(railLine(RAIL.body, slot, truncate(`${statusIcon} ${describe}${th.fg("dim", parts.join(" · "))}`, budget), th, budget));
143
284
 
144
- const desc = this.handle.description ?? this.handle.role ?? "";
145
- lines.push(
146
- row(
147
- `${statusIcon} ${th.fg("accent", name)} ${th.fg("muted", desc)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
148
- ),
149
- );
150
- lines.push(hrMid);
151
-
152
- // Content
285
+ // Transcript body.
153
286
  const vh = this.viewportHeight();
154
287
  const visible = this.cachedLines.slice(this.scrollOffset, this.scrollOffset + vh);
155
288
  for (const line of visible) {
156
- lines.push(row(th.fg("dim", line)));
289
+ lines.push(railLine(RAIL.body, "border", th.fg("dim", truncate(line, budget)), th, budget));
157
290
  }
158
291
 
159
- // Footer
160
- lines.push(hrMid);
161
- const footerText = this.autoScroll
162
- ? "auto-scroll · esc/q close"
163
- : `line ${this.scrollOffset + 1}/${this.cachedLines.length} · ↑/k ↓/j G/g · esc/q close`;
164
- lines.push(row(th.fg("dim", footerText)));
165
- lines.push(hrBot);
292
+ // Close cap: hint (left) ···· scroll state (right).
293
+ lines.push(
294
+ railLine(RAIL.close, slot, railLeaders(th.fg("dim", this.hintText()), th.fg("dim", this.stateText()), budget, th), th, budget),
295
+ );
166
296
  return lines;
167
297
  }
168
298