@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,303 @@
1
+ /**
2
+ * surface-kit-extra.ts, small shared helpers layered on the modal surface kit
3
+ * (surface-kit.ts, surface-kit-list.ts, surface-kit-parts.ts) for surfaces
4
+ * converted from the old ModalFactory / overlay-box frames:
5
+ *
6
+ * - legacy hint strings ("r refresh", "Enter repair", "←/→ tab") become
7
+ * KitHint keycaps; strings that are not a key plus an action (for example
8
+ * "/health for latency & routes") are returned as notes to show as text
9
+ * - tab rows that wrap onto a second row instead of dropping tabs
10
+ * - content-sized modal heights (small dialogs grow to fit their body)
11
+ * - measuring a kit list's height, group spacing included
12
+ *
13
+ * Colors come only from the active theme's tokens.
14
+ */
15
+
16
+ import { createEmptyLine, type Line } from '@pellux/goodvibes-sdk/platform/types';
17
+ import { getDisplayWidth } from '../utils/terminal-width.ts';
18
+ import { activeTokens } from './theme.ts';
19
+ import {
20
+ MODAL_PAD_X,
21
+ layoutHintRows,
22
+ hintLayoutWidth,
23
+ maxModalHeight,
24
+ modalGeometry,
25
+ wrapLines,
26
+ type KitHint,
27
+ type KitStyle,
28
+ type ModalGeometryOptions,
29
+ type SurfaceCanvas,
30
+ type SurfaceLayer,
31
+ } from './surface-kit.ts';
32
+ import { isHeader, measureRow, type KitRow } from './surface-kit-list.ts';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Hints
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const NAMED_KEYS: Readonly<Record<string, string>> = {
39
+ enter: '⏎',
40
+ return: '⏎',
41
+ esc: 'esc',
42
+ escape: 'esc',
43
+ tab: 'tab',
44
+ 'shift+tab': 'shift+tab',
45
+ space: 'space',
46
+ ' ': 'space',
47
+ backspace: 'bksp',
48
+ delete: 'del',
49
+ del: 'del',
50
+ pageup: 'pgup',
51
+ pagedown: 'pgdn',
52
+ pgup: 'pgup',
53
+ pgdn: 'pgdn',
54
+ up: '↑',
55
+ down: '↓',
56
+ left: '←',
57
+ right: '→',
58
+ 'up/down': '↑↓',
59
+ 'left/right': '←→',
60
+ '←/→': '←→',
61
+ '↑/↓': '↑↓',
62
+ '↑↓': '↑↓',
63
+ '←→': '←→',
64
+ };
65
+
66
+ /** The keycap text for a key name ("enter" → "⏎", "Up/Down" → "↑↓", "d" → "d"). */
67
+ function keycapLabel(key: string): string {
68
+ const lower = key.toLowerCase();
69
+ const named = NAMED_KEYS[lower] ?? NAMED_KEYS[key];
70
+ if (named) return named;
71
+ if (/^(ctrl|shift|alt|meta)\+/i.test(key)) return lower;
72
+ return key;
73
+ }
74
+
75
+ /** True when `token` reads as a key (a single character, a named key, a range like 1-6, a chord). */
76
+ function isKeyToken(token: string): boolean {
77
+ if ([...token].length === 1) return true;
78
+ const lower = token.toLowerCase();
79
+ if (NAMED_KEYS[lower] !== undefined || NAMED_KEYS[token] !== undefined) return true;
80
+ if (/^\d-\d$/.test(token)) return true;
81
+ if (/^(ctrl|shift|alt|meta)\+\S+$/i.test(token)) return true;
82
+ if (/^f\d{1,2}$/i.test(token)) return true;
83
+ return false;
84
+ }
85
+
86
+ /** One legacy hint string as a keycap hint, or null when it is prose (a note). */
87
+ function hintFromString(text: string): KitHint | null {
88
+ const trimmed = text.trim();
89
+ // Legacy "[Enter] Load" style.
90
+ const bracket = /^\[([^\]]+)\]\s+(.+)$/.exec(trimmed);
91
+ if (bracket) return [keycapLabel(bracket[1]!), bracket[2]!];
92
+ const space = trimmed.indexOf(' ');
93
+ if (space <= 0) return null;
94
+ const key = trimmed.slice(0, space);
95
+ const action = trimmed.slice(space + 1).trim();
96
+ if (!action || !isKeyToken(key)) return null;
97
+ return [keycapLabel(key), action];
98
+ }
99
+
100
+ /**
101
+ * Convert legacy hint strings into keycap hints plus notes. Hints whose key is
102
+ * already present (earlier in `base` or the list) are dropped, so a surface
103
+ * hint and the matching action hint show once.
104
+ */
105
+ export function kitHintsFromStrings(
106
+ strings: readonly string[],
107
+ base: readonly KitHint[] = [],
108
+ ): { readonly hints: KitHint[]; readonly notes: string[] } {
109
+ const hints: KitHint[] = [...base];
110
+ const notes: string[] = [];
111
+ const seen = new Set(base.map(([key]) => key));
112
+ for (const text of strings) {
113
+ if (!text.trim()) continue;
114
+ const hint = hintFromString(text);
115
+ if (!hint) {
116
+ if (!notes.includes(text.trim())) notes.push(text.trim());
117
+ continue;
118
+ }
119
+ if (seen.has(hint[0])) continue;
120
+ seen.add(hint[0]);
121
+ hints.push(hint);
122
+ }
123
+ return { hints, notes };
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Heights
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /** Rows a kit list needs, the blank row above each group but the first included. */
131
+ export function listHeight(rows: readonly KitRow[], x0: number, x1: number): number {
132
+ let total = 0;
133
+ rows.forEach((row, k) => {
134
+ total += measureRow(row, x0, x1);
135
+ if (k > 0 && isHeader(row)) total += 1;
136
+ });
137
+ return total;
138
+ }
139
+
140
+ /**
141
+ * The fill height a modal needs for `bodyRows` rows of body (from frame.top to
142
+ * frame.bottom), given its hints, clamped to what the screen allows. Pass the
143
+ * result as `height` to beginModal.
144
+ */
145
+ export function modalHeightFor(
146
+ screenW: number,
147
+ screenH: number,
148
+ options: ModalGeometryOptions & { readonly hints?: readonly KitHint[] },
149
+ bodyRows: number,
150
+ ): number {
151
+ const g = modalGeometry(screenW, screenH, { width: options.width });
152
+ const inner = Math.max(1, g.w - 2 * MODAL_PAD_X);
153
+ const hintRows = options.hints && options.hints.length > 0
154
+ ? layoutHintRows(options.hints, hintLayoutWidth(inner)).length
155
+ : 0;
156
+ // Fill rows: padding, title, blank, body..., [blank, hints...], padding.
157
+ const need = hintRows > 0 ? bodyRows + hintRows + 5 : bodyRows + 4;
158
+ return Math.max(5, Math.min(need, maxModalHeight(screenH)));
159
+ }
160
+
161
+ /** Text width between a modal's inner padding for a screen width (and optional preferred width). */
162
+ export function modalTextWidth(screenW: number, screenH: number, width?: number): number {
163
+ const g = modalGeometry(screenW, screenH, { width });
164
+ return Math.max(1, g.w - 2 * MODAL_PAD_X);
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Tabs that wrap
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /** Rows `labels` take when laid out as tabs from x to maxX. */
172
+ export function tabRowCount(labels: readonly string[], x: number, maxX: number): number {
173
+ return layoutTabs(labels, x, maxX).rows;
174
+ }
175
+
176
+ function layoutTabs(labels: readonly string[], x: number, maxX: number): { rows: number; at: Array<{ x: number; row: number }> } {
177
+ const at: Array<{ x: number; row: number }> = [];
178
+ let cx = x;
179
+ let row = 0;
180
+ labels.forEach((label, k) => {
181
+ const w = getDisplayWidth(label);
182
+ if (k > 0 && cx + w - 1 > maxX) {
183
+ row++;
184
+ cx = x;
185
+ }
186
+ at.push({ x: cx, row });
187
+ cx += w + 4;
188
+ });
189
+ return { rows: labels.length === 0 ? 0 : row + 1, at };
190
+ }
191
+
192
+ /**
193
+ * Tabs across one or more rows (a tab that does not fit moves to the next
194
+ * row; none is dropped). The active tab carries the gradient with dark bold
195
+ * text, the rest are muted. Returns the number of rows used.
196
+ */
197
+ export function drawTabRows(canvas: SurfaceCanvas, x: number, y: number, labels: readonly string[], active: number, maxX: number): number {
198
+ const t = activeTokens();
199
+ const layout = layoutTabs(labels, x, maxX);
200
+ labels.forEach((label, k) => {
201
+ const pos = layout.at[k]!;
202
+ const text = wrapLines(label, Math.max(1, maxX - pos.x + 1))[0] ?? label;
203
+ const w = getDisplayWidth(text);
204
+ if (k === active) {
205
+ canvas.grad(pos.x - 1, y + pos.row, w + 2, t.brand, t.brandEnd);
206
+ canvas.put(pos.x, y + pos.row, text, { fg: t.selectedListItemText, bold: true });
207
+ } else {
208
+ canvas.put(pos.x, y + pos.row, text, { fg: t.textMuted });
209
+ }
210
+ });
211
+ return layout.rows;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Text blocks
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /** A block of text lines, each wrapped in full, with its own style. */
219
+ export interface TextLine {
220
+ readonly text: string;
221
+ readonly style?: KitStyle;
222
+ }
223
+
224
+ /** Wrapped row count of a text block. */
225
+ export function textBlockHeight(lines: readonly TextLine[], width: number): number {
226
+ return lines.reduce((n, line) => n + (line.text === '' ? 1 : wrapLines(line.text, width).length), 0);
227
+ }
228
+
229
+ /** Draw a text block from row y (wrapping each line in full); returns the row after it. */
230
+ export function drawTextBlock(canvas: SurfaceCanvas, x: number, y: number, width: number, lines: readonly TextLine[], maxY = Number.POSITIVE_INFINITY): number {
231
+ const t = activeTokens();
232
+ let yy = y;
233
+ for (const line of lines) {
234
+ if (line.text === '') { yy++; continue; }
235
+ for (const part of wrapLines(line.text, width)) {
236
+ if (yy > maxY) return yy;
237
+ canvas.put(x, yy, part, { fg: t.text, ...line.style });
238
+ yy++;
239
+ }
240
+ }
241
+ return yy;
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // List and detail: a list on the left, an element inset on the right
246
+ // ---------------------------------------------------------------------------
247
+
248
+ /** Text width below which list-and-detail modals stack the inset under the list. */
249
+ const LIST_DETAIL_MIN_WIDTH = 60;
250
+
251
+ export interface ListDetailSplit {
252
+ /** The list's text span and rows (inclusive). */
253
+ readonly x0: number;
254
+ readonly x1: number;
255
+ readonly top: number;
256
+ readonly bottom: number;
257
+ /** The inset fill rectangle (text inside it keeps 2 columns of padding and a padding row). */
258
+ readonly detailX: number;
259
+ readonly detailY: number;
260
+ readonly detailW: number;
261
+ readonly detailH: number;
262
+ readonly stacked: boolean;
263
+ }
264
+
265
+ /**
266
+ * Split a modal body (rows top..bottom between text columns l..r) into a list
267
+ * and an element inset. Wide bodies put the list on the left (`ratio` of the
268
+ * width) and the inset on the right, reaching 2 columns from the fill's edge
269
+ * like the concept's palette preview; narrow bodies stack the inset under the
270
+ * list (`listRows` rows for the list, at least a third of the body).
271
+ */
272
+ export function splitListDetail(l: number, r: number, top: number, bottom: number, ratio = 0.45, listRows?: number): ListDetailSplit {
273
+ const inner = r - l + 1;
274
+ if (inner >= LIST_DETAIL_MIN_WIDTH) {
275
+ const x1 = l + Math.max(20, Math.floor(inner * ratio)) - 1;
276
+ return { x0: l, x1, top, bottom, detailX: x1 + 3, detailY: top, detailW: r - x1, detailH: bottom - top + 1, stacked: false };
277
+ }
278
+ const body = bottom - top + 1;
279
+ const rows = Math.max(3, Math.min(listRows ?? Math.floor(body / 2), body - 5));
280
+ const listBottom = top + rows - 1;
281
+ return { x0: l, x1: r, top, bottom: listBottom, detailX: l - 2, detailY: listBottom + 2, detailW: inner + 4, detailH: bottom - listBottom - 1, stacked: true };
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Layers as plain lines
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * A layer's cells placed on `height` blank lines of `width` columns, with no
290
+ * dimming (for a surface that owns the whole viewport, such as onboarding).
291
+ */
292
+ export function layerToLines(layer: SurfaceLayer, width: number, height: number): Line[] {
293
+ const lines: Line[] = Array.from({ length: height }, () => createEmptyLine(width));
294
+ layer.lines.forEach((row, r) => {
295
+ const target = lines[layer.y + r];
296
+ if (!target) return;
297
+ row.forEach((cell, c) => {
298
+ const x = layer.x + c;
299
+ if (x >= 0 && x < width) target[x] = { ...cell };
300
+ });
301
+ });
302
+ return lines;
303
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * surface-kit-list.ts, list rows and group headers for the modal surface kit.
3
+ *
4
+ * ✦ group header lowercase, accent (violet), bold; a blank row above
5
+ * every group except the first (dropped when a
6
+ * narrow or short list does not fit)
7
+ * ● Label description right metadata
8
+ * wrapped continuation of a long label or description
9
+ *
10
+ * The selected row is the one loud element: a brand → brandEnd gradient inset 2
11
+ * columns from each side of the fill, with dark bold text
12
+ * (selectedListItemText). Long labels and descriptions wrap onto continuation
13
+ * rows; nothing is clipped. When the list does not fit, group spacing collapses
14
+ * first (always when that makes it fit; on a narrow screen or a short list
15
+ * area in every case), then the list scrolls around the selected row and reports how many
16
+ * rows are hidden above and below (drawn as a muted "12 more ↓" count).
17
+ */
18
+
19
+ import { getDisplayWidth } from '../utils/terminal-width.ts';
20
+ import { activeTokens } from './theme.ts';
21
+ import { MODAL_MARK_INSET, SurfaceCanvas, scrollCountText, type KitStyle } from './surface-kit.ts';
22
+
23
+ /** A run of text with one style (used for custom labels such as fuzzy-match highlights). */
24
+ export interface KitSpan {
25
+ readonly text: string;
26
+ readonly fg?: string;
27
+ readonly bold?: boolean;
28
+ readonly italic?: boolean;
29
+ readonly strikethrough?: boolean;
30
+ }
31
+
32
+ /** One list row: a group header when `header` is set, an item otherwise. */
33
+ export interface KitRow {
34
+ readonly header?: string;
35
+ readonly headerRight?: string;
36
+ readonly label?: string;
37
+ /** Custom label spans; replaces `label` when set. */
38
+ readonly spans?: readonly KitSpan[];
39
+ /** Muted text after the label (wraps with it). */
40
+ readonly desc?: string;
41
+ /** Muted metadata right-aligned on the row's first line. */
42
+ readonly right?: string;
43
+ readonly rightFg?: string;
44
+ /** A marker 2 columns in from the fill edge (● ✓ ◐ ✕ …). */
45
+ readonly mark?: string;
46
+ readonly markFg?: string;
47
+ readonly selected?: boolean;
48
+ /** The active choice: label in the brand color when not selected. */
49
+ readonly current?: boolean;
50
+ /** A destructive / armed row: label in the error color; a red fill instead of the gradient when selected. */
51
+ readonly danger?: boolean;
52
+ readonly labelFg?: string;
53
+ readonly bold?: boolean;
54
+ /** Rows shown dimmer (unavailable entries). */
55
+ readonly muted?: boolean;
56
+ }
57
+
58
+ export function isHeader(row: KitRow): boolean {
59
+ return row.header !== undefined;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Wrapping styled spans
64
+ // ---------------------------------------------------------------------------
65
+
66
+ interface Piece { text: string; style: KitStyle }
67
+
68
+ /** Greedy word wrap over styled spans; each output row is a list of pieces. */
69
+ function wrapSpans(spans: readonly Piece[], width: number): Piece[][] {
70
+ const rows: Piece[][] = [[]];
71
+ let used = 0;
72
+ const pushText = (text: string, style: KitStyle): void => {
73
+ const row = rows[rows.length - 1]!;
74
+ const last = row[row.length - 1];
75
+ if (last && last.style === style) last.text += text;
76
+ else row.push({ text, style });
77
+ used += getDisplayWidth(text);
78
+ };
79
+ const newRow = (): void => { rows.push([]); used = 0; };
80
+ for (const span of spans) {
81
+ // Split into words keeping the separating spaces attached to the front.
82
+ const words = span.text.split(/(?= )/);
83
+ for (const raw of words) {
84
+ let word = raw;
85
+ const w = getDisplayWidth(word);
86
+ if (used > 0 && used + w > width) {
87
+ newRow();
88
+ word = word.replace(/^ +/, '');
89
+ } else if (used === 0) {
90
+ word = word.replace(/^ +/, '');
91
+ }
92
+ // A word longer than the row: hard-split it.
93
+ while (getDisplayWidth(word) > width) {
94
+ let cut = '';
95
+ let cw = 0;
96
+ for (const ch of word) {
97
+ const chw = getDisplayWidth(ch);
98
+ if (used + cw + chw > width) break;
99
+ cut += ch;
100
+ cw += chw;
101
+ }
102
+ if (cut.length === 0) { newRow(); continue; }
103
+ pushText(cut, span.style);
104
+ word = word.slice(cut.length);
105
+ newRow();
106
+ }
107
+ if (word.length > 0) pushText(word, span.style);
108
+ }
109
+ }
110
+ return rows.filter((row, k) => row.length > 0 || k === 0);
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Rows
115
+ // ---------------------------------------------------------------------------
116
+
117
+ interface RowLayout {
118
+ readonly lines: Piece[][];
119
+ readonly right?: string;
120
+ }
121
+
122
+ function rowPieces(row: KitRow): Piece[] {
123
+ const t = activeTokens();
124
+ const sel = row.selected === true;
125
+ const ink = t.selectedListItemText;
126
+ const labelFg = sel ? ink
127
+ : row.danger ? t.error
128
+ : row.current ? t.brand
129
+ : row.muted ? t.textFaint
130
+ : row.labelFg ?? t.text;
131
+ const pieces: Piece[] = [];
132
+ if (row.spans) {
133
+ for (const span of row.spans) {
134
+ pieces.push({ text: span.text, style: { fg: sel ? ink : span.fg ?? labelFg, bold: sel || span.bold === true, italic: span.italic, strikethrough: span.strikethrough } });
135
+ }
136
+ } else {
137
+ pieces.push({ text: row.label ?? '', style: { fg: labelFg, bold: sel || row.bold === true } });
138
+ }
139
+ if (row.desc) pieces.push({ text: ` ${row.desc}`, style: { fg: sel ? ink : t.textMuted } });
140
+ return pieces;
141
+ }
142
+
143
+ function layoutRow(row: KitRow, x0: number, x1: number): RowLayout {
144
+ const width = Math.max(1, x1 - x0 + 1);
145
+ if (isHeader(row)) return { lines: [[{ text: row.header!.toLowerCase(), style: {} }]], right: row.headerRight };
146
+ const rightW = row.right ? getDisplayWidth(row.right) + 2 : 0;
147
+ const textW = Math.max(4, width - rightW);
148
+ const lines = wrapSpans(rowPieces(row), textW);
149
+ return { lines, right: row.right };
150
+ }
151
+
152
+ /** Rows a list row takes at the given text span. */
153
+ export function measureRow(row: KitRow, x0: number, x1: number): number {
154
+ return layoutRow(row, x0, x1).lines.length;
155
+ }
156
+
157
+ /**
158
+ * Draw one row (header or item) with its text starting at x0 and right
159
+ * metadata ending at x1. Returns the number of canvas rows used.
160
+ */
161
+ export function drawRow(canvas: SurfaceCanvas, y: number, row: KitRow, x0: number, x1: number, maxY = Number.POSITIVE_INFINITY): number {
162
+ const t = activeTokens();
163
+ const layout = layoutRow(row, x0, x1);
164
+ if (isHeader(row)) {
165
+ canvas.put(x0 - MODAL_MARK_INSET, y, '✦', { fg: t.brandEnd });
166
+ canvas.put(x0, y, row.header!.toLowerCase(), { fg: t.accent, bold: true });
167
+ if (row.headerRight) canvas.right(x1, y, row.headerRight, { fg: t.textFaint });
168
+ return 1;
169
+ }
170
+ const sel = row.selected === true;
171
+ const ink = t.selectedListItemText;
172
+ const used = Math.min(layout.lines.length, Math.max(1, maxY - y + 1));
173
+ if (sel) {
174
+ // An armed (danger) row is red, not the gradient: it is about to do something destructive.
175
+ const span = x1 - x0 + 1 + 2 * MODAL_MARK_INSET;
176
+ for (let k = 0; k < used; k++) {
177
+ if (row.danger) canvas.tint(x0 - MODAL_MARK_INSET, y + k, span, t.error);
178
+ else canvas.grad(x0 - MODAL_MARK_INSET, y + k, span, t.brand, t.brandEnd);
179
+ }
180
+ }
181
+ if (row.mark) canvas.put(x0 - MODAL_MARK_INSET, y, row.mark, { fg: sel ? ink : row.markFg ?? t.brand, bold: sel });
182
+ for (let k = 0; k < used; k++) {
183
+ let cx = x0;
184
+ for (const piece of layout.lines[k]!) cx = canvas.put(cx, y + k, piece.text, piece.style);
185
+ }
186
+ if (layout.right) canvas.right(x1, y, layout.right, { fg: sel ? ink : row.rightFg ?? t.textFaint, bold: false });
187
+ return used;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Lists
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export interface KitListOptions {
195
+ readonly rows: readonly KitRow[];
196
+ /** First and last canvas rows the list may use (inclusive). */
197
+ readonly top: number;
198
+ readonly bottom: number;
199
+ readonly x0: number;
200
+ readonly x1: number;
201
+ /** Row index to start from when scrolling (clamped so the selected row stays visible). */
202
+ readonly scrollStart?: number;
203
+ /**
204
+ * Remember this list's scroll position between renders (keyed by an owner
205
+ * object, usually the modal's state, and a list name), so the window only
206
+ * moves when the selection leaves it instead of re-anchoring every frame.
207
+ */
208
+ readonly scrollKey?: { readonly owner: object; readonly name: string };
209
+ }
210
+
211
+ const scrollMemory = new WeakMap<object, Map<string, number>>();
212
+
213
+ /** The remembered first row of a list (see KitListOptions.scrollKey). */
214
+ export function rememberedStart(key: KitListOptions['scrollKey']): number | undefined {
215
+ return key ? scrollMemory.get(key.owner)?.get(key.name) : undefined;
216
+ }
217
+
218
+ /** Record a list's first row for the next render. */
219
+ export function rememberStart(key: KitListOptions['scrollKey'], start: number): void {
220
+ if (!key) return;
221
+ let names = scrollMemory.get(key.owner);
222
+ if (!names) {
223
+ names = new Map();
224
+ scrollMemory.set(key.owner, names);
225
+ }
226
+ names.set(key.name, start);
227
+ }
228
+
229
+ export interface KitListResult {
230
+ /** Item rows (headers excluded) hidden above / below the window. */
231
+ readonly above: number;
232
+ readonly below: number;
233
+ /** Index of the first row drawn. */
234
+ readonly start: number;
235
+ /** Canvas row after the last row drawn. */
236
+ readonly endY: number;
237
+ }
238
+
239
+ interface ListPlan {
240
+ readonly capacity: number;
241
+ readonly heights: readonly number[];
242
+ /** Whether the blank row above each group header (but the first) is drawn. */
243
+ readonly gaps: boolean;
244
+ /** True when the rows do not all fit and the list scrolls. */
245
+ readonly scrolls: boolean;
246
+ /** Canvas rows that rows from..to take, spacing included when `gaps` is on. */
247
+ readonly spanOf: (from: number, to: number) => number;
248
+ }
249
+
250
+ /** Heights, group spacing and whether the list scrolls, by the rules drawList documents. */
251
+ function planList(canvas: SurfaceCanvas, options: KitListOptions): ListPlan {
252
+ const { rows, top, bottom, x0, x1 } = options;
253
+ const capacity = Math.max(1, bottom - top + 1);
254
+ const heights = rows.map((row) => measureRow(row, x0, x1));
255
+ // Prefix sums keep every span query O(1): catalogs run to thousands of rows.
256
+ const heightPre = [0];
257
+ const headerPre = [0];
258
+ for (let i = 0; i < rows.length; i++) {
259
+ heightPre.push(heightPre[i]! + heights[i]!);
260
+ headerPre.push(headerPre[i]! + (isHeader(rows[i]!) ? 1 : 0));
261
+ }
262
+ const span = (from: number, to: number, withGaps: boolean): number => {
263
+ if (to < from) return 0;
264
+ const base = heightPre[to + 1]! - heightPre[from]!;
265
+ // A blank row above every header after the first row of the span.
266
+ return withGaps ? base + (headerPre[to + 1]! - headerPre[from + 1]!) : base;
267
+ };
268
+ const last = rows.length - 1;
269
+ if (rows.length === 0 || span(0, last, true) <= capacity) {
270
+ return { capacity, heights, gaps: true, scrolls: false, spanOf: (from, to) => span(from, to, true) };
271
+ }
272
+ // Spacing collapses before anything is cut; when the list scrolls anyway it
273
+ // stays on a wide screen with room, and goes on a narrow screen or a short list.
274
+ const scrolls = span(0, last, false) > capacity;
275
+ const gaps = scrolls && !canvas.compact && capacity >= SHORT_LIST_ROWS;
276
+ return { capacity, heights, gaps, scrolls, spanOf: (from, to) => span(from, to, gaps) };
277
+ }
278
+
279
+ /**
280
+ * The furthest first row a list can scroll to and still fill its area: for
281
+ * callers that track their own scroll position (drawList's scrollStart) and
282
+ * need to know where the end is, by the same spacing rules drawList uses.
283
+ */
284
+ export function listScrollEnd(canvas: SurfaceCanvas, options: KitListOptions): number {
285
+ const plan = planList(canvas, options);
286
+ if (!plan.scrolls) return 0;
287
+ const last = options.rows.length - 1;
288
+ let start = last;
289
+ while (start > 0 && plan.spanOf(start - 1, last) <= plan.capacity) start--;
290
+ return start;
291
+ }
292
+
293
+ /**
294
+ * A list shorter than this many rows drops its group spacing as soon as it
295
+ * scrolls: on a short screen every row of content is worth more than the gap.
296
+ */
297
+ const SHORT_LIST_ROWS = 10;
298
+
299
+ /**
300
+ * Draw a list of rows between `top` and `bottom`, keeping the selected row in
301
+ * view. Group spacing (the blank row above each header but the first) stays
302
+ * while the list scrolls on a wide screen. It collapses before any row is cut
303
+ * when everything would fit without it, and it is dropped whenever the list
304
+ * does not fit on a narrow screen (below 90 columns) or a short list area.
305
+ */
306
+ export function drawList(canvas: SurfaceCanvas, options: KitListOptions): KitListResult {
307
+ const { rows, top, bottom, x0, x1 } = options;
308
+ const plan = planList(canvas, options);
309
+ const { gaps, heights } = plan;
310
+ let start = 0;
311
+ const last = rows.length - 1;
312
+ if (plan.scrolls) {
313
+ const { capacity, spanOf } = plan;
314
+ const selected = rows.findIndex((row) => row.selected === true);
315
+ start = Math.max(0, Math.min(rememberedStart(options.scrollKey) ?? options.scrollStart ?? 0, last));
316
+ if (selected >= 0) {
317
+ if (selected < start) start = selected;
318
+ while (start < selected && spanOf(start, selected) > capacity) start++;
319
+ // Show the selected row's group header when there is room for it.
320
+ if (start > 0 && start === selected && isHeader(rows[start - 1]!) && spanOf(start - 1, selected) <= capacity) start--;
321
+ }
322
+ // Do not leave empty space at the end when scrolled down.
323
+ while (start > 0 && spanOf(start - 1, last) <= capacity) start--;
324
+ }
325
+
326
+ rememberStart(options.scrollKey, start);
327
+ let y = top;
328
+ let lastDrawn = start - 1;
329
+ for (let i = start; i < rows.length; i++) {
330
+ const gap = gaps && i > start && isHeader(rows[i]!) ? 1 : 0;
331
+ if (y + gap > bottom) break;
332
+ // A header with no room for an item under it is left for the next page.
333
+ if (isHeader(rows[i]!) && y + gap + 1 > bottom && i < rows.length - 1) break;
334
+ y += gap;
335
+ const used = drawRow(canvas, y, rows[i]!, x0, x1, bottom);
336
+ y += used;
337
+ lastDrawn = i;
338
+ if (used < heights[i]!) break;
339
+ }
340
+ const countItems = (from: number, to: number): number => {
341
+ let n = 0;
342
+ for (let i = from; i <= to; i++) if (!isHeader(rows[i]!)) n++;
343
+ return n;
344
+ };
345
+ return {
346
+ above: countItems(0, start - 1),
347
+ below: countItems(lastDrawn + 1, last),
348
+ start,
349
+ endY: y,
350
+ };
351
+ }
352
+
353
+ /**
354
+ * drawList for a list that has no hint row of its own: when rows are hidden,
355
+ * the list's last row carries the muted scroll count ("3 more ↑ · 12 more ↓").
356
+ */
357
+ export function drawScrollingList(canvas: SurfaceCanvas, options: KitListOptions): KitListResult {
358
+ const scratch = new SurfaceCanvas(canvas.width, canvas.height);
359
+ scratch.compact = canvas.compact;
360
+ const trial = drawList(scratch, options);
361
+ if (trial.above === 0 && trial.below === 0) return drawList(canvas, options);
362
+ const result = drawList(canvas, { ...options, bottom: options.bottom - 1 });
363
+ const text = scrollCountText(result.above, result.below);
364
+ if (text) canvas.right(options.x1, options.bottom, text, { fg: activeTokens().textFaint });
365
+ return result;
366
+ }