@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
@@ -113,7 +113,7 @@ export function buildHunkSelectionState(request: PermissionPromptRequest): HunkS
113
113
  /**
114
114
  * applyHunkKey, pure reducer over raw stdin `data` for an active hunk-selection
115
115
  * prompt. Mirrors the outer y/a/n prompt's raw-data key routing (not the
116
- * normalized panel key vocabulary) since Space must be distinguishable from
116
+ * normalized key vocabulary) since Space must be distinguishable from
117
117
  * the trimmed/lowercased `key` the outer switch uses.
118
118
  */
119
119
  export function applyHunkKey(state: HunkSelectionState, data: string): HunkKeyResult {
@@ -0,0 +1,511 @@
1
+ /**
2
+ * prompt-card.ts, the permission dialog drawn with the modal surface kit.
3
+ *
4
+ * ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ (amber: this needs you) ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
5
+ *
6
+ * △ Run a command? engineer · 1 more waiting
7
+ *
8
+ * ┌ element inset ─────────────────────────────────────────────────┐
9
+ * $ rm -rf dist && bun run build
10
+ * └────────────────────────────────────────────────────────────────┘
11
+ *
12
+ * high risk deletes files recursively sandbox · network off
13
+ *
14
+ * Allow once Allow for session ▸ Deny d details
15
+ *
16
+ * ←→ choose ⏎ confirm y allow once a session n deny d details
17
+ *
18
+ * The thing being approved is the whole card: the command itself (in full,
19
+ * wrapped), or the target path(s). Risk shows as one colored chip. Edits show
20
+ * the actual diff with tinted rows; hunk-selectable edits give every hunk its
21
+ * own checkbox. Every other fact (directory, sandbox, policy judgment, review
22
+ * reasons, the raw arguments) is one key away (d), wrapped in full. Choices are
23
+ * buttons: arrows pick, Enter confirms, and y / a / n (and 1-9 for remember
24
+ * tiers) still work. "Allow for session ▸" opens the remember tiers as a
25
+ * submenu when the request offers them. Typing starts a denial with a reason.
26
+ *
27
+ * Pure rendering; the keys live in shell/blocking-input.ts, which shares
28
+ * permissionButtons() with this file so the two can never disagree.
29
+ */
30
+
31
+ import type { PermissionCategory, PermissionPromptRequest, PermissionRequestAnalysis } from '@pellux/goodvibes-sdk/platform/permissions';
32
+ import { buildPermissionApprovalBrief, getDisplayArg } from '@pellux/goodvibes-sdk/platform/permissions';
33
+ import { activeTokens } from '../renderer/theme.ts';
34
+ import {
35
+ beginModal,
36
+ finishModal,
37
+ layoutHintRows,
38
+ hintLayoutWidth,
39
+ MODAL_PAD_X,
40
+ modalInnerWidth,
41
+ scrollCountText,
42
+ standardModalWidth,
43
+ wrapLines,
44
+ type KitHint,
45
+ type SurfaceLayer,
46
+ } from '../renderer/surface-kit.ts';
47
+ import { chip, button, buttonWidth } from '../renderer/surface-kit-parts.ts';
48
+ import { drawRow, measureRow, type KitRow, type KitSpan } from '../renderer/surface-kit-list.ts';
49
+ import type { HunkSelectionState } from './hunk-selection.ts';
50
+ import { readSandboxAskAnnotation } from './sandbox-exec-gate.ts';
51
+
52
+ /** Hunks listed before a "+N more hunks" trailer. */
53
+ export const MAX_VISIBLE_HUNKS = 8;
54
+ /** Diff rows shown for a whole-file write before a "+N more" trailer. */
55
+ const MAX_DIFF_LINES = 10;
56
+ /** Target rows listed before they collapse to an "N files: …" summary. */
57
+ const MAX_TARGET_ROWS = 3;
58
+ /** Diff rows shown under one hunk before a trailer. */
59
+ const MAX_HUNK_LINES = 6;
60
+
61
+ /** What the card shows beyond the request itself. */
62
+ export interface PermissionCardState {
63
+ readonly hunkState?: HunkSelectionState | undefined;
64
+ readonly detailsExpanded?: boolean | undefined;
65
+ readonly requestedBy?: string | undefined;
66
+ readonly replyMode?: 'deny-reason' | 'exec-answer' | undefined;
67
+ readonly replyBuffer?: string | undefined;
68
+ /** OTHER broker asks waiting behind this one. */
69
+ readonly queueCount?: number | undefined;
70
+ /** The chosen button (index into permissionButtons). */
71
+ readonly choice?: number | undefined;
72
+ /** The remember-tier submenu is open. */
73
+ readonly submenuOpen?: boolean | undefined;
74
+ readonly submenuIndex?: number | undefined;
75
+ /** First body row shown when the card is taller than the screen. */
76
+ readonly scroll?: number | undefined;
77
+ }
78
+
79
+ export type PermissionButtonId = 'allow' | 'session' | 'remember' | 'apply' | 'deny';
80
+
81
+ export interface PermissionButton {
82
+ readonly id: PermissionButtonId;
83
+ readonly label: string;
84
+ }
85
+
86
+ /** The card's buttons, in order (the safe answers are Allow once / Apply first, Deny last). */
87
+ export function permissionButtons(request: PermissionPromptRequest, state: Pick<PermissionCardState, 'hunkState' | 'replyMode'>): PermissionButton[] {
88
+ if (state.replyMode === 'exec-answer') return [];
89
+ if (state.hunkState) return [{ id: 'apply', label: 'Apply selected' }, { id: 'deny', label: 'Deny' }];
90
+ const tiers = request.rememberOptions?.length ?? 0;
91
+ return [
92
+ { id: 'allow', label: 'Allow once' },
93
+ tiers > 0 ? { id: 'remember', label: 'Allow for session ▸' } : { id: 'session', label: 'Allow for session' },
94
+ { id: 'deny', label: 'Deny' },
95
+ ];
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Request facts
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export function fallbackAnalysis(request: PermissionPromptRequest): PermissionRequestAnalysis {
103
+ return request.analysis ?? {
104
+ classification: request.category,
105
+ riskLevel: request.category === 'read' ? 'low' : request.category === 'write' ? 'medium' : 'high',
106
+ summary: `Review ${request.tool} request`,
107
+ reasons: ['Inspect the target and intent before approving this action.'],
108
+ target: getDisplayArg(request.tool, request.args),
109
+ targetKind: 'generic',
110
+ };
111
+ }
112
+
113
+ const MODEL_JUDGMENT_PREFIX = 'model judgment:';
114
+
115
+ /** The sandbox model-judgment annotation gets its own row so it is never cut from the reasons. */
116
+ export function extractModelJudgmentAnnotation(reasons: readonly string[]): { annotation: string | undefined; rest: string[] } {
117
+ const idx = reasons.findIndex((r) => r.toLowerCase().startsWith(MODEL_JUDGMENT_PREFIX));
118
+ if (idx === -1) return { annotation: undefined, rest: [...reasons] };
119
+ return { annotation: reasons[idx], rest: [...reasons.slice(0, idx), ...reasons.slice(idx + 1)] };
120
+ }
121
+
122
+ function relativize(p: string, cwd?: string): string {
123
+ if (!cwd || !p.startsWith('/')) return p;
124
+ if (p === cwd) return '.';
125
+ const prefix = cwd.endsWith('/') ? cwd : `${cwd}/`;
126
+ return p.startsWith(prefix) ? p.slice(prefix.length) : p;
127
+ }
128
+
129
+ function collectField(arr: unknown, field: string): string[] {
130
+ if (!Array.isArray(arr)) return [];
131
+ const out: string[] = [];
132
+ for (const el of arr) {
133
+ if (typeof el === 'string') out.push(el);
134
+ else if (el && typeof el === 'object' && typeof (el as Record<string, unknown>)[field] === 'string') out.push((el as Record<string, unknown>)[field] as string);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /** The real display target(s) of a tool call (paths, commands, urls), relativised; a fallback when none is recognisable. */
140
+ export function cardTargets(request: PermissionPromptRequest): string[] {
141
+ const args = request.args ?? {};
142
+ const pick = (): string[] => {
143
+ const files = collectField(args.files, 'path');
144
+ if (files.length) return files;
145
+ if (typeof args.path === 'string') return [args.path];
146
+ if (typeof args.file === 'string') return [args.file];
147
+ const commands = collectField(args.commands, 'cmd');
148
+ if (commands.length) return commands;
149
+ if (typeof args.command === 'string') return [args.command];
150
+ if (typeof args.cmd === 'string') return [args.cmd];
151
+ if (typeof args.pattern === 'string') return [args.pattern];
152
+ const urls = collectField(args.urls, 'url');
153
+ if (urls.length) return urls;
154
+ const queries = collectField(args.queries, 'query');
155
+ if (queries.length) return queries;
156
+ if (typeof args.query === 'string') return [args.query];
157
+ if (typeof args.task === 'string') return [args.task];
158
+ return [];
159
+ };
160
+ const targets = pick().map((t) => relativize(t, request.workingDirectory));
161
+ return targets.length > 0 ? targets : [getDisplayArg(request.tool, args)];
162
+ }
163
+
164
+ function commandOf(request: PermissionPromptRequest): string {
165
+ const args = request.args ?? {};
166
+ return typeof args.command === 'string' ? args.command : typeof args.cmd === 'string' ? args.cmd : '';
167
+ }
168
+
169
+ /** The exact session rule "Allow for session" remembers (mirrors the SDK's approval key). */
170
+ export function rememberScopeKey(request: PermissionPromptRequest): string {
171
+ const args = request.args ?? {};
172
+ if (typeof args.path === 'string' && args.path.length > 0) return `${request.tool}:${args.path}`;
173
+ if (typeof args.command === 'string' && args.command.length > 0) return `${request.tool}:${args.command}`;
174
+ return request.tool;
175
+ }
176
+
177
+ function riskColor(level: PermissionRequestAnalysis['riskLevel']): string {
178
+ const t = activeTokens();
179
+ if (level === 'low') return t.success;
180
+ if (level === 'medium') return t.warning;
181
+ return t.error;
182
+ }
183
+
184
+ /** The request category, named on the title row. */
185
+ function categoryLabel(category: PermissionCategory): string {
186
+ switch (category) {
187
+ case 'write': return 'WRITE';
188
+ case 'execute': return 'EXECUTE';
189
+ case 'delegate': return 'DELEGATE';
190
+ default: return 'PERMISSION';
191
+ }
192
+ }
193
+
194
+ function titleGlyph(category: PermissionCategory): string {
195
+ return category === 'read' ? '○' : '△';
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Body rows
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /** One visual row of the card body. */
203
+ interface CardRow {
204
+ readonly spans: readonly KitSpan[];
205
+ /** Fill the row as part of an element inset (bg), with this tint instead when set. */
206
+ readonly inset?: boolean;
207
+ readonly tint?: string;
208
+ readonly right?: { readonly text: string; readonly fg: string };
209
+ /** The hunk cursor row (drawn as the selected row). */
210
+ readonly cursor?: boolean;
211
+ }
212
+
213
+ /** Wrap code keeping its leading indentation on every wrapped line. */
214
+ function wrapCode(line: string, width: number): string[] {
215
+ const indent = /^\s*/.exec(line)?.[0] ?? '';
216
+ const room = Math.max(4, width - indent.length);
217
+ return wrapLines(line.slice(indent.length), room).map((part) => `${indent}${part}`);
218
+ }
219
+
220
+ function textRows(text: string, width: number, span: Omit<KitSpan, 'text'>, indent = ''): CardRow[] {
221
+ return text.split('\n').flatMap((para) => wrapLines(para, Math.max(1, width - indent.length)))
222
+ .map((line, i) => ({ spans: [{ ...span, text: `${i === 0 ? '' : indent}${line}` }] }));
223
+ }
224
+
225
+ function insetRows(rows: CardRow[]): CardRow[] {
226
+ const blank: CardRow = { spans: [], inset: true };
227
+ return [blank, ...rows.map((r) => ({ ...r, inset: true })), blank];
228
+ }
229
+
230
+ function labelled(label: string, value: string, width: number, fg: string): CardRow[] {
231
+ const t = activeTokens();
232
+ const pad = 12;
233
+ const lines = value.split('\n').flatMap((para) => wrapLines(para, Math.max(8, width - pad)));
234
+ return lines.map((line, i) => ({
235
+ spans: i === 0
236
+ ? [{ text: label.padEnd(pad), fg: t.textMuted }, { text: line, fg }]
237
+ : [{ text: `${' '.repeat(pad)}${line}`, fg }],
238
+ }));
239
+ }
240
+
241
+ function diffRows(lines: readonly string[], width: number): CardRow[] {
242
+ const t = activeTokens();
243
+ const rows: CardRow[] = [];
244
+ for (const raw of lines) {
245
+ const sign = raw.startsWith('+') ? '+' : raw.startsWith('-') ? '-' : raw.startsWith('@@') ? '@' : ' ';
246
+ const body = sign === '@' || sign === ' ' ? raw : raw.slice(1);
247
+ const tint = sign === '+' ? t.diffAddedBg : sign === '-' ? t.diffRemovedBg : undefined;
248
+ const fg = sign === '@' ? t.info : sign === ' ' ? t.textFaint : t.text;
249
+ const signFg = sign === '+' ? t.success : sign === '-' ? t.error : t.textFaint;
250
+ wrapCode(body, Math.max(4, width - 2)).forEach((part, i) => {
251
+ rows.push({
252
+ spans: sign === '@' || sign === ' '
253
+ ? [{ text: part, fg }]
254
+ : [{ text: i === 0 ? `${sign} ` : ' ', fg: signFg }, { text: part, fg }],
255
+ inset: true,
256
+ tint,
257
+ });
258
+ });
259
+ }
260
+ return rows;
261
+ }
262
+
263
+ /** Unified-diff text for a whole-file write or an edit list (not hunk mode), capped with an honest trailer. */
264
+ export function writeDiffLines(request: PermissionPromptRequest, hunkState: HunkSelectionState | undefined): string[] | null {
265
+ if (hunkState || request.category !== 'write') return null;
266
+ const args = request.args ?? {};
267
+ const target = typeof args.path === 'string' ? args.path : typeof args.file === 'string' ? args.file : '';
268
+ const out: string[] = [];
269
+ if (typeof args.content === 'string') {
270
+ const contentLines = args.content.split('\n');
271
+ out.push(`@@ ${target || '(new content)'} @@`);
272
+ for (const line of contentLines.slice(0, MAX_DIFF_LINES)) out.push(`+${line}`);
273
+ if (contentLines.length > MAX_DIFF_LINES) out.push(` … +${contentLines.length - MAX_DIFF_LINES} more lines`);
274
+ return out;
275
+ }
276
+ if (Array.isArray(args.edits)) {
277
+ let budget = MAX_DIFF_LINES;
278
+ let hidden = 0;
279
+ for (const edit of args.edits) {
280
+ if (!edit || typeof edit !== 'object') continue;
281
+ const e = edit as { path?: unknown; find?: unknown; replace?: unknown };
282
+ if (typeof e.find !== 'string' || typeof e.replace !== 'string') continue;
283
+ const findLines = e.find.split('\n');
284
+ const replaceLines = e.replace.split('\n');
285
+ const need = 1 + findLines.length + replaceLines.length;
286
+ if (budget < need) {
287
+ hidden += 1;
288
+ continue;
289
+ }
290
+ out.push(`@@ ${typeof e.path === 'string' ? e.path : target} @@`);
291
+ for (const line of findLines) out.push(`-${line}`);
292
+ for (const line of replaceLines) out.push(`+${line}`);
293
+ budget -= need;
294
+ }
295
+ if (out.length === 0) return null;
296
+ if (hidden > 0) out.push(` … +${hidden} more edits`);
297
+ return out;
298
+ }
299
+ return null;
300
+ }
301
+
302
+ function subjectRows(request: PermissionPromptRequest, width: number): CardRow[] {
303
+ const t = activeTokens();
304
+ const attribution = request.attribution;
305
+ if (attribution?.kind === 'exec-prompt') {
306
+ const rows: CardRow[] = [];
307
+ if (typeof attribution.command === 'string' && attribution.command.length > 0) rows.push(...labelled('Running', attribution.command, width, t.text));
308
+ if (typeof attribution.prompt === 'string' && attribution.prompt.length > 0) rows.push(...labelled('Asks', attribution.prompt, width, t.warning));
309
+ return rows;
310
+ }
311
+ const command = request.category === 'execute' ? commandOf(request) : '';
312
+ if (command) {
313
+ // The FULL command, wrapped, never truncated.
314
+ return command.split('\n').flatMap((para, p) => wrapCode(para, Math.max(4, width - 2)).map((line, i) => ({
315
+ spans: [{ text: p === 0 && i === 0 ? '$ ' : ' ', fg: t.textFaint }, { text: line, fg: t.text }],
316
+ })));
317
+ }
318
+ const brief = buildPermissionApprovalBrief(request);
319
+ const targets = cardTargets(request);
320
+ // Up to three targets are listed in full; more collapse to a summary (the
321
+ // complete list stays in the details' Args row).
322
+ if (targets.length > MAX_TARGET_ROWS) {
323
+ const base = (p: string): string => p.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? p;
324
+ return labelled(brief.subjectLabel, `${targets.length} files: ${targets.slice(0, 2).map(base).join(', ')}, +${targets.length - 2} more`, width, t.text);
325
+ }
326
+ return targets.flatMap((target, i) => labelled(i === 0 ? brief.subjectLabel : '', target, width, t.text));
327
+ }
328
+
329
+ function hunkRows(hunkState: HunkSelectionState, width: number): CardRow[] {
330
+ const t = activeTokens();
331
+ const rows: CardRow[] = [];
332
+ const { hunks, cursor, selected } = hunkState;
333
+ rows.push(...textRows(`${selected.size} of ${hunks.length} hunks selected`, width + 4, { fg: t.textMuted }));
334
+ hunks.slice(0, MAX_VISIBLE_HUNKS).forEach((hunk, i) => {
335
+ const on = selected.has(i);
336
+ wrapLines(`${i + 1}. ${hunk.path}`, Math.max(4, width)).forEach((part, k) => rows.push({
337
+ spans: [{ text: k === 0 ? (on ? '[x] ' : '[ ] ') : ' ', fg: on ? t.success : t.textFaint, bold: true }, { text: part, fg: on ? t.text : t.textMuted }],
338
+ cursor: i === cursor,
339
+ }));
340
+ const lines = [...hunk.find.split('\n').map((l) => `-${l}`), ...hunk.replace.split('\n').map((l) => `+${l}`)];
341
+ const shown = diffRows(lines.slice(0, MAX_HUNK_LINES), width);
342
+ rows.push(...insetRows(lines.length > MAX_HUNK_LINES
343
+ ? [...shown, { spans: [{ text: `+${lines.length - MAX_HUNK_LINES} more lines`, fg: t.textFaint }] }]
344
+ : shown));
345
+ });
346
+ if (hunks.length > MAX_VISIBLE_HUNKS) rows.push({ spans: [{ text: `+${hunks.length - MAX_VISIBLE_HUNKS} more hunks`, fg: t.textFaint }] });
347
+ return rows;
348
+ }
349
+
350
+ function detailRows(request: PermissionPromptRequest, state: PermissionCardState, width: number): CardRow[] {
351
+ const t = activeTokens();
352
+ const analysis = fallbackAnalysis(request);
353
+ const brief = buildPermissionApprovalBrief(request);
354
+ const { rest: reasons } = extractModelJudgmentAnnotation(analysis.reasons);
355
+ const rows: CardRow[] = [];
356
+ rows.push(...labelled('Tool', request.tool, width, t.text));
357
+ rows.push(...labelled('Directory', request.workingDirectory ?? '(unknown)', width, t.textMuted));
358
+ rows.push(...labelled('Risk', `${analysis.riskLevel} (${analysis.classification})`, width, riskColor(analysis.riskLevel)));
359
+ if (analysis.surface || analysis.blastRadius) rows.push(...labelled('Surface', `${analysis.surface ?? 'generic'}${analysis.blastRadius ? ` · radius ${analysis.blastRadius}` : ''}`, width, t.textMuted));
360
+ if (analysis.host) rows.push(...labelled('Host', analysis.host, width, t.textMuted));
361
+ const sandbox = readSandboxAskAnnotation(request);
362
+ if (sandbox && sandbox.sandboxEscalations.length > 0) rows.push(...labelled('Sandbox', sandbox.sandboxEscalations.join('; '), width, t.warning));
363
+ rows.push(...labelled('Decision', brief.decisionModeLabel, width, t.textMuted));
364
+ if (analysis.sideEffects && analysis.sideEffects.length > 0) rows.push(...labelled('Effects', analysis.sideEffects.join(', '), width, t.textMuted));
365
+ reasons.forEach((reason, i) => rows.push(...labelled(i === 0 ? 'Review' : '', reason, width, t.textMuted)));
366
+ rows.push(...labelled('Checklist', brief.checklist, width, t.textMuted));
367
+ if (!state.hunkState) rows.push(...labelled('Remembers', `"Allow for session" remembers ${rememberScopeKey(request)}`, width, t.textMuted));
368
+ rows.push(...labelled('Args', JSON.stringify(request.args ?? {}), width, t.textFaint));
369
+ return rows;
370
+ }
371
+
372
+ function bodyRows(request: PermissionPromptRequest, state: PermissionCardState, width: number): CardRow[] {
373
+ const t = activeTokens();
374
+ const analysis = fallbackAnalysis(request);
375
+ const rows: CardRow[] = [];
376
+ rows.push(...insetRows(subjectRows(request, width - 4)));
377
+ rows.push({ spans: [] });
378
+ // Risk chip + summary; the model judgment on its own row so it is never cut.
379
+ const summary = wrapLines(analysis.summary, Math.max(8, width - 14));
380
+ summary.forEach((line, i) => rows.push({
381
+ spans: i === 0
382
+ ? [{ text: `\u0000${analysis.riskLevel} risk`, fg: riskColor(analysis.riskLevel) }, { text: ` ${line}`, fg: t.textMuted }]
383
+ : [{ text: `${' '.repeat(analysis.riskLevel.length + 9)}${line}`, fg: t.textMuted }],
384
+ }));
385
+ const { annotation } = extractModelJudgmentAnnotation(analysis.reasons);
386
+ if (annotation) rows.push(...labelled('Judgment', annotation, width, t.accent));
387
+ const diff = writeDiffLines(request, state.hunkState);
388
+ if (diff) {
389
+ rows.push({ spans: [] });
390
+ rows.push(...insetRows(diffRows(diff, width - 4)));
391
+ }
392
+ if (state.hunkState) {
393
+ rows.push({ spans: [] });
394
+ rows.push(...hunkRows(state.hunkState, width - 4));
395
+ }
396
+ if (state.detailsExpanded) {
397
+ rows.push({ spans: [] });
398
+ rows.push(...detailRows(request, state, width));
399
+ }
400
+ if (state.replyMode) {
401
+ const label = state.replyMode === 'exec-answer' ? 'Answer' : 'Reason';
402
+ rows.push({ spans: [] });
403
+ rows.push(...insetRows(labelled(label, `${state.replyBuffer ?? ''}▏`, width - 4, t.text)));
404
+ }
405
+ return rows;
406
+ }
407
+
408
+ function cardHints(request: PermissionPromptRequest, state: PermissionCardState): KitHint[] {
409
+ if (state.replyMode === 'exec-answer') return [['⏎', 'send answer'], ['esc', (state.replyBuffer ?? '').length > 0 ? 'clear' : 'decline'], ['ctrl+c', 'abort turn']];
410
+ if (state.replyMode === 'deny-reason') return [['⏎', 'deny with this reason'], ['esc', 'back'], ['ctrl+c', 'abort turn']];
411
+ if (state.submenuOpen) return [['↑↓', 'tier'], ['⏎', 'allow and remember'], ['esc', 'back']];
412
+ if (state.hunkState) return [['↑↓', 'hunk'], ['space', 'toggle'], ['a', 'all'], ['←→', 'choose'], ['⏎', 'confirm'], ['n', 'deny']];
413
+ const tiers = request.rememberOptions?.length ?? 0;
414
+ return [
415
+ ['←→', 'choose'], ['⏎', 'confirm'], ['y', 'allow once'],
416
+ tiers > 0 ? [tiers > 1 ? `1-${tiers}` : '1', 'remember'] : ['a', 'allow for session'],
417
+ ['n', 'deny'], ['type', 'a reason to deny'],
418
+ ];
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // Renderer
423
+ // ---------------------------------------------------------------------------
424
+
425
+ export function renderPermissionCard(request: PermissionPromptRequest, state: PermissionCardState, screenWidth: number, screenHeight: number): SurfaceLayer {
426
+ const t = activeTokens();
427
+ const width = standardModalWidth(screenWidth);
428
+ const inner = modalInnerWidth(width);
429
+ const hints = cardHints(request, state);
430
+ const hintRows = layoutHintRows(hints, hintLayoutWidth(inner)).length;
431
+ const body = bodyRows(request, state, inner);
432
+ const buttons = permissionButtons(request, state);
433
+ const tiers = state.submenuOpen ? (request.rememberOptions ?? []) : [];
434
+ const submenuIndex = Math.max(0, Math.min(state.submenuIndex ?? 0, tiers.length - 1));
435
+ const tierRows = tiers.map((tier, i): KitRow => ({ label: `${i + 1}. ${tier.label}`, desc: tier.detail, selected: i === submenuIndex }));
436
+ // The submenu is indented 2 columns under the buttons it belongs to.
437
+ const tierX0 = MODAL_PAD_X + 2;
438
+ const tierX1 = width - MODAL_PAD_X - 1;
439
+ const tierHeight = tierRows.reduce((sum, row) => sum + measureRow(row, tierX0, tierX1), 0);
440
+ // Pinned under the scrolling body: blank, buttons row, then the submenu.
441
+ const pinned = (buttons.length > 0 ? 2 : 0) + (tierRows.length > 0 ? tierHeight + 1 : 0);
442
+ // Fill rows: padding, title, blank, body..., pinned, blank, hints, empty last row.
443
+ const f = beginModal(screenWidth, screenHeight, {
444
+ title: buildPermissionApprovalBrief(request).title,
445
+ titleGlyph: { char: titleGlyph(request.category), fg: t.warning },
446
+ width,
447
+ height: body.length + pinned + hintRows + 5,
448
+ center: true,
449
+ cap: 'warning',
450
+ escKey: false,
451
+ hints,
452
+ });
453
+
454
+ const right = [categoryLabel(request.category), state.requestedBy, state.queueCount && state.queueCount > 0 ? `${state.queueCount} more waiting` : ''].filter(Boolean).join(' · ');
455
+ if (right) f.canvas.right(f.r, f.t, right, { fg: t.textFaint });
456
+
457
+ const scrollBottom = f.bottom - pinned;
458
+ const capacity = Math.max(1, scrollBottom - f.top + 1);
459
+ const maxScroll = Math.max(0, body.length - capacity);
460
+ let scroll = Math.max(0, Math.min(state.scroll ?? 0, maxScroll));
461
+ // Keep the hunk cursor in view.
462
+ const cursorRow = body.findIndex((r) => r.cursor);
463
+ if (cursorRow >= 0) {
464
+ if (cursorRow < scroll) scroll = cursorRow;
465
+ else if (cursorRow >= scroll + capacity) scroll = cursorRow - capacity + 1;
466
+ }
467
+ for (let i = 0; i < capacity && scroll + i < body.length; i++) {
468
+ const row = body[scroll + i]!;
469
+ const y = f.top + i;
470
+ if (row.inset) f.canvas.fill(f.l - 2, y, f.r - f.l + 5, 1, row.tint ?? t.backgroundElement);
471
+ if (row.cursor) {
472
+ drawRow(f.canvas, y, { spans: row.spans.map((s) => ({ ...s, fg: undefined })), selected: true }, f.l, f.r);
473
+ continue;
474
+ }
475
+ let x = row.inset ? f.l : f.l;
476
+ for (const span of row.spans) {
477
+ if (span.text.startsWith('\u0000')) {
478
+ x = chip(f.canvas, x, y, span.text.slice(1), span.fg ?? t.warning);
479
+ continue;
480
+ }
481
+ x = f.canvas.put(x, y, span.text, { fg: span.fg ?? t.text, bold: span.bold });
482
+ }
483
+ if (row.right) f.canvas.right(f.r, y, row.right.text, { fg: row.right.fg });
484
+ }
485
+ if (body.length > capacity) f.hintRight = scrollCountText(scroll, Math.max(0, body.length - scroll - capacity));
486
+
487
+ let y = scrollBottom + 1;
488
+ if (buttons.length > 0) {
489
+ y++;
490
+ const choice = Math.max(0, Math.min(state.choice ?? 0, buttons.length - 1));
491
+ let bx = f.l;
492
+ buttons.forEach((b, i) => {
493
+ if (bx + buttonWidth(b.label) - 1 > f.r) return;
494
+ bx = button(f.canvas, bx, y, b.label, i === choice, b.id === 'deny' ? 'danger' : 'warning') + 2;
495
+ });
496
+ const detailsHint = state.detailsExpanded ? 'hide details' : 'details';
497
+ if (!state.hunkState && bx + detailsHint.length + 4 <= f.r) {
498
+ const start = f.canvas.right(f.r, y, detailsHint, { fg: t.textFaint });
499
+ f.canvas.put(start - 2, y, 'd', { fg: t.text, bold: true });
500
+ }
501
+ y++;
502
+ }
503
+ if (tierRows.length > 0) {
504
+ y++;
505
+ for (const row of tierRows) {
506
+ if (y > f.bottom) break;
507
+ y += drawRow(f.canvas, y, row, tierX0, tierX1, f.bottom);
508
+ }
509
+ }
510
+ return finishModal(f);
511
+ }