@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,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-line.ts, the one row directly under the composer.
|
|
3
|
+
*
|
|
4
|
+
* Left end: the chips that must always be visible, never dropped for lack of
|
|
5
|
+
* room: the approval mode (muted, plan in the info color), or "! auto-approve"
|
|
6
|
+
* in the error color while everything is auto-approved; "sleep disabled"; the
|
|
7
|
+
* live microphone. Optional chips after them (attachments, orchestration) are
|
|
8
|
+
* dropped first when the row is short. 2 columns between chips.
|
|
9
|
+
*
|
|
10
|
+
* Then, one of (first match wins):
|
|
11
|
+
* - a notice the user must see now (the "press Ctrl+C again" exit guard, a
|
|
12
|
+
* copy receipt);
|
|
13
|
+
* - the working directory and branch, plus a background-work summary
|
|
14
|
+
* while agents or processes run (highlighted with its keys while it owns
|
|
15
|
+
* keyboard focus). While a main turn runs, an `esc` keycap with
|
|
16
|
+
* "interrupt" (or "clear input") leads them. What the turn is doing (the
|
|
17
|
+
* spinner, the phrase, the elapsed time) is the throbber's, the row above
|
|
18
|
+
* the input area (throbber.ts); the status line keeps session state.
|
|
19
|
+
*
|
|
20
|
+
* Right side, built right to left from width-4 and stopping where the left
|
|
21
|
+
* side really ends (3 columns between pieces, 1 between a key and its
|
|
22
|
+
* action): the `ctrl+p` keycap with "menu", the context bar, the cost.
|
|
23
|
+
*
|
|
24
|
+
* A long directory is shortened before anything else is given up: ~ for the
|
|
25
|
+
* home directory (the caller does that), then a middle ellipsis that keeps the
|
|
26
|
+
* first segment and as many trailing segments as fit (~/…/demo-proj). Only
|
|
27
|
+
* when even that does not leave the right side its full room is it dropped.
|
|
28
|
+
*
|
|
29
|
+
* Inside an agent or process view the left side is that view's keys (esc back
|
|
30
|
+
* to main, ctrl+x stop, …) followed by a short trailing piece that keeps main
|
|
31
|
+
* in sight (◐ main · working).
|
|
32
|
+
*
|
|
33
|
+
* Narrow screens: below 100 columns the directory goes first, then the cost,
|
|
34
|
+
* then the bar narrows from 16 cells toward 6, then the "used / total" label
|
|
35
|
+
* goes, then the word "context"; the bar is dropped only when even the bare
|
|
36
|
+
* 6 cells and percent do not fit. From the warning level up the left side
|
|
37
|
+
* yields room for that bare bar, so a filling window is never hidden. The
|
|
38
|
+
* kept chips at the left end are never dropped.
|
|
39
|
+
*
|
|
40
|
+
* The background summary shares the row with the cost and the context bar at
|
|
41
|
+
* every width: the right side's room is reserved before it is drawn
|
|
42
|
+
* (reserveRight), giving way in the same order, and it is truncated into
|
|
43
|
+
* what remains. It never pushes the bar off the row.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { type Line, createEmptyLine } from '@pellux/goodvibes-sdk/platform/types';
|
|
47
|
+
import { getDisplayWidth, interpolateColor, truncateDisplay } from '../utils/terminal-width.ts';
|
|
48
|
+
import { abbreviateCount } from '../utils/format-number.ts';
|
|
49
|
+
import { activeTokens } from './theme.ts';
|
|
50
|
+
import { keycapHintsWidth, paintKeycapHints } from './surface-kit-parts.ts';
|
|
51
|
+
import type { KitHint } from './surface-kit.ts';
|
|
52
|
+
|
|
53
|
+
/** The bar's cell count when there is room, and the least it narrows to. */
|
|
54
|
+
const CONTEXT_BAR_CELLS = 16;
|
|
55
|
+
const CONTEXT_BAR_MIN_CELLS = 6;
|
|
56
|
+
/** From this fraction of the window the bar turns to the warning color. */
|
|
57
|
+
const CONTEXT_WARN_FRACTION = 0.65;
|
|
58
|
+
/** Directory is shown only from this width up. */
|
|
59
|
+
const DIRECTORY_MIN_WIDTH = 100;
|
|
60
|
+
/** Pieces on the status line sit this far apart. */
|
|
61
|
+
const GAP = 3;
|
|
62
|
+
/** Left edge of the status line (the fill column of the composer above). */
|
|
63
|
+
const LEFT_X = 3;
|
|
64
|
+
/** Columns between the chips at the left end. */
|
|
65
|
+
const CHIP_GAP = 2;
|
|
66
|
+
/**
|
|
67
|
+
* Room the background summary keeps before the right side gives anything up
|
|
68
|
+
* for it: a readable start of the summary. Past that the summary is
|
|
69
|
+
* truncated; the context bar never is.
|
|
70
|
+
*/
|
|
71
|
+
const LEFT_TEXT_MIN = 26;
|
|
72
|
+
|
|
73
|
+
/** A chip at the left end of the status line. */
|
|
74
|
+
export interface StatusChip {
|
|
75
|
+
readonly text: string;
|
|
76
|
+
readonly fg: string;
|
|
77
|
+
readonly bold?: boolean;
|
|
78
|
+
/** A filled chip (the wake indicator's banner prominence); no fill otherwise. */
|
|
79
|
+
readonly bg?: string;
|
|
80
|
+
/** Never dropped for lack of room (the mode, auto-approve, microphone, sleep). */
|
|
81
|
+
readonly keep?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A main turn is running: the status line leads with what Esc does now. */
|
|
85
|
+
export interface StatusBusyState {
|
|
86
|
+
/** What the next Esc does, when not "interrupt" (the composer has text: "clear input"). */
|
|
87
|
+
readonly escAction?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface StatusBackgroundState {
|
|
91
|
+
readonly agents: number;
|
|
92
|
+
readonly processes: number;
|
|
93
|
+
/** True while the background summary owns keyboard focus. */
|
|
94
|
+
readonly focused: boolean;
|
|
95
|
+
/** Optional one-line progress of the running agents. */
|
|
96
|
+
readonly progress?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface StatusContextState {
|
|
100
|
+
/** Tokens in the last request (0 while unknown). */
|
|
101
|
+
readonly usedTokens: number;
|
|
102
|
+
/**
|
|
103
|
+
* The model's context window, or null when nothing states it (a guessed
|
|
104
|
+
* window, or one a larger accepted request disproved). An unknown window
|
|
105
|
+
* draws "context 29.9k / unknown": no bar and no percent, since neither can
|
|
106
|
+
* be computed.
|
|
107
|
+
*/
|
|
108
|
+
readonly windowTokens: number | null;
|
|
109
|
+
/** Compaction threshold as a fraction [0..1]. */
|
|
110
|
+
readonly compactFraction: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface StatusLineOptions {
|
|
114
|
+
readonly width: number;
|
|
115
|
+
/** Chips at the left end, drawn left to right; see the file header. */
|
|
116
|
+
readonly chips?: readonly StatusChip[];
|
|
117
|
+
readonly notice?: { readonly text: string; readonly tone: 'error' | 'info' } | null;
|
|
118
|
+
readonly busy?: StatusBusyState | null;
|
|
119
|
+
readonly directory?: string;
|
|
120
|
+
readonly branch?: string;
|
|
121
|
+
readonly background?: StatusBackgroundState | null;
|
|
122
|
+
/** Formatted cost text ("$0.246", "you $0.25 · fleet $0.47"); omitted when unknown. */
|
|
123
|
+
readonly cost?: string | null;
|
|
124
|
+
readonly context?: StatusContextState | null;
|
|
125
|
+
/**
|
|
126
|
+
* Keys of the view the keyboard is in, shown in place of the esc hint
|
|
127
|
+
* or directory (the conversation work tree: move, fold, open, copy, back).
|
|
128
|
+
*/
|
|
129
|
+
readonly keys?: readonly KitHint[] | null;
|
|
130
|
+
/** A piece drawn after the keys (an agent view keeps main in sight: "◐ main · working"). */
|
|
131
|
+
readonly trail?: { readonly text: string; readonly fg: string } | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface Piece {
|
|
135
|
+
readonly text: string;
|
|
136
|
+
readonly fg: string;
|
|
137
|
+
readonly bold?: boolean;
|
|
138
|
+
readonly bg?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function putText(line: Line, x: number, endX: number, piece: Piece): number {
|
|
142
|
+
let cx = x;
|
|
143
|
+
for (const ch of piece.text) {
|
|
144
|
+
const w = getDisplayWidth(ch);
|
|
145
|
+
if (w <= 0) continue;
|
|
146
|
+
if (cx + w > endX || cx >= line.length) break;
|
|
147
|
+
line[cx] = { char: ch, fg: piece.fg, bg: piece.bg ?? '', bold: piece.bold ?? false, dim: false, underline: false, italic: false, strikethrough: false };
|
|
148
|
+
if (w === 2 && cx + 1 < line.length) line[cx + 1] = { ...line[cx]!, char: '' };
|
|
149
|
+
cx += w;
|
|
150
|
+
}
|
|
151
|
+
return cx;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function piecesWidth(pieces: readonly Piece[]): number {
|
|
155
|
+
return pieces.reduce((sum, p) => sum + getDisplayWidth(p.text), 0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** "13.2k / 1.0M", "— / 1.0M" before the first count arrives, "13.2k / unknown" with no known window. */
|
|
159
|
+
function contextUsageLabel(usedTokens: number, windowTokens: number | null): string {
|
|
160
|
+
const used = usedTokens > 0 ? abbreviateCount(usedTokens, { bSuffix: true }) : '—';
|
|
161
|
+
return `${used} / ${windowTokens === null ? 'unknown' : abbreviateCount(windowTokens, { bSuffix: true })}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function contextFraction(state: StatusContextState): number {
|
|
165
|
+
if (state.windowTokens === null || !(state.windowTokens > 0)) return 0;
|
|
166
|
+
return Math.max(0, Math.min(1, state.usedTokens / state.windowTokens));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Which optional parts of the context piece are drawn. */
|
|
170
|
+
interface ContextBarForm {
|
|
171
|
+
readonly cells: number;
|
|
172
|
+
readonly word: boolean;
|
|
173
|
+
readonly label: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Width of the context bar piece in a given form. */
|
|
177
|
+
function contextBarWidth(state: StatusContextState, form: ContextBarForm): number {
|
|
178
|
+
if (state.windowTokens === null) {
|
|
179
|
+
// Unknown window: the word and the label only ("context 29.9k / unknown").
|
|
180
|
+
return (form.word ? getDisplayWidth('context') + 1 : 0) + getDisplayWidth(contextUsageLabel(state.usedTokens, null));
|
|
181
|
+
}
|
|
182
|
+
const pct = `${Math.round(contextFraction(state) * 100)}%`;
|
|
183
|
+
return (form.word ? getDisplayWidth('context') + 1 : 0) + form.cells + 1 + getDisplayWidth(pct)
|
|
184
|
+
+ (form.label ? 1 + getDisplayWidth(contextUsageLabel(state.usedTokens, state.windowTokens)) : 0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The bare form: minimum cells and the percent, nothing else. */
|
|
188
|
+
const BARE_CONTEXT_FORM: ContextBarForm = { cells: CONTEXT_BAR_MIN_CELLS, word: false, label: false };
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The widest form that fits `room`: 16 cells narrowing to 6 with the word and
|
|
192
|
+
* label, then without the label, then without the word. Null when even the
|
|
193
|
+
* bare form does not fit.
|
|
194
|
+
*/
|
|
195
|
+
function fitContextForm(state: StatusContextState, room: number): ContextBarForm | null {
|
|
196
|
+
const full: ContextBarForm = { cells: CONTEXT_BAR_CELLS, word: true, label: true };
|
|
197
|
+
if (state.windowTokens === null) {
|
|
198
|
+
// No bar to narrow: the word goes first, then the whole piece.
|
|
199
|
+
for (const form of [full, { ...full, word: false }] satisfies ContextBarForm[]) {
|
|
200
|
+
if (contextBarWidth(state, form) <= room) return form;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const fullW = contextBarWidth(state, full);
|
|
205
|
+
if (fullW <= room) return full;
|
|
206
|
+
const narrowed = CONTEXT_BAR_CELLS - (fullW - room);
|
|
207
|
+
if (narrowed >= CONTEXT_BAR_MIN_CELLS) return { ...full, cells: narrowed };
|
|
208
|
+
for (const form of [
|
|
209
|
+
{ cells: CONTEXT_BAR_MIN_CELLS, word: true, label: false },
|
|
210
|
+
BARE_CONTEXT_FORM,
|
|
211
|
+
] satisfies ContextBarForm[]) {
|
|
212
|
+
if (contextBarWidth(state, form) <= room) return form;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Draw "context ███░░│░░ 34% 340K / 1.0M" at x. Filled cells carry the brand
|
|
219
|
+
* gradient while healthy, the warning color from 65%, the error color at or
|
|
220
|
+
* past the compaction threshold; the track is the border color and an amber
|
|
221
|
+
* tick marks the threshold. Returns the column after the label.
|
|
222
|
+
*/
|
|
223
|
+
function drawContextBar(line: Line, x: number, state: StatusContextState, form: ContextBarForm): number {
|
|
224
|
+
const cells = form.cells;
|
|
225
|
+
const t = activeTokens();
|
|
226
|
+
if (state.windowTokens === null) {
|
|
227
|
+
const cx = form.word ? putText(line, x, line.length, { text: 'context', fg: t.textMuted }) + 1 : x;
|
|
228
|
+
return putText(line, cx, line.length, { text: contextUsageLabel(state.usedTokens, null), fg: t.textMuted });
|
|
229
|
+
}
|
|
230
|
+
const used = contextFraction(state);
|
|
231
|
+
const threshold = Math.max(0, Math.min(1, state.compactFraction > 0 ? state.compactFraction : 1));
|
|
232
|
+
const hot = used >= threshold ? t.error : used >= CONTEXT_WARN_FRACTION ? t.warning : null;
|
|
233
|
+
const filled = used > 0 ? Math.max(1, Math.round(cells * used)) : 0;
|
|
234
|
+
const tick = threshold < 1 ? Math.min(cells - 1, Math.round(cells * threshold)) : -1;
|
|
235
|
+
let cx = form.word ? putText(line, x, line.length, { text: 'context', fg: t.textMuted }) + 1 : x;
|
|
236
|
+
for (let q = 0; q < cells; q++) {
|
|
237
|
+
if (q === tick) {
|
|
238
|
+
putText(line, cx + q, line.length, { text: '│', fg: t.warning, bold: true });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const isFilled = q < filled;
|
|
242
|
+
const fg = isFilled ? (hot ?? interpolateColor(t.brand, t.brandEnd, cells > 1 ? q / (cells - 1) : 0)) : t.border;
|
|
243
|
+
putText(line, cx + q, line.length, { text: isFilled ? '█' : '░', fg });
|
|
244
|
+
}
|
|
245
|
+
cx += cells + 1;
|
|
246
|
+
cx = putText(line, cx, line.length, { text: `${Math.round(used * 100)}%`, fg: hot ?? t.text, bold: true });
|
|
247
|
+
if (!form.label) return cx;
|
|
248
|
+
return putText(line, cx + 1, line.length, { text: contextUsageLabel(state.usedTokens, state.windowTokens), fg: t.textMuted });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function backgroundSummary(bg: StatusBackgroundState): string {
|
|
252
|
+
const parts: string[] = [];
|
|
253
|
+
if (bg.agents > 0) parts.push(`${bg.agents} agent${bg.agents === 1 ? '' : 's'} running`);
|
|
254
|
+
if (bg.processes > 0) parts.push(`${bg.processes} process${bg.processes === 1 ? '' : 'es'} running`);
|
|
255
|
+
if (parts.length === 0) return 'no background work';
|
|
256
|
+
return parts.join(' · ');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Draw the left side from `startX`; returns the column where it really ends.
|
|
261
|
+
* `textMax` bounds the background summary: the room the right side's
|
|
262
|
+
* reservation left it (reserveRight).
|
|
263
|
+
*/
|
|
264
|
+
function drawLeft(line: Line, options: StatusLineOptions, startX: number, maxX: number, textMax: number, withDirectory: boolean, cut: { summary: boolean }): number {
|
|
265
|
+
const t = activeTokens();
|
|
266
|
+
const width = options.width;
|
|
267
|
+
if (options.notice) {
|
|
268
|
+
return putText(line, startX, maxX, { text: options.notice.text, fg: options.notice.tone === 'error' ? t.error : t.info, bold: true });
|
|
269
|
+
}
|
|
270
|
+
if (options.keys && options.keys.length > 0) {
|
|
271
|
+
// As many whole keys as fit, in order.
|
|
272
|
+
const keys: KitHint[] = [];
|
|
273
|
+
for (const key of options.keys) {
|
|
274
|
+
if (keycapHintsWidth([...keys, key]) > maxX - startX) break;
|
|
275
|
+
keys.push(key);
|
|
276
|
+
}
|
|
277
|
+
let x = keys.length > 0 ? paintKeycapHints(line, startX, maxX, keys, { fg: t.textFaint }) : startX;
|
|
278
|
+
const trail = options.trail;
|
|
279
|
+
if (trail && keys.length === options.keys.length && x + GAP + getDisplayWidth(trail.text) <= maxX) {
|
|
280
|
+
x = putText(line, x + GAP, maxX, { text: trail.text, fg: trail.fg });
|
|
281
|
+
}
|
|
282
|
+
return x;
|
|
283
|
+
}
|
|
284
|
+
let x = startX;
|
|
285
|
+
let lead = false; // the esc hint was drawn: what follows keeps a GAP from it
|
|
286
|
+
if (options.busy) {
|
|
287
|
+
// A running turn: Esc's action first, then the at-rest pieces after it.
|
|
288
|
+
const hints: KitHint[] = [['esc', options.busy.escAction ?? 'interrupt']];
|
|
289
|
+
if (x + keycapHintsWidth(hints) > maxX) return x;
|
|
290
|
+
x = paintKeycapHints(line, x, maxX, hints, { fg: t.textFaint });
|
|
291
|
+
lead = true;
|
|
292
|
+
}
|
|
293
|
+
const place: Piece[] = [];
|
|
294
|
+
if (withDirectory && options.directory && width >= DIRECTORY_MIN_WIDTH) place.push({ text: options.directory, fg: t.textFaint });
|
|
295
|
+
if (options.branch) place.push({ text: options.branch, fg: t.textFaint });
|
|
296
|
+
if (place.length > 0) {
|
|
297
|
+
const joined = place.map((p) => p.text).join(' · ');
|
|
298
|
+
const from = lead ? x + GAP : x;
|
|
299
|
+
const shown = truncateDisplay(joined, Math.max(0, maxX - from));
|
|
300
|
+
if (shown.length > 0) x = putText(line, from, maxX, { text: shown, fg: t.textFaint });
|
|
301
|
+
}
|
|
302
|
+
const bg = options.background;
|
|
303
|
+
if (bg && (bg.focused || bg.agents + bg.processes > 0)) {
|
|
304
|
+
const start = place.length > 0 || lead ? x + GAP : x;
|
|
305
|
+
const summary = backgroundSummary(bg) + (bg.progress ? ` · ${bg.progress}` : '');
|
|
306
|
+
const hints: KitHint[] = bg.focused
|
|
307
|
+
? (bg.agents + bg.processes > 0 ? [['⏎', 'open'], ['esc', 'back']] : [['esc', 'back']])
|
|
308
|
+
: [['⏎', 'view']];
|
|
309
|
+
const end = Math.min(maxX, textMax);
|
|
310
|
+
// The directory yields to the summary: a cut summary makes the caller
|
|
311
|
+
// try a shorter directory, then none (renderStatusLine).
|
|
312
|
+
cut.summary = getDisplayWidth(`◐ ${summary}`) > Math.max(0, end - start - (bg.focused ? keycapHintsWidth(hints) + GAP : 0));
|
|
313
|
+
if (bg.focused) {
|
|
314
|
+
const text = truncateDisplay(`▸ ${summary}`, Math.max(0, end - start - keycapHintsWidth(hints) - GAP));
|
|
315
|
+
x = putText(line, start, end, { text, fg: t.text, bold: true, bg: t.backgroundSelected });
|
|
316
|
+
x = paintKeycapHints(line, x + GAP, end, hints, { fg: t.textFaint });
|
|
317
|
+
} else {
|
|
318
|
+
const text = truncateDisplay(`◐ ${summary}`, Math.max(0, end - start));
|
|
319
|
+
x = putText(line, start, end, { text, fg: t.brand, bold: true });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return x;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Columns the right side (left of the menu) keeps from the background
|
|
327
|
+
* summary, `avail` being the room both share. Pieces give way in
|
|
328
|
+
* the Measurements table's order until LEFT_TEXT_MIN is left for the text:
|
|
329
|
+
* the cost first, then the bar narrows from 16 cells to 6, then its label
|
|
330
|
+
* goes, then the word "context". The bare bar and its percent are always
|
|
331
|
+
* kept (when they fit on the row at all): the summary never hides the
|
|
332
|
+
* context bar. Each kept piece counts its GAP.
|
|
333
|
+
*/
|
|
334
|
+
function reserveRight(ctx: StatusContextState | null, cost: string | null, avail: number): number {
|
|
335
|
+
const costW = cost ? GAP + getDisplayWidth(cost) : 0;
|
|
336
|
+
const leaves = (w: number): boolean => avail - w >= LEFT_TEXT_MIN;
|
|
337
|
+
if (!ctx) return cost && leaves(costW) ? costW : 0;
|
|
338
|
+
const full: ContextBarForm = { cells: CONTEXT_BAR_CELLS, word: true, label: true };
|
|
339
|
+
const barW = (form: ContextBarForm): number => GAP + contextBarWidth(ctx, form);
|
|
340
|
+
if (leaves(barW(full) + costW) && cost) return barW(full) + costW;
|
|
341
|
+
const forms: ContextBarForm[] = ctx.windowTokens === null
|
|
342
|
+
? [full, { ...full, word: false }]
|
|
343
|
+
: [
|
|
344
|
+
...Array.from({ length: CONTEXT_BAR_CELLS - CONTEXT_BAR_MIN_CELLS + 1 }, (_, k) => ({ ...full, cells: CONTEXT_BAR_CELLS - k })),
|
|
345
|
+
{ cells: CONTEXT_BAR_MIN_CELLS, word: true, label: false },
|
|
346
|
+
BARE_CONTEXT_FORM,
|
|
347
|
+
];
|
|
348
|
+
for (const form of forms) if (leaves(barW(form))) return barW(form);
|
|
349
|
+
const last = forms[forms.length - 1]!;
|
|
350
|
+
return barW(last) <= avail ? barW(last) : 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Draw the left-end chips; returns the column after the last one (LEFT_X when
|
|
355
|
+
* none). Optional chips that would run past `maxX` are dropped, last first; a
|
|
356
|
+
* kept chip is always drawn, up to the row's right edge.
|
|
357
|
+
*/
|
|
358
|
+
function drawChips(line: Line, chips: readonly StatusChip[], maxX: number, rightEdge: number): number {
|
|
359
|
+
const shown = [...chips];
|
|
360
|
+
const width = (list: readonly StatusChip[]): number => list.reduce((s, c, i) => s + (i > 0 ? CHIP_GAP : 0) + getDisplayWidth(c.text), 0);
|
|
361
|
+
while (shown.length > 0 && LEFT_X + width(shown) > maxX) {
|
|
362
|
+
const idx = shown.map((c) => c.keep === true).lastIndexOf(false);
|
|
363
|
+
if (idx < 0) break;
|
|
364
|
+
shown.splice(idx, 1);
|
|
365
|
+
}
|
|
366
|
+
let x = LEFT_X;
|
|
367
|
+
shown.forEach((chip, i) => {
|
|
368
|
+
if (i > 0) x += CHIP_GAP;
|
|
369
|
+
x = putText(line, x, rightEdge, { text: chip.text, fg: chip.fg, bold: chip.bold, bg: chip.bg });
|
|
370
|
+
});
|
|
371
|
+
return x;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Render the status line. */
|
|
375
|
+
export function renderStatusLine(options: StatusLineOptions): Line {
|
|
376
|
+
// The directory is the first thing to shorten, then to go: when it would
|
|
377
|
+
// cost the cost or the full context bar their room, the row is laid out
|
|
378
|
+
// again with a shorter form of it, and finally without it.
|
|
379
|
+
const withDirectory = layoutStatusLine(options, true);
|
|
380
|
+
if (withDirectory.fullFit || !options.directory) return withDirectory.line;
|
|
381
|
+
for (const shorter of abbreviatedDirectories(options.directory)) {
|
|
382
|
+
const attempt = layoutStatusLine({ ...options, directory: shorter }, true);
|
|
383
|
+
if (attempt.fullFit) return attempt.line;
|
|
384
|
+
}
|
|
385
|
+
const withoutDirectory = layoutStatusLine(options, false);
|
|
386
|
+
// While a turn runs the esc hint leads the row: when even without the
|
|
387
|
+
// directory the background summary is cut, the branch gives way too.
|
|
388
|
+
if (!withoutDirectory.summaryCut || !options.busy || !options.branch) return withoutDirectory.line;
|
|
389
|
+
return layoutStatusLine({ ...options, branch: undefined }, false).line;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Shorter forms of a directory, longest first: the first segment kept, the
|
|
394
|
+
* middle replaced by …, as many trailing segments as the form allows
|
|
395
|
+
* (~/Projects/…/app/demo-proj, then ~/…/demo-proj).
|
|
396
|
+
*/
|
|
397
|
+
function abbreviatedDirectories(directory: string): string[] {
|
|
398
|
+
const absolute = directory.startsWith('/');
|
|
399
|
+
const segments = directory.split('/').filter((s) => s.length > 0);
|
|
400
|
+
if (segments.length < 3) return [];
|
|
401
|
+
const head = absolute ? `/${segments[0]}` : segments[0]!;
|
|
402
|
+
const out: string[] = [];
|
|
403
|
+
for (let keep = segments.length - 2; keep >= 1; keep--) {
|
|
404
|
+
const form = `${head}/…/${segments.slice(segments.length - keep).join('/')}`;
|
|
405
|
+
if (form.length < directory.length && !out.includes(form)) out.push(form);
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function layoutStatusLine(options: StatusLineOptions, withDirectory: boolean): { line: Line; fullFit: boolean; summaryCut: boolean } {
|
|
411
|
+
const t = activeTokens();
|
|
412
|
+
const width = options.width;
|
|
413
|
+
const line = createEmptyLine(width);
|
|
414
|
+
for (const cell of line) cell.bg = '';
|
|
415
|
+
const rightEdge = width - 3; // exclusive: right-aligned text ends at width-4
|
|
416
|
+
const menu: KitHint[] = [['ctrl+p', 'menu']];
|
|
417
|
+
const menuW = keycapHintsWidth(menu);
|
|
418
|
+
|
|
419
|
+
const ctx = options.context && (options.context.windowTokens === null || options.context.windowTokens > 0) ? options.context : null;
|
|
420
|
+
// From the warning level up the bare bar is reserved before the left side
|
|
421
|
+
// is laid out, so the left side truncates instead of hiding a filling window.
|
|
422
|
+
const reserve = ctx && contextFraction(ctx) >= CONTEXT_WARN_FRACTION
|
|
423
|
+
? contextBarWidth(ctx, BARE_CONTEXT_FORM) + GAP
|
|
424
|
+
: 0;
|
|
425
|
+
|
|
426
|
+
// The left side is drawn first so the right side knows where it really ends.
|
|
427
|
+
// It may use everything left of the menu keycap (and the reserve).
|
|
428
|
+
const leftMax = Math.max(LEFT_X, rightEdge - menuW - GAP - reserve);
|
|
429
|
+
const chipsEnd = drawChips(line, options.chips ?? [], leftMax, rightEdge);
|
|
430
|
+
const leftStart = chipsEnd > LEFT_X ? chipsEnd + GAP : LEFT_X;
|
|
431
|
+
// The background summary shares the row: the right side's
|
|
432
|
+
// room is reserved first and the summary is truncated into what is left.
|
|
433
|
+
// A running turn's esc hint leads the left side; the summary's room is what follows it.
|
|
434
|
+
const leadW = options.busy && !options.notice && !(options.keys && options.keys.length > 0)
|
|
435
|
+
? keycapHintsWidth([['esc', options.busy.escAction ?? 'interrupt']]) + GAP
|
|
436
|
+
: 0;
|
|
437
|
+
const avail = Math.max(0, rightEdge - menuW - GAP - leftStart - leadW);
|
|
438
|
+
const textMax = leftStart + leadW + Math.max(0, avail - reserveRight(ctx, options.cost ?? null, avail));
|
|
439
|
+
const cut = { summary: false };
|
|
440
|
+
const drawn = drawLeft(line, options, leftStart, Math.max(leftStart, leftMax), Math.max(leftStart, Math.min(leftMax, textMax)), withDirectory, cut);
|
|
441
|
+
const leftEnd = drawn > leftStart ? drawn : chipsEnd;
|
|
442
|
+
|
|
443
|
+
const cost = options.cost ?? null;
|
|
444
|
+
const costW = cost ? getDisplayWidth(cost) : 0;
|
|
445
|
+
const full: ContextBarForm = { cells: CONTEXT_BAR_CELLS, word: true, label: true };
|
|
446
|
+
const fullRightW = menuW
|
|
447
|
+
+ (ctx ? GAP + contextBarWidth(ctx, full) : 0)
|
|
448
|
+
+ (cost ? GAP + costW : 0);
|
|
449
|
+
const fullFit = leftEnd + GAP + fullRightW <= rightEdge && !cut.summary;
|
|
450
|
+
|
|
451
|
+
let rx = rightEdge;
|
|
452
|
+
if (rx - menuW >= leftEnd + GAP) {
|
|
453
|
+
paintKeycapHints(line, rx - menuW, rx, menu, { fg: t.textFaint });
|
|
454
|
+
rx -= menuW + GAP;
|
|
455
|
+
} else {
|
|
456
|
+
return { line, fullFit, summaryCut: cut.summary };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const room = rx - leftEnd - GAP;
|
|
460
|
+
let form: ContextBarForm | null = null;
|
|
461
|
+
let showCost = false;
|
|
462
|
+
if (ctx) {
|
|
463
|
+
if (cost && contextBarWidth(ctx, full) + GAP + costW <= room) { form = full; showCost = true; }
|
|
464
|
+
else form = fitContextForm(ctx, room);
|
|
465
|
+
} else if (cost && costW <= room) {
|
|
466
|
+
showCost = true;
|
|
467
|
+
}
|
|
468
|
+
if (ctx && form) {
|
|
469
|
+
const w = contextBarWidth(ctx, form);
|
|
470
|
+
drawContextBar(line, rx - w, ctx, form);
|
|
471
|
+
rx -= w + GAP;
|
|
472
|
+
}
|
|
473
|
+
if (showCost && cost) putText(line, rx - costW, rx, { text: cost, fg: t.textMuted });
|
|
474
|
+
return { line, fullFit, summaryCut: cut.summary };
|
|
475
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface-compose.ts, the compositor's two modal passes.
|
|
3
|
+
*
|
|
4
|
+
* Dim pass: after the screen is composed, every cell's foreground and
|
|
5
|
+
* background blend toward a scrim color (dark themes: black at 0.62; light
|
|
6
|
+
* themes: the text color at 0.38, a lighter scrim). A cell on the terminal's
|
|
7
|
+
* own background (bg '') blends against the probed terminal background, or a
|
|
8
|
+
* theme-derived stand-in when the terminal did not answer the probe. Bold is
|
|
9
|
+
* dropped so nothing behind the modal competes with it.
|
|
10
|
+
*
|
|
11
|
+
* Stamp over: a SurfaceLayer is drawn over the (dimmed) screen at its own
|
|
12
|
+
* position instead of replacing whole rows; cells outside its rectangle keep
|
|
13
|
+
* their dimmed content. Layer cells with bg '' keep the bg underneath (the
|
|
14
|
+
* half-block caps), transparent cells are skipped, and a wide character cut by
|
|
15
|
+
* the layer's edge is blanked so no half glyph is left behind.
|
|
16
|
+
*
|
|
17
|
+
* The passes work on any CellGrid, so the compositor (TerminalBuffer) and the
|
|
18
|
+
* golden-frame tests (plain Line[]) share them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createEmptyCell, type Cell } from '@pellux/goodvibes-sdk/platform/types';
|
|
22
|
+
import { ansiToHex, isHexColor, mixHex, normalizeHex } from '@pellux/goodvibes-sdk/platform/presentation';
|
|
23
|
+
import { getDisplayWidth } from '../utils/terminal-width.ts';
|
|
24
|
+
import { getTerminalPalette } from './terminal-palette.ts';
|
|
25
|
+
import { activeThemeMode, activeTokens } from './theme.ts';
|
|
26
|
+
import { isTransparentCell, type SurfaceLayer } from './surface-kit.ts';
|
|
27
|
+
|
|
28
|
+
export interface CellGrid {
|
|
29
|
+
readonly width: number;
|
|
30
|
+
readonly height: number;
|
|
31
|
+
getCell(x: number, y: number): Cell | undefined;
|
|
32
|
+
setCell(x: number, y: number, cell: Cell): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DimParams {
|
|
36
|
+
/** The color everything blends toward. */
|
|
37
|
+
readonly scrim: string;
|
|
38
|
+
/** Blend amount, 0..1. */
|
|
39
|
+
readonly amount: number;
|
|
40
|
+
/** What an empty bg means on this terminal. */
|
|
41
|
+
readonly terminalBg: string;
|
|
42
|
+
/** What an empty fg means on this terminal. */
|
|
43
|
+
readonly terminalFg: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Dim amount on dark themes (about 60% toward black). */
|
|
47
|
+
const DARK_DIM_AMOUNT = 0.62;
|
|
48
|
+
/** Dim amount on light themes (a lighter scrim). */
|
|
49
|
+
const LIGHT_DIM_AMOUNT = 0.38;
|
|
50
|
+
|
|
51
|
+
/** The dim parameters for the active theme, mode and probed terminal colors. */
|
|
52
|
+
function currentDimParams(): DimParams {
|
|
53
|
+
const t = activeTokens();
|
|
54
|
+
const mode = activeThemeMode();
|
|
55
|
+
const palette = getTerminalPalette();
|
|
56
|
+
const black = ansiToHex(0);
|
|
57
|
+
const white = ansiToHex(15);
|
|
58
|
+
const terminalBg = hexOr(palette?.background, t.background)
|
|
59
|
+
?? (mode === 'dark' ? mixHex(t.backgroundBase, black, 0.3) : white);
|
|
60
|
+
const terminalFg = hexOr(palette?.foreground, undefined) ?? t.text;
|
|
61
|
+
return mode === 'dark'
|
|
62
|
+
? { scrim: black, amount: DARK_DIM_AMOUNT, terminalBg, terminalFg }
|
|
63
|
+
: { scrim: t.text, amount: LIGHT_DIM_AMOUNT, terminalBg, terminalFg };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hexOr(a: string | undefined, b: string | undefined): string | undefined {
|
|
67
|
+
if (a && isHexColor(a)) return normalizeHex(a);
|
|
68
|
+
if (b && isHexColor(b)) return normalizeHex(b);
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** '#rgb' / '#rrggbb' / 'r;g;b' to '#rrggbb'; undefined for anything else (including ''). */
|
|
73
|
+
function toHexColor(color: string): string | undefined {
|
|
74
|
+
if (!color) return undefined;
|
|
75
|
+
if (color.startsWith('#')) return isHexColor(color) ? normalizeHex(color) : undefined;
|
|
76
|
+
const parts = color.split(';');
|
|
77
|
+
if (parts.length === 3 && parts.every((p) => /^\d{1,3}$/.test(p))) {
|
|
78
|
+
return `#${parts.map((p) => Math.min(255, Number(p)).toString(16).padStart(2, '0')).join('')}`;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Blend every cell of the grid toward the scrim. */
|
|
84
|
+
function dimGrid(grid: CellGrid, params: DimParams): void {
|
|
85
|
+
const cache = new Map<string, string>();
|
|
86
|
+
const blend = (color: string, fallback: string): string => {
|
|
87
|
+
const key = color || `default:${fallback}`;
|
|
88
|
+
let out = cache.get(key);
|
|
89
|
+
if (out === undefined) {
|
|
90
|
+
out = mixHex(toHexColor(color) ?? fallback, params.scrim, params.amount);
|
|
91
|
+
cache.set(key, out);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
for (let y = 0; y < grid.height; y++) {
|
|
96
|
+
for (let x = 0; x < grid.width; x++) {
|
|
97
|
+
const cell = grid.getCell(x, y);
|
|
98
|
+
if (!cell) continue;
|
|
99
|
+
grid.setCell(x, y, {
|
|
100
|
+
...cell,
|
|
101
|
+
fg: blend(cell.fg, params.terminalFg),
|
|
102
|
+
bg: blend(cell.bg, params.terminalBg),
|
|
103
|
+
bold: false,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stamp one layer over the grid. A dimming layer (a modal) also clears the
|
|
111
|
+
* backdrop beside each row it stamps: the cell just outside the edge always,
|
|
112
|
+
* and a word the edge cut in half out to its nearest space. The dimmed screen
|
|
113
|
+
* keeps its colours there; only glyphs go. Whole words beside a modal stay
|
|
114
|
+
* visible, but no fragment of one (a splash or transcript line a cell or two
|
|
115
|
+
* wider than the modal read as a stray glyph of the modal itself) and no text
|
|
116
|
+
* touching the fill edge.
|
|
117
|
+
*/
|
|
118
|
+
function stampLayer(grid: CellGrid, layer: SurfaceLayer): void {
|
|
119
|
+
for (let r = 0; r < layer.lines.length; r++) {
|
|
120
|
+
const y = layer.y + r;
|
|
121
|
+
if (y < 0 || y >= grid.height) continue;
|
|
122
|
+
const line = layer.lines[r]!;
|
|
123
|
+
let firstX = -1;
|
|
124
|
+
let lastX = -1;
|
|
125
|
+
for (let q = 0; q < line.length; q++) {
|
|
126
|
+
const x = layer.x + q;
|
|
127
|
+
if (x < 0 || x >= grid.width) continue;
|
|
128
|
+
const cell = line[q]!;
|
|
129
|
+
if (isTransparentCell(cell)) continue;
|
|
130
|
+
const under = grid.getCell(x, y);
|
|
131
|
+
if (layer.clearBlank && cell.char === ' ' && cell.bg === '') grid.setCell(x, y, createEmptyCell());
|
|
132
|
+
else grid.setCell(x, y, { ...cell, bg: cell.bg !== '' ? cell.bg : under?.bg ?? '' });
|
|
133
|
+
if (firstX < 0) firstX = x;
|
|
134
|
+
lastX = x;
|
|
135
|
+
}
|
|
136
|
+
if (firstX < 0) continue;
|
|
137
|
+
// A wide glyph whose right half the layer covered: blank its left half.
|
|
138
|
+
const left = grid.getCell(firstX - 1, y);
|
|
139
|
+
if (left && left.char !== '' && getDisplayWidth(left.char) > 1) grid.setCell(firstX - 1, y, { ...left, char: ' ' });
|
|
140
|
+
// A continuation cell whose wide glyph the layer covered: make it a plain space.
|
|
141
|
+
const right = grid.getCell(lastX + 1, y);
|
|
142
|
+
if (right && right.char === '') grid.setCell(lastX + 1, y, { ...createEmptyCell(), bg: right.bg, fg: right.fg });
|
|
143
|
+
if (layer.dim) {
|
|
144
|
+
clearCutWord(grid, firstX - 1, y, -1);
|
|
145
|
+
clearCutWord(grid, lastX + 1, y, 1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Blank glyphs from (x, y) outward in direction `step` until a space (or the
|
|
152
|
+
* screen edge), keeping each cell's colours. Wide-glyph continuation cells are
|
|
153
|
+
* part of their word and blank with it.
|
|
154
|
+
*/
|
|
155
|
+
function clearCutWord(grid: CellGrid, x: number, y: number, step: 1 | -1): void {
|
|
156
|
+
for (let cx = x; cx >= 0 && cx < grid.width; cx += step) {
|
|
157
|
+
const cell = grid.getCell(cx, y);
|
|
158
|
+
if (!cell || cell.char === ' ') return;
|
|
159
|
+
grid.setCell(cx, y, { ...cell, char: ' ' });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Apply layers in order, bottom first. A layer that asks for dimming dims
|
|
165
|
+
* everything under it (the screen, and any modal below it: a confirm dialog
|
|
166
|
+
* over a modal puts that modal in the background too); layers that do not
|
|
167
|
+
* dim (toasts, popups) are stamped as they are.
|
|
168
|
+
*/
|
|
169
|
+
export function composeLayers(grid: CellGrid, layers: readonly SurfaceLayer[], params: DimParams = currentDimParams()): void {
|
|
170
|
+
for (const layer of layers) {
|
|
171
|
+
if (layer.dim) dimGrid(grid, params);
|
|
172
|
+
stampLayer(grid, layer);
|
|
173
|
+
}
|
|
174
|
+
}
|