@pellux/goodvibes-tui 2.0.21 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (388) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +16 -16
  3. package/docs/README.md +4 -4
  4. package/docs/foundation-artifacts/operator-contract.json +1 -1
  5. package/package.json +16 -6
  6. package/src/cli/management.ts +5 -2
  7. package/src/cli/tui-startup.ts +34 -39
  8. package/src/core/agent-view-render.ts +157 -0
  9. package/src/core/approval-alert.ts +49 -11
  10. package/src/core/bookmark-navigation.ts +7 -1
  11. package/src/core/budget-breach-notifier.ts +27 -8
  12. package/src/core/compaction-receipt.ts +1 -1
  13. package/src/core/context-usage.ts +1 -1
  14. package/src/core/conversation-compaction-render.ts +3 -3
  15. package/src/core/conversation-display-only.ts +86 -0
  16. package/src/core/conversation-event-navigation.ts +27 -0
  17. package/src/core/conversation-line-cache.ts +216 -447
  18. package/src/core/conversation-render-context.ts +20 -27
  19. package/src/core/conversation-rendering.ts +157 -575
  20. package/src/core/conversation-splash-state.ts +6 -1
  21. package/src/core/conversation-types.ts +35 -0
  22. package/src/core/conversation-usage.ts +12 -0
  23. package/src/core/conversation.ts +130 -131
  24. package/src/core/long-task-notifier.ts +100 -31
  25. package/src/core/notices.ts +75 -0
  26. package/src/core/pairing-tailscale-gateway.ts +1 -1
  27. package/src/core/process-output-log.ts +143 -0
  28. package/src/core/rewind-receipt.ts +1 -1
  29. package/src/core/session-focus.ts +190 -0
  30. package/src/core/session-resume-core.ts +23 -81
  31. package/src/core/stream-event-wiring.ts +31 -5
  32. package/src/core/system-message-noise.ts +2 -2
  33. package/src/core/system-message-router.ts +16 -18
  34. package/src/core/turn-event-wiring.ts +272 -33
  35. package/src/core/work-tree-focus.ts +290 -0
  36. package/src/core/work-tree-fold-store.ts +143 -0
  37. package/src/core/work-tree-model.ts +799 -0
  38. package/src/core/work-tree-render.ts +384 -0
  39. package/src/core/work-tree-sources.ts +184 -0
  40. package/src/core/work-tree-wiring.ts +176 -0
  41. package/src/core/workstream-notification.ts +36 -6
  42. package/src/core/wrfc-notice-titles.ts +37 -0
  43. package/src/input/agents-modal-text.ts +119 -0
  44. package/src/input/agents-modal.ts +699 -0
  45. package/src/input/bookmark-modal.ts +33 -9
  46. package/src/input/changes-git.ts +251 -0
  47. package/src/input/changes-modal.ts +629 -0
  48. package/src/input/changes-model.ts +125 -0
  49. package/src/input/command-palette-types.ts +41 -0
  50. package/src/input/command-palette.ts +272 -0
  51. package/src/input/command-registry.ts +77 -35
  52. package/src/input/commands/acp-runtime.ts +19 -9
  53. package/src/input/commands/channel-runtime.ts +2 -2
  54. package/src/input/commands/ci-runtime.ts +1 -1
  55. package/src/input/commands/codebase-runtime.ts +1 -1
  56. package/src/input/commands/config.ts +7 -1
  57. package/src/input/commands/context-window.ts +25 -19
  58. package/src/input/commands/control-room-runtime.ts +18 -18
  59. package/src/input/commands/conversation-runtime.ts +0 -1
  60. package/src/input/commands/cost-runtime.ts +29 -20
  61. package/src/input/commands/diff-runtime.ts +35 -181
  62. package/src/input/commands/eval.ts +1 -1
  63. package/src/input/commands/experience-runtime.ts +3 -7
  64. package/src/input/commands/fresh-conversation.ts +86 -0
  65. package/src/input/commands/git-runtime.ts +5 -22
  66. package/src/input/commands/graph-runtime.ts +1 -1
  67. package/src/input/commands/guidance-runtime.ts +1 -1
  68. package/src/input/commands/health-runtime.ts +8 -9
  69. package/src/input/commands/hooks-runtime.ts +2 -2
  70. package/src/input/commands/hosted-runtime.ts +3 -3
  71. package/src/input/commands/incident-runtime.ts +3 -3
  72. package/src/input/commands/intelligence-runtime.ts +6 -9
  73. package/src/input/commands/knowledge.ts +2 -2
  74. package/src/input/commands/legacy-panel-command.ts +57 -0
  75. package/src/input/commands/local-auth-runtime.ts +8 -9
  76. package/src/input/commands/local-provider-runtime.ts +7 -4
  77. package/src/input/commands/local-runtime.ts +12 -15
  78. package/src/input/commands/local-setup-review.ts +1 -1
  79. package/src/input/commands/local-setup.ts +13 -19
  80. package/src/input/commands/memory.ts +1 -1
  81. package/src/input/commands/notify-runtime.ts +9 -0
  82. package/src/input/commands/operator-runtime.ts +10 -12
  83. package/src/input/commands/permissions-provenance.ts +3 -3
  84. package/src/input/commands/planning-runtime.ts +7 -7
  85. package/src/input/commands/platform-sandbox-runtime.ts +2 -2
  86. package/src/input/commands/platform-services-runtime.ts +1 -1
  87. package/src/input/commands/policy-dispatch.ts +2 -2
  88. package/src/input/commands/policy.ts +3 -3
  89. package/src/input/commands/provider.ts +1 -1
  90. package/src/input/commands/remote-runtime.ts +1 -1
  91. package/src/input/commands/review-runtime.ts +39 -94
  92. package/src/input/commands/rewind-runtime.ts +81 -108
  93. package/src/input/commands/runtime-services.ts +15 -17
  94. package/src/input/commands/services-runtime.ts +2 -2
  95. package/src/input/commands/session-content.ts +21 -5
  96. package/src/input/commands/session-workflow.ts +13 -38
  97. package/src/input/commands/settings-sync-runtime.ts +2 -2
  98. package/src/input/commands/shell-core.ts +19 -4
  99. package/src/input/commands/skills-runtime.ts +2 -2
  100. package/src/input/commands/status-runtime.ts +13 -0
  101. package/src/input/commands/subscription-runtime.ts +1 -1
  102. package/src/input/commands/tasks-runtime.ts +2 -8
  103. package/src/input/commands/work-plan-runtime.ts +4 -4
  104. package/src/input/commands/workstream-attempts.ts +24 -47
  105. package/src/input/commands/workstream-runtime.ts +2 -2
  106. package/src/input/commands/worktree-runtime.ts +3 -3
  107. package/src/input/commands.ts +3 -1
  108. package/src/input/composer-editor.ts +44 -0
  109. package/src/input/concealed-input.ts +1 -1
  110. package/src/input/config-modal-types.ts +20 -8
  111. package/src/input/config-modal.ts +106 -105
  112. package/src/input/confirm-dialog-types.ts +28 -0
  113. package/src/input/confirm-dialog.ts +70 -0
  114. package/src/input/delete-key-policy.ts +3 -3
  115. package/src/input/feed-context-factory.ts +15 -16
  116. package/src/input/handler-command-route.ts +7 -42
  117. package/src/input/handler-content-actions.ts +16 -15
  118. package/src/input/handler-feed-routes.ts +26 -211
  119. package/src/input/handler-feed.ts +68 -95
  120. package/src/input/handler-interactions.ts +5 -3
  121. package/src/input/handler-modal-routes.ts +215 -216
  122. package/src/input/handler-modal-stack.ts +81 -11
  123. package/src/input/handler-modal-token-routes.ts +4 -0
  124. package/src/input/handler-onboarding.ts +0 -1
  125. package/src/input/handler-picker-routes.ts +65 -107
  126. package/src/input/handler-session-view-route.ts +149 -0
  127. package/src/input/handler-shortcuts.ts +16 -154
  128. package/src/input/handler-types.ts +4 -2
  129. package/src/input/handler-ui-state.ts +45 -15
  130. package/src/input/handler-work-tree-route.ts +123 -0
  131. package/src/input/handler.ts +25 -70
  132. package/src/input/keybindings.ts +16 -71
  133. package/src/input/masked-entry-modal.ts +117 -0
  134. package/src/input/mcp-workspace.ts +48 -17
  135. package/src/input/model-picker-filter.ts +8 -0
  136. package/src/input/model-picker-open.ts +99 -0
  137. package/src/input/model-picker-types.ts +1 -1
  138. package/src/input/model-picker.ts +92 -9
  139. package/src/input/notifications-modal.ts +123 -0
  140. package/src/input/overlay-filter.ts +26 -0
  141. package/src/input/profile-picker-modal.ts +37 -8
  142. package/src/input/search.ts +3 -3
  143. package/src/input/selection-modal-queue.ts +2 -0
  144. package/src/input/selection-modal.ts +28 -16
  145. package/src/input/session-picker-modal.ts +33 -8
  146. package/src/input/settings-modal-activation.ts +7 -2
  147. package/src/input/settings-modal-data.ts +11 -6
  148. package/src/input/settings-modal-types.ts +1 -1
  149. package/src/input/settings-modal.ts +20 -20
  150. package/src/input/submission-intent.ts +1 -1
  151. package/src/input/submission-router.ts +4 -9
  152. package/src/input/surface-modal-host.ts +110 -0
  153. package/src/input/theme-settings-actions.ts +82 -0
  154. package/src/input/usage-modal.ts +183 -0
  155. package/src/input/views.ts +97 -0
  156. package/src/main.ts +175 -165
  157. package/src/permissions/hunk-selection.ts +1 -1
  158. package/src/permissions/prompt-card.ts +511 -0
  159. package/src/permissions/prompt.ts +59 -622
  160. package/src/renderer/agents-modal.ts +310 -0
  161. package/src/renderer/autocomplete-overlay.ts +42 -220
  162. package/src/renderer/back-to-bottom.ts +80 -0
  163. package/src/renderer/block-actions-overlay.ts +30 -48
  164. package/src/renderer/bookmark-modal.ts +48 -79
  165. package/src/renderer/changes-modal.ts +325 -0
  166. package/src/renderer/code-block.ts +184 -106
  167. package/src/renderer/command-palette.ts +93 -0
  168. package/src/renderer/composer-fragments.ts +4 -4
  169. package/src/renderer/composer.ts +146 -0
  170. package/src/renderer/compositor.ts +26 -160
  171. package/src/renderer/config-modal.ts +185 -81
  172. package/src/renderer/confirm-dialog.ts +50 -0
  173. package/src/renderer/context-inspector.ts +116 -154
  174. package/src/renderer/context-status-hint.ts +4 -4
  175. package/src/renderer/conversation-layout.ts +20 -0
  176. package/src/renderer/conversation-overlays.ts +94 -84
  177. package/src/renderer/conversation-surface.ts +19 -38
  178. package/src/renderer/diff-tint.ts +98 -0
  179. package/src/renderer/file-picker-overlay.ts +63 -90
  180. package/src/renderer/footer-targets.ts +38 -0
  181. package/src/renderer/git-status.ts +3 -4
  182. package/src/renderer/header-line.ts +140 -117
  183. package/src/renderer/help-overlay.ts +133 -219
  184. package/src/renderer/history-search-overlay.ts +15 -12
  185. package/src/renderer/lane-graph/bead.ts +800 -0
  186. package/src/renderer/lane-graph/glyphs.ts +134 -0
  187. package/src/renderer/lane-graph/layout.ts +248 -0
  188. package/src/renderer/lane-graph/paint.ts +537 -0
  189. package/src/renderer/lane-graph/semantic-memo.ts +77 -0
  190. package/src/renderer/layout-engine.ts +2 -57
  191. package/src/renderer/layout.ts +7 -13
  192. package/src/renderer/markdown-inline.ts +2 -1
  193. package/src/renderer/markdown-table.ts +17 -12
  194. package/src/renderer/markdown.ts +92 -34
  195. package/src/renderer/masked-entry-modal.ts +78 -0
  196. package/src/renderer/mcp-workspace.ts +166 -203
  197. package/src/renderer/model-workspace.ts +381 -436
  198. package/src/renderer/notifications-modal.ts +116 -0
  199. package/src/renderer/onboarding/onboarding-wizard.ts +178 -588
  200. package/src/renderer/process-view.ts +192 -0
  201. package/src/renderer/profile-picker-modal.ts +58 -110
  202. package/src/renderer/search-overlay.ts +6 -3
  203. package/src/renderer/selection-modal-overlay.ts +87 -327
  204. package/src/renderer/semantic-diff.ts +2 -2
  205. package/src/renderer/session-chips.ts +102 -0
  206. package/src/renderer/session-picker-modal.ts +177 -297
  207. package/src/renderer/settings-modal-connections.ts +14 -23
  208. package/src/renderer/settings-modal-context.ts +367 -0
  209. package/src/renderer/settings-modal-helpers.ts +6 -11
  210. package/src/renderer/settings-modal.ts +322 -726
  211. package/src/renderer/shell-surface.ts +277 -175
  212. package/src/renderer/shortcuts-overlay.ts +217 -0
  213. package/src/renderer/status-line.ts +475 -0
  214. package/src/renderer/surface-compose.ts +174 -0
  215. package/src/renderer/surface-kit-extra.ts +303 -0
  216. package/src/renderer/surface-kit-list.ts +366 -0
  217. package/src/renderer/surface-kit-parts.ts +360 -0
  218. package/src/renderer/surface-kit.ts +568 -0
  219. package/src/renderer/syntax-highlighter.ts +280 -197
  220. package/src/renderer/syntax-theme.ts +51 -0
  221. package/src/renderer/system-message.ts +0 -1
  222. package/src/renderer/term-caps.ts +42 -1
  223. package/src/renderer/terminal-bg-probe.ts +116 -19
  224. package/src/renderer/terminal-palette-probe.ts +322 -0
  225. package/src/renderer/terminal-palette-reader.ts +183 -0
  226. package/src/renderer/terminal-palette.ts +58 -0
  227. package/src/renderer/theme-mode-config.ts +16 -1
  228. package/src/renderer/theme.ts +234 -207
  229. package/src/renderer/thinking.ts +0 -1
  230. package/src/renderer/throbber.ts +188 -0
  231. package/src/renderer/toast-center.ts +119 -0
  232. package/src/renderer/ui-factory.ts +39 -602
  233. package/src/renderer/ui-primitives.ts +8 -25
  234. package/src/renderer/usage-modal.ts +353 -0
  235. package/src/renderer/user-message.ts +58 -0
  236. package/src/renderer/voice-capture-chip.ts +53 -0
  237. package/src/runtime/bootstrap-command-context.ts +0 -5
  238. package/src/runtime/bootstrap-command-parts.ts +38 -89
  239. package/src/runtime/bootstrap-core.ts +13 -15
  240. package/src/runtime/bootstrap-hook-bridge.ts +12 -21
  241. package/src/runtime/bootstrap-notifier-sync.ts +17 -1
  242. package/src/runtime/bootstrap-shell.ts +16 -50
  243. package/src/runtime/bootstrap.ts +14 -14
  244. package/src/runtime/client/fleet-union.ts +5 -5
  245. package/src/runtime/client/hosted-exit.ts +2 -2
  246. package/src/runtime/client/hosted-sessions.ts +1 -1
  247. package/src/runtime/client/spine-adoption.ts +1 -1
  248. package/src/runtime/context-accounting-source.ts +5 -4
  249. package/src/runtime/index.ts +1 -0
  250. package/src/runtime/notification-dispatch.ts +102 -29
  251. package/src/runtime/recovery-offer-wiring.ts +4 -1
  252. package/src/runtime/recovery-prompt.ts +31 -15
  253. package/src/runtime/runtime-services-types.ts +10 -6
  254. package/src/runtime/services.ts +17 -12
  255. package/src/runtime/session-continuity-hints.ts +2 -5
  256. package/src/runtime/session-inbound-inputs.ts +1 -1
  257. package/src/runtime/session-resume-liveness-confirm.ts +14 -2
  258. package/src/runtime/startup-typeahead-gate.ts +103 -0
  259. package/src/runtime/store/domains/domain-read-matrix.ts +0 -1
  260. package/src/runtime/store/domains/index.ts +0 -8
  261. package/src/runtime/store/selectors/index.ts +0 -28
  262. package/src/runtime/store/state.ts +0 -8
  263. package/src/runtime/ui-services.ts +4 -6
  264. package/src/runtime/usage-tracker.ts +348 -0
  265. package/src/runtime/wrfc-persistence.ts +4 -4
  266. package/src/shell/blocking-input.ts +92 -2
  267. package/src/shell/recovery-input-helpers.ts +1 -1
  268. package/src/shell/session-views.ts +539 -0
  269. package/src/shell/status-report.ts +86 -0
  270. package/src/shell/transcript-scroll.ts +68 -0
  271. package/src/shell/ui-openers.ts +84 -172
  272. package/src/shell/view-openers.ts +205 -0
  273. package/src/tools/wrfc-agent-guard.ts +22 -2
  274. package/src/utils/format-duration.ts +4 -8
  275. package/src/utils/splash-lines.ts +9 -1
  276. package/src/utils/terminal-width.ts +56 -0
  277. package/src/verification/verification-ledger.ts +18 -16
  278. package/src/{panels → views}/agent-inspector-shared.ts +2 -2
  279. package/src/{panels → views}/builtin-modals.ts +22 -21
  280. package/src/views/builtin-views.ts +135 -0
  281. package/src/{panels → views}/confirm-state.ts +9 -9
  282. package/src/{panels → views}/diff-review-model.ts +10 -3
  283. package/src/{panels → views}/eval-registry.ts +1 -1
  284. package/src/{panels → views}/fleet-acts.ts +49 -85
  285. package/src/{panels/fleet-panel-format.ts → views/fleet-format.ts} +40 -40
  286. package/src/{panels → views}/fleet-gateway.ts +5 -5
  287. package/src/{panels → views}/fleet-observed-render.ts +16 -16
  288. package/src/{panels → views}/fleet-read-model.ts +9 -9
  289. package/src/{panels/fleet-tabs.ts → views/fleet-session-tabs.ts} +13 -13
  290. package/src/{panels → views}/fleet-spawn.ts +33 -49
  291. package/src/{panels → views}/fleet-steer.ts +12 -12
  292. package/src/{panels → views}/fleet-stop.ts +4 -4
  293. package/src/{panels → views}/fleet-transcript.ts +26 -27
  294. package/src/{panels/fleet-panel-worktree-detail.ts → views/fleet-worktree-detail.ts} +7 -7
  295. package/src/{panels → views}/hosted-session-feed.ts +6 -6
  296. package/src/views/modal-surface-registry.ts +41 -0
  297. package/src/{panels → views}/modals/devices-modal.ts +2 -2
  298. package/src/{panels → views}/modals/hooks-modal.ts +13 -13
  299. package/src/{panels → views}/modals/keybindings-modal.ts +19 -27
  300. package/src/{panels → views}/modals/knowledge-modal.ts +6 -5
  301. package/src/{panels → views}/modals/local-auth-modal.ts +22 -21
  302. package/src/{panels → views}/modals/marketplace-modal.ts +11 -11
  303. package/src/{panels → views}/modals/memory-modal.ts +8 -8
  304. package/src/{panels → views}/modals/modal-surface-helpers.ts +15 -5
  305. package/src/{panels → views}/modals/pairing-modal.ts +9 -9
  306. package/src/{panels → views}/modals/planning-modal.ts +15 -20
  307. package/src/{panels → views}/modals/plugins-modal.ts +6 -6
  308. package/src/{panels → views}/modals/policy-modal.ts +13 -5
  309. package/src/{panels → views}/modals/provider-health-modal.ts +4 -4
  310. package/src/{panels → views}/modals/remote-modal.ts +3 -3
  311. package/src/{panels → views}/modals/sandbox-modal.ts +4 -4
  312. package/src/{panels → views}/modals/security-modal.ts +22 -16
  313. package/src/{panels → views}/modals/services-modal.ts +3 -3
  314. package/src/{panels → views}/modals/settings-sync-modal.ts +4 -4
  315. package/src/{panels → views}/modals/skills-modal.ts +9 -9
  316. package/src/{panels → views}/modals/subscription-modal.ts +3 -3
  317. package/src/{panels → views}/modals/work-plan-modal.ts +10 -13
  318. package/src/views/notifications-feed.ts +314 -0
  319. package/src/{panels → views}/polish-core.ts +38 -36
  320. package/src/{panels → views}/polish-tables.ts +35 -26
  321. package/src/{panels → views}/polish.ts +101 -95
  322. package/src/{panels → views}/project-planning-answer-actions.ts +3 -3
  323. package/src/{panels → views}/provider-health-routes.ts +14 -14
  324. package/src/{panels → views}/search-focus.ts +8 -8
  325. package/src/{panels → views}/session-maintenance.ts +11 -11
  326. package/src/views/skills-discovery.ts +159 -0
  327. package/src/views/view-deps.ts +80 -0
  328. package/src/views/view-panel-adapter.ts +53 -0
  329. package/src/{panels → views}/workstream-graph-render.ts +1 -1
  330. package/src/core/conversation-fold.ts +0 -101
  331. package/src/core/conversation-turn-structure.ts +0 -464
  332. package/src/core/conversation-user-receipts.ts +0 -42
  333. package/src/input/commands/operator-panel-runtime.ts +0 -186
  334. package/src/input/panel-integration-actions.ts +0 -33
  335. package/src/input/panel-mouse-geometry.ts +0 -97
  336. package/src/input/panel-paste-flood-guard.ts +0 -86
  337. package/src/panels/base-panel.ts +0 -301
  338. package/src/panels/builtin/agent.ts +0 -27
  339. package/src/panels/builtin/development.ts +0 -92
  340. package/src/panels/builtin/knowledge.ts +0 -12
  341. package/src/panels/builtin/operations.ts +0 -177
  342. package/src/panels/builtin/session.ts +0 -81
  343. package/src/panels/builtin/shared.ts +0 -357
  344. package/src/panels/builtin-panels.ts +0 -28
  345. package/src/panels/cost-tracker-panel.ts +0 -746
  346. package/src/panels/diff-panel.ts +0 -797
  347. package/src/panels/diff-review-panel.ts +0 -395
  348. package/src/panels/expandable-list-panel.ts +0 -189
  349. package/src/panels/fleet-deep-link.ts +0 -31
  350. package/src/panels/fleet-panel.ts +0 -799
  351. package/src/panels/git-panel.ts +0 -800
  352. package/src/panels/hosted-session-panel.ts +0 -221
  353. package/src/panels/index.ts +0 -21
  354. package/src/panels/local-auth-panel.ts +0 -478
  355. package/src/panels/notifications-feed.ts +0 -127
  356. package/src/panels/notifications-panel.ts +0 -160
  357. package/src/panels/panel-confirm-overlay.ts +0 -72
  358. package/src/panels/panel-manager.ts +0 -791
  359. package/src/panels/plugins-panel.ts +0 -371
  360. package/src/panels/scrollable-list-panel.ts +0 -498
  361. package/src/panels/skills-panel.ts +0 -454
  362. package/src/panels/token-budget-panel.ts +0 -688
  363. package/src/panels/types.ts +0 -191
  364. package/src/panels/wrfc-panel-format.ts +0 -133
  365. package/src/renderer/diff-view.ts +0 -134
  366. package/src/renderer/fleet-tab-strip.ts +0 -56
  367. package/src/renderer/footer-tips.ts +0 -53
  368. package/src/renderer/fullscreen-primitives.ts +0 -146
  369. package/src/renderer/fullscreen-workspace.ts +0 -194
  370. package/src/renderer/hint-grammar.ts +0 -52
  371. package/src/renderer/modal-factory.ts +0 -480
  372. package/src/renderer/overlay-box.ts +0 -154
  373. package/src/renderer/overlay-viewport.ts +0 -104
  374. package/src/renderer/panel-composite.ts +0 -145
  375. package/src/renderer/panel-workspace-bar.ts +0 -54
  376. package/src/renderer/process-indicator.ts +0 -163
  377. package/src/renderer/tab-strip.ts +0 -222
  378. package/src/renderer/text-layout.ts +0 -31
  379. package/src/renderer/tool-call.ts +0 -276
  380. package/src/renderer/tool-result-expanded-lines.ts +0 -48
  381. package/src/renderer/tool-result-summary.ts +0 -219
  382. package/src/runtime/diagnostics/panels/index.ts +0 -21
  383. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  384. package/src/runtime/perf/panel-contracts.ts +0 -32
  385. package/src/runtime/store/domains/panels.ts +0 -117
  386. /package/src/{panels → views}/memory-consolidation-gateway.ts +0 -0
  387. /package/src/{panels → views}/modals/modal-theme.ts +0 -0
  388. /package/src/{panels → views}/provider-health-domains.ts +0 -0
@@ -1,779 +1,375 @@
1
1
  /**
2
- * Fullscreen configuration workspace.
2
+ * renderSettingsModal, the settings modal drawn with the surface kit.
3
3
  *
4
- * This intentionally does not use ModalFactory. Configuration needs a stable,
5
- * roomy workspace with contextual documentation, not a cramped modal list.
4
+ * ✦ Settings › Display › Theme esc
5
+ *
6
+ * ▏Search all settings display
7
+ *
8
+ * ✦ interface ┌ element inset ─────────────────────────────┐
9
+ * Display 9 ◇ Theme goodvibes
10
+ * UI 4 Color palette for the whole interface…
11
+ * ✦ ai routing display.theme · default goodvibes · …
12
+ * Provider 7 Theme mode dark
13
+ *
14
+ * ↑↓ move ⏎ change ←→ category ctrl+r reset to default
15
+ *
16
+ * Search reaches every setting in every category at once (the search row is
17
+ * always live). Categories on the left, plain rows on the right: name, value,
18
+ * and a ◇ when the value differs from the default. The selected row explains
19
+ * itself in place, wrapped in full, with its key, default, type and source
20
+ * (PgUp/PgDn scroll long documentation). The breadcrumb in the title says
21
+ * where you are. Changes to the theme preview live (see theme-settings-actions).
6
22
  */
7
23
 
8
- import type { Line } from '@pellux/goodvibes-sdk/platform/types';
9
- import type { SettingsModal, SettingEntry, McpEntry, SubscriptionEntry, SettingsCategory } from '../input/settings-modal.ts';
24
+ import type { SettingsModal, SettingEntry, SettingsCategory } from '../input/settings-modal.ts';
10
25
  import { SETTINGS_CATEGORIES, SETTINGS_CATEGORY_GROUPS } from '../input/settings-modal.ts';
11
- import { FEATURE_SETTINGS_BY_ID, isFeatureValueEnabled } from '@pellux/goodvibes-terminal-shell';
12
- import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
13
- import { CATEGORY_INFO, CATEGORY_LABELS, describeUiRouting, formatValue, getSettingLabel, inferSubscriptionRouteReason, valueColor } from './settings-modal-helpers.ts';
14
- import { buildConnectionContext, renderConnectionRows } from './settings-modal-connections.ts';
15
- import { isSecretConfigKey } from '../config/secret-config.ts';
16
- import { maskConcealedText } from '../input/concealed-input.ts';
17
- import { CVV_PROMPT_TRADEOFF_WARNING } from '@pellux/goodvibes-sdk/platform/payments';
18
- import { GLYPHS } from './ui-primitives.ts';
19
- import { formatHints, joinHints } from './hint-grammar.ts';
26
+ import { isFeatureValueEnabled } from '@pellux/goodvibes-terminal-shell';
27
+ import { CATEGORY_LABELS, getSettingLabel, inferSubscriptionRouteReason, valueColor } from './settings-modal-helpers.ts';
28
+ import { connectionRows } from './settings-modal-connections.ts';
29
+ import { categoryItemCount, currentSettingValue, settingContextLines } from './settings-modal-context.ts';
30
+ import { activeTokens } from './theme.ts';
31
+ import { getDisplayWidth } from '../utils/terminal-width.ts';
20
32
  import {
21
- clamp,
22
- getFullscreenWorkspaceMetrics,
23
- padDisplay,
24
- renderFullscreenWorkspace,
25
- stableWindow,
26
- WORKSPACE_PALETTE as PALETTE,
27
- type WorkspaceRow,
28
- } from './fullscreen-workspace.ts';
29
-
30
-
31
- const ENUM_VALUE_DESCRIPTIONS: Record<string, Record<string, string>> = {
32
- 'display.themeMode': {
33
- auto: 'Probe the terminal background colour (OSC 11) once at startup and pick light or dark. Falls back to dark on unreadable/unsupported terminals. Only evaluated at startup: selecting auto takes effect next launch.',
34
- dark: 'Force the dark theme regardless of terminal background. Applies immediately.',
35
- light: 'Force the light theme regardless of terminal background. Applies immediately.',
36
- },
37
- 'behavior.hitlMode': {
38
- quiet: 'Minimize operational interruptions and surface fewer Human-in-the-Loop prompts.',
39
- balanced: 'Show important Human-in-the-Loop prompts without turning routine work into noise.',
40
- operator: 'Surface more operational detail for users actively supervising agents, tools, services, and automation.',
41
- },
42
- 'behavior.guidanceMode': {
43
- off: 'Do not add extra guidance beyond direct command output.',
44
- minimal: 'Show concise guidance only when it helps avoid mistakes.',
45
- guided: 'Provide more explanation and next-step context during configuration and operations.',
46
- },
47
- 'permissions.mode': {
48
- prompt: 'Normal: ask before powerful or risky actions according to tool policy.',
49
- plan: 'Plan mode: read-only planning posture; writes, commands, and network calls are blocked so the model can plan without changing anything. Toggle with /plan or Shift+Tab.',
50
- 'accept-edits': 'Accept edits: file writes and edits are auto-approved, but exec, network, and escalations are still gated.',
51
- 'allow-all': 'Auto: allow all actions without prompting. Fast, but removes an important safety gate.',
52
- custom: 'Use per-tool-class permission settings from the rows below.',
53
- },
54
- 'permissions.backgroundAgents': {
55
- inherit: 'Background and subagent tool calls run through the SAME session permission mode as foreground work.',
56
- 'allow-all': 'Background and subagent tool calls are exempt from prompting (auto-approved) even when foreground work is gated.',
57
- },
58
- 'diagnostics.postEdit': {
59
- on: 'After the model edits or writes a file, run language diagnostics on it and surface any new problems.',
60
- off: 'Do not run diagnostics automatically after edits.',
61
- },
62
- 'storage.secretPolicy': {
63
- preferred_secure: 'Use secure secret storage when available, with supported fallback behavior.',
64
- require_secure: 'Require secure secret storage and reject plaintext fallback.',
65
- plaintext_allowed: 'Allow plaintext fallback when secure storage is unavailable.',
66
- },
67
- 'batch.mode': {
68
- off: 'Keep daemon work on the immediate local path.',
69
- explicit: 'Use batch only when callers explicitly request batch execution.',
70
- 'eligible-by-default': 'Allow eligible daemon work to use the batch path unless callers opt out.',
71
- },
72
- 'controlPlane.hostMode': {
73
- localhost: 'Bind only to this computer.',
74
- network: 'Bind for LAN access using the default network host.',
75
- custom: 'Use the explicit host value in the related host setting.',
76
- },
77
- 'httpListener.hostMode': {
78
- localhost: 'Bind only to this computer.',
79
- network: 'Bind for LAN/webhook access using the default network host.',
80
- custom: 'Use the explicit host value in the related host setting.',
81
- },
82
- 'web.hostMode': {
83
- localhost: 'Serve the browser UI only on this computer.',
84
- network: 'Serve the browser UI on the LAN.',
85
- custom: 'Use the explicit host value in the related host setting.',
86
- },
87
- 'ui.systemMessages': {
88
- panel: 'Show system messages in panels only.',
89
- conversation: 'Show system messages inline in the transcript.',
90
- both: 'Show system messages in both panels and the transcript.',
91
- },
92
- 'ui.operationalMessages': {
93
- panel: 'Show operational messages in panels only.',
94
- conversation: 'Show operational messages inline in the transcript.',
95
- both: 'Show operational messages in both panels and the transcript.',
96
- },
97
- 'ui.wrfcMessages': {
98
- panel: 'Show WRFC messages in panels only.',
99
- conversation: 'Show WRFC messages inline in the transcript.',
100
- both: 'Show WRFC messages in both panels and the transcript.',
101
- },
102
- 'surfaces.telegram.mode': {
103
- webhook: 'Receive Telegram updates through webhook delivery.',
104
- polling: 'Poll Telegram for updates from the service.',
105
- },
106
- 'surfaces.whatsapp.provider': {
107
- 'meta-cloud': 'Use Meta Cloud API credentials and identifiers.',
108
- bridge: 'Use a bridge service URL/token flow instead of direct Meta Cloud API delivery.',
109
- },
110
- 'payments.cvvHandling': {
111
- stored: 'Store the CVV through the secret manager so the daemon can complete a purchase unattended.',
112
- prompt: CVV_PROMPT_TRADEOFF_WARNING,
113
- },
114
- };
115
-
116
- function paddedWrapped(text: string, width: number, prefix = ''): string[] {
117
- const safeWidth = Math.max(1, width - getDisplayWidth(prefix));
118
- const wrapped = wrapText(text, safeWidth);
119
- if (prefix.length === 0) return wrapped;
120
- return wrapped.map((line, index) => `${index === 0 ? prefix : ' '.repeat(getDisplayWidth(prefix))}${line}`);
121
- }
122
-
123
- function formatDefaultValue(value: unknown): string {
124
- if (value === '') return '(empty)';
125
- if (value === null || value === undefined) return '(unset)';
126
- return String(value);
127
- }
128
-
129
- function currentSettingValue(modal: SettingsModal, entry: SettingEntry, selected: boolean): string {
130
- if (selected && modal.editingMode) {
131
- // Secret-backed keys (surfaces.*.botToken, .signingSecret, etc., see
132
- // secret-config.ts) must never echo the in-progress plaintext buffer:
133
- // not in the row, not in the "Current: ..." context line, not in search
134
- // results. Reuse the composer's concealed-input mask (concealed-input.ts)
135
- // rather than a second masking implementation, same bullet-per-character
136
- // shape, so keystrokes still visibly register without revealing content.
137
- const buffer = isSecretConfigKey(entry.setting.key) ? maskConcealedText(modal.editBuffer) : modal.editBuffer;
138
- return `${buffer}${GLYPHS.surface.cursor}`;
139
- }
140
- return formatValue(entry);
141
- }
142
-
143
- function buildSettingContext(modal: SettingsModal, entry: SettingEntry): string[] {
144
- const lines: string[] = [
145
- getSettingLabel(entry),
146
- `Key: ${entry.setting.key}`,
147
- `Current: ${currentSettingValue(modal, entry, true)}`,
148
- `Default: ${formatDefaultValue(entry.setting.default)}`,
149
- `Type: ${entry.setting.type}${entry.setting.enumValues ? ` with ${entry.setting.enumValues.length} possible value(s)` : ''}`,
150
- `Source: ${entry.effectiveSource ?? 'default'}${entry.sourceLabel ? ` from ${entry.sourceLabel}` : ''}`,
151
- ];
152
-
153
- if (entry.locked) lines.push(`Locked: ${entry.lockReason ?? 'This setting is locked by a higher-priority layer.'}`);
154
- if (entry.conflict) lines.push(`Conflict: resolve with /settings-sync resolve ${entry.setting.key} local|synced.`);
155
-
156
- // A settings sub-row owned by a feature unit names its feature so "what does this do" is answerable without scrolling back to the header row.
157
- if (entry.ownerFlagId) {
158
- const owner = FEATURE_SETTINGS_BY_ID.get(entry.ownerFlagId);
159
- if (owner) lines.push(`Part of feature: ${owner.name} (the header row above).`);
160
- }
161
-
162
- lines.push('', entry.setting.description);
163
-
164
- if (
165
- entry.setting.key === 'ui.systemMessages'
166
- || entry.setting.key === 'ui.operationalMessages'
167
- || entry.setting.key === 'ui.wrfcMessages'
168
- ) {
169
- lines.push(`Routing meaning: ${describeUiRouting(String(entry.currentValue))}.`);
170
- }
171
-
172
- if (entry.setting.type === 'boolean') {
173
- lines.push('');
174
- lines.push('Possible values:');
175
- lines.push('true: enabled or allowed for this setting.');
176
- lines.push('false: disabled or not allowed for this setting.');
177
- }
178
-
179
- if (entry.setting.type === 'enum' && entry.setting.enumValues) {
180
- lines.push('');
181
- lines.push('Possible values:');
182
- const descriptions = ENUM_VALUE_DESCRIPTIONS[entry.setting.key] ?? {};
183
- for (const value of entry.setting.enumValues) {
184
- lines.push(`${value}: ${descriptions[value] ?? `Use ${value} for this setting.`}`);
185
- }
186
- }
187
-
188
- if (isSecretConfigKey(entry.setting.key)) {
189
- lines.push('');
190
- lines.push('Secret handling: raw values entered here are stored through the secret manager and the config receives a goodvibes:// secret reference. Empty input clears the config value.');
191
- }
192
-
193
- if (entry.setting.type === 'number') {
194
- lines.push('');
195
- lines.push('Editing: Enter opens inline edit, then type the value and press Enter to save. Arrow keys only navigate.');
196
- }
197
-
198
- if (entry.setting.type === 'string' && !isSecretConfigKey(entry.setting.key)) {
199
- lines.push('');
200
- lines.push('Editing: Enter opens inline edit. Delete the current text to save an empty value when that is valid for the setting.');
201
- }
202
-
203
- return lines;
204
- }
33
+ beginModal,
34
+ clipText,
35
+ finishModal,
36
+ scrollCountText,
37
+ searchRow,
38
+ tailText,
39
+ wrapLines,
40
+ type KitHint,
41
+ type ModalFrame,
42
+ type SurfaceLayer,
43
+ } from './surface-kit.ts';
44
+ import { drawRow, drawScrollingList, measureRow, rememberStart, rememberedStart, type KitRow } from './surface-kit-list.ts';
45
+ import { inset, type KitInset } from './surface-kit-parts.ts';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Rows
49
+ // ---------------------------------------------------------------------------
205
50
 
206
51
  /**
207
- * The option shape of a feature header, rendered from the same schema the
208
- * write path uses: enum headers list every mode choice (marking the current
209
- * value and which values keep the feature active), boolean headers state the
210
- * two positions.
52
+ * Keep a long value from crowding the name out (the documentation shows it in
53
+ * full). While editing, the value gets all the room the label leaves, and a
54
+ * draft longer than that keeps its end (where the cursor is) in view.
211
55
  */
212
- function buildFeatureOptionLines(entry: SettingEntry): string[] {
213
- const feature = entry.flag!.feature;
214
- const setting = entry.setting;
215
- const lines: string[] = [];
216
- if (setting.type === 'enum' && setting.enumValues) {
217
- lines.push('', `Mode choices for ${setting.key}:`);
218
- const activeValues = feature.enablement.enabledValues ?? [];
219
- const descriptions = ENUM_VALUE_DESCRIPTIONS[setting.key] ?? {};
220
- for (const value of setting.enumValues) {
221
- const marks: string[] = [];
222
- if (value === String(entry.currentValue)) marks.push('current');
223
- if (feature.enablement.kind === 'enum') {
224
- marks.push(activeValues.includes(value) ? 'feature on' : 'feature off');
225
- }
226
- const suffix = marks.length > 0 ? ` (${marks.join(', ')})` : '';
227
- lines.push(`${value}${suffix}: ${descriptions[value] ?? `Use ${value} for this setting.`}`);
228
- }
229
- } else if (setting.type === 'boolean') {
230
- lines.push('');
231
- lines.push('Possible values:');
232
- lines.push('true: the feature is enabled.');
233
- lines.push('false: the feature is disabled.');
234
- }
235
- return lines;
56
+ function fitValue(value: string, width: number, editing: boolean, label = ''): string {
57
+ if (editing) return tailText(value, Math.max(8, width - getDisplayWidth(label) - 4));
58
+ return clipText(value, Math.max(8, Math.floor(width * 0.45)));
236
59
  }
237
60
 
238
- /**
239
- * Under-cursor documentation for a feature-unit header, rendered entirely
240
- * from the SDK's per-feature settings metadata: full behavior description,
241
- * the real option shape, every settings key that tunes the feature, and the
242
- * honest live/restart state from the gate manager.
243
- */
244
- function buildFlagContext(entry: SettingEntry | null): string[] {
245
- const flagEntry = entry?.flag ?? null;
246
- if (!entry || !flagEntry) return ['Features', 'No feature is selected.'];
247
- const { feature, flag, state, persistedState, pendingRestart } = flagEntry;
248
- const configOn = isFeatureValueEnabled(feature, entry.currentValue);
249
- const displayState = state === 'killed' ? 'killed' : configOn ? 'enabled' : 'disabled';
250
- const lines: string[] = [
251
- feature.name,
252
- `Feature: ${feature.id} (${feature.domain} domain)`,
253
- `Setting: ${feature.enablement.key} = ${formatValue(entry)}`,
254
- `State: ${displayState}`,
255
- // A capability the registry declares not operable reads as disabled no
256
- // matter what its settings key says. Without this line the row shows a
257
- // value of true beside a state of disabled and explains nothing, the
258
- // written reason exists in the registry, so render it where the user meets
259
- // the contradiction rather than leaving it to be discovered.
260
- ...(feature.operable === false && feature.inoperableDetail
261
- ? [`Not available in this build: ${feature.inoperableDetail}`]
262
- : []),
263
- `Default: ${feature.defaultEnabled ? 'enabled' : 'disabled'}`,
264
- `Applies: ${feature.restartRequired ? 'on next launch (startup-gated)' : 'immediately'}`,
265
- ...(pendingRestart
266
- ? [`Pending restart: saved as ${persistedState}; effective state stays ${state} until the next launch.`]
267
- : []),
268
- '',
269
- feature.description,
270
- ];
271
-
272
- lines.push(...buildFeatureOptionLines(entry));
273
-
274
- lines.push('');
275
- if (feature.enablement.kind === 'enum') {
276
- lines.push(`How it turns on: active while ${feature.enablement.key} is ${(feature.enablement.enabledValues ?? []).join(' or ')}.`);
277
- } else if (feature.enablement.kind === 'constant' && entry.setting.type !== 'boolean') {
278
- lines.push('How it turns on: always active; the settings listed below tune its behavior directly.');
279
- } else {
280
- lines.push(`How it turns on: ${feature.enablement.key} set to true.`);
281
- }
282
-
283
- if (feature.settings.length > 1) {
284
- lines.push('', 'Settings in this feature:');
285
- for (const key of feature.settings) {
286
- lines.push(key === feature.enablement.key ? `${key} (this row)` : key);
287
- }
288
- }
289
-
290
- if (state === 'killed' && flag.killReason) {
291
- lines.push('', `Kill reason: ${flag.killReason}`);
61
+ function settingRow(modal: SettingsModal, entry: SettingEntry, selected: boolean, focused: boolean, width: number, category?: string): KitRow {
62
+ const t = activeTokens();
63
+ const editing = selected && modal.editingMode;
64
+ const danger = modal.currentCategory === 'danger' && !modal.searchFocused;
65
+ if (entry.flag) {
66
+ const { feature, state, pendingRestart } = entry.flag;
67
+ const configOn = isFeatureValueEnabled(feature, entry.currentValue);
68
+ const raw = currentSettingValue(modal, entry, selected);
69
+ // A startup-gated feature changed this session shows its saved value with
70
+ // a restart-pending marker (⟳); the documentation spells it out.
71
+ const value = pendingRestart ? `${raw} ⟳` : raw;
72
+ return {
73
+ label: feature.name,
74
+ desc: category,
75
+ right: fitValue(value, width, editing, feature.name),
76
+ rightFg: valueColor(entry),
77
+ mark: state === 'killed' ? '✕' : configOn ? '●' : '○',
78
+ markFg: state === 'killed' ? t.error : configOn ? t.success : t.textFaint,
79
+ selected: selected && focused,
80
+ current: selected && !focused,
81
+ labelFg: danger ? t.error : undefined,
82
+ };
292
83
  }
293
- return lines;
294
- }
295
-
296
- function buildMcpContext(modal: SettingsModal, entry: McpEntry | null): string[] {
297
- if (!entry) return ['MCP trust', 'No MCP server is selected.'];
298
- const scope = entry.allowedPaths.length > 0
299
- ? `Allowed paths: ${entry.allowedPaths.join(', ')}`
300
- : entry.allowedHosts.length > 0
301
- ? `Allowed hosts: ${entry.allowedHosts.join(', ')}`
302
- : 'No explicit path or host scope is configured.';
303
- const confirmation = modal.mcpAllowAllConfirmationTarget === entry.name
304
- ? `Confirmation required: type ALLOW ALL ${entry.name} to grant unrestricted trust.`
305
- : 'Enter edits the trust mode. Valid values are constrained, ask-on-risk, allow-all, and blocked.';
306
- return [
307
- entry.name,
308
- `Connection: ${entry.connected ? 'connected' : 'disconnected'}`,
309
- `Role: ${entry.role}`,
310
- `Trust mode: ${entry.trustMode}`,
311
- confirmation,
312
- '',
313
- scope,
314
- '',
315
- 'Trust meanings:',
316
- 'constrained: keep MCP activity inside declared paths/hosts and prompt on risk.',
317
- 'ask-on-risk: allow routine MCP operations but ask before risky behavior.',
318
- 'allow-all: allow unrestricted MCP operations for this server after explicit confirmation.',
319
- 'blocked: prevent this MCP server from being used.',
320
- ];
84
+ const flags = [entry.locked ? 'locked' : '', entry.conflict ? 'conflict' : ''].filter(Boolean).join(' · ');
85
+ const desc = [category, flags].filter(Boolean).join(' · ');
86
+ return {
87
+ // Sub-options of a feature unit read as part of the unit above them.
88
+ label: entry.ownerFlagId ? `· ${getSettingLabel(entry)}` : getSettingLabel(entry),
89
+ desc: desc || undefined,
90
+ right: fitValue(currentSettingValue(modal, entry, selected), width, editing, getSettingLabel(entry)),
91
+ rightFg: valueColor(entry),
92
+ mark: entry.isDefault ? undefined : '◇',
93
+ markFg: t.warning,
94
+ selected: selected && focused,
95
+ current: selected && !focused,
96
+ labelFg: danger ? t.error : undefined,
97
+ };
321
98
  }
322
99
 
323
- function buildSubscriptionContext(modal: SettingsModal, entry: SubscriptionEntry | null): string[] {
324
- if (!entry) return ['Subscriptions', 'No subscription provider is selected.'];
325
- const expires = entry.expiresAt ? new Date(entry.expiresAt).toISOString() : 'not reported';
326
- const routeReason = inferSubscriptionRouteReason(entry);
327
- const logout = entry.state === 'active' || entry.state === 'pending'
328
- ? modal.subscriptionLogoutConfirmationTarget === entry.provider
329
- ? `Sign out ${entry.provider}? Enter/y to confirm, n/Esc to cancel.`
330
- : 'Press Enter to begin sign-out for this provider session.'
331
- : `Use /subscription login ${entry.provider} start to begin OAuth sign-in for this provider.`;
332
- return [
333
- entry.provider,
334
- `State: ${entry.state}`,
335
- ...(routeReason ? [routeReason] : []),
336
- logout,
337
- `Active route: ${entry.activeRoute ?? 'n/a'}`,
338
- `Preferred route: ${entry.preferredRoute ?? 'n/a'}`,
339
- `OAuth configured: ${entry.oauthConfigured ? 'yes' : 'no'}`,
340
- `Freshness: ${entry.authFreshness ?? 'n/a'}`,
341
- `Expires: ${expires}`,
342
- ...((entry.issues ?? []).length > 0 ? ['', 'Issues:', ...(entry.issues ?? [])] : []),
343
- ...((entry.nextActions ?? []).length > 0 ? ['', 'Next actions:', ...(entry.nextActions ?? [])] : []),
344
- ];
100
+ function categoryOf(entry: SettingEntry): string {
101
+ const prefix = entry.setting.key.split('.')[0] ?? '';
102
+ return CATEGORY_LABELS[prefix as SettingsCategory] ?? prefix;
345
103
  }
346
104
 
347
- function buildContextLines(modal: SettingsModal, width: number): string[] {
348
- const category = modal.currentCategory;
349
-
350
- // Search mode: show context for the selected search result, or a help blurb
105
+ /** The rows of the right-hand list for the current view, and which one is selected. */
106
+ function listRows(modal: SettingsModal, width: number): { rows: KitRow[]; selected: number } {
107
+ const focused = modal.searchFocused || (modal.focusRegion ?? 'settings') === 'settings';
108
+ const clampIndex = (n: number): number => Math.max(0, Math.min(modal.selectedIndex, n - 1));
351
109
  if (modal.searchFocused) {
352
- const selected = modal.getSelected();
353
- const lines: string[] = ['Search Results'];
354
- if (selected) {
355
- lines.push(...buildSettingContext(modal, selected));
356
- } else {
357
- lines.push(
358
- modal.searchQuery.trim().length === 0
359
- ? 'Type a query to search across all settings categories.'
360
- : 'No settings matched the search query.',
361
- );
110
+ const results = modal.searchResults;
111
+ if (results.length === 0) return { rows: [], selected: -1 };
112
+ const sel = clampIndex(results.length);
113
+ return { rows: results.map((entry, i) => settingRow(modal, entry, i === sel, true, width, categoryOf(entry))), selected: sel };
114
+ }
115
+ const t = activeTokens();
116
+ switch (modal.currentCategory) {
117
+ case 'mcp': {
118
+ const items = modal.mcpEntries;
119
+ if (items.length === 0) return { rows: [{ label: 'No MCP servers registered.', muted: true }], selected: -1 };
120
+ const sel = clampIndex(items.length);
121
+ return {
122
+ rows: items.map((entry, i) => {
123
+ const scope = entry.allowedPaths.length > 0 ? entry.allowedPaths.join(', ') : entry.allowedHosts.length > 0 ? entry.allowedHosts.join(', ') : 'no scope';
124
+ const trust = i === sel && modal.editingMode ? `${modal.editBuffer}▏` : entry.trustMode;
125
+ return {
126
+ label: entry.name,
127
+ desc: `${entry.role} · ${scope}`,
128
+ right: trust,
129
+ mark: entry.connected ? '●' : '○',
130
+ markFg: entry.connected ? t.success : t.textFaint,
131
+ selected: i === sel && focused,
132
+ current: i === sel && !focused,
133
+ };
134
+ }),
135
+ selected: sel,
136
+ };
362
137
  }
363
- const wrapped: string[] = [];
364
- for (const line of lines) {
365
- if (line === '') { wrapped.push(''); continue; }
366
- wrapped.push(...paddedWrapped(line, width));
138
+ case 'subscriptions': {
139
+ const items = modal.subscriptionEntries;
140
+ if (items.length === 0) return { rows: [{ label: 'No provider subscriptions available or configured.', muted: true }], selected: -1 };
141
+ const sel = clampIndex(items.length);
142
+ return {
143
+ rows: items.map((entry, i) => ({
144
+ label: entry.provider,
145
+ desc: [entry.activeRoute ?? '', entry.authFreshness ?? '', inferSubscriptionRouteReason(entry) ?? ''].filter(Boolean).join(' · ') || undefined,
146
+ right: entry.state,
147
+ rightFg: entry.state === 'active' ? t.success : undefined,
148
+ selected: i === sel && focused,
149
+ current: i === sel && !focused,
150
+ })),
151
+ selected: sel,
152
+ };
367
153
  }
368
- return wrapped;
369
- }
370
-
371
- const lines: string[] = [
372
- `${CATEGORY_LABELS[category]} configuration`,
373
- ];
374
-
375
- if (category === 'mcp') {
376
- lines.push(...buildMcpContext(modal, modal.getSelectedMcp()));
377
- } else if (category === 'subscriptions') {
378
- lines.push(...buildSubscriptionContext(modal, modal.getSelectedSubscription()));
379
- } else if (category === 'connections') {
380
- lines.push(...buildConnectionContext(modal));
381
- } else {
382
- const selected = modal.getSelected();
383
- // A feature-unit header shows the feature's documentation (full
384
- // description, option shape, settings list, live/restart state); its
385
- // settings sub-rows and plain settings show the setting context.
386
- if (selected?.flag) lines.push(...buildFlagContext(selected));
387
- else if (selected) lines.push(...buildSettingContext(modal, selected));
388
- else lines.push('No setting is selected in this category.');
389
- }
390
-
391
- lines.push('', `Category purpose: ${CATEGORY_INFO[category]}`);
392
-
393
- const wrapped: string[] = [];
394
- for (const line of lines) {
395
- if (line === '') {
396
- wrapped.push('');
397
- continue;
154
+ case 'connections':
155
+ return { rows: connectionRows(modal, focused), selected: modal.connectionEntries.length > 0 ? clampIndex(modal.connectionEntries.length) : -1 };
156
+ default: {
157
+ const items = modal.currentItems;
158
+ if (items.length === 0) return { rows: [{ label: 'No settings in this category.', muted: true }], selected: -1 };
159
+ const sel = clampIndex(items.length);
160
+ return { rows: items.map((entry, i) => settingRow(modal, entry, i === sel, focused, width)), selected: sel };
398
161
  }
399
- wrapped.push(...paddedWrapped(line, width));
400
162
  }
401
- return wrapped;
402
163
  }
403
164
 
404
- function categoryItemCount(modal: SettingsModal, category: SettingsCategory): number {
405
- if (category === 'mcp') return modal.mcpEntries.length;
406
- if (category === 'subscriptions') return modal.subscriptionEntries.length;
407
- if (category === 'connections') return modal.connectionEntries.length;
408
- return modal.groups.get(category)?.length ?? 0;
409
- }
410
-
411
- type CategoryRailEntry =
412
- | { readonly type: 'group'; readonly label: string }
413
- | { readonly type: 'category'; readonly category: SettingsCategory; readonly index: number };
414
-
415
- type CategoryRailRow = {
416
- readonly text: string;
417
- readonly type: CategoryRailEntry['type'] | 'more' | 'empty';
418
- readonly selected: boolean;
419
- };
420
-
421
- function buildCategoryRailEntries(): CategoryRailEntry[] {
422
- const entries: CategoryRailEntry[] = [];
165
+ function categoryRows(modal: SettingsModal): KitRow[] {
166
+ const focused = !modal.searchFocused && modal.focusRegion === 'categories';
167
+ const rows: KitRow[] = [];
423
168
  for (const group of SETTINGS_CATEGORY_GROUPS) {
424
- const categories = group.categories.filter(category => SETTINGS_CATEGORIES.includes(category));
169
+ const categories = group.categories.filter((category) => SETTINGS_CATEGORIES.includes(category));
425
170
  if (categories.length === 0) continue;
426
- entries.push({ type: 'group', label: group.label });
171
+ rows.push({ header: group.label });
427
172
  for (const category of categories) {
428
- entries.push({
429
- type: 'category',
430
- category,
431
- index: SETTINGS_CATEGORIES.indexOf(category),
173
+ const active = SETTINGS_CATEGORIES.indexOf(category) === modal.categoryIndex && !modal.searchFocused;
174
+ const count = categoryItemCount(modal, category);
175
+ rows.push({
176
+ label: CATEGORY_LABELS[category],
177
+ right: count > 0 ? String(count) : undefined,
178
+ selected: active && focused,
179
+ current: active && !focused,
432
180
  });
433
181
  }
434
182
  }
435
- return entries;
183
+ return rows;
436
184
  }
437
185
 
438
- function renderCategories(modal: SettingsModal, width: number, height: number): CategoryRailRow[] {
439
- const rows: CategoryRailRow[] = [];
440
- const entries = buildCategoryRailEntries();
441
- const selectedEntryIndex = Math.max(0, entries.findIndex(entry => entry.type === 'category' && entry.index === modal.categoryIndex));
442
- const window = stableWindow(entries.length, selectedEntryIndex, height);
443
- if (window.start > 0) rows.push({ text: `${GLYPHS.navigation.moreAbove} ${window.start} more row(s) above`, type: 'more', selected: false });
444
- for (let railIndex = window.start; railIndex < window.end; railIndex += 1) {
445
- const entry = entries[railIndex]!;
446
- if (entry.type === 'group') {
447
- rows.push({ text: entry.label.toUpperCase(), type: 'group', selected: false });
186
+ // ---------------------------------------------------------------------------
187
+ // Documentation block
188
+ // ---------------------------------------------------------------------------
189
+
190
+ interface DocLine { readonly text: string; readonly fg: string; readonly bold?: boolean }
191
+
192
+ const FACT_PREFIXES = ['Key:', 'Current:', 'Default:', 'Type:', 'Source:', 'Feature:', 'Setting:', 'Applies:', 'Connection:', 'Role:', 'Trust mode:', 'State:', 'Active route:', 'Preferred route:', 'OAuth configured:', 'Freshness:', 'Expires:'];
193
+ const WARN_PREFIXES = ['Locked:', 'Conflict:', 'Pending restart:', 'Not available in this build:', 'Confirmation required:', 'Kill reason:', 'Secret handling:'];
194
+
195
+ /** Fact lines folded into one compact line under the selected row (the row itself shows the current value). */
196
+ const FOLDED_FACTS: ReadonlyArray<readonly [string, (value: string) => string]> = [
197
+ ['Key: ', (v) => v],
198
+ ['Default: ', (v) => `default ${v}`],
199
+ ['Type: ', (v) => v],
200
+ ['Source: ', (v) => `source ${v}`],
201
+ ];
202
+
203
+ function docLines(modal: SettingsModal, hasSelection: boolean, width: number): DocLine[] {
204
+ const t = activeTokens();
205
+ const source = settingContextLines(modal);
206
+ // With a selected row, the first line (its name) is already on the row.
207
+ const lines = hasSelection ? source.slice(1) : source;
208
+ const facts: string[] = [];
209
+ const rest: string[] = [];
210
+ for (const line of lines) {
211
+ if (line.startsWith('Current: ')) continue;
212
+ const fact = FOLDED_FACTS.find(([prefix]) => line.startsWith(prefix));
213
+ if (fact) facts.push(fact[1](line.slice(fact[0].length)));
214
+ else rest.push(line);
215
+ }
216
+ const out: DocLine[] = [];
217
+ if (facts.length > 0) for (const wrapped of wrapLines(facts.join(' · '), width)) out.push({ text: wrapped, fg: t.textFaint });
218
+ for (const line of rest) {
219
+ if (line === '') {
220
+ if (out.length > 0 && out[out.length - 1]!.text !== '') out.push({ text: '', fg: t.textMuted });
448
221
  continue;
449
222
  }
450
- const category = entry.category;
451
- const active = entry.index === modal.categoryIndex;
452
- const count = categoryItemCount(modal, category);
453
- const cursor = active ? (modal.focusPane === 'categories' ? GLYPHS.navigation.selected : '•') : ' ';
454
- rows.push({ text: ` ${cursor} ${CATEGORY_LABELS[category]} (${count})`, type: 'category', selected: active });
223
+ const fg = WARN_PREFIXES.some((p) => line.startsWith(p)) ? t.warning
224
+ : FACT_PREFIXES.some((p) => line.startsWith(p)) ? t.textFaint
225
+ : t.textMuted;
226
+ const bold = line.endsWith(':');
227
+ for (const wrapped of wrapLines(line, width)) out.push({ text: wrapped, fg: bold ? t.text : fg, bold });
455
228
  }
456
- if (window.end < entries.length) rows.push({ text: `${GLYPHS.navigation.moreBelow} ${entries.length - window.end} more row(s) below`, type: 'more', selected: false });
457
- while (rows.length < height) rows.push({ text: '', type: 'empty', selected: false });
458
- return rows.slice(0, height);
459
- }
460
-
461
- function renderSettingRows(modal: SettingsModal, width: number, height: number): string[] {
462
- const rows: string[] = [];
463
- const items = modal.currentItems;
464
- if (items.length === 0) return ['No settings in this category.'];
465
- const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
466
- const typeWidth = 9;
467
- const sourceWidth = 12;
468
- const defaultWidth = 12;
469
- const available = Math.max(24, width - typeWidth - sourceWidth - defaultWidth - 13);
470
- const keyWidth = clamp(Math.floor(available * 0.56), 18, 52);
471
- const valueWidth = Math.max(10, available - keyWidth);
472
- rows.push(` ${padDisplay('Setting', keyWidth)} ${padDisplay('Value', valueWidth)} ${padDisplay('Type', typeWidth)} ${padDisplay('Source', sourceWidth)} ${padDisplay('Default', defaultWidth)}`);
473
- const visibleCount = Math.max(1, height - 2);
474
- const window = stableWindow(items.length, selectedIndex, visibleCount);
475
- if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more setting(s) above`);
476
-
477
- for (let index = window.start; index < window.end; index += 1) {
478
- const entry = items[index]!;
479
- const selected = index === selectedIndex;
480
- const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : entry.isDefault ? ' ' : '◇';
481
- rows.push(renderSettingTableRow(modal, entry, selected, marker, keyWidth, valueWidth, typeWidth, sourceWidth, defaultWidth));
482
- }
483
-
484
- if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more setting(s) below`);
485
- return rows.slice(0, height);
229
+ while (out.length > 0 && out[out.length - 1]!.text === '') out.pop();
230
+ return out;
486
231
  }
487
232
 
488
233
  /**
489
- * One settings-table row. A feature-unit toggle header (entry.flag) shows the
490
- * flag name, its live state, tier/toggleability, and default state. A config
491
- * sub-option owned by a feature unit (entry.ownerFlagId) is indented under its
492
- * header. Everything else renders as a plain setting row.
234
+ * The complete documentation the selected row explains itself with (its name
235
+ * first), as the wrapped lines the renderer windows over with PgUp/PgDn.
493
236
  */
494
- function renderSettingTableRow(
495
- modal: SettingsModal,
496
- entry: SettingEntry,
497
- selected: boolean,
498
- marker: string,
499
- keyWidth: number,
500
- valueWidth: number,
501
- typeWidth: number,
502
- sourceWidth: number,
503
- defaultWidth: number,
504
- ): string {
505
- if (entry.flag) {
506
- const { feature, state, pendingRestart } = entry.flag;
507
- const configOn = isFeatureValueEnabled(feature, entry.currentValue);
508
- const stateMark = state === 'killed' ? '✕' : configOn ? '●' : '○';
509
- const label = `${stateMark} ${feature.name}`;
510
- const source = feature.restartRequired ? 'restart' : 'live';
511
- // A startup-gated feature changed this session shows its saved value with
512
- // a compact restart-pending marker (⟳), so the row never implies the
513
- // change already took effect; the documentation pane spells out the full
514
- // "Pending restart: ..." sentence.
515
- const rawValue = currentSettingValue(modal, entry, selected);
516
- const value = pendingRestart ? `${rawValue} ⟳` : rawValue;
517
- return `${marker} ${padDisplay(label, keyWidth)} ${padDisplay(value, valueWidth)} ${padDisplay('feature', typeWidth)} ${padDisplay(source, sourceWidth)} ${padDisplay(feature.defaultEnabled ? 'enabled' : 'disabled', defaultWidth)}`;
518
- }
519
- const value = currentSettingValue(modal, entry, selected);
520
- const source = `${entry.effectiveSource ?? 'default'}${entry.locked ? ' locked' : ''}${entry.conflict ? ' conflict' : ''}`;
521
- // Sub-options of a feature unit are indented under their toggle header so the
522
- // "one unit = toggle + its knobs" grouping reads at a glance.
523
- const label = entry.ownerFlagId ? ` · ${getSettingLabel(entry)}` : getSettingLabel(entry);
524
- return `${marker} ${padDisplay(label, keyWidth)} ${padDisplay(value, valueWidth)} ${padDisplay(entry.setting.type, typeWidth)} ${padDisplay(source, sourceWidth)} ${padDisplay(formatDefaultValue(entry.setting.default), defaultWidth)}`;
237
+ export function settingsDocumentation(modal: SettingsModal, width: number): string[] {
238
+ return docLines(modal, false, width).map((line) => line.text);
525
239
  }
526
240
 
527
- function renderMcpRows(modal: SettingsModal, width: number, height: number): string[] {
528
- const rows: string[] = [];
529
- const items = modal.mcpEntries;
530
- if (items.length === 0) return ['No MCP servers registered.'];
531
- const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
532
- const nameWidth = clamp(Math.floor(width * 0.32), 18, 44);
533
- const trustWidth = 14;
534
- const roleWidth = 12;
535
- const statusWidth = 12;
536
- const scopeWidth = Math.max(12, width - nameWidth - trustWidth - roleWidth - statusWidth - 10);
537
- rows.push(` ${padDisplay('Server', nameWidth)} ${padDisplay('Trust', trustWidth)} ${padDisplay('Role', roleWidth)} ${padDisplay('Status', statusWidth)} ${padDisplay('Scope', scopeWidth)}`);
538
- const window = stableWindow(items.length, selectedIndex, Math.max(1, height - 2));
539
- if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more MCP server(s) above`);
540
- for (let index = window.start; index < window.end; index += 1) {
541
- const entry = items[index]!;
542
- const selected = index === selectedIndex;
543
- const trust = selected && modal.editingMode ? `${modal.editBuffer}${GLYPHS.surface.cursor}` : entry.trustMode;
544
- const scope = entry.allowedPaths.length > 0 ? entry.allowedPaths.join(', ') : entry.allowedHosts.length > 0 ? entry.allowedHosts.join(', ') : 'none';
545
- const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : ' ';
546
- rows.push(`${marker} ${padDisplay(entry.name, nameWidth)} ${padDisplay(trust, trustWidth)} ${padDisplay(entry.role, roleWidth)} ${padDisplay(entry.connected ? 'connected' : 'offline', statusWidth)} ${padDisplay(scope, scopeWidth)}`);
547
- }
548
- if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more MCP server(s) below`);
549
- return rows.slice(0, height);
241
+ /** Window the documentation by modal.contextScroll, marking hidden lines honestly. */
242
+ function windowDoc(modal: SettingsModal, lines: DocLine[], budget: number): DocLine[] {
243
+ const t = activeTokens();
244
+ if (lines.length <= budget) return lines;
245
+ const maxOffset = Math.max(0, lines.length - budget);
246
+ const offset = Math.max(0, Math.min(modal.contextScroll ?? 0, maxOffset));
247
+ const shown = lines.slice(offset, offset + budget);
248
+ if (offset > 0) shown[0] = { text: `${offset} more ↑ · pgup`, fg: t.textFaint };
249
+ const below = lines.length - offset - budget;
250
+ if (below > 0) shown[shown.length - 1] = { text: `${below} more ↓ · pgdn`, fg: t.textFaint };
251
+ return shown;
550
252
  }
551
253
 
552
- function renderSubscriptionRows(modal: SettingsModal, width: number, height: number): string[] {
553
- const rows: string[] = [];
554
- const items = modal.subscriptionEntries;
555
- if (items.length === 0) return ['No provider subscriptions available or configured.'];
556
- const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
557
- const providerWidth = clamp(Math.floor(width * 0.28), 14, 36);
558
- const stateWidth = 10;
559
- const routeWidth = 16;
560
- const freshnessWidth = 14;
561
- const oauthWidth = 8;
562
- const noteWidth = Math.max(12, width - providerWidth - stateWidth - routeWidth - freshnessWidth - oauthWidth - 12);
563
- rows.push(` ${padDisplay('Provider', providerWidth)} ${padDisplay('State', stateWidth)} ${padDisplay('Route', routeWidth)} ${padDisplay('Freshness', freshnessWidth)} ${padDisplay('OAuth', oauthWidth)} ${padDisplay('Note', noteWidth)}`);
564
- const window = stableWindow(items.length, selectedIndex, Math.max(1, height - 2));
565
- if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more subscription provider(s) above`);
566
- for (let index = window.start; index < window.end; index += 1) {
567
- const entry = items[index]!;
568
- const selected = index === selectedIndex;
569
- const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : ' ';
570
- rows.push(`${marker} ${padDisplay(entry.provider, providerWidth)} ${padDisplay(entry.state, stateWidth)} ${padDisplay(entry.activeRoute ?? 'n/a', routeWidth)} ${padDisplay(entry.authFreshness ?? 'n/a', freshnessWidth)} ${padDisplay(entry.oauthConfigured ? 'yes' : 'no', oauthWidth)} ${padDisplay(inferSubscriptionRouteReason(entry) ?? '', noteWidth)}`);
571
- }
572
- if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more subscription provider(s) below`);
573
- return rows.slice(0, height);
574
- }
254
+ // ---------------------------------------------------------------------------
255
+ // Right side: the settings column
256
+ // ---------------------------------------------------------------------------
575
257
 
576
- function renderSearchRows(modal: SettingsModal, width: number, height: number): string[] {
577
- const rows: string[] = [];
578
- const query = modal.searchQuery;
579
- // Search-prompt row shows the current query
580
- const promptRow = `/ ${query}${GLYPHS.surface.cursor}`;
581
- rows.push(promptRow);
258
+ function drawSettingsColumn(f: ModalFrame, modal: SettingsModal, p: KitInset): { above: number; below: number } {
259
+ const t = activeTokens();
260
+ const sx = p.x + 4;
261
+ const sr = p.x + p.w - 5;
262
+ const width = sr - sx + 1;
582
263
 
583
- const results = modal.searchResults;
584
- if (query.trim().length === 0 || results.length === 0) {
585
- rows.push(query.trim().length === 0 ? 'Type to search across all categories.' : 'No results.');
586
- return rows.slice(0, height);
264
+ const notices = [
265
+ ...(modal.lastSaveTriggeredRestart ? [`Restarting ${modal.lastSaveTriggeredRestart}`] : []),
266
+ ...(modal.lastSettingEffectMessage ? [modal.lastSettingEffectMessage] : []),
267
+ ].flatMap((n) => wrapLines(n, width));
268
+ const bottom = notices.length > 0 ? p.bottom - notices.length - 1 : p.bottom;
269
+ notices.forEach((line, k) => f.canvas.put(sx, bottom + 2 + k, line, { fg: t.accent }));
270
+
271
+ const { rows, selected } = listRows(modal, width);
272
+ const capacity = Math.max(1, bottom - p.top + 1);
273
+ const doc = docLines(modal, selected >= 0, width);
274
+
275
+ if (selected < 0) {
276
+ let y = p.top;
277
+ for (const row of rows) y += drawRow(f.canvas, y, row, sx, sr, bottom);
278
+ if (rows.length > 0) y++;
279
+ for (const line of windowDoc(modal, doc, Math.max(1, bottom - y + 1))) {
280
+ if (y > bottom) break;
281
+ f.canvas.put(sx, y++, line.text, { fg: line.fg, bold: line.bold });
282
+ }
283
+ return { above: 0, below: 0 };
587
284
  }
588
285
 
589
- const selectedIndex = clamp(modal.selectedIndex, 0, results.length - 1);
590
- const typeWidth = 9;
591
- const sourceWidth = 12;
592
- const categoryWidth = 14;
593
- const available = Math.max(24, width - typeWidth - sourceWidth - categoryWidth - 16);
594
- const keyWidth = clamp(Math.floor(available * 0.56), 18, 52);
595
- const valueWidth = Math.max(10, available - keyWidth);
596
- rows.push(` ${padDisplay('Setting', keyWidth)} ${padDisplay('Value', valueWidth)} ${padDisplay('Type', typeWidth)} ${padDisplay('Category', categoryWidth)} ${padDisplay('Source', sourceWidth)}`);
597
-
598
- const visibleCount = Math.max(1, height - 3);
599
- const window = stableWindow(results.length, selectedIndex, visibleCount);
600
- if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more result(s) above`);
601
-
602
- for (let index = window.start; index < window.end; index += 1) {
603
- const entry = results[index]!;
604
- const selected = index === selectedIndex;
605
- const marker = selected ? GLYPHS.navigation.selected : entry.isDefault ? ' ' : '◇';
606
- const value = currentSettingValue(modal, entry, selected);
607
- const source = `${entry.effectiveSource ?? 'default'}${entry.locked ? ' locked' : ''}${entry.conflict ? ' conflict' : ''}`;
608
- const label = getSettingLabel(entry);
609
- // Derive category label from setting key prefix
610
- const keyPrefix = entry.setting.key.split('.')[0] ?? '';
611
- const categoryLabel = CATEGORY_LABELS[keyPrefix as SettingsCategory] ?? keyPrefix;
612
- rows.push(`${marker} ${padDisplay(label, keyWidth)} ${padDisplay(value, valueWidth)} ${padDisplay(entry.setting.type, typeWidth)} ${padDisplay(categoryLabel, categoryWidth)} ${padDisplay(source, sourceWidth)}`);
286
+ const heights = rows.map((row) => measureRow(row, sx, sr));
287
+ // The documentation takes what the rows leave, but never less than a few lines.
288
+ const rowsTotal = heights.reduce((a, b) => a + b, 0);
289
+ const docBudget = Math.max(3, Math.min(doc.length, capacity - Math.min(rowsTotal, Math.max(3, Math.floor(capacity * 0.35))) - 2));
290
+ const shownDoc = windowDoc(modal, doc, docBudget);
291
+ const blockH = shownDoc.length > 0 ? shownDoc.length + 2 : 0;
292
+ const rowCapacity = Math.max(1, capacity - blockH);
293
+
294
+ // Scroll so the selected row (and its block) is in view; the window only
295
+ // moves when the selection leaves it.
296
+ const listName = modal.searchFocused ? 'search' : `category:${modal.currentCategory}`;
297
+ const scrollKey = { owner: modal, name: listName };
298
+ let start = Math.max(0, Math.min(rememberedStart(scrollKey) ?? 0, rows.length - 1));
299
+ const span = (from: number, to: number): number => heights.slice(from, to + 1).reduce((a, b) => a + b, 0);
300
+ if (selected < start) start = selected;
301
+ while (start < selected && span(start, selected) > rowCapacity) start++;
302
+ while (start > 0 && span(start - 1, rows.length - 1) <= rowCapacity) start--;
303
+ rememberStart(scrollKey, start);
304
+
305
+ let y = p.top;
306
+ let last = start - 1;
307
+ for (let i = start; i < rows.length; i++) {
308
+ if (y + heights[i]! - 1 > bottom) break;
309
+ if (i !== selected && i > selected && y + heights[i]! - 1 > bottom) break;
310
+ y += drawRow(f.canvas, y, rows[i]!, sx, sr, bottom);
311
+ last = i;
312
+ if (i === selected && shownDoc.length > 0) {
313
+ y++;
314
+ for (const line of shownDoc) {
315
+ if (y > bottom) break;
316
+ f.canvas.put(sx, y++, line.text, { fg: line.fg, bold: line.bold });
317
+ }
318
+ y++;
319
+ }
613
320
  }
614
-
615
- if (window.end < results.length) rows.push(`${GLYPHS.navigation.moreBelow} ${results.length - window.end} more result(s) below`);
616
- return rows.slice(0, height);
321
+ return { above: start, below: Math.max(0, rows.length - last - 1) };
617
322
  }
618
323
 
619
- function renderControlRows(modal: SettingsModal, width: number, height: number): string[] {
620
- if (modal.searchFocused) return renderSearchRows(modal, width, height);
621
- if (modal.currentCategory === 'mcp') return renderMcpRows(modal, width, height);
622
- if (modal.currentCategory === 'subscriptions') return renderSubscriptionRows(modal, width, height);
623
- if (modal.currentCategory === 'connections') return renderConnectionRows(modal, width, height);
624
- return renderSettingRows(modal, width, height);
324
+ // ---------------------------------------------------------------------------
325
+ // Hints
326
+ // ---------------------------------------------------------------------------
327
+
328
+ function settingsHints(modal: SettingsModal): KitHint[] {
329
+ if (modal.resetCategoryConfirm !== null || modal.resetAllConfirm !== null) return [['⏎', 'confirm reset'], ['esc', 'cancel']];
330
+ if (modal.subscriptionLogoutConfirmationTarget) return [['⏎', 'sign out'], ['esc', 'cancel']];
331
+ if (modal.editingMode) return [['⏎', 'save'], ['esc', 'cancel edit']];
332
+ if (modal.searchFocused) return [['↑↓', 'move'], ['⏎', 'change'], ['⌫', 'edit search'], ['ctrl+r', 'reset']];
333
+ if (modal.focusRegion === 'categories') return [['↑↓', 'category'], ['→', 'settings'], ['tab', 'pane']];
334
+ const enter: KitHint = modal.currentCategory === 'mcp' ? ['⏎', 'edit trust']
335
+ : modal.currentCategory === 'subscriptions' ? ['⏎', 'review or sign out']
336
+ : ['⏎', 'change'];
337
+ return [['↑↓', 'move'], enter, ['←→', 'category'], ['pgup pgdn', 'docs'], ['ctrl+r', 'reset'], ['shift+r', 'reset category'], ['ctrl+shift+r', 'reset all']];
625
338
  }
626
339
 
627
- function rowColorForSetting(modal: SettingsModal, rowText: string): string {
628
- if (modal.currentCategory === 'danger') return PALETTE.bad;
629
- if (rowText.startsWith(GLYPHS.navigation.selected)) return PALETTE.text;
630
- const selected = modal.getSelected();
631
- if (!selected) return PALETTE.text;
632
- return valueColor(selected);
633
- }
634
-
635
- function footerText(modal: SettingsModal, width: number): string {
636
- // Every branch speaks the shared hint grammar: bracketed [Key] Verb segments
637
- // joined by the middle dot, with Esc sorted last. Leading words like
638
- // "Focus settings" are state labels appended verbatim, not key hints.
639
- // Armed reset gate takes priority over all other footer states.
640
- if (modal.resetCategoryConfirm !== null || modal.resetAllConfirm !== null)
641
- return joinHints('Reset armed', formatHints([
642
- { key: 'Enter/y', verb: 'confirm' },
643
- { key: 'Esc/n', verb: 'cancel' },
644
- ]));
645
- if (modal.searchFocused)
646
- return joinHints('Search', 'type to filter', formatHints([
647
- { key: 'Up/Down', verb: 'Navigate' },
648
- { key: 'Enter', verb: 'Select' },
649
- { key: 'Esc', verb: 'Exit search' },
650
- ]));
651
- if (modal.editingMode)
652
- return formatHints([
653
- { key: 'Enter', verb: 'Confirm edit' },
654
- { key: 'Esc', verb: 'Cancel edit' },
655
- ]);
656
- if (modal.focusPane === 'categories')
657
- return joinHints('Focus categories', formatHints([
658
- { key: 'Up/Down', verb: 'Choose' },
659
- { key: 'Right/Enter', verb: 'Settings' },
660
- { key: 'Tab', verb: 'Pane' },
661
- { key: '/', verb: 'Search' },
662
- { key: 'Esc', verb: 'Close' },
663
- ]));
664
- if (modal.currentCategory === 'subscriptions')
665
- return joinHints('Focus settings', formatHints([
666
- { key: 'Up/Down', verb: 'Provider' },
667
- { key: 'Left', verb: 'Categories' },
668
- { key: 'Tab', verb: 'Pane' },
669
- { key: '/', verb: 'Search' },
670
- { key: 'Enter', verb: 'Review/sign out' },
671
- { key: 'Esc', verb: 'Close' },
672
- ]));
673
- if (modal.currentCategory === 'mcp')
674
- return joinHints('Focus settings', formatHints([
675
- { key: 'Up/Down', verb: 'Server' },
676
- { key: 'Left', verb: 'Categories' },
677
- { key: 'Tab', verb: 'Pane' },
678
- { key: '/', verb: 'Search' },
679
- { key: 'Enter', verb: 'Edit trust' },
680
- { key: 'Esc', verb: 'Close' },
681
- ]));
682
- // Every topical category that hosts feature units flows through the default
683
- // settings footer below, Enter/Space toggles a feature-unit header exactly
684
- // as it edits/toggles any setting row.
685
- // Default settings pane: tier the reset affordances by available width.
686
- // W<80: minimal, only the most critical action survives.
687
- // W<160: compact but still shows both reset affordances.
688
- // W≥160: standard with all navigation tokens.
689
- if (width < 80)
690
- return formatHints([{ key: 'R', verb: 'reset' }, { key: 'Esc', verb: 'Close' }]);
691
- if (width < 160)
692
- return formatHints([
693
- { key: 'Up/Down', verb: 'Move' },
694
- { key: 'Enter/Space', verb: 'Edit' },
695
- { key: 'PgUp/PgDn', verb: 'docs' },
696
- { key: '⇧R', verb: 'reset cat' },
697
- { key: '^⇧R', verb: 'reset all' },
698
- { key: 'Esc', verb: 'Close' },
699
- ]);
700
- return joinHints('Focus settings', formatHints([
701
- { key: 'Up/Down', verb: 'Setting' },
702
- { key: 'Left', verb: 'Categories' },
703
- { key: 'Enter/Space', verb: 'Edit/toggle' },
704
- { key: 'PgUp/PgDn', verb: 'Scroll docs' },
705
- { key: '⇧R', verb: 'reset cat' },
706
- { key: '^⇧R', verb: 'reset all' },
707
- { key: 'Esc', verb: 'Close' },
708
- ]));
709
- }
710
-
711
- export function renderSettingsModal(
712
- modal: SettingsModal,
713
- width: number,
714
- viewportHeight = 24,
715
- ): Line[] {
716
- const notices = [
717
- ...(modal.lastSaveTriggeredRestart ? [`Restarting ${modal.lastSaveTriggeredRestart}`] : []),
718
- ...(modal.lastSettingEffectMessage ? [modal.lastSettingEffectMessage] : []),
719
- ];
720
- const metrics = getFullscreenWorkspaceMetrics({ width, height: viewportHeight });
721
- const categoryRows = renderCategories(modal, metrics.leftWidth - 2, metrics.bodyRows);
722
- // Documentation pane: long content SCROLLS (PgUp/PgDn) with honest
723
- // more-above/below markers, it is never silently clipped.
724
- const allContextLines = buildContextLines(modal, metrics.contextWidth);
725
- const visibleContext = metrics.contextRows;
726
- const maxContextScroll = Math.max(0, allContextLines.length - visibleContext);
727
- const contextOffset = Math.min(Math.max(0, modal.contextScroll ?? 0), maxContextScroll);
728
- const windowedContext = allContextLines.slice(contextOffset, contextOffset + visibleContext);
729
- if (contextOffset > 0) {
730
- windowedContext[0] = `${GLYPHS.navigation.moreAbove} ${contextOffset} more line(s) above; PgUp`;
340
+ // ---------------------------------------------------------------------------
341
+ // Renderer
342
+ // ---------------------------------------------------------------------------
343
+
344
+ /** Below this many text columns the category list folds into the breadcrumb (←→ still switch). */
345
+ const MIN_LIST_DETAIL_WIDTH = 56;
346
+
347
+ export function renderSettingsModal(modal: SettingsModal, screenWidth: number, screenHeight = 24): SurfaceLayer {
348
+ const selectedEntry = !modal.searchFocused && modal.currentCategory !== 'mcp' && modal.currentCategory !== 'subscriptions' && modal.currentCategory !== 'connections'
349
+ ? modal.getSelected()
350
+ : null;
351
+ const crumbs = modal.searchFocused
352
+ ? ['Search']
353
+ : [CATEGORY_LABELS[modal.currentCategory], ...(selectedEntry ? [selectedEntry.flag ? selectedEntry.flag.feature.name : getSettingLabel(selectedEntry)] : [])];
354
+ const f = beginModal(screenWidth, screenHeight, { title: 'Settings', crumbs, hints: settingsHints(modal) });
355
+
356
+ const count = modal.searchFocused
357
+ ? `${modal.searchResults.length} result${modal.searchResults.length === 1 ? '' : 's'}`
358
+ : CATEGORY_LABELS[modal.currentCategory].toLowerCase();
359
+ searchRow(f, f.top, modal.searchQuery, 'Search all settings', count);
360
+
361
+ const body = f.top + 2;
362
+ const inner = f.r - f.l + 1;
363
+ let detailX = 2;
364
+ // Search spans every category, so the category list steps aside for it.
365
+ if (inner >= MIN_LIST_DETAIL_WIDTH && !modal.searchFocused) {
366
+ const catW = Math.max(18, Math.min(26, Math.round(inner * 0.22)));
367
+ const x1 = f.l + catW - 1;
368
+ drawScrollingList(f.canvas, { rows: categoryRows(modal), top: body, bottom: f.bottom, x0: f.l, x1, scrollKey: { owner: modal, name: 'categories' } });
369
+ detailX = x1 + 3;
731
370
  }
732
- if (contextOffset + visibleContext < allContextLines.length) {
733
- const below = allContextLines.length - contextOffset - visibleContext;
734
- windowedContext[windowedContext.length - 1] = `${GLYPHS.navigation.moreBelow} ${below} more line(s) below; PgDn`;
735
- }
736
- const contextRows = windowedContext.map((text, row): WorkspaceRow => {
737
- const selectedSetting = modal.getSelected();
738
- const isTitle = (row === 0 && contextOffset === 0) || (selectedSetting !== null && text === getSettingLabel(selectedSetting));
739
- return {
740
- text,
741
- fg: row === 0 && contextOffset === 0 ? PALETTE.title : text.endsWith(':') ? PALETTE.subtitle : PALETTE.text,
742
- bold: isTitle,
743
- dim: text.length === 0,
744
- };
745
- });
746
- const controlRows = renderControlRows(modal, metrics.contextWidth, metrics.controlRows).map((text): WorkspaceRow => {
747
- const selected = text.startsWith(GLYPHS.navigation.selected);
748
- return {
749
- text,
750
- selected,
751
- fg: selected
752
- ? PALETTE.text
753
- : text.startsWith('value:') || text.trimStart().startsWith('value:')
754
- ? PALETTE.info
755
- : rowColorForSetting(modal, text),
756
- bold: selected,
757
- dim: text.length === 0,
758
- };
759
- });
760
-
761
- return renderFullscreenWorkspace({
762
- width,
763
- height: viewportHeight,
764
- title: 'Configuration Workspace / Settings',
765
- leftHeader: 'Categories',
766
- mainHeader: modal.searchFocused
767
- ? `Search: ${modal.searchQuery || '…'} (${modal.searchResults.length} result${modal.searchResults.length === 1 ? '' : 's'})${notices.length > 0 ? ` · ${notices.join(' · ')}` : ''}`
768
- : `${CATEGORY_LABELS[modal.currentCategory]} (${categoryItemCount(modal, modal.currentCategory)})${notices.length > 0 ? ` · ${notices.join(' · ')}` : ''}`,
769
- leftRows: categoryRows.map((row): WorkspaceRow => ({
770
- text: row.text,
771
- selected: row.selected,
772
- kind: row.type === 'group' ? 'group' : row.type === 'more' ? 'more' : row.type === 'empty' ? 'empty' : 'item',
773
- bold: row.selected || row.type === 'group',
774
- })),
775
- contextRows,
776
- controlRows,
777
- footer: footerText(modal, width),
778
- });
371
+ const p = inset(f.canvas, detailX, body, f.r + 2 - detailX + 1, f.bottom - body + 1);
372
+ const hidden = drawSettingsColumn(f, modal, p);
373
+ f.hintRight = scrollCountText(hidden.above, hidden.below);
374
+ return finishModal(f);
779
375
  }