@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
@@ -1,12 +1,20 @@
1
1
  /**
2
2
  * long-task-notifier, fires push notifications when a turn or agent task
3
- * completes after running longer than the configured threshold.
3
+ * ends after running longer than the configured threshold.
4
4
  *
5
- * PRIVACY GUARANTEE: Notification text must never include conversation content
6
- * (user messages, assistant replies, tool outputs). Only metadata is included:
7
- * task kind, elapsed time, ok/fail status, and session id. This module enforces
8
- * that constraint by construction, it receives no conversation object and
9
- * builds all message text from structural metadata only.
5
+ * NAMING RULE (owner ruling 2026-09-29): a notification names the work it is
6
+ * about. The title is the turn's name (the user-set conversation title, else
7
+ * the first line of the message that started the turn, trimmed at a word
8
+ * boundary), the body is the outcome: done, failed or cancelled, elapsed time,
9
+ * files changed, tool calls, agents started, review score, and for a failure
10
+ * the reason. Text comes from the SDK's runtime/turn-notification.ts so every
11
+ * channel and host words it the same way.
12
+ *
13
+ * PRIVACY RULE: behavior.notificationsMetadataOnly (default off). When on,
14
+ * every channel this module sends on (desktop and webhook) carries metadata
15
+ * only: the outcome, elapsed time, counts and the session id prefix, never the
16
+ * turn's name or a failure reason. When off, both channels, the webhook
17
+ * included, carry the name and the outcome.
10
18
  *
11
19
  * Delivery targets (in preference order):
12
20
  * 1. Desktop notification (linux notify-send / mac osascript) via SDK
@@ -32,7 +40,15 @@ import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
32
40
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
33
41
  import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
34
42
  import type { FocusTracker } from '@pellux/goodvibes-sdk/platform/runtime/operations';
35
- import { readNotifyOnlyWhenUnfocused, type ConfigGet } from '@pellux/goodvibes-sdk/platform/runtime/operations';
43
+ import {
44
+ FORCE_NOTIFY_DURATION_MS,
45
+ buildTurnNotification,
46
+ formatWebhookText,
47
+ readNotificationsMetadataOnly,
48
+ readNotifyOnlyWhenUnfocused,
49
+ type ConfigGet,
50
+ type TurnOutcome,
51
+ } from '@pellux/goodvibes-sdk/platform/runtime/operations';
36
52
 
37
53
  /** Default threshold in seconds. Turns shorter than this do not notify. */
38
54
  export const NOTIFY_AFTER_SECONDS_DEFAULT = 60;
@@ -66,9 +82,52 @@ export interface MaybeNotifyLongTaskOptions {
66
82
  /** Session id for correlation. Must not be a PII value. */
67
83
  readonly sessionId: string;
68
84
 
85
+ /**
86
+ * How the task ended. Overrides `status` when given, so a cancelled turn is
87
+ * told as cancelled rather than failed.
88
+ */
89
+ readonly outcome?: TurnOutcome | undefined;
90
+
91
+ /** The turn's name (SDK resolveTurnName). Left out of the text when metadata-only is on. */
92
+ readonly name?: string | null | undefined;
93
+
94
+ /** Why a failed or cancelled task stopped. Left out of the text when metadata-only is on. */
95
+ readonly reason?: string | null | undefined;
96
+
97
+ /** What the task did (SDK TurnActivityTally.snapshot()). */
98
+ readonly activity?: {
99
+ readonly toolCalls?: number | undefined;
100
+ readonly filesChanged?: number | undefined;
101
+ readonly agentsStarted?: number | undefined;
102
+ readonly reviewScore?: number | null | undefined;
103
+ } | undefined;
104
+
105
+ /**
106
+ * behavior.notificationsMetadataOnly. When omitted it is read through
107
+ * `configGet`; with neither, it is the default (off).
108
+ */
109
+ readonly metadataOnly?: boolean | undefined;
110
+
111
+ /** Desktop delivery; defaults to the SDK notifyCompletion. Tests pass a spy to read the text. */
112
+ readonly notifyDesktop?: typeof notifyCompletion | undefined;
113
+
114
+ /**
115
+ * False skips the desktop popup (behavior.notifyOnComplete off for a turn);
116
+ * the webhook still goes out. Default true.
117
+ */
118
+ readonly desktop?: boolean | undefined;
119
+
120
+ /**
121
+ * The desktop popup is also due past this many milliseconds, even under
122
+ * `thresholdSeconds` (and when the threshold is 0): a turn's end passes the
123
+ * SDK Orchestrator's own popup rule here (over 30s), since this notice
124
+ * replaced that popup. The webhook still waits for `thresholdSeconds`.
125
+ */
126
+ readonly desktopAfterMs?: number | undefined;
127
+
69
128
  /**
70
129
  * Threshold in seconds from config (behavior.notifyAfterSeconds).
71
- * 0 means off; notifications are suppressed entirely.
130
+ * 0 means off: no webhook, and no popup unless `desktopAfterMs` makes one due.
72
131
  * Should be the raw config value; this function normalises it.
73
132
  */
74
133
  readonly thresholdSeconds: number;
@@ -95,28 +154,25 @@ export interface MaybeNotifyLongTaskOptions {
95
154
  }
96
155
 
97
156
  /**
98
- * Fires push notifications for a completed long task if the elapsed time
157
+ * Fires push notifications for a finished long task if the elapsed time
99
158
  * exceeds the configured threshold.
100
159
  *
101
160
  * Returns true when at least one delivery was attempted, false when the
102
161
  * call was a no-op (threshold not reached, off-state, or focus-gated off,
103
162
  * see `focusTracker`/`configGet` above).
104
163
  *
105
- * PRIVACY: builds message text from structural metadata only (kind, elapsed,
106
- * status, sessionId). Never includes conversation content.
164
+ * Text: see the NAMING and PRIVACY rules at the top of this file.
107
165
  */
108
166
  export function maybeNotifyLongTask(opts: MaybeNotifyLongTaskOptions): boolean {
109
167
  const { elapsedMs, status, kind, sessionId, thresholdSeconds, webhookNotifier, focusTracker, configGet } = opts;
168
+ const notifyDesktop = opts.notifyDesktop ?? notifyCompletion;
110
169
 
111
- // Off-state: 0 disables notifications entirely.
112
- if (thresholdSeconds === NOTIFY_AFTER_SECONDS_OFF) {
113
- logger.debug('long-task-notifier: disabled (threshold=0)');
114
- return false;
115
- }
116
-
117
- // Gate: only notify when the task exceeded the threshold.
170
+ // The threshold gates both deliveries (0 is off); a desktop floor
171
+ // (desktopAfterMs) can make the popup alone due before it.
118
172
  const elapsedSeconds = Math.floor(elapsedMs / 1000);
119
- if (elapsedSeconds < thresholdSeconds) {
173
+ const thresholdMet = thresholdSeconds !== NOTIFY_AFTER_SECONDS_OFF && elapsedSeconds >= thresholdSeconds;
174
+ const desktopDue = opts.desktop !== false && (thresholdMet || (opts.desktopAfterMs !== undefined && elapsedMs > opts.desktopAfterMs));
175
+ if (!thresholdMet && !desktopDue) {
120
176
  logger.debug('long-task-notifier: below threshold', { elapsedSeconds, thresholdSeconds });
121
177
  return false;
122
178
  }
@@ -129,25 +185,38 @@ export function maybeNotifyLongTask(opts: MaybeNotifyLongTaskOptions): boolean {
129
185
  return false;
130
186
  }
131
187
 
132
- // Build concise, metadata-only message. No conversation text.
133
- const statusLabel = status === 'ok' ? 'completed' : 'failed';
134
- const title = `GoodVibes: ${kind} ${statusLabel}`;
135
- // PRIVACY: message contains only structural metadata, never conversation content.
136
- const message = `${kind} ${statusLabel} in ${elapsedSeconds}s · session ${sessionId.slice(0, 8)}`;
188
+ const metadataOnly = opts.metadataOnly ?? (configGet ? readNotificationsMetadataOnly(configGet) : false);
189
+ const notice = buildTurnNotification({
190
+ outcome: opts.outcome ?? (status === 'ok' ? 'completed' : 'failed'),
191
+ elapsedMs,
192
+ name: opts.name,
193
+ reason: opts.reason,
194
+ sessionId,
195
+ subject: kind,
196
+ ...opts.activity,
197
+ }, { metadataOnly });
137
198
 
138
199
  // Delivery 1: desktop notification (notify-send on linux, osascript on mac).
139
200
  // notifyCompletion is non-throwing; SDK handles platform absence silently.
140
- try {
141
- notifyCompletion(title, message, elapsedMs);
142
- } catch (err) {
143
- logger.debug('long-task-notifier: desktop notify error', { error: String(err) });
201
+ // Its own duration heuristic only pops a desktop notification above 30s;
202
+ // the threshold above is the user's (behavior.notifyAfterSeconds), so a
203
+ // threshold under 30s must still reach the desktop.
204
+ let delivered = false;
205
+ if (desktopDue) {
206
+ delivered = true;
207
+ try {
208
+ notifyDesktop(notice.title, notice.body, Math.max(elapsedMs, FORCE_NOTIFY_DURATION_MS));
209
+ } catch (err) {
210
+ logger.debug('long-task-notifier: desktop notify error', { error: String(err) });
211
+ }
144
212
  }
145
213
 
146
214
  // Delivery 2: outbound webhook (ntfy / generic endpoint) if configured.
147
- if (webhookNotifier) {
215
+ if (webhookNotifier && thresholdMet) {
148
216
  const urls = webhookNotifier.getUrls();
149
217
  if (urls.length > 0) {
150
- webhookNotifier.send(message).catch((err: unknown) => {
218
+ delivered = true;
219
+ webhookNotifier.send(formatWebhookText(notice)).catch((err: unknown) => {
151
220
  logger.debug('long-task-notifier: webhook send error', { error: String(err) });
152
221
  });
153
222
  } else {
@@ -155,7 +224,7 @@ export function maybeNotifyLongTask(opts: MaybeNotifyLongTaskOptions): boolean {
155
224
  }
156
225
  }
157
226
 
158
- return true;
227
+ return delivered;
159
228
  }
160
229
 
161
230
  /**
@@ -0,0 +1,75 @@
1
+ /**
2
+ * notices.ts, where a conversation system notice goes.
3
+ *
4
+ * A system notice ([WRFC] …, [Agents] …, a compaction receipt, a failover
5
+ * line) is not part of any turn, so the transcript does not draw it (the
6
+ * concept's turns hold only the turn's own text, beads, lanes and answer;
7
+ * agent and chain state are the lanes' own spawn and merge rows). Each notice
8
+ * becomes a toast in the top right and an entry in the notification history
9
+ * (/notifications), with its full text: the first line is the title, any
10
+ * further lines are the body. The conversation still stores the message, so a
11
+ * saved session keeps it, and restoring that session puts it back in the
12
+ * history without toasting it again.
13
+ *
14
+ * One entry per event: a "[WRFC] …" or "[Agents] …" line that restates an
15
+ * agent or chain event (the SDK's runtimeEventOfNotice names it) is kept under
16
+ * that event's plain title, with the line's detail as the body, and carries
17
+ * the event's key; the terminal's own "[WRFC] …" lines get their plain titles
18
+ * from core/wrfc-notice-titles.ts. The runtime-bus bridge (runtime/notification-dispatch.ts)
19
+ * records the same event with the same key, and the feed keeps one entry for
20
+ * both (NotificationFeed, eventKey).
21
+ */
22
+
23
+ import type { Notification } from '@/runtime/index.ts';
24
+ import { runtimeEventOfNotice } from '@pellux/goodvibes-sdk/platform/runtime/bootstrap';
25
+ import { shellChainNoticeOf } from './wrfc-notice-titles.ts';
26
+ import type { NotificationFeed } from '../views/notifications-feed.ts';
27
+ import { classifySystemMessage } from '../renderer/system-message.ts';
28
+
29
+ /** Receives every system notice added to (or restored into) the conversation. */
30
+ export type NoticeSink = (content: string, options: { readonly restored: boolean }) => void;
31
+
32
+ /** The pieces of a notice as the history and the toast show them. */
33
+ export interface NoticeParts {
34
+ /** Lower-case bracket tag ('wrfc', 'agents', 'compaction', …) or 'system' when untagged. */
35
+ readonly domain: string;
36
+ readonly level: Notification['level'];
37
+ /** The first non-empty line, in full. */
38
+ readonly title: string;
39
+ /** Every further line, in full, or undefined. */
40
+ readonly body: string | undefined;
41
+ }
42
+
43
+ const TAG_RE = /^\s*\[([^\]\n]+)\]/;
44
+
45
+ export function noticeParts(text: string): NoticeParts {
46
+ const trimmed = text.replace(/^\s*\n+/, '').replace(/\s+$/, '');
47
+ const tag = TAG_RE.exec(trimmed)?.[1];
48
+ const domain = tag ? tag.trim().toLowerCase().replace(/\s+/g, '-') : 'system';
49
+ const kind = classifySystemMessage(trimmed);
50
+ const level: Notification['level'] = kind === 'error' ? 'critical' : kind === 'warning' ? 'warning' : 'info';
51
+ const newline = trimmed.indexOf('\n');
52
+ const title = newline < 0 ? trimmed : trimmed.slice(0, newline);
53
+ const rest = newline < 0 ? '' : trimmed.slice(newline + 1).replace(/^\n+/, '');
54
+ return { domain, level, title, body: rest.length > 0 ? rest : undefined };
55
+ }
56
+
57
+ export interface NoticeOptions {
58
+ /** Restored from a saved session: kept in history, not toasted. */
59
+ readonly restored?: boolean;
60
+ readonly now?: () => number;
61
+ }
62
+
63
+ /** Keep one system notice in the history; the feed's toast bridge toasts it unless restored. */
64
+ export function publishNotice(feed: NotificationFeed, text: string, options: NoticeOptions = {}): void {
65
+ if (text.trim().length === 0) return;
66
+ const restored = options.restored === true;
67
+ const parts = noticeParts(text);
68
+ const sdkEvent = runtimeEventOfNotice(text);
69
+ const event = sdkEvent ?? shellChainNoticeOf(text);
70
+ if (event) {
71
+ feed.recordNotice({ domain: parts.domain, level: event.level, title: event.title, body: event.detail, timestamp: (options.now ?? Date.now)(), restored, eventKey: sdkEvent?.key });
72
+ return;
73
+ }
74
+ feed.recordNotice({ ...parts, timestamp: (options.now ?? Date.now)(), restored });
75
+ }
@@ -16,7 +16,7 @@ import { resolveOperatorRpc } from '../input/commands/operator-rpc.ts';
16
16
  import type {
17
17
  PairingTailscaleServeReceipt,
18
18
  PairingTailscaleStatus,
19
- } from '../panels/modals/pairing-modal.ts';
19
+ } from '../views/modals/pairing-modal.ts';
20
20
 
21
21
  export interface PairingTailscaleGatewayDeps {
22
22
  readonly configManager: ConfigManager;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * process-output-log.ts, a background process's output as timestamped lines.
3
+ *
4
+ * The process manager keeps each process's stdout and stderr as chunk arrays
5
+ * that grow while it runs, but it does not record when a chunk arrived. This
6
+ * log reads the arrays on every poll, splits new chunks into lines and stamps
7
+ * each line with the time it was first seen. The poll runs twice a second
8
+ * from startup (see shell/session-views.ts), so a stamp is at most about half
9
+ * a second late. A partial line (no newline yet) is held until it completes
10
+ * or the process ends.
11
+ *
12
+ * Bounded: each process keeps its newest MAX_LINES lines; a process the
13
+ * manager no longer lists is dropped on the next poll.
14
+ */
15
+
16
+ export interface ProcessLine {
17
+ /** Epoch ms the line was first seen. */
18
+ readonly at: number;
19
+ readonly text: string;
20
+ readonly stream: 'stdout' | 'stderr';
21
+ /** The line reads as an error (drawn in the error color). */
22
+ readonly error: boolean;
23
+ }
24
+
25
+ /** The slice of a process record this log reads (BackgroundProcess from the SDK). */
26
+ export interface ProcessRecordView {
27
+ readonly stdout: readonly string[];
28
+ readonly stderr: readonly string[];
29
+ readonly done: boolean;
30
+ }
31
+
32
+ /** Newest lines kept per process. */
33
+ export const MAX_LINES = 5000;
34
+
35
+ const ERROR_LINE = /\b(error|errors|failed|failure|fatal|exception|panic|traceback)\b|ERR!|✘|✖/i;
36
+ const PORT_PATTERNS: readonly RegExp[] = [
37
+ /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})\b/,
38
+ /\blistening on (?:port )?:?(\d{2,5})\b/i,
39
+ /\bport[ =:]+(\d{2,5})\b/i,
40
+ ];
41
+
42
+ /** Terminal control sequences never reach the view: colors and cursor moves are stripped. */
43
+ function clean(text: string): string {
44
+ return text
45
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
46
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
47
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
48
+ }
49
+
50
+ export function isErrorLine(text: string): boolean {
51
+ return ERROR_LINE.test(text);
52
+ }
53
+
54
+ /** The first port a line announces ("http://localhost:5173/", "listening on 3000"), when it names one. */
55
+ export function portOf(text: string): number | undefined {
56
+ for (const pattern of PORT_PATTERNS) {
57
+ const m = pattern.exec(text);
58
+ if (m) {
59
+ const port = Number(m[1]);
60
+ if (port > 0 && port < 65536) return port;
61
+ }
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ interface StreamCursor { chunks: number; partial: string }
67
+
68
+ interface Entry {
69
+ readonly lines: ProcessLine[];
70
+ readonly out: StreamCursor;
71
+ readonly err: StreamCursor;
72
+ port: number | undefined;
73
+ /** Lines dropped off the front by the bound. */
74
+ dropped: number;
75
+ }
76
+
77
+ export class ProcessOutputLog {
78
+ private readonly entries = new Map<string, Entry>();
79
+
80
+ /**
81
+ * Read every listed process's new output. `records` are the processes the
82
+ * manager lists right now; anything else is forgotten.
83
+ */
84
+ poll(records: ReadonlyMap<string, ProcessRecordView>, now = Date.now()): boolean {
85
+ let changed = false;
86
+ for (const id of [...this.entries.keys()]) if (!records.has(id)) this.entries.delete(id);
87
+ for (const [id, record] of records) {
88
+ let entry = this.entries.get(id);
89
+ if (!entry) {
90
+ entry = { lines: [], out: { chunks: 0, partial: '' }, err: { chunks: 0, partial: '' }, port: undefined, dropped: 0 };
91
+ this.entries.set(id, entry);
92
+ }
93
+ if (this.read(entry, entry.out, record.stdout, 'stdout', record.done, now)) changed = true;
94
+ if (this.read(entry, entry.err, record.stderr, 'stderr', record.done, now)) changed = true;
95
+ }
96
+ return changed;
97
+ }
98
+
99
+ lines(id: string): readonly ProcessLine[] {
100
+ return this.entries.get(id)?.lines ?? [];
101
+ }
102
+
103
+ /** Lines dropped off the front of this process's log by the bound. */
104
+ dropped(id: string): number {
105
+ return this.entries.get(id)?.dropped ?? 0;
106
+ }
107
+
108
+ /** The port the process announced, once one of its lines named it. */
109
+ port(id: string): number | undefined {
110
+ return this.entries.get(id)?.port;
111
+ }
112
+
113
+ private read(entry: Entry, cursor: StreamCursor, chunks: readonly string[], stream: ProcessLine['stream'], done: boolean, now: number): boolean {
114
+ if (chunks.length < cursor.chunks) cursor.chunks = 0; // the manager trimmed its buffer: start over from what it has
115
+ let changed = false;
116
+ if (chunks.length > cursor.chunks) {
117
+ const text = cursor.partial + chunks.slice(cursor.chunks).join('');
118
+ cursor.chunks = chunks.length;
119
+ const parts = text.split(/\r?\n/);
120
+ cursor.partial = parts.pop() ?? '';
121
+ for (const part of parts) this.push(entry, part, stream, now);
122
+ changed = parts.length > 0;
123
+ }
124
+ if (done && cursor.partial) {
125
+ this.push(entry, cursor.partial, stream, now);
126
+ cursor.partial = '';
127
+ changed = true;
128
+ }
129
+ return changed;
130
+ }
131
+
132
+ private push(entry: Entry, raw: string, stream: ProcessLine['stream'], now: number): void {
133
+ // A carriage return without a newline redraws the same line (progress bars): keep what is left visible.
134
+ const text = clean(raw.includes('\r') ? raw.slice(raw.lastIndexOf('\r') + 1) : raw).trimEnd();
135
+ entry.lines.push({ at: now, text, stream, error: isErrorLine(text) });
136
+ if (entry.port === undefined) entry.port = portOf(text);
137
+ if (entry.lines.length > MAX_LINES) {
138
+ const extra = entry.lines.length - MAX_LINES;
139
+ entry.lines.splice(0, extra);
140
+ entry.dropped += extra;
141
+ }
142
+ }
143
+ }
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * The `[Rewind]` prefix is one of the FORCE_CONVERSATION_PREFIXES the
12
12
  * system-message router always surfaces inline (see core/system-message-router.ts),
13
- * so the receipt cannot be routed into a panel and vanish.
13
+ * so the receipt cannot be routed away from the transcript and vanish.
14
14
  */
15
15
 
16
16
  /** The subset of the SDK's RewindReceipt this formatter renders. */
@@ -0,0 +1,190 @@
1
+ /**
2
+ * session-focus.ts, which session the terminal is showing: main, one agent,
3
+ * or one background process.
4
+ *
5
+ * Enter on an agent lane (or its row in the Agents modal) opens that agent
6
+ * full screen; Enter on a ▶ bead (or a process row) opens the process. The
7
+ * session chips under the header list every session there is to switch to,
8
+ * and Tab / Shift+Tab cycle them while the composer is empty.
9
+ *
10
+ * Esc inside an agent or process view goes up exactly one level: a process
11
+ * or a top-level agent returns to main; an agent spawned by another agent
12
+ * returns to that agent. Going up never stops or cancels anything. Stopping
13
+ * is ctrl+x pressed twice: the first press arms, the second (within
14
+ * STOP_CONFIRM_MS) stops.
15
+ *
16
+ * Pure state plus the live facts it is handed (SessionFocusDeps); nothing
17
+ * here renders or talks to the runtime directly.
18
+ */
19
+
20
+ export type SessionTarget =
21
+ | { readonly kind: 'main' }
22
+ | { readonly kind: 'agent'; readonly id: string }
23
+ | { readonly kind: 'process'; readonly id: string };
24
+
25
+ /** The status a chip's mark shows. */
26
+ export type SessionMark = 'run' | 'idle' | 'ok' | 'err' | 'cancel' | 'wait';
27
+
28
+ /** One session the chips row lists. */
29
+ export interface SessionEntry {
30
+ readonly target: SessionTarget;
31
+ /** "main", the agent's template name, or the process command. */
32
+ readonly name: string;
33
+ readonly mark: SessionMark;
34
+ }
35
+
36
+ export interface SessionFocusDeps {
37
+ /** Agents worth a chip: running or pending (finished ones leave the row). */
38
+ readonly liveAgents: () => ReadonlyArray<{ readonly id: string; readonly name: string; readonly mark: SessionMark }>;
39
+ /** Background processes still running. */
40
+ readonly liveProcesses: () => ReadonlyArray<{ readonly id: string; readonly name: string; readonly mark: SessionMark }>;
41
+ /** Name and mark of any agent (the focused one may have finished), or null when unknown. */
42
+ readonly agent: (id: string) => { readonly name: string; readonly mark: SessionMark } | null;
43
+ /** Name and mark of any process, or null when unknown. */
44
+ readonly process: (id: string) => { readonly name: string; readonly mark: SessionMark } | null;
45
+ /** The agent that spawned this agent, or null when main did (or it is unknown). */
46
+ readonly parentAgent: (id: string) => string | null;
47
+ /** Whether main is working on a turn. */
48
+ readonly mainBusy: () => boolean;
49
+ readonly now?: () => number;
50
+ }
51
+
52
+ export const MAIN: SessionTarget = { kind: 'main' };
53
+
54
+ /** How long the first ctrl+x stays armed. */
55
+ export const STOP_CONFIRM_MS = 3000;
56
+
57
+ export function sameTarget(a: SessionTarget, b: SessionTarget): boolean {
58
+ if (a.kind !== b.kind) return false;
59
+ return a.kind === 'main' || a.id === (b as { id: string }).id;
60
+ }
61
+
62
+ export class SessionFocus {
63
+ private currentTarget: SessionTarget = MAIN;
64
+ private armed: { readonly target: SessionTarget; readonly at: number } | null = null;
65
+ private listener: (() => void) | null = null;
66
+
67
+ constructor(private readonly deps: SessionFocusDeps) {}
68
+
69
+ get current(): SessionTarget { return this.currentTarget; }
70
+ /** An agent or process view is showing. */
71
+ get active(): boolean { return this.currentTarget.kind !== 'main'; }
72
+
73
+ /** Be told whenever the view changes (the transcript scroll resets, the frame repaints). */
74
+ onChange(listener: (() => void) | null): void { this.listener = listener; }
75
+
76
+ /** Show `target`. Opening an unknown agent or process is refused (false). */
77
+ open(target: SessionTarget): boolean {
78
+ if (target.kind === 'agent' && this.deps.agent(target.id) === null) return false;
79
+ if (target.kind === 'process' && this.deps.process(target.id) === null) return false;
80
+ if (sameTarget(target, this.currentTarget)) return true;
81
+ this.currentTarget = target;
82
+ this.armed = null;
83
+ this.listener?.();
84
+ return true;
85
+ }
86
+
87
+ /** The level Esc goes back to from `target`. */
88
+ parentOf(target: SessionTarget): SessionTarget {
89
+ if (target.kind !== 'agent') return MAIN;
90
+ const parent = this.deps.parentAgent(target.id);
91
+ return parent !== null && parent !== target.id && this.deps.agent(parent) !== null ? { kind: 'agent', id: parent } : MAIN;
92
+ }
93
+
94
+ /** Esc in a view: go up one level. Returns false in main (nothing to go back from). Never stops anything. */
95
+ back(): boolean {
96
+ if (!this.active) return false;
97
+ this.currentTarget = this.parentOf(this.currentTarget);
98
+ this.armed = null;
99
+ this.listener?.();
100
+ return true;
101
+ }
102
+
103
+ /** The display name of a target. */
104
+ nameOf(target: SessionTarget): string {
105
+ if (target.kind === 'main') return 'main';
106
+ const known = target.kind === 'agent' ? this.deps.agent(target.id) : this.deps.process(target.id);
107
+ return known?.name ?? target.id;
108
+ }
109
+
110
+ /** "main › engineer › tester": the path from main down to the current view. */
111
+ breadcrumb(): SessionTarget[] {
112
+ const path: SessionTarget[] = [];
113
+ let at: SessionTarget = this.currentTarget;
114
+ for (let guard = 0; at.kind !== 'main' && guard < 16; guard++) {
115
+ path.unshift(at);
116
+ at = this.parentOf(at);
117
+ }
118
+ return [MAIN, ...path];
119
+ }
120
+
121
+ /**
122
+ * Every session there is to switch to: main, then running agents, then
123
+ * running processes. The session being shown is always listed, even after
124
+ * it finished.
125
+ */
126
+ sessions(): SessionEntry[] {
127
+ const entries: SessionEntry[] = [{ target: MAIN, name: 'main', mark: this.deps.mainBusy() ? 'run' : 'idle' }];
128
+ const agents = this.deps.liveAgents();
129
+ const processes = this.deps.liveProcesses();
130
+ const cur = this.currentTarget;
131
+ const names = new Map<string, number>();
132
+ const label = (name: string): string => {
133
+ const n = (names.get(name) ?? 0) + 1;
134
+ names.set(name, n);
135
+ return n > 1 ? `${name} ${n}` : name;
136
+ };
137
+ const pushAgent = (id: string, name: string, mark: SessionMark): void => { entries.push({ target: { kind: 'agent', id }, name: label(name), mark }); };
138
+ const pushProcess = (id: string, name: string, mark: SessionMark): void => { entries.push({ target: { kind: 'process', id }, name: label(name), mark }); };
139
+ for (const a of agents) pushAgent(a.id, a.name, a.mark);
140
+ if (cur.kind === 'agent' && !agents.some((a) => a.id === cur.id)) {
141
+ const known = this.deps.agent(cur.id);
142
+ if (known) pushAgent(cur.id, known.name, known.mark);
143
+ }
144
+ for (const p of processes) pushProcess(p.id, p.name, p.mark);
145
+ if (cur.kind === 'process' && !processes.some((p) => p.id === cur.id)) {
146
+ const known = this.deps.process(cur.id);
147
+ if (known) pushProcess(cur.id, known.name, known.mark);
148
+ }
149
+ return entries;
150
+ }
151
+
152
+ /** The chips row shows only when there is more than one session to switch to. */
153
+ chipsVisible(): boolean {
154
+ return this.sessions().length > 1;
155
+ }
156
+
157
+ /** Tab (+1) / Shift+Tab (-1): show the next or previous session. False when there is nothing to cycle to. */
158
+ cycle(delta: 1 | -1): boolean {
159
+ const list = this.sessions();
160
+ if (list.length < 2) return false;
161
+ const at = Math.max(0, list.findIndex((e) => sameTarget(e.target, this.currentTarget)));
162
+ const next = list[(at + delta + list.length) % list.length]!;
163
+ return this.open(next.target);
164
+ }
165
+
166
+ /**
167
+ * ctrl+x in a view. The first press arms and returns 'armed'; a second
168
+ * press within STOP_CONFIRM_MS returns 'confirmed' (the caller stops the
169
+ * work). In main it returns 'none'.
170
+ */
171
+ pressStop(): 'armed' | 'confirmed' | 'none' {
172
+ if (!this.active) return 'none';
173
+ const now = this.deps.now?.() ?? Date.now();
174
+ if (this.armed && sameTarget(this.armed.target, this.currentTarget) && now - this.armed.at <= STOP_CONFIRM_MS) {
175
+ this.armed = null;
176
+ return 'confirmed';
177
+ }
178
+ this.armed = { target: this.currentTarget, at: now };
179
+ return 'armed';
180
+ }
181
+
182
+ /** Whether a first ctrl+x is waiting for its confirming press. */
183
+ stopArmed(): boolean {
184
+ if (!this.armed || !sameTarget(this.armed.target, this.currentTarget)) return false;
185
+ const now = this.deps.now?.() ?? Date.now();
186
+ return now - this.armed.at <= STOP_CONFIRM_MS;
187
+ }
188
+
189
+ disarm(): void { this.armed = null; }
190
+ }