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