@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.
- package/CHANGELOG.md +58 -0
- package/README.md +16 -16
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- 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,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface-kit.ts, the shared modal surface kit.
|
|
3
|
+
*
|
|
4
|
+
* Every modal and overlay is drawn with these helpers, which is what keeps them
|
|
5
|
+
* consistent. The numbers are the Measurements table of the design:
|
|
6
|
+
*
|
|
7
|
+
* - Modal: 86% of the terminal width (at most 124 columns), top edge at 8% of
|
|
8
|
+
* the height. Below 90 columns it takes the full width minus 1 per side.
|
|
9
|
+
* - A gradient half-block cap row (▄, brand → brandEnd) sits above the fill
|
|
10
|
+
* and a surface-colored ▀ cap row below it. No box-drawing frame.
|
|
11
|
+
* - 4 columns of inner padding on both sides; row markers 2 columns in from
|
|
12
|
+
* the edge; the selected row's gradient is inset 2 columns from each side.
|
|
13
|
+
* - Title on the first content row (✦ + bold title + `esc` keycap right),
|
|
14
|
+
* hints on the second-to-last row, the last row empty.
|
|
15
|
+
*
|
|
16
|
+
* Coordinates: a modal is drawn into a SurfaceCanvas that covers the modal's
|
|
17
|
+
* columns and its rows from the top cap to the bottom cap. The canvas is then
|
|
18
|
+
* handed to the compositor as a SurfaceLayer (screen x/y of its top-left).
|
|
19
|
+
* Cells whose bg is '' keep whatever is underneath (the caps' other half).
|
|
20
|
+
*
|
|
21
|
+
* Colors come only from the active theme's tokens (activeTokens()).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createEmptyCell, type Cell, type Line } from '@pellux/goodvibes-sdk/platform/types';
|
|
25
|
+
import { mixHex } from '@pellux/goodvibes-sdk/platform/presentation';
|
|
26
|
+
import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
|
|
27
|
+
import { activeTokens } from './theme.ts';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Layers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** A rectangle of cells stamped over the composed screen. */
|
|
34
|
+
export interface SurfaceLayer {
|
|
35
|
+
/** Screen column of the layer's first cell. */
|
|
36
|
+
readonly x: number;
|
|
37
|
+
/** Screen row of the layer's first line. */
|
|
38
|
+
readonly y: number;
|
|
39
|
+
/** The cells. A cell with bg '' keeps the bg underneath it. */
|
|
40
|
+
readonly lines: Line[];
|
|
41
|
+
/** Dim the whole screen before this layer is stamped (modals do; popups and toasts do not). */
|
|
42
|
+
readonly dim: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* A blank cell of this layer (a space with no background) clears the screen
|
|
45
|
+
* cell under it to the plain screen instead of keeping its background: the
|
|
46
|
+
* layer's own gap around a floating surface (toasts), so nothing underneath
|
|
47
|
+
* touches its edges.
|
|
48
|
+
*/
|
|
49
|
+
readonly clearBlank?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isTransparentCell(cell: Cell): boolean {
|
|
53
|
+
return cell.char === '' && cell.fg === '' && cell.bg === '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Canvas
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export interface KitStyle {
|
|
61
|
+
fg?: string;
|
|
62
|
+
/** undefined keeps the cell's current bg (text drawn on a fill stays on it). */
|
|
63
|
+
bg?: string;
|
|
64
|
+
bold?: boolean;
|
|
65
|
+
italic?: boolean;
|
|
66
|
+
underline?: boolean;
|
|
67
|
+
strikethrough?: boolean;
|
|
68
|
+
dim?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A width x height grid of cells with the concept's small painter API. */
|
|
72
|
+
export class SurfaceCanvas {
|
|
73
|
+
readonly lines: Line[];
|
|
74
|
+
/**
|
|
75
|
+
* True when the screen is narrower than the modal breakpoint (90 columns):
|
|
76
|
+
* lists on this canvas drop their group spacing as soon as they do not fit.
|
|
77
|
+
*/
|
|
78
|
+
compact = false;
|
|
79
|
+
|
|
80
|
+
constructor(readonly width: number, readonly height: number) {
|
|
81
|
+
this.lines = Array.from({ length: Math.max(0, height) }, () =>
|
|
82
|
+
Array.from({ length: Math.max(0, width) }, () => createEmptyCell()));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
cell(x: number, y: number): Cell | undefined {
|
|
86
|
+
return this.lines[y]?.[x];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write `text` from column x on row y; returns the column after the last
|
|
91
|
+
* character written. Wide characters take two cells (the second is an empty
|
|
92
|
+
* continuation cell). Characters past the canvas edge are dropped.
|
|
93
|
+
*/
|
|
94
|
+
put(x: number, y: number, text: string, style: KitStyle = {}): number {
|
|
95
|
+
const row = this.lines[y];
|
|
96
|
+
let cx = x;
|
|
97
|
+
for (const ch of text) {
|
|
98
|
+
const w = getDisplayWidth(ch);
|
|
99
|
+
if (w <= 0) continue;
|
|
100
|
+
if (row && cx >= 0 && cx + w - 1 < this.width) {
|
|
101
|
+
const prev = row[cx]!;
|
|
102
|
+
row[cx] = {
|
|
103
|
+
char: ch,
|
|
104
|
+
fg: style.fg ?? activeTokens().text,
|
|
105
|
+
bg: style.bg ?? prev.bg,
|
|
106
|
+
bold: style.bold ?? false,
|
|
107
|
+
dim: style.dim ?? false,
|
|
108
|
+
underline: style.underline ?? false,
|
|
109
|
+
italic: style.italic ?? false,
|
|
110
|
+
strikethrough: style.strikethrough ?? false,
|
|
111
|
+
};
|
|
112
|
+
if (w === 2) row[cx + 1] = { ...row[cx]!, char: '' };
|
|
113
|
+
}
|
|
114
|
+
cx += w;
|
|
115
|
+
}
|
|
116
|
+
return cx;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Write `text` so its last cell sits on column xr; returns the start column. */
|
|
120
|
+
right(xr: number, y: number, text: string, style: KitStyle = {}): number {
|
|
121
|
+
const start = xr - getDisplayWidth(text) + 1;
|
|
122
|
+
this.put(start, y, text, style);
|
|
123
|
+
return start;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Fill a rectangle with `bg`, clearing its characters. */
|
|
127
|
+
fill(x: number, y: number, w: number, h: number, bg: string): void {
|
|
128
|
+
for (let r = y; r < y + h; r++) {
|
|
129
|
+
const row = this.lines[r];
|
|
130
|
+
if (!row) continue;
|
|
131
|
+
for (let q = x; q < x + w; q++) {
|
|
132
|
+
if (q < 0 || q >= this.width) continue;
|
|
133
|
+
row[q] = { ...createEmptyCell(), bg };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Change the bg of `w` cells on row y, keeping their characters. */
|
|
139
|
+
tint(x: number, y: number, w: number, bg: string): void {
|
|
140
|
+
const row = this.lines[y];
|
|
141
|
+
if (!row) return;
|
|
142
|
+
for (let q = x; q < x + w; q++) {
|
|
143
|
+
const c = row[q];
|
|
144
|
+
if (c) row[q] = { ...c, bg };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A horizontal bg gradient from `from` to `to` across `w` cells. */
|
|
149
|
+
grad(x: number, y: number, w: number, from: string, to: string): void {
|
|
150
|
+
const row = this.lines[y];
|
|
151
|
+
if (!row) return;
|
|
152
|
+
for (let q = 0; q < w; q++) {
|
|
153
|
+
const c = row[x + q];
|
|
154
|
+
if (c) row[x + q] = { ...c, bg: gradientAt(from, to, q, w) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Text whose fg runs along a gradient; returns the column after it. */
|
|
159
|
+
gradFg(x: number, y: number, text: string, from: string, to: string, style: KitStyle = {}): number {
|
|
160
|
+
const chars = [...text];
|
|
161
|
+
let cx = x;
|
|
162
|
+
chars.forEach((ch, k) => {
|
|
163
|
+
cx = this.put(cx, y, ch, { ...style, fg: gradientAt(from, to, k, chars.length) });
|
|
164
|
+
});
|
|
165
|
+
return cx;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Color at step k of n along from → to. */
|
|
170
|
+
function gradientAt(from: string, to: string, k: number, n: number): string {
|
|
171
|
+
return mixHex(from, to, n > 1 ? k / (n - 1) : 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Geometry
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/** Width below which modals take the full width minus 1 per side. */
|
|
179
|
+
export const NARROW_MODAL_BREAKPOINT = 90;
|
|
180
|
+
/** Upper bound on a modal's width. */
|
|
181
|
+
const MAX_MODAL_WIDTH = 124;
|
|
182
|
+
/** Columns between the fill edge and text. */
|
|
183
|
+
export const MODAL_PAD_X = 4;
|
|
184
|
+
/** Columns between the fill edge and a row marker / the selection gradient. */
|
|
185
|
+
export const MODAL_MARK_INSET = 2;
|
|
186
|
+
|
|
187
|
+
export interface ModalGeometry {
|
|
188
|
+
/** Screen column of the fill's left edge. */
|
|
189
|
+
readonly x: number;
|
|
190
|
+
/** Screen row of the fill's first row (the cap sits on y - 1). */
|
|
191
|
+
readonly y: number;
|
|
192
|
+
readonly w: number;
|
|
193
|
+
/** Rows of fill (caps excluded). */
|
|
194
|
+
readonly h: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface ModalGeometryOptions {
|
|
198
|
+
/** Preferred width; defaults to the 86% rule. Always clamped to the screen. */
|
|
199
|
+
readonly width?: number;
|
|
200
|
+
/** Preferred fill height; defaults to the tallest the screen allows. */
|
|
201
|
+
readonly height?: number;
|
|
202
|
+
/** Center vertically (small dialogs) instead of the 8% top edge. */
|
|
203
|
+
readonly center?: boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The standard modal width for a screen width. */
|
|
207
|
+
export function standardModalWidth(screenW: number): number {
|
|
208
|
+
if (screenW < NARROW_MODAL_BREAKPOINT) return Math.max(10, screenW - 2);
|
|
209
|
+
return Math.min(MAX_MODAL_WIDTH, Math.round(screenW * 0.86), screenW - 2);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The top edge row for a screen height (8%, at least 1 so the cap fits). */
|
|
213
|
+
function standardModalTop(screenH: number): number {
|
|
214
|
+
return Math.max(1, Math.round(screenH * 0.08));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The tallest fill that keeps a dimmed margin below the bottom cap as wide as the one above. */
|
|
218
|
+
export function maxModalHeight(screenH: number): number {
|
|
219
|
+
const y = standardModalTop(screenH);
|
|
220
|
+
return Math.max(5, screenH - 2 * y);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function modalGeometry(screenW: number, screenH: number, options: ModalGeometryOptions = {}): ModalGeometry {
|
|
224
|
+
const maxW = screenW < NARROW_MODAL_BREAKPOINT ? Math.max(10, screenW - 2) : Math.max(10, screenW - 2);
|
|
225
|
+
const w = Math.max(10, Math.min(options.width ?? standardModalWidth(screenW), maxW));
|
|
226
|
+
const x = Math.max(0, Math.floor((screenW - w) / 2));
|
|
227
|
+
const top = standardModalTop(screenH);
|
|
228
|
+
const maxH = maxModalHeight(screenH);
|
|
229
|
+
const h = Math.max(5, Math.min(options.height ?? maxH, maxH));
|
|
230
|
+
const y = options.center
|
|
231
|
+
? Math.max(top, Math.min(screenH - h - 1, Math.round((screenH - h) / 2)))
|
|
232
|
+
: top;
|
|
233
|
+
return { x, y, w, h };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Keycaps and hints
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/** One hint: the key (drawn as a keycap) and its action (muted). */
|
|
241
|
+
export type KitHint = readonly [key: string, action: string];
|
|
242
|
+
|
|
243
|
+
/** Width of ` key `. */
|
|
244
|
+
function keycapWidth(key: string): number {
|
|
245
|
+
return getDisplayWidth(key) + 2;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A key drawn as a small filled chip: bold text on the border fill. Returns the column after it. */
|
|
249
|
+
function keycap(canvas: SurfaceCanvas, x: number, y: number, key: string): number {
|
|
250
|
+
const t = activeTokens();
|
|
251
|
+
return canvas.put(x, y, ` ${key} `, { fg: t.text, bg: t.border, bold: true });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Gap between one hint's action and the next hint's key. */
|
|
255
|
+
const HINT_GAP = 3;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A hint wider than a whole row keeps its keycap on the first row and wraps
|
|
259
|
+
* its action onto rows of its own (an empty key draws no keycap).
|
|
260
|
+
*/
|
|
261
|
+
function splitWideHint(hint: KitHint, width: number): KitHint[] {
|
|
262
|
+
const first = Math.max(1, width - keycapWidth(hint[0]) - 1);
|
|
263
|
+
const words = hint[1].split(' ');
|
|
264
|
+
const parts: string[] = [];
|
|
265
|
+
let line = '';
|
|
266
|
+
let room = first;
|
|
267
|
+
for (const word of words) {
|
|
268
|
+
const candidate = line ? `${line} ${word}` : word;
|
|
269
|
+
if (getDisplayWidth(candidate) > room && line) {
|
|
270
|
+
parts.push(line);
|
|
271
|
+
line = word;
|
|
272
|
+
room = width;
|
|
273
|
+
} else {
|
|
274
|
+
line = candidate;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (line) parts.push(line);
|
|
278
|
+
return parts.map((part, i) => [i === 0 ? hint[0] : '', clipText(part, i === 0 ? first : width)] as const);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Lay hints into rows no wider than `width`; each entry is the hints of one row. */
|
|
282
|
+
export function layoutHintRows(hints: readonly KitHint[], width: number): KitHint[][] {
|
|
283
|
+
const rows: KitHint[][] = [];
|
|
284
|
+
let current: KitHint[] = [];
|
|
285
|
+
let used = 0;
|
|
286
|
+
const expanded = hints.flatMap((hint) => (keycapWidth(hint[0]) + 1 + getDisplayWidth(hint[1]) > width ? splitWideHint(hint, width) : [hint]));
|
|
287
|
+
for (const hint of expanded) {
|
|
288
|
+
if (hint[0] === '') {
|
|
289
|
+
// A wrapped action continues on a row of its own.
|
|
290
|
+
if (current.length > 0) rows.push(current);
|
|
291
|
+
rows.push([hint]);
|
|
292
|
+
current = [];
|
|
293
|
+
used = 0;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const w = keycapWidth(hint[0]) + 1 + getDisplayWidth(hint[1]);
|
|
297
|
+
const need = current.length === 0 ? w : used + HINT_GAP + w;
|
|
298
|
+
if (current.length > 0 && need > width) {
|
|
299
|
+
rows.push(current);
|
|
300
|
+
current = [hint];
|
|
301
|
+
used = w;
|
|
302
|
+
} else {
|
|
303
|
+
current.push(hint);
|
|
304
|
+
used = need;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (current.length > 0) rows.push(current);
|
|
308
|
+
return rows;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Draw one row of hints from column x; returns the column after the last action. */
|
|
312
|
+
function drawHintRow(canvas: SurfaceCanvas, x: number, y: number, hints: readonly KitHint[]): number {
|
|
313
|
+
const t = activeTokens();
|
|
314
|
+
let cx = x;
|
|
315
|
+
hints.forEach(([key, action], k) => {
|
|
316
|
+
if (k > 0) cx += HINT_GAP;
|
|
317
|
+
if (key === '') {
|
|
318
|
+
cx = canvas.put(cx, y, action, { fg: t.textMuted });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
cx = keycap(canvas, cx, y, key);
|
|
322
|
+
cx = canvas.put(cx + 1, y, action, { fg: t.textMuted });
|
|
323
|
+
});
|
|
324
|
+
return cx;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A muted scroll count, e.g. "12 more ↓" or "3 more ↑ · 12 more ↓". '' when nothing is hidden. */
|
|
328
|
+
export function scrollCountText(above: number, below: number): string {
|
|
329
|
+
const parts: string[] = [];
|
|
330
|
+
if (above > 0) parts.push(`${above} more ↑`);
|
|
331
|
+
if (below > 0) parts.push(`${below} more ↓`);
|
|
332
|
+
return parts.join(' · ');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Modal frame
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
export interface ModalOptions extends ModalGeometryOptions {
|
|
340
|
+
readonly title: string;
|
|
341
|
+
/** Breadcrumb parts drawn after the title as › part › part. */
|
|
342
|
+
readonly crumbs?: readonly string[];
|
|
343
|
+
/** A muted note after the title (and crumbs). */
|
|
344
|
+
readonly sub?: string;
|
|
345
|
+
/** Keycap hints for the second-to-last row. Wrap to more rows when they do not fit. */
|
|
346
|
+
readonly hints?: readonly KitHint[];
|
|
347
|
+
/** Top cap color: the brand gradient (default) or a solid warning strip ("this needs you"). */
|
|
348
|
+
readonly cap?: 'brand' | 'warning' | 'danger';
|
|
349
|
+
/** Draw the `esc` keycap on the title row (default true). */
|
|
350
|
+
readonly escKey?: boolean;
|
|
351
|
+
/** Dim the screen behind the modal (default true). */
|
|
352
|
+
readonly dim?: boolean;
|
|
353
|
+
/** Title glyph; defaults to ✦ in the brand gradient. */
|
|
354
|
+
readonly titleGlyph?: { readonly char: string; readonly fg: string };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A modal being drawn. All coordinates are canvas coordinates: canvas row 0 is
|
|
359
|
+
* the top cap, rows 1..h are the fill, row h+1 is the bottom cap; canvas column
|
|
360
|
+
* 0 is the fill's left edge.
|
|
361
|
+
*/
|
|
362
|
+
export interface ModalFrame {
|
|
363
|
+
readonly canvas: SurfaceCanvas;
|
|
364
|
+
readonly geometry: ModalGeometry;
|
|
365
|
+
/** First text column (4 in from the left edge). */
|
|
366
|
+
readonly l: number;
|
|
367
|
+
/** Last text column (4 in from the right edge). */
|
|
368
|
+
readonly r: number;
|
|
369
|
+
/** The title row. */
|
|
370
|
+
readonly t: number;
|
|
371
|
+
/** First body row (one blank row under the title). */
|
|
372
|
+
readonly top: number;
|
|
373
|
+
/** Last body row (a blank row separates it from the hints). */
|
|
374
|
+
readonly bottom: number;
|
|
375
|
+
/** First hint row. */
|
|
376
|
+
readonly hintTop: number;
|
|
377
|
+
readonly hintRows: KitHint[][];
|
|
378
|
+
readonly dim: boolean;
|
|
379
|
+
/** Right-aligned text on the last hint row (typically a scroll count). */
|
|
380
|
+
hintRight: string;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The text width inside a modal of width w. */
|
|
384
|
+
export function modalInnerWidth(w: number): number {
|
|
385
|
+
return Math.max(1, w - 2 * MODAL_PAD_X);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Room kept on the hint row for a right-aligned scroll count ("12 more ↓"). */
|
|
389
|
+
const HINT_RIGHT_RESERVE = 12;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The width hints are laid out in for a modal with `inner` text columns. Wide
|
|
393
|
+
* modals keep room for the scroll count beside the hints; narrow ones give
|
|
394
|
+
* the hints the whole row (the count moves to the row above them).
|
|
395
|
+
*/
|
|
396
|
+
export function hintLayoutWidth(inner: number): number {
|
|
397
|
+
return inner >= 60 ? inner - HINT_RIGHT_RESERVE : Math.max(1, inner);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Open a modal: fill, caps, title row and the space for hint rows. Draw the body, then call finishModal. */
|
|
401
|
+
export function beginModal(screenW: number, screenH: number, options: ModalOptions): ModalFrame {
|
|
402
|
+
const t = activeTokens();
|
|
403
|
+
const geometry = modalGeometry(screenW, screenH, options);
|
|
404
|
+
const { w, h } = geometry;
|
|
405
|
+
const canvas = new SurfaceCanvas(w, h + 2);
|
|
406
|
+
canvas.compact = screenW < NARROW_MODAL_BREAKPOINT;
|
|
407
|
+
canvas.fill(0, 1, w, h, t.backgroundPanel);
|
|
408
|
+
|
|
409
|
+
// Soft caps: ▄ above in the brand gradient (or a warning strip), ▀ below in the surface color.
|
|
410
|
+
const capFrom = options.cap === 'warning' ? t.warning : options.cap === 'danger' ? t.error : t.brand;
|
|
411
|
+
const capTo = options.cap === 'warning' ? t.warning : options.cap === 'danger' ? t.error : t.brandEnd;
|
|
412
|
+
for (let q = 0; q < w; q++) {
|
|
413
|
+
canvas.lines[0]![q] = { ...createEmptyCell(), char: '▄', fg: gradientAt(capFrom, capTo, q, w) };
|
|
414
|
+
canvas.lines[h + 1]![q] = { ...createEmptyCell(), char: '▀', fg: t.backgroundPanel };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const l = MODAL_PAD_X;
|
|
418
|
+
const r = w - MODAL_PAD_X - 1;
|
|
419
|
+
const titleRow = 2;
|
|
420
|
+
const hints = options.hints ?? [];
|
|
421
|
+
const hintRows = hints.length > 0 ? layoutHintRows(hints, hintLayoutWidth(r - l + 1)) : [];
|
|
422
|
+
// The last fill row (canvas row h) stays empty; hints end on the row above it.
|
|
423
|
+
const lastHintRow = h - 1;
|
|
424
|
+
const hintTop = hintRows.length > 0 ? lastHintRow - hintRows.length + 1 : lastHintRow;
|
|
425
|
+
const bottom = hintRows.length > 0 ? hintTop - 2 : lastHintRow;
|
|
426
|
+
|
|
427
|
+
// Title row: ✦ + bold title + crumbs + sub, esc keycap on the right.
|
|
428
|
+
const escW = options.escKey === false ? 0 : keycapWidth('esc') + 2;
|
|
429
|
+
let tx = l;
|
|
430
|
+
if (options.titleGlyph) tx = canvas.put(tx, titleRow, options.titleGlyph.char, { fg: options.titleGlyph.fg });
|
|
431
|
+
else tx = canvas.gradFg(tx, titleRow, '✦', capFrom, capTo);
|
|
432
|
+
const titleRoom = Math.max(1, r - escW - tx);
|
|
433
|
+
tx = canvas.put(tx + 1, titleRow, clipText(options.title, titleRoom), { fg: t.text, bold: true });
|
|
434
|
+
for (const crumb of options.crumbs ?? []) {
|
|
435
|
+
if (tx + 4 > r - escW) break;
|
|
436
|
+
canvas.put(tx + 1, titleRow, '›', { fg: t.textFaint });
|
|
437
|
+
tx = canvas.put(tx + 3, titleRow, clipText(crumb, r - escW - tx - 3), { fg: t.textMuted });
|
|
438
|
+
}
|
|
439
|
+
if (options.sub && tx + 3 < r - escW) {
|
|
440
|
+
tx = canvas.put(tx + 2, titleRow, clipText(options.sub, r - escW - tx - 2), { fg: t.textMuted });
|
|
441
|
+
}
|
|
442
|
+
if (options.escKey !== false) keycap(canvas, r - keycapWidth('esc') + 1, titleRow, 'esc');
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
canvas,
|
|
446
|
+
geometry,
|
|
447
|
+
l,
|
|
448
|
+
r,
|
|
449
|
+
t: titleRow,
|
|
450
|
+
top: titleRow + 2,
|
|
451
|
+
bottom: Math.max(titleRow + 2, bottom),
|
|
452
|
+
hintTop,
|
|
453
|
+
hintRows,
|
|
454
|
+
dim: options.dim !== false,
|
|
455
|
+
hintRight: '',
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Draw the hint rows and return the layer for the compositor. */
|
|
460
|
+
export function finishModal(frame: ModalFrame): SurfaceLayer {
|
|
461
|
+
const t = activeTokens();
|
|
462
|
+
let lastEnd = frame.l;
|
|
463
|
+
frame.hintRows.forEach((row, k) => { lastEnd = drawHintRow(frame.canvas, frame.l, frame.hintTop + k, row); });
|
|
464
|
+
if (frame.hintRight) {
|
|
465
|
+
frame.hintRight = fitCount(frame.hintRight, frame.r - frame.l + 1);
|
|
466
|
+
const width = getDisplayWidth(frame.hintRight);
|
|
467
|
+
const lastRow = frame.hintRows.length > 0 ? frame.hintTop + frame.hintRows.length - 1 : frame.hintTop;
|
|
468
|
+
// On the last hint row when it fits beside the hints, else on the blank row above them.
|
|
469
|
+
const fits = frame.hintRows.length === 0 || frame.r - width + 1 > lastEnd + 2;
|
|
470
|
+
frame.canvas.right(frame.r, fits ? lastRow : frame.hintTop - 1, frame.hintRight, { fg: t.textFaint });
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
x: frame.geometry.x,
|
|
474
|
+
y: frame.geometry.y - 1,
|
|
475
|
+
lines: frame.canvas.lines,
|
|
476
|
+
dim: frame.dim,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Search row
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The always-live search row: the typed text with a brand-colored ▏ cursor, or
|
|
486
|
+
* the cursor and a faint placeholder when empty; an optional count on the right.
|
|
487
|
+
*/
|
|
488
|
+
export function searchRow(frame: ModalFrame, y: number, query: string, placeholder: string, countText?: string): void {
|
|
489
|
+
let count = countText;
|
|
490
|
+
const t = activeTokens();
|
|
491
|
+
const { canvas, l, r } = frame;
|
|
492
|
+
// A count wider than half the row gives way (the query matters more).
|
|
493
|
+
const inner = r - l + 1;
|
|
494
|
+
if (count && getDisplayWidth(count) > Math.floor(inner / 2)) count = fitCount(count, Math.floor(inner / 2));
|
|
495
|
+
const countW = count ? getDisplayWidth(count) + 2 : 0;
|
|
496
|
+
const room = Math.max(1, r - l + 1 - countW - 1);
|
|
497
|
+
if (query.length > 0) {
|
|
498
|
+
// Keep the end of a long query (where the cursor is) visible.
|
|
499
|
+
const shown = tailText(query, room);
|
|
500
|
+
const end = canvas.put(l, y, shown, { fg: t.text });
|
|
501
|
+
canvas.put(end, y, '▏', { fg: t.brand });
|
|
502
|
+
} else {
|
|
503
|
+
canvas.put(l, y, '▏', { fg: t.brand });
|
|
504
|
+
canvas.put(l + 1, y, clipText(placeholder, room - 1), { fg: t.textFaint });
|
|
505
|
+
}
|
|
506
|
+
if (count) canvas.right(r, y, count, { fg: t.textFaint });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Text helpers
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Fit a count or status ("12 more ↓", "3 of 5,582") into `width`: first the
|
|
515
|
+
* words go ("12 ↓"), then it is cut. Counts are short on any real terminal;
|
|
516
|
+
* this only matters on very narrow ones.
|
|
517
|
+
*/
|
|
518
|
+
function fitCount(text: string, width: number): string {
|
|
519
|
+
if (getDisplayWidth(text) <= width) return text;
|
|
520
|
+
const compact = text.replace(/\bmore\s+/g, '').replace(/\s*\blines?\b/g, '').replace(/\s+/g, ' ').trim();
|
|
521
|
+
if (getDisplayWidth(compact) <= width) return compact;
|
|
522
|
+
return clipText(compact, Math.max(0, width));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Cut `text` to at most `width` cells (no ellipsis; callers wrap instead where text matters). */
|
|
526
|
+
export function clipText(text: string, width: number): string {
|
|
527
|
+
if (width <= 0) return '';
|
|
528
|
+
let used = 0;
|
|
529
|
+
let out = '';
|
|
530
|
+
for (const ch of text) {
|
|
531
|
+
const w = getDisplayWidth(ch);
|
|
532
|
+
if (used + w > width) break;
|
|
533
|
+
out += ch;
|
|
534
|
+
used += w;
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The last `width` cells of `text`. */
|
|
540
|
+
export function tailText(text: string, width: number): string {
|
|
541
|
+
const chars = [...text];
|
|
542
|
+
let used = 0;
|
|
543
|
+
let start = chars.length;
|
|
544
|
+
while (start > 0) {
|
|
545
|
+
const w = getDisplayWidth(chars[start - 1]!);
|
|
546
|
+
if (used + w > width) break;
|
|
547
|
+
used += w;
|
|
548
|
+
start--;
|
|
549
|
+
}
|
|
550
|
+
return chars.slice(start).join('');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Wrap text to `width`, never returning an empty array. */
|
|
554
|
+
export function wrapLines(text: string, width: number): string[] {
|
|
555
|
+
const lines = wrapText(text, Math.max(1, width));
|
|
556
|
+
return lines.length > 0 ? lines : [''];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Draw wrapped text from (x, y) within `width`; returns the row after the last line. */
|
|
560
|
+
export function drawWrapped(canvas: SurfaceCanvas, x: number, y: number, width: number, text: string, style: KitStyle, maxY = Number.POSITIVE_INFINITY): number {
|
|
561
|
+
let yy = y;
|
|
562
|
+
for (const line of wrapLines(text, width)) {
|
|
563
|
+
if (yy > maxY) break;
|
|
564
|
+
canvas.put(x, yy, line, style);
|
|
565
|
+
yy++;
|
|
566
|
+
}
|
|
567
|
+
return yy;
|
|
568
|
+
}
|