@pellux/goodvibes-tui 2.0.21 → 2.1.1
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 +65 -1
- package/README.md +30 -29
- package/docs/README.md +8 -5
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +28 -19
- 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-reference.ts +2 -2
- 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/live-verifier.ts +0 -10
- package/src/version.ts +1 -1
- 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/verification/verification-ledger.ts +0 -707
- /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,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syntax-theme.ts, the one role -> colour map for code.
|
|
3
|
+
*
|
|
4
|
+
* Both highlighters resolve through here: the tree-sitter path
|
|
5
|
+
* (syntax-highlighter.ts, NODE_TYPE_COLORS holds roles, not colours) and the
|
|
6
|
+
* regex fallback (code-block.ts). So a code block looks the same whichever
|
|
7
|
+
* path rendered it, and both follow the active theme.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { activeTokens, type PaletteTokens } from './theme.ts';
|
|
11
|
+
|
|
12
|
+
/** A syntax colour role. `plain` is text with no specific role. */
|
|
13
|
+
export type SyntaxRole =
|
|
14
|
+
| 'keyword'
|
|
15
|
+
| 'string'
|
|
16
|
+
| 'number'
|
|
17
|
+
| 'comment'
|
|
18
|
+
| 'function'
|
|
19
|
+
| 'type'
|
|
20
|
+
| 'operator'
|
|
21
|
+
| 'property'
|
|
22
|
+
| 'builtin'
|
|
23
|
+
| 'plain';
|
|
24
|
+
|
|
25
|
+
export interface SyntaxStyle {
|
|
26
|
+
readonly fg: string;
|
|
27
|
+
readonly italic?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const stylesByPalette = new WeakMap<PaletteTokens, Readonly<Record<SyntaxRole, SyntaxStyle>>>();
|
|
31
|
+
|
|
32
|
+
/** Role styles for a token table (memoized per table; defaults to the active theme). */
|
|
33
|
+
export function syntaxStyles(tokens: Readonly<PaletteTokens> = activeTokens()): Readonly<Record<SyntaxRole, SyntaxStyle>> {
|
|
34
|
+
const cached = stylesByPalette.get(tokens);
|
|
35
|
+
if (cached !== undefined) return cached;
|
|
36
|
+
const styles: Record<SyntaxRole, SyntaxStyle> = {
|
|
37
|
+
keyword: { fg: tokens.syntaxKeyword },
|
|
38
|
+
string: { fg: tokens.syntaxString },
|
|
39
|
+
number: { fg: tokens.syntaxNumber },
|
|
40
|
+
comment: { fg: tokens.syntaxComment, italic: true },
|
|
41
|
+
function: { fg: tokens.syntaxFunction },
|
|
42
|
+
type: { fg: tokens.syntaxType },
|
|
43
|
+
operator: { fg: tokens.syntaxOperator },
|
|
44
|
+
property: { fg: tokens.syntaxProperty },
|
|
45
|
+
builtin: { fg: tokens.syntaxBuiltin },
|
|
46
|
+
plain: { fg: tokens.text },
|
|
47
|
+
};
|
|
48
|
+
Object.freeze(styles);
|
|
49
|
+
stylesByPalette.set(tokens, styles);
|
|
50
|
+
return styles;
|
|
51
|
+
}
|
|
@@ -56,7 +56,7 @@ export function probeTermCaps(stdout: NodeJS.WriteStream): TermColorCaps {
|
|
|
56
56
|
: 1;
|
|
57
57
|
|
|
58
58
|
let capability: ColorCapability;
|
|
59
|
-
if (depth >= 24) {
|
|
59
|
+
if (depth >= 24 || (depth >= 8 && advertisesTruecolor(process.env))) {
|
|
60
60
|
capability = 'truecolor';
|
|
61
61
|
} else if (depth >= 8) {
|
|
62
62
|
capability = 'ansi256';
|
|
@@ -70,6 +70,47 @@ export function probeTermCaps(stdout: NodeJS.WriteStream): TermColorCaps {
|
|
|
70
70
|
return { capability, syncedOutput };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** TERM values of terminals that draw 24-bit color without saying so in COLORTERM. */
|
|
74
|
+
const TRUECOLOR_TERMS: ReadonlySet<string> = new Set([
|
|
75
|
+
'xterm-ghostty', 'xterm-kitty', 'alacritty', 'foot', 'foot-extra', 'wezterm', 'contour', 'rio', 'xterm-direct',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
/** TERM_PROGRAM values of terminals that draw 24-bit color. */
|
|
79
|
+
const TRUECOLOR_PROGRAMS: ReadonlySet<string> = new Set([
|
|
80
|
+
'ghostty', 'wezterm', 'iterm.app', 'vscode', 'hyper', 'tabby', 'rio', 'warpterminal',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether the environment says the terminal draws 24-bit color. The stream's
|
|
85
|
+
* own depth check only trusts COLORTERM and a short list, so it reports 256
|
|
86
|
+
* colors for Ghostty, kitty, Alacritty and foot, and a 256-color downsample
|
|
87
|
+
* turns every gradient into visible bands. This reads COLORTERM
|
|
88
|
+
* (truecolor / 24bit), a TERM ending in "-direct" (the terminfo convention for
|
|
89
|
+
* direct color), and TERM / TERM_PROGRAM values of terminals known to draw it.
|
|
90
|
+
*/
|
|
91
|
+
function advertisesTruecolor(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
92
|
+
const colorterm = (env['COLORTERM'] ?? '').toLowerCase();
|
|
93
|
+
if (colorterm === 'truecolor' || colorterm === '24bit') return true;
|
|
94
|
+
const term = (env['TERM'] ?? '').toLowerCase();
|
|
95
|
+
if (term.endsWith('-direct') || TRUECOLOR_TERMS.has(term)) return true;
|
|
96
|
+
return TRUECOLOR_PROGRAMS.has((env['TERM_PROGRAM'] ?? '').toLowerCase());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether the terminal can draw unicode (box drawing, beads, triangles), read
|
|
101
|
+
* from the environment: TERM=dumb cannot, and a locale naming a charset other
|
|
102
|
+
* than UTF-8 (including C and POSIX) cannot. No locale variables at all counts
|
|
103
|
+
* as capable, which is what modern terminal emulators report in practice.
|
|
104
|
+
* The conversation work tree draws ascii on a terminal this reports as limited.
|
|
105
|
+
*/
|
|
106
|
+
export function probeUnicodeSupport(env: Readonly<Record<string, string | undefined>> = process.env): boolean {
|
|
107
|
+
if ((env['TERM'] ?? '') === 'dumb') return false;
|
|
108
|
+
const locale = env['LC_ALL'] || env['LC_CTYPE'] || env['LANG'] || '';
|
|
109
|
+
if (locale === '') return true;
|
|
110
|
+
if (locale === 'C' || locale === 'POSIX') return false;
|
|
111
|
+
return /utf-?8/i.test(locale);
|
|
112
|
+
}
|
|
113
|
+
|
|
73
114
|
// ---------------------------------------------------------------------------
|
|
74
115
|
// Color parsing helpers
|
|
75
116
|
// ---------------------------------------------------------------------------
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* terminal-bg-probe, OSC 11 terminal-background detection for `auto` theme mode.
|
|
3
3
|
*
|
|
4
|
-
|
|
4
|
+
* On startup, when appearance is `auto` and stdout is a TTY, we ask
|
|
5
5
|
* the terminal for its background colour with an OSC 11 query and classify the
|
|
6
6
|
* reply as light or dark. Everything about this is best-effort and conservative:
|
|
7
7
|
* any timeout, any unparseable reply, any ambiguity resolves to DARK, which is
|
|
@@ -27,15 +27,29 @@
|
|
|
27
27
|
* leak into the input pipeline.
|
|
28
28
|
*
|
|
29
29
|
* tmux:
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* The query goes out as it is. tmux answers OSC 10 / 11 / 4 itself from the
|
|
31
|
+
* attached terminal's colours; the DCS passthrough envelope this used to use
|
|
32
|
+
* is discarded unless allow-passthrough is on (off by default). A query
|
|
33
|
+
* written while no client is attached is dropped by tmux, which is what the
|
|
34
|
+
* palette reader's retries are for.
|
|
35
|
+
*
|
|
36
|
+
* Palette (opt-in via `probePalette`):
|
|
37
|
+
* OSC 10 (foreground) and OSC 4;0..15 ride in the same write, after the OSC 11
|
|
38
|
+
* query. This probe's filter runs first and hands the OSC 11 body to the
|
|
39
|
+
* palette reader (terminal-palette-reader.ts), whose round consumes the other
|
|
40
|
+
* 17 replies within a 175 ms window (150 ms + 25 ms margin) and stores the
|
|
41
|
+
* result via terminal-palette.ts. A round with no colours is retried; a late
|
|
42
|
+
* palette re-themes and, in auto mode after a background timeout, settles
|
|
43
|
+
* light vs dark. With `probePalette` unset the bytes written and the
|
|
44
|
+
* filtering are exactly as described above.
|
|
34
45
|
*/
|
|
35
46
|
|
|
36
|
-
import { setActiveThemeMode, type ThemeMode } from './theme.ts';
|
|
37
|
-
import { resolveConfiguredThemeMode } from './theme-mode-config.ts';
|
|
47
|
+
import { refreshForTerminalPalette, setActiveThemeMode, setActiveThemeName, type ThemeMode } from './theme.ts';
|
|
48
|
+
import { resolveConfiguredThemeMode, resolveConfiguredThemeName } from './theme-mode-config.ts';
|
|
38
49
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
50
|
+
import { PALETTE_QUERIES, type PaletteProbeResolution } from './terminal-palette-probe.ts';
|
|
51
|
+
import { TerminalPaletteReader, type TerminalPaletteReaderOptions } from './terminal-palette-reader.ts';
|
|
52
|
+
import { setTerminalPalette } from './terminal-palette.ts';
|
|
39
53
|
|
|
40
54
|
/** OSC 11 "query background colour" request, ST-terminated. */
|
|
41
55
|
export const OSC11_QUERY = '\x1b]11;?\x1b\\';
|
|
@@ -172,6 +186,12 @@ export interface TerminalBackgroundProbeOptions {
|
|
|
172
186
|
readonly onResolve: (result: ProbeResolution) => void;
|
|
173
187
|
/** Probe window in ms (default DEFAULT_PROBE_TIMEOUT_MS). */
|
|
174
188
|
readonly timeoutMs?: number;
|
|
189
|
+
/**
|
|
190
|
+
* Observer for the raw body of a complete OSC 11 reply (before
|
|
191
|
+
* classification). The palette probe uses it to record the background
|
|
192
|
+
* colour, since this filter consumes the reply ahead of it.
|
|
193
|
+
*/
|
|
194
|
+
readonly onReplySpec?: (spec: string) => void;
|
|
175
195
|
}
|
|
176
196
|
|
|
177
197
|
/**
|
|
@@ -187,10 +207,12 @@ export class TerminalBackgroundProbe {
|
|
|
187
207
|
private resolved = false;
|
|
188
208
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
189
209
|
private readonly onResolve: (result: ProbeResolution) => void;
|
|
210
|
+
private readonly onReplySpec: ((spec: string) => void) | undefined;
|
|
190
211
|
private readonly timeoutMs: number;
|
|
191
212
|
|
|
192
213
|
constructor(options: TerminalBackgroundProbeOptions) {
|
|
193
214
|
this.onResolve = options.onResolve;
|
|
215
|
+
this.onReplySpec = options.onReplySpec;
|
|
194
216
|
this.timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
195
217
|
}
|
|
196
218
|
|
|
@@ -235,6 +257,7 @@ export class TerminalBackgroundProbe {
|
|
|
235
257
|
// keystrokes must be preserved here, not read back off this.buffer).
|
|
236
258
|
const spec = this.buffer.slice(prefixIdx + OSC11_PREFIX.length, term.start);
|
|
237
259
|
const remainder = this.buffer.slice(term.end);
|
|
260
|
+
this.onReplySpec?.(spec);
|
|
238
261
|
const rgb = parseColorSpec(spec);
|
|
239
262
|
if (rgb === null) {
|
|
240
263
|
this.resolve('dark', 'unparseable');
|
|
@@ -267,14 +290,9 @@ export class TerminalBackgroundProbe {
|
|
|
267
290
|
}
|
|
268
291
|
|
|
269
292
|
// ---------------------------------------------------------------------------
|
|
270
|
-
//
|
|
293
|
+
// Install entry
|
|
271
294
|
// ---------------------------------------------------------------------------
|
|
272
295
|
|
|
273
|
-
/** Wrap a control sequence in the tmux DCS passthrough envelope (ESC doubled). */
|
|
274
|
-
export function wrapForTmuxPassthrough(seq: string): string {
|
|
275
|
-
return `\x1bPtmux;${seq.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
296
|
/** The stdin filter the caller installs at the front of its data handler. */
|
|
279
297
|
export interface ThemeProbeHandle {
|
|
280
298
|
/** Filter a raw stdin chunk; returns the bytes to forward downstream. */
|
|
@@ -285,8 +303,6 @@ export interface InstallThemeProbeOptions {
|
|
|
285
303
|
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
286
304
|
/** Whether stdout is a real TTY (probe only runs on a TTY in auto mode). */
|
|
287
305
|
readonly isTTY: boolean;
|
|
288
|
-
/** Process env (only TMUX is read). */
|
|
289
|
-
readonly env: Record<string, string | undefined>;
|
|
290
306
|
/** Writes the query bytes (caller wraps in its terminal-output guard). */
|
|
291
307
|
readonly writeQuery: (bytes: string) => void;
|
|
292
308
|
/** Called when a light reply wins within the window → repaint once. */
|
|
@@ -295,6 +311,23 @@ export interface InstallThemeProbeOptions {
|
|
|
295
311
|
readonly timeoutMs?: number;
|
|
296
312
|
/** Optional resolution observer (tests / diagnostics). */
|
|
297
313
|
readonly onResolve?: (result: ProbeResolution) => void;
|
|
314
|
+
/**
|
|
315
|
+
* Also read the terminal palette (OSC 10 + OSC 4;0..15) in the same write as
|
|
316
|
+
* the OSC 11 query, and keep asking until it arrives (terminal-palette-reader.ts).
|
|
317
|
+
* Runs on a TTY in every appearance mode; the result lands in the
|
|
318
|
+
* terminal-palette store. Default false (query bytes unchanged).
|
|
319
|
+
*/
|
|
320
|
+
readonly probePalette?: boolean;
|
|
321
|
+
/** Palette window override (tests). Default PALETTE_PROBE_TIMEOUT_MS. */
|
|
322
|
+
readonly paletteTimeoutMs?: number;
|
|
323
|
+
/** Optional palette round observer (tests / diagnostics). */
|
|
324
|
+
readonly onPaletteResolve?: (result: PaletteProbeResolution) => void;
|
|
325
|
+
/** Palette retry tuning (tests). */
|
|
326
|
+
readonly paletteRetry?: Pick<TerminalPaletteReaderOptions, 'retryDelayMs' | 'maxRounds' | 'minRoundSpacingMs' | 'now'>;
|
|
327
|
+
/** Where an Esc / Alt+] key held at a palette round's close goes (the input pipeline). */
|
|
328
|
+
readonly forwardInput?: (bytes: string) => void;
|
|
329
|
+
/** Subscribe to terminal resizes (a client attaching); retries an unanswered palette read. */
|
|
330
|
+
readonly subscribeResize?: (listener: () => void) => void;
|
|
298
331
|
}
|
|
299
332
|
|
|
300
333
|
/**
|
|
@@ -305,13 +338,32 @@ export interface InstallThemeProbeOptions {
|
|
|
305
338
|
* - auto + !TTY → stay dark (headless/piped; probe cannot run).
|
|
306
339
|
* Returns a handle whose filterInput() the caller wires at the front of its
|
|
307
340
|
* stdin data handler. For non-auto and non-TTY cases the filter is a passthrough.
|
|
341
|
+
*
|
|
342
|
+
* The queries are always written as they are, never wrapped for tmux
|
|
343
|
+
* passthrough: tmux answers OSC 10, OSC 11 and OSC 4 itself from the attached
|
|
344
|
+
* terminal's colours, while a passthrough envelope is discarded unless the
|
|
345
|
+
* user turned allow-passthrough on (it is off by default), which left every
|
|
346
|
+
* tmux user without a palette.
|
|
308
347
|
*/
|
|
309
348
|
export function installBackgroundThemeProbe(options: InstallThemeProbeOptions): ThemeProbeHandle {
|
|
310
349
|
const pref = resolveConfiguredThemeMode(options.configManager);
|
|
350
|
+
// The configured theme applies before the first paint; the mode below
|
|
351
|
+
// (forced, or dark until the probe answers) completes the resolution.
|
|
352
|
+
setActiveThemeName(resolveConfiguredThemeName(options.configManager));
|
|
353
|
+
let backgroundTimedOut = false;
|
|
354
|
+
const palette = options.probePalette === true && options.isTTY
|
|
355
|
+
? createPaletteReader(options, pref, () => backgroundTimedOut)
|
|
356
|
+
: null;
|
|
357
|
+
if (palette !== null) options.subscribeResize?.(() => palette.noteResize());
|
|
311
358
|
|
|
312
359
|
if (pref === 'dark' || pref === 'light') {
|
|
313
360
|
setActiveThemeMode(pref);
|
|
314
|
-
return { filterInput: (chunk) => chunk };
|
|
361
|
+
if (palette === null) return { filterInput: (chunk) => chunk };
|
|
362
|
+
// Forced mode: no background classification, but the palette batch (which
|
|
363
|
+
// still carries OSC 11 for the background colour) goes out and the
|
|
364
|
+
// reader's filter consumes every reply.
|
|
365
|
+
palette.start();
|
|
366
|
+
return { filterInput: (chunk) => palette.feed(chunk) };
|
|
315
367
|
}
|
|
316
368
|
|
|
317
369
|
// pref === 'auto'
|
|
@@ -322,7 +374,9 @@ export function installBackgroundThemeProbe(options: InstallThemeProbeOptions):
|
|
|
322
374
|
|
|
323
375
|
const probe = new TerminalBackgroundProbe({
|
|
324
376
|
timeoutMs: options.timeoutMs,
|
|
377
|
+
onReplySpec: palette === null ? undefined : (spec) => palette.noteBackgroundSpec(spec),
|
|
325
378
|
onResolve: (result) => {
|
|
379
|
+
backgroundTimedOut = result.reason === 'timeout';
|
|
326
380
|
if (result.mode === 'light') {
|
|
327
381
|
setActiveThemeMode('light');
|
|
328
382
|
options.requestRepaint();
|
|
@@ -331,9 +385,52 @@ export function installBackgroundThemeProbe(options: InstallThemeProbeOptions):
|
|
|
331
385
|
},
|
|
332
386
|
});
|
|
333
387
|
|
|
334
|
-
|
|
335
|
-
|
|
388
|
+
if (palette === null) {
|
|
389
|
+
options.writeQuery(OSC11_QUERY);
|
|
390
|
+
probe.startTimeout();
|
|
391
|
+
return { filterInput: (chunk) => (probe.active ? probe.feed(chunk) : chunk) };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// One write: OSC 11 first (so the background reply arrives first and the
|
|
395
|
+
// background probe's timing is unchanged), then OSC 10 and OSC 4;0..15.
|
|
396
|
+
palette.start();
|
|
336
397
|
probe.startTimeout();
|
|
398
|
+
return {
|
|
399
|
+
filterInput: (chunk) => {
|
|
400
|
+
const afterBackground = probe.active ? probe.feed(chunk) : chunk;
|
|
401
|
+
return palette.feed(afterBackground);
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
337
405
|
|
|
338
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Palette reader whose result is stored beside the background probe's. In auto
|
|
408
|
+
* mode a palette that arrives after the background probe timed out (tmux with
|
|
409
|
+
* no client attached yet) still settles light vs dark from its background.
|
|
410
|
+
*/
|
|
411
|
+
function createPaletteReader(
|
|
412
|
+
options: InstallThemeProbeOptions,
|
|
413
|
+
pref: ThemeMode | 'auto',
|
|
414
|
+
backgroundTimedOut: () => boolean,
|
|
415
|
+
): TerminalPaletteReader {
|
|
416
|
+
return new TerminalPaletteReader({
|
|
417
|
+
...options.paletteRetry,
|
|
418
|
+
timeoutMs: options.paletteTimeoutMs,
|
|
419
|
+
writeBatch: () => options.writeQuery(OSC11_QUERY + PALETTE_QUERIES),
|
|
420
|
+
onFlush: options.forwardInput,
|
|
421
|
+
onRoundResolve: (result) => options.onPaletteResolve?.(result),
|
|
422
|
+
onPalette: (result) => {
|
|
423
|
+
setTerminalPalette(result);
|
|
424
|
+
let repaint = refreshForTerminalPalette();
|
|
425
|
+
if (pref === 'auto' && backgroundTimedOut() && result.background !== undefined) {
|
|
426
|
+
const rgb = parseColorSpec(result.background);
|
|
427
|
+
if (rgb !== null && classifyBackgroundLuminance(rgb) === 'light') {
|
|
428
|
+
setActiveThemeMode('light');
|
|
429
|
+
repaint = true;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// The system theme is generated from this palette: re-resolve and repaint.
|
|
433
|
+
if (repaint) options.requestRepaint();
|
|
434
|
+
},
|
|
435
|
+
});
|
|
339
436
|
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal-palette-probe, reads the terminal's foreground and 16 ANSI colours.
|
|
3
|
+
*
|
|
4
|
+
* The queries (OSC 10 foreground, OSC 4;0..15 palette) go out in the SAME write
|
|
5
|
+
* as the existing OSC 11 background query (see installBackgroundThemeProbe in
|
|
6
|
+
* terminal-bg-probe.ts), so the whole batch costs one write and one round trip.
|
|
7
|
+
* Terminals answer in request order: OSC 11 first, which keeps the background
|
|
8
|
+
* probe's timing exactly as before, then OSC 10, then OSC 4;0..15.
|
|
9
|
+
*
|
|
10
|
+
* Budget:
|
|
11
|
+
* The collection window is PALETTE_PROBE_TIMEOUT_MS = 175 ms, which is the
|
|
12
|
+
* background probe's 150 ms (DEFAULT_PROBE_TIMEOUT_MS) plus a 25 ms margin
|
|
13
|
+
* (PALETTE_PROBE_MARGIN_MS) for the 17 replies queued behind the OSC 11 one.
|
|
14
|
+
* Both windows start from the same write. Nothing blocks on it: first paint
|
|
15
|
+
* is not delayed, the only cost is that the stdin filter stays in front of the
|
|
16
|
+
* input pipeline for at most 25 ms longer than before. A terminal that
|
|
17
|
+
* answers all 18 queries ends the window early (typically single-digit ms).
|
|
18
|
+
*
|
|
19
|
+
* Stream safety mirrors the background probe: only matched OSC 4 / OSC 10 /
|
|
20
|
+
* OSC 11 replies are consumed, every other byte passes through in order, a
|
|
21
|
+
* reply split across chunks is held until complete, and on timeout any held
|
|
22
|
+
* reply fragment is discarded rather than flushed into the composer.
|
|
23
|
+
*
|
|
24
|
+
* This module deliberately imports nothing from terminal-bg-probe.ts (that file
|
|
25
|
+
* imports this one), so the two never form an import cycle.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
emptyTerminalPalette,
|
|
30
|
+
TERMINAL_PALETTE_ANSI_SLOTS,
|
|
31
|
+
type TerminalPalette,
|
|
32
|
+
} from './terminal-palette.ts';
|
|
33
|
+
|
|
34
|
+
/** OSC 10 "query default foreground colour", ST-terminated. */
|
|
35
|
+
const OSC10_QUERY = '\x1b]10;?\x1b\\';
|
|
36
|
+
|
|
37
|
+
/** OSC 4 "query palette entry N", ST-terminated. */
|
|
38
|
+
function osc4Query(index: number): string {
|
|
39
|
+
return `\x1b]4;${index};?\x1b\\`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** OSC 10 followed by OSC 4;0 .. OSC 4;15, appended after the OSC 11 query. */
|
|
43
|
+
export const PALETTE_QUERIES: string = OSC10_QUERY
|
|
44
|
+
+ Array.from({ length: TERMINAL_PALETTE_ANSI_SLOTS }, (_, i) => osc4Query(i)).join('');
|
|
45
|
+
|
|
46
|
+
/** Extra time the palette window stays open past the background probe window. */
|
|
47
|
+
const PALETTE_PROBE_MARGIN_MS = 25;
|
|
48
|
+
|
|
49
|
+
/** Palette collection window: background probe window (150 ms) + margin (25 ms). */
|
|
50
|
+
export const PALETTE_PROBE_TIMEOUT_MS = 150 + PALETTE_PROBE_MARGIN_MS;
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Pure parsing
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Scale a 1..4 digit hex channel to 8 bits against its own full scale. */
|
|
57
|
+
function channelTo8Bit(hex: string): number | null {
|
|
58
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(hex)) return null;
|
|
59
|
+
const max = 16 ** hex.length - 1;
|
|
60
|
+
return Math.round((parseInt(hex, 16) / max) * 255);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toHexByte(value: number): string {
|
|
64
|
+
return value.toString(16).padStart(2, '0');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse an OSC colour reply body into `#rrggbb`. Accepts `rgb:R/G/B` with 1 to
|
|
69
|
+
* 4 hex digits per channel (each channel scaled by its own width, so `f`, `ff`,
|
|
70
|
+
* `fff`, `ffff` all give `ff`), `rgba:R/G/B/A` (alpha ignored), and `#rrggbb` /
|
|
71
|
+
* `#rrrrggggbbbb`. Returns null for anything else.
|
|
72
|
+
*/
|
|
73
|
+
export function parseOscColorToHex(spec: string): string | null {
|
|
74
|
+
const trimmed = spec.trim();
|
|
75
|
+
let channels: string[];
|
|
76
|
+
|
|
77
|
+
if (trimmed.startsWith('rgb:') || trimmed.startsWith('rgba:')) {
|
|
78
|
+
const parts = trimmed.slice(trimmed.indexOf(':') + 1).split('/');
|
|
79
|
+
const expected = trimmed.startsWith('rgba:') ? 4 : 3;
|
|
80
|
+
if (parts.length !== expected) return null;
|
|
81
|
+
channels = parts.slice(0, 3);
|
|
82
|
+
} else if (trimmed.startsWith('#')) {
|
|
83
|
+
const hex = trimmed.slice(1);
|
|
84
|
+
if (hex.length !== 6 && hex.length !== 12) return null;
|
|
85
|
+
const per = hex.length / 3;
|
|
86
|
+
channels = [hex.slice(0, per), hex.slice(per, per * 2), hex.slice(per * 2)];
|
|
87
|
+
} else {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const values = channels.map(channelTo8Bit);
|
|
92
|
+
if (values.some((v) => v === null)) return null;
|
|
93
|
+
return `#${(values as number[]).map(toHexByte).join('')}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Start of a reply we own: ESC ] 4 ; N ; or ESC ] 10 ; or ESC ] 11 ; */
|
|
97
|
+
const REPLY_INTRODUCER = /\x1b\](?:4;(\d{1,3});|(1[01]);)/;
|
|
98
|
+
|
|
99
|
+
/** A buffer tail that could still grow into REPLY_INTRODUCER. */
|
|
100
|
+
const PARTIAL_INTRODUCER = /\x1b(?:\](?:4(?:;\d{0,3})?|1[01]?)?)?$/;
|
|
101
|
+
|
|
102
|
+
/** Length of a trailing partial introducer to hold back, or 0. */
|
|
103
|
+
function trailingPartialLen(buffer: string): number {
|
|
104
|
+
const esc = buffer.lastIndexOf('\x1b');
|
|
105
|
+
if (esc === -1) return 0;
|
|
106
|
+
const tail = buffer.slice(esc);
|
|
107
|
+
const m = PARTIAL_INTRODUCER.exec(tail);
|
|
108
|
+
return m !== null && m.index === 0 ? tail.length : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* OSC string terminator at/after `from`: BEL or ST (ESC \). Same rules as the
|
|
113
|
+
* background probe: a lone trailing ESC waits for the next byte; an ESC followed
|
|
114
|
+
* by anything other than `\` ends the body (malformed terminator).
|
|
115
|
+
*/
|
|
116
|
+
function findTerminator(buffer: string, from: number): { start: number; end: number } | null {
|
|
117
|
+
for (let k = from; k < buffer.length; k++) {
|
|
118
|
+
const c = buffer[k];
|
|
119
|
+
if (c === '\x07') return { start: k, end: k + 1 };
|
|
120
|
+
if (c === '\x1b') {
|
|
121
|
+
if (k + 1 >= buffer.length) return null;
|
|
122
|
+
return buffer[k + 1] === '\\' ? { start: k, end: k + 2 } : { start: k, end: k + 1 };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// TerminalPaletteProbe, the stateful stream filter
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export interface PaletteProbeResolution {
|
|
133
|
+
/** Collected colours; unanswered or unparseable slots are undefined. */
|
|
134
|
+
readonly palette: TerminalPalette;
|
|
135
|
+
/** 'complete' when all 18 replies arrived, 'timeout' when the window closed. */
|
|
136
|
+
readonly reason: 'complete' | 'timeout';
|
|
137
|
+
/** How many of the 18 queries got a reply (parseable or not). */
|
|
138
|
+
readonly replies: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface TerminalPaletteProbeOptions {
|
|
142
|
+
/** Called exactly once, on completion or timeout. */
|
|
143
|
+
readonly onResolve: (result: PaletteProbeResolution) => void;
|
|
144
|
+
/** Window in ms (default PALETTE_PROBE_TIMEOUT_MS). */
|
|
145
|
+
readonly timeoutMs?: number;
|
|
146
|
+
/**
|
|
147
|
+
* Receives a key held at the window's close that never became a reply: a
|
|
148
|
+
* lone ESC (Esc) or ESC ] (Alt+]). Without it they are dropped, which is
|
|
149
|
+
* tolerable for the startup window only; a retry round can open while the
|
|
150
|
+
* user is typing.
|
|
151
|
+
*/
|
|
152
|
+
readonly onFlush?: (bytes: string) => void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Total replies expected: background + foreground + 16 ANSI slots. */
|
|
156
|
+
const EXPECTED_REPLIES = 2 + TERMINAL_PALETTE_ANSI_SLOTS;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Stateful OSC 4 / 10 / 11 reply filter. feed() takes a raw stdin chunk and
|
|
160
|
+
* returns the bytes that continue down the input pipeline. The background
|
|
161
|
+
* probe, when it runs in front of this filter and consumes the OSC 11 reply,
|
|
162
|
+
* hands the reply body over with noteBackgroundSpec().
|
|
163
|
+
*/
|
|
164
|
+
export class TerminalPaletteProbe {
|
|
165
|
+
/** True until resolved. */
|
|
166
|
+
public active = true;
|
|
167
|
+
|
|
168
|
+
private buffer = '';
|
|
169
|
+
private resolved = false;
|
|
170
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
171
|
+
private readonly palette: TerminalPalette = emptyTerminalPalette();
|
|
172
|
+
private backgroundAnswered = false;
|
|
173
|
+
private foregroundAnswered = false;
|
|
174
|
+
private readonly ansiAnswered = new Array<boolean>(TERMINAL_PALETTE_ANSI_SLOTS).fill(false);
|
|
175
|
+
private readonly onResolve: (result: PaletteProbeResolution) => void;
|
|
176
|
+
private readonly onFlush: ((bytes: string) => void) | undefined;
|
|
177
|
+
private readonly timeoutMs: number;
|
|
178
|
+
|
|
179
|
+
constructor(options: TerminalPaletteProbeOptions) {
|
|
180
|
+
this.onResolve = options.onResolve;
|
|
181
|
+
this.onFlush = options.onFlush;
|
|
182
|
+
this.timeoutMs = options.timeoutMs ?? PALETTE_PROBE_TIMEOUT_MS;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Arm the window. Call right after writing the batched query. */
|
|
186
|
+
startTimeout(): void {
|
|
187
|
+
this.timer = setTimeout(() => this.resolve('timeout'), this.timeoutMs);
|
|
188
|
+
(this.timer as unknown as { unref?: () => void }).unref?.();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Record an OSC 11 reply body consumed upstream by the background probe. */
|
|
192
|
+
noteBackgroundSpec(spec: string): void {
|
|
193
|
+
if (!this.active) return;
|
|
194
|
+
this.recordBackground(spec);
|
|
195
|
+
if (this.isComplete()) this.resolve('complete');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Consume a raw stdin chunk; returns the passthrough bytes. */
|
|
199
|
+
feed(chunk: string): string {
|
|
200
|
+
if (!this.active) return chunk;
|
|
201
|
+
this.buffer += chunk;
|
|
202
|
+
let out = '';
|
|
203
|
+
|
|
204
|
+
for (;;) {
|
|
205
|
+
const m = REPLY_INTRODUCER.exec(this.buffer);
|
|
206
|
+
if (m === null) {
|
|
207
|
+
const keep = trailingPartialLen(this.buffer);
|
|
208
|
+
out += this.buffer.slice(0, this.buffer.length - keep);
|
|
209
|
+
this.buffer = keep > 0 ? this.buffer.slice(this.buffer.length - keep) : '';
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
out += this.buffer.slice(0, m.index);
|
|
214
|
+
const bodyStart = m.index + m[0].length;
|
|
215
|
+
const term = findTerminator(this.buffer, bodyStart);
|
|
216
|
+
if (term === null) {
|
|
217
|
+
this.buffer = this.buffer.slice(m.index);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const spec = this.buffer.slice(bodyStart, term.start);
|
|
222
|
+
if (m[1] !== undefined) this.recordAnsi(Number(m[1]), spec);
|
|
223
|
+
else if (m[2] === '10') this.recordForeground(spec);
|
|
224
|
+
else this.recordBackground(spec);
|
|
225
|
+
this.buffer = this.buffer.slice(term.end);
|
|
226
|
+
|
|
227
|
+
if (this.isComplete()) {
|
|
228
|
+
// Keep the post-reply bytes before resolve() clears the buffer.
|
|
229
|
+
const remainder = this.buffer;
|
|
230
|
+
this.resolve('complete');
|
|
231
|
+
out += remainder;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private recordBackground(spec: string): void {
|
|
240
|
+
this.backgroundAnswered = true;
|
|
241
|
+
this.palette.background = parseOscColorToHex(spec) ?? undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private recordForeground(spec: string): void {
|
|
245
|
+
this.foregroundAnswered = true;
|
|
246
|
+
this.palette.foreground = parseOscColorToHex(spec) ?? undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private recordAnsi(index: number, spec: string): void {
|
|
250
|
+
// Replies for slots we never asked about are still consumed, not recorded.
|
|
251
|
+
if (index < 0 || index >= TERMINAL_PALETTE_ANSI_SLOTS) return;
|
|
252
|
+
this.ansiAnswered[index] = true;
|
|
253
|
+
this.palette.ansi[index] = parseOscColorToHex(spec) ?? undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private replyCount(): number {
|
|
257
|
+
return (this.backgroundAnswered ? 1 : 0)
|
|
258
|
+
+ (this.foregroundAnswered ? 1 : 0)
|
|
259
|
+
+ this.ansiAnswered.filter(Boolean).length;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private isComplete(): boolean {
|
|
263
|
+
return this.replyCount() === EXPECTED_REPLIES;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private resolve(reason: PaletteProbeResolution['reason']): void {
|
|
267
|
+
if (this.resolved) return;
|
|
268
|
+
this.resolved = true;
|
|
269
|
+
this.active = false;
|
|
270
|
+
if (this.timer !== null) {
|
|
271
|
+
clearTimeout(this.timer);
|
|
272
|
+
this.timer = null;
|
|
273
|
+
}
|
|
274
|
+
// A held reply fragment is discarded, never flushed into the composer.
|
|
275
|
+
// The two holds that are real keys (Esc, Alt+]) are handed on.
|
|
276
|
+
const held = this.buffer;
|
|
277
|
+
this.buffer = '';
|
|
278
|
+
if (held === '\x1b' || held === '\x1b]') this.onFlush?.(held);
|
|
279
|
+
this.onResolve({
|
|
280
|
+
palette: { ...this.palette, ansi: [...this.palette.ansi] },
|
|
281
|
+
reason,
|
|
282
|
+
replies: this.replyCount(),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Late replies (outside any collection window)
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
/** One reply found by sweepPaletteReplies. */
|
|
292
|
+
export interface PaletteReply {
|
|
293
|
+
readonly slot: 'background' | 'foreground' | number;
|
|
294
|
+
/** `#rrggbb`, or undefined when the body did not parse. */
|
|
295
|
+
readonly hex: string | undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Remove complete OSC 4 / 10 / 11 replies from a chunk that arrived outside a
|
|
300
|
+
* collection window (a reply slower than the window, over ssh or a busy
|
|
301
|
+
* multiplexer). Unlike the probe's own filter this never holds bytes back: a
|
|
302
|
+
* reply split across chunks passes through, so a lone Esc key is never
|
|
303
|
+
* delayed. Replies are complete in one read in practice.
|
|
304
|
+
*/
|
|
305
|
+
export function sweepPaletteReplies(chunk: string): { readonly out: string; readonly replies: PaletteReply[] } {
|
|
306
|
+
if (!chunk.includes('\x1b]')) return { out: chunk, replies: [] };
|
|
307
|
+
const replies: PaletteReply[] = [];
|
|
308
|
+
let rest = chunk;
|
|
309
|
+
let out = '';
|
|
310
|
+
for (;;) {
|
|
311
|
+
const m = REPLY_INTRODUCER.exec(rest);
|
|
312
|
+
if (m === null) break;
|
|
313
|
+
const term = findTerminator(rest, m.index + m[0].length);
|
|
314
|
+
if (term === null) break;
|
|
315
|
+
const hex = parseOscColorToHex(rest.slice(m.index + m[0].length, term.start)) ?? undefined;
|
|
316
|
+
const slot = m[1] !== undefined ? Number(m[1]) : m[2] === '10' ? 'foreground' : 'background';
|
|
317
|
+
if (typeof slot !== 'number' || slot < TERMINAL_PALETTE_ANSI_SLOTS) replies.push({ slot, hex });
|
|
318
|
+
out += rest.slice(0, m.index);
|
|
319
|
+
rest = rest.slice(term.end);
|
|
320
|
+
}
|
|
321
|
+
return { out: out + rest, replies };
|
|
322
|
+
}
|