@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,153 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-stop.ts
3
+ //
4
+ // d1 (W6.2) — the "stopping…" write-window overlay, split out of fleet-panel.ts
5
+ // to keep that file under the architecture 800-line gate (same rationale as
6
+ // fleet-steer.ts). FleetPanel owns nothing here beyond a FleetStopTracker
7
+ // instance; all the state->display logic lives in these pure helpers.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import type { ProcessNode, ProcessState } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
11
+ import { fleetStateGlyph, fleetStateTone, isTerminalProcessState, type FleetStateTone, type FleetTreeRow } from './fleet-read-model.ts';
12
+
13
+ /** How long 'stopping…' lingers after a stop keypress before the true state is shown regardless (never masks a stuck kill). */
14
+ export const STOP_SETTLE_MS = 1500;
15
+ /** Display-only glyph for a node with an in-flight stop request. Verified free against STATE_GLYPHS (fleet-read-model.ts) and width-1 like its ⊘/⊟ siblings. */
16
+ export const STOPPING_GLYPH = '⊗';
17
+
18
+ /**
19
+ * Tracks node ids the operator just asked to stop (kill/interrupt/pause) mapped
20
+ * to the keypress time. While an id is present AND within STOP_SETTLE_MS its row
21
+ * renders a display-only 'stopping…' instead of the raw state — so the tree
22
+ * never claims the past-tense 'killed'/'interrupted' during the brief write
23
+ * window between the keypress and the state actually flipping. After the settle
24
+ * window the TRUE state is ALWAYS shown, so a genuinely stuck kill is never
25
+ * masked. 'stopping…' is NOT a ProcessState; it lives only here.
26
+ */
27
+ export class FleetStopTracker {
28
+ private readonly requestedAt = new Map<string, number>();
29
+
30
+ /** Record a stop request for `id` (kill/interrupt/pause). */
31
+ mark(id: string, now: number = Date.now()): void {
32
+ this.requestedAt.set(id, now);
33
+ }
34
+
35
+ /** Drop any stop request for `id` (e.g. a resume, which is a start not a stop). */
36
+ clear(id: string): void {
37
+ this.requestedAt.delete(id);
38
+ }
39
+
40
+ /** True while `id` has an in-flight stop request inside the settle window. Prunes expired markers so the map stays bounded and stuck kills are never masked past the window. */
41
+ isStopping(id: string, now: number = Date.now()): boolean {
42
+ const at = this.requestedAt.get(id);
43
+ if (at === undefined) return false;
44
+ if (now - at < STOP_SETTLE_MS) return true;
45
+ this.requestedAt.delete(id);
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /** Glyph + literal label + tone for a node, applying the display-only 'stopping…' override while a stop is in flight. */
51
+ export interface FleetStateDisplay {
52
+ readonly glyph: string;
53
+ readonly label: string;
54
+ readonly tone: FleetStateTone;
55
+ }
56
+
57
+ export function fleetStateDisplay(state: ProcessState, stopping: boolean): FleetStateDisplay {
58
+ return stopping
59
+ ? { glyph: STOPPING_GLYPH, label: 'stopping…', tone: 'warn' }
60
+ : { glyph: fleetStateGlyph(state), label: state, tone: fleetStateTone(state) };
61
+ }
62
+
63
+ /** The narrow action + side-effect surface `toggleFleetPause` needs from FleetPanel. */
64
+ export interface FleetPauseDeps {
65
+ readonly interrupt: (id: string) => boolean;
66
+ readonly resume: (id: string) => boolean;
67
+ readonly setError: (message: string) => void;
68
+ readonly markDirty: () => void;
69
+ readonly tracker: FleetStopTracker;
70
+ }
71
+
72
+ /**
73
+ * d2 (W6.2): 'p' toggles pause<->resume by the node's state. A `paused`
74
+ * resumable node is resumed (interrupt's inverse); a live pausable node is
75
+ * paused via the registry's interrupt() (its disable path). Honest refusal for
76
+ * non-resumable/non-pausable kinds. Returns true when the key is consumed,
77
+ * false to fall through (terminal, non-paused node — nothing to toggle).
78
+ */
79
+ export function toggleFleetPause(node: ProcessNode, deps: FleetPauseDeps): boolean {
80
+ if (node.state === 'paused') {
81
+ if (!node.capabilities.resumable) { deps.setError(`${node.kind} cannot be resumed.`); return true; }
82
+ deps.resume(node.id);
83
+ deps.tracker.clear(node.id); // a resume is a start, not a stop
84
+ deps.markDirty();
85
+ return true;
86
+ }
87
+ if (isTerminalProcessState(node.state)) return false;
88
+ if (!node.capabilities.pausable) { deps.setError(`${node.kind} does not support pause.`); return true; }
89
+ deps.interrupt(node.id);
90
+ deps.tracker.mark(node.id);
91
+ deps.markDirty();
92
+ return true;
93
+ }
94
+
95
+ /** A single keyboard-hint chip in the fleet tree footer. */
96
+ export interface FleetHint {
97
+ readonly keys: string;
98
+ readonly label: string;
99
+ }
100
+
101
+ /**
102
+ * Build the fleet tree footer hints for the current selection. Capability gates
103
+ * mirror the handleInput guards: interrupt/kill only on a live capable node;
104
+ * 'p' shows 'pause' for a live pausable node and 'resume' for a paused resumable
105
+ * one (never both, d2).
106
+ */
107
+ export function buildFleetTreeHints(
108
+ selected: ProcessNode | undefined,
109
+ follow: boolean,
110
+ hasTabs: boolean,
111
+ ): FleetHint[] {
112
+ const live = selected !== undefined && !isTerminalProcessState(selected.state);
113
+ const isPaused = selected !== undefined && selected.state === 'paused';
114
+ const hints: FleetHint[] = [
115
+ { keys: 'j/k', label: 'navigate' },
116
+ { keys: 'Enter', label: 'attach' },
117
+ ];
118
+ if (live && selected.capabilities.steerable) hints.push({ keys: 's', label: 'steer' }); // D4: discoverable from the tree (attach-and-steer)
119
+ if (live && selected.capabilities.interruptible) hints.push({ keys: 'i', label: 'interrupt' });
120
+ if (live && selected.capabilities.killable) hints.push({ keys: 'K', label: 'kill' });
121
+ if (live && !isPaused && selected.capabilities.pausable) hints.push({ keys: 'p', label: 'pause' });
122
+ if (isPaused && selected.capabilities.resumable) hints.push({ keys: 'p', label: 'resume' });
123
+ hints.push({ keys: 'f', label: follow ? 'follow:on' : 'follow' });
124
+ if (hasTabs) hints.push({ keys: '[ ]', label: 'tabs' });
125
+ return hints;
126
+ }
127
+
128
+ /** K-confirm descendant stats (UX-C item 6): total = every non-terminal descendant (what a cascade kill takes down); active = the individually-killable subset. Was "active only" (Wave-3 C7), undercounting a mixed subtree. */
129
+ export function countDescendantStats(rows: readonly FleetTreeRow[], nodeId: string): { total: number; active: number } {
130
+ const byParent = new Map<string, string[]>();
131
+ for (const row of rows) {
132
+ if (!row.node.parentId) continue;
133
+ const siblings = byParent.get(row.node.parentId) ?? [];
134
+ siblings.push(row.node.id);
135
+ byParent.set(row.node.parentId, siblings);
136
+ }
137
+ const byId = new Map(rows.map((row) => [row.node.id, row] as const));
138
+ let total = 0, active = 0;
139
+ const stack = [...(byParent.get(nodeId) ?? [])];
140
+ const seen = new Set<string>();
141
+ while (stack.length > 0) {
142
+ const id = stack.pop()!;
143
+ if (seen.has(id)) continue;
144
+ seen.add(id);
145
+ const row = byId.get(id);
146
+ if (row && !isTerminalProcessState(row.node.state)) {
147
+ total++;
148
+ if (row.node.capabilities.killable) active++;
149
+ }
150
+ for (const child of byParent.get(id) ?? []) stack.push(child);
151
+ }
152
+ return { total, active };
153
+ }
@@ -0,0 +1,230 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-tabs.ts
3
+ //
4
+ // Wave-3 (W3.1 Part C) — pure, testable tab-state model for FleetPanel's
5
+ // session tabs. No BasePanel/rendering dependency, mirroring the
6
+ // fleet-read-model.ts convention: this module owns attach/detach/switch as
7
+ // pure state transitions over a small immutable FleetTabsState, so the tab
8
+ // lifecycle is unit-testable without a live registry or a rendered panel.
9
+ //
10
+ // Tab-index convention: index 0 is always the fleet TREE (the panel's root
11
+ // view, never stored in `tabs`); `activeTabIndex` 1..N addresses
12
+ // `tabs[0..N-1]`. This mirrors the brief's "tree is the root/home tab"
13
+ // framing while keeping the array itself simple (only attached tabs).
14
+ //
15
+ // Attachable kinds: only 'agent' and 'wrfc-chain' carry anything worth
16
+ // attaching to (a transcript for an agent; a live member summary for a
17
+ // chain — see fleet-transcript.ts). workflow/trigger/schedule/watcher/
18
+ // background-process/wrfc-subtask nodes have no transcript and are never
19
+ // attachable (FleetPanel shows a status message instead, matching the
20
+ // existing i/K "not supported" convention).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import type { ProcessKind, ProcessNode } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
24
+ import { MessageLineCache } from '../core/conversation-line-cache.ts';
25
+ import { fleetKindTag } from './fleet-read-model.ts';
26
+
27
+ /** Node kinds that can be attached as a session tab. */
28
+ export type FleetAttachableKind = 'agent' | 'wrfc-chain';
29
+
30
+ export function isAttachableFleetKind(kind: ProcessKind): kind is FleetAttachableKind {
31
+ return kind === 'agent' || kind === 'wrfc-chain';
32
+ }
33
+
34
+ /**
35
+ * Wave-3 (W3.2 steering) — lifecycle of a queued steer message, tracked
36
+ * per-tab. `queued` means `ProcessRegistry.steer()` accepted the message
37
+ * onto the target's inbox, NOT that the agent has seen it — that is the
38
+ * later, honest `consumed` transition (a `COMMUNICATION_CONSUMED` runtime-bus
39
+ * event matching this badge's `messageId`). `dropped` is a TUI-side
40
+ * inference: the SDK emits no "expired"/"cancelled" signal for a queued
41
+ * steer, so if the target node goes terminal (done/failed/killed/
42
+ * interrupted) while the badge is still `queued`, FleetPanel resolves it to
43
+ * `dropped` itself rather than leaving the badge hanging forever (see
44
+ * fleet-panel.ts reconcileSteerBadges — cross-WO note: the SDK engineer
45
+ * confirmed no dropped signal exists).
46
+ */
47
+ export type SteerBadgeStatus = 'queued' | 'consumed' | 'dropped';
48
+
49
+ /** A tab's steer-message badge state (null on the tab = no active/recent steer). */
50
+ export interface SteerBadge {
51
+ readonly messageId: string;
52
+ readonly status: SteerBadgeStatus;
53
+ /** Present for 'dropped' — a one-line honest explanation shown in the tab. */
54
+ readonly note?: string;
55
+ /** epoch ms when status left 'queued' (consumed or dropped) — drives FleetPanel's linger-then-clear tick. */
56
+ readonly resolvedAt?: number;
57
+ /**
58
+ * epoch ms when status entered 'queued' (set once, at submit time). Drives
59
+ * fleet-steer.ts's reconcileSteerBadges TTL-expiry fallback: the SDK's
60
+ * MessageBus attaches its own STEER_TTL_MS to the underlying steer message
61
+ * (see registry.js's steer()) but never tells the TUI when that TTL lapses
62
+ * without delivery — the "agent stays healthy/non-terminal through one
63
+ * very long tool call, and the steer simply expires unseen in the bus"
64
+ * case. Without this, that badge would show 'queued' forever. Optional
65
+ * because older/hand-built badges (tests, pre-fix data) may not carry it —
66
+ * absence just means "no TTL-expiry inference possible for this badge",
67
+ * not an error.
68
+ */
69
+ readonly queuedAt?: number;
70
+ }
71
+
72
+ /**
73
+ * Append one character of steer-composer input to a draft, normalizing a
74
+ * pasted multi-line block's line breaks (terminals transmit bracketed-paste
75
+ * newlines as literal `\r`/`\n` characters delivered one at a time through
76
+ * the same per-char burst pipeline as ordinary typing — see
77
+ * handler-feed-routes.ts's isCapturingTextBurst contract) to a single
78
+ * collapsed space instead of either corrupting the one-line field with a
79
+ * raw control character or silently dropping the content. Mirrors how a
80
+ * plain single-line text input normalizes pasted newlines to whitespace.
81
+ */
82
+ export function appendSteerText(draft: string, ch: string): string {
83
+ if (ch === '\r' || ch === '\n') {
84
+ if (draft.length === 0 || draft.endsWith(' ')) return draft;
85
+ return `${draft} `;
86
+ }
87
+ return draft + ch;
88
+ }
89
+
90
+ /**
91
+ * One attached session tab. `agentId` is the SDK attach handle for
92
+ * `AgentManager.getConversationSnapshot(agentId)` — populated only for
93
+ * 'agent' tabs (agent.ts sets ProcessNode.id = record.id, so node.id IS the
94
+ * agentId; see the W3.1 brief's C2 note). 'wrfc-chain' tabs render a live
95
+ * member-summary instead of a transcript (a chain has no single conversation
96
+ * of its own — see fleet-transcript.ts renderFleetChainSummary) and carry an
97
+ * empty agentId, unused.
98
+ *
99
+ * `lineCache` is a PER-TAB `MessageLineCache` (C5 backpressure): switching
100
+ * tabs must never invalidate or thrash the main session's own cache, and a
101
+ * background (non-focused) tab must render nothing beyond its existing
102
+ * tree-row, so its cache simply stays empty (size 0) until the tab is
103
+ * focused. Disposed (`.clear()`) on detach so a closed tab's rendered
104
+ * Line[] are not retained (brief risk #8).
105
+ */
106
+ export interface FleetTab {
107
+ readonly nodeId: string;
108
+ readonly kind: FleetAttachableKind;
109
+ readonly agentId: string;
110
+ readonly label: string;
111
+ readonly lineCache: MessageLineCache;
112
+ /**
113
+ * Cached parsed ledger fallback for a terminal agent whose conversation
114
+ * snapshot has been evicted from the SDK's retention ring (C6 degraded
115
+ * path). `null` = not yet loaded/attempted; `[]` = loaded and empty (or
116
+ * the load failed) — both render the same honest "no transcript" state
117
+ * once loaded is true.
118
+ */
119
+ ledgerEntries: Record<string, unknown>[] | null;
120
+ ledgerLoadStarted: boolean;
121
+ /**
122
+ * Wave-3 (W3.2): the one-line steer composer's in-progress text, or `null`
123
+ * when not composing. Mirrors git-panel.ts's `commitMessage` mutable-slot
124
+ * convention (FleetPanel.isCapturingTextBurst() gates on this being
125
+ * non-null so a burst/paste lands here char-by-char, never as tree/tab
126
+ * hotkeys — see FleetPanel.handleSteerInput).
127
+ */
128
+ steerDraft: string | null;
129
+ /** Wave-3 (W3.2): this tab's most recent steer message's lifecycle, or null. */
130
+ steerBadge: SteerBadge | null;
131
+ }
132
+
133
+ /** Immutable tab-bar state: the attached tabs plus which one (or the root tree) is active. */
134
+ export interface FleetTabsState {
135
+ readonly tabs: readonly FleetTab[];
136
+ /** 0 = root tree; 1..tabs.length addresses tabs[activeTabIndex - 1]. */
137
+ readonly activeTabIndex: number;
138
+ }
139
+
140
+ export const EMPTY_FLEET_TABS_STATE: FleetTabsState = { tabs: [], activeTabIndex: 0 };
141
+
142
+ function shortNodeId(id: string): string {
143
+ if (id.length <= 10) return id;
144
+ // Trim a leading '-' left over from slicing mid-token (e.g. 'agent-done-01'
145
+ // -> '-done-01' before this trim) so the label never starts with a stray
146
+ // separator character.
147
+ return id.slice(-8).replace(/^-+/, '');
148
+ }
149
+
150
+ export function fleetTabLabel(node: ProcessNode): string {
151
+ return `${fleetKindTag(node.kind)} ${shortNodeId(node.id)}`;
152
+ }
153
+
154
+ function makeFleetTab(node: ProcessNode & { kind: FleetAttachableKind }): FleetTab {
155
+ return {
156
+ nodeId: node.id,
157
+ kind: node.kind,
158
+ agentId: node.kind === 'agent' ? node.id : '',
159
+ label: fleetTabLabel(node),
160
+ lineCache: new MessageLineCache(),
161
+ ledgerEntries: null,
162
+ ledgerLoadStarted: false,
163
+ steerDraft: null,
164
+ steerBadge: null,
165
+ };
166
+ }
167
+
168
+ /** The currently-active tab, or null when the root tree is active. */
169
+ export function activeFleetTab(state: FleetTabsState): FleetTab | null {
170
+ return state.activeTabIndex > 0 ? (state.tabs[state.activeTabIndex - 1] ?? null) : null;
171
+ }
172
+
173
+ /**
174
+ * Attach a node as a tab (Enter on an attachable tree row). Re-focuses an
175
+ * already-open tab for the same node instead of creating a duplicate.
176
+ * Returns `state` unchanged if `node.kind` is not attachable — callers
177
+ * (FleetPanel) are expected to guard with `isAttachableFleetKind` first and
178
+ * report a status message rather than silently doing nothing; this
179
+ * function's own no-op fallback is defense in depth.
180
+ */
181
+ export function attachFleetTab(state: FleetTabsState, node: ProcessNode): FleetTabsState {
182
+ if (!isAttachableFleetKind(node.kind)) return state;
183
+ const existingIndex = state.tabs.findIndex((tab) => tab.nodeId === node.id);
184
+ if (existingIndex >= 0) {
185
+ return { tabs: state.tabs, activeTabIndex: existingIndex + 1 };
186
+ }
187
+ const tab = makeFleetTab(node as ProcessNode & { kind: FleetAttachableKind });
188
+ const tabs = [...state.tabs, tab];
189
+ return { tabs, activeTabIndex: tabs.length };
190
+ }
191
+
192
+ /**
193
+ * Detach the tab at `tabIndex` (a plain index into `state.tabs`, NOT an
194
+ * `activeTabIndex`). Disposes its line cache. Re-anchors `activeTabIndex`:
195
+ * detaching the active tab falls back to the root tree; detaching a tab
196
+ * before the active one shifts the active index left to keep pointing at
197
+ * the same logical tab.
198
+ */
199
+ export function detachFleetTab(state: FleetTabsState, tabIndex: number): FleetTabsState {
200
+ if (tabIndex < 0 || tabIndex >= state.tabs.length) return state;
201
+ state.tabs[tabIndex]?.lineCache.clear();
202
+ const tabs = state.tabs.filter((_, i) => i !== tabIndex);
203
+ const removedActiveSlot = tabIndex + 1;
204
+ let activeTabIndex = state.activeTabIndex;
205
+ if (activeTabIndex === removedActiveSlot) {
206
+ activeTabIndex = 0;
207
+ } else if (activeTabIndex > removedActiveSlot) {
208
+ activeTabIndex -= 1;
209
+ }
210
+ return { tabs, activeTabIndex };
211
+ }
212
+
213
+ /** Detach whichever tab is currently active. No-op (returns state unchanged) when the root tree is active. */
214
+ export function detachActiveFleetTab(state: FleetTabsState): FleetTabsState {
215
+ if (state.activeTabIndex === 0) return state;
216
+ return detachFleetTab(state, state.activeTabIndex - 1);
217
+ }
218
+
219
+ /** Switch to an absolute tab-strip index (0 = root tree). Clamped to the valid range; out-of-range requests are a no-op. */
220
+ export function switchFleetTab(state: FleetTabsState, activeTabIndex: number): FleetTabsState {
221
+ if (activeTabIndex < 0 || activeTabIndex > state.tabs.length) return state;
222
+ if (activeTabIndex === state.activeTabIndex) return state;
223
+ return { tabs: state.tabs, activeTabIndex };
224
+ }
225
+
226
+ /** Move focus to the next/previous tab in the strip (wrapping is deliberately NOT applied — clamps at the ends). */
227
+ export function stepFleetTab(state: FleetTabsState, direction: 1 | -1): FleetTabsState {
228
+ const next = state.activeTabIndex + direction;
229
+ return switchFleetTab(state, Math.max(0, Math.min(state.tabs.length, next)));
230
+ }