@pellux/goodvibes-tui 2.0.20 → 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.
- package/CHANGELOG.md +70 -0
- package/README.md +16 -16
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +59 -4
- package/package.json +16 -6
- package/src/cli/management.ts +5 -2
- package/src/cli/tui-startup.ts +34 -39
- package/src/core/agent-view-render.ts +157 -0
- package/src/core/approval-alert.ts +49 -11
- package/src/core/bookmark-navigation.ts +7 -1
- package/src/core/budget-breach-notifier.ts +27 -8
- package/src/core/compaction-receipt.ts +1 -1
- package/src/core/context-usage.ts +1 -1
- package/src/core/conversation-compaction-render.ts +3 -3
- package/src/core/conversation-display-only.ts +86 -0
- package/src/core/conversation-event-navigation.ts +27 -0
- package/src/core/conversation-line-cache.ts +216 -447
- package/src/core/conversation-render-context.ts +20 -27
- package/src/core/conversation-rendering.ts +157 -575
- package/src/core/conversation-splash-state.ts +6 -1
- package/src/core/conversation-types.ts +35 -0
- package/src/core/conversation-usage.ts +12 -0
- package/src/core/conversation.ts +130 -131
- package/src/core/long-task-notifier.ts +100 -31
- package/src/core/notices.ts +75 -0
- package/src/core/pairing-tailscale-gateway.ts +1 -1
- package/src/core/process-output-log.ts +143 -0
- package/src/core/rewind-receipt.ts +1 -1
- package/src/core/session-focus.ts +190 -0
- package/src/core/session-resume-core.ts +23 -81
- package/src/core/stream-event-wiring.ts +31 -5
- package/src/core/system-message-noise.ts +2 -2
- package/src/core/system-message-router.ts +16 -18
- package/src/core/turn-event-wiring.ts +272 -33
- package/src/core/work-tree-focus.ts +290 -0
- package/src/core/work-tree-fold-store.ts +143 -0
- package/src/core/work-tree-model.ts +799 -0
- package/src/core/work-tree-render.ts +384 -0
- package/src/core/work-tree-sources.ts +184 -0
- package/src/core/work-tree-wiring.ts +176 -0
- package/src/core/workstream-notification.ts +36 -6
- package/src/core/wrfc-notice-titles.ts +37 -0
- package/src/input/agents-modal-text.ts +119 -0
- package/src/input/agents-modal.ts +699 -0
- package/src/input/bookmark-modal.ts +33 -9
- package/src/input/changes-git.ts +251 -0
- package/src/input/changes-modal.ts +629 -0
- package/src/input/changes-model.ts +125 -0
- package/src/input/command-palette-types.ts +41 -0
- package/src/input/command-palette.ts +272 -0
- package/src/input/command-registry.ts +77 -35
- package/src/input/commands/acp-runtime.ts +19 -9
- package/src/input/commands/channel-runtime.ts +2 -2
- package/src/input/commands/ci-runtime.ts +1 -1
- package/src/input/commands/codebase-runtime.ts +1 -1
- package/src/input/commands/config.ts +7 -1
- package/src/input/commands/context-window.ts +25 -19
- package/src/input/commands/control-room-runtime.ts +18 -18
- package/src/input/commands/conversation-runtime.ts +0 -1
- package/src/input/commands/cost-runtime.ts +29 -20
- package/src/input/commands/diff-runtime.ts +35 -181
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/experience-runtime.ts +3 -7
- package/src/input/commands/fresh-conversation.ts +86 -0
- package/src/input/commands/git-runtime.ts +5 -22
- package/src/input/commands/graph-runtime.ts +1 -1
- package/src/input/commands/guidance-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +8 -9
- package/src/input/commands/hooks-runtime.ts +2 -2
- package/src/input/commands/hosted-runtime.ts +3 -3
- package/src/input/commands/incident-runtime.ts +3 -3
- package/src/input/commands/intelligence-runtime.ts +6 -9
- package/src/input/commands/knowledge.ts +2 -2
- package/src/input/commands/legacy-panel-command.ts +57 -0
- package/src/input/commands/local-auth-runtime.ts +8 -9
- package/src/input/commands/local-provider-runtime.ts +7 -4
- package/src/input/commands/local-runtime.ts +12 -15
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/local-setup.ts +13 -19
- package/src/input/commands/memory.ts +1 -1
- package/src/input/commands/notify-runtime.ts +9 -0
- package/src/input/commands/operator-runtime.ts +10 -12
- package/src/input/commands/permissions-provenance.ts +3 -3
- package/src/input/commands/planning-runtime.ts +7 -7
- package/src/input/commands/platform-sandbox-runtime.ts +2 -2
- package/src/input/commands/platform-services-runtime.ts +1 -1
- package/src/input/commands/policy-dispatch.ts +2 -2
- package/src/input/commands/policy.ts +3 -3
- package/src/input/commands/provider.ts +1 -1
- package/src/input/commands/remote-runtime.ts +1 -1
- package/src/input/commands/review-runtime.ts +39 -94
- package/src/input/commands/rewind-runtime.ts +81 -108
- package/src/input/commands/runtime-services.ts +15 -17
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-content.ts +21 -5
- package/src/input/commands/session-workflow.ts +13 -38
- package/src/input/commands/settings-sync-runtime.ts +2 -2
- package/src/input/commands/shell-core.ts +19 -4
- package/src/input/commands/skills-runtime.ts +2 -2
- package/src/input/commands/status-runtime.ts +13 -0
- package/src/input/commands/subscription-runtime.ts +1 -1
- package/src/input/commands/tasks-runtime.ts +2 -8
- package/src/input/commands/work-plan-runtime.ts +4 -4
- package/src/input/commands/workstream-attempts.ts +24 -47
- package/src/input/commands/workstream-runtime.ts +2 -2
- package/src/input/commands/worktree-runtime.ts +3 -3
- package/src/input/commands.ts +3 -1
- package/src/input/composer-editor.ts +44 -0
- package/src/input/concealed-input.ts +1 -1
- package/src/input/config-modal-types.ts +20 -8
- package/src/input/config-modal.ts +106 -105
- package/src/input/confirm-dialog-types.ts +28 -0
- package/src/input/confirm-dialog.ts +70 -0
- package/src/input/delete-key-policy.ts +3 -3
- package/src/input/feed-context-factory.ts +15 -16
- package/src/input/handler-command-route.ts +7 -42
- package/src/input/handler-content-actions.ts +16 -15
- package/src/input/handler-feed-routes.ts +26 -211
- package/src/input/handler-feed.ts +68 -95
- package/src/input/handler-interactions.ts +5 -3
- package/src/input/handler-modal-routes.ts +215 -216
- package/src/input/handler-modal-stack.ts +81 -11
- package/src/input/handler-modal-token-routes.ts +4 -0
- package/src/input/handler-onboarding.ts +0 -1
- package/src/input/handler-picker-routes.ts +65 -107
- package/src/input/handler-session-view-route.ts +149 -0
- package/src/input/handler-shortcuts.ts +16 -154
- package/src/input/handler-types.ts +4 -2
- package/src/input/handler-ui-state.ts +45 -15
- package/src/input/handler-work-tree-route.ts +123 -0
- package/src/input/handler.ts +25 -70
- package/src/input/keybindings.ts +16 -71
- package/src/input/masked-entry-modal.ts +117 -0
- package/src/input/mcp-workspace.ts +48 -17
- package/src/input/model-picker-filter.ts +8 -0
- package/src/input/model-picker-open.ts +99 -0
- package/src/input/model-picker-types.ts +1 -1
- package/src/input/model-picker.ts +92 -9
- package/src/input/notifications-modal.ts +123 -0
- package/src/input/overlay-filter.ts +26 -0
- package/src/input/profile-picker-modal.ts +37 -8
- package/src/input/search.ts +3 -3
- package/src/input/selection-modal-queue.ts +2 -0
- package/src/input/selection-modal.ts +28 -16
- package/src/input/session-picker-modal.ts +33 -8
- package/src/input/settings-modal-activation.ts +7 -2
- package/src/input/settings-modal-data.ts +11 -6
- package/src/input/settings-modal-types.ts +1 -1
- package/src/input/settings-modal.ts +20 -20
- package/src/input/submission-intent.ts +1 -1
- package/src/input/submission-router.ts +4 -9
- package/src/input/surface-modal-host.ts +110 -0
- package/src/input/theme-settings-actions.ts +82 -0
- package/src/input/usage-modal.ts +183 -0
- package/src/input/views.ts +97 -0
- package/src/main.ts +175 -165
- package/src/permissions/hunk-selection.ts +1 -1
- package/src/permissions/prompt-card.ts +511 -0
- package/src/permissions/prompt.ts +59 -622
- package/src/renderer/agents-modal.ts +310 -0
- package/src/renderer/autocomplete-overlay.ts +42 -220
- package/src/renderer/back-to-bottom.ts +80 -0
- package/src/renderer/block-actions-overlay.ts +30 -48
- package/src/renderer/bookmark-modal.ts +48 -79
- package/src/renderer/changes-modal.ts +325 -0
- package/src/renderer/code-block.ts +184 -106
- package/src/renderer/command-palette.ts +93 -0
- package/src/renderer/composer-fragments.ts +4 -4
- package/src/renderer/composer.ts +146 -0
- package/src/renderer/compositor.ts +26 -160
- package/src/renderer/config-modal.ts +185 -81
- package/src/renderer/confirm-dialog.ts +50 -0
- package/src/renderer/context-inspector.ts +116 -154
- package/src/renderer/context-status-hint.ts +4 -4
- package/src/renderer/conversation-layout.ts +20 -0
- package/src/renderer/conversation-overlays.ts +94 -84
- package/src/renderer/conversation-surface.ts +19 -38
- package/src/renderer/diff-tint.ts +98 -0
- package/src/renderer/file-picker-overlay.ts +63 -90
- package/src/renderer/footer-targets.ts +38 -0
- package/src/renderer/git-status.ts +3 -4
- package/src/renderer/header-line.ts +140 -117
- package/src/renderer/help-overlay.ts +133 -219
- package/src/renderer/history-search-overlay.ts +15 -12
- package/src/renderer/lane-graph/bead.ts +800 -0
- package/src/renderer/lane-graph/glyphs.ts +134 -0
- package/src/renderer/lane-graph/layout.ts +248 -0
- package/src/renderer/lane-graph/paint.ts +537 -0
- package/src/renderer/lane-graph/semantic-memo.ts +77 -0
- package/src/renderer/layout-engine.ts +2 -57
- package/src/renderer/layout.ts +7 -13
- package/src/renderer/markdown-inline.ts +2 -1
- package/src/renderer/markdown-table.ts +17 -12
- package/src/renderer/markdown.ts +92 -34
- package/src/renderer/masked-entry-modal.ts +78 -0
- package/src/renderer/mcp-workspace.ts +166 -203
- package/src/renderer/model-workspace.ts +381 -436
- package/src/renderer/notifications-modal.ts +116 -0
- package/src/renderer/onboarding/onboarding-wizard.ts +178 -588
- package/src/renderer/process-view.ts +192 -0
- package/src/renderer/profile-picker-modal.ts +58 -110
- package/src/renderer/search-overlay.ts +6 -3
- package/src/renderer/selection-modal-overlay.ts +87 -327
- package/src/renderer/semantic-diff.ts +2 -2
- package/src/renderer/session-chips.ts +102 -0
- package/src/renderer/session-picker-modal.ts +177 -297
- package/src/renderer/settings-modal-connections.ts +14 -23
- package/src/renderer/settings-modal-context.ts +367 -0
- package/src/renderer/settings-modal-helpers.ts +6 -11
- package/src/renderer/settings-modal.ts +322 -726
- package/src/renderer/shell-surface.ts +277 -175
- package/src/renderer/shortcuts-overlay.ts +217 -0
- package/src/renderer/status-line.ts +475 -0
- package/src/renderer/surface-compose.ts +174 -0
- package/src/renderer/surface-kit-extra.ts +303 -0
- package/src/renderer/surface-kit-list.ts +366 -0
- package/src/renderer/surface-kit-parts.ts +360 -0
- package/src/renderer/surface-kit.ts +568 -0
- package/src/renderer/syntax-highlighter.ts +280 -197
- package/src/renderer/syntax-theme.ts +51 -0
- package/src/renderer/system-message.ts +0 -1
- package/src/renderer/term-caps.ts +42 -1
- package/src/renderer/terminal-bg-probe.ts +116 -19
- package/src/renderer/terminal-palette-probe.ts +322 -0
- package/src/renderer/terminal-palette-reader.ts +183 -0
- package/src/renderer/terminal-palette.ts +58 -0
- package/src/renderer/theme-mode-config.ts +16 -1
- package/src/renderer/theme.ts +234 -207
- package/src/renderer/thinking.ts +0 -1
- package/src/renderer/throbber.ts +188 -0
- package/src/renderer/toast-center.ts +119 -0
- package/src/renderer/ui-factory.ts +39 -602
- package/src/renderer/ui-primitives.ts +8 -25
- package/src/renderer/usage-modal.ts +353 -0
- package/src/renderer/user-message.ts +58 -0
- package/src/renderer/voice-capture-chip.ts +53 -0
- package/src/runtime/bootstrap-command-context.ts +0 -5
- package/src/runtime/bootstrap-command-parts.ts +38 -89
- package/src/runtime/bootstrap-core.ts +13 -15
- package/src/runtime/bootstrap-hook-bridge.ts +12 -21
- package/src/runtime/bootstrap-notifier-sync.ts +17 -1
- package/src/runtime/bootstrap-shell.ts +16 -50
- package/src/runtime/bootstrap.ts +14 -14
- package/src/runtime/client/fleet-union.ts +5 -5
- package/src/runtime/client/hosted-exit.ts +2 -2
- package/src/runtime/client/hosted-sessions.ts +1 -1
- package/src/runtime/client/spine-adoption.ts +1 -1
- package/src/runtime/context-accounting-source.ts +5 -4
- package/src/runtime/index.ts +1 -0
- package/src/runtime/notification-dispatch.ts +102 -29
- package/src/runtime/recovery-offer-wiring.ts +4 -1
- package/src/runtime/recovery-prompt.ts +31 -15
- package/src/runtime/runtime-services-types.ts +10 -6
- package/src/runtime/services.ts +17 -12
- package/src/runtime/session-continuity-hints.ts +2 -5
- package/src/runtime/session-inbound-inputs.ts +1 -1
- package/src/runtime/session-resume-liveness-confirm.ts +14 -2
- package/src/runtime/startup-typeahead-gate.ts +103 -0
- package/src/runtime/store/domains/domain-read-matrix.ts +0 -1
- package/src/runtime/store/domains/index.ts +0 -8
- package/src/runtime/store/selectors/index.ts +0 -28
- package/src/runtime/store/state.ts +0 -8
- package/src/runtime/ui-services.ts +4 -6
- package/src/runtime/usage-tracker.ts +348 -0
- package/src/runtime/wrfc-persistence.ts +4 -4
- package/src/shell/blocking-input.ts +92 -2
- package/src/shell/recovery-input-helpers.ts +1 -1
- package/src/shell/session-views.ts +539 -0
- package/src/shell/status-report.ts +86 -0
- package/src/shell/transcript-scroll.ts +68 -0
- package/src/shell/ui-openers.ts +84 -172
- package/src/shell/view-openers.ts +205 -0
- package/src/tools/wrfc-agent-guard.ts +22 -2
- package/src/utils/format-duration.ts +4 -8
- package/src/utils/splash-lines.ts +9 -1
- package/src/utils/terminal-width.ts +56 -0
- package/src/verification/verification-ledger.ts +18 -16
- package/src/{panels → views}/agent-inspector-shared.ts +2 -2
- package/src/{panels → views}/builtin-modals.ts +22 -21
- package/src/views/builtin-views.ts +135 -0
- package/src/{panels → views}/confirm-state.ts +9 -9
- package/src/{panels → views}/diff-review-model.ts +10 -3
- package/src/{panels → views}/eval-registry.ts +1 -1
- package/src/{panels → views}/fleet-acts.ts +49 -85
- package/src/{panels/fleet-panel-format.ts → views/fleet-format.ts} +40 -40
- package/src/{panels → views}/fleet-gateway.ts +5 -5
- package/src/{panels → views}/fleet-observed-render.ts +16 -16
- package/src/{panels → views}/fleet-read-model.ts +9 -9
- package/src/{panels/fleet-tabs.ts → views/fleet-session-tabs.ts} +13 -13
- package/src/{panels → views}/fleet-spawn.ts +33 -49
- package/src/{panels → views}/fleet-steer.ts +12 -12
- package/src/{panels → views}/fleet-stop.ts +4 -4
- package/src/{panels → views}/fleet-transcript.ts +26 -27
- package/src/{panels/fleet-panel-worktree-detail.ts → views/fleet-worktree-detail.ts} +7 -7
- package/src/{panels → views}/hosted-session-feed.ts +6 -6
- package/src/views/modal-surface-registry.ts +41 -0
- package/src/{panels → views}/modals/devices-modal.ts +2 -2
- package/src/{panels → views}/modals/hooks-modal.ts +13 -13
- package/src/{panels → views}/modals/keybindings-modal.ts +19 -27
- package/src/{panels → views}/modals/knowledge-modal.ts +6 -5
- package/src/{panels → views}/modals/local-auth-modal.ts +22 -21
- package/src/{panels → views}/modals/marketplace-modal.ts +11 -11
- package/src/{panels → views}/modals/memory-modal.ts +8 -8
- package/src/{panels → views}/modals/modal-surface-helpers.ts +15 -5
- package/src/{panels → views}/modals/pairing-modal.ts +9 -9
- package/src/{panels → views}/modals/planning-modal.ts +15 -20
- package/src/{panels → views}/modals/plugins-modal.ts +6 -6
- package/src/{panels → views}/modals/policy-modal.ts +13 -5
- package/src/{panels → views}/modals/provider-health-modal.ts +4 -4
- package/src/{panels → views}/modals/remote-modal.ts +3 -3
- package/src/{panels → views}/modals/sandbox-modal.ts +4 -4
- package/src/{panels → views}/modals/security-modal.ts +22 -16
- package/src/{panels → views}/modals/services-modal.ts +3 -3
- package/src/{panels → views}/modals/settings-sync-modal.ts +4 -4
- package/src/{panels → views}/modals/skills-modal.ts +9 -9
- package/src/{panels → views}/modals/subscription-modal.ts +3 -3
- package/src/{panels → views}/modals/work-plan-modal.ts +10 -13
- package/src/views/notifications-feed.ts +314 -0
- package/src/{panels → views}/polish-core.ts +38 -36
- package/src/{panels → views}/polish-tables.ts +35 -26
- package/src/{panels → views}/polish.ts +101 -95
- package/src/{panels → views}/project-planning-answer-actions.ts +3 -3
- package/src/{panels → views}/provider-health-routes.ts +14 -14
- package/src/{panels → views}/search-focus.ts +8 -8
- package/src/{panels → views}/session-maintenance.ts +11 -11
- package/src/views/skills-discovery.ts +159 -0
- package/src/views/view-deps.ts +80 -0
- package/src/views/view-panel-adapter.ts +53 -0
- package/src/{panels → views}/workstream-graph-render.ts +1 -1
- package/src/core/conversation-fold.ts +0 -101
- package/src/core/conversation-turn-structure.ts +0 -464
- package/src/core/conversation-user-receipts.ts +0 -42
- package/src/input/commands/operator-panel-runtime.ts +0 -186
- package/src/input/panel-integration-actions.ts +0 -33
- package/src/input/panel-mouse-geometry.ts +0 -97
- package/src/input/panel-paste-flood-guard.ts +0 -86
- package/src/panels/base-panel.ts +0 -301
- package/src/panels/builtin/agent.ts +0 -27
- package/src/panels/builtin/development.ts +0 -92
- package/src/panels/builtin/knowledge.ts +0 -12
- package/src/panels/builtin/operations.ts +0 -177
- package/src/panels/builtin/session.ts +0 -81
- package/src/panels/builtin/shared.ts +0 -357
- package/src/panels/builtin-panels.ts +0 -28
- package/src/panels/cost-tracker-panel.ts +0 -746
- package/src/panels/diff-panel.ts +0 -797
- package/src/panels/diff-review-panel.ts +0 -395
- package/src/panels/expandable-list-panel.ts +0 -189
- package/src/panels/fleet-deep-link.ts +0 -31
- package/src/panels/fleet-panel.ts +0 -799
- package/src/panels/git-panel.ts +0 -800
- package/src/panels/hosted-session-panel.ts +0 -221
- package/src/panels/index.ts +0 -21
- package/src/panels/local-auth-panel.ts +0 -478
- package/src/panels/notifications-feed.ts +0 -127
- package/src/panels/notifications-panel.ts +0 -160
- package/src/panels/panel-confirm-overlay.ts +0 -72
- package/src/panels/panel-manager.ts +0 -791
- package/src/panels/plugins-panel.ts +0 -371
- package/src/panels/scrollable-list-panel.ts +0 -498
- package/src/panels/skills-panel.ts +0 -454
- package/src/panels/token-budget-panel.ts +0 -688
- package/src/panels/types.ts +0 -191
- package/src/panels/wrfc-panel-format.ts +0 -133
- package/src/renderer/diff-view.ts +0 -134
- package/src/renderer/fleet-tab-strip.ts +0 -56
- package/src/renderer/footer-tips.ts +0 -53
- package/src/renderer/fullscreen-primitives.ts +0 -146
- package/src/renderer/fullscreen-workspace.ts +0 -194
- package/src/renderer/hint-grammar.ts +0 -52
- package/src/renderer/modal-factory.ts +0 -480
- package/src/renderer/overlay-box.ts +0 -154
- package/src/renderer/overlay-viewport.ts +0 -104
- package/src/renderer/panel-composite.ts +0 -145
- package/src/renderer/panel-workspace-bar.ts +0 -54
- package/src/renderer/process-indicator.ts +0 -163
- package/src/renderer/tab-strip.ts +0 -222
- package/src/renderer/text-layout.ts +0 -31
- package/src/renderer/tool-call.ts +0 -276
- package/src/renderer/tool-result-expanded-lines.ts +0 -48
- package/src/renderer/tool-result-summary.ts +0 -219
- package/src/runtime/diagnostics/panels/index.ts +0 -21
- package/src/runtime/diagnostics/panels/ops.ts +0 -156
- package/src/runtime/perf/panel-contracts.ts +0 -32
- package/src/runtime/store/domains/panels.ts +0 -117
- /package/src/{panels → views}/memory-consolidation-gateway.ts +0 -0
- /package/src/{panels → views}/modals/modal-theme.ts +0 -0
- /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
|
+
}
|