Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfa009e7df | |||
| db950f3f7a | |||
| fe9cc15818 | |||
| 34fa512778 | |||
| e63591492a | |||
| 7a0b979b01 | |||
| eaab442794 | |||
| 78d6dcc28c | |||
| 53c8200c5a | |||
| 7ca953400c | |||
| 591cb60459 | |||
| c4d0f4b5ad | |||
| 97d7491c12 | |||
| 8049e28667 | |||
| a056183a67 | |||
| fceab59ec6 | |||
| 19eb83fb24 | |||
| f2764362fd | |||
| 990f2703b1 | |||
| 59d43589c9 | |||
| 78c7bc16f0 | |||
| 2956842911 | |||
| 5c0d211ddf | |||
| 9c0f5feab6 | |||
| 7925ec3e9a | |||
| 87fac953aa | |||
| d79eefb043 | |||
| 79b8e4e2f5 | |||
| fc09688879 | |||
| a34a632b82 | |||
| 25b69228dd | |||
| 7e2ad30f77 | |||
| b90840f68c | |||
| b13ea14a8c | |||
| da7eaffe03 | |||
| 9aea4c1265 | |||
| 2d05b3b891 | |||
| 6edc1b4277 |
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: bun
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
ignore:
|
||||
- dependency-name: "ink"
|
||||
open-pull-requests-limit: 10
|
||||
cooldown:
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
production-dependencies:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
@@ -0,0 +1,36 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint & Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun run lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun test
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun run build
|
||||
- run: test -f dist/ccstatusline.js
|
||||
Vendored
+1
@@ -21,6 +21,7 @@
|
||||
"ccstatusline",
|
||||
"Powerline",
|
||||
"statusline",
|
||||
"sublabel",
|
||||
"Worktree",
|
||||
"worktrees"
|
||||
]
|
||||
|
||||
@@ -33,7 +33,10 @@ bun test
|
||||
bun test --watch
|
||||
|
||||
# Lint and type check
|
||||
bun run lint # Runs TypeScript type checking and ESLint with auto-fix
|
||||
bun run lint # Runs TypeScript type checking and ESLint without modifying files
|
||||
|
||||
# Apply ESLint auto-fixes intentionally
|
||||
bun run lint:fix
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -97,7 +100,7 @@ All widgets must implement:
|
||||
|
||||
**Available Widgets:**
|
||||
- Model, Version, OutputStyle - Claude Code metadata display
|
||||
- GitBranch, GitChanges, GitWorktree - Git repository status
|
||||
- GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status
|
||||
- TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics
|
||||
- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models)
|
||||
- BlockTimer, SessionClock, SessionCost - Time and cost tracking
|
||||
@@ -136,7 +139,7 @@ Default to using Bun instead of Node.js:
|
||||
2. `postbuild`: Runs scripts/replace-version.ts to replace `__PACKAGE_VERSION__` placeholder with actual version from package.json
|
||||
- **ESLint configuration**: Uses flat config format (eslint.config.js) with TypeScript and React plugins
|
||||
- **Dependencies**: All runtime dependencies are bundled using `--packages=external` for npm package
|
||||
- **Type checking and linting**: Only run via `bun run lint` command, never using `npx eslint` or `eslint` directly. Never run `tsx`, `bun tsc` or any other variation
|
||||
- **Type checking and linting**: Run checks via `bun run lint` and use `bun run lint:fix` only when you intentionally want ESLint auto-fixes. Never use `npx eslint`, `eslint`, `tsx`, `bun tsc`, or any other variation directly
|
||||
- **Lint rules**: Never disable a lint rule via a comment, no matter how benign the lint warning or error may seem
|
||||
- **Testing**: Uses Vitest (via Bun) with 6 test files and ~40 test cases covering:
|
||||
- Model context detection and token calculation (src/utils/__tests__/model-context.test.ts)
|
||||
|
||||
@@ -46,12 +46,29 @@
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
### v2.1.0 - v2.1.3 - Usage widgets, links, and reliability fixes
|
||||
### v2.2.0 - v2.2.2 - Token Speed + Skills widget updates
|
||||
|
||||
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Reset Timer**, and **Context Bar** widgets.
|
||||
- **🚀 New Token Speed widgets** - Added three widgets: **Input Speed**, **Output Speed**, and **Total Speed**.
|
||||
- Each speed widget supports a configurable window of `0-120` seconds in the widget editor (`w` key).
|
||||
- `0` disables window mode and uses a full-session average speed.
|
||||
- `1-120` calculates recent speed over the selected rolling window.
|
||||
- **🧩 New Skills widget controls (v2.2.1)** - Added configurable Skills modes (last/count/list), optional hide-when-empty behavior, and list-size limiting with most-recent-first ordering.
|
||||
- **🌐 Usage API proxy support (v2.2.2)** - Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
|
||||
- **🤝 Better subagent-aware speed reporting** - Token speed calculations continue to include referenced subagent activity so displayed speeds better reflect actual concurrent work.
|
||||
|
||||
### v2.1.0 - v2.1.10 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes
|
||||
|
||||
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Block Reset Timer**, and **Context Bar** widgets.
|
||||
- **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts.
|
||||
- **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file.
|
||||
- **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support.
|
||||
- **➕ New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`).
|
||||
- **➖ New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`).
|
||||
- **🧠 Context format fallback fix (v2.1.6)** - When `context_window_size` is missing, context widgets now infer 1M models from long-context labels such as `[1m]` and `1M context` in model identifiers.
|
||||
- **⏳ Weekly reset timer split (v2.1.7)** - Added a separate `Weekly Reset Timer` widget.
|
||||
- **⚙️ Custom config file flag (v2.1.8)** - Added `--config <path>` support so ccstatusline can load/save settings from a custom file location.
|
||||
- **🔣 Unicode separator hex input upgrade (v2.1.9)** - Powerline separator hex input now supports 4-6 digits (full Unicode code points up to `U+10FFFF`).
|
||||
- **🌳 Bare repo worktree detection fix (v2.1.10)** - `Git Worktree` now correctly detects linked worktrees created from bare repositories.
|
||||
|
||||
### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements
|
||||
|
||||
@@ -187,6 +204,8 @@ The interactive configuration tool provides a terminal UI where you can:
|
||||
> $env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
|
||||
> ```
|
||||
|
||||
> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
|
||||
|
||||
### Claude Code settings.json format
|
||||
|
||||
When you install from the TUI, ccstatusline writes a `statusLine` command object to your Claude Code settings:
|
||||
@@ -409,36 +428,43 @@ bun run example
|
||||
|
||||
### 📊 Available Widgets
|
||||
|
||||
- **Model Name** - Shows the current Claude model (e.g., "Claude 3.5 Sonnet")
|
||||
- **Git Branch** - Displays current git branch name
|
||||
- **Git Changes** - Shows uncommitted insertions/deletions (e.g., "+42,-10")
|
||||
- **Model** - Displays the Claude model name (e.g., "Claude 3.5 Sonnet")
|
||||
- **Output Style** - Shows the current Claude Code output style
|
||||
- **Git Branch** - Shows the current git branch name
|
||||
- **Git Changes** - Shows git changes count (`+insertions`, `-deletions`)
|
||||
- **Git Insertions** - Shows git insertions count
|
||||
- **Git Deletions** - Shows git deletions count
|
||||
- **Git Root Dir** - Shows the git repository root directory name
|
||||
- **Git Worktree** - Shows the name of the current git worktree
|
||||
- **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m")
|
||||
- **Session Usage** - Shows current 5-hour/session API usage percentage
|
||||
- **Weekly Usage** - Shows rolling 7-day API usage percentage
|
||||
- **Session Cost** - Shows total session cost in USD (e.g., "$1.23")
|
||||
- **Session Name** - Shows the session name set via `/rename` command in Claude Code
|
||||
- **Git Worktree** - Shows the current git worktree name
|
||||
- **Current Working Dir** - Shows current working directory with segment limit, fish-style abbreviation, and optional `~` home abbreviation
|
||||
- **Tokens Input** - Shows input token count for the current session
|
||||
- **Tokens Output** - Shows output token count for the current session
|
||||
- **Tokens Cached** - Shows cached token count for the current session
|
||||
- **Tokens Total** - Shows total token count (`input + output + cache`) for the current session
|
||||
- **Input Speed** - Shows session-average input token speed (`tokens/sec`) with optional per-widget window (`0-120` seconds; `0` = full-session average)
|
||||
- **Output Speed** - Shows session-average output token speed (`tokens/sec`) with optional per-widget window (`0-120` seconds; `0` = full-session average)
|
||||
- **Total Speed** - Shows session-average total token speed (`tokens/sec`) with optional per-widget window (`0-120` seconds; `0` = full-session average)
|
||||
- **Context Length** - Shows the current context window size in tokens
|
||||
- **Context %** - Shows percentage of context window used or remaining
|
||||
- **Context % (usable)** - Shows percentage of usable context used or remaining (80% of max before auto-compact)
|
||||
- **Session Clock** - Shows elapsed time since current session started
|
||||
- **Session Cost** - Shows the total session cost in USD
|
||||
- **Block Timer** - Shows current 5-hour block elapsed time or progress
|
||||
- **Terminal Width** - Shows current terminal width in columns
|
||||
- **Version** - Shows Claude Code CLI version number
|
||||
- **Custom Text** - Displays user-defined custom text
|
||||
- **Custom Command** - Executes a custom shell command and displays output (refreshes whenever Claude Code updates the status line)
|
||||
- **Link** - Displays a clickable terminal hyperlink using OSC 8
|
||||
- **Claude Session ID** - Shows the current Claude Code session ID from status JSON
|
||||
- **Block Timer** - Shows time elapsed in current 5-hour block or progress bar
|
||||
- **Reset Timer** - Shows time remaining until the current 5-hour block resets
|
||||
- **Current Working Directory** - Shows current working directory with segment limit, fish-style abbreviation, and optional `~` home abbreviation
|
||||
- **Version** - Shows Claude Code version
|
||||
- **Output Style** - Shows the currently set output style in Claude Code
|
||||
- **Tokens Input** - Shows input tokens used
|
||||
- **Tokens Output** - Shows output tokens used
|
||||
- **Tokens Cached** - Shows cached tokens used
|
||||
- **Tokens Total** - Shows total tokens used
|
||||
- **Context Length** - Shows current context length in tokens
|
||||
- **Context Percentage** - Shows percentage of context limit used (dynamic: 1M for model IDs with `[1m]` suffix, 200k otherwise)
|
||||
- **Context Percentage (usable)** - Shows percentage of usable context (dynamic: 800k for model IDs with `[1m]` suffix, 160k otherwise, accounting for auto-compact at 80%)
|
||||
- **Session Name** - Shows the session name set via `/rename` in Claude Code
|
||||
- **Memory Usage** - Shows system memory usage (used/total)
|
||||
- **Session Usage** - Shows daily/session API usage percentage
|
||||
- **Weekly Usage** - Shows weekly API usage percentage
|
||||
- **Block Reset Timer** - Shows time remaining until current 5-hour block reset window
|
||||
- **Weekly Reset Timer** - Shows time remaining until weekly usage reset
|
||||
- **Context Bar** - Shows context usage as a progress bar with short/full display modes
|
||||
- **Terminal Width** - Shows detected terminal width (for debugging)
|
||||
- **Memory Usage** - Shows system memory usage (used/total, e.g., "Mem: 12.4G/16.0G")
|
||||
- **Custom Text** - Add your own custom text to the status line
|
||||
- **Custom Command** - Execute shell commands and display their output (refreshes whenever the statusline is updated by Claude Code)
|
||||
- **Link** - Add clickable terminal hyperlinks (OSC 8) with configurable URL and display text
|
||||
- **Separator** - Visual divider between widgets (customizable: |, -, comma, space; available when Powerline mode is off and no default separator is configured)
|
||||
- **Skills** - Shows skill activity as last used, total count, or unique list (with optional list limit and hide-when-empty toggle)
|
||||
- **Separator** - Visual divider between widgets (available when Powerline mode is off and no default separator is configured)
|
||||
- **Flex Separator** - Expands to fill available space (available when Powerline mode is off)
|
||||
|
||||
---
|
||||
@@ -523,6 +549,8 @@ Widget-specific shortcuts:
|
||||
- **Git widgets**: `h` toggle hide `no git` output
|
||||
- **Context % widgets**: `u` toggle used vs remaining display
|
||||
- **Block Timer**: `p` cycle display mode (time/full bar/short bar)
|
||||
- **Block Reset Timer**: `p` cycle display mode (time/full bar/short bar)
|
||||
- **Weekly Reset Timer**: `p` cycle display mode (time/full bar/short bar)
|
||||
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
|
||||
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
|
||||
- **Link**: `u` URL, `e` link text
|
||||
@@ -648,9 +676,12 @@ bun run example
|
||||
# Run tests
|
||||
bun test
|
||||
|
||||
# Run typecheck + eslint autofix
|
||||
# Run typecheck + eslint checks without modifying files
|
||||
bun run lint
|
||||
|
||||
# Apply ESLint auto-fixes intentionally
|
||||
bun run lint:fix
|
||||
|
||||
# Build for distribution
|
||||
bun run build
|
||||
|
||||
|
||||
@@ -16,19 +16,20 @@
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-import-newlines": "^1.4.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"ink": "^6.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"ink": "6.2.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-select-input": "^6.2.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react-devtools-core": "^6.1.5",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"typedoc": "^0.28.12",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest": "^4.0.18",
|
||||
"zod": "^4.0.17",
|
||||
},
|
||||
},
|
||||
@@ -42,6 +43,38 @@
|
||||
"packages": {
|
||||
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
|
||||
@@ -100,25 +133,25 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.9", "", { "os": "win32", "cpu": "x64" }, "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.3.1", "", {}, "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA=="],
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="],
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.33.0", "", {}, "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A=="],
|
||||
"@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="],
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.12.2", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.12.2", "@shikijs/langs": "^3.12.2", "@shikijs/themes": "^3.12.2", "@shikijs/types": "^3.12.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HKZPmO8OSSAAo20H2B3xgJdxZaLTwtlMwxg0967scnrDlPwe6j5+ULGHyIqwgTbFCn9yv/ff8CmfWZLE9YKBzA=="],
|
||||
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
@@ -128,16 +161,18 @@
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w=="],
|
||||
@@ -180,17 +215,19 @@
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w=="],
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2" } }, "sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww=="],
|
||||
"@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2" } }, "sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A=="],
|
||||
"@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@3.12.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q=="],
|
||||
"@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.2.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/types": "^8.38.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-oY7GVkJGVMI5benlBDCaRrSC1qPasafyv5dOBLLv5MTilMGnErKhO6ziEfodDDIZbo5QxPUNW360VudJOFODMw=="],
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.9.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
|
||||
|
||||
@@ -214,31 +251,31 @@
|
||||
|
||||
"@types/pluralize": ["@types/pluralize@0.0.33", "", {}, "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/tinycolor2": ["@types/tinycolor2@1.4.6", "", {}, "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/type-utils": "8.39.1", "@typescript-eslint/utils": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.1", "@typescript-eslint/types": "^8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1" } }, "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw=="],
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="],
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.1", "@typescript-eslint/tsconfig-utils": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw=="],
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg=="],
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="],
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
|
||||
|
||||
@@ -278,31 +315,33 @@
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
|
||||
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
|
||||
"@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
|
||||
"@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
"@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
|
||||
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
@@ -322,8 +361,6 @@
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||
@@ -332,14 +369,14 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
@@ -348,11 +385,11 @@
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
|
||||
|
||||
"chalk": ["chalk@5.5.0", "", {}, "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg=="],
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
@@ -368,11 +405,13 @@
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
||||
@@ -382,8 +421,6 @@
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
@@ -394,6 +431,8 @@
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
@@ -418,13 +457,15 @@
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.39.8", "", {}, "sha512-A8QO9TfF+rltS8BXpdu8OS+rpGgEdnRhqIVxO/ZmNvnXBYgOdSsxukT55ELyP94gZIntWJ+Li9QRrT2u1Kitpg=="],
|
||||
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.33.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.33.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA=="],
|
||||
"eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="],
|
||||
|
||||
"eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="],
|
||||
|
||||
@@ -436,11 +477,11 @@
|
||||
|
||||
"eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
|
||||
|
||||
"eslint-plugin-import-newlines": ["eslint-plugin-import-newlines@1.4.0", "", { "peerDependencies": { "eslint": ">=6.0.0" }, "bin": { "import-linter": "lib/index.js" } }, "sha512-+Cz1x2xBLtI9gJbmuYEpvY7F8K75wskBmJ7rk4VRObIJo+jklUJaejFJgtnWeL0dCFWabGEkhausrikXaNbtoQ=="],
|
||||
"eslint-plugin-import-newlines": ["eslint-plugin-import-newlines@1.4.1", "", { "peerDependencies": { "eslint": ">=6.0.0 < 10.0.0" }, "bin": { "import-linter": "lib/index.js" } }, "sha512-C9PQEJ4jS5tKoE9k0yY/5j4l1bxkxmVjrWvAFc1EToCnuQuwZGl1kS1JBlqYNF8svp0pnXLlkSdjByYa3JALcg=="],
|
||||
|
||||
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
@@ -462,22 +503,16 @@
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
@@ -494,6 +529,8 @@
|
||||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
@@ -514,8 +551,6 @@
|
||||
|
||||
"gradient-string": ["gradient-string@2.0.2", "", { "dependencies": { "chalk": "^4.1.2", "tinygradient": "^1.1.5" } }, "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw=="],
|
||||
|
||||
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
@@ -530,6 +565,12 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
@@ -580,8 +621,6 @@
|
||||
|
||||
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
@@ -614,6 +653,8 @@
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
@@ -636,11 +677,11 @@
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
|
||||
|
||||
@@ -648,10 +689,6 @@
|
||||
|
||||
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
@@ -666,6 +703,8 @@
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
@@ -682,6 +721,8 @@
|
||||
|
||||
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
@@ -704,8 +745,6 @@
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
@@ -724,11 +763,9 @@
|
||||
|
||||
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
||||
|
||||
"react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
|
||||
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
@@ -746,12 +783,8 @@
|
||||
|
||||
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
@@ -786,7 +819,7 @@
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
|
||||
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
@@ -796,7 +829,7 @@
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
@@ -812,14 +845,12 @@
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
@@ -828,23 +859,17 @@
|
||||
|
||||
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
|
||||
"tinyspy": ["tinyspy@4.0.3", "", {}, "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
||||
|
||||
@@ -862,11 +887,11 @@
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
|
||||
|
||||
"typedoc": ["typedoc@0.28.12", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.12.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.8.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-H5ODu4f7N+myG4MfuSp2Vh6wV+WLoZaEYxKPt2y8hmmqNEMVrH69DAjjdmYivF4tP/C2jrIZCZhPalZlTU/ipA=="],
|
||||
"typedoc": ["typedoc@0.28.17", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.17.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.8.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.39.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.39.1", "@typescript-eslint/parser": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg=="],
|
||||
"typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="],
|
||||
|
||||
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||
|
||||
@@ -876,13 +901,13 @@
|
||||
|
||||
"unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"vite": ["vite@7.1.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
|
||||
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
@@ -904,23 +929,41 @@
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
"@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
"@typescript-eslint/project-service/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
|
||||
|
||||
@@ -930,36 +973,56 @@
|
||||
|
||||
"eslint-import-resolver-node/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
|
||||
"eslint-import-resolver-typescript/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
"eslint-plugin-react-hooks/zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
|
||||
|
||||
"gradient-string/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
"ink-gradient/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||
|
||||
"react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "get-east-asian-width": "^1.0.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="],
|
||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
"string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"typedoc/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
"vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
|
||||
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"gradient-string/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"ink-gradient/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
|
||||
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"typedoc/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
}
|
||||
}
|
||||
|
||||
+20
-32
@@ -7,6 +7,23 @@ import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
|
||||
const importResolverSettings = {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: ['./tsconfig.json'],
|
||||
alwaysTryTypes: true,
|
||||
noWarnOnMultipleProjects: true
|
||||
},
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
|
||||
}
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx']
|
||||
},
|
||||
'import/external-module-folders': ['node_modules', 'node_modules/@types']
|
||||
};
|
||||
|
||||
export default ts.config([
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
@@ -38,21 +55,7 @@ export default ts.config([
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: ['./tsconfig.json'],
|
||||
alwaysTryTypes: true,
|
||||
noWarnOnMultipleProjects: true
|
||||
},
|
||||
parcel2: {},
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
|
||||
}
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx']
|
||||
},
|
||||
'import/external-module-folders': ['node_modules', 'node_modules/@types']
|
||||
...importResolverSettings
|
||||
},
|
||||
rules: {
|
||||
'no-control-regex': 'off', // We intentionally match ANSI escape sequences
|
||||
@@ -119,22 +122,7 @@ export default ts.config([
|
||||
'react-hooks': reactHooksPlugin
|
||||
},
|
||||
settings: {
|
||||
...{
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: ['./tsconfig.json'],
|
||||
alwaysTryTypes: true,
|
||||
noWarnOnMultipleProjects: true
|
||||
},
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
|
||||
}
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx']
|
||||
},
|
||||
'import/external-module-folders': ['node_modules', 'node_modules/@types']
|
||||
},
|
||||
...importResolverSettings,
|
||||
react: {
|
||||
version: 'detect'
|
||||
}
|
||||
@@ -156,4 +144,4 @@ export default ts.config([
|
||||
'!eslint.config.js'
|
||||
]
|
||||
}
|
||||
]);
|
||||
]);
|
||||
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ccstatusline",
|
||||
"version": "2.1.3",
|
||||
"version": "2.2.3",
|
||||
"description": "A customizable status line formatter for Claude Code CLI",
|
||||
"module": "src/ccstatusline.ts",
|
||||
"type": "module",
|
||||
@@ -16,7 +16,8 @@
|
||||
"postbuild": "bun run scripts/replace-version.ts",
|
||||
"example": "cat scripts/payload.example.json | bun start",
|
||||
"prepublishOnly": "bun run build",
|
||||
"lint": "bun tsc --noEmit; eslint . --config eslint.config.js --max-warnings=999999 --fix",
|
||||
"lint": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0",
|
||||
"lint:fix": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0 --fix",
|
||||
"docs": "typedoc",
|
||||
"docs:clean": "rm -rf docs"
|
||||
},
|
||||
@@ -32,19 +33,20 @@
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-import-newlines": "^1.4.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"ink": "^6.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"ink": "6.2.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-select-input": "^6.2.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react-devtools-core": "^6.1.5",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"typedoc": "^0.28.12",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest": "^4.0.18",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
+126
-6
@@ -2,18 +2,24 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { runTUI } from './tui';
|
||||
import type { TokenMetrics } from './types';
|
||||
import type {
|
||||
SkillsMetrics,
|
||||
SpeedMetrics,
|
||||
TokenMetrics
|
||||
} from './types';
|
||||
import type { RenderContext } from './types/RenderContext';
|
||||
import type { StatusJSON } from './types/StatusJSON';
|
||||
import { StatusJSONSchema } from './types/StatusJSON';
|
||||
import { getVisibleText } from './utils/ansi';
|
||||
import { updateColorMap } from './utils/colors';
|
||||
import {
|
||||
initConfigPath,
|
||||
loadSettings,
|
||||
saveSettings
|
||||
} from './utils/config';
|
||||
import {
|
||||
getSessionDuration,
|
||||
getSpeedMetricsCollection,
|
||||
getTokenMetrics
|
||||
} from './utils/jsonl';
|
||||
import {
|
||||
@@ -21,6 +27,16 @@ import {
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine
|
||||
} from './utils/renderer';
|
||||
import { advanceGlobalSeparatorIndex } from './utils/separator-index';
|
||||
import {
|
||||
getSkillsFilePath,
|
||||
getSkillsMetrics
|
||||
} from './utils/skills';
|
||||
import {
|
||||
getWidgetSpeedWindowSeconds,
|
||||
isWidgetSpeedWindowEnabled
|
||||
} from './utils/speed-window';
|
||||
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
|
||||
|
||||
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
|
||||
const durationMs = data.cost?.total_duration_ms;
|
||||
@@ -84,6 +100,17 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
// Check if session clock is needed
|
||||
const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock'));
|
||||
|
||||
const speedWidgetTypes = new Set(['output-speed', 'input-speed', 'total-speed']);
|
||||
const hasSpeedItems = lines.some(line => line.some(item => speedWidgetTypes.has(item.type)));
|
||||
const requestedSpeedWindows = new Set<number>();
|
||||
for (const line of lines) {
|
||||
for (const item of line) {
|
||||
if (speedWidgetTypes.has(item.type) && isWidgetSpeedWindowEnabled(item)) {
|
||||
requestedSpeedWindows.add(getWidgetSpeedWindowSeconds(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tokenMetrics: TokenMetrics | null = null;
|
||||
if (data.transcript_path) {
|
||||
tokenMetrics = await getTokenMetrics(data.transcript_path);
|
||||
@@ -94,11 +121,34 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
sessionDuration = await getSessionDuration(data.transcript_path);
|
||||
}
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines);
|
||||
|
||||
let speedMetrics: SpeedMetrics | null = null;
|
||||
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
|
||||
if (hasSpeedItems && data.transcript_path) {
|
||||
const speedMetricsCollection = await getSpeedMetricsCollection(data.transcript_path, {
|
||||
includeSubagents: true,
|
||||
windowSeconds: Array.from(requestedSpeedWindows)
|
||||
});
|
||||
|
||||
speedMetrics = speedMetricsCollection.sessionAverage;
|
||||
windowedSpeedMetrics = speedMetricsCollection.windowed;
|
||||
}
|
||||
|
||||
let skillsMetrics: SkillsMetrics | null = null;
|
||||
if (data.session_id) {
|
||||
skillsMetrics = getSkillsMetrics(data.session_id);
|
||||
}
|
||||
|
||||
// Create render context
|
||||
const context: RenderContext = {
|
||||
data,
|
||||
tokenMetrics,
|
||||
speedMetrics,
|
||||
windowedSpeedMetrics,
|
||||
usageData,
|
||||
sessionDuration,
|
||||
skillsMetrics,
|
||||
isPreview: false
|
||||
};
|
||||
|
||||
@@ -119,17 +169,14 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
// Strip ANSI codes to check if there's actual text
|
||||
const strippedLine = getVisibleText(line).trim();
|
||||
if (strippedLine.length > 0) {
|
||||
// Count separators used in this line (widgets - 1, excluding merged widgets)
|
||||
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
|
||||
if (nonMergedWidgets.length > 1)
|
||||
globalSeparatorIndex += nonMergedWidgets.length - 1;
|
||||
|
||||
// Replace all spaces with non-breaking spaces to prevent VSCode trimming
|
||||
let outputLine = line.replace(/ /g, '\u00A0');
|
||||
|
||||
// Add reset code at the beginning to override Claude Code's dim setting
|
||||
outputLine = '\x1b[0m' + outputLine;
|
||||
console.log(outputLine);
|
||||
|
||||
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +211,80 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfigArg(): string | undefined {
|
||||
const idx = process.argv.indexOf('--config');
|
||||
if (idx === -1)
|
||||
return undefined;
|
||||
const configPath = process.argv[idx + 1];
|
||||
if (!configPath || configPath.startsWith('--')) {
|
||||
console.error('--config requires a file path argument');
|
||||
process.exit(1);
|
||||
}
|
||||
process.argv.splice(idx, 2);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
interface HookInput {
|
||||
session_id?: string;
|
||||
hook_event_name?: string;
|
||||
tool_name?: string;
|
||||
tool_input?: { skill?: string };
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
async function handleHook(): Promise<void> {
|
||||
const input = await readStdin();
|
||||
if (!input) {
|
||||
console.log('{}');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(input) as HookInput;
|
||||
const sessionId = data.session_id;
|
||||
if (!sessionId) {
|
||||
console.log('{}');
|
||||
return;
|
||||
}
|
||||
|
||||
let skillName = '';
|
||||
if (data.hook_event_name === 'PreToolUse' && data.tool_name === 'Skill') {
|
||||
skillName = data.tool_input?.skill ?? '';
|
||||
} else if (data.hook_event_name === 'UserPromptSubmit') {
|
||||
const match = /^\/([a-zA-Z0-9_:-]+)/.exec(data.prompt ?? '');
|
||||
if (match) {
|
||||
skillName = match[1] ?? '';
|
||||
}
|
||||
}
|
||||
if (!skillName) {
|
||||
console.log('{}');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = getSkillsFilePath(sessionId);
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const entry = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: sessionId,
|
||||
skill: skillName,
|
||||
source: data.hook_event_name
|
||||
});
|
||||
fs.appendFileSync(filePath, entry + '\n');
|
||||
} catch { /* ignore parse errors */ }
|
||||
console.log('{}');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Parse --config before anything else
|
||||
initConfigPath(parseConfigArg());
|
||||
|
||||
// Handle --hook mode (cross-platform hook handler for widgets)
|
||||
if (process.argv.includes('--hook')) {
|
||||
await handleHook();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in a piped/non-TTY environment first
|
||||
if (!process.stdin.isTTY) {
|
||||
await ensureWindowsUtf8CodePage();
|
||||
|
||||
+117
-78
@@ -22,9 +22,13 @@ import {
|
||||
installStatusLine,
|
||||
isBunxAvailable,
|
||||
isInstalled,
|
||||
isKnownCommand,
|
||||
uninstallStatusLine
|
||||
} from '../utils/claude-settings';
|
||||
import { cloneSettings } from '../utils/clone-settings';
|
||||
import {
|
||||
getConfigPath,
|
||||
isCustomConfigPath,
|
||||
loadSettings,
|
||||
saveSettings
|
||||
} from '../utils/config';
|
||||
@@ -48,7 +52,8 @@ import {
|
||||
PowerlineSetup,
|
||||
StatusLinePreview,
|
||||
TerminalOptionsMenu,
|
||||
TerminalWidthMenu
|
||||
TerminalWidthMenu,
|
||||
type MainMenuOption
|
||||
} from './components';
|
||||
|
||||
const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline';
|
||||
@@ -58,15 +63,47 @@ interface FlashMessage {
|
||||
color: 'green' | 'red';
|
||||
}
|
||||
|
||||
type AppScreen = 'main'
|
||||
| 'lines'
|
||||
| 'items'
|
||||
| 'colorLines'
|
||||
| 'colors'
|
||||
| 'terminalWidth'
|
||||
| 'terminalConfig'
|
||||
| 'globalOverrides'
|
||||
| 'confirm'
|
||||
| 'powerline'
|
||||
| 'install';
|
||||
|
||||
interface ConfirmDialogState {
|
||||
message: string;
|
||||
action: () => Promise<void>;
|
||||
cancelScreen?: Exclude<AppScreen, 'confirm'>;
|
||||
}
|
||||
|
||||
export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude<AppScreen, 'confirm'> {
|
||||
return confirmDialog?.cancelScreen ?? 'main';
|
||||
}
|
||||
|
||||
export function clearInstallMenuSelection(menuSelections: Record<string, number>): Record<string, number> {
|
||||
if (menuSelections.install === undefined) {
|
||||
return menuSelections;
|
||||
}
|
||||
|
||||
const next = { ...menuSelections };
|
||||
delete next.install;
|
||||
return next;
|
||||
}
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const { exit } = useApp();
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [originalSettings, setOriginalSettings] = useState<Settings | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colorLines' | 'colors' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' | 'confirm' | 'powerline' | 'install'>('main');
|
||||
const [screen, setScreen] = useState<AppScreen>('main');
|
||||
const [selectedLine, setSelectedLine] = useState(0);
|
||||
const [menuSelections, setMenuSelections] = useState<Record<string, number>>({});
|
||||
const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise<void> } | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null);
|
||||
const [isClaudeInstalled, setIsClaudeInstalled] = useState(false);
|
||||
const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
|
||||
const [powerlineFontStatus, setPowerlineFontStatus] = useState<PowerlineFontStatus>({ installed: false });
|
||||
@@ -84,7 +121,7 @@ export const App: React.FC = () => {
|
||||
// Set global chalk level based on settings (default to 256 colors for compatibility)
|
||||
chalk.level = loadedSettings.colorLevel;
|
||||
setSettings(loadedSettings);
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings)) as Settings); // Deep copy
|
||||
setOriginalSettings(cloneSettings(loadedSettings));
|
||||
});
|
||||
void isInstalled().then(setIsClaudeInstalled);
|
||||
|
||||
@@ -133,7 +170,7 @@ export const App: React.FC = () => {
|
||||
if (key.ctrl && input === 's' && settings) {
|
||||
void (async () => {
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings);
|
||||
setOriginalSettings(cloneSettings(settings));
|
||||
setHasChanges(false);
|
||||
setFlashMessage({
|
||||
text: '✓ Configuration saved',
|
||||
@@ -145,7 +182,7 @@ export const App: React.FC = () => {
|
||||
|
||||
const handleInstallSelection = useCallback((command: string, displayName: string, useBunx: boolean) => {
|
||||
void getExistingStatusLine().then((existing) => {
|
||||
const isAlreadyInstalled = [CCSTATUSLINE_COMMANDS.NPM, CCSTATUSLINE_COMMANDS.BUNX, CCSTATUSLINE_COMMANDS.SELF_MANAGED].includes(existing ?? '');
|
||||
const isAlreadyInstalled = isKnownCommand(existing ?? '');
|
||||
let message: string;
|
||||
|
||||
if (existing && !isAlreadyInstalled) {
|
||||
@@ -158,6 +195,7 @@ export const App: React.FC = () => {
|
||||
|
||||
setConfirmDialog({
|
||||
message,
|
||||
cancelScreen: 'install',
|
||||
action: async () => {
|
||||
await installStatusLine(useBunx);
|
||||
setIsClaudeInstalled(true);
|
||||
@@ -171,13 +209,20 @@ export const App: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const handleNpxInstall = useCallback(() => {
|
||||
setMenuSelections(prev => ({ ...prev, install: 0 }));
|
||||
handleInstallSelection(CCSTATUSLINE_COMMANDS.NPM, 'npx', false);
|
||||
}, [handleInstallSelection]);
|
||||
|
||||
const handleBunxInstall = useCallback(() => {
|
||||
setMenuSelections(prev => ({ ...prev, install: 1 }));
|
||||
handleInstallSelection(CCSTATUSLINE_COMMANDS.BUNX, 'bunx', true);
|
||||
}, [handleInstallSelection]);
|
||||
|
||||
const handleInstallMenuCancel = useCallback(() => {
|
||||
setMenuSelections(clearInstallMenuSelection);
|
||||
setScreen('main');
|
||||
}, []);
|
||||
|
||||
if (!settings) {
|
||||
return <Text>Loading settings...</Text>;
|
||||
}
|
||||
@@ -202,58 +247,58 @@ export const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMainMenuSelect = async (value: string) => {
|
||||
const handleMainMenuSelect = async (value: MainMenuOption) => {
|
||||
switch (value) {
|
||||
case 'lines':
|
||||
setScreen('lines');
|
||||
break;
|
||||
case 'colors':
|
||||
setScreen('colorLines');
|
||||
break;
|
||||
case 'terminalConfig':
|
||||
setScreen('terminalConfig');
|
||||
break;
|
||||
case 'globalOverrides':
|
||||
setScreen('globalOverrides');
|
||||
break;
|
||||
case 'powerline':
|
||||
setScreen('powerline');
|
||||
break;
|
||||
case 'install':
|
||||
handleInstallUninstall();
|
||||
break;
|
||||
case 'starGithub':
|
||||
setConfirmDialog({
|
||||
message: `Open the ccstatusline GitHub repository in your browser?\n\n${GITHUB_REPO_URL}`,
|
||||
action: () => {
|
||||
const result = openExternalUrl(GITHUB_REPO_URL);
|
||||
if (result.success) {
|
||||
setFlashMessage({
|
||||
text: '✓ Opened GitHub repository in browser',
|
||||
color: 'green'
|
||||
});
|
||||
} else {
|
||||
setFlashMessage({
|
||||
text: `✗ Could not open browser. Visit: ${GITHUB_REPO_URL}`,
|
||||
color: 'red'
|
||||
});
|
||||
case 'lines':
|
||||
setScreen('lines');
|
||||
break;
|
||||
case 'colors':
|
||||
setScreen('colorLines');
|
||||
break;
|
||||
case 'terminalConfig':
|
||||
setScreen('terminalConfig');
|
||||
break;
|
||||
case 'globalOverrides':
|
||||
setScreen('globalOverrides');
|
||||
break;
|
||||
case 'powerline':
|
||||
setScreen('powerline');
|
||||
break;
|
||||
case 'install':
|
||||
handleInstallUninstall();
|
||||
break;
|
||||
case 'starGithub':
|
||||
setConfirmDialog({
|
||||
message: `Open the ccstatusline GitHub repository in your browser?\n\n${GITHUB_REPO_URL}`,
|
||||
action: () => {
|
||||
const result = openExternalUrl(GITHUB_REPO_URL);
|
||||
if (result.success) {
|
||||
setFlashMessage({
|
||||
text: '✓ Opened GitHub repository in browser',
|
||||
color: 'green'
|
||||
});
|
||||
} else {
|
||||
setFlashMessage({
|
||||
text: `✗ Could not open browser. Visit: ${GITHUB_REPO_URL}`,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
setScreen('main');
|
||||
setConfirmDialog(null);
|
||||
return Promise.resolve();
|
||||
}
|
||||
setScreen('main');
|
||||
setConfirmDialog(null);
|
||||
return Promise.resolve();
|
||||
}
|
||||
});
|
||||
setScreen('confirm');
|
||||
break;
|
||||
case 'save':
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings); // Update original after save
|
||||
setHasChanges(false);
|
||||
exit();
|
||||
break;
|
||||
case 'exit':
|
||||
exit();
|
||||
break;
|
||||
});
|
||||
setScreen('confirm');
|
||||
break;
|
||||
case 'save':
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(cloneSettings(settings)); // Update original after save
|
||||
setHasChanges(false);
|
||||
exit();
|
||||
break;
|
||||
case 'exit':
|
||||
exit();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -289,6 +334,9 @@ export const App: React.FC = () => {
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{isCustomConfigPath() && (
|
||||
<Text dimColor>{`Config: ${getConfigPath()}`}</Text>
|
||||
)}
|
||||
|
||||
<StatusLinePreview
|
||||
lines={settings.lines}
|
||||
@@ -300,20 +348,12 @@ export const App: React.FC = () => {
|
||||
<Box marginTop={1}>
|
||||
{screen === 'main' && (
|
||||
<MainMenu
|
||||
onSelect={(value) => {
|
||||
onSelect={(value, index) => {
|
||||
// Only persist menu selection if not exiting
|
||||
if (value !== 'save' && value !== 'exit') {
|
||||
const menuMap: Record<string, number> = {
|
||||
lines: 0,
|
||||
colors: 1,
|
||||
powerline: 2,
|
||||
terminalConfig: 3,
|
||||
globalOverrides: 4,
|
||||
install: 5,
|
||||
starGithub: hasChanges ? 8 : 7
|
||||
};
|
||||
setMenuSelections({ ...menuSelections, main: menuMap[value] ?? 0 });
|
||||
setMenuSelections(prev => ({ ...prev, main: index }));
|
||||
}
|
||||
|
||||
void handleMainMenuSelect(value);
|
||||
}}
|
||||
isClaudeInstalled={isClaudeInstalled}
|
||||
@@ -328,14 +368,14 @@ export const App: React.FC = () => {
|
||||
<LineSelector
|
||||
lines={settings.lines}
|
||||
onSelect={(line) => {
|
||||
setMenuSelections({ ...menuSelections, lines: line });
|
||||
setMenuSelections(prev => ({ ...prev, lines: line }));
|
||||
handleLineSelect(line);
|
||||
}}
|
||||
onLinesUpdate={updateLines}
|
||||
onBack={() => {
|
||||
// Save that we came from 'lines' menu (index 0)
|
||||
// Clear the line selection so it resets next time we enter
|
||||
setMenuSelections({ ...menuSelections, main: 0 });
|
||||
setMenuSelections(prev => ({ ...prev, main: 0 }));
|
||||
setScreen('main');
|
||||
}}
|
||||
initialSelection={menuSelections.lines}
|
||||
@@ -349,7 +389,7 @@ export const App: React.FC = () => {
|
||||
onUpdate={(widgets) => { updateLine(selectedLine, widgets); }}
|
||||
onBack={() => {
|
||||
// When going back to lines menu, preserve which line was selected
|
||||
setMenuSelections({ ...menuSelections, lines: selectedLine });
|
||||
setMenuSelections(prev => ({ ...prev, lines: selectedLine }));
|
||||
setScreen('lines');
|
||||
}}
|
||||
lineNumber={selectedLine + 1}
|
||||
@@ -361,13 +401,13 @@ export const App: React.FC = () => {
|
||||
lines={settings.lines}
|
||||
onLinesUpdate={updateLines}
|
||||
onSelect={(line) => {
|
||||
setMenuSelections({ ...menuSelections, lines: line });
|
||||
setMenuSelections(prev => ({ ...prev, lines: line }));
|
||||
setSelectedLine(line);
|
||||
setScreen('colors');
|
||||
}}
|
||||
onBack={() => {
|
||||
// Save that we came from 'colors' menu (index 1)
|
||||
setMenuSelections({ ...menuSelections, main: 1 });
|
||||
setMenuSelections(prev => ({ ...prev, main: 1 }));
|
||||
setScreen('main');
|
||||
}}
|
||||
initialSelection={menuSelections.lines}
|
||||
@@ -405,7 +445,7 @@ export const App: React.FC = () => {
|
||||
setScreen('terminalWidth');
|
||||
} else {
|
||||
// Save that we came from 'terminalConfig' menu (index 3)
|
||||
setMenuSelections({ ...menuSelections, main: 3 });
|
||||
setMenuSelections(prev => ({ ...prev, main: 3 }));
|
||||
setScreen('main');
|
||||
}
|
||||
}}
|
||||
@@ -430,7 +470,7 @@ export const App: React.FC = () => {
|
||||
}}
|
||||
onBack={() => {
|
||||
// Save that we came from 'globalOverrides' menu (index 4)
|
||||
setMenuSelections({ ...menuSelections, main: 4 });
|
||||
setMenuSelections(prev => ({ ...prev, main: 4 }));
|
||||
setScreen('main');
|
||||
}}
|
||||
/>
|
||||
@@ -440,7 +480,7 @@ export const App: React.FC = () => {
|
||||
message={confirmDialog.message}
|
||||
onConfirm={() => void confirmDialog.action()}
|
||||
onCancel={() => {
|
||||
setScreen('main');
|
||||
setScreen(getConfirmCancelScreen(confirmDialog));
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
/>
|
||||
@@ -451,9 +491,8 @@ export const App: React.FC = () => {
|
||||
existingStatusLine={existingStatusLine}
|
||||
onSelectNpx={handleNpxInstall}
|
||||
onSelectBunx={handleBunxInstall}
|
||||
onCancel={() => {
|
||||
setScreen('main');
|
||||
}}
|
||||
onCancel={handleInstallMenuCancel}
|
||||
initialSelection={menuSelections.install}
|
||||
/>
|
||||
)}
|
||||
{screen === 'powerline' && (
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
clearInstallMenuSelection,
|
||||
getConfirmCancelScreen
|
||||
} from '../App';
|
||||
|
||||
describe('App confirm navigation helpers', () => {
|
||||
it('defaults confirmation cancel navigation to the main menu', () => {
|
||||
expect(getConfirmCancelScreen(null)).toBe('main');
|
||||
expect(getConfirmCancelScreen({
|
||||
message: 'Confirm install?',
|
||||
action: () => Promise.resolve()
|
||||
})).toBe('main');
|
||||
});
|
||||
|
||||
it('returns to the install menu when the confirm dialog requests it', () => {
|
||||
expect(getConfirmCancelScreen({
|
||||
message: 'Confirm install?',
|
||||
action: () => Promise.resolve(),
|
||||
cancelScreen: 'install'
|
||||
})).toBe('install');
|
||||
});
|
||||
|
||||
it('clears saved install selection when leaving the install menu', () => {
|
||||
expect(clearInstallMenuSelection({
|
||||
main: 5,
|
||||
install: 1
|
||||
})).toEqual({ main: 5 });
|
||||
|
||||
const menuSelections = { main: 5 };
|
||||
|
||||
expect(clearInstallMenuSelection(menuSelections)).toBe(menuSelections);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,13 @@ import { shouldInsertInput } from '../../utils/input-guards';
|
||||
import { getWidget } from '../../utils/widgets';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
clearAllWidgetStyling,
|
||||
cycleWidgetColor,
|
||||
resetWidgetStyling,
|
||||
setWidgetColor,
|
||||
toggleWidgetBold
|
||||
} from './color-menu/mutations';
|
||||
|
||||
export interface ColorMenuProps {
|
||||
widgets: WidgetItem[];
|
||||
@@ -80,17 +87,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
const hexColor = `hex:${hexInput}`;
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
// IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === highlightedItemId) {
|
||||
if (editingBackground) {
|
||||
return { ...widget, backgroundColor: hexColor };
|
||||
} else {
|
||||
return { ...widget, color: hexColor };
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground);
|
||||
onUpdate(newItems);
|
||||
}
|
||||
setHexInputMode(false);
|
||||
@@ -126,17 +123,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
|
||||
if (selectedWidget) {
|
||||
// IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === highlightedItemId) {
|
||||
if (editingBackground) {
|
||||
return { ...widget, backgroundColor: ansiColor };
|
||||
} else {
|
||||
return { ...widget, color: ansiColor };
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground);
|
||||
|
||||
onUpdate(newItems);
|
||||
setAnsi256InputMode(false);
|
||||
@@ -199,12 +186,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
// Toggle bold for the highlighted item
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === selectedWidget.id) {
|
||||
return { ...widget, bold: !widget.bold };
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
const newItems = toggleWidgetBold(widgets, selectedWidget.id);
|
||||
onUpdate(newItems);
|
||||
}
|
||||
}
|
||||
@@ -213,17 +195,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
// Reset all styling (color, background, and bold) for the highlighted item
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === selectedWidget.id) {
|
||||
// Remove color, backgroundColor, and bold properties
|
||||
const { color, backgroundColor, bold, ...restWidget } = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
return restWidget;
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
const newItems = resetWidgetStyling(widgets, selectedWidget.id);
|
||||
onUpdate(newItems);
|
||||
}
|
||||
}
|
||||
@@ -235,52 +207,13 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
if (highlightedItemId && highlightedItemId !== 'back') {
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === selectedWidget.id) {
|
||||
if (editingBackground) {
|
||||
const currentBgColor = widget.backgroundColor ?? ''; // Empty string for 'none'
|
||||
let currentBgColorIndex = bgColors.indexOf(currentBgColor);
|
||||
// If color not found, start from beginning
|
||||
if (currentBgColorIndex === -1)
|
||||
currentBgColorIndex = 0;
|
||||
|
||||
let nextBgColorIndex;
|
||||
if (key.rightArrow) {
|
||||
nextBgColorIndex = (currentBgColorIndex + 1) % bgColors.length;
|
||||
} else {
|
||||
nextBgColorIndex = currentBgColorIndex === 0 ? bgColors.length - 1 : currentBgColorIndex - 1;
|
||||
}
|
||||
const nextBgColor = bgColors[nextBgColorIndex];
|
||||
return { ...widget, backgroundColor: nextBgColor === '' ? undefined : nextBgColor };
|
||||
} else {
|
||||
let defaultColor = 'white';
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
defaultColor = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
let currentColor = widget.color ?? defaultColor;
|
||||
// If color is 'dim', treat as if no color was set
|
||||
if (currentColor === 'dim') {
|
||||
currentColor = defaultColor;
|
||||
}
|
||||
let currentColorIndex = colors.indexOf(currentColor);
|
||||
// If color not found, start from beginning
|
||||
if (currentColorIndex === -1)
|
||||
currentColorIndex = 0;
|
||||
|
||||
let nextColorIndex;
|
||||
if (key.rightArrow) {
|
||||
nextColorIndex = (currentColorIndex + 1) % colors.length;
|
||||
} else {
|
||||
nextColorIndex = currentColorIndex === 0 ? colors.length - 1 : currentColorIndex - 1;
|
||||
}
|
||||
const nextColor = colors[nextColorIndex];
|
||||
return { ...widget, color: nextColor };
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
const newItems = cycleWidgetColor({
|
||||
widgets,
|
||||
widgetId: selectedWidget.id,
|
||||
direction: key.rightArrow ? 'right' : 'left',
|
||||
editingBackground,
|
||||
colors,
|
||||
backgroundColors: bgColors
|
||||
});
|
||||
onUpdate(newItems);
|
||||
}
|
||||
@@ -430,15 +363,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
<ConfirmDialog
|
||||
inline={true}
|
||||
onConfirm={() => {
|
||||
// Clear all colors from all widgets
|
||||
const newItems = widgets.map((widget) => {
|
||||
// Remove color, backgroundColor, and bold properties
|
||||
const { color, backgroundColor, bold, ...restWidget } = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
return restWidget;
|
||||
});
|
||||
const newItems = clearAllWidgetStyling(widgets);
|
||||
onUpdate(newItems);
|
||||
setShowClearConfirm(false);
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,12 @@ import {
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
message?: string;
|
||||
@@ -12,52 +17,57 @@ export interface ConfirmDialogProps {
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm, onCancel, inline = false }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0); // Default to "Yes"
|
||||
const CONFIRM_OPTIONS: ListEntry<boolean>[] = [
|
||||
{
|
||||
label: 'Yes',
|
||||
value: true
|
||||
},
|
||||
{
|
||||
label: 'No',
|
||||
value: false
|
||||
}
|
||||
];
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(1, selectedIndex + 1));
|
||||
} else if (key.return) {
|
||||
if (selectedIndex === 0) {
|
||||
onConfirm();
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
} else if (key.escape) {
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm, onCancel, inline = false }) => {
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
const renderOptions = () => {
|
||||
const yesStyle = selectedIndex === 0 ? { color: 'cyan' } : {};
|
||||
const noStyle = selectedIndex === 1 ? { color: 'cyan' } : {};
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text {...yesStyle}>
|
||||
{selectedIndex === 0 ? '▶ ' : ' '}
|
||||
Yes
|
||||
</Text>
|
||||
<Text {...noStyle}>
|
||||
{selectedIndex === 1 ? '▶ ' : ' '}
|
||||
No
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (inline) {
|
||||
return renderOptions();
|
||||
return (
|
||||
<List
|
||||
items={CONFIRM_OPTIONS}
|
||||
onSelect={(confirmed) => {
|
||||
if (confirmed) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
onCancel();
|
||||
}}
|
||||
color='cyan'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text>{message}</Text>
|
||||
<Box marginTop={1}>
|
||||
{renderOptions()}
|
||||
<List
|
||||
items={CONFIRM_OPTIONS}
|
||||
onSelect={(confirmed) => {
|
||||
if (confirmed) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
onCancel();
|
||||
}}
|
||||
color='cyan'
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -3,16 +3,19 @@ import {
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { getClaudeSettingsPath } from '../../utils/claude-settings';
|
||||
|
||||
import { List } from './List';
|
||||
|
||||
export interface InstallMenuProps {
|
||||
bunxAvailable: boolean;
|
||||
existingStatusLine: string | null;
|
||||
onSelectNpx: () => void;
|
||||
onSelectBunx: () => void;
|
||||
onCancel: () => void;
|
||||
initialSelection?: number;
|
||||
}
|
||||
|
||||
export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
@@ -20,39 +23,44 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
existingStatusLine,
|
||||
onSelectNpx,
|
||||
onSelectBunx,
|
||||
onCancel
|
||||
onCancel,
|
||||
initialSelection = 0
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const maxIndex = 2; // npx, bunx (if available), and back
|
||||
|
||||
useInput((input, key) => {
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onCancel();
|
||||
} else if (key.upArrow) {
|
||||
if (selectedIndex === 2) {
|
||||
setSelectedIndex(bunxAvailable ? 1 : 0); // Skip bunx if not available
|
||||
} else {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
}
|
||||
} else if (key.downArrow) {
|
||||
if (selectedIndex === 0) {
|
||||
setSelectedIndex(bunxAvailable ? 1 : 2); // Skip bunx if not available
|
||||
} else if (selectedIndex === 1 && bunxAvailable) {
|
||||
setSelectedIndex(2);
|
||||
} else {
|
||||
setSelectedIndex(Math.min(maxIndex, selectedIndex + 1));
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (selectedIndex === 0) {
|
||||
onSelectNpx();
|
||||
} else if (selectedIndex === 1 && bunxAvailable) {
|
||||
onSelectBunx();
|
||||
} else if (selectedIndex === 2) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function onSelect(value: string) {
|
||||
switch (value) {
|
||||
case 'npx':
|
||||
onSelectNpx();
|
||||
break;
|
||||
case 'bunx':
|
||||
if (bunxAvailable) {
|
||||
onSelectBunx();
|
||||
}
|
||||
break;
|
||||
case 'back':
|
||||
onCancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const listItems = [
|
||||
{
|
||||
label: 'npx - Node Package Execute',
|
||||
value: 'npx'
|
||||
},
|
||||
{
|
||||
label: 'bunx - Bun Package Execute',
|
||||
sublabel: bunxAvailable ? undefined : '(not installed)',
|
||||
value: 'bunx',
|
||||
disabled: !bunxAvailable
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Install ccstatusline to Claude Code</Text>
|
||||
@@ -71,29 +79,21 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
<Text dimColor>Select package manager to use:</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box>
|
||||
<Text color={selectedIndex === 0 ? 'blue' : undefined}>
|
||||
{selectedIndex === 0 ? '▶ ' : ' '}
|
||||
npx - Node Package Execute
|
||||
</Text>
|
||||
</Box>
|
||||
<List
|
||||
color='blue'
|
||||
marginTop={1}
|
||||
items={listItems}
|
||||
onSelect={(line) => {
|
||||
if (line === 'back') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
<Box>
|
||||
<Text color={selectedIndex === 1 && bunxAvailable ? 'blue' : undefined} dimColor={!bunxAvailable}>
|
||||
{selectedIndex === 1 && bunxAvailable ? '▶ ' : ' '}
|
||||
bunx - Bun Package Execute
|
||||
{!bunxAvailable && ' (not installed)'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedIndex === 2 ? 'blue' : undefined}>
|
||||
{selectedIndex === 2 ? '▶ ' : ' '}
|
||||
← Back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
onSelect(line);
|
||||
}}
|
||||
initialSelection={initialSelection}
|
||||
showBackButton={true}
|
||||
/>
|
||||
|
||||
<Box marginTop={2}>
|
||||
<Text dimColor>
|
||||
|
||||
@@ -23,6 +23,16 @@ import {
|
||||
} from '../../utils/widgets';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
handleMoveInputMode,
|
||||
handleNormalInputMode,
|
||||
handlePickerInputMode,
|
||||
normalizePickerState,
|
||||
type CustomEditorWidgetState,
|
||||
type WidgetPickerAction,
|
||||
type WidgetPickerState
|
||||
} from './items-editor/input-handlers';
|
||||
import { shouldShowCustomKeybind } from './items-editor/keybind-visibility';
|
||||
|
||||
export interface ItemsEditorProps {
|
||||
widgets: WidgetItem[];
|
||||
@@ -32,22 +42,10 @@ export interface ItemsEditorProps {
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
type WidgetPickerAction = 'change' | 'add' | 'insert';
|
||||
type WidgetPickerLevel = 'category' | 'widget';
|
||||
|
||||
interface WidgetPickerState {
|
||||
action: WidgetPickerAction;
|
||||
level: WidgetPickerLevel;
|
||||
selectedCategory: string | null;
|
||||
categoryQuery: string;
|
||||
widgetQuery: string;
|
||||
selectedType: WidgetItemType | null;
|
||||
}
|
||||
|
||||
export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onBack, lineNumber, settings }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [moveMode, setMoveMode] = useState(false);
|
||||
const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: Widget; action?: string } | null>(null);
|
||||
const [customEditorWidget, setCustomEditorWidget] = useState<CustomEditorWidgetState | null>(null);
|
||||
const [widgetPicker, setWidgetPicker] = useState<WidgetPickerState | null>(null);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const separatorChars = ['|', '-', ',', ' '];
|
||||
@@ -97,41 +95,6 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
setCustomEditorWidget(null);
|
||||
};
|
||||
|
||||
const getFilteredCategories = (query: string): string[] => {
|
||||
void query;
|
||||
return [...widgetCategories];
|
||||
};
|
||||
|
||||
const normalizePickerState = (state: WidgetPickerState): WidgetPickerState => {
|
||||
const filteredCategories = getFilteredCategories(state.categoryQuery);
|
||||
const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory)
|
||||
? state.selectedCategory
|
||||
: (filteredCategories[0] ?? null);
|
||||
|
||||
const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0;
|
||||
const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All');
|
||||
const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery;
|
||||
const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery);
|
||||
const hasSelectedType = state.selectedType
|
||||
? filteredWidgets.some(entry => entry.type === state.selectedType)
|
||||
: false;
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedCategory,
|
||||
selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null)
|
||||
};
|
||||
};
|
||||
|
||||
const shouldShowCustomKeybind = (widget: WidgetItem, keybind: CustomKeybind): boolean => {
|
||||
if (keybind.action !== 'toggle-invert') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const mode = widget.metadata?.display;
|
||||
return mode === 'progress' || mode === 'progress-short';
|
||||
};
|
||||
|
||||
const getVisibleCustomKeybinds = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
|
||||
if (!widgetImpl.getCustomKeybinds) {
|
||||
return [];
|
||||
@@ -155,7 +118,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType
|
||||
}));
|
||||
}, widgetCatalog, widgetCategories));
|
||||
};
|
||||
|
||||
const applyWidgetPickerSelection = (selectedType: WidgetItemType) => {
|
||||
@@ -201,286 +164,45 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
}
|
||||
|
||||
if (widgetPicker) {
|
||||
const filteredCategories = getFilteredCategories(widgetPicker.categoryQuery);
|
||||
const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory)
|
||||
? widgetPicker.selectedCategory
|
||||
: (filteredCategories[0] ?? null);
|
||||
const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0;
|
||||
const topLevelSearchEntries = hasTopLevelSearch
|
||||
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
|
||||
: [];
|
||||
const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0];
|
||||
const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery);
|
||||
const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0];
|
||||
|
||||
if (widgetPicker.level === 'category') {
|
||||
if (key.escape) {
|
||||
if (widgetPicker.categoryQuery.length > 0) {
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
categoryQuery: ''
|
||||
}) : prev);
|
||||
} else {
|
||||
setWidgetPicker(null);
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (hasTopLevelSearch) {
|
||||
if (topLevelSelectedEntry) {
|
||||
applyWidgetPickerSelection(topLevelSelectedEntry.type);
|
||||
}
|
||||
} else if (selectedCategory) {
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
level: 'widget',
|
||||
selectedCategory
|
||||
}) : prev);
|
||||
}
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
if (hasTopLevelSearch) {
|
||||
if (topLevelSearchEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
const nextType = topLevelSearchEntries[nextIndex]?.type ?? null;
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
selectedType: nextType
|
||||
}) : prev);
|
||||
} else {
|
||||
if (filteredCategories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = filteredCategories.findIndex(category => category === selectedCategory);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(filteredCategories.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
const nextCategory = filteredCategories[nextIndex] ?? null;
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
selectedCategory: nextCategory
|
||||
}) : prev);
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
categoryQuery: prev.categoryQuery.slice(0, -1)
|
||||
}) : prev);
|
||||
} else if (
|
||||
input
|
||||
&& !key.ctrl
|
||||
&& !key.meta
|
||||
&& !key.tab
|
||||
) {
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
categoryQuery: prev.categoryQuery + input
|
||||
}) : prev);
|
||||
}
|
||||
} else {
|
||||
if (key.escape) {
|
||||
if (widgetPicker.widgetQuery.length > 0) {
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
widgetQuery: ''
|
||||
}) : prev);
|
||||
} else {
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
level: 'category'
|
||||
}) : prev);
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (selectedEntry) {
|
||||
applyWidgetPickerSelection(selectedEntry.type);
|
||||
}
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
if (filteredWidgets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(filteredWidgets.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
const nextType = filteredWidgets[nextIndex]?.type ?? null;
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
selectedType: nextType
|
||||
}) : prev);
|
||||
} else if (key.backspace || key.delete) {
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
widgetQuery: prev.widgetQuery.slice(0, -1)
|
||||
}) : prev);
|
||||
} else if (
|
||||
input
|
||||
&& !key.ctrl
|
||||
&& !key.meta
|
||||
&& !key.tab
|
||||
) {
|
||||
setWidgetPicker(prev => prev ? normalizePickerState({
|
||||
...prev,
|
||||
widgetQuery: prev.widgetQuery + input
|
||||
}) : prev);
|
||||
}
|
||||
}
|
||||
|
||||
handlePickerInputMode({
|
||||
input,
|
||||
key,
|
||||
widgetPicker,
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker,
|
||||
applyWidgetPickerSelection
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (moveMode) {
|
||||
// In move mode, use up/down to move the selected item
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
const newWidgets = [...widgets];
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const prev = newWidgets[selectedIndex - 1];
|
||||
if (temp && prev) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
|
||||
const newWidgets = [...widgets];
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const next = newWidgets[selectedIndex + 1];
|
||||
if (temp && next) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex + 1);
|
||||
} else if (key.escape || key.return) {
|
||||
// Exit move mode
|
||||
setMoveMode(false);
|
||||
}
|
||||
} else {
|
||||
// Normal mode
|
||||
if (key.upArrow && widgets.length > 0) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow && widgets.length > 0) {
|
||||
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
|
||||
} else if (key.leftArrow && widgets.length > 0) {
|
||||
openWidgetPicker('change');
|
||||
} else if (key.rightArrow && widgets.length > 0) {
|
||||
openWidgetPicker('change');
|
||||
} else if (key.return && widgets.length > 0) {
|
||||
// Enter move mode
|
||||
setMoveMode(true);
|
||||
} else if (input === 'a') {
|
||||
openWidgetPicker('add');
|
||||
} else if (input === 'i') {
|
||||
openWidgetPicker('insert');
|
||||
} else if (input === 'd' && widgets.length > 0) {
|
||||
// Delete selected item
|
||||
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
|
||||
onUpdate(newWidgets);
|
||||
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
}
|
||||
} else if (input === 'c') {
|
||||
if (widgets.length > 0) {
|
||||
setShowClearConfirm(true);
|
||||
}
|
||||
} else if (input === ' ' && widgets.length > 0) {
|
||||
// Space key - cycle separator character for separator types only (not flex)
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type === 'separator') {
|
||||
const currentChar = currentWidget.character ?? '|';
|
||||
const currentCharIndex = separatorChars.indexOf(currentChar);
|
||||
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'r' && widgets.length > 0) {
|
||||
// Toggle raw value for widgets that support it
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (!widgetImpl?.supportsRawValue()) {
|
||||
return;
|
||||
}
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'm' && widgets.length > 0) {
|
||||
// Cycle through merge states: undefined -> true -> 'no-padding' -> undefined
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
// Don't allow merge on the last item or on separators
|
||||
if (currentWidget && selectedIndex < widgets.length - 1
|
||||
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const newWidgets = [...widgets];
|
||||
let nextMergeState: boolean | 'no-padding' | undefined;
|
||||
|
||||
if (currentWidget.merge === undefined) {
|
||||
nextMergeState = true;
|
||||
} else if (currentWidget.merge === true) {
|
||||
nextMergeState = 'no-padding';
|
||||
} else {
|
||||
nextMergeState = undefined;
|
||||
}
|
||||
|
||||
if (nextMergeState === undefined) {
|
||||
const { merge, ...rest } = currentWidget;
|
||||
void merge; // Intentionally unused
|
||||
newWidgets[selectedIndex] = rest;
|
||||
} else {
|
||||
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onBack();
|
||||
} else if (widgets.length > 0) {
|
||||
// Check for custom widget keybinds
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (widgetImpl) {
|
||||
if (widgetImpl.getCustomKeybinds) {
|
||||
const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
|
||||
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
|
||||
|
||||
if (matchedKeybind && !key.ctrl) {
|
||||
// Check if widget handles the action directly
|
||||
if (widgetImpl.handleEditorAction) {
|
||||
// Let the widget handle the action directly
|
||||
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
|
||||
if (updatedWidget) {
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = updatedWidget;
|
||||
onUpdate(newWidgets);
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
// If handleEditorAction returned null, open the editor
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
// Open the widget's custom editor with the action
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
handleMoveInputMode({
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handleNormalInputMode({
|
||||
input,
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
separatorChars,
|
||||
onBack,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode,
|
||||
setShowClearConfirm,
|
||||
openWidgetPicker,
|
||||
getVisibleCustomKeybinds,
|
||||
setCustomEditorWidget
|
||||
});
|
||||
});
|
||||
|
||||
const getWidgetDisplay = (widget: WidgetItem) => {
|
||||
@@ -508,14 +230,14 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
const hasFlexSeparator = widgets.some(widget => widget.type === 'flex-separator');
|
||||
const widthDetectionAvailable = canDetectTerminalWidth();
|
||||
const pickerCategories = widgetPicker
|
||||
? getFilteredCategories(widgetPicker.categoryQuery)
|
||||
? [...widgetCategories]
|
||||
: [];
|
||||
const selectedPickerCategory = widgetPicker
|
||||
? (widgetPicker.selectedCategory && pickerCategories.includes(widgetPicker.selectedCategory)
|
||||
? widgetPicker.selectedCategory
|
||||
: (pickerCategories[0] ?? null))
|
||||
: null;
|
||||
const topLevelSearchEntries = widgetPicker && widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0
|
||||
const topLevelSearchEntries = widgetPicker?.level === 'category' && widgetPicker.categoryQuery.trim().length > 0
|
||||
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
|
||||
: [];
|
||||
const selectedTopLevelSearchEntry = widgetPicker
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Settings } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { List } from './List';
|
||||
|
||||
interface LineSelectorProps {
|
||||
lines: WidgetItem[][];
|
||||
@@ -47,6 +48,10 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
setLocalLines(lines);
|
||||
}, [lines]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(initialSelection);
|
||||
}, [initialSelection]);
|
||||
|
||||
const selectedLine = useMemo(
|
||||
() => localLines[selectedIndex],
|
||||
[localLines, selectedIndex]
|
||||
@@ -60,7 +65,7 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
};
|
||||
|
||||
const deleteLine = (lineIndex: number) => {
|
||||
// Don't allow deleting the last remaining line
|
||||
// Don't allow deleting the last remaining line
|
||||
if (localLines.length <= 1) {
|
||||
return;
|
||||
}
|
||||
@@ -119,35 +124,25 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
}
|
||||
|
||||
switch (input) {
|
||||
case 'a':
|
||||
if (allowEditing) {
|
||||
appendLine();
|
||||
}
|
||||
return;
|
||||
case 'd':
|
||||
if (allowEditing && localLines.length > 1) {
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
return;
|
||||
case 'm':
|
||||
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
|
||||
setMoveMode(true);
|
||||
}
|
||||
return;
|
||||
case 'a':
|
||||
if (allowEditing) {
|
||||
appendLine();
|
||||
}
|
||||
return;
|
||||
case 'd':
|
||||
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
return;
|
||||
case 'm':
|
||||
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
|
||||
setMoveMode(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(localLines.length, selectedIndex + 1));
|
||||
} else if (key.return) {
|
||||
if (selectedIndex === localLines.length) {
|
||||
onBack();
|
||||
} else {
|
||||
onSelect(selectedIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -197,7 +192,6 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
<Text>
|
||||
<Text>
|
||||
☰ Line
|
||||
{' '}
|
||||
{selectedIndex + 1}
|
||||
</Text>
|
||||
{' '}
|
||||
@@ -228,6 +222,12 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const lineItems = localLines.map((line, index) => ({
|
||||
label: `☰ Line ${index + 1}`,
|
||||
sublabel: `(${line.length > 0 ? pluralize('widget', line.length, true) : 'empty'})`,
|
||||
value: index
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box flexDirection='column'>
|
||||
@@ -253,44 +253,55 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{localLines.map((line, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const suffix = line.length
|
||||
? pluralize('widget', line.length, true)
|
||||
: 'empty';
|
||||
{moveMode ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{localLines.map((line, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const suffix = line.length
|
||||
? pluralize('widget', line.length, true)
|
||||
: 'empty';
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
|
||||
<Text>{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}</Text>
|
||||
<Text>
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Text color={isSelected ? 'blue' : undefined}>
|
||||
<Text>{isSelected ? '◆ ' : ' '}</Text>
|
||||
<Text>
|
||||
☰ Line
|
||||
<Text>
|
||||
☰ Line
|
||||
{' '}
|
||||
{index + 1}
|
||||
</Text>
|
||||
{' '}
|
||||
{index + 1}
|
||||
</Text>
|
||||
{' '}
|
||||
<Text dimColor={!isSelected}>
|
||||
(
|
||||
{suffix}
|
||||
)
|
||||
<Text dimColor={!isSelected}>
|
||||
(
|
||||
{suffix}
|
||||
)
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
marginTop={1}
|
||||
items={lineItems}
|
||||
onSelect={(line) => {
|
||||
if (line === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
{!moveMode && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedIndex === localLines.length ? 'green' : undefined}>
|
||||
{selectedIndex === localLines.length ? '▶ ' : ' '}
|
||||
← Back
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
onSelect(line);
|
||||
}}
|
||||
onSelectionChange={(_, index) => {
|
||||
setSelectedIndex(index);
|
||||
}}
|
||||
initialSelection={selectedIndex}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { ForegroundColorName } from 'chalk';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput,
|
||||
type BoxProps
|
||||
} from 'ink';
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren
|
||||
} from 'react';
|
||||
|
||||
export interface ListEntry<V = string | number> {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
value: V;
|
||||
props?: BoxProps;
|
||||
}
|
||||
|
||||
interface ListProps<V = string | number> extends BoxProps {
|
||||
items: (ListEntry<V> | '-')[];
|
||||
onSelect: (value: V | 'back', index: number) => void;
|
||||
onSelectionChange?: (value: V | 'back', index: number) => void;
|
||||
initialSelection?: number;
|
||||
showBackButton?: boolean;
|
||||
color?: ForegroundColorName;
|
||||
wrapNavigation?: boolean;
|
||||
}
|
||||
|
||||
export function List<V = string | number>({
|
||||
items,
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
initialSelection = 0,
|
||||
showBackButton,
|
||||
color,
|
||||
wrapNavigation = false,
|
||||
...boxProps
|
||||
}: ListProps<V>) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
|
||||
const latestOnSelectionChangeRef = useRef(onSelectionChange);
|
||||
|
||||
const _items = useMemo(() => {
|
||||
if (showBackButton) {
|
||||
return [...items, '-' as const, { label: '← Back', value: 'back' as V }];
|
||||
}
|
||||
return items;
|
||||
}, [items, showBackButton]);
|
||||
|
||||
const selectableItems = _items.filter(item => item !== '-' && !item.disabled) as ListEntry<V>[];
|
||||
const selectedItem = selectableItems[selectedIndex];
|
||||
const selectedValue = selectedItem?.value;
|
||||
const actualIndex = _items.findIndex(item => item === selectedItem);
|
||||
|
||||
useEffect(() => {
|
||||
latestOnSelectionChangeRef.current = onSelectionChange;
|
||||
}, [onSelectionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxIndex = Math.max(selectableItems.length - 1, 0);
|
||||
setSelectedIndex(Math.min(initialSelection, maxIndex));
|
||||
}, [initialSelection, selectableItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedValue !== undefined) {
|
||||
latestOnSelectionChangeRef.current?.(selectedValue, selectedIndex);
|
||||
}
|
||||
}, [selectedIndex, selectedValue]);
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.upArrow) {
|
||||
const prev = selectedIndex - 1;
|
||||
const prevIndex = prev < 0
|
||||
? (wrapNavigation ? selectableItems.length - 1 : 0)
|
||||
: prev;
|
||||
|
||||
setSelectedIndex(prevIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
const next = selectedIndex + 1;
|
||||
const nextIndex = next > selectableItems.length - 1
|
||||
? (wrapNavigation ? 0 : selectableItems.length - 1)
|
||||
: next;
|
||||
|
||||
setSelectedIndex(nextIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return && selectedItem) {
|
||||
onSelect(selectedItem.value, selectedIndex);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection='column' {...boxProps}>
|
||||
{_items.map((item, index) => {
|
||||
if (item === '-') {
|
||||
return <ListSeparator key={index} />;
|
||||
}
|
||||
|
||||
const isSelected = index === actualIndex;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={index}
|
||||
isSelected={isSelected}
|
||||
color={color}
|
||||
disabled={item.disabled}
|
||||
{...item.props}
|
||||
>
|
||||
<Text>
|
||||
<Text>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.sublabel && (
|
||||
<Text dimColor={!isSelected}>
|
||||
{' '}
|
||||
{item.sublabel}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedItem?.description && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor wrap='wrap'>
|
||||
{selectedItem.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListItemProps extends PropsWithChildren, BoxProps {
|
||||
isSelected: boolean;
|
||||
color?: ForegroundColorName;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ListItem({
|
||||
children,
|
||||
isSelected,
|
||||
color = 'green',
|
||||
disabled,
|
||||
...boxProps
|
||||
}: ListItemProps) {
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
<Text color={isSelected ? color : undefined} dimColor={disabled}>
|
||||
<Text>{isSelected ? '▶ ' : ' '}</Text>
|
||||
<Text>{children}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListSeparator() {
|
||||
return <Text> </Text>;
|
||||
}
|
||||
+116
-88
@@ -1,15 +1,26 @@
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput
|
||||
Text
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import { type PowerlineFontStatus } from '../../utils/powerline';
|
||||
|
||||
import { List } from './List';
|
||||
|
||||
export type MainMenuOption = 'lines'
|
||||
| 'colors'
|
||||
| 'powerline'
|
||||
| 'terminalConfig'
|
||||
| 'globalOverrides'
|
||||
| 'install'
|
||||
| 'starGithub'
|
||||
| 'save'
|
||||
| 'exit';
|
||||
|
||||
export interface MainMenuProps {
|
||||
onSelect: (value: string) => void;
|
||||
onSelect: (value: MainMenuOption, index: number) => void;
|
||||
isClaudeInstalled: boolean;
|
||||
hasChanges: boolean;
|
||||
initialSelection?: number;
|
||||
@@ -18,110 +29,127 @@ export interface MainMenuProps {
|
||||
previewIsTruncated?: boolean;
|
||||
}
|
||||
|
||||
export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled, hasChanges, initialSelection = 0, powerlineFontStatus, settings, previewIsTruncated }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
|
||||
|
||||
export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
onSelect,
|
||||
isClaudeInstalled,
|
||||
hasChanges,
|
||||
initialSelection = 0,
|
||||
powerlineFontStatus,
|
||||
settings,
|
||||
previewIsTruncated
|
||||
}) => {
|
||||
// Build menu structure with visual gaps
|
||||
const menuItems = [
|
||||
{ label: '📝 Edit Lines', value: 'lines', selectable: true },
|
||||
{ label: '🎨 Edit Colors', value: 'colors', selectable: true },
|
||||
{ label: '⚡ Powerline Setup', value: 'powerline', selectable: true },
|
||||
{ label: '', value: '_gap1', selectable: false }, // Visual gap
|
||||
{ label: '💻 Terminal Options', value: 'terminalConfig', selectable: true },
|
||||
{ label: '🌐 Global Overrides', value: 'globalOverrides', selectable: true },
|
||||
{ label: '', value: '_gap2', selectable: false }, // Visual gap
|
||||
{ label: isClaudeInstalled ? '🔌 Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install', selectable: true }
|
||||
const menuItems: ({
|
||||
label: string;
|
||||
value: MainMenuOption;
|
||||
description: string;
|
||||
} | '-')[] = [
|
||||
{
|
||||
label: '📝 Edit Lines',
|
||||
value: 'lines',
|
||||
description:
|
||||
'Configure any number of status lines with various widgets like model info, git status, and token usage'
|
||||
},
|
||||
{
|
||||
label: '🎨 Edit Colors',
|
||||
value: 'colors',
|
||||
description:
|
||||
'Customize colors for each widget including foreground, background, and bold styling'
|
||||
},
|
||||
{
|
||||
label: '⚡ Powerline Setup',
|
||||
value: 'powerline',
|
||||
description:
|
||||
'Install Powerline fonts for enhanced visual separators and symbols in your status line'
|
||||
},
|
||||
'-' as const,
|
||||
{
|
||||
label: '💻 Terminal Options',
|
||||
value: 'terminalConfig',
|
||||
description: 'Configure terminal-specific settings for optimal display'
|
||||
},
|
||||
{
|
||||
label: '🌐 Global Overrides',
|
||||
value: 'globalOverrides',
|
||||
description:
|
||||
'Set global padding, separators, and color overrides that apply to all widgets'
|
||||
},
|
||||
'-' as const,
|
||||
{
|
||||
label: isClaudeInstalled
|
||||
? '🔌 Uninstall from Claude Code'
|
||||
: '📦 Install to Claude Code',
|
||||
value: 'install',
|
||||
description: isClaudeInstalled
|
||||
? 'Remove ccstatusline from your Claude Code settings'
|
||||
: 'Add ccstatusline to your Claude Code settings for automatic status line rendering'
|
||||
}
|
||||
];
|
||||
|
||||
if (hasChanges) {
|
||||
menuItems.push(
|
||||
{ label: '💾 Save & Exit', value: 'save', selectable: true },
|
||||
{ label: '❌ Exit without saving', value: 'exit', selectable: true },
|
||||
{ label: '', value: '_gap3', selectable: false }, // Visual gap
|
||||
{ label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true }
|
||||
{
|
||||
label: '💾 Save & Exit',
|
||||
value: 'save',
|
||||
description: 'Save all changes and exit the configuration tool'
|
||||
},
|
||||
{
|
||||
label: '❌ Exit without saving',
|
||||
value: 'exit',
|
||||
description: 'Exit without saving your changes'
|
||||
},
|
||||
'-' as const,
|
||||
{
|
||||
label: '⭐ Like ccstatusline? Star us on GitHub',
|
||||
value: 'starGithub',
|
||||
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
|
||||
}
|
||||
);
|
||||
} else {
|
||||
menuItems.push(
|
||||
{ label: '🚪 Exit', value: 'exit', selectable: true },
|
||||
{ label: '', value: '_gap3', selectable: false }, // Visual gap
|
||||
{ label: '⭐ Like ccstatusline? Star us on GitHub', value: 'starGithub', selectable: true }
|
||||
{
|
||||
label: '🚪 Exit',
|
||||
value: 'exit',
|
||||
description: 'Exit the configuration tool'
|
||||
},
|
||||
'-' as const,
|
||||
{
|
||||
label: '⭐ Like ccstatusline? Star us on GitHub',
|
||||
value: 'starGithub',
|
||||
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get only selectable items for navigation
|
||||
const selectableItems = menuItems.filter(item => item.selectable);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(selectableItems.length - 1, selectedIndex + 1));
|
||||
} else if (key.return) {
|
||||
const item = selectableItems[selectedIndex];
|
||||
if (item) {
|
||||
onSelect(item.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get description for selected item
|
||||
const getDescription = (value: string): string => {
|
||||
const descriptions: Record<string, string> = {
|
||||
lines: 'Configure any number of status lines with various widgets like model info, git status, and token usage',
|
||||
colors: 'Customize colors for each widget including foreground, background, and bold styling',
|
||||
powerline: 'Install Powerline fonts for enhanced visual separators and symbols in your status line',
|
||||
globalOverrides: 'Set global padding, separators, and color overrides that apply to all widgets',
|
||||
install: isClaudeInstalled
|
||||
? 'Remove ccstatusline from your Claude Code settings'
|
||||
: 'Add ccstatusline to your Claude Code settings for automatic status line rendering',
|
||||
terminalConfig: 'Configure terminal-specific settings for optimal display',
|
||||
starGithub: 'Open the ccstatusline GitHub repository in your browser so you can star the project',
|
||||
save: 'Save all changes and exit the configuration tool',
|
||||
exit: hasChanges
|
||||
? 'Exit without saving your changes'
|
||||
: 'Exit the configuration tool'
|
||||
};
|
||||
return descriptions[value] ?? '';
|
||||
};
|
||||
|
||||
const selectedItem = selectableItems[selectedIndex];
|
||||
const description = selectedItem ? getDescription(selectedItem.value) : '';
|
||||
|
||||
// Check if we should show the truncation warning
|
||||
const showTruncationWarning = previewIsTruncated && settings?.flexMode === 'full-minus-40';
|
||||
const showTruncationWarning
|
||||
= previewIsTruncated && settings?.flexMode === 'full-minus-40';
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
{showTruncationWarning && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color='yellow'>⚠ Some lines are truncated, see Terminal Options → Terminal Width for info</Text>
|
||||
<Text color='yellow'>
|
||||
⚠ Some lines are truncated, see Terminal Options → Terminal Width
|
||||
for info
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text bold>Main Menu</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{menuItems.map((item, idx) => {
|
||||
if (!item.selectable && item.value.startsWith('_gap')) {
|
||||
return <Text key={item.value}> </Text>;
|
||||
}
|
||||
const selectableIdx = selectableItems.indexOf(item);
|
||||
const isSelected = selectableIdx === selectedIndex;
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={item.value}
|
||||
color={isSelected ? 'green' : undefined}
|
||||
>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{description && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor wrap='wrap'>{description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text bold>Main Menu</Text>
|
||||
|
||||
<List
|
||||
items={menuItems}
|
||||
marginTop={1}
|
||||
onSelect={(value, index) => {
|
||||
if (value === 'back') {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(value, index);
|
||||
}}
|
||||
initialSelection={initialSelection}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -28,12 +28,12 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
// Get the appropriate array based on mode
|
||||
const getItems = () => {
|
||||
switch (mode) {
|
||||
case 'separator':
|
||||
return powerlineConfig.separators;
|
||||
case 'startCap':
|
||||
return powerlineConfig.startCaps;
|
||||
case 'endCap':
|
||||
return powerlineConfig.endCaps;
|
||||
case 'separator':
|
||||
return powerlineConfig.separators;
|
||||
case 'startCap':
|
||||
return powerlineConfig.startCaps;
|
||||
case 'endCap':
|
||||
return powerlineConfig.endCaps;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,24 +83,25 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
const inversionText = mode === 'separator' && invertBg ? ' [Inverted]' : '';
|
||||
return `${preset.char} - ${preset.name}${inversionText}`;
|
||||
}
|
||||
const hexCode = char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
|
||||
return `${char} - Custom (\\u${hexCode})${invertBg ? ' [Inverted]' : ''}`;
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
const hexCode = codePoint.toString(16).toUpperCase().padStart(4, '0');
|
||||
return `${char} - Custom (U+${hexCode})${invertBg ? ' [Inverted]' : ''}`;
|
||||
};
|
||||
|
||||
const updateSeparators = (newSeparators: string[], newInvertBgs?: boolean[]) => {
|
||||
const updatedPowerline = { ...powerlineConfig };
|
||||
|
||||
switch (mode) {
|
||||
case 'separator':
|
||||
updatedPowerline.separators = newSeparators;
|
||||
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
|
||||
break;
|
||||
case 'startCap':
|
||||
updatedPowerline.startCaps = newSeparators;
|
||||
break;
|
||||
case 'endCap':
|
||||
updatedPowerline.endCaps = newSeparators;
|
||||
break;
|
||||
case 'separator':
|
||||
updatedPowerline.separators = newSeparators;
|
||||
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
|
||||
break;
|
||||
case 'startCap':
|
||||
updatedPowerline.startCaps = newSeparators;
|
||||
break;
|
||||
case 'endCap':
|
||||
updatedPowerline.endCaps = newSeparators;
|
||||
break;
|
||||
}
|
||||
|
||||
onUpdate({
|
||||
@@ -117,24 +118,27 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
setHexInput('');
|
||||
setCursorPos(0);
|
||||
} else if (key.return) {
|
||||
if (hexInput.length === 4) {
|
||||
const char = String.fromCharCode(parseInt(hexInput, 16));
|
||||
const newSeparators = [...separators];
|
||||
if (separators.length === 0) {
|
||||
// Add new item if list is empty
|
||||
newSeparators.push(char);
|
||||
} else {
|
||||
newSeparators[selectedIndex] = char;
|
||||
if (hexInput.length >= 4 && hexInput.length <= 6) {
|
||||
const codePoint = parseInt(hexInput, 16);
|
||||
if (codePoint >= 0 && codePoint <= 0x10FFFF) {
|
||||
const char = String.fromCodePoint(codePoint);
|
||||
const newSeparators = [...separators];
|
||||
if (separators.length === 0) {
|
||||
// Add new item if list is empty
|
||||
newSeparators.push(char);
|
||||
} else {
|
||||
newSeparators[selectedIndex] = char;
|
||||
}
|
||||
updateSeparators(newSeparators);
|
||||
setHexInputMode(false);
|
||||
setHexInput('');
|
||||
setCursorPos(0);
|
||||
}
|
||||
updateSeparators(newSeparators);
|
||||
setHexInputMode(false);
|
||||
setHexInput('');
|
||||
setCursorPos(0);
|
||||
}
|
||||
} else if (key.backspace && cursorPos > 0) {
|
||||
setHexInput(hexInput.slice(0, cursorPos - 1) + hexInput.slice(cursorPos));
|
||||
setCursorPos(cursorPos - 1);
|
||||
} else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 4) {
|
||||
} else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 6) {
|
||||
setHexInput(hexInput.slice(0, cursorPos) + input.toUpperCase() + hexInput.slice(cursorPos));
|
||||
setCursorPos(cursorPos + 1);
|
||||
}
|
||||
@@ -257,12 +261,12 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
|
||||
const getTitle = () => {
|
||||
switch (mode) {
|
||||
case 'separator':
|
||||
return 'Powerline Separator Configuration';
|
||||
case 'startCap':
|
||||
return 'Powerline Start Cap Configuration';
|
||||
case 'endCap':
|
||||
return 'Powerline End Cap Configuration';
|
||||
case 'separator':
|
||||
return 'Powerline Separator Configuration';
|
||||
case 'startCap':
|
||||
return 'Powerline Start Cap Configuration';
|
||||
case 'endCap':
|
||||
return 'Powerline End Cap Configuration';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -276,20 +280,21 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
{hexInputMode ? (
|
||||
<Box marginTop={2} flexDirection='column'>
|
||||
<Text>
|
||||
Enter 4-digit hex code for
|
||||
Enter hex code (4-6 digits) for
|
||||
{' '}
|
||||
{mode === 'separator' ? 'separator' : 'cap'}
|
||||
{separators.length > 0 ? ` ${selectedIndex + 1}` : ''}
|
||||
:
|
||||
</Text>
|
||||
<Text>
|
||||
\u
|
||||
U+
|
||||
{hexInput.slice(0, cursorPos)}
|
||||
<Text backgroundColor='gray' color='black'>{hexInput[cursorPos] ?? '_'}</Text>
|
||||
{hexInput.slice(cursorPos + 1)}
|
||||
{hexInput.length < 4 && hexInput.length === cursorPos && <Text dimColor>{'_'.repeat(4 - hexInput.length - 1)}</Text>}
|
||||
{hexInput.length < 6 && hexInput.length === cursorPos && <Text dimColor>{'_'.repeat(6 - hexInput.length - 1)}</Text>}
|
||||
</Text>
|
||||
<Text dimColor>Enter 4 hex digits (0-9, A-F), then press Enter. ESC to cancel.</Text>
|
||||
<Text dimColor>Enter 4-6 hex digits (0-9, A-F) for a Unicode code point, then press Enter. ESC to cancel.</Text>
|
||||
<Text dimColor>Examples: E0B0 (powerline), 1F984 (🦄), 2764 (❤)</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -6,14 +6,139 @@ import {
|
||||
import * as os from 'os';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { PowerlineConfig } from '../../types/PowerlineConfig';
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import { getDefaultPowerlineTheme } from '../../utils/colors';
|
||||
import { type PowerlineFontStatus } from '../../utils/powerline';
|
||||
import { buildEnabledPowerlineSettings } from '../../utils/powerline-settings';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
import { PowerlineSeparatorEditor } from './PowerlineSeparatorEditor';
|
||||
import { PowerlineThemeSelector } from './PowerlineThemeSelector';
|
||||
|
||||
type PowerlineMenuValue = 'separator' | 'startCap' | 'endCap' | 'themes';
|
||||
type Screen = 'menu' | PowerlineMenuValue;
|
||||
const POWERLINE_MENU_LABEL_WIDTH = 11;
|
||||
|
||||
function formatPowerlineMenuLabel(label: string): string {
|
||||
return label.padEnd(POWERLINE_MENU_LABEL_WIDTH, ' ');
|
||||
}
|
||||
|
||||
export function getSeparatorDisplay(powerlineConfig: PowerlineConfig): string {
|
||||
const seps = powerlineConfig.separators;
|
||||
|
||||
if (seps.length > 1) {
|
||||
return 'multiple';
|
||||
}
|
||||
|
||||
const sep = seps[0] ?? '\uE0B0';
|
||||
const presets = [
|
||||
{ char: '\uE0B0', name: 'Triangle Right' },
|
||||
{ char: '\uE0B2', name: 'Triangle Left' },
|
||||
{ char: '\uE0B4', name: 'Round Right' },
|
||||
{ char: '\uE0B6', name: 'Round Left' }
|
||||
];
|
||||
const preset = presets.find(item => item.char === sep);
|
||||
|
||||
if (preset) {
|
||||
return `${preset.char} - ${preset.name}`;
|
||||
}
|
||||
|
||||
return `${sep} - Custom`;
|
||||
}
|
||||
|
||||
export function getCapDisplay(
|
||||
powerlineConfig: PowerlineConfig,
|
||||
type: 'start' | 'end'
|
||||
): string {
|
||||
const caps = type === 'start'
|
||||
? powerlineConfig.startCaps
|
||||
: powerlineConfig.endCaps;
|
||||
|
||||
if (caps.length === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (caps.length > 1) {
|
||||
return 'multiple';
|
||||
}
|
||||
|
||||
const cap = caps[0];
|
||||
|
||||
if (!cap) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const presets = type === 'start' ? [
|
||||
{ char: '\uE0B2', name: 'Triangle' },
|
||||
{ char: '\uE0B6', name: 'Round' },
|
||||
{ char: '\uE0BA', name: 'Lower Triangle' },
|
||||
{ char: '\uE0BE', name: 'Diagonal' }
|
||||
] : [
|
||||
{ char: '\uE0B0', name: 'Triangle' },
|
||||
{ char: '\uE0B4', name: 'Round' },
|
||||
{ char: '\uE0B8', name: 'Lower Triangle' },
|
||||
{ char: '\uE0BC', name: 'Diagonal' }
|
||||
];
|
||||
const preset = presets.find(item => item.char === cap);
|
||||
|
||||
if (preset) {
|
||||
return `${preset.char} - ${preset.name}`;
|
||||
}
|
||||
|
||||
return `${cap} - Custom`;
|
||||
}
|
||||
|
||||
export function getThemeDisplay(powerlineConfig: PowerlineConfig): string {
|
||||
const theme = powerlineConfig.theme;
|
||||
|
||||
if (!theme || theme === 'custom') {
|
||||
return 'Custom';
|
||||
}
|
||||
|
||||
return theme.charAt(0).toUpperCase() + theme.slice(1);
|
||||
}
|
||||
|
||||
export function buildPowerlineSetupMenuItems(
|
||||
powerlineConfig: PowerlineConfig
|
||||
): ListEntry<PowerlineMenuValue>[] {
|
||||
const disabled = !powerlineConfig.enabled;
|
||||
|
||||
return [
|
||||
{
|
||||
label: formatPowerlineMenuLabel('Separator'),
|
||||
sublabel: `(${getSeparatorDisplay(powerlineConfig)})`,
|
||||
value: 'separator',
|
||||
disabled,
|
||||
description: 'Choose the glyph used between powerline segments.'
|
||||
},
|
||||
{
|
||||
label: formatPowerlineMenuLabel('Start Cap'),
|
||||
sublabel: `(${getCapDisplay(powerlineConfig, 'start')})`,
|
||||
value: 'startCap',
|
||||
disabled,
|
||||
description: 'Configure the cap glyph that appears at the start of each powerline line.'
|
||||
},
|
||||
{
|
||||
label: formatPowerlineMenuLabel('End Cap'),
|
||||
sublabel: `(${getCapDisplay(powerlineConfig, 'end')})`,
|
||||
value: 'endCap',
|
||||
disabled,
|
||||
description: 'Configure the cap glyph that appears at the end of each powerline line.'
|
||||
},
|
||||
{
|
||||
label: formatPowerlineMenuLabel('Themes'),
|
||||
sublabel: `(${getThemeDisplay(powerlineConfig)})`,
|
||||
value: 'themes',
|
||||
disabled,
|
||||
description: 'Preview built-in powerline themes or copy a theme into custom widget colors.'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export interface PowerlineSetupProps {
|
||||
settings: Settings;
|
||||
powerlineFontStatus: PowerlineFontStatus;
|
||||
@@ -25,8 +150,6 @@ export interface PowerlineSetupProps {
|
||||
onClearMessage: () => void;
|
||||
}
|
||||
|
||||
type Screen = 'menu' | 'separator' | 'startCap' | 'endCap' | 'themes';
|
||||
|
||||
export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
settings,
|
||||
powerlineFontStatus,
|
||||
@@ -43,155 +166,55 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
const [confirmingEnable, setConfirmingEnable] = useState(false);
|
||||
const [confirmingFontInstall, setConfirmingFontInstall] = useState(false);
|
||||
|
||||
// Check if there are any separators or flex-separators in the current configuration
|
||||
const hasSeparatorItems = settings.lines.some(line => line.some(item => item.type === 'separator' || item.type === 'flex-separator'));
|
||||
|
||||
// Menu items for navigation
|
||||
const menuItems = [
|
||||
{ label: 'Separator', value: 'separator' },
|
||||
{ label: 'Start Cap', value: 'startCap' },
|
||||
{ label: 'End Cap', value: 'endCap' },
|
||||
{ label: 'Themes', value: 'themes' },
|
||||
{ label: '← Back', value: 'back' }
|
||||
];
|
||||
|
||||
// Helper functions for display
|
||||
const getSeparatorDisplay = (): string => {
|
||||
const seps = powerlineConfig.separators;
|
||||
if (seps.length > 1) {
|
||||
return 'multiple';
|
||||
}
|
||||
const sep = seps[0] ?? '\uE0B0';
|
||||
const presets = [
|
||||
{ char: '\uE0B0', name: 'Triangle Right' },
|
||||
{ char: '\uE0B2', name: 'Triangle Left' },
|
||||
{ char: '\uE0B4', name: 'Round Right' },
|
||||
{ char: '\uE0B6', name: 'Round Left' }
|
||||
];
|
||||
const preset = presets.find(p => p.char === sep);
|
||||
if (preset) {
|
||||
return `${preset.char} - ${preset.name}`;
|
||||
}
|
||||
return `${sep} - Custom`;
|
||||
};
|
||||
|
||||
const getCapDisplay = (type: 'start' | 'end'): string => {
|
||||
const caps = type === 'start'
|
||||
? powerlineConfig.startCaps
|
||||
: powerlineConfig.endCaps;
|
||||
|
||||
if (caps.length === 0)
|
||||
return 'none';
|
||||
if (caps.length > 1)
|
||||
return 'multiple';
|
||||
|
||||
const cap = caps[0];
|
||||
if (!cap)
|
||||
return 'none';
|
||||
|
||||
const presets = type === 'start' ? [
|
||||
{ char: '\uE0B2', name: 'Triangle' },
|
||||
{ char: '\uE0B6', name: 'Round' },
|
||||
{ char: '\uE0BA', name: 'Lower Triangle' },
|
||||
{ char: '\uE0BE', name: 'Diagonal' }
|
||||
] : [
|
||||
{ char: '\uE0B0', name: 'Triangle' },
|
||||
{ char: '\uE0B4', name: 'Round' },
|
||||
{ char: '\uE0B8', name: 'Lower Triangle' },
|
||||
{ char: '\uE0BC', name: 'Diagonal' }
|
||||
];
|
||||
|
||||
const preset = presets.find(c => c.char === cap);
|
||||
if (preset) {
|
||||
return `${preset.char} - ${preset.name}`;
|
||||
}
|
||||
return `${cap} - Custom`;
|
||||
};
|
||||
|
||||
const getThemeDisplay = (): string => {
|
||||
const theme = powerlineConfig.theme;
|
||||
if (!theme || theme === 'custom')
|
||||
return 'Custom';
|
||||
return theme.charAt(0).toUpperCase() + theme.slice(1);
|
||||
};
|
||||
const hasSeparatorItems = settings.lines.some(line => line.some(
|
||||
item => item.type === 'separator' || item.type === 'flex-separator'
|
||||
));
|
||||
|
||||
useInput((input, key) => {
|
||||
// Block all input handling when font installation message is shown or installing
|
||||
if (fontInstallMessage || installingFonts) {
|
||||
// Only clear message on non-escape keys when message is shown
|
||||
if (fontInstallMessage && !key.escape) {
|
||||
onClearMessage();
|
||||
}
|
||||
// Always return early to prevent any other input handling
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip input handling when confirmations are active - let ConfirmDialog handle it
|
||||
if (confirmingFontInstall || confirmingEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (screen === 'menu') {
|
||||
// Menu navigation mode
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedMenuItem(Math.max(0, selectedMenuItem - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedMenuItem(Math.min(menuItems.length - 1, selectedMenuItem + 1));
|
||||
} else if (key.return) {
|
||||
const selected = menuItems[selectedMenuItem];
|
||||
if (selected) {
|
||||
if (selected.value === 'back') {
|
||||
onBack();
|
||||
} else if (powerlineConfig.enabled) {
|
||||
setScreen(selected.value as Screen);
|
||||
}
|
||||
}
|
||||
} else if (input === 't' || input === 'T') {
|
||||
// Toggle powerline mode
|
||||
if (!powerlineConfig.enabled) {
|
||||
// Only show confirmation when enabling if there are separators to remove
|
||||
if (hasSeparatorItems) {
|
||||
setConfirmingEnable(true);
|
||||
} else {
|
||||
// Set to nord theme if currently custom or undefined (first time enabling)
|
||||
const theme = (!powerlineConfig.theme || powerlineConfig.theme === 'custom')
|
||||
? getDefaultPowerlineTheme()
|
||||
: powerlineConfig.theme;
|
||||
|
||||
// Enable directly without confirmation since there are no separators
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
enabled: true,
|
||||
theme,
|
||||
// Separators are already initialized by Zod
|
||||
separators: powerlineConfig.separators,
|
||||
separatorInvertBackground: powerlineConfig.separatorInvertBackground
|
||||
},
|
||||
defaultPadding: ' ' // Set padding to space when enabling powerline
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
onUpdate(buildEnabledPowerlineSettings(settings, false));
|
||||
}
|
||||
} else {
|
||||
// Disable without confirmation
|
||||
const newConfig = { ...powerlineConfig, enabled: false };
|
||||
onUpdate({ ...settings, powerline: newConfig });
|
||||
onUpdate({
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
enabled: false
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (input === 'i' || input === 'I') {
|
||||
// Show font installation consent prompt
|
||||
setConfirmingFontInstall(true);
|
||||
} else if ((input === 'a' || input === 'A') && powerlineConfig.enabled) {
|
||||
// Toggle autoAlign when powerline is enabled
|
||||
const newConfig = { ...powerlineConfig, autoAlign: !powerlineConfig.autoAlign };
|
||||
onUpdate({ ...settings, powerline: newConfig });
|
||||
onUpdate({
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
autoAlign: !powerlineConfig.autoAlign
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Render sub-screens
|
||||
if (screen === 'separator') {
|
||||
return (
|
||||
<PowerlineSeparatorEditor
|
||||
@@ -235,7 +258,6 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Main menu screen
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
{!confirmingFontInstall && !installingFonts && !fontInstallMessage && (
|
||||
@@ -324,28 +346,7 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
<ConfirmDialog
|
||||
inline={true}
|
||||
onConfirm={() => {
|
||||
// Set to nord theme if currently custom or undefined (first time enabling)
|
||||
const theme = (!powerlineConfig.theme || powerlineConfig.theme === 'custom')
|
||||
? getDefaultPowerlineTheme()
|
||||
: powerlineConfig.theme;
|
||||
|
||||
// Remove all separators and flex-separators from lines
|
||||
// Also set default padding to a space when enabling powerline
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
enabled: true,
|
||||
theme,
|
||||
// Separators are already initialized by Zod
|
||||
separators: powerlineConfig.separators,
|
||||
separatorInvertBackground: powerlineConfig.separatorInvertBackground
|
||||
},
|
||||
defaultPadding: ' ', // Set padding to space when enabling powerline
|
||||
lines: settings.lines.map(line => line.filter(item => item.type !== 'separator' && item.type !== 'flex-separator')
|
||||
)
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
onUpdate(buildEnabledPowerlineSettings(settings, true));
|
||||
setConfirmingEnable(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
@@ -412,62 +413,29 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{powerlineConfig.enabled ? (
|
||||
<>
|
||||
{menuItems.map((item, index) => {
|
||||
const isSelected = index === selectedMenuItem;
|
||||
let displayValue = '';
|
||||
{!powerlineConfig.enabled && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Enable Powerline mode to configure separators, caps, and themes.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
switch (item.value) {
|
||||
case 'separator':
|
||||
displayValue = getSeparatorDisplay();
|
||||
break;
|
||||
case 'startCap':
|
||||
displayValue = getCapDisplay('start');
|
||||
break;
|
||||
case 'endCap':
|
||||
displayValue = getCapDisplay('end');
|
||||
break;
|
||||
case 'themes':
|
||||
displayValue = getThemeDisplay();
|
||||
break;
|
||||
case 'back':
|
||||
displayValue = '';
|
||||
break;
|
||||
}
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildPowerlineSetupMenuItems(powerlineConfig)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.value === 'back') {
|
||||
return (
|
||||
<Box key={item.value} marginTop={1}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={item.value}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
{item.label.padEnd(11, ' ')}
|
||||
<Text dimColor>
|
||||
{displayValue && `(${displayValue})`}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
// When powerline is disabled, show ESC to go back message
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press ESC to go back</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
setScreen(value);
|
||||
}}
|
||||
onSelectionChange={(_, index) => {
|
||||
setSelectedMenuItem(index);
|
||||
}}
|
||||
initialSelection={selectedMenuItem}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
@@ -16,6 +18,74 @@ import {
|
||||
} from '../../utils/colors';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
export function buildPowerlineThemeItems(
|
||||
themes: string[],
|
||||
originalTheme: string
|
||||
): ListEntry<string>[] {
|
||||
return themes.map((themeName) => {
|
||||
const theme = getPowerlineTheme(themeName);
|
||||
|
||||
return {
|
||||
label: theme?.name ?? themeName,
|
||||
sublabel: themeName === originalTheme ? '(original)' : undefined,
|
||||
value: themeName,
|
||||
description: theme?.description ?? ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function applyCustomPowerlineTheme(
|
||||
settings: Settings,
|
||||
themeName: string
|
||||
): Settings | null {
|
||||
const theme = getPowerlineTheme(themeName);
|
||||
|
||||
if (!theme || themeName === 'custom') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colorLevel = getColorLevelString(settings.colorLevel);
|
||||
const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3';
|
||||
const themeColors = theme[colorLevelKey];
|
||||
|
||||
if (!themeColors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = settings.lines.map((line) => {
|
||||
let widgetColorIndex = 0;
|
||||
|
||||
return line.map((widget) => {
|
||||
if (widget.type === 'separator' || widget.type === 'flex-separator') {
|
||||
return widget;
|
||||
}
|
||||
|
||||
const fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length];
|
||||
const bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length];
|
||||
widgetColorIndex++;
|
||||
|
||||
return {
|
||||
...widget,
|
||||
color: fgColor,
|
||||
backgroundColor: bgColor
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...settings,
|
||||
powerline: {
|
||||
...settings.powerline,
|
||||
theme: 'custom'
|
||||
},
|
||||
lines
|
||||
};
|
||||
}
|
||||
|
||||
export interface PowerlineThemeSelectorProps {
|
||||
settings: Settings;
|
||||
@@ -28,120 +98,63 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
|
||||
onUpdate,
|
||||
onBack
|
||||
}) => {
|
||||
const themes = getPowerlineThemes();
|
||||
const themes = useMemo(() => getPowerlineThemes(), []);
|
||||
const currentTheme = settings.powerline.theme ?? 'custom';
|
||||
const [selectedIndex, setSelectedIndex] = useState(Math.max(0, themes.indexOf(currentTheme)));
|
||||
const [showCustomizeConfirm, setShowCustomizeConfirm] = useState(false);
|
||||
const originalThemeRef = useRef(currentTheme);
|
||||
const originalSettingsRef = useRef(settings);
|
||||
const latestSettingsRef = useRef(settings);
|
||||
const latestOnUpdateRef = useRef(onUpdate);
|
||||
const didHandleInitialSelectionRef = useRef(false);
|
||||
|
||||
const applyTheme = (themeName: string) => {
|
||||
// Simply change the theme setting, don't modify widget colors
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
useEffect(() => {
|
||||
latestSettingsRef.current = settings;
|
||||
latestOnUpdateRef.current = onUpdate;
|
||||
}, [settings, onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
const themeName = themes[selectedIndex];
|
||||
|
||||
if (!themeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!didHandleInitialSelectionRef.current) {
|
||||
didHandleInitialSelectionRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
latestOnUpdateRef.current({
|
||||
...latestSettingsRef.current,
|
||||
powerline: {
|
||||
...settings.powerline,
|
||||
...latestSettingsRef.current.powerline,
|
||||
theme: themeName
|
||||
}
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
};
|
||||
|
||||
const customizeTheme = () => {
|
||||
// Copy current theme's colors to widgets and switch to custom theme
|
||||
const currentThemeName = themes[selectedIndex];
|
||||
if (!currentThemeName) {
|
||||
return;
|
||||
}
|
||||
const theme = getPowerlineTheme(currentThemeName);
|
||||
|
||||
if (!theme || currentThemeName === 'custom') {
|
||||
// If already on custom, just go back
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
const colorLevel = getColorLevelString(settings.colorLevel);
|
||||
const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3';
|
||||
const themeColors = theme[colorLevelKey];
|
||||
|
||||
if (themeColors) {
|
||||
// Apply theme colors to widgets
|
||||
const newLines = settings.lines.map((line) => {
|
||||
let widgetColorIndex = 0;
|
||||
return line.map((widget) => {
|
||||
// Skip separators
|
||||
if (widget.type === 'separator' || widget.type === 'flex-separator') {
|
||||
return widget;
|
||||
}
|
||||
|
||||
const fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length];
|
||||
const bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length];
|
||||
widgetColorIndex++;
|
||||
|
||||
return {
|
||||
...widget,
|
||||
color: fgColor,
|
||||
backgroundColor: bgColor
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
powerline: {
|
||||
...settings.powerline,
|
||||
theme: 'custom'
|
||||
},
|
||||
lines: newLines
|
||||
};
|
||||
|
||||
onUpdate(updatedSettings);
|
||||
}
|
||||
|
||||
onBack();
|
||||
};
|
||||
});
|
||||
}, [selectedIndex, themes]);
|
||||
|
||||
useInput((input, key) => {
|
||||
// Skip input handling when confirmation is active - let ConfirmDialog handle it
|
||||
if (showCustomizeConfirm) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
// Normal input handling
|
||||
if (key.escape) {
|
||||
// Restore original settings completely when canceling
|
||||
onUpdate(originalSettingsRef.current);
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
const newIndex = Math.max(0, selectedIndex - 1);
|
||||
setSelectedIndex(newIndex);
|
||||
const newTheme = themes[newIndex];
|
||||
if (newTheme) {
|
||||
applyTheme(newTheme);
|
||||
}
|
||||
} else if (key.downArrow) {
|
||||
const newIndex = Math.min(themes.length - 1, selectedIndex + 1);
|
||||
setSelectedIndex(newIndex);
|
||||
const newTheme = themes[newIndex];
|
||||
if (newTheme) {
|
||||
applyTheme(newTheme);
|
||||
}
|
||||
} else if (key.return) {
|
||||
// User confirmed their selection, so we keep the current theme
|
||||
onBack();
|
||||
} else if (input === 'c' || input === 'C') {
|
||||
// Customize theme - copy theme colors to widgets
|
||||
const currentThemeName = themes[selectedIndex];
|
||||
if (currentThemeName && currentThemeName !== 'custom') {
|
||||
setShowCustomizeConfirm(true);
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onUpdate(originalSettingsRef.current);
|
||||
onBack();
|
||||
} else if (input === 'c' || input === 'C') {
|
||||
const currentThemeName = themes[selectedIndex];
|
||||
if (currentThemeName && currentThemeName !== 'custom') {
|
||||
setShowCustomizeConfirm(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const selectedThemeName = themes[selectedIndex];
|
||||
const selectedTheme = selectedThemeName ? getPowerlineTheme(selectedThemeName) : undefined;
|
||||
const themeItems = useMemo(
|
||||
() => buildPowerlineThemeItems(themes, originalThemeRef.current),
|
||||
[themes]
|
||||
);
|
||||
|
||||
if (showCustomizeConfirm) {
|
||||
return (
|
||||
@@ -159,8 +172,14 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
|
||||
<ConfirmDialog
|
||||
inline={true}
|
||||
onConfirm={() => {
|
||||
customizeTheme();
|
||||
if (selectedThemeName) {
|
||||
const updatedSettings = applyCustomPowerlineTheme(settings, selectedThemeName);
|
||||
if (updatedSettings) {
|
||||
onUpdate(updatedSettings);
|
||||
}
|
||||
}
|
||||
setShowCustomizeConfirm(false);
|
||||
onBack();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowCustomizeConfirm(false);
|
||||
@@ -185,40 +204,30 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{themes.map((themeName, index) => {
|
||||
const theme = getPowerlineTheme(themeName);
|
||||
const isSelected = index === selectedIndex;
|
||||
const isOriginal = themeName === originalThemeRef.current;
|
||||
<List
|
||||
marginTop={1}
|
||||
items={themeItems}
|
||||
onSelect={() => {
|
||||
onBack();
|
||||
}}
|
||||
onSelectionChange={(themeName, index) => {
|
||||
if (themeName === 'back') {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={themeName}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
{theme?.name ?? themeName}
|
||||
{isOriginal && <Text dimColor> (original)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
setSelectedIndex(index);
|
||||
}}
|
||||
initialSelection={selectedIndex}
|
||||
/>
|
||||
|
||||
{selectedTheme && (
|
||||
<Box marginTop={2} flexDirection='column'>
|
||||
<Text dimColor>Description:</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text>{selectedTheme.description}</Text>
|
||||
</Box>
|
||||
{selectedThemeName && selectedThemeName !== 'custom' && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press (c) to customize this theme - copies colors to widgets</Text>
|
||||
</Box>
|
||||
)}
|
||||
{settings.colorLevel === 1 && (
|
||||
<Box>
|
||||
<Text color='yellow'>⚠ 16 color mode themes have a very limited palette, we recommend switching color level in Terminal Options</Text>
|
||||
</Box>
|
||||
)}
|
||||
{selectedThemeName && selectedThemeName !== 'custom' && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press (c) to customize this theme - copies colors to widgets</Text>
|
||||
</Box>
|
||||
)}
|
||||
{settings.colorLevel === 1 && (
|
||||
<Box marginTop={1}>
|
||||
<Text color='yellow'>⚠ 16 color mode themes have a very limited palette, we recommend switching color level in Terminal Options</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type PreRenderedWidget,
|
||||
type RenderResult
|
||||
} from '../../utils/renderer';
|
||||
import { canDetectTerminalWidth } from '../../utils/terminal';
|
||||
import { advanceGlobalSeparatorIndex } from '../../utils/separator-index';
|
||||
|
||||
export interface StatusLinePreviewProps {
|
||||
lines: WidgetItem[][];
|
||||
@@ -27,7 +27,6 @@ export interface StatusLinePreviewProps {
|
||||
const renderSingleLine = (
|
||||
widgets: WidgetItem[],
|
||||
terminalWidth: number,
|
||||
widthDetectionAvailable: boolean,
|
||||
settings: Settings,
|
||||
lineIndex: number,
|
||||
globalSeparatorIndex: number,
|
||||
@@ -46,8 +45,6 @@ const renderSingleLine = (
|
||||
};
|
||||
|
||||
export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings, onTruncationChange }) => {
|
||||
const widthDetectionAvailable = React.useMemo(() => canDetectTerminalWidth(), []);
|
||||
|
||||
// Render each configured line
|
||||
// Pass the full terminal width - the renderer will handle preview adjustments
|
||||
const { renderedLines, anyTruncated } = React.useMemo(() => {
|
||||
@@ -66,22 +63,18 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
const lineItems = lines[i];
|
||||
if (lineItems && lineItems.length > 0) {
|
||||
const preRenderedWidgets = preRenderedLines[i] ?? [];
|
||||
const renderResult = renderSingleLine(lineItems, terminalWidth, widthDetectionAvailable, settings, i, globalSeparatorIndex, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
const renderResult = renderSingleLine(lineItems, terminalWidth, settings, i, globalSeparatorIndex, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
result.push(renderResult.line);
|
||||
if (renderResult.wasTruncated) {
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
// Count separators used in this line (widgets - 1, excluding merged widgets)
|
||||
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
|
||||
if (nonMergedWidgets.length > 1) {
|
||||
globalSeparatorIndex += nonMergedWidgets.length - 1;
|
||||
}
|
||||
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
|
||||
}
|
||||
}
|
||||
|
||||
return { renderedLines: result, anyTruncated: truncated };
|
||||
}, [lines, terminalWidth, widthDetectionAvailable, settings]);
|
||||
}, [lines, terminalWidth, settings]);
|
||||
|
||||
// Notify parent when truncation status changes
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -7,10 +7,56 @@ import {
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { getWidget } from '../../utils/widgets';
|
||||
import {
|
||||
hasCustomWidgetColors,
|
||||
sanitizeLinesForColorLevel
|
||||
} from '../../utils/color-sanitize';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
type TerminalOptionsValue = 'width' | 'colorLevel';
|
||||
|
||||
export function getNextColorLevel(level: 0 | 1 | 2 | 3): 0 | 1 | 2 | 3 {
|
||||
return ((level + 1) % 4) as 0 | 1 | 2 | 3;
|
||||
}
|
||||
|
||||
export function shouldWarnOnColorLevelChange(
|
||||
currentLevel: 0 | 1 | 2 | 3,
|
||||
nextLevel: 0 | 1 | 2 | 3,
|
||||
hasCustomColors: boolean
|
||||
): boolean {
|
||||
return hasCustomColors
|
||||
&& ((currentLevel === 2 && nextLevel !== 2)
|
||||
|| (currentLevel === 3 && nextLevel !== 3));
|
||||
}
|
||||
|
||||
export function buildTerminalOptionsItems(
|
||||
colorLevel: 0 | 1 | 2 | 3
|
||||
): ListEntry<TerminalOptionsValue>[] {
|
||||
return [
|
||||
{
|
||||
label: '◱ Terminal Width',
|
||||
value: 'width',
|
||||
description: 'Configure how the status line uses available terminal width and when it should compact.'
|
||||
},
|
||||
{
|
||||
label: '▓ Color Level',
|
||||
sublabel: `(${getColorLevelLabel(colorLevel)})`,
|
||||
value: 'colorLevel',
|
||||
description: [
|
||||
'Color level affects how colors are rendered:',
|
||||
'• Truecolor: Full 24-bit RGB colors (16.7M colors)',
|
||||
'• 256 Color: Extended color palette (256 colors)',
|
||||
'• Basic: Standard 16-color terminal palette',
|
||||
'• No Color: Disables all color output'
|
||||
].join('\n')
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export interface TerminalOptionsMenuProps {
|
||||
settings: Settings;
|
||||
@@ -18,124 +64,51 @@ export interface TerminalOptionsMenuProps {
|
||||
onBack: (target?: string) => void;
|
||||
}
|
||||
|
||||
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settings, onUpdate, onBack }) => {
|
||||
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({
|
||||
settings,
|
||||
onUpdate,
|
||||
onBack
|
||||
}) => {
|
||||
const [showColorWarning, setShowColorWarning] = useState(false);
|
||||
const [pendingColorLevel, setPendingColorLevel] = useState<0 | 1 | 2 | 3 | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const handleSelect = () => {
|
||||
if (selectedIndex === 2) {
|
||||
// Back button
|
||||
const handleSelect = (value: TerminalOptionsValue | 'back') => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
} else if (selectedIndex === 0) {
|
||||
// Terminal Width Options
|
||||
onBack('width');
|
||||
} else if (selectedIndex === 1) {
|
||||
// Color Level
|
||||
// Check if there are any custom colors that would be lost
|
||||
const hasCustomColors = settings.lines.some((line: WidgetItem[]) => line.some((widget: WidgetItem) => Boolean(widget.color && (widget.color.startsWith('ansi256:') || widget.color.startsWith('hex:')))
|
||||
|| Boolean(widget.backgroundColor && (widget.backgroundColor.startsWith('ansi256:') || widget.backgroundColor.startsWith('hex:')))
|
||||
)
|
||||
);
|
||||
|
||||
const currentLevel = settings.colorLevel;
|
||||
const nextLevel = ((currentLevel + 1) % 4) as 0 | 1 | 2 | 3;
|
||||
|
||||
// Warn if switching away from mode that supports custom colors
|
||||
if (hasCustomColors
|
||||
&& ((currentLevel === 2 && nextLevel !== 2) // Switching from 256 color mode
|
||||
|| (currentLevel === 3 && nextLevel !== 3))) { // Switching from truecolor mode
|
||||
setShowColorWarning(true);
|
||||
setPendingColorLevel(nextLevel);
|
||||
} else {
|
||||
// Update chalk level immediately
|
||||
chalk.level = nextLevel;
|
||||
|
||||
// Clean up incompatible custom colors even when no warning is shown
|
||||
const cleanedLines = settings.lines.map(line => line.map((widget) => {
|
||||
const newWidget = { ...widget };
|
||||
// Remove custom colors incompatible with the new mode
|
||||
if (nextLevel === 2) {
|
||||
// Switching to 256 color mode - remove hex colors
|
||||
if (widget.color?.startsWith('hex:')) {
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
newWidget.color = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.backgroundColor?.startsWith('hex:')) {
|
||||
newWidget.backgroundColor = undefined;
|
||||
}
|
||||
} else if (nextLevel === 3) {
|
||||
// Switching to truecolor mode - remove ansi256 colors
|
||||
if (widget.color?.startsWith('ansi256:')) {
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
newWidget.color = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.backgroundColor?.startsWith('ansi256:')) {
|
||||
newWidget.backgroundColor = undefined;
|
||||
}
|
||||
} else {
|
||||
// Switching to 16 color mode - remove all custom colors
|
||||
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
newWidget.color = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
|
||||
newWidget.backgroundColor = undefined;
|
||||
}
|
||||
}
|
||||
return newWidget;
|
||||
})
|
||||
);
|
||||
|
||||
onUpdate({
|
||||
...settings,
|
||||
lines: cleanedLines,
|
||||
colorLevel: nextLevel
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'width') {
|
||||
onBack('width');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCustomColors = hasCustomWidgetColors(settings.lines);
|
||||
const currentLevel = settings.colorLevel;
|
||||
const nextLevel = getNextColorLevel(currentLevel);
|
||||
|
||||
if (shouldWarnOnColorLevelChange(currentLevel, nextLevel, hasCustomColors)) {
|
||||
setShowColorWarning(true);
|
||||
setPendingColorLevel(nextLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
chalk.level = nextLevel;
|
||||
|
||||
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, nextLevel);
|
||||
|
||||
onUpdate({
|
||||
...settings,
|
||||
lines: cleanedLines,
|
||||
colorLevel: nextLevel
|
||||
});
|
||||
};
|
||||
|
||||
const handleColorConfirm = () => {
|
||||
// Proceed with color level change and clean up custom colors
|
||||
if (pendingColorLevel !== null) {
|
||||
chalk.level = pendingColorLevel;
|
||||
|
||||
// Clean up custom colors if switching away from modes that support them
|
||||
const cleanedLines = settings.lines.map(line => line.map((widget) => {
|
||||
const newWidget = { ...widget };
|
||||
// Remove custom colors if switching to a mode that doesn't support them
|
||||
if ((pendingColorLevel !== 2 && pendingColorLevel !== 3)
|
||||
|| (pendingColorLevel === 2 && (widget.color?.startsWith('hex:') || widget.backgroundColor?.startsWith('hex:')))
|
||||
|| (pendingColorLevel === 3 && (widget.color?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('ansi256:')))) {
|
||||
// Reset custom colors to defaults
|
||||
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
newWidget.color = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
|
||||
newWidget.backgroundColor = undefined;
|
||||
}
|
||||
}
|
||||
return newWidget;
|
||||
})
|
||||
);
|
||||
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, pendingColorLevel);
|
||||
|
||||
onUpdate({
|
||||
...settings,
|
||||
@@ -152,19 +125,9 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
|
||||
setPendingColorLevel(null);
|
||||
};
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
if (!showColorWarning) {
|
||||
onBack();
|
||||
}
|
||||
} else if (!showColorWarning) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(2, selectedIndex + 1));
|
||||
} else if (key.return) {
|
||||
handleSelect();
|
||||
}
|
||||
useInput((_, key) => {
|
||||
if (key.escape && !showColorWarning) {
|
||||
onBack();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -187,39 +150,12 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
|
||||
) : (
|
||||
<>
|
||||
<Text color='white'>Configure terminal-specific settings for optimal display</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box>
|
||||
<Text color={selectedIndex === 0 ? 'green' : undefined}>
|
||||
{selectedIndex === 0 ? '▶ ' : ' '}
|
||||
◱ Terminal Width
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={selectedIndex === 1 ? 'green' : undefined}>
|
||||
{selectedIndex === 1 ? '▶ ' : ' '}
|
||||
▓ Color Level:
|
||||
{' '}
|
||||
{getColorLevelLabel(settings.colorLevel)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedIndex === 2 ? 'green' : undefined}>
|
||||
{selectedIndex === 2 ? '▶ ' : ' '}
|
||||
← Back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{selectedIndex === 1 && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text dimColor>Color level affects how colors are rendered:</Text>
|
||||
<Text dimColor>• Truecolor: Full 24-bit RGB colors (16.7M colors)</Text>
|
||||
<Text dimColor>• 256 Color: Extended color palette (256 colors)</Text>
|
||||
<Text dimColor>• Basic: Standard 16-color terminal palette</Text>
|
||||
<Text dimColor>• No Color: Disables all color output</Text>
|
||||
</Box>
|
||||
)}
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildTerminalOptionsItems(settings.colorLevel)}
|
||||
onSelect={handleSelect}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -228,11 +164,11 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
|
||||
|
||||
export const getColorLevelLabel = (level?: 0 | 1 | 2 | 3): string => {
|
||||
switch (level) {
|
||||
case 0: return 'No Color';
|
||||
case 1: return 'Basic';
|
||||
case 2:
|
||||
case undefined: return '256 Color (default)';
|
||||
case 3: return 'Truecolor';
|
||||
default: return '256 Color (default)';
|
||||
case 0: return 'No Color';
|
||||
case 1: return 'Basic';
|
||||
case 2:
|
||||
case undefined: return '256 Color (default)';
|
||||
case 3: return 'Truecolor';
|
||||
default: return '256 Color (default)';
|
||||
}
|
||||
};
|
||||
@@ -9,38 +9,89 @@ import type { FlexMode } from '../../types/FlexMode';
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import { shouldInsertInput } from '../../utils/input-guards';
|
||||
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
export const TERMINAL_WIDTH_OPTIONS: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
|
||||
|
||||
export function getTerminalWidthSelectionIndex(selectedOption: FlexMode): number {
|
||||
const selectedIndex = TERMINAL_WIDTH_OPTIONS.indexOf(selectedOption);
|
||||
|
||||
return selectedIndex >= 0 ? selectedIndex : 0;
|
||||
}
|
||||
|
||||
export function validateCompactThresholdInput(value: string): string | null {
|
||||
const parsedValue = parseInt(value, 10);
|
||||
|
||||
if (isNaN(parsedValue)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
|
||||
if (parsedValue < 1 || parsedValue > 99) {
|
||||
return `Value must be between 1 and 99 (you entered ${parsedValue})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildTerminalWidthItems(
|
||||
selectedOption: FlexMode,
|
||||
compactThreshold: number
|
||||
): ListEntry<FlexMode>[] {
|
||||
return [
|
||||
{
|
||||
value: 'full',
|
||||
label: 'Full width always',
|
||||
sublabel: selectedOption === 'full' ? '(active)' : undefined,
|
||||
description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.'
|
||||
},
|
||||
{
|
||||
value: 'full-minus-40',
|
||||
label: 'Full width minus 40',
|
||||
sublabel: selectedOption === 'full-minus-40' ? '(active)' : '(default)',
|
||||
description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.'
|
||||
},
|
||||
{
|
||||
value: 'full-until-compact',
|
||||
label: 'Full width until compact',
|
||||
sublabel: selectedOption === 'full-until-compact'
|
||||
? `(threshold ${compactThreshold}%, active)`
|
||||
: `(threshold ${compactThreshold}%)`,
|
||||
description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export interface TerminalWidthMenuProps {
|
||||
settings: Settings;
|
||||
onUpdate: (settings: Settings) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings, onUpdate, onBack }) => {
|
||||
export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({
|
||||
settings,
|
||||
onUpdate,
|
||||
onBack
|
||||
}) => {
|
||||
const [selectedOption, setSelectedOption] = useState<FlexMode>(settings.flexMode);
|
||||
const [compactThreshold, setCompactThreshold] = useState(settings.compactThreshold);
|
||||
const [editingThreshold, setEditingThreshold] = useState(false);
|
||||
const [thresholdInput, setThresholdInput] = useState(String(settings.compactThreshold));
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
// For manual navigation: 0-2 for options, 3 for back
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => {
|
||||
const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
|
||||
return options.indexOf(settings.flexMode);
|
||||
});
|
||||
|
||||
const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
|
||||
|
||||
useInput((input, key) => {
|
||||
if (editingThreshold) {
|
||||
if (key.return) {
|
||||
const value = parseInt(thresholdInput, 10);
|
||||
if (isNaN(value)) {
|
||||
setValidationError('Please enter a valid number');
|
||||
} else if (value < 1 || value > 99) {
|
||||
setValidationError(`Value must be between 1 and 99 (you entered ${value})`);
|
||||
const error = validateCompactThresholdInput(thresholdInput);
|
||||
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
} else {
|
||||
const value = parseInt(thresholdInput, 10);
|
||||
setCompactThreshold(value);
|
||||
// Update settings with both flexMode and the new threshold
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
flexMode: selectedOption,
|
||||
@@ -66,59 +117,14 @@ export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings,
|
||||
setValidationError(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(3, selectedIndex + 1)); // 0-2 for options, 3 for back
|
||||
} else if (key.return) {
|
||||
if (selectedIndex === 3) {
|
||||
onBack();
|
||||
} else if (selectedIndex >= 0 && selectedIndex < options.length) {
|
||||
const mode = options[selectedIndex];
|
||||
if (mode) {
|
||||
setSelectedOption(mode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update settings
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
flexMode: mode,
|
||||
compactThreshold: compactThreshold
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
|
||||
if (mode === 'full-until-compact') {
|
||||
// Prompt for threshold editing
|
||||
setEditingThreshold(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
}
|
||||
});
|
||||
|
||||
const optionDetails = [
|
||||
{
|
||||
value: 'full' as FlexMode,
|
||||
label: 'Full width always',
|
||||
description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it\'s not recommended to use this mode.'
|
||||
},
|
||||
{
|
||||
value: 'full-minus-40' as FlexMode,
|
||||
label: 'Full width minus 40 (default)',
|
||||
description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.'
|
||||
},
|
||||
{
|
||||
value: 'full-until-compact' as FlexMode,
|
||||
label: 'Full width until compact',
|
||||
description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it's not recommended to use this mode.`
|
||||
}
|
||||
];
|
||||
|
||||
const currentOption = selectedIndex < 3 ? optionDetails[selectedIndex] : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Terminal Width</Text>
|
||||
@@ -140,38 +146,31 @@ export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings,
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{optionDetails.map((opt, index) => (
|
||||
<Box key={opt.value}>
|
||||
<Text color={selectedIndex === index ? 'green' : undefined}>
|
||||
{selectedIndex === index ? '▶ ' : ' '}
|
||||
{opt.label}
|
||||
{opt.value === selectedOption ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildTerminalWidthItems(selectedOption, compactThreshold)}
|
||||
initialSelection={getTerminalWidthSelectionIndex(selectedOption)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedIndex === 3 ? 'green' : undefined}>
|
||||
{selectedIndex === 3 ? '▶ ' : ' '}
|
||||
← Back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
setSelectedOption(value);
|
||||
|
||||
{currentOption && (
|
||||
<Box marginTop={1} marginBottom={1} borderStyle='round' borderColor='dim' paddingX={1}>
|
||||
<Box flexDirection='column'>
|
||||
<Text>
|
||||
<Text color='yellow'>{currentOption.label}</Text>
|
||||
{currentOption.value === 'full-until-compact' && ` | Current threshold: ${compactThreshold}%`}
|
||||
</Text>
|
||||
<Text dimColor wrap='wrap'>{currentOption.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
flexMode: value,
|
||||
compactThreshold
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
|
||||
if (value === 'full-until-compact') {
|
||||
setEditingThreshold(true);
|
||||
}
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { InstallMenu } from '../InstallMenu';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): CapturedWriteStream {
|
||||
const stream = new MockTtyStream();
|
||||
const chunks: string[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
return Object.assign(stream as unknown as NodeJS.WriteStream, {
|
||||
getOutput() {
|
||||
return stripAnsi(chunks.join(''));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('InstallMenu', () => {
|
||||
it('calls onCancel when escape is pressed', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onCancel = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
bunxAvailable: true,
|
||||
existingStatusLine: null,
|
||||
onSelectNpx: vi.fn(),
|
||||
onSelectBunx: vi.fn(),
|
||||
onCancel
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
|
||||
stdin.write('\u001B');
|
||||
await flushInk();
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('respects the provided initial selection', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
bunxAvailable: true,
|
||||
existingStatusLine: null,
|
||||
onSelectNpx: vi.fn(),
|
||||
onSelectBunx: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
initialSelection: 1
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('▶ bunx - Bun Package Execute');
|
||||
expect(stdout.getOutput()).not.toContain('▶ npx - Node Package Execute');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../../types/Settings';
|
||||
import {
|
||||
buildPowerlineSetupMenuItems,
|
||||
getCapDisplay,
|
||||
getSeparatorDisplay,
|
||||
getThemeDisplay
|
||||
} from '../PowerlineSetup';
|
||||
|
||||
describe('PowerlineSetup helpers', () => {
|
||||
it('formats separator, cap, and theme display values', () => {
|
||||
const config = {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B4'],
|
||||
startCaps: ['\uE0B2'],
|
||||
endCaps: ['\uE0B0'],
|
||||
theme: 'gruvbox'
|
||||
};
|
||||
|
||||
expect(getSeparatorDisplay(config)).toBe('\uE0B4 - Round Right');
|
||||
expect(getCapDisplay(config, 'start')).toBe('\uE0B2 - Triangle');
|
||||
expect(getCapDisplay(config, 'end')).toBe('\uE0B0 - Triangle');
|
||||
expect(getThemeDisplay(config)).toBe('Gruvbox');
|
||||
});
|
||||
|
||||
it('builds powerline setup items with disabled states and sublabels', () => {
|
||||
const disabledItems = buildPowerlineSetupMenuItems({
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: false
|
||||
});
|
||||
|
||||
expect(disabledItems.every(item => item.disabled)).toBe(true);
|
||||
|
||||
const enabledItems = buildPowerlineSetupMenuItems({
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B0', '\uE0B4'],
|
||||
startCaps: [],
|
||||
endCaps: ['\uE0BC'],
|
||||
theme: undefined
|
||||
});
|
||||
|
||||
expect(enabledItems[0]).toMatchObject({
|
||||
label: 'Separator ',
|
||||
sublabel: '(multiple)',
|
||||
disabled: false
|
||||
});
|
||||
expect(enabledItems[1]).toMatchObject({
|
||||
label: 'Start Cap ',
|
||||
sublabel: '(none)'
|
||||
});
|
||||
expect(enabledItems[2]).toMatchObject({
|
||||
label: 'End Cap ',
|
||||
sublabel: '(\uE0BC - Diagonal)'
|
||||
});
|
||||
expect(enabledItems[3]).toMatchObject({
|
||||
label: 'Themes ',
|
||||
sublabel: '(Custom)'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../../types/Settings';
|
||||
import { getPowerlineThemes } from '../../../utils/colors';
|
||||
import {
|
||||
PowerlineThemeSelector,
|
||||
applyCustomPowerlineTheme,
|
||||
buildPowerlineThemeItems,
|
||||
type PowerlineThemeSelectorProps
|
||||
} from '../PowerlineThemeSelector';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): NodeJS.WriteStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.WriteStream;
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('PowerlineThemeSelector helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds powerline theme list items with original theme sublabels', () => {
|
||||
const items = buildPowerlineThemeItems(['gruvbox', 'onedark'], 'onedark');
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({
|
||||
label: 'Gruvbox',
|
||||
value: 'gruvbox'
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
label: 'One Dark',
|
||||
sublabel: '(original)',
|
||||
value: 'onedark'
|
||||
});
|
||||
});
|
||||
|
||||
it('copies a built-in theme into widget colors and switches to custom mode', () => {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
colorLevel: 2 as const,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
theme: 'gruvbox'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedSettings = applyCustomPowerlineTheme(settings, 'gruvbox');
|
||||
|
||||
expect(updatedSettings).not.toBeNull();
|
||||
expect(updatedSettings?.powerline.theme).toBe('custom');
|
||||
expect(updatedSettings?.lines[0]?.[0]).toMatchObject({
|
||||
color: 'ansi256:16',
|
||||
backgroundColor: 'ansi256:167'
|
||||
});
|
||||
expect(updatedSettings?.lines[0]?.[1]).toEqual(settings.lines[0]?.[1]);
|
||||
expect(updatedSettings?.lines[0]?.[2]).toMatchObject({
|
||||
color: 'ansi256:235',
|
||||
backgroundColor: 'ansi256:214'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the requested theme cannot be customized', () => {
|
||||
expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'custom')).toBeNull();
|
||||
expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'missing-theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('previews the highlighted theme once without triggering update-depth warnings', async () => {
|
||||
const themes = getPowerlineThemes();
|
||||
|
||||
expect(themes.length).toBeGreaterThan(1);
|
||||
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn<PowerlineThemeSelectorProps['onUpdate']>();
|
||||
const onBack = vi.fn();
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const instance = render(
|
||||
React.createElement(PowerlineThemeSelector, {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
theme: themes[0]
|
||||
}
|
||||
},
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
|
||||
stdin.write('\u001B[B');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(onUpdate.mock.calls[0]?.[0]?.powerline.theme).toBe(themes[1]);
|
||||
|
||||
const maximumUpdateDepthWarnings = consoleErrorSpy.mock.calls.filter((call) => {
|
||||
return call.some(arg => typeof arg === 'string' && arg.includes('Maximum update depth exceeded'));
|
||||
});
|
||||
|
||||
expect(maximumUpdateDepthWarnings).toHaveLength(0);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
buildTerminalOptionsItems,
|
||||
getNextColorLevel,
|
||||
shouldWarnOnColorLevelChange
|
||||
} from '../TerminalOptionsMenu';
|
||||
|
||||
describe('TerminalOptionsMenu helpers', () => {
|
||||
it('cycles color levels in order', () => {
|
||||
expect(getNextColorLevel(0)).toBe(1);
|
||||
expect(getNextColorLevel(1)).toBe(2);
|
||||
expect(getNextColorLevel(2)).toBe(3);
|
||||
expect(getNextColorLevel(3)).toBe(0);
|
||||
});
|
||||
|
||||
it('warns only when custom colors would be lost', () => {
|
||||
expect(shouldWarnOnColorLevelChange(2, 3, true)).toBe(true);
|
||||
expect(shouldWarnOnColorLevelChange(3, 0, true)).toBe(true);
|
||||
expect(shouldWarnOnColorLevelChange(2, 2, true)).toBe(false);
|
||||
expect(shouldWarnOnColorLevelChange(1, 2, true)).toBe(false);
|
||||
expect(shouldWarnOnColorLevelChange(3, 0, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds terminal options list items with the current color level label', () => {
|
||||
const items = buildTerminalOptionsItems(2);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({
|
||||
label: '◱ Terminal Width',
|
||||
value: 'width'
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
label: '▓ Color Level',
|
||||
sublabel: '(256 Color (default))',
|
||||
value: 'colorLevel'
|
||||
});
|
||||
expect(items[1]?.description).toContain('Truecolor');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../../types/Settings';
|
||||
import {
|
||||
TerminalWidthMenu,
|
||||
buildTerminalWidthItems,
|
||||
getTerminalWidthSelectionIndex,
|
||||
validateCompactThresholdInput
|
||||
} from '../TerminalWidthMenu';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedWriteStream extends NodeJS.WriteStream {
|
||||
clearOutput: () => void;
|
||||
getOutput: () => string;
|
||||
}
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): CapturedWriteStream {
|
||||
const stream = new MockTtyStream();
|
||||
const chunks: string[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
return Object.assign(stream as unknown as NodeJS.WriteStream, {
|
||||
clearOutput() {
|
||||
chunks.length = 0;
|
||||
},
|
||||
getOutput() {
|
||||
return chunks.join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('TerminalWidthMenu helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('validates compact threshold input', () => {
|
||||
expect(validateCompactThresholdInput('')).toBe('Please enter a valid number');
|
||||
expect(validateCompactThresholdInput('0')).toBe('Value must be between 1 and 99 (you entered 0)');
|
||||
expect(validateCompactThresholdInput('100')).toBe('Value must be between 1 and 99 (you entered 100)');
|
||||
expect(validateCompactThresholdInput('42')).toBeNull();
|
||||
});
|
||||
|
||||
it('builds terminal width menu items with active and threshold sublabels', () => {
|
||||
const items = buildTerminalWidthItems('full-until-compact', 60);
|
||||
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]).toMatchObject({
|
||||
label: 'Full width always',
|
||||
value: 'full'
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
label: 'Full width minus 40',
|
||||
sublabel: '(default)',
|
||||
value: 'full-minus-40'
|
||||
});
|
||||
expect(items[2]).toMatchObject({
|
||||
label: 'Full width until compact',
|
||||
sublabel: '(threshold 60%, active)',
|
||||
value: 'full-until-compact'
|
||||
});
|
||||
expect(items[2]?.description).toContain('60%');
|
||||
});
|
||||
|
||||
it('returns the current option index for list selection', () => {
|
||||
expect(getTerminalWidthSelectionIndex('full')).toBe(0);
|
||||
expect(getTerminalWidthSelectionIndex('full-minus-40')).toBe(1);
|
||||
expect(getTerminalWidthSelectionIndex('full-until-compact')).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps full-until-compact selected after confirming the threshold prompt', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(TerminalWidthMenu, {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
flexMode: 'full',
|
||||
compactThreshold: 60
|
||||
},
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('\u001B[B');
|
||||
await flushInk();
|
||||
stdin.write('\u001B[B');
|
||||
await flushInk();
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('Enter compact threshold (1-99):');
|
||||
|
||||
stdout.clearOutput();
|
||||
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
flexMode: 'full-until-compact',
|
||||
compactThreshold: 60
|
||||
}));
|
||||
|
||||
const output = stdout.getOutput();
|
||||
|
||||
expect(output).toContain('▶ Full width until compact');
|
||||
expect(output).not.toContain('▶ Full width always');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../../../types/Widget';
|
||||
import {
|
||||
clearAllWidgetStyling,
|
||||
cycleWidgetColor,
|
||||
resetWidgetStyling,
|
||||
toggleWidgetBold,
|
||||
updateWidgetById
|
||||
} from '../mutations';
|
||||
|
||||
describe('color-menu mutations', () => {
|
||||
it('updateWidgetById only updates the matching widget', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input', color: 'blue' },
|
||||
{ id: '2', type: 'tokens-output', color: 'white' }
|
||||
];
|
||||
|
||||
const updated = updateWidgetById(widgets, '1', widget => ({
|
||||
...widget,
|
||||
color: 'red'
|
||||
}));
|
||||
|
||||
expect(updated[0]?.color).toBe('red');
|
||||
expect(updated[1]?.color).toBe('white');
|
||||
});
|
||||
|
||||
it('toggleWidgetBold flips bold state for the selected widget only', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input', bold: true },
|
||||
{ id: '2', type: 'tokens-output', bold: false }
|
||||
];
|
||||
|
||||
const updated = toggleWidgetBold(widgets, '1');
|
||||
|
||||
expect(updated[0]?.bold).toBe(false);
|
||||
expect(updated[1]?.bold).toBe(false);
|
||||
});
|
||||
|
||||
it('resetWidgetStyling removes color, backgroundColor, and bold from one widget', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tokens-input',
|
||||
color: 'red',
|
||||
backgroundColor: 'blue',
|
||||
bold: true
|
||||
},
|
||||
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
|
||||
];
|
||||
|
||||
const updated = resetWidgetStyling(widgets, '1');
|
||||
|
||||
expect(updated[0]).toEqual({ id: '1', type: 'tokens-input' });
|
||||
expect(updated[1]).toEqual({ id: '2', type: 'tokens-output', color: 'white', bold: true });
|
||||
});
|
||||
|
||||
it('clearAllWidgetStyling strips styling fields from every widget', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tokens-input',
|
||||
color: 'red',
|
||||
backgroundColor: 'blue',
|
||||
bold: true
|
||||
},
|
||||
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
|
||||
];
|
||||
|
||||
const updated = clearAllWidgetStyling(widgets);
|
||||
|
||||
expect(updated).toEqual([
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('cycles background colors and maps empty background to undefined', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input', backgroundColor: 'bg:red' }
|
||||
];
|
||||
|
||||
const right = cycleWidgetColor({
|
||||
widgets,
|
||||
widgetId: '1',
|
||||
direction: 'right',
|
||||
editingBackground: true,
|
||||
colors: ['blue', 'red'],
|
||||
backgroundColors: ['bg:red', '']
|
||||
});
|
||||
const left = cycleWidgetColor({
|
||||
widgets: right,
|
||||
widgetId: '1',
|
||||
direction: 'left',
|
||||
editingBackground: true,
|
||||
colors: ['blue', 'red'],
|
||||
backgroundColors: ['bg:red', '']
|
||||
});
|
||||
|
||||
expect(right[0]?.backgroundColor).toBeUndefined();
|
||||
expect(left[0]?.backgroundColor).toBe('bg:red');
|
||||
});
|
||||
|
||||
it('cycles foreground colors from widget default and treats dim as default', () => {
|
||||
const fromDefault: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
];
|
||||
const fromDim: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input', color: 'dim' }
|
||||
];
|
||||
|
||||
const defaultCycle = cycleWidgetColor({
|
||||
widgets: fromDefault,
|
||||
widgetId: '1',
|
||||
direction: 'right',
|
||||
editingBackground: false,
|
||||
colors: ['blue', 'red'],
|
||||
backgroundColors: ['bg:red', '']
|
||||
});
|
||||
const dimCycle = cycleWidgetColor({
|
||||
widgets: fromDim,
|
||||
widgetId: '1',
|
||||
direction: 'right',
|
||||
editingBackground: false,
|
||||
colors: ['blue', 'red'],
|
||||
backgroundColors: ['bg:red', '']
|
||||
});
|
||||
|
||||
expect(defaultCycle[0]?.color).toBe('red');
|
||||
expect(dimCycle[0]?.color).toBe('red');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { WidgetItem } from '../../../types/Widget';
|
||||
import { getWidget } from '../../../utils/widgets';
|
||||
|
||||
export function updateWidgetById(
|
||||
widgets: WidgetItem[],
|
||||
widgetId: string,
|
||||
updater: (widget: WidgetItem) => WidgetItem
|
||||
): WidgetItem[] {
|
||||
return widgets.map(widget => widget.id === widgetId ? updater(widget) : widget);
|
||||
}
|
||||
|
||||
export function setWidgetColor(
|
||||
widgets: WidgetItem[],
|
||||
widgetId: string,
|
||||
color: string,
|
||||
editingBackground: boolean
|
||||
): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, (widget) => {
|
||||
if (editingBackground) {
|
||||
return {
|
||||
...widget,
|
||||
backgroundColor: color
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...widget,
|
||||
color
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, widget => ({
|
||||
...widget,
|
||||
bold: !widget.bold
|
||||
}));
|
||||
}
|
||||
|
||||
export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, (widget) => {
|
||||
const {
|
||||
color,
|
||||
backgroundColor,
|
||||
bold,
|
||||
...restWidget
|
||||
} = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
return restWidget;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] {
|
||||
return widgets.map((widget) => {
|
||||
const {
|
||||
color,
|
||||
backgroundColor,
|
||||
bold,
|
||||
...restWidget
|
||||
} = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
return restWidget;
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultForegroundColor(widget: WidgetItem): string {
|
||||
if (widget.type === 'separator' || widget.type === 'flex-separator') {
|
||||
return 'white';
|
||||
}
|
||||
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
return widgetImpl ? widgetImpl.getDefaultColor() : 'white';
|
||||
}
|
||||
|
||||
function getNextIndex(currentIndex: number, length: number, direction: 'left' | 'right'): number {
|
||||
if (direction === 'right') {
|
||||
return (currentIndex + 1) % length;
|
||||
}
|
||||
|
||||
return currentIndex === 0 ? length - 1 : currentIndex - 1;
|
||||
}
|
||||
|
||||
export interface CycleWidgetColorOptions {
|
||||
widgets: WidgetItem[];
|
||||
widgetId: string;
|
||||
direction: 'left' | 'right';
|
||||
editingBackground: boolean;
|
||||
colors: string[];
|
||||
backgroundColors: string[];
|
||||
}
|
||||
|
||||
export function cycleWidgetColor({
|
||||
widgets,
|
||||
widgetId,
|
||||
direction,
|
||||
editingBackground,
|
||||
colors,
|
||||
backgroundColors
|
||||
}: CycleWidgetColorOptions): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, (widget) => {
|
||||
if (editingBackground) {
|
||||
if (backgroundColors.length === 0) {
|
||||
return widget;
|
||||
}
|
||||
|
||||
const currentBgColor = widget.backgroundColor ?? '';
|
||||
let currentBgColorIndex = backgroundColors.indexOf(currentBgColor);
|
||||
if (currentBgColorIndex === -1) {
|
||||
currentBgColorIndex = 0;
|
||||
}
|
||||
|
||||
const nextBgColorIndex = getNextIndex(currentBgColorIndex, backgroundColors.length, direction);
|
||||
const nextBgColor = backgroundColors[nextBgColorIndex];
|
||||
|
||||
return {
|
||||
...widget,
|
||||
backgroundColor: nextBgColor === '' ? undefined : nextBgColor
|
||||
};
|
||||
}
|
||||
|
||||
if (colors.length === 0) {
|
||||
return widget;
|
||||
}
|
||||
|
||||
const defaultColor = getDefaultForegroundColor(widget);
|
||||
let currentColor = widget.color ?? defaultColor;
|
||||
if (currentColor === 'dim') {
|
||||
currentColor = defaultColor;
|
||||
}
|
||||
|
||||
let currentColorIndex = colors.indexOf(currentColor);
|
||||
if (currentColorIndex === -1) {
|
||||
currentColorIndex = 0;
|
||||
}
|
||||
|
||||
const nextColorIndex = getNextIndex(currentColorIndex, colors.length, direction);
|
||||
const nextColor = colors[nextColorIndex];
|
||||
|
||||
return {
|
||||
...widget,
|
||||
color: nextColor
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../../../types/Widget';
|
||||
import type { WidgetCatalogEntry } from '../../../../utils/widgets';
|
||||
import {
|
||||
handleMoveInputMode,
|
||||
handleNormalInputMode,
|
||||
handlePickerInputMode,
|
||||
normalizePickerState,
|
||||
type WidgetPickerState
|
||||
} from '../input-handlers';
|
||||
|
||||
function createStateSetter<T>(initial: T) {
|
||||
let state = initial;
|
||||
|
||||
return {
|
||||
get: () => state,
|
||||
set: (value: T | ((prev: T) => T)) => {
|
||||
state = typeof value === 'function'
|
||||
? (value as (prev: T) => T)(state)
|
||||
: value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function requireState<T>(value: T | null): T {
|
||||
if (!value) {
|
||||
throw new Error('Expected state value');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function createCatalog(entries: (Partial<WidgetCatalogEntry> & Pick<WidgetCatalogEntry, 'type'>)[]): WidgetCatalogEntry[] {
|
||||
return entries.map(entry => ({
|
||||
type: entry.type,
|
||||
displayName: entry.displayName ?? entry.type,
|
||||
description: entry.description ?? entry.type,
|
||||
category: entry.category ?? 'Other',
|
||||
searchText: `${entry.displayName ?? entry.type} ${entry.description ?? entry.type} ${entry.type}`.toLowerCase()
|
||||
}));
|
||||
}
|
||||
|
||||
describe('items-editor input handlers', () => {
|
||||
it('normalizes picker state with valid fallback category and selected type', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const state: WidgetPickerState = {
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'Missing',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: null
|
||||
};
|
||||
|
||||
const normalized = normalizePickerState(state, widgetCatalog, widgetCategories);
|
||||
|
||||
expect(normalized.selectedCategory).toBe('All');
|
||||
expect(normalized.selectedType).toBe('git-branch');
|
||||
});
|
||||
|
||||
it('applies top-level category search selection on Enter', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: 'git',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
const applySelection = vi.fn();
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { return: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: applySelection
|
||||
});
|
||||
|
||||
expect(applySelection).toHaveBeenCalledWith('git-branch');
|
||||
});
|
||||
|
||||
it('returns to category level from widget picker on escape when widget query is empty', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'widget',
|
||||
selectedCategory: 'Git',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { escape: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.level).toBe('category');
|
||||
});
|
||||
|
||||
it('moves selected widget up in move mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
const setMoveMode = vi.fn();
|
||||
|
||||
handleMoveInputMode({
|
||||
key: { upArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 1,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
]);
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(0);
|
||||
expect(setMoveMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles raw value in normal mode for supported widgets', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'r',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('cycles separator character in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'separator', character: '|' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: ' ',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.character).toBe('-');
|
||||
});
|
||||
|
||||
it('applies custom widget keybind actions in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'session-usage' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'p',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.metadata?.display).toBe('progress');
|
||||
});
|
||||
|
||||
it('uses v to cycle skills widget mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'skills' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'v',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.metadata?.mode).toBe('count');
|
||||
});
|
||||
|
||||
it('opens custom editor for skills list limit action', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'skills', metadata: { mode: 'list' } }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setCustomEditorWidget = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'l',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
|
||||
| { action?: string; widget?: WidgetItem }
|
||||
| undefined;
|
||||
expect(customEditorState?.action).toBe('edit-list-limit');
|
||||
expect(customEditorState?.widget?.type).toBe('skills');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type {
|
||||
CustomKeybind,
|
||||
WidgetItem
|
||||
} from '../../../../types/Widget';
|
||||
import { shouldShowCustomKeybind } from '../keybind-visibility';
|
||||
|
||||
const TOGGLE_COMPACT_KEYBIND: CustomKeybind = { key: 's', label: '(s)hort time', action: 'toggle-compact' };
|
||||
const TOGGLE_INVERT_KEYBIND: CustomKeybind = { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' };
|
||||
const EDIT_LIST_LIMIT_KEYBIND: CustomKeybind = { key: 'l', label: '(l)imit list', action: 'edit-list-limit' };
|
||||
|
||||
function createWidget(type: string, metadata?: Record<string, string>): WidgetItem {
|
||||
return {
|
||||
id: 'widget',
|
||||
type,
|
||||
metadata
|
||||
};
|
||||
}
|
||||
|
||||
describe('shouldShowCustomKeybind', () => {
|
||||
it('shows invert only in progress modes', () => {
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer'), TOGGLE_INVERT_KEYBIND)).toBe(false);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress' }), TOGGLE_INVERT_KEYBIND)).toBe(true);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress-short' }), TOGGLE_INVERT_KEYBIND)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides short time in progress modes', () => {
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer'), TOGGLE_COMPACT_KEYBIND)).toBe(true);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'time' }), TOGGLE_COMPACT_KEYBIND)).toBe(true);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress' }), TOGGLE_COMPACT_KEYBIND)).toBe(false);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress-short' }), TOGGLE_COMPACT_KEYBIND)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows list limit only for skills list mode', () => {
|
||||
expect(shouldShowCustomKeybind(createWidget('skills'), EDIT_LIST_LIMIT_KEYBIND)).toBe(false);
|
||||
expect(shouldShowCustomKeybind(createWidget('skills', { mode: 'count' }), EDIT_LIST_LIMIT_KEYBIND)).toBe(false);
|
||||
expect(shouldShowCustomKeybind(createWidget('skills', { mode: 'list' }), EDIT_LIST_LIMIT_KEYBIND)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetItem,
|
||||
WidgetItemType
|
||||
} from '../../../types/Widget';
|
||||
import {
|
||||
filterWidgetCatalog,
|
||||
getWidget,
|
||||
type WidgetCatalogEntry
|
||||
} from '../../../utils/widgets';
|
||||
|
||||
export type WidgetPickerAction = 'change' | 'add' | 'insert';
|
||||
export type WidgetPickerLevel = 'category' | 'widget';
|
||||
|
||||
export interface WidgetPickerState {
|
||||
action: WidgetPickerAction;
|
||||
level: WidgetPickerLevel;
|
||||
selectedCategory: string | null;
|
||||
categoryQuery: string;
|
||||
widgetQuery: string;
|
||||
selectedType: WidgetItemType | null;
|
||||
}
|
||||
|
||||
export interface CustomEditorWidgetState {
|
||||
widget: WidgetItem;
|
||||
impl: Widget;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface InputKey {
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
tab?: boolean;
|
||||
shift?: boolean;
|
||||
upArrow?: boolean;
|
||||
downArrow?: boolean;
|
||||
leftArrow?: boolean;
|
||||
rightArrow?: boolean;
|
||||
return?: boolean;
|
||||
escape?: boolean;
|
||||
backspace?: boolean;
|
||||
delete?: boolean;
|
||||
}
|
||||
|
||||
type Setter<T> = (value: T | ((prev: T) => T)) => void;
|
||||
|
||||
function setPickerState(
|
||||
setWidgetPicker: Setter<WidgetPickerState | null>,
|
||||
normalizeState: (state: WidgetPickerState) => WidgetPickerState,
|
||||
updater: (prev: WidgetPickerState) => WidgetPickerState
|
||||
): void {
|
||||
setWidgetPicker((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return normalizeState(updater(prev));
|
||||
});
|
||||
}
|
||||
|
||||
function getPickerCategories(widgetCategories: string[]): string[] {
|
||||
return [...widgetCategories];
|
||||
}
|
||||
|
||||
export function normalizePickerState(
|
||||
state: WidgetPickerState,
|
||||
widgetCatalog: WidgetCatalogEntry[],
|
||||
widgetCategories: string[]
|
||||
): WidgetPickerState {
|
||||
const filteredCategories = getPickerCategories(widgetCategories);
|
||||
const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory)
|
||||
? state.selectedCategory
|
||||
: (filteredCategories[0] ?? null);
|
||||
|
||||
const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0;
|
||||
const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All');
|
||||
const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery;
|
||||
const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery);
|
||||
const hasSelectedType = state.selectedType
|
||||
? filteredWidgets.some(entry => entry.type === state.selectedType)
|
||||
: false;
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedCategory,
|
||||
selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null)
|
||||
};
|
||||
}
|
||||
|
||||
interface PickerViewState {
|
||||
filteredCategories: string[];
|
||||
selectedCategory: string | null;
|
||||
hasTopLevelSearch: boolean;
|
||||
topLevelSearchEntries: WidgetCatalogEntry[];
|
||||
topLevelSelectedEntry: WidgetCatalogEntry | undefined;
|
||||
filteredWidgets: WidgetCatalogEntry[];
|
||||
selectedEntry: WidgetCatalogEntry | undefined;
|
||||
}
|
||||
|
||||
function getPickerViewState(
|
||||
widgetPicker: WidgetPickerState,
|
||||
widgetCatalog: WidgetCatalogEntry[],
|
||||
widgetCategories: string[]
|
||||
): PickerViewState {
|
||||
const filteredCategories = getPickerCategories(widgetCategories);
|
||||
const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory)
|
||||
? widgetPicker.selectedCategory
|
||||
: (filteredCategories[0] ?? null);
|
||||
const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0;
|
||||
const topLevelSearchEntries = hasTopLevelSearch
|
||||
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
|
||||
: [];
|
||||
const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0];
|
||||
const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery);
|
||||
const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0];
|
||||
|
||||
return {
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
hasTopLevelSearch,
|
||||
topLevelSearchEntries,
|
||||
topLevelSelectedEntry,
|
||||
filteredWidgets,
|
||||
selectedEntry
|
||||
};
|
||||
}
|
||||
|
||||
export interface HandlePickerInputModeArgs {
|
||||
input: string;
|
||||
key: InputKey;
|
||||
widgetPicker: WidgetPickerState;
|
||||
widgetCatalog: WidgetCatalogEntry[];
|
||||
widgetCategories: string[];
|
||||
setWidgetPicker: Setter<WidgetPickerState | null>;
|
||||
applyWidgetPickerSelection: (selectedType: WidgetItemType) => void;
|
||||
}
|
||||
|
||||
export function handlePickerInputMode({
|
||||
input,
|
||||
key,
|
||||
widgetPicker,
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker,
|
||||
applyWidgetPickerSelection
|
||||
}: HandlePickerInputModeArgs): void {
|
||||
const normalizeState = (state: WidgetPickerState) => normalizePickerState(state, widgetCatalog, widgetCategories);
|
||||
const {
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
hasTopLevelSearch,
|
||||
topLevelSearchEntries,
|
||||
topLevelSelectedEntry,
|
||||
filteredWidgets,
|
||||
selectedEntry
|
||||
} = getPickerViewState(widgetPicker, widgetCatalog, widgetCategories);
|
||||
|
||||
if (widgetPicker.level === 'category') {
|
||||
if (key.escape) {
|
||||
if (widgetPicker.categoryQuery.length > 0) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
categoryQuery: ''
|
||||
}));
|
||||
} else {
|
||||
setWidgetPicker(null);
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (hasTopLevelSearch) {
|
||||
if (topLevelSelectedEntry) {
|
||||
applyWidgetPickerSelection(topLevelSelectedEntry.type);
|
||||
}
|
||||
} else if (selectedCategory) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
level: 'widget',
|
||||
selectedCategory
|
||||
}));
|
||||
}
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
if (hasTopLevelSearch) {
|
||||
if (topLevelSearchEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
const nextType = topLevelSearchEntries[nextIndex]?.type ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
selectedType: nextType
|
||||
}));
|
||||
} else {
|
||||
if (filteredCategories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = filteredCategories.findIndex(category => category === selectedCategory);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(filteredCategories.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
const nextCategory = filteredCategories[nextIndex] ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
selectedCategory: nextCategory
|
||||
}));
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
categoryQuery: prev.categoryQuery.slice(0, -1)
|
||||
}));
|
||||
} else if (
|
||||
input
|
||||
&& !key.ctrl
|
||||
&& !key.meta
|
||||
&& !key.tab
|
||||
) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
categoryQuery: prev.categoryQuery + input
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if (key.escape) {
|
||||
if (widgetPicker.widgetQuery.length > 0) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
widgetQuery: ''
|
||||
}));
|
||||
} else {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
level: 'category'
|
||||
}));
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (selectedEntry) {
|
||||
applyWidgetPickerSelection(selectedEntry.type);
|
||||
}
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
if (filteredWidgets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(filteredWidgets.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
const nextType = filteredWidgets[nextIndex]?.type ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
selectedType: nextType
|
||||
}));
|
||||
} else if (key.backspace || key.delete) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
widgetQuery: prev.widgetQuery.slice(0, -1)
|
||||
}));
|
||||
} else if (
|
||||
input
|
||||
&& !key.ctrl
|
||||
&& !key.meta
|
||||
&& !key.tab
|
||||
) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
widgetQuery: prev.widgetQuery + input
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface HandleMoveInputModeArgs {
|
||||
key: InputKey;
|
||||
widgets: WidgetItem[];
|
||||
selectedIndex: number;
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
setMoveMode: (moveMode: boolean) => void;
|
||||
}
|
||||
|
||||
export function handleMoveInputMode({
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode
|
||||
}: HandleMoveInputModeArgs): void {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
const newWidgets = [...widgets];
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const prev = newWidgets[selectedIndex - 1];
|
||||
if (temp && prev) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
|
||||
const newWidgets = [...widgets];
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const next = newWidgets[selectedIndex + 1];
|
||||
if (temp && next) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex + 1);
|
||||
} else if (key.escape || key.return) {
|
||||
setMoveMode(false);
|
||||
}
|
||||
}
|
||||
|
||||
export interface HandleNormalInputModeArgs {
|
||||
input: string;
|
||||
key: InputKey;
|
||||
widgets: WidgetItem[];
|
||||
selectedIndex: number;
|
||||
separatorChars: string[];
|
||||
onBack: () => void;
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
setMoveMode: (moveMode: boolean) => void;
|
||||
setShowClearConfirm: (show: boolean) => void;
|
||||
openWidgetPicker: (action: WidgetPickerAction) => void;
|
||||
getVisibleCustomKeybinds: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
|
||||
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
|
||||
}
|
||||
|
||||
export function handleNormalInputMode({
|
||||
input,
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
separatorChars,
|
||||
onBack,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode,
|
||||
setShowClearConfirm,
|
||||
openWidgetPicker,
|
||||
getVisibleCustomKeybinds,
|
||||
setCustomEditorWidget
|
||||
}: HandleNormalInputModeArgs): void {
|
||||
if (key.upArrow && widgets.length > 0) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow && widgets.length > 0) {
|
||||
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
|
||||
} else if (key.leftArrow && widgets.length > 0) {
|
||||
openWidgetPicker('change');
|
||||
} else if (key.rightArrow && widgets.length > 0) {
|
||||
openWidgetPicker('change');
|
||||
} else if (key.return && widgets.length > 0) {
|
||||
setMoveMode(true);
|
||||
} else if (input === 'a') {
|
||||
openWidgetPicker('add');
|
||||
} else if (input === 'i') {
|
||||
openWidgetPicker('insert');
|
||||
} else if (input === 'd' && widgets.length > 0) {
|
||||
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
|
||||
onUpdate(newWidgets);
|
||||
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
}
|
||||
} else if (input === 'c') {
|
||||
if (widgets.length > 0) {
|
||||
setShowClearConfirm(true);
|
||||
}
|
||||
} else if (input === ' ' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget?.type === 'separator') {
|
||||
const currentChar = currentWidget.character ?? '|';
|
||||
const currentCharIndex = separatorChars.indexOf(currentChar);
|
||||
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'r' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (!widgetImpl?.supportsRawValue()) {
|
||||
return;
|
||||
}
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'm' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && selectedIndex < widgets.length - 1
|
||||
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const newWidgets = [...widgets];
|
||||
let nextMergeState: boolean | 'no-padding' | undefined;
|
||||
|
||||
if (currentWidget.merge === undefined) {
|
||||
nextMergeState = true;
|
||||
} else if (currentWidget.merge === true) {
|
||||
nextMergeState = 'no-padding';
|
||||
} else {
|
||||
nextMergeState = undefined;
|
||||
}
|
||||
|
||||
if (nextMergeState === undefined) {
|
||||
const { merge, ...rest } = currentWidget;
|
||||
void merge; // Intentionally unused
|
||||
newWidgets[selectedIndex] = rest;
|
||||
} else {
|
||||
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onBack();
|
||||
} else if (widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (!widgetImpl?.getCustomKeybinds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
|
||||
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
|
||||
|
||||
if (matchedKeybind && !key.ctrl) {
|
||||
if (widgetImpl.handleEditorAction) {
|
||||
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
|
||||
if (updatedWidget) {
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = updatedWidget;
|
||||
onUpdate(newWidgets);
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
CustomKeybind,
|
||||
WidgetItem
|
||||
} from '../../../types/Widget';
|
||||
|
||||
function isProgressMode(widget: WidgetItem): boolean {
|
||||
const mode = widget.metadata?.display;
|
||||
return mode === 'progress' || mode === 'progress-short';
|
||||
}
|
||||
|
||||
export function shouldShowCustomKeybind(widget: WidgetItem, keybind: CustomKeybind): boolean {
|
||||
if (keybind.action === 'edit-list-limit') {
|
||||
return widget.type === 'skills' && widget.metadata?.mode === 'list';
|
||||
}
|
||||
|
||||
if (keybind.action === 'toggle-invert') {
|
||||
return isProgressMode(widget);
|
||||
}
|
||||
|
||||
if (keybind.action === 'toggle-compact') {
|
||||
return !isProgressMode(widget);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -17,13 +17,13 @@ export type ColorLevelString = 'ansi16' | 'ansi256' | 'truecolor';
|
||||
// Helper to get color level as string for chalk
|
||||
export function getColorLevelString(level: ColorLevel | undefined): ColorLevelString {
|
||||
switch (level) {
|
||||
case 0:
|
||||
case 1:
|
||||
return 'ansi16';
|
||||
case 3:
|
||||
return 'truecolor';
|
||||
case 2:
|
||||
default:
|
||||
return 'ansi256';
|
||||
case 0:
|
||||
case 1:
|
||||
return 'ansi16';
|
||||
case 3:
|
||||
return 'truecolor';
|
||||
case 2:
|
||||
default:
|
||||
return 'ansi256';
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,33 @@
|
||||
import type { BlockMetrics } from '../types';
|
||||
import type {
|
||||
BlockMetrics,
|
||||
SkillsMetrics
|
||||
} from '../types';
|
||||
|
||||
import type { SpeedMetrics } from './SpeedMetrics';
|
||||
import type { StatusJSON } from './StatusJSON';
|
||||
import type { TokenMetrics } from './TokenMetrics';
|
||||
|
||||
export interface RenderUsageData {
|
||||
sessionUsage?: number;
|
||||
sessionResetAt?: string;
|
||||
weeklyUsage?: number;
|
||||
weeklyResetAt?: string;
|
||||
extraUsageEnabled?: boolean;
|
||||
extraUsageLimit?: number;
|
||||
extraUsageUsed?: number;
|
||||
extraUsageUtilization?: number;
|
||||
error?: 'no-credentials' | 'timeout' | 'rate-limited' | 'api-error' | 'parse-error';
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
data?: StatusJSON;
|
||||
tokenMetrics?: TokenMetrics | null;
|
||||
speedMetrics?: SpeedMetrics | null;
|
||||
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
|
||||
usageData?: RenderUsageData | null;
|
||||
sessionDuration?: string | null;
|
||||
blockMetrics?: BlockMetrics | null;
|
||||
skillsMetrics?: SkillsMetrics | null;
|
||||
terminalWidth?: number | null;
|
||||
isPreview?: boolean;
|
||||
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface SkillInvocation {
|
||||
timestamp: string;
|
||||
session_id: string;
|
||||
skill: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface SkillsMetrics {
|
||||
totalInvocations: number;
|
||||
uniqueSkills: string[];
|
||||
lastSkill: string | null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Speed metrics for calculating token processing rates.
|
||||
* Provides time-based data needed for speed calculations.
|
||||
*/
|
||||
export interface SpeedMetrics {
|
||||
/** Active processing duration in milliseconds (sum of user request → assistant response times) */
|
||||
totalDurationMs: number;
|
||||
|
||||
/** Total input tokens across all requests */
|
||||
inputTokens: number;
|
||||
|
||||
/** Total output tokens across all requests */
|
||||
outputTokens: number;
|
||||
|
||||
/** Total tokens (input + output) */
|
||||
totalTokens: number;
|
||||
|
||||
/** Number of assistant usage entries included in speed aggregation */
|
||||
requestCount: number;
|
||||
}
|
||||
+29
-15
@@ -1,5 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const CoercedNumberSchema = z.preprocess((value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}, z.number());
|
||||
|
||||
export const StatusJSONSchema = z.looseObject({
|
||||
hook_event_name: z.string().optional(),
|
||||
session_id: z.string().optional(),
|
||||
@@ -19,27 +33,27 @@ export const StatusJSONSchema = z.looseObject({
|
||||
version: z.string().optional(),
|
||||
output_style: z.object({ name: z.string().optional() }).optional(),
|
||||
cost: z.object({
|
||||
total_cost_usd: z.number().optional(),
|
||||
total_duration_ms: z.number().optional(),
|
||||
total_api_duration_ms: z.number().optional(),
|
||||
total_lines_added: z.number().optional(),
|
||||
total_lines_removed: z.number().optional()
|
||||
total_cost_usd: CoercedNumberSchema.optional(),
|
||||
total_duration_ms: CoercedNumberSchema.optional(),
|
||||
total_api_duration_ms: CoercedNumberSchema.optional(),
|
||||
total_lines_added: CoercedNumberSchema.optional(),
|
||||
total_lines_removed: CoercedNumberSchema.optional()
|
||||
}).optional(),
|
||||
context_window: z.object({
|
||||
context_window_size: z.number().nullable().optional(),
|
||||
total_input_tokens: z.number().nullable().optional(),
|
||||
total_output_tokens: z.number().nullable().optional(),
|
||||
context_window_size: CoercedNumberSchema.nullable().optional(),
|
||||
total_input_tokens: CoercedNumberSchema.nullable().optional(),
|
||||
total_output_tokens: CoercedNumberSchema.nullable().optional(),
|
||||
current_usage: z.union([
|
||||
z.number(),
|
||||
CoercedNumberSchema,
|
||||
z.object({
|
||||
input_tokens: z.number().optional(),
|
||||
output_tokens: z.number().optional(),
|
||||
cache_creation_input_tokens: z.number().optional(),
|
||||
cache_read_input_tokens: z.number().optional()
|
||||
input_tokens: CoercedNumberSchema.optional(),
|
||||
output_tokens: CoercedNumberSchema.optional(),
|
||||
cache_creation_input_tokens: CoercedNumberSchema.optional(),
|
||||
cache_read_input_tokens: CoercedNumberSchema.optional()
|
||||
})
|
||||
]).nullable().optional(),
|
||||
used_percentage: z.number().nullable().optional(),
|
||||
remaining_percentage: z.number().nullable().optional()
|
||||
used_percentage: CoercedNumberSchema.nullable().optional(),
|
||||
remaining_percentage: CoercedNumberSchema.nullable().optional()
|
||||
}).nullable().optional()
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface TranscriptLine {
|
||||
isSidechain?: boolean;
|
||||
timestamp?: string;
|
||||
isApiErrorMessage?: boolean;
|
||||
type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot';
|
||||
}
|
||||
|
||||
export interface TokenMetrics {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { StatusJSONSchema } from '../StatusJSON';
|
||||
|
||||
describe('StatusJSONSchema numeric coercion', () => {
|
||||
it('coerces numeric strings to numbers', () => {
|
||||
const result = StatusJSONSchema.safeParse({
|
||||
cost: {
|
||||
total_cost_usd: '1.25',
|
||||
total_duration_ms: '12345',
|
||||
total_api_duration_ms: '2345',
|
||||
total_lines_added: '12',
|
||||
total_lines_removed: '3'
|
||||
},
|
||||
context_window: {
|
||||
context_window_size: '200000',
|
||||
total_input_tokens: '1200',
|
||||
total_output_tokens: '340',
|
||||
current_usage: {
|
||||
input_tokens: '100',
|
||||
output_tokens: '50',
|
||||
cache_creation_input_tokens: '20',
|
||||
cache_read_input_tokens: '10'
|
||||
},
|
||||
used_percentage: '9.3',
|
||||
remaining_percentage: '90.7'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.data.cost?.total_duration_ms).toBe(12345);
|
||||
expect(result.data.context_window?.context_window_size).toBe(200000);
|
||||
expect(result.data.context_window?.current_usage).toEqual({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 20,
|
||||
cache_read_input_tokens: 10
|
||||
});
|
||||
expect(result.data.context_window?.used_percentage).toBe(9.3);
|
||||
});
|
||||
|
||||
it('keeps invalid numeric strings rejected', () => {
|
||||
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: 'not-a-number' } });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps empty numeric strings rejected', () => {
|
||||
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: '' } });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -17,4 +17,6 @@ export type { RenderContext } from './RenderContext';
|
||||
export type { PowerlineFontStatus } from './PowerlineFontStatus';
|
||||
export type { ClaudeSettings } from './ClaudeSettings';
|
||||
export type { ColorEntry } from './ColorEntry';
|
||||
export type { BlockMetrics } from './BlockMetrics';
|
||||
export type { BlockMetrics } from './BlockMetrics';
|
||||
export type { SpeedMetrics } from './SpeedMetrics';
|
||||
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
|
||||
@@ -0,0 +1,358 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import {
|
||||
CCSTATUSLINE_COMMANDS,
|
||||
getClaudeSettingsPath,
|
||||
getExistingStatusLine,
|
||||
installStatusLine,
|
||||
isInstalled,
|
||||
isKnownCommand,
|
||||
loadClaudeSettings,
|
||||
saveClaudeSettings,
|
||||
uninstallStatusLine
|
||||
} from '../claude-settings';
|
||||
import { initConfigPath } from '../config';
|
||||
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
let testClaudeConfigDir = '';
|
||||
|
||||
function readInstalledCommand(): string {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const data = JSON.parse(content) as { statusLine?: { command?: string } };
|
||||
return data.statusLine?.command ?? '';
|
||||
}
|
||||
|
||||
function writeRawClaudeSettings(content: string): void {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-settings-'));
|
||||
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
|
||||
initConfigPath();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
initConfigPath();
|
||||
if (testClaudeConfigDir) {
|
||||
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
describe('isKnownCommand', () => {
|
||||
it('should match exact NPM command', () => {
|
||||
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.NPM)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match exact BUNX command', () => {
|
||||
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.BUNX)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match exact SELF_MANAGED command', () => {
|
||||
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.SELF_MANAGED)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match NPM command with --config and simple path', () => {
|
||||
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match BUNX command with --config and quoted path with spaces', () => {
|
||||
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match command with --config and quoted path with parens', () => {
|
||||
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match command with --config and double-quoted Windows path', () => {
|
||||
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config "C:\\Users\\Alice\\My Settings\\settings.json"`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match unknown commands', () => {
|
||||
expect(isKnownCommand('some-other-command')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match empty string', () => {
|
||||
expect(isKnownCommand('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match partial prefix', () => {
|
||||
expect(isKnownCommand('npx -y ccstatusline')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match prefix that is a substring', () => {
|
||||
expect(isKnownCommand('npx -y ccstatusline@latestFOO')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommand via installStatusLine', () => {
|
||||
it('should use base command when no custom config path', async () => {
|
||||
initConfigPath();
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(CCSTATUSLINE_COMMANDS.NPM);
|
||||
});
|
||||
|
||||
it('should append --config with simple path (no quoting needed)', async () => {
|
||||
initConfigPath('/tmp/settings.json');
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`);
|
||||
});
|
||||
|
||||
it('should quote path with spaces', async () => {
|
||||
initConfigPath('/my path/settings.json');
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should quote path with parentheses', async () => {
|
||||
initConfigPath('/my(path)/settings.json');
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`);
|
||||
});
|
||||
|
||||
it('should escape embedded single quotes in path', async () => {
|
||||
initConfigPath('/my\'path/settings.json');
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my'\\''path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should use bunx command when useBunx is true', async () => {
|
||||
initConfigPath('/my path/settings.json');
|
||||
await installStatusLine(true);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should sync hooks on install when settings include hook-enabled widgets', async () => {
|
||||
const configPath = path.join(testClaudeConfigDir, 'ccstatusline-settings.json');
|
||||
initConfigPath(configPath);
|
||||
const settingsWithSkills = {
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [[{ id: 'skills-1', type: 'skills' }], [], []]
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(settingsWithSkills, null, 2), 'utf-8');
|
||||
|
||||
await installStatusLine(false);
|
||||
|
||||
const installedCommand = `${CCSTATUSLINE_COMMANDS.NPM} --config ${configPath}`;
|
||||
const claudeSettings = await loadClaudeSettings();
|
||||
expect(claudeSettings.statusLine?.command).toBe(installedCommand);
|
||||
const hooks = (claudeSettings.hooks ?? {}) as Record<string, unknown[]>;
|
||||
expect(hooks.PreToolUse).toEqual([
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
|
||||
}
|
||||
]);
|
||||
expect(hooks.UserPromptSubmit).toEqual([
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup and error handling behavior', () => {
|
||||
it('saveClaudeSettings should create .bak backup before overwrite', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'preexisting-command',
|
||||
padding: 1
|
||||
}
|
||||
}));
|
||||
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string } };
|
||||
expect(saved.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
|
||||
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true);
|
||||
|
||||
const backup = JSON.parse(fs.readFileSync(`${settingsPath}.bak`, 'utf-8')) as { statusLine?: { command?: string } };
|
||||
expect(backup.statusLine?.command).toBe('preexisting-command');
|
||||
});
|
||||
|
||||
it('installStatusLine should create .orig backup before updating settings', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'old-command',
|
||||
padding: 1
|
||||
}
|
||||
}));
|
||||
|
||||
await installStatusLine(false);
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
|
||||
|
||||
const orig = JSON.parse(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')) as { statusLine?: { command?: string } };
|
||||
expect(orig.statusLine?.command).toBe('old-command');
|
||||
});
|
||||
|
||||
it('loadClaudeSettings should return empty object when settings file is missing', async () => {
|
||||
await expect(loadClaudeSettings()).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('loadClaudeSettings should log and throw when settings file is invalid JSON', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await expect(loadClaudeSettings()).rejects.toThrow();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to load Claude settings:',
|
||||
expect.anything()
|
||||
);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('isInstalled should return false when settings cannot be loaded', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await expect(isInstalled()).resolves.toBe(false);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('installStatusLine should warn and recover when existing settings are invalid', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await installStatusLine(false);
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const installed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string; padding?: number } };
|
||||
expect(installed.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
|
||||
expect(installed.statusLine?.padding).toBe(0);
|
||||
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
|
||||
expect(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')).toBe('{ invalid json');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`Warning: Could not read existing Claude settings. A backup exists at ${settingsPath}.orig.`
|
||||
);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uninstallStatusLine should warn and return without modifying invalid settings', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await uninstallStatusLine();
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
|
||||
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Warning: Could not read existing Claude settings.'
|
||||
);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uninstallStatusLine should remove all managed hooks', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0
|
||||
},
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
|
||||
},
|
||||
{
|
||||
matcher: 'Other',
|
||||
hooks: [{ type: 'command', command: 'keep-me' }]
|
||||
}
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
await uninstallStatusLine();
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const updated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
statusLine?: unknown;
|
||||
hooks?: Record<string, unknown[]>;
|
||||
};
|
||||
expect(updated.statusLine).toBeUndefined();
|
||||
expect(updated.hooks).toEqual({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Other',
|
||||
hooks: [{ type: 'command', command: 'keep-me' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('getExistingStatusLine should return null when settings cannot be loaded', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await expect(getExistingStatusLine()).resolves.toBeNull();
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('isInstalled should accept known commands with --config and undefined padding', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: `${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`
|
||||
}
|
||||
});
|
||||
|
||||
await expect(isInstalled()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import { cloneSettings } from '../clone-settings';
|
||||
|
||||
describe('cloneSettings', () => {
|
||||
it('creates a deep clone that is independent from source', () => {
|
||||
const original = {
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [
|
||||
[
|
||||
{ id: '1', type: 'model', metadata: { key: 'value' } }
|
||||
]
|
||||
]
|
||||
};
|
||||
|
||||
const cloned = cloneSettings(original);
|
||||
const originalWidget = original.lines[0]?.[0];
|
||||
const clonedWidget = cloned.lines[0]?.[0];
|
||||
|
||||
expect(originalWidget).toBeDefined();
|
||||
expect(clonedWidget).toBeDefined();
|
||||
|
||||
if (!originalWidget || !clonedWidget) {
|
||||
throw new Error('Expected cloned settings to include widget entries');
|
||||
}
|
||||
|
||||
const originalMetadata = originalWidget.metadata as Record<string, string>;
|
||||
const clonedMetadata = (clonedWidget.metadata ?? {});
|
||||
clonedWidget.metadata = clonedMetadata;
|
||||
clonedMetadata.key = 'changed';
|
||||
|
||||
expect(originalMetadata.key).toBe('value');
|
||||
expect(clonedMetadata.key).toBe('changed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
hasCustomWidgetColors,
|
||||
sanitizeLinesForColorLevel
|
||||
} from '../color-sanitize';
|
||||
|
||||
describe('color sanitize helpers', () => {
|
||||
it('detects custom ansi256/hex colors in foreground and background', () => {
|
||||
const lines: WidgetItem[][] = [
|
||||
[
|
||||
{ id: '1', type: 'model', color: 'ansi256:120' }
|
||||
],
|
||||
[
|
||||
{ id: '2', type: 'context-length', backgroundColor: 'hex:AA00BB' }
|
||||
]
|
||||
];
|
||||
|
||||
expect(hasCustomWidgetColors(lines)).toBe(true);
|
||||
expect(hasCustomWidgetColors([[{ id: '3', type: 'model', color: 'cyan' }]])).toBe(false);
|
||||
});
|
||||
|
||||
it('sanitizes hex colors when moving to ansi256 mode', () => {
|
||||
const lines: WidgetItem[][] = [[
|
||||
{ id: '1', type: 'model', color: 'hex:FF00AA', backgroundColor: 'hex:112233' },
|
||||
{ id: '2', type: 'context-length', color: 'ansi256:111', backgroundColor: 'ansi256:24' }
|
||||
]];
|
||||
|
||||
const sanitized = sanitizeLinesForColorLevel(lines, 2);
|
||||
|
||||
expect(sanitized[0]?.[0]?.color).toBe('cyan');
|
||||
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
|
||||
expect(sanitized[0]?.[1]?.color).toBe('ansi256:111');
|
||||
expect(sanitized[0]?.[1]?.backgroundColor).toBe('ansi256:24');
|
||||
});
|
||||
|
||||
it('sanitizes ansi256 colors when moving to truecolor mode', () => {
|
||||
const lines: WidgetItem[][] = [[
|
||||
{ id: '1', type: 'model', color: 'ansi256:120', backgroundColor: 'ansi256:244' },
|
||||
{ id: '2', type: 'context-length', color: 'hex:AA11BB', backgroundColor: 'hex:112233' }
|
||||
]];
|
||||
|
||||
const sanitized = sanitizeLinesForColorLevel(lines, 3);
|
||||
|
||||
expect(sanitized[0]?.[0]?.color).toBe('cyan');
|
||||
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
|
||||
expect(sanitized[0]?.[1]?.color).toBe('hex:AA11BB');
|
||||
expect(sanitized[0]?.[1]?.backgroundColor).toBe('hex:112233');
|
||||
});
|
||||
|
||||
it('sanitizes all custom colors when moving to basic/no-color modes', () => {
|
||||
const lines: WidgetItem[][] = [[
|
||||
{ id: '1', type: 'model', color: 'ansi256:99', backgroundColor: 'hex:123456' },
|
||||
{ id: '2', type: 'separator', color: 'hex:ABCDEF', backgroundColor: 'ansi256:2' }
|
||||
]];
|
||||
|
||||
const sanitized = sanitizeLinesForColorLevel(lines, 1);
|
||||
|
||||
expect(sanitized[0]?.[0]?.color).toBe('cyan');
|
||||
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
|
||||
// Preserve existing behavior: separator foreground is not reset by current logic.
|
||||
expect(sanitized[0]?.[1]?.color).toBe('hex:ABCDEF');
|
||||
expect(sanitized[0]?.[1]?.backgroundColor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
initConfigPath,
|
||||
isCustomConfigPath
|
||||
} from '../config';
|
||||
|
||||
const DEFAULT_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json');
|
||||
|
||||
describe('initConfigPath / getConfigPath', () => {
|
||||
beforeEach(() => {
|
||||
initConfigPath();
|
||||
});
|
||||
|
||||
it('should return the default settings path when no arg is provided', () => {
|
||||
initConfigPath();
|
||||
expect(getConfigPath()).toBe(DEFAULT_PATH);
|
||||
expect(isCustomConfigPath()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return a custom settings path when a file path is provided', () => {
|
||||
initConfigPath('/tmp/my-ccsl/settings.json');
|
||||
expect(getConfigPath()).toBe('/tmp/my-ccsl/settings.json');
|
||||
expect(isCustomConfigPath()).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve relative paths', () => {
|
||||
initConfigPath('relative/settings.json');
|
||||
expect(path.isAbsolute(getConfigPath())).toBe(true);
|
||||
expect(getConfigPath()).toBe(path.resolve('relative/settings.json'));
|
||||
});
|
||||
|
||||
it('should reset to default when called with undefined', () => {
|
||||
initConfigPath('/tmp/custom.json');
|
||||
expect(isCustomConfigPath()).toBe(true);
|
||||
initConfigPath(undefined);
|
||||
expect(getConfigPath()).toBe(DEFAULT_PATH);
|
||||
expect(isCustomConfigPath()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
CURRENT_VERSION,
|
||||
DEFAULT_SETTINGS,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
|
||||
const MOCK_HOME_DIR = '/tmp/ccstatusline-config-test-home';
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
let loadSettings: () => Promise<Settings>;
|
||||
let saveSettings: (settings: Settings) => Promise<void>;
|
||||
let initConfigPath: (filePath?: string) => void;
|
||||
let consoleErrorSpy: MockInstance<typeof console.error>;
|
||||
|
||||
function getSettingsPaths(): { configDir: string; settingsPath: string; backupPath: string } {
|
||||
const configDir = path.join(MOCK_HOME_DIR, '.config', 'ccstatusline');
|
||||
return {
|
||||
configDir,
|
||||
settingsPath: path.join(configDir, 'settings.json'),
|
||||
backupPath: path.join(configDir, 'settings.bak')
|
||||
};
|
||||
}
|
||||
|
||||
function getClaudeConfigDir(): string {
|
||||
return path.join(MOCK_HOME_DIR, '.claude');
|
||||
}
|
||||
|
||||
describe('config utilities', () => {
|
||||
beforeAll(async () => {
|
||||
const configModule = await import('../config');
|
||||
loadSettings = configModule.loadSettings;
|
||||
saveSettings = configModule.saveSettings;
|
||||
initConfigPath = configModule.initConfigPath;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
|
||||
process.env.CLAUDE_CONFIG_DIR = getClaudeConfigDir();
|
||||
const { settingsPath } = getSettingsPaths();
|
||||
initConfigPath(settingsPath);
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
initConfigPath();
|
||||
});
|
||||
|
||||
it('writes defaults when settings file does not exist', async () => {
|
||||
const { settingsPath } = getSettingsPaths();
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
version?: number;
|
||||
lines?: unknown[];
|
||||
};
|
||||
expect(onDisk.version).toBe(CURRENT_VERSION);
|
||||
expect(Array.isArray(onDisk.lines)).toBe(true);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Default settings written to')
|
||||
);
|
||||
});
|
||||
|
||||
it('backs up invalid JSON and recovers with defaults', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, '{ invalid json', 'utf-8');
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
expect(fs.existsSync(backupPath)).toBe(true);
|
||||
expect(fs.readFileSync(backupPath, 'utf-8')).toBe('{ invalid json');
|
||||
|
||||
const recovered = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
|
||||
expect(recovered.version).toBe(CURRENT_VERSION);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse settings.json, backing up and using defaults'
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Bad settings backed up to')
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Default settings written to')
|
||||
);
|
||||
});
|
||||
|
||||
it('backs up invalid v1 payloads and recovers with defaults', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ flexMode: 123 }), 'utf-8');
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
expect(fs.existsSync(backupPath)).toBe(true);
|
||||
const recovered = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
|
||||
expect(recovered.version).toBe(CURRENT_VERSION);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Invalid v1 settings format:',
|
||||
expect.anything()
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Bad settings backed up to')
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Default settings written to')
|
||||
);
|
||||
});
|
||||
|
||||
it('migrates older versioned settings and persists migrated result', async () => {
|
||||
const { settingsPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
lines: [[{ id: 'widget-1', type: 'model' }]]
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
const migrated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
version?: number;
|
||||
updatemessage?: { message?: string };
|
||||
};
|
||||
expect(migrated.version).toBe(CURRENT_VERSION);
|
||||
expect(migrated.updatemessage?.message).toContain('v2.0.2');
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('always saves current version in saveSettings', async () => {
|
||||
const { settingsPath } = getSettingsPaths();
|
||||
|
||||
await saveSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
version: 1
|
||||
});
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
|
||||
expect(saved.version).toBe(CURRENT_VERSION);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,59 @@ describe('calculateContextPercentage', () => {
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(100);
|
||||
});
|
||||
|
||||
it('should calculate percentage using 1M denominator with 1M context label', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'Opus 4.6 (1M context)' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
|
||||
it('should calculate percentage using 1M denominator with 1M in parentheses', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'Opus 4.6 (1M)' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
|
||||
it('should calculate percentage from display_name when model id lacks context size suffix', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: {
|
||||
id: 'claude-opus-4-6',
|
||||
display_name: 'Opus 4.6 (1M context)'
|
||||
}
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Older models with 200k context window', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import {
|
||||
getGitChangeCounts,
|
||||
isInsideGitWorkTree,
|
||||
resolveGitCwd,
|
||||
runGit
|
||||
@@ -20,6 +21,7 @@ const mockExecSync = execSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockImplementation: (impl: () => never) => void;
|
||||
mockReturnValue: (value: string) => void;
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
describe('git utils', () => {
|
||||
@@ -134,4 +136,35 @@ describe('git utils', () => {
|
||||
expect(isInsideGitWorkTree({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitChangeCounts', () => {
|
||||
it('sums staged and unstaged insertions/deletions', () => {
|
||||
mockExecSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)');
|
||||
mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)');
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 5,
|
||||
deletions: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('handles singular insertion/deletion forms', () => {
|
||||
mockExecSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 1,
|
||||
deletions: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counts when git diff commands fail', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 0,
|
||||
deletions: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import { syncWidgetHooks } from '../hooks';
|
||||
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
let testClaudeConfigDir = '';
|
||||
|
||||
function getClaudeSettingsPath(): string {
|
||||
return path.join(testClaudeConfigDir, 'settings.json');
|
||||
}
|
||||
|
||||
describe('syncWidgetHooks', () => {
|
||||
beforeEach(() => {
|
||||
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-hooks-'));
|
||||
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (testClaudeConfigDir) {
|
||||
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
it('removes managed hooks and persists cleanup when status line is unset', async () => {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: 'old-command --hook' }]
|
||||
},
|
||||
{
|
||||
matcher: 'Other',
|
||||
hooks: [{ type: 'command', command: 'keep-command' }]
|
||||
}
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
hooks: [{ type: 'command', command: 'old-command --hook' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}, null, 2), 'utf-8');
|
||||
|
||||
await syncWidgetHooks(DEFAULT_SETTINGS);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record<string, unknown[]> };
|
||||
expect(saved.hooks).toEqual({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Other',
|
||||
hooks: [{ type: 'command', command: 'keep-command' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getBlockMetrics } from '../jsonl';
|
||||
|
||||
function floorToHourUtc(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
|
||||
function makeUsageLine(timestamp: Date): string {
|
||||
return JSON.stringify({
|
||||
timestamp: timestamp.toISOString(),
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('jsonl block metrics integration', () => {
|
||||
let tempClaudeDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempClaudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-blocks-'));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempClaudeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
}
|
||||
fs.rmSync(tempClaudeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the current block start for recent activity after an older session gap', () => {
|
||||
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
const transcriptPath = path.join(projectsDir, 'session.jsonl');
|
||||
|
||||
const now = new Date();
|
||||
const oldActivity = new Date(now.getTime() - (10 * 60 * 60 * 1000));
|
||||
const currentBlockStartSource = new Date(now.getTime() - (2 * 60 * 60 * 1000) - (10 * 60 * 1000));
|
||||
const recentActivity = new Date(now.getTime() - (40 * 60 * 1000));
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeUsageLine(oldActivity),
|
||||
makeUsageLine(currentBlockStartSource),
|
||||
makeUsageLine(recentActivity)
|
||||
].join('\n'));
|
||||
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
expect(metrics).not.toBeNull();
|
||||
expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(currentBlockStartSource).toISOString());
|
||||
expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString());
|
||||
});
|
||||
|
||||
it('returns null when the most recent activity is older than the session window', () => {
|
||||
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
const transcriptPath = path.join(projectsDir, 'stale-session.jsonl');
|
||||
|
||||
const now = new Date();
|
||||
const staleActivity = new Date(now.getTime() - (6 * 60 * 60 * 1000));
|
||||
|
||||
fs.writeFileSync(transcriptPath, makeUsageLine(staleActivity));
|
||||
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
expect(metrics).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,886 @@
|
||||
import * as fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getSessionDuration,
|
||||
getSpeedMetrics,
|
||||
getSpeedMetricsCollection,
|
||||
getTokenMetrics
|
||||
} from '../jsonl';
|
||||
|
||||
function makeUsageLine(params: {
|
||||
timestamp: string;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead?: number;
|
||||
cacheCreate?: number;
|
||||
isSidechain?: boolean;
|
||||
isApiErrorMessage?: boolean;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
timestamp: params.timestamp,
|
||||
isSidechain: params.isSidechain,
|
||||
isApiErrorMessage: params.isApiErrorMessage,
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: params.input,
|
||||
output_tokens: params.output,
|
||||
cache_read_input_tokens: params.cacheRead,
|
||||
cache_creation_input_tokens: params.cacheCreate
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function makeTranscriptLine(params: {
|
||||
timestamp: string;
|
||||
type: 'user' | 'assistant';
|
||||
input?: number;
|
||||
output?: number;
|
||||
isSidechain?: boolean;
|
||||
isApiErrorMessage?: boolean;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
timestamp: params.timestamp,
|
||||
type: params.type,
|
||||
isSidechain: params.isSidechain,
|
||||
isApiErrorMessage: params.isApiErrorMessage,
|
||||
message: typeof params.input === 'number' || typeof params.output === 'number'
|
||||
? {
|
||||
usage: {
|
||||
input_tokens: params.input ?? 0,
|
||||
output_tokens: params.output ?? 0
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
}
|
||||
|
||||
describe('jsonl transcript metrics', () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (tempRoots.length > 0) {
|
||||
const root = tempRoots.pop();
|
||||
if (root) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('formats session duration as <1m for sub-minute transcripts', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'short.jsonl');
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }),
|
||||
JSON.stringify({ timestamp: '2026-01-01T10:00:30.000Z' })
|
||||
].join('\n'));
|
||||
|
||||
const duration = await getSessionDuration(transcriptPath);
|
||||
|
||||
expect(duration).toBe('<1m');
|
||||
});
|
||||
|
||||
it('formats multi-hour session durations', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'long.jsonl');
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }),
|
||||
JSON.stringify({ timestamp: '2026-01-01T12:05:00.000Z' })
|
||||
].join('\n'));
|
||||
|
||||
const duration = await getSessionDuration(transcriptPath);
|
||||
|
||||
expect(duration).toBe('2hr 5m');
|
||||
});
|
||||
|
||||
it('returns null for missing transcript files', async () => {
|
||||
const duration = await getSessionDuration('/tmp/ccstatusline-jsonl-metrics-missing.jsonl');
|
||||
expect(duration).toBeNull();
|
||||
});
|
||||
|
||||
it('aggregates token totals and computes context length from the latest main-chain non-error entry', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'tokens.jsonl');
|
||||
|
||||
const lines = [
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
input: 100,
|
||||
output: 50,
|
||||
cacheRead: 20,
|
||||
cacheCreate: 10
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:00:00.000Z',
|
||||
input: 200,
|
||||
output: 80,
|
||||
cacheRead: 30,
|
||||
cacheCreate: 20
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:30:00.000Z',
|
||||
input: 500,
|
||||
output: 10,
|
||||
cacheRead: 5,
|
||||
cacheCreate: 5,
|
||||
isSidechain: true
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:45:00.000Z',
|
||||
input: 999,
|
||||
output: 1,
|
||||
cacheRead: 1,
|
||||
cacheCreate: 1,
|
||||
isApiErrorMessage: true
|
||||
})
|
||||
];
|
||||
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const metrics = await getTokenMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 1799,
|
||||
outputTokens: 141,
|
||||
cachedTokens: 92,
|
||||
totalTokens: 2032,
|
||||
contextLength: 250
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zeroed token metrics when file is missing', async () => {
|
||||
const metrics = await getTokenMetrics('/tmp/ccstatusline-jsonl-metrics-missing.jsonl');
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates speed metrics from user-to-assistant processing windows', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'assistant',
|
||||
input: 200,
|
||||
output: 100
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:08.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 50
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:01:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:01:04.000Z',
|
||||
type: 'assistant',
|
||||
input: 300,
|
||||
output: 150
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 12000,
|
||||
inputTokens: 600,
|
||||
outputTokens: 300,
|
||||
totalTokens: 900,
|
||||
requestCount: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates windowed speed metrics from recent requests only', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-window.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 50
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:01:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:01:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 200,
|
||||
output: 100
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:02:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:02:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 300,
|
||||
output: 150
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { windowSeconds: 70 });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 20000,
|
||||
inputTokens: 500,
|
||||
outputTokens: 250,
|
||||
totalTokens: 750,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('returns session and windowed speed metrics in one collection call', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-window-collection.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 50
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:40.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:50.000Z',
|
||||
type: 'assistant',
|
||||
input: 200,
|
||||
output: 100
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metricsCollection = await getSpeedMetricsCollection(transcriptPath, { windowSeconds: [30, 90] });
|
||||
|
||||
expect(metricsCollection.sessionAverage).toEqual({
|
||||
totalDurationMs: 20000,
|
||||
inputTokens: 300,
|
||||
outputTokens: 150,
|
||||
totalTokens: 450,
|
||||
requestCount: 2
|
||||
});
|
||||
expect(metricsCollection.windowed['30']).toEqual({
|
||||
totalDurationMs: 10000,
|
||||
inputTokens: 200,
|
||||
outputTokens: 100,
|
||||
totalTokens: 300,
|
||||
requestCount: 1
|
||||
});
|
||||
expect(metricsCollection.windowed['90']).toEqual({
|
||||
totalDurationMs: 20000,
|
||||
inputTokens: 300,
|
||||
outputTokens: 150,
|
||||
totalTokens: 450,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores sidechain and API error entries in speed metrics', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-filtering.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:01.000Z',
|
||||
type: 'assistant',
|
||||
input: 999,
|
||||
output: 999,
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:02.000Z',
|
||||
type: 'assistant',
|
||||
input: 500,
|
||||
output: 500,
|
||||
isApiErrorMessage: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 50
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 3000,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('does not parse subagent transcripts unless includeSubagents is enabled', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
const subagentTranscriptPath = path.join(subagentsDir, 'agent-1.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:04.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: '1' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(subagentTranscriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:01.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:11.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 200,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 4000,
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalTokens: 30,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates subagent speed metrics with merged active windows when enabled', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-with-subagents.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 50,
|
||||
output: 100
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'a' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'b' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:15.000Z',
|
||||
type: 'assistant',
|
||||
input: 150,
|
||||
output: 300,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-b.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:20.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:25.000Z',
|
||||
type: 'assistant',
|
||||
input: 25,
|
||||
output: 50,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 20000,
|
||||
inputTokens: 225,
|
||||
outputTokens: 450,
|
||||
totalTokens: 675,
|
||||
requestCount: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('applies window filtering to aggregated subagent speed metrics', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-subagent-windowed.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:04.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'a' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 30,
|
||||
output: 60,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, {
|
||||
includeSubagents: true,
|
||||
windowSeconds: 4
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 4000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 60,
|
||||
totalTokens: 90,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('includes only referenced subagent transcripts from the parent transcript', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-referenced-subagents.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'assistant',
|
||||
input: 20,
|
||||
output: 30
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'referenced-agent' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-referenced-agent.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:06.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:08.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-unrelated-agent.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:06.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:18.000Z',
|
||||
type: 'assistant',
|
||||
input: 500,
|
||||
output: 900,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 7000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 50,
|
||||
totalTokens: 80,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('finds subagents in session-directory layout used by Claude transcripts', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const sessionId = 'session-123';
|
||||
const transcriptPath = path.join(root, `${sessionId}.jsonl`);
|
||||
const subagentsDir = path.join(root, sessionId, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:04.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'layout-agent' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-layout-agent.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:08.000Z',
|
||||
type: 'assistant',
|
||||
input: 15,
|
||||
output: 25,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 7000,
|
||||
inputTokens: 25,
|
||||
outputTokens: 45,
|
||||
totalTokens: 70,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to main transcript metrics when subagent folder cannot be listed', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-discovery-failure.jsonl');
|
||||
const subagentsPath = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'assistant',
|
||||
input: 30,
|
||||
output: 60
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'unreadable' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
// Create a regular file where the subagents directory is expected.
|
||||
fs.writeFileSync(subagentsPath, 'not-a-directory');
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 3000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 60,
|
||||
totalTokens: 90,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed subagent lines without failing', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-malformed-subagent.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:02.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'malformed' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-malformed.jsonl'), [
|
||||
'not-json',
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:07.000Z',
|
||||
type: 'assistant',
|
||||
input: 5,
|
||||
output: 15,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 6000,
|
||||
inputTokens: 15,
|
||||
outputTokens: 35,
|
||||
totalTokens: 50,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to main transcript metrics when subagents directory is missing', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-no-subagents.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'assistant',
|
||||
input: 30,
|
||||
output: 60
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'unreadable' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 3000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 60,
|
||||
totalTokens: 90,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unreadable subagent transcript files without failing', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(true).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-unreadable-subagent.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
const unreadableSubagentPath = path.join(subagentsDir, 'agent-unreadable.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'assistant',
|
||||
input: 30,
|
||||
output: 60
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(unreadableSubagentPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:04.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:06.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 200,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.chmodSync(unreadableSubagentPath, 0o000);
|
||||
const metrics = await (async () => {
|
||||
try {
|
||||
return await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
} finally {
|
||||
fs.chmodSync(unreadableSubagentPath, 0o600);
|
||||
}
|
||||
})();
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 3000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 60,
|
||||
totalTokens: 90,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty speed metrics when transcript path points to an unreadable directory', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'not-a-jsonl-file');
|
||||
|
||||
fs.mkdirSync(transcriptPath);
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
requestCount: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('counts assistant tokens without timestamps while keeping active duration at zero', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-missing-timestamps.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 7,
|
||||
output_tokens: 9
|
||||
}
|
||||
}
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 0,
|
||||
inputTokens: 7,
|
||||
outputTokens: 9,
|
||||
totalTokens: 16,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty speed metrics when transcript is missing', async () => {
|
||||
const metrics = await getSpeedMetrics('/tmp/ccstatusline-jsonl-speed-missing.jsonl');
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
requestCount: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
detectVersion,
|
||||
migrateConfig,
|
||||
needsMigration
|
||||
} from '../migrations';
|
||||
|
||||
describe('migrations', () => {
|
||||
it('detects version for unknown data and versioned objects', () => {
|
||||
expect(detectVersion(null)).toBe(1);
|
||||
expect(detectVersion('invalid')).toBe(1);
|
||||
expect(detectVersion({})).toBe(1);
|
||||
expect(detectVersion({ version: 2 })).toBe(2);
|
||||
});
|
||||
|
||||
it('reports whether migration is needed', () => {
|
||||
expect(needsMigration({ version: 2 }, 3)).toBe(true);
|
||||
expect(needsMigration({ version: 3 }, 3)).toBe(false);
|
||||
expect(needsMigration({}, 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns original value for non-record migration input', () => {
|
||||
expect(migrateConfig('invalid', 3)).toBe('invalid');
|
||||
expect(migrateConfig(123, 3)).toBe(123);
|
||||
});
|
||||
|
||||
it('migrates v1 to v2 by copying known fields and assigning ids', () => {
|
||||
const migrated = migrateConfig({
|
||||
lines: [[
|
||||
{ type: 'model', color: 'cyan' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'git-branch' }
|
||||
]],
|
||||
flexMode: 'full',
|
||||
compactThreshold: 70,
|
||||
colorLevel: 3,
|
||||
defaultSeparator: '|',
|
||||
defaultPadding: ' ',
|
||||
inheritSeparatorColors: true,
|
||||
overrideBackgroundColor: 'black',
|
||||
overrideForegroundColor: 'white',
|
||||
globalBold: true,
|
||||
unknownField: 'ignored'
|
||||
}, 2) as Record<string, unknown>;
|
||||
|
||||
expect(migrated.version).toBe(2);
|
||||
expect(migrated.flexMode).toBe('full');
|
||||
expect(migrated.compactThreshold).toBe(70);
|
||||
expect(migrated.colorLevel).toBe(3);
|
||||
expect(migrated.defaultSeparator).toBe('|');
|
||||
expect(migrated.defaultPadding).toBe(' ');
|
||||
expect(migrated.inheritSeparatorColors).toBe(true);
|
||||
expect(migrated.overrideBackgroundColor).toBe('black');
|
||||
expect(migrated.overrideForegroundColor).toBe('white');
|
||||
expect(migrated.globalBold).toBe(true);
|
||||
expect(migrated.unknownField).toBeUndefined();
|
||||
|
||||
const lines = migrated.lines as Record<string, unknown>[][];
|
||||
const firstLine = lines[0];
|
||||
expect(Array.isArray(firstLine)).toBe(true);
|
||||
expect(firstLine?.map(item => item.type)).toEqual(['model', 'git-branch']);
|
||||
expect(typeof firstLine?.[0]?.id).toBe('string');
|
||||
expect(typeof firstLine?.[1]?.id).toBe('string');
|
||||
|
||||
const updateMessage = migrated.updatemessage as { message?: string; remaining?: number };
|
||||
expect(updateMessage.message).toContain('v2.0.0');
|
||||
expect(updateMessage.remaining).toBe(12);
|
||||
});
|
||||
|
||||
it('applies sequential migrations to reach target version', () => {
|
||||
const migrated = migrateConfig({
|
||||
lines: [[
|
||||
{ type: 'model' }
|
||||
]]
|
||||
}, 3) as Record<string, unknown>;
|
||||
|
||||
expect(migrated.version).toBe(3);
|
||||
const updateMessage = migrated.updatemessage as { message?: string; remaining?: number };
|
||||
expect(updateMessage.message).toContain('v2.0.2');
|
||||
expect(updateMessage.remaining).toBe(12);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getContextConfig } from '../model-context';
|
||||
import {
|
||||
getContextConfig,
|
||||
getModelContextIdentifier
|
||||
} from '../model-context';
|
||||
|
||||
describe('getContextConfig', () => {
|
||||
describe('Status JSON context window size override', () => {
|
||||
@@ -53,6 +56,34 @@ describe('getContextConfig', () => {
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for model IDs with 1M context label', () => {
|
||||
const config = getContextConfig('Opus 4.6 (1M context)');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for model IDs with 1M token context label', () => {
|
||||
const config = getContextConfig('Claude Opus 4.6 - 1M token context');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for model IDs with 1M in parentheses', () => {
|
||||
const config = getContextConfig('Opus 4.6 (1M)');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for model IDs with 1M in square brackets', () => {
|
||||
const config = getContextConfig('Opus 4.5 [1M]');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Models without [1m] suffix', () => {
|
||||
@@ -95,4 +126,26 @@ describe('getContextConfig', () => {
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelContextIdentifier', () => {
|
||||
it('returns string model identifier unchanged', () => {
|
||||
expect(getModelContextIdentifier('claude-sonnet-4-5-20250929[1m]')).toBe('claude-sonnet-4-5-20250929[1m]');
|
||||
});
|
||||
|
||||
it('prefers both id and display name when available', () => {
|
||||
expect(getModelContextIdentifier({
|
||||
id: 'claude-opus-4-6',
|
||||
display_name: 'Opus 4.6 (1M context)'
|
||||
})).toBe('claude-opus-4-6 Opus 4.6 (1M context)');
|
||||
});
|
||||
|
||||
it('returns display name when id is missing', () => {
|
||||
expect(getModelContextIdentifier({ display_name: 'Opus 4.6 (1M context)' })).toBe('Opus 4.6 (1M context)');
|
||||
});
|
||||
|
||||
it('returns undefined when no model value exists', () => {
|
||||
expect(getModelContextIdentifier(undefined)).toBeUndefined();
|
||||
expect(getModelContextIdentifier({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,43 @@ describe('openExternalUrl', () => {
|
||||
expect(mockSpawnSync.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
const result = openExternalUrl('not-a-valid-url');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid URL'
|
||||
});
|
||||
expect(mockSpawnSync.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns command spawn error details', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
mockSpawnSync.mockReturnValue({ error: new Error('spawn failed') });
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'spawn failed'
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves status-based error formatting when signal is present', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
mockSpawnSync.mockReturnValue({
|
||||
status: null,
|
||||
signal: 'SIGTERM'
|
||||
});
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Command exited with status null'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unsupported platform error', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('freebsd');
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { buildEnabledPowerlineSettings } from '../powerline-settings';
|
||||
|
||||
describe('powerline settings helpers', () => {
|
||||
it('enables powerline with default theme and default padding', () => {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: false,
|
||||
theme: undefined
|
||||
}
|
||||
};
|
||||
|
||||
const updated = buildEnabledPowerlineSettings(settings, false);
|
||||
|
||||
expect(updated.powerline.enabled).toBe(true);
|
||||
expect(updated.powerline.theme).toBe('nord-aurora');
|
||||
expect(updated.defaultPadding).toBe(' ');
|
||||
});
|
||||
|
||||
it('preserves non-custom theme when enabling powerline', () => {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: false,
|
||||
theme: 'catppuccin'
|
||||
}
|
||||
};
|
||||
|
||||
const updated = buildEnabledPowerlineSettings(settings, false);
|
||||
expect(updated.powerline.theme).toBe('catppuccin');
|
||||
});
|
||||
|
||||
it('removes manual separators when requested', () => {
|
||||
const line: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'separator' },
|
||||
{ id: '3', type: 'context-length' },
|
||||
{ id: '4', type: 'flex-separator' }
|
||||
];
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [line]
|
||||
};
|
||||
|
||||
const updated = buildEnabledPowerlineSettings(settings, true);
|
||||
expect(updated.lines[0]?.map(item => item.type)).toEqual(['model', 'context-length']);
|
||||
});
|
||||
|
||||
it('keeps manual separators when removal is not requested', () => {
|
||||
const line: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'separator' },
|
||||
{ id: '3', type: 'context-length' }
|
||||
];
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [line]
|
||||
};
|
||||
|
||||
const updated = buildEnabledPowerlineSettings(settings, false);
|
||||
expect(updated.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { getVisibleWidth } from '../ansi';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine
|
||||
} from '../renderer';
|
||||
|
||||
function createSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...overrides,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
...(overrides.powerline ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderLine(
|
||||
widgets: WidgetItem[],
|
||||
settingsOverrides: Partial<Settings>,
|
||||
contextOverrides: Partial<RenderContext> = {}
|
||||
): string {
|
||||
const settings = createSettings(settingsOverrides);
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: 50,
|
||||
...contextOverrides
|
||||
};
|
||||
|
||||
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
const preRenderedWidgets = preRenderedLines[0] ?? [];
|
||||
|
||||
return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
}
|
||||
|
||||
describe('renderer flex width behavior', () => {
|
||||
const longTextWidget: WidgetItem = {
|
||||
id: 'text',
|
||||
type: 'custom-text',
|
||||
customText: 'abcdefghijklmnopqrstuvwxyz1234567890'
|
||||
};
|
||||
|
||||
it('uses full-minus-40 width in normal mode', () => {
|
||||
const line = renderLine([longTextWidget], { flexMode: 'full-minus-40' });
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(10);
|
||||
expect(line.endsWith('...')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses full width in full-until-compact when under threshold', () => {
|
||||
const line = renderLine([longTextWidget], {
|
||||
flexMode: 'full-until-compact',
|
||||
compactThreshold: 60
|
||||
}, { data: { context_window: { used_percentage: 20 } } });
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(longTextWidget.customText?.length);
|
||||
expect(line.endsWith('...')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses compact width in full-until-compact when above threshold', () => {
|
||||
const line = renderLine([longTextWidget], {
|
||||
flexMode: 'full-until-compact',
|
||||
compactThreshold: 60
|
||||
}, { data: { context_window: { used_percentage: 80 } } });
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(10);
|
||||
expect(line.endsWith('...')).toBe(true);
|
||||
});
|
||||
|
||||
it('always uses full preview width in full-until-compact preview mode', () => {
|
||||
const line = renderLine([longTextWidget], {
|
||||
flexMode: 'full-until-compact',
|
||||
compactThreshold: 60
|
||||
}, {
|
||||
isPreview: true,
|
||||
data: { context_window: { used_percentage: 99 } }
|
||||
});
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(longTextWidget.customText?.length);
|
||||
expect(line.endsWith('...')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies the same width behavior in powerline mode', () => {
|
||||
const line = renderLine([{
|
||||
...longTextWidget,
|
||||
backgroundColor: 'bgBlue',
|
||||
color: 'white'
|
||||
}], {
|
||||
flexMode: 'full-minus-40',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(10);
|
||||
expect(line.endsWith('...')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
advanceGlobalSeparatorIndex,
|
||||
countSeparatorSlots
|
||||
} from '../separator-index';
|
||||
|
||||
describe('separator index utils', () => {
|
||||
it('returns zero for empty and single-item lines', () => {
|
||||
expect(countSeparatorSlots([])).toBe(0);
|
||||
|
||||
const single: WidgetItem[] = [{ id: '1', type: 'model' }];
|
||||
expect(countSeparatorSlots(single)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts one separator slot between two non-merged items', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'context-length' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not count separator slots for merged items', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model', merge: true },
|
||||
{ id: '2', type: 'context-length' },
|
||||
{ id: '3', type: 'version' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('treats no-padding merge the same as merged', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model', merge: 'no-padding' },
|
||||
{ id: '2', type: 'context-length' },
|
||||
{ id: '3', type: 'version' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('advances a running global separator index', () => {
|
||||
const firstLine: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'context-length' },
|
||||
{ id: '3', type: 'version' }
|
||||
];
|
||||
const secondLine: WidgetItem[] = [
|
||||
{ id: '4', type: 'git-branch', merge: true },
|
||||
{ id: '5', type: 'git-changes' },
|
||||
{ id: '6', type: 'session-cost' }
|
||||
];
|
||||
|
||||
const afterFirst = advanceGlobalSeparatorIndex(0, firstLine);
|
||||
const afterSecond = advanceGlobalSeparatorIndex(afterFirst, secondLine);
|
||||
|
||||
expect(afterFirst).toBe(2);
|
||||
expect(afterSecond).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getSkillsFilePath,
|
||||
getSkillsMetrics
|
||||
} from '../skills';
|
||||
|
||||
let testHomeDir = '';
|
||||
|
||||
function writeSkillsLog(sessionId: string, lines: string[]): void {
|
||||
const skillsPath = getSkillsFilePath(sessionId);
|
||||
fs.mkdirSync(path.dirname(skillsPath), { recursive: true });
|
||||
fs.writeFileSync(skillsPath, lines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
describe('skills metrics', () => {
|
||||
beforeEach(() => {
|
||||
testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-home-'));
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(testHomeDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (testHomeDir) {
|
||||
fs.rmSync(testHomeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses ~/.cache/ccstatusline/skills path for skill logs', () => {
|
||||
expect(getSkillsFilePath('session-1')).toBe(
|
||||
path.join(testHomeDir, '.cache', 'ccstatusline', 'skills', 'skills-session-1.jsonl')
|
||||
);
|
||||
});
|
||||
|
||||
it('returns total, unique (most-recent-first), and last skill from a valid log', () => {
|
||||
writeSkillsLog('session-1', [
|
||||
JSON.stringify({ skill: 'commit', session_id: 'session-1' }),
|
||||
JSON.stringify({ skill: 'review-pr', session_id: 'session-1' }),
|
||||
JSON.stringify({ skill: 'lint', session_id: 'session-1' }),
|
||||
JSON.stringify({ skill: 'commit', session_id: 'session-1' })
|
||||
]);
|
||||
|
||||
expect(getSkillsMetrics('session-1')).toEqual({
|
||||
totalInvocations: 4,
|
||||
uniqueSkills: ['commit', 'lint', 'review-pr'],
|
||||
lastSkill: 'commit'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { SpeedMetrics } from '../../types/SpeedMetrics';
|
||||
import {
|
||||
calculateInputSpeed,
|
||||
calculateOutputSpeed,
|
||||
calculateTotalSpeed,
|
||||
formatSpeed
|
||||
} from '../speed-metrics';
|
||||
|
||||
function createMetrics(overrides: Partial<SpeedMetrics> = {}): SpeedMetrics {
|
||||
return {
|
||||
totalDurationMs: 10000,
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
requestCount: 5,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('speed metrics calculations', () => {
|
||||
it('calculateOutputSpeed returns null when duration is zero', () => {
|
||||
const result = calculateOutputSpeed(createMetrics({ totalDurationMs: 0 }));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('calculateOutputSpeed computes output tokens per second', () => {
|
||||
const result = calculateOutputSpeed(createMetrics({ outputTokens: 750, totalDurationMs: 15000 }));
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
|
||||
it('calculateInputSpeed returns null when duration is zero', () => {
|
||||
const result = calculateInputSpeed(createMetrics({ totalDurationMs: 0 }));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('calculateInputSpeed computes input tokens per second', () => {
|
||||
const result = calculateInputSpeed(createMetrics({ inputTokens: 1200, totalDurationMs: 6000 }));
|
||||
expect(result).toBe(200);
|
||||
});
|
||||
|
||||
it('calculateTotalSpeed returns null when duration is zero', () => {
|
||||
const result = calculateTotalSpeed(createMetrics({ totalDurationMs: 0 }));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('calculateTotalSpeed computes total tokens per second from totalTokens', () => {
|
||||
const result = calculateTotalSpeed(createMetrics({ totalTokens: 3000, totalDurationMs: 12000 }));
|
||||
expect(result).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSpeed', () => {
|
||||
it('formats null as an em dash placeholder', () => {
|
||||
expect(formatSpeed(null)).toBe('\u2014');
|
||||
});
|
||||
|
||||
it('formats sub-1000 speeds with one decimal place', () => {
|
||||
expect(formatSpeed(42.54)).toBe('42.5 t/s');
|
||||
});
|
||||
|
||||
it('formats exact threshold values in k notation', () => {
|
||||
expect(formatSpeed(1000)).toBe('1.0k t/s');
|
||||
});
|
||||
|
||||
it('formats high speeds in k notation with one decimal place', () => {
|
||||
expect(formatSpeed(1250)).toBe('1.3k t/s');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
DEFAULT_SPEED_WINDOW_SECONDS,
|
||||
clampSpeedWindowSeconds,
|
||||
getWidgetSpeedWindowSeconds,
|
||||
isWidgetSpeedWindowEnabled,
|
||||
withWidgetSpeedWindowSeconds
|
||||
} from '../speed-window';
|
||||
|
||||
function createWidget(metadata?: Record<string, string>): WidgetItem {
|
||||
return {
|
||||
id: 'speed-widget',
|
||||
type: 'total-speed',
|
||||
metadata
|
||||
};
|
||||
}
|
||||
|
||||
describe('speed-window helpers', () => {
|
||||
it('clamps values to the supported range', () => {
|
||||
expect(clampSpeedWindowSeconds(-1)).toBe(0);
|
||||
expect(clampSpeedWindowSeconds(0)).toBe(0);
|
||||
expect(clampSpeedWindowSeconds(90)).toBe(90);
|
||||
expect(clampSpeedWindowSeconds(300)).toBe(120);
|
||||
});
|
||||
|
||||
it('returns default window seconds when metadata is missing or invalid', () => {
|
||||
expect(getWidgetSpeedWindowSeconds(createWidget())).toBe(DEFAULT_SPEED_WINDOW_SECONDS);
|
||||
expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: 'abc' }))).toBe(DEFAULT_SPEED_WINDOW_SECONDS);
|
||||
});
|
||||
|
||||
it('parses and clamps widget metadata window seconds', () => {
|
||||
expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: '45' }))).toBe(45);
|
||||
expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: '999' }))).toBe(120);
|
||||
});
|
||||
|
||||
it('stores clamped window seconds in metadata while preserving existing keys', () => {
|
||||
const updated = withWidgetSpeedWindowSeconds(createWidget({ keep: 'true' }), -3);
|
||||
expect(updated.metadata).toEqual({
|
||||
keep: 'true',
|
||||
windowSeconds: '0'
|
||||
});
|
||||
});
|
||||
|
||||
it('treats zero as disabled and positive values as enabled', () => {
|
||||
expect(isWidgetSpeedWindowEnabled(createWidget())).toBe(false);
|
||||
expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '0' }))).toBe(false);
|
||||
expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '30' }))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { execSync } from 'child_process';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
canDetectTerminalWidth,
|
||||
getTerminalWidth
|
||||
} from '../terminal';
|
||||
|
||||
vi.mock('child_process', () => ({ execSync: vi.fn() }));
|
||||
|
||||
describe('terminal utils', () => {
|
||||
const mockExecSync = execSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockImplementationOnce: (impl: () => never) => void;
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns width from tty probe when available', () => {
|
||||
mockExecSync.mockReturnValueOnce('ttys001\n');
|
||||
mockExecSync.mockReturnValueOnce('120\n');
|
||||
|
||||
expect(getTerminalWidth()).toBe(120);
|
||||
expect(mockExecSync.mock.calls[0]?.[0]).toContain('ps -o tty=');
|
||||
expect(mockExecSync.mock.calls[1]?.[0]).toContain('stty size < /dev/ttys001');
|
||||
});
|
||||
|
||||
it('falls back to tput cols when tty probe fails', () => {
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
|
||||
mockExecSync.mockReturnValueOnce('90\n');
|
||||
|
||||
expect(getTerminalWidth()).toBe(90);
|
||||
expect(mockExecSync.mock.calls[1]?.[0]).toBe('tput cols 2>/dev/null');
|
||||
});
|
||||
|
||||
it('returns null when width probes fail', () => {
|
||||
mockExecSync.mockReturnValueOnce('ttys001\n');
|
||||
mockExecSync.mockReturnValueOnce('not-a-number\n');
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
|
||||
|
||||
expect(getTerminalWidth()).toBeNull();
|
||||
});
|
||||
|
||||
it('detects availability when tty probe succeeds', () => {
|
||||
mockExecSync.mockReturnValueOnce('ttys001\n');
|
||||
mockExecSync.mockReturnValueOnce('80\n');
|
||||
|
||||
expect(canDetectTerminalWidth()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for availability when all probes fail', () => {
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
|
||||
|
||||
expect(canDetectTerminalWidth()).toBe(false);
|
||||
});
|
||||
|
||||
it('disables width detection on Windows', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
||||
|
||||
expect(getTerminalWidth()).toBeNull();
|
||||
expect(canDetectTerminalWidth()).toBe(false);
|
||||
expect(mockExecSync.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -10,94 +10,589 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
interface UsageProbeResult {
|
||||
first: { error?: string };
|
||||
second: { error?: string };
|
||||
first: Record<string, unknown>;
|
||||
second: Record<string, unknown>;
|
||||
lockExists: boolean;
|
||||
cacheExists: boolean;
|
||||
requestCount: number;
|
||||
proxyAgentConfigured: boolean;
|
||||
requestHost: string | null;
|
||||
lockContents: string | null;
|
||||
}
|
||||
|
||||
describe('fetchUsageData error handling', () => {
|
||||
it('preserves root errors within lock window and avoids locking on no-credentials', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-'));
|
||||
const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs');
|
||||
const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url));
|
||||
interface TokenHome {
|
||||
bin: string;
|
||||
claudeConfig: string;
|
||||
home: string;
|
||||
}
|
||||
|
||||
const noCredentialsHome = path.join(tempRoot, 'home-no-credentials');
|
||||
const apiErrorHome = path.join(tempRoot, 'home-api-error');
|
||||
const apiErrorBin = path.join(tempRoot, 'bin-api-error');
|
||||
const apiErrorClaudeConfig = path.join(tempRoot, 'claude-api-error');
|
||||
const securityScript = path.join(apiErrorBin, 'security');
|
||||
const credentialsFile = path.join(apiErrorClaudeConfig, '.credentials.json');
|
||||
interface ProbeOptions {
|
||||
claudeConfigDir?: string;
|
||||
home: string;
|
||||
httpsProxy?: string;
|
||||
lowercaseHttpsProxy?: string;
|
||||
mode?: 'error' | 'status' | 'success' | 'unexpected';
|
||||
nowMs: number;
|
||||
pathDir?: string;
|
||||
responseBody?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
fs.mkdirSync(noCredentialsHome, { recursive: true });
|
||||
fs.mkdirSync(apiErrorHome, { recursive: true });
|
||||
fs.mkdirSync(apiErrorBin, { recursive: true });
|
||||
fs.mkdirSync(apiErrorClaudeConfig, { recursive: true });
|
||||
function createProbeHarness() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-'));
|
||||
const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs');
|
||||
const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url));
|
||||
|
||||
const probeScript = `
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const https = require('https');
|
||||
const mode = process.env.TEST_REQUEST_MODE || 'success';
|
||||
const responseBody = process.env.TEST_RESPONSE_BODY || '';
|
||||
const responseHeaders = JSON.parse(process.env.TEST_RESPONSE_HEADERS_JSON || '{}');
|
||||
const statusCode = Number(process.env.TEST_STATUS_CODE || (mode === 'success' ? '200' : '500'));
|
||||
let requestCount = 0;
|
||||
let proxyAgentConfigured = false;
|
||||
let requestHost = null;
|
||||
|
||||
https.request = (...args) => {
|
||||
requestCount += 1;
|
||||
const callback = args.find(value => typeof value === 'function');
|
||||
const options = args.find(value => value && typeof value === 'object' && !Buffer.isBuffer(value));
|
||||
proxyAgentConfigured = Boolean(options?.agent);
|
||||
requestHost = options?.hostname ?? null;
|
||||
const requestHandlers = new Map();
|
||||
const responseHandlers = new Map();
|
||||
|
||||
const response = {
|
||||
headers: responseHeaders,
|
||||
statusCode,
|
||||
setEncoding() {},
|
||||
on(event, handler) {
|
||||
const existing = responseHandlers.get(event) || [];
|
||||
existing.push(handler);
|
||||
responseHandlers.set(event, existing);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
const request = {
|
||||
on(event, handler) {
|
||||
const existing = requestHandlers.get(event) || [];
|
||||
existing.push(handler);
|
||||
requestHandlers.set(event, existing);
|
||||
return request;
|
||||
},
|
||||
destroy() {},
|
||||
end() {
|
||||
if (mode === 'error') {
|
||||
const handlers = requestHandlers.get('error') || [];
|
||||
for (const handler of handlers) {
|
||||
handler(new Error('mock request failure'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'unexpected') {
|
||||
const handlers = requestHandlers.get('error') || [];
|
||||
for (const handler of handlers) {
|
||||
handler(new Error('unexpected request'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(response);
|
||||
}
|
||||
|
||||
if (responseBody !== '') {
|
||||
const dataHandlers = responseHandlers.get('data') || [];
|
||||
for (const handler of dataHandlers) {
|
||||
handler(responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
const endHandlers = responseHandlers.get('end') || [];
|
||||
for (const handler of endHandlers) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
const { fetchUsageData } = await import(${JSON.stringify(usageModulePath)});
|
||||
|
||||
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock');
|
||||
const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json');
|
||||
const nowMs = Number(process.env.TEST_NOW_MS || Date.now());
|
||||
Date.now = () => nowMs;
|
||||
|
||||
const first = await fetchUsageData();
|
||||
const second = await fetchUsageData();
|
||||
process.stdout.write(JSON.stringify({
|
||||
first,
|
||||
second,
|
||||
lockExists: fs.existsSync(lockFile),
|
||||
cacheExists: fs.existsSync(cacheFile),
|
||||
requestCount,
|
||||
proxyAgentConfigured,
|
||||
requestHost,
|
||||
lockContents: fs.existsSync(lockFile) ? fs.readFileSync(lockFile, 'utf8') : null
|
||||
}));
|
||||
`;
|
||||
|
||||
fs.writeFileSync(probeScriptPath, probeScript);
|
||||
|
||||
function createEmptyHome(name: string): { home: string } {
|
||||
const home = path.join(tempRoot, `home-${name}`);
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
return { home };
|
||||
}
|
||||
|
||||
function createTokenHome(name: string): TokenHome {
|
||||
const home = path.join(tempRoot, `home-${name}`);
|
||||
const bin = path.join(tempRoot, `bin-${name}`);
|
||||
const claudeConfig = path.join(tempRoot, `claude-${name}`);
|
||||
const securityScript = path.join(bin, 'security');
|
||||
const credentialsFile = path.join(claudeConfig, '.credentials.json');
|
||||
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
fs.mkdirSync(claudeConfig, { recursive: true });
|
||||
|
||||
fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
|
||||
fs.chmodSync(securityScript, 0o755);
|
||||
fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));
|
||||
|
||||
const probeScript = `
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { fetchUsageData } from ${JSON.stringify(usageModulePath)};
|
||||
return {
|
||||
bin,
|
||||
claudeConfig,
|
||||
home
|
||||
};
|
||||
}
|
||||
|
||||
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock');
|
||||
const nowMs = Date.now() + (10 * 365 * 24 * 60 * 60 * 1000);
|
||||
Date.now = () => nowMs;
|
||||
function runProbe(options: ProbeOptions): UsageProbeResult {
|
||||
const output = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: options.home,
|
||||
PATH: options.pathDir ?? '/nonexistent',
|
||||
TEST_NOW_MS: String(options.nowMs),
|
||||
TEST_REQUEST_MODE: options.mode ?? 'success',
|
||||
TEST_RESPONSE_BODY: options.responseBody ?? '',
|
||||
TEST_RESPONSE_HEADERS_JSON: JSON.stringify(options.responseHeaders ?? {}),
|
||||
TEST_STATUS_CODE: String(options.statusCode ?? (options.mode === 'success' ? 200 : 500)),
|
||||
...(options.claudeConfigDir ? { CLAUDE_CONFIG_DIR: options.claudeConfigDir } : {}),
|
||||
...(options.httpsProxy !== undefined ? { HTTPS_PROXY: options.httpsProxy } : {}),
|
||||
...(options.lowercaseHttpsProxy !== undefined ? { https_proxy: options.lowercaseHttpsProxy } : {})
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.TEST_FORCE_BAD_EXEC_PATH === '1') {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/nonexistent-runtime',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
return JSON.parse(output) as UsageProbeResult;
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
createEmptyHome,
|
||||
createTokenHome,
|
||||
runProbe
|
||||
};
|
||||
}
|
||||
|
||||
const first = fetchUsageData();
|
||||
const second = fetchUsageData();
|
||||
process.stdout.write(JSON.stringify({
|
||||
first,
|
||||
second,
|
||||
lockExists: fs.existsSync(lockFile)
|
||||
}));
|
||||
`;
|
||||
function parseLockContents(lockContents: string | null): { blockedUntil: number; error?: string } | null {
|
||||
return lockContents ? JSON.parse(lockContents) as { blockedUntil: number; error?: string } : null;
|
||||
}
|
||||
|
||||
describe('fetchUsageData error handling', () => {
|
||||
const nowMs = 2200000000000;
|
||||
const successResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 17,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
const updatedSuccessResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 55,
|
||||
resets_at: '2030-01-02T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 21,
|
||||
resets_at: '2030-01-08T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
const rateLimitedResponseBody = JSON.stringify({
|
||||
error: {
|
||||
message: 'Rate limited. Please try again later.',
|
||||
type: 'rate_limit_error'
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves root errors within a process and keeps existing proxy and cache behavior', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
fs.writeFileSync(probeScriptPath, probeScript);
|
||||
const noCredentialsHome = harness.createEmptyHome('no-credentials');
|
||||
const apiErrorHome = harness.createTokenHome('api-error');
|
||||
const successHome = harness.createTokenHome('success');
|
||||
const invalidProxyHome = harness.createTokenHome('invalid-proxy');
|
||||
const proxyHome = harness.createTokenHome('proxy');
|
||||
const blankProxyHome = harness.createTokenHome('blank-proxy');
|
||||
const lowercaseProxyHome = harness.createTokenHome('lowercase-proxy');
|
||||
|
||||
const noCredentialsOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: noCredentialsHome,
|
||||
PATH: '/nonexistent',
|
||||
TEST_FORCE_BAD_EXEC_PATH: '0'
|
||||
}
|
||||
const noCredentialsResult = harness.runProbe({
|
||||
home: noCredentialsHome.home,
|
||||
mode: 'unexpected',
|
||||
nowMs
|
||||
});
|
||||
|
||||
const noCredentialsResult = JSON.parse(noCredentialsOutput) as UsageProbeResult;
|
||||
expect(noCredentialsResult.first).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.second).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.lockExists).toBe(false);
|
||||
expect(noCredentialsResult.cacheExists).toBe(false);
|
||||
expect(noCredentialsResult.requestCount).toBe(0);
|
||||
expect(noCredentialsResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const apiErrorOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: apiErrorHome,
|
||||
PATH: apiErrorBin,
|
||||
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
|
||||
TEST_FORCE_BAD_EXEC_PATH: '1'
|
||||
}
|
||||
const apiErrorResult = harness.runProbe({
|
||||
claudeConfigDir: apiErrorHome.claudeConfig,
|
||||
home: apiErrorHome.home,
|
||||
mode: 'error',
|
||||
nowMs,
|
||||
pathDir: apiErrorHome.bin
|
||||
});
|
||||
|
||||
const apiErrorResult = JSON.parse(apiErrorOutput) as UsageProbeResult;
|
||||
expect(apiErrorResult.first).toEqual({ error: 'api-error' });
|
||||
expect(apiErrorResult.second).toEqual({ error: 'api-error' });
|
||||
expect(apiErrorResult.cacheExists).toBe(false);
|
||||
expect(apiErrorResult.requestCount).toBe(1);
|
||||
expect(apiErrorResult.proxyAgentConfigured).toBe(false);
|
||||
expect(apiErrorResult.requestHost).toBe('api.anthropic.com');
|
||||
expect(parseLockContents(apiErrorResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(nowMs / 1000) + 30,
|
||||
error: 'timeout'
|
||||
});
|
||||
|
||||
const genericLockResult = harness.runProbe({
|
||||
claudeConfigDir: apiErrorHome.claudeConfig,
|
||||
home: apiErrorHome.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: apiErrorHome.bin
|
||||
});
|
||||
|
||||
expect(genericLockResult.first).toEqual({ error: 'timeout' });
|
||||
expect(genericLockResult.second).toEqual({ error: 'timeout' });
|
||||
expect(genericLockResult.requestCount).toBe(0);
|
||||
|
||||
const successResult = harness.runProbe({
|
||||
claudeConfigDir: successHome.claudeConfig,
|
||||
home: successHome.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: successHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(successResult.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z'
|
||||
});
|
||||
expect(successResult.second).toEqual(successResult.first);
|
||||
expect(successResult.cacheExists).toBe(true);
|
||||
expect(successResult.requestCount).toBe(1);
|
||||
expect(successResult.proxyAgentConfigured).toBe(false);
|
||||
expect(successResult.requestHost).toBe('api.anthropic.com');
|
||||
|
||||
const httpsProxyResult = harness.runProbe({
|
||||
claudeConfigDir: proxyHome.claudeConfig,
|
||||
home: proxyHome.home,
|
||||
httpsProxy: 'http://proxy.local:8080',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: proxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(httpsProxyResult.first).toEqual(successResult.first);
|
||||
expect(httpsProxyResult.second).toEqual(successResult.first);
|
||||
expect(httpsProxyResult.requestCount).toBe(1);
|
||||
expect(httpsProxyResult.proxyAgentConfigured).toBe(true);
|
||||
expect(httpsProxyResult.requestHost).toBe('api.anthropic.com');
|
||||
|
||||
const lowercaseProxyResult = harness.runProbe({
|
||||
claudeConfigDir: lowercaseProxyHome.claudeConfig,
|
||||
home: lowercaseProxyHome.home,
|
||||
lowercaseHttpsProxy: 'http://proxy.local:8080',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: lowercaseProxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(lowercaseProxyResult.first).toEqual(successResult.first);
|
||||
expect(lowercaseProxyResult.second).toEqual(successResult.first);
|
||||
expect(lowercaseProxyResult.requestCount).toBe(1);
|
||||
expect(lowercaseProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const blankProxyResult = harness.runProbe({
|
||||
claudeConfigDir: blankProxyHome.claudeConfig,
|
||||
home: blankProxyHome.home,
|
||||
httpsProxy: ' ',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: blankProxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(blankProxyResult.first).toEqual(successResult.first);
|
||||
expect(blankProxyResult.second).toEqual(successResult.first);
|
||||
expect(blankProxyResult.requestCount).toBe(1);
|
||||
expect(blankProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const invalidProxyResult = harness.runProbe({
|
||||
claudeConfigDir: invalidProxyHome.claudeConfig,
|
||||
home: invalidProxyHome.home,
|
||||
httpsProxy: '://bad-proxy',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: invalidProxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(invalidProxyResult.first).toEqual({ error: 'api-error' });
|
||||
expect(invalidProxyResult.second).toEqual({ error: 'api-error' });
|
||||
expect(invalidProxyResult.requestCount).toBe(0);
|
||||
expect(invalidProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const staleProxyResult = harness.runProbe({
|
||||
claudeConfigDir: successHome.claudeConfig,
|
||||
home: successHome.home,
|
||||
httpsProxy: '://bad-proxy',
|
||||
mode: 'success',
|
||||
nowMs: nowMs + 181000,
|
||||
pathDir: successHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(staleProxyResult.first).toEqual(successResult.first);
|
||||
expect(staleProxyResult.second).toEqual(successResult.first);
|
||||
expect(staleProxyResult.requestCount).toBe(0);
|
||||
expect(staleProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const cachedSuccessResult = harness.runProbe({
|
||||
claudeConfigDir: successHome.claudeConfig,
|
||||
home: successHome.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: successHome.bin
|
||||
});
|
||||
|
||||
expect(cachedSuccessResult.first).toEqual(successResult.first);
|
||||
expect(cachedSuccessResult.second).toEqual(successResult.first);
|
||||
expect(cachedSuccessResult.cacheExists).toBe(true);
|
||||
expect(cachedSuccessResult.requestCount).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses stale cached data during a numeric Retry-After backoff and retries after expiry', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('rate-limited-with-cache');
|
||||
const rateLimitNowMs = nowMs + 31000;
|
||||
const successResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
const rateLimitedResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs: rateLimitNowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': '3600' },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(rateLimitedResult.first).toEqual(successResult.first);
|
||||
expect(rateLimitedResult.second).toEqual(successResult.first);
|
||||
expect(rateLimitedResult.requestCount).toBe(1);
|
||||
expect(parseLockContents(rateLimitedResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(rateLimitNowMs / 1000) + 3600,
|
||||
error: 'rate-limited'
|
||||
});
|
||||
|
||||
const activeBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: rateLimitNowMs + 600000,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(activeBackoffResult.first).toEqual(successResult.first);
|
||||
expect(activeBackoffResult.second).toEqual(successResult.first);
|
||||
expect(activeBackoffResult.requestCount).toBe(0);
|
||||
|
||||
const postBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs: rateLimitNowMs + 3601000,
|
||||
pathDir: home.bin,
|
||||
responseBody: updatedSuccessResponseBody
|
||||
});
|
||||
|
||||
expect(postBackoffResult.first).toEqual({
|
||||
sessionUsage: 55,
|
||||
sessionResetAt: '2030-01-02T00:00:00.000Z',
|
||||
weeklyUsage: 21,
|
||||
weeklyResetAt: '2030-01-08T00:00:00.000Z'
|
||||
});
|
||||
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
|
||||
expect(postBackoffResult.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns rate-limited without stale cache and falls back to the default backoff when Retry-After is invalid', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('rate-limited-no-cache');
|
||||
const firstRateLimitedResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': 'not-a-number' },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(firstRateLimitedResult.first).toEqual({ error: 'rate-limited' });
|
||||
expect(firstRateLimitedResult.second).toEqual({ error: 'rate-limited' });
|
||||
expect(firstRateLimitedResult.requestCount).toBe(1);
|
||||
expect(parseLockContents(firstRateLimitedResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(nowMs / 1000) + 300,
|
||||
error: 'rate-limited'
|
||||
});
|
||||
|
||||
const activeBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: nowMs + 299000,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(activeBackoffResult.first).toEqual({ error: 'rate-limited' });
|
||||
expect(activeBackoffResult.second).toEqual({ error: 'rate-limited' });
|
||||
expect(activeBackoffResult.requestCount).toBe(0);
|
||||
|
||||
const postBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs: nowMs + 301000,
|
||||
pathDir: home.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(postBackoffResult.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z'
|
||||
});
|
||||
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
|
||||
expect(postBackoffResult.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('parses HTTP-date Retry-After headers', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('rate-limited-http-date');
|
||||
const retryAt = new Date(nowMs + 900000).toUTCString();
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': retryAt },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({ error: 'rate-limited' });
|
||||
expect(result.second).toEqual({ error: 'rate-limited' });
|
||||
expect(parseLockContents(result.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor((nowMs + 900000) / 1000),
|
||||
error: 'rate-limited'
|
||||
});
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports the legacy empty lock file fallback', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('legacy-lock');
|
||||
const lockDir = path.join(home.home, '.cache', 'ccstatusline');
|
||||
const lockFile = path.join(lockDir, 'usage.lock');
|
||||
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(lockFile, '');
|
||||
fs.utimesSync(lockFile, new Date(nowMs), new Date(nowMs));
|
||||
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({ error: 'timeout' });
|
||||
expect(result.second).toEqual({ error: 'timeout' });
|
||||
expect(result.requestCount).toBe(0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import * as usage from '../usage';
|
||||
import {
|
||||
hasUsageDependentWidgets,
|
||||
prefetchUsageDataIfNeeded
|
||||
} from '../usage-prefetch';
|
||||
import type { UsageData } from '../usage-types';
|
||||
|
||||
function makeLines(...lineItems: WidgetItem[][]): WidgetItem[][] {
|
||||
return lineItems;
|
||||
}
|
||||
|
||||
describe('usage prefetch', () => {
|
||||
let mockFetchUsageData: {
|
||||
mock: { calls: unknown[][] };
|
||||
mockResolvedValue: (value: UsageData) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockFetchUsageData = vi.spyOn(usage, 'fetchUsageData');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
expected: true,
|
||||
lines: makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'block-timer' }]
|
||||
),
|
||||
name: 'detects when usage widgets are present'
|
||||
},
|
||||
{
|
||||
expected: false,
|
||||
lines: makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'git-branch' }]
|
||||
),
|
||||
name: 'does not detect usage requirement for non-usage widgets'
|
||||
}
|
||||
])('$name', ({ expected, lines }) => {
|
||||
expect(hasUsageDependentWidgets(lines)).toBe(expected);
|
||||
});
|
||||
|
||||
it('fetches usage data once when at least one usage widget exists', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ sessionUsage: 12.3 });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'session-usage' }, { id: '3', type: 'weekly-usage' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines);
|
||||
|
||||
expect(usageData).toEqual({ sessionUsage: 12.3 });
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not fetch usage data when no usage widgets exist', async () => {
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'git-branch' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines);
|
||||
|
||||
expect(usageData).toBeNull();
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getUsageErrorMessage } from '../usage-windows';
|
||||
|
||||
describe('getUsageErrorMessage', () => {
|
||||
it('returns the rate-limited label', () => {
|
||||
expect(getUsageErrorMessage('rate-limited')).toBe('[Rate limited]');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
@@ -7,24 +8,32 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { BlockMetrics } from '../../types';
|
||||
import { getCachedBlockMetrics } from '../jsonl';
|
||||
import * as jsonl from '../jsonl';
|
||||
import {
|
||||
FIVE_HOUR_BLOCK_MS,
|
||||
SEVEN_DAY_WINDOW_MS
|
||||
} from '../usage-types';
|
||||
import {
|
||||
formatUsageDuration,
|
||||
getUsageWindowFromResetAt,
|
||||
resolveUsageWindowWithFallback
|
||||
} from '../usage';
|
||||
|
||||
vi.mock('../jsonl', () => ({ getCachedBlockMetrics: vi.fn() }));
|
||||
|
||||
const mockGetCachedBlockMetrics = getCachedBlockMetrics as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockReturnValue: (value: BlockMetrics | null) => void;
|
||||
};
|
||||
getWeeklyUsageWindowFromResetAt,
|
||||
resolveUsageWindowWithFallback,
|
||||
resolveWeeklyUsageWindow
|
||||
} from '../usage-windows';
|
||||
|
||||
describe('usage window helpers', () => {
|
||||
let mockGetCachedBlockMetrics: {
|
||||
mock: { calls: unknown[][] };
|
||||
mockReturnValue: (value: BlockMetrics | null) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
mockGetCachedBlockMetrics = vi.spyOn(jsonl, 'getCachedBlockMetrics');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('parses usage reset timestamp into elapsed and remaining metrics', () => {
|
||||
@@ -96,10 +105,45 @@ describe('usage window helpers', () => {
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('parses weekly reset timestamp into elapsed and remaining metrics', () => {
|
||||
const nowMs = Date.parse('2026-03-04T20:00:00.000Z');
|
||||
const resetAt = '2026-03-09T20:00:00.000Z';
|
||||
|
||||
const window = getWeeklyUsageWindowFromResetAt(resetAt, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(2 * 24 * 60 * 60 * 1000);
|
||||
expect(window?.remainingMs).toBe(5 * 24 * 60 * 60 * 1000);
|
||||
expect(window?.elapsedPercent).toBeCloseTo((2 / 7) * 100, 5);
|
||||
expect(window?.remainingPercent).toBeCloseTo((5 / 7) * 100, 5);
|
||||
expect(window?.sessionDurationMs).toBe(SEVEN_DAY_WINDOW_MS);
|
||||
});
|
||||
|
||||
it('returns null for missing or invalid weekly reset timestamps', () => {
|
||||
expect(getWeeklyUsageWindowFromResetAt(undefined, Date.now())).toBeNull();
|
||||
expect(getWeeklyUsageWindowFromResetAt('not-a-date', Date.now())).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves weekly window directly from usage data without JSONL fallback', () => {
|
||||
const nowMs = Date.parse('2026-03-04T20:00:00.000Z');
|
||||
const window = resolveWeeklyUsageWindow({ weeklyResetAt: '2026-03-09T20:00:00.000Z' }, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.remainingMs).toBe(5 * 24 * 60 * 60 * 1000);
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('formats duration in block timer style', () => {
|
||||
expect(formatUsageDuration(0)).toBe('0hr');
|
||||
expect(formatUsageDuration(3 * 60 * 60 * 1000)).toBe('3hr');
|
||||
expect(formatUsageDuration(3.5 * 60 * 60 * 1000)).toBe('3hr 30m');
|
||||
expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000)).toBe('4hr 5m');
|
||||
});
|
||||
|
||||
it('formats duration in compact style', () => {
|
||||
expect(formatUsageDuration(0, true)).toBe('0h');
|
||||
expect(formatUsageDuration(3 * 60 * 60 * 1000, true)).toBe('3h');
|
||||
expect(formatUsageDuration(3.5 * 60 * 60 * 1000, true)).toBe('3h30m');
|
||||
expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000, true)).toBe('4h5m');
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
import type { WidgetItemType } from '../../types/Widget';
|
||||
import {
|
||||
filterWidgetCatalog,
|
||||
getAllWidgetTypes,
|
||||
getWidget,
|
||||
getWidgetCatalog,
|
||||
getWidgetCatalogCategories,
|
||||
isKnownWidgetType,
|
||||
type WidgetCatalogEntry
|
||||
} from '../widgets';
|
||||
|
||||
@@ -28,6 +31,13 @@ describe('widget catalog', () => {
|
||||
const model = catalog.find(entry => entry.type === 'model');
|
||||
const separator = catalog.find(entry => entry.type === 'separator');
|
||||
const link = catalog.find(entry => entry.type === 'link');
|
||||
const gitInsertions = catalog.find(entry => entry.type === 'git-insertions');
|
||||
const gitDeletions = catalog.find(entry => entry.type === 'git-deletions');
|
||||
const inputSpeed = catalog.find(entry => entry.type === 'input-speed');
|
||||
const outputSpeed = catalog.find(entry => entry.type === 'output-speed');
|
||||
const totalSpeed = catalog.find(entry => entry.type === 'total-speed');
|
||||
const resetTimer = catalog.find(entry => entry.type === 'reset-timer');
|
||||
const weeklyResetTimer = catalog.find(entry => entry.type === 'weekly-reset-timer');
|
||||
|
||||
expect(model?.displayName).toBe('Model');
|
||||
expect(model?.category).toBe('Core');
|
||||
@@ -35,6 +45,20 @@ describe('widget catalog', () => {
|
||||
expect(separator?.category).toBe('Layout');
|
||||
expect(link?.displayName).toBe('Link');
|
||||
expect(link?.category).toBe('Custom');
|
||||
expect(gitInsertions?.displayName).toBe('Git Insertions');
|
||||
expect(gitInsertions?.category).toBe('Git');
|
||||
expect(gitDeletions?.displayName).toBe('Git Deletions');
|
||||
expect(gitDeletions?.category).toBe('Git');
|
||||
expect(inputSpeed?.displayName).toBe('Input Speed');
|
||||
expect(inputSpeed?.category).toBe('Token Speed');
|
||||
expect(outputSpeed?.displayName).toBe('Output Speed');
|
||||
expect(outputSpeed?.category).toBe('Token Speed');
|
||||
expect(totalSpeed?.displayName).toBe('Total Speed');
|
||||
expect(totalSpeed?.category).toBe('Token Speed');
|
||||
expect(resetTimer?.displayName).toBe('Block Reset Timer');
|
||||
expect(resetTimer?.category).toBe('Usage');
|
||||
expect(weeklyResetTimer?.displayName).toBe('Weekly Reset Timer');
|
||||
expect(weeklyResetTimer?.category).toBe('Usage');
|
||||
});
|
||||
|
||||
it('hides manual separator when default separator is configured', () => {
|
||||
@@ -69,12 +93,32 @@ describe('widget catalog', () => {
|
||||
expect(categories).toContain('Git');
|
||||
expect(categories).toContain('Context');
|
||||
expect(categories).toContain('Tokens');
|
||||
expect(categories).toContain('Token Speed');
|
||||
expect(categories).toContain('Session');
|
||||
expect(categories).toContain('Usage');
|
||||
expect(categories).toContain('Environment');
|
||||
expect(categories).toContain('Custom');
|
||||
expect(categories).toContain('Layout');
|
||||
});
|
||||
|
||||
it('returns runtime widget instances for non-layout widget types', () => {
|
||||
const runtimeTypes = getAllWidgetTypes(baseSettings).filter(
|
||||
type => type !== 'separator' && type !== 'flex-separator'
|
||||
);
|
||||
|
||||
for (const type of runtimeTypes) {
|
||||
const widget = getWidget(type);
|
||||
expect(widget).not.toBeNull();
|
||||
expect(widget?.getDisplayName().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('recognizes known widget and layout types', () => {
|
||||
expect(isKnownWidgetType('model')).toBe(true);
|
||||
expect(isKnownWidgetType('separator')).toBe(true);
|
||||
expect(isKnownWidgetType('flex-separator')).toBe(true);
|
||||
expect(isKnownWidgetType('unknown-widget-type')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('widget catalog filtering', () => {
|
||||
|
||||
+151
-24
@@ -4,6 +4,15 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { ClaudeSettings } from '../types/ClaudeSettings';
|
||||
import {
|
||||
SettingsSchema,
|
||||
type Settings
|
||||
} from '../types/Settings';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
isCustomConfigPath
|
||||
} from './config';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { ClaudeSettings };
|
||||
@@ -19,6 +28,32 @@ export const CCSTATUSLINE_COMMANDS = {
|
||||
SELF_MANAGED: 'ccstatusline'
|
||||
};
|
||||
|
||||
export function isKnownCommand(command: string): boolean {
|
||||
const prefixes = [CCSTATUSLINE_COMMANDS.NPM, CCSTATUSLINE_COMMANDS.BUNX, CCSTATUSLINE_COMMANDS.SELF_MANAGED];
|
||||
return prefixes.some(prefix => command === prefix || command.startsWith(`${prefix} --config `));
|
||||
}
|
||||
|
||||
function needsQuoting(filePath: string): boolean {
|
||||
if (process.platform === 'win32') {
|
||||
// cmd.exe-safe set of characters that require quoting.
|
||||
return /[\s&()<>|^"]/.test(filePath);
|
||||
}
|
||||
|
||||
return /[\s()[\];&#|'"\\$`]/.test(filePath);
|
||||
}
|
||||
|
||||
function quotePathIfNeeded(filePath: string): string {
|
||||
if (!needsQuoting(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return `"${filePath.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return `'${filePath.replace(/'/g, '\'\\\'\'')}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the Claude config directory, checking CLAUDE_CONFIG_DIR environment variable first,
|
||||
* then falling back to the default ~/.claude directory.
|
||||
@@ -58,16 +93,44 @@ export function getClaudeSettingsPath(): string {
|
||||
return path.join(getClaudeConfigDir(), 'settings.json');
|
||||
}
|
||||
|
||||
export async function loadClaudeSettings(): Promise<ClaudeSettings> {
|
||||
/**
|
||||
* Creates a backup of the current Claude settings file.
|
||||
*/
|
||||
async function backupClaudeSettings(suffix = '.bak'): Promise<string | null> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const backupPath = settingsPath + suffix;
|
||||
try {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const content = await readFile(settingsPath, 'utf-8');
|
||||
await writeFile(backupPath, content, 'utf-8');
|
||||
return backupPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to backup Claude settings:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface LoadClaudeSettingsOptions { logErrors?: boolean }
|
||||
|
||||
export async function loadClaudeSettings(options: LoadClaudeSettingsOptions = {}): Promise<ClaudeSettings> {
|
||||
const { logErrors = true } = options;
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
|
||||
// File doesn't exist - return empty object
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(settingsPath, 'utf-8');
|
||||
return JSON.parse(content) as ClaudeSettings;
|
||||
} catch {
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (logErrors) {
|
||||
console.error('Failed to load Claude settings:', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,23 +139,26 @@ export async function saveClaudeSettings(
|
||||
): Promise<void> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
|
||||
// Backup settings before overwriting
|
||||
await backupClaudeSettings();
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function isInstalled(): Promise<boolean> {
|
||||
const settings = await loadClaudeSettings();
|
||||
// Check if command is either npx or bunx version AND padding is 0 (or undefined for new installs)
|
||||
const validCommands = [
|
||||
// Default autoinstalled npm command
|
||||
CCSTATUSLINE_COMMANDS.NPM,
|
||||
// Default autoinstalled bunx command
|
||||
CCSTATUSLINE_COMMANDS.BUNX,
|
||||
// Self managed installation command
|
||||
CCSTATUSLINE_COMMANDS.SELF_MANAGED
|
||||
];
|
||||
let settings: ClaudeSettings;
|
||||
|
||||
try {
|
||||
settings = await loadClaudeSettings({ logErrors: false });
|
||||
} catch {
|
||||
return false; // Can't determine if installed, assume not
|
||||
}
|
||||
const command = settings.statusLine?.command ?? '';
|
||||
|
||||
return (
|
||||
validCommands.includes(settings.statusLine?.command ?? '')
|
||||
isKnownCommand(command)
|
||||
&& (settings.statusLine?.padding === 0
|
||||
|| settings.statusLine?.padding === undefined)
|
||||
);
|
||||
@@ -109,31 +175,92 @@ export function isBunxAvailable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function buildCommand(baseCommand: string): string {
|
||||
if (isCustomConfigPath()) {
|
||||
return `${baseCommand} --config ${quotePathIfNeeded(getConfigPath())}`;
|
||||
}
|
||||
return baseCommand;
|
||||
}
|
||||
|
||||
async function loadSavedSettingsForHookSync(): Promise<Settings | null> {
|
||||
const configPath = getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
const result = SettingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installStatusLine(useBunx = false): Promise<void> {
|
||||
const settings = await loadClaudeSettings();
|
||||
let settings: ClaudeSettings;
|
||||
|
||||
const backupPath = await backupClaudeSettings('.orig');
|
||||
try {
|
||||
settings = await loadClaudeSettings({ logErrors: false });
|
||||
} catch {
|
||||
const fallbackBackupPath = `${getClaudeSettingsPath()}.orig`;
|
||||
console.error(`Warning: Could not read existing Claude settings. A backup exists at ${backupPath ?? fallbackBackupPath}.`);
|
||||
settings = {};
|
||||
}
|
||||
|
||||
const baseCommand = useBunx
|
||||
? CCSTATUSLINE_COMMANDS.BUNX
|
||||
: CCSTATUSLINE_COMMANDS.NPM;
|
||||
|
||||
// Update settings with our status line (confirmation already handled in TUI)
|
||||
settings.statusLine = {
|
||||
type: 'command',
|
||||
command: useBunx
|
||||
? CCSTATUSLINE_COMMANDS.BUNX
|
||||
: CCSTATUSLINE_COMMANDS.NPM,
|
||||
command: buildCommand(baseCommand),
|
||||
padding: 0
|
||||
};
|
||||
|
||||
await saveClaudeSettings(settings);
|
||||
|
||||
const savedSettings = await loadSavedSettingsForHookSync();
|
||||
if (savedSettings) {
|
||||
const { syncWidgetHooks } = await import('./hooks');
|
||||
await syncWidgetHooks(savedSettings);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallStatusLine(): Promise<void> {
|
||||
const settings = await loadClaudeSettings();
|
||||
let settings: ClaudeSettings;
|
||||
|
||||
try {
|
||||
settings = await loadClaudeSettings({ logErrors: false });
|
||||
} catch {
|
||||
console.error('Warning: Could not read existing Claude settings.');
|
||||
return; // if we can't read, return... what are we uninstalling?
|
||||
}
|
||||
|
||||
if (settings.statusLine) {
|
||||
delete settings.statusLine;
|
||||
await saveClaudeSettings(settings);
|
||||
}
|
||||
|
||||
try {
|
||||
const { removeManagedHooks } = await import('./hooks');
|
||||
await removeManagedHooks();
|
||||
} catch {
|
||||
// Ignore hook cleanup failures during uninstall
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExistingStatusLine(): Promise<string | null> {
|
||||
const settings = await loadClaudeSettings();
|
||||
return settings.statusLine?.command ?? null;
|
||||
try {
|
||||
const settings = await loadClaudeSettings({ logErrors: false });
|
||||
return settings.statusLine?.command ?? null;
|
||||
} catch {
|
||||
return null; // Can't read settings, return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Settings } from '../types/Settings';
|
||||
|
||||
export function cloneSettings(settings: Settings): Settings {
|
||||
const cloneFn = globalThis.structuredClone;
|
||||
if (typeof cloneFn === 'function') {
|
||||
return cloneFn(settings);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(settings)) as Settings;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { WidgetItem } from '../types/Widget';
|
||||
|
||||
import { getWidget } from './widgets';
|
||||
|
||||
function isCustomColor(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.startsWith('ansi256:') || value.startsWith('hex:');
|
||||
}
|
||||
|
||||
function isIncompatibleForLevel(value: string | undefined, nextLevel: 0 | 1 | 2 | 3): boolean {
|
||||
if (!isCustomColor(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextLevel === 2) {
|
||||
return Boolean(value?.startsWith('hex:'));
|
||||
}
|
||||
|
||||
if (nextLevel === 3) {
|
||||
return Boolean(value?.startsWith('ansi256:'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetWidgetForegroundToDefault(widget: WidgetItem, nextWidget: WidgetItem): WidgetItem {
|
||||
if (widget.type === 'separator' || widget.type === 'flex-separator') {
|
||||
return nextWidget;
|
||||
}
|
||||
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (!widgetImpl) {
|
||||
return nextWidget;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextWidget,
|
||||
color: widgetImpl.getDefaultColor()
|
||||
};
|
||||
}
|
||||
|
||||
export function hasCustomWidgetColors(lines: WidgetItem[][]): boolean {
|
||||
return lines.some(line => line.some(widget => isCustomColor(widget.color) || isCustomColor(widget.backgroundColor)));
|
||||
}
|
||||
|
||||
export function sanitizeLinesForColorLevel(lines: WidgetItem[][], nextLevel: 0 | 1 | 2 | 3): WidgetItem[][] {
|
||||
return lines.map(line => line.map((widget) => {
|
||||
let nextWidget: WidgetItem = { ...widget };
|
||||
|
||||
if (isIncompatibleForLevel(widget.color, nextLevel)) {
|
||||
nextWidget = resetWidgetForegroundToDefault(widget, nextWidget);
|
||||
}
|
||||
|
||||
if (isIncompatibleForLevel(widget.backgroundColor, nextLevel)) {
|
||||
nextWidget = {
|
||||
...nextWidget,
|
||||
backgroundColor: undefined
|
||||
};
|
||||
}
|
||||
|
||||
return nextWidget;
|
||||
}));
|
||||
}
|
||||
+42
-37
@@ -111,13 +111,13 @@ export function getChalkColor(colorName: string | undefined, colorLevel: 'ansi16
|
||||
}
|
||||
|
||||
switch (colorLevel) {
|
||||
case 'ansi256':
|
||||
return colorEntry.ansi256;
|
||||
case 'truecolor':
|
||||
return colorEntry.truecolor;
|
||||
case 'ansi16':
|
||||
default:
|
||||
return colorEntry.ansi16;
|
||||
case 'ansi256':
|
||||
return colorEntry.ansi256;
|
||||
case 'truecolor':
|
||||
return colorEntry.truecolor;
|
||||
case 'ansi16':
|
||||
default:
|
||||
return colorEntry.ansi16;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,31 +132,36 @@ export function applyColors(
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = text;
|
||||
// Use raw ANSI codes for precise reset sequencing.
|
||||
// This avoids style leakage (for example, bold affecting later widgets).
|
||||
let prefix = '';
|
||||
let suffix = '';
|
||||
|
||||
// Apply background color first
|
||||
// This ensures the background is properly established before other styles
|
||||
if (backgroundColor) {
|
||||
const bgChalk = getChalkColor(backgroundColor, colorLevel, true);
|
||||
if (bgChalk) {
|
||||
result = bgChalk(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply foreground color second
|
||||
if (foregroundColor) {
|
||||
const fgChalk = getChalkColor(foregroundColor, colorLevel, false);
|
||||
if (fgChalk) {
|
||||
result = fgChalk(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply bold last if needed
|
||||
// Apply bold first so it can be reset independently before color resets.
|
||||
if (bold) {
|
||||
result = chalk.bold(result);
|
||||
prefix += '\x1b[1m';
|
||||
suffix = '\x1b[22m' + suffix;
|
||||
}
|
||||
|
||||
return result;
|
||||
// Apply background color
|
||||
if (backgroundColor) {
|
||||
const bgCode = getColorAnsiCode(backgroundColor, colorLevel, true);
|
||||
if (bgCode) {
|
||||
prefix += bgCode;
|
||||
suffix = '\x1b[49m' + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply foreground color
|
||||
if (foregroundColor) {
|
||||
const fgCode = getColorAnsiCode(foregroundColor, colorLevel, false);
|
||||
if (fgCode) {
|
||||
prefix += fgCode;
|
||||
suffix = '\x1b[39m' + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
return prefix + text + suffix;
|
||||
}
|
||||
|
||||
// Get raw ANSI codes for a color without the reset codes
|
||||
@@ -193,15 +198,15 @@ export function getColorAnsiCode(colorName: string | undefined, colorLevel: 'ans
|
||||
// Now that chalk.level is set correctly, we can use chalk to generate the codes
|
||||
let chalkFn: ChalkInstance;
|
||||
switch (colorLevel) {
|
||||
case 'ansi256':
|
||||
chalkFn = colorEntry.ansi256;
|
||||
break;
|
||||
case 'truecolor':
|
||||
chalkFn = colorEntry.truecolor;
|
||||
break;
|
||||
default:
|
||||
chalkFn = colorEntry.ansi16;
|
||||
break;
|
||||
case 'ansi256':
|
||||
chalkFn = colorEntry.ansi256;
|
||||
break;
|
||||
case 'truecolor':
|
||||
chalkFn = colorEntry.truecolor;
|
||||
break;
|
||||
default:
|
||||
chalkFn = colorEntry.ansi16;
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply the color and extract the opening ANSI code
|
||||
|
||||
+72
-29
@@ -19,23 +19,60 @@ const readFile = fs.promises.readFile;
|
||||
const writeFile = fs.promises.writeFile;
|
||||
const mkdir = fs.promises.mkdir;
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccstatusline');
|
||||
const SETTINGS_PATH = path.join(CONFIG_DIR, 'settings.json');
|
||||
const SETTINGS_BACKUP_PATH = path.join(CONFIG_DIR, 'settings.bak');
|
||||
const DEFAULT_SETTINGS_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json');
|
||||
|
||||
async function backupBadSettings(): Promise<void> {
|
||||
let settingsPath = DEFAULT_SETTINGS_PATH;
|
||||
|
||||
export function initConfigPath(filePath?: string): void {
|
||||
settingsPath = filePath ? path.resolve(filePath) : DEFAULT_SETTINGS_PATH;
|
||||
}
|
||||
|
||||
export function getConfigPath(): string {
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
export function isCustomConfigPath(): boolean {
|
||||
return settingsPath !== DEFAULT_SETTINGS_PATH;
|
||||
}
|
||||
|
||||
interface SettingsPaths {
|
||||
configDir: string;
|
||||
settingsPath: string;
|
||||
settingsBackupPath: string;
|
||||
}
|
||||
|
||||
function getSettingsPaths(): SettingsPaths {
|
||||
const configDir = path.dirname(settingsPath);
|
||||
const parsedPath = path.parse(settingsPath);
|
||||
const backupBaseName = parsedPath.ext
|
||||
? `${parsedPath.name}.bak`
|
||||
: `${parsedPath.base}.bak`;
|
||||
|
||||
return {
|
||||
configDir,
|
||||
settingsPath,
|
||||
settingsBackupPath: path.join(configDir, backupBaseName)
|
||||
};
|
||||
}
|
||||
|
||||
async function writeSettingsJson(settings: unknown, paths: SettingsPaths): Promise<void> {
|
||||
await mkdir(paths.configDir, { recursive: true });
|
||||
await writeFile(paths.settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async function backupBadSettings(paths: SettingsPaths): Promise<void> {
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_PATH)) {
|
||||
const content = await readFile(SETTINGS_PATH, 'utf-8');
|
||||
await writeFile(SETTINGS_BACKUP_PATH, content, 'utf-8');
|
||||
console.error(`Bad settings backed up to ${SETTINGS_BACKUP_PATH}`);
|
||||
if (fs.existsSync(paths.settingsPath)) {
|
||||
const content = await readFile(paths.settingsPath, 'utf-8');
|
||||
await writeFile(paths.settingsBackupPath, content, 'utf-8');
|
||||
console.error(`Bad settings backed up to ${paths.settingsBackupPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to backup bad settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDefaultSettings(): Promise<Settings> {
|
||||
async function writeDefaultSettings(paths: SettingsPaths): Promise<Settings> {
|
||||
const defaults = SettingsSchema.parse({});
|
||||
const settingsWithVersion = {
|
||||
...defaults,
|
||||
@@ -43,9 +80,8 @@ async function writeDefaultSettings(): Promise<Settings> {
|
||||
};
|
||||
|
||||
try {
|
||||
await mkdir(CONFIG_DIR, { recursive: true });
|
||||
await writeFile(SETTINGS_PATH, JSON.stringify(settingsWithVersion, null, 2), 'utf-8');
|
||||
console.error(`Default settings written to ${SETTINGS_PATH}`);
|
||||
await writeSettingsJson(settingsWithVersion, paths);
|
||||
console.error(`Default settings written to ${paths.settingsPath}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to write default settings:', error);
|
||||
}
|
||||
@@ -53,13 +89,20 @@ async function writeDefaultSettings(): Promise<Settings> {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
async function recoverWithDefaults(paths: SettingsPaths): Promise<Settings> {
|
||||
await backupBadSettings(paths);
|
||||
return await writeDefaultSettings(paths);
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<Settings> {
|
||||
const paths = getSettingsPaths();
|
||||
|
||||
try {
|
||||
// Check if settings file exists
|
||||
if (!fs.existsSync(SETTINGS_PATH))
|
||||
return await writeDefaultSettings();
|
||||
if (!fs.existsSync(paths.settingsPath))
|
||||
return await writeDefaultSettings(paths);
|
||||
|
||||
const content = await readFile(SETTINGS_PATH, 'utf-8');
|
||||
const content = await readFile(paths.settingsPath, 'utf-8');
|
||||
let rawData: unknown;
|
||||
|
||||
try {
|
||||
@@ -67,8 +110,7 @@ export async function loadSettings(): Promise<Settings> {
|
||||
} catch {
|
||||
// If we can't parse the JSON, backup and write defaults
|
||||
console.error('Failed to parse settings.json, backing up and using defaults');
|
||||
await backupBadSettings();
|
||||
return await writeDefaultSettings();
|
||||
return await recoverWithDefaults(paths);
|
||||
}
|
||||
|
||||
// Check if this is a v1 config (no version field)
|
||||
@@ -78,17 +120,16 @@ export async function loadSettings(): Promise<Settings> {
|
||||
const v1Result = SettingsSchema_v1.safeParse(rawData);
|
||||
if (!v1Result.success) {
|
||||
console.error('Invalid v1 settings format:', v1Result.error);
|
||||
await backupBadSettings();
|
||||
return await writeDefaultSettings();
|
||||
return await recoverWithDefaults(paths);
|
||||
}
|
||||
|
||||
// Migrate v1 to current version and save the migrated settings back to disk
|
||||
rawData = migrateConfig(rawData, CURRENT_VERSION);
|
||||
await writeFile(SETTINGS_PATH, JSON.stringify(rawData, null, 2), 'utf-8');
|
||||
await writeSettingsJson(rawData, paths);
|
||||
} else if (needsMigration(rawData, CURRENT_VERSION)) {
|
||||
// Handle migrations for versioned configs (v2+) and save the migrated settings back to disk
|
||||
rawData = migrateConfig(rawData, CURRENT_VERSION);
|
||||
await writeFile(SETTINGS_PATH, JSON.stringify(rawData, null, 2), 'utf-8');
|
||||
await writeSettingsJson(rawData, paths);
|
||||
}
|
||||
|
||||
// At this point, data should be in current format with version field
|
||||
@@ -96,22 +137,19 @@ export async function loadSettings(): Promise<Settings> {
|
||||
const result = SettingsSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
console.error('Failed to parse settings:', result.error);
|
||||
await backupBadSettings();
|
||||
return await writeDefaultSettings();
|
||||
return await recoverWithDefaults(paths);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
// Any other error, backup and write defaults
|
||||
console.error('Error loading settings:', error);
|
||||
await backupBadSettings();
|
||||
return await writeDefaultSettings();
|
||||
return await recoverWithDefaults(paths);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSettings(settings: Settings): Promise<void> {
|
||||
// Ensure config directory exists
|
||||
await mkdir(CONFIG_DIR, { recursive: true });
|
||||
const paths = getSettingsPaths();
|
||||
|
||||
// Always include version when saving
|
||||
const settingsWithVersion = {
|
||||
@@ -119,6 +157,11 @@ export async function saveSettings(settings: Settings): Promise<void> {
|
||||
version: CURRENT_VERSION
|
||||
};
|
||||
|
||||
// Write settings using Node.js-compatible API
|
||||
await writeFile(SETTINGS_PATH, JSON.stringify(settingsWithVersion, null, 2), 'utf-8');
|
||||
await writeSettingsJson(settingsWithVersion, paths);
|
||||
|
||||
// Sync widget hooks to Claude settings
|
||||
try {
|
||||
const { syncWidgetHooks } = await import('./hooks');
|
||||
await syncWidgetHooks(settings);
|
||||
} catch { /* ignore hook sync failures */ }
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { RenderContext } from '../types';
|
||||
|
||||
import { getContextWindowMetrics } from './context-window';
|
||||
import { getContextConfig } from './model-context';
|
||||
import {
|
||||
getContextConfig,
|
||||
getModelContextIdentifier
|
||||
} from './model-context';
|
||||
|
||||
/**
|
||||
* Calculate context window usage percentage based on model's max tokens
|
||||
@@ -16,9 +19,8 @@ export function calculateContextPercentage(context: RenderContext): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const model = context.data?.model;
|
||||
const modelId = typeof model === 'string' ? model : model?.id;
|
||||
const contextConfig = getContextConfig(modelId, contextWindowMetrics.windowSize);
|
||||
const modelIdentifier = getModelContextIdentifier(context.data?.model);
|
||||
const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize);
|
||||
|
||||
return Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100);
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { execSync } from 'child_process';
|
||||
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
|
||||
export interface GitChangeCounts {
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export function resolveGitCwd(context: RenderContext): string | undefined {
|
||||
const candidates = [
|
||||
context.data?.cwd,
|
||||
@@ -35,4 +40,26 @@ export function runGit(command: string, context: RenderContext): string | null {
|
||||
|
||||
export function isInsideGitWorkTree(context: RenderContext): boolean {
|
||||
return runGit('rev-parse --is-inside-work-tree', context) === 'true';
|
||||
}
|
||||
|
||||
function parseDiffShortStat(stat: string): GitChangeCounts {
|
||||
const insertMatch = /(\d+)\s+insertions?/.exec(stat);
|
||||
const deleteMatch = /(\d+)\s+deletions?/.exec(stat);
|
||||
|
||||
return {
|
||||
insertions: insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0,
|
||||
deletions: deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0
|
||||
};
|
||||
}
|
||||
|
||||
export function getGitChangeCounts(context: RenderContext): GitChangeCounts {
|
||||
const unstagedStat = runGit('diff --shortstat', context) ?? '';
|
||||
const stagedStat = runGit('diff --cached --shortstat', context) ?? '';
|
||||
const unstagedCounts = parseDiffShortStat(unstagedStat);
|
||||
const stagedCounts = parseDiffShortStat(stagedStat);
|
||||
|
||||
return {
|
||||
insertions: unstagedCounts.insertions + stagedCounts.insertions,
|
||||
deletions: unstagedCounts.deletions + stagedCounts.deletions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type { Widget } from '../types/Widget';
|
||||
|
||||
import {
|
||||
getExistingStatusLine,
|
||||
loadClaudeSettings,
|
||||
saveClaudeSettings
|
||||
} from './claude-settings';
|
||||
import { getWidget } from './widgets';
|
||||
|
||||
export interface WidgetHookDef {
|
||||
event: string;
|
||||
matcher?: string;
|
||||
}
|
||||
|
||||
const HOOK_TAG = 'ccstatusline-managed';
|
||||
|
||||
interface HookEntry {
|
||||
_tag?: string;
|
||||
matcher?: string;
|
||||
hooks?: { type: string; command: string }[];
|
||||
}
|
||||
|
||||
function stripManagedHooks(hooks: Record<string, HookEntry[]>): void {
|
||||
for (const event of Object.keys(hooks)) {
|
||||
hooks[event] = (hooks[event] ?? []).filter(entry => entry._tag !== HOOK_TAG);
|
||||
if (hooks[event].length === 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete hooks[event];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveHookDefs(settings: Settings): WidgetHookDef[] {
|
||||
const seen = new Set<string>();
|
||||
const defs: WidgetHookDef[] = [];
|
||||
for (const line of settings.lines) {
|
||||
for (const item of line) {
|
||||
const widget = getWidget(item.type) as (Widget & { getHooks?: () => WidgetHookDef[] }) | null;
|
||||
if (!widget?.getHooks) {
|
||||
continue;
|
||||
}
|
||||
for (const hook of widget.getHooks()) {
|
||||
const key = `${hook.event}:${hook.matcher ?? ''}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
defs.push(hook);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
export async function syncWidgetHooks(settings: Settings): Promise<void> {
|
||||
const needed = getActiveHookDefs(settings);
|
||||
const claudeSettings = await loadClaudeSettings({ logErrors: false });
|
||||
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;
|
||||
|
||||
// Remove all ccstatusline-managed hooks
|
||||
stripManagedHooks(hooks);
|
||||
|
||||
const statusCommand = await getExistingStatusLine();
|
||||
if (!statusCommand) {
|
||||
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
|
||||
await saveClaudeSettings(claudeSettings);
|
||||
return;
|
||||
}
|
||||
const hookCommand = `${statusCommand} --hook`;
|
||||
|
||||
// Add needed hooks
|
||||
for (const def of needed) {
|
||||
const entry: HookEntry = {
|
||||
_tag: HOOK_TAG,
|
||||
hooks: [{ type: 'command', command: hookCommand }]
|
||||
};
|
||||
if (def.matcher) {
|
||||
entry.matcher = def.matcher;
|
||||
}
|
||||
const list = hooks[def.event] ??= [];
|
||||
list.push(entry);
|
||||
}
|
||||
|
||||
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
|
||||
await saveClaudeSettings(claudeSettings);
|
||||
}
|
||||
|
||||
export async function removeManagedHooks(): Promise<void> {
|
||||
const claudeSettings = await loadClaudeSettings({ logErrors: false });
|
||||
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;
|
||||
|
||||
stripManagedHooks(hooks);
|
||||
|
||||
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
|
||||
await saveClaudeSettings(claudeSettings);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import {
|
||||
parseJsonlLine,
|
||||
readJsonlLinesSync
|
||||
} from './jsonl-lines';
|
||||
|
||||
const statSync = fs.statSync;
|
||||
|
||||
/**
|
||||
* Gets block metrics for the current 5-hour block from JSONL files
|
||||
*/
|
||||
export function getBlockMetrics(): BlockMetrics | null {
|
||||
const claudeDir: string | null = getClaudeConfigDir();
|
||||
|
||||
if (!claudeDir)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return findMostRecentBlockStartTime(claudeDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently finds the most recent 5-hour block start time from JSONL files
|
||||
* Uses file modification times as hints to avoid unnecessary reads
|
||||
*/
|
||||
function findMostRecentBlockStartTime(
|
||||
rootDir: string,
|
||||
sessionDurationHours = 5
|
||||
): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
||||
// Step 1: Find all JSONL files with their modification times
|
||||
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
|
||||
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
|
||||
const files = globSync([pattern], {
|
||||
absolute: true, // Ensure we get absolute paths
|
||||
cwd: rootDir // Set working directory to rootDir
|
||||
});
|
||||
|
||||
if (files.length === 0)
|
||||
return null;
|
||||
|
||||
// Step 2: Get file stats and sort by modification time (most recent first)
|
||||
const filesWithStats = files.map((file) => {
|
||||
const stats = statSync(file);
|
||||
return { file, mtime: stats.mtime };
|
||||
});
|
||||
|
||||
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
// Step 3: Progressive lookback - start small and expand if needed
|
||||
// Start with 2x session duration (10 hours), expand to 48 hours if needed
|
||||
const lookbackChunks = [
|
||||
10, // 2x session duration - catches most cases
|
||||
20, // 4x session duration - catches longer sessions
|
||||
48 // Maximum lookback for marathon sessions
|
||||
];
|
||||
|
||||
let timestamps: Date[] = [];
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
let continuousWorkStart: Date | null = null;
|
||||
let foundSessionGap = false;
|
||||
|
||||
for (const lookbackHours of lookbackChunks) {
|
||||
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
|
||||
timestamps = [];
|
||||
|
||||
// Collect timestamps for this lookback period
|
||||
for (const { file, mtime } of filesWithStats) {
|
||||
if (mtime.getTime() < cutoffTime.getTime()) {
|
||||
break;
|
||||
}
|
||||
const fileTimestamps = getAllTimestampsFromFile(file);
|
||||
timestamps.push(...fileTimestamps);
|
||||
}
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
continue; // Try next chunk
|
||||
}
|
||||
|
||||
// Sort timestamps (most recent first)
|
||||
timestamps.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
// Get most recent timestamp (only set once)
|
||||
if (!mostRecentTimestamp && timestamps[0]) {
|
||||
mostRecentTimestamp = timestamps[0];
|
||||
|
||||
// Check if the most recent activity is within the current session period
|
||||
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
|
||||
if (timeSinceLastActivity > sessionDurationMs) {
|
||||
// No activity within the current session period
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for a session gap in this chunk
|
||||
continuousWorkStart = mostRecentTimestamp;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const currentTimestamp = timestamps[i];
|
||||
const previousTimestamp = timestamps[i - 1];
|
||||
|
||||
if (!currentTimestamp || !previousTimestamp)
|
||||
continue;
|
||||
|
||||
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
|
||||
|
||||
if (gap >= sessionDurationMs) {
|
||||
// Found a true session boundary
|
||||
foundSessionGap = true;
|
||||
break;
|
||||
}
|
||||
|
||||
continuousWorkStart = currentTimestamp;
|
||||
}
|
||||
|
||||
// If we found a gap, we're done
|
||||
if (foundSessionGap) {
|
||||
break;
|
||||
}
|
||||
|
||||
// If this was our last chunk, use what we have
|
||||
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mostRecentTimestamp || !continuousWorkStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build actual blocks from timestamps going forward
|
||||
const blocks: { start: Date; end: Date }[] = [];
|
||||
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
let currentBlockStart: Date | null = null;
|
||||
let currentBlockEnd: Date | null = null;
|
||||
|
||||
for (const timestamp of sortedTimestamps) {
|
||||
if (timestamp.getTime() < continuousWorkStart.getTime())
|
||||
continue;
|
||||
|
||||
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
|
||||
// Start new block
|
||||
currentBlockStart = floorToHour(timestamp);
|
||||
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
|
||||
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
|
||||
}
|
||||
}
|
||||
|
||||
// Find current block
|
||||
for (const block of blocks) {
|
||||
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
|
||||
// Verify we have activity in this block
|
||||
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
|
||||
&& t.getTime() <= block.end.getTime()
|
||||
);
|
||||
|
||||
if (hasActivity) {
|
||||
return {
|
||||
startTime: block.start,
|
||||
lastActivity: mostRecentTimestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all timestamps from a JSONL file
|
||||
*/
|
||||
function getAllTimestampsFromFile(filePath: string): Date[] {
|
||||
const timestamps: Date[] = [];
|
||||
try {
|
||||
const lines = readJsonlLinesSync(filePath);
|
||||
|
||||
for (const line of lines) {
|
||||
const json = parseJsonlLine(line) as {
|
||||
timestamp?: string;
|
||||
isSidechain?: boolean;
|
||||
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
|
||||
} | null;
|
||||
if (!json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only treat entries with real token usage as block activity
|
||||
const usage = json.message?.usage;
|
||||
if (!usage)
|
||||
continue;
|
||||
|
||||
const hasInputTokens = typeof usage.input_tokens === 'number';
|
||||
const hasOutputTokens = typeof usage.output_tokens === 'number';
|
||||
if (!hasInputTokens || !hasOutputTokens)
|
||||
continue;
|
||||
|
||||
if (json.isSidechain === true)
|
||||
continue;
|
||||
|
||||
const timestamp = json.timestamp;
|
||||
if (typeof timestamp !== 'string')
|
||||
continue;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isNaN(date.getTime()))
|
||||
timestamps.push(date);
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floors a timestamp to the beginning of the hour (matching existing logic)
|
||||
*/
|
||||
function floorToHour(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as fs from 'fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import { getBlockMetrics } from './jsonl-blocks';
|
||||
|
||||
const readFileSync = fs.readFileSync;
|
||||
const writeFileSync = fs.writeFileSync;
|
||||
const mkdirSync = fs.mkdirSync;
|
||||
const existsSync = fs.existsSync;
|
||||
|
||||
interface BlockCache {
|
||||
startTime: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
function normalizeConfigDir(configDir: string): string {
|
||||
return path.resolve(configDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the block cache file for a specific Claude config directory
|
||||
*/
|
||||
export function getBlockCachePath(configDir = getClaudeConfigDir()): string {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const configHash = createHash('sha256')
|
||||
.update(normalizedConfigDir)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
'.cache',
|
||||
'ccstatusline',
|
||||
`block-cache-${configHash}.json`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the block cache file and returns the cached start time
|
||||
* Returns null if cache doesn't exist or is invalid
|
||||
*/
|
||||
export function readBlockCache(expectedConfigDir?: string): Date | null {
|
||||
try {
|
||||
const normalizedExpectedConfigDir = expectedConfigDir !== undefined
|
||||
? normalizeConfigDir(expectedConfigDir)
|
||||
: undefined;
|
||||
const cachePath = getBlockCachePath(normalizedExpectedConfigDir);
|
||||
if (!existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
const content = readFileSync(cachePath, 'utf-8');
|
||||
const cache = JSON.parse(content) as BlockCache;
|
||||
if (typeof cache.startTime !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (normalizedExpectedConfigDir !== undefined) {
|
||||
if (typeof cache.configDir !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (cache.configDir !== normalizedExpectedConfigDir) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const date = new Date(cache.startTime);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the block start time to the cache file
|
||||
* Creates the cache directory if it doesn't exist
|
||||
*/
|
||||
export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void {
|
||||
try {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const cachePath = getBlockCachePath(normalizedConfigDir);
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
const cache: BlockCache = {
|
||||
startTime: startTime.toISOString(),
|
||||
configDir: normalizedConfigDir
|
||||
};
|
||||
writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - caching is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block metrics with caching support
|
||||
* Returns cached result if still valid, otherwise recalculates
|
||||
*/
|
||||
export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const activeConfigDir = getClaudeConfigDir();
|
||||
|
||||
// Check cache first
|
||||
const cachedStartTime = readBlockCache(activeConfigDir);
|
||||
if (cachedStartTime) {
|
||||
const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs);
|
||||
if (now.getTime() <= blockEndTime.getTime()) {
|
||||
// Cache is valid - return cached result
|
||||
return {
|
||||
startTime: cachedStartTime,
|
||||
lastActivity: now // We don't cache lastActivity, use current time
|
||||
};
|
||||
}
|
||||
// Cache expired - need to recalculate
|
||||
}
|
||||
|
||||
// Cache miss or expired - run full calculation
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
// Write to cache if we found a valid block
|
||||
if (metrics) {
|
||||
writeBlockCache(metrics.startTime, activeConfigDir);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as fs from 'fs';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const readFile = promisify(fs.readFile);
|
||||
const readFileSync = fs.readFileSync;
|
||||
|
||||
function splitJsonlContent(content: string): string[] {
|
||||
return content.trim().split('\n').filter(line => line.length > 0);
|
||||
}
|
||||
|
||||
export async function readJsonlLines(filePath: string): Promise<string[]> {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
return splitJsonlContent(content);
|
||||
}
|
||||
|
||||
export function readJsonlLinesSync(filePath: string): string[] {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
return splitJsonlContent(content);
|
||||
}
|
||||
|
||||
export function parseJsonlLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
SpeedMetrics,
|
||||
TokenMetrics,
|
||||
TranscriptLine
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
parseJsonlLine,
|
||||
readJsonlLines
|
||||
} from './jsonl-lines';
|
||||
|
||||
export interface SpeedMetricsOptions {
|
||||
includeSubagents?: boolean;
|
||||
windowSeconds?: number;
|
||||
}
|
||||
|
||||
interface SpeedMetricsCollectionOptions {
|
||||
includeSubagents?: boolean;
|
||||
windowSeconds?: number[];
|
||||
}
|
||||
|
||||
export interface SpeedMetricsCollection {
|
||||
sessionAverage: SpeedMetrics;
|
||||
windowed: Record<string, SpeedMetrics>;
|
||||
}
|
||||
|
||||
interface SpeedInterval {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
interface SpeedRequest {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
assistantTimestampMs: number | null;
|
||||
interval: SpeedInterval | null;
|
||||
}
|
||||
|
||||
interface CollectedSpeedMetrics {
|
||||
requests: SpeedRequest[];
|
||||
latestTimestampMs: number | null;
|
||||
}
|
||||
|
||||
function collectAgentIds(value: unknown, agentIds: Set<string>) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectAgentIds(item, agentIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
if (key === 'agentId' && typeof nestedValue === 'string' && nestedValue.trim() !== '') {
|
||||
agentIds.add(nestedValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
collectAgentIds(nestedValue, agentIds);
|
||||
}
|
||||
}
|
||||
|
||||
function getReferencedSubagentIds(lines: string[]): Set<string> {
|
||||
const agentIds = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line);
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
collectAgentIds(data, agentIds);
|
||||
}
|
||||
|
||||
return agentIds;
|
||||
}
|
||||
|
||||
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
|
||||
try {
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = await readJsonlLines(transcriptPath);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let firstTimestamp: Date | null = null;
|
||||
let lastTimestamp: Date | null = null;
|
||||
|
||||
// Find first valid timestamp
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line) as { timestamp?: string } | null;
|
||||
if (data?.timestamp) {
|
||||
firstTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find last valid timestamp (iterate backwards)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = parseJsonlLine(line) as { timestamp?: string } | null;
|
||||
if (data?.timestamp) {
|
||||
lastTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstTimestamp || !lastTimestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate duration in milliseconds
|
||||
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
|
||||
|
||||
// Convert to minutes
|
||||
const totalMinutes = Math.floor(durationMs / (1000 * 60));
|
||||
|
||||
if (totalMinutes < 1) {
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}m`;
|
||||
} else if (minutes === 0) {
|
||||
return `${hours}hr`;
|
||||
} else {
|
||||
return `${hours}hr ${minutes}m`;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
|
||||
try {
|
||||
// Use Node.js-compatible file reading
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
|
||||
const lines = await readJsonlLines(transcriptPath);
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
let contextLength = 0;
|
||||
|
||||
// Parse each line and sum up token usage for totals
|
||||
let mostRecentMainChainEntry: TranscriptLine | null = null;
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line) as TranscriptLine | null;
|
||||
if (data?.message?.usage) {
|
||||
inputTokens += data.message.usage.input_tokens || 0;
|
||||
outputTokens += data.message.usage.output_tokens || 0;
|
||||
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
|
||||
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
|
||||
|
||||
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
|
||||
// Also skip API error messages (synthetic messages with 0 tokens)
|
||||
if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) {
|
||||
const entryTime = new Date(data.timestamp);
|
||||
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
|
||||
mostRecentTimestamp = entryTime;
|
||||
mostRecentMainChainEntry = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate context length from the most recent main chain message
|
||||
if (mostRecentMainChainEntry?.message?.usage) {
|
||||
const usage = mostRecentMainChainEntry.message.usage;
|
||||
contextLength = (usage.input_tokens || 0)
|
||||
+ (usage.cache_read_input_tokens ?? 0)
|
||||
+ (usage.cache_creation_input_tokens ?? 0);
|
||||
}
|
||||
|
||||
const totalTokens = inputTokens + outputTokens + cachedTokens;
|
||||
|
||||
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
|
||||
} catch {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = new Date(value);
|
||||
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
|
||||
}
|
||||
|
||||
function mergeIntervals(intervals: SpeedInterval[]): SpeedInterval[] {
|
||||
if (intervals.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sorted = intervals
|
||||
.slice()
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
const first = sorted[0];
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
const merged: SpeedInterval[] = [{ ...first }];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const current = sorted[i];
|
||||
const last = merged[merged.length - 1];
|
||||
if (!current || !last) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.startMs <= last.endMs) {
|
||||
last.endMs = Math.max(last.endMs, current.endMs);
|
||||
} else {
|
||||
merged.push({ ...current });
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function getIntervalsDurationMs(intervals: SpeedInterval[]): number {
|
||||
return intervals.reduce((total, interval) => total + (interval.endMs - interval.startMs), 0);
|
||||
}
|
||||
|
||||
function createEmptySpeedMetrics(): SpeedMetrics {
|
||||
return {
|
||||
totalDurationMs: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
requestCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWindowSeconds(value: number | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = Math.trunc(value);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function collectSpeedMetricsFromLines(lines: string[], ignoreSidechain: boolean): CollectedSpeedMetrics {
|
||||
const requests: SpeedRequest[] = [];
|
||||
|
||||
let lastUserTimestamp: Date | null = null;
|
||||
let latestTimestampMs: number | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line) as TranscriptLine | null;
|
||||
if (!data || data.isApiErrorMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoreSidechain && data.isSidechain === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryTimestamp = parseTimestamp(data.timestamp);
|
||||
if (entryTimestamp) {
|
||||
const entryTimestampMs = entryTimestamp.getTime();
|
||||
if (latestTimestampMs === null || entryTimestampMs > latestTimestampMs) {
|
||||
latestTimestampMs = entryTimestampMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'user' && entryTimestamp) {
|
||||
lastUserTimestamp = entryTimestamp;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === 'assistant' && data.message?.usage) {
|
||||
const inputTokens = data.message.usage.input_tokens || 0;
|
||||
const outputTokens = data.message.usage.output_tokens || 0;
|
||||
let interval: SpeedInterval | null = null;
|
||||
if (entryTimestamp && lastUserTimestamp) {
|
||||
const startMs = lastUserTimestamp.getTime();
|
||||
const endMs = entryTimestamp.getTime();
|
||||
if (endMs > startMs) {
|
||||
interval = { startMs, endMs };
|
||||
}
|
||||
}
|
||||
|
||||
requests.push({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
assistantTimestampMs: entryTimestamp ? entryTimestamp.getTime() : null,
|
||||
interval
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requests,
|
||||
latestTimestampMs
|
||||
};
|
||||
}
|
||||
|
||||
function mergeCollectedSpeedMetrics(parts: CollectedSpeedMetrics[]): CollectedSpeedMetrics {
|
||||
const requests: SpeedRequest[] = [];
|
||||
let latestTimestampMs: number | null = null;
|
||||
|
||||
for (const part of parts) {
|
||||
requests.push(...part.requests);
|
||||
|
||||
if (part.latestTimestampMs !== null && (latestTimestampMs === null || part.latestTimestampMs > latestTimestampMs)) {
|
||||
latestTimestampMs = part.latestTimestampMs;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requests,
|
||||
latestTimestampMs
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpeedMetrics(
|
||||
collected: CollectedSpeedMetrics,
|
||||
windowSeconds?: number
|
||||
): SpeedMetrics {
|
||||
const normalizedWindowSeconds = normalizeWindowSeconds(windowSeconds);
|
||||
if (normalizedWindowSeconds !== null && collected.latestTimestampMs === null) {
|
||||
return createEmptySpeedMetrics();
|
||||
}
|
||||
|
||||
const windowEndMs = normalizedWindowSeconds !== null && collected.latestTimestampMs !== null
|
||||
? collected.latestTimestampMs
|
||||
: null;
|
||||
const windowStartMs = normalizedWindowSeconds !== null && windowEndMs !== null
|
||||
? windowEndMs - (normalizedWindowSeconds * 1000)
|
||||
: null;
|
||||
|
||||
const selectedRequests = normalizedWindowSeconds !== null && windowStartMs !== null && windowEndMs !== null
|
||||
? collected.requests.filter(request => request.assistantTimestampMs !== null
|
||||
&& request.assistantTimestampMs >= windowStartMs
|
||||
&& request.assistantTimestampMs <= windowEndMs
|
||||
)
|
||||
: collected.requests;
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
const intervals: SpeedInterval[] = [];
|
||||
|
||||
for (const request of selectedRequests) {
|
||||
inputTokens += request.inputTokens;
|
||||
outputTokens += request.outputTokens;
|
||||
|
||||
if (!request.interval) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (windowStartMs === null || windowEndMs === null) {
|
||||
intervals.push(request.interval);
|
||||
continue;
|
||||
}
|
||||
|
||||
const clippedStartMs = Math.max(request.interval.startMs, windowStartMs);
|
||||
const clippedEndMs = Math.min(request.interval.endMs, windowEndMs);
|
||||
if (clippedEndMs > clippedStartMs) {
|
||||
intervals.push({
|
||||
startMs: clippedStartMs,
|
||||
endMs: clippedEndMs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mergedIntervals = mergeIntervals(intervals);
|
||||
const totalDurationMs = getIntervalsDurationMs(mergedIntervals);
|
||||
|
||||
return {
|
||||
totalDurationMs,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
requestCount: selectedRequests.length
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyWindowedMetrics(windowSeconds: number[]): Record<string, SpeedMetrics> {
|
||||
const windowed: Record<string, SpeedMetrics> = {};
|
||||
for (const window of windowSeconds) {
|
||||
windowed[window.toString()] = createEmptySpeedMetrics();
|
||||
}
|
||||
return windowed;
|
||||
}
|
||||
|
||||
function getSubagentTranscriptPaths(transcriptPath: string, referencedAgentIds: Set<string>): string[] {
|
||||
if (referencedAgentIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const transcriptDir = path.dirname(transcriptPath);
|
||||
const transcriptStem = path.parse(transcriptPath).name;
|
||||
const candidateDirs = [
|
||||
path.join(transcriptDir, 'subagents'),
|
||||
path.join(transcriptDir, transcriptStem, 'subagents')
|
||||
];
|
||||
const seenPaths = new Set<string>();
|
||||
const matchedPaths: string[] = [];
|
||||
|
||||
for (const subagentsDir of candidateDirs) {
|
||||
if (!fs.existsSync(subagentsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dirEntries = fs.readdirSync(subagentsDir, { withFileTypes: true });
|
||||
for (const entry of dirEntries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = /^agent-(.+)\.jsonl$/.exec(entry.name);
|
||||
if (!match?.[1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!referencedAgentIds.has(match[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(subagentsDir, entry.name);
|
||||
if (seenPaths.has(fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenPaths.add(fullPath);
|
||||
matchedPaths.push(fullPath);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return matchedPaths;
|
||||
}
|
||||
|
||||
export async function getSpeedMetricsCollection(
|
||||
transcriptPath: string,
|
||||
options: SpeedMetricsCollectionOptions = {}
|
||||
): Promise<SpeedMetricsCollection> {
|
||||
const normalizedWindows = Array.from(
|
||||
new Set(
|
||||
(options.windowSeconds ?? [])
|
||||
.map(window => normalizeWindowSeconds(window))
|
||||
.filter((window): window is number => window !== null)
|
||||
)
|
||||
);
|
||||
const emptyWindowedMetrics = buildEmptyWindowedMetrics(normalizedWindows);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return {
|
||||
sessionAverage: createEmptySpeedMetrics(),
|
||||
windowed: emptyWindowedMetrics
|
||||
};
|
||||
}
|
||||
|
||||
const mainLines = await readJsonlLines(transcriptPath);
|
||||
const allCollected: CollectedSpeedMetrics[] = [
|
||||
collectSpeedMetricsFromLines(mainLines, true)
|
||||
];
|
||||
|
||||
if (options.includeSubagents === true) {
|
||||
const referencedSubagentIds = getReferencedSubagentIds(mainLines);
|
||||
const subagentPaths = getSubagentTranscriptPaths(transcriptPath, referencedSubagentIds);
|
||||
const subagentMetricsResults = await Promise.all(subagentPaths.map(async (subagentPath) => {
|
||||
try {
|
||||
const subagentLines = await readJsonlLines(subagentPath);
|
||||
return collectSpeedMetricsFromLines(subagentLines, false);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
for (const subagentMetrics of subagentMetricsResults) {
|
||||
if (!subagentMetrics) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allCollected.push(subagentMetrics);
|
||||
}
|
||||
}
|
||||
|
||||
const combined = mergeCollectedSpeedMetrics(allCollected);
|
||||
const windowed: Record<string, SpeedMetrics> = {};
|
||||
for (const window of normalizedWindows) {
|
||||
windowed[window.toString()] = buildSpeedMetrics(combined, window);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionAverage: buildSpeedMetrics(combined),
|
||||
windowed
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
sessionAverage: createEmptySpeedMetrics(),
|
||||
windowed: emptyWindowedMetrics
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSpeedMetrics(
|
||||
transcriptPath: string,
|
||||
options: SpeedMetricsOptions = {}
|
||||
): Promise<SpeedMetrics> {
|
||||
const requestedWindow = normalizeWindowSeconds(options.windowSeconds);
|
||||
const metricsCollection = await getSpeedMetricsCollection(transcriptPath, {
|
||||
includeSubagents: options.includeSubagents,
|
||||
windowSeconds: requestedWindow ? [requestedWindow] : []
|
||||
});
|
||||
|
||||
if (requestedWindow === null) {
|
||||
return metricsCollection.sessionAverage;
|
||||
}
|
||||
|
||||
return metricsCollection.windowed[requestedWindow.toString()] ?? createEmptySpeedMetrics();
|
||||
}
|
||||
+13
-496
@@ -1,496 +1,13 @@
|
||||
import * as fs from 'fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import type {
|
||||
BlockMetrics,
|
||||
TokenMetrics,
|
||||
TranscriptLine
|
||||
} from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
|
||||
// Ensure fs.promises compatibility for older Node versions
|
||||
const readFile = promisify(fs.readFile);
|
||||
const readFileSync = fs.readFileSync;
|
||||
const statSync = fs.statSync;
|
||||
const writeFileSync = fs.writeFileSync;
|
||||
const mkdirSync = fs.mkdirSync;
|
||||
const existsSync = fs.existsSync;
|
||||
|
||||
// --- Block Cache Functions ---
|
||||
|
||||
interface BlockCache {
|
||||
startTime: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
function normalizeConfigDir(configDir: string): string {
|
||||
return path.resolve(configDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the block cache file for a specific Claude config directory
|
||||
*/
|
||||
export function getBlockCachePath(configDir = getClaudeConfigDir()): string {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const configHash = createHash('sha256')
|
||||
.update(normalizedConfigDir)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
'.cache',
|
||||
'ccstatusline',
|
||||
`block-cache-${configHash}.json`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the block cache file and returns the cached start time
|
||||
* Returns null if cache doesn't exist or is invalid
|
||||
*/
|
||||
export function readBlockCache(expectedConfigDir?: string): Date | null {
|
||||
try {
|
||||
const normalizedExpectedConfigDir = expectedConfigDir !== undefined
|
||||
? normalizeConfigDir(expectedConfigDir)
|
||||
: undefined;
|
||||
const cachePath = getBlockCachePath(normalizedExpectedConfigDir);
|
||||
if (!existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
const content = readFileSync(cachePath, 'utf-8');
|
||||
const cache = JSON.parse(content) as BlockCache;
|
||||
if (typeof cache.startTime !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (normalizedExpectedConfigDir !== undefined) {
|
||||
if (typeof cache.configDir !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (cache.configDir !== normalizedExpectedConfigDir) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const date = new Date(cache.startTime);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the block start time to the cache file
|
||||
* Creates the cache directory if it doesn't exist
|
||||
*/
|
||||
export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void {
|
||||
try {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const cachePath = getBlockCachePath(normalizedConfigDir);
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
const cache: BlockCache = {
|
||||
startTime: startTime.toISOString(),
|
||||
configDir: normalizedConfigDir
|
||||
};
|
||||
writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - caching is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block metrics with caching support
|
||||
* Returns cached result if still valid, otherwise recalculates
|
||||
*/
|
||||
export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const activeConfigDir = getClaudeConfigDir();
|
||||
|
||||
// Check cache first
|
||||
const cachedStartTime = readBlockCache(activeConfigDir);
|
||||
if (cachedStartTime) {
|
||||
const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs);
|
||||
if (now.getTime() <= blockEndTime.getTime()) {
|
||||
// Cache is valid - return cached result
|
||||
return {
|
||||
startTime: cachedStartTime,
|
||||
lastActivity: now // We don't cache lastActivity, use current time
|
||||
};
|
||||
}
|
||||
// Cache expired - need to recalculate
|
||||
}
|
||||
|
||||
// Cache miss or expired - run full calculation
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
// Write to cache if we found a valid block
|
||||
if (metrics) {
|
||||
writeBlockCache(metrics.startTime, activeConfigDir);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
|
||||
try {
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await readFile(transcriptPath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter((line: string) => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let firstTimestamp: Date | null = null;
|
||||
let lastTimestamp: Date | null = null;
|
||||
|
||||
// Find first valid timestamp
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line) as { timestamp?: string };
|
||||
if (data.timestamp) {
|
||||
firstTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
// Find last valid timestamp (iterate backwards)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const data = JSON.parse(lines[i] ?? '') as { timestamp?: string };
|
||||
if (data.timestamp) {
|
||||
lastTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstTimestamp || !lastTimestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate duration in milliseconds
|
||||
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
|
||||
|
||||
// Convert to minutes
|
||||
const totalMinutes = Math.floor(durationMs / (1000 * 60));
|
||||
|
||||
if (totalMinutes < 1) {
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}m`;
|
||||
} else if (minutes === 0) {
|
||||
return `${hours}hr`;
|
||||
} else {
|
||||
return `${hours}hr ${minutes}m`;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
|
||||
try {
|
||||
// Use Node.js-compatible file reading
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
|
||||
const content = await readFile(transcriptPath, 'utf-8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
let contextLength = 0;
|
||||
|
||||
// Parse each line and sum up token usage for totals
|
||||
let mostRecentMainChainEntry: TranscriptLine | null = null;
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line) as TranscriptLine;
|
||||
if (data.message?.usage) {
|
||||
inputTokens += data.message.usage.input_tokens || 0;
|
||||
outputTokens += data.message.usage.output_tokens || 0;
|
||||
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
|
||||
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
|
||||
|
||||
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
|
||||
// Also skip API error messages (synthetic messages with 0 tokens)
|
||||
if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) {
|
||||
const entryTime = new Date(data.timestamp);
|
||||
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
|
||||
mostRecentTimestamp = entryTime;
|
||||
mostRecentMainChainEntry = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate context length from the most recent main chain message
|
||||
if (mostRecentMainChainEntry?.message?.usage) {
|
||||
const usage = mostRecentMainChainEntry.message.usage;
|
||||
contextLength = (usage.input_tokens || 0)
|
||||
+ (usage.cache_read_input_tokens ?? 0)
|
||||
+ (usage.cache_creation_input_tokens ?? 0);
|
||||
}
|
||||
|
||||
const totalTokens = inputTokens + outputTokens + cachedTokens;
|
||||
|
||||
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
|
||||
} catch {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block metrics for the current 5-hour block from JSONL files
|
||||
*/
|
||||
export function getBlockMetrics(): BlockMetrics | null {
|
||||
const claudeDir: string | null = getClaudeConfigDir();
|
||||
|
||||
if (!claudeDir)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return findMostRecentBlockStartTime(claudeDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently finds the most recent 5-hour block start time from JSONL files
|
||||
* Uses file modification times as hints to avoid unnecessary reads
|
||||
*/
|
||||
function findMostRecentBlockStartTime(
|
||||
rootDir: string,
|
||||
sessionDurationHours = 5
|
||||
): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
||||
// Step 1: Find all JSONL files with their modification times
|
||||
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
|
||||
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
|
||||
const files = globSync([pattern], {
|
||||
absolute: true, // Ensure we get absolute paths
|
||||
cwd: rootDir // Set working directory to rootDir
|
||||
});
|
||||
|
||||
if (files.length === 0)
|
||||
return null;
|
||||
|
||||
// Step 2: Get file stats and sort by modification time (most recent first)
|
||||
const filesWithStats = files.map((file) => {
|
||||
const stats = statSync(file);
|
||||
return { file, mtime: stats.mtime };
|
||||
});
|
||||
|
||||
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
// Step 3: Progressive lookback - start small and expand if needed
|
||||
// Start with 2x session duration (10 hours), expand to 48 hours if needed
|
||||
const lookbackChunks = [
|
||||
10, // 2x session duration - catches most cases
|
||||
20, // 4x session duration - catches longer sessions
|
||||
48 // Maximum lookback for marathon sessions
|
||||
];
|
||||
|
||||
let timestamps: Date[] = [];
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
let continuousWorkStart: Date | null = null;
|
||||
let foundSessionGap = false;
|
||||
|
||||
for (const lookbackHours of lookbackChunks) {
|
||||
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
|
||||
timestamps = [];
|
||||
|
||||
// Collect timestamps for this lookback period
|
||||
for (const { file, mtime } of filesWithStats) {
|
||||
if (mtime.getTime() < cutoffTime.getTime()) {
|
||||
break;
|
||||
}
|
||||
const fileTimestamps = getAllTimestampsFromFile(file);
|
||||
timestamps.push(...fileTimestamps);
|
||||
}
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
continue; // Try next chunk
|
||||
}
|
||||
|
||||
// Sort timestamps (most recent first)
|
||||
timestamps.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
// Get most recent timestamp (only set once)
|
||||
if (!mostRecentTimestamp && timestamps[0]) {
|
||||
mostRecentTimestamp = timestamps[0];
|
||||
|
||||
// Check if the most recent activity is within the current session period
|
||||
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
|
||||
if (timeSinceLastActivity > sessionDurationMs) {
|
||||
// No activity within the current session period
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for a session gap in this chunk
|
||||
continuousWorkStart = mostRecentTimestamp;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const currentTimestamp = timestamps[i];
|
||||
const previousTimestamp = timestamps[i - 1];
|
||||
|
||||
if (!currentTimestamp || !previousTimestamp)
|
||||
continue;
|
||||
|
||||
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
|
||||
|
||||
if (gap >= sessionDurationMs) {
|
||||
// Found a true session boundary
|
||||
foundSessionGap = true;
|
||||
break;
|
||||
}
|
||||
|
||||
continuousWorkStart = currentTimestamp;
|
||||
}
|
||||
|
||||
// If we found a gap, we're done
|
||||
if (foundSessionGap) {
|
||||
break;
|
||||
}
|
||||
|
||||
// If this was our last chunk, use what we have
|
||||
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mostRecentTimestamp || !continuousWorkStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build actual blocks from timestamps going forward
|
||||
const blocks: { start: Date; end: Date }[] = [];
|
||||
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
let currentBlockStart: Date | null = null;
|
||||
let currentBlockEnd: Date | null = null;
|
||||
|
||||
for (const timestamp of sortedTimestamps) {
|
||||
if (timestamp.getTime() < continuousWorkStart.getTime())
|
||||
continue;
|
||||
|
||||
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
|
||||
// Start new block
|
||||
currentBlockStart = floorToHour(timestamp);
|
||||
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
|
||||
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
|
||||
}
|
||||
}
|
||||
|
||||
// Find current block
|
||||
for (const block of blocks) {
|
||||
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
|
||||
// Verify we have activity in this block
|
||||
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
|
||||
&& t.getTime() <= block.end.getTime()
|
||||
);
|
||||
|
||||
if (hasActivity) {
|
||||
return {
|
||||
startTime: block.start,
|
||||
lastActivity: mostRecentTimestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all timestamps from a JSONL file
|
||||
*/
|
||||
function getAllTimestampsFromFile(filePath: string): Date[] {
|
||||
const timestamps: Date[] = [];
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter(line => line.length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line) as {
|
||||
timestamp?: string;
|
||||
isSidechain?: boolean;
|
||||
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
|
||||
};
|
||||
|
||||
// Only treat entries with real token usage as block activity
|
||||
const usage = json.message?.usage;
|
||||
if (!usage)
|
||||
continue;
|
||||
|
||||
const hasInputTokens = typeof usage.input_tokens === 'number';
|
||||
const hasOutputTokens = typeof usage.output_tokens === 'number';
|
||||
if (!hasInputTokens || !hasOutputTokens)
|
||||
continue;
|
||||
|
||||
if (json.isSidechain === true)
|
||||
continue;
|
||||
|
||||
const timestamp = json.timestamp;
|
||||
if (typeof timestamp !== 'string')
|
||||
continue;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isNaN(date.getTime()))
|
||||
timestamps.push(date);
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floors a timestamp to the beginning of the hour (matching existing logic)
|
||||
*/
|
||||
function floorToHour(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
export {
|
||||
getBlockCachePath,
|
||||
getCachedBlockMetrics,
|
||||
readBlockCache,
|
||||
writeBlockCache
|
||||
} from './jsonl-cache';
|
||||
export { getBlockMetrics } from './jsonl-blocks';
|
||||
export {
|
||||
getSessionDuration,
|
||||
getSpeedMetrics,
|
||||
getSpeedMetricsCollection,
|
||||
getTokenMetrics
|
||||
} from './jsonl-metrics';
|
||||
+108
-49
@@ -10,11 +10,116 @@ interface Migration {
|
||||
migrate: (data: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
type V1MigratedField
|
||||
= | 'flexMode'
|
||||
| 'compactThreshold'
|
||||
| 'colorLevel'
|
||||
| 'defaultSeparator'
|
||||
| 'defaultPadding'
|
||||
| 'inheritSeparatorColors'
|
||||
| 'overrideBackgroundColor'
|
||||
| 'overrideForegroundColor'
|
||||
| 'globalBold';
|
||||
|
||||
interface V1FieldRule {
|
||||
key: V1MigratedField;
|
||||
isValid: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
// Type guards for checking data structure
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const V1_FIELD_RULES: V1FieldRule[] = [
|
||||
{
|
||||
key: 'flexMode',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'compactThreshold',
|
||||
isValid: value => typeof value === 'number'
|
||||
},
|
||||
{
|
||||
key: 'colorLevel',
|
||||
isValid: value => typeof value === 'number'
|
||||
},
|
||||
{
|
||||
key: 'defaultSeparator',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'defaultPadding',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'inheritSeparatorColors',
|
||||
isValid: value => typeof value === 'boolean'
|
||||
},
|
||||
{
|
||||
key: 'overrideBackgroundColor',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'overrideForegroundColor',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'globalBold',
|
||||
isValid: value => typeof value === 'boolean'
|
||||
}
|
||||
];
|
||||
|
||||
function toWidgetLine(line: unknown[], stripSeparators: boolean): WidgetItem[] {
|
||||
const lineToProcess = stripSeparators
|
||||
? line.filter((item) => {
|
||||
if (isRecord(item)) {
|
||||
return item.type !== 'separator';
|
||||
}
|
||||
return true;
|
||||
})
|
||||
: line;
|
||||
|
||||
const typedLine: WidgetItem[] = [];
|
||||
for (const item of lineToProcess) {
|
||||
if (isRecord(item) && typeof item.type === 'string') {
|
||||
typedLine.push({
|
||||
...item,
|
||||
id: generateGuid(),
|
||||
type: item.type
|
||||
} as WidgetItem);
|
||||
}
|
||||
}
|
||||
|
||||
return typedLine;
|
||||
}
|
||||
|
||||
function migrateV1Lines(data: Record<string, unknown>): WidgetItem[][] | undefined {
|
||||
if (!Array.isArray(data.lines)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripSeparators = Boolean(data.defaultSeparator);
|
||||
const processedLines: WidgetItem[][] = [];
|
||||
|
||||
for (const line of data.lines) {
|
||||
if (Array.isArray(line)) {
|
||||
processedLines.push(toWidgetLine(line, stripSeparators));
|
||||
}
|
||||
}
|
||||
|
||||
return processedLines;
|
||||
}
|
||||
|
||||
function copyV1Fields(data: Record<string, unknown>, target: Record<string, unknown>): void {
|
||||
for (const rule of V1_FIELD_RULES) {
|
||||
const value = data[rule.key];
|
||||
if (rule.isValid(value)) {
|
||||
target[rule.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define all migrations here
|
||||
export const migrations: Migration[] = [
|
||||
{
|
||||
@@ -26,59 +131,13 @@ export const migrations: Migration[] = [
|
||||
const migrated: Record<string, unknown> = {};
|
||||
|
||||
// Process lines: strip separators if needed and assign GUIDs
|
||||
if (data.lines && Array.isArray(data.lines)) {
|
||||
const processedLines: WidgetItem[][] = [];
|
||||
|
||||
for (const line of data.lines) {
|
||||
if (Array.isArray(line)) {
|
||||
// Filter out separators if defaultSeparator is enabled
|
||||
let processedLine = line;
|
||||
if (data.defaultSeparator) {
|
||||
processedLine = line.filter((item: unknown) => {
|
||||
if (isRecord(item)) {
|
||||
return item.type !== 'separator';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Assign GUIDs to all items and build typed array
|
||||
const typedLine: WidgetItem[] = [];
|
||||
for (const item of processedLine) {
|
||||
if (isRecord(item) && typeof item.type === 'string') {
|
||||
typedLine.push({
|
||||
...item,
|
||||
id: generateGuid(),
|
||||
type: item.type
|
||||
} as WidgetItem);
|
||||
}
|
||||
}
|
||||
processedLines.push(typedLine);
|
||||
}
|
||||
}
|
||||
|
||||
const processedLines = migrateV1Lines(data);
|
||||
if (processedLines) {
|
||||
migrated.lines = processedLines;
|
||||
}
|
||||
|
||||
// Copy all v1 fields that exist
|
||||
if (typeof data.flexMode === 'string')
|
||||
migrated.flexMode = data.flexMode;
|
||||
if (typeof data.compactThreshold === 'number')
|
||||
migrated.compactThreshold = data.compactThreshold;
|
||||
if (typeof data.colorLevel === 'number')
|
||||
migrated.colorLevel = data.colorLevel;
|
||||
if (typeof data.defaultSeparator === 'string')
|
||||
migrated.defaultSeparator = data.defaultSeparator;
|
||||
if (typeof data.defaultPadding === 'string')
|
||||
migrated.defaultPadding = data.defaultPadding;
|
||||
if (typeof data.inheritSeparatorColors === 'boolean')
|
||||
migrated.inheritSeparatorColors = data.inheritSeparatorColors;
|
||||
if (typeof data.overrideBackgroundColor === 'string')
|
||||
migrated.overrideBackgroundColor = data.overrideBackgroundColor;
|
||||
if (typeof data.overrideForegroundColor === 'string')
|
||||
migrated.overrideForegroundColor = data.overrideForegroundColor;
|
||||
if (typeof data.globalBold === 'boolean')
|
||||
migrated.globalBold = data.globalBold;
|
||||
copyV1Fields(data, migrated);
|
||||
|
||||
// Add version field for v2
|
||||
migrated.version = 2;
|
||||
|
||||
+72
-11
@@ -3,6 +3,14 @@ interface ModelContextConfig {
|
||||
usableTokens: number;
|
||||
}
|
||||
|
||||
interface ModelIdentifier {
|
||||
id?: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW_SIZE = 200000;
|
||||
const USABLE_CONTEXT_RATIO = 0.8;
|
||||
|
||||
function toValidWindowSize(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
@@ -11,33 +19,86 @@ function toValidWindowSize(value: number | null | undefined): number | null {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getContextConfig(modelId?: string, contextWindowSize?: number | null): ModelContextConfig {
|
||||
function parseContextWindowSize(modelIdentifier: string): number | null {
|
||||
const delimitedMatch = /(?:\(|\[)\s*(\d+(?:[,_]\d+)*(?:\.\d+)?)\s*([km])\s*(?:\)|\])/i.exec(modelIdentifier);
|
||||
if (delimitedMatch) {
|
||||
const delimitedValue = delimitedMatch[1];
|
||||
const delimitedUnit = delimitedMatch[2];
|
||||
if (!delimitedValue || !delimitedUnit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(delimitedValue.replace(/[,_]/g, ''));
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.round(parsed * (delimitedUnit.toLowerCase() === 'm' ? 1000000 : 1000));
|
||||
}
|
||||
}
|
||||
|
||||
const contextMatch = /\b(\d+(?:[,_]\d+)*(?:\.\d+)?)\s*([km])(?:\s*(?:token\s*)?context)?\b/i.exec(modelIdentifier);
|
||||
if (!contextMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contextValue = contextMatch[1];
|
||||
const contextUnit = contextMatch[2];
|
||||
if (!contextValue || !contextUnit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(contextValue.replace(/[,_]/g, ''));
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(parsed * (contextUnit.toLowerCase() === 'm' ? 1000000 : 1000));
|
||||
}
|
||||
|
||||
export function getModelContextIdentifier(model?: string | ModelIdentifier): string | undefined {
|
||||
if (typeof model === 'string') {
|
||||
const trimmed = model.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const id = model.id?.trim();
|
||||
const displayName = model.display_name?.trim();
|
||||
|
||||
if (id && displayName) {
|
||||
return `${id} ${displayName}`;
|
||||
}
|
||||
|
||||
return id ?? displayName;
|
||||
}
|
||||
|
||||
export function getContextConfig(modelIdentifier?: string, contextWindowSize?: number | null): ModelContextConfig {
|
||||
const statusWindowSize = toValidWindowSize(contextWindowSize);
|
||||
if (statusWindowSize !== null) {
|
||||
return {
|
||||
maxTokens: statusWindowSize,
|
||||
usableTokens: Math.floor(statusWindowSize * 0.8)
|
||||
usableTokens: Math.floor(statusWindowSize * USABLE_CONTEXT_RATIO)
|
||||
};
|
||||
}
|
||||
|
||||
// Default to 200k for older models
|
||||
const defaultConfig = {
|
||||
maxTokens: 200000,
|
||||
usableTokens: 160000
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW_SIZE,
|
||||
usableTokens: Math.floor(DEFAULT_CONTEXT_WINDOW_SIZE * USABLE_CONTEXT_RATIO)
|
||||
};
|
||||
|
||||
if (!modelId)
|
||||
if (!modelIdentifier) {
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
// Any model with [1m] suffix has 1M context
|
||||
if (modelId.toLowerCase().includes('[1m]')) {
|
||||
const inferredWindowSize = parseContextWindowSize(modelIdentifier);
|
||||
if (inferredWindowSize !== null) {
|
||||
return {
|
||||
maxTokens: 1000000,
|
||||
usableTokens: 800000 // 80% of 1M
|
||||
maxTokens: inferredWindowSize,
|
||||
usableTokens: Math.floor(inferredWindowSize * USABLE_CONTEXT_RATIO)
|
||||
};
|
||||
}
|
||||
|
||||
// Add future models here as needed
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
+51
-24
@@ -6,6 +6,12 @@ export interface OpenExternalUrlResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OpenCommandPlan {
|
||||
command: string;
|
||||
args: (url: string) => string[];
|
||||
errorPrefix?: string;
|
||||
}
|
||||
|
||||
function runOpenCommand(command: string, args: string[]): string | null {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: 'ignore',
|
||||
@@ -27,6 +33,33 @@ function runOpenCommand(command: string, args: string[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const PLATFORM_OPEN_PLANS: Record<string, OpenCommandPlan[]> = {
|
||||
darwin: [
|
||||
{
|
||||
command: 'open',
|
||||
args: url => [url]
|
||||
}
|
||||
],
|
||||
win32: [
|
||||
{
|
||||
command: 'cmd',
|
||||
args: url => ['/c', 'start', '', url]
|
||||
}
|
||||
],
|
||||
linux: [
|
||||
{
|
||||
command: 'xdg-open',
|
||||
args: url => [url],
|
||||
errorPrefix: 'xdg-open failed: '
|
||||
},
|
||||
{
|
||||
command: 'gio',
|
||||
args: url => ['open', url],
|
||||
errorPrefix: 'gio open failed: '
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function openExternalUrl(url: string): OpenExternalUrlResult {
|
||||
let parsedUrl: URL;
|
||||
|
||||
@@ -47,36 +80,30 @@ export function openExternalUrl(url: string): OpenExternalUrlResult {
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const commandError = runOpenCommand('open', [url]);
|
||||
return commandError ? { success: false, error: commandError } : { success: true };
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const commandError = runOpenCommand('cmd', ['/c', 'start', '', url]);
|
||||
return commandError ? { success: false, error: commandError } : { success: true };
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
const xdgError = runOpenCommand('xdg-open', [url]);
|
||||
if (!xdgError) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const gioError = runOpenCommand('gio', ['open', url]);
|
||||
if (!gioError) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const plans = PLATFORM_OPEN_PLANS[platform];
|
||||
if (!plans) {
|
||||
return {
|
||||
success: false,
|
||||
error: `xdg-open failed: ${xdgError}; gio open failed: ${gioError}`
|
||||
error: `Unsupported platform: ${platform}`
|
||||
};
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const plan of plans) {
|
||||
const commandError = runOpenCommand(plan.command, plan.args(url));
|
||||
if (!commandError) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (plan.errorPrefix) {
|
||||
errors.push(`${plan.errorPrefix}${commandError}`);
|
||||
} else {
|
||||
errors.push(commandError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Unsupported platform: ${platform}`
|
||||
error: errors.join('; ')
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Settings } from '../types/Settings';
|
||||
|
||||
import { getDefaultPowerlineTheme } from './colors';
|
||||
|
||||
function resolveEnabledPowerlineTheme(theme: string | undefined): string {
|
||||
if (!theme || theme === 'custom') {
|
||||
return getDefaultPowerlineTheme();
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
export function buildEnabledPowerlineSettings(settings: Settings, removeManualSeparators: boolean): Settings {
|
||||
const powerlineConfig = settings.powerline;
|
||||
const lines = removeManualSeparators
|
||||
? settings.lines.map(line => line.filter(item => item.type !== 'separator' && item.type !== 'flex-separator'))
|
||||
: settings.lines;
|
||||
|
||||
return {
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
enabled: true,
|
||||
theme: resolveEnabledPowerlineTheme(powerlineConfig.theme),
|
||||
// Separators are initialized by schema defaults, preserve existing values.
|
||||
separators: powerlineConfig.separators,
|
||||
separatorInvertBackground: powerlineConfig.separatorInvertBackground
|
||||
},
|
||||
defaultPadding: ' ',
|
||||
lines
|
||||
};
|
||||
}
|
||||
+43
-68
@@ -31,6 +31,47 @@ export function formatTokens(count: number): string {
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
function resolveEffectiveTerminalWidth(
|
||||
detectedWidth: number | null,
|
||||
settings: Settings,
|
||||
context: RenderContext
|
||||
): number | null {
|
||||
if (!detectedWidth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flexMode = settings.flexMode as string;
|
||||
|
||||
if (context.isPreview) {
|
||||
if (flexMode === 'full') {
|
||||
return detectedWidth - 6;
|
||||
}
|
||||
if (flexMode === 'full-minus-40') {
|
||||
return detectedWidth - 40;
|
||||
}
|
||||
if (flexMode === 'full-until-compact') {
|
||||
return detectedWidth - 6;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (flexMode === 'full') {
|
||||
return detectedWidth - 6;
|
||||
}
|
||||
if (flexMode === 'full-minus-40') {
|
||||
return detectedWidth - 40;
|
||||
}
|
||||
if (flexMode === 'full-until-compact') {
|
||||
const threshold = settings.compactThreshold;
|
||||
const contextPercentage = calculateContextPercentage(context);
|
||||
return contextPercentage >= threshold
|
||||
? detectedWidth - 40
|
||||
: detectedWidth - 6;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPowerlineStatusLine(
|
||||
widgets: WidgetItem[],
|
||||
settings: Settings,
|
||||
@@ -82,37 +123,7 @@ function renderPowerlineStatusLine(
|
||||
const detectedWidth = context.terminalWidth ?? getTerminalWidth();
|
||||
|
||||
// Calculate terminal width based on flex mode settings
|
||||
let terminalWidth: number | null = null;
|
||||
if (detectedWidth) {
|
||||
const flexMode = settings.flexMode as string;
|
||||
|
||||
if (context.isPreview) {
|
||||
// In preview mode, account for box borders and padding (6 chars total)
|
||||
if (flexMode === 'full') {
|
||||
terminalWidth = detectedWidth - 6;
|
||||
} else if (flexMode === 'full-minus-40') {
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else if (flexMode === 'full-until-compact') {
|
||||
terminalWidth = detectedWidth - 6;
|
||||
}
|
||||
} else {
|
||||
// In actual rendering mode
|
||||
if (flexMode === 'full') {
|
||||
terminalWidth = detectedWidth - 6;
|
||||
} else if (flexMode === 'full-minus-40') {
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else if (flexMode === 'full-until-compact') {
|
||||
const threshold = settings.compactThreshold;
|
||||
const contextPercentage = calculateContextPercentage(context);
|
||||
|
||||
if (contextPercentage >= threshold) {
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else {
|
||||
terminalWidth = detectedWidth - 6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const terminalWidth = resolveEffectiveTerminalWidth(detectedWidth, settings, context);
|
||||
|
||||
// Build widget elements (similar to regular mode but without separators)
|
||||
const widgetElements: { content: string; bgColor?: string; fgColor?: string; widget: WidgetItem }[] = [];
|
||||
@@ -632,43 +643,7 @@ export function renderStatusLine(
|
||||
const detectedWidth = context.terminalWidth ?? getTerminalWidth();
|
||||
|
||||
// Calculate terminal width based on flex mode settings
|
||||
let terminalWidth: number | null = null;
|
||||
if (detectedWidth) {
|
||||
const flexMode = settings.flexMode as string;
|
||||
|
||||
if (context.isPreview) {
|
||||
// In preview mode, account for box borders and padding (6 chars total)
|
||||
if (flexMode === 'full') {
|
||||
terminalWidth = detectedWidth - 6; // Subtract 6 for box borders and padding in preview
|
||||
} else if (flexMode === 'full-minus-40') {
|
||||
terminalWidth = detectedWidth - 40; // -40 for auto-compact + 3 for preview
|
||||
} else if (flexMode === 'full-until-compact') {
|
||||
// For preview, always show full width minus preview padding
|
||||
terminalWidth = detectedWidth - 6;
|
||||
}
|
||||
} else {
|
||||
// In actual rendering mode
|
||||
if (flexMode === 'full') {
|
||||
// Use full width minus 4 for terminal padding
|
||||
terminalWidth = detectedWidth - 6;
|
||||
} else if (flexMode === 'full-minus-40') {
|
||||
// Always subtract 41 for auto-compact message
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else if (flexMode === 'full-until-compact') {
|
||||
// Check context percentage to decide
|
||||
const threshold = settings.compactThreshold;
|
||||
const contextPercentage = calculateContextPercentage(context);
|
||||
|
||||
if (contextPercentage >= threshold) {
|
||||
// Context is high, leave space for auto-compact
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else {
|
||||
// Context is low, use full width minus 4 for padding
|
||||
terminalWidth = detectedWidth - 6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const terminalWidth = resolveEffectiveTerminalWidth(detectedWidth, settings, context);
|
||||
|
||||
const elements: { content: string; type: string; widget?: WidgetItem }[] = [];
|
||||
let hasFlexSeparator = false;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { WidgetItem } from '../types/Widget';
|
||||
|
||||
export function countSeparatorSlots(widgets: WidgetItem[]): number {
|
||||
const nonMergedWidgets = widgets.filter((_, idx) => idx === widgets.length - 1 || !widgets[idx]?.merge);
|
||||
return Math.max(0, nonMergedWidgets.length - 1);
|
||||
}
|
||||
|
||||
export function advanceGlobalSeparatorIndex(currentIndex: number, widgets: WidgetItem[]): number {
|
||||
return currentIndex + countSeparatorSlots(widgets);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
SkillInvocation,
|
||||
SkillsMetrics
|
||||
} from '../types/SkillsMetrics';
|
||||
|
||||
const EMPTY: SkillsMetrics = { totalInvocations: 0, uniqueSkills: [], lastSkill: null };
|
||||
|
||||
function getSkillsDir(): string {
|
||||
return path.join(os.homedir(), '.cache', 'ccstatusline', 'skills');
|
||||
}
|
||||
|
||||
export function getSkillsFilePath(sessionId: string): string {
|
||||
return path.join(getSkillsDir(), `skills-${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
export function getSkillsMetrics(sessionId: string): SkillsMetrics {
|
||||
const filePath = getSkillsFilePath(sessionId);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
try {
|
||||
const invocations: SkillInvocation[] = fs.readFileSync(filePath, 'utf-8')
|
||||
.trim().split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map((line) => {
|
||||
try { return JSON.parse(line) as SkillInvocation; } catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((e): e is SkillInvocation => e !== null && typeof e.skill === 'string' && typeof e.session_id === 'string');
|
||||
if (invocations.length === 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const uniqueSkills: string[] = [];
|
||||
const seenSkills = new Set<string>();
|
||||
for (let i = invocations.length - 1; i >= 0; i--) {
|
||||
const skill = invocations[i]?.skill;
|
||||
if (skill && !seenSkills.has(skill)) {
|
||||
seenSkills.add(skill);
|
||||
uniqueSkills.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalInvocations: invocations.length,
|
||||
uniqueSkills,
|
||||
lastSkill: invocations[invocations.length - 1]?.skill ?? null
|
||||
};
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SpeedMetrics } from '../types/SpeedMetrics';
|
||||
|
||||
/**
|
||||
* Calculates output tokens per second from speed metrics.
|
||||
*
|
||||
* @param metrics SpeedMetrics containing timing and token data
|
||||
* @returns Output tokens per second, or null if duration is zero
|
||||
*/
|
||||
export function calculateOutputSpeed(metrics: SpeedMetrics): number | null {
|
||||
if (metrics.totalDurationMs === 0) {
|
||||
return null;
|
||||
}
|
||||
const seconds = metrics.totalDurationMs / 1000;
|
||||
return metrics.outputTokens / seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates input tokens per second from speed metrics.
|
||||
*
|
||||
* @param metrics SpeedMetrics containing timing and token data
|
||||
* @returns Input tokens per second, or null if duration is zero
|
||||
*/
|
||||
export function calculateInputSpeed(metrics: SpeedMetrics): number | null {
|
||||
if (metrics.totalDurationMs === 0) {
|
||||
return null;
|
||||
}
|
||||
const seconds = metrics.totalDurationMs / 1000;
|
||||
return metrics.inputTokens / seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total tokens per second from speed metrics.
|
||||
*
|
||||
* @param metrics SpeedMetrics containing timing and token data
|
||||
* @returns Total tokens per second, or null if duration is zero
|
||||
*/
|
||||
export function calculateTotalSpeed(metrics: SpeedMetrics): number | null {
|
||||
if (metrics.totalDurationMs === 0) {
|
||||
return null;
|
||||
}
|
||||
const seconds = metrics.totalDurationMs / 1000;
|
||||
return metrics.totalTokens / seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a tokens per second value for display.
|
||||
*
|
||||
* @param tokensPerSec Tokens per second value, or null if unavailable
|
||||
* @returns Formatted string (e.g., "42.5 t/s", "1.2k t/s", or "—" for null)
|
||||
*/
|
||||
export function formatSpeed(tokensPerSec: number | null): string {
|
||||
if (tokensPerSec === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (tokensPerSec >= 1000) {
|
||||
const kValue = tokensPerSec / 1000;
|
||||
return `${kValue.toFixed(1)}k t/s`;
|
||||
}
|
||||
|
||||
return `${tokensPerSec.toFixed(1)} t/s`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { WidgetItem } from '../types/Widget';
|
||||
|
||||
export const SPEED_WINDOW_METADATA_KEY = 'windowSeconds';
|
||||
export const DEFAULT_SPEED_WINDOW_SECONDS = 0;
|
||||
export const MIN_SPEED_WINDOW_SECONDS = 0;
|
||||
export const MAX_SPEED_WINDOW_SECONDS = 120;
|
||||
|
||||
export function clampSpeedWindowSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_SPEED_WINDOW_SECONDS;
|
||||
}
|
||||
|
||||
const normalized = Math.trunc(value);
|
||||
if (normalized < MIN_SPEED_WINDOW_SECONDS) {
|
||||
return MIN_SPEED_WINDOW_SECONDS;
|
||||
}
|
||||
if (normalized > MAX_SPEED_WINDOW_SECONDS) {
|
||||
return MAX_SPEED_WINDOW_SECONDS;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getWidgetSpeedWindowSeconds(item: WidgetItem): number {
|
||||
const metadataValue = item.metadata?.[SPEED_WINDOW_METADATA_KEY];
|
||||
if (!metadataValue) {
|
||||
return DEFAULT_SPEED_WINDOW_SECONDS;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(metadataValue, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_SPEED_WINDOW_SECONDS;
|
||||
}
|
||||
|
||||
return clampSpeedWindowSeconds(parsed);
|
||||
}
|
||||
|
||||
export function isWidgetSpeedWindowEnabled(item: WidgetItem): boolean {
|
||||
return getWidgetSpeedWindowSeconds(item) > 0;
|
||||
}
|
||||
|
||||
export function withWidgetSpeedWindowSeconds(item: WidgetItem, seconds: number): WidgetItem {
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
[SPEED_WINDOW_METADATA_KEY]: clampSpeedWindowSeconds(seconds).toString()
|
||||
}
|
||||
};
|
||||
}
|
||||
+7
-47
@@ -32,8 +32,7 @@ export function getPackageVersion(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get terminal width
|
||||
export function getTerminalWidth(): number | null {
|
||||
function probeTerminalWidth(): number | null {
|
||||
// Preserve historical behavior on Windows: width detection is unavailable.
|
||||
// This avoids Unix fallback command behavior (e.g. 2>/dev/null) on Windows.
|
||||
if (process.platform === 'win32') {
|
||||
@@ -87,51 +86,12 @@ export function getTerminalWidth(): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get terminal width
|
||||
export function getTerminalWidth(): number | null {
|
||||
return probeTerminalWidth();
|
||||
}
|
||||
|
||||
// Check if terminal width detection is available
|
||||
export function canDetectTerminalWidth(): boolean {
|
||||
// Preserve historical behavior on Windows: width detection is unavailable.
|
||||
if (process.platform === 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// First try to get the tty of the parent process
|
||||
const tty = execSync('ps -o tty= -p $(ps -o ppid= -p $$)', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
shell: '/bin/sh'
|
||||
}).trim();
|
||||
|
||||
// Check if we got a valid tty
|
||||
if (tty && tty !== '??' && tty !== '?') {
|
||||
const width = execSync(
|
||||
`stty size < /dev/${tty} | awk '{print $2}'`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
shell: '/bin/sh'
|
||||
}
|
||||
).trim();
|
||||
|
||||
const parsed = parseInt(width, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try fallback
|
||||
}
|
||||
|
||||
// Fallback: try tput cols
|
||||
try {
|
||||
const width = execSync('tput cols 2>/dev/null', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
|
||||
const parsed = parseInt(width, 10);
|
||||
return !isNaN(parsed) && parsed > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return probeTerminalWidth() !== null;
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as https from 'https';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import type {
|
||||
UsageData,
|
||||
UsageError
|
||||
} from './usage-types';
|
||||
import { UsageErrorSchema } from './usage-types';
|
||||
|
||||
// Cache configuration
|
||||
const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline');
|
||||
const CACHE_FILE = path.join(CACHE_DIR, 'usage.json');
|
||||
const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock');
|
||||
const CACHE_MAX_AGE = 180; // seconds
|
||||
const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds
|
||||
const DEFAULT_RATE_LIMIT_BACKOFF = 300; // seconds
|
||||
const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour
|
||||
|
||||
const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
|
||||
const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited']);
|
||||
const UsageLockSchema = z.object({
|
||||
blockedUntil: z.number(),
|
||||
error: UsageLockErrorSchema.optional()
|
||||
});
|
||||
|
||||
const CachedUsageDataSchema = z.object({
|
||||
sessionUsage: z.number().nullable().optional(),
|
||||
sessionResetAt: z.string().nullable().optional(),
|
||||
weeklyUsage: z.number().nullable().optional(),
|
||||
weeklyResetAt: z.string().nullable().optional(),
|
||||
extraUsageEnabled: z.boolean().nullable().optional(),
|
||||
extraUsageLimit: z.number().nullable().optional(),
|
||||
extraUsageUsed: z.number().nullable().optional(),
|
||||
extraUsageUtilization: z.number().nullable().optional(),
|
||||
error: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
const UsageApiResponseSchema = z.object({
|
||||
five_hour: z.object({
|
||||
utilization: z.number().nullable().optional(),
|
||||
resets_at: z.string().nullable().optional()
|
||||
}).optional(),
|
||||
seven_day: z.object({
|
||||
utilization: z.number().nullable().optional(),
|
||||
resets_at: z.string().nullable().optional()
|
||||
}).optional(),
|
||||
extra_usage: z.object({
|
||||
is_enabled: z.boolean().nullable().optional(),
|
||||
monthly_limit: z.number().nullable().optional(),
|
||||
used_credits: z.number().nullable().optional(),
|
||||
utilization: z.number().nullable().optional()
|
||||
}).optional()
|
||||
});
|
||||
|
||||
function parseJsonWithSchema<T>(rawJson: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
const parsed = schema.safeParse(JSON.parse(rawJson));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUsageAccessToken(rawJson: string): string | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema);
|
||||
return parsed?.claudeAiOauth?.accessToken ?? null;
|
||||
}
|
||||
|
||||
function parseCachedUsageData(rawJson: string): UsageData | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, CachedUsageDataSchema);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedError = UsageErrorSchema.safeParse(parsed.error);
|
||||
|
||||
return {
|
||||
sessionUsage: parsed.sessionUsage ?? undefined,
|
||||
sessionResetAt: parsed.sessionResetAt ?? undefined,
|
||||
weeklyUsage: parsed.weeklyUsage ?? undefined,
|
||||
weeklyResetAt: parsed.weeklyResetAt ?? undefined,
|
||||
extraUsageEnabled: parsed.extraUsageEnabled ?? undefined,
|
||||
extraUsageLimit: parsed.extraUsageLimit ?? undefined,
|
||||
extraUsageUsed: parsed.extraUsageUsed ?? undefined,
|
||||
extraUsageUtilization: parsed.extraUsageUtilization ?? undefined,
|
||||
error: parsedError.success ? parsedError.data : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function parseUsageApiResponse(rawJson: string): UsageData | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, UsageApiResponseSchema);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionUsage: parsed.five_hour?.utilization ?? undefined,
|
||||
sessionResetAt: parsed.five_hour?.resets_at ?? undefined,
|
||||
weeklyUsage: parsed.seven_day?.utilization ?? undefined,
|
||||
weeklyResetAt: parsed.seven_day?.resets_at ?? undefined,
|
||||
extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined,
|
||||
extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined,
|
||||
extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined,
|
||||
extraUsageUtilization: parsed.extra_usage?.utilization ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Memory caches
|
||||
let cachedUsageData: UsageData | null = null;
|
||||
let usageCacheTime = 0;
|
||||
let cachedUsageToken: string | null = null;
|
||||
let usageTokenCacheTime = 0;
|
||||
let usageErrorCacheMaxAge = LOCK_MAX_AGE;
|
||||
|
||||
type UsageLockError = z.infer<typeof UsageLockErrorSchema>;
|
||||
|
||||
type UsageApiFetchResult = { kind: 'success'; body: string } | { kind: 'rate-limited'; retryAfterSeconds: number } | { kind: 'error' };
|
||||
|
||||
function ensureCacheDirExists(): void {
|
||||
if (!fs.existsSync(CACHE_DIR)) {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function setCachedUsageError(error: UsageError, now: number, maxAge = LOCK_MAX_AGE): UsageData {
|
||||
const errorData: UsageData = { error };
|
||||
cachedUsageData = errorData;
|
||||
usageCacheTime = now;
|
||||
usageErrorCacheMaxAge = maxAge;
|
||||
return errorData;
|
||||
}
|
||||
|
||||
function cacheUsageData(data: UsageData, now: number): UsageData {
|
||||
cachedUsageData = data;
|
||||
usageCacheTime = now;
|
||||
usageErrorCacheMaxAge = LOCK_MAX_AGE;
|
||||
return data;
|
||||
}
|
||||
|
||||
function getStaleUsageOrError(error: UsageError, now: number, errorCacheMaxAge = LOCK_MAX_AGE): UsageData {
|
||||
const stale = readStaleUsageCache();
|
||||
if (stale && !stale.error) {
|
||||
return cacheUsageData(stale, now);
|
||||
}
|
||||
return setCachedUsageError(error, now, errorCacheMaxAge);
|
||||
}
|
||||
|
||||
function getUsageToken(): string | null {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Return cached token if still valid
|
||||
if (cachedUsageToken && (now - usageTokenCacheTime) < TOKEN_CACHE_MAX_AGE) {
|
||||
return cachedUsageToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const isMac = process.platform === 'darwin';
|
||||
if (isMac) {
|
||||
// macOS: read from keychain
|
||||
const result = execSync(
|
||||
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
const token = parseUsageAccessToken(result);
|
||||
if (token) {
|
||||
cachedUsageToken = token;
|
||||
usageTokenCacheTime = now;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Non-macOS: read from credentials file, honoring CLAUDE_CONFIG_DIR
|
||||
const credFile = path.join(getClaudeConfigDir(), '.credentials.json');
|
||||
const token = parseUsageAccessToken(fs.readFileSync(credFile, 'utf8'));
|
||||
if (token) {
|
||||
cachedUsageToken = token;
|
||||
usageTokenCacheTime = now;
|
||||
}
|
||||
return token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStaleUsageCache(): UsageData | null {
|
||||
try {
|
||||
return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeUsageLock(blockedUntil: number, error: UsageLockError): void {
|
||||
try {
|
||||
ensureCacheDirExists();
|
||||
fs.writeFileSync(LOCK_FILE, JSON.stringify({ blockedUntil, error }));
|
||||
} catch {
|
||||
// Ignore lock file errors
|
||||
}
|
||||
}
|
||||
|
||||
function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageLockError } | null {
|
||||
let hasValidJsonLock = false;
|
||||
|
||||
try {
|
||||
const parsed = parseJsonWithSchema(fs.readFileSync(LOCK_FILE, 'utf8'), UsageLockSchema);
|
||||
if (parsed) {
|
||||
hasValidJsonLock = true;
|
||||
if (parsed.blockedUntil > now) {
|
||||
return {
|
||||
blockedUntil: parsed.blockedUntil,
|
||||
error: parsed.error ?? 'timeout'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the legacy mtime-based lock behavior below.
|
||||
}
|
||||
|
||||
if (hasValidJsonLock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const lockStat = fs.statSync(LOCK_FILE);
|
||||
const lockMtime = Math.floor(lockStat.mtimeMs / 1000);
|
||||
const blockedUntil = lockMtime + LOCK_MAX_AGE;
|
||||
if (blockedUntil > now) {
|
||||
return {
|
||||
blockedUntil,
|
||||
error: 'timeout'
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Lock file doesn't exist - OK to proceed
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRetryAfterSeconds(headerValue: string | string[] | undefined, nowMs = Date.now()): number | null {
|
||||
const rawValue = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
||||
const trimmedValue = rawValue?.trim();
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(trimmedValue)) {
|
||||
const seconds = Number.parseInt(trimmedValue, 10);
|
||||
return seconds > 0 ? seconds : null;
|
||||
}
|
||||
|
||||
const retryAtMs = Date.parse(trimmedValue);
|
||||
if (Number.isNaN(retryAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const retryAfterSeconds = Math.ceil((retryAtMs - nowMs) / 1000);
|
||||
return retryAfterSeconds > 0 ? retryAfterSeconds : null;
|
||||
}
|
||||
|
||||
const USAGE_API_HOST = 'api.anthropic.com';
|
||||
const USAGE_API_PATH = '/api/oauth/usage';
|
||||
const USAGE_API_TIMEOUT_MS = 5000;
|
||||
|
||||
function getUsageApiProxyUrl(): string | null {
|
||||
const proxyUrl = process.env.HTTPS_PROXY?.trim();
|
||||
if (proxyUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return proxyUrl ?? null;
|
||||
}
|
||||
|
||||
function getUsageApiRequestOptions(token: string): https.RequestOptions | null {
|
||||
const proxyUrl = getUsageApiProxyUrl();
|
||||
|
||||
try {
|
||||
return {
|
||||
hostname: USAGE_API_HOST,
|
||||
path: USAGE_API_PATH,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'anthropic-beta': 'oauth-2025-04-20'
|
||||
},
|
||||
timeout: USAGE_API_TIMEOUT_MS,
|
||||
...(proxyUrl ? { agent: new HttpsProxyAgent(proxyUrl) } : {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFromUsageApi(token: string): Promise<UsageApiFetchResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: UsageApiFetchResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const requestOptions = getUsageApiRequestOptions(token);
|
||||
if (!requestOptions) {
|
||||
finish({ kind: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const request = https.request(requestOptions, (response) => {
|
||||
let data = '';
|
||||
response.setEncoding('utf8');
|
||||
|
||||
response.on('data', (chunk: string) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
response.on('end', () => {
|
||||
if (response.statusCode === 200 && data) {
|
||||
finish({ kind: 'success', body: data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode === 429) {
|
||||
finish({
|
||||
kind: 'rate-limited',
|
||||
retryAfterSeconds: parseRetryAfterSeconds(response.headers['retry-after']) ?? DEFAULT_RATE_LIMIT_BACKOFF
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
finish({ kind: 'error' });
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', () => { finish({ kind: 'error' }); });
|
||||
request.on('timeout', () => {
|
||||
request.destroy();
|
||||
finish({ kind: 'error' });
|
||||
});
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchUsageData(): Promise<UsageData> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Check memory cache (fast path)
|
||||
if (cachedUsageData) {
|
||||
const cacheAge = now - usageCacheTime;
|
||||
if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) {
|
||||
return cachedUsageData;
|
||||
}
|
||||
if (cachedUsageData.error && cacheAge < usageErrorCacheMaxAge) {
|
||||
return cachedUsageData;
|
||||
}
|
||||
}
|
||||
|
||||
// Check file cache
|
||||
try {
|
||||
const stat = fs.statSync(CACHE_FILE);
|
||||
const fileAge = now - Math.floor(stat.mtimeMs / 1000);
|
||||
if (fileAge < CACHE_MAX_AGE) {
|
||||
const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
if (fileData && !fileData.error) {
|
||||
return cacheUsageData(fileData, now);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or read error - continue to API call
|
||||
}
|
||||
|
||||
// Get token before lock/rate-limit checks so auth failures are not masked as timeout.
|
||||
const token = getUsageToken();
|
||||
if (!token) {
|
||||
return getStaleUsageOrError('no-credentials', now);
|
||||
}
|
||||
|
||||
const activeLock = readActiveUsageLock(now);
|
||||
if (activeLock) {
|
||||
return getStaleUsageOrError(
|
||||
activeLock.error,
|
||||
now,
|
||||
Math.max(1, activeLock.blockedUntil - now)
|
||||
);
|
||||
}
|
||||
|
||||
writeUsageLock(now + LOCK_MAX_AGE, 'timeout');
|
||||
|
||||
// Fetch from API using Node's https module
|
||||
try {
|
||||
const response = await fetchFromUsageApi(token);
|
||||
|
||||
if (response.kind === 'rate-limited') {
|
||||
writeUsageLock(now + response.retryAfterSeconds, 'rate-limited');
|
||||
return getStaleUsageOrError('rate-limited', now, response.retryAfterSeconds);
|
||||
}
|
||||
|
||||
if (response.kind === 'error') {
|
||||
return getStaleUsageOrError('api-error', now);
|
||||
}
|
||||
|
||||
const usageData = parseUsageApiResponse(response.body);
|
||||
if (!usageData) {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
|
||||
// Validate we got actual data
|
||||
if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
try {
|
||||
ensureCacheDirExists();
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData));
|
||||
} catch {
|
||||
// Ignore cache write errors
|
||||
}
|
||||
|
||||
return cacheUsageData(usageData, now);
|
||||
} catch {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { WidgetItem } from '../types/Widget';
|
||||
|
||||
import type { UsageData } from './usage';
|
||||
import { fetchUsageData } from './usage';
|
||||
|
||||
const USAGE_WIDGET_TYPES = new Set<string>([
|
||||
'session-usage',
|
||||
'weekly-usage',
|
||||
'block-timer',
|
||||
'reset-timer',
|
||||
'weekly-reset-timer'
|
||||
]);
|
||||
|
||||
export function hasUsageDependentWidgets(lines: WidgetItem[][]): boolean {
|
||||
return lines.some(line => line.some(item => USAGE_WIDGET_TYPES.has(item.type)));
|
||||
}
|
||||
|
||||
export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][]): Promise<UsageData | null> {
|
||||
if (!hasUsageDependentWidgets(lines)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await fetchUsageData();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000;
|
||||
export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'rate-limited', 'api-error', 'parse-error']);
|
||||
export type UsageError = z.infer<typeof UsageErrorSchema>;
|
||||
|
||||
export interface UsageData {
|
||||
sessionUsage?: number; // five_hour.utilization (percentage)
|
||||
sessionResetAt?: string; // five_hour.resets_at
|
||||
weeklyUsage?: number; // seven_day.utilization (percentage)
|
||||
weeklyResetAt?: string; // seven_day.resets_at
|
||||
extraUsageEnabled?: boolean;
|
||||
extraUsageLimit?: number; // in cents
|
||||
extraUsageUsed?: number; // in cents
|
||||
extraUsageUtilization?: number;
|
||||
error?: UsageError;
|
||||
}
|
||||
|
||||
export interface UsageWindowMetrics {
|
||||
sessionDurationMs: number;
|
||||
elapsedMs: number;
|
||||
remainingMs: number;
|
||||
elapsedPercent: number;
|
||||
remainingPercent: number;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getCachedBlockMetrics } from './jsonl';
|
||||
import {
|
||||
FIVE_HOUR_BLOCK_MS,
|
||||
SEVEN_DAY_WINDOW_MS,
|
||||
type UsageData,
|
||||
type UsageError,
|
||||
type UsageWindowMetrics
|
||||
} from './usage-types';
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function buildUsageWindow(resetAtMs: number, nowMs: number, durationMs: number): UsageWindowMetrics | null {
|
||||
if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs) || !Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startAtMs = resetAtMs - durationMs;
|
||||
const elapsedMs = clamp(nowMs - startAtMs, 0, durationMs);
|
||||
const remainingMs = durationMs - elapsedMs;
|
||||
const elapsedPercent = (elapsedMs / durationMs) * 100;
|
||||
|
||||
return {
|
||||
sessionDurationMs: durationMs,
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
elapsedPercent,
|
||||
remainingPercent: 100 - elapsedPercent
|
||||
};
|
||||
}
|
||||
|
||||
export function getUsageWindowFromResetAt(sessionResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
if (!sessionResetAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resetAtMs = Date.parse(sessionResetAt);
|
||||
if (Number.isNaN(resetAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildUsageWindow(resetAtMs, nowMs, FIVE_HOUR_BLOCK_MS);
|
||||
}
|
||||
|
||||
export function getUsageWindowFromBlockMetrics(blockMetrics: BlockMetrics, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
const startAtMs = blockMetrics.startTime.getTime();
|
||||
if (Number.isNaN(startAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildUsageWindow(startAtMs + FIVE_HOUR_BLOCK_MS, nowMs, FIVE_HOUR_BLOCK_MS);
|
||||
}
|
||||
|
||||
export function resolveUsageWindowWithFallback(
|
||||
usageData: UsageData,
|
||||
blockMetrics?: BlockMetrics | null,
|
||||
nowMs = Date.now()
|
||||
): UsageWindowMetrics | null {
|
||||
const usageWindow = getUsageWindowFromResetAt(usageData.sessionResetAt, nowMs);
|
||||
if (usageWindow) {
|
||||
return usageWindow;
|
||||
}
|
||||
|
||||
const fallbackMetrics = blockMetrics ?? getCachedBlockMetrics();
|
||||
if (!fallbackMetrics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getUsageWindowFromBlockMetrics(fallbackMetrics, nowMs);
|
||||
}
|
||||
|
||||
export function getWeeklyUsageWindowFromResetAt(weeklyResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
if (!weeklyResetAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resetAtMs = Date.parse(weeklyResetAt);
|
||||
if (Number.isNaN(resetAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildUsageWindow(resetAtMs, nowMs, SEVEN_DAY_WINDOW_MS);
|
||||
}
|
||||
|
||||
export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, nowMs);
|
||||
}
|
||||
|
||||
export function formatUsageDuration(durationMs: number, compact = false): string {
|
||||
const clampedMs = Math.max(0, durationMs);
|
||||
const elapsedHours = Math.floor(clampedMs / (1000 * 60 * 60));
|
||||
const elapsedMinutes = Math.floor((clampedMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (compact) {
|
||||
return elapsedMinutes === 0 ? `${elapsedHours}h` : `${elapsedHours}h${elapsedMinutes}m`;
|
||||
}
|
||||
|
||||
if (elapsedMinutes === 0) {
|
||||
return `${elapsedHours}hr`;
|
||||
}
|
||||
|
||||
return `${elapsedHours}hr ${elapsedMinutes}m`;
|
||||
}
|
||||
|
||||
export function getUsageErrorMessage(error: UsageError): string {
|
||||
switch (error) {
|
||||
case 'no-credentials': return '[No credentials]';
|
||||
case 'timeout': return '[Timeout]';
|
||||
case 'rate-limited': return '[Rate limited]';
|
||||
case 'api-error': return '[API Error]';
|
||||
case 'parse-error': return '[Parse Error]';
|
||||
}
|
||||
}
|
||||
|
||||
export function makeUsageProgressBar(percent: number, width = 15): string {
|
||||
const filled = Math.round((percent / 100) * width);
|
||||
const empty = width - filled;
|
||||
return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
|
||||
}
|
||||
+18
-434
@@ -1,434 +1,18 @@
|
||||
import {
|
||||
execSync,
|
||||
spawnSync
|
||||
} from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import { getCachedBlockMetrics } from './jsonl';
|
||||
|
||||
// Cache configuration
|
||||
const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline');
|
||||
const CACHE_FILE = path.join(CACHE_DIR, 'usage.json');
|
||||
const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock');
|
||||
const CACHE_MAX_AGE = 180; // seconds
|
||||
const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds
|
||||
const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour
|
||||
export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000;
|
||||
|
||||
// Error types matching shell script
|
||||
const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'api-error', 'parse-error']);
|
||||
export type UsageError = z.infer<typeof UsageErrorSchema>;
|
||||
|
||||
export interface UsageData {
|
||||
sessionUsage?: number; // five_hour.utilization (percentage)
|
||||
sessionResetAt?: string; // five_hour.reset_at
|
||||
weeklyUsage?: number; // seven_day.utilization (percentage)
|
||||
extraUsageEnabled?: boolean;
|
||||
extraUsageLimit?: number; // in cents
|
||||
extraUsageUsed?: number; // in cents
|
||||
extraUsageUtilization?: number;
|
||||
error?: UsageError;
|
||||
}
|
||||
|
||||
export interface UsageWindowMetrics {
|
||||
sessionDurationMs: number;
|
||||
elapsedMs: number;
|
||||
remainingMs: number;
|
||||
elapsedPercent: number;
|
||||
remainingPercent: number;
|
||||
}
|
||||
|
||||
const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
|
||||
|
||||
const CachedUsageDataSchema = z.object({
|
||||
sessionUsage: z.number().nullable().optional(),
|
||||
sessionResetAt: z.string().nullable().optional(),
|
||||
weeklyUsage: z.number().nullable().optional(),
|
||||
extraUsageEnabled: z.boolean().nullable().optional(),
|
||||
extraUsageLimit: z.number().nullable().optional(),
|
||||
extraUsageUsed: z.number().nullable().optional(),
|
||||
extraUsageUtilization: z.number().nullable().optional(),
|
||||
error: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
const UsageApiResponseSchema = z.object({
|
||||
five_hour: z.object({
|
||||
utilization: z.number().nullable().optional(),
|
||||
reset_at: z.string().nullable().optional(),
|
||||
resets_at: z.string().nullable().optional()
|
||||
}).optional(),
|
||||
seven_day: z.object({ utilization: z.number().nullable().optional() }).optional(),
|
||||
extra_usage: z.object({
|
||||
is_enabled: z.boolean().nullable().optional(),
|
||||
monthly_limit: z.number().nullable().optional(),
|
||||
used_credits: z.number().nullable().optional(),
|
||||
utilization: z.number().nullable().optional()
|
||||
}).optional()
|
||||
});
|
||||
|
||||
function parseJsonWithSchema<T>(rawJson: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
const parsed = schema.safeParse(JSON.parse(rawJson));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUsageAccessToken(rawJson: string): string | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema);
|
||||
return parsed?.claudeAiOauth?.accessToken ?? null;
|
||||
}
|
||||
|
||||
function parseCachedUsageData(rawJson: string): UsageData | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, CachedUsageDataSchema);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedError = UsageErrorSchema.safeParse(parsed.error);
|
||||
|
||||
return {
|
||||
sessionUsage: parsed.sessionUsage ?? undefined,
|
||||
sessionResetAt: parsed.sessionResetAt ?? undefined,
|
||||
weeklyUsage: parsed.weeklyUsage ?? undefined,
|
||||
extraUsageEnabled: parsed.extraUsageEnabled ?? undefined,
|
||||
extraUsageLimit: parsed.extraUsageLimit ?? undefined,
|
||||
extraUsageUsed: parsed.extraUsageUsed ?? undefined,
|
||||
extraUsageUtilization: parsed.extraUsageUtilization ?? undefined,
|
||||
error: parsedError.success ? parsedError.data : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function parseUsageApiResponse(rawJson: string): UsageData | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, UsageApiResponseSchema);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionUsage: parsed.five_hour?.utilization ?? undefined,
|
||||
sessionResetAt: parsed.five_hour?.resets_at ?? parsed.five_hour?.reset_at ?? undefined,
|
||||
weeklyUsage: parsed.seven_day?.utilization ?? undefined,
|
||||
extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined,
|
||||
extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined,
|
||||
extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined,
|
||||
extraUsageUtilization: parsed.extra_usage?.utilization ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Memory caches
|
||||
let cachedUsageData: UsageData | null = null;
|
||||
let usageCacheTime = 0;
|
||||
let cachedUsageToken: string | null = null;
|
||||
let usageTokenCacheTime = 0;
|
||||
|
||||
function setCachedUsageError(error: UsageError, now: number): UsageData {
|
||||
const errorData: UsageData = { error };
|
||||
cachedUsageData = errorData;
|
||||
usageCacheTime = now;
|
||||
return errorData;
|
||||
}
|
||||
|
||||
function getStaleUsageOrError(error: UsageError, now: number): UsageData {
|
||||
const stale = readStaleUsageCache();
|
||||
if (stale && !stale.error) {
|
||||
cachedUsageData = stale;
|
||||
usageCacheTime = now;
|
||||
return stale;
|
||||
}
|
||||
return setCachedUsageError(error, now);
|
||||
}
|
||||
|
||||
function getUsageToken(): string | null {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Return cached token if still valid
|
||||
if (cachedUsageToken && (now - usageTokenCacheTime) < TOKEN_CACHE_MAX_AGE) {
|
||||
return cachedUsageToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const isMac = process.platform === 'darwin';
|
||||
if (isMac) {
|
||||
// macOS: read from keychain
|
||||
const result = execSync(
|
||||
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
const token = parseUsageAccessToken(result);
|
||||
if (token) {
|
||||
cachedUsageToken = token;
|
||||
usageTokenCacheTime = now;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Non-macOS: read from credentials file, honoring CLAUDE_CONFIG_DIR
|
||||
const credFile = path.join(getClaudeConfigDir(), '.credentials.json');
|
||||
const token = parseUsageAccessToken(fs.readFileSync(credFile, 'utf8'));
|
||||
if (token) {
|
||||
cachedUsageToken = token;
|
||||
usageTokenCacheTime = now;
|
||||
}
|
||||
return token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStaleUsageCache(): UsageData | null {
|
||||
try {
|
||||
return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFromUsageApi(token: string): string | null {
|
||||
// Use the active runtime to make a synchronous API call via spawnSync.
|
||||
const script = `
|
||||
const token = process.env.TOKEN;
|
||||
if (!token) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const https = require('https');
|
||||
const options = {
|
||||
hostname: 'api.anthropic.com',
|
||||
path: '/api/oauth/usage',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'anthropic-beta': 'oauth-2025-04-20'
|
||||
},
|
||||
timeout: 5000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200 && data) {
|
||||
process.stdout.write(data);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => process.exit(1));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
req.end();
|
||||
`;
|
||||
|
||||
const runtimePath = process.execPath || 'node';
|
||||
const result = spawnSync(runtimePath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
timeout: 6000,
|
||||
env: { ...process.env, TOKEN: token }
|
||||
});
|
||||
|
||||
if (result.error || result.status !== 0 || !result.stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export function fetchUsageData(): UsageData {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Check memory cache (fast path)
|
||||
if (cachedUsageData) {
|
||||
const cacheAge = now - usageCacheTime;
|
||||
if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) {
|
||||
return cachedUsageData;
|
||||
}
|
||||
if (cachedUsageData.error && cacheAge < LOCK_MAX_AGE) {
|
||||
return cachedUsageData;
|
||||
}
|
||||
}
|
||||
|
||||
// Check file cache
|
||||
try {
|
||||
const stat = fs.statSync(CACHE_FILE);
|
||||
const fileAge = now - Math.floor(stat.mtimeMs / 1000);
|
||||
if (fileAge < CACHE_MAX_AGE) {
|
||||
const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
if (fileData && !fileData.error) {
|
||||
cachedUsageData = fileData;
|
||||
usageCacheTime = now;
|
||||
return fileData;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or read error - continue to API call
|
||||
}
|
||||
|
||||
// Get token before lock/rate-limit checks so auth failures are not masked as timeout.
|
||||
const token = getUsageToken();
|
||||
if (!token) {
|
||||
return getStaleUsageOrError('no-credentials', now);
|
||||
}
|
||||
|
||||
// Rate limit: only try API once per 30 seconds
|
||||
try {
|
||||
const lockStat = fs.statSync(LOCK_FILE);
|
||||
const lockAge = now - Math.floor(lockStat.mtimeMs / 1000);
|
||||
if (lockAge < LOCK_MAX_AGE) {
|
||||
// Rate limited - return stale cache or timeout error
|
||||
const stale = readStaleUsageCache();
|
||||
if (stale && !stale.error)
|
||||
return stale;
|
||||
return { error: 'timeout' };
|
||||
}
|
||||
} catch {
|
||||
// Lock file doesn't exist - OK to proceed
|
||||
}
|
||||
|
||||
// Touch lock file
|
||||
try {
|
||||
const lockDir = path.dirname(LOCK_FILE);
|
||||
if (!fs.existsSync(lockDir)) {
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(LOCK_FILE, '');
|
||||
} catch {
|
||||
// Ignore lock file errors
|
||||
}
|
||||
|
||||
// Fetch from API using Node's https module
|
||||
try {
|
||||
const response = fetchFromUsageApi(token);
|
||||
|
||||
if (!response) {
|
||||
return getStaleUsageOrError('api-error', now);
|
||||
}
|
||||
|
||||
const usageData = parseUsageApiResponse(response);
|
||||
if (!usageData) {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
|
||||
// Validate we got actual data
|
||||
if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
try {
|
||||
if (!fs.existsSync(CACHE_DIR)) {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData));
|
||||
} catch {
|
||||
// Ignore cache write errors
|
||||
}
|
||||
|
||||
cachedUsageData = usageData;
|
||||
usageCacheTime = now;
|
||||
return usageData;
|
||||
} catch {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function buildUsageWindow(resetAtMs: number, nowMs: number): UsageWindowMetrics | null {
|
||||
if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startAtMs = resetAtMs - FIVE_HOUR_BLOCK_MS;
|
||||
const elapsedMs = clamp(nowMs - startAtMs, 0, FIVE_HOUR_BLOCK_MS);
|
||||
const remainingMs = FIVE_HOUR_BLOCK_MS - elapsedMs;
|
||||
const elapsedPercent = (elapsedMs / FIVE_HOUR_BLOCK_MS) * 100;
|
||||
|
||||
return {
|
||||
sessionDurationMs: FIVE_HOUR_BLOCK_MS,
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
elapsedPercent,
|
||||
remainingPercent: 100 - elapsedPercent
|
||||
};
|
||||
}
|
||||
|
||||
export function getUsageWindowFromResetAt(sessionResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
if (!sessionResetAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resetAtMs = Date.parse(sessionResetAt);
|
||||
if (Number.isNaN(resetAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildUsageWindow(resetAtMs, nowMs);
|
||||
}
|
||||
|
||||
export function getUsageWindowFromBlockMetrics(blockMetrics: BlockMetrics, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
const startAtMs = blockMetrics.startTime.getTime();
|
||||
if (Number.isNaN(startAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildUsageWindow(startAtMs + FIVE_HOUR_BLOCK_MS, nowMs);
|
||||
}
|
||||
|
||||
export function resolveUsageWindowWithFallback(
|
||||
usageData: UsageData,
|
||||
blockMetrics?: BlockMetrics | null,
|
||||
nowMs = Date.now()
|
||||
): UsageWindowMetrics | null {
|
||||
const usageWindow = getUsageWindowFromResetAt(usageData.sessionResetAt, nowMs);
|
||||
if (usageWindow) {
|
||||
return usageWindow;
|
||||
}
|
||||
|
||||
const fallbackMetrics = blockMetrics ?? getCachedBlockMetrics();
|
||||
if (!fallbackMetrics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getUsageWindowFromBlockMetrics(fallbackMetrics, nowMs);
|
||||
}
|
||||
|
||||
export function formatUsageDuration(durationMs: number): string {
|
||||
const clampedMs = Math.max(0, durationMs);
|
||||
const elapsedHours = Math.floor(clampedMs / (1000 * 60 * 60));
|
||||
const elapsedMinutes = Math.floor((clampedMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (elapsedMinutes === 0) {
|
||||
return `${elapsedHours}hr`;
|
||||
}
|
||||
|
||||
return `${elapsedHours}hr ${elapsedMinutes}m`;
|
||||
}
|
||||
|
||||
export function getUsageErrorMessage(error: UsageError): string {
|
||||
switch (error) {
|
||||
case 'no-credentials': return '[No credentials]';
|
||||
case 'timeout': return '[Timeout]';
|
||||
case 'api-error': return '[API Error]';
|
||||
case 'parse-error': return '[Parse Error]';
|
||||
}
|
||||
}
|
||||
|
||||
export function makeUsageProgressBar(percent: number, width = 15): string {
|
||||
const filled = Math.round((percent / 100) * width);
|
||||
const empty = width - filled;
|
||||
return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
|
||||
}
|
||||
export { fetchUsageData } from './usage-fetch';
|
||||
export {
|
||||
formatUsageDuration,
|
||||
getUsageErrorMessage,
|
||||
getUsageWindowFromBlockMetrics,
|
||||
getUsageWindowFromResetAt,
|
||||
getWeeklyUsageWindowFromResetAt,
|
||||
makeUsageProgressBar,
|
||||
resolveUsageWindowWithFallback,
|
||||
resolveWeeklyUsageWindow
|
||||
} from './usage-windows';
|
||||
export {
|
||||
FIVE_HOUR_BLOCK_MS,
|
||||
SEVEN_DAY_WINDOW_MS,
|
||||
type UsageData,
|
||||
type UsageError,
|
||||
type UsageWindowMetrics
|
||||
} from './usage-types';
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
Widget,
|
||||
WidgetItemType
|
||||
} from '../types/Widget';
|
||||
import * as widgets from '../widgets';
|
||||
|
||||
export interface WidgetManifestEntry {
|
||||
type: WidgetItemType;
|
||||
create: () => Widget;
|
||||
}
|
||||
|
||||
export interface LayoutWidgetManifestEntry {
|
||||
type: WidgetItemType;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
|
||||
{ type: 'model', create: () => new widgets.ModelWidget() },
|
||||
{ type: 'output-style', create: () => new widgets.OutputStyleWidget() },
|
||||
{ type: 'git-branch', create: () => new widgets.GitBranchWidget() },
|
||||
{ type: 'git-changes', create: () => new widgets.GitChangesWidget() },
|
||||
{ type: 'git-insertions', create: () => new widgets.GitInsertionsWidget() },
|
||||
{ type: 'git-deletions', create: () => new widgets.GitDeletionsWidget() },
|
||||
{ type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() },
|
||||
{ type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() },
|
||||
{ type: 'current-working-dir', create: () => new widgets.CurrentWorkingDirWidget() },
|
||||
{ type: 'tokens-input', create: () => new widgets.TokensInputWidget() },
|
||||
{ type: 'tokens-output', create: () => new widgets.TokensOutputWidget() },
|
||||
{ type: 'tokens-cached', create: () => new widgets.TokensCachedWidget() },
|
||||
{ type: 'tokens-total', create: () => new widgets.TokensTotalWidget() },
|
||||
{ type: 'input-speed', create: () => new widgets.InputSpeedWidget() },
|
||||
{ type: 'output-speed', create: () => new widgets.OutputSpeedWidget() },
|
||||
{ type: 'total-speed', create: () => new widgets.TotalSpeedWidget() },
|
||||
{ type: 'context-length', create: () => new widgets.ContextLengthWidget() },
|
||||
{ type: 'context-percentage', create: () => new widgets.ContextPercentageWidget() },
|
||||
{ type: 'context-percentage-usable', create: () => new widgets.ContextPercentageUsableWidget() },
|
||||
{ type: 'session-clock', create: () => new widgets.SessionClockWidget() },
|
||||
{ type: 'session-cost', create: () => new widgets.SessionCostWidget() },
|
||||
{ type: 'block-timer', create: () => new widgets.BlockTimerWidget() },
|
||||
{ type: 'terminal-width', create: () => new widgets.TerminalWidthWidget() },
|
||||
{ type: 'version', create: () => new widgets.VersionWidget() },
|
||||
{ type: 'custom-text', create: () => new widgets.CustomTextWidget() },
|
||||
{ type: 'custom-command', create: () => new widgets.CustomCommandWidget() },
|
||||
{ type: 'link', create: () => new widgets.LinkWidget() },
|
||||
{ type: 'claude-session-id', create: () => new widgets.ClaudeSessionIdWidget() },
|
||||
{ type: 'session-name', create: () => new widgets.SessionNameWidget() },
|
||||
{ type: 'free-memory', create: () => new widgets.FreeMemoryWidget() },
|
||||
{ type: 'session-usage', create: () => new widgets.SessionUsageWidget() },
|
||||
{ type: 'weekly-usage', create: () => new widgets.WeeklyUsageWidget() },
|
||||
{ type: 'reset-timer', create: () => new widgets.BlockResetTimerWidget() },
|
||||
{ type: 'weekly-reset-timer', create: () => new widgets.WeeklyResetTimerWidget() },
|
||||
{ type: 'context-bar', create: () => new widgets.ContextBarWidget() },
|
||||
{ type: 'skills', create: () => new widgets.SkillsWidget() }
|
||||
];
|
||||
|
||||
export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [
|
||||
{
|
||||
type: 'separator',
|
||||
displayName: 'Separator',
|
||||
description: 'A separator character between status line widgets',
|
||||
category: 'Layout'
|
||||
},
|
||||
{
|
||||
type: 'flex-separator',
|
||||
displayName: 'Flex Separator',
|
||||
description: 'Expands to fill available terminal width',
|
||||
category: 'Layout'
|
||||
}
|
||||
];
|
||||
+24
-59
@@ -3,47 +3,24 @@ import type {
|
||||
Widget,
|
||||
WidgetItemType
|
||||
} from '../types/Widget';
|
||||
import * as widgets from '../widgets';
|
||||
|
||||
import {
|
||||
LAYOUT_WIDGET_MANIFEST,
|
||||
WIDGET_MANIFEST
|
||||
} from './widget-manifest';
|
||||
|
||||
// Create widget registry
|
||||
const widgetRegistry = new Map<WidgetItemType, Widget>([
|
||||
['model', new widgets.ModelWidget()],
|
||||
['output-style', new widgets.OutputStyleWidget()],
|
||||
['git-branch', new widgets.GitBranchWidget()],
|
||||
['git-changes', new widgets.GitChangesWidget()],
|
||||
['git-root-dir', new widgets.GitRootDirWidget()],
|
||||
['git-worktree', new widgets.GitWorktreeWidget()],
|
||||
['current-working-dir', new widgets.CurrentWorkingDirWidget()],
|
||||
['tokens-input', new widgets.TokensInputWidget()],
|
||||
['tokens-output', new widgets.TokensOutputWidget()],
|
||||
['tokens-cached', new widgets.TokensCachedWidget()],
|
||||
['tokens-total', new widgets.TokensTotalWidget()],
|
||||
['context-length', new widgets.ContextLengthWidget()],
|
||||
['context-percentage', new widgets.ContextPercentageWidget()],
|
||||
['context-percentage-usable', new widgets.ContextPercentageUsableWidget()],
|
||||
['session-clock', new widgets.SessionClockWidget()],
|
||||
['session-cost', new widgets.SessionCostWidget()],
|
||||
['block-timer', new widgets.BlockTimerWidget()],
|
||||
['terminal-width', new widgets.TerminalWidthWidget()],
|
||||
['version', new widgets.VersionWidget()],
|
||||
['custom-text', new widgets.CustomTextWidget()],
|
||||
['custom-command', new widgets.CustomCommandWidget()],
|
||||
['link', new widgets.LinkWidget()],
|
||||
['claude-session-id', new widgets.ClaudeSessionIdWidget()],
|
||||
['session-name', new widgets.SessionNameWidget()],
|
||||
['free-memory', new widgets.FreeMemoryWidget()],
|
||||
['session-usage', new widgets.SessionUsageWidget()],
|
||||
['weekly-usage', new widgets.WeeklyUsageWidget()],
|
||||
['reset-timer', new widgets.ResetTimerWidget()],
|
||||
['context-bar', new widgets.ContextBarWidget()]
|
||||
]);
|
||||
const widgetRegistry = new Map<WidgetItemType, Widget>(
|
||||
WIDGET_MANIFEST.map((entry): [WidgetItemType, Widget] => [entry.type, entry.create()])
|
||||
);
|
||||
const layoutWidgetTypes = new Set<WidgetItemType>(LAYOUT_WIDGET_MANIFEST.map(entry => entry.type));
|
||||
|
||||
export function getWidget(type: WidgetItemType): Widget | null {
|
||||
return widgetRegistry.get(type) ?? null;
|
||||
}
|
||||
|
||||
export function getAllWidgetTypes(settings: Settings): WidgetItemType[] {
|
||||
const allTypes = Array.from(widgetRegistry.keys());
|
||||
const allTypes = WIDGET_MANIFEST.map(entry => entry.type);
|
||||
|
||||
// Add separator types based on settings
|
||||
if (!settings.powerline.enabled) {
|
||||
@@ -64,32 +41,21 @@ export interface WidgetCatalogEntry {
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
const LAYOUT_WIDGETS: Record<string, Omit<WidgetCatalogEntry, 'type' | 'searchText'>> = {
|
||||
'separator': {
|
||||
displayName: 'Separator',
|
||||
description: 'A separator character between status line widgets',
|
||||
category: 'Layout'
|
||||
},
|
||||
'flex-separator': {
|
||||
displayName: 'Flex Separator',
|
||||
description: 'Expands to fill available terminal width',
|
||||
category: 'Layout'
|
||||
}
|
||||
};
|
||||
const layoutCatalogEntries = new Map<WidgetItemType, WidgetCatalogEntry>(
|
||||
LAYOUT_WIDGET_MANIFEST.map((entry): [WidgetItemType, WidgetCatalogEntry] => [
|
||||
entry.type,
|
||||
{
|
||||
type: entry.type,
|
||||
displayName: entry.displayName,
|
||||
description: entry.description,
|
||||
category: entry.category,
|
||||
searchText: `${entry.displayName} ${entry.description} ${entry.type}`.toLowerCase()
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
function getLayoutCatalogEntry(type: WidgetItemType): WidgetCatalogEntry | null {
|
||||
const layout = LAYOUT_WIDGETS[type];
|
||||
if (!layout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
displayName: layout.displayName,
|
||||
description: layout.description,
|
||||
category: layout.category,
|
||||
searchText: `${layout.displayName} ${layout.description} ${type}`.toLowerCase()
|
||||
};
|
||||
return layoutCatalogEntries.get(type) ?? null;
|
||||
}
|
||||
|
||||
export function getWidgetCatalog(settings: Settings): WidgetCatalogEntry[] {
|
||||
@@ -182,6 +148,5 @@ export function filterWidgetCatalog(catalog: WidgetCatalogEntry[], category: str
|
||||
|
||||
export function isKnownWidgetType(type: string): boolean {
|
||||
return widgetRegistry.has(type)
|
||||
|| type === 'separator'
|
||||
|| type === 'flex-separator';
|
||||
|| layoutWidgetTypes.has(type);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
formatUsageDuration,
|
||||
getUsageErrorMessage,
|
||||
resolveUsageWindowWithFallback
|
||||
} from '../utils/usage';
|
||||
|
||||
import { formatRawOrLabeledValue } from './shared/raw-or-labeled';
|
||||
import {
|
||||
cycleUsageDisplayMode,
|
||||
getUsageDisplayMode,
|
||||
getUsageDisplayModifierText,
|
||||
getUsageProgressBarWidth,
|
||||
isUsageCompact,
|
||||
isUsageInverted,
|
||||
isUsageProgressMode,
|
||||
toggleUsageCompact,
|
||||
toggleUsageInverted
|
||||
} from './shared/usage-display';
|
||||
|
||||
function makeTimerProgressBar(percent: number, width: number): string {
|
||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||
const filledWidth = Math.floor((clampedPercent / 100) * width);
|
||||
const emptyWidth = width - filledWidth;
|
||||
return '█'.repeat(filledWidth) + '░'.repeat(emptyWidth);
|
||||
}
|
||||
|
||||
export class BlockResetTimerWidget implements Widget {
|
||||
getDefaultColor(): string { return 'brightBlue'; }
|
||||
getDescription(): string { return 'Shows time remaining until current 5hr block reset window'; }
|
||||
getDisplayName(): string { return 'Block Reset Timer'; }
|
||||
getCategory(): string { return 'Usage'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: getUsageDisplayModifierText(item, { includeCompact: true })
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
return cycleUsageDisplayMode(item);
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
return toggleUsageInverted(item);
|
||||
}
|
||||
|
||||
if (action === 'toggle-compact') {
|
||||
return toggleUsageCompact(item);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const displayMode = getUsageDisplayMode(item);
|
||||
const inverted = isUsageInverted(item);
|
||||
const compact = isUsageCompact(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
const previewPercent = inverted ? 90.0 : 10.0;
|
||||
|
||||
if (isUsageProgressMode(displayMode)) {
|
||||
const barWidth = getUsageProgressBarWidth(displayMode);
|
||||
const progressBar = makeTimerProgressBar(previewPercent, barWidth);
|
||||
return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, 'Reset: ', compact ? '4h30m' : '4hr 30m');
|
||||
}
|
||||
|
||||
const usageData = context.usageData ?? {};
|
||||
const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);
|
||||
|
||||
if (!window) {
|
||||
if (usageData.error) {
|
||||
return getUsageErrorMessage(usageData.error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isUsageProgressMode(displayMode)) {
|
||||
const barWidth = getUsageProgressBarWidth(displayMode);
|
||||
const percent = inverted ? window.remainingPercent : window.elapsedPercent;
|
||||
const progressBar = makeTimerProgressBar(percent, barWidth);
|
||||
const percentage = percent.toFixed(1);
|
||||
return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${percentage}%`);
|
||||
}
|
||||
|
||||
const remainingTime = formatUsageDuration(window.remainingMs, compact);
|
||||
return formatRawOrLabeledValue(item, 'Reset: ', remainingTime);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
|
||||
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
|
||||
];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
+38
-82
@@ -7,24 +7,22 @@ import type {
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
resolveUsageWindowWithFallback
|
||||
} from '../utils/usage';
|
||||
|
||||
type DisplayMode = 'time' | 'progress' | 'progress-short';
|
||||
|
||||
function getDisplayMode(item: WidgetItem): DisplayMode {
|
||||
const mode = item.metadata?.display;
|
||||
if (mode === 'progress' || mode === 'progress-short') {
|
||||
return mode;
|
||||
}
|
||||
return 'time';
|
||||
}
|
||||
|
||||
function isInverted(item: WidgetItem): boolean {
|
||||
return item.metadata?.invert === 'true';
|
||||
}
|
||||
import { formatRawOrLabeledValue } from './shared/raw-or-labeled';
|
||||
import {
|
||||
cycleUsageDisplayMode,
|
||||
getUsageDisplayMode,
|
||||
getUsageDisplayModifierText,
|
||||
getUsageProgressBarWidth,
|
||||
isUsageCompact,
|
||||
isUsageInverted,
|
||||
isUsageProgressMode,
|
||||
toggleUsageCompact,
|
||||
toggleUsageInverted
|
||||
} from './shared/usage-display';
|
||||
|
||||
function makeTimerProgressBar(percent: number, width: number): string {
|
||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||
@@ -40,117 +38,75 @@ export class BlockTimerWidget implements Widget {
|
||||
getCategory(): string { return 'Usage'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const mode = getDisplayMode(item);
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (mode === 'progress') {
|
||||
modifiers.push('progress bar');
|
||||
} else if (mode === 'progress-short') {
|
||||
modifiers.push('short bar');
|
||||
}
|
||||
|
||||
if (isInverted(item)) {
|
||||
modifiers.push('inverted');
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
|
||||
modifierText: getUsageDisplayModifierText(item, { includeCompact: true })
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
const currentMode = getDisplayMode(item);
|
||||
let nextMode: DisplayMode;
|
||||
|
||||
if (currentMode === 'time') {
|
||||
nextMode = 'progress';
|
||||
} else if (currentMode === 'progress') {
|
||||
nextMode = 'progress-short';
|
||||
} else {
|
||||
nextMode = 'time';
|
||||
}
|
||||
|
||||
const nextMetadata: Record<string, string> = {
|
||||
...(item.metadata ?? {}),
|
||||
display: nextMode
|
||||
};
|
||||
|
||||
if (nextMode === 'time') {
|
||||
delete nextMetadata.invert;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: nextMetadata
|
||||
};
|
||||
return cycleUsageDisplayMode(item);
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
invert: (!isInverted(item)).toString()
|
||||
}
|
||||
};
|
||||
return toggleUsageInverted(item);
|
||||
}
|
||||
|
||||
if (action === 'toggle-compact') {
|
||||
return toggleUsageCompact(item);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const displayMode = getDisplayMode(item);
|
||||
const inverted = isInverted(item);
|
||||
const displayMode = getUsageDisplayMode(item);
|
||||
const inverted = isUsageInverted(item);
|
||||
const compact = isUsageCompact(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
const previewPercent = inverted ? 26.1 : 73.9;
|
||||
const prefix = item.rawValue ? '' : 'Block ';
|
||||
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
if (isUsageProgressMode(displayMode)) {
|
||||
const barWidth = getUsageProgressBarWidth(displayMode);
|
||||
const progressBar = makeTimerProgressBar(previewPercent, barWidth);
|
||||
return `${prefix}[${progressBar}] ${previewPercent.toFixed(1)}%`;
|
||||
return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${previewPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
return item.rawValue ? '3hr 45m' : 'Block: 3hr 45m';
|
||||
return formatRawOrLabeledValue(item, 'Block: ', compact ? '3h45m' : '3hr 45m');
|
||||
}
|
||||
|
||||
const usageData = fetchUsageData();
|
||||
const usageData = context.usageData ?? {};
|
||||
const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);
|
||||
|
||||
if (!window) {
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
if (isUsageProgressMode(displayMode)) {
|
||||
const barWidth = getUsageProgressBarWidth(displayMode);
|
||||
const emptyBar = '░'.repeat(barWidth);
|
||||
return item.rawValue ? `[${emptyBar}] 0.0%` : `Block [${emptyBar}] 0.0%`;
|
||||
return formatRawOrLabeledValue(item, 'Block ', `[${emptyBar}] 0.0%`);
|
||||
}
|
||||
|
||||
return item.rawValue ? '0hr 0m' : 'Block: 0hr 0m';
|
||||
return formatRawOrLabeledValue(item, 'Block: ', compact ? '0h' : '0hr 0m');
|
||||
}
|
||||
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
if (isUsageProgressMode(displayMode)) {
|
||||
const barWidth = getUsageProgressBarWidth(displayMode);
|
||||
const percent = inverted ? window.remainingPercent : window.elapsedPercent;
|
||||
const progressBar = makeTimerProgressBar(percent, barWidth);
|
||||
const percentage = percent.toFixed(1);
|
||||
|
||||
if (item.rawValue) {
|
||||
return `[${progressBar}] ${percentage}%`;
|
||||
}
|
||||
|
||||
return `Block [${progressBar}] ${percentage}%`;
|
||||
return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${percentage}%`);
|
||||
}
|
||||
|
||||
const elapsedTime = formatUsageDuration(window.elapsedMs);
|
||||
return item.rawValue ? elapsedTime : `Block: ${elapsedTime}`;
|
||||
const elapsedTime = formatUsageDuration(window.elapsedMs, compact);
|
||||
return formatRawOrLabeledValue(item, 'Block: ', elapsedTime);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
|
||||
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user