@pellux/goodvibes-tui 2.0.21 → 2.1.1

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 (391) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/README.md +30 -29
  3. package/docs/README.md +8 -5
  4. package/docs/foundation-artifacts/operator-contract.json +1 -1
  5. package/package.json +28 -19
  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-reference.ts +2 -2
  52. package/src/input/command-registry.ts +77 -35
  53. package/src/input/commands/acp-runtime.ts +19 -9
  54. package/src/input/commands/channel-runtime.ts +2 -2
  55. package/src/input/commands/ci-runtime.ts +1 -1
  56. package/src/input/commands/codebase-runtime.ts +1 -1
  57. package/src/input/commands/config.ts +7 -1
  58. package/src/input/commands/context-window.ts +25 -19
  59. package/src/input/commands/control-room-runtime.ts +18 -18
  60. package/src/input/commands/conversation-runtime.ts +0 -1
  61. package/src/input/commands/cost-runtime.ts +29 -20
  62. package/src/input/commands/diff-runtime.ts +35 -181
  63. package/src/input/commands/eval.ts +1 -1
  64. package/src/input/commands/experience-runtime.ts +3 -7
  65. package/src/input/commands/fresh-conversation.ts +86 -0
  66. package/src/input/commands/git-runtime.ts +5 -22
  67. package/src/input/commands/graph-runtime.ts +1 -1
  68. package/src/input/commands/guidance-runtime.ts +1 -1
  69. package/src/input/commands/health-runtime.ts +8 -9
  70. package/src/input/commands/hooks-runtime.ts +2 -2
  71. package/src/input/commands/hosted-runtime.ts +3 -3
  72. package/src/input/commands/incident-runtime.ts +3 -3
  73. package/src/input/commands/intelligence-runtime.ts +6 -9
  74. package/src/input/commands/knowledge.ts +2 -2
  75. package/src/input/commands/legacy-panel-command.ts +57 -0
  76. package/src/input/commands/local-auth-runtime.ts +8 -9
  77. package/src/input/commands/local-provider-runtime.ts +7 -4
  78. package/src/input/commands/local-runtime.ts +12 -15
  79. package/src/input/commands/local-setup-review.ts +1 -1
  80. package/src/input/commands/local-setup.ts +13 -19
  81. package/src/input/commands/memory.ts +1 -1
  82. package/src/input/commands/notify-runtime.ts +9 -0
  83. package/src/input/commands/operator-runtime.ts +10 -12
  84. package/src/input/commands/permissions-provenance.ts +3 -3
  85. package/src/input/commands/planning-runtime.ts +7 -7
  86. package/src/input/commands/platform-sandbox-runtime.ts +2 -2
  87. package/src/input/commands/platform-services-runtime.ts +1 -1
  88. package/src/input/commands/policy-dispatch.ts +2 -2
  89. package/src/input/commands/policy.ts +3 -3
  90. package/src/input/commands/provider.ts +1 -1
  91. package/src/input/commands/remote-runtime.ts +1 -1
  92. package/src/input/commands/review-runtime.ts +39 -94
  93. package/src/input/commands/rewind-runtime.ts +81 -108
  94. package/src/input/commands/runtime-services.ts +15 -17
  95. package/src/input/commands/services-runtime.ts +2 -2
  96. package/src/input/commands/session-content.ts +21 -5
  97. package/src/input/commands/session-workflow.ts +13 -38
  98. package/src/input/commands/settings-sync-runtime.ts +2 -2
  99. package/src/input/commands/shell-core.ts +19 -4
  100. package/src/input/commands/skills-runtime.ts +2 -2
  101. package/src/input/commands/status-runtime.ts +13 -0
  102. package/src/input/commands/subscription-runtime.ts +1 -1
  103. package/src/input/commands/tasks-runtime.ts +2 -8
  104. package/src/input/commands/work-plan-runtime.ts +4 -4
  105. package/src/input/commands/workstream-attempts.ts +24 -47
  106. package/src/input/commands/workstream-runtime.ts +2 -2
  107. package/src/input/commands/worktree-runtime.ts +3 -3
  108. package/src/input/commands.ts +3 -1
  109. package/src/input/composer-editor.ts +44 -0
  110. package/src/input/concealed-input.ts +1 -1
  111. package/src/input/config-modal-types.ts +20 -8
  112. package/src/input/config-modal.ts +106 -105
  113. package/src/input/confirm-dialog-types.ts +28 -0
  114. package/src/input/confirm-dialog.ts +70 -0
  115. package/src/input/delete-key-policy.ts +3 -3
  116. package/src/input/feed-context-factory.ts +15 -16
  117. package/src/input/handler-command-route.ts +7 -42
  118. package/src/input/handler-content-actions.ts +16 -15
  119. package/src/input/handler-feed-routes.ts +26 -211
  120. package/src/input/handler-feed.ts +68 -95
  121. package/src/input/handler-interactions.ts +5 -3
  122. package/src/input/handler-modal-routes.ts +215 -216
  123. package/src/input/handler-modal-stack.ts +81 -11
  124. package/src/input/handler-modal-token-routes.ts +4 -0
  125. package/src/input/handler-onboarding.ts +0 -1
  126. package/src/input/handler-picker-routes.ts +65 -107
  127. package/src/input/handler-session-view-route.ts +149 -0
  128. package/src/input/handler-shortcuts.ts +16 -154
  129. package/src/input/handler-types.ts +4 -2
  130. package/src/input/handler-ui-state.ts +45 -15
  131. package/src/input/handler-work-tree-route.ts +123 -0
  132. package/src/input/handler.ts +25 -70
  133. package/src/input/keybindings.ts +16 -71
  134. package/src/input/masked-entry-modal.ts +117 -0
  135. package/src/input/mcp-workspace.ts +48 -17
  136. package/src/input/model-picker-filter.ts +8 -0
  137. package/src/input/model-picker-open.ts +99 -0
  138. package/src/input/model-picker-types.ts +1 -1
  139. package/src/input/model-picker.ts +92 -9
  140. package/src/input/notifications-modal.ts +123 -0
  141. package/src/input/overlay-filter.ts +26 -0
  142. package/src/input/profile-picker-modal.ts +37 -8
  143. package/src/input/search.ts +3 -3
  144. package/src/input/selection-modal-queue.ts +2 -0
  145. package/src/input/selection-modal.ts +28 -16
  146. package/src/input/session-picker-modal.ts +33 -8
  147. package/src/input/settings-modal-activation.ts +7 -2
  148. package/src/input/settings-modal-data.ts +11 -6
  149. package/src/input/settings-modal-types.ts +1 -1
  150. package/src/input/settings-modal.ts +20 -20
  151. package/src/input/submission-intent.ts +1 -1
  152. package/src/input/submission-router.ts +4 -9
  153. package/src/input/surface-modal-host.ts +110 -0
  154. package/src/input/theme-settings-actions.ts +82 -0
  155. package/src/input/usage-modal.ts +183 -0
  156. package/src/input/views.ts +97 -0
  157. package/src/main.ts +175 -165
  158. package/src/permissions/hunk-selection.ts +1 -1
  159. package/src/permissions/prompt-card.ts +511 -0
  160. package/src/permissions/prompt.ts +59 -622
  161. package/src/renderer/agents-modal.ts +310 -0
  162. package/src/renderer/autocomplete-overlay.ts +42 -220
  163. package/src/renderer/back-to-bottom.ts +80 -0
  164. package/src/renderer/block-actions-overlay.ts +30 -48
  165. package/src/renderer/bookmark-modal.ts +48 -79
  166. package/src/renderer/changes-modal.ts +325 -0
  167. package/src/renderer/code-block.ts +184 -106
  168. package/src/renderer/command-palette.ts +93 -0
  169. package/src/renderer/composer-fragments.ts +4 -4
  170. package/src/renderer/composer.ts +146 -0
  171. package/src/renderer/compositor.ts +26 -160
  172. package/src/renderer/config-modal.ts +185 -81
  173. package/src/renderer/confirm-dialog.ts +50 -0
  174. package/src/renderer/context-inspector.ts +116 -154
  175. package/src/renderer/context-status-hint.ts +4 -4
  176. package/src/renderer/conversation-layout.ts +20 -0
  177. package/src/renderer/conversation-overlays.ts +94 -84
  178. package/src/renderer/conversation-surface.ts +19 -38
  179. package/src/renderer/diff-tint.ts +98 -0
  180. package/src/renderer/file-picker-overlay.ts +63 -90
  181. package/src/renderer/footer-targets.ts +38 -0
  182. package/src/renderer/git-status.ts +3 -4
  183. package/src/renderer/header-line.ts +140 -117
  184. package/src/renderer/help-overlay.ts +133 -219
  185. package/src/renderer/history-search-overlay.ts +15 -12
  186. package/src/renderer/lane-graph/bead.ts +800 -0
  187. package/src/renderer/lane-graph/glyphs.ts +134 -0
  188. package/src/renderer/lane-graph/layout.ts +248 -0
  189. package/src/renderer/lane-graph/paint.ts +537 -0
  190. package/src/renderer/lane-graph/semantic-memo.ts +77 -0
  191. package/src/renderer/layout-engine.ts +2 -57
  192. package/src/renderer/layout.ts +7 -13
  193. package/src/renderer/markdown-inline.ts +2 -1
  194. package/src/renderer/markdown-table.ts +17 -12
  195. package/src/renderer/markdown.ts +92 -34
  196. package/src/renderer/masked-entry-modal.ts +78 -0
  197. package/src/renderer/mcp-workspace.ts +166 -203
  198. package/src/renderer/model-workspace.ts +381 -436
  199. package/src/renderer/notifications-modal.ts +116 -0
  200. package/src/renderer/onboarding/onboarding-wizard.ts +178 -588
  201. package/src/renderer/process-view.ts +192 -0
  202. package/src/renderer/profile-picker-modal.ts +58 -110
  203. package/src/renderer/search-overlay.ts +6 -3
  204. package/src/renderer/selection-modal-overlay.ts +87 -327
  205. package/src/renderer/semantic-diff.ts +2 -2
  206. package/src/renderer/session-chips.ts +102 -0
  207. package/src/renderer/session-picker-modal.ts +177 -297
  208. package/src/renderer/settings-modal-connections.ts +14 -23
  209. package/src/renderer/settings-modal-context.ts +367 -0
  210. package/src/renderer/settings-modal-helpers.ts +6 -11
  211. package/src/renderer/settings-modal.ts +322 -726
  212. package/src/renderer/shell-surface.ts +277 -175
  213. package/src/renderer/shortcuts-overlay.ts +217 -0
  214. package/src/renderer/status-line.ts +475 -0
  215. package/src/renderer/surface-compose.ts +174 -0
  216. package/src/renderer/surface-kit-extra.ts +303 -0
  217. package/src/renderer/surface-kit-list.ts +366 -0
  218. package/src/renderer/surface-kit-parts.ts +360 -0
  219. package/src/renderer/surface-kit.ts +568 -0
  220. package/src/renderer/syntax-highlighter.ts +280 -197
  221. package/src/renderer/syntax-theme.ts +51 -0
  222. package/src/renderer/system-message.ts +0 -1
  223. package/src/renderer/term-caps.ts +42 -1
  224. package/src/renderer/terminal-bg-probe.ts +116 -19
  225. package/src/renderer/terminal-palette-probe.ts +322 -0
  226. package/src/renderer/terminal-palette-reader.ts +183 -0
  227. package/src/renderer/terminal-palette.ts +58 -0
  228. package/src/renderer/theme-mode-config.ts +16 -1
  229. package/src/renderer/theme.ts +234 -207
  230. package/src/renderer/thinking.ts +0 -1
  231. package/src/renderer/throbber.ts +188 -0
  232. package/src/renderer/toast-center.ts +119 -0
  233. package/src/renderer/ui-factory.ts +39 -602
  234. package/src/renderer/ui-primitives.ts +8 -25
  235. package/src/renderer/usage-modal.ts +353 -0
  236. package/src/renderer/user-message.ts +58 -0
  237. package/src/renderer/voice-capture-chip.ts +53 -0
  238. package/src/runtime/bootstrap-command-context.ts +0 -5
  239. package/src/runtime/bootstrap-command-parts.ts +38 -89
  240. package/src/runtime/bootstrap-core.ts +13 -15
  241. package/src/runtime/bootstrap-hook-bridge.ts +12 -21
  242. package/src/runtime/bootstrap-notifier-sync.ts +17 -1
  243. package/src/runtime/bootstrap-shell.ts +16 -50
  244. package/src/runtime/bootstrap.ts +14 -14
  245. package/src/runtime/client/fleet-union.ts +5 -5
  246. package/src/runtime/client/hosted-exit.ts +2 -2
  247. package/src/runtime/client/hosted-sessions.ts +1 -1
  248. package/src/runtime/client/spine-adoption.ts +1 -1
  249. package/src/runtime/context-accounting-source.ts +5 -4
  250. package/src/runtime/index.ts +1 -0
  251. package/src/runtime/notification-dispatch.ts +102 -29
  252. package/src/runtime/recovery-offer-wiring.ts +4 -1
  253. package/src/runtime/recovery-prompt.ts +31 -15
  254. package/src/runtime/runtime-services-types.ts +10 -6
  255. package/src/runtime/services.ts +17 -12
  256. package/src/runtime/session-continuity-hints.ts +2 -5
  257. package/src/runtime/session-inbound-inputs.ts +1 -1
  258. package/src/runtime/session-resume-liveness-confirm.ts +14 -2
  259. package/src/runtime/startup-typeahead-gate.ts +103 -0
  260. package/src/runtime/store/domains/domain-read-matrix.ts +0 -1
  261. package/src/runtime/store/domains/index.ts +0 -8
  262. package/src/runtime/store/selectors/index.ts +0 -28
  263. package/src/runtime/store/state.ts +0 -8
  264. package/src/runtime/ui-services.ts +4 -6
  265. package/src/runtime/usage-tracker.ts +348 -0
  266. package/src/runtime/wrfc-persistence.ts +4 -4
  267. package/src/shell/blocking-input.ts +92 -2
  268. package/src/shell/recovery-input-helpers.ts +1 -1
  269. package/src/shell/session-views.ts +539 -0
  270. package/src/shell/status-report.ts +86 -0
  271. package/src/shell/transcript-scroll.ts +68 -0
  272. package/src/shell/ui-openers.ts +84 -172
  273. package/src/shell/view-openers.ts +205 -0
  274. package/src/tools/wrfc-agent-guard.ts +22 -2
  275. package/src/utils/format-duration.ts +4 -8
  276. package/src/utils/splash-lines.ts +9 -1
  277. package/src/utils/terminal-width.ts +56 -0
  278. package/src/verification/live-verifier.ts +0 -10
  279. package/src/version.ts +1 -1
  280. package/src/{panels → views}/agent-inspector-shared.ts +2 -2
  281. package/src/{panels → views}/builtin-modals.ts +22 -21
  282. package/src/views/builtin-views.ts +135 -0
  283. package/src/{panels → views}/confirm-state.ts +9 -9
  284. package/src/{panels → views}/diff-review-model.ts +10 -3
  285. package/src/{panels → views}/eval-registry.ts +1 -1
  286. package/src/{panels → views}/fleet-acts.ts +49 -85
  287. package/src/{panels/fleet-panel-format.ts → views/fleet-format.ts} +40 -40
  288. package/src/{panels → views}/fleet-gateway.ts +5 -5
  289. package/src/{panels → views}/fleet-observed-render.ts +16 -16
  290. package/src/{panels → views}/fleet-read-model.ts +9 -9
  291. package/src/{panels/fleet-tabs.ts → views/fleet-session-tabs.ts} +13 -13
  292. package/src/{panels → views}/fleet-spawn.ts +33 -49
  293. package/src/{panels → views}/fleet-steer.ts +12 -12
  294. package/src/{panels → views}/fleet-stop.ts +4 -4
  295. package/src/{panels → views}/fleet-transcript.ts +26 -27
  296. package/src/{panels/fleet-panel-worktree-detail.ts → views/fleet-worktree-detail.ts} +7 -7
  297. package/src/{panels → views}/hosted-session-feed.ts +6 -6
  298. package/src/views/modal-surface-registry.ts +41 -0
  299. package/src/{panels → views}/modals/devices-modal.ts +2 -2
  300. package/src/{panels → views}/modals/hooks-modal.ts +13 -13
  301. package/src/{panels → views}/modals/keybindings-modal.ts +19 -27
  302. package/src/{panels → views}/modals/knowledge-modal.ts +6 -5
  303. package/src/{panels → views}/modals/local-auth-modal.ts +22 -21
  304. package/src/{panels → views}/modals/marketplace-modal.ts +11 -11
  305. package/src/{panels → views}/modals/memory-modal.ts +8 -8
  306. package/src/{panels → views}/modals/modal-surface-helpers.ts +15 -5
  307. package/src/{panels → views}/modals/pairing-modal.ts +9 -9
  308. package/src/{panels → views}/modals/planning-modal.ts +15 -20
  309. package/src/{panels → views}/modals/plugins-modal.ts +6 -6
  310. package/src/{panels → views}/modals/policy-modal.ts +13 -5
  311. package/src/{panels → views}/modals/provider-health-modal.ts +4 -4
  312. package/src/{panels → views}/modals/remote-modal.ts +3 -3
  313. package/src/{panels → views}/modals/sandbox-modal.ts +4 -4
  314. package/src/{panels → views}/modals/security-modal.ts +22 -16
  315. package/src/{panels → views}/modals/services-modal.ts +3 -3
  316. package/src/{panels → views}/modals/settings-sync-modal.ts +4 -4
  317. package/src/{panels → views}/modals/skills-modal.ts +9 -9
  318. package/src/{panels → views}/modals/subscription-modal.ts +3 -3
  319. package/src/{panels → views}/modals/work-plan-modal.ts +10 -13
  320. package/src/views/notifications-feed.ts +314 -0
  321. package/src/{panels → views}/polish-core.ts +38 -36
  322. package/src/{panels → views}/polish-tables.ts +35 -26
  323. package/src/{panels → views}/polish.ts +101 -95
  324. package/src/{panels → views}/project-planning-answer-actions.ts +3 -3
  325. package/src/{panels → views}/provider-health-routes.ts +14 -14
  326. package/src/{panels → views}/search-focus.ts +8 -8
  327. package/src/{panels → views}/session-maintenance.ts +11 -11
  328. package/src/views/skills-discovery.ts +159 -0
  329. package/src/views/view-deps.ts +80 -0
  330. package/src/views/view-panel-adapter.ts +53 -0
  331. package/src/{panels → views}/workstream-graph-render.ts +1 -1
  332. package/src/core/conversation-fold.ts +0 -101
  333. package/src/core/conversation-turn-structure.ts +0 -464
  334. package/src/core/conversation-user-receipts.ts +0 -42
  335. package/src/input/commands/operator-panel-runtime.ts +0 -186
  336. package/src/input/panel-integration-actions.ts +0 -33
  337. package/src/input/panel-mouse-geometry.ts +0 -97
  338. package/src/input/panel-paste-flood-guard.ts +0 -86
  339. package/src/panels/base-panel.ts +0 -301
  340. package/src/panels/builtin/agent.ts +0 -27
  341. package/src/panels/builtin/development.ts +0 -92
  342. package/src/panels/builtin/knowledge.ts +0 -12
  343. package/src/panels/builtin/operations.ts +0 -177
  344. package/src/panels/builtin/session.ts +0 -81
  345. package/src/panels/builtin/shared.ts +0 -357
  346. package/src/panels/builtin-panels.ts +0 -28
  347. package/src/panels/cost-tracker-panel.ts +0 -746
  348. package/src/panels/diff-panel.ts +0 -797
  349. package/src/panels/diff-review-panel.ts +0 -395
  350. package/src/panels/expandable-list-panel.ts +0 -189
  351. package/src/panels/fleet-deep-link.ts +0 -31
  352. package/src/panels/fleet-panel.ts +0 -799
  353. package/src/panels/git-panel.ts +0 -800
  354. package/src/panels/hosted-session-panel.ts +0 -221
  355. package/src/panels/index.ts +0 -21
  356. package/src/panels/local-auth-panel.ts +0 -478
  357. package/src/panels/notifications-feed.ts +0 -127
  358. package/src/panels/notifications-panel.ts +0 -160
  359. package/src/panels/panel-confirm-overlay.ts +0 -72
  360. package/src/panels/panel-manager.ts +0 -791
  361. package/src/panels/plugins-panel.ts +0 -371
  362. package/src/panels/scrollable-list-panel.ts +0 -498
  363. package/src/panels/skills-panel.ts +0 -454
  364. package/src/panels/token-budget-panel.ts +0 -688
  365. package/src/panels/types.ts +0 -191
  366. package/src/panels/wrfc-panel-format.ts +0 -133
  367. package/src/renderer/diff-view.ts +0 -134
  368. package/src/renderer/fleet-tab-strip.ts +0 -56
  369. package/src/renderer/footer-tips.ts +0 -53
  370. package/src/renderer/fullscreen-primitives.ts +0 -146
  371. package/src/renderer/fullscreen-workspace.ts +0 -194
  372. package/src/renderer/hint-grammar.ts +0 -52
  373. package/src/renderer/modal-factory.ts +0 -480
  374. package/src/renderer/overlay-box.ts +0 -154
  375. package/src/renderer/overlay-viewport.ts +0 -104
  376. package/src/renderer/panel-composite.ts +0 -145
  377. package/src/renderer/panel-workspace-bar.ts +0 -54
  378. package/src/renderer/process-indicator.ts +0 -163
  379. package/src/renderer/tab-strip.ts +0 -222
  380. package/src/renderer/text-layout.ts +0 -31
  381. package/src/renderer/tool-call.ts +0 -276
  382. package/src/renderer/tool-result-expanded-lines.ts +0 -48
  383. package/src/renderer/tool-result-summary.ts +0 -219
  384. package/src/runtime/diagnostics/panels/index.ts +0 -21
  385. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  386. package/src/runtime/perf/panel-contracts.ts +0 -32
  387. package/src/runtime/store/domains/panels.ts +0 -117
  388. package/src/verification/verification-ledger.ts +0 -707
  389. /package/src/{panels → views}/memory-consolidation-gateway.ts +0 -0
  390. /package/src/{panels → views}/modals/modal-theme.ts +0 -0
  391. /package/src/{panels → views}/provider-health-domains.ts +0 -0
@@ -0,0 +1,800 @@
1
+ /**
2
+ * lane-graph/bead.ts, what one tool call's bead says: its status mark, its
3
+ * name and key argument, the result summary on its row, and what opening it
4
+ * shows.
5
+ *
6
+ * The bead IS the status mark. There is no separate result row and no
7
+ * separate live row: a running call spins on its own bead, a failed call's
8
+ * bead is the ✕, and the result's one-line summary sits on the same row,
9
+ * right-aligned before the time.
10
+ *
11
+ * Everything here is pure data (no Lines, no theme): paint.ts draws it.
12
+ */
13
+
14
+ import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
15
+ import { stripDangerousAnsi } from '../ansi-sanitize.ts';
16
+
17
+ /** Text safe to put in a cell: no escape sequences at all, no control characters. */
18
+ export function cellText(text: string): string {
19
+ return stripDangerousAnsi(text)
20
+ // CSI (colors included), OSC, and two-byte escapes.
21
+ .replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/g, '')
22
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
23
+ }
24
+
25
+ /**
26
+ * ok ✓ done
27
+ * warn ! done, with something to look at (review findings, an expected non-zero exit, a partial edit)
28
+ * err ✕ failed
29
+ * run ◐ running (spins in place, the time counts up)
30
+ * wait ● waiting on the user (a permission prompt is open for it)
31
+ * cancel ○ cancelled (the row's text is struck through)
32
+ * bg ▶ a process it started keeps running in the background
33
+ */
34
+ export type BeadStatus = 'ok' | 'warn' | 'err' | 'run' | 'wait' | 'cancel' | 'bg';
35
+
36
+ /** How a settled call's result reads (conversation-render-context.ts outcomeOfToolContent). */
37
+ export type CallOutcome = 'ok' | 'error' | 'cancelled';
38
+
39
+ export interface BeadStatusInput {
40
+ /** Undefined while no result has arrived. */
41
+ readonly outcome: CallOutcome | undefined;
42
+ /** The result text, when settled. */
43
+ readonly content?: string | undefined;
44
+ /** A permission prompt is open for this call. */
45
+ readonly waiting: boolean;
46
+ /** The call's owner (the turn, or the agent whose lane it is on) is still working. */
47
+ readonly ownerActive: boolean;
48
+ /** The call asked for a background process. */
49
+ readonly background: boolean;
50
+ /** The settled result carries something to look at (see needsAttention). */
51
+ readonly attention: boolean;
52
+ }
53
+
54
+ /**
55
+ * The status a bead shows. An unsettled call whose owner stopped working never
56
+ * got a result, so it reads as cancelled rather than spinning forever.
57
+ */
58
+ export function beadStatus(input: BeadStatusInput): BeadStatus {
59
+ if (input.outcome === undefined) {
60
+ if (input.waiting) return 'wait';
61
+ return input.ownerActive ? 'run' : 'cancel';
62
+ }
63
+ if (input.outcome === 'error') return 'err';
64
+ if (input.outcome === 'cancelled') return 'cancel';
65
+ if (input.background) return 'bg';
66
+ return input.attention ? 'warn' : 'ok';
67
+ }
68
+
69
+ /**
70
+ * Extract the most meaningful argument from a tool call for display.
71
+ */
72
+ function extractKeyArg(toolCall: ToolCall): string {
73
+ const args = toolCall.arguments;
74
+ // Path-based tools
75
+ if (typeof args.path === 'string') return args.path;
76
+ if (typeof args.file === 'string') return args.file;
77
+ if (typeof args.query === 'string') return args.query;
78
+ if (typeof args.title === 'string') return args.title;
79
+ // Edit: the first edit's path
80
+ if (Array.isArray(args.edits) && args.edits.length > 0) {
81
+ const first = args.edits[0];
82
+ if (first && typeof first === 'object' && typeof (first as Record<string, unknown>).path === 'string') {
83
+ const paths = new Set(args.edits.map((e) => (e && typeof e === 'object' ? (e as Record<string, unknown>).path : undefined)).filter((p): p is string => typeof p === 'string'));
84
+ return paths.size > 1 ? `${(first as Record<string, unknown>).path as string} +${paths.size - 1} more` : (first as Record<string, unknown>).path as string;
85
+ }
86
+ }
87
+ // Array-based (read/write)
88
+ if (Array.isArray(args.files) && args.files.length > 0) {
89
+ const first = args.files[0];
90
+ if (typeof first === 'string') return first;
91
+ if (first && typeof first === 'object' && typeof (first as Record<string, unknown>).path === 'string')
92
+ return (first as Record<string, unknown>).path as string;
93
+ }
94
+ // Exec
95
+ if (typeof args.command === 'string') return args.command;
96
+ if (typeof args.cmd === 'string') return args.cmd;
97
+ if (Array.isArray(args.commands) && args.commands.length > 0) {
98
+ const first = args.commands[0];
99
+ if (first && typeof first === 'object' && typeof (first as Record<string, unknown>).cmd === 'string')
100
+ return (first as Record<string, unknown>).cmd as string;
101
+ }
102
+ // Find/grep
103
+ if (typeof args.pattern === 'string') return args.pattern;
104
+ if (Array.isArray(args.queries) && args.queries.length > 0) {
105
+ const first = args.queries[0];
106
+ if (first && typeof first === 'object') {
107
+ const firstRecord = first as Record<string, unknown>;
108
+ if (typeof firstRecord.query === 'string') return firstRecord.query;
109
+ if (typeof firstRecord.pattern === 'string') return firstRecord.pattern;
110
+ }
111
+ }
112
+ // Fetch
113
+ if (Array.isArray(args.urls) && args.urls.length > 0) {
114
+ const first = args.urls[0];
115
+ if (first && typeof first === 'object' && typeof (first as Record<string, unknown>).url === 'string')
116
+ return (first as Record<string, unknown>).url as string;
117
+ }
118
+ // Agent
119
+ if (typeof args.task === 'string') return args.task.slice(0, 40);
120
+ if (typeof args.mode === 'string') return args.mode;
121
+ // Fallback: first string value
122
+ for (const val of Object.values(args)) {
123
+ if (typeof val === 'string' && val.length > 0) return val.slice(0, 40);
124
+ }
125
+ return '';
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Parsing helpers
130
+ // ---------------------------------------------------------------------------
131
+
132
+ type Json = Record<string, unknown>;
133
+
134
+ function parseObject(content: string | undefined): Json | null {
135
+ if (!content) return null;
136
+ const trimmed = content.trimStart();
137
+ if (!trimmed.startsWith('{')) return null;
138
+ try {
139
+ const parsed: unknown = JSON.parse(trimmed);
140
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Json : null;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function num(value: unknown): number | undefined {
147
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
148
+ }
149
+
150
+ function str(value: unknown): string | undefined {
151
+ return typeof value === 'string' ? value : undefined;
152
+ }
153
+
154
+ /** The tool family: an MCP tool's last name segment, lowercased. */
155
+ export function toolFamily(name: string): string {
156
+ return (name.includes('__') ? name.split('__').pop()! : name).toLowerCase();
157
+ }
158
+
159
+ /** The name drawn on the bead row. */
160
+ export function beadName(call: Pick<ToolCall, 'name'>): string {
161
+ return cellText(call.name.includes('__') ? call.name.split('__').pop()! : call.name);
162
+ }
163
+
164
+ /** The key argument drawn after the name (the path, the command, the pattern). */
165
+ export function beadArgument(call: ToolCall): string {
166
+ return cellText(extractKeyArg(call)).replace(/\s+/g, ' ').trim();
167
+ }
168
+
169
+ /** True when the call asked for a process to keep running in the background. */
170
+ export function isBackgroundCall(call: ToolCall): boolean {
171
+ const args = call.arguments;
172
+ if (args.background === true) return true;
173
+ if (Array.isArray(args.commands)) {
174
+ return args.commands.length > 0 && args.commands.every((c) => c !== null && typeof c === 'object' && (c as Json).background === true);
175
+ }
176
+ return false;
177
+ }
178
+
179
+ /** The error text of a failed result (after the "Error: " prefix). */
180
+ function errorText(content: string): string {
181
+ return content.replace(/^Error:\s*/, '');
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Exec results
186
+ // ---------------------------------------------------------------------------
187
+
188
+ interface ExecCommandResult {
189
+ readonly cmd?: string;
190
+ readonly exitCode: number | null | undefined;
191
+ readonly stdout: string;
192
+ readonly stderr: string;
193
+ readonly timedOut: boolean;
194
+ readonly skipped: boolean;
195
+ readonly droppedLines: number;
196
+ }
197
+
198
+ function execCommands(obj: Json): ExecCommandResult[] {
199
+ const one = (o: Json): ExecCommandResult => ({
200
+ cmd: str(o.cmd),
201
+ exitCode: o.exit_code === null ? null : num(o.exit_code),
202
+ stdout: str(o.stdout) ?? '',
203
+ stderr: str(o.stderr) ?? '',
204
+ timedOut: o.timed_out === true,
205
+ skipped: o.skipped === true,
206
+ droppedLines: (num(o.stdout_dropped_lines) ?? 0) + (num(o.stderr_dropped_lines) ?? 0),
207
+ });
208
+ if (Array.isArray(obj.commands)) {
209
+ return obj.commands.filter((c): c is Json => c !== null && typeof c === 'object').map(one);
210
+ }
211
+ if ('exit_code' in obj || 'stdout' in obj || 'stderr' in obj) return [one(obj)];
212
+ return [];
213
+ }
214
+
215
+ /** "4 pass", "2 fail": test-runner totals read off the output, when it printed them. */
216
+ function testTotals(text: string): string | undefined {
217
+ const pass = /(\d+)\s+pass(?:ed|ing)?\b/i.exec(text);
218
+ const fail = /(\d+)\s+fail(?:ed|ing|ures?)?\b/i.exec(text);
219
+ if (!pass && !fail) return undefined;
220
+ const failCount = fail ? Number(fail[1]) : 0;
221
+ if (failCount > 0) return `${failCount} failing`;
222
+ return pass ? `${pass[1]} pass` : undefined;
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // A result whose call is gone
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /** The name a result with no call gets when its shape does not say which tool made it. */
230
+ const UNNAMED_RESULT = 'result';
231
+
232
+ /** ` 12 | text`: the read tool's line-numbered file text (formatContent's standard format). */
233
+ const NUMBERED_LINE = /^ *\d+ \| ?/;
234
+
235
+ /** True when every non-blank line is line-numbered file text. */
236
+ function isNumberedFileText(content: string): boolean {
237
+ const lines = textLines(content).filter((l) => l.trim() !== '');
238
+ return lines.length > 0 && lines.every((l) => NUMBERED_LINE.test(l));
239
+ }
240
+
241
+ /**
242
+ * The tool behind a stored result whose call was lost (results kept without
243
+ * their call before compaction kept each call whole), named only when the
244
+ * result's own shape leaves no doubt: a unified diff or the edit tool's
245
+ * applied/failed report is an edit, command output or test totals are exec,
246
+ * line-numbered file text or a read summary is a read. Anything else is
247
+ * UNNAMED_RESULT, never the bare word "tool".
248
+ */
249
+ export function inferResultToolName(content: string): string {
250
+ const obj = parseObject(content);
251
+ if (obj) {
252
+ if (execCommands(obj).length > 0) return 'exec';
253
+ const summary = obj.summary && typeof obj.summary === 'object' ? obj.summary as Json : null;
254
+ if (summary && num(summary.files_read) !== undefined && num(summary.total_lines) !== undefined) return 'read';
255
+ if (num(obj.applied) !== undefined && num(obj.failed) !== undefined) return 'edit';
256
+ return UNNAMED_RESULT;
257
+ }
258
+ if (/^@@ /m.test(content) && /^(\+\+\+|---) /m.test(content)) return 'edit';
259
+ if (/^Edits applied: \d+, failed: \d+/m.test(content)) return 'edit';
260
+ if (testTotals(content)) return 'exec';
261
+ if (isNumberedFileText(content)) return 'read';
262
+ return UNNAMED_RESULT;
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Attention
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /**
270
+ * A result that settled without failing but still has something the user
271
+ * should look at: a command that exited non-zero, an edit with failed items,
272
+ * or a test run with failures.
273
+ */
274
+ export function needsAttention(call: ToolCall, content: string): boolean {
275
+ const family = toolFamily(call.name);
276
+ const obj = parseObject(content);
277
+ if (family === 'exec' && obj) {
278
+ return execCommands(obj).some((c) => (c.exitCode !== undefined && c.exitCode !== null && c.exitCode !== 0) || c.timedOut);
279
+ }
280
+ if (family === 'edit') {
281
+ if (obj) return (num(obj.failed) ?? 0) > 0;
282
+ const m = /Edits applied: \d+, failed: (\d+)/.exec(content);
283
+ return m !== null && Number(m[1]) > 0;
284
+ }
285
+ return false;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Summaries
290
+ // ---------------------------------------------------------------------------
291
+
292
+ export type SummaryTone = 'faint' | 'good' | 'warn' | 'bad';
293
+
294
+ export interface BeadSummary {
295
+ readonly text: string;
296
+ readonly tone: SummaryTone;
297
+ }
298
+
299
+ function plural(n: number, word: string): string {
300
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
301
+ }
302
+
303
+ function formatBytes(n: number): string {
304
+ if (n < 1024) return `${n} B`;
305
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
306
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
307
+ }
308
+
309
+ /** Added and removed line counts of a unified diff. */
310
+ function diffCounts(diff: string): { added: number; removed: number } {
311
+ let added = 0;
312
+ let removed = 0;
313
+ for (const line of diff.split('\n')) {
314
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
315
+ if (line.startsWith('+')) added++;
316
+ else if (line.startsWith('-')) removed++;
317
+ }
318
+ return { added, removed };
319
+ }
320
+
321
+ function countSummary(counts: { added: number; removed: number }): string {
322
+ const parts: string[] = [];
323
+ if (counts.added > 0 || counts.removed === 0) parts.push(`+${counts.added}`);
324
+ if (counts.removed > 0) parts.push(`−${counts.removed}`);
325
+ return parts.join(' ');
326
+ }
327
+
328
+ /** Match entries (file, line, text) found anywhere in a find result. */
329
+ function findEntries(value: unknown, out: Array<{ file: string; line?: number; text?: string }>, depth = 0): void {
330
+ if (depth > 4 || value === null || typeof value !== 'object') return;
331
+ if (Array.isArray(value)) {
332
+ for (const item of value) {
333
+ if (typeof item === 'string') out.push({ file: item });
334
+ else if (item && typeof item === 'object') {
335
+ const rec = item as Json;
336
+ const file = str(rec.file) ?? str(rec.path);
337
+ if (file !== undefined) out.push({ file, line: num(rec.line), text: str(rec.text) ?? str(rec.name) });
338
+ else findEntries(item, out, depth + 1);
339
+ }
340
+ }
341
+ return;
342
+ }
343
+ for (const [key, child] of Object.entries(value as Json)) {
344
+ if (key === 'relationships' || key === 'warnings' || key.startsWith('_')) continue;
345
+ findEntries(child, out, depth + 1);
346
+ }
347
+ }
348
+
349
+ /**
350
+ * The one-line result summary on a bead's row, or null when the row should
351
+ * carry none (a running call, or a shape with nothing honest to say).
352
+ */
353
+ export function beadSummary(call: ToolCall, status: BeadStatus, content: string | undefined): BeadSummary | null {
354
+ const summary = rawSummary(call, status, content);
355
+ return summary ? { ...summary, text: cellText(summary.text) } : null;
356
+ }
357
+
358
+ function rawSummary(call: ToolCall, status: BeadStatus, content: string | undefined): BeadSummary | null {
359
+ if (status === 'run') return null;
360
+ if (status === 'wait') return { text: 'waiting for you', tone: 'warn' };
361
+ if (status === 'cancel') return { text: 'cancelled', tone: 'faint' };
362
+ if (content === undefined) return null;
363
+ if (status === 'err') {
364
+ const totals = testTotals(content);
365
+ if (totals) return { text: totals, tone: 'bad' };
366
+ const first = errorText(content).split('\n')[0] ?? '';
367
+ const execExit = /exit(?: code)? (\d+)/i.exec(first);
368
+ return { text: execExit ? `exit ${execExit[1]}` : first, tone: 'bad' };
369
+ }
370
+ const family = toolFamily(call.name);
371
+ const obj = parseObject(content);
372
+
373
+ if (status === 'bg') {
374
+ const pid = obj ? num(obj.pid) ?? num(obj.process_id) : undefined;
375
+ const port = /(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::\]|\[::1\]):(\d{2,5})\b/.exec(content)?.[1];
376
+ return { text: ['in background', port ? `:${port}` : pid !== undefined ? `pid ${pid}` : undefined].filter(Boolean).join(' · '), tone: 'good' };
377
+ }
378
+
379
+ switch (family) {
380
+ case 'read': {
381
+ if (!obj) return textReadSummary(call, content);
382
+ const summary = (obj.summary && typeof obj.summary === 'object') ? obj.summary as Json : obj;
383
+ const files = num(summary.files_read);
384
+ const lines = num(summary.total_lines);
385
+ if (files !== undefined && files > 1) return { text: `${plural(files, 'file')}${lines !== undefined ? ` · ${plural(lines, 'line')}` : ''}`, tone: 'faint' };
386
+ if (lines !== undefined) return { text: plural(lines, 'line'), tone: 'faint' };
387
+ break;
388
+ }
389
+ case 'find': {
390
+ if (!obj) return textFindSummary(content);
391
+ const entries: Array<{ file: string }> = [];
392
+ findEntries(obj, entries);
393
+ const count = num(obj.count) ?? entries.length;
394
+ const files = new Set(entries.map((e) => e.file)).size;
395
+ if (count === 0) return { text: 'no matches', tone: 'faint' };
396
+ return { text: files > 1 ? `${plural(count, 'match').replace('matchs', 'matches')} in ${files} files` : plural(count, 'match').replace('matchs', 'matches'), tone: 'faint' };
397
+ }
398
+ case 'edit': {
399
+ const diff = editDiff(call, content);
400
+ const failed = obj ? num(obj.failed) ?? 0 : Number(/failed: (\d+)/.exec(content)?.[1] ?? 0);
401
+ if (failed > 0) return { text: `${failed} failed`, tone: 'warn' };
402
+ if (diff) return { text: countSummary(diffCounts(diff.text)), tone: 'good' };
403
+ break;
404
+ }
405
+ case 'write': {
406
+ const written = writtenFiles(call);
407
+ if (written.length > 0) {
408
+ const lines = written.reduce((n, f) => n + f.content.split('\n').length, 0);
409
+ return { text: written.length > 1 ? `${plural(written.length, 'file')} · +${lines}` : `+${lines}`, tone: 'good' };
410
+ }
411
+ if (obj) {
412
+ const bytes = num(obj.bytes_written);
413
+ if (bytes !== undefined) return { text: formatBytes(bytes), tone: 'good' };
414
+ }
415
+ break;
416
+ }
417
+ case 'exec': {
418
+ if (!obj) return textExecSummary(content);
419
+ const commands = execCommands(obj);
420
+ if (commands.length === 0) break;
421
+ if (commands.length > 1) {
422
+ const failing = commands.filter((c) => c.exitCode !== 0 && !c.skipped).length;
423
+ return failing > 0
424
+ ? { text: `${commands.length} commands · ${failing} failed`, tone: 'warn' }
425
+ : { text: `${commands.length} commands · exit 0`, tone: 'faint' };
426
+ }
427
+ const c = commands[0]!;
428
+ if (c.timedOut) return { text: 'timed out', tone: 'warn' };
429
+ const totals = testTotals(`${c.stdout}\n${c.stderr}`);
430
+ const exit = c.exitCode === null || c.exitCode === undefined ? 'no exit code' : `exit ${c.exitCode}`;
431
+ return { text: totals ? `${totals} · ${exit}` : exit, tone: c.exitCode === 0 || c.exitCode === undefined ? 'faint' : 'warn' };
432
+ }
433
+ case 'fetch': {
434
+ return { text: formatBytes(Buffer.byteLength(content, 'utf8')), tone: 'faint' };
435
+ }
436
+ default:
437
+ break;
438
+ }
439
+ if (obj) return null;
440
+ const shaped = shapedTextSummary(call, content);
441
+ if (shaped) return shaped;
442
+ const first = content.split('\n').find((line) => line.trim().length > 0);
443
+ return first ? { text: first.trim(), tone: 'faint' } : null;
444
+ }
445
+
446
+ /**
447
+ * A multi-line text result whose shape says what it is, whatever the tool
448
+ * (a result whose call is unknown carries no tool name to go by): a unified
449
+ * diff reads as its line counts, test output as its totals, grep-style lines
450
+ * as their match count. Anything else keeps its first line.
451
+ */
452
+ function shapedTextSummary(call: ToolCall, content: string): BeadSummary | null {
453
+ const lines = textLines(content);
454
+ if (lines.length < 2) return null;
455
+ const diff = /^@@ /m.test(content) && /^(\+\+\+|---) /m.test(content) ? editDiff(call, content) : null;
456
+ if (diff) return { text: countSummary(diffCounts(diff.text)), tone: 'good' };
457
+ const totals = testTotals(content);
458
+ if (totals) return { text: totals, tone: /failing/.test(totals) ? 'warn' : 'faint' };
459
+ if (lines.every((l) => MATCH_LINE.test(l))) return textFindSummary(content);
460
+ return null;
461
+ }
462
+
463
+ /** Non-empty lines of a text result (trailing blank lines dropped). */
464
+ function textLines(content: string): string[] {
465
+ const trimmed = content.replace(/\s+$/, '');
466
+ return trimmed === '' ? [] : trimmed.split('\n');
467
+ }
468
+
469
+ /** The paths a read call asked for, from its arguments. */
470
+ function readPaths(call: ToolCall): string[] {
471
+ const args = call.arguments;
472
+ const out: string[] = [];
473
+ if (Array.isArray(args.files)) {
474
+ for (const f of args.files) {
475
+ if (typeof f === 'string') out.push(f);
476
+ else if (f && typeof f === 'object' && typeof (f as Json).path === 'string') out.push((f as Json).path as string);
477
+ }
478
+ }
479
+ if (out.length === 0 && typeof args.path === 'string') out.push(args.path);
480
+ if (out.length === 0 && typeof args.file === 'string') out.push(args.file);
481
+ return out;
482
+ }
483
+
484
+ /**
485
+ * A read whose result is the file text itself (an imported or older session,
486
+ * an MCP read) says how much it read, like a structured read does: never the
487
+ * file's first line.
488
+ */
489
+ function textReadSummary(call: ToolCall, content: string): BeadSummary {
490
+ const lines = textLines(content).length;
491
+ const files = new Set(readPaths(call)).size;
492
+ if (lines === 0) return { text: 'empty', tone: 'faint' };
493
+ return { text: files > 1 ? `${plural(files, 'file')} · ${plural(lines, 'line')}` : plural(lines, 'line'), tone: 'faint' };
494
+ }
495
+
496
+ /** `path:line: text` (grep style) or `path:line:col: text`. */
497
+ const MATCH_LINE = /^(.+?):(\d+)(?::\d+)?[:\s-]/;
498
+
499
+ /** A find whose result is grep-style text lines: how many matches in how many files, or how many files. */
500
+ function textFindSummary(content: string): BeadSummary {
501
+ const lines = textLines(content).filter((l) => l.trim().length > 0);
502
+ if (lines.length === 0 || /^no (matches|results|files)\b/i.test(lines[0]!.trim())) return { text: 'no matches', tone: 'faint' };
503
+ const matches = lines.map((l) => MATCH_LINE.exec(l)).filter((m): m is RegExpExecArray => m !== null);
504
+ if (matches.length === 0) return { text: plural(lines.length, 'file'), tone: 'faint' };
505
+ const files = new Set(matches.map((m) => m[1])).size;
506
+ const count = plural(matches.length, 'match').replace('matchs', 'matches');
507
+ return { text: files > 1 ? `${count} in ${files} files` : count, tone: 'faint' };
508
+ }
509
+
510
+ /** A command whose result is its bare output: test totals when it printed them, otherwise how much it printed. */
511
+ function textExecSummary(content: string): BeadSummary {
512
+ const totals = testTotals(content);
513
+ if (totals) return { text: totals, tone: /failing/.test(totals) ? 'warn' : 'faint' };
514
+ const lines = textLines(content).length;
515
+ return { text: lines === 0 ? 'no output' : `${plural(lines, 'line')} of output`, tone: 'faint' };
516
+ }
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // Bodies
520
+ // ---------------------------------------------------------------------------
521
+
522
+ export type BodyTone = 'text' | 'muted' | 'faint' | 'good' | 'bad' | 'warn';
523
+
524
+ export type BeadBody =
525
+ /** A unified diff. `numbered` is false when line numbers are unknown (built from the call's own find/replace). */
526
+ | { readonly kind: 'diff'; readonly diff: string; readonly numbered: boolean; readonly path: string | undefined }
527
+ /** Terminal output, one entry per line. */
528
+ | { readonly kind: 'output'; readonly lines: ReadonlyArray<{ readonly text: string; readonly tone: BodyTone }>; readonly footer?: string }
529
+ /** A short list (files read, matches found). */
530
+ | { readonly kind: 'list'; readonly items: ReadonlyArray<{ readonly text: string; readonly detail?: string }> }
531
+ /**
532
+ * The text a read returned, one section per file: its path and line count,
533
+ * then the file's lines exactly as read (indentation kept, the read tool's
534
+ * line-number prefix removed). `path` is undefined when the call named no
535
+ * path (a single text result).
536
+ */
537
+ | { readonly kind: 'read'; readonly files: ReadonlyArray<ReadBodyFile> }
538
+ /** A failure, wrapped in full. */
539
+ | { readonly kind: 'error'; readonly text: string }
540
+ /** Plain text lines (a result that is text, or a flattened object). */
541
+ | { readonly kind: 'text'; readonly lines: readonly string[] };
542
+
543
+ /** One file of a read body. */
544
+ export interface ReadBodyFile {
545
+ readonly path: string | undefined;
546
+ /** Lines the file has in all (the read's own count), which may exceed `lines` for a ranged read. */
547
+ readonly lineCount: number;
548
+ readonly lines: readonly string[];
549
+ /** Why the file could not be read, instead of lines. */
550
+ readonly error?: string;
551
+ }
552
+
553
+ /** A file's text as the model read it: the `NNNNN | ` prefix removed when every line carries one. */
554
+ function readText(content: string): string[] {
555
+ const lines = content.replace(/\n+$/, '').split('\n');
556
+ const numbered = lines.filter((l) => l.trim() !== '').every((l) => NUMBERED_LINE.test(l));
557
+ return numbered ? lines.map((l) => l.replace(NUMBERED_LINE, '')) : lines;
558
+ }
559
+
560
+ /** The files a write call wrote, with their content. */
561
+ function writtenFiles(call: ToolCall): Array<{ path: string; content: string }> {
562
+ const args = call.arguments;
563
+ const out: Array<{ path: string; content: string }> = [];
564
+ const push = (rec: Json): void => {
565
+ const path = str(rec.path) ?? str(rec.file);
566
+ const content = str(rec.content);
567
+ if (path !== undefined && content !== undefined) out.push({ path, content });
568
+ };
569
+ if (Array.isArray(args.files)) for (const f of args.files) if (f && typeof f === 'object') push(f as Json);
570
+ if (out.length === 0) push(args);
571
+ return out;
572
+ }
573
+
574
+ /**
575
+ * The diff an edit made: the unified diff the edit tool printed when it ran
576
+ * with a diff output format, otherwise one built from the call's own
577
+ * find/replace pairs (then line numbers are unknown and the body shows none).
578
+ */
579
+ function editDiff(call: ToolCall, content: string | undefined): { text: string; numbered: boolean; path: string | undefined } | null {
580
+ if (content && /^@@ /m.test(content) && /^(\+\+\+|---) /m.test(content)) {
581
+ // The edit tool prints its own "--- path (N replacement(s)) ---" banners around each
582
+ // file's diff: start at the real diff header, and drop any banner lines inside it.
583
+ let start = content.search(/^diff --git /m);
584
+ if (start < 0) start = content.search(/^--- \S+\n\+\+\+ /m);
585
+ const text = (start >= 0 ? content.slice(start) : content)
586
+ .split('\n')
587
+ .filter((line) => !/^--- .* ---$/.test(line) && !/^\s*WARN: /.test(line) && !/^\[diff truncated/.test(line))
588
+ .join('\n');
589
+ const path = /^\+\+\+ (?:b\/)?(.+)$/m.exec(text)?.[1];
590
+ return { text, numbered: true, path };
591
+ }
592
+ const edits = Array.isArray(call.arguments.edits) ? call.arguments.edits.filter((e): e is Json => e !== null && typeof e === 'object') : [];
593
+ const usable = edits.filter((e) => typeof e.find === 'string' && typeof e.replace === 'string');
594
+ if (usable.length === 0) return null;
595
+ const byPath = new Map<string, Json[]>();
596
+ for (const edit of usable) {
597
+ const path = str(edit.path) ?? 'file';
598
+ byPath.set(path, [...(byPath.get(path) ?? []), edit]);
599
+ }
600
+ const chunks: string[] = [];
601
+ for (const [path, list] of byPath) {
602
+ chunks.push(`diff --git a/${path} b/${path}`, `--- a/${path}`, `+++ b/${path}`);
603
+ for (const edit of list) {
604
+ const find = (edit.find as string).split('\n');
605
+ const replace = (edit.replace as string).split('\n');
606
+ // Shared leading and trailing lines are context, not change.
607
+ let head = 0;
608
+ while (head < find.length && head < replace.length && find[head] === replace[head]) head++;
609
+ let tail = 0;
610
+ while (tail < find.length - head && tail < replace.length - head && find[find.length - 1 - tail] === replace[replace.length - 1 - tail]) tail++;
611
+ const near = num((edit.hints as Json | undefined)?.near_line) ?? 1;
612
+ chunks.push(`@@ -${near},${find.length} +${near},${replace.length} @@`);
613
+ for (const line of find.slice(0, head)) chunks.push(` ${line}`);
614
+ for (const line of find.slice(head, find.length - tail)) chunks.push(`-${line}`);
615
+ for (const line of replace.slice(head, replace.length - tail)) chunks.push(`+${line}`);
616
+ for (const line of find.slice(find.length - tail)) chunks.push(` ${line}`);
617
+ }
618
+ }
619
+ return { text: `${chunks.join('\n')}\n`, numbered: false, path: byPath.size === 1 ? [...byPath.keys()][0] : undefined };
620
+ }
621
+
622
+ function flattenObject(obj: Json, prefix = '', out: string[] = [], depth = 0): string[] {
623
+ for (const [key, value] of Object.entries(obj)) {
624
+ const label = prefix ? `${prefix}.${key}` : key;
625
+ if (value === null || value === undefined) continue;
626
+ if (typeof value === 'string') {
627
+ const lines = value.split('\n');
628
+ out.push(`${label} ${lines[0] ?? ''}`);
629
+ for (const line of lines.slice(1)) out.push(` ${line}`);
630
+ } else if (typeof value === 'number' || typeof value === 'boolean') {
631
+ out.push(`${label} ${String(value)}`);
632
+ } else if (Array.isArray(value)) {
633
+ const scalars = value.filter((v) => typeof v === 'string' || typeof v === 'number');
634
+ if (scalars.length === value.length && value.length <= 8) out.push(`${label} ${scalars.join(', ')}`);
635
+ else out.push(`${label} ${plural(value.length, 'item')}`);
636
+ } else if (typeof value === 'object' && depth < 2) {
637
+ flattenObject(value as Json, label, out, depth + 1);
638
+ }
639
+ }
640
+ return out;
641
+ }
642
+
643
+ /**
644
+ * What opening a bead shows, or null when there is nothing inside (then the
645
+ * row carries no ▸).
646
+ */
647
+ export function beadBody(call: ToolCall, status: BeadStatus, content: string | undefined): BeadBody | null {
648
+ const body = rawBody(call, status, content);
649
+ if (!body) return null;
650
+ switch (body.kind) {
651
+ case 'list': return { kind: 'list', items: body.items.map((i) => ({ text: cellText(i.text), detail: i.detail !== undefined ? cellText(i.detail) : undefined })) };
652
+ case 'read': return {
653
+ kind: 'read',
654
+ files: body.files.map((f) => ({
655
+ ...f,
656
+ path: f.path !== undefined ? cellText(f.path) : undefined,
657
+ lines: f.lines.map(cellText),
658
+ ...(f.error !== undefined ? { error: cellText(f.error) } : {}),
659
+ })),
660
+ };
661
+ case 'text': return { kind: 'text', lines: body.lines.map(cellText) };
662
+ case 'error': return { kind: 'error', text: body.text.split('\n').map(cellText).join('\n') };
663
+ default: return body;
664
+ }
665
+ }
666
+
667
+ function rawBody(call: ToolCall, status: BeadStatus, content: string | undefined): BeadBody | null {
668
+ const family = toolFamily(call.name);
669
+ if (family === 'edit') {
670
+ const diff = editDiff(call, content);
671
+ if (status === 'err' && content !== undefined) return { kind: 'error', text: errorText(content) };
672
+ if (diff) return { kind: 'diff', diff: diff.text, numbered: diff.numbered, path: diff.path };
673
+ }
674
+ if (family === 'write') {
675
+ const files = writtenFiles(call);
676
+ if (status === 'err' && content !== undefined) return { kind: 'error', text: errorText(content) };
677
+ if (files.length > 0) {
678
+ const text = files.map((f) => {
679
+ const lines = f.content.replace(/\n$/, '').split('\n');
680
+ return [`diff --git a/${f.path} b/${f.path}`, '--- /dev/null', `+++ b/${f.path}`, `@@ -0,0 +1,${lines.length} @@`, ...lines.map((l) => `+${l}`)].join('\n');
681
+ }).join('\n');
682
+ return { kind: 'diff', diff: `${text}\n`, numbered: true, path: files.length === 1 ? files[0]!.path : undefined };
683
+ }
684
+ }
685
+ if (content === undefined) return null;
686
+ if (status === 'err') return { kind: 'error', text: errorText(content) };
687
+ // Any result that is itself a unified diff opens as one.
688
+ if (/^--- \S/m.test(content) && /^\+\+\+ \S/m.test(content) && /^@@ /m.test(content)) {
689
+ const diff = editDiff(call, content);
690
+ if (diff) return { kind: 'diff', diff: diff.text, numbered: diff.numbered, path: diff.path };
691
+ }
692
+ if (status === 'cancel') {
693
+ // Keep the partial output's own indentation: drop only blank lines around it.
694
+ const partial = content.split('\n').slice(1).join('\n').replace(/^(?:[ \t]*\n)+/, '').replace(/\s+$/, '');
695
+ return partial ? { kind: 'text', lines: partial.split('\n') } : null;
696
+ }
697
+ const obj = parseObject(content);
698
+
699
+ if (family === 'exec' && obj) {
700
+ const commands = execCommands(obj);
701
+ if (commands.length > 0) {
702
+ const lines: Array<{ text: string; tone: BodyTone }> = [];
703
+ let dropped = 0;
704
+ for (const c of commands) {
705
+ if (commands.length > 1 && c.cmd) lines.push({ text: `$ ${c.cmd}`, tone: 'muted' });
706
+ for (const text of c.stdout.replace(/\n$/, '').split('\n')) if (c.stdout !== '') lines.push({ text: cellText(text), tone: 'text' });
707
+ for (const text of c.stderr.replace(/\n$/, '').split('\n')) if (c.stderr !== '') lines.push({ text: cellText(text), tone: c.exitCode === 0 ? 'muted' : 'bad' });
708
+ dropped += c.droppedLines;
709
+ }
710
+ const exits = commands.map((c) => (c.skipped ? 'skipped' : c.timedOut ? 'timed out' : c.exitCode === null || c.exitCode === undefined ? 'no exit code' : `exit ${c.exitCode}`));
711
+ const footer = [commands.length === 1 ? exits[0] : `${plural(commands.length, 'command')}`, dropped > 0 ? `${dropped} lines trimmed by the tool` : undefined].filter(Boolean).join(' · ');
712
+ if (lines.length === 0) return { kind: 'output', lines: [{ text: 'no output', tone: 'faint' }], footer };
713
+ return { kind: 'output', lines, footer };
714
+ }
715
+ }
716
+ if (family === 'read' && obj && Array.isArray(obj.files)) {
717
+ const entries = obj.files.filter((f): f is Json => f !== null && typeof f === 'object');
718
+ // Opened, a read shows the text the model read, like a command shows its
719
+ // output: one section per file, headed by its path and line count.
720
+ const withText = entries.filter((f) => typeof f.content === 'string' || str(f.error) !== undefined);
721
+ if (withText.length > 0 && withText.length === entries.length && entries.some((f) => typeof f.content === 'string')) {
722
+ return {
723
+ kind: 'read',
724
+ files: entries.map((f): ReadBodyFile => {
725
+ const error = str(f.error);
726
+ const lines = typeof f.content === 'string' ? readText(f.content) : [];
727
+ return {
728
+ path: str(f.path) ?? str(f.resolvedPath),
729
+ lineCount: num(f.lineCount) ?? lines.length,
730
+ lines: error !== undefined ? [] : lines,
731
+ ...(error !== undefined ? { error } : {}),
732
+ };
733
+ }),
734
+ };
735
+ }
736
+ // A read that returned no text (a metadata-only format) lists its files;
737
+ // one file with nothing but its line count repeats the row, so it has no body.
738
+ const items = entries.map((f) => {
739
+ const path = str(f.path) ?? str(f.resolvedPath) ?? '?';
740
+ const lines = num(f.lineCount);
741
+ const error = str(f.error);
742
+ return { text: path, detail: error ? error : lines !== undefined ? plural(lines, 'line') : undefined, error };
743
+ });
744
+ if (items.length === 1 && items[0]!.error === undefined) return null;
745
+ if (items.length > 0) return { kind: 'list', items: items.map(({ text, detail }) => ({ text, detail })) };
746
+ }
747
+ if (family === 'read' && !obj) {
748
+ // A read whose result is the file text itself.
749
+ const lines = readText(content);
750
+ if (lines.some((l) => l.trim() !== '')) {
751
+ const paths = readPaths(call);
752
+ return { kind: 'read', files: [{ path: paths.length === 1 ? paths[0] : undefined, lineCount: lines.length, lines }] };
753
+ }
754
+ }
755
+ if (family === 'find' && obj) {
756
+ const entries: Array<{ file: string; line?: number; text?: string }> = [];
757
+ findEntries(obj, entries);
758
+ if (entries.length > 0) {
759
+ return {
760
+ kind: 'list',
761
+ items: entries.map((e) => ({ text: e.line !== undefined ? `${e.file}:${e.line}` : e.file, detail: e.text?.trim() })),
762
+ };
763
+ }
764
+ }
765
+ if (obj) {
766
+ const lines = flattenObject(obj);
767
+ return lines.length > 0 ? { kind: 'text', lines } : null;
768
+ }
769
+ const trimmed = content.replace(/\n+$/, '');
770
+ if (trimmed.trim() === '') return null;
771
+ // A JSON array or other structured text still never shows raw: one entry per line.
772
+ if (trimmed.trimStart().startsWith('[')) {
773
+ try {
774
+ const parsed: unknown = JSON.parse(trimmed);
775
+ if (Array.isArray(parsed)) {
776
+ return { kind: 'text', lines: parsed.map((v) => (typeof v === 'string' ? v : v && typeof v === 'object' ? flattenObject(v as Json).join(' ') : String(v))) };
777
+ }
778
+ } catch {
779
+ // Not JSON after all, falls through to plain text.
780
+ }
781
+ }
782
+ return { kind: 'text', lines: trimmed.split('\n').map((l) => cellText(l)) };
783
+ }
784
+
785
+ /** Compact duration for a bead row: 0.2s, 1.4s, 38s, 2m 14s, 1h 02m. */
786
+ export function formatBeadTime(ms: number): string {
787
+ if (!Number.isFinite(ms) || ms < 0) ms = 0;
788
+ if (ms < 10_000) return `${(Math.floor(ms / 100) / 10).toFixed(1)}s`;
789
+ const secs = Math.floor(ms / 1000);
790
+ if (secs < 60) return `${secs}s`;
791
+ const mins = Math.floor(secs / 60);
792
+ if (mins < 60) return `${mins}m ${String(secs % 60).padStart(2, '0')}s`;
793
+ return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`;
794
+ }
795
+
796
+ /** Compact dollars: $0.12, $1.40, $12. */
797
+ export function formatCost(usd: number): string {
798
+ if (usd >= 10) return `$${Math.round(usd)}`;
799
+ return `$${usd.toFixed(2)}`;
800
+ }