@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,799 @@
1
+ /**
2
+ * work-tree-model.ts, one assistant turn as the rows of its lane graph.
3
+ *
4
+ * A turn is every assistant and tool message between two user messages. Its
5
+ * rows, in display order:
6
+ *
7
+ * head the turn header (the spine opens)
8
+ * prose narration between tool calls, on the spine
9
+ * bead one tool call, on the lane of whoever made it
10
+ * spawn an agent branching into its own lane
11
+ * merge that agent finishing, back into the lane it came from
12
+ * folded a folded agent lane: one ◉ bead on its parent's lane
13
+ * answer the final prose (the spine closes on its first row)
14
+ *
15
+ * Ordering: calls are placed by when they started when the timing store
16
+ * knows (so lanes that ran at the same time interleave), and otherwise in
17
+ * transcript order with a child lane's rows directly under the call that
18
+ * started it.
19
+ *
20
+ * Pure: every live fact comes in through WorkTreeSources.
21
+ */
22
+
23
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
24
+ import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
25
+ import {
26
+ beadArgument,
27
+ beadBody,
28
+ beadName,
29
+ beadStatus,
30
+ beadSummary,
31
+ cellText,
32
+ formatBeadTime,
33
+ formatCost,
34
+ inferResultToolName,
35
+ isBackgroundCall,
36
+ needsAttention,
37
+ toolFamily,
38
+ type BeadBody,
39
+ type BeadStatus,
40
+ type BeadSummary,
41
+ type CallOutcome,
42
+ } from '../renderer/lane-graph/bead.ts';
43
+ import { SPINE, type LaneId } from '../renderer/lane-graph/layout.ts';
44
+ import { userMessageFingerprint, type AgentLaneInfo, type TurnOutcome, type TurnTiming, type WorkTreeSources, type WrfcPhaseInfo } from './work-tree-sources.ts';
45
+
46
+ type Message = ConversationMessageSnapshot;
47
+ type AssistantMessage = Extract<Message, { role: 'assistant' }>;
48
+ type ToolMessage = Extract<Message, { role: 'tool' }>;
49
+
50
+ /** Deepest agent nesting drawn as lanes; deeper work folds to one bead that says so. */
51
+ export const MAX_NEST_DEPTH = 8;
52
+
53
+ /** Collapse key of a turn (true = folded to its header). */
54
+ export function turnKeyOf(headIdx: number): string {
55
+ return `turn_${headIdx}`;
56
+ }
57
+ /** Collapse key of a bead's body (true or unset = closed). */
58
+ export function beadKeyOf(beadId: string): string {
59
+ return `bead_${beadId}`;
60
+ }
61
+ /** Collapse key of a bead body's "… N more" (false or unset = capped). */
62
+ export function beadMoreKeyOf(beadId: string): string {
63
+ return `beadmore_${beadId}`;
64
+ }
65
+ /** Collapse key of an agent lane (true = folded to one ◉ bead). */
66
+ export function laneKeyOf(scope: string, agentId: string): string {
67
+ return `lane_${scope}${agentId}`;
68
+ }
69
+
70
+ export interface BeadModel {
71
+ /** Stable id: `<scope>c:<messageIndex>:<callIndex>`. */
72
+ readonly id: string;
73
+ readonly lane: LaneId;
74
+ readonly call: ToolCall;
75
+ /** Absolute index (in its own transcript) of the message that made the call. */
76
+ readonly messageIndex: number;
77
+ /** Absolute index of the result message, when settled (root transcript only). */
78
+ readonly resultIndex: number | undefined;
79
+ readonly scope: string;
80
+ readonly result: string | undefined;
81
+ readonly status: BeadStatus;
82
+ readonly name: string;
83
+ readonly arg: string;
84
+ readonly summary: BeadSummary | null;
85
+ readonly time: string | undefined;
86
+ readonly body: BeadBody | null;
87
+ readonly open: boolean;
88
+ readonly expanded: boolean;
89
+ /** WRFC fix rows: the finding this call answers. */
90
+ readonly answers?: string | undefined;
91
+ }
92
+
93
+ export interface LaneModel {
94
+ readonly id: LaneId;
95
+ readonly key: string;
96
+ readonly parent: LaneId;
97
+ readonly name: string;
98
+ readonly arg: string;
99
+ /** ok ✓, warn ! (finished carrying a failure), err ✕ (the agent failed), run (still working). */
100
+ readonly outcome: 'ok' | 'warn' | 'err' | 'run';
101
+ readonly mergeText: string;
102
+ /** Folded row summary: "5 tools · $0.12". */
103
+ readonly foldSummary: string;
104
+ readonly time: string | undefined;
105
+ readonly folded: boolean;
106
+ /** Recursion stopped here (depth ceiling or a cycle); the lane is drawn folded and says why. */
107
+ readonly truncated?: 'depth' | 'cycle' | undefined;
108
+ }
109
+
110
+ export type TurnRow =
111
+ | { readonly kind: 'head' }
112
+ | { readonly kind: 'prose'; readonly messageIndex: number; readonly content: string; readonly role: 'assistant' | 'system' | 'thinking' | 'summary' }
113
+ | { readonly kind: 'bead'; readonly bead: BeadModel }
114
+ | { readonly kind: 'spawn'; readonly lane: LaneModel; readonly bead: BeadModel }
115
+ | { readonly kind: 'merge'; readonly lane: LaneModel }
116
+ | { readonly kind: 'folded'; readonly lane: LaneModel; readonly bead: BeadModel }
117
+ | { readonly kind: 'answer'; readonly messageIndex: number; readonly content: string };
118
+
119
+ export interface TurnModel {
120
+ readonly turnKey: string;
121
+ readonly headIndex: number;
122
+ /** Every message index (root transcript) the turn covers. */
123
+ readonly memberIndexes: readonly number[];
124
+ readonly folded: boolean;
125
+ /** "claude-opus-5-5 · 4 tools · 1 agent · 1.9s" */
126
+ readonly headerText: string;
127
+ readonly rows: readonly TurnRow[];
128
+ /** Something on the turn is live (a running bead or lane): it repaints as time passes. */
129
+ readonly live: boolean;
130
+ readonly toolCount: number;
131
+ readonly agentCount: number;
132
+ /** Collapse keys of every bead in the turn (search and /expand reach them). */
133
+ readonly beadKeys: readonly string[];
134
+ /**
135
+ * System notices that arrived while the turn ran ([WRFC] …, [Agents] …,
136
+ * compaction receipts), in order. A notice is not a step of the turn: it is
137
+ * drawn after the turn, outside its lanes (conversation-rendering.ts), the
138
+ * way a notice between turns is.
139
+ */
140
+ readonly notices: ReadonlyArray<{ readonly messageIndex: number; readonly content: string }>;
141
+ }
142
+
143
+ /** A contiguous stretch of the transcript: one user message, one turn, or one standalone system message. */
144
+ export type TranscriptUnit =
145
+ | { readonly kind: 'message'; readonly index: number }
146
+ | { readonly kind: 'turn'; readonly start: number; readonly end: number; readonly headIndex: number };
147
+
148
+ /**
149
+ * Split a transcript slice into units. A turn runs from the first assistant
150
+ * message after a user message to the last assistant or tool message before
151
+ * the next one; system messages inside that stretch belong to the turn, and
152
+ * system messages around it stand alone.
153
+ */
154
+ export function transcriptUnits(messages: readonly Message[], offset = 0): TranscriptUnit[] {
155
+ const units: TranscriptUnit[] = [];
156
+ let i = 0;
157
+ while (i < messages.length) {
158
+ const role = messages[i]!.role;
159
+ if (role !== 'assistant' && role !== 'tool') {
160
+ units.push({ kind: 'message', index: offset + i });
161
+ i++;
162
+ continue;
163
+ }
164
+ let end = i;
165
+ let j = i;
166
+ while (j < messages.length && messages[j]!.role !== 'user') {
167
+ if (messages[j]!.role !== 'system') end = j;
168
+ j++;
169
+ }
170
+ if (messages[i]!.role === 'tool' && !messages.slice(i, end + 1).some((m) => m.role === 'assistant')) {
171
+ // Orphan results with no assistant message in the slice (a display cleared mid-turn).
172
+ for (let k = i; k <= end; k++) units.push({ kind: 'message', index: offset + k });
173
+ } else {
174
+ const head = messages.slice(i, end + 1).findIndex((m) => m.role === 'assistant');
175
+ units.push({ kind: 'turn', start: offset + i, end: offset + end, headIndex: offset + i + Math.max(0, head) });
176
+ }
177
+ for (let k = end + 1; k < j; k++) units.push({ kind: 'message', index: offset + k });
178
+ i = j;
179
+ }
180
+ return units;
181
+ }
182
+
183
+ function outcomeOf(content: string): CallOutcome {
184
+ if (/^Error: cancelled by user\b/.test(content)) return 'cancelled';
185
+ if (/^Error: /.test(content)) return 'error';
186
+ return 'ok';
187
+ }
188
+
189
+ function hasProse(message: AssistantMessage): boolean {
190
+ return typeof message.content === 'string' && message.content.trim().length > 0;
191
+ }
192
+
193
+ function firstLine(text: string, max = 120): string {
194
+ const line = cellText(text.split('\n').find((l) => l.trim().length > 0) ?? '').trim();
195
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
196
+ }
197
+
198
+ function plural(n: number, word: string): string {
199
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
200
+ }
201
+
202
+ /** The agent ids a call started: one for `agent` spawn, several for batch-spawn. */
203
+ function spawnedAgentIds(call: ToolCall, result: string | undefined): string[] {
204
+ if (toolFamily(call.name) !== 'agent') return [];
205
+ const mode = call.arguments.mode;
206
+ if (mode !== 'spawn' && mode !== 'batch-spawn' && !(mode === undefined && typeof call.arguments.task === 'string')) return [];
207
+ if (!result || !result.includes('agentId')) return [];
208
+ try {
209
+ const parsed: unknown = JSON.parse(result);
210
+ const ids: string[] = [];
211
+ const visit = (value: unknown, depth: number): void => {
212
+ if (depth > 3 || value === null || typeof value !== 'object') return;
213
+ if (Array.isArray(value)) { for (const v of value) visit(v, depth + 1); return; }
214
+ const rec = value as Record<string, unknown>;
215
+ if (typeof rec.agentId === 'string' && rec.agentId.length > 0) ids.push(rec.agentId);
216
+ for (const [key, child] of Object.entries(rec)) if (key !== 'agentId') visit(child, depth + 1);
217
+ };
218
+ visit(parsed, 0);
219
+ return [...new Set(ids)];
220
+ } catch {
221
+ return [];
222
+ }
223
+ }
224
+
225
+ /** Rows of one lane plus their start times, before interleaving. */
226
+ interface TimedRow {
227
+ readonly row: TurnRow;
228
+ /** Epoch ms this row happened, when known. */
229
+ readonly t: number | undefined;
230
+ }
231
+
232
+ interface BuildContext {
233
+ readonly sources: WorkTreeSources;
234
+ readonly collapse: ReadonlyMap<string, boolean>;
235
+ readonly waiting: ReadonlySet<string>;
236
+ readonly now: number;
237
+ /** Counters for the header. */
238
+ lanes: number;
239
+ live: boolean;
240
+ beadKeys: string[];
241
+ }
242
+
243
+ function callTime(ctx: BuildContext, call: ToolCall, status: BeadStatus): { t: number | undefined; text: string | undefined } {
244
+ const timing = call.id !== undefined ? ctx.sources.callTiming?.(call.id) : undefined;
245
+ if (!timing) return { t: undefined, text: undefined };
246
+ if (status === 'run' && timing.startedAt !== undefined) return { t: timing.startedAt, text: formatBeadTime(ctx.now - timing.startedAt) };
247
+ if (status === 'wait' && timing.startedAt !== undefined) return { t: timing.startedAt, text: formatBeadTime(ctx.now - timing.startedAt) };
248
+ return { t: timing.startedAt, text: timing.durationMs !== undefined ? formatBeadTime(timing.durationMs) : undefined };
249
+ }
250
+
251
+ function makeBead(
252
+ ctx: BuildContext,
253
+ lane: LaneId,
254
+ scope: string,
255
+ call: ToolCall,
256
+ messageIndex: number,
257
+ callIndex: number,
258
+ result: { content: string; index: number } | undefined,
259
+ ownerActive: boolean,
260
+ extra: { answers?: string } = {},
261
+ ): { bead: BeadModel; t: number | undefined } {
262
+ const id = `${scope}c:${messageIndex}:${callIndex}`;
263
+ const content = result?.content;
264
+ const outcome = content !== undefined ? outcomeOf(content) : undefined;
265
+ const status = beadStatus({
266
+ outcome,
267
+ content,
268
+ waiting: call.id !== undefined && ctx.waiting.has(call.id),
269
+ ownerActive,
270
+ background: isBackgroundCall(call),
271
+ attention: content !== undefined && outcome === 'ok' && needsAttention(call, content),
272
+ });
273
+ if (status === 'run' || status === 'wait') ctx.live = true;
274
+ const body = beadBody(call, status, content);
275
+ const key = beadKeyOf(id);
276
+ ctx.beadKeys.push(key);
277
+ const time = callTime(ctx, call, status);
278
+ return {
279
+ t: time.t,
280
+ bead: {
281
+ id,
282
+ lane,
283
+ call,
284
+ messageIndex,
285
+ resultIndex: scope === '' ? result?.index : undefined,
286
+ scope,
287
+ result: content,
288
+ status,
289
+ name: beadName(call),
290
+ arg: beadArgument(call),
291
+ summary: beadSummary(call, status, content),
292
+ time: time.text,
293
+ body,
294
+ open: body !== null && ctx.collapse.get(key) === false,
295
+ expanded: ctx.collapse.get(beadMoreKeyOf(id)) === true,
296
+ answers: extra.answers,
297
+ },
298
+ };
299
+ }
300
+
301
+ /** Interleave a child lane's rows with the parent rows that follow its spawn. */
302
+ function interleave(parent: readonly TimedRow[], child: readonly TimedRow[]): TimedRow[] {
303
+ const out: TimedRow[] = [];
304
+ let p = 0;
305
+ let c = 0;
306
+ while (p < parent.length && c < child.length) {
307
+ const pt = parent[p]!.t;
308
+ const ct = child[c]!.t;
309
+ // Without both times, a child's rows come first: sequential nesting.
310
+ if (pt !== undefined && ct !== undefined && pt < ct) out.push(parent[p++]!);
311
+ else out.push(child[c++]!);
312
+ }
313
+ while (c < child.length) out.push(child[c++]!);
314
+ while (p < parent.length) out.push(parent[p++]!);
315
+ return out;
316
+ }
317
+
318
+ /** A pending row list item: either a finished row, or a spawn whose child rows interleave with what follows. */
319
+ type Pending = TimedRow | { readonly spawn: TimedRow; readonly child: readonly TimedRow[] };
320
+
321
+ function resolvePending(items: readonly Pending[]): TimedRow[] {
322
+ for (let i = 0; i < items.length; i++) {
323
+ const item = items[i]!;
324
+ if ('spawn' in item) {
325
+ const rest = resolvePending(items.slice(i + 1));
326
+ const before = items.slice(0, i) as TimedRow[];
327
+ return [...before, item.spawn, ...interleave(rest, item.child)];
328
+ }
329
+ }
330
+ return items as TimedRow[];
331
+ }
332
+
333
+ /** Fill in missing times so each row keeps its place relative to the timed rows before it. */
334
+ function inheritTimes(rows: readonly TimedRow[], start: number | undefined): TimedRow[] {
335
+ let last = start;
336
+ return rows.map((r) => {
337
+ if (r.t !== undefined) { last = r.t; return r; }
338
+ return { row: r.row, t: last };
339
+ });
340
+ }
341
+
342
+ function laneOutcome(info: AgentLaneInfo, rows: readonly TimedRow[]): LaneModel['outcome'] {
343
+ if (info.status === 'running' || info.status === 'pending') return 'run';
344
+ if (info.status === 'failed') return 'err';
345
+ if (info.status === 'cancelled') return 'warn';
346
+ if (info.wrfcPassed === false) return 'warn';
347
+ // A lane carries a failure when one of ITS OWN calls failed; a failure deeper
348
+ // down shows on the merge row of the lane it happened in.
349
+ const carriesFailure = rows.some((r) => r.row.kind === 'bead' && r.row.bead.lane === info.id && r.row.bead.status === 'err');
350
+ return carriesFailure ? 'warn' : 'ok';
351
+ }
352
+
353
+ function laneTexts(info: AgentLaneInfo, name: string, rows: readonly TimedRow[], outcome: LaneModel['outcome'], ctx: BuildContext): { merge: string; fold: string; time: string | undefined } {
354
+ const tools = info.wrfcPhases ? plural(info.wrfcPhases.length, 'phase') : plural(info.toolCallCount, 'tool');
355
+ const end = info.completedAt ?? ctx.now;
356
+ const elapsed = info.startedAt !== undefined ? formatBeadTime(end - info.startedAt) : undefined;
357
+ const cost = info.costUsd !== undefined ? formatCost(info.costUsd) : undefined;
358
+ const failedTools = rows.filter((r) => r.row.kind === 'bead' && r.row.bead.lane === info.id && r.row.bead.status === 'err').length;
359
+ const nested = rows.filter((r) => r.row.kind === 'spawn' || r.row.kind === 'folded').length;
360
+ let lead: string;
361
+ if (info.wrfcPhases) {
362
+ lead = outcome === 'run' ? `${name} running` : info.wrfcPassed === false || outcome === 'err' ? `${name} failed` : `${name} passed`;
363
+ } else if (info.status === 'failed') {
364
+ lead = `${name} failed${info.error ? `: ${firstLine(info.error, 60)}` : ''}`;
365
+ } else if (info.status === 'cancelled') {
366
+ lead = `${name} cancelled`;
367
+ } else if (failedTools > 0) {
368
+ lead = `${name} finished with ${plural(failedTools, 'failed tool')}`;
369
+ } else {
370
+ lead = `${name} finished`;
371
+ }
372
+ const merge = [lead, tools, nested > 0 ? `${plural(nested, 'agent')} inside` : undefined, elapsed, cost].filter(Boolean).join(' · ');
373
+ const fold = [tools, cost].filter(Boolean).join(' · ');
374
+ return { merge, fold, time: elapsed };
375
+ }
376
+
377
+ /**
378
+ * The rows of an agent's own lane: its tool calls as beads, its own spawns as
379
+ * nested lanes. `ancestors` guards cycles; `depth` guards runaway nesting.
380
+ */
381
+ function agentLaneRows(ctx: BuildContext, info: AgentLaneInfo, scope: string, depth: number, ancestors: readonly string[]): TimedRow[] {
382
+ const lane = info.id;
383
+ const active = info.status === 'running' || info.status === 'pending';
384
+ if (info.wrfcPhases) return wrfcLaneRows(ctx, info, scope, active);
385
+ const results = new Map<string, { content: string; index: number }>();
386
+ info.messages.forEach((m, i) => { if (m.role === 'tool' && m.callId) results.set(m.callId, { content: m.content, index: i }); });
387
+ const items: Pending[] = [];
388
+ info.messages.forEach((m, messageIndex) => {
389
+ if (m.role !== 'assistant') return;
390
+ (m.toolCalls ?? []).forEach((call, k) => {
391
+ const result = call.id !== undefined ? results.get(call.id) : undefined;
392
+ items.push(...callRows(ctx, lane, scope, call, messageIndex, k, result, active, depth, ancestors));
393
+ });
394
+ });
395
+ return inheritTimes(resolvePending(items), info.startedAt);
396
+ }
397
+
398
+ /** A WRFC chain's lane: write, review, fix and confirm as beads, a fix pointing at the finding it answers. */
399
+ function wrfcLaneRows(ctx: BuildContext, info: AgentLaneInfo, scope: string, active: boolean): TimedRow[] {
400
+ const rows: TimedRow[] = [];
401
+ const phases = info.wrfcPhases ?? [];
402
+ let lastFindings: readonly string[] | undefined;
403
+ let fixed = false;
404
+ phases.forEach((phase, k) => {
405
+ const label = phaseLabel(phase, fixed);
406
+ if (phase.role === 'fixer') fixed = true;
407
+ const running = phase.status === 'running' || phase.status === 'pending';
408
+ const status: BeadStatus = running ? (active ? 'run' : 'cancel')
409
+ : phase.status === 'failed' ? 'err'
410
+ : phase.status === 'cancelled' ? 'cancel'
411
+ : phase.role === 'reviewer' && (phase.passed === false || (phase.findings?.length ?? 0) > 0) ? 'warn' : 'ok';
412
+ if (status === 'run') ctx.live = true;
413
+ const id = `${scope}w:${info.id}:${k}`;
414
+ const key = beadKeyOf(id);
415
+ ctx.beadKeys.push(key);
416
+ const findings = phase.findings ?? [];
417
+ const summary: BeadSummary | null = status === 'run' ? null
418
+ : phase.role === 'reviewer'
419
+ ? (findings.length > 0 ? { text: plural(findings.length, 'finding'), tone: 'warn' } : phase.passed === false ? { text: 'changes requested', tone: 'warn' } : { text: 'pass', tone: 'good' })
420
+ : status === 'err' ? { text: 'failed', tone: 'bad' } : null;
421
+ const answers = phase.role === 'fixer' ? (lastFindings?.[0] ?? undefined) : undefined;
422
+ if (phase.role === 'reviewer') lastFindings = findings.length > 0 ? findings : lastFindings;
423
+ const bodyLines = [
424
+ ...findings.map((f) => `• ${f}`),
425
+ ...(phase.output ? phase.output.split('\n') : []),
426
+ ].filter((l) => l.trim().length > 0);
427
+ const end = phase.completedAt ?? ctx.now;
428
+ // A phase is an agent, not a tool call: the bead's call carries its phase id and label only.
429
+ const call: ToolCall = { id, name: label, arguments: {} };
430
+ rows.push({
431
+ t: phase.startedAt,
432
+ row: {
433
+ kind: 'bead',
434
+ bead: {
435
+ id,
436
+ lane: info.id,
437
+ call,
438
+ messageIndex: -1,
439
+ resultIndex: undefined,
440
+ scope,
441
+ result: phase.output,
442
+ status,
443
+ name: label,
444
+ arg: firstLine(phase.role === 'reviewer' && findings.length > 0 ? findings[0]! : phase.task, 90),
445
+ summary,
446
+ time: phase.startedAt !== undefined ? formatBeadTime(end - phase.startedAt) : undefined,
447
+ body: bodyLines.length > 0 ? { kind: 'text', lines: bodyLines } : null,
448
+ open: bodyLines.length > 0 && ctx.collapse.get(key) === false,
449
+ expanded: ctx.collapse.get(beadMoreKeyOf(id)) === true,
450
+ answers,
451
+ },
452
+ },
453
+ });
454
+ });
455
+ return inheritTimes(rows, info.startedAt);
456
+ }
457
+
458
+ function phaseLabel(phase: WrfcPhaseInfo, afterFix: boolean): string {
459
+ switch (phase.role) {
460
+ case 'engineer': return 'write';
461
+ case 'reviewer': return afterFix ? 'confirm' : 'review';
462
+ case 'fixer': return 'fix';
463
+ case 'integrator': return 'integrate';
464
+ case 'verifier': return 'verify';
465
+ default: return phase.role;
466
+ }
467
+ }
468
+
469
+ /** The row(s) one call contributes: a bead, or a spawn with its child lane(s). */
470
+ function callRows(
471
+ ctx: BuildContext,
472
+ lane: LaneId,
473
+ scope: string,
474
+ call: ToolCall,
475
+ messageIndex: number,
476
+ callIndex: number,
477
+ result: { content: string; index: number } | undefined,
478
+ ownerActive: boolean,
479
+ depth: number,
480
+ ancestors: readonly string[],
481
+ ): Pending[] {
482
+ const { bead, t } = makeBead(ctx, lane, scope, call, messageIndex, callIndex, result, ownerActive);
483
+ const agentIds = ctx.sources.agent ? spawnedAgentIds(call, result?.content) : [];
484
+ const out: Pending[] = [];
485
+ const infos = agentIds.map((id) => ({ id, info: ctx.sources.agent!(id) })).filter((a): a is { id: string; info: AgentLaneInfo } => a.info !== null);
486
+ if (infos.length === 0) return [{ row: { kind: 'bead', bead }, t }];
487
+ for (const { id, info } of infos) {
488
+ ctx.lanes++;
489
+ const key = laneKeyOf(scope, id);
490
+ const truncated: LaneModel['truncated'] = ancestors.includes(id) ? 'cycle' : depth + 1 > MAX_NEST_DEPTH ? 'depth' : undefined;
491
+ const childScope = `${scope}a:${id}/`;
492
+ const childRows = truncated ? [] : agentLaneRows(ctx, info, childScope, depth + 1, [...ancestors, id]);
493
+ const outcome = laneOutcome(info, childRows);
494
+ if (outcome === 'run') ctx.live = true;
495
+ // A hosted agent's lane is named for its harness ("Claude Code"), its task marked hosted.
496
+ const name = cellText(info.wrfcPhases ? 'WRFC chain' : info.hostedLabel ?? info.name);
497
+ const texts = laneTexts(info, name, childRows, outcome, ctx);
498
+ const arg = `${info.hostedLabel ? 'hosted · ' : ''}${firstLine(info.task, 100)}`;
499
+ const laneModel: LaneModel = {
500
+ id,
501
+ key,
502
+ parent: lane,
503
+ name,
504
+ arg: truncated === 'depth' ? `${arg} · nested deeper than ${MAX_NEST_DEPTH} lanes` : truncated === 'cycle' ? `${arg} · repeats an agent shown above` : arg,
505
+ outcome,
506
+ mergeText: texts.merge,
507
+ foldSummary: texts.fold,
508
+ time: texts.time,
509
+ folded: truncated !== undefined || ctx.collapse.get(key) === true,
510
+ truncated,
511
+ };
512
+ const spawnT = t ?? info.startedAt;
513
+ if (laneModel.folded) {
514
+ out.push({ row: { kind: 'folded', lane: laneModel, bead }, t: spawnT });
515
+ continue;
516
+ }
517
+ const child: TimedRow[] = childRows.length > 0 ? inheritTimes(childRows, spawnT) : [];
518
+ if (outcome !== 'run') child.push({ row: { kind: 'merge', lane: laneModel }, t: info.completedAt ?? child[child.length - 1]?.t ?? spawnT });
519
+ out.push({ spawn: { row: { kind: 'spawn', lane: laneModel, bead }, t: spawnT }, child });
520
+ }
521
+ return out;
522
+ }
523
+
524
+ export interface BuildTurnInput {
525
+ readonly messages: readonly Message[];
526
+ /** Absolute index of messages[0]. */
527
+ readonly offset: number;
528
+ readonly unit: Extract<TranscriptUnit, { kind: 'turn' }>;
529
+ readonly sources: WorkTreeSources;
530
+ readonly collapse: ReadonlyMap<string, boolean>;
531
+ /** The streaming placeholder's absolute index (its prose is drawn by the streaming path), or -1. */
532
+ readonly streamingIndex: number;
533
+ /** display.showThinking: reasoning text rows (collapsed to one row by default). */
534
+ readonly showThinking?: boolean;
535
+ /** display.showReasoningSummary: reasoning summary rows. */
536
+ readonly showReasoningSummary?: boolean;
537
+ }
538
+
539
+ export function buildTurnModel(input: BuildTurnInput): TurnModel {
540
+ const { messages, offset, unit, sources } = input;
541
+ const at = (abs: number): Message => messages[abs - offset]!;
542
+ const now = sources.now?.() ?? Date.now();
543
+ const ctx: BuildContext = {
544
+ sources,
545
+ collapse: input.collapse,
546
+ waiting: sources.waitingCallIds?.() ?? new Set<string>(),
547
+ now,
548
+ lanes: 0,
549
+ live: false,
550
+ beadKeys: [],
551
+ };
552
+ // Whether the main conversation says it is working. With no live source (a
553
+ // plain transcript render) that is unknown: the header claims nothing, and
554
+ // an unsettled call in the latest turn still reads as running (an earlier
555
+ // turn has ended: a user message followed it).
556
+ const turnActiveNow = sources.turnActive?.() ?? false;
557
+ const callsMayRun = sources.turnActive?.() ?? true;
558
+
559
+ // Is this the transcript's latest turn? Only that one can still be running.
560
+ let isLatest = true;
561
+ for (let abs = unit.end + 1; abs < offset + messages.length; abs++) {
562
+ if (at(abs).role === 'user') { isLatest = false; break; }
563
+ }
564
+ const active = turnActiveNow && isLatest;
565
+ const callsLive = callsMayRun && isLatest;
566
+
567
+ const results = new Map<string, { content: string; index: number }>();
568
+ const members: number[] = [];
569
+ for (let abs = unit.start; abs <= unit.end; abs++) {
570
+ members.push(abs);
571
+ const m = at(abs);
572
+ if (m.role === 'tool' && m.callId) results.set(m.callId, { content: m.content, index: abs });
573
+ }
574
+
575
+ // The answer: the last assistant message, when it has prose and no calls.
576
+ let answerIndex = -1;
577
+ for (let abs = unit.end; abs >= unit.start; abs--) {
578
+ const m = at(abs);
579
+ if (m.role !== 'assistant') continue;
580
+ if (hasProse(m) && (m.toolCalls?.length ?? 0) === 0 && abs !== input.streamingIndex) answerIndex = abs;
581
+ break;
582
+ }
583
+
584
+ const head = at(unit.headIndex) as AssistantMessage;
585
+ let model = head.model;
586
+ let toolCount = 0;
587
+ const items: Pending[] = [];
588
+ const notices: Array<{ messageIndex: number; content: string }> = [];
589
+ const calledIds = new Set<string>();
590
+ for (let abs = unit.start; abs <= unit.end; abs++) {
591
+ const m = at(abs);
592
+ if (m.role === 'assistant') for (const c of m.toolCalls ?? []) if (c.id !== undefined) calledIds.add(c.id);
593
+ }
594
+ for (let abs = unit.start; abs <= unit.end; abs++) {
595
+ const m = at(abs);
596
+ if (m.role === 'tool' && !calledIds.has(m.callId)) {
597
+ // A result whose call is not in this turn (a cleared display, a replayed
598
+ // journal): its own bead, where it sits.
599
+ toolCount++;
600
+ const orphan = orphanResultBead(m, abs, ctx.collapse);
601
+ ctx.beadKeys.push(beadKeyOf(orphan.id));
602
+ items.push({ row: { kind: 'bead', bead: orphan }, t: undefined });
603
+ continue;
604
+ }
605
+ if (m.role === 'system') {
606
+ notices.push({ messageIndex: abs, content: m.content });
607
+ continue;
608
+ }
609
+ if (m.role !== 'assistant') continue;
610
+ if (m.model && model && m.model !== model) {
611
+ items.push({ row: { kind: 'prose', messageIndex: abs, content: `switched to ${m.model}`, role: 'system' }, t: undefined });
612
+ model = m.model;
613
+ }
614
+ if (input.showThinking && m.reasoningContent) {
615
+ items.push({ row: { kind: 'prose', messageIndex: abs, content: m.reasoningContent, role: 'thinking' }, t: undefined });
616
+ }
617
+ if (input.showReasoningSummary && m.reasoningSummary) {
618
+ items.push({ row: { kind: 'prose', messageIndex: abs, content: m.reasoningSummary, role: 'summary' }, t: undefined });
619
+ }
620
+ if (abs !== answerIndex && hasProse(m) && abs !== input.streamingIndex) {
621
+ items.push({ row: { kind: 'prose', messageIndex: abs, content: m.content, role: 'assistant' }, t: undefined });
622
+ }
623
+ (m.toolCalls ?? []).forEach((call, k) => {
624
+ toolCount++;
625
+ const result = call.id !== undefined ? results.get(call.id) : undefined;
626
+ items.push(...callRows(ctx, SPINE, '', call, abs, k, result, callsLive, 0, []));
627
+ });
628
+ }
629
+
630
+ let rows = inheritTimes(resolvePending(items), undefined);
631
+ if (answerIndex >= 0) {
632
+ // With every lane finished the answer closes the turn. While a lane still
633
+ // runs, the answer sits after the spine's own last row and the running
634
+ // lane keeps drawing under it.
635
+ const running = rows.some((r) => (r.row.kind === 'spawn' && r.row.lane.outcome === 'run'));
636
+ let answerAt = rows.length;
637
+ if (running) {
638
+ let lastSpine = -1;
639
+ rows.forEach((r, k) => {
640
+ const lane = r.row.kind === 'bead' ? r.row.bead.lane : r.row.kind === 'spawn' || r.row.kind === 'merge' || r.row.kind === 'folded' ? r.row.lane.parent : SPINE;
641
+ if (lane === SPINE) lastSpine = k;
642
+ });
643
+ answerAt = lastSpine + 1;
644
+ // Merges into the spine must stay above the answer.
645
+ while (answerAt < rows.length && rows[answerAt]!.row.kind === 'merge' && (rows[answerAt]!.row as { lane: LaneModel }).lane.parent === SPINE) answerAt++;
646
+ }
647
+ rows = [...rows.slice(0, answerAt), { row: { kind: 'answer', messageIndex: answerIndex, content: (at(answerIndex) as AssistantMessage).content }, t: undefined }, ...rows.slice(answerAt)];
648
+ }
649
+
650
+ // Header text.
651
+ const turnKey = turnKeyOf(unit.headIndex);
652
+ let userIndex = -1;
653
+ for (let abs = unit.start - 1; abs >= offset; abs--) if (at(abs).role === 'user') { userIndex = abs; break; }
654
+ const userMessage = userIndex >= 0 ? at(userIndex) : undefined;
655
+ const timing = turnTimingFor(sources, userIndex, userMessage);
656
+ const outcome = active ? undefined : turnOutcomeFor(sources, userIndex, userMessage, timing);
657
+ const parts: string[] = [];
658
+ if (model) parts.push(model);
659
+ if (active) parts.push('working');
660
+ else if (outcome === 'failed' || outcome === 'cancelled') parts.push(outcome);
661
+ if (toolCount > 0) parts.push(plural(toolCount, 'tool'));
662
+ if (ctx.lanes > 0) parts.push(plural(ctx.lanes, 'agent'));
663
+ if (head.reasoningContent || head.reasoningSummary) parts.push('reasoning');
664
+ // A finished turn whose end was never recorded states no time rather than a wrong one.
665
+ if (timing && (timing.endedAt !== undefined || active)) parts.push(formatBeadTime((timing.endedAt ?? now) - timing.startedAt));
666
+
667
+ return {
668
+ turnKey,
669
+ headIndex: unit.headIndex,
670
+ memberIndexes: members,
671
+ folded: input.collapse.get(turnKey) === true && !active,
672
+ headerText: parts.join(' · '),
673
+ rows: [{ kind: 'head' }, ...rows.map((r) => r.row)],
674
+ live: ctx.live || active,
675
+ toolCount,
676
+ agentCount: ctx.lanes,
677
+ beadKeys: ctx.beadKeys,
678
+ notices,
679
+ };
680
+ }
681
+
682
+ /**
683
+ * A WRFC chain owner opened full screen. The owner never takes a turn of its
684
+ * own (it supervises), so its view is built from its chain: each phase agent
685
+ * (engineer, reviewer, fixer, …) branches off the owner's spine as a lane
686
+ * drawn from that agent's own transcript, the way main draws any spawned
687
+ * agent. The header names the chain and counts phases, not tools.
688
+ */
689
+ export function buildWrfcOwnerModel(input: {
690
+ readonly info: AgentLaneInfo;
691
+ readonly sources: WorkTreeSources;
692
+ readonly collapse: ReadonlyMap<string, boolean>;
693
+ }): TurnModel {
694
+ const { info, sources } = input;
695
+ const now = sources.now?.() ?? Date.now();
696
+ const ctx: BuildContext = { sources, collapse: input.collapse, waiting: sources.waitingCallIds?.() ?? new Set<string>(), now, lanes: 0, live: false, beadKeys: [] };
697
+ const active = info.status === 'running' || info.status === 'pending';
698
+ const scope = `wrfc:${info.id}/`;
699
+ const items: Pending[] = [];
700
+ (info.wrfcPhases ?? []).forEach((phase, k) => {
701
+ // The phase is an agent the chain spawned: the same shape main's spawn
702
+ // call has, so its lane, beads and merge row come from the one path.
703
+ const call: ToolCall = { id: `${scope}${phase.agentId}`, name: 'agent', arguments: { mode: 'spawn', task: phase.task } };
704
+ items.push(...callRows(ctx, SPINE, scope, call, -1, k, { content: JSON.stringify({ agentId: phase.agentId }), index: -1 }, active, 0, [info.id]));
705
+ });
706
+ const rows = inheritTimes(resolvePending(items), info.startedAt);
707
+ const phases = info.wrfcPhases?.length ?? 0;
708
+ const parts = ['WRFC chain', plural(phases, 'phase')];
709
+ if (active) parts.push('working');
710
+ else if (info.wrfcPassed === true) parts.push('passed');
711
+ else if (info.wrfcPassed === false || info.status === 'failed') parts.push('failed');
712
+ if (info.startedAt !== undefined) parts.push(formatBeadTime((info.completedAt ?? now) - info.startedAt));
713
+ return {
714
+ turnKey: `${scope}turn`,
715
+ headIndex: -1,
716
+ memberIndexes: [],
717
+ folded: false,
718
+ headerText: parts.join(' · '),
719
+ rows: [{ kind: 'head' }, ...rows.map((r) => r.row)],
720
+ live: ctx.live || active,
721
+ toolCount: 0,
722
+ agentCount: ctx.lanes,
723
+ beadKeys: ctx.beadKeys,
724
+ notices: [],
725
+ };
726
+ }
727
+
728
+ /**
729
+ * The timing record of the turn answering the user message at `userIndex`,
730
+ * when it is that turn's: a record made for a different message (the
731
+ * transcript was compacted or replaced since) is ignored.
732
+ */
733
+ function turnTimingFor(sources: WorkTreeSources, userIndex: number, userMessage: Message | undefined): TurnTiming | undefined {
734
+ if (userIndex < 0) return undefined;
735
+ const timing = sources.turnTiming?.(userIndex);
736
+ if (!timing) return undefined;
737
+ return timing.fingerprint === undefined || timing.fingerprint === userMessageFingerprint(userMessage) ? timing : undefined;
738
+ }
739
+
740
+ /**
741
+ * How a finished turn ended: cancelled when its user message says so, else
742
+ * the live turn-end record, else the session's saved record for this message.
743
+ */
744
+ function turnOutcomeFor(sources: WorkTreeSources, userIndex: number, userMessage: Message | undefined, timing: TurnTiming | undefined): TurnOutcome | undefined {
745
+ if (userMessage?.role === 'user' && userMessage.cancelled) return 'cancelled';
746
+ if (timing?.outcome) return timing.outcome;
747
+ if (userIndex < 0) return undefined;
748
+ const saved = sources.turnOutcome?.(userIndex);
749
+ return saved && saved.fingerprint === userMessageFingerprint(userMessage) ? saved.outcome : undefined;
750
+ }
751
+
752
+ /**
753
+ * Everything a drawn turn depends on, flattened for an element-by-element
754
+ * comparison (the line cache reuses a turn's lines while this is unchanged).
755
+ * Strings compare by value; a bead's result only counts while its body is open.
756
+ */
757
+ export function turnSignature(model: TurnModel): Array<string | number | boolean | undefined> {
758
+ const parts: Array<string | number | boolean | undefined> = [model.turnKey, model.folded, model.headerText, model.live, model.rows.length];
759
+ for (const notice of model.notices) parts.push('notice', notice.messageIndex, notice.content);
760
+ const bead = (b: BeadModel): void => {
761
+ parts.push(b.id, b.lane, b.status, b.name, b.arg, b.summary?.text, b.summary?.tone, b.time, b.open, b.expanded, b.answers, b.body?.kind);
762
+ if (b.open) parts.push(b.result, b.body?.kind === 'diff' ? b.body.diff : undefined);
763
+ };
764
+ const lane = (l: LaneModel): void => {
765
+ parts.push(l.id, l.key, l.parent, l.name, l.arg, l.outcome, l.mergeText, l.foldSummary, l.time, l.folded, l.truncated);
766
+ };
767
+ for (const row of model.rows) {
768
+ parts.push(row.kind);
769
+ switch (row.kind) {
770
+ case 'prose': parts.push(row.messageIndex, row.role, row.content); break;
771
+ case 'answer': parts.push(row.messageIndex, row.content); break;
772
+ case 'bead': bead(row.bead); break;
773
+ case 'spawn':
774
+ case 'folded': lane(row.lane); bead(row.bead); break;
775
+ case 'merge': lane(row.lane); break;
776
+ default: break;
777
+ }
778
+ }
779
+ return parts;
780
+ }
781
+
782
+ /** Whether a turn draws an opened diff (its ◈ summary may still be computing). */
783
+ export function turnHasOpenDiff(model: TurnModel): boolean {
784
+ return model.rows.some((r) => r.kind === 'bead' && r.bead.open && r.bead.body?.kind === 'diff');
785
+ }
786
+
787
+ /**
788
+ * A result whose call is outside the rendered slice (the display was cleared
789
+ * mid-turn): one bead of its own, named from the result, with the same body,
790
+ * block and open state as any other bead.
791
+ */
792
+ export function orphanResultBead(message: ToolMessage, index: number, collapse: ReadonlyMap<string, boolean>): BeadModel {
793
+ const ctx: BuildContext = { sources: {}, collapse, waiting: new Set(), now: 0, lanes: 0, live: false, beadKeys: [] };
794
+ // A result stored without its call and without a tool name is named from
795
+ // its own shape when that is unambiguous, otherwise "result", never "tool".
796
+ const call: ToolCall = { id: message.callId, name: message.toolName ?? inferResultToolName(message.content), arguments: {} };
797
+ const { bead } = makeBead(ctx, SPINE, 'o', call, index, 0, { content: message.content, index }, false);
798
+ return bead;
799
+ }