@pellux/goodvibes-tui 1.1.0 → 1.9.0

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 (267) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +28 -18
  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/core/alert-gating.ts +67 -0
  12. package/src/core/approval-alert.ts +72 -0
  13. package/src/core/budget-breach-notifier.ts +106 -0
  14. package/src/core/composer-state.ts +11 -3
  15. package/src/core/conversation-line-cache.ts +27 -3
  16. package/src/core/conversation-rendering.ts +90 -14
  17. package/src/core/conversation.ts +18 -0
  18. package/src/core/focus-tracker.ts +41 -0
  19. package/src/core/long-task-notifier.ts +33 -6
  20. package/src/core/stream-event-wiring.ts +20 -1
  21. package/src/core/system-message-noise.ts +87 -0
  22. package/src/core/system-message-router.ts +104 -51
  23. package/src/core/turn-cancellation.ts +25 -0
  24. package/src/core/turn-event-wiring.ts +72 -3
  25. package/src/daemon/cli.ts +29 -2
  26. package/src/daemon/handlers/register.ts +8 -1
  27. package/src/daemon/service-commands.ts +329 -0
  28. package/src/export/cost-utils.ts +36 -0
  29. package/src/input/autocomplete.ts +27 -1
  30. package/src/input/command-registry.ts +84 -5
  31. package/src/input/commands/checkpoint-runtime.ts +280 -0
  32. package/src/input/commands/codebase-runtime.ts +232 -0
  33. package/src/input/commands/config.ts +43 -3
  34. package/src/input/commands/eval.ts +1 -1
  35. package/src/input/commands/health-runtime.ts +10 -2
  36. package/src/input/commands/image-runtime.ts +112 -0
  37. package/src/input/commands/intelligence-runtime.ts +11 -1
  38. package/src/input/commands/local-auth-runtime.ts +4 -1
  39. package/src/input/commands/local-runtime.ts +9 -3
  40. package/src/input/commands/marketplace-runtime.ts +2 -2
  41. package/src/input/commands/memory.ts +73 -35
  42. package/src/input/commands/operator-panel-runtime.ts +71 -31
  43. package/src/input/commands/planning-runtime.ts +116 -4
  44. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  45. package/src/input/commands/plugin-runtime.ts +2 -2
  46. package/src/input/commands/policy-dispatch.ts +21 -0
  47. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  48. package/src/input/commands/qrcode-runtime.ts +26 -6
  49. package/src/input/commands/recall-review.ts +35 -0
  50. package/src/input/commands/remote-runtime-setup.ts +5 -3
  51. package/src/input/commands/remote-runtime.ts +3 -3
  52. package/src/input/commands/runtime-services.ts +54 -13
  53. package/src/input/commands/services-runtime.ts +1 -1
  54. package/src/input/commands/session-content.ts +20 -9
  55. package/src/input/commands/session-workflow.ts +16 -1
  56. package/src/input/commands/settings-sync-runtime.ts +16 -4
  57. package/src/input/commands/shell-core.ts +25 -8
  58. package/src/input/commands/skills-runtime.ts +2 -8
  59. package/src/input/commands/subscription-runtime.ts +2 -2
  60. package/src/input/commands/test-runtime.ts +277 -0
  61. package/src/input/commands/websearch-runtime.ts +101 -0
  62. package/src/input/commands/work-plan-runtime.ts +36 -10
  63. package/src/input/commands/workstream-runtime.ts +488 -0
  64. package/src/input/commands.ts +12 -0
  65. package/src/input/config-modal-types.ts +175 -0
  66. package/src/input/config-modal.ts +592 -0
  67. package/src/input/feed-context-factory.ts +10 -8
  68. package/src/input/handler-command-route.ts +37 -20
  69. package/src/input/handler-content-actions.ts +17 -2
  70. package/src/input/handler-feed-routes.ts +74 -114
  71. package/src/input/handler-feed.ts +79 -27
  72. package/src/input/handler-interactions.ts +1 -3
  73. package/src/input/handler-modal-routes.ts +136 -6
  74. package/src/input/handler-modal-stack.ts +14 -7
  75. package/src/input/handler-modal-token-routes.ts +16 -49
  76. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  77. package/src/input/handler-onboarding.ts +71 -59
  78. package/src/input/handler-picker-routes.ts +26 -102
  79. package/src/input/handler-shortcuts.ts +74 -14
  80. package/src/input/handler-types.ts +2 -6
  81. package/src/input/handler-ui-state.ts +10 -22
  82. package/src/input/handler.ts +12 -29
  83. package/src/input/keybindings.ts +23 -8
  84. package/src/input/model-picker-types.ts +24 -6
  85. package/src/input/model-picker.ts +77 -2
  86. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  87. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  88. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  89. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  90. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  91. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  92. package/src/input/panel-integration-actions.ts +9 -170
  93. package/src/input/panel-mouse-geometry.ts +97 -0
  94. package/src/input/panel-paste-flood-guard.ts +86 -0
  95. package/src/input/selection-modal.ts +11 -0
  96. package/src/input/session-picker-modal.ts +44 -4
  97. package/src/input/settings-modal-data.ts +146 -2
  98. package/src/input/settings-modal-mutations.ts +6 -4
  99. package/src/input/settings-modal-types.ts +4 -2
  100. package/src/main.ts +49 -51
  101. package/src/panels/agent-inspector-shared.ts +2 -1
  102. package/src/panels/base-panel.ts +22 -1
  103. package/src/panels/builtin/agent.ts +21 -107
  104. package/src/panels/builtin/development.ts +15 -61
  105. package/src/panels/builtin/knowledge.ts +8 -32
  106. package/src/panels/builtin/operations.ts +84 -428
  107. package/src/panels/builtin/session.ts +21 -112
  108. package/src/panels/builtin/shared.ts +18 -46
  109. package/src/panels/builtin-modals.ts +218 -0
  110. package/src/panels/builtin-panels.ts +5 -0
  111. package/src/panels/cost-tracker-panel.ts +36 -10
  112. package/src/panels/diff-panel.ts +12 -43
  113. package/src/panels/eval-registry.ts +60 -0
  114. package/src/panels/fleet-deep-link.ts +31 -0
  115. package/src/panels/fleet-panel-format.ts +62 -0
  116. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  117. package/src/panels/fleet-panel.ts +758 -0
  118. package/src/panels/fleet-read-model.ts +520 -0
  119. package/src/panels/fleet-steer.ts +125 -0
  120. package/src/panels/fleet-stop.ts +153 -0
  121. package/src/panels/fleet-tabs.ts +230 -0
  122. package/src/panels/fleet-transcript.ts +356 -0
  123. package/src/panels/index.ts +7 -31
  124. package/src/panels/modals/hooks-modal.ts +187 -0
  125. package/src/panels/modals/keybindings-modal.ts +166 -0
  126. package/src/panels/modals/knowledge-modal.ts +158 -0
  127. package/src/panels/modals/local-auth-modal.ts +132 -0
  128. package/src/panels/modals/marketplace-modal.ts +218 -0
  129. package/src/panels/modals/memory-modal.ts +180 -0
  130. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  131. package/src/panels/modals/modal-theme.ts +42 -0
  132. package/src/panels/modals/pairing-modal.ts +163 -0
  133. package/src/panels/modals/planning-modal.ts +304 -0
  134. package/src/panels/modals/plugins-modal.ts +174 -0
  135. package/src/panels/modals/policy-modal.ts +256 -0
  136. package/src/panels/modals/provider-health-modal.ts +136 -0
  137. package/src/panels/modals/remote-modal.ts +115 -0
  138. package/src/panels/modals/sandbox-modal.ts +150 -0
  139. package/src/panels/modals/security-modal.ts +217 -0
  140. package/src/panels/modals/services-modal.ts +179 -0
  141. package/src/panels/modals/settings-sync-modal.ts +142 -0
  142. package/src/panels/modals/skills-modal.ts +189 -0
  143. package/src/panels/modals/subscription-modal.ts +172 -0
  144. package/src/panels/modals/work-plan-modal.ts +177 -0
  145. package/src/panels/panel-confirm-overlay.ts +72 -0
  146. package/src/panels/panel-manager.ts +123 -9
  147. package/src/panels/polish-core.ts +38 -25
  148. package/src/panels/project-planning-answer-actions.ts +8 -11
  149. package/src/panels/skills-panel.ts +7 -51
  150. package/src/panels/types.ts +37 -0
  151. package/src/permissions/hunk-selection.ts +179 -0
  152. package/src/permissions/prompt.ts +216 -13
  153. package/src/renderer/autocomplete-overlay.ts +28 -2
  154. package/src/renderer/compaction-history-modal.ts +19 -3
  155. package/src/renderer/compaction-preview.ts +13 -2
  156. package/src/renderer/compaction-quality.ts +100 -0
  157. package/src/renderer/compositor.ts +2 -3
  158. package/src/renderer/config-modal.ts +95 -0
  159. package/src/renderer/conversation-overlays.ts +10 -17
  160. package/src/renderer/fleet-tab-strip.ts +56 -0
  161. package/src/renderer/footer-tips.ts +15 -3
  162. package/src/renderer/fullscreen-primitives.ts +32 -22
  163. package/src/renderer/git-status.ts +43 -1
  164. package/src/renderer/help-overlay.ts +2 -2
  165. package/src/renderer/layout.ts +0 -4
  166. package/src/renderer/markdown.ts +7 -3
  167. package/src/renderer/modal-factory.ts +25 -20
  168. package/src/renderer/model-workspace.ts +62 -4
  169. package/src/renderer/overlay-box.ts +21 -17
  170. package/src/renderer/panel-workspace-bar.ts +8 -2
  171. package/src/renderer/process-indicator.ts +14 -3
  172. package/src/renderer/selection-modal-overlay.ts +6 -1
  173. package/src/renderer/session-picker-modal.ts +196 -3
  174. package/src/renderer/settings-modal-helpers.ts +2 -0
  175. package/src/renderer/settings-modal.ts +7 -0
  176. package/src/renderer/shell-surface.ts +8 -1
  177. package/src/renderer/status-glyphs.ts +14 -15
  178. package/src/renderer/system-message.ts +15 -3
  179. package/src/renderer/terminal-bg-probe.ts +339 -0
  180. package/src/renderer/terminal-escapes.ts +20 -0
  181. package/src/renderer/theme-mode-config.ts +67 -0
  182. package/src/renderer/theme.ts +91 -1
  183. package/src/renderer/thinking.ts +11 -3
  184. package/src/renderer/tool-call.ts +15 -9
  185. package/src/renderer/tool-result-summary.ts +148 -0
  186. package/src/renderer/turn-injection.ts +133 -0
  187. package/src/renderer/ui-factory.ts +154 -85
  188. package/src/renderer/ui-primitives.ts +30 -129
  189. package/src/runtime/bootstrap-command-context.ts +27 -1
  190. package/src/runtime/bootstrap-command-parts.ts +41 -10
  191. package/src/runtime/bootstrap-core.ts +47 -15
  192. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  193. package/src/runtime/bootstrap-shell.ts +48 -17
  194. package/src/runtime/bootstrap.ts +129 -22
  195. package/src/runtime/code-index-services.ts +135 -0
  196. package/src/runtime/legacy-daemon-migration.ts +516 -0
  197. package/src/runtime/memory-fold.ts +26 -0
  198. package/src/runtime/onboarding/derivation.ts +7 -2
  199. package/src/runtime/onboarding/snapshot.ts +27 -1
  200. package/src/runtime/onboarding/types.ts +31 -1
  201. package/src/runtime/operator-token-cleanup.ts +82 -1
  202. package/src/runtime/orchestrator-core-services.ts +59 -0
  203. package/src/runtime/process-lifecycle.ts +3 -1
  204. package/src/runtime/resume-notice.ts +209 -0
  205. package/src/runtime/services.ts +98 -46
  206. package/src/runtime/session-inbound-inputs.ts +252 -0
  207. package/src/runtime/session-spine-transport.ts +64 -0
  208. package/src/runtime/terminal-output-guard.ts +15 -8
  209. package/src/runtime/ui-services.ts +27 -3
  210. package/src/runtime/workstream-services.ts +327 -0
  211. package/src/runtime/wrfc-persistence.ts +124 -17
  212. package/src/shell/blocking-input.ts +68 -4
  213. package/src/shell/recovery-input-helpers.ts +170 -1
  214. package/src/shell/ui-openers.ts +171 -26
  215. package/src/utils/format-duration.ts +8 -18
  216. package/src/utils/splash-lines.ts +1 -1
  217. package/src/utils/terminal-width.ts +52 -0
  218. package/src/version.ts +1 -1
  219. package/src/panels/agent-inspector-panel.ts +0 -786
  220. package/src/panels/approval-panel.ts +0 -252
  221. package/src/panels/automation-control-panel.ts +0 -479
  222. package/src/panels/cockpit-panel.ts +0 -481
  223. package/src/panels/cockpit-read-model.ts +0 -238
  224. package/src/panels/communication-panel.ts +0 -256
  225. package/src/panels/control-plane-panel.ts +0 -470
  226. package/src/panels/debug-panel.ts +0 -615
  227. package/src/panels/docs-panel.ts +0 -384
  228. package/src/panels/eval-panel.ts +0 -627
  229. package/src/panels/file-explorer-panel.ts +0 -673
  230. package/src/panels/file-preview-panel.ts +0 -517
  231. package/src/panels/hooks-panel.ts +0 -401
  232. package/src/panels/incident-review-panel.ts +0 -406
  233. package/src/panels/intelligence-panel.ts +0 -383
  234. package/src/panels/knowledge-graph-panel.ts +0 -515
  235. package/src/panels/marketplace-panel.ts +0 -399
  236. package/src/panels/memory-panel.ts +0 -558
  237. package/src/panels/ops-control-panel.ts +0 -329
  238. package/src/panels/ops-strategy-panel.ts +0 -321
  239. package/src/panels/orchestration-panel.ts +0 -465
  240. package/src/panels/panel-list-panel.ts +0 -566
  241. package/src/panels/plan-dashboard-panel.ts +0 -707
  242. package/src/panels/policy-panel.ts +0 -517
  243. package/src/panels/project-planning-panel.ts +0 -731
  244. package/src/panels/provider-health-panel.ts +0 -678
  245. package/src/panels/provider-health-tracker.ts +0 -310
  246. package/src/panels/provider-health-views.ts +0 -567
  247. package/src/panels/qr-panel.ts +0 -280
  248. package/src/panels/remote-panel.ts +0 -534
  249. package/src/panels/routes-panel.ts +0 -241
  250. package/src/panels/sandbox-panel.ts +0 -456
  251. package/src/panels/security-panel.ts +0 -447
  252. package/src/panels/services-panel.ts +0 -329
  253. package/src/panels/session-browser-panel.ts +0 -496
  254. package/src/panels/settings-sync-panel.ts +0 -398
  255. package/src/panels/subscription-panel.ts +0 -342
  256. package/src/panels/symbol-outline-panel.ts +0 -619
  257. package/src/panels/system-messages-panel.ts +0 -364
  258. package/src/panels/tasks-panel.ts +0 -608
  259. package/src/panels/thinking-panel.ts +0 -333
  260. package/src/panels/tool-inspector-panel.ts +0 -537
  261. package/src/panels/work-plan-panel.ts +0 -540
  262. package/src/panels/worktree-panel.ts +0 -360
  263. package/src/panels/wrfc-panel.ts +0 -790
  264. package/src/renderer/agent-detail-modal.ts +0 -466
  265. package/src/renderer/live-tail-modal.ts +0 -156
  266. package/src/renderer/process-modal.ts +0 -671
  267. package/src/renderer/qr-renderer.ts +0 -120
@@ -0,0 +1,758 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-panel.ts
3
+ //
4
+ // W2.2 — FleetPanel: the live unified observability tree. Renders the
5
+ // registered process fleet (agents incl. WRFC roles, WRFC chains/subtasks,
6
+ // workflow-tool FSMs, watchers, background processes) as a depth-first tree
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).
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import { readFile } from 'node:fs/promises';
15
+ import type { Line } from '../types/grid.ts';
16
+ import type { ProcessCostState, ProcessUsage, SteerResult } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
17
+ import { formatWorkItemIsolationDetailFromRaw } from './fleet-panel-worktree-detail.ts';
18
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
19
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
20
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
21
+ import { PanelConfirmOverlay } from './panel-confirm-overlay.ts';
22
+ import { formatAgentCost } from './agent-inspector-shared.ts';
23
+ import { formatElapsed } from '../utils/format-elapsed.ts';
24
+ import { truncateDisplay } from '../utils/terminal-width.ts';
25
+ import { isPanelSearchBackspace, isPanelSearchCancel, isPanelSearchCommit, isPanelSearchPrintable } from './search-focus.ts';
26
+ import { buildAlignedRow, buildKeyboardHints, buildPanelLine, buildPanelWorkspace, buildSearchInputLine, DEFAULT_PANEL_PALETTE, getPanelWorkspaceContentBudget, type ColumnSpec, type PanelPalette } from './polish.ts';
27
+ import {
28
+ fleetKindTag,
29
+ fleetUsageTokens,
30
+ hasFleetCost,
31
+ isRunningProcessState,
32
+ isTerminalProcessState,
33
+ type FleetReadModel,
34
+ type FleetStateTone,
35
+ type FleetTreeRow,
36
+ } from './fleet-read-model.ts';
37
+ import {
38
+ activeFleetTab,
39
+ appendSteerText,
40
+ attachFleetTab,
41
+ detachActiveFleetTab,
42
+ EMPTY_FLEET_TABS_STATE,
43
+ isAttachableFleetKind,
44
+ stepFleetTab,
45
+ type FleetTab,
46
+ type FleetTabsState,
47
+ type SteerBadge,
48
+ } from './fleet-tabs.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';
52
+ import { parseAgentLedger, renderFleetAgentTranscript, renderFleetChainSummary, renderFleetLedgerFallback, renderFleetTranscriptLoading } from './fleet-transcript.ts';
53
+ import { renderFleetTabStrip } from '../renderer/fleet-tab-strip.ts';
54
+ import { planColumns, toneColor, formatFleetTokens, formatFleetCost } from './fleet-panel-format.ts';
55
+
56
+ const C = DEFAULT_PANEL_PALETTE;
57
+
58
+ export interface FleetActionCallbacks {
59
+ /** Graceful interruption (AgentManager.cancel / trigger-schedule disable, via the registry). */
60
+ readonly interrupt: (id: string) => boolean;
61
+ /** Wave-6 (wo-F item d2): re-arm a `paused` node (disabled trigger/schedule or automation job) — interrupt()'s inverse. Honest false when not paused / non-resumable. */
62
+ readonly resume: (id: string) => boolean;
63
+ /** Hard stop, optionally cascading to killable descendants. Returns the node ids acted on. */
64
+ readonly kill: (id: string, opts: { readonly cascade: boolean }) => readonly string[];
65
+ /**
66
+ * Wave-3 (C6): fetch an agent's conversation history — live (running) or
67
+ * frozen (just-completed, still in the SDK's retention ring). Empty array
68
+ * means "unavailable" (evicted, or never registered) — FleetPanel degrades
69
+ * to the on-disk ledger fallback in that case, never fabricating content.
70
+ */
71
+ readonly getConversationSnapshot: (agentId: string) => readonly ConversationMessageSnapshot[];
72
+ /** Wave-3 (C6): resolve the on-disk `<agentId>.jsonl` event-ledger path for the degraded fallback view. */
73
+ readonly resolveSessionLogPath: (agentId: string) => string;
74
+ /**
75
+ * Wave-3 (W3.2): queue a human message for a live in-process agent (or a
76
+ * wrfc-subtask's current live member agent), delivered at the target's
77
+ * next turn boundary — never mid-token. Honest refusal
78
+ * (`{queued:false,reason}`) for anything that cannot take mid-run input
79
+ * (terminal nodes, non-agent kinds, the wrfc-chain coordinator itself).
80
+ */
81
+ readonly steer: (id: string, text: string) => SteerResult;
82
+ }
83
+
84
+ const NOOP_ACTIONS: FleetActionCallbacks = {
85
+ interrupt: (_id: string) => false,
86
+ resume: (_id: string) => false,
87
+ kill: (_id: string, _opts: { readonly cascade: boolean }) => [],
88
+ getConversationSnapshot: (_agentId: string) => [],
89
+ resolveSessionLogPath: (agentId: string) => agentId,
90
+ steer: (_id: string, _text: string) => ({ queued: false, reason: 'no live registry' }),
91
+ };
92
+
93
+ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
94
+ private readonly unsub: () => void;
95
+ private readonly unsubConsumed: () => void;
96
+ private readonly actions: FleetActionCallbacks;
97
+ private readonly confirmOverlay: PanelConfirmOverlay;
98
+ private follow = false;
99
+ private tabsState: FleetTabsState = EMPTY_FLEET_TABS_STATE;
100
+ private tickTimer: ReturnType<typeof setInterval> | null = null;
101
+ /**
102
+ * The node id the cursor is anchored to, independent of its row index.
103
+ * `selectedIndex` is a plain offset into `getItems()`'s current order —
104
+ * every snapshot rebuilds that array from scratch (new nodes, completed
105
+ * nodes pruned, tree re-sorted), so a stale index silently points at
106
+ * whatever row now happens to occupy that slot. `reanchorSelection()`
107
+ * re-locates this id in every fresh snapshot and repoints selectedIndex
108
+ * there; null means "no anchor yet" (follows the base class's index-0
109
+ * default until the first navigation/tick establishes one).
110
+ */
111
+ private selectedNodeId: string | null = null;
112
+
113
+ /** d1 (W6.2): the "stopping…" write-window overlay tracker (see fleet-stop.ts). */
114
+ private readonly stopTracker = new FleetStopTracker();
115
+
116
+ public constructor(
117
+ private readonly readModel: FleetReadModel,
118
+ actions: Partial<FleetActionCallbacks> = {},
119
+ private readonly configManager: ConfigManager | null = null,
120
+ ) {
121
+ super('fleet', 'Fleet', '⊟', 'runtime-ops');
122
+ this.showSelectionGutter = true; // W0.8: visible ▸ focus indicator
123
+ this.actions = { ...NOOP_ACTIONS, ...actions };
124
+ this.confirmOverlay = new PanelConfirmOverlay(() => this.markDirty());
125
+ this.unsub = readModel.subscribe(() => {
126
+ this.reanchorSelection();
127
+ this.applyFollow();
128
+ this.reconcileSteerBadges();
129
+ this.markDirty();
130
+ });
131
+ // Wave-3 (W3.2): the honest "the agent actually consumed this steer at
132
+ // its turn boundary" signal — see fleet-tabs.ts's SteerBadgeStatus doc.
133
+ // A read-model without a runtimeBus dep never invokes this (graceful
134
+ // no-op), same degrade as steer()/steerable without a messageBus dep.
135
+ this.unsubConsumed = readModel.subscribeConsumed((event) => {
136
+ let changed = false;
137
+ for (const tab of this.tabsState.tabs) {
138
+ const badge = tab.steerBadge;
139
+ if (!badge || badge.messageId !== event.messageId) continue;
140
+ // Consumed-wins: this real signal can still upgrade an already
141
+ // 'dropped' (inferred) badge, as long as it hasn't linger-cleared yet.
142
+ if (badge.status === 'queued' || badge.status === 'dropped') {
143
+ tab.steerBadge = { messageId: badge.messageId, status: 'consumed', queuedAt: badge.queuedAt, resolvedAt: Date.now() };
144
+ changed = true;
145
+ }
146
+ }
147
+ if (changed) this.markDirty();
148
+ });
149
+ }
150
+
151
+ public override onActivate(): void {
152
+ super.onActivate();
153
+ // Time-derived fields (elapsed, follow target) stay live even when no
154
+ // registry tick fires while this panel is on screen (cockpit-panel.ts
155
+ // STALL_TICK_MS precedent). Also re-anchors defensively in case the
156
+ // underlying list changed without a subscribe notification.
157
+ if (this.tickTimer === null) {
158
+ this.tickTimer = this.registerTimer(setInterval(() => {
159
+ this.reanchorSelection();
160
+ this.applyFollow();
161
+ this.reconcileSteerBadges();
162
+ this.markDirty();
163
+ }, 1000));
164
+ }
165
+ }
166
+
167
+ public override onDeactivate(): void {
168
+ super.onDeactivate();
169
+ if (this.tickTimer !== null) {
170
+ this.clearTimer(this.tickTimer);
171
+ this.tickTimer = null;
172
+ }
173
+ }
174
+
175
+ public override onDestroy(): void {
176
+ super.onDestroy();
177
+ this.unsub();
178
+ this.unsubConsumed();
179
+ for (const tab of this.tabsState.tabs) tab.lineCache.clear();
180
+ }
181
+
182
+ /** True while a session tab (not the root tree) is focused. Replaces the old Wave-2 isDetailFocused() seam. */
183
+ public isTabActive(): boolean {
184
+ return this.tabsState.activeTabIndex > 0;
185
+ }
186
+
187
+ /**
188
+ * Wave-3 (W3.2): true while the steer composer on the active tab is open —
189
+ * every character of a burst (paste, or several fast-typed chars in one
190
+ * stdin chunk) must land in the draft one at a time rather than being
191
+ * routed away as panel hotkeys or exploded to the main composer. See
192
+ * Panel.isCapturingTextBurst's doc comment and git-panel.ts's
193
+ * `commitMessage` precedent.
194
+ */
195
+ public override isCapturingTextBurst(): boolean {
196
+ const tab = activeFleetTab(this.tabsState);
197
+ return tab !== null && tab.steerDraft !== null;
198
+ }
199
+
200
+ /** Introspection for tests: the current tab-bar state (read-only). */
201
+ public getTabsState(): FleetTabsState {
202
+ return this.tabsState;
203
+ }
204
+
205
+ public isFollowing(): boolean {
206
+ return this.follow;
207
+ }
208
+
209
+ /**
210
+ * Ctrl+X (panel-close) collision resolution (Wave-3 Part C4): the global
211
+ * shortcut route (handler-shortcuts.ts) calls this BEFORE closing the
212
+ * panel. Detaching the active tab consumes the key; on the root tree tab
213
+ * there is nothing to detach, so this returns false and Ctrl+X falls
214
+ * through to its ordinary panel-close behavior.
215
+ */
216
+ public interceptPanelClose(): boolean {
217
+ if (this.tabsState.activeTabIndex === 0) return false;
218
+ this.tabsState = detachActiveFleetTab(this.tabsState);
219
+ this.markDirty();
220
+ return true;
221
+ }
222
+
223
+ protected getItems(): readonly FleetTreeRow[] {
224
+ return this.readModel.getSnapshot().rows;
225
+ }
226
+
227
+ protected override getPalette(): PanelPalette {
228
+ return C;
229
+ }
230
+
231
+ protected override getEmptyStateMessage(): string {
232
+ return ' No processes tracked yet.';
233
+ }
234
+
235
+ /**
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.
241
+ */
242
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
243
+ return [
244
+ {
245
+ command: 'previous sessions',
246
+ summary: "not shown here — this list resets on TUI restart; each agent's .goodvibes/tui/sessions/<id>.jsonl event log still persists on disk",
247
+ },
248
+ ];
249
+ }
250
+
251
+ private applyFollow(): void {
252
+ if (!this.follow) return;
253
+ const rows = this.getItems();
254
+ if (rows.length === 0) return;
255
+ let bestIdx = -1;
256
+ let bestStarted = -Infinity;
257
+ rows.forEach((row, idx) => {
258
+ if (!isRunningProcessState(row.node.state)) return;
259
+ const started = row.node.startedAt ?? -Infinity;
260
+ if (started >= bestStarted) {
261
+ bestStarted = started;
262
+ bestIdx = idx;
263
+ }
264
+ });
265
+ if (bestIdx >= 0) {
266
+ this.selectedIndex = bestIdx;
267
+ this.selectedNodeId = rows[bestIdx]!.node.id;
268
+ this.needsRender = true;
269
+ }
270
+ }
271
+
272
+ /** Record the current selection's node id as the anchor for future snapshot updates. */
273
+ private captureSelectionAnchor(): void {
274
+ const item = this.getSelectedItem();
275
+ this.selectedNodeId = item ? item.node.id : null;
276
+ }
277
+
278
+ /**
279
+ * Re-locate `selectedNodeId` in the current (possibly just-changed)
280
+ * snapshot and repoint `selectedIndex` at its new position — this is what
281
+ * makes the selection follow a specific process across adds/removes/
282
+ * reorders instead of drifting onto whatever row now occupies the old
283
+ * index. When the anchored node is no longer present (completed and
284
+ * pruned, killed and pruned, etc.) — or no anchor has been established
285
+ * yet — falls back to the base class's clampSelection() (nearest valid
286
+ * index, never an out-of-bounds/vanished selection) and re-anchors to
287
+ * whatever that lands on.
288
+ */
289
+ private reanchorSelection(): void {
290
+ const rows = this.getItems();
291
+ if (this.selectedNodeId !== null) {
292
+ const idx = rows.findIndex((row) => row.node.id === this.selectedNodeId);
293
+ if (idx >= 0) {
294
+ this.selectedIndex = idx;
295
+ return;
296
+ }
297
+ }
298
+ this.clampSelection();
299
+ this.captureSelectionAnchor();
300
+ }
301
+
302
+ /** Find a node's live ProcessNode in the current snapshot, or null (pruned/never registered). */
303
+ private findLiveNode(nodeId: string): FleetTreeRow['node'] | null {
304
+ return this.getItems().find((row) => row.node.id === nodeId)?.node ?? null;
305
+ }
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
+
319
+ /**
320
+ * Wave-3 (W3.2, risk #2 — "dropped inference"): called on every read-model
321
+ * snapshot update and on the panel's 1s tick. See fleet-steer.ts's
322
+ * reconcileSteerBadges doc for why this is needed (the SDK emits no
323
+ * cancelled/expired signal for a queued steer).
324
+ */
325
+ private reconcileSteerBadges(): void {
326
+ if (this.tabsState.tabs.length === 0) return;
327
+ const changed = reconcileSteerBadgesPure(this.tabsState.tabs, (id) => this.findLiveNode(id), Date.now());
328
+ if (changed) this.markDirty();
329
+ }
330
+
331
+ /** Kick off (once) the async ledger-fallback load for a tab whose conversation snapshot is unavailable. */
332
+ private ensureLedgerLoaded(tab: FleetTab): void {
333
+ if (tab.ledgerLoadStarted) return;
334
+ tab.ledgerLoadStarted = true;
335
+ const path = this.actions.resolveSessionLogPath(tab.agentId);
336
+ readFile(path, 'utf-8')
337
+ .then((raw) => {
338
+ tab.ledgerEntries = parseAgentLedger(raw);
339
+ this.markDirty();
340
+ })
341
+ .catch(() => {
342
+ tab.ledgerEntries = [];
343
+ this.markDirty();
344
+ });
345
+ }
346
+
347
+ public override handleInput(key: string): boolean {
348
+ if (this.lastError !== null) this.clearError();
349
+
350
+ // Confirm overlay owns input first (K-armed kill confirm).
351
+ if (this.confirmOverlay.handleInput(key)) return true;
352
+
353
+ // Wave-3 (W3.2): once the steer composer is open on the active tab, it
354
+ // owns EVERY key (same full-priority-while-composing contract as
355
+ // git-panel.ts's commitMessage entry) until Enter (submit) or Esc
356
+ // (cancel) — a burst/paste and single hotkeys like 'j'/'s'/'[' all land
357
+ // in the draft, never as tree/tab navigation (W0.8 focus rule).
358
+ const composingTab = activeFleetTab(this.tabsState);
359
+ if (composingTab && composingTab.steerDraft !== null) {
360
+ return this.handleSteerInput(composingTab, key);
361
+ }
362
+
363
+ // Tab switching works from ANY view (root tree or an attached tab) once
364
+ // at least one tab is open — [ / ] step through Tree, tab 1, tab 2, ...
365
+ // Ctrl+X detach is handled by interceptPanelClose(), not handleInput (it
366
+ // must win a global-shortcut race — see that method's doc comment).
367
+ if ((key === '[' || key === ']') && this.tabsState.tabs.length > 0) {
368
+ this.tabsState = stepFleetTab(this.tabsState, key === ']' ? 1 : -1);
369
+ this.markDirty();
370
+ return true;
371
+ }
372
+
373
+ if (this.tabsState.activeTabIndex > 0) {
374
+ if (key === 's') {
375
+ return this.tryOpenSteerDraft(activeFleetTab(this.tabsState)!);
376
+ }
377
+ // A session tab is focused — the tree isn't visible, so tree-only keys
378
+ // (navigate/i/K/f/Enter) don't apply here.
379
+ return false;
380
+ }
381
+
382
+ const selected = this.getSelectedItem();
383
+
384
+ if (key === 'i') {
385
+ // Guard: only consume on a real, non-terminal node (cockpit-panel.ts
386
+ // precedent) so the key falls through when there is nothing to act on.
387
+ if (!selected || isTerminalProcessState(selected.node.state)) return false;
388
+ // Not every kind can be interrupted (only 'agent' nodes ever are — see
389
+ // the SDK's fleet adapters: schedule/trigger/watcher/workflow/
390
+ // wrfc-chain/wrfc-subtask/background-process all report
391
+ // capabilities.interruptible: false unconditionally). Consume the key
392
+ // and say so rather than silently calling interrupt() on a node the
393
+ // registry has no interrupt route for.
394
+ if (!selected.node.capabilities.interruptible) {
395
+ this.setError(`${selected.node.kind} does not support interrupt.`);
396
+ return true;
397
+ }
398
+ this.actions.interrupt(selected.node.id);
399
+ this.stopTracker.mark(selected.node.id);
400
+ this.markDirty();
401
+ return true;
402
+ }
403
+
404
+ if (key === 'K') {
405
+ if (!selected || isTerminalProcessState(selected.node.state)) return false;
406
+ if (!selected.node.capabilities.killable) {
407
+ this.setError(`${selected.node.kind} does not support kill.`);
408
+ return true;
409
+ }
410
+ const node = selected.node;
411
+ const shortId = node.id.length > 8 ? node.id.slice(-8) : node.id;
412
+ const stats = countDescendantStats(this.getItems(), node.id);
413
+ const suffix = stats.total > 0 ? ` (+${stats.total} descendant${stats.total === 1 ? '' : 's'}, ${stats.active} active)` : '';
414
+ this.confirmOverlay.arm({
415
+ id: node.id,
416
+ label: `${node.kind} ${shortId}${suffix}`,
417
+ verb: 'Kill',
418
+ // d1 (W6.2): mark 'stopping…' only once the kill is confirmed, not while
419
+ // the confirm overlay is merely armed.
420
+ onConfirm: () => {
421
+ this.actions.kill(node.id, { cascade: true });
422
+ this.stopTracker.mark(node.id);
423
+ },
424
+ });
425
+ return true;
426
+ }
427
+
428
+ // d2 (W6.2): 'p' toggles pause<->resume by the selected node's state (see
429
+ // toggleFleetPause in fleet-stop.ts).
430
+ if (key === 'p') {
431
+ if (!selected) return false;
432
+ return toggleFleetPause(selected.node, {
433
+ interrupt: (id) => this.actions.interrupt(id),
434
+ resume: (id) => this.actions.resume(id),
435
+ setError: (m) => this.setError(m),
436
+ markDirty: () => this.markDirty(),
437
+ tracker: this.stopTracker,
438
+ });
439
+ }
440
+
441
+ if (key === 'f') {
442
+ this.follow = !this.follow;
443
+ if (this.follow) this.applyFollow();
444
+ this.markDirty();
445
+ return true;
446
+ }
447
+
448
+ if (key === 'enter' || key === 'return') {
449
+ if (!selected) return false;
450
+ const node = selected.node;
451
+ if (!isAttachableFleetKind(node.kind)) {
452
+ this.setError(`${node.kind} has no transcript to attach.`);
453
+ return true;
454
+ }
455
+ this.tabsState = attachFleetTab(this.tabsState, node);
456
+ this.markDirty();
457
+ return true;
458
+ }
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
+
482
+ const consumed = super.handleInput(key);
483
+ if (consumed) this.captureSelectionAnchor();
484
+ return consumed;
485
+ }
486
+
487
+ /**
488
+ * Wave-3 (W3.2): the `s` gate — mirrors the i/K capability-gating template
489
+ * exactly (guard on the live node existing, non-terminal, and the specific
490
+ * capability; consume the key and say so rather than silently no-op-ing).
491
+ */
492
+ private tryOpenSteerDraft(tab: FleetTab): boolean {
493
+ const node = this.findLiveNode(tab.nodeId);
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.`);
500
+ return true;
501
+ }
502
+ tab.steerDraft = '';
503
+ this.markDirty();
504
+ return true;
505
+ }
506
+
507
+ /** Dispatch for the open steer composer — mirrors git-panel.ts's handleCommitMessageInput. */
508
+ private handleSteerInput(tab: FleetTab, key: string): boolean {
509
+ if (isPanelSearchCancel(key)) {
510
+ tab.steerDraft = null;
511
+ this.markDirty();
512
+ return true;
513
+ }
514
+ if (isPanelSearchCommit(key)) {
515
+ const text = (tab.steerDraft ?? '').trim();
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;
525
+ this.markDirty();
526
+ return true;
527
+ }
528
+ if (isPanelSearchBackspace(key)) {
529
+ tab.steerDraft = (tab.steerDraft ?? '').slice(0, -1);
530
+ this.markDirty();
531
+ return true;
532
+ }
533
+ // Ordinary typing AND a pasted multi-line block's line-break characters
534
+ // (delivered one at a time through the same burst pipeline — see
535
+ // isCapturingTextBurst) both flow through appendSteerText, which
536
+ // normalizes \r/\n to a collapsed space instead of corrupting the
537
+ // one-line field or silently dropping the content.
538
+ if (key.length === 1 && (isPanelSearchPrintable(key) || key === '\r' || key === '\n')) {
539
+ tab.steerDraft = appendSteerText(tab.steerDraft ?? '', key);
540
+ this.markDirty();
541
+ return true;
542
+ }
543
+ return true; // absorb every other key while composing
544
+ }
545
+
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 {
554
+ const result = this.actions.steer(tab.nodeId, text);
555
+ if (result.queued) {
556
+ // queuedAt drives reconcileSteerBadges' TTL-expiry fallback (fleet-steer.ts).
557
+ tab.steerBadge = { messageId: result.messageId, status: 'queued', queuedAt: Date.now() };
558
+ this.markDirty();
559
+ return true;
560
+ }
561
+ const siblings = liveSteerableLabels(this.readModel.getSnapshot().rows.map((row) => row.node), tab.nodeId);
562
+ this.setError(steerRefusalMessage(result.reason, siblings));
563
+ this.markDirty();
564
+ return false;
565
+ }
566
+
567
+ protected renderItem(row: FleetTreeRow, _index: number, _selected: boolean, width: number): Line {
568
+ const node = row.node;
569
+ // d1: while a stop is in flight, fleetStateDisplay overrides the state
570
+ // glyph/label/tone with a display-only 'stopping…' so the row never claims
571
+ // the past-tense outcome before the state has actually flipped.
572
+ const stopping = this.stopTracker.isStopping(node.id);
573
+ const disp = fleetStateDisplay(node.state, stopping);
574
+ const color = toneColor(disp.tone, C);
575
+ const label = `${row.treePrefix}${node.label}`;
576
+ const badge = this.steerBadgeForNode(node.id);
577
+ const activity = stopping
578
+ ? disp.label
579
+ : badge
580
+ ? `${steerBadgeGlyph(badge.status)} ${node.currentActivity?.text ?? ''}`.trimEnd()
581
+ : (node.currentActivity?.text ?? '');
582
+ const activityColor = stopping ? color : badge ? steerBadgeTone(badge.status, C) : C.dim;
583
+
584
+ return buildAlignedRow(
585
+ width,
586
+ [
587
+ { text: disp.glyph, fg: color },
588
+ { text: fleetKindTag(node.kind), fg: C.dim },
589
+ { text: label, fg: C.value },
590
+ { text: formatElapsed(node.elapsedMs), fg: C.dim },
591
+ { text: formatFleetTokens(node.usage), fg: C.dim },
592
+ { text: formatFleetCost(node.costUsd, node.costState), fg: C.value },
593
+ { text: activity, fg: activityColor },
594
+ ],
595
+ planColumns(width),
596
+ );
597
+ }
598
+
599
+ /** A node's steer badge, looked up via its attached tab (if any) — null when the node has no tab or no active/recent steer. */
600
+ private steerBadgeForNode(nodeId: string): SteerBadge | null {
601
+ return this.tabsState.tabs.find((t) => t.nodeId === nodeId)?.steerBadge ?? null;
602
+ }
603
+
604
+ private renderDetail(row: FleetTreeRow, width: number): Line[] {
605
+ const node = row.node;
606
+ // d1: mirror the tree row's 'stopping…' override so the literal state text
607
+ // never claims 'killed' mid-write either.
608
+ const disp = fleetStateDisplay(node.state, this.stopTracker.isStopping(node.id));
609
+ const color = toneColor(disp.tone, C);
610
+
611
+ const line1 = buildPanelLine(width, [
612
+ [' ', C.dim],
613
+ [disp.glyph, color],
614
+ [` ${node.kind}`, C.dim],
615
+ [' id ', C.label],
616
+ [node.id, C.value],
617
+ [' state ', C.label],
618
+ [disp.label, color],
619
+ [' elapsed ', C.label],
620
+ [formatElapsed(node.elapsedMs), C.value],
621
+ ]);
622
+ const line2 = buildPanelLine(width, [
623
+ [' model ', C.label],
624
+ [node.model ?? 'unknown', C.info],
625
+ [' tokens ', C.label],
626
+ [formatFleetTokens(node.usage), C.value],
627
+ [' cost ', C.label],
628
+ [formatFleetCost(node.costUsd, node.costState), C.value],
629
+ ]);
630
+ const activityText = node.currentActivity
631
+ ? `${node.currentActivity.kind}: ${node.currentActivity.text}`
632
+ : '(no recent activity)';
633
+ const line3 = buildPanelLine(width, [
634
+ [' activity ', C.label],
635
+ [truncateDisplay(activityText, Math.max(0, width - 11)), C.dim],
636
+ ]);
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]])] : [])];
641
+ }
642
+
643
+ /** Renders the active session tab's content (transcript / chain summary / ledger fallback) in place of the tree. */
644
+ private renderTabView(width: number, height: number): Line[] {
645
+ const palette = this.getPalette();
646
+ const tab = activeFleetTab(this.tabsState);
647
+ if (!tab) return this.renderTreeView(width, height); // defensive; should be unreachable
648
+ const stripLine = renderFleetTabStrip(this.tabsState, width);
649
+
650
+ const snapshotRows = this.readModel.getSnapshot().rows;
651
+ const liveNode = snapshotRows.find((row) => row.node.id === tab.nodeId)?.node ?? null;
652
+ // Wave-3 (W3.2): the steer composer only ever applies to 'agent' tabs
653
+ // (wrfc-chain has no conversation loop of its own — see
654
+ // fleet-tabs.ts's FleetAttachableKind doc); the live-node lookup is
655
+ // shared with the isTerminal computation below either way.
656
+ const canSteer = liveNode !== null && !isTerminalProcessState(liveNode.state) && liveNode.capabilities.steerable;
657
+
658
+ // Wave-3 (W3.2): while composing, Enter/Esc replace the tab-switch/detach
659
+ // hints (mirrors git-panel.ts's renderCommitCompose footer) so the
660
+ // footer never advertises a key the composer itself has absorbed.
661
+ const hints: Array<{ keys: string; label: string }> = tab.steerDraft !== null
662
+ ? [{ keys: 'Enter', label: 'send' }, { keys: 'Esc', label: 'cancel' }]
663
+ : [
664
+ { keys: '[ ]', label: 'switch tab' },
665
+ { keys: 'Ctrl+X', label: 'detach' },
666
+ ...(canSteer ? [{ keys: 's', label: 'steer' }] : []),
667
+ ];
668
+
669
+ const errorLine = this.renderErrorLine(width);
670
+ const composerLines: Line[] = [];
671
+ if (tab.steerDraft !== null) {
672
+ composerLines.push(buildSearchInputLine(
673
+ width,
674
+ 'Steer: ',
675
+ `${tab.steerDraft}_`,
676
+ palette,
677
+ { active: true, bg: palette.inputBg, valueColor: palette.info },
678
+ ));
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));
685
+ }
686
+
687
+ const footerLines = [
688
+ ...(errorLine ? [errorLine] : []),
689
+ ...composerLines,
690
+ buildKeyboardHints(width, hints, palette),
691
+ ];
692
+ const contentBudget = getPanelWorkspaceContentBudget(width, height, { footerLines, palette });
693
+ const contentHeight = Math.max(1, contentBudget - (stripLine ? 1 : 0));
694
+
695
+ let contentLines: Line[];
696
+
697
+ if (tab.kind === 'wrfc-chain') {
698
+ const memberRows = snapshotRows.filter((row) => row.node.parentId === tab.nodeId);
699
+ // d3 (W6.2): a completed chain prunes its wrapper node, so an empty member
700
+ // list can mean "finished", not "not started yet". Absent (pruned) OR
701
+ // terminal => the honest "completed" wording; present + non-terminal with
702
+ // no members yet => the "not started" wording.
703
+ const chainDoneOrAbsent = liveNode === null || isTerminalProcessState(liveNode.state);
704
+ contentLines = renderFleetChainSummary(memberRows, width, chainDoneOrAbsent);
705
+ } else {
706
+ const isTerminal = liveNode ? isTerminalProcessState(liveNode.state) : true;
707
+ const snapshot = this.actions.getConversationSnapshot(tab.agentId);
708
+ const result = renderFleetAgentTranscript(snapshot, isTerminal, tab.lineCache, width, contentHeight, this.configManager);
709
+ if (result.mode === 'unavailable') {
710
+ this.ensureLedgerLoaded(tab);
711
+ contentLines = tab.ledgerEntries !== null
712
+ ? renderFleetLedgerFallback(tab.ledgerEntries, width, contentHeight)
713
+ : renderFleetTranscriptLoading(width);
714
+ } else {
715
+ contentLines = result.lines;
716
+ }
717
+ }
718
+
719
+ const sections = [
720
+ ...(stripLine ? [{ lines: [stripLine] }] : []),
721
+ { lines: contentLines },
722
+ ];
723
+ return buildPanelWorkspace(width, height, { title: `Fleet — ${tab.label}`, sections, footerLines, palette });
724
+ }
725
+
726
+ private renderTreeView(width: number, height: number): Line[] {
727
+ const selected = this.getSelectedItem();
728
+ const footer: Line[] = [];
729
+ if (this.confirmOverlay.pending) {
730
+ footer.push(...(this.confirmOverlay.renderLines(width) ?? []));
731
+ } else if (selected) {
732
+ footer.push(...this.renderDetail(selected, width));
733
+ }
734
+
735
+ // Capability-gated tree hints — only advertise i/K/p when the selected node
736
+ // can actually accept them (most kinds are never interruptible; nothing is
737
+ // killable once terminal), incl. the d2 state-dependent p pause/resume chip.
738
+ // Mirrors the handleInput guards above. See buildFleetTreeHints (fleet-stop.ts).
739
+ const hints = buildFleetTreeHints(selected?.node, this.follow, this.tabsState.tabs.length > 0);
740
+
741
+ // Tab strip renders only when tabs exist — omitting it entirely with no
742
+ // tabs attached keeps the pre-Wave-3 root-tree rendering byte-identical.
743
+ const stripLine = renderFleetTabStrip(this.tabsState, width);
744
+ const header = stripLine ? [stripLine] : undefined;
745
+
746
+ return this.renderList(width, height, {
747
+ title: 'Fleet',
748
+ header,
749
+ footer,
750
+ hints,
751
+ });
752
+ }
753
+
754
+ public override render(width: number, height: number): Line[] {
755
+ if (this.tabsState.activeTabIndex > 0) return this.renderTabView(width, height);
756
+ return this.renderTreeView(width, height);
757
+ }
758
+ }