@pellux/goodvibes-tui 2.0.21 → 2.1.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 (388) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +16 -16
  3. package/docs/README.md +4 -4
  4. package/docs/foundation-artifacts/operator-contract.json +1 -1
  5. package/package.json +16 -6
  6. package/src/cli/management.ts +5 -2
  7. package/src/cli/tui-startup.ts +34 -39
  8. package/src/core/agent-view-render.ts +157 -0
  9. package/src/core/approval-alert.ts +49 -11
  10. package/src/core/bookmark-navigation.ts +7 -1
  11. package/src/core/budget-breach-notifier.ts +27 -8
  12. package/src/core/compaction-receipt.ts +1 -1
  13. package/src/core/context-usage.ts +1 -1
  14. package/src/core/conversation-compaction-render.ts +3 -3
  15. package/src/core/conversation-display-only.ts +86 -0
  16. package/src/core/conversation-event-navigation.ts +27 -0
  17. package/src/core/conversation-line-cache.ts +216 -447
  18. package/src/core/conversation-render-context.ts +20 -27
  19. package/src/core/conversation-rendering.ts +157 -575
  20. package/src/core/conversation-splash-state.ts +6 -1
  21. package/src/core/conversation-types.ts +35 -0
  22. package/src/core/conversation-usage.ts +12 -0
  23. package/src/core/conversation.ts +130 -131
  24. package/src/core/long-task-notifier.ts +100 -31
  25. package/src/core/notices.ts +75 -0
  26. package/src/core/pairing-tailscale-gateway.ts +1 -1
  27. package/src/core/process-output-log.ts +143 -0
  28. package/src/core/rewind-receipt.ts +1 -1
  29. package/src/core/session-focus.ts +190 -0
  30. package/src/core/session-resume-core.ts +23 -81
  31. package/src/core/stream-event-wiring.ts +31 -5
  32. package/src/core/system-message-noise.ts +2 -2
  33. package/src/core/system-message-router.ts +16 -18
  34. package/src/core/turn-event-wiring.ts +272 -33
  35. package/src/core/work-tree-focus.ts +290 -0
  36. package/src/core/work-tree-fold-store.ts +143 -0
  37. package/src/core/work-tree-model.ts +799 -0
  38. package/src/core/work-tree-render.ts +384 -0
  39. package/src/core/work-tree-sources.ts +184 -0
  40. package/src/core/work-tree-wiring.ts +176 -0
  41. package/src/core/workstream-notification.ts +36 -6
  42. package/src/core/wrfc-notice-titles.ts +37 -0
  43. package/src/input/agents-modal-text.ts +119 -0
  44. package/src/input/agents-modal.ts +699 -0
  45. package/src/input/bookmark-modal.ts +33 -9
  46. package/src/input/changes-git.ts +251 -0
  47. package/src/input/changes-modal.ts +629 -0
  48. package/src/input/changes-model.ts +125 -0
  49. package/src/input/command-palette-types.ts +41 -0
  50. package/src/input/command-palette.ts +272 -0
  51. package/src/input/command-registry.ts +77 -35
  52. package/src/input/commands/acp-runtime.ts +19 -9
  53. package/src/input/commands/channel-runtime.ts +2 -2
  54. package/src/input/commands/ci-runtime.ts +1 -1
  55. package/src/input/commands/codebase-runtime.ts +1 -1
  56. package/src/input/commands/config.ts +7 -1
  57. package/src/input/commands/context-window.ts +25 -19
  58. package/src/input/commands/control-room-runtime.ts +18 -18
  59. package/src/input/commands/conversation-runtime.ts +0 -1
  60. package/src/input/commands/cost-runtime.ts +29 -20
  61. package/src/input/commands/diff-runtime.ts +35 -181
  62. package/src/input/commands/eval.ts +1 -1
  63. package/src/input/commands/experience-runtime.ts +3 -7
  64. package/src/input/commands/fresh-conversation.ts +86 -0
  65. package/src/input/commands/git-runtime.ts +5 -22
  66. package/src/input/commands/graph-runtime.ts +1 -1
  67. package/src/input/commands/guidance-runtime.ts +1 -1
  68. package/src/input/commands/health-runtime.ts +8 -9
  69. package/src/input/commands/hooks-runtime.ts +2 -2
  70. package/src/input/commands/hosted-runtime.ts +3 -3
  71. package/src/input/commands/incident-runtime.ts +3 -3
  72. package/src/input/commands/intelligence-runtime.ts +6 -9
  73. package/src/input/commands/knowledge.ts +2 -2
  74. package/src/input/commands/legacy-panel-command.ts +57 -0
  75. package/src/input/commands/local-auth-runtime.ts +8 -9
  76. package/src/input/commands/local-provider-runtime.ts +7 -4
  77. package/src/input/commands/local-runtime.ts +12 -15
  78. package/src/input/commands/local-setup-review.ts +1 -1
  79. package/src/input/commands/local-setup.ts +13 -19
  80. package/src/input/commands/memory.ts +1 -1
  81. package/src/input/commands/notify-runtime.ts +9 -0
  82. package/src/input/commands/operator-runtime.ts +10 -12
  83. package/src/input/commands/permissions-provenance.ts +3 -3
  84. package/src/input/commands/planning-runtime.ts +7 -7
  85. package/src/input/commands/platform-sandbox-runtime.ts +2 -2
  86. package/src/input/commands/platform-services-runtime.ts +1 -1
  87. package/src/input/commands/policy-dispatch.ts +2 -2
  88. package/src/input/commands/policy.ts +3 -3
  89. package/src/input/commands/provider.ts +1 -1
  90. package/src/input/commands/remote-runtime.ts +1 -1
  91. package/src/input/commands/review-runtime.ts +39 -94
  92. package/src/input/commands/rewind-runtime.ts +81 -108
  93. package/src/input/commands/runtime-services.ts +15 -17
  94. package/src/input/commands/services-runtime.ts +2 -2
  95. package/src/input/commands/session-content.ts +21 -5
  96. package/src/input/commands/session-workflow.ts +13 -38
  97. package/src/input/commands/settings-sync-runtime.ts +2 -2
  98. package/src/input/commands/shell-core.ts +19 -4
  99. package/src/input/commands/skills-runtime.ts +2 -2
  100. package/src/input/commands/status-runtime.ts +13 -0
  101. package/src/input/commands/subscription-runtime.ts +1 -1
  102. package/src/input/commands/tasks-runtime.ts +2 -8
  103. package/src/input/commands/work-plan-runtime.ts +4 -4
  104. package/src/input/commands/workstream-attempts.ts +24 -47
  105. package/src/input/commands/workstream-runtime.ts +2 -2
  106. package/src/input/commands/worktree-runtime.ts +3 -3
  107. package/src/input/commands.ts +3 -1
  108. package/src/input/composer-editor.ts +44 -0
  109. package/src/input/concealed-input.ts +1 -1
  110. package/src/input/config-modal-types.ts +20 -8
  111. package/src/input/config-modal.ts +106 -105
  112. package/src/input/confirm-dialog-types.ts +28 -0
  113. package/src/input/confirm-dialog.ts +70 -0
  114. package/src/input/delete-key-policy.ts +3 -3
  115. package/src/input/feed-context-factory.ts +15 -16
  116. package/src/input/handler-command-route.ts +7 -42
  117. package/src/input/handler-content-actions.ts +16 -15
  118. package/src/input/handler-feed-routes.ts +26 -211
  119. package/src/input/handler-feed.ts +68 -95
  120. package/src/input/handler-interactions.ts +5 -3
  121. package/src/input/handler-modal-routes.ts +215 -216
  122. package/src/input/handler-modal-stack.ts +81 -11
  123. package/src/input/handler-modal-token-routes.ts +4 -0
  124. package/src/input/handler-onboarding.ts +0 -1
  125. package/src/input/handler-picker-routes.ts +65 -107
  126. package/src/input/handler-session-view-route.ts +149 -0
  127. package/src/input/handler-shortcuts.ts +16 -154
  128. package/src/input/handler-types.ts +4 -2
  129. package/src/input/handler-ui-state.ts +45 -15
  130. package/src/input/handler-work-tree-route.ts +123 -0
  131. package/src/input/handler.ts +25 -70
  132. package/src/input/keybindings.ts +16 -71
  133. package/src/input/masked-entry-modal.ts +117 -0
  134. package/src/input/mcp-workspace.ts +48 -17
  135. package/src/input/model-picker-filter.ts +8 -0
  136. package/src/input/model-picker-open.ts +99 -0
  137. package/src/input/model-picker-types.ts +1 -1
  138. package/src/input/model-picker.ts +92 -9
  139. package/src/input/notifications-modal.ts +123 -0
  140. package/src/input/overlay-filter.ts +26 -0
  141. package/src/input/profile-picker-modal.ts +37 -8
  142. package/src/input/search.ts +3 -3
  143. package/src/input/selection-modal-queue.ts +2 -0
  144. package/src/input/selection-modal.ts +28 -16
  145. package/src/input/session-picker-modal.ts +33 -8
  146. package/src/input/settings-modal-activation.ts +7 -2
  147. package/src/input/settings-modal-data.ts +11 -6
  148. package/src/input/settings-modal-types.ts +1 -1
  149. package/src/input/settings-modal.ts +20 -20
  150. package/src/input/submission-intent.ts +1 -1
  151. package/src/input/submission-router.ts +4 -9
  152. package/src/input/surface-modal-host.ts +110 -0
  153. package/src/input/theme-settings-actions.ts +82 -0
  154. package/src/input/usage-modal.ts +183 -0
  155. package/src/input/views.ts +97 -0
  156. package/src/main.ts +175 -165
  157. package/src/permissions/hunk-selection.ts +1 -1
  158. package/src/permissions/prompt-card.ts +511 -0
  159. package/src/permissions/prompt.ts +59 -622
  160. package/src/renderer/agents-modal.ts +310 -0
  161. package/src/renderer/autocomplete-overlay.ts +42 -220
  162. package/src/renderer/back-to-bottom.ts +80 -0
  163. package/src/renderer/block-actions-overlay.ts +30 -48
  164. package/src/renderer/bookmark-modal.ts +48 -79
  165. package/src/renderer/changes-modal.ts +325 -0
  166. package/src/renderer/code-block.ts +184 -106
  167. package/src/renderer/command-palette.ts +93 -0
  168. package/src/renderer/composer-fragments.ts +4 -4
  169. package/src/renderer/composer.ts +146 -0
  170. package/src/renderer/compositor.ts +26 -160
  171. package/src/renderer/config-modal.ts +185 -81
  172. package/src/renderer/confirm-dialog.ts +50 -0
  173. package/src/renderer/context-inspector.ts +116 -154
  174. package/src/renderer/context-status-hint.ts +4 -4
  175. package/src/renderer/conversation-layout.ts +20 -0
  176. package/src/renderer/conversation-overlays.ts +94 -84
  177. package/src/renderer/conversation-surface.ts +19 -38
  178. package/src/renderer/diff-tint.ts +98 -0
  179. package/src/renderer/file-picker-overlay.ts +63 -90
  180. package/src/renderer/footer-targets.ts +38 -0
  181. package/src/renderer/git-status.ts +3 -4
  182. package/src/renderer/header-line.ts +140 -117
  183. package/src/renderer/help-overlay.ts +133 -219
  184. package/src/renderer/history-search-overlay.ts +15 -12
  185. package/src/renderer/lane-graph/bead.ts +800 -0
  186. package/src/renderer/lane-graph/glyphs.ts +134 -0
  187. package/src/renderer/lane-graph/layout.ts +248 -0
  188. package/src/renderer/lane-graph/paint.ts +537 -0
  189. package/src/renderer/lane-graph/semantic-memo.ts +77 -0
  190. package/src/renderer/layout-engine.ts +2 -57
  191. package/src/renderer/layout.ts +7 -13
  192. package/src/renderer/markdown-inline.ts +2 -1
  193. package/src/renderer/markdown-table.ts +17 -12
  194. package/src/renderer/markdown.ts +92 -34
  195. package/src/renderer/masked-entry-modal.ts +78 -0
  196. package/src/renderer/mcp-workspace.ts +166 -203
  197. package/src/renderer/model-workspace.ts +381 -436
  198. package/src/renderer/notifications-modal.ts +116 -0
  199. package/src/renderer/onboarding/onboarding-wizard.ts +178 -588
  200. package/src/renderer/process-view.ts +192 -0
  201. package/src/renderer/profile-picker-modal.ts +58 -110
  202. package/src/renderer/search-overlay.ts +6 -3
  203. package/src/renderer/selection-modal-overlay.ts +87 -327
  204. package/src/renderer/semantic-diff.ts +2 -2
  205. package/src/renderer/session-chips.ts +102 -0
  206. package/src/renderer/session-picker-modal.ts +177 -297
  207. package/src/renderer/settings-modal-connections.ts +14 -23
  208. package/src/renderer/settings-modal-context.ts +367 -0
  209. package/src/renderer/settings-modal-helpers.ts +6 -11
  210. package/src/renderer/settings-modal.ts +322 -726
  211. package/src/renderer/shell-surface.ts +277 -175
  212. package/src/renderer/shortcuts-overlay.ts +217 -0
  213. package/src/renderer/status-line.ts +475 -0
  214. package/src/renderer/surface-compose.ts +174 -0
  215. package/src/renderer/surface-kit-extra.ts +303 -0
  216. package/src/renderer/surface-kit-list.ts +366 -0
  217. package/src/renderer/surface-kit-parts.ts +360 -0
  218. package/src/renderer/surface-kit.ts +568 -0
  219. package/src/renderer/syntax-highlighter.ts +280 -197
  220. package/src/renderer/syntax-theme.ts +51 -0
  221. package/src/renderer/system-message.ts +0 -1
  222. package/src/renderer/term-caps.ts +42 -1
  223. package/src/renderer/terminal-bg-probe.ts +116 -19
  224. package/src/renderer/terminal-palette-probe.ts +322 -0
  225. package/src/renderer/terminal-palette-reader.ts +183 -0
  226. package/src/renderer/terminal-palette.ts +58 -0
  227. package/src/renderer/theme-mode-config.ts +16 -1
  228. package/src/renderer/theme.ts +234 -207
  229. package/src/renderer/thinking.ts +0 -1
  230. package/src/renderer/throbber.ts +188 -0
  231. package/src/renderer/toast-center.ts +119 -0
  232. package/src/renderer/ui-factory.ts +39 -602
  233. package/src/renderer/ui-primitives.ts +8 -25
  234. package/src/renderer/usage-modal.ts +353 -0
  235. package/src/renderer/user-message.ts +58 -0
  236. package/src/renderer/voice-capture-chip.ts +53 -0
  237. package/src/runtime/bootstrap-command-context.ts +0 -5
  238. package/src/runtime/bootstrap-command-parts.ts +38 -89
  239. package/src/runtime/bootstrap-core.ts +13 -15
  240. package/src/runtime/bootstrap-hook-bridge.ts +12 -21
  241. package/src/runtime/bootstrap-notifier-sync.ts +17 -1
  242. package/src/runtime/bootstrap-shell.ts +16 -50
  243. package/src/runtime/bootstrap.ts +14 -14
  244. package/src/runtime/client/fleet-union.ts +5 -5
  245. package/src/runtime/client/hosted-exit.ts +2 -2
  246. package/src/runtime/client/hosted-sessions.ts +1 -1
  247. package/src/runtime/client/spine-adoption.ts +1 -1
  248. package/src/runtime/context-accounting-source.ts +5 -4
  249. package/src/runtime/index.ts +1 -0
  250. package/src/runtime/notification-dispatch.ts +102 -29
  251. package/src/runtime/recovery-offer-wiring.ts +4 -1
  252. package/src/runtime/recovery-prompt.ts +31 -15
  253. package/src/runtime/runtime-services-types.ts +10 -6
  254. package/src/runtime/services.ts +17 -12
  255. package/src/runtime/session-continuity-hints.ts +2 -5
  256. package/src/runtime/session-inbound-inputs.ts +1 -1
  257. package/src/runtime/session-resume-liveness-confirm.ts +14 -2
  258. package/src/runtime/startup-typeahead-gate.ts +103 -0
  259. package/src/runtime/store/domains/domain-read-matrix.ts +0 -1
  260. package/src/runtime/store/domains/index.ts +0 -8
  261. package/src/runtime/store/selectors/index.ts +0 -28
  262. package/src/runtime/store/state.ts +0 -8
  263. package/src/runtime/ui-services.ts +4 -6
  264. package/src/runtime/usage-tracker.ts +348 -0
  265. package/src/runtime/wrfc-persistence.ts +4 -4
  266. package/src/shell/blocking-input.ts +92 -2
  267. package/src/shell/recovery-input-helpers.ts +1 -1
  268. package/src/shell/session-views.ts +539 -0
  269. package/src/shell/status-report.ts +86 -0
  270. package/src/shell/transcript-scroll.ts +68 -0
  271. package/src/shell/ui-openers.ts +84 -172
  272. package/src/shell/view-openers.ts +205 -0
  273. package/src/tools/wrfc-agent-guard.ts +22 -2
  274. package/src/utils/format-duration.ts +4 -8
  275. package/src/utils/splash-lines.ts +9 -1
  276. package/src/utils/terminal-width.ts +56 -0
  277. package/src/verification/verification-ledger.ts +18 -16
  278. package/src/{panels → views}/agent-inspector-shared.ts +2 -2
  279. package/src/{panels → views}/builtin-modals.ts +22 -21
  280. package/src/views/builtin-views.ts +135 -0
  281. package/src/{panels → views}/confirm-state.ts +9 -9
  282. package/src/{panels → views}/diff-review-model.ts +10 -3
  283. package/src/{panels → views}/eval-registry.ts +1 -1
  284. package/src/{panels → views}/fleet-acts.ts +49 -85
  285. package/src/{panels/fleet-panel-format.ts → views/fleet-format.ts} +40 -40
  286. package/src/{panels → views}/fleet-gateway.ts +5 -5
  287. package/src/{panels → views}/fleet-observed-render.ts +16 -16
  288. package/src/{panels → views}/fleet-read-model.ts +9 -9
  289. package/src/{panels/fleet-tabs.ts → views/fleet-session-tabs.ts} +13 -13
  290. package/src/{panels → views}/fleet-spawn.ts +33 -49
  291. package/src/{panels → views}/fleet-steer.ts +12 -12
  292. package/src/{panels → views}/fleet-stop.ts +4 -4
  293. package/src/{panels → views}/fleet-transcript.ts +26 -27
  294. package/src/{panels/fleet-panel-worktree-detail.ts → views/fleet-worktree-detail.ts} +7 -7
  295. package/src/{panels → views}/hosted-session-feed.ts +6 -6
  296. package/src/views/modal-surface-registry.ts +41 -0
  297. package/src/{panels → views}/modals/devices-modal.ts +2 -2
  298. package/src/{panels → views}/modals/hooks-modal.ts +13 -13
  299. package/src/{panels → views}/modals/keybindings-modal.ts +19 -27
  300. package/src/{panels → views}/modals/knowledge-modal.ts +6 -5
  301. package/src/{panels → views}/modals/local-auth-modal.ts +22 -21
  302. package/src/{panels → views}/modals/marketplace-modal.ts +11 -11
  303. package/src/{panels → views}/modals/memory-modal.ts +8 -8
  304. package/src/{panels → views}/modals/modal-surface-helpers.ts +15 -5
  305. package/src/{panels → views}/modals/pairing-modal.ts +9 -9
  306. package/src/{panels → views}/modals/planning-modal.ts +15 -20
  307. package/src/{panels → views}/modals/plugins-modal.ts +6 -6
  308. package/src/{panels → views}/modals/policy-modal.ts +13 -5
  309. package/src/{panels → views}/modals/provider-health-modal.ts +4 -4
  310. package/src/{panels → views}/modals/remote-modal.ts +3 -3
  311. package/src/{panels → views}/modals/sandbox-modal.ts +4 -4
  312. package/src/{panels → views}/modals/security-modal.ts +22 -16
  313. package/src/{panels → views}/modals/services-modal.ts +3 -3
  314. package/src/{panels → views}/modals/settings-sync-modal.ts +4 -4
  315. package/src/{panels → views}/modals/skills-modal.ts +9 -9
  316. package/src/{panels → views}/modals/subscription-modal.ts +3 -3
  317. package/src/{panels → views}/modals/work-plan-modal.ts +10 -13
  318. package/src/views/notifications-feed.ts +314 -0
  319. package/src/{panels → views}/polish-core.ts +38 -36
  320. package/src/{panels → views}/polish-tables.ts +35 -26
  321. package/src/{panels → views}/polish.ts +101 -95
  322. package/src/{panels → views}/project-planning-answer-actions.ts +3 -3
  323. package/src/{panels → views}/provider-health-routes.ts +14 -14
  324. package/src/{panels → views}/search-focus.ts +8 -8
  325. package/src/{panels → views}/session-maintenance.ts +11 -11
  326. package/src/views/skills-discovery.ts +159 -0
  327. package/src/views/view-deps.ts +80 -0
  328. package/src/views/view-panel-adapter.ts +53 -0
  329. package/src/{panels → views}/workstream-graph-render.ts +1 -1
  330. package/src/core/conversation-fold.ts +0 -101
  331. package/src/core/conversation-turn-structure.ts +0 -464
  332. package/src/core/conversation-user-receipts.ts +0 -42
  333. package/src/input/commands/operator-panel-runtime.ts +0 -186
  334. package/src/input/panel-integration-actions.ts +0 -33
  335. package/src/input/panel-mouse-geometry.ts +0 -97
  336. package/src/input/panel-paste-flood-guard.ts +0 -86
  337. package/src/panels/base-panel.ts +0 -301
  338. package/src/panels/builtin/agent.ts +0 -27
  339. package/src/panels/builtin/development.ts +0 -92
  340. package/src/panels/builtin/knowledge.ts +0 -12
  341. package/src/panels/builtin/operations.ts +0 -177
  342. package/src/panels/builtin/session.ts +0 -81
  343. package/src/panels/builtin/shared.ts +0 -357
  344. package/src/panels/builtin-panels.ts +0 -28
  345. package/src/panels/cost-tracker-panel.ts +0 -746
  346. package/src/panels/diff-panel.ts +0 -797
  347. package/src/panels/diff-review-panel.ts +0 -395
  348. package/src/panels/expandable-list-panel.ts +0 -189
  349. package/src/panels/fleet-deep-link.ts +0 -31
  350. package/src/panels/fleet-panel.ts +0 -799
  351. package/src/panels/git-panel.ts +0 -800
  352. package/src/panels/hosted-session-panel.ts +0 -221
  353. package/src/panels/index.ts +0 -21
  354. package/src/panels/local-auth-panel.ts +0 -478
  355. package/src/panels/notifications-feed.ts +0 -127
  356. package/src/panels/notifications-panel.ts +0 -160
  357. package/src/panels/panel-confirm-overlay.ts +0 -72
  358. package/src/panels/panel-manager.ts +0 -791
  359. package/src/panels/plugins-panel.ts +0 -371
  360. package/src/panels/scrollable-list-panel.ts +0 -498
  361. package/src/panels/skills-panel.ts +0 -454
  362. package/src/panels/token-budget-panel.ts +0 -688
  363. package/src/panels/types.ts +0 -191
  364. package/src/panels/wrfc-panel-format.ts +0 -133
  365. package/src/renderer/diff-view.ts +0 -134
  366. package/src/renderer/fleet-tab-strip.ts +0 -56
  367. package/src/renderer/footer-tips.ts +0 -53
  368. package/src/renderer/fullscreen-primitives.ts +0 -146
  369. package/src/renderer/fullscreen-workspace.ts +0 -194
  370. package/src/renderer/hint-grammar.ts +0 -52
  371. package/src/renderer/modal-factory.ts +0 -480
  372. package/src/renderer/overlay-box.ts +0 -154
  373. package/src/renderer/overlay-viewport.ts +0 -104
  374. package/src/renderer/panel-composite.ts +0 -145
  375. package/src/renderer/panel-workspace-bar.ts +0 -54
  376. package/src/renderer/process-indicator.ts +0 -163
  377. package/src/renderer/tab-strip.ts +0 -222
  378. package/src/renderer/text-layout.ts +0 -31
  379. package/src/renderer/tool-call.ts +0 -276
  380. package/src/renderer/tool-result-expanded-lines.ts +0 -48
  381. package/src/renderer/tool-result-summary.ts +0 -219
  382. package/src/runtime/diagnostics/panels/index.ts +0 -21
  383. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  384. package/src/runtime/perf/panel-contracts.ts +0 -32
  385. package/src/runtime/store/domains/panels.ts +0 -117
  386. /package/src/{panels → views}/memory-consolidation-gateway.ts +0 -0
  387. /package/src/{panels → views}/modals/modal-theme.ts +0 -0
  388. /package/src/{panels → views}/provider-health-domains.ts +0 -0
@@ -0,0 +1,699 @@
1
+ /**
2
+ * agents-modal.ts, the Agents modal (/agents, F2, ctrl+o, ctrl+p "agents" and
3
+ * every old console name: fleet, cockpit, tasks, wrfc, inspector, ops, …).
4
+ *
5
+ * One list of everything running or finished this session (agents, WRFC
6
+ * chains and their members, workflows, watchers, schedules, background
7
+ * processes, third-party agents hosted over ACP, observed foreign sessions)
8
+ * plus the daemon-hosted conversation this terminal is attached to. The right
9
+ * side tails the selected one live.
10
+ *
11
+ * Keys on the list: ↑↓ move; Enter opens the selected agent (or background
12
+ * process) full screen, outside the modal, with its own composer (or acts on
13
+ * a row waiting on you: pick a winner, resolve a conflict); other kinds with
14
+ * a transcript open full height inside the modal; s steers
15
+ * (inline input); x stops it and its descendants (asks first); i interrupts;
16
+ * p pauses or resumes; n hosts a third-party agent; D discards a worktree;
17
+ * b jumps to the next thing waiting on you; f follows the newest running
18
+ * agent; v switches to the archive, a archives or restores, A archives
19
+ * everything finished; / filters. In the full view ctrl+x stops (asks first),
20
+ * [ ] steps between opened agents, and Esc goes back up a level and never
21
+ * interrupts anything.
22
+ */
23
+
24
+ import { readFile } from 'node:fs/promises';
25
+ import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
26
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
27
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
28
+ import type { ProcessNode, SteerResult } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
29
+ import type { Line } from '@pellux/goodvibes-sdk/platform/types';
30
+ import type { SurfaceModal, SurfaceModalHost } from './surface-modal-host.ts';
31
+ import type { KitHint, SurfaceLayer } from '../renderer/surface-kit.ts';
32
+ import type { KitRow } from '../renderer/surface-kit-list.ts';
33
+ import { activeTokens } from '../renderer/theme.ts';
34
+ import { renderAgentsModal, type AgentsDetail, type AgentsModalView, type AgentsText } from '../renderer/agents-modal.ts';
35
+ import { formatUsd } from '../renderer/usage-modal.ts';
36
+ import {
37
+ fleetAttentionText, fleetNodeAttention, fleetStallMarker, isBlockedOnUserNode, isRunningProcessState, isTerminalProcessState,
38
+ type FleetReadModel, type FleetTreeRow,
39
+ } from '../views/fleet-read-model.ts';
40
+ import type { FleetActs } from '../views/fleet-acts.ts';
41
+ import type { FleetSpawn } from '../views/fleet-spawn.ts';
42
+ import {
43
+ activeFleetTab, attachFleetTab, EMPTY_FLEET_TABS_STATE, isAttachableFleetKind, stepFleetTab, type FleetTab, type FleetTabsState,
44
+ } from '../views/fleet-session-tabs.ts';
45
+ import { liveSteerableLabels, reconcileSteerBadges, steerBadgeGlyph, steerRefusalMessage } from '../views/fleet-steer.ts';
46
+ import { FleetStopTracker, fleetKillConfirmArgs, fleetStateDisplay, toggleFleetPause } from '../views/fleet-stop.ts';
47
+ import { formatFleetCost, renderFleetDetailLines } from '../views/fleet-format.ts';
48
+ import { hasFleetCost } from '../views/fleet-read-model.ts';
49
+ import { parseAgentLedger, renderFleetAgentTranscript, renderFleetChainSummary, renderFleetLedgerFallback, renderFleetTranscriptLoading } from '../views/fleet-transcript.ts';
50
+ import { isObservedExternalNode } from '../views/fleet-observed-render.ts';
51
+ import type { HostedSessionFeed } from '../views/hosted-session-feed.ts';
52
+ import { formatElapsed } from '../utils/format-elapsed.ts';
53
+ import { isTextBackspace } from './delete-key-policy.ts';
54
+ import type { ConfirmOptions } from './confirm-dialog.ts';
55
+ import type { ViewTarget } from './views.ts';
56
+ import { hostedBody, hostedHeaderTexts, hostedRowTexts, transcriptTail } from './agents-modal-text.ts';
57
+
58
+ /** What the modal can do to a process (the process registry, through the fleet read model). */
59
+ export interface FleetActionCallbacks {
60
+ readonly interrupt: (id: string) => boolean;
61
+ readonly resume: (id: string) => boolean;
62
+ readonly kill: (id: string, opts: { readonly cascade: boolean }) => readonly string[];
63
+ /** An agent's conversation, live or just finished; empty when unavailable. */
64
+ readonly getConversationSnapshot: (agentId: string) => readonly ConversationMessageSnapshot[];
65
+ /** The agent's on-disk event log, the fallback when its conversation is gone. */
66
+ readonly resolveSessionLogPath: (agentId: string) => string;
67
+ readonly steer: (id: string, text: string) => SteerResult;
68
+ }
69
+
70
+ export interface AgentsModalDeps {
71
+ readonly readModel: FleetReadModel;
72
+ readonly actions: FleetActionCallbacks;
73
+ readonly acts?: FleetActs | null;
74
+ readonly spawn?: FleetSpawn | null;
75
+ readonly configManager?: ConfigManager | null;
76
+ readonly hosted?: HostedSessionFeed;
77
+ /** Send a line to the hosted session (/hosted say). */
78
+ readonly steerHosted?: (text: string) => void;
79
+ /** This session's own cost (the "you" half of the header), or null when unpriced. */
80
+ readonly sessionCost?: () => number | null;
81
+ readonly confirm?: (options: ConfirmOptions) => Promise<boolean>;
82
+ readonly requestRender: () => void;
83
+ /** Tick for elapsed times and steer badges (ms; 0 disables, for tests). */
84
+ readonly tickMs?: number;
85
+ /** Open an agent or background process full screen (shell/session-views.ts); false when it is not known here. */
86
+ readonly openSessionView?: (target: { readonly kind: 'agent' | 'process'; readonly id: string }) => boolean;
87
+ }
88
+
89
+ const HOSTED_ID = 'hosted-session';
90
+
91
+ type ListEntry = { readonly kind: 'node'; readonly row: FleetTreeRow } | { readonly kind: 'hosted' };
92
+
93
+ /** One key in the vocabulary the fleet controllers (acts, spawn) take. */
94
+ function keyName(token: InputToken): string | null {
95
+ if (token.type === 'text') return token.value.length === 1 ? token.value : null;
96
+ if (token.type === 'key') return token.logicalName ?? null;
97
+ return null;
98
+ }
99
+
100
+ export class AgentsModal implements SurfaceModal {
101
+ readonly name = 'agents';
102
+ view: 'active' | 'archived' = 'active';
103
+ query = '';
104
+ filtering = false;
105
+ selectedId: string | null = null;
106
+ follow = false;
107
+ tabs: FleetTabsState = EMPTY_FLEET_TABS_STATE;
108
+ /** The full-height view of the hosted session is open. */
109
+ hostedFull = false;
110
+ steer: { readonly id: string; draft: string } | null = null;
111
+ status: AgentsText | null = null;
112
+ readonly stopTracker = new FleetStopTracker();
113
+ private readonly unsubs: Array<() => void> = [];
114
+ private tickTimer: ReturnType<typeof setInterval> | null = null;
115
+
116
+ constructor(private readonly deps: AgentsModalDeps) {
117
+ this.unsubs.push(deps.readModel.subscribe(() => { this.onFleetChange(); deps.requestRender(); }));
118
+ this.unsubs.push(deps.readModel.subscribeConsumed((event) => {
119
+ for (const tab of this.tabs.tabs) {
120
+ const badge = tab.steerBadge;
121
+ if (badge && badge.messageId === event.messageId && (badge.status === 'queued' || badge.status === 'dropped')) {
122
+ tab.steerBadge = { messageId: badge.messageId, status: 'consumed', queuedAt: badge.queuedAt, resolvedAt: Date.now() };
123
+ }
124
+ }
125
+ deps.requestRender();
126
+ }));
127
+ if (deps.hosted) this.unsubs.push(deps.hosted.subscribe(() => deps.requestRender()));
128
+ const tickMs = deps.tickMs ?? 1_000;
129
+ if (tickMs > 0) this.tickTimer = setInterval(() => { this.onFleetChange(); deps.requestRender(); }, tickMs);
130
+ }
131
+
132
+ onClose(): void {
133
+ for (const unsub of this.unsubs) unsub();
134
+ this.unsubs.length = 0;
135
+ if (this.tickTimer !== null) clearInterval(this.tickTimer);
136
+ this.tickTimer = null;
137
+ for (const tab of this.tabs.tabs) tab.lineCache.clear();
138
+ }
139
+
140
+ private onFleetChange(): void {
141
+ this.applyFollow();
142
+ if (this.tabs.tabs.length > 0) reconcileSteerBadges(this.tabs.tabs, (id) => this.findNode(id), Date.now());
143
+ }
144
+
145
+ // ── Data ───────────────────────────────────────────────────────────────────
146
+
147
+ private snapshotRows(): readonly FleetTreeRow[] {
148
+ return this.view === 'archived' ? this.deps.readModel.getArchivedSnapshot().rows : this.deps.readModel.getSnapshot().rows;
149
+ }
150
+
151
+ private findNode(id: string): ProcessNode | null {
152
+ return this.deps.readModel.getSnapshot().rows.find((row) => row.node.id === id)?.node ?? null;
153
+ }
154
+
155
+ private hostedAttached(): boolean {
156
+ return this.view === 'active' && Boolean(this.deps.hosted?.getState().record);
157
+ }
158
+
159
+ private matches(row: FleetTreeRow): boolean {
160
+ const q = this.query.trim().toLowerCase();
161
+ if (!q) return true;
162
+ const node = row.node;
163
+ return [node.label, node.task ?? '', node.kind, node.id, node.currentActivity?.text ?? '', node.headline?.text ?? '']
164
+ .some((text) => text.toLowerCase().includes(q));
165
+ }
166
+
167
+ /** The list in display order, grouped: hosted, running (whole subtrees), finished. */
168
+ private groups(): Array<{ readonly name: string; readonly entries: ListEntry[] }> {
169
+ const rows = this.snapshotRows().filter((row) => this.matches(row));
170
+ if (this.view === 'archived') return rows.length > 0 ? [{ name: 'Archived', entries: rows.map((row) => ({ kind: 'node', row })) }] : [];
171
+ const groups: Array<{ name: string; entries: ListEntry[] }> = [];
172
+ const hostedMatches = !this.query.trim() || 'hosted'.includes(this.query.trim().toLowerCase()) || (this.deps.hosted?.getState().record?.title ?? '').toLowerCase().includes(this.query.trim().toLowerCase());
173
+ if (this.hostedAttached() && hostedMatches) groups.push({ name: 'Hosted', entries: [{ kind: 'hosted' }] });
174
+ // A root and everything under it stays together, in the group of its liveliest node.
175
+ const all = this.snapshotRows();
176
+ const rootOf = new Map<string, string>();
177
+ let currentRoot = '';
178
+ for (const row of all) {
179
+ if (row.depth === 0) currentRoot = row.node.id;
180
+ rootOf.set(row.node.id, currentRoot);
181
+ }
182
+ const liveRoots = new Set<string>();
183
+ for (const row of all) if (!isTerminalProcessState(row.node.state)) liveRoots.add(rootOf.get(row.node.id) ?? row.node.id);
184
+ const running = rows.filter((row) => liveRoots.has(rootOf.get(row.node.id) ?? row.node.id));
185
+ const finished = rows.filter((row) => !liveRoots.has(rootOf.get(row.node.id) ?? row.node.id));
186
+ if (running.length > 0) groups.push({ name: 'Running', entries: running.map((row) => ({ kind: 'node', row })) });
187
+ if (finished.length > 0) groups.push({ name: 'Finished', entries: finished.map((row) => ({ kind: 'node', row })) });
188
+ return groups;
189
+ }
190
+
191
+ private entries(): ListEntry[] {
192
+ return this.groups().flatMap((group) => group.entries);
193
+ }
194
+
195
+ private entryId(entry: ListEntry): string {
196
+ return entry.kind === 'hosted' ? HOSTED_ID : entry.row.node.id;
197
+ }
198
+
199
+ private selectedEntry(): ListEntry | null {
200
+ const entries = this.entries();
201
+ if (entries.length === 0) return null;
202
+ return entries.find((entry) => this.entryId(entry) === this.selectedId) ?? entries[0]!;
203
+ }
204
+
205
+ selectedNode(): ProcessNode | null {
206
+ const entry = this.selectedEntry();
207
+ return entry?.kind === 'node' ? entry.row.node : null;
208
+ }
209
+
210
+ private move(delta: number): void {
211
+ const entries = this.entries();
212
+ if (entries.length === 0) return;
213
+ const current = entries.findIndex((entry) => this.entryId(entry) === this.selectedId);
214
+ const next = Math.max(0, Math.min(entries.length - 1, (current < 0 ? 0 : current) + delta));
215
+ this.selectedId = this.entryId(entries[next]!);
216
+ this.follow = false;
217
+ }
218
+
219
+ private applyFollow(): void {
220
+ if (!this.follow) return;
221
+ let best: FleetTreeRow | null = null;
222
+ for (const row of this.deps.readModel.getSnapshot().rows) {
223
+ if (!isRunningProcessState(row.node.state)) continue;
224
+ if (!best || (row.node.startedAt ?? 0) >= (best.node.startedAt ?? 0)) best = row;
225
+ }
226
+ if (best) this.selectedId = best.node.id;
227
+ }
228
+
229
+ /** Select a specific process (a deep link from a notification, the work plan, /agents --target). */
230
+ reveal(target: ViewTarget): boolean {
231
+ const rows = this.deps.readModel.getSnapshot().rows;
232
+ // A tool target (the keybindings modal's Tools tab) names a tool, not a
233
+ // process: select the first process whose current step is that tool.
234
+ const row = target.kind === 'tool'
235
+ ? rows.find((r) => r.node.currentActivity?.toolName === target.id)
236
+ : rows.find((r) => r.node.id === target.id && (!target.kind || r.node.kind === target.kind));
237
+ this.view = 'active';
238
+ this.tabs = { tabs: this.tabs.tabs, activeTabIndex: 0 };
239
+ this.hostedFull = false;
240
+ this.query = '';
241
+ if (!row) {
242
+ this.status = target.kind === 'tool'
243
+ ? { text: `Nothing is running ${target.id} right now.`, tone: 'muted' }
244
+ : { text: `${target.id} is no longer running here.`, tone: 'warning' };
245
+ return false;
246
+ }
247
+ this.selectedId = row.node.id;
248
+ this.status = null;
249
+ return true;
250
+ }
251
+
252
+ /** Open the hosted session's full view (after /hosted new or attach). */
253
+ showHosted(): void {
254
+ this.view = 'active';
255
+ this.selectedId = HOSTED_ID;
256
+ this.hostedFull = this.hostedAttached();
257
+ this.tabs = { tabs: this.tabs.tabs, activeTabIndex: 0 };
258
+ }
259
+
260
+ private level(): 'list' | 'full' | 'picker' {
261
+ if (this.deps.acts?.pickModeActive() || this.deps.spawn?.spawnModeActive()) return 'picker';
262
+ if (this.hostedFull || this.tabs.activeTabIndex > 0) return 'full';
263
+ return 'list';
264
+ }
265
+
266
+ // ── Actions ────────────────────────────────────────────────────────────────
267
+
268
+ private say(text: string, tone: AgentsText['tone'] = 'muted'): void {
269
+ this.status = { text, tone };
270
+ }
271
+
272
+ private openFull(host: SurfaceModalHost): void {
273
+ const entry = this.selectedEntry();
274
+ if (!entry) return;
275
+ if (entry.kind === 'hosted') { this.hostedFull = true; return; }
276
+ const node = entry.row.node;
277
+ if (this.deps.acts?.handleTreeKey('enter', node)) return;
278
+ // An agent or a background process opens full screen, outside the modal.
279
+ const target = node.kind === 'agent' ? { kind: 'agent' as const, id: node.id }
280
+ : node.kind === 'background-process' ? { kind: 'process' as const, id: node.id } : null;
281
+ if (target && this.deps.openSessionView?.(target)) {
282
+ host.close(this, 'done');
283
+ return;
284
+ }
285
+ if (!isAttachableFleetKind(node.kind)) {
286
+ this.say(`${node.kind} has no transcript to open; its details are on the right.`, 'faint');
287
+ return;
288
+ }
289
+ this.tabs = attachFleetTab(this.tabs, node);
290
+ }
291
+
292
+ private beginSteer(): void {
293
+ const entry = this.selectedEntry();
294
+ if (!entry) return;
295
+ if (entry.kind === 'hosted') {
296
+ if (!this.deps.steerHosted) { this.say('Steering the hosted session is not available here.', 'error'); return; }
297
+ this.steer = { id: HOSTED_ID, draft: '' };
298
+ return;
299
+ }
300
+ const node = entry.row.node;
301
+ if (isObservedExternalNode(node)) { this.deps.acts?.handleTreeKey('s', node); return; }
302
+ if (isTerminalProcessState(node.state)) { this.say(`${node.label} already finished: nothing to steer.`, 'faint'); return; }
303
+ if (!node.capabilities.steerable || !isAttachableFleetKind(node.kind)) { this.say(`${node.kind} does not take steering.`, 'faint'); return; }
304
+ this.steer = { id: node.id, draft: '' };
305
+ }
306
+
307
+ private submitSteer(): void {
308
+ const steer = this.steer;
309
+ if (!steer) return;
310
+ const text = steer.draft.trim();
311
+ if (!text) { this.steer = null; return; }
312
+ if (steer.id === HOSTED_ID) {
313
+ this.steer = null;
314
+ this.deps.steerHosted?.(text);
315
+ this.say('Sent to the hosted session.');
316
+ return;
317
+ }
318
+ const result = this.deps.actions.steer(steer.id, text);
319
+ if (!result.queued) {
320
+ // The draft stays so nothing typed is lost.
321
+ const siblings = liveSteerableLabels(this.deps.readModel.getSnapshot().rows.map((row) => row.node), steer.id);
322
+ this.say(steerRefusalMessage(result.reason, siblings), 'error');
323
+ return;
324
+ }
325
+ const node = this.findNode(steer.id);
326
+ if (node) {
327
+ const keep = this.tabs.activeTabIndex;
328
+ this.tabs = attachFleetTab(this.tabs, node);
329
+ if (this.level() !== 'full' || keep === 0) this.tabs = { tabs: this.tabs.tabs, activeTabIndex: keep };
330
+ const tab = this.tabs.tabs.find((t) => t.nodeId === steer.id);
331
+ if (tab) tab.steerBadge = { messageId: result.messageId, status: 'queued', queuedAt: Date.now() };
332
+ }
333
+ this.steer = null;
334
+ this.say(`Steer queued for ${node?.label ?? steer.id}: it arrives at its next turn (⧗ until then).`);
335
+ }
336
+
337
+ /** x / ctrl+x: stop the node and its descendants, after asking. */
338
+ private stopSelected(nodeId?: string): void {
339
+ const node = nodeId ? this.findNode(nodeId) : this.selectedNode();
340
+ if (!node || isTerminalProcessState(node.state)) { if (node) this.say(`${node.label} already finished.`, 'faint'); return; }
341
+ if (!node.capabilities.killable) { this.say(`${node.kind} cannot be stopped from here.`, 'faint'); return; }
342
+ const args = fleetKillConfirmArgs(node, this.snapshotRows(), {
343
+ kill: (id) => this.deps.actions.kill(id, { cascade: true }),
344
+ markStopping: (id) => this.stopTracker.mark(id),
345
+ });
346
+ if (!this.deps.confirm) { this.say('Stopping needs a confirm dialog, which is not available here.', 'error'); return; }
347
+ void this.deps.confirm({
348
+ title: `Stop ${node.label}?`,
349
+ body: `This stops ${args.label} and everything it started. It cannot be resumed.\nPress ctrl+x again to stop it; Esc keeps it running.`,
350
+ confirmLabel: 'Stop',
351
+ tone: 'danger',
352
+ confirmChord: { key: 'x', ctrl: true },
353
+ }).then((ok) => {
354
+ if (ok) { args.onConfirm(); this.say(`Stopping ${node.label}…`, 'warning'); }
355
+ this.deps.requestRender();
356
+ });
357
+ }
358
+
359
+ private interruptSelected(): void {
360
+ const node = this.selectedNode();
361
+ if (!node || isTerminalProcessState(node.state)) return;
362
+ if (!node.capabilities.interruptible) { this.say(`${node.kind} does not support interrupt.`, 'faint'); return; }
363
+ this.deps.actions.interrupt(node.id);
364
+ this.stopTracker.mark(node.id);
365
+ this.say(`Interrupting ${node.label}…`, 'warning');
366
+ }
367
+
368
+ private jumpToBlocked(): void {
369
+ const blocked = this.deps.readModel.getSnapshot().blockedNodeIds;
370
+ if (blocked.length === 0) { this.say('Nothing is waiting on you right now.', 'faint'); return; }
371
+ const at = this.selectedId ? blocked.indexOf(this.selectedId) : -1;
372
+ this.view = 'active';
373
+ this.query = '';
374
+ this.tabs = { tabs: this.tabs.tabs, activeTabIndex: 0 };
375
+ this.hostedFull = false;
376
+ this.selectedId = blocked[(at + 1) % blocked.length]!;
377
+ this.status = null;
378
+ }
379
+
380
+ private archiveSelected(all: boolean): void {
381
+ if (all) {
382
+ if (this.view !== 'active') return;
383
+ const count = this.deps.readModel.archiveFinished();
384
+ this.say(count > 0 ? `Archived ${count} finished process${count === 1 ? '' : 'es'}.` : 'Nothing has fully finished to archive.', 'faint');
385
+ return;
386
+ }
387
+ const node = this.selectedNode();
388
+ if (!node) return;
389
+ if (this.view === 'archived') {
390
+ const restored = this.deps.readModel.unarchive(node.id);
391
+ this.say(restored > 0 ? `Restored ${node.label}.` : 'Nothing restored for this one.', 'faint');
392
+ return;
393
+ }
394
+ if (!isTerminalProcessState(node.state)) { this.say('Only finished processes can be archived.', 'faint'); return; }
395
+ const result = this.deps.readModel.archive(node.id);
396
+ this.say(result.archived ? `Archived ${node.label}.` : result.reason ?? 'Could not archive this one.', 'faint');
397
+ }
398
+
399
+ // ── Keys ───────────────────────────────────────────────────────────────────
400
+
401
+ escape(): boolean {
402
+ if (this.steer) { this.steer = null; return true; }
403
+ if (this.deps.acts?.observedSteerActive()) { this.deps.acts.handleObservedSteerInput('escape'); return true; }
404
+ if (this.deps.acts?.pickModeActive()) { this.deps.acts.handlePickInput('escape'); return true; }
405
+ if (this.deps.spawn?.spawnModeActive()) { this.deps.spawn.handleSpawnInput('escape'); return true; }
406
+ if (this.filtering) { this.filtering = false; return true; }
407
+ if (this.hostedFull) { this.hostedFull = false; return true; }
408
+ if (this.tabs.activeTabIndex > 0) { this.tabs = { tabs: this.tabs.tabs, activeTabIndex: 0 }; return true; }
409
+ if (this.query) { this.query = ''; return true; }
410
+ return false;
411
+ }
412
+
413
+ handleToken(token: InputToken, host: SurfaceModalHost): void {
414
+ if (this.steer) { this.handleSteerToken(token); return; }
415
+ // F2 and ctrl+o open this modal; pressed again here, they close it.
416
+ if (token.type === 'key' && ((token.logicalName === 'f2' && !token.ctrl) || (token.ctrl && token.logicalName === 'o'))) {
417
+ host.close(this, 'done');
418
+ return;
419
+ }
420
+ const key = keyName(token);
421
+ if (this.deps.acts?.observedSteerActive()) {
422
+ if (token.type === 'text') for (const ch of token.value) this.deps.acts.handleObservedSteerInput(ch);
423
+ else if (key) this.deps.acts.handleObservedSteerInput(key);
424
+ return;
425
+ }
426
+ if (this.deps.acts?.pickModeActive()) { if (key) this.deps.acts.handlePickInput(key); return; }
427
+ if (this.deps.spawn?.spawnModeActive()) { if (key) this.deps.spawn.handleSpawnInput(key); return; }
428
+ if (this.filtering) { this.handleFilterToken(token); return; }
429
+ if (this.level() === 'full') { this.handleFullToken(token); return; }
430
+ if (token.type === 'key') {
431
+ const name = token.logicalName ?? '';
432
+ if (token.ctrl && name === 'x') { this.stopSelected(); return; }
433
+ if (name === 'up') this.move(-1);
434
+ else if (name === 'down') this.move(1);
435
+ else if (name === 'pageup') this.move(-10);
436
+ else if (name === 'pagedown') this.move(10);
437
+ else if (name === 'enter') this.openFull(host);
438
+ else if (isTextBackspace(name) && this.query) this.query = this.query.slice(0, -1);
439
+ return;
440
+ }
441
+ if (token.type !== 'text' || token.value.length !== 1) return;
442
+ this.status = null;
443
+ const node = this.selectedNode();
444
+ switch (token.value) {
445
+ case '/': this.filtering = true; break;
446
+ case 'j': this.move(1); break;
447
+ case 'k': this.move(-1); break;
448
+ case 's': this.beginSteer(); break;
449
+ case 'x': case 'K': this.stopSelected(); break;
450
+ case 'i': this.interruptSelected(); break;
451
+ case 'p':
452
+ if (node) toggleFleetPause(node, { interrupt: (id) => this.deps.actions.interrupt(id), resume: (id) => this.deps.actions.resume(id), setError: (m) => this.say(m, 'faint'), markDirty: () => {}, tracker: this.stopTracker });
453
+ break;
454
+ case 'n':
455
+ if (this.deps.spawn) void this.deps.spawn.begin().then(() => this.deps.requestRender());
456
+ else this.say('Hosting a third-party agent needs the daemon, which is not connected.', 'faint');
457
+ break;
458
+ case 'D':
459
+ if (node && !this.deps.acts?.handleTreeKey('D', node)) this.say('This row does not own a worktree.', 'faint');
460
+ break;
461
+ case 'b': this.jumpToBlocked(); break;
462
+ case 'f': this.follow = !this.follow; this.applyFollow(); this.say(this.follow ? 'Following the newest running agent.' : 'Stopped following.', 'faint'); break;
463
+ case 'v': this.view = this.view === 'active' ? 'archived' : 'active'; this.selectedId = null; break;
464
+ case 'a': this.archiveSelected(false); break;
465
+ case 'A': this.archiveSelected(true); break;
466
+ case ']':
467
+ if (this.tabs.tabs.length > 0) this.tabs = { tabs: this.tabs.tabs, activeTabIndex: 1 };
468
+ break;
469
+ case '[':
470
+ if (this.tabs.tabs.length > 0) this.tabs = { tabs: this.tabs.tabs, activeTabIndex: this.tabs.tabs.length };
471
+ break;
472
+ default: break;
473
+ }
474
+ }
475
+
476
+ private handleFilterToken(token: InputToken): void {
477
+ if (token.type === 'text') {
478
+ this.query += [...token.value].filter((ch) => ch >= ' ').join('');
479
+ this.selectedId = null;
480
+ return;
481
+ }
482
+ if (token.type !== 'key') return;
483
+ const name = token.logicalName ?? '';
484
+ if (isTextBackspace(name)) { this.query = this.query.slice(0, -1); return; }
485
+ if (name === 'enter') { this.filtering = false; return; }
486
+ if (name === 'up' || name === 'down') { this.filtering = false; this.move(name === 'up' ? -1 : 1); }
487
+ }
488
+
489
+ private handleFullToken(token: InputToken): void {
490
+ if (token.type === 'key') {
491
+ const name = token.logicalName ?? '';
492
+ if (token.ctrl && name === 'x') {
493
+ const tab = activeFleetTab(this.tabs);
494
+ if (tab) this.stopSelected(tab.nodeId);
495
+ else this.say('The hosted session ends with /hosted kill; detaching keeps or ends it by its policy.', 'faint');
496
+ }
497
+ return;
498
+ }
499
+ if (token.type !== 'text' || token.value.length !== 1) return;
500
+ if (token.value === 's') {
501
+ const tab = activeFleetTab(this.tabs);
502
+ if (tab) this.selectedId = tab.nodeId;
503
+ this.beginSteer();
504
+ } else if ((token.value === ']' || token.value === '[') && !this.hostedFull) {
505
+ const next = stepFleetTab(this.tabs, token.value === ']' ? 1 : -1);
506
+ if (next.activeTabIndex > 0) this.tabs = next;
507
+ }
508
+ }
509
+
510
+ private handleSteerToken(token: InputToken): void {
511
+ const steer = this.steer!;
512
+ if (token.type === 'text') {
513
+ steer.draft += token.value.replace(/[\r\n]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '');
514
+ return;
515
+ }
516
+ if (token.type !== 'key') return;
517
+ const name = token.logicalName ?? '';
518
+ if (name === 'enter') this.submitSteer();
519
+ else if (isTextBackspace(name)) steer.draft = steer.draft.slice(0, -1);
520
+ }
521
+
522
+ // ── View ───────────────────────────────────────────────────────────────────
523
+
524
+ private badgeFor(nodeId: string): FleetTab['steerBadge'] {
525
+ return this.tabs.tabs.find((tab) => tab.nodeId === nodeId)?.steerBadge ?? null;
526
+ }
527
+
528
+ private nodeRow(row: FleetTreeRow, selected: boolean): KitRow {
529
+ const t = activeTokens();
530
+ const node = row.node;
531
+ const stopping = this.stopTracker.isStopping(node.id);
532
+ const blocked = !stopping && isBlockedOnUserNode(node);
533
+ const disp = fleetStateDisplay(node.state, stopping, blocked);
534
+ const running = isRunningProcessState(node.state) || node.state === 'awaiting-approval';
535
+ const mark = stopping || blocked ? disp.glyph : running ? '◐' : node.state === 'done' ? '✓' : node.state === 'failed' || node.state === 'killed' ? '✕' : '○';
536
+ const markFg = stopping || blocked ? t.warning : running ? t.brand : node.state === 'done' ? t.success : node.state === 'failed' || node.state === 'killed' ? t.error : t.textFaint;
537
+ const badge = this.badgeFor(node.id);
538
+ const attention = fleetNodeAttention(node);
539
+ const stall = fleetStallMarker(node);
540
+ const activity = stopping ? 'stopping…'
541
+ : blocked ? fleetAttentionText(attention ?? { reason: 'approval' })
542
+ : [node.currentActivity?.toolName ? `${node.currentActivity.toolName}: ${node.currentActivity.text}` : node.headline?.text ?? node.currentActivity?.text ?? node.task ?? '', stall ?? ''].filter(Boolean).join(' · ');
543
+ const attempt = node.attemptGroup ? ` attempt ${node.attemptGroup.index + 1}/${node.attemptGroup.total}${node.attemptGroup.held ? ' held' : ''}` : '';
544
+ const kindTag = node.kind === 'agent' ? '' : node.kind === 'acp-agent' ? 'hosted' : node.kind.replace(/-/g, ' ');
545
+ const desc = [kindTag, `${activity}${attempt}`.trim(), badge ? steerBadgeGlyph(badge.status) : ''].filter(Boolean).join(' · ');
546
+ const cost = hasFleetCost(node.costUsd, node.costState) ? formatFleetCost(node.costUsd, node.costState) : '';
547
+ const prefix = row.depth > 0 ? `${'│ '.repeat(row.depth - 1)}└ ` : '';
548
+ return { label: `${prefix}${node.label}`, desc: desc || undefined, right: [formatElapsed(node.elapsedMs), cost].filter(Boolean).join(' · '), mark, markFg, selected };
549
+ }
550
+
551
+ private listRows(): KitRow[] {
552
+ const t = activeTokens();
553
+ const selected = this.selectedEntry();
554
+ const rows: KitRow[] = [];
555
+ for (const group of this.groups()) {
556
+ rows.push({ header: group.name, headerRight: group.name === 'Hosted' ? 'on the daemon' : undefined });
557
+ for (const entry of group.entries) {
558
+ const isSelected = selected !== null && this.entryId(entry) === this.entryId(selected);
559
+ if (entry.kind === 'hosted') {
560
+ const state = this.deps.hosted!.getState();
561
+ const record = state.record!;
562
+ rows.push({ label: record.title.trim() || record.id, desc: `${state.streaming ? 'live' : 'not streaming'} · ${record.status}`, right: `${record.turnCount} turns`, mark: '◈', markFg: t.brandEnd, selected: isSelected });
563
+ } else {
564
+ rows.push(this.nodeRow(entry.row, isSelected));
565
+ }
566
+ }
567
+ }
568
+ return rows;
569
+ }
570
+
571
+ private detail(): AgentsDetail | null {
572
+ const entry = this.selectedEntry();
573
+ if (!entry) return null;
574
+ if (entry.kind === 'hosted') {
575
+ const state = this.deps.hosted!.getState();
576
+ return { title: state.record!.title.trim() || 'Hosted session', meta: 'a conversation running inside the daemon', blocks: [{ kind: 'text', lines: hostedHeaderTexts(state) }], tail: hostedRowTexts(state.rows.slice(-40)) };
577
+ }
578
+ const node = entry.row.node;
579
+ const stopping = this.stopTracker.isStopping(node.id);
580
+ const blocked = !stopping && isBlockedOnUserNode(node);
581
+ this.deps.acts?.ensureGraphFor(node);
582
+ const observedDraft = this.deps.acts?.observedSteerDraftFor(node.id) ?? null;
583
+ const graph = this.deps.acts?.graphFor(node.id) ?? null;
584
+ const meta = [node.kind === 'acp-agent' ? 'hosted over ACP' : node.kind, node.model, node.provider].filter(Boolean).join(' · ');
585
+ const tail = node.kind === 'agent' || node.kind === 'acp-agent' ? transcriptTail(this.deps.actions.getConversationSnapshot(node.id), 30) : [];
586
+ return { title: node.label, meta, blocks: [{ kind: 'lines', build: (width) => renderFleetDetailLines(node, width, stopping, blocked, undefined, observedDraft, graph) }], tail };
587
+ }
588
+
589
+ private fullBody(width: number, height: number): readonly Line[] {
590
+ if (this.hostedFull) return [];
591
+ const tab = activeFleetTab(this.tabs);
592
+ if (!tab) return [];
593
+ const live = this.findNode(tab.nodeId);
594
+ if (tab.kind === 'wrfc-chain') {
595
+ const members = this.deps.readModel.getSnapshot().rows.filter((row) => row.node.parentId === tab.nodeId);
596
+ return renderFleetChainSummary(members, width, live === null || isTerminalProcessState(live.state));
597
+ }
598
+ const isTerminal = live ? isTerminalProcessState(live.state) : true;
599
+ const snapshot = this.deps.actions.getConversationSnapshot(tab.nodeId);
600
+ const result = renderFleetAgentTranscript(snapshot, isTerminal, tab.lineCache, width, height, this.deps.configManager ?? null);
601
+ if (result.mode !== 'unavailable') return result.lines;
602
+ if (!tab.ledgerLoadStarted) {
603
+ tab.ledgerLoadStarted = true;
604
+ readFile(this.deps.actions.resolveSessionLogPath(tab.nodeId), 'utf-8')
605
+ .then((raw) => { tab.ledgerEntries = parseAgentLedger(raw); })
606
+ .catch(() => { tab.ledgerEntries = []; })
607
+ .finally(() => this.deps.requestRender());
608
+ }
609
+ return tab.ledgerEntries !== null ? renderFleetLedgerFallback(tab.ledgerEntries, width, height) : renderFleetTranscriptLoading(width);
610
+ }
611
+
612
+ private sub(): string {
613
+ const snapshot = this.deps.readModel.getSnapshot();
614
+ const failed = snapshot.rows.filter((row) => row.node.state === 'failed').length;
615
+ const blocked = snapshot.blockedNodeIds.length;
616
+ const parts = [`${snapshot.runningCount} running`];
617
+ if (failed > 0) parts.push(`${failed} failed`);
618
+ if (blocked > 0) parts.push(`${blocked} waiting on you`);
619
+ const you = this.deps.sessionCost?.();
620
+ if (you !== undefined) parts.push(`you ${you === null ? 'unpriced' : formatUsd(you)}`);
621
+ parts.push(`fleet ${snapshot.totalCost === null ? '$0.00' : formatUsd(snapshot.totalCost)}`);
622
+ return parts.join(' · ');
623
+ }
624
+
625
+ private hints(level: 'list' | 'full' | 'picker'): KitHint[] {
626
+ if (this.steer) return [['⏎', 'send'], ['esc', 'cancel']];
627
+ if (this.deps.acts?.observedSteerActive()) return [['⏎', 'send to the session'], ['esc', 'cancel']];
628
+ if (level === 'picker') return [['↑↓', 'choose'], ['⏎', this.deps.acts?.pickModeActive() ? 'pick (asks first)' : 'next'], ['esc', 'back']];
629
+ if (this.filtering) return [['⏎', 'done'], ['esc', 'stop filtering']];
630
+ if (level === 'full') {
631
+ if (this.hostedFull) return [['s', 'say'], ['esc', 'back']];
632
+ return [['s', 'steer'], ['ctrl+x', 'stop (asks first)'], ['[ ]', 'other agents'], ['esc', 'back']];
633
+ }
634
+ const node = this.selectedNode();
635
+ const attention = node ? fleetNodeAttention(node) : null;
636
+ const enter = attention?.reason === 'pick' ? 'pick a winner' : attention?.reason === 'conflict' ? 'resolve conflict' : 'open full screen';
637
+ const hints: KitHint[] = [['⏎', enter], ['s', 'steer'], ['ctrl+x', 'stop'], ['n', 'new agent']];
638
+ if (node && !isTerminalProcessState(node.state) && node.capabilities.interruptible) hints.push(['i', 'interrupt']);
639
+ if (node && (node.state === 'paused' ? node.capabilities.resumable : node.capabilities.pausable && !isTerminalProcessState(node.state))) hints.push(['p', node.state === 'paused' ? 'resume' : 'pause']);
640
+ const blocked = this.deps.readModel.getSnapshot().blockedNodeIds.length;
641
+ if (blocked > 0) hints.push(['b', `waiting on you (${blocked})`]);
642
+ hints.push(['v', this.view === 'archived' ? 'live' : 'archive'], ['a', this.view === 'archived' ? 'restore' : 'archive one'], ['f', this.follow ? 'following' : 'follow']);
643
+ return hints;
644
+ }
645
+
646
+ private buildView(): AgentsModalView {
647
+ const level = this.level();
648
+ const tab = activeFleetTab(this.tabs);
649
+ const input = this.steer
650
+ ? { label: this.steer.id === HOSTED_ID ? 'say: ' : `steer ${this.findNode(this.steer.id)?.label ?? ''}: `, draft: this.steer.draft }
651
+ : this.deps.acts?.observedSteerActive() && this.selectedNode()
652
+ ? { label: 'steer the foreign session: ', draft: this.deps.acts.observedSteerDraftFor(this.selectedNode()!.id) ?? '' }
653
+ : null;
654
+ const pick = this.deps.acts?.pickView() ?? null;
655
+ const spawn = this.deps.spawn?.spawnView() ?? null;
656
+ const shown = this.entries().length;
657
+ const total = this.snapshotRows().length + (this.hostedAttached() ? 1 : 0);
658
+ const hostedState = this.deps.hosted?.getState();
659
+ return {
660
+ level,
661
+ crumbs: level === 'full' ? [this.hostedFull ? (hostedState?.record?.title || 'hosted session') : (this.findNode(tab!.nodeId)?.label ?? tab!.label)]
662
+ : level === 'picker' ? [pick ? 'pick a winner' : spawn?.title ?? 'host an agent']
663
+ : this.view === 'archived' ? ['archive'] : [],
664
+ sub: level === 'list' ? this.sub() : '',
665
+ hints: this.hints(level),
666
+ status: this.status,
667
+ input,
668
+ filter: { query: this.query, active: this.filtering, count: this.query ? `${shown} of ${total}` : `${total} shown` },
669
+ rows: level === 'list' ? this.listRows() : [],
670
+ detail: level === 'list' ? this.detail() : null,
671
+ full: level === 'full' ? {
672
+ meta: this.hostedFull ? 'hosted on the daemon' : [tab!.kind, this.findNode(tab!.nodeId)?.state ?? 'finished'].join(' · '),
673
+ header: this.hostedFull && hostedState ? hostedHeaderTexts(hostedState) : [],
674
+ body: this.hostedFull && hostedState ? (width, height) => hostedBody(hostedState.rows, width, height) : (width, height) => this.fullBody(width, height),
675
+ } : null,
676
+ picker: level === 'picker'
677
+ ? pick
678
+ ? { intro: `Best of N: ${pick.title}. Choose the winner; Enter shows its diff and asks before merging.${pick.proposal ? ` The judge proposes ${pick.proposal} (advisory).` : ''}`, options: pick.candidates, side: pick.diff ? diffSide(pick.diff) : [{ text: 'This candidate has no diff to preview.', tone: 'faint' }] }
679
+ : { intro: spawn?.creating ? 'Hosting the agent…' : spawn?.intro ?? '', options: spawn?.options ?? [], side: [] }
680
+ : null,
681
+ scrollOwner: this,
682
+ };
683
+ }
684
+
685
+ render(screenWidth: number, screenHeight: number): SurfaceLayer {
686
+ return renderAgentsModal(this.buildView(), screenWidth, screenHeight);
687
+ }
688
+ }
689
+
690
+ /** The files a candidate diff touches, for the picker's side column. */
691
+ function diffSide(diff: string): AgentsText[] {
692
+ const files = diff.split('\n').filter((line) => line.startsWith('diff --git ')).map((line) => line.replace(/^diff --git a\/.+? b\//, ''));
693
+ const added = diff.split('\n').filter((line) => line.startsWith('+') && !line.startsWith('+++')).length;
694
+ const removed = diff.split('\n').filter((line) => line.startsWith('-') && !line.startsWith('---')).length;
695
+ return [
696
+ { text: `${files.length} file${files.length === 1 ? '' : 's'} · +${added} −${removed}`, tone: 'text', bold: true },
697
+ ...files.map((file) => ({ text: file, tone: 'muted' as const })),
698
+ ];
699
+ }