@pellux/goodvibes-tui 2.0.21 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (388) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +16 -16
  3. package/docs/README.md +4 -4
  4. package/docs/foundation-artifacts/operator-contract.json +1 -1
  5. package/package.json +16 -6
  6. package/src/cli/management.ts +5 -2
  7. package/src/cli/tui-startup.ts +34 -39
  8. package/src/core/agent-view-render.ts +157 -0
  9. package/src/core/approval-alert.ts +49 -11
  10. package/src/core/bookmark-navigation.ts +7 -1
  11. package/src/core/budget-breach-notifier.ts +27 -8
  12. package/src/core/compaction-receipt.ts +1 -1
  13. package/src/core/context-usage.ts +1 -1
  14. package/src/core/conversation-compaction-render.ts +3 -3
  15. package/src/core/conversation-display-only.ts +86 -0
  16. package/src/core/conversation-event-navigation.ts +27 -0
  17. package/src/core/conversation-line-cache.ts +216 -447
  18. package/src/core/conversation-render-context.ts +20 -27
  19. package/src/core/conversation-rendering.ts +157 -575
  20. package/src/core/conversation-splash-state.ts +6 -1
  21. package/src/core/conversation-types.ts +35 -0
  22. package/src/core/conversation-usage.ts +12 -0
  23. package/src/core/conversation.ts +130 -131
  24. package/src/core/long-task-notifier.ts +100 -31
  25. package/src/core/notices.ts +75 -0
  26. package/src/core/pairing-tailscale-gateway.ts +1 -1
  27. package/src/core/process-output-log.ts +143 -0
  28. package/src/core/rewind-receipt.ts +1 -1
  29. package/src/core/session-focus.ts +190 -0
  30. package/src/core/session-resume-core.ts +23 -81
  31. package/src/core/stream-event-wiring.ts +31 -5
  32. package/src/core/system-message-noise.ts +2 -2
  33. package/src/core/system-message-router.ts +16 -18
  34. package/src/core/turn-event-wiring.ts +272 -33
  35. package/src/core/work-tree-focus.ts +290 -0
  36. package/src/core/work-tree-fold-store.ts +143 -0
  37. package/src/core/work-tree-model.ts +799 -0
  38. package/src/core/work-tree-render.ts +384 -0
  39. package/src/core/work-tree-sources.ts +184 -0
  40. package/src/core/work-tree-wiring.ts +176 -0
  41. package/src/core/workstream-notification.ts +36 -6
  42. package/src/core/wrfc-notice-titles.ts +37 -0
  43. package/src/input/agents-modal-text.ts +119 -0
  44. package/src/input/agents-modal.ts +699 -0
  45. package/src/input/bookmark-modal.ts +33 -9
  46. package/src/input/changes-git.ts +251 -0
  47. package/src/input/changes-modal.ts +629 -0
  48. package/src/input/changes-model.ts +125 -0
  49. package/src/input/command-palette-types.ts +41 -0
  50. package/src/input/command-palette.ts +272 -0
  51. package/src/input/command-registry.ts +77 -35
  52. package/src/input/commands/acp-runtime.ts +19 -9
  53. package/src/input/commands/channel-runtime.ts +2 -2
  54. package/src/input/commands/ci-runtime.ts +1 -1
  55. package/src/input/commands/codebase-runtime.ts +1 -1
  56. package/src/input/commands/config.ts +7 -1
  57. package/src/input/commands/context-window.ts +25 -19
  58. package/src/input/commands/control-room-runtime.ts +18 -18
  59. package/src/input/commands/conversation-runtime.ts +0 -1
  60. package/src/input/commands/cost-runtime.ts +29 -20
  61. package/src/input/commands/diff-runtime.ts +35 -181
  62. package/src/input/commands/eval.ts +1 -1
  63. package/src/input/commands/experience-runtime.ts +3 -7
  64. package/src/input/commands/fresh-conversation.ts +86 -0
  65. package/src/input/commands/git-runtime.ts +5 -22
  66. package/src/input/commands/graph-runtime.ts +1 -1
  67. package/src/input/commands/guidance-runtime.ts +1 -1
  68. package/src/input/commands/health-runtime.ts +8 -9
  69. package/src/input/commands/hooks-runtime.ts +2 -2
  70. package/src/input/commands/hosted-runtime.ts +3 -3
  71. package/src/input/commands/incident-runtime.ts +3 -3
  72. package/src/input/commands/intelligence-runtime.ts +6 -9
  73. package/src/input/commands/knowledge.ts +2 -2
  74. package/src/input/commands/legacy-panel-command.ts +57 -0
  75. package/src/input/commands/local-auth-runtime.ts +8 -9
  76. package/src/input/commands/local-provider-runtime.ts +7 -4
  77. package/src/input/commands/local-runtime.ts +12 -15
  78. package/src/input/commands/local-setup-review.ts +1 -1
  79. package/src/input/commands/local-setup.ts +13 -19
  80. package/src/input/commands/memory.ts +1 -1
  81. package/src/input/commands/notify-runtime.ts +9 -0
  82. package/src/input/commands/operator-runtime.ts +10 -12
  83. package/src/input/commands/permissions-provenance.ts +3 -3
  84. package/src/input/commands/planning-runtime.ts +7 -7
  85. package/src/input/commands/platform-sandbox-runtime.ts +2 -2
  86. package/src/input/commands/platform-services-runtime.ts +1 -1
  87. package/src/input/commands/policy-dispatch.ts +2 -2
  88. package/src/input/commands/policy.ts +3 -3
  89. package/src/input/commands/provider.ts +1 -1
  90. package/src/input/commands/remote-runtime.ts +1 -1
  91. package/src/input/commands/review-runtime.ts +39 -94
  92. package/src/input/commands/rewind-runtime.ts +81 -108
  93. package/src/input/commands/runtime-services.ts +15 -17
  94. package/src/input/commands/services-runtime.ts +2 -2
  95. package/src/input/commands/session-content.ts +21 -5
  96. package/src/input/commands/session-workflow.ts +13 -38
  97. package/src/input/commands/settings-sync-runtime.ts +2 -2
  98. package/src/input/commands/shell-core.ts +19 -4
  99. package/src/input/commands/skills-runtime.ts +2 -2
  100. package/src/input/commands/status-runtime.ts +13 -0
  101. package/src/input/commands/subscription-runtime.ts +1 -1
  102. package/src/input/commands/tasks-runtime.ts +2 -8
  103. package/src/input/commands/work-plan-runtime.ts +4 -4
  104. package/src/input/commands/workstream-attempts.ts +24 -47
  105. package/src/input/commands/workstream-runtime.ts +2 -2
  106. package/src/input/commands/worktree-runtime.ts +3 -3
  107. package/src/input/commands.ts +3 -1
  108. package/src/input/composer-editor.ts +44 -0
  109. package/src/input/concealed-input.ts +1 -1
  110. package/src/input/config-modal-types.ts +20 -8
  111. package/src/input/config-modal.ts +106 -105
  112. package/src/input/confirm-dialog-types.ts +28 -0
  113. package/src/input/confirm-dialog.ts +70 -0
  114. package/src/input/delete-key-policy.ts +3 -3
  115. package/src/input/feed-context-factory.ts +15 -16
  116. package/src/input/handler-command-route.ts +7 -42
  117. package/src/input/handler-content-actions.ts +16 -15
  118. package/src/input/handler-feed-routes.ts +26 -211
  119. package/src/input/handler-feed.ts +68 -95
  120. package/src/input/handler-interactions.ts +5 -3
  121. package/src/input/handler-modal-routes.ts +215 -216
  122. package/src/input/handler-modal-stack.ts +81 -11
  123. package/src/input/handler-modal-token-routes.ts +4 -0
  124. package/src/input/handler-onboarding.ts +0 -1
  125. package/src/input/handler-picker-routes.ts +65 -107
  126. package/src/input/handler-session-view-route.ts +149 -0
  127. package/src/input/handler-shortcuts.ts +16 -154
  128. package/src/input/handler-types.ts +4 -2
  129. package/src/input/handler-ui-state.ts +45 -15
  130. package/src/input/handler-work-tree-route.ts +123 -0
  131. package/src/input/handler.ts +25 -70
  132. package/src/input/keybindings.ts +16 -71
  133. package/src/input/masked-entry-modal.ts +117 -0
  134. package/src/input/mcp-workspace.ts +48 -17
  135. package/src/input/model-picker-filter.ts +8 -0
  136. package/src/input/model-picker-open.ts +99 -0
  137. package/src/input/model-picker-types.ts +1 -1
  138. package/src/input/model-picker.ts +92 -9
  139. package/src/input/notifications-modal.ts +123 -0
  140. package/src/input/overlay-filter.ts +26 -0
  141. package/src/input/profile-picker-modal.ts +37 -8
  142. package/src/input/search.ts +3 -3
  143. package/src/input/selection-modal-queue.ts +2 -0
  144. package/src/input/selection-modal.ts +28 -16
  145. package/src/input/session-picker-modal.ts +33 -8
  146. package/src/input/settings-modal-activation.ts +7 -2
  147. package/src/input/settings-modal-data.ts +11 -6
  148. package/src/input/settings-modal-types.ts +1 -1
  149. package/src/input/settings-modal.ts +20 -20
  150. package/src/input/submission-intent.ts +1 -1
  151. package/src/input/submission-router.ts +4 -9
  152. package/src/input/surface-modal-host.ts +110 -0
  153. package/src/input/theme-settings-actions.ts +82 -0
  154. package/src/input/usage-modal.ts +183 -0
  155. package/src/input/views.ts +97 -0
  156. package/src/main.ts +175 -165
  157. package/src/permissions/hunk-selection.ts +1 -1
  158. package/src/permissions/prompt-card.ts +511 -0
  159. package/src/permissions/prompt.ts +59 -622
  160. package/src/renderer/agents-modal.ts +310 -0
  161. package/src/renderer/autocomplete-overlay.ts +42 -220
  162. package/src/renderer/back-to-bottom.ts +80 -0
  163. package/src/renderer/block-actions-overlay.ts +30 -48
  164. package/src/renderer/bookmark-modal.ts +48 -79
  165. package/src/renderer/changes-modal.ts +325 -0
  166. package/src/renderer/code-block.ts +184 -106
  167. package/src/renderer/command-palette.ts +93 -0
  168. package/src/renderer/composer-fragments.ts +4 -4
  169. package/src/renderer/composer.ts +146 -0
  170. package/src/renderer/compositor.ts +26 -160
  171. package/src/renderer/config-modal.ts +185 -81
  172. package/src/renderer/confirm-dialog.ts +50 -0
  173. package/src/renderer/context-inspector.ts +116 -154
  174. package/src/renderer/context-status-hint.ts +4 -4
  175. package/src/renderer/conversation-layout.ts +20 -0
  176. package/src/renderer/conversation-overlays.ts +94 -84
  177. package/src/renderer/conversation-surface.ts +19 -38
  178. package/src/renderer/diff-tint.ts +98 -0
  179. package/src/renderer/file-picker-overlay.ts +63 -90
  180. package/src/renderer/footer-targets.ts +38 -0
  181. package/src/renderer/git-status.ts +3 -4
  182. package/src/renderer/header-line.ts +140 -117
  183. package/src/renderer/help-overlay.ts +133 -219
  184. package/src/renderer/history-search-overlay.ts +15 -12
  185. package/src/renderer/lane-graph/bead.ts +800 -0
  186. package/src/renderer/lane-graph/glyphs.ts +134 -0
  187. package/src/renderer/lane-graph/layout.ts +248 -0
  188. package/src/renderer/lane-graph/paint.ts +537 -0
  189. package/src/renderer/lane-graph/semantic-memo.ts +77 -0
  190. package/src/renderer/layout-engine.ts +2 -57
  191. package/src/renderer/layout.ts +7 -13
  192. package/src/renderer/markdown-inline.ts +2 -1
  193. package/src/renderer/markdown-table.ts +17 -12
  194. package/src/renderer/markdown.ts +92 -34
  195. package/src/renderer/masked-entry-modal.ts +78 -0
  196. package/src/renderer/mcp-workspace.ts +166 -203
  197. package/src/renderer/model-workspace.ts +381 -436
  198. package/src/renderer/notifications-modal.ts +116 -0
  199. package/src/renderer/onboarding/onboarding-wizard.ts +178 -588
  200. package/src/renderer/process-view.ts +192 -0
  201. package/src/renderer/profile-picker-modal.ts +58 -110
  202. package/src/renderer/search-overlay.ts +6 -3
  203. package/src/renderer/selection-modal-overlay.ts +87 -327
  204. package/src/renderer/semantic-diff.ts +2 -2
  205. package/src/renderer/session-chips.ts +102 -0
  206. package/src/renderer/session-picker-modal.ts +177 -297
  207. package/src/renderer/settings-modal-connections.ts +14 -23
  208. package/src/renderer/settings-modal-context.ts +367 -0
  209. package/src/renderer/settings-modal-helpers.ts +6 -11
  210. package/src/renderer/settings-modal.ts +322 -726
  211. package/src/renderer/shell-surface.ts +277 -175
  212. package/src/renderer/shortcuts-overlay.ts +217 -0
  213. package/src/renderer/status-line.ts +475 -0
  214. package/src/renderer/surface-compose.ts +174 -0
  215. package/src/renderer/surface-kit-extra.ts +303 -0
  216. package/src/renderer/surface-kit-list.ts +366 -0
  217. package/src/renderer/surface-kit-parts.ts +360 -0
  218. package/src/renderer/surface-kit.ts +568 -0
  219. package/src/renderer/syntax-highlighter.ts +280 -197
  220. package/src/renderer/syntax-theme.ts +51 -0
  221. package/src/renderer/system-message.ts +0 -1
  222. package/src/renderer/term-caps.ts +42 -1
  223. package/src/renderer/terminal-bg-probe.ts +116 -19
  224. package/src/renderer/terminal-palette-probe.ts +322 -0
  225. package/src/renderer/terminal-palette-reader.ts +183 -0
  226. package/src/renderer/terminal-palette.ts +58 -0
  227. package/src/renderer/theme-mode-config.ts +16 -1
  228. package/src/renderer/theme.ts +234 -207
  229. package/src/renderer/thinking.ts +0 -1
  230. package/src/renderer/throbber.ts +188 -0
  231. package/src/renderer/toast-center.ts +119 -0
  232. package/src/renderer/ui-factory.ts +39 -602
  233. package/src/renderer/ui-primitives.ts +8 -25
  234. package/src/renderer/usage-modal.ts +353 -0
  235. package/src/renderer/user-message.ts +58 -0
  236. package/src/renderer/voice-capture-chip.ts +53 -0
  237. package/src/runtime/bootstrap-command-context.ts +0 -5
  238. package/src/runtime/bootstrap-command-parts.ts +38 -89
  239. package/src/runtime/bootstrap-core.ts +13 -15
  240. package/src/runtime/bootstrap-hook-bridge.ts +12 -21
  241. package/src/runtime/bootstrap-notifier-sync.ts +17 -1
  242. package/src/runtime/bootstrap-shell.ts +16 -50
  243. package/src/runtime/bootstrap.ts +14 -14
  244. package/src/runtime/client/fleet-union.ts +5 -5
  245. package/src/runtime/client/hosted-exit.ts +2 -2
  246. package/src/runtime/client/hosted-sessions.ts +1 -1
  247. package/src/runtime/client/spine-adoption.ts +1 -1
  248. package/src/runtime/context-accounting-source.ts +5 -4
  249. package/src/runtime/index.ts +1 -0
  250. package/src/runtime/notification-dispatch.ts +102 -29
  251. package/src/runtime/recovery-offer-wiring.ts +4 -1
  252. package/src/runtime/recovery-prompt.ts +31 -15
  253. package/src/runtime/runtime-services-types.ts +10 -6
  254. package/src/runtime/services.ts +17 -12
  255. package/src/runtime/session-continuity-hints.ts +2 -5
  256. package/src/runtime/session-inbound-inputs.ts +1 -1
  257. package/src/runtime/session-resume-liveness-confirm.ts +14 -2
  258. package/src/runtime/startup-typeahead-gate.ts +103 -0
  259. package/src/runtime/store/domains/domain-read-matrix.ts +0 -1
  260. package/src/runtime/store/domains/index.ts +0 -8
  261. package/src/runtime/store/selectors/index.ts +0 -28
  262. package/src/runtime/store/state.ts +0 -8
  263. package/src/runtime/ui-services.ts +4 -6
  264. package/src/runtime/usage-tracker.ts +348 -0
  265. package/src/runtime/wrfc-persistence.ts +4 -4
  266. package/src/shell/blocking-input.ts +92 -2
  267. package/src/shell/recovery-input-helpers.ts +1 -1
  268. package/src/shell/session-views.ts +539 -0
  269. package/src/shell/status-report.ts +86 -0
  270. package/src/shell/transcript-scroll.ts +68 -0
  271. package/src/shell/ui-openers.ts +84 -172
  272. package/src/shell/view-openers.ts +205 -0
  273. package/src/tools/wrfc-agent-guard.ts +22 -2
  274. package/src/utils/format-duration.ts +4 -8
  275. package/src/utils/splash-lines.ts +9 -1
  276. package/src/utils/terminal-width.ts +56 -0
  277. package/src/verification/verification-ledger.ts +18 -16
  278. package/src/{panels → views}/agent-inspector-shared.ts +2 -2
  279. package/src/{panels → views}/builtin-modals.ts +22 -21
  280. package/src/views/builtin-views.ts +135 -0
  281. package/src/{panels → views}/confirm-state.ts +9 -9
  282. package/src/{panels → views}/diff-review-model.ts +10 -3
  283. package/src/{panels → views}/eval-registry.ts +1 -1
  284. package/src/{panels → views}/fleet-acts.ts +49 -85
  285. package/src/{panels/fleet-panel-format.ts → views/fleet-format.ts} +40 -40
  286. package/src/{panels → views}/fleet-gateway.ts +5 -5
  287. package/src/{panels → views}/fleet-observed-render.ts +16 -16
  288. package/src/{panels → views}/fleet-read-model.ts +9 -9
  289. package/src/{panels/fleet-tabs.ts → views/fleet-session-tabs.ts} +13 -13
  290. package/src/{panels → views}/fleet-spawn.ts +33 -49
  291. package/src/{panels → views}/fleet-steer.ts +12 -12
  292. package/src/{panels → views}/fleet-stop.ts +4 -4
  293. package/src/{panels → views}/fleet-transcript.ts +26 -27
  294. package/src/{panels/fleet-panel-worktree-detail.ts → views/fleet-worktree-detail.ts} +7 -7
  295. package/src/{panels → views}/hosted-session-feed.ts +6 -6
  296. package/src/views/modal-surface-registry.ts +41 -0
  297. package/src/{panels → views}/modals/devices-modal.ts +2 -2
  298. package/src/{panels → views}/modals/hooks-modal.ts +13 -13
  299. package/src/{panels → views}/modals/keybindings-modal.ts +19 -27
  300. package/src/{panels → views}/modals/knowledge-modal.ts +6 -5
  301. package/src/{panels → views}/modals/local-auth-modal.ts +22 -21
  302. package/src/{panels → views}/modals/marketplace-modal.ts +11 -11
  303. package/src/{panels → views}/modals/memory-modal.ts +8 -8
  304. package/src/{panels → views}/modals/modal-surface-helpers.ts +15 -5
  305. package/src/{panels → views}/modals/pairing-modal.ts +9 -9
  306. package/src/{panels → views}/modals/planning-modal.ts +15 -20
  307. package/src/{panels → views}/modals/plugins-modal.ts +6 -6
  308. package/src/{panels → views}/modals/policy-modal.ts +13 -5
  309. package/src/{panels → views}/modals/provider-health-modal.ts +4 -4
  310. package/src/{panels → views}/modals/remote-modal.ts +3 -3
  311. package/src/{panels → views}/modals/sandbox-modal.ts +4 -4
  312. package/src/{panels → views}/modals/security-modal.ts +22 -16
  313. package/src/{panels → views}/modals/services-modal.ts +3 -3
  314. package/src/{panels → views}/modals/settings-sync-modal.ts +4 -4
  315. package/src/{panels → views}/modals/skills-modal.ts +9 -9
  316. package/src/{panels → views}/modals/subscription-modal.ts +3 -3
  317. package/src/{panels → views}/modals/work-plan-modal.ts +10 -13
  318. package/src/views/notifications-feed.ts +314 -0
  319. package/src/{panels → views}/polish-core.ts +38 -36
  320. package/src/{panels → views}/polish-tables.ts +35 -26
  321. package/src/{panels → views}/polish.ts +101 -95
  322. package/src/{panels → views}/project-planning-answer-actions.ts +3 -3
  323. package/src/{panels → views}/provider-health-routes.ts +14 -14
  324. package/src/{panels → views}/search-focus.ts +8 -8
  325. package/src/{panels → views}/session-maintenance.ts +11 -11
  326. package/src/views/skills-discovery.ts +159 -0
  327. package/src/views/view-deps.ts +80 -0
  328. package/src/views/view-panel-adapter.ts +53 -0
  329. package/src/{panels → views}/workstream-graph-render.ts +1 -1
  330. package/src/core/conversation-fold.ts +0 -101
  331. package/src/core/conversation-turn-structure.ts +0 -464
  332. package/src/core/conversation-user-receipts.ts +0 -42
  333. package/src/input/commands/operator-panel-runtime.ts +0 -186
  334. package/src/input/panel-integration-actions.ts +0 -33
  335. package/src/input/panel-mouse-geometry.ts +0 -97
  336. package/src/input/panel-paste-flood-guard.ts +0 -86
  337. package/src/panels/base-panel.ts +0 -301
  338. package/src/panels/builtin/agent.ts +0 -27
  339. package/src/panels/builtin/development.ts +0 -92
  340. package/src/panels/builtin/knowledge.ts +0 -12
  341. package/src/panels/builtin/operations.ts +0 -177
  342. package/src/panels/builtin/session.ts +0 -81
  343. package/src/panels/builtin/shared.ts +0 -357
  344. package/src/panels/builtin-panels.ts +0 -28
  345. package/src/panels/cost-tracker-panel.ts +0 -746
  346. package/src/panels/diff-panel.ts +0 -797
  347. package/src/panels/diff-review-panel.ts +0 -395
  348. package/src/panels/expandable-list-panel.ts +0 -189
  349. package/src/panels/fleet-deep-link.ts +0 -31
  350. package/src/panels/fleet-panel.ts +0 -799
  351. package/src/panels/git-panel.ts +0 -800
  352. package/src/panels/hosted-session-panel.ts +0 -221
  353. package/src/panels/index.ts +0 -21
  354. package/src/panels/local-auth-panel.ts +0 -478
  355. package/src/panels/notifications-feed.ts +0 -127
  356. package/src/panels/notifications-panel.ts +0 -160
  357. package/src/panels/panel-confirm-overlay.ts +0 -72
  358. package/src/panels/panel-manager.ts +0 -791
  359. package/src/panels/plugins-panel.ts +0 -371
  360. package/src/panels/scrollable-list-panel.ts +0 -498
  361. package/src/panels/skills-panel.ts +0 -454
  362. package/src/panels/token-budget-panel.ts +0 -688
  363. package/src/panels/types.ts +0 -191
  364. package/src/panels/wrfc-panel-format.ts +0 -133
  365. package/src/renderer/diff-view.ts +0 -134
  366. package/src/renderer/fleet-tab-strip.ts +0 -56
  367. package/src/renderer/footer-tips.ts +0 -53
  368. package/src/renderer/fullscreen-primitives.ts +0 -146
  369. package/src/renderer/fullscreen-workspace.ts +0 -194
  370. package/src/renderer/hint-grammar.ts +0 -52
  371. package/src/renderer/modal-factory.ts +0 -480
  372. package/src/renderer/overlay-box.ts +0 -154
  373. package/src/renderer/overlay-viewport.ts +0 -104
  374. package/src/renderer/panel-composite.ts +0 -145
  375. package/src/renderer/panel-workspace-bar.ts +0 -54
  376. package/src/renderer/process-indicator.ts +0 -163
  377. package/src/renderer/tab-strip.ts +0 -222
  378. package/src/renderer/text-layout.ts +0 -31
  379. package/src/renderer/tool-call.ts +0 -276
  380. package/src/renderer/tool-result-expanded-lines.ts +0 -48
  381. package/src/renderer/tool-result-summary.ts +0 -219
  382. package/src/runtime/diagnostics/panels/index.ts +0 -21
  383. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  384. package/src/runtime/perf/panel-contracts.ts +0 -32
  385. package/src/runtime/store/domains/panels.ts +0 -117
  386. /package/src/{panels → views}/memory-consolidation-gateway.ts +0 -0
  387. /package/src/{panels → views}/modals/modal-theme.ts +0 -0
  388. /package/src/{panels → views}/provider-health-domains.ts +0 -0
@@ -0,0 +1,629 @@
1
+ /**
2
+ * changes-modal.ts, the Changes modal (/changes, /diff, /git diff, /review,
3
+ * ctrl+p "changes"). Holds the Git, Diff and Review views.
4
+ *
5
+ * Two modes:
6
+ * - workspace: the repository's changes from git (this session's files, not
7
+ * staged, staged, or everything vs HEAD; v cycles), recent commits under
8
+ * the file list. Hunks can be staged, commented on for the model, reverted
9
+ * and opened in the editor; files marked reviewed; staged work committed.
10
+ * - preview: a diff handed in by something else (a fleet candidate, a rewind
11
+ * checkpoint, a workstream attempt), read-only, optionally carrying a
12
+ * question with two buttons (pick it? restore it?).
13
+ *
14
+ * Esc peels one level: an open comment or commit message, then the modal.
15
+ * Esc on a preview question answers "no".
16
+ */
17
+
18
+ import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
19
+ import type { SurfaceModal, SurfaceModalCloseReason, SurfaceModalHost } from './surface-modal-host.ts';
20
+ import type { SurfaceLayer, KitHint } from '../renderer/surface-kit.ts';
21
+ import type { KitRow } from '../renderer/surface-kit-list.ts';
22
+ import { formatSemanticDiffSummary, type SemanticDiff } from '../renderer/semantic-diff.ts';
23
+ import { renderChangesModal, type ChangesFootLine, type ChangesModalView, type ChangesQuestion } from '../renderer/changes-modal.ts';
24
+ import { activeTokens } from '../renderer/theme.ts';
25
+ import { changeTotals, hunkPatch, parseChanges, type ChangeFile, type ChangeHunk } from './changes-model.ts';
26
+ import {
27
+ applyHunkToIndex, commitStaged, initRepo, isGitRepo, loadCommitDiff, loadDiff, loadRecentCommits, loadRepoSummary,
28
+ loadSemanticDiff, setFileStaged, CHANGES_SOURCES, CHANGES_SOURCE_LABELS, type ChangesSource, type CommitEntry, type RepoSummary,
29
+ } from './changes-git.ts';
30
+ import { buildSteerMessage, type HunkComment, type ReviewHunk } from '../views/diff-review-model.ts';
31
+ import { isTextBackspace } from './delete-key-policy.ts';
32
+ import type { ConfirmOptions } from './confirm-dialog.ts';
33
+
34
+ export interface ChangesDeps {
35
+ readonly workingDirectory: string;
36
+ /** Files this session edited (the session change tracker). */
37
+ readonly getSessionFiles: () => readonly string[];
38
+ readonly requestRender: () => void;
39
+ /** Send text to the model as the next user turn (review comments). */
40
+ readonly submitInput?: (text: string) => void;
41
+ readonly openInEditor?: (path: string, line?: number) => void;
42
+ /** Revert one hunk in the working tree (checkpoint-backed, asks first). Resolves to a status line. */
43
+ readonly revertHunk?: (hunk: ReviewHunk) => Promise<string>;
44
+ readonly confirm?: (options: ConfirmOptions) => Promise<boolean>;
45
+ }
46
+
47
+ export interface ChangesQuestionSpec {
48
+ readonly text: string;
49
+ readonly confirmLabel: string;
50
+ readonly cancelLabel?: string;
51
+ readonly tone?: 'danger' | 'warning' | 'primary';
52
+ }
53
+
54
+ interface PendingQuestion extends ChangesQuestionSpec {
55
+ chosen: 0 | 1;
56
+ readonly answer: (confirmed: boolean) => void;
57
+ }
58
+
59
+ type Item = { readonly kind: 'file'; readonly index: number } | { readonly kind: 'commit'; readonly index: number };
60
+
61
+ function commentKey(hunk: ReviewHunk): string {
62
+ return `${hunk.filePath}:${hunk.header}`;
63
+ }
64
+
65
+ export class ChangesModal implements SurfaceModal {
66
+ readonly name = 'changes';
67
+ readonly mode: 'workspace' | 'preview';
68
+ source: ChangesSource = 'session';
69
+ files: ChangeFile[] = [];
70
+ commits: CommitEntry[] = [];
71
+ label = '';
72
+ summary: RepoSummary | null = null;
73
+ selected = 0;
74
+ hunkIndex = 0;
75
+ scrollDelta = 0;
76
+ loading = false;
77
+ error: string | null = null;
78
+ notRepo = false;
79
+ status: { text: string; tone: ChangesFootLine['tone'] } | null = null;
80
+ composing: string | null = null;
81
+ commitDraft: string | null = null;
82
+ readonly reviewed = new Set<string>();
83
+ readonly comments = new Map<string, { hunk: ReviewHunk; text: string; sent: boolean }>();
84
+ private readonly semantic = new Map<string, SemanticDiff | null | undefined>();
85
+ private readonly commitFiles = new Map<string, ChangeFile[] | string>();
86
+ private previewTitle = '';
87
+ private previewNote: string | null = null;
88
+ private question: PendingQuestion | null = null;
89
+ private loadSeq = 0;
90
+
91
+ constructor(private readonly deps: ChangesDeps, mode: 'workspace' | 'preview' = 'workspace') {
92
+ this.mode = mode;
93
+ }
94
+
95
+ // ── Loading ────────────────────────────────────────────────────────────────
96
+
97
+ /** Load (or reload) the workspace diff for a source. */
98
+ async reload(source: ChangesSource = this.source): Promise<void> {
99
+ this.source = source;
100
+ const seq = ++this.loadSeq;
101
+ const cwd = this.deps.workingDirectory;
102
+ this.notRepo = !isGitRepo(cwd);
103
+ if (this.notRepo) {
104
+ this.files = [];
105
+ this.commits = [];
106
+ this.error = null;
107
+ this.deps.requestRender();
108
+ return;
109
+ }
110
+ this.loading = true;
111
+ this.deps.requestRender();
112
+ const [diff, summary, commits] = await Promise.all([
113
+ loadDiff(cwd, source, this.deps.getSessionFiles()),
114
+ loadRepoSummary(cwd),
115
+ loadRecentCommits(cwd, 10),
116
+ ]);
117
+ if (seq !== this.loadSeq) return;
118
+ const previousPath = this.currentFile()?.path;
119
+ this.loading = false;
120
+ this.error = diff.error;
121
+ this.label = diff.label;
122
+ this.files = diff.error ? [] : parseChanges(diff.raw);
123
+ this.summary = summary;
124
+ this.commits = commits;
125
+ this.semantic.clear();
126
+ const keep = previousPath ? this.files.findIndex((f) => f.path === previousPath) : -1;
127
+ this.selected = keep >= 0 ? keep : Math.min(this.selected, Math.max(0, this.files.length - 1));
128
+ this.hunkIndex = Math.min(this.hunkIndex, Math.max(0, this.diffHunks().length - 1));
129
+ this.scrollDelta = 0;
130
+ this.ensureSemantic();
131
+ this.deps.requestRender();
132
+ }
133
+
134
+ /** Show a diff handed in by something else (preview mode). */
135
+ loadPreview(title: string, diff: string | null, note: string | null): void {
136
+ this.previewTitle = title;
137
+ this.previewNote = note;
138
+ this.files = diff ? parseChanges(diff) : [];
139
+ if (this.files.length === 1 && this.files[0]!.path === 'unknown') this.files = [{ ...this.files[0]!, path: title }];
140
+ this.selected = 0;
141
+ this.hunkIndex = 0;
142
+ this.scrollDelta = 0;
143
+ }
144
+
145
+ /** Ask a question over this preview; resolves when answered (Esc or closing answers no). */
146
+ ask(spec: ChangesQuestionSpec): Promise<boolean> {
147
+ this.question?.answer(false);
148
+ return new Promise((resolve) => {
149
+ this.question = { ...spec, chosen: spec.tone === 'danger' || spec.tone === 'warning' ? 0 : 1, answer: resolve };
150
+ this.deps.requestRender();
151
+ });
152
+ }
153
+
154
+ private ensureSemantic(): void {
155
+ const file = this.currentFile();
156
+ if (!file || this.mode !== 'workspace' || this.semantic.has(file.path) || file.headerOnly) return;
157
+ this.semantic.set(file.path, undefined);
158
+ void loadSemanticDiff(this.deps.workingDirectory, file.path, 'HEAD').then((result) => {
159
+ this.semantic.set(file.path, result);
160
+ this.deps.requestRender();
161
+ });
162
+ }
163
+
164
+ private ensureCommit(commit: CommitEntry): void {
165
+ if (this.commitFiles.has(commit.hash)) return;
166
+ this.commitFiles.set(commit.hash, []);
167
+ void loadCommitDiff(this.deps.workingDirectory, commit.hash).then((result) => {
168
+ this.commitFiles.set(commit.hash, result.error ?? parseChanges(result.raw));
169
+ this.deps.requestRender();
170
+ });
171
+ }
172
+
173
+ // ── Selection ──────────────────────────────────────────────────────────────
174
+
175
+ private items(): Item[] {
176
+ const items: Item[] = this.files.map((_, index) => ({ kind: 'file', index }));
177
+ if (this.mode === 'workspace') this.commits.forEach((_, index) => items.push({ kind: 'commit', index }));
178
+ return items;
179
+ }
180
+
181
+ private currentItem(): Item | null {
182
+ return this.items()[this.selected] ?? null;
183
+ }
184
+
185
+ currentFile(): ChangeFile | null {
186
+ const item = this.currentItem();
187
+ return item?.kind === 'file' ? this.files[item.index] ?? null : null;
188
+ }
189
+
190
+ private currentCommit(): CommitEntry | null {
191
+ const item = this.currentItem();
192
+ return item?.kind === 'commit' ? this.commits[item.index] ?? null : null;
193
+ }
194
+
195
+ /** The files the diff region shows. */
196
+ diffFiles(): ChangeFile[] {
197
+ const commit = this.currentCommit();
198
+ if (commit) {
199
+ const loaded = this.commitFiles.get(commit.hash);
200
+ return Array.isArray(loaded) ? loaded : [];
201
+ }
202
+ const file = this.currentFile();
203
+ return file ? [file] : [];
204
+ }
205
+
206
+ private diffHunks(): Array<{ file: ChangeFile; hunk: ChangeHunk }> {
207
+ return this.diffFiles().flatMap((file) => file.hunks.map((hunk) => ({ file, hunk })));
208
+ }
209
+
210
+ currentHunk(): { file: ChangeFile; hunk: ChangeHunk } | null {
211
+ return this.diffHunks()[this.hunkIndex] ?? null;
212
+ }
213
+
214
+ private select(index: number): void {
215
+ const count = this.items().length;
216
+ if (count === 0) return;
217
+ this.selected = Math.max(0, Math.min(count - 1, index));
218
+ this.hunkIndex = 0;
219
+ this.scrollDelta = 0;
220
+ const commit = this.currentCommit();
221
+ if (commit) this.ensureCommit(commit);
222
+ else this.ensureSemantic();
223
+ }
224
+
225
+ /** ] and [: the next or previous hunk, moving to the next or previous file at the ends. */
226
+ stepHunk(direction: 1 | -1): void {
227
+ const hunks = this.diffHunks();
228
+ const next = this.hunkIndex + direction;
229
+ this.scrollDelta = 0;
230
+ if (next >= 0 && next < hunks.length) {
231
+ this.hunkIndex = next;
232
+ return;
233
+ }
234
+ const item = this.currentItem();
235
+ if (item?.kind !== 'file') return;
236
+ for (let i = item.index + direction; i >= 0 && i < this.files.length; i += direction) {
237
+ if (this.files[i]!.hunks.length === 0) continue;
238
+ this.select(i);
239
+ this.hunkIndex = direction === 1 ? 0 : Math.max(0, this.files[i]!.hunks.length - 1);
240
+ return;
241
+ }
242
+ this.status = { text: direction === 1 ? 'That was the last hunk.' : 'That was the first hunk.', tone: 'faint' };
243
+ }
244
+
245
+ // ── Actions ────────────────────────────────────────────────────────────────
246
+
247
+ private note(text: string, tone: ChangesFootLine['tone'] = 'muted'): void {
248
+ this.status = { text, tone };
249
+ this.deps.requestRender();
250
+ }
251
+
252
+ /** Space: stage the selected hunk (unstage it in the staged view). */
253
+ async stageHunk(): Promise<void> {
254
+ const current = this.currentHunk();
255
+ if (!current || this.mode !== 'workspace' || this.currentCommit()) return;
256
+ const reverse = this.source === 'staged';
257
+ const error = await applyHunkToIndex(this.deps.workingDirectory, hunkPatch(current.file, current.hunk), reverse);
258
+ if (error) {
259
+ this.note(`${reverse ? 'Unstage' : 'Stage'} failed: ${error}`, 'error');
260
+ return;
261
+ }
262
+ const range = `${current.file.path}:${current.hunk.review.newStart}`;
263
+ await this.reload();
264
+ this.note(reverse ? `Unstaged the hunk at ${range}.` : `Staged the hunk at ${range}.`, 'success');
265
+ }
266
+
267
+ /** S: stage the selected file (unstage it in the staged view). */
268
+ async stageFile(): Promise<void> {
269
+ const file = this.currentFile();
270
+ if (!file || this.mode !== 'workspace') return;
271
+ const stage = this.source !== 'staged';
272
+ const error = await setFileStaged(this.deps.workingDirectory, file.path, stage);
273
+ if (error) {
274
+ this.note(`${stage ? 'Stage' : 'Unstage'} failed: ${error}`, 'error');
275
+ return;
276
+ }
277
+ await this.reload();
278
+ this.note(stage ? `Staged ${file.path}.` : `Unstaged ${file.path}.`, 'success');
279
+ }
280
+
281
+ toggleReviewed(): void {
282
+ const file = this.currentFile();
283
+ if (!file) return;
284
+ if (this.reviewed.has(file.path)) this.reviewed.delete(file.path);
285
+ else this.reviewed.add(file.path);
286
+ this.status = { text: this.reviewed.has(file.path) ? `Marked ${file.path} reviewed.` : `${file.path} is no longer marked reviewed.`, tone: 'muted' };
287
+ }
288
+
289
+ private openEditor(): void {
290
+ const current = this.currentHunk();
291
+ const file = current?.file ?? this.currentFile();
292
+ if (!file) return;
293
+ if (!this.deps.openInEditor) {
294
+ this.status = { text: 'Opening an editor is not available here.', tone: 'error' };
295
+ return;
296
+ }
297
+ this.deps.openInEditor(file.path, current?.hunk.review.newStart);
298
+ }
299
+
300
+ private async revert(): Promise<void> {
301
+ const current = this.currentHunk();
302
+ if (!current || this.mode !== 'workspace' || this.currentCommit()) return;
303
+ if (this.source === 'staged') {
304
+ this.note('Revert works on the working tree; switch the view with v first.', 'faint');
305
+ return;
306
+ }
307
+ if (!this.deps.revertHunk) {
308
+ this.note('Hunk revert is not available in this session.', 'error');
309
+ return;
310
+ }
311
+ const message = await this.deps.revertHunk(current.hunk.review);
312
+ await this.reload();
313
+ this.note(message);
314
+ }
315
+
316
+ /** Send every attached comment that has not been sent (closes the modal first). */
317
+ private sendComments(host: SurfaceModalHost): void {
318
+ const pending: HunkComment[] = [...this.comments.values()].filter((c) => !c.sent).map((c) => ({ hunk: c.hunk, comment: c.text }));
319
+ if (pending.length === 0) {
320
+ this.status = { text: 'No comments to send. Press c on a hunk to write one.', tone: 'faint' };
321
+ return;
322
+ }
323
+ if (!this.deps.submitInput) {
324
+ this.status = { text: 'No session is wired to receive comments here.', tone: 'error' };
325
+ return;
326
+ }
327
+ for (const c of this.comments.values()) c.sent = true;
328
+ // Each commented file's semantic summary (when it was read) rides along,
329
+ // so the model sees which functions and imports the change touched.
330
+ const semanticByFile = new Map<string, string>();
331
+ for (const { hunk } of pending) {
332
+ const diff = this.semantic.get(hunk.filePath);
333
+ const summary = diff ? formatSemanticDiffSummary(diff) : '';
334
+ if (summary) semanticByFile.set(hunk.filePath, summary);
335
+ }
336
+ // A turn must never start while a modal owns the keyboard: close first.
337
+ host.close(this, 'done');
338
+ this.deps.submitInput(buildSteerMessage(pending, this.label || CHANGES_SOURCE_LABELS[this.source], semanticByFile));
339
+ }
340
+
341
+ private async commit(message: string): Promise<void> {
342
+ const ok = this.deps.confirm
343
+ ? await this.deps.confirm({ title: 'Commit staged changes?', body: message, confirmLabel: 'Commit', tone: 'primary' })
344
+ : true;
345
+ if (!ok) {
346
+ this.note('Commit cancelled.', 'faint');
347
+ return;
348
+ }
349
+ const result = await commitStaged(this.deps.workingDirectory, message);
350
+ if (result.error) {
351
+ this.note(`Commit failed: ${result.error}`, 'error');
352
+ return;
353
+ }
354
+ await this.reload();
355
+ this.note(`Committed ${result.hash.slice(0, 7)}: ${message}`, 'success');
356
+ }
357
+
358
+ private async initialize(): Promise<void> {
359
+ const ok = this.deps.confirm
360
+ ? await this.deps.confirm({ title: 'Initialize a git repository?', body: this.deps.workingDirectory, confirmLabel: 'Initialize', tone: 'warning' })
361
+ : false;
362
+ if (!ok) return;
363
+ const error = initRepo(this.deps.workingDirectory);
364
+ if (error) this.note(`git init failed: ${error}`, 'error');
365
+ await this.reload();
366
+ }
367
+
368
+ // ── Keys ───────────────────────────────────────────────────────────────────
369
+
370
+ escape(): boolean {
371
+ if (this.composing !== null) { this.composing = null; return true; }
372
+ if (this.commitDraft !== null) { this.commitDraft = null; return true; }
373
+ return false;
374
+ }
375
+
376
+ onClose(_reason: SurfaceModalCloseReason): void {
377
+ if (this.question) {
378
+ const q = this.question;
379
+ this.question = null;
380
+ q.answer(false);
381
+ }
382
+ }
383
+
384
+ private answer(host: SurfaceModalHost, confirmed: boolean): void {
385
+ const q = this.question;
386
+ if (!q) return;
387
+ this.question = null;
388
+ host.close(this, 'done');
389
+ q.answer(confirmed);
390
+ }
391
+
392
+ private handleTextEntry(token: InputToken, draft: string, commit: (text: string) => void, cancel: () => void): string {
393
+ if (token.type === 'text') return draft + [...token.value].map((ch) => (ch === '\n' || ch === '\r' ? ' ' : ch)).filter((ch) => ch >= ' ').join('');
394
+ if (token.type !== 'key') return draft;
395
+ const key = token.logicalName ?? '';
396
+ if (key === 'enter') { commit(draft); return draft; }
397
+ if (isTextBackspace(key)) return draft.slice(0, -1);
398
+ if (key === 'escape') cancel();
399
+ return draft;
400
+ }
401
+
402
+ handleToken(token: InputToken, host: SurfaceModalHost): void {
403
+ if (this.question) {
404
+ if (token.type === 'text') {
405
+ if (token.value.toLowerCase() === 'y') this.answer(host, true);
406
+ else if (token.value.toLowerCase() === 'n') this.answer(host, false);
407
+ else this.handleNav(token);
408
+ return;
409
+ }
410
+ const key = token.type === 'key' ? token.logicalName ?? '' : '';
411
+ if (key === 'left' || key === 'right' || key === 'tab') { this.question.chosen = this.question.chosen === 0 ? 1 : 0; return; }
412
+ if (key === 'enter') { this.answer(host, this.question.chosen === 1); return; }
413
+ this.handleNav(token);
414
+ return;
415
+ }
416
+ if (this.composing !== null) {
417
+ this.composing = this.handleTextEntry(token, this.composing, (text) => {
418
+ const current = this.currentHunk();
419
+ const trimmed = text.trim();
420
+ if (current && trimmed) {
421
+ this.comments.set(commentKey(current.hunk.review), { hunk: current.hunk.review, text: trimmed, sent: false });
422
+ this.status = { text: 'Comment attached. ⏎ sends every attached comment to the model.', tone: 'muted' };
423
+ }
424
+ this.composing = null;
425
+ }, () => { this.composing = null; });
426
+ return;
427
+ }
428
+ if (this.commitDraft !== null) {
429
+ this.commitDraft = this.handleTextEntry(token, this.commitDraft, (text) => {
430
+ const message = text.trim();
431
+ this.commitDraft = null;
432
+ if (message) void this.commit(message);
433
+ }, () => { this.commitDraft = null; });
434
+ return;
435
+ }
436
+ if (token.type === 'text') {
437
+ this.handleLetter(token.value);
438
+ return;
439
+ }
440
+ if (token.type === 'key' && token.logicalName === 'enter') {
441
+ if (this.mode === 'workspace') this.sendComments(host);
442
+ return;
443
+ }
444
+ if (token.type === 'key' && token.logicalName === 'space') {
445
+ this.handleLetter(' ');
446
+ return;
447
+ }
448
+ this.handleNav(token);
449
+ }
450
+
451
+ private handleNav(token: InputToken): void {
452
+ if (token.type === 'text') {
453
+ if (token.value === ']') this.stepHunk(1);
454
+ else if (token.value === '[') this.stepHunk(-1);
455
+ else if (token.value === 'j') this.select(this.selected + 1);
456
+ else if (token.value === 'k') this.select(this.selected - 1);
457
+ return;
458
+ }
459
+ if (token.type !== 'key') return;
460
+ const key = token.logicalName ?? '';
461
+ const visible = 12;
462
+ if (key === 'up') this.select(this.selected - 1);
463
+ else if (key === 'down') this.select(this.selected + 1);
464
+ else if (key === 'pagedown') this.scrollDelta += visible;
465
+ else if (key === 'pageup') this.scrollDelta -= visible;
466
+ else if (key === 'home') this.select(0);
467
+ else if (key === 'end') this.select(this.items().length - 1);
468
+ this.clampScroll();
469
+ }
470
+
471
+ private clampScroll(): void {
472
+ const lines = this.diffFiles().reduce((n, f) => n + f.hunks.reduce((m, h) => m + h.lines.length + 1, 0), 0);
473
+ this.scrollDelta = Math.max(-lines, Math.min(lines, this.scrollDelta));
474
+ }
475
+
476
+ private handleLetter(ch: string): void {
477
+ if (ch === ']' || ch === '[' || ch === 'j' || ch === 'k') { this.handleNav({ type: 'text', value: ch }); return; }
478
+ if (this.mode !== 'workspace') return;
479
+ if (this.notRepo) {
480
+ if (ch === 'i') void this.initialize();
481
+ return;
482
+ }
483
+ switch (ch) {
484
+ case ' ': void this.stageHunk(); break;
485
+ case 'S': void this.stageFile(); break;
486
+ case 'c':
487
+ if (this.currentHunk() && !this.currentCommit()) this.composing = this.comments.get(commentKey(this.currentHunk()!.hunk.review))?.text ?? '';
488
+ break;
489
+ case 'm': this.toggleReviewed(); break;
490
+ case 'o': this.openEditor(); break;
491
+ case 'x': void this.revert(); break;
492
+ case 'v': {
493
+ const next = CHANGES_SOURCES[(CHANGES_SOURCES.indexOf(this.source) + 1) % CHANGES_SOURCES.length]!;
494
+ this.selected = 0;
495
+ this.hunkIndex = 0;
496
+ void this.reload(next);
497
+ break;
498
+ }
499
+ case 'r': void this.reload(); break;
500
+ case 'C':
501
+ if ((this.summary?.staged ?? 0) === 0) this.status = { text: 'Nothing is staged to commit. Stage hunks with space or files with S.', tone: 'faint' };
502
+ else this.commitDraft = '';
503
+ break;
504
+ default: break;
505
+ }
506
+ }
507
+
508
+ // ── View ───────────────────────────────────────────────────────────────────
509
+
510
+ private listRows(): KitRow[] {
511
+ const t = activeTokens();
512
+ const rows: KitRow[] = [];
513
+ const item = this.currentItem();
514
+ if (this.files.length > 0) {
515
+ rows.push({ header: 'Files', headerRight: String(this.files.length) });
516
+ this.files.forEach((file, index) => rows.push({
517
+ label: file.path,
518
+ right: file.removed > 0 ? `+${file.added} −${file.removed}` : `+${file.added}`,
519
+ mark: this.reviewed.has(file.path) ? '✓' : undefined,
520
+ markFg: t.success,
521
+ selected: item?.kind === 'file' && item.index === index,
522
+ }));
523
+ }
524
+ if (this.mode === 'workspace' && this.commits.length > 0) {
525
+ rows.push({ header: 'Recent commits' });
526
+ this.commits.forEach((commit, index) => rows.push({
527
+ spans: [{ text: commit.hash.slice(0, 7), fg: t.textFaint }, { text: ` ${commit.message}` }],
528
+ selected: item?.kind === 'commit' && item.index === index,
529
+ }));
530
+ }
531
+ return rows;
532
+ }
533
+
534
+ private sub(): string {
535
+ if (this.mode === 'preview') {
536
+ const totals = changeTotals(this.files);
537
+ return this.files.length > 0 ? `${this.files.length} file${this.files.length === 1 ? '' : 's'} · +${totals.added} −${totals.removed}` : '';
538
+ }
539
+ const totals = changeTotals(this.files);
540
+ const parts: string[] = [];
541
+ if (this.summary) parts.push(this.summary.branch);
542
+ parts.push(`${this.files.length} file${this.files.length === 1 ? '' : 's'}`, `+${totals.added} −${totals.removed}`, CHANGES_SOURCE_LABELS[this.source]);
543
+ return parts.join(' · ');
544
+ }
545
+
546
+ private message(): string | null {
547
+ if (this.mode === 'preview') {
548
+ if (this.previewNote && this.files.length === 0) return this.previewNote;
549
+ return this.files.length === 0 ? 'Nothing to show for this preview.' : null;
550
+ }
551
+ if (this.notRepo) return 'Not a git repository here, so there are no changes to show.\nPress i to initialize one (it asks first).';
552
+ if (this.loading && this.files.length === 0 && this.commits.length === 0) return 'Loading changes…';
553
+ if (this.error) return `git could not produce the diff: ${this.error}`;
554
+ const commit = this.currentCommit();
555
+ if (commit) {
556
+ const loaded = this.commitFiles.get(commit.hash);
557
+ if (typeof loaded === 'string') return `git could not show ${commit.hash.slice(0, 7)}: ${loaded}`;
558
+ if (!loaded || loaded.length === 0) return `Loading ${commit.hash.slice(0, 7)} ${commit.message}…`;
559
+ return null;
560
+ }
561
+ if (this.files.length === 0) return `No changes (${this.label || CHANGES_SOURCE_LABELS[this.source]}). Press v to switch between this session, not staged, staged and everything vs HEAD.`;
562
+ return null;
563
+ }
564
+
565
+ private foot(): ChangesFootLine[] {
566
+ const lines: ChangesFootLine[] = [];
567
+ if (this.previewNote && this.files.length > 0) lines.push({ text: this.previewNote, tone: 'muted' });
568
+ const current = this.currentHunk();
569
+ if (this.composing !== null) lines.push({ text: `comment: ${this.composing}`, tone: 'text', cursor: true });
570
+ else if (this.commitDraft !== null) lines.push({ text: `commit message: ${this.commitDraft}`, tone: 'text', cursor: true });
571
+ else if (current) {
572
+ const comment = this.comments.get(commentKey(current.hunk.review));
573
+ if (comment) lines.push({ text: `${comment.sent ? 'sent' : 'comment'}: ${comment.text}`, tone: comment.sent ? 'success' : 'text' });
574
+ }
575
+ if (this.status) lines.push({ text: this.status.text, tone: this.status.tone });
576
+ return lines;
577
+ }
578
+
579
+ private hints(): KitHint[] {
580
+ if (this.question) return [['⏎', 'choose'], ['y', this.question.confirmLabel.toLowerCase()], ['n', 'cancel'], [']', 'next hunk'], ['↑↓', 'files']];
581
+ if (this.composing !== null) return [['⏎', 'attach comment'], ['esc', 'cancel']];
582
+ if (this.commitDraft !== null) return [['⏎', 'commit'], ['esc', 'cancel']];
583
+ if (this.mode === 'preview') return [['] [', 'next / prev hunk'], ['↑↓', 'files'], ['pgdn', 'scroll']];
584
+ if (this.notRepo) return [['i', 'initialize a repository']];
585
+ const pending = [...this.comments.values()].filter((c) => !c.sent).length;
586
+ const hints: KitHint[] = [
587
+ ['] [', 'next / prev hunk'],
588
+ ['space', this.source === 'staged' ? 'unstage hunk' : 'stage hunk'],
589
+ ['c', 'comment'],
590
+ ['m', 'mark reviewed'],
591
+ ['o', 'open in editor'],
592
+ ['x', 'revert hunk'],
593
+ ['S', this.source === 'staged' ? 'unstage file' : 'stage file'],
594
+ ['C', 'commit'],
595
+ ['v', 'view'],
596
+ ];
597
+ if (pending > 0) hints.push(['⏎', `send ${pending} comment${pending === 1 ? '' : 's'}`]);
598
+ return hints;
599
+ }
600
+
601
+ private view(): ChangesModalView {
602
+ const q = this.question;
603
+ const question: ChangesQuestion | null = q
604
+ ? { text: q.text, confirmLabel: q.confirmLabel, cancelLabel: q.cancelLabel ?? 'Cancel', tone: q.tone ?? 'primary', chosen: q.chosen }
605
+ : null;
606
+ const file = this.currentFile();
607
+ const commit = this.currentCommit();
608
+ return {
609
+ title: 'Changes',
610
+ crumbs: this.mode === 'preview' && this.previewTitle ? [this.previewTitle] : commit ? [commit.hash.slice(0, 7)] : [],
611
+ sub: this.sub(),
612
+ listRows: this.mode === 'preview' && this.files.length <= 1 ? [] : this.listRows(),
613
+ diffFiles: this.diffFiles(),
614
+ fileHeaders: this.diffFiles().length > 1,
615
+ hunkIndex: this.hunkIndex,
616
+ scrollDelta: this.scrollDelta,
617
+ semantic: this.mode === 'workspace' && file && !commit && this.semantic.has(file.path) ? this.semantic.get(file.path) : null,
618
+ message: this.message(),
619
+ foot: this.foot(),
620
+ hints: this.hints(),
621
+ question,
622
+ scrollOwner: this,
623
+ };
624
+ }
625
+
626
+ render(screenWidth: number, screenHeight: number): SurfaceLayer {
627
+ return renderChangesModal(this.view(), screenWidth, screenHeight);
628
+ }
629
+ }