@oh-my-pi/pi-tui 18.2.3 → 18.2.5
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 +29 -0
- package/README.md +43 -0
- package/dist/types/app-keybindings.d.ts +395 -0
- package/dist/types/apps/autoresearch-dashboard.d.ts +43 -0
- package/dist/types/apps/autoresearch-data.d.ts +15 -0
- package/dist/types/apps/cleanse-board.d.ts +66 -0
- package/dist/types/apps/cleanse-picker.d.ts +23 -0
- package/dist/types/apps/debug/log-formatting.d.ts +6 -0
- package/dist/types/apps/debug/log-viewer.d.ts +81 -0
- package/dist/types/apps/debug/protocol-probe.d.ts +42 -0
- package/dist/types/apps/debug/raw-sse-buffer.d.ts +58 -0
- package/dist/types/apps/debug/raw-sse.d.ts +26 -0
- package/dist/types/apps/debug/terminal-info.d.ts +34 -0
- package/dist/types/apps/debug/viewer-frame.d.ts +52 -0
- package/dist/types/apps/git/avatar.d.ts +9 -0
- package/dist/types/apps/git/colors.d.ts +49 -0
- package/dist/types/apps/git/diff-pane.d.ts +188 -0
- package/dist/types/apps/git/git-tui.d.ts +66 -0
- package/dist/types/apps/git/sidebar.d.ts +149 -0
- package/dist/types/apps/git/state.d.ts +117 -0
- package/dist/types/apps/if-bench-board.d.ts +75 -0
- package/dist/types/apps/live-visualizer.d.ts +33 -0
- package/dist/types/apps/ps-data.d.ts +51 -0
- package/dist/types/apps/ps-top.d.ts +33 -0
- package/dist/types/apps/session-picker.d.ts +26 -0
- package/dist/types/apps/setup-model-picker.d.ts +13 -0
- package/dist/types/apps/standalone-picker.d.ts +42 -0
- package/dist/types/autocomplete.d.ts +4 -0
- package/dist/types/chat/advisor-message.d.ts +11 -0
- package/dist/types/chat/assistant-message.d.ts +106 -0
- package/dist/types/chat/background-tan-message.d.ts +9 -0
- package/dist/types/chat/bash-execution.d.ts +47 -0
- package/dist/types/chat/cache-invalidation-marker.d.ts +50 -0
- package/dist/types/chat/chat-transcript-builder.d.ts +50 -0
- package/dist/types/chat/collab-prompt-message.d.ts +10 -0
- package/dist/types/chat/compaction-summary-message.d.ts +54 -0
- package/dist/types/chat/custom-message.d.ts +10 -0
- package/dist/types/chat/display-preferences.d.ts +13 -0
- package/dist/types/chat/eval-execution.d.ts +29 -0
- package/dist/types/chat/execution-shared.d.ts +51 -0
- package/dist/types/chat/extension-types.d.ts +28 -0
- package/dist/types/chat/hook-message.d.ts +10 -0
- package/dist/types/chat/image-loading.d.ts +46 -0
- package/dist/types/chat/late-diagnostics-message.d.ts +21 -0
- package/dist/types/chat/messages.d.ts +189 -0
- package/dist/types/chat/reaction.d.ts +30 -0
- package/dist/types/chat/read-target.d.ts +4 -0
- package/dist/types/chat/read-tool-group.d.ts +67 -0
- package/dist/types/chat/served-model-marker.d.ts +47 -0
- package/dist/types/chat/skill-message.d.ts +33 -0
- package/dist/types/chat/skill-title-input.d.ts +14 -0
- package/dist/types/chat/stripped-tool-calls-placeholder.d.ts +12 -0
- package/dist/types/chat/thinking-display.d.ts +12 -0
- package/dist/types/chat/todo-reminder.d.ts +13 -0
- package/dist/types/chat/tool-execution.d.ts +138 -0
- package/dist/types/chat/transcript-browser.d.ts +93 -0
- package/dist/types/chat/transcript-entry.d.ts +45 -0
- package/dist/types/chat/transcript-outline.d.ts +86 -0
- package/dist/types/chat/transcript-render-helpers.d.ts +92 -0
- package/dist/types/chat/ttsr-notification.d.ts +23 -0
- package/dist/types/chat/user-message.d.ts +52 -0
- package/dist/types/chrome/chat-block.d.ts +64 -0
- package/dist/types/chrome/collab-qrcode.d.ts +28 -0
- package/dist/types/chrome/context-thresholds.d.ts +10 -0
- package/dist/types/chrome/countdown-timer.d.ts +14 -0
- package/dist/types/chrome/diff.d.ts +14 -0
- package/dist/types/chrome/dynamic-border.d.ts +16 -0
- package/dist/types/chrome/error-block.d.ts +17 -0
- package/dist/types/chrome/form-theme.d.ts +3 -0
- package/dist/types/chrome/format.d.ts +12 -0
- package/dist/types/chrome/index.d.ts +20 -0
- package/dist/types/chrome/keybinding-hints.d.ts +40 -0
- package/dist/types/chrome/live-board.d.ts +23 -0
- package/dist/types/chrome/local-date.d.ts +4 -0
- package/dist/types/chrome/message-divider.d.ts +22 -0
- package/dist/types/chrome/message-frame.d.ts +52 -0
- package/dist/types/chrome/message-notice.d.ts +35 -0
- package/dist/types/chrome/overlay-box.d.ts +74 -0
- package/dist/types/chrome/qrcode.d.ts +48 -0
- package/dist/types/chrome/segment-track.d.ts +27 -0
- package/dist/types/chrome/select-list-mouse-routing.d.ts +4 -0
- package/dist/types/chrome/selector-helpers.d.ts +52 -0
- package/dist/types/chrome/shared.d.ts +5 -0
- package/dist/types/chrome/streaming-panel.d.ts +19 -0
- package/dist/types/chrome/tool-activity.d.ts +17 -0
- package/dist/types/chrome/transcript-container.d.ts +129 -0
- package/dist/types/chrome/transcript-status.d.ts +12 -0
- package/dist/types/chrome/visual-truncate.d.ts +19 -0
- package/dist/types/components/disclosure.d.ts +42 -0
- package/dist/types/components/form.d.ts +130 -0
- package/dist/types/components/image.d.ts +18 -16
- package/dist/types/components/key-value-list.d.ts +24 -0
- package/dist/types/components/layout/geometry.d.ts +73 -0
- package/dist/types/components/layout/row.d.ts +55 -0
- package/dist/types/components/layout/split-pane.d.ts +80 -0
- package/dist/types/components/layout/stack.d.ts +50 -0
- package/dist/types/components/menu-selection.d.ts +71 -0
- package/dist/types/components/metric.d.ts +44 -0
- package/dist/types/components/progress-bar.d.ts +36 -0
- package/dist/types/components/scroll-view.d.ts +53 -19
- package/dist/types/components/scroll-viewport.d.ts +46 -0
- package/dist/types/components/section.d.ts +19 -0
- package/dist/types/components/select-list.d.ts +46 -2
- package/dist/types/components/settings-list.d.ts +20 -0
- package/dist/types/components/table.d.ts +32 -0
- package/dist/types/components/tree-view.d.ts +130 -0
- package/dist/types/components/wizard-step.d.ts +60 -0
- package/dist/types/hotkeys-markdown.d.ts +7 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/keybinding-matchers.d.ts +35 -0
- package/dist/types/keys.d.ts +2 -2
- package/dist/types/lang-from-path.d.ts +9 -0
- package/dist/types/overlays/advisor-config.d.ts +114 -0
- package/dist/types/overlays/agent-activity.d.ts +41 -0
- package/dist/types/overlays/agent-hub-projection.d.ts +33 -0
- package/dist/types/overlays/agent-hub-renderer.d.ts +53 -0
- package/dist/types/overlays/agent-hub-types.d.ts +99 -0
- package/dist/types/overlays/agent-hub.d.ts +126 -0
- package/dist/types/overlays/agent-transcript-viewer.d.ts +71 -0
- package/dist/types/overlays/agents-hub.d.ts +72 -0
- package/dist/types/overlays/ask-dialog.d.ts +88 -0
- package/dist/types/overlays/bordered-loader.d.ts +11 -0
- package/dist/types/overlays/btw-history-panel.d.ts +29 -0
- package/dist/types/overlays/btw-history.d.ts +17 -0
- package/dist/types/overlays/btw-panel.d.ts +26 -0
- package/dist/types/overlays/cleanse-panel.d.ts +36 -0
- package/dist/types/overlays/codex-reset-fireworks.d.ts +52 -0
- package/dist/types/overlays/composer-shape-preview.d.ts +47 -0
- package/dist/types/overlays/composer-shape-registry.d.ts +50 -0
- package/dist/types/overlays/copy-selector.d.ts +48 -0
- package/dist/types/overlays/copy-targets.d.ts +63 -0
- package/dist/types/overlays/error-banner.d.ts +13 -0
- package/dist/types/overlays/extensions/display-text.d.ts +10 -0
- package/dist/types/overlays/extensions/extension-dashboard.d.ts +63 -0
- package/dist/types/overlays/extensions/extension-list.d.ts +54 -0
- package/dist/types/overlays/extensions/inspector-model.d.ts +109 -0
- package/dist/types/overlays/extensions/inspector-panel.d.ts +17 -0
- package/dist/types/overlays/extensions/live-tool-session.d.ts +28 -0
- package/dist/types/overlays/extensions/mcp-runtime.d.ts +107 -0
- package/dist/types/overlays/extensions/state-manager.d.ts +42 -0
- package/dist/types/overlays/extensions/types.d.ts +165 -0
- package/dist/types/overlays/history-search.d.ts +16 -0
- package/dist/types/overlays/hook-editor.d.ts +44 -0
- package/dist/types/overlays/hook-input.d.ts +20 -0
- package/dist/types/overlays/hook-selector.d.ts +71 -0
- package/dist/types/overlays/hub-frame.d.ts +77 -0
- package/dist/types/overlays/login-dialog.d.ts +43 -0
- package/dist/types/overlays/logout-account-selector.d.ts +15 -0
- package/dist/types/overlays/mcp-add-wizard.d.ts +87 -0
- package/dist/types/overlays/model-browser.d.ts +213 -0
- package/dist/types/overlays/model-hub.d.ts +72 -0
- package/dist/types/overlays/model-picker.d.ts +74 -0
- package/dist/types/overlays/model-selector.d.ts +52 -0
- package/dist/types/overlays/move-overlay.d.ts +39 -0
- package/dist/types/overlays/oauth-selector.d.ts +41 -0
- package/dist/types/overlays/omfg-panel.d.ts +20 -0
- package/dist/types/overlays/pause-screen.d.ts +43 -0
- package/dist/types/overlays/plan-review-overlay.d.ts +89 -0
- package/dist/types/overlays/plan-save-overlay.d.ts +19 -0
- package/dist/types/overlays/plan-toc.d.ts +41 -0
- package/dist/types/overlays/plugin-selector.d.ts +28 -0
- package/dist/types/overlays/plugin-settings.d.ts +180 -0
- package/dist/types/overlays/queue-mode-selector.d.ts +11 -0
- package/dist/types/overlays/reset-usage-selector.d.ts +23 -0
- package/dist/types/overlays/rewind-selector.d.ts +61 -0
- package/dist/types/overlays/running-subagent-badge.d.ts +6 -0
- package/dist/types/overlays/session-account-selector.d.ts +24 -0
- package/dist/types/overlays/session-info-overlay.d.ts +17 -0
- package/dist/types/overlays/session-observer-registry.d.ts +77 -0
- package/dist/types/overlays/session-selector.d.ts +131 -0
- package/dist/types/overlays/settings-defs.d.ts +119 -0
- package/dist/types/overlays/settings-selector.d.ts +73 -0
- package/dist/types/overlays/show-images-selector.d.ts +11 -0
- package/dist/types/overlays/snapcompact-shape-preview.d.ts +31 -0
- package/dist/types/overlays/theme-selector.d.ts +12 -0
- package/dist/types/overlays/thinking-selector.d.ts +12 -0
- package/dist/types/overlays/tiny-title-download-progress.d.ts +21 -0
- package/dist/types/overlays/tree-selector.d.ts +92 -0
- package/dist/types/overlays/usage-dashboard.d.ts +94 -0
- package/dist/types/overlays/usage-display.d.ts +5 -0
- package/dist/types/overlays/usage-row.d.ts +17 -0
- package/dist/types/prompt/attachment-chips.d.ts +19 -0
- package/dist/types/prompt/composer-attachments.d.ts +71 -0
- package/dist/types/prompt/composer-cache.d.ts +37 -0
- package/dist/types/prompt/composer.d.ts +159 -0
- package/dist/types/prompt/custom-editor.d.ts +261 -0
- package/dist/types/prompt/editor-top-gap.d.ts +18 -0
- package/dist/types/prompt/emoji-autocomplete.d.ts +16 -0
- package/dist/types/prompt/github-ref-autocomplete.d.ts +35 -0
- package/dist/types/prompt/gradient-highlight.d.ts +32 -0
- package/dist/types/prompt/image-format.d.ts +4 -0
- package/dist/types/prompt/image-references.d.ts +26 -0
- package/dist/types/prompt/interactive-context-helpers.d.ts +43 -0
- package/dist/types/prompt/internal-url-autocomplete.d.ts +56 -0
- package/dist/types/prompt/macos-spelling.d.ts +44 -0
- package/dist/types/prompt/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/prompt/magic-keywords.d.ts +26 -0
- package/dist/types/prompt/markdown-prose.d.ts +15 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +25 -0
- package/dist/types/prompt/model-mention-syntax.d.ts +24 -0
- package/dist/types/prompt/orchestrate.d.ts +13 -0
- package/dist/types/prompt/prompt-action-autocomplete.d.ts +56 -0
- package/dist/types/prompt/queue-input.d.ts +8 -0
- package/dist/types/prompt/skill-tokens.d.ts +13 -0
- package/dist/types/prompt/tools-markdown.d.ts +12 -0
- package/dist/types/prompt/ultrathink.d.ts +13 -0
- package/dist/types/prompt/usage-amounts.d.ts +26 -0
- package/dist/types/prompt/video.d.ts +18 -0
- package/dist/types/prompt/welcome.d.ts +83 -0
- package/dist/types/prompt/workflow.d.ts +13 -0
- package/dist/types/render/code-cell.d.ts +51 -0
- package/dist/types/render/file-list.d.ts +22 -0
- package/dist/types/render/hyperlink.d.ts +80 -0
- package/dist/types/render/index.d.ts +16 -0
- package/dist/types/render/output-block.d.ts +54 -0
- package/dist/types/render/output-pane.d.ts +80 -0
- package/dist/types/render/render-utils.d.ts +312 -0
- package/dist/types/render/sixel.d.ts +21 -0
- package/dist/types/render/status-line.d.ts +23 -0
- package/dist/types/render/tool-card.d.ts +71 -0
- package/dist/types/render/tree-list.d.ts +29 -0
- package/dist/types/render/types.d.ts +17 -0
- package/dist/types/render/utils.d.ts +43 -0
- package/dist/types/render/width-aware-text.d.ts +23 -0
- package/dist/types/setup/lazy.d.ts +3 -0
- package/dist/types/setup/scenes/composer.d.ts +3 -0
- package/dist/types/setup/scenes/glyph.d.ts +3 -0
- package/dist/types/setup/scenes/model.d.ts +3 -0
- package/dist/types/setup/scenes/outro.d.ts +2 -0
- package/dist/types/setup/scenes/providers.d.ts +3 -0
- package/dist/types/setup/scenes/sign-in.d.ts +21 -0
- package/dist/types/setup/scenes/splash.d.ts +11 -0
- package/dist/types/setup/scenes/theme.d.ts +3 -0
- package/dist/types/setup/scenes/types.d.ts +110 -0
- package/dist/types/setup/scenes/web-search.d.ts +22 -0
- package/dist/types/setup/setup-version.d.ts +11 -0
- package/dist/types/setup/startup-splash.d.ts +9 -0
- package/dist/types/setup/wizard-overlay.d.ts +16 -0
- package/dist/types/setup/wizard.d.ts +25 -0
- package/dist/types/status-line/component.d.ts +182 -0
- package/dist/types/status-line/context-usage.d.ts +156 -0
- package/dist/types/status-line/footer.d.ts +30 -0
- package/dist/types/status-line/git-utils.d.ts +22 -0
- package/dist/types/status-line/host.d.ts +129 -0
- package/dist/types/status-line/index.d.ts +6 -0
- package/dist/types/status-line/loop.d.ts +18 -0
- package/dist/types/status-line/metrics.d.ts +21 -0
- package/dist/types/status-line/presets.d.ts +3 -0
- package/dist/types/status-line/schema.d.ts +15 -0
- package/dist/types/status-line/segments.d.ts +5 -0
- package/dist/types/status-line/separators.d.ts +3 -0
- package/dist/types/status-line/types.d.ts +236 -0
- package/dist/types/stdin-buffer.d.ts +4 -0
- package/dist/types/theme/color.d.ts +24 -0
- package/dist/types/theme/defaults/index.d.ts +9489 -0
- package/dist/types/theme/index.d.ts +4 -0
- package/dist/types/theme/loader.d.ts +23 -0
- package/dist/types/theme/mermaid-cache.d.ts +26 -0
- package/dist/types/theme/schema-validation.d.ts +3 -0
- package/dist/types/theme/schema.d.ts +25 -0
- package/dist/types/theme/session-color.d.ts +43 -0
- package/dist/types/theme/shimmer.d.ts +44 -0
- package/dist/types/theme/symbols.d.ts +34 -0
- package/dist/types/theme/theme-class.d.ts +299 -0
- package/dist/types/theme/theme.d.ts +111 -0
- package/dist/types/theme/tui-adapters.d.ts +30 -0
- package/dist/types/thinking.d.ts +165 -0
- package/dist/types/tools/agent-tree.d.ts +36 -0
- package/dist/types/tools/ask.d.ts +67 -0
- package/dist/types/tools/ast-edit.d.ts +53 -0
- package/dist/types/tools/ast-grep.d.ts +52 -0
- package/dist/types/tools/autoresearch.d.ts +156 -0
- package/dist/types/tools/bash-interactive.d.ts +28 -0
- package/dist/types/tools/bash.d.ts +103 -0
- package/dist/types/tools/conflict-detect.d.ts +43 -0
- package/dist/types/tools/debug.d.ts +60 -0
- package/dist/types/tools/default-renderer.d.ts +24 -0
- package/dist/types/tools/edit.d.ts +158 -0
- package/dist/types/tools/eval-format/code-mode-declarations.d.ts +5 -0
- package/dist/types/tools/eval-format/index.d.ts +5 -0
- package/dist/types/tools/eval-format/javascript.d.ts +2 -0
- package/dist/types/tools/eval-format/python.d.ts +2 -0
- package/dist/types/tools/eval.d.ts +94 -0
- package/dist/types/tools/fetch.d.ts +42 -0
- package/dist/types/tools/gh-format.d.ts +8 -0
- package/dist/types/tools/github.d.ts +144 -0
- package/dist/types/tools/glob.d.ts +44 -0
- package/dist/types/tools/goal.d.ts +42 -0
- package/dist/types/tools/grep.d.ts +64 -0
- package/dist/types/tools/grouped-file-output.d.ts +71 -0
- package/dist/types/tools/hashline-format.d.ts +28 -0
- package/dist/types/tools/hub.d.ts +340 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/json-tree.d.ts +59 -0
- package/dist/types/tools/line-ranges.d.ts +29 -0
- package/dist/types/tools/list-limit.d.ts +15 -0
- package/dist/types/tools/lsp.d.ts +72 -0
- package/dist/types/tools/match-line-format.d.ts +11 -0
- package/dist/types/tools/mcp.d.ts +81 -0
- package/dist/types/tools/memory.d.ts +67 -0
- package/dist/types/tools/output-meta.d.ts +151 -0
- package/dist/types/tools/read.d.ts +139 -0
- package/dist/types/tools/renderer.d.ts +98 -0
- package/dist/types/tools/report-tool-issue.d.ts +8 -0
- package/dist/types/tools/resolve.d.ts +47 -0
- package/dist/types/tools/streaming-output.d.ts +282 -0
- package/dist/types/tools/subprocess.d.ts +24 -0
- package/dist/types/tools/task-repair-args.d.ts +53 -0
- package/dist/types/tools/task-yield-assembly.d.ts +20 -0
- package/dist/types/tools/task.d.ts +425 -0
- package/dist/types/tools/terminal-output.d.ts +8 -0
- package/dist/types/tools/think.d.ts +13 -0
- package/dist/types/tools/todo.d.ts +118 -0
- package/dist/types/tools/tool-errors.d.ts +7 -0
- package/dist/types/tools/vibe.d.ts +120 -0
- package/dist/types/tools/web-search.d.ts +195 -0
- package/dist/types/tools/write.d.ts +76 -0
- package/dist/types/tools/xd-url.d.ts +13 -0
- package/dist/types/tools/xdev.d.ts +52 -0
- package/dist/types/tui.d.ts +18 -0
- package/dist/types/utils.d.ts +2 -0
- package/package.json +37 -3
- package/src/app-keybindings.ts +760 -0
- package/src/apps/autoresearch-dashboard.ts +500 -0
- package/src/apps/autoresearch-data.ts +64 -0
- package/src/apps/cleanse-board.ts +341 -0
- package/src/apps/cleanse-picker.ts +57 -0
- package/src/apps/debug/log-formatting.ts +53 -0
- package/src/apps/debug/log-viewer.ts +868 -0
- package/src/apps/debug/protocol-probe.ts +267 -0
- package/src/apps/debug/raw-sse-buffer.ts +449 -0
- package/src/apps/debug/raw-sse.ts +282 -0
- package/src/apps/debug/terminal-info.ts +115 -0
- package/src/apps/debug/viewer-frame.ts +174 -0
- package/src/apps/git/avatar.ts +52 -0
- package/src/apps/git/colors.ts +109 -0
- package/src/apps/git/diff-pane.ts +1512 -0
- package/src/apps/git/git-tui.ts +889 -0
- package/src/apps/git/sidebar.ts +1204 -0
- package/src/apps/git/state.ts +113 -0
- package/src/apps/if-bench-board.ts +324 -0
- package/src/apps/live-visualizer.ts +238 -0
- package/src/apps/ps-data.ts +116 -0
- package/src/apps/ps-top.ts +422 -0
- package/src/apps/session-picker.ts +95 -0
- package/src/apps/setup-model-picker.ts +21 -0
- package/src/apps/standalone-picker.ts +111 -0
- package/src/autocomplete.ts +9 -13
- package/src/chat/advisor-message.ts +175 -0
- package/src/chat/assistant-message.ts +1214 -0
- package/src/chat/background-tan-message.ts +35 -0
- package/src/chat/bash-execution.ts +394 -0
- package/src/chat/cache-invalidation-marker.ts +94 -0
- package/src/chat/chat-transcript-builder.ts +593 -0
- package/src/chat/collab-prompt-message.ts +34 -0
- package/src/chat/compaction-summary-message.ts +261 -0
- package/src/chat/custom-message.ts +23 -0
- package/src/chat/display-preferences.ts +24 -0
- package/src/chat/eval-execution.ts +172 -0
- package/src/chat/execution-shared.ts +113 -0
- package/src/chat/extension-types.ts +46 -0
- package/src/chat/hook-message.ts +20 -0
- package/src/chat/image-loading.ts +122 -0
- package/src/chat/late-diagnostics-message.ts +92 -0
- package/src/chat/messages.ts +305 -0
- package/src/chat/reaction.ts +65 -0
- package/src/chat/read-target.ts +7 -0
- package/src/chat/read-tool-group.ts +873 -0
- package/src/chat/served-model-marker.ts +91 -0
- package/src/chat/skill-message.ts +220 -0
- package/src/chat/skill-title-input.ts +41 -0
- package/src/chat/stripped-tool-calls-placeholder.ts +35 -0
- package/src/chat/thinking-display.ts +312 -0
- package/src/chat/todo-reminder.ts +33 -0
- package/src/chat/tool-execution.ts +1395 -0
- package/src/chat/transcript-browser.ts +268 -0
- package/src/chat/transcript-entry.ts +96 -0
- package/src/chat/transcript-outline.ts +260 -0
- package/src/chat/transcript-render-helpers.ts +315 -0
- package/src/chat/ttsr-notification.ts +123 -0
- package/src/chat/user-message.ts +287 -0
- package/src/chrome/chat-block.ts +110 -0
- package/src/chrome/collab-qrcode.ts +74 -0
- package/src/chrome/context-thresholds.ts +85 -0
- package/src/chrome/countdown-timer.ts +74 -0
- package/src/chrome/diff.ts +266 -0
- package/src/chrome/dynamic-border.ts +36 -0
- package/src/chrome/error-block.ts +63 -0
- package/src/chrome/form-theme.ts +10 -0
- package/src/chrome/format.ts +66 -0
- package/src/chrome/index.ts +21 -0
- package/src/chrome/keybinding-hints.ts +55 -0
- package/src/chrome/live-board.ts +123 -0
- package/src/chrome/local-date.ts +20 -0
- package/src/chrome/message-divider.ts +51 -0
- package/src/chrome/message-frame.ts +164 -0
- package/src/chrome/message-notice.ts +103 -0
- package/src/chrome/overlay-box.ts +301 -0
- package/src/chrome/qrcode.ts +539 -0
- package/src/chrome/segment-track.ts +86 -0
- package/src/chrome/select-list-mouse-routing.ts +11 -0
- package/src/chrome/selector-helpers.ts +119 -0
- package/src/chrome/shared.ts +27 -0
- package/src/chrome/streaming-panel.ts +72 -0
- package/src/chrome/tool-activity.ts +44 -0
- package/src/chrome/transcript-container.ts +902 -0
- package/src/chrome/transcript-status.ts +27 -0
- package/src/chrome/visual-truncate.ts +70 -0
- package/src/components/disclosure.ts +235 -0
- package/src/components/editor.ts +10 -12
- package/src/components/form.ts +566 -0
- package/src/components/image.ts +80 -53
- package/src/components/input.ts +6 -27
- package/src/components/key-value-list.ts +88 -0
- package/src/components/layout/geometry.ts +202 -0
- package/src/components/layout/row.ts +283 -0
- package/src/components/layout/split-pane.ts +349 -0
- package/src/components/layout/stack.ts +261 -0
- package/src/components/markdown.ts +16 -12
- package/src/components/menu-selection.ts +248 -0
- package/src/components/metric.ts +132 -0
- package/src/components/progress-bar.ts +140 -0
- package/src/components/scroll-view.ts +372 -70
- package/src/components/scroll-viewport.ts +154 -0
- package/src/components/section.ts +77 -0
- package/src/components/select-list.ts +238 -159
- package/src/components/settings-list.ts +103 -80
- package/src/components/table.ts +127 -0
- package/src/components/tree-view.ts +481 -0
- package/src/components/wizard-step.ts +229 -0
- package/src/deccara.ts +1 -3
- package/src/hotkeys-markdown.ts +100 -0
- package/src/index.ts +18 -0
- package/src/keybinding-matchers.ts +99 -0
- package/src/keys.ts +2 -2
- package/src/lang-from-path.ts +252 -0
- package/src/overlays/advisor-config.ts +761 -0
- package/src/overlays/agent-activity.ts +113 -0
- package/src/overlays/agent-hub-projection.ts +250 -0
- package/src/overlays/agent-hub-renderer.ts +281 -0
- package/src/overlays/agent-hub-types.ts +74 -0
- package/src/overlays/agent-hub.ts +1562 -0
- package/src/overlays/agent-transcript-viewer.ts +669 -0
- package/src/overlays/agents-hub.ts +1203 -0
- package/src/overlays/ask-dialog.ts +1202 -0
- package/src/overlays/bordered-loader.ts +41 -0
- package/src/overlays/btw-history-panel.ts +591 -0
- package/src/overlays/btw-history.ts +33 -0
- package/src/overlays/btw-panel.ts +167 -0
- package/src/overlays/cleanse-panel.ts +167 -0
- package/src/overlays/codex-reset-fireworks.ts +375 -0
- package/src/overlays/composer-shape-preview.ts +137 -0
- package/src/overlays/composer-shape-registry.ts +82 -0
- package/src/overlays/copy-selector.ts +618 -0
- package/src/overlays/copy-targets.ts +210 -0
- package/src/overlays/error-banner.ts +39 -0
- package/src/overlays/extensions/display-text.ts +39 -0
- package/src/overlays/extensions/extension-dashboard.ts +645 -0
- package/src/overlays/extensions/extension-list.ts +667 -0
- package/src/overlays/extensions/inspector-model.ts +484 -0
- package/src/overlays/extensions/inspector-panel.ts +669 -0
- package/src/overlays/extensions/live-tool-session.ts +67 -0
- package/src/overlays/extensions/mcp-runtime.ts +289 -0
- package/src/overlays/extensions/state-manager.ts +337 -0
- package/src/overlays/extensions/types.ts +208 -0
- package/src/overlays/history-search.ts +268 -0
- package/src/overlays/hook-editor.ts +272 -0
- package/src/overlays/hook-input.ts +84 -0
- package/src/overlays/hook-selector.ts +635 -0
- package/src/overlays/hub-frame.ts +262 -0
- package/src/overlays/login-dialog.ts +231 -0
- package/src/overlays/logout-account-selector.ts +115 -0
- package/src/overlays/mcp-add-wizard.ts +1436 -0
- package/src/overlays/model-browser.ts +1176 -0
- package/src/overlays/model-hub.ts +2279 -0
- package/src/overlays/model-picker.ts +285 -0
- package/src/overlays/model-selector.ts +107 -0
- package/src/overlays/move-overlay.ts +197 -0
- package/src/overlays/oauth-selector.ts +446 -0
- package/src/overlays/omfg-panel.ts +136 -0
- package/src/overlays/pause-screen.ts +198 -0
- package/src/overlays/plan-review-overlay.ts +1227 -0
- package/src/overlays/plan-save-overlay.ts +74 -0
- package/src/overlays/plan-toc.ts +139 -0
- package/src/overlays/plugin-selector.ts +97 -0
- package/src/overlays/plugin-settings.ts +839 -0
- package/src/overlays/queue-mode-selector.ts +55 -0
- package/src/overlays/reset-usage-selector.ts +170 -0
- package/src/overlays/rewind-selector.ts +499 -0
- package/src/overlays/running-subagent-badge.ts +19 -0
- package/src/overlays/session-account-selector.ts +68 -0
- package/src/overlays/session-info-overlay.ts +96 -0
- package/src/overlays/session-observer-registry.ts +289 -0
- package/src/overlays/session-selector.ts +1137 -0
- package/src/overlays/settings-defs.ts +315 -0
- package/src/overlays/settings-selector.ts +1415 -0
- package/src/overlays/show-images-selector.ts +44 -0
- package/src/overlays/snapcompact-shape-preview-doc.md +14 -0
- package/src/overlays/snapcompact-shape-preview.ts +192 -0
- package/src/overlays/theme-selector.ts +62 -0
- package/src/overlays/thinking-selector.ts +51 -0
- package/src/overlays/tiny-title-download-progress.ts +102 -0
- package/src/overlays/tree-selector.ts +1121 -0
- package/src/overlays/usage-dashboard.ts +629 -0
- package/src/overlays/usage-display.ts +45 -0
- package/src/overlays/usage-row.ts +118 -0
- package/src/prompt/attachment-chips.ts +201 -0
- package/src/prompt/composer-attachments.ts +280 -0
- package/src/prompt/composer-cache.ts +304 -0
- package/src/prompt/composer.ts +934 -0
- package/src/prompt/custom-editor.ts +1392 -0
- package/src/prompt/data/emojis.json +1 -0
- package/src/prompt/editor-top-gap.ts +28 -0
- package/src/prompt/emoji-autocomplete.ts +285 -0
- package/src/prompt/github-ref-autocomplete.ts +75 -0
- package/src/prompt/gradient-highlight.ts +101 -0
- package/src/prompt/image-format.ts +28 -0
- package/src/prompt/image-references.ts +102 -0
- package/src/prompt/interactive-context-helpers.ts +126 -0
- package/src/prompt/internal-url-autocomplete.ts +155 -0
- package/src/prompt/macos-spelling.ts +456 -0
- package/src/prompt/magic-keyword-boundary.ts +23 -0
- package/src/prompt/magic-keywords.ts +42 -0
- package/src/prompt/markdown-prose.ts +245 -0
- package/src/prompt/model-mention-autocomplete.ts +94 -0
- package/src/prompt/model-mention-syntax.ts +41 -0
- package/src/prompt/orchestrate.ts +26 -0
- package/src/prompt/prompt-action-autocomplete.ts +337 -0
- package/src/prompt/queue-input.ts +132 -0
- package/src/prompt/skill-tokens.ts +36 -0
- package/src/prompt/tips.txt +27 -0
- package/src/prompt/tools-markdown.ts +29 -0
- package/src/prompt/ultrathink.ts +26 -0
- package/src/prompt/usage-amounts.ts +99 -0
- package/src/prompt/video.ts +47 -0
- package/src/prompt/welcome.ts +605 -0
- package/src/prompt/workflow.ts +26 -0
- package/src/render/code-cell.ts +244 -0
- package/src/render/file-list.ts +58 -0
- package/src/render/hyperlink.ts +192 -0
- package/src/render/index.ts +17 -0
- package/src/render/output-block.ts +267 -0
- package/src/render/output-pane.ts +299 -0
- package/src/render/render-utils.ts +1182 -0
- package/src/render/sixel.ts +69 -0
- package/src/render/status-line.ts +55 -0
- package/src/render/tool-card.ts +272 -0
- package/src/render/tree-list.ts +188 -0
- package/src/render/types.ts +19 -0
- package/src/render/utils.ts +109 -0
- package/src/render/width-aware-text.ts +60 -0
- package/src/setup/lazy.ts +17 -0
- package/src/setup/scenes/composer.ts +127 -0
- package/src/setup/scenes/glyph.ts +120 -0
- package/src/setup/scenes/model.ts +138 -0
- package/src/setup/scenes/outro.ts +24 -0
- package/src/setup/scenes/providers.ts +107 -0
- package/src/setup/scenes/sign-in.ts +373 -0
- package/src/setup/scenes/splash.ts +192 -0
- package/src/setup/scenes/theme.ts +343 -0
- package/src/setup/scenes/types.ts +111 -0
- package/src/setup/scenes/web-search.ts +165 -0
- package/src/setup/setup-version.ts +11 -0
- package/src/setup/startup-splash.ts +107 -0
- package/src/setup/wizard-overlay.ts +319 -0
- package/src/setup/wizard.ts +101 -0
- package/src/status-line/component.ts +3003 -0
- package/src/status-line/context-usage.ts +688 -0
- package/src/status-line/footer.ts +323 -0
- package/src/status-line/git-utils.ts +42 -0
- package/src/status-line/host.ts +97 -0
- package/src/status-line/index.ts +6 -0
- package/src/status-line/loop.ts +28 -0
- package/src/status-line/metrics.ts +94 -0
- package/src/status-line/presets.ts +107 -0
- package/src/status-line/schema.ts +57 -0
- package/src/status-line/segments.ts +934 -0
- package/src/status-line/separators.ts +55 -0
- package/src/status-line/types.ts +227 -0
- package/src/stdin-buffer.ts +4 -2
- package/src/theme/color.ts +95 -0
- package/src/theme/dark.json +95 -0
- package/src/theme/defaults/alabaster.json +93 -0
- package/src/theme/defaults/amethyst.json +96 -0
- package/src/theme/defaults/anthracite.json +93 -0
- package/src/theme/defaults/basalt.json +91 -0
- package/src/theme/defaults/birch.json +95 -0
- package/src/theme/defaults/dark-abyss.json +91 -0
- package/src/theme/defaults/dark-arctic.json +104 -0
- package/src/theme/defaults/dark-aurora.json +95 -0
- package/src/theme/defaults/dark-catppuccin.json +107 -0
- package/src/theme/defaults/dark-cavern.json +91 -0
- package/src/theme/defaults/dark-celestial.json +106 -0
- package/src/theme/defaults/dark-copper.json +95 -0
- package/src/theme/defaults/dark-cosmos.json +90 -0
- package/src/theme/defaults/dark-cyberpunk.json +102 -0
- package/src/theme/defaults/dark-dracula.json +98 -0
- package/src/theme/defaults/dark-eclipse.json +91 -0
- package/src/theme/defaults/dark-ember.json +95 -0
- package/src/theme/defaults/dark-equinox.json +90 -0
- package/src/theme/defaults/dark-forest.json +96 -0
- package/src/theme/defaults/dark-github.json +105 -0
- package/src/theme/defaults/dark-gruvbox.json +112 -0
- package/src/theme/defaults/dark-lavender.json +95 -0
- package/src/theme/defaults/dark-lunar.json +89 -0
- package/src/theme/defaults/dark-midnight.json +95 -0
- package/src/theme/defaults/dark-monochrome.json +94 -0
- package/src/theme/defaults/dark-monokai.json +98 -0
- package/src/theme/defaults/dark-nebula.json +90 -0
- package/src/theme/defaults/dark-nord.json +98 -0
- package/src/theme/defaults/dark-ocean.json +101 -0
- package/src/theme/defaults/dark-one.json +100 -0
- package/src/theme/defaults/dark-poimandres.json +115 -0
- package/src/theme/defaults/dark-rainforest.json +91 -0
- package/src/theme/defaults/dark-reef.json +91 -0
- package/src/theme/defaults/dark-retro.json +92 -0
- package/src/theme/defaults/dark-rose-pine.json +96 -0
- package/src/theme/defaults/dark-sakura.json +95 -0
- package/src/theme/defaults/dark-slate.json +95 -0
- package/src/theme/defaults/dark-solarized.json +97 -0
- package/src/theme/defaults/dark-solstice.json +90 -0
- package/src/theme/defaults/dark-starfall.json +91 -0
- package/src/theme/defaults/dark-sunset.json +99 -0
- package/src/theme/defaults/dark-swamp.json +90 -0
- package/src/theme/defaults/dark-synthwave.json +103 -0
- package/src/theme/defaults/dark-taiga.json +91 -0
- package/src/theme/defaults/dark-terminal.json +95 -0
- package/src/theme/defaults/dark-tokyo-night.json +101 -0
- package/src/theme/defaults/dark-tundra.json +91 -0
- package/src/theme/defaults/dark-twilight.json +91 -0
- package/src/theme/defaults/dark-volcanic.json +91 -0
- package/src/theme/defaults/graphite.json +92 -0
- package/src/theme/defaults/index.ts +201 -0
- package/src/theme/defaults/light-arctic.json +107 -0
- package/src/theme/defaults/light-aurora-day.json +91 -0
- package/src/theme/defaults/light-canyon.json +91 -0
- package/src/theme/defaults/light-catppuccin.json +106 -0
- package/src/theme/defaults/light-cirrus.json +90 -0
- package/src/theme/defaults/light-coral.json +95 -0
- package/src/theme/defaults/light-cyberpunk.json +96 -0
- package/src/theme/defaults/light-dawn.json +90 -0
- package/src/theme/defaults/light-dunes.json +91 -0
- package/src/theme/defaults/light-eucalyptus.json +95 -0
- package/src/theme/defaults/light-forest.json +100 -0
- package/src/theme/defaults/light-frost.json +95 -0
- package/src/theme/defaults/light-github.json +115 -0
- package/src/theme/defaults/light-glacier.json +91 -0
- package/src/theme/defaults/light-gruvbox.json +108 -0
- package/src/theme/defaults/light-haze.json +90 -0
- package/src/theme/defaults/light-honeycomb.json +95 -0
- package/src/theme/defaults/light-lagoon.json +91 -0
- package/src/theme/defaults/light-lavender.json +95 -0
- package/src/theme/defaults/light-meadow.json +91 -0
- package/src/theme/defaults/light-mint.json +95 -0
- package/src/theme/defaults/light-monochrome.json +101 -0
- package/src/theme/defaults/light-ocean.json +99 -0
- package/src/theme/defaults/light-one.json +99 -0
- package/src/theme/defaults/light-opal.json +91 -0
- package/src/theme/defaults/light-orchard.json +91 -0
- package/src/theme/defaults/light-paper.json +95 -0
- package/src/theme/defaults/light-poimandres.json +115 -0
- package/src/theme/defaults/light-prism.json +90 -0
- package/src/theme/defaults/light-retro.json +98 -0
- package/src/theme/defaults/light-sand.json +95 -0
- package/src/theme/defaults/light-savanna.json +91 -0
- package/src/theme/defaults/light-solarized.json +102 -0
- package/src/theme/defaults/light-soleil.json +90 -0
- package/src/theme/defaults/light-sunset.json +99 -0
- package/src/theme/defaults/light-synthwave.json +98 -0
- package/src/theme/defaults/light-tokyo-night.json +111 -0
- package/src/theme/defaults/light-wetland.json +91 -0
- package/src/theme/defaults/light-zenith.json +89 -0
- package/src/theme/defaults/limestone.json +94 -0
- package/src/theme/defaults/mahogany.json +97 -0
- package/src/theme/defaults/marble.json +93 -0
- package/src/theme/defaults/obsidian.json +91 -0
- package/src/theme/defaults/onyx.json +91 -0
- package/src/theme/defaults/pearl.json +93 -0
- package/src/theme/defaults/porcelain.json +91 -0
- package/src/theme/defaults/quartz.json +96 -0
- package/src/theme/defaults/sandstone.json +95 -0
- package/src/theme/defaults/titanium.json +90 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/light.json +93 -0
- package/src/theme/loader.ts +200 -0
- package/src/theme/mermaid-cache.ts +92 -0
- package/src/theme/schema-validation.ts +119 -0
- package/src/theme/schema.ts +168 -0
- package/src/theme/session-color.ts +253 -0
- package/src/theme/shimmer.ts +307 -0
- package/src/theme/symbols.ts +1433 -0
- package/src/theme/theme-class.ts +787 -0
- package/src/theme/theme-schema.json +463 -0
- package/src/theme/theme.ts +810 -0
- package/src/theme/tui-adapters.ts +339 -0
- package/src/thinking.ts +380 -0
- package/src/tools/agent-tree.ts +128 -0
- package/src/tools/ask.ts +482 -0
- package/src/tools/ast-edit.ts +210 -0
- package/src/tools/ast-grep.ts +171 -0
- package/src/tools/autoresearch.ts +287 -0
- package/src/tools/bash-interactive.ts +295 -0
- package/src/tools/bash.ts +468 -0
- package/src/tools/conflict-detect.ts +77 -0
- package/src/tools/debug.ts +167 -0
- package/src/tools/default-renderer.ts +172 -0
- package/src/tools/edit.ts +1258 -0
- package/src/tools/eval-format/code-mode-declarations.ts +61 -0
- package/src/tools/eval-format/index.ts +16 -0
- package/src/tools/eval-format/javascript.ts +952 -0
- package/src/tools/eval-format/python.ts +544 -0
- package/src/tools/eval.ts +835 -0
- package/src/tools/fetch.ts +226 -0
- package/src/tools/gh-format.ts +18 -0
- package/src/tools/github.ts +686 -0
- package/src/tools/glob.ts +213 -0
- package/src/tools/goal.ts +153 -0
- package/src/tools/grep.ts +385 -0
- package/src/tools/grouped-file-output.ts +211 -0
- package/src/tools/hashline-format.ts +59 -0
- package/src/tools/hub.ts +1488 -0
- package/src/tools/index.ts +71 -0
- package/src/tools/json-tree.ts +388 -0
- package/src/tools/line-ranges.ts +98 -0
- package/src/tools/list-limit.ts +43 -0
- package/src/tools/lsp.ts +702 -0
- package/src/tools/match-line-format.ts +20 -0
- package/src/tools/mcp.ts +309 -0
- package/src/tools/memory.ts +220 -0
- package/src/tools/output-meta.ts +367 -0
- package/src/tools/read.ts +628 -0
- package/src/tools/renderer.ts +107 -0
- package/src/tools/report-tool-issue.ts +13 -0
- package/src/tools/resolve.ts +139 -0
- package/src/tools/streaming-output.ts +1576 -0
- package/src/tools/subprocess.ts +39 -0
- package/src/tools/task-repair-args.ts +118 -0
- package/src/tools/task-yield-assembly.ts +147 -0
- package/src/tools/task.ts +2110 -0
- package/src/tools/terminal-output.ts +153 -0
- package/src/tools/think.ts +28 -0
- package/src/tools/todo.ts +580 -0
- package/src/tools/tool-errors.ts +15 -0
- package/src/tools/vibe.ts +420 -0
- package/src/tools/web-search.ts +409 -0
- package/src/tools/write.ts +511 -0
- package/src/tools/xd-url.ts +28 -0
- package/src/tools/xdev.ts +201 -0
- package/src/tui.ts +70 -8
- package/src/utils.ts +8 -0
|
@@ -0,0 +1,3003 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
3
|
+
import type { AssistantMessage, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
|
|
4
|
+
import {
|
|
5
|
+
getAntigravityCounterKeyForModel,
|
|
6
|
+
scopeAntigravityLimitsForModel,
|
|
7
|
+
} from "@oh-my-pi/pi-ai/usage/google-antigravity";
|
|
8
|
+
import { getNextTimeBasedPricingTransition } from "@oh-my-pi/pi-catalog/models";
|
|
9
|
+
import type { Model, ModelCost } from "@oh-my-pi/pi-catalog/types";
|
|
10
|
+
import type { VcsRepo } from "@oh-my-pi/pi-natives";
|
|
11
|
+
import * as vcs from "@oh-my-pi/pi-natives/vcs";
|
|
12
|
+
import {
|
|
13
|
+
type Component,
|
|
14
|
+
type ComposerStyle,
|
|
15
|
+
claudeComposerStyle,
|
|
16
|
+
padding,
|
|
17
|
+
SPINNER_ADVANCE_MS,
|
|
18
|
+
truncateToWidth,
|
|
19
|
+
visibleWidth,
|
|
20
|
+
} from "../index";
|
|
21
|
+
import { adjustHsv, formatNumber, getProjectDir, hexToRgb, rgbToHex } from "@oh-my-pi/pi-utils";
|
|
22
|
+
import type {
|
|
23
|
+
ActiveRepoContext,
|
|
24
|
+
StatusAccountIdentity as OAuthAccountIdentity,
|
|
25
|
+
StatusLineHost,
|
|
26
|
+
StatusLineSession,
|
|
27
|
+
} from "./host";
|
|
28
|
+
import { getSessionAccentAnsi, getSessionAccentHex } from "../theme/session-color";
|
|
29
|
+
import { sanitizeStatusText } from "../chrome/shared";
|
|
30
|
+
import { getThemeEpoch, theme } from "../theme";
|
|
31
|
+
import { type CompactionBoundaries, EMPTY_STRING_PARTS, getToolSchemaMetadataRevision } from "./context-usage";
|
|
32
|
+
import {
|
|
33
|
+
type CodexResetFireworksEvent,
|
|
34
|
+
type CodexResetUsageSnapshot,
|
|
35
|
+
detectCodexResetFireworks,
|
|
36
|
+
} from "../overlays/codex-reset-fireworks";
|
|
37
|
+
import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils";
|
|
38
|
+
import { getPreset } from "./presets";
|
|
39
|
+
import { renderSegment, type SegmentContext } from "./segments";
|
|
40
|
+
import { getSeparator } from "./separators";
|
|
41
|
+
import type {
|
|
42
|
+
CollabStatus,
|
|
43
|
+
EffectiveStatusLineSettings,
|
|
44
|
+
StatusLineSegmentId,
|
|
45
|
+
StatusLineSegmentOptions,
|
|
46
|
+
StatusLineSettings,
|
|
47
|
+
} from "./types";
|
|
48
|
+
|
|
49
|
+
const JJ_REFRESH_TTL_MS = 5000;
|
|
50
|
+
const JJ_COMMAND_TIMEOUT_MS = 5_000;
|
|
51
|
+
const WATCHER_FAILURE_POLL_TTL_MS = 5000;
|
|
52
|
+
/** Brand-color fade duration across working-state edges (rust omp's `BRAND_FADE`). */
|
|
53
|
+
const BRAND_FADE_MS = 450;
|
|
54
|
+
/** Repaint cadence while the brand fade is in flight (rust omp's `FADE_FRAME`). */
|
|
55
|
+
const BRAND_FADE_FRAME_MS = 40;
|
|
56
|
+
|
|
57
|
+
/** A displayable limit after provider, account, model, and window filtering. */
|
|
58
|
+
interface UsageWindowCandidate {
|
|
59
|
+
id?: string;
|
|
60
|
+
windowClass: "5h" | "daily" | "7d" | "monthly";
|
|
61
|
+
fraction: number;
|
|
62
|
+
resetsAt?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Limits sharing one model/tier scope, ranked as an indivisible display unit. */
|
|
66
|
+
interface UsageScopeGroup {
|
|
67
|
+
priority: number;
|
|
68
|
+
tier?: string;
|
|
69
|
+
candidates: UsageWindowCandidate[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeUsageScopeValue(value: unknown): string | undefined {
|
|
73
|
+
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fireworks are stateful, so their report match must be stricter than the
|
|
78
|
+
* status display's fallback matching: every known credential identifier must
|
|
79
|
+
* be present and equal or a workspace sibling can mutate this account's
|
|
80
|
+
* baseline.
|
|
81
|
+
*/
|
|
82
|
+
function codexReportMatchesExactIdentity(report: UsageReport, identity: OAuthAccountIdentity | undefined): boolean {
|
|
83
|
+
if (!identity) return false;
|
|
84
|
+
const accountId = normalizeUsageScopeValue(identity.accountId);
|
|
85
|
+
const email = normalizeUsageScopeValue(identity.email);
|
|
86
|
+
const projectId = normalizeUsageScopeValue(identity.projectId);
|
|
87
|
+
const orgId = normalizeUsageScopeValue(identity.orgId);
|
|
88
|
+
if (!accountId && !email && !projectId && !orgId) return false;
|
|
89
|
+
|
|
90
|
+
const metadata = report.metadata ?? {};
|
|
91
|
+
const reportAccountId =
|
|
92
|
+
normalizeUsageScopeValue(metadata.accountId) ?? normalizeUsageScopeValue(metadata.account_id);
|
|
93
|
+
const reportProjectId =
|
|
94
|
+
normalizeUsageScopeValue(metadata.projectId) ?? normalizeUsageScopeValue(metadata.project_id);
|
|
95
|
+
if (accountId && reportAccountId !== accountId) return false;
|
|
96
|
+
if (email && normalizeUsageScopeValue(metadata.email) !== email) return false;
|
|
97
|
+
if (projectId && reportProjectId !== projectId) return false;
|
|
98
|
+
if (orgId && normalizeUsageScopeValue(metadata.orgId) !== orgId) return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
103
|
+
// Context-usage memo
|
|
104
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Cheap structural fingerprint of a message's tokenizable content. O(blocks) —
|
|
108
|
+
* only reads string `.length` and primitives, never copies or serializes.
|
|
109
|
+
* Detects in-place growth of the streaming tail (and other in-place mutations)
|
|
110
|
+
* so the cached `getContextUsage()` result is recomputed when — and only when —
|
|
111
|
+
* the numbers it depends on change.
|
|
112
|
+
*/
|
|
113
|
+
function messageFingerprint(msg: AgentMessage): string {
|
|
114
|
+
const role = (msg as { role?: string }).role ?? "";
|
|
115
|
+
const ts = (msg as { timestamp?: number }).timestamp ?? 0;
|
|
116
|
+
let textLen = 0;
|
|
117
|
+
let blocks = 0;
|
|
118
|
+
let images = 0;
|
|
119
|
+
if (role === "bashExecution") {
|
|
120
|
+
const b = msg as { command?: unknown; output?: unknown };
|
|
121
|
+
if (typeof b.command === "string") textLen += b.command.length;
|
|
122
|
+
if (typeof b.output === "string") textLen += b.output.length;
|
|
123
|
+
} else if (role === "user") {
|
|
124
|
+
const content = (msg as { content?: unknown }).content;
|
|
125
|
+
if (typeof content === "string") {
|
|
126
|
+
textLen += content.length;
|
|
127
|
+
} else if (Array.isArray(content)) {
|
|
128
|
+
blocks = content.length;
|
|
129
|
+
for (const block of content) {
|
|
130
|
+
if (block?.type === "text" && typeof block.text === "string") textLen += block.text.length;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} else if (role === "assistant") {
|
|
134
|
+
const assistantMsg = msg as AssistantMessage;
|
|
135
|
+
const usageExt = assistantMsg.usage as unknown as { promptTokensDetails?: unknown };
|
|
136
|
+
const usageTotal = assistantMsg.usage?.totalTokens ?? 0;
|
|
137
|
+
const promptBuckets = usageExt?.promptTokensDetails ? 1 : 0;
|
|
138
|
+
const stopReason = assistantMsg.stopReason ?? "";
|
|
139
|
+
|
|
140
|
+
let signatureLen = 0;
|
|
141
|
+
let redactedLen = 0;
|
|
142
|
+
const msgExt = assistantMsg as unknown as {
|
|
143
|
+
thinkingSignature?: string;
|
|
144
|
+
textSignature?: string;
|
|
145
|
+
thoughtSignature?: string;
|
|
146
|
+
redactedThinking?: { data?: string };
|
|
147
|
+
};
|
|
148
|
+
const thinkingSignature = msgExt.thinkingSignature;
|
|
149
|
+
if (typeof thinkingSignature === "string") {
|
|
150
|
+
signatureLen += thinkingSignature.length;
|
|
151
|
+
}
|
|
152
|
+
const textSignature = msgExt.textSignature;
|
|
153
|
+
if (typeof textSignature === "string") {
|
|
154
|
+
signatureLen += textSignature.length;
|
|
155
|
+
}
|
|
156
|
+
const thoughtSignature = msgExt.thoughtSignature;
|
|
157
|
+
if (typeof thoughtSignature === "string") {
|
|
158
|
+
signatureLen += thoughtSignature.length;
|
|
159
|
+
}
|
|
160
|
+
const redactedData = msgExt.redactedThinking?.data;
|
|
161
|
+
if (typeof redactedData === "string") {
|
|
162
|
+
redactedLen += redactedData.length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const content = (msg as { content?: unknown }).content;
|
|
166
|
+
if (Array.isArray(content)) {
|
|
167
|
+
blocks = content.length;
|
|
168
|
+
for (const block of content) {
|
|
169
|
+
if (!block || typeof block !== "object") continue;
|
|
170
|
+
const b = block as {
|
|
171
|
+
type?: string;
|
|
172
|
+
text?: string;
|
|
173
|
+
thinking?: string;
|
|
174
|
+
thinkingSignature?: string;
|
|
175
|
+
signature?: string;
|
|
176
|
+
textSignature?: string;
|
|
177
|
+
thoughtSignature?: string;
|
|
178
|
+
data?: string;
|
|
179
|
+
name?: string;
|
|
180
|
+
arguments?: unknown;
|
|
181
|
+
};
|
|
182
|
+
if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
|
|
183
|
+
else if (b.type === "thinking") {
|
|
184
|
+
if (typeof b.thinking === "string") textLen += b.thinking.length;
|
|
185
|
+
if (typeof b.thinkingSignature === "string") signatureLen += b.thinkingSignature.length;
|
|
186
|
+
if (typeof b.signature === "string") signatureLen += b.signature.length;
|
|
187
|
+
if (typeof b.textSignature === "string") signatureLen += b.textSignature.length;
|
|
188
|
+
if (typeof b.thoughtSignature === "string") signatureLen += b.thoughtSignature.length;
|
|
189
|
+
} else if (b.type === "redactedThinking" && typeof b.data === "string") {
|
|
190
|
+
redactedLen += b.data.length;
|
|
191
|
+
} else if (b.type === "toolCall") {
|
|
192
|
+
if (typeof b.name === "string") textLen += b.name.length;
|
|
193
|
+
if (b.arguments !== undefined) {
|
|
194
|
+
try {
|
|
195
|
+
textLen += JSON.stringify(b.arguments, (_key, value) =>
|
|
196
|
+
typeof value === "bigint" ? value.toString() : value,
|
|
197
|
+
).length;
|
|
198
|
+
} catch {
|
|
199
|
+
textLen += String(b.arguments).length;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return `${role}:${ts}:${textLen}:${blocks}:${images}:${signatureLen}:${redactedLen}:${usageTotal}:${promptBuckets}:${stopReason}`;
|
|
206
|
+
} else if (role === "toolResult" || role === "hookMessage") {
|
|
207
|
+
const content = (msg as { content?: unknown }).content;
|
|
208
|
+
if (typeof content === "string") {
|
|
209
|
+
textLen += content.length;
|
|
210
|
+
} else if (Array.isArray(content)) {
|
|
211
|
+
blocks = content.length;
|
|
212
|
+
for (const block of content) {
|
|
213
|
+
if (!block || typeof block !== "object") continue;
|
|
214
|
+
const b = block as { type?: string; text?: string };
|
|
215
|
+
if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
|
|
216
|
+
else if (b.type === "image") images++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else if (role === "branchSummary" || role === "compactionSummary") {
|
|
220
|
+
const s = (msg as { summary?: unknown }).summary;
|
|
221
|
+
if (typeof s === "string") textLen += s.length;
|
|
222
|
+
}
|
|
223
|
+
return `${role}:${ts}:${textLen}:${blocks}:${images}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface ContextUsageMemo {
|
|
227
|
+
messagesRef: readonly AgentMessage[];
|
|
228
|
+
length: number;
|
|
229
|
+
lastFingerprint: string | undefined;
|
|
230
|
+
modelContextWindow: number;
|
|
231
|
+
contextUsageRevision: number;
|
|
232
|
+
toolSchemaMetadataRevision: number;
|
|
233
|
+
settingsRevision: number;
|
|
234
|
+
tokenizerRef: unknown;
|
|
235
|
+
usedTokens: number;
|
|
236
|
+
contextWindow: number;
|
|
237
|
+
systemPromptRef: readonly string[] | undefined;
|
|
238
|
+
toolsRef: readonly any[] | undefined;
|
|
239
|
+
skillsRef: readonly any[] | undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface StatusLineExternalInputs {
|
|
243
|
+
themeRef: unknown;
|
|
244
|
+
themeEpoch: number;
|
|
245
|
+
projectDir: string;
|
|
246
|
+
sessionRef: StatusLineSession;
|
|
247
|
+
sessionFile: string | undefined;
|
|
248
|
+
sessionSettingsRef: unknown;
|
|
249
|
+
sessionSettingsRevision: number;
|
|
250
|
+
globalSettingsRevision: number;
|
|
251
|
+
stateRef: unknown;
|
|
252
|
+
stateMessagesRef: readonly AgentMessage[];
|
|
253
|
+
stateMessagesLength: number;
|
|
254
|
+
stateLastMessageRef: AgentMessage | undefined;
|
|
255
|
+
stateLastMessageContentRef: unknown;
|
|
256
|
+
messagesRef: readonly AgentMessage[];
|
|
257
|
+
messagesLength: number;
|
|
258
|
+
lastMessageRef: AgentMessage | undefined;
|
|
259
|
+
lastMessageRole: string | undefined;
|
|
260
|
+
lastMessageTimestamp: number;
|
|
261
|
+
lastMessageContentSize: number;
|
|
262
|
+
lastMessageBlockCount: number;
|
|
263
|
+
lastMessageUsageTotal: number;
|
|
264
|
+
lastMessageStopReason: string | undefined;
|
|
265
|
+
modelRef: unknown;
|
|
266
|
+
sessionModelRef: unknown;
|
|
267
|
+
sessionModelId: string | undefined;
|
|
268
|
+
sessionModelContextWindow: Model["contextWindow"] | undefined;
|
|
269
|
+
modelId: string | undefined;
|
|
270
|
+
modelName: string | undefined;
|
|
271
|
+
modelProvider: string | undefined;
|
|
272
|
+
modelContextWindow: Model["contextWindow"] | undefined;
|
|
273
|
+
modelThinking: Model["thinking"];
|
|
274
|
+
thinkingLevel: unknown;
|
|
275
|
+
modelCostRef: unknown;
|
|
276
|
+
contextUsageRevision: number;
|
|
277
|
+
systemPromptRef: unknown;
|
|
278
|
+
systemPromptLength: number;
|
|
279
|
+
systemPromptContentSize: number;
|
|
280
|
+
toolsRef: readonly unknown[] | undefined;
|
|
281
|
+
toolsLength: number;
|
|
282
|
+
toolSchemaMetadataRevision: number;
|
|
283
|
+
tokenizerRef: unknown;
|
|
284
|
+
skillsRef: unknown;
|
|
285
|
+
skillsLength: number;
|
|
286
|
+
sessionName: string | undefined;
|
|
287
|
+
sessionId: string | undefined;
|
|
288
|
+
isStreaming: boolean | undefined;
|
|
289
|
+
isAutoThinking: boolean | undefined;
|
|
290
|
+
isFastModeActive: boolean;
|
|
291
|
+
compactionSpeculation: unknown;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface CachedStatusLine {
|
|
295
|
+
content: string;
|
|
296
|
+
dimmedContent: string;
|
|
297
|
+
width: number;
|
|
298
|
+
availableWidth: number;
|
|
299
|
+
renderRevision: number;
|
|
300
|
+
inputRevision: number;
|
|
301
|
+
placeholders: boolean;
|
|
302
|
+
previewTitle: string | undefined;
|
|
303
|
+
externalInputs: StatusLineExternalInputs;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
interface ActiveRepoCache {
|
|
307
|
+
projectDir: string;
|
|
308
|
+
activeRepo: ActiveRepoContext | null;
|
|
309
|
+
effectiveGitCwd: string;
|
|
310
|
+
repository: VcsRepo | null;
|
|
311
|
+
displayRepository: VcsRepo | null;
|
|
312
|
+
displayRepositoryCheckedAt: number;
|
|
313
|
+
repositoryCheckedAt: number;
|
|
314
|
+
/** Project + worktree dir name when `projectDir` is a linked worktree, else null. */
|
|
315
|
+
worktree: WorktreeContext | null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface BranchResolveRequest {
|
|
319
|
+
id: number;
|
|
320
|
+
cwd: string;
|
|
321
|
+
controller: AbortController;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface JjResolveRequest {
|
|
325
|
+
id: number;
|
|
326
|
+
controller: AbortController;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
interface WorktreeContext {
|
|
330
|
+
/** Primary-checkout (project) name shown by the path segment. */
|
|
331
|
+
projectName: string;
|
|
332
|
+
/** Worktree directory name — suppressed from the path when it equals the branch. */
|
|
333
|
+
worktreeName: string;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Project + worktree-dir names when `cwd` is a linked git worktree, else null.
|
|
338
|
+
* The project name comes from the shared primary checkout; bare-repo worktrees
|
|
339
|
+
* resolve to the shared `foo.git` dir, so a trailing `.git` is stripped.
|
|
340
|
+
*/
|
|
341
|
+
function resolveWorktreeContext(cwd: string): WorktreeContext | null {
|
|
342
|
+
const worktree = vcs.git(cwd)?.linkedWorktree();
|
|
343
|
+
if (!worktree) return null;
|
|
344
|
+
const base = path.basename(worktree.primaryRoot);
|
|
345
|
+
const projectName = base.endsWith(".git") ? base.slice(0, -4) : base;
|
|
346
|
+
if (!projectName) return null;
|
|
347
|
+
return { projectName, worktreeName: path.basename(worktree.root) };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Per-{@link StatusLineSession} active-processing meter for the `time_spent`
|
|
352
|
+
* segment. `activeMs` is the union of every completed `agent_start`→
|
|
353
|
+
* `agent_end` window; `activeStartedAt` is the start timestamp of the
|
|
354
|
+
* currently-running window, or `null` when idle.
|
|
355
|
+
*
|
|
356
|
+
* `sessionFile` snapshots the loaded session-file path at meter-creation
|
|
357
|
+
* time. `StatusLineSession.switchSession` (/resume, /move, ACP fork, RPC
|
|
358
|
+
* `switch_session`, extension `switchSession`) mutates the loaded file
|
|
359
|
+
* under the same {@link StatusLineSession} ref, so the WeakMap key alone
|
|
360
|
+
* cannot tell two conversations apart. `#meter()` compares this snapshot
|
|
361
|
+
* against the live `session.sessionFile`, and a real-to-real change
|
|
362
|
+
* starts the meter fresh instead of crediting the new conversation with
|
|
363
|
+
* the previous one's accumulated active time. The undefined → real
|
|
364
|
+
* first-save transition does not reset, since the session identity has
|
|
365
|
+
* not changed.
|
|
366
|
+
*/
|
|
367
|
+
interface ActiveMeter {
|
|
368
|
+
activeMs: number;
|
|
369
|
+
activeStartedAt: number | null;
|
|
370
|
+
sessionFile: string | undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const EMPTY_MESSAGES: readonly AgentMessage[] = [];
|
|
374
|
+
const STATUS_USAGE_START_DELAY_MS = 0;
|
|
375
|
+
const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
|
|
376
|
+
|
|
377
|
+
function isContextSegment(segment: StatusLineSegmentId): boolean {
|
|
378
|
+
return segment === "context_pct" || segment === "context_total";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
|
382
|
+
return segments.includes("context_pct") || segments.includes("context_total");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function hasNonContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
|
386
|
+
for (const segment of segments) {
|
|
387
|
+
if (!isContextSegment(segment)) return true;
|
|
388
|
+
}
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function removeContextSegments(parts: string[], segments: StatusLineSegmentId[]): void {
|
|
393
|
+
let writeIndex = 0;
|
|
394
|
+
for (let readIndex = 0; readIndex < segments.length; readIndex++) {
|
|
395
|
+
const segment = segments[readIndex];
|
|
396
|
+
if (isContextSegment(segment)) continue;
|
|
397
|
+
parts[writeIndex] = parts[readIndex];
|
|
398
|
+
segments[writeIndex] = segment;
|
|
399
|
+
writeIndex++;
|
|
400
|
+
}
|
|
401
|
+
parts.length = writeIndex;
|
|
402
|
+
segments.length = writeIndex;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function formatEmbeddedContextPercent(percent: number): string {
|
|
406
|
+
return `${percent > 0 && percent < 1 ? percent.toFixed(1) : Math.round(percent)}%`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function embeddedContextGaugeMinWidth(percent: number, contextWindow: number): number {
|
|
410
|
+
return formatEmbeddedContextPercent(percent).length + formatNumber(contextWindow).length + 4;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function hasGitSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
|
414
|
+
return segments.includes("git");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function hasPrSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
|
418
|
+
return segments.includes("pr");
|
|
419
|
+
}
|
|
420
|
+
function hasPathSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
|
421
|
+
return segments.includes("path");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
|
425
|
+
return hasGitSegment(segments) || hasPrSegment(segments);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
type StatusLineLayout = "box" | "band" | "plain-full" | "plain-left" | "plain-right";
|
|
429
|
+
|
|
430
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
431
|
+
// StatusLineComponent
|
|
432
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
433
|
+
|
|
434
|
+
export class StatusLineComponent<TSession extends StatusLineSession = StatusLineSession> implements Component {
|
|
435
|
+
#standalone: false | "full" | "left-only" = false;
|
|
436
|
+
#topAttachment: ComposerStyle["statusAttachment"] = "top-border";
|
|
437
|
+
#standaloneGap = false;
|
|
438
|
+
#autocompleteActiveProbe: (() => boolean) | undefined;
|
|
439
|
+
#renderRevision = 0;
|
|
440
|
+
/** Fully rendered bars for the current revision/clock tick, keyed by layout. */
|
|
441
|
+
#statusLineRenderCache: Record<StatusLineLayout, CachedStatusLine | undefined> = {
|
|
442
|
+
box: undefined,
|
|
443
|
+
band: undefined,
|
|
444
|
+
"plain-full": undefined,
|
|
445
|
+
"plain-left": undefined,
|
|
446
|
+
"plain-right": undefined,
|
|
447
|
+
};
|
|
448
|
+
#statusLineInputRevision = 0;
|
|
449
|
+
#statusLineClockTick = 0;
|
|
450
|
+
#settings: StatusLineSettings = {};
|
|
451
|
+
#effectiveSettings: EffectiveStatusLineSettings | undefined;
|
|
452
|
+
#cachedBranch: string | null | undefined = undefined;
|
|
453
|
+
#cachedBranchRepoId: string | null | undefined = undefined;
|
|
454
|
+
#cachedBranchCwd: string | undefined = undefined;
|
|
455
|
+
// In-flight reftable resolve slot. Ownership is the launch id, not the cwd:
|
|
456
|
+
// two live resolves can share a cwd string across an invalidation, and a
|
|
457
|
+
// stale one must never free (or poison) a slot it no longer owns.
|
|
458
|
+
#branchResolveSeq = 0;
|
|
459
|
+
#branchResolveActive: BranchResolveRequest | undefined = undefined;
|
|
460
|
+
// Bumped on every branch-cache reset (#invalidateGitCaches — a HEAD move or
|
|
461
|
+
// repo-context change). An in-flight reftable resolve captures this at
|
|
462
|
+
// launch; a mismatch on resolve means the cache was invalidated underneath
|
|
463
|
+
// it (a newer resolve superseded it), so its result is stale and must be
|
|
464
|
+
// dropped rather than overwrite the value the newer resolve committed.
|
|
465
|
+
// Mirrors #jjCacheGeneration / #getBranchLabel in this file.
|
|
466
|
+
// Timestamp of the latest branch read; only bounds cache freshness when the
|
|
467
|
+
// HEAD watcher could not be installed.
|
|
468
|
+
#branchLastFetch: number | undefined = undefined;
|
|
469
|
+
// Bumped on every branch-cache reset (invalidateGitCaches — a HEAD move or
|
|
470
|
+
// repo-context change). An in-flight reftable resolve captures this at
|
|
471
|
+
// launch; a mismatch on resolve means the cache was invalidated underneath
|
|
472
|
+
// it (a newer resolve superseded it), so its result is stale and must be
|
|
473
|
+
// dropped rather than overwrite the value the newer resolve committed.
|
|
474
|
+
// Mirrors #jjCacheGeneration / #getBranchLabel in this file.
|
|
475
|
+
#branchCacheGeneration = 0;
|
|
476
|
+
#gitUnwatch: (() => void) | null = null;
|
|
477
|
+
#gitWatcherUnavailable = false;
|
|
478
|
+
#onBranchChange: (() => void) | null = null;
|
|
479
|
+
#disposed = false;
|
|
480
|
+
#autoCompactEnabled: boolean = true;
|
|
481
|
+
/** Pulse timer for the running-speculation indicator; live only while speculation runs. */
|
|
482
|
+
#speculationBlinkTimer: NodeJS.Timeout | undefined;
|
|
483
|
+
#speculationBlinkOn = true;
|
|
484
|
+
/** In-flight brand-color fade across a working-state edge; null when settled. */
|
|
485
|
+
#brandFade: { fromHex: string; toHex: string; startedAt: number } | null = null;
|
|
486
|
+
/** Working flag as of the last brand render, for edge detection. */
|
|
487
|
+
#brandWorking = false;
|
|
488
|
+
/** Frame timer driving repaints while the brand fade is unsettled. */
|
|
489
|
+
#brandFadeTimer: NodeJS.Timeout | undefined;
|
|
490
|
+
/** One wall-clock wakeup for the active model's next tariff change, including while idle. */
|
|
491
|
+
#pricingTimer: NodeJS.Timeout | undefined;
|
|
492
|
+
#pricingTimerCost: ModelCost | undefined;
|
|
493
|
+
#pricingTransition: number | undefined;
|
|
494
|
+
#hookStatuses: Map<string, string> = new Map();
|
|
495
|
+
#sortedHookStatuses: readonly string[] = [];
|
|
496
|
+
#subagentCount: number = 0;
|
|
497
|
+
#runningSubagentIds = new Set<string>();
|
|
498
|
+
/**
|
|
499
|
+
* Active-processing accounting for the `time_spent` segment, keyed per
|
|
500
|
+
* {@link StatusLineSession} so the focus-controller mid-turn attach path
|
|
501
|
+
* cannot leak an unmatched synthesized `agent_start` from a subagent
|
|
502
|
+
* into the main session's meter.
|
|
503
|
+
*
|
|
504
|
+
* Each meter is `{ activeMs, activeStartedAt }`: `activeMs` is the union
|
|
505
|
+
* of every completed `agent_start`→`agent_end` window since
|
|
506
|
+
* {@link resetActiveTime} last reset it; `activeStartedAt` is the start
|
|
507
|
+
* timestamp of the currently-running window (or `null` when idle).
|
|
508
|
+
* `getActiveMs()` returns `activeMs + (now - activeStartedAt)` for the
|
|
509
|
+
* currently-attached session, so the counter ticks live during a turn
|
|
510
|
+
* and freezes the instant the agent yields.
|
|
511
|
+
*
|
|
512
|
+
* WeakMap so meters die with their session (e.g. a parked subagent
|
|
513
|
+
* dropped from the registry); the main session's meter survives focus
|
|
514
|
+
* round-trips because the same {@link StatusLineSession} ref is reused.
|
|
515
|
+
*/
|
|
516
|
+
#activeMeters: WeakMap<TSession, ActiveMeter> = new WeakMap();
|
|
517
|
+
#planModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
|
518
|
+
#loopModeStatus: SegmentContext["loopMode"] = null;
|
|
519
|
+
#goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
|
520
|
+
#vibeModeStatus: { enabled: boolean } | null = null;
|
|
521
|
+
#vimStatus: SegmentContext["vim"] = null;
|
|
522
|
+
/**
|
|
523
|
+
* Injected aggregator that returns the aggregate tok/s of this session's
|
|
524
|
+
* live vibe worker sessions, or null when no workers are streaming. Kept as
|
|
525
|
+
* a callback so the render layer doesn't import the heavy vibe/task
|
|
526
|
+
* dependency graph; interactive-mode wires it to VibeSessionRegistry.
|
|
527
|
+
*/
|
|
528
|
+
#vibeWorkerTokenRate: (() => number | null) | null = null;
|
|
529
|
+
#collabStatus: CollabStatus | null = null;
|
|
530
|
+
#streamStatus: { viewers: number } | null = null;
|
|
531
|
+
#focusedAgentId: string | undefined;
|
|
532
|
+
#activeRepoCache: ActiveRepoCache | undefined;
|
|
533
|
+
|
|
534
|
+
// Git status caching (1s TTL)
|
|
535
|
+
#cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
536
|
+
#cachedGitStatusCwd: string | undefined = undefined;
|
|
537
|
+
#gitStatusLastFetch = 0;
|
|
538
|
+
#gitStatusInFlightCwd: string | undefined = undefined;
|
|
539
|
+
#cachedJjBranch: string | null = null;
|
|
540
|
+
#jjBranchLastFetch = 0;
|
|
541
|
+
#jjResolveSeq = 0;
|
|
542
|
+
#jjBranchActive: JjResolveRequest | undefined = undefined;
|
|
543
|
+
#cachedJjStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
544
|
+
#jjStatusLastFetch = 0;
|
|
545
|
+
#jjStatusActive: JjResolveRequest | undefined = undefined;
|
|
546
|
+
// Bumped on every jj-cache reset — a cwd switch (#applyCwdChange) or a HEAD /
|
|
547
|
+
// bookmark move (#invalidateGitCaches). An in-flight jj query captures this
|
|
548
|
+
// at launch; a mismatch on resolve means the caches were reset underneath it
|
|
549
|
+
// (including a reset that re-resolves to the SAME root, which a root-equality
|
|
550
|
+
// check cannot detect), so the result is stale and must be dropped rather
|
|
551
|
+
// than poison the fresh cache or advance its throttle.
|
|
552
|
+
#jjCacheGeneration = 0;
|
|
553
|
+
|
|
554
|
+
// PR lookup caching (invalidated on branch/repo context changes)
|
|
555
|
+
#cachedPr: { number: number; url: string } | null | undefined = undefined;
|
|
556
|
+
#cachedPrContext: PrCacheContext | undefined = undefined;
|
|
557
|
+
#prLookupInFlight = false;
|
|
558
|
+
#defaultBranch?: string;
|
|
559
|
+
#defaultBranchCwd: string | undefined = undefined;
|
|
560
|
+
#lastTokensPerSecond: number | null = null;
|
|
561
|
+
#lastTokensPerSecondTimestamp: number | null = null;
|
|
562
|
+
|
|
563
|
+
// Provider usage caching (5-min TTL, OAuth/sub only)
|
|
564
|
+
#cachedUsage: {
|
|
565
|
+
tier?: string;
|
|
566
|
+
fiveHour?: { percent: number; resetMinutes?: number };
|
|
567
|
+
daily?: { percent: number; resetMinutes?: number };
|
|
568
|
+
sevenDay?: { percent: number; resetHours?: number };
|
|
569
|
+
monthly?: { percent: number; resetHours?: number };
|
|
570
|
+
} | null = null;
|
|
571
|
+
#cachedUsageContextKey: string | null = null;
|
|
572
|
+
#usageFetchedAt = 0;
|
|
573
|
+
#usageInFlight = false;
|
|
574
|
+
#usageStartTimer: Timer | null = null;
|
|
575
|
+
// A timed-out request may still resolve. Its result remains eligible only
|
|
576
|
+
// until a newer request has applied.
|
|
577
|
+
#usageRefreshSequence = 0;
|
|
578
|
+
#latestAppliedUsageRefreshSequence = 0;
|
|
579
|
+
#codexResetSnapshots = new Map<string, CodexResetUsageSnapshot>();
|
|
580
|
+
#onCodexResetFireworks: ((event: CodexResetFireworksEvent) => void) | undefined;
|
|
581
|
+
// Context-usage memo. The status line redraws on every agent event, so the
|
|
582
|
+
// hot path must not recompute context tokens unless an input changed.
|
|
583
|
+
// `getContextUsage()` anchors on the last assistant's real prompt-token
|
|
584
|
+
// count (matching the provider and the `/context` panel), so a stable
|
|
585
|
+
// message list + model window yields a stable result we can return verbatim.
|
|
586
|
+
#contextUsageCache: ContextUsageMemo | undefined;
|
|
587
|
+
|
|
588
|
+
constructor(
|
|
589
|
+
private session: TSession,
|
|
590
|
+
private readonly host: StatusLineHost<TSession>,
|
|
591
|
+
) {
|
|
592
|
+
this.#settings = host.getSettings();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
#gitEnabled(): boolean {
|
|
596
|
+
return this.host.gitEnabled();
|
|
597
|
+
}
|
|
598
|
+
#hasGitBackedSegment(): boolean {
|
|
599
|
+
const effectiveSettings = this.#resolveSettings();
|
|
600
|
+
return (
|
|
601
|
+
hasGitBackedSegment(effectiveSettings.leftSegments) || hasGitBackedSegment(effectiveSettings.rightSegments)
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
#resolveActiveRepoCache(): ActiveRepoCache {
|
|
606
|
+
const projectDir = getProjectDir();
|
|
607
|
+
if (this.#activeRepoCache?.projectDir === projectDir) {
|
|
608
|
+
return this.#activeRepoCache;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const projectRepository = vcs.repo(projectDir);
|
|
612
|
+
const activeRepo = projectRepository ? null : this.host.resolveActiveRepo(projectDir);
|
|
613
|
+
const effectiveGitCwd = activeRepo?.repoRoot ?? projectDir;
|
|
614
|
+
const repository = projectRepository ?? (activeRepo ? vcs.repo(effectiveGitCwd) : null);
|
|
615
|
+
// Presentation follows a second detector whose only policy difference
|
|
616
|
+
// is preferring jj on equal-root ties (see detect_for_display);
|
|
617
|
+
// automation keeps `repository` above. A failed display lookup (or a
|
|
618
|
+
// caller that only stubs the operational detector) degrades to the
|
|
619
|
+
// operational repository rather than hiding the segment.
|
|
620
|
+
let displayRepository: VcsRepo | null;
|
|
621
|
+
try {
|
|
622
|
+
displayRepository = vcs.repoForDisplay(effectiveGitCwd);
|
|
623
|
+
} catch {
|
|
624
|
+
displayRepository = null;
|
|
625
|
+
}
|
|
626
|
+
displayRepository ??= repository;
|
|
627
|
+
// Only collapse the bare-cwd case: a single-direct-child-repo context
|
|
628
|
+
// (activeRepo set) renders `<parent> ↳ <child>`, which we leave intact.
|
|
629
|
+
const worktree = activeRepo ? null : resolveWorktreeContext(effectiveGitCwd);
|
|
630
|
+
this.#activeRepoCache = {
|
|
631
|
+
projectDir,
|
|
632
|
+
activeRepo,
|
|
633
|
+
effectiveGitCwd,
|
|
634
|
+
repository,
|
|
635
|
+
displayRepository,
|
|
636
|
+
displayRepositoryCheckedAt: Date.now(),
|
|
637
|
+
repositoryCheckedAt: Date.now(),
|
|
638
|
+
worktree,
|
|
639
|
+
};
|
|
640
|
+
return this.#activeRepoCache;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
#resolveRepository(cache: ActiveRepoCache): VcsRepo | null {
|
|
644
|
+
if (cache.repository) return cache.repository;
|
|
645
|
+
const now = Date.now();
|
|
646
|
+
if (now - cache.repositoryCheckedAt < WATCHER_FAILURE_POLL_TTL_MS) return null;
|
|
647
|
+
cache.repository = vcs.repo(cache.effectiveGitCwd);
|
|
648
|
+
cache.repositoryCheckedAt = now;
|
|
649
|
+
return cache.repository;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
#resolveDisplayRepository(cache: ActiveRepoCache): VcsRepo | null {
|
|
653
|
+
if (cache.displayRepository) return cache.displayRepository;
|
|
654
|
+
const now = Date.now();
|
|
655
|
+
if (now - cache.displayRepositoryCheckedAt < WATCHER_FAILURE_POLL_TTL_MS) return null;
|
|
656
|
+
let display: VcsRepo | null;
|
|
657
|
+
try {
|
|
658
|
+
display = vcs.repoForDisplay(cache.effectiveGitCwd);
|
|
659
|
+
} catch {
|
|
660
|
+
display = null;
|
|
661
|
+
}
|
|
662
|
+
cache.displayRepository = display ?? cache.repository;
|
|
663
|
+
cache.displayRepositoryCheckedAt = now;
|
|
664
|
+
return cache.displayRepository;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive
|
|
669
|
+
* from it. `focusedAgentId` is the focused subagent id while the view is proxied, undefined for main.
|
|
670
|
+
*/
|
|
671
|
+
setSession(session: TSession, focusedAgentId?: string): void {
|
|
672
|
+
const sessionChanged = this.session !== session;
|
|
673
|
+
if (!sessionChanged && this.#focusedAgentId === focusedAgentId) return;
|
|
674
|
+
this.session = session;
|
|
675
|
+
this.#focusedAgentId = focusedAgentId;
|
|
676
|
+
if (sessionChanged) {
|
|
677
|
+
this.#invalidateSessionCaches();
|
|
678
|
+
this.#closeStaleActiveWindow();
|
|
679
|
+
}
|
|
680
|
+
this.invalidate();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Drop a meter's in-flight window when the newly-attached session is no
|
|
685
|
+
* longer streaming. Handles the case where the focus controller
|
|
686
|
+
* synthesized an `agent_start` on a mid-turn attach but the matching
|
|
687
|
+
* real `agent_end` never reached us — the user detached before it
|
|
688
|
+
* fired, and re-focusing later (after the agent finished) would
|
|
689
|
+
* otherwise tick over the entire detached gap. Crediting that gap to
|
|
690
|
+
* `activeMs` would be wrong (the agent finished at some point we never
|
|
691
|
+
* observed), so the window is dropped rather than folded in.
|
|
692
|
+
*/
|
|
693
|
+
#closeStaleActiveWindow(): void {
|
|
694
|
+
const meter = this.#meter();
|
|
695
|
+
if (meter.activeStartedAt === null) return;
|
|
696
|
+
if (this.session.isStreaming) return;
|
|
697
|
+
meter.activeStartedAt = null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
updateSettings(settings: StatusLineSettings): void {
|
|
701
|
+
this.#settings = settings;
|
|
702
|
+
this.#effectiveSettings = undefined;
|
|
703
|
+
this.#invalidateStatusLineRenderCache();
|
|
704
|
+
if (this.#onBranchChange) this.#setupGitWatcher();
|
|
705
|
+
this.#syncPricingTimer();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
getEffectiveSettingsForTest(): EffectiveStatusLineSettings {
|
|
709
|
+
return this.#resolveSettings();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
setAutoCompactEnabled(enabled: boolean): void {
|
|
713
|
+
if (this.#autoCompactEnabled === enabled) return;
|
|
714
|
+
this.#autoCompactEnabled = enabled;
|
|
715
|
+
this.#invalidateStatusLineRenderCache();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
setRunningSubagents(agentIds: readonly string[]): void {
|
|
719
|
+
if (
|
|
720
|
+
agentIds.length === this.#runningSubagentIds.size &&
|
|
721
|
+
agentIds.every(agentId => this.#runningSubagentIds.has(agentId))
|
|
722
|
+
) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
this.#subagentCount = agentIds.length;
|
|
726
|
+
this.#runningSubagentIds = new Set(agentIds);
|
|
727
|
+
this.#invalidateStatusLineRenderCache();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Compatibility shim for callers predating the simplified subagent badge.
|
|
732
|
+
* The status line now intentionally shows only the active count.
|
|
733
|
+
*/
|
|
734
|
+
setSubagentHubHint(_hint: string | undefined): void {}
|
|
735
|
+
|
|
736
|
+
/** Active subagent count as currently displayed (collab state mirroring). */
|
|
737
|
+
get subagentCount(): number {
|
|
738
|
+
return this.#subagentCount;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Reset the currently-attached session's active-time accumulators so
|
|
743
|
+
* the `time_spent` segment starts from zero. Called from `/clear`,
|
|
744
|
+
* fresh-session, and joined-collab paths; both the completed
|
|
745
|
+
* accumulator and any in-flight window are dropped, so a reset
|
|
746
|
+
* mid-turn ignores the running window (the matching `markActivityEnd`
|
|
747
|
+
* will see an idle meter and no-op).
|
|
748
|
+
*/
|
|
749
|
+
resetActiveTime(): void {
|
|
750
|
+
const meter = this.#meter();
|
|
751
|
+
if (meter.activeMs === 0 && meter.activeStartedAt === null) return;
|
|
752
|
+
meter.activeMs = 0;
|
|
753
|
+
meter.activeStartedAt = null;
|
|
754
|
+
this.#invalidateStatusLineRenderCache();
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Mark the currently-attached session as having started a unit of
|
|
759
|
+
* active processing. Idempotent: a second start while a window is
|
|
760
|
+
* already open is a no-op, so reentrant `agent_start` events (e.g.
|
|
761
|
+
* nested auto-compaction loops, focus-controller mid-turn attach onto
|
|
762
|
+
* an already-running window) do not double-count.
|
|
763
|
+
*/
|
|
764
|
+
markActivityStart(): void {
|
|
765
|
+
const meter = this.#meter();
|
|
766
|
+
if (meter.activeStartedAt !== null) return;
|
|
767
|
+
meter.activeStartedAt = Date.now();
|
|
768
|
+
this.#invalidateStatusLineRenderCache();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Close the currently-attached session's open active-processing
|
|
773
|
+
* window, folding its elapsed time into the accumulator. Idempotent
|
|
774
|
+
* when the meter is already idle so callers can fire it on every
|
|
775
|
+
* `agent_end` without guarding.
|
|
776
|
+
*/
|
|
777
|
+
markActivityEnd(): void {
|
|
778
|
+
const meter = this.#meter();
|
|
779
|
+
if (meter.activeStartedAt === null) return;
|
|
780
|
+
meter.activeMs += Math.max(0, Date.now() - meter.activeStartedAt);
|
|
781
|
+
meter.activeStartedAt = null;
|
|
782
|
+
this.#invalidateStatusLineRenderCache();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Snapshot of total active-processing time for the currently-attached
|
|
787
|
+
* session, including any in-flight window. Exposed for the segment
|
|
788
|
+
* context builder; tests assert against this too.
|
|
789
|
+
*/
|
|
790
|
+
getActiveMs(): number {
|
|
791
|
+
const meter = this.#meter();
|
|
792
|
+
if (meter.activeStartedAt === null) return meter.activeMs;
|
|
793
|
+
return meter.activeMs + Math.max(0, Date.now() - meter.activeStartedAt);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Elapsed ms of the currently-open active-processing window, or null when
|
|
797
|
+
* idle. Feeds the `pi` segment's working spinner + turn timer.
|
|
798
|
+
*/
|
|
799
|
+
getTurnElapsedMs(): number | null {
|
|
800
|
+
const startedAt = this.#meter().activeStartedAt;
|
|
801
|
+
return startedAt === null ? null : Math.max(0, Date.now() - startedAt);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Return (lazily creating) the meter for the currently-attached
|
|
806
|
+
* session. Detects an in-place session-file swap under the same
|
|
807
|
+
* {@link StatusLineSession} ref (`switchSession` paths: `/resume`, `/move`,
|
|
808
|
+
* ACP fork/load, RPC `switch_session`, extension `switchSession`):
|
|
809
|
+
* a real-to-real change starts the meter fresh so the new
|
|
810
|
+
* conversation does not inherit the previous one's accumulated active
|
|
811
|
+
* time. The undefined → real first-save transition only refreshes the
|
|
812
|
+
* snapshot — the conversation identity has not changed.
|
|
813
|
+
*/
|
|
814
|
+
#meter(): ActiveMeter {
|
|
815
|
+
const currentFile = this.session.sessionFile;
|
|
816
|
+
let meter = this.#activeMeters.get(this.session);
|
|
817
|
+
if (meter) {
|
|
818
|
+
const switched =
|
|
819
|
+
currentFile !== undefined && meter.sessionFile !== undefined && meter.sessionFile !== currentFile;
|
|
820
|
+
if (switched) {
|
|
821
|
+
meter = undefined;
|
|
822
|
+
} else {
|
|
823
|
+
meter.sessionFile = currentFile;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
if (!meter) {
|
|
827
|
+
meter = { activeMs: 0, activeStartedAt: null, sessionFile: currentFile };
|
|
828
|
+
this.#activeMeters.set(this.session, meter);
|
|
829
|
+
}
|
|
830
|
+
return meter;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
setPlanModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
|
|
834
|
+
const next = status ?? null;
|
|
835
|
+
if (
|
|
836
|
+
this.#planModeStatus === next ||
|
|
837
|
+
(this.#planModeStatus?.enabled === next?.enabled && this.#planModeStatus?.paused === next?.paused)
|
|
838
|
+
) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
this.#planModeStatus = next;
|
|
842
|
+
this.#invalidateStatusLineRenderCache();
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
setLoopModeStatus(status: NonNullable<SegmentContext["loopMode"]> | undefined): void {
|
|
846
|
+
const next = status ?? null;
|
|
847
|
+
if (
|
|
848
|
+
this.#loopModeStatus === next ||
|
|
849
|
+
(this.#loopModeStatus?.state === next?.state &&
|
|
850
|
+
this.#loopModeStatus?.limit === next?.limit &&
|
|
851
|
+
this.#loopModeStatus?.condition === next?.condition)
|
|
852
|
+
) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
this.#loopModeStatus = next;
|
|
856
|
+
this.#invalidateStatusLineRenderCache();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
setGoalModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
|
|
860
|
+
const next = status ?? null;
|
|
861
|
+
if (
|
|
862
|
+
this.#goalModeStatus === next ||
|
|
863
|
+
(this.#goalModeStatus?.enabled === next?.enabled && this.#goalModeStatus?.paused === next?.paused)
|
|
864
|
+
) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
this.#goalModeStatus = next;
|
|
868
|
+
this.#invalidateStatusLineRenderCache();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
setVibeModeStatus(status: { enabled: boolean } | undefined): void {
|
|
872
|
+
const next = status ?? null;
|
|
873
|
+
if (this.#vibeModeStatus === next || this.#vibeModeStatus?.enabled === next?.enabled) return;
|
|
874
|
+
this.#vibeModeStatus = next;
|
|
875
|
+
this.#invalidateStatusLineRenderCache();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Mirror of the editor's modal state; `undefined` clears it (Vim mode off). */
|
|
879
|
+
setVimStatus(status: NonNullable<SegmentContext["vim"]> | undefined): void {
|
|
880
|
+
const next = status ?? null;
|
|
881
|
+
if (
|
|
882
|
+
this.#vimStatus === next ||
|
|
883
|
+
(this.#vimStatus?.mode === next?.mode &&
|
|
884
|
+
this.#vimStatus?.pending === next?.pending &&
|
|
885
|
+
this.#vimStatus?.selectedLines === next?.selectedLines &&
|
|
886
|
+
this.#vimStatus?.display === next?.display)
|
|
887
|
+
) {
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
this.#vimStatus = next;
|
|
891
|
+
this.#invalidateStatusLineRenderCache();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Inject the aggregator that returns the aggregate tok/s of this session's
|
|
896
|
+
* live vibe worker sessions (null when no workers are streaming). Wired by
|
|
897
|
+
* interactive-mode, which owns the VibeSessionRegistry coupling, so the
|
|
898
|
+
* render layer stays off the heavy vibe/task dependency graph. Pass
|
|
899
|
+
* `undefined` to clear.
|
|
900
|
+
*/
|
|
901
|
+
setVibeWorkerTokenRateProvider(provider: (() => number | null) | undefined): void {
|
|
902
|
+
this.#vibeWorkerTokenRate = provider ?? null;
|
|
903
|
+
this.#invalidateStatusLineRenderCache();
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
setCollabStatus(status: CollabStatus | null): void {
|
|
907
|
+
if (
|
|
908
|
+
!this.#collabStatus?.stateOverride &&
|
|
909
|
+
!status?.stateOverride &&
|
|
910
|
+
this.#collabStatus?.role === status?.role &&
|
|
911
|
+
this.#collabStatus?.participantCount === status?.participantCount
|
|
912
|
+
) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
this.#collabStatus = status;
|
|
916
|
+
this.#invalidateStatusLineRenderCache();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
setStreamStatus(status: { viewers: number } | null): void {
|
|
920
|
+
if (this.#streamStatus?.viewers === status?.viewers) return;
|
|
921
|
+
this.#streamStatus = status;
|
|
922
|
+
this.#invalidateStatusLineRenderCache();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Set the callback that presents detected Codex reset celebrations, or clear it with `undefined`. */
|
|
926
|
+
setCodexResetFireworksHandler(handler: ((event: CodexResetFireworksEvent) => void) | undefined): void {
|
|
927
|
+
this.#onCodexResetFireworks = handler;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
setHookStatus(key: string, text: string | undefined): void {
|
|
931
|
+
if (text === undefined) {
|
|
932
|
+
if (!this.#hookStatuses.delete(key)) return;
|
|
933
|
+
} else {
|
|
934
|
+
if (this.#hookStatuses.get(key) === text) return;
|
|
935
|
+
this.#hookStatuses.set(key, text);
|
|
936
|
+
}
|
|
937
|
+
this.#sortedHookStatuses = Array.from(this.#hookStatuses.entries())
|
|
938
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
939
|
+
.map(([, status]) => status);
|
|
940
|
+
this.#invalidateStatusLineRenderCache();
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
watchBranch(onBranchChange: () => void): void {
|
|
944
|
+
this.#onBranchChange = onBranchChange;
|
|
945
|
+
this.#setupGitWatcher();
|
|
946
|
+
this.#syncPricingTimer();
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
#setupGitWatcher(): void {
|
|
950
|
+
this.#retireGitWatcher();
|
|
951
|
+
this.#gitWatcherUnavailable = false;
|
|
952
|
+
|
|
953
|
+
if (!this.#gitEnabled() || !this.#hasGitBackedSegment()) {
|
|
954
|
+
this.invalidateGitCaches();
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const activeRepoCache = this.#resolveActiveRepoCache();
|
|
959
|
+
const repository = this.#resolveDisplayRepository(activeRepoCache);
|
|
960
|
+
if (!repository) {
|
|
961
|
+
// There is no path to watch yet. Cache the negative result only for the
|
|
962
|
+
// fallback poll interval so a later `git init` becomes visible without
|
|
963
|
+
// generic invalidations or a render-path probe on every paint.
|
|
964
|
+
this.#gitWatcherUnavailable = true;
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Backends atomically replace their head markers, so stat-poll the
|
|
969
|
+
// backend-selected target instead of binding a watcher to one inode.
|
|
970
|
+
try {
|
|
971
|
+
const unwatch = vcs.watch(repository, () => {
|
|
972
|
+
if (this.#disposed || this.#gitUnwatch !== unwatch) return;
|
|
973
|
+
this.invalidateGitCaches();
|
|
974
|
+
this.#onBranchChange?.();
|
|
975
|
+
});
|
|
976
|
+
this.#gitUnwatch = unwatch;
|
|
977
|
+
} catch {
|
|
978
|
+
this.#gitWatcherUnavailable = true;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
#retireGitWatcher(): void {
|
|
983
|
+
const unwatch = this.#gitUnwatch;
|
|
984
|
+
this.#gitUnwatch = null;
|
|
985
|
+
unwatch?.();
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
dispose(): void {
|
|
989
|
+
this.#disposed = true;
|
|
990
|
+
this.#branchResolveActive?.controller.abort();
|
|
991
|
+
this.#branchResolveActive = undefined;
|
|
992
|
+
this.#resetJjRequests();
|
|
993
|
+
this.#onBranchChange = null;
|
|
994
|
+
this.#stopSpeculationBlink();
|
|
995
|
+
this.#stopBrandFadeTimer();
|
|
996
|
+
this.#stopPricingTimer();
|
|
997
|
+
this.#clearUsageStartTimer();
|
|
998
|
+
this.#onCodexResetFireworks = undefined;
|
|
999
|
+
this.#codexResetSnapshots.clear();
|
|
1000
|
+
this.#retireGitWatcher();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Drive the context segment's pulse while a background speculative
|
|
1005
|
+
* compaction runs: a slow toggle that invalidates and repaints through the
|
|
1006
|
+
* same host callback async git/PR resolves use. Stops (and resets phase)
|
|
1007
|
+
* the first render after speculation leaves the running state.
|
|
1008
|
+
*/
|
|
1009
|
+
#syncSpeculationBlink(state: "idle" | "running" | "armed"): void {
|
|
1010
|
+
if (state === "running" && !this.#disposed) {
|
|
1011
|
+
this.#speculationBlinkTimer ??= setInterval(() => {
|
|
1012
|
+
this.#speculationBlinkOn = !this.#speculationBlinkOn;
|
|
1013
|
+
this.invalidate();
|
|
1014
|
+
this.#onBranchChange?.();
|
|
1015
|
+
}, 600);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
this.#stopSpeculationBlink();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
#stopSpeculationBlink(): void {
|
|
1022
|
+
if (!this.#speculationBlinkTimer) return;
|
|
1023
|
+
clearInterval(this.#speculationBlinkTimer);
|
|
1024
|
+
this.#speculationBlinkTimer = undefined;
|
|
1025
|
+
this.#speculationBlinkOn = true;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Foreground ANSI for the `pi` brand segment: dim gray while idle, fading
|
|
1029
|
+
* to the accent (session accent when enabled, else theme accent) while a
|
|
1030
|
+
* turn runs — a port of rust omp's status-band brand fade (450ms cubic
|
|
1031
|
+
* ease-in-out). A working-state edge retargets the tween from the color
|
|
1032
|
+
* currently on screen, so interrupting a running fade never jumps, and arms
|
|
1033
|
+
* a 40ms frame timer so the fade keeps animating after the working loader
|
|
1034
|
+
* (the usual repaint driver) has stopped.
|
|
1035
|
+
*/
|
|
1036
|
+
#brandFgAnsi(working: boolean, sessionAccentEnabled: boolean): string {
|
|
1037
|
+
const sessionName = sessionAccentEnabled ? this.session.sessionManager?.getSessionName() : undefined;
|
|
1038
|
+
const idleHex = theme.getColorHex("dim");
|
|
1039
|
+
const workingHex =
|
|
1040
|
+
(sessionName ? getSessionAccentHex(sessionName, theme.sessionAccentInputs) : undefined) ??
|
|
1041
|
+
theme.getColorHex("accent");
|
|
1042
|
+
const now = Date.now();
|
|
1043
|
+
if (working !== this.#brandWorking) {
|
|
1044
|
+
const previousTargetHex = this.#brandWorking ? workingHex : idleHex;
|
|
1045
|
+
this.#brandFade = {
|
|
1046
|
+
fromHex: this.#sampleBrandHex(previousTargetHex, now),
|
|
1047
|
+
toHex: working ? workingHex : idleHex,
|
|
1048
|
+
startedAt: now,
|
|
1049
|
+
};
|
|
1050
|
+
this.#brandWorking = working;
|
|
1051
|
+
this.#startBrandFadeTimer();
|
|
1052
|
+
}
|
|
1053
|
+
const hex = this.#sampleBrandHex(working ? workingHex : idleHex, now);
|
|
1054
|
+
return getSessionAccentAnsi(hex) ?? theme.getFgAnsi(working ? "accent" : "dim");
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* The brand hex at `now`: the eased blend while a fade is in flight,
|
|
1059
|
+
* otherwise `settledHex`. A fade past its deadline is cleared here so the
|
|
1060
|
+
* next frame timer tick shuts the timer down.
|
|
1061
|
+
*/
|
|
1062
|
+
#sampleBrandHex(settledHex: string, now: number): string {
|
|
1063
|
+
const fade = this.#brandFade;
|
|
1064
|
+
if (!fade) return settledHex;
|
|
1065
|
+
const t = (now - fade.startedAt) / BRAND_FADE_MS;
|
|
1066
|
+
if (t >= 1) {
|
|
1067
|
+
this.#brandFade = null;
|
|
1068
|
+
return settledHex;
|
|
1069
|
+
}
|
|
1070
|
+
// Cubic ease-in-out, matching rust omp's Easing::EaseInOut.
|
|
1071
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
1072
|
+
const from = hexToRgb(fade.fromHex);
|
|
1073
|
+
const to = hexToRgb(fade.toHex);
|
|
1074
|
+
return rgbToHex({
|
|
1075
|
+
r: Math.round(from.r + (to.r - from.r) * eased),
|
|
1076
|
+
g: Math.round(from.g + (to.g - from.g) * eased),
|
|
1077
|
+
b: Math.round(from.b + (to.b - from.b) * eased),
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
#startBrandFadeTimer(): void {
|
|
1082
|
+
if (this.#brandFadeTimer || this.#disposed) return;
|
|
1083
|
+
this.#brandFadeTimer = setInterval(() => {
|
|
1084
|
+
const fade = this.#brandFade;
|
|
1085
|
+
if (!fade || Date.now() - fade.startedAt >= BRAND_FADE_MS) this.#stopBrandFadeTimer();
|
|
1086
|
+
// One trailing repaint after the stop paints the settled color.
|
|
1087
|
+
this.invalidate();
|
|
1088
|
+
this.#onBranchChange?.();
|
|
1089
|
+
}, BRAND_FADE_FRAME_MS);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
#stopBrandFadeTimer(): void {
|
|
1093
|
+
if (!this.#brandFadeTimer) return;
|
|
1094
|
+
clearInterval(this.#brandFadeTimer);
|
|
1095
|
+
this.#brandFadeTimer = undefined;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
#stopPricingTimer(): void {
|
|
1099
|
+
clearTimeout(this.#pricingTimer);
|
|
1100
|
+
this.#pricingTimer = undefined;
|
|
1101
|
+
this.#pricingTimerCost = undefined;
|
|
1102
|
+
this.#pricingTransition = undefined;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
#syncPricingTimer(): void {
|
|
1106
|
+
const cost = this.session.state.model?.cost;
|
|
1107
|
+
const effectiveSettings = this.#resolveSettings();
|
|
1108
|
+
const costVisible =
|
|
1109
|
+
(effectiveSettings.leftSegments.includes("cost") &&
|
|
1110
|
+
(this.#standalone !== false ||
|
|
1111
|
+
this.#topAttachment === "top-border" ||
|
|
1112
|
+
this.#topAttachment === "top-band")) ||
|
|
1113
|
+
(effectiveSettings.rightSegments.includes("cost") &&
|
|
1114
|
+
(this.#standalone === "full" || this.#topAttachment !== "none"));
|
|
1115
|
+
if (this.#disposed || !this.#onBranchChange || !cost?.timeBased || !costVisible) {
|
|
1116
|
+
this.#stopPricingTimer();
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
const now = Date.now();
|
|
1120
|
+
if (this.#pricingTimerCost === cost && this.#pricingTransition !== undefined && this.#pricingTransition > now) {
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
this.#stopPricingTimer();
|
|
1124
|
+
const transition = getNextTimeBasedPricingTransition(cost, now);
|
|
1125
|
+
if (transition === undefined) return;
|
|
1126
|
+
this.#pricingTimerCost = cost;
|
|
1127
|
+
this.#pricingTransition = transition;
|
|
1128
|
+
const timer = setTimeout(() => {
|
|
1129
|
+
if (this.#disposed || this.#pricingTimer !== timer) return;
|
|
1130
|
+
this.#stopPricingTimer();
|
|
1131
|
+
this.invalidate();
|
|
1132
|
+
this.#onBranchChange?.();
|
|
1133
|
+
}, transition - now);
|
|
1134
|
+
this.#pricingTimer = timer;
|
|
1135
|
+
timer.unref();
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
#clearUsageStartTimer(): void {
|
|
1139
|
+
if (!this.#usageStartTimer) return;
|
|
1140
|
+
clearTimeout(this.#usageStartTimer);
|
|
1141
|
+
this.#usageStartTimer = null;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
#invalidateStatusLineRenderCache(): void {
|
|
1145
|
+
this.#statusLineInputRevision++;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
invalidate(): void {
|
|
1149
|
+
this.#renderRevision++;
|
|
1150
|
+
this.#syncPricingTimer();
|
|
1151
|
+
// Generic repaint invalidation (theme change, message event, model
|
|
1152
|
+
// switch, …). Must NOT abort or restart a live reftable HEAD/PR resolve:
|
|
1153
|
+
// the render path self-invalidates via cwd/context cache-miss checks, so
|
|
1154
|
+
// a generic paint only needs to re-render — not tear down in-flight VCS
|
|
1155
|
+
// work. Aborting here would fan out a new git subprocess on every agent
|
|
1156
|
+
// event, re-introducing the render-path spawn churn the async resolve
|
|
1157
|
+
// was designed to avoid. Explicit Git/repository invalidation (watcher
|
|
1158
|
+
// HEAD-move, cwd/repo switch) goes through {@link invalidateGitCaches}.
|
|
1159
|
+
// A tool may open, close, or merge a PR without moving HEAD. Expire the
|
|
1160
|
+
// settled PR context on ordinary activity while leaving HEAD work intact.
|
|
1161
|
+
this.#cachedPrContext = undefined;
|
|
1162
|
+
}
|
|
1163
|
+
#invalidateSessionCaches(): void {
|
|
1164
|
+
this.#clearUsageStartTimer();
|
|
1165
|
+
this.#cachedUsage = null;
|
|
1166
|
+
this.#usageFetchedAt = 0;
|
|
1167
|
+
this.#usageInFlight = false;
|
|
1168
|
+
this.#contextUsageCache = undefined;
|
|
1169
|
+
this.#lastTokensPerSecond = null;
|
|
1170
|
+
this.#lastTokensPerSecondTimestamp = null;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Explicit Git/repository cache invalidation. Aborts any in-flight
|
|
1175
|
+
* reftable HEAD/PR resolve, bumps the stale-result generation, and drops
|
|
1176
|
+
* the branch/PR/jj caches so the next render refetches from disk. Called
|
|
1177
|
+
* by the git watcher on a HEAD move and by {@link applyCwdChange} on a
|
|
1178
|
+
* repo/cwd switch. Generic repaints use {@link invalidate} instead and
|
|
1179
|
+
* must never reach this path.
|
|
1180
|
+
*/
|
|
1181
|
+
invalidateGitCaches(): void {
|
|
1182
|
+
this.#invalidateStatusLineRenderCache();
|
|
1183
|
+
this.#cachedBranch = undefined;
|
|
1184
|
+
this.#cachedBranchRepoId = undefined;
|
|
1185
|
+
this.#cachedBranchCwd = undefined;
|
|
1186
|
+
// Abort before releasing the in-flight slot. Releasing alone would allow
|
|
1187
|
+
// repeated invalidations to fan out still-running git subprocesses.
|
|
1188
|
+
this.#branchResolveActive?.controller.abort();
|
|
1189
|
+
this.#branchResolveActive = undefined;
|
|
1190
|
+
this.#branchLastFetch = undefined;
|
|
1191
|
+
this.#branchCacheGeneration++;
|
|
1192
|
+
this.#cachedPrContext = undefined;
|
|
1193
|
+
// jj label/status share the git segment's lifecycle: a HEAD move (e.g. a
|
|
1194
|
+
// colocated `jj new`/bookmark move) must drop the throttled jj caches too,
|
|
1195
|
+
// so the next render refetches.
|
|
1196
|
+
this.#resetJjRequests();
|
|
1197
|
+
this.#cachedJjBranch = null;
|
|
1198
|
+
this.#jjBranchLastFetch = 0;
|
|
1199
|
+
this.#cachedJjStatus = null;
|
|
1200
|
+
this.#jjStatusLastFetch = 0;
|
|
1201
|
+
this.#jjCacheGeneration++;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Re-point the status line's VCS watcher and caches at a new cwd/repository.
|
|
1206
|
+
* Atomically retires the old watcher/listeners, invalidates VCS caches and
|
|
1207
|
+
* in-flight controllers, then runs watcher setup for the new cwd and requests
|
|
1208
|
+
* a repaint. Called by {@link InteractiveMode.applyCwdChange} after the
|
|
1209
|
+
* SessionManager's cwd has moved — the watcher ownership always follows the
|
|
1210
|
+
* effective cwd/repo, so a stale watcher for the previous repo can never
|
|
1211
|
+
* invalidate the new one. Generic repaints use {@link invalidate} and must
|
|
1212
|
+
* never retire the watcher or abort a live resolve.
|
|
1213
|
+
*/
|
|
1214
|
+
applyCwdChange(): void {
|
|
1215
|
+
this.#retireGitWatcher();
|
|
1216
|
+
this.invalidateGitCaches();
|
|
1217
|
+
this.#setupGitWatcher();
|
|
1218
|
+
this.#onBranchChange?.();
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
#resetJjRequests(): void {
|
|
1222
|
+
this.#jjBranchActive?.controller.abort();
|
|
1223
|
+
this.#jjBranchActive = undefined;
|
|
1224
|
+
this.#jjStatusActive?.controller.abort();
|
|
1225
|
+
this.#jjStatusActive = undefined;
|
|
1226
|
+
}
|
|
1227
|
+
#getBranchLabel(
|
|
1228
|
+
activeRepoCache: ActiveRepoCache = this.#resolveActiveRepoCache(),
|
|
1229
|
+
// Presentation defaults to the display detector; PR lookup passes the
|
|
1230
|
+
// operational repository so a jj label never becomes a GitHub head.
|
|
1231
|
+
repository: VcsRepo | null = this.#resolveDisplayRepository(activeRepoCache),
|
|
1232
|
+
): string | null {
|
|
1233
|
+
if (!this.#gitEnabled()) return null;
|
|
1234
|
+
|
|
1235
|
+
const gitCwd = activeRepoCache.effectiveGitCwd;
|
|
1236
|
+
if (!repository) return null;
|
|
1237
|
+
const gitRepository = repository.asGit();
|
|
1238
|
+
if (!gitRepository) {
|
|
1239
|
+
if (this.#jjBranchActive || Date.now() - this.#jjBranchLastFetch < JJ_REFRESH_TTL_MS) {
|
|
1240
|
+
return this.#cachedJjBranch;
|
|
1241
|
+
}
|
|
1242
|
+
const request: JjResolveRequest = {
|
|
1243
|
+
id: ++this.#jjResolveSeq,
|
|
1244
|
+
controller: new AbortController(),
|
|
1245
|
+
};
|
|
1246
|
+
this.#jjBranchActive = request;
|
|
1247
|
+
const generation = this.#jjCacheGeneration;
|
|
1248
|
+
(async () => {
|
|
1249
|
+
let next: string | null = null;
|
|
1250
|
+
try {
|
|
1251
|
+
const raw =
|
|
1252
|
+
(await repository.label(
|
|
1253
|
+
AbortSignal.any([request.controller.signal, AbortSignal.timeout(JJ_COMMAND_TIMEOUT_MS)]),
|
|
1254
|
+
)) ?? null;
|
|
1255
|
+
// Repository-controlled jj metadata can carry control
|
|
1256
|
+
// characters; sanitize at the cache boundary (the git segment
|
|
1257
|
+
// renders the label verbatim).
|
|
1258
|
+
next = raw === null ? null : sanitizeStatusText(raw);
|
|
1259
|
+
} catch {
|
|
1260
|
+
next = null;
|
|
1261
|
+
} finally {
|
|
1262
|
+
if (this.#jjBranchActive?.id === request.id) this.#jjBranchActive = undefined;
|
|
1263
|
+
if (this.#jjCacheGeneration === generation) this.#jjBranchLastFetch = Date.now();
|
|
1264
|
+
}
|
|
1265
|
+
if (this.#jjCacheGeneration !== generation || this.#disposed) return;
|
|
1266
|
+
const changed = next !== this.#cachedJjBranch;
|
|
1267
|
+
this.#cachedJjBranch = next;
|
|
1268
|
+
if (changed) {
|
|
1269
|
+
this.#invalidateStatusLineRenderCache();
|
|
1270
|
+
this.#onBranchChange?.();
|
|
1271
|
+
}
|
|
1272
|
+
})();
|
|
1273
|
+
return this.#cachedJjBranch;
|
|
1274
|
+
}
|
|
1275
|
+
const fallbackCacheExpired =
|
|
1276
|
+
this.#gitWatcherUnavailable &&
|
|
1277
|
+
(this.#branchLastFetch === undefined || Date.now() - this.#branchLastFetch >= WATCHER_FAILURE_POLL_TTL_MS);
|
|
1278
|
+
if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === gitCwd && !fallbackCacheExpired) {
|
|
1279
|
+
return this.#cachedBranch;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// A reftable repo resolves HEAD by spawning `git symbolic-ref` +
|
|
1283
|
+
// `git rev-parse` — the unbounded spawn that froze the render path (F7).
|
|
1284
|
+
// A non-reftable repo resolves HEAD with cheap sync filesystem reads, so
|
|
1285
|
+
// only the reftable branch moves off the render path, mirroring
|
|
1286
|
+
// #getStatus and the Jujutsu label path above.
|
|
1287
|
+
const repoInfo = vcs.gitInfo(gitCwd);
|
|
1288
|
+
if (repoInfo?.isReftable) {
|
|
1289
|
+
if (this.#branchResolveActive !== undefined) {
|
|
1290
|
+
return this.#branchResolveActive.cwd === gitCwd && this.#cachedBranchCwd === gitCwd
|
|
1291
|
+
? (this.#cachedBranch ?? null)
|
|
1292
|
+
: null;
|
|
1293
|
+
}
|
|
1294
|
+
const request: BranchResolveRequest = {
|
|
1295
|
+
id: ++this.#branchResolveSeq,
|
|
1296
|
+
cwd: gitCwd,
|
|
1297
|
+
controller: new AbortController(),
|
|
1298
|
+
};
|
|
1299
|
+
this.#branchResolveActive = request;
|
|
1300
|
+
// Capture the cache generation at launch. invalidateGitCaches bumps it
|
|
1301
|
+
// on a HEAD move and clears the in-flight slot, so a fresher resolve can
|
|
1302
|
+
// start while this one is still pending. Without a generation check the
|
|
1303
|
+
// older resolve would finish later, install its stale HEAD, and clear the
|
|
1304
|
+
// slot — dropping the fresh result and freezing the status line on the
|
|
1305
|
+
// pre-change branch. Mirrors #jjCacheGeneration / #getBranchLabel.
|
|
1306
|
+
const generation = this.#branchCacheGeneration;
|
|
1307
|
+
(async () => {
|
|
1308
|
+
let next: string | null = null;
|
|
1309
|
+
let repoId: string | null = null;
|
|
1310
|
+
try {
|
|
1311
|
+
const headState = await gitRepository.head(request.controller.signal);
|
|
1312
|
+
repoId = repoInfo.headPath;
|
|
1313
|
+
next = headState.kind === "ref" ? (headState.branch ?? headState.refName ?? "HEAD") : "detached";
|
|
1314
|
+
} catch {
|
|
1315
|
+
next = null;
|
|
1316
|
+
} finally {
|
|
1317
|
+
// Release the slot only if this resolve still owns it: after an
|
|
1318
|
+
// invalidation a fresher resolve may hold it, and freeing that
|
|
1319
|
+
// slot here would let a third same-generation resolve launch and
|
|
1320
|
+
// race the fresh one to the cache commit.
|
|
1321
|
+
if (this.#branchResolveActive?.id === request.id) this.#branchResolveActive = undefined;
|
|
1322
|
+
}
|
|
1323
|
+
// Only the latest generation may update the cache; a mismatch means a
|
|
1324
|
+
// newer resolve superseded this one (or the component disposed).
|
|
1325
|
+
if (this.#branchCacheGeneration !== generation || this.#disposed) return;
|
|
1326
|
+
const prev = this.#cachedBranchCwd === gitCwd ? this.#cachedBranch : undefined;
|
|
1327
|
+
this.#cachedBranchCwd = gitCwd;
|
|
1328
|
+
this.#cachedBranchRepoId = repoId;
|
|
1329
|
+
this.#cachedBranch = next;
|
|
1330
|
+
this.#branchLastFetch = Date.now();
|
|
1331
|
+
if (prev !== next) {
|
|
1332
|
+
this.#invalidateStatusLineRenderCache();
|
|
1333
|
+
this.#onBranchChange?.();
|
|
1334
|
+
}
|
|
1335
|
+
})();
|
|
1336
|
+
return this.#cachedBranchCwd === gitCwd ? (this.#cachedBranch ?? null) : null;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Non-reftable: cheap sync filesystem read, safe on the render path.
|
|
1340
|
+
const head = (() => {
|
|
1341
|
+
try {
|
|
1342
|
+
return gitRepository.headSync();
|
|
1343
|
+
} catch {
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
})();
|
|
1347
|
+
const gitHeadPath = repoInfo?.headPath ?? null;
|
|
1348
|
+
this.#cachedBranchCwd = gitCwd;
|
|
1349
|
+
this.#cachedBranchRepoId = gitHeadPath;
|
|
1350
|
+
this.#branchLastFetch = Date.now();
|
|
1351
|
+
if (!head) {
|
|
1352
|
+
this.#cachedBranch = null;
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
this.#cachedBranch = head.kind === "ref" ? (head.branch ?? head.refName ?? "HEAD") : "detached";
|
|
1356
|
+
return this.#cachedBranch ?? null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
#isDefaultBranch(branch: string, effectiveGitCwd: string): boolean {
|
|
1360
|
+
if (this.#defaultBranchCwd !== effectiveGitCwd) {
|
|
1361
|
+
this.#defaultBranch = undefined;
|
|
1362
|
+
this.#defaultBranchCwd = effectiveGitCwd;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (this.#defaultBranch === undefined) {
|
|
1366
|
+
this.#defaultBranch = "main";
|
|
1367
|
+
const lookupCwd = effectiveGitCwd;
|
|
1368
|
+
(async () => {
|
|
1369
|
+
const resolved = await vcs.git(lookupCwd)?.defaultBranch();
|
|
1370
|
+
if (this.#disposed || this.#defaultBranchCwd !== lookupCwd) return;
|
|
1371
|
+
if (resolved) {
|
|
1372
|
+
this.#defaultBranch = resolved;
|
|
1373
|
+
this.#invalidateStatusLineRenderCache();
|
|
1374
|
+
this.#onBranchChange?.();
|
|
1375
|
+
}
|
|
1376
|
+
})();
|
|
1377
|
+
}
|
|
1378
|
+
return branch === this.#defaultBranch;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
#getStatus(activeRepoCache: ActiveRepoCache = this.#resolveActiveRepoCache()): {
|
|
1382
|
+
staged: number;
|
|
1383
|
+
unstaged: number;
|
|
1384
|
+
untracked: number;
|
|
1385
|
+
} | null {
|
|
1386
|
+
if (!this.#gitEnabled()) return null;
|
|
1387
|
+
|
|
1388
|
+
const gitCwd = activeRepoCache.effectiveGitCwd;
|
|
1389
|
+
const repository = this.#resolveRepository(activeRepoCache);
|
|
1390
|
+
if (!repository) return null;
|
|
1391
|
+
if (repository.kind() === "jj") {
|
|
1392
|
+
if (this.#jjStatusActive || Date.now() - this.#jjStatusLastFetch < JJ_REFRESH_TTL_MS) {
|
|
1393
|
+
return this.#cachedJjStatus;
|
|
1394
|
+
}
|
|
1395
|
+
const request: JjResolveRequest = {
|
|
1396
|
+
id: ++this.#jjResolveSeq,
|
|
1397
|
+
controller: new AbortController(),
|
|
1398
|
+
};
|
|
1399
|
+
this.#jjStatusActive = request;
|
|
1400
|
+
const generation = this.#jjCacheGeneration;
|
|
1401
|
+
(async () => {
|
|
1402
|
+
let next: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
1403
|
+
try {
|
|
1404
|
+
next = await repository.statusSummary(
|
|
1405
|
+
AbortSignal.any([request.controller.signal, AbortSignal.timeout(JJ_COMMAND_TIMEOUT_MS)]),
|
|
1406
|
+
);
|
|
1407
|
+
} catch {
|
|
1408
|
+
next = null;
|
|
1409
|
+
} finally {
|
|
1410
|
+
if (this.#jjStatusActive?.id === request.id) this.#jjStatusActive = undefined;
|
|
1411
|
+
if (this.#jjCacheGeneration === generation) this.#jjStatusLastFetch = Date.now();
|
|
1412
|
+
}
|
|
1413
|
+
if (this.#jjCacheGeneration !== generation || this.#disposed) return;
|
|
1414
|
+
const prev = this.#cachedJjStatus;
|
|
1415
|
+
this.#cachedJjStatus = next;
|
|
1416
|
+
if (JSON.stringify(prev) !== JSON.stringify(next)) {
|
|
1417
|
+
this.#invalidateStatusLineRenderCache();
|
|
1418
|
+
this.#onBranchChange?.();
|
|
1419
|
+
}
|
|
1420
|
+
})();
|
|
1421
|
+
return this.#cachedJjStatus;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (this.#gitStatusInFlightCwd !== undefined) {
|
|
1425
|
+
return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
|
|
1426
|
+
}
|
|
1427
|
+
if (this.#cachedGitStatusCwd === gitCwd && Date.now() - this.#gitStatusLastFetch < 1000) {
|
|
1428
|
+
return this.#cachedGitStatus;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
this.#gitStatusInFlightCwd = gitCwd;
|
|
1432
|
+
|
|
1433
|
+
(async () => {
|
|
1434
|
+
let nextStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
1435
|
+
try {
|
|
1436
|
+
nextStatus = (await repository.statusSummary()) ?? null;
|
|
1437
|
+
} catch {
|
|
1438
|
+
nextStatus = null;
|
|
1439
|
+
} finally {
|
|
1440
|
+
if (this.#gitStatusInFlightCwd === gitCwd) {
|
|
1441
|
+
const prev = this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
|
|
1442
|
+
this.#cachedGitStatus = nextStatus;
|
|
1443
|
+
this.#cachedGitStatusCwd = gitCwd;
|
|
1444
|
+
this.#gitStatusLastFetch = Date.now();
|
|
1445
|
+
this.#gitStatusInFlightCwd = undefined;
|
|
1446
|
+
if (!this.#disposed && JSON.stringify(prev) !== JSON.stringify(nextStatus)) {
|
|
1447
|
+
this.#invalidateStatusLineRenderCache();
|
|
1448
|
+
this.#onBranchChange?.();
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
})();
|
|
1453
|
+
|
|
1454
|
+
return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
#lookupPr(activeRepoCache: ActiveRepoCache = this.#resolveActiveRepoCache()): {
|
|
1458
|
+
number: number;
|
|
1459
|
+
url: string;
|
|
1460
|
+
} | null {
|
|
1461
|
+
if (!this.#gitEnabled()) return null;
|
|
1462
|
+
|
|
1463
|
+
const gitCwd = activeRepoCache.effectiveGitCwd;
|
|
1464
|
+
if (this.#resolveRepository(activeRepoCache)?.kind() !== "git") return null;
|
|
1465
|
+
const branch = this.#getBranchLabel(activeRepoCache, this.#resolveRepository(activeRepoCache));
|
|
1466
|
+
const currentContext = branch ? createPrCacheContext(branch, this.#cachedBranchRepoId ?? null) : null;
|
|
1467
|
+
|
|
1468
|
+
if (canReuseCachedPr(this.#cachedPr, this.#cachedPrContext, currentContext)) {
|
|
1469
|
+
return this.#cachedPr ?? null;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const stalePr = this.#cachedPr;
|
|
1473
|
+
|
|
1474
|
+
if (!branch) {
|
|
1475
|
+
this.#cachedPr = null;
|
|
1476
|
+
this.#cachedPrContext = undefined;
|
|
1477
|
+
return null;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Don't look up if detached, default branch, or already in flight.
|
|
1481
|
+
if (branch === "detached" || this.#isDefaultBranch(branch, gitCwd) || this.#prLookupInFlight) {
|
|
1482
|
+
return stalePr ?? null;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
this.#prLookupInFlight = true;
|
|
1486
|
+
const lookupContext = currentContext;
|
|
1487
|
+
const lookupCwd = gitCwd;
|
|
1488
|
+
|
|
1489
|
+
// Fire async lookup, keep stale value visible until resolved
|
|
1490
|
+
(async () => {
|
|
1491
|
+
// Helper: only write cache if branch/repo context hasn't changed since launch
|
|
1492
|
+
const setCachedPr = (value: { number: number; url: string } | null) => {
|
|
1493
|
+
const latestActiveRepoCache = this.#resolveActiveRepoCache();
|
|
1494
|
+
if (latestActiveRepoCache.effectiveGitCwd !== lookupCwd) return;
|
|
1495
|
+
const latestBranch = this.#getBranchLabel(
|
|
1496
|
+
latestActiveRepoCache,
|
|
1497
|
+
this.#resolveRepository(latestActiveRepoCache),
|
|
1498
|
+
);
|
|
1499
|
+
const latestContext = latestBranch
|
|
1500
|
+
? createPrCacheContext(latestBranch, this.#cachedBranchRepoId ?? null)
|
|
1501
|
+
: undefined;
|
|
1502
|
+
if (lookupContext && isSamePrCacheContext(latestContext, lookupContext)) {
|
|
1503
|
+
this.#cachedPr = value;
|
|
1504
|
+
this.#cachedPrContext = lookupContext;
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
try {
|
|
1508
|
+
// Route through the shared `gh` helper so the child inherits
|
|
1509
|
+
// `GH_NON_INTERACTIVE_ENV` (disables terminal/keychain prompts) and
|
|
1510
|
+
// hard-terminates on the git command deadline instead of stalling
|
|
1511
|
+
// the status-line indefinitely (#4234). Requires `gh repo set-default`;
|
|
1512
|
+
// non-zero exit still falls through to the null cache below.
|
|
1513
|
+
const result = await this.host.lookupPullRequest(lookupCwd);
|
|
1514
|
+
if (this.#disposed) return;
|
|
1515
|
+
if (result.exitCode !== 0) {
|
|
1516
|
+
setCachedPr(null);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
const pr = JSON.parse(result.stdout) as { number: number; url: string };
|
|
1520
|
+
if (typeof pr.number === "number") {
|
|
1521
|
+
setCachedPr({ number: pr.number, url: pr.url });
|
|
1522
|
+
} else {
|
|
1523
|
+
setCachedPr(null);
|
|
1524
|
+
}
|
|
1525
|
+
} catch {
|
|
1526
|
+
if (this.#disposed) return;
|
|
1527
|
+
setCachedPr(null);
|
|
1528
|
+
} finally {
|
|
1529
|
+
this.#prLookupInFlight = false;
|
|
1530
|
+
if (!this.#disposed) {
|
|
1531
|
+
this.#invalidateStatusLineRenderCache();
|
|
1532
|
+
this.#onBranchChange?.();
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
})();
|
|
1536
|
+
|
|
1537
|
+
return stalePr ?? null;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
#getTokensPerSecond(): number | null {
|
|
1541
|
+
// Aggregate tok/s across the main session AND every live vibe worker.
|
|
1542
|
+
// In vibe mode the director is often idle while workers stream, so the
|
|
1543
|
+
// main session's own rate alone would show a stale/zero value while
|
|
1544
|
+
// parallel work is actively generating tokens.
|
|
1545
|
+
const workerRate = this.#getVibeWorkerTokensPerSecond();
|
|
1546
|
+
if (workerRate !== null) {
|
|
1547
|
+
// At least one worker is streaming — add the director's live rate
|
|
1548
|
+
// only when it is itself streaming (a finalized last-turn rate would
|
|
1549
|
+
// double-count and overstate throughput).
|
|
1550
|
+
const mainRate = this.session.isStreaming
|
|
1551
|
+
? this.host.calculateTokensPerSecond(this.session.state.messages, true)
|
|
1552
|
+
: 0;
|
|
1553
|
+
return (mainRate ?? 0) + workerRate;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// No workers streaming — fall back to the main session's own rate with
|
|
1557
|
+
// its sticky per-assistant-message cache so the badge doesn't flicker
|
|
1558
|
+
// off in the brief gap between stream end and the finalized message.
|
|
1559
|
+
return this.#getMainSessionTokensPerSecond();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Main session's tok/s with sticky caching keyed on the last assistant
|
|
1564
|
+
* message timestamp. Preserves the pre-aggregation behavior when no vibe
|
|
1565
|
+
* workers are active.
|
|
1566
|
+
*/
|
|
1567
|
+
#getMainSessionTokensPerSecond(): number | null {
|
|
1568
|
+
let lastAssistantTimestamp: number | null = null;
|
|
1569
|
+
for (let i = this.session.state.messages.length - 1; i >= 0; i--) {
|
|
1570
|
+
const message = this.session.state.messages[i];
|
|
1571
|
+
if (message?.role === "assistant") {
|
|
1572
|
+
lastAssistantTimestamp = message.timestamp;
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
if (lastAssistantTimestamp === null) {
|
|
1578
|
+
this.#lastTokensPerSecond = null;
|
|
1579
|
+
this.#lastTokensPerSecondTimestamp = null;
|
|
1580
|
+
return null;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const rate = this.host.calculateTokensPerSecond(this.session.state.messages, this.session.isStreaming);
|
|
1584
|
+
if (rate !== null) {
|
|
1585
|
+
this.#lastTokensPerSecond = rate;
|
|
1586
|
+
this.#lastTokensPerSecondTimestamp = lastAssistantTimestamp;
|
|
1587
|
+
return rate;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
if (this.#lastTokensPerSecondTimestamp === lastAssistantTimestamp) {
|
|
1591
|
+
return this.#lastTokensPerSecond;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Aggregate tok/s across every live vibe worker session owned by this
|
|
1599
|
+
* session. Returns null when no workers are streaming (so the main
|
|
1600
|
+
* session's own rate shines through unchanged). The aggregation itself is
|
|
1601
|
+
* injected via {@link setVibeWorkerTokenRateProvider} to keep this render
|
|
1602
|
+
* layer off the heavy vibe/task dependency graph.
|
|
1603
|
+
*/
|
|
1604
|
+
#getVibeWorkerTokensPerSecond(): number | null {
|
|
1605
|
+
return this.#vibeWorkerTokenRate?.() ?? null;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
#formatUsageContextKey(activeProvider: string | undefined, identity: OAuthAccountIdentity | undefined): string {
|
|
1609
|
+
if (!activeProvider) return "";
|
|
1610
|
+
// orgId is part of the key: rotating between two same-email Anthropic
|
|
1611
|
+
// subscriptions must invalidate the cached usage immediately instead of
|
|
1612
|
+
// showing the previous org's quota for the rest of the cache TTL.
|
|
1613
|
+
return [
|
|
1614
|
+
activeProvider,
|
|
1615
|
+
identity?.accountId ?? "",
|
|
1616
|
+
identity?.email ?? "",
|
|
1617
|
+
identity?.projectId ?? "",
|
|
1618
|
+
identity?.orgId ?? "",
|
|
1619
|
+
].join("\0");
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
#getUsageContextKey(session: TSession): string {
|
|
1623
|
+
const activeProvider = session.state.model?.provider ?? session.model?.provider;
|
|
1624
|
+
const identity = activeProvider ? this.host.activeAccount(session, activeProvider) : undefined;
|
|
1625
|
+
// Model id is part of the invalidation key (but not the account-scoped
|
|
1626
|
+
// fireworks key): normalized usage now selects a model-scoped window group,
|
|
1627
|
+
// so switching models must drop the previous model's cached scope instead
|
|
1628
|
+
// of showing it for the rest of the TTL.
|
|
1629
|
+
const activeModelId = session.state.model?.id ?? session.model?.id ?? "";
|
|
1630
|
+
return `${this.#formatUsageContextKey(activeProvider, identity)}\0${activeModelId}`;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Startup redraws only arm a short-delayed task; timeout releases the render
|
|
1635
|
+
* cadence while a late successful fetch can still refresh the cached segment.
|
|
1636
|
+
*/
|
|
1637
|
+
refreshUsageInBackground(): void {
|
|
1638
|
+
const now = Date.now();
|
|
1639
|
+
const session = this.session;
|
|
1640
|
+
const usageContextKey = this.#getUsageContextKey(session);
|
|
1641
|
+
if (this.#cachedUsageContextKey !== usageContextKey) {
|
|
1642
|
+
this.#cachedUsage = null;
|
|
1643
|
+
this.#usageFetchedAt = 0;
|
|
1644
|
+
this.#cachedUsageContextKey = usageContextKey;
|
|
1645
|
+
}
|
|
1646
|
+
if (this.#usageInFlight || this.#usageStartTimer) return;
|
|
1647
|
+
if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
|
|
1648
|
+
if (!this.host.canFetchUsageReports(session)) return;
|
|
1649
|
+
this.#usageInFlight = true;
|
|
1650
|
+
this.#usageStartTimer = setTimeout(() => {
|
|
1651
|
+
this.#usageStartTimer = null;
|
|
1652
|
+
void this.#runUsageRefresh(session);
|
|
1653
|
+
}, STATUS_USAGE_START_DELAY_MS);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
async #runUsageRefresh(session: TSession): Promise<void> {
|
|
1657
|
+
if (this.#disposed || this.session !== session) {
|
|
1658
|
+
this.#usageInFlight = false;
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
const sequence = ++this.#usageRefreshSequence;
|
|
1662
|
+
const signal = AbortSignal.timeout(STATUS_USAGE_REFRESH_TIMEOUT_MS);
|
|
1663
|
+
let reportsPromise: Promise<unknown> | undefined;
|
|
1664
|
+
try {
|
|
1665
|
+
reportsPromise = this.host.fetchUsageReports(session, signal);
|
|
1666
|
+
this.#applyUsageRefreshReports(
|
|
1667
|
+
session,
|
|
1668
|
+
await this.#raceUsageRefreshWithSignal(reportsPromise, signal),
|
|
1669
|
+
sequence,
|
|
1670
|
+
);
|
|
1671
|
+
} catch {
|
|
1672
|
+
if (this.session !== session) return;
|
|
1673
|
+
this.#usageFetchedAt = Date.now();
|
|
1674
|
+
if (signal.aborted && reportsPromise) {
|
|
1675
|
+
this.#observeLateUsageRefresh(session, reportsPromise, sequence);
|
|
1676
|
+
}
|
|
1677
|
+
} finally {
|
|
1678
|
+
if (this.session === session) this.#usageInFlight = false;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
#applyUsageRefreshReports(session: TSession, reports: unknown, sequence: number): void {
|
|
1683
|
+
if (this.#disposed || this.session !== session || sequence < this.#latestAppliedUsageRefreshSequence) {
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
this.#latestAppliedUsageRefreshSequence = sequence;
|
|
1687
|
+
const activeProvider = session.state.model?.provider ?? session.model?.provider;
|
|
1688
|
+
const activeModelId = session.state.model?.id ?? session.model?.id;
|
|
1689
|
+
const activeIdentity = activeProvider ? this.host.activeAccount(session, activeProvider) : undefined;
|
|
1690
|
+
const normalized = this.#normalizeUsageReports(reports, {
|
|
1691
|
+
provider: activeProvider,
|
|
1692
|
+
modelId: activeModelId,
|
|
1693
|
+
identity: activeIdentity,
|
|
1694
|
+
});
|
|
1695
|
+
const resetSnapshot =
|
|
1696
|
+
activeProvider === "openai-codex" ? this.#normalizeCodexResetSnapshot(reports, activeIdentity) : null;
|
|
1697
|
+
const usageChanged = this.#cachedUsage !== normalized;
|
|
1698
|
+
this.#cachedUsage = normalized;
|
|
1699
|
+
this.#usageFetchedAt = Date.now();
|
|
1700
|
+
// Usage fetch is async; without a repaint the top border stays blank until
|
|
1701
|
+
// some unrelated event (git resolve, keystroke, …) rebuilds it.
|
|
1702
|
+
if (usageChanged) {
|
|
1703
|
+
this.#invalidateStatusLineRenderCache();
|
|
1704
|
+
this.#onBranchChange?.();
|
|
1705
|
+
}
|
|
1706
|
+
if (!resetSnapshot) return;
|
|
1707
|
+
const contextKey = this.#formatUsageContextKey(activeProvider, activeIdentity);
|
|
1708
|
+
const previous = this.#codexResetSnapshots.get(contextKey);
|
|
1709
|
+
this.#codexResetSnapshots.set(contextKey, resetSnapshot);
|
|
1710
|
+
if (!previous || !this.host.codexResetFireworksEnabled()) return;
|
|
1711
|
+
const event = detectCodexResetFireworks(previous, resetSnapshot);
|
|
1712
|
+
if (event) this.#onCodexResetFireworks?.(event);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
#observeLateUsageRefresh(session: TSession, reportsPromise: Promise<unknown>, sequence: number): void {
|
|
1716
|
+
void reportsPromise
|
|
1717
|
+
.then(reports => {
|
|
1718
|
+
this.#applyUsageRefreshReports(session, reports, sequence);
|
|
1719
|
+
})
|
|
1720
|
+
.catch(() => {
|
|
1721
|
+
if (this.#disposed || this.session !== session || sequence < this.#latestAppliedUsageRefreshSequence) {
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
this.#usageFetchedAt = Date.now();
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async #raceUsageRefreshWithSignal(promise: Promise<unknown>, signal: AbortSignal): Promise<unknown> {
|
|
1729
|
+
if (signal.aborted) throw signal.reason;
|
|
1730
|
+
const aborted = Promise.withResolvers<never>();
|
|
1731
|
+
const onAbort = () => aborted.reject(signal.reason);
|
|
1732
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1733
|
+
try {
|
|
1734
|
+
return await Promise.race([promise, aborted.promise]);
|
|
1735
|
+
} finally {
|
|
1736
|
+
signal.removeEventListener("abort", onAbort);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
#normalizeCodexResetSnapshot(
|
|
1741
|
+
reports: unknown,
|
|
1742
|
+
activeIdentity: OAuthAccountIdentity | undefined,
|
|
1743
|
+
): CodexResetUsageSnapshot | null {
|
|
1744
|
+
if (!Array.isArray(reports)) return null;
|
|
1745
|
+
let matchingReport: UsageReport | undefined;
|
|
1746
|
+
for (const report of reports) {
|
|
1747
|
+
if (!report || typeof report !== "object") continue;
|
|
1748
|
+
if (
|
|
1749
|
+
!("provider" in report) ||
|
|
1750
|
+
report.provider !== "openai-codex" ||
|
|
1751
|
+
!("limits" in report) ||
|
|
1752
|
+
!Array.isArray(report.limits)
|
|
1753
|
+
) {
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
// The report boundary above validates the fields this extractor iterates;
|
|
1757
|
+
// optional metadata and credit fields are narrowed again before use.
|
|
1758
|
+
const usageReport = report as UsageReport;
|
|
1759
|
+
if (!codexReportMatchesExactIdentity(usageReport, activeIdentity)) continue;
|
|
1760
|
+
matchingReport = usageReport;
|
|
1761
|
+
break;
|
|
1762
|
+
}
|
|
1763
|
+
if (!matchingReport) return null;
|
|
1764
|
+
|
|
1765
|
+
const plan =
|
|
1766
|
+
typeof matchingReport.metadata?.planType === "string" && matchingReport.metadata.planType
|
|
1767
|
+
? matchingReport.metadata.planType
|
|
1768
|
+
: undefined;
|
|
1769
|
+
let sevenDay: CodexResetUsageSnapshot["sevenDay"];
|
|
1770
|
+
let sevenDayTier: string | undefined;
|
|
1771
|
+
for (const limit of matchingReport.limits) {
|
|
1772
|
+
if (!limit || typeof limit !== "object") continue;
|
|
1773
|
+
const candidate = limit as {
|
|
1774
|
+
scope?: { windowId?: string; tier?: string };
|
|
1775
|
+
window?: { resetsAt?: number };
|
|
1776
|
+
amount?: { usedFraction?: number };
|
|
1777
|
+
};
|
|
1778
|
+
const fraction = candidate.amount?.usedFraction;
|
|
1779
|
+
if (candidate.scope?.windowId !== "7d" || typeof fraction !== "number" || !Number.isFinite(fraction)) {
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
const tier =
|
|
1783
|
+
typeof candidate.scope?.tier === "string" && candidate.scope.tier ? candidate.scope.tier : undefined;
|
|
1784
|
+
if (sevenDay && (sevenDayTier === undefined || tier)) continue;
|
|
1785
|
+
const resetsAt = candidate.window?.resetsAt;
|
|
1786
|
+
sevenDay = {
|
|
1787
|
+
percent: fraction * 100,
|
|
1788
|
+
resetsAt: typeof resetsAt === "number" && Number.isFinite(resetsAt) ? resetsAt : undefined,
|
|
1789
|
+
tier,
|
|
1790
|
+
plan,
|
|
1791
|
+
};
|
|
1792
|
+
sevenDayTier = tier;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
const fetchedAt = matchingReport.fetchedAt;
|
|
1796
|
+
const availableCount = matchingReport.resetCredits?.availableCount;
|
|
1797
|
+
const observedAt = typeof fetchedAt === "number" && Number.isFinite(fetchedAt) ? fetchedAt : undefined;
|
|
1798
|
+
const savedResets =
|
|
1799
|
+
typeof availableCount === "number" && Number.isFinite(availableCount)
|
|
1800
|
+
? Math.max(0, Math.trunc(availableCount))
|
|
1801
|
+
: undefined;
|
|
1802
|
+
if (!sevenDay && savedResets === undefined) return null;
|
|
1803
|
+
return { observedAt, sevenDay, savedResets };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
#normalizeUsageReports(
|
|
1807
|
+
reports: unknown,
|
|
1808
|
+
context: { provider?: string; modelId?: string; identity?: OAuthAccountIdentity },
|
|
1809
|
+
): {
|
|
1810
|
+
tier?: string;
|
|
1811
|
+
fiveHour?: { percent: number; resetMinutes?: number };
|
|
1812
|
+
daily?: { percent: number; resetMinutes?: number };
|
|
1813
|
+
sevenDay?: { percent: number; resetHours?: number };
|
|
1814
|
+
monthly?: { percent: number; resetHours?: number };
|
|
1815
|
+
} | null {
|
|
1816
|
+
if (!Array.isArray(reports)) return null;
|
|
1817
|
+
const now = Date.now();
|
|
1818
|
+
const activeModelId = normalizeUsageScopeValue(context.modelId);
|
|
1819
|
+
const activeAntigravityCounter =
|
|
1820
|
+
context.provider === "google-antigravity" ? getAntigravityCounterKeyForModel(context.modelId) : undefined;
|
|
1821
|
+
const scopeGroups = new Map<string, UsageScopeGroup>();
|
|
1822
|
+
for (const report of reports) {
|
|
1823
|
+
if (!report || typeof report !== "object") continue;
|
|
1824
|
+
const provider = "provider" in report ? report.provider : undefined;
|
|
1825
|
+
if (context.provider && provider !== context.provider) continue;
|
|
1826
|
+
const reportLimits = "limits" in report ? report.limits : undefined;
|
|
1827
|
+
if (!Array.isArray(reportLimits)) continue;
|
|
1828
|
+
// fetchUsageReports supplies normalized rows; the guards above protect
|
|
1829
|
+
// the unknown session boundary before the account matcher reads metadata.
|
|
1830
|
+
const usageReport = report as UsageReport;
|
|
1831
|
+
const limits =
|
|
1832
|
+
provider === "google-antigravity" && activeAntigravityCounter
|
|
1833
|
+
? scopeAntigravityLimitsForModel(usageReport, context)
|
|
1834
|
+
: reportLimits;
|
|
1835
|
+
for (const limit of limits) {
|
|
1836
|
+
if (
|
|
1837
|
+
!limit ||
|
|
1838
|
+
typeof limit !== "object" ||
|
|
1839
|
+
!("scope" in limit) ||
|
|
1840
|
+
!limit.scope ||
|
|
1841
|
+
typeof limit.scope !== "object" ||
|
|
1842
|
+
!("amount" in limit) ||
|
|
1843
|
+
!limit.amount ||
|
|
1844
|
+
typeof limit.amount !== "object"
|
|
1845
|
+
) {
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1848
|
+
const scope = limit.scope;
|
|
1849
|
+
const amount = limit.amount;
|
|
1850
|
+
// The matcher only reads scope identity fields, whose object boundary
|
|
1851
|
+
// is validated above; other required UsageLimit fields are irrelevant.
|
|
1852
|
+
const usageLimit = limit as UsageLimit;
|
|
1853
|
+
if (context.identity && !this.host.limitMatchesActiveAccount(usageReport, usageLimit, context.identity))
|
|
1854
|
+
continue;
|
|
1855
|
+
|
|
1856
|
+
const fraction = "usedFraction" in amount ? amount.usedFraction : undefined;
|
|
1857
|
+
if (typeof fraction !== "number") continue;
|
|
1858
|
+
const window =
|
|
1859
|
+
"window" in limit && limit.window && typeof limit.window === "object" ? limit.window : undefined;
|
|
1860
|
+
const windowId = "windowId" in scope ? scope.windowId : undefined;
|
|
1861
|
+
const durationValue = window && "durationMs" in window ? window.durationMs : undefined;
|
|
1862
|
+
const durationMs = typeof durationValue === "number" ? durationValue : undefined;
|
|
1863
|
+
const subscriptionWindow =
|
|
1864
|
+
windowId === "5h" || windowId === "7d"
|
|
1865
|
+
? windowId
|
|
1866
|
+
: windowId === "daily" || windowId === "24h" || windowId === "1d"
|
|
1867
|
+
? "daily"
|
|
1868
|
+
: durationMs !== undefined && Math.abs(durationMs - 5 * 3_600_000) <= 60_000
|
|
1869
|
+
? "5h"
|
|
1870
|
+
: durationMs !== undefined && Math.abs(durationMs - 86_400_000) <= 60_000
|
|
1871
|
+
? "daily"
|
|
1872
|
+
: durationMs !== undefined && Math.abs(durationMs - 7 * 86_400_000) <= 60_000
|
|
1873
|
+
? "7d"
|
|
1874
|
+
: undefined;
|
|
1875
|
+
const windowClass =
|
|
1876
|
+
subscriptionWindow ??
|
|
1877
|
+
((context.provider === "cursor" || context.provider === "opencode-go") &&
|
|
1878
|
+
(windowId === "monthly" || windowId === "30d")
|
|
1879
|
+
? "monthly"
|
|
1880
|
+
: undefined);
|
|
1881
|
+
if (!windowClass) continue;
|
|
1882
|
+
|
|
1883
|
+
const modelId = normalizeUsageScopeValue("modelId" in scope ? scope.modelId : undefined);
|
|
1884
|
+
if (modelId && modelId !== activeModelId) continue;
|
|
1885
|
+
const rawTier = "tier" in scope ? scope.tier : undefined;
|
|
1886
|
+
const rawPlanType = usageReport.metadata?.planType;
|
|
1887
|
+
// Scoped tiers (Claude Fable, Codex Spark) win; plan-wide tiers
|
|
1888
|
+
// (Z.AI `pro`, Codex `pro`) label otherwise-untiered windows.
|
|
1889
|
+
const tier =
|
|
1890
|
+
(typeof rawTier === "string" && rawTier.trim() ? rawTier.trim() : undefined) ??
|
|
1891
|
+
(typeof rawPlanType === "string" && rawPlanType.trim() ? rawPlanType.trim() : undefined);
|
|
1892
|
+
const normalizedTier = normalizeUsageScopeValue(tier);
|
|
1893
|
+
const scopeKey = `${modelId ?? ""}\0${normalizedTier ?? ""}`;
|
|
1894
|
+
// Exact-model groups outrank provider-wide groups; within either
|
|
1895
|
+
// specificity, untiered limits preserve the historical preference.
|
|
1896
|
+
const priority = modelId ? (normalizedTier ? 1 : 0) : normalizedTier ? 3 : 2;
|
|
1897
|
+
const id = "id" in limit && typeof limit.id === "string" ? limit.id : undefined;
|
|
1898
|
+
const resetValue = window && "resetsAt" in window ? window.resetsAt : undefined;
|
|
1899
|
+
const displayCandidate: UsageWindowCandidate = {
|
|
1900
|
+
id,
|
|
1901
|
+
windowClass,
|
|
1902
|
+
fraction,
|
|
1903
|
+
resetsAt: typeof resetValue === "number" ? resetValue : undefined,
|
|
1904
|
+
};
|
|
1905
|
+
const group = scopeGroups.get(scopeKey);
|
|
1906
|
+
if (group) {
|
|
1907
|
+
group.candidates.push(displayCandidate);
|
|
1908
|
+
} else {
|
|
1909
|
+
scopeGroups.set(scopeKey, { priority, tier, candidates: [displayCandidate] });
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
let selectedGroup: UsageScopeGroup | undefined;
|
|
1915
|
+
for (const group of scopeGroups.values()) {
|
|
1916
|
+
if (!selectedGroup || group.priority < selectedGroup.priority) selectedGroup = group;
|
|
1917
|
+
}
|
|
1918
|
+
if (!selectedGroup) return null;
|
|
1919
|
+
|
|
1920
|
+
let fiveHour: { percent: number; resetMinutes?: number } | undefined;
|
|
1921
|
+
let daily: { percent: number; resetMinutes?: number } | undefined;
|
|
1922
|
+
let sevenDay: { percent: number; resetHours?: number } | undefined;
|
|
1923
|
+
let monthly: { percent: number; resetHours?: number } | undefined;
|
|
1924
|
+
let monthlyPriority = Number.POSITIVE_INFINITY;
|
|
1925
|
+
const cursorMonthlyPriority = (limitId: unknown): number => {
|
|
1926
|
+
// When /auth/usage and /api/usage-summary are merged, prefer the personal
|
|
1927
|
+
// dashboard rails over legacy per-model request fractions.
|
|
1928
|
+
if (limitId === "cursor:usd:individual-auto") return 0;
|
|
1929
|
+
if (limitId === "cursor:usd:individual-plan" || limitId === "cursor:usd:individual-overall") return 1;
|
|
1930
|
+
if (typeof limitId === "string" && limitId.startsWith("cursor:usd:individual-")) return 2;
|
|
1931
|
+
return 3;
|
|
1932
|
+
};
|
|
1933
|
+
for (const candidate of selectedGroup.candidates) {
|
|
1934
|
+
if (candidate.windowClass === "5h" && !fiveHour) {
|
|
1935
|
+
fiveHour = {
|
|
1936
|
+
percent: candidate.fraction * 100,
|
|
1937
|
+
resetMinutes:
|
|
1938
|
+
typeof candidate.resetsAt === "number"
|
|
1939
|
+
? Math.max(0, Math.round((candidate.resetsAt - now) / 60_000))
|
|
1940
|
+
: undefined,
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
if (candidate.windowClass === "daily" && !daily) {
|
|
1944
|
+
daily = {
|
|
1945
|
+
percent: candidate.fraction * 100,
|
|
1946
|
+
resetMinutes:
|
|
1947
|
+
typeof candidate.resetsAt === "number"
|
|
1948
|
+
? Math.max(0, Math.round((candidate.resetsAt - now) / 60_000))
|
|
1949
|
+
: undefined,
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
if (candidate.windowClass === "7d" && !sevenDay) {
|
|
1953
|
+
sevenDay = {
|
|
1954
|
+
percent: candidate.fraction * 100,
|
|
1955
|
+
resetHours:
|
|
1956
|
+
typeof candidate.resetsAt === "number"
|
|
1957
|
+
? Math.max(0, Math.round((candidate.resetsAt - now) / 3_600_000))
|
|
1958
|
+
: undefined,
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
if (candidate.windowClass === "monthly") {
|
|
1962
|
+
const priority = cursorMonthlyPriority(candidate.id);
|
|
1963
|
+
if (priority < monthlyPriority) {
|
|
1964
|
+
monthly = {
|
|
1965
|
+
percent: candidate.fraction * 100,
|
|
1966
|
+
resetHours:
|
|
1967
|
+
typeof candidate.resetsAt === "number"
|
|
1968
|
+
? Math.max(0, Math.round((candidate.resetsAt - now) / 3_600_000))
|
|
1969
|
+
: undefined,
|
|
1970
|
+
};
|
|
1971
|
+
monthlyPriority = priority;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
if (!fiveHour && !daily && !sevenDay && !monthly) return null;
|
|
1976
|
+
return { tier: selectedGroup.tier, fiveHour, daily, sevenDay, monthly };
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/**
|
|
1980
|
+
* Used-tokens / context-window totals for the status-line context% segment,
|
|
1981
|
+
* memoized so the per-event redraw stays O(1) when nothing changed.
|
|
1982
|
+
*
|
|
1983
|
+
* The numerator comes from `session.getContextUsage()`, which anchors on the
|
|
1984
|
+
* last assistant's real prompt-token count — so the bar matches the provider
|
|
1985
|
+
* and the `/context` panel — and reports `null` while that count is unknown
|
|
1986
|
+
* (right after compaction, before the next response). Exposed (non-private)
|
|
1987
|
+
* for unit tests and the collab host's state broadcast.
|
|
1988
|
+
*/
|
|
1989
|
+
getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
|
|
1990
|
+
const messages = this.session.messages ?? EMPTY_MESSAGES;
|
|
1991
|
+
const modelContextWindow = this.session.model?.contextWindow ?? 0;
|
|
1992
|
+
const length = messages.length;
|
|
1993
|
+
const lastFingerprint = length > 0 ? messageFingerprint(messages[length - 1]!) : undefined;
|
|
1994
|
+
// Bumps when the in-flight pending snapshot is set/cleared. Without it a
|
|
1995
|
+
// value computed mid-turn (estimate of the active tail) would survive after
|
|
1996
|
+
// the turn ends/aborts, since clearing the snapshot touches no message.
|
|
1997
|
+
const contextUsageRevision = this.session.contextUsageRevision ?? 0;
|
|
1998
|
+
|
|
1999
|
+
const systemPrompt = this.session.systemPrompt;
|
|
2000
|
+
const tools = this.session.agent?.state?.tools;
|
|
2001
|
+
const toolSchemaMetadataRevision = tools ? getToolSchemaMetadataRevision(tools) : 0;
|
|
2002
|
+
const settingsRevision = this.host.getSessionSettingsRevision(this.session);
|
|
2003
|
+
const tokenizer = this.session.agent?.tokenizer;
|
|
2004
|
+
const skills = this.session.skills;
|
|
2005
|
+
|
|
2006
|
+
const cache = this.#contextUsageCache;
|
|
2007
|
+
if (
|
|
2008
|
+
cache &&
|
|
2009
|
+
cache.messagesRef === messages &&
|
|
2010
|
+
cache.length === length &&
|
|
2011
|
+
cache.lastFingerprint === lastFingerprint &&
|
|
2012
|
+
cache.modelContextWindow === modelContextWindow &&
|
|
2013
|
+
cache.contextUsageRevision === contextUsageRevision &&
|
|
2014
|
+
cache.systemPromptRef === systemPrompt &&
|
|
2015
|
+
cache.toolsRef === tools &&
|
|
2016
|
+
cache.toolSchemaMetadataRevision === toolSchemaMetadataRevision &&
|
|
2017
|
+
cache.settingsRevision === settingsRevision &&
|
|
2018
|
+
cache.tokenizerRef === tokenizer &&
|
|
2019
|
+
cache.skillsRef === skills
|
|
2020
|
+
) {
|
|
2021
|
+
return { usedTokens: cache.usedTokens, contextWindow: cache.contextWindow };
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
const usage = typeof this.session.getContextUsage === "function" ? this.session.getContextUsage() : undefined;
|
|
2025
|
+
const usedTokens = usage?.tokens ?? 0;
|
|
2026
|
+
const contextWindow = usage?.contextWindow ?? modelContextWindow;
|
|
2027
|
+
this.#contextUsageCache = {
|
|
2028
|
+
messagesRef: messages,
|
|
2029
|
+
length,
|
|
2030
|
+
lastFingerprint,
|
|
2031
|
+
modelContextWindow,
|
|
2032
|
+
contextUsageRevision,
|
|
2033
|
+
toolSchemaMetadataRevision,
|
|
2034
|
+
settingsRevision,
|
|
2035
|
+
tokenizerRef: tokenizer,
|
|
2036
|
+
usedTokens,
|
|
2037
|
+
contextWindow,
|
|
2038
|
+
systemPromptRef: systemPrompt,
|
|
2039
|
+
toolsRef: tools,
|
|
2040
|
+
skillsRef: skills,
|
|
2041
|
+
};
|
|
2042
|
+
return { usedTokens, contextWindow };
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
#buildSegmentContext(
|
|
2046
|
+
width: number,
|
|
2047
|
+
segmentOptions: StatusLineSettings["segmentOptions"],
|
|
2048
|
+
includePath: boolean,
|
|
2049
|
+
includeGit: boolean,
|
|
2050
|
+
includePr: boolean,
|
|
2051
|
+
nowMs: number,
|
|
2052
|
+
previewTitle?: string,
|
|
2053
|
+
): SegmentContext {
|
|
2054
|
+
const state = this.session.state;
|
|
2055
|
+
|
|
2056
|
+
// Trigger background fetch (5-min TTL); render uses cached value
|
|
2057
|
+
this.refreshUsageInBackground();
|
|
2058
|
+
|
|
2059
|
+
// Get usage statistics
|
|
2060
|
+
const aggregateUsageStats = this.session.sessionManager?.getUsageStatistics() ?? {
|
|
2061
|
+
input: 0,
|
|
2062
|
+
output: 0,
|
|
2063
|
+
cacheRead: 0,
|
|
2064
|
+
cacheWrite: 0,
|
|
2065
|
+
totalTokens: 0,
|
|
2066
|
+
orchestrationInput: 0,
|
|
2067
|
+
orchestrationOutput: 0,
|
|
2068
|
+
orchestrationCacheRead: 0,
|
|
2069
|
+
premiumRequests: 0,
|
|
2070
|
+
cost: 0,
|
|
2071
|
+
};
|
|
2072
|
+
const usageStats = {
|
|
2073
|
+
...aggregateUsageStats,
|
|
2074
|
+
tokensPerSecond: this.#getTokensPerSecond(),
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2077
|
+
let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
|
|
2078
|
+
const breakdown = this.getCachedContextBreakdown();
|
|
2079
|
+
let contextTokens = breakdown.usedTokens;
|
|
2080
|
+
contextWindow = breakdown.contextWindow || contextWindow;
|
|
2081
|
+
let contextPercent: number | null = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null;
|
|
2082
|
+
// Collab guest: context comes from the host's state frames — the local
|
|
2083
|
+
// replica does no accounting of its own.
|
|
2084
|
+
const collabState = this.#collabStatus?.stateOverride;
|
|
2085
|
+
if (collabState?.contextUsage) {
|
|
2086
|
+
contextWindow = collabState.contextUsage.contextWindow || contextWindow;
|
|
2087
|
+
contextTokens = collabState.contextUsage.tokens ?? contextTokens;
|
|
2088
|
+
contextPercent = collabState.contextUsage.percent ?? contextPercent;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
const shouldResolveActiveRepo = this.#gitEnabled() && (includePath || includeGit || includePr);
|
|
2092
|
+
const projectDir = getProjectDir();
|
|
2093
|
+
const activeRepoCache = shouldResolveActiveRepo
|
|
2094
|
+
? this.#resolveActiveRepoCache()
|
|
2095
|
+
: {
|
|
2096
|
+
projectDir,
|
|
2097
|
+
activeRepo: null,
|
|
2098
|
+
effectiveGitCwd: projectDir,
|
|
2099
|
+
repository: null,
|
|
2100
|
+
displayRepository: null,
|
|
2101
|
+
displayRepositoryCheckedAt: Date.now(),
|
|
2102
|
+
repositoryCheckedAt: Date.now(),
|
|
2103
|
+
worktree: null,
|
|
2104
|
+
};
|
|
2105
|
+
const gitBranch = includeGit || includePr ? this.#getBranchLabel(activeRepoCache) : null;
|
|
2106
|
+
const gitStatus = includeGit ? this.#getStatus(activeRepoCache) : null;
|
|
2107
|
+
const gitPr = includePr ? this.#lookupPr(activeRepoCache) : null;
|
|
2108
|
+
const compactionSpeculation = this.session.compactionSpeculation ?? "idle";
|
|
2109
|
+
this.#syncSpeculationBlink(compactionSpeculation);
|
|
2110
|
+
const sessionAccentEnabled = this.#resolveSettings().sessionAccent !== false;
|
|
2111
|
+
const turnElapsedMs = this.getTurnElapsedMs();
|
|
2112
|
+
return {
|
|
2113
|
+
session: this.session,
|
|
2114
|
+
focusedAgentId: this.#focusedAgentId,
|
|
2115
|
+
sessionAccent: sessionAccentEnabled,
|
|
2116
|
+
previewTitle,
|
|
2117
|
+
activeRepo: activeRepoCache.activeRepo,
|
|
2118
|
+
width,
|
|
2119
|
+
options: segmentOptions ?? {},
|
|
2120
|
+
compactThinkingLevel: this.#resolveSettings().compactThinkingLevel ?? false,
|
|
2121
|
+
hookStatuses: this.#sortedHookStatuses,
|
|
2122
|
+
planMode: this.#planModeStatus,
|
|
2123
|
+
loopMode: this.#loopModeStatus,
|
|
2124
|
+
prewalk:
|
|
2125
|
+
typeof this.session.getPrewalkState === "function" && this.session.getPrewalkState()
|
|
2126
|
+
? { enabled: true }
|
|
2127
|
+
: null,
|
|
2128
|
+
goalMode: this.#goalModeStatus,
|
|
2129
|
+
goalStatusInFooter: this.#goalModeStatus ? this.host.goalStatusInFooter(this.session) : false,
|
|
2130
|
+
vibeMode: this.#vibeModeStatus,
|
|
2131
|
+
vim: this.#vimStatus,
|
|
2132
|
+
collab: this.#collabStatus,
|
|
2133
|
+
stream: this.#streamStatus,
|
|
2134
|
+
usageStats,
|
|
2135
|
+
contextPercent,
|
|
2136
|
+
contextTokens,
|
|
2137
|
+
contextWindow,
|
|
2138
|
+
autoCompactEnabled: this.#autoCompactEnabled,
|
|
2139
|
+
compactionSpeculation,
|
|
2140
|
+
speculationBlinkOn: this.#speculationBlinkOn,
|
|
2141
|
+
subagentCount: this.#subagentCount,
|
|
2142
|
+
activeMs: this.getActiveMs(),
|
|
2143
|
+
turnElapsedMs,
|
|
2144
|
+
now: new Date(nowMs),
|
|
2145
|
+
brandFgAnsi: this.#brandFgAnsi(turnElapsedMs !== null, sessionAccentEnabled),
|
|
2146
|
+
git: {
|
|
2147
|
+
branch: gitBranch,
|
|
2148
|
+
status: gitStatus,
|
|
2149
|
+
pr: gitPr,
|
|
2150
|
+
},
|
|
2151
|
+
worktree: activeRepoCache.worktree,
|
|
2152
|
+
usage: this.#cachedUsage,
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
#resolveSettings(): EffectiveStatusLineSettings {
|
|
2157
|
+
if (this.#effectiveSettings === undefined) {
|
|
2158
|
+
this.#effectiveSettings = this.#computeEffectiveSettings();
|
|
2159
|
+
}
|
|
2160
|
+
return this.#effectiveSettings;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
#computeEffectiveSettings(): EffectiveStatusLineSettings {
|
|
2164
|
+
const preset = this.#settings.preset ?? "default";
|
|
2165
|
+
const presetDef = getPreset(preset);
|
|
2166
|
+
const useCustomSegments = preset === "custom";
|
|
2167
|
+
const mergedSegmentOptions: StatusLineSettings["segmentOptions"] = {};
|
|
2168
|
+
|
|
2169
|
+
for (const [segment, options] of Object.entries(presetDef.segmentOptions ?? {})) {
|
|
2170
|
+
mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = { ...(options as Record<string, unknown>) };
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
for (const [segment, options] of Object.entries(this.#settings.segmentOptions ?? {})) {
|
|
2174
|
+
const current = mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] ?? {};
|
|
2175
|
+
mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = {
|
|
2176
|
+
...(current as Record<string, unknown>),
|
|
2177
|
+
...(options as Record<string, unknown>),
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
const leftSegments = useCustomSegments
|
|
2182
|
+
? (this.#settings.leftSegments ?? presetDef.leftSegments)
|
|
2183
|
+
: presetDef.leftSegments;
|
|
2184
|
+
const rightSegments = useCustomSegments
|
|
2185
|
+
? (this.#settings.rightSegments ?? presetDef.rightSegments)
|
|
2186
|
+
: presetDef.rightSegments;
|
|
2187
|
+
|
|
2188
|
+
return {
|
|
2189
|
+
...this.#settings,
|
|
2190
|
+
leftSegments,
|
|
2191
|
+
rightSegments,
|
|
2192
|
+
separator: this.#settings.separator ?? presetDef.separator,
|
|
2193
|
+
segmentOptions: mergedSegmentOptions,
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
#subagentBadgeText(): string | undefined {
|
|
2198
|
+
if (this.#subagentCount === 0) return undefined;
|
|
2199
|
+
return theme.fg("statusLineSubagents", `${theme.icon.agents} ${this.#subagentCount}`);
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* Cheap, non-rendering inputs whose mutation can change a segment without
|
|
2204
|
+
* going through this component's setters. This deliberately uses refs,
|
|
2205
|
+
* scalars, and string lengths only: no schema estimation, serialization,
|
|
2206
|
+
* usage aggregation, VCS probing, or segment getters run on a cache hit.
|
|
2207
|
+
*/
|
|
2208
|
+
#readStatusLineExternalInputs(): StatusLineExternalInputs {
|
|
2209
|
+
const state = this.session.state;
|
|
2210
|
+
const stateMessages = state.messages ?? EMPTY_MESSAGES;
|
|
2211
|
+
const stateLastMessage = stateMessages[stateMessages.length - 1];
|
|
2212
|
+
const stateLastMessageContent =
|
|
2213
|
+
stateLastMessage && typeof stateLastMessage === "object" && "content" in stateLastMessage
|
|
2214
|
+
? stateLastMessage.content
|
|
2215
|
+
: undefined;
|
|
2216
|
+
const messages = this.session.messages ?? EMPTY_MESSAGES;
|
|
2217
|
+
const lastMessage = messages[messages.length - 1];
|
|
2218
|
+
const lastMessageView = lastMessage as
|
|
2219
|
+
| {
|
|
2220
|
+
role?: string;
|
|
2221
|
+
timestamp?: number;
|
|
2222
|
+
content?: unknown;
|
|
2223
|
+
usage?: { totalTokens?: number };
|
|
2224
|
+
stopReason?: string;
|
|
2225
|
+
}
|
|
2226
|
+
| undefined;
|
|
2227
|
+
const content = lastMessageView?.content;
|
|
2228
|
+
let lastMessageContentSize = 0;
|
|
2229
|
+
let lastMessageBlockCount = 0;
|
|
2230
|
+
if (typeof content === "string") {
|
|
2231
|
+
lastMessageContentSize = content.length;
|
|
2232
|
+
} else if (Array.isArray(content)) {
|
|
2233
|
+
lastMessageBlockCount = content.length;
|
|
2234
|
+
for (const block of content) {
|
|
2235
|
+
if (!block || typeof block !== "object") continue;
|
|
2236
|
+
const candidate = block as {
|
|
2237
|
+
text?: unknown;
|
|
2238
|
+
thinking?: unknown;
|
|
2239
|
+
data?: unknown;
|
|
2240
|
+
name?: unknown;
|
|
2241
|
+
arguments?: unknown;
|
|
2242
|
+
};
|
|
2243
|
+
if (typeof candidate.text === "string") lastMessageContentSize += candidate.text.length;
|
|
2244
|
+
if (typeof candidate.thinking === "string") lastMessageContentSize += candidate.thinking.length;
|
|
2245
|
+
if (typeof candidate.data === "string") lastMessageContentSize += candidate.data.length;
|
|
2246
|
+
if (typeof candidate.name === "string") lastMessageContentSize += candidate.name.length;
|
|
2247
|
+
if (typeof candidate.arguments === "string") lastMessageContentSize += candidate.arguments.length;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
const model = state.model;
|
|
2252
|
+
const sessionModel = this.session.model;
|
|
2253
|
+
const systemPrompt = this.session.systemPrompt;
|
|
2254
|
+
let systemPromptContentSize = 0;
|
|
2255
|
+
for (const part of systemPrompt ?? EMPTY_STRING_PARTS) {
|
|
2256
|
+
if (typeof part === "string") systemPromptContentSize += part.length;
|
|
2257
|
+
}
|
|
2258
|
+
const tools = this.session.agent?.state?.tools;
|
|
2259
|
+
return {
|
|
2260
|
+
themeRef: theme,
|
|
2261
|
+
themeEpoch: getThemeEpoch(),
|
|
2262
|
+
projectDir: getProjectDir(),
|
|
2263
|
+
sessionRef: this.session,
|
|
2264
|
+
sessionFile: this.session.sessionFile,
|
|
2265
|
+
sessionSettingsRef: this.host.getSessionSettingsIdentity(this.session),
|
|
2266
|
+
sessionSettingsRevision: this.host.getSessionSettingsRevision(this.session),
|
|
2267
|
+
globalSettingsRevision: this.host.getSettingsRevision(),
|
|
2268
|
+
stateRef: state,
|
|
2269
|
+
stateMessagesRef: stateMessages,
|
|
2270
|
+
stateMessagesLength: stateMessages.length,
|
|
2271
|
+
stateLastMessageRef: stateLastMessage,
|
|
2272
|
+
stateLastMessageContentRef: stateLastMessageContent,
|
|
2273
|
+
messagesRef: messages,
|
|
2274
|
+
messagesLength: messages.length,
|
|
2275
|
+
lastMessageRef: lastMessage,
|
|
2276
|
+
lastMessageRole: lastMessageView?.role,
|
|
2277
|
+
lastMessageTimestamp: lastMessageView?.timestamp ?? 0,
|
|
2278
|
+
lastMessageContentSize,
|
|
2279
|
+
lastMessageBlockCount,
|
|
2280
|
+
lastMessageUsageTotal: lastMessageView?.usage?.totalTokens ?? 0,
|
|
2281
|
+
lastMessageStopReason: lastMessageView?.stopReason,
|
|
2282
|
+
modelRef: model,
|
|
2283
|
+
sessionModelRef: sessionModel,
|
|
2284
|
+
sessionModelId: sessionModel?.id,
|
|
2285
|
+
sessionModelContextWindow: sessionModel?.contextWindow,
|
|
2286
|
+
modelId: model?.id,
|
|
2287
|
+
modelName: model?.name,
|
|
2288
|
+
modelProvider: model?.provider,
|
|
2289
|
+
modelContextWindow: model?.contextWindow,
|
|
2290
|
+
modelThinking: model?.thinking,
|
|
2291
|
+
thinkingLevel: state.thinkingLevel,
|
|
2292
|
+
modelCostRef: model?.cost,
|
|
2293
|
+
contextUsageRevision: this.session.contextUsageRevision ?? 0,
|
|
2294
|
+
systemPromptRef: systemPrompt,
|
|
2295
|
+
systemPromptLength: systemPrompt?.length ?? 0,
|
|
2296
|
+
systemPromptContentSize,
|
|
2297
|
+
toolsRef: tools,
|
|
2298
|
+
toolsLength: tools?.length ?? 0,
|
|
2299
|
+
toolSchemaMetadataRevision: tools ? getToolSchemaMetadataRevision(tools) : 0,
|
|
2300
|
+
tokenizerRef: this.session.agent?.tokenizer,
|
|
2301
|
+
skillsRef: this.session.skills,
|
|
2302
|
+
skillsLength: this.session.skills?.length ?? 0,
|
|
2303
|
+
sessionName: this.session.sessionManager?.getSessionName?.(),
|
|
2304
|
+
sessionId: this.session.sessionManager?.getSessionId?.(),
|
|
2305
|
+
isStreaming: this.session.isStreaming,
|
|
2306
|
+
isAutoThinking: this.session.isAutoThinking,
|
|
2307
|
+
isFastModeActive:
|
|
2308
|
+
typeof this.session.isFastModeActive === "function" ? this.session.isFastModeActive() : false,
|
|
2309
|
+
compactionSpeculation: this.session.compactionSpeculation,
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
#sameStatusLineExternalInputs(left: StatusLineExternalInputs, right: StatusLineExternalInputs): boolean {
|
|
2314
|
+
return (
|
|
2315
|
+
left.themeRef === right.themeRef &&
|
|
2316
|
+
left.themeEpoch === right.themeEpoch &&
|
|
2317
|
+
left.projectDir === right.projectDir &&
|
|
2318
|
+
left.sessionRef === right.sessionRef &&
|
|
2319
|
+
left.sessionFile === right.sessionFile &&
|
|
2320
|
+
left.sessionSettingsRef === right.sessionSettingsRef &&
|
|
2321
|
+
left.sessionSettingsRevision === right.sessionSettingsRevision &&
|
|
2322
|
+
left.globalSettingsRevision === right.globalSettingsRevision &&
|
|
2323
|
+
left.stateRef === right.stateRef &&
|
|
2324
|
+
left.stateMessagesRef === right.stateMessagesRef &&
|
|
2325
|
+
left.stateMessagesLength === right.stateMessagesLength &&
|
|
2326
|
+
left.stateLastMessageRef === right.stateLastMessageRef &&
|
|
2327
|
+
left.stateLastMessageContentRef === right.stateLastMessageContentRef &&
|
|
2328
|
+
left.messagesRef === right.messagesRef &&
|
|
2329
|
+
left.messagesLength === right.messagesLength &&
|
|
2330
|
+
left.lastMessageRef === right.lastMessageRef &&
|
|
2331
|
+
left.lastMessageRole === right.lastMessageRole &&
|
|
2332
|
+
left.lastMessageTimestamp === right.lastMessageTimestamp &&
|
|
2333
|
+
left.lastMessageContentSize === right.lastMessageContentSize &&
|
|
2334
|
+
left.lastMessageBlockCount === right.lastMessageBlockCount &&
|
|
2335
|
+
left.lastMessageUsageTotal === right.lastMessageUsageTotal &&
|
|
2336
|
+
left.lastMessageStopReason === right.lastMessageStopReason &&
|
|
2337
|
+
left.modelRef === right.modelRef &&
|
|
2338
|
+
left.sessionModelRef === right.sessionModelRef &&
|
|
2339
|
+
left.sessionModelId === right.sessionModelId &&
|
|
2340
|
+
left.sessionModelContextWindow === right.sessionModelContextWindow &&
|
|
2341
|
+
left.modelId === right.modelId &&
|
|
2342
|
+
left.modelName === right.modelName &&
|
|
2343
|
+
left.modelProvider === right.modelProvider &&
|
|
2344
|
+
left.modelContextWindow === right.modelContextWindow &&
|
|
2345
|
+
left.modelThinking === right.modelThinking &&
|
|
2346
|
+
left.thinkingLevel === right.thinkingLevel &&
|
|
2347
|
+
left.modelCostRef === right.modelCostRef &&
|
|
2348
|
+
left.contextUsageRevision === right.contextUsageRevision &&
|
|
2349
|
+
left.systemPromptRef === right.systemPromptRef &&
|
|
2350
|
+
left.systemPromptLength === right.systemPromptLength &&
|
|
2351
|
+
left.systemPromptContentSize === right.systemPromptContentSize &&
|
|
2352
|
+
left.toolsRef === right.toolsRef &&
|
|
2353
|
+
left.toolsLength === right.toolsLength &&
|
|
2354
|
+
left.toolSchemaMetadataRevision === right.toolSchemaMetadataRevision &&
|
|
2355
|
+
left.tokenizerRef === right.tokenizerRef &&
|
|
2356
|
+
left.skillsRef === right.skillsRef &&
|
|
2357
|
+
left.skillsLength === right.skillsLength &&
|
|
2358
|
+
left.sessionName === right.sessionName &&
|
|
2359
|
+
left.sessionId === right.sessionId &&
|
|
2360
|
+
left.isStreaming === right.isStreaming &&
|
|
2361
|
+
left.isAutoThinking === right.isAutoThinking &&
|
|
2362
|
+
left.isFastModeActive === right.isFastModeActive &&
|
|
2363
|
+
left.compactionSpeculation === right.compactionSpeculation
|
|
2364
|
+
);
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
/**
|
|
2368
|
+
* Smallest wall-clock unit that can change a visible configured segment.
|
|
2369
|
+
* A zero tick keeps truly static bars cached indefinitely; active animation,
|
|
2370
|
+
* clocks, countdowns, and VCS fallback polling advance only at their own
|
|
2371
|
+
* display/probe cadence.
|
|
2372
|
+
*/
|
|
2373
|
+
#statusLineClock(nowMs: number, effectiveSettings: EffectiveStatusLineSettings, placeholders: boolean): number {
|
|
2374
|
+
if (placeholders) return 0;
|
|
2375
|
+
const leftSegments = effectiveSettings.leftSegments;
|
|
2376
|
+
const rightSegments = effectiveSettings.rightSegments;
|
|
2377
|
+
const meter = this.#meter();
|
|
2378
|
+
if (meter.activeStartedAt !== null || this.#brandFade !== null) {
|
|
2379
|
+
return Math.floor(nowMs / SPINNER_ADVANCE_MS);
|
|
2380
|
+
}
|
|
2381
|
+
const includesTime = leftSegments.includes("time") || rightSegments.includes("time");
|
|
2382
|
+
if (
|
|
2383
|
+
leftSegments.includes("time_spent") ||
|
|
2384
|
+
rightSegments.includes("time_spent") ||
|
|
2385
|
+
(this.#loopModeStatus?.limit?.kind === "duration" &&
|
|
2386
|
+
(leftSegments.includes("mode") || rightSegments.includes("mode"))) ||
|
|
2387
|
+
(includesTime && effectiveSettings.segmentOptions?.time?.showSeconds === true)
|
|
2388
|
+
) {
|
|
2389
|
+
return Math.floor(nowMs / 1_000);
|
|
2390
|
+
}
|
|
2391
|
+
if (includesTime) {
|
|
2392
|
+
return Math.floor(nowMs / 60_000);
|
|
2393
|
+
}
|
|
2394
|
+
if (this.#gitEnabled() && (hasGitBackedSegment(leftSegments) || hasGitBackedSegment(rightSegments))) {
|
|
2395
|
+
return Math.floor(nowMs / 1_000);
|
|
2396
|
+
}
|
|
2397
|
+
if (this.#gitEnabled() && (hasPathSegment(leftSegments) || hasPathSegment(rightSegments))) {
|
|
2398
|
+
return Math.floor(nowMs / WATCHER_FAILURE_POLL_TTL_MS);
|
|
2399
|
+
}
|
|
2400
|
+
return 0;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
#buildStatusLine(
|
|
2404
|
+
width: number,
|
|
2405
|
+
layout: StatusLineLayout = "box",
|
|
2406
|
+
previewTitle?: string,
|
|
2407
|
+
options?: { readonly placeholders?: boolean },
|
|
2408
|
+
): CachedStatusLine {
|
|
2409
|
+
const effectiveSettings = this.#resolveSettings();
|
|
2410
|
+
const placeholders = options?.placeholders === true;
|
|
2411
|
+
const externalInputs = this.#readStatusLineExternalInputs();
|
|
2412
|
+
const nowMs = Date.now();
|
|
2413
|
+
const clockTick = this.#statusLineClock(nowMs, effectiveSettings, placeholders);
|
|
2414
|
+
if (clockTick !== this.#statusLineClockTick) {
|
|
2415
|
+
this.#statusLineClockTick = clockTick;
|
|
2416
|
+
this.#invalidateStatusLineRenderCache();
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
const cached = this.#statusLineRenderCache[layout];
|
|
2420
|
+
if (
|
|
2421
|
+
cached &&
|
|
2422
|
+
cached.availableWidth === width &&
|
|
2423
|
+
cached.renderRevision === this.#renderRevision &&
|
|
2424
|
+
cached.inputRevision === this.#statusLineInputRevision &&
|
|
2425
|
+
cached.placeholders === placeholders &&
|
|
2426
|
+
cached.previewTitle === previewTitle &&
|
|
2427
|
+
this.#sameStatusLineExternalInputs(cached.externalInputs, externalInputs)
|
|
2428
|
+
) {
|
|
2429
|
+
return cached;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const content = this.#renderStatusLine(width, layout, previewTitle, options, nowMs);
|
|
2433
|
+
const result = {
|
|
2434
|
+
content,
|
|
2435
|
+
dimmedContent: this.#dimWhileFocusProxied(content),
|
|
2436
|
+
width: visibleWidth(content),
|
|
2437
|
+
availableWidth: width,
|
|
2438
|
+
renderRevision: this.#renderRevision,
|
|
2439
|
+
inputRevision: this.#statusLineInputRevision,
|
|
2440
|
+
placeholders,
|
|
2441
|
+
previewTitle,
|
|
2442
|
+
externalInputs,
|
|
2443
|
+
};
|
|
2444
|
+
this.#statusLineRenderCache[layout] = result;
|
|
2445
|
+
return result;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/**
|
|
2449
|
+
* Build the status bar for one of five layouts:
|
|
2450
|
+
* - `box`: powerline groups joined by the context-reactive gauge line
|
|
2451
|
+
* (embedded in the editor's top border).
|
|
2452
|
+
* - `band`: the box layout opened by a soft flush-left cap, for the band
|
|
2453
|
+
* composer's frameless full-width top row.
|
|
2454
|
+
* - `plain-full`: no background, no powerline caps, dot separators, gap is
|
|
2455
|
+
* plain spaces — the standalone bottom bar for pi/borderless composers.
|
|
2456
|
+
* - `plain-left`: left segments only (claude composer; the right group
|
|
2457
|
+
* lives in the editor's top rule).
|
|
2458
|
+
* - `plain-right`: right segments only (claude composer's top rule).
|
|
2459
|
+
*
|
|
2460
|
+
* `previewTitle` is a stand-in session title for composer previews; the
|
|
2461
|
+
* `session_name` segment renders it when the session is unnamed.
|
|
2462
|
+
*/
|
|
2463
|
+
#renderStatusLine(
|
|
2464
|
+
width: number,
|
|
2465
|
+
layout: StatusLineLayout,
|
|
2466
|
+
previewTitle: string | undefined,
|
|
2467
|
+
options: { readonly placeholders?: boolean } | undefined,
|
|
2468
|
+
nowMs: number,
|
|
2469
|
+
): string {
|
|
2470
|
+
const effectiveSettings = this.#resolveSettings();
|
|
2471
|
+
this.#syncPricingTimer();
|
|
2472
|
+
const placeholders = options?.placeholders === true;
|
|
2473
|
+
const plain = layout !== "box" && layout !== "band";
|
|
2474
|
+
const includePath =
|
|
2475
|
+
hasPathSegment(effectiveSettings.leftSegments) || hasPathSegment(effectiveSettings.rightSegments);
|
|
2476
|
+
const gitEnabled = this.#gitEnabled();
|
|
2477
|
+
const includeGit =
|
|
2478
|
+
gitEnabled &&
|
|
2479
|
+
(hasGitSegment(effectiveSettings.leftSegments) || hasGitSegment(effectiveSettings.rightSegments));
|
|
2480
|
+
const includePr =
|
|
2481
|
+
gitEnabled && (hasPrSegment(effectiveSettings.leftSegments) || hasPrSegment(effectiveSettings.rightSegments));
|
|
2482
|
+
const liveCtx = this.#buildSegmentContext(
|
|
2483
|
+
width,
|
|
2484
|
+
effectiveSettings.segmentOptions,
|
|
2485
|
+
includePath,
|
|
2486
|
+
includeGit,
|
|
2487
|
+
includePr,
|
|
2488
|
+
nowMs,
|
|
2489
|
+
previewTitle,
|
|
2490
|
+
);
|
|
2491
|
+
const ctx: SegmentContext = placeholders ? { ...liveCtx, startupPlaceholder: true } : liveCtx;
|
|
2492
|
+
const separatorDef = plain
|
|
2493
|
+
? { left: "·", right: "·" }
|
|
2494
|
+
: getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
|
|
2495
|
+
|
|
2496
|
+
// `transparent` reuses the empty-string sentinel (`\x1b[49m`) so the bar
|
|
2497
|
+
// inherits the terminal's default background, matching custom themes that
|
|
2498
|
+
// set `statusLineBg: ""`. Powerline end caps need a contrasting fill to
|
|
2499
|
+
// bridge the bar into the surrounding terminal; without one they read as
|
|
2500
|
+
// stray glyphs, so the cap renderer drops them when the fill is empty.
|
|
2501
|
+
const TRANSPARENT_BG_ANSI = "\x1b[49m";
|
|
2502
|
+
const themeBgAnsi = theme.getBgAnsi("statusLineBg");
|
|
2503
|
+
// Plain bottom bars drop the background entirely; the claude top-rule
|
|
2504
|
+
// chip (`plain-right`) keeps it so the group reads as a chip on the rule.
|
|
2505
|
+
const transparentLayout = layout === "plain-full" || layout === "plain-left";
|
|
2506
|
+
const bgAnsi = transparentLayout || effectiveSettings.transparent ? TRANSPARENT_BG_ANSI : themeBgAnsi;
|
|
2507
|
+
const transparentBg = bgAnsi === TRANSPARENT_BG_ANSI;
|
|
2508
|
+
const fgAnsi = theme.getFgAnsi("text");
|
|
2509
|
+
const sepAnsi = theme.getFgAnsi("statusLineSep");
|
|
2510
|
+
const subagentBadge = this.#subagentBadgeText();
|
|
2511
|
+
|
|
2512
|
+
// Collect visible segment contents
|
|
2513
|
+
const leftParts: string[] = [];
|
|
2514
|
+
const leftSegIds: StatusLineSegmentId[] = [];
|
|
2515
|
+
const leftSegmentIds = layout === "plain-right" ? [] : effectiveSettings.leftSegments;
|
|
2516
|
+
for (const segId of leftSegmentIds) {
|
|
2517
|
+
if (subagentBadge && segId === "subagents") continue;
|
|
2518
|
+
// The band composer relocates the title to the working row's trailer.
|
|
2519
|
+
if (layout === "band" && segId === "session_name") continue;
|
|
2520
|
+
const rendered = renderSegment(segId, ctx);
|
|
2521
|
+
if (rendered.visible && rendered.content) {
|
|
2522
|
+
leftParts.push(rendered.content);
|
|
2523
|
+
leftSegIds.push(segId);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
const rightParts: string[] = [];
|
|
2528
|
+
const rightSegIds: StatusLineSegmentId[] = [];
|
|
2529
|
+
const rightSegmentIds = layout === "plain-left" ? [] : effectiveSettings.rightSegments;
|
|
2530
|
+
for (const segId of rightSegmentIds) {
|
|
2531
|
+
if (subagentBadge && segId === "subagents") continue;
|
|
2532
|
+
if (layout === "band" && segId === "session_name") continue;
|
|
2533
|
+
const rendered = renderSegment(segId, ctx);
|
|
2534
|
+
if (rendered.visible && rendered.content) {
|
|
2535
|
+
rightParts.push(rendered.content);
|
|
2536
|
+
rightSegIds.push(segId);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
const embedContext =
|
|
2541
|
+
!plain &&
|
|
2542
|
+
effectiveSettings.contextLine === "embedded" &&
|
|
2543
|
+
ctx.contextPercent !== null &&
|
|
2544
|
+
ctx.contextPercent !== undefined &&
|
|
2545
|
+
ctx.contextWindow > 0 &&
|
|
2546
|
+
(hasContextSegment(leftSegIds) || hasContextSegment(rightSegIds)) &&
|
|
2547
|
+
(hasNonContextSegment(leftSegIds) || hasNonContextSegment(rightSegIds));
|
|
2548
|
+
if (embedContext) {
|
|
2549
|
+
removeContextSegments(leftParts, leftSegIds);
|
|
2550
|
+
removeContextSegments(rightParts, rightSegIds);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
if (layout !== "plain-left") {
|
|
2554
|
+
// Count task jobs only until their AgentRegistry ref appears. Once it is
|
|
2555
|
+
// running, the subagent badge represents that same agent; bash and eval
|
|
2556
|
+
// jobs always remain independent background work.
|
|
2557
|
+
const runningBackgroundJobs =
|
|
2558
|
+
this.session
|
|
2559
|
+
.getAsyncJobSnapshot()
|
|
2560
|
+
?.running.filter(
|
|
2561
|
+
job => job.type !== "task" || job.agentId === undefined || !this.#runningSubagentIds.has(job.agentId),
|
|
2562
|
+
).length ?? 0;
|
|
2563
|
+
if (runningBackgroundJobs > 0) {
|
|
2564
|
+
const count = placeholders ? "…" : `${runningBackgroundJobs}`;
|
|
2565
|
+
rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${count}`));
|
|
2566
|
+
}
|
|
2567
|
+
if (subagentBadge) {
|
|
2568
|
+
const content = placeholders ? [theme.icon.agents, "…"].filter(Boolean).join(" ") : subagentBadge;
|
|
2569
|
+
rightParts.unshift(placeholders ? theme.fg("statusLineSubagents", content) : content);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
const topFillWidth = Math.max(0, width);
|
|
2573
|
+
const left = [...leftParts];
|
|
2574
|
+
const right = [...rightParts];
|
|
2575
|
+
|
|
2576
|
+
const leftSepWidth = visibleWidth(separatorDef.left);
|
|
2577
|
+
const rightSepWidth = visibleWidth(separatorDef.right);
|
|
2578
|
+
// Transparent mode drops powerline caps (they need a bg fill to bridge),
|
|
2579
|
+
// so the width budget excludes them too.
|
|
2580
|
+
const leftCapWidth = separatorDef.endCaps && !transparentBg ? visibleWidth(separatorDef.endCaps.right) : 0;
|
|
2581
|
+
const rightCapWidth = separatorDef.endCaps && !transparentBg ? visibleWidth(separatorDef.endCaps.left) : 0;
|
|
2582
|
+
// The band layout opens flush against the terminal edge with a soft cap
|
|
2583
|
+
// (rust omp's status band). Like the other caps it needs an opaque
|
|
2584
|
+
// background to bridge, and only powerline separator styles carry caps.
|
|
2585
|
+
const bandCap = layout === "band" && separatorDef.endCaps && !transparentBg ? theme.sep.powerlineCapLeft : "";
|
|
2586
|
+
const bandCapWidth = visibleWidth(bandCap);
|
|
2587
|
+
|
|
2588
|
+
const groupWidth = (parts: string[], capWidth: number, sepWidth: number): number => {
|
|
2589
|
+
if (parts.length === 0) return 0;
|
|
2590
|
+
const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
|
|
2591
|
+
const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
|
|
2592
|
+
return partsWidth + sepTotal + 2 + capWidth;
|
|
2593
|
+
};
|
|
2594
|
+
|
|
2595
|
+
let leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
|
|
2596
|
+
let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
|
|
2597
|
+
// Embedded mode removes the standalone context segment before overflow
|
|
2598
|
+
// handling, so the gauge must reserve enough room for both labels. Without
|
|
2599
|
+
// this budget a long path/session title can leave a one-cell gap: the
|
|
2600
|
+
// context segment is gone, and the gauge silently omits its labels too.
|
|
2601
|
+
const embeddedContextWidth = embedContext
|
|
2602
|
+
? ctx.startupPlaceholder
|
|
2603
|
+
? "…%".length + "…".length + 4
|
|
2604
|
+
: embeddedContextGaugeMinWidth(ctx.contextPercent ?? 0, ctx.contextWindow)
|
|
2605
|
+
: 0;
|
|
2606
|
+
const minimumGapWidth = (): number => {
|
|
2607
|
+
if (!embeddedContextWidth) return left.length > 0 && right.length > 0 ? 1 : 0;
|
|
2608
|
+
// If the labels cannot coexist with the last surviving segment, fall
|
|
2609
|
+
// back to the original one-cell gauge instead of dropping the entire
|
|
2610
|
+
// status line. At this width the labels cannot render either way.
|
|
2611
|
+
if (left.length + right.length === 1 && leftWidth + rightWidth + embeddedContextWidth > topFillWidth) {
|
|
2612
|
+
return 1;
|
|
2613
|
+
}
|
|
2614
|
+
return embeddedContextWidth;
|
|
2615
|
+
};
|
|
2616
|
+
const totalWidth = () => leftWidth + rightWidth + minimumGapWidth();
|
|
2617
|
+
|
|
2618
|
+
if (topFillWidth > 0) {
|
|
2619
|
+
// Truncate the session-name segment before dropping right segments —
|
|
2620
|
+
// the title is the only elastic one on the right, and dropping it
|
|
2621
|
+
// wholesale left narrow bars (and the ≤76-col composer previews)
|
|
2622
|
+
// without any title.
|
|
2623
|
+
const nameSegIdx = rightSegIds.indexOf("session_name");
|
|
2624
|
+
if (nameSegIdx >= 0 && totalWidth() > topFillWidth) {
|
|
2625
|
+
// Badge/job parts were unshifted ahead of the tracked segment ids.
|
|
2626
|
+
const nameIdx = nameSegIdx + (right.length - rightSegIds.length);
|
|
2627
|
+
const currentNameVW = visibleWidth(right[nameIdx]);
|
|
2628
|
+
const minNameVW = 8;
|
|
2629
|
+
const shrinkBy = Math.min(Math.max(0, currentNameVW - minNameVW), totalWidth() - topFillWidth);
|
|
2630
|
+
if (shrinkBy > 0) {
|
|
2631
|
+
right[nameIdx] = truncateToWidth(right[nameIdx], currentNameVW - shrinkBy);
|
|
2632
|
+
rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
while (totalWidth() > topFillWidth && right.length > 0) {
|
|
2636
|
+
right.pop();
|
|
2637
|
+
rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
|
|
2638
|
+
}
|
|
2639
|
+
// Shrink path before dropping left segments — path is the only elastic segment
|
|
2640
|
+
const pathIdx = leftSegIds.indexOf("path");
|
|
2641
|
+
if (pathIdx >= 0 && totalWidth() > topFillWidth) {
|
|
2642
|
+
const overflow = totalWidth() - topFillWidth;
|
|
2643
|
+
const currentPathVW = visibleWidth(left[pathIdx]);
|
|
2644
|
+
const minPathVW = 8; // icon + ellipsis + a few chars
|
|
2645
|
+
const shrinkable = currentPathVW - minPathVW;
|
|
2646
|
+
if (shrinkable > 0) {
|
|
2647
|
+
const shrinkBy = Math.min(shrinkable, overflow);
|
|
2648
|
+
const currentMaxLen = ctx.options.path?.maxLength ?? 40;
|
|
2649
|
+
let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
|
|
2650
|
+
const pathCtx = (maxLen: number): SegmentContext => ({
|
|
2651
|
+
...ctx,
|
|
2652
|
+
options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
|
|
2653
|
+
});
|
|
2654
|
+
let reRendered = renderSegment("path", pathCtx(newMaxLen));
|
|
2655
|
+
if (reRendered.visible && reRendered.content) {
|
|
2656
|
+
// maxLength governs path text, not icon prefix; iterate to compensate
|
|
2657
|
+
for (let i = 0; i < 8; i++) {
|
|
2658
|
+
const saved = currentPathVW - visibleWidth(reRendered.content);
|
|
2659
|
+
if (saved >= shrinkBy) break;
|
|
2660
|
+
const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
|
|
2661
|
+
if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
|
|
2662
|
+
newMaxLen = nextMaxLen;
|
|
2663
|
+
const adjusted = renderSegment("path", pathCtx(newMaxLen));
|
|
2664
|
+
if (!adjusted.visible || !adjusted.content) break;
|
|
2665
|
+
reRendered = adjusted;
|
|
2666
|
+
}
|
|
2667
|
+
left[pathIdx] = reRendered.content;
|
|
2668
|
+
leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
const leftOverflowDropIndex = (): number => {
|
|
2673
|
+
// Preserve the current working directory as long as possible. The
|
|
2674
|
+
// previous right-to-left pop could collapse a normal-width bar to
|
|
2675
|
+
// just the model segment, hiding the path before less-critical left
|
|
2676
|
+
// segments such as model/mode/collab were removed.
|
|
2677
|
+
for (let i = leftSegIds.length - 1; i >= 0; i--) {
|
|
2678
|
+
if (leftSegIds[i] !== "path") return i;
|
|
2679
|
+
}
|
|
2680
|
+
return left.length - 1;
|
|
2681
|
+
};
|
|
2682
|
+
|
|
2683
|
+
while (totalWidth() > topFillWidth && left.length > 0) {
|
|
2684
|
+
const dropIdx = leftOverflowDropIndex();
|
|
2685
|
+
left.splice(dropIdx, 1);
|
|
2686
|
+
leftSegIds.splice(dropIdx, 1);
|
|
2687
|
+
leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
const renderGroup = (parts: string[], direction: "left" | "right"): string => {
|
|
2692
|
+
if (parts.length === 0) return "";
|
|
2693
|
+
const sep = direction === "left" ? separatorDef.left : separatorDef.right;
|
|
2694
|
+
const cap =
|
|
2695
|
+
separatorDef.endCaps && !transparentBg
|
|
2696
|
+
? direction === "left"
|
|
2697
|
+
? separatorDef.endCaps.right
|
|
2698
|
+
: separatorDef.endCaps.left
|
|
2699
|
+
: "";
|
|
2700
|
+
const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : bgAnsi + sepAnsi;
|
|
2701
|
+
const capText = cap ? `${capPrefix}${this.#focusedAgentId ? "\x1b[22m" : ""}${cap}\x1b[0m` : "";
|
|
2702
|
+
const openText =
|
|
2703
|
+
direction === "left" && bandCap
|
|
2704
|
+
? `${capPrefix}${this.#focusedAgentId ? "\x1b[22m" : ""}${bandCap}\x1b[0m`
|
|
2705
|
+
: "";
|
|
2706
|
+
|
|
2707
|
+
let content = bgAnsi + fgAnsi;
|
|
2708
|
+
content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
|
|
2709
|
+
content += "\x1b[0m";
|
|
2710
|
+
|
|
2711
|
+
if (direction === "right") return capText + content;
|
|
2712
|
+
return openText + content + capText;
|
|
2713
|
+
};
|
|
2714
|
+
|
|
2715
|
+
const leftGroup = renderGroup(left, "left");
|
|
2716
|
+
const rightGroup = renderGroup(right, "right");
|
|
2717
|
+
if (!leftGroup && !rightGroup) return "";
|
|
2718
|
+
|
|
2719
|
+
if (topFillWidth === 0 || (plain && (left.length === 0 || right.length === 0))) {
|
|
2720
|
+
return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
|
|
2724
|
+
if (plain) {
|
|
2725
|
+
// Standalone composers: no gauge line between the groups, just air.
|
|
2726
|
+
return leftGroup + padding(gapWidth) + rightGroup;
|
|
2727
|
+
}
|
|
2728
|
+
// Box layout: with one group absent (an unnamed session hides
|
|
2729
|
+
// `session_name`, emptying the default preset's right group) the gauge
|
|
2730
|
+
// runs to the border edge instead of disappearing, so embedded context
|
|
2731
|
+
// labels don't fall back to a context chip until the session is titled.
|
|
2732
|
+
return leftGroup + this.#buildContextGaugeFill(gapWidth, ctx, effectiveSettings, embedContext) + rightGroup;
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
/**
|
|
2736
|
+
* The gauge line bridging the left and right groups in box layout. Driven
|
|
2737
|
+
* by `statusLine.contextLine`:
|
|
2738
|
+
* - `off`: solid accent line (no context feedback).
|
|
2739
|
+
* - `percentage`: used portion in accent, remainder in the border color.
|
|
2740
|
+
* - `annotated` : percentage plus preset-aware markers where
|
|
2741
|
+
* speculative compaction starts and where auto-compaction fires.
|
|
2742
|
+
* - `embedded` (default): annotated markers plus percentage/window labels absorbed
|
|
2743
|
+
* from configured context segments.
|
|
2744
|
+
*/
|
|
2745
|
+
#buildContextGaugeFill(
|
|
2746
|
+
gapWidth: number,
|
|
2747
|
+
ctx: SegmentContext,
|
|
2748
|
+
effectiveSettings: EffectiveStatusLineSettings,
|
|
2749
|
+
embedContext: boolean,
|
|
2750
|
+
): string {
|
|
2751
|
+
const sessionName =
|
|
2752
|
+
effectiveSettings.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined;
|
|
2753
|
+
const accentHex = sessionName ? getSessionAccentHex(sessionName, theme.sessionAccentInputs) : undefined;
|
|
2754
|
+
const usedColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("borderAccent");
|
|
2755
|
+
const horizontal = theme.boxRound.horizontal;
|
|
2756
|
+
const mode = effectiveSettings.contextLine ?? "embedded";
|
|
2757
|
+
const pct = ctx.contextPercent;
|
|
2758
|
+
if (mode === "off" || pct === null || pct === undefined) {
|
|
2759
|
+
return `\x1b[49m${usedColor}${horizontal.repeat(gapWidth)}\x1b[39m`;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
const clampedPct = Math.min(100, Math.max(0, pct));
|
|
2763
|
+
let percentLabel = "";
|
|
2764
|
+
let windowLabel = "";
|
|
2765
|
+
let percentStart = -1;
|
|
2766
|
+
let windowStart = -1;
|
|
2767
|
+
let scaleWidth = gapWidth;
|
|
2768
|
+
// >100%: usage anchored past the active window (e.g. model switch to a
|
|
2769
|
+
// smaller window). The bar clamps full, but the embedded label breaks
|
|
2770
|
+
// past the window label — `──200K─120%` with the percent in error color.
|
|
2771
|
+
const percentOverflow = pct > 100;
|
|
2772
|
+
if (embedContext) {
|
|
2773
|
+
const candidatePercent = ctx.startupPlaceholder
|
|
2774
|
+
? "…%"
|
|
2775
|
+
: formatEmbeddedContextPercent(percentOverflow ? pct : clampedPct);
|
|
2776
|
+
const candidateWindow = ctx.startupPlaceholder ? "…" : formatNumber(ctx.contextWindow);
|
|
2777
|
+
const minimumLabelWidth = candidatePercent.length + candidateWindow.length + 4;
|
|
2778
|
+
if (gapWidth >= minimumLabelWidth) {
|
|
2779
|
+
percentLabel = candidatePercent;
|
|
2780
|
+
windowLabel = candidateWindow;
|
|
2781
|
+
if (percentOverflow) {
|
|
2782
|
+
percentStart = gapWidth - percentLabel.length;
|
|
2783
|
+
windowStart = percentStart - 1 - windowLabel.length;
|
|
2784
|
+
} else {
|
|
2785
|
+
windowStart = gapWidth - windowLabel.length - 1;
|
|
2786
|
+
}
|
|
2787
|
+
scaleWidth = windowStart;
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
// At least one accent cell: a fresh session still shows the session-accent
|
|
2792
|
+
// line starting at the left instead of a fully dim bar.
|
|
2793
|
+
const usedCount = Math.min(scaleWidth, Math.max(1, Math.round((clampedPct / 100) * scaleWidth)));
|
|
2794
|
+
const unusedColor = theme.getFgAnsi("border");
|
|
2795
|
+
|
|
2796
|
+
// Boundary markers are only meaningful when auto-compaction can fire and
|
|
2797
|
+
// the line is long enough for the markers to read as positions.
|
|
2798
|
+
let speculationIdx = -1;
|
|
2799
|
+
let thresholdIdx = -1;
|
|
2800
|
+
if ((mode === "annotated" || mode === "embedded") && ctx.autoCompactEnabled && gapWidth >= 8) {
|
|
2801
|
+
const boundaries = this.#compactionBoundaries(ctx.contextWindow);
|
|
2802
|
+
if (boundaries) {
|
|
2803
|
+
const cellFor = (percent: number) =>
|
|
2804
|
+
Math.min(scaleWidth - 1, Math.max(0, Math.round((percent / 100) * scaleWidth)));
|
|
2805
|
+
thresholdIdx = cellFor(boundaries.thresholdPercent);
|
|
2806
|
+
// null = no background speculation will run (async disabled or the
|
|
2807
|
+
// first available method is local/instant) — no tick to show.
|
|
2808
|
+
if (boundaries.speculationPercent !== null) speculationIdx = cellFor(boundaries.speculationPercent);
|
|
2809
|
+
if (speculationIdx === thresholdIdx) speculationIdx = -1; // threshold wins the cell
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
if (percentLabel && percentStart < 0) {
|
|
2814
|
+
const maxStart = scaleWidth - percentLabel.length - 1;
|
|
2815
|
+
const preferredStart = Math.min(maxStart, Math.max(1, usedCount));
|
|
2816
|
+
const overlapsBoundary = (start: number): boolean => {
|
|
2817
|
+
const end = start + percentLabel.length;
|
|
2818
|
+
return (speculationIdx >= start && speculationIdx < end) || (thresholdIdx >= start && thresholdIdx < end);
|
|
2819
|
+
};
|
|
2820
|
+
for (let distance = 0; distance <= maxStart; distance++) {
|
|
2821
|
+
const left = preferredStart - distance;
|
|
2822
|
+
if (left >= 1 && !overlapsBoundary(left)) {
|
|
2823
|
+
percentStart = left;
|
|
2824
|
+
break;
|
|
2825
|
+
}
|
|
2826
|
+
if (distance === 0) continue;
|
|
2827
|
+
const right = preferredStart + distance;
|
|
2828
|
+
if (right <= maxStart && !overlapsBoundary(right)) {
|
|
2829
|
+
percentStart = right;
|
|
2830
|
+
break;
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
const speculationGlyph = theme.symbol("context.speculation");
|
|
2836
|
+
const thresholdGlyph = theme.symbol("context.compaction");
|
|
2837
|
+
const speculationColor = theme.getFgAnsi("muted");
|
|
2838
|
+
const overflowColor = theme.getFgAnsi("error");
|
|
2839
|
+
const rawAccentHex = accentHex ?? theme.getColorHex("borderAccent");
|
|
2840
|
+
const dimmedAccentHex = adjustHsv(rawAccentHex, { s: 0.7, v: 0.75 });
|
|
2841
|
+
const thresholdColor = getSessionAccentAnsi(dimmedAccentHex) ?? usedColor;
|
|
2842
|
+
|
|
2843
|
+
let out = "\x1b[49m";
|
|
2844
|
+
let activeColor = "";
|
|
2845
|
+
for (let i = 0; i < gapWidth; i++) {
|
|
2846
|
+
let color = i < usedCount ? usedColor : unusedColor;
|
|
2847
|
+
let glyph = horizontal;
|
|
2848
|
+
if (percentStart >= 0 && i >= percentStart && i < percentStart + percentLabel.length) {
|
|
2849
|
+
color = percentOverflow ? overflowColor : usedColor;
|
|
2850
|
+
glyph = percentLabel.charAt(i - percentStart);
|
|
2851
|
+
} else if (i === thresholdIdx) {
|
|
2852
|
+
color = thresholdColor;
|
|
2853
|
+
glyph = thresholdGlyph;
|
|
2854
|
+
} else if (i === speculationIdx) {
|
|
2855
|
+
color = speculationColor;
|
|
2856
|
+
glyph = speculationGlyph;
|
|
2857
|
+
} else if (windowStart >= 0 && i >= windowStart && i < windowStart + windowLabel.length) {
|
|
2858
|
+
color = thresholdColor;
|
|
2859
|
+
glyph = windowLabel.charAt(i - windowStart);
|
|
2860
|
+
}
|
|
2861
|
+
if (color !== activeColor) {
|
|
2862
|
+
out += color;
|
|
2863
|
+
activeColor = color;
|
|
2864
|
+
}
|
|
2865
|
+
out += glyph;
|
|
2866
|
+
}
|
|
2867
|
+
return `${out}\x1b[39m`;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
/** Auto-compaction boundary percents, or null when unavailable (disabled, no window). */
|
|
2871
|
+
#compactionBoundaries(contextWindow: number): CompactionBoundaries | null {
|
|
2872
|
+
const model = this.session.state?.model ?? this.session.model;
|
|
2873
|
+
try {
|
|
2874
|
+
return this.host.computeCompactionBoundaries(this.session, contextWindow, model);
|
|
2875
|
+
} catch {
|
|
2876
|
+
return null;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
/** Render startup ellipses inside each segment's normal icon, color, and static chrome. */
|
|
2881
|
+
renderStartupPlaceholder(width: number, layout: StatusLineLayout): string {
|
|
2882
|
+
return this.#buildStatusLine(width, layout, undefined, { placeholders: true }).content;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
getTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
|
|
2886
|
+
const statusLine = this.#buildStatusLine(width, "box", previewTitle);
|
|
2887
|
+
return {
|
|
2888
|
+
content: statusLine.dimmedContent,
|
|
2889
|
+
width: statusLine.width,
|
|
2890
|
+
revision: this.#renderRevision,
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/** Flush-left soft-capped powerline band (the band composer's top row). */
|
|
2895
|
+
getBandTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
|
|
2896
|
+
const statusLine = this.#buildStatusLine(width, "band", previewTitle);
|
|
2897
|
+
return {
|
|
2898
|
+
content: statusLine.dimmedContent,
|
|
2899
|
+
width: statusLine.width,
|
|
2900
|
+
revision: this.#renderRevision,
|
|
2901
|
+
};
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/** Dim the whole bar while focus-proxied. Group/cap terminators emit full
|
|
2905
|
+
* `\x1b[0m` resets that would cancel faint mid-bar, so re-open it after each. */
|
|
2906
|
+
#dimWhileFocusProxied(content: string): string {
|
|
2907
|
+
if (!this.#focusedAgentId || !content) return content;
|
|
2908
|
+
return `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
|
|
2909
|
+
}
|
|
2910
|
+
/**
|
|
2911
|
+
* Standalone bar placement derived from the composer style. `bottomBar`
|
|
2912
|
+
* `"full"` renders both groups on the bottom bar (pi/borderless/field/rail);
|
|
2913
|
+
* `"left"` renders just the left group there — the right group attaches to
|
|
2914
|
+
* the editor's top rule via {@link getStandaloneTopBorder} (claude/rule);
|
|
2915
|
+
* `"none"` returns the bar to the box composer's embedded top border.
|
|
2916
|
+
* `bottomBarGap` inserts a blank spacer row above the bar for styles whose
|
|
2917
|
+
* editor has no bottom chrome.
|
|
2918
|
+
*/
|
|
2919
|
+
setComposerStyle(style: Pick<ComposerStyle, "statusAttachment" | "bottomBar" | "bottomBarGap">): void {
|
|
2920
|
+
this.#standalone = style.bottomBar === "none" ? false : style.bottomBar === "left" ? "left-only" : "full";
|
|
2921
|
+
this.#topAttachment = style.statusAttachment;
|
|
2922
|
+
this.#standaloneGap = style.bottomBarGap;
|
|
2923
|
+
this.#syncPricingTimer();
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/** While true, the standalone bar yields its row to the editor's autocomplete menu. */
|
|
2927
|
+
setAutocompleteActiveProbe(probe: (() => boolean) | undefined): void {
|
|
2928
|
+
this.#autocompleteActiveProbe = probe;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
/** Plain right-group content for the claude composer's top rule. */
|
|
2932
|
+
getStandaloneTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
|
|
2933
|
+
const statusLine = this.#buildStatusLine(width, "plain-right", previewTitle);
|
|
2934
|
+
return {
|
|
2935
|
+
content: statusLine.dimmedContent,
|
|
2936
|
+
width: statusLine.width,
|
|
2937
|
+
revision: this.#renderRevision,
|
|
2938
|
+
};
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
/**
|
|
2942
|
+
* The plain standalone bottom bar through the real segment/gauge pipeline —
|
|
2943
|
+
* `groups` picks which segment groups it carries. Used by the live render
|
|
2944
|
+
* loop and by composer previews (which inject a candidate layout instead of
|
|
2945
|
+
* the active one).
|
|
2946
|
+
*/
|
|
2947
|
+
renderBottomBar(width: number, groups: "left" | "full", previewTitle?: string): string {
|
|
2948
|
+
return this.#buildStatusLine(width, groups === "left" ? "plain-left" : "plain-full", previewTitle).dimmedContent;
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* Status bar lines for a composer layout, rendered through the real
|
|
2952
|
+
* pipeline — the single source for the /settings appearance preview.
|
|
2953
|
+
* `style` overrides the layout (candidate composer shape); omitted, the
|
|
2954
|
+
* active layout is used. Ignores the autocomplete probe: previews always
|
|
2955
|
+
* render.
|
|
2956
|
+
*/
|
|
2957
|
+
getPreviewLines(width: number, style?: Pick<ComposerStyle, "statusAttachment" | "bottomBar">): string[] {
|
|
2958
|
+
const attachment = style?.statusAttachment ?? this.#topAttachment;
|
|
2959
|
+
const bottomBar =
|
|
2960
|
+
style?.bottomBar ?? (this.#standalone === false ? "none" : this.#standalone === "left-only" ? "left" : "full");
|
|
2961
|
+
const lines: string[] = [];
|
|
2962
|
+
if (attachment === "top-border") {
|
|
2963
|
+
const border = this.getTopBorder(width);
|
|
2964
|
+
if (border.content) lines.push(border.content);
|
|
2965
|
+
} else if (attachment === "top-band") {
|
|
2966
|
+
const band = this.getBandTopBorder(width);
|
|
2967
|
+
if (band.content) lines.push(band.content);
|
|
2968
|
+
} else if (attachment === "top-rule-chip") {
|
|
2969
|
+
// Render the chip on its rule exactly as the claude composer does.
|
|
2970
|
+
const rule = claudeComposerStyle.renderTop({
|
|
2971
|
+
width,
|
|
2972
|
+
paddingX: 0,
|
|
2973
|
+
borderColor: str => theme.fg("border", str),
|
|
2974
|
+
accentColor: str => theme.fg("accent", str),
|
|
2975
|
+
surfaceColor: str => theme.bgFill("userMessageBg", theme.fgOnBg("userMessageText", "userMessageBg", str)),
|
|
2976
|
+
box: theme.boxRound,
|
|
2977
|
+
topBorder: this.getStandaloneTopBorder(width),
|
|
2978
|
+
});
|
|
2979
|
+
if (rule !== undefined) lines.push(rule);
|
|
2980
|
+
}
|
|
2981
|
+
if (bottomBar !== "none") {
|
|
2982
|
+
const main = this.renderBottomBar(width, bottomBar);
|
|
2983
|
+
if (main) lines.push(main);
|
|
2984
|
+
}
|
|
2985
|
+
return lines;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
render(width: number): readonly string[] {
|
|
2989
|
+
const lines: string[] = [];
|
|
2990
|
+
if (this.#standalone && !this.#autocompleteActiveProbe?.()) {
|
|
2991
|
+
const content = this.renderBottomBar(width, this.#standalone === "left-only" ? "left" : "full");
|
|
2992
|
+
if (content) {
|
|
2993
|
+
if (this.#standaloneGap) lines.push("");
|
|
2994
|
+
lines.push(content);
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
const showHooks = this.#settings.showHookStatus ?? true;
|
|
2998
|
+
if (showHooks && this.#sortedHookStatuses.length > 0) {
|
|
2999
|
+
lines.push(...this.#sortedHookStatuses.map(text => truncateToWidth(sanitizeStatusText(text), width)));
|
|
3000
|
+
}
|
|
3001
|
+
return lines;
|
|
3002
|
+
}
|
|
3003
|
+
}
|