@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,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
+ }