@pellux/goodvibes-tui 1.7.0 → 1.9.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 (140) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -9
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/config/credential-availability.ts +122 -0
  12. package/src/config/index.ts +15 -0
  13. package/src/core/composer-state.ts +11 -3
  14. package/src/core/conversation-line-cache.ts +27 -3
  15. package/src/core/conversation-rendering.ts +71 -14
  16. package/src/core/stream-event-wiring.ts +20 -1
  17. package/src/core/system-message-noise.ts +87 -0
  18. package/src/core/system-message-router.ts +68 -1
  19. package/src/core/turn-cancellation.ts +7 -2
  20. package/src/core/turn-event-wiring.ts +10 -2
  21. package/src/daemon/cli.ts +29 -2
  22. package/src/daemon/handlers/register.ts +8 -1
  23. package/src/daemon/service-commands.ts +329 -0
  24. package/src/input/autocomplete.ts +27 -1
  25. package/src/input/command-registry.ts +46 -4
  26. package/src/input/commands/codebase-runtime.ts +46 -6
  27. package/src/input/commands/config.ts +43 -3
  28. package/src/input/commands/health-runtime.ts +9 -1
  29. package/src/input/commands/memory.ts +68 -35
  30. package/src/input/commands/operator-panel-runtime.ts +31 -7
  31. package/src/input/commands/planning-runtime.ts +95 -6
  32. package/src/input/commands/qrcode-runtime.ts +25 -5
  33. package/src/input/commands/remote-runtime-setup.ts +5 -3
  34. package/src/input/commands/session-content.ts +20 -9
  35. package/src/input/commands/settings-sync-runtime.ts +15 -3
  36. package/src/input/commands/shell-core.ts +10 -1
  37. package/src/input/commands/workstream-runtime.ts +168 -18
  38. package/src/input/config-modal-types.ts +15 -1
  39. package/src/input/config-modal.ts +227 -12
  40. package/src/input/feed-context-factory.ts +3 -0
  41. package/src/input/handler-command-route.ts +10 -3
  42. package/src/input/handler-content-actions.ts +17 -2
  43. package/src/input/handler-feed-routes.ts +43 -121
  44. package/src/input/handler-feed.ts +32 -4
  45. package/src/input/handler-modal-routes.ts +78 -13
  46. package/src/input/handler-modal-stack.ts +11 -2
  47. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  48. package/src/input/handler-onboarding.ts +71 -59
  49. package/src/input/handler-picker-routes.ts +15 -8
  50. package/src/input/handler-shortcuts.ts +59 -9
  51. package/src/input/handler.ts +6 -1
  52. package/src/input/keybindings.ts +6 -5
  53. package/src/input/model-picker.ts +19 -2
  54. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  55. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  56. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  57. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  58. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  59. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  60. package/src/input/panel-mouse-geometry.ts +97 -0
  61. package/src/input/panel-paste-flood-guard.ts +86 -0
  62. package/src/input/selection-modal.ts +11 -0
  63. package/src/input/session-picker-modal.ts +44 -4
  64. package/src/input/settings-modal-data.ts +51 -2
  65. package/src/input/settings-modal-types.ts +4 -2
  66. package/src/main.ts +28 -27
  67. package/src/panels/builtin/shared.ts +9 -3
  68. package/src/panels/fleet-deep-link.ts +31 -0
  69. package/src/panels/fleet-panel-format.ts +62 -0
  70. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  71. package/src/panels/fleet-panel.ts +89 -131
  72. package/src/panels/fleet-read-model.ts +43 -0
  73. package/src/panels/fleet-steer.ts +35 -2
  74. package/src/panels/fleet-stop.ts +29 -1
  75. package/src/panels/modals/keybindings-modal.ts +16 -1
  76. package/src/panels/modals/modal-theme.ts +35 -29
  77. package/src/panels/modals/pairing-modal.ts +25 -6
  78. package/src/panels/modals/planning-modal.ts +43 -21
  79. package/src/panels/modals/work-plan-modal.ts +28 -0
  80. package/src/panels/panel-manager.ts +15 -4
  81. package/src/panels/polish-core.ts +38 -25
  82. package/src/panels/project-planning-answer-actions.ts +8 -13
  83. package/src/panels/types.ts +24 -0
  84. package/src/permissions/prompt.ts +160 -13
  85. package/src/renderer/autocomplete-overlay.ts +28 -2
  86. package/src/renderer/compositor.ts +2 -3
  87. package/src/renderer/config-modal.ts +19 -5
  88. package/src/renderer/footer-tips.ts +5 -1
  89. package/src/renderer/fullscreen-primitives.ts +32 -22
  90. package/src/renderer/git-status.ts +3 -1
  91. package/src/renderer/layout.ts +0 -4
  92. package/src/renderer/markdown.ts +7 -3
  93. package/src/renderer/modal-factory.ts +25 -20
  94. package/src/renderer/model-workspace.ts +18 -3
  95. package/src/renderer/overlay-box.ts +21 -17
  96. package/src/renderer/process-indicator.ts +14 -3
  97. package/src/renderer/selection-modal-overlay.ts +6 -1
  98. package/src/renderer/session-picker-modal.ts +196 -3
  99. package/src/renderer/settings-modal-helpers.ts +2 -0
  100. package/src/renderer/settings-modal.ts +7 -0
  101. package/src/renderer/shell-surface.ts +8 -1
  102. package/src/renderer/status-glyphs.ts +14 -15
  103. package/src/renderer/system-message.ts +15 -3
  104. package/src/renderer/terminal-bg-probe.ts +339 -0
  105. package/src/renderer/terminal-escapes.ts +20 -0
  106. package/src/renderer/theme-mode-config.ts +67 -0
  107. package/src/renderer/theme.ts +91 -1
  108. package/src/renderer/thinking.ts +11 -3
  109. package/src/renderer/tool-call.ts +15 -9
  110. package/src/renderer/tool-result-summary.ts +148 -0
  111. package/src/renderer/turn-injection.ts +22 -3
  112. package/src/renderer/ui-factory.ts +154 -85
  113. package/src/renderer/ui-primitives.ts +30 -129
  114. package/src/runtime/bootstrap-command-context.ts +6 -0
  115. package/src/runtime/bootstrap-command-parts.ts +8 -4
  116. package/src/runtime/bootstrap-core.ts +46 -24
  117. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  118. package/src/runtime/bootstrap-shell.ts +19 -1
  119. package/src/runtime/bootstrap.ts +118 -5
  120. package/src/runtime/code-index-services.ts +25 -2
  121. package/src/runtime/legacy-daemon-migration.ts +516 -0
  122. package/src/runtime/memory-fold.ts +55 -0
  123. package/src/runtime/onboarding/derivation.ts +7 -2
  124. package/src/runtime/onboarding/snapshot.ts +27 -1
  125. package/src/runtime/onboarding/types.ts +31 -1
  126. package/src/runtime/operator-token-cleanup.ts +82 -1
  127. package/src/runtime/orchestrator-core-services.ts +10 -0
  128. package/src/runtime/resume-notice.ts +209 -0
  129. package/src/runtime/services.ts +12 -8
  130. package/src/runtime/session-inbound-inputs.ts +252 -0
  131. package/src/runtime/session-spine-transport.ts +64 -0
  132. package/src/runtime/terminal-output-guard.ts +15 -8
  133. package/src/runtime/ui-services.ts +19 -3
  134. package/src/runtime/workstream-services.ts +160 -28
  135. package/src/runtime/wrfc-persistence.ts +124 -17
  136. package/src/shell/blocking-input.ts +46 -3
  137. package/src/shell/recovery-input-helpers.ts +170 -1
  138. package/src/shell/ui-openers.ts +42 -9
  139. package/src/utils/terminal-width.ts +52 -0
  140. package/src/version.ts +1 -1
@@ -4,23 +4,17 @@
4
4
  // W2.2 — FleetPanel: the live unified observability tree. Renders the
5
5
  // registered process fleet (agents incl. WRFC roles, WRFC chains/subtasks,
6
6
  // workflow-tool FSMs, watchers, background processes) as a depth-first tree
7
- // with a selected-row detail region.
8
- //
9
- // Wave-3 (W3.1 Part C) adds session tabs: Enter on an attachable node (agent
10
- // or wrfc-chain) opens a tab showing that process's transcript (agent) or
11
- // live member summary (chain); the tree itself is the always-present root
12
- // tab. See fleet-tabs.ts for the tab-state model and fleet-transcript.ts for
13
- // tab content rendering — this file owns input dispatch and layout only.
14
- //
15
- // Coexists with (does not replace) the process modal, ops, wrfc, and
16
- // inspector entry points this wave — only the footer process indicator's
17
- // [Enter] is repointed to open this panel (see handler-feed-routes.ts).
18
- // Removal of the older entry points is Wave 6.
7
+ // with a selected-row detail region. Session tabs (W3.1 Part C): Enter on an
8
+ // attachable node (agent/wrfc-chain) opens a tab with that process's
9
+ // transcript/member summary see fleet-tabs.ts + fleet-transcript.ts. This
10
+ // file owns input dispatch and layout; fleet-deep-link.ts owns the
11
+ // cross-panel jump target (DEBT-5 item 4).
19
12
  // ---------------------------------------------------------------------------
20
13
 
21
14
  import { readFile } from 'node:fs/promises';
22
15
  import type { Line } from '../types/grid.ts';
23
16
  import type { ProcessCostState, ProcessUsage, SteerResult } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
17
+ import { formatWorkItemIsolationDetailFromRaw } from './fleet-panel-worktree-detail.ts';
24
18
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
25
19
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
26
20
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
@@ -52,62 +46,15 @@ import {
52
46
  type FleetTabsState,
53
47
  type SteerBadge,
54
48
  } from './fleet-tabs.ts';
55
- import { reconcileSteerBadges as reconcileSteerBadgesPure, steerBadgeGlyph, steerBadgeTone } from './fleet-steer.ts';
56
- import { FleetStopTracker, fleetStateDisplay, toggleFleetPause, buildFleetTreeHints } from './fleet-stop.ts';
49
+ import { liveSteerableLabels, reconcileSteerBadges as reconcileSteerBadgesPure, renderSteerBadgeLine, steerBadgeGlyph, steerBadgeTone, steerRefusalMessage } from './fleet-steer.ts';
50
+ import { FleetStopTracker, fleetStateDisplay, toggleFleetPause, buildFleetTreeHints, countDescendantStats } from './fleet-stop.ts';
51
+ import { resolveFleetDeepLinkIndex } from './fleet-deep-link.ts';
57
52
  import { parseAgentLedger, renderFleetAgentTranscript, renderFleetChainSummary, renderFleetLedgerFallback, renderFleetTranscriptLoading } from './fleet-transcript.ts';
58
53
  import { renderFleetTabStrip } from '../renderer/fleet-tab-strip.ts';
54
+ import { planColumns, toneColor, formatFleetTokens, formatFleetCost } from './fleet-panel-format.ts';
59
55
 
60
56
  const C = DEFAULT_PANEL_PALETTE;
61
57
 
62
- // Column widths for the tree row layout. `label` absorbs whatever width is
63
- // left over after the fixed columns + gaps; on hostile (narrow) widths the
64
- // trailing columns simply clip (buildAlignedRow/buildSelectablePanelLine stop
65
- // writing once a cell would overflow the row) rather than throwing or
66
- // wrapping — the tree stays readable, just denser.
67
- const KIND_W = 8;
68
- const ELAPSED_W = 7;
69
- const TOKENS_W = 7;
70
- const COST_W = 8;
71
- const ACTIVITY_W = 20;
72
- const GAP = 1;
73
- const FIXED_W = 1 /* glyph */ + KIND_W + ELAPSED_W + TOKENS_W + COST_W + ACTIVITY_W + GAP * 6;
74
-
75
- function planColumns(width: number): ColumnSpec[] {
76
- const labelWidth = Math.max(10, width - FIXED_W);
77
- return [
78
- { width: 1 },
79
- { width: KIND_W },
80
- { width: labelWidth },
81
- { width: ELAPSED_W, align: 'right' },
82
- { width: TOKENS_W, align: 'right' },
83
- { width: COST_W, align: 'right' },
84
- { width: ACTIVITY_W },
85
- ];
86
- }
87
-
88
- function toneColor(tone: FleetStateTone, palette: PanelPalette): string {
89
- switch (tone) {
90
- case 'active': return palette.info ?? DEFAULT_PANEL_PALETTE.info;
91
- case 'success': return palette.good ?? DEFAULT_PANEL_PALETTE.good;
92
- case 'failure': return palette.bad ?? DEFAULT_PANEL_PALETTE.bad;
93
- case 'warn': return palette.warn ?? DEFAULT_PANEL_PALETTE.warn;
94
- case 'muted': return palette.dim;
95
- }
96
- }
97
-
98
- function formatFleetTokens(usage: ProcessUsage | undefined): string {
99
- const total = fleetUsageTokens(usage);
100
- if (total === null) return 'n/a';
101
- return total >= 1000 ? `${(total / 1000).toFixed(1)}k` : String(total);
102
- }
103
-
104
- /** Honest cost display: never a fabricated $0.00 — 'unpriced' when costState says so. */
105
- function formatFleetCost(costUsd: number | null | undefined, costState: ProcessCostState): string {
106
- if (!hasFleetCost(costUsd, costState)) return 'unpriced';
107
- const formatted = formatAgentCost(costUsd as number);
108
- return costState === 'estimated' ? `~${formatted}` : formatted;
109
- }
110
-
111
58
  export interface FleetActionCallbacks {
112
59
  /** Graceful interruption (AgentManager.cancel / trigger-schedule disable, via the registry). */
113
60
  readonly interrupt: (id: string) => boolean;
@@ -286,15 +233,11 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
286
233
  }
287
234
 
288
235
  /**
289
- * W3.3 (cross-restart honesty) investigation confirmed there is no
290
- * bridge from a prior TUI process's in-memory registry (or the daemon's
291
- * separate one) into this one: a restart always starts from an empty
292
- * `AgentManager` Map, so a completed process from a previous session can
293
- * never reappear as a tree row here, no matter how recently it ran. That
294
- * is a real, permanent limitation (not a bug to fix in this item — see the
295
- * W3.3 brief's design point 5), so it is documented plainly here rather
296
- * than silently leaving the empty tree looking like "nothing has ever run"
297
- * or, worse, implying a restart would bring prior processes back.
236
+ * W3.3 (cross-restart honesty): a restart always starts from an empty
237
+ * `AgentManager` Map (no bridge from a prior TUI/daemon registry), so a
238
+ * completed process from a previous session can never reappear here a
239
+ * real, permanent limitation (W3.3 brief point 5), documented rather than
240
+ * silently implying "nothing has ever run" or that a restart brings it back.
298
241
  */
299
242
  protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
300
243
  return [
@@ -361,6 +304,18 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
361
304
  return this.getItems().find((row) => row.node.id === nodeId)?.node ?? null;
362
305
  }
363
306
 
307
+ /** DEBT-5 item 4 (see fleet-deep-link.ts). */
308
+ public receiveDeepLink(target: { readonly id: string; readonly kind?: string }): void {
309
+ const idx = resolveFleetDeepLinkIndex(this.getItems(), target);
310
+ if (idx < 0) { this.setError('node no longer present.'); return; }
311
+ // A successful reveal must be VISIBLE: with a session tab active, render()
312
+ // routes to the tab view and the selection change would be silently
313
+ // swallowed (batch refutation finding 1) — return to the tree first
314
+ // (tabs kept; only the active view switches).
315
+ this.tabsState = { tabs: this.tabsState.tabs, activeTabIndex: 0 };
316
+ this.clearError(); this.selectedIndex = idx; this.selectedNodeId = target.id; this.markDirty();
317
+ }
318
+
364
319
  /**
365
320
  * Wave-3 (W3.2, risk #2 — "dropped inference"): called on every read-model
366
321
  * snapshot update and on the panel's 1s tick. See fleet-steer.ts's
@@ -373,31 +328,6 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
373
328
  if (changed) this.markDirty();
374
329
  }
375
330
 
376
- /** Count of descendants of `nodeId` (in the current snapshot) that report `capabilities.killable` — the Wave-3 K-confirm child count (Part C7). */
377
- private countKillableDescendants(nodeId: string): number {
378
- const rows = this.getItems();
379
- const byParent = new Map<string, string[]>();
380
- for (const row of rows) {
381
- if (!row.node.parentId) continue;
382
- const siblings = byParent.get(row.node.parentId) ?? [];
383
- siblings.push(row.node.id);
384
- byParent.set(row.node.parentId, siblings);
385
- }
386
- const byId = new Map(rows.map((row) => [row.node.id, row] as const));
387
- let count = 0;
388
- const stack = [...(byParent.get(nodeId) ?? [])];
389
- const seen = new Set<string>();
390
- while (stack.length > 0) {
391
- const id = stack.pop()!;
392
- if (seen.has(id)) continue;
393
- seen.add(id);
394
- const row = byId.get(id);
395
- if (row?.node.capabilities.killable) count++;
396
- for (const child of byParent.get(id) ?? []) stack.push(child);
397
- }
398
- return count;
399
- }
400
-
401
331
  /** Kick off (once) the async ledger-fallback load for a tab whose conversation snapshot is unavailable. */
402
332
  private ensureLedgerLoaded(tab: FleetTab): void {
403
333
  if (tab.ledgerLoadStarted) return;
@@ -479,8 +409,8 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
479
409
  }
480
410
  const node = selected.node;
481
411
  const shortId = node.id.length > 8 ? node.id.slice(-8) : node.id;
482
- const childCount = this.countKillableDescendants(node.id);
483
- const suffix = childCount > 0 ? ` (+${childCount} child${childCount === 1 ? '' : 'ren'})` : '';
412
+ const stats = countDescendantStats(this.getItems(), node.id);
413
+ const suffix = stats.total > 0 ? ` (+${stats.total} descendant${stats.total === 1 ? '' : 's'}, ${stats.active} active)` : '';
484
414
  this.confirmOverlay.arm({
485
415
  id: node.id,
486
416
  label: `${node.kind} ${shortId}${suffix}`,
@@ -527,6 +457,28 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
527
457
  return true;
528
458
  }
529
459
 
460
+ if (key === 's') {
461
+ // Batch replay D4: 's' from the TREE was silently dead — steering
462
+ // required an undiscoverable Enter-attach first. Attach-and-steer in
463
+ // one press for a steerable node; honest refusal otherwise.
464
+ if (!selected) return false;
465
+ const node = selected.node;
466
+ // Closure replay note: a node that FINISHED in the hint→keypress race
467
+ // window must say so — "does not support steer" is misleading for an
468
+ // agent whose only problem is being done.
469
+ if (isTerminalProcessState(node.state)) {
470
+ this.setError(`${node.kind} already finished — nothing to steer.`);
471
+ return true;
472
+ }
473
+ if (!node.capabilities.steerable || !isAttachableFleetKind(node.kind)) {
474
+ this.setError(`${node.kind} does not support steer.`);
475
+ return true;
476
+ }
477
+ this.tabsState = attachFleetTab(this.tabsState, node);
478
+ this.markDirty();
479
+ return this.tryOpenSteerDraft(activeFleetTab(this.tabsState)!);
480
+ }
481
+
530
482
  const consumed = super.handleInput(key);
531
483
  if (consumed) this.captureSelectionAnchor();
532
484
  return consumed;
@@ -539,8 +491,12 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
539
491
  */
540
492
  private tryOpenSteerDraft(tab: FleetTab): boolean {
541
493
  const node = this.findLiveNode(tab.nodeId);
542
- if (!node || isTerminalProcessState(node.state) || !node.capabilities.steerable) {
543
- this.setError(`${node?.kind ?? tab.kind} does not support steering.`);
494
+ if (!node || isTerminalProcessState(node.state)) {
495
+ this.setError(`${node?.kind ?? tab.kind} already finished nothing to steer.`);
496
+ return true;
497
+ }
498
+ if (!node.capabilities.steerable) {
499
+ this.setError(`${node.kind} does not support steering.`);
544
500
  return true;
545
501
  }
546
502
  tab.steerDraft = '';
@@ -557,8 +513,15 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
557
513
  }
558
514
  if (isPanelSearchCommit(key)) {
559
515
  const text = (tab.steerDraft ?? '').trim();
560
- tab.steerDraft = null;
561
- if (text.length > 0) this.submitSteer(tab, text);
516
+ if (text.length === 0) {
517
+ tab.steerDraft = null;
518
+ this.markDirty();
519
+ return true;
520
+ }
521
+ // Clear the draft ONLY on a confirmed queue. A refusal keeps the composer
522
+ // open with the text intact (submitSteer explains why + suggests live targets)
523
+ // so the operator never loses what they typed to a just-idle agent.
524
+ if (this.submitSteer(tab, text)) tab.steerDraft = null;
562
525
  this.markDirty();
563
526
  return true;
564
527
  }
@@ -580,16 +543,25 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
580
543
  return true; // absorb every other key while composing
581
544
  }
582
545
 
583
- /** Submit a composed steer message: queued -> badge; refused -> honest inline error (setError), same convention as the i/K "not supported" messages. */
584
- private submitSteer(tab: FleetTab, text: string): void {
546
+ /**
547
+ * Submit a composed steer message. Returns whether it was queued so the caller
548
+ * clears the draft only on a confirmed send (WO UX-A item 4): a refusal (target
549
+ * went idle while composing) PRESERVES the typed text and suggests which agents
550
+ * ARE steerable, instead of silently discarding the draft. On a successful queue
551
+ * the ⧗ badge line is the immediate honest acknowledgment.
552
+ */
553
+ private submitSteer(tab: FleetTab, text: string): boolean {
585
554
  const result = this.actions.steer(tab.nodeId, text);
586
555
  if (result.queued) {
587
556
  // queuedAt drives reconcileSteerBadges' TTL-expiry fallback (fleet-steer.ts).
588
557
  tab.steerBadge = { messageId: result.messageId, status: 'queued', queuedAt: Date.now() };
589
- } else {
590
- this.setError(result.reason);
558
+ this.markDirty();
559
+ return true;
591
560
  }
561
+ const siblings = liveSteerableLabels(this.readModel.getSnapshot().rows.map((row) => row.node), tab.nodeId);
562
+ this.setError(steerRefusalMessage(result.reason, siblings));
592
563
  this.markDirty();
564
+ return false;
593
565
  }
594
566
 
595
567
  protected renderItem(row: FleetTreeRow, _index: number, _selected: boolean, width: number): Line {
@@ -662,12 +634,10 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
662
634
  [' activity ', C.label],
663
635
  [truncateDisplay(activityText, Math.max(0, width - 11)), C.dim],
664
636
  ]);
665
- // Wave 3 hook: approval history attaches here once session tabs land.
666
- const line4 = buildPanelLine(width, [
667
- [' approvals ', C.label],
668
- ['(Wave 3)', C.dim],
669
- ]);
670
- return [line1, line2, line3, line4];
637
+ // Approval history attaches here once session tabs land.
638
+ const line4 = buildPanelLine(width, [[' approvals ', C.label], ['—', C.dim]]);
639
+ const isolationDetail = node.kind === 'work-item' ? formatWorkItemIsolationDetailFromRaw(node.raw) : null;
640
+ return [line1, line2, line3, line4, ...(isolationDetail ? [buildPanelLine(width, [[' isolation ', C.label], [isolationDetail, C.dim]])] : [])];
671
641
  }
672
642
 
673
643
  /** Renders the active session tab's content (transcript / chain summary / ledger fallback) in place of the tree. */
@@ -706,8 +676,12 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
706
676
  palette,
707
677
  { active: true, bg: palette.inputBg, valueColor: palette.info },
708
678
  ));
709
- } else if (tab.steerBadge) {
710
- composerLines.push(this.renderSteerBadgeLine(tab.steerBadge, width, palette));
679
+ } else if (tab.steerBadge && !this.stopTracker.isStopping(tab.nodeId)) {
680
+ // A queued-steer promise ("delivers on its next turn") must not render
681
+ // while a stop is in flight for this node — there may be no next turn
682
+ // (refutation finding 4). The badge reconciles to dropped/consumed once
683
+ // the node's terminal state lands.
684
+ composerLines.push(renderSteerBadgeLine(tab.steerBadge, width, palette, liveNode?.label));
711
685
  }
712
686
 
713
687
  const footerLines = [
@@ -749,22 +723,6 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
749
723
  return buildPanelWorkspace(width, height, { title: `Fleet — ${tab.label}`, sections, footerLines, palette });
750
724
  }
751
725
 
752
- /** One-line honest status for the active tab's steer badge (queued/consumed/dropped) — see fleet-tabs.ts's SteerBadgeStatus doc. */
753
- private renderSteerBadgeLine(badge: SteerBadge, width: number, palette: PanelPalette): Line {
754
- const glyph = steerBadgeGlyph(badge.status);
755
- const tone = steerBadgeTone(badge.status, palette);
756
- const label = badge.status === 'queued'
757
- ? 'steer queued — awaiting the agent\'s next turn'
758
- : badge.status === 'consumed'
759
- ? 'steer consumed'
760
- : `steer dropped — ${badge.note ?? 'the target ended before delivery'}`;
761
- return buildPanelLine(width, [
762
- [' ', palette.dim],
763
- [glyph, tone],
764
- [` ${label}`, palette.dim],
765
- ]);
766
- }
767
-
768
726
  private renderTreeView(width: number, height: number): Line[] {
769
727
  const selected = this.getSelectedItem();
770
728
  const footer: Line[] = [];
@@ -71,6 +71,15 @@ export interface FleetSnapshot {
71
71
 
72
72
  export type FleetStateTone = 'active' | 'success' | 'failure' | 'warn' | 'muted';
73
73
 
74
+ // W6-P1 ruling: this is a DIFFERENT table from the SDK presentation
75
+ // contract's STATE_GLYPHS (@pellux/goodvibes-sdk/platform/presentation) — that
76
+ // one is a 4-state semantic alias (good/warn/bad/info) shared by the TUI and
77
+ // agent renderers; this one is a 12-state ProcessNode taxonomy specific to the
78
+ // Fleet tree panel (thinking/executing-tool/awaiting-approval/streaming/
79
+ // stalled/retrying/done/failed/killed/interrupted/idle/queued/paused), and no
80
+ // other surface (checked: the agent has no fleet-panel renderer) consumes it
81
+ // today. Per the presentation-parity brief, a table only moves to the SDK
82
+ // contract when 2+ surfaces need it — this one stays TUI-renderer-local.
74
83
  const STATE_GLYPHS: Record<ProcessState, string> = {
75
84
  thinking: '◔',
76
85
  'executing-tool': '●',
@@ -374,6 +383,40 @@ export function buildFleetSnapshot(nodes: readonly ProcessNode[], capturedAt: nu
374
383
  return { rows, totalCost, totalTokens, runningCount, capturedAt };
375
384
  }
376
385
 
386
+ /**
387
+ * The fleet's honest leaf cost total — the SAME figure as
388
+ * FleetSnapshot.totalCost, but computed in one pass WITHOUT building the display
389
+ * rows, for cheap per-frame use (the always-visible footer). Excludes rollup
390
+ * aggregates (chain/subtask/workstream/phase) AND the WRFC owner (its usage is a
391
+ * mixed-model rollup of its children), so it is a true leaf-sum with no
392
+ * double-count. Null when nothing priced. Add this to the main session cost for
393
+ * the true "you + fleet" total.
394
+ */
395
+ export function fleetLeafCostTotal(nodes: readonly ProcessNode[]): number | null {
396
+ let total: number | null = null;
397
+ for (const node of nodes) {
398
+ if (isFleetAggregateRollupNode(node)) continue;
399
+ if (!hasFleetCost(node.costUsd, node.costState)) continue;
400
+ total = (total ?? 0) + (node.costUsd as number);
401
+ }
402
+ return total;
403
+ }
404
+
405
+ /**
406
+ * The fleet cost figure for the always-visible footer: fleetLeafCostTotal over a
407
+ * fresh registry query, but ONLY while a fleet is live — the idle single-session
408
+ * path must never pay the aggregate-on-read query() scan. Query failures degrade to
409
+ * null (honest "no fleet cost"), never a throw into the render loop.
410
+ */
411
+ export function footerFleetCost(queryNodes: () => readonly ProcessNode[], hasLiveFleet: boolean): number | null {
412
+ if (!hasLiveFleet) return null;
413
+ try {
414
+ return fleetLeafCostTotal(queryNodes());
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+
377
420
  // ---------------------------------------------------------------------------
378
421
  // Read-model — two-factory shape (live / static), mirrors cockpit-read-model.ts
379
422
  // ---------------------------------------------------------------------------
@@ -11,8 +11,9 @@
11
11
 
12
12
  import { STEER_TTL_MS, type ProcessNode } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
13
13
  import { isTerminalProcessState } from './fleet-read-model.ts';
14
- import type { FleetTab, SteerBadgeStatus } from './fleet-tabs.ts';
15
- import { DEFAULT_PANEL_PALETTE, type PanelPalette } from './polish.ts';
14
+ import type { FleetTab, SteerBadge, SteerBadgeStatus } from './fleet-tabs.ts';
15
+ import { buildPanelLine, DEFAULT_PANEL_PALETTE, type PanelPalette } from './polish.ts';
16
+ import type { Line } from '../types/grid.ts';
16
17
 
17
18
  /** Linger before an auto-resolved (consumed/dropped) steer badge is cleared from the tab. */
18
19
  export const STEER_BADGE_LINGER_MS = 4_000;
@@ -33,6 +34,38 @@ export function steerBadgeTone(status: SteerBadgeStatus, palette: PanelPalette):
33
34
  }
34
35
  }
35
36
 
37
+ /** Labels of the currently live, steerable agent nodes other than `excludeNodeId` — offered when a steer target has gone inactive (WO UX-A item 4). */
38
+ export function liveSteerableLabels(nodes: readonly ProcessNode[], excludeNodeId: string): string[] {
39
+ return nodes
40
+ .filter((node) => node.id !== excludeNodeId && node.kind === 'agent' && node.capabilities.steerable && !isTerminalProcessState(node.state))
41
+ .map((node) => node.label);
42
+ }
43
+
44
+ /** Refusal message for a steer that could not be queued: states why + preserves the draft + suggests live targets. */
45
+ export function steerRefusalMessage(reason: string, siblingLabels: readonly string[]): string {
46
+ const suggestion = siblingLabels.length > 0
47
+ ? ` Draft kept — steerable now: ${siblingLabels.slice(0, 3).join(', ')}${siblingLabels.length > 3 ? '…' : ''}.`
48
+ : ' Draft kept — no other agents are currently steerable.';
49
+ return `${reason}.${suggestion}`;
50
+ }
51
+
52
+ /** One-line honest status for a tab's steer badge; the queued line names the target and points at the ⧗ delivery badge (WO UX-A item 4). */
53
+ export function renderSteerBadgeLine(badge: SteerBadge, width: number, palette: PanelPalette, targetLabel?: string): Line {
54
+ const glyph = steerBadgeGlyph(badge.status);
55
+ const tone = steerBadgeTone(badge.status, palette);
56
+ const forTarget = targetLabel ? ` for ${targetLabel}` : '';
57
+ const label = badge.status === 'queued'
58
+ ? `steer queued${forTarget} — delivers on its next turn (watch the ${glyph} badge)`
59
+ : badge.status === 'consumed'
60
+ ? 'steer consumed'
61
+ : `steer dropped — ${badge.note ?? 'the target ended before delivery'}`;
62
+ return buildPanelLine(width, [
63
+ [' ', palette.dim],
64
+ [glyph, tone],
65
+ [` ${label}`, palette.dim],
66
+ ]);
67
+ }
68
+
36
69
  /**
37
70
  * Wave-3 (W3.2, risk #2 — "dropped inference"): the SDK emits no
38
71
  * cancelled/expired signal for a queued steer, so a badge left `queued`
@@ -8,7 +8,7 @@
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  import type { ProcessNode, ProcessState } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
11
- import { fleetStateGlyph, fleetStateTone, isTerminalProcessState, type FleetStateTone } from './fleet-read-model.ts';
11
+ import { fleetStateGlyph, fleetStateTone, isTerminalProcessState, type FleetStateTone, type FleetTreeRow } from './fleet-read-model.ts';
12
12
 
13
13
  /** How long 'stopping…' lingers after a stop keypress before the true state is shown regardless (never masks a stuck kill). */
14
14
  export const STOP_SETTLE_MS = 1500;
@@ -115,6 +115,7 @@ export function buildFleetTreeHints(
115
115
  { keys: 'j/k', label: 'navigate' },
116
116
  { keys: 'Enter', label: 'attach' },
117
117
  ];
118
+ if (live && selected.capabilities.steerable) hints.push({ keys: 's', label: 'steer' }); // D4: discoverable from the tree (attach-and-steer)
118
119
  if (live && selected.capabilities.interruptible) hints.push({ keys: 'i', label: 'interrupt' });
119
120
  if (live && selected.capabilities.killable) hints.push({ keys: 'K', label: 'kill' });
120
121
  if (live && !isPaused && selected.capabilities.pausable) hints.push({ keys: 'p', label: 'pause' });
@@ -123,3 +124,30 @@ export function buildFleetTreeHints(
123
124
  if (hasTabs) hints.push({ keys: '[ ]', label: 'tabs' });
124
125
  return hints;
125
126
  }
127
+
128
+ /** K-confirm descendant stats (UX-C item 6): total = every non-terminal descendant (what a cascade kill takes down); active = the individually-killable subset. Was "active only" (Wave-3 C7), undercounting a mixed subtree. */
129
+ export function countDescendantStats(rows: readonly FleetTreeRow[], nodeId: string): { total: number; active: number } {
130
+ const byParent = new Map<string, string[]>();
131
+ for (const row of rows) {
132
+ if (!row.node.parentId) continue;
133
+ const siblings = byParent.get(row.node.parentId) ?? [];
134
+ siblings.push(row.node.id);
135
+ byParent.set(row.node.parentId, siblings);
136
+ }
137
+ const byId = new Map(rows.map((row) => [row.node.id, row] as const));
138
+ let total = 0, active = 0;
139
+ const stack = [...(byParent.get(nodeId) ?? [])];
140
+ const seen = new Set<string>();
141
+ while (stack.length > 0) {
142
+ const id = stack.pop()!;
143
+ if (seen.has(id)) continue;
144
+ seen.add(id);
145
+ const row = byId.get(id);
146
+ if (row && !isTerminalProcessState(row.node.state)) {
147
+ total++;
148
+ if (row.node.capabilities.killable) active++;
149
+ }
150
+ for (const child of byParent.get(id) ?? []) stack.push(child);
151
+ }
152
+ return { total, active };
153
+ }
@@ -111,7 +111,22 @@ class KeybindingsModalSurface implements ConfigModalSurface {
111
111
  onAction(id: string, ctx: ConfigModalActionContext): void {
112
112
  if (id === 'refresh') { ctx.setStatus('Docs & shortcuts are read live.'); ctx.requestRender(); return; }
113
113
  if (id !== 'activate') return;
114
- if (ctx.tabId === 'tools') { void ctx.executeCommand?.('panel', ['open', 'fleet']); ctx.setStatus('Opened the tool inspector (fleet).'); return; }
114
+ if (ctx.tabId === 'tools') {
115
+ // DEBT-5 item 4: pass the tool name through as a deep-link target.
116
+ // Honest caveat (documented, not a bug to silently paper over): fleet's
117
+ // ProcessKind set has no 'tool' node — a docs Tools row names a static
118
+ // tool DEFINITION, not a live process, so FleetPanel.receiveDeepLink
119
+ // will not currently find a match and shows its honest "node no longer
120
+ // present" line. The plumbing is still worth wiring now (matches the
121
+ // work-plan agent/wrfc jumps' shape) — it starts resolving for free the
122
+ // day fleet grows a per-tool-call node (the retired DocsPanel's own
123
+ // comment already anticipated this: "no filter-by-tool equivalent
124
+ // there yet").
125
+ const toolName = ctx.row?.id.startsWith('tool:') ? ctx.row.id.slice('tool:'.length) : null;
126
+ void ctx.executeCommand?.('panel', toolName ? ['open', 'fleet', '--target', `${toolName}:tool`] : ['open', 'fleet']);
127
+ ctx.setStatus('Opened the tool inspector (fleet).');
128
+ return;
129
+ }
115
130
  if (ctx.tabId === 'models') {
116
131
  const key = ctx.row?.id.startsWith('model:') ? ctx.row.id.slice('model:'.length) : null;
117
132
  if (!key) return;
@@ -1,36 +1,42 @@
1
- import { resolveUiTones } from '../../renderer/theme.ts';
1
+ import { activeUiTones, registerThemeRefresh } from '../../renderer/theme.ts';
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
4
  // Semantic tone tokens for the W6.1 group-B config modals.
5
5
  //
6
- // Derived entirely from the chrome token set (resolveUiTones) so the builders
7
- // carry NO raw hex color literals (architecture: no-raw-hex-literal-growth).
8
- // Mode is fixed to 'dark' — the single read path the whole renderer uses until
9
- // the terminal-bg-probe lands (see renderer/modal-factory.ts, which resolves
10
- // the same way).
6
+ // Derived entirely from the chrome token set so the builders carry NO raw hex
7
+ // color literals (architecture: no-raw-hex-literal-growth). Rebuilt IN PLACE
8
+ // on theme flips via registerThemeRefresh — the group-B surfaces read
9
+ // MODAL_TONES by reference at render time, so a dark-pinned module const here
10
+ // left them rendering dark accents on light chrome (batch refutation
11
+ // finding 2). Mirrors the polish-core/overlay-box/modal-factory pattern.
11
12
  // ---------------------------------------------------------------------------
12
13
 
13
- const T = resolveUiTones('dark');
14
+ function buildModalTones() {
15
+ const T = activeUiTones();
16
+ return {
17
+ /** Informational / accent (blue). */
18
+ info: T.state.info,
19
+ /** Positive / healthy (green). */
20
+ good: T.state.good,
21
+ /** Caution (amber). */
22
+ warn: T.state.warn,
23
+ /** Error / danger (red). */
24
+ bad: T.state.bad,
25
+ /** Reasoning / secondary accent (purple). */
26
+ reasoning: T.state.reasoning,
27
+ /** Muted secondary text. */
28
+ muted: T.fg.muted,
29
+ /** Dim tertiary text. */
30
+ dim: T.fg.dim,
31
+ /** Primary text. */
32
+ primary: T.fg.primary,
33
+ /** QR module foreground — high-contrast, mode-appropriate. */
34
+ qrDark: T.fg.inverse,
35
+ /** QR field background — high-contrast, mode-appropriate. */
36
+ qrLight: T.fg.primary,
37
+ };
38
+ }
14
39
 
15
- export const MODAL_TONES = {
16
- /** Informational / accent (blue). */
17
- info: T.state.info,
18
- /** Positive / healthy (green). */
19
- good: T.state.good,
20
- /** Caution (amber). */
21
- warn: T.state.warn,
22
- /** Error / danger (red). */
23
- bad: T.state.bad,
24
- /** Reasoning / secondary accent (purple). */
25
- reasoning: T.state.reasoning,
26
- /** Muted secondary text. */
27
- muted: T.fg.muted,
28
- /** Dim tertiary text. */
29
- dim: T.fg.dim,
30
- /** Primary (near-white) text. */
31
- primary: T.fg.primary,
32
- /** QR module foreground — high-contrast dark, not pure black. */
33
- qrDark: T.fg.inverse,
34
- /** QR field background — high-contrast light, not pure white. */
35
- qrLight: T.fg.primary,
36
- } as const;
40
+ export const MODAL_TONES: Readonly<ReturnType<typeof buildModalTones>> = buildModalTones();
41
+
42
+ registerThemeRefresh(() => Object.assign(MODAL_TONES as Record<string, string>, buildModalTones()));
@@ -18,9 +18,11 @@ import { encodeConnectionPayload, generateQrMatrix, renderQrToString } from '@pe
18
18
  // buildView, cached) so a missing daemon home degrades honestly rather than
19
19
  // throwing at modal-registration time.
20
20
  //
21
- // KNOWN GAP (flagged in the report): src/input/commands/qrcode-runtime.ts only
22
- // registers `/qrcode` (open); it has no `regenerate` subcommand yet, and this
23
- // surface cannot observe a regenerated token without being re-opened.
21
+ // DEBT-3: the regenerate action now dispatches the real `/qrcode regenerate`
22
+ // verb (which rotates the shared operator-token store) and, once it resolves,
23
+ // RE-PULLS the lazy connection-info thunk so the modal shows the rotated token +
24
+ // QR in place without being re-opened. `confirm: true` keeps the two-press
25
+ // guard so a stray keystroke never rotates a live companion's token.
24
26
  // ---------------------------------------------------------------------------
25
27
 
26
28
  export interface PairingModalConnectionInfo {
@@ -58,7 +60,7 @@ class PairingModalSurface implements ConfigModalSurface {
58
60
  readonly actions = [
59
61
  { key: 'v', id: 'toggleReveal', label: 'reveal token' },
60
62
  { key: 'c', id: 'copyToken', label: 'copy token', enabledFor: () => Boolean(this.deps.copyToClipboard) },
61
- { key: 'r', id: 'regenerate', label: 'regenerate token' },
63
+ { key: 'r', id: 'regenerate', label: 'regenerate token', confirm: true },
62
64
  ];
63
65
 
64
66
  onOpen(requestRender: () => void): void { this.requestRender = requestRender; }
@@ -117,8 +119,25 @@ class PairingModalSurface implements ConfigModalSurface {
117
119
  return;
118
120
  }
119
121
  if (id === 'regenerate') {
120
- void ctx.executeCommand?.('qrcode', ['regenerate']);
121
- ctx.setStatus('Dispatched /qrcode regenerate (reopen to see the new token).');
122
+ const dispatched = ctx.executeCommand?.('qrcode', ['regenerate']);
123
+ if (!dispatched) {
124
+ ctx.setStatus('Token rotation is unavailable in this runtime.');
125
+ return;
126
+ }
127
+ ctx.setStatus('Rotating pairing token…');
128
+ void dispatched
129
+ .then((ok) => {
130
+ if (ok === false) { ctx.setStatus('Token rotation did not complete.'); return; }
131
+ // Re-pull the lazy connection-info thunk: /qrcode regenerate rewrote
132
+ // the shared token store, so a fresh getConnectionInfo() reflects the
133
+ // rotated token + QR. ensureInfo caches on first build — clear it.
134
+ this.info = undefined;
135
+ this.revealed = false;
136
+ this.ensureInfo();
137
+ this.requestRender();
138
+ ctx.setStatus('Pairing token rotated — new token and QR loaded.');
139
+ })
140
+ .catch(() => { ctx.setStatus('Token rotation failed.'); });
122
141
  }
123
142
  }
124
143
  }