Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7411d6ee6 | |||
| 860df1763c | |||
| 236cd79cb5 | |||
| eecdf46b04 | |||
| bd74e68c71 | |||
| a523195573 | |||
| 98e3c8bbbc | |||
| 1edc66516d | |||
| b8ffa30b66 | |||
| f22564bf51 | |||
| 1b6e738cea | |||
| 1af051e207 | |||
| 745299d819 | |||
| 8c40ef0a83 | |||
| 460c2519d9 | |||
| 4fefc51139 | |||
| 44f49d3efb | |||
| c56849db42 | |||
| d47b7bd902 | |||
| 477164e605 | |||
| f7d7af7c32 | |||
| c20e11145b | |||
| cea4d3c9fd | |||
| aae5addb13 | |||
| 08d3c7cc00 | |||
| 6793c75515 | |||
| 70ca56353e | |||
| 8398d91706 | |||
| 151521ca6e | |||
| 2bb0cdfc65 | |||
| c04247f2f4 | |||
| 30de97505f | |||
| cb2555d180 | |||
| 50195e15ae | |||
| 7be789fa56 | |||
| e51b0027c6 | |||
| b20296c008 | |||
| f3ecfe8254 | |||
| 4f01094f21 | |||
| 2159379a3a | |||
| 6d4f10a273 |
@@ -10,7 +10,7 @@ jobs:
|
||||
name: Lint & Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun run lint
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun test
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun run build
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
@@ -47,6 +47,28 @@
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
### v2.2.22 - v2.2.24 - Powerline flex mode, cache/CI/sandbox visibility, layout controls, composable metrics, and safer config
|
||||
|
||||

|
||||
|
||||
- **⏳ Prompt cache timer** - Added a `Cache Timer` widget with live `HOT` state, TTL countdown, configurable 5-minute/1-hour windows, and customizable state glyphs.
|
||||
- **✅ GitHub CI status** - Added a `Git CI Status` widget that summarizes failing, pending, and successful checks for the current branch's pull request.
|
||||
- **🔒 Sandbox status** - Added a `Sandbox Status` widget with glyph, text, and Nerd Font formats that follows Claude Code's layered sandbox setting on each refresh.
|
||||
- **↔️ One-sided default padding** - Default widget padding can now apply to both sides, the left only, or the right only in standard and Powerline layouts.
|
||||
- **⚡ Powerline flex mode** - Flex separators now work in Powerline mode, letting Powerline status lines right-align content or absorb available width.
|
||||
- **🌗 Per-widget dim styling** - The color editor can dim an entire widget or only parenthesized text, with reset and clear-all actions covering dim state.
|
||||
- **🧯 Safer settings recovery** - Invalid `settings.json` files are left untouched, defaults render in memory, and the status line shows an invalid-config warning.
|
||||
- **🧭 Selective Powerline alignment** - Press `x` in the line editor to let a widget and the rest of its line keep their natural widths while earlier Powerline columns stay auto-aligned.
|
||||
- **📏 Git widget width limits** - `Git Branch` and `Git Root Dir` can cap their visible width with ellipsis-safe truncation while preserving hyperlink targets.
|
||||
- **🔣 Current directory glyphs** - `Current Working Dir` can prepend an optional custom glyph, including in raw-value mode.
|
||||
- **🧠 Configurable context fallback** - `CCSTATUSLINE_CONTEXT_SIZE_FALLBACK` overrides the default 200k last-resort context window when Claude Code and the model name do not report one.
|
||||
- **🧩 Composable compaction metrics** - `Compaction Counter` can render count, auto, manual, unknown, or reclaimed-token values independently for custom layouts.
|
||||
- **🏷️ CLI version flag** - `ccstatusline --version` now prints the installed package version and exits.
|
||||
- **🛡️ Guarded invalid-config saves** - The TUI warns when it loaded defaults for an invalid settings file and requires confirmation before replacing that file.
|
||||
- **🔄 Usage display and cache fixes** - Used/remaining direction now works in every percentage mode, account switches invalidate cached usage, and fetch locks no longer cause repeated requests or stale timeout output.
|
||||
- **⏱️ Calmer reset timer startup** - Reset timers show labeled loading placeholders instead of transient rate-limit errors while Claude Code's embedded usage-window data is still arriving.
|
||||
- **🔇 Quieter install detection** - Best-effort npm and Bun probes no longer leak expected package-manager errors into the TUI.
|
||||
|
||||
### v2.2.21 - Cache widgets, compaction details, extra usage currency, and package fixes
|
||||
|
||||
- **🔣 Custom widget glyphs** - Git and JJ symbol widgets can override or suppress their built-in glyphs from the TUI.
|
||||
@@ -100,7 +122,7 @@
|
||||
- **🧮 Git file status widgets** - Added `Git Staged Files`, `Git Unstaged Files`, `Git Untracked Files`, and `Git Clean Status` for file counts and clean/dirty state.
|
||||
- **🏷️ Clear context percentage labels** - `Context %` and `Context % (usable)` now label rendered values as used or left when toggling used/remaining mode.
|
||||
- **⚡ More Powerline caps** - The Powerline separator editor now supports more than three start/end caps.
|
||||
- **🧠 Thinking Effort updates** - Added `xhigh`, show `default` when no effort is set, mark unknown future effort levels with `?`, and track live status JSON plus `/effort` command changes.
|
||||
- **🧠 Thinking Effort updates** - Added `xhigh`, show `default` when no effort is set, mark unknown future effort levels with `?`, and track live status JSON plus `/effort` command changes. Claude Code reports Ultracode as `xhigh` in status line data.
|
||||
- **🧮 More accurate token counts** - Streaming duplicate JSONL entries are deduped so token widgets do not overcount live Claude Code output.
|
||||
- **🏷️ Cleaner model display** - The Model widget strips trailing context suffixes like `(1M context)`; use `Context Window` when you want the total window size shown.
|
||||
- **🧹 Cleaner empty-widget separators** - Manual separators now collapse around widgets that render empty, avoiding dangling separators when hide-when-empty widgets disappear.
|
||||
@@ -358,11 +380,16 @@ If ccstatusline is useful to you, consider buying me a coffee:
|
||||
|
||||
## 🔗 Related Projects
|
||||
|
||||
- [ccstatusline-editor](https://github.com/refinist/ccstatusline-editor) - A visual editor for building ccstatusline configurations — drag, drop, preview, ship.
|
||||
- [tweakcc](https://github.com/Piebald-AI/tweakcc) - Customize Claude Code themes, thinking verbs, and more.
|
||||
- [ccusage](https://github.com/ryoppippi/ccusage) - Track and display Claude Code usage metrics.
|
||||
- [ccsidekick](https://ccsidekick.krayong.com/) - A Claude Code status-line with a reactive character plus cost, git, and usage widgets.
|
||||
- [codachi](https://github.com/vincent-k2026/codachi) - A tamagotchi-style statusline pet that grows with your context window.
|
||||
- [AIWatch](https://ai-watch.dev) - Live status monitor for 30+ AI APIs and apps; pairs with a Custom Command widget to surface provider outages in your status line.
|
||||
|
||||
- [ccsessions](https://github.com/treebird7/ccsessions) - CLI session manager for Claude Code; includes `cc-session-num`, a Custom Command widget that shows the current session's rank (`#1`, `#2`, …).
|
||||
- [crispy-recall](https://github.com/TheSylvester/crispy-recall) - Searchable memory for your Claude Code and Codex sessions. Local, fast, no daemon.
|
||||
- [statuslin.es](https://statuslin.es) - Community gallery of Claude Code status lines with live, sandbox-rendered previews.
|
||||
- [claude-carbon](https://github.com/gwittebolle/claude-carbon) - Live CO2 estimate for your Claude Code sessions, next to the cost. Ships a `--segment` mode built to embed as a Custom Command widget.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
|
||||
@@ -86,57 +86,57 @@
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -176,11 +176,11 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.45.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-vLQw8cY7Me6pvTTMkMhOiH9UCuINzfTOETCeDxbGNeNfDqc/7QlxloUH1Ylp/Zz2ek0O8kc6YdygV2vWAPakrA=="],
|
||||
"@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.50.7", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-8KF0w9s/fxqTp/V4Z/AHRe1BztdKaVYJy25ovdyrJdTVROhXhk65aI9/useIi0o4F7XgXxCkN7zC9LiAPCgJsg=="],
|
||||
|
||||
"@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.45.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-LfKbAMZVkxRS7PpEIVnWOY/l0KcHv+rjO7pYY3O0TPCZvbHWfrnQjn8JPacPIfuq6Yv7r4f8lhcl7yHSynoRkQ=="],
|
||||
"@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.50.7", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-p1+C4vQb1+vJvqcTJNAQ0tljTG1hIm4nzQ6W7w9CIwNlJUVvD87DvXpFKNHP3lfTFpQC8gvfAeSQ1seVlEblQg=="],
|
||||
|
||||
"@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.45.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-Bobi6AaQYEc7TWmPJ8Q0/hcUtBN7pLUC2qjoC7oZR4FcGqGztby6k7A1SWlmswoMOEIhYsOrgDaemrSDAC0QVQ=="],
|
||||
"@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.50.7", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-nYhphp1q9xSBRL4rhMg2yw63Krw2lAy4i7Lv6WjqWYabJayOd5ckHV7jB6Rn8A0SeqdUl5fwwia5uRJGcujNrg=="],
|
||||
|
||||
"@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="],
|
||||
|
||||
@@ -200,49 +200,49 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
|
||||
|
||||
"@package-json/types": ["@package-json/types@0.0.12", "", {}, "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw=="],
|
||||
"@remotion/bundler": ["@remotion/bundler@4.0.487", "", { "dependencies": { "@remotion/media-parser": "4.0.487", "@remotion/studio": "4.0.487", "@remotion/studio-shared": "4.0.487", "@remotion/timeline-utils": "4.0.487", "@rspack/core": "1.7.11", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.1", "react-refresh": "0.18.0", "remotion": "4.0.487", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-Mn+MPYB2y6dSkxQAWPDG1ZXvycTr8G/DZT+0eIZklgntfP0a+d2+dlFHKFcwx19y0rtG+b7Eyzjf4Ui8QwSFZg=="],
|
||||
|
||||
"@remotion/bundler": ["@remotion/bundler@4.0.472", "", { "dependencies": { "@remotion/media-parser": "4.0.472", "@remotion/studio": "4.0.472", "@remotion/studio-shared": "4.0.472", "@remotion/timeline-utils": "4.0.472", "@rspack/core": "1.7.6", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.0", "react-refresh": "0.18.0", "remotion": "4.0.472", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-aj5nZkQJfI45HlHZ9wsjG1pRXJ4QsCHT12LUF7k18pT+7ysoDipAFkGNq85pBbBdQ7pSdl6+uJuGzDVFw2Hacg=="],
|
||||
"@remotion/canvas-capture": ["@remotion/canvas-capture@4.0.487", "", { "dependencies": { "mediabunny": "1.50.7", "remotion": "4.0.487" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-EhOSCG/B5QNN4hiIeSoyh7QFIIIWlmuUi5j6iMxiG3//hCvez8ztO6NU0dtJCZXnr04dkq6I6RHA4Qj/HSksFw=="],
|
||||
|
||||
"@remotion/cli": ["@remotion/cli@4.0.472", "", { "dependencies": { "@remotion/bundler": "4.0.472", "@remotion/media-utils": "4.0.472", "@remotion/player": "4.0.472", "@remotion/renderer": "4.0.472", "@remotion/studio": "4.0.472", "@remotion/studio-server": "4.0.472", "@remotion/studio-shared": "4.0.472", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.472" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-A+oSK7LcDzo5A+95/PnjU3tRShbD3opTYd7wtb4Pgv6uO2HFpatmfSzsT0uwkEc0MuETs6bjSt5IMttGk/2cKA=="],
|
||||
"@remotion/cli": ["@remotion/cli@4.0.487", "", { "dependencies": { "@remotion/bundler": "4.0.487", "@remotion/media-utils": "4.0.487", "@remotion/player": "4.0.487", "@remotion/renderer": "4.0.487", "@remotion/studio": "4.0.487", "@remotion/studio-server": "4.0.487", "@remotion/studio-shared": "4.0.487", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.487" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-N9/gWsrvrHrkkpFTMjy39dRIhZm3mUtQRH5en0mqo5W0xCDHRbp+z21b5StV6bJBECyLFUpQjqkzwgsQlvE9sQ=="],
|
||||
|
||||
"@remotion/compositor-darwin-arm64": ["@remotion/compositor-darwin-arm64@4.0.472", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rx1CfDem1AutPYWGqXyV8dFQ+YsCE6pYrj0cLDAtx/5xPazTnMhx1X59SM21legGzBrcMVmXyV7TXoc0psGb+A=="],
|
||||
"@remotion/compositor-darwin-arm64": ["@remotion/compositor-darwin-arm64@4.0.487", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lKxI+rbOt0SL7TDHNoPTHC9L2i6HXNCjivXGTdBXHIxU6a8dGAsT1k/KaubFtZww3xhKa/ubQ+yiaFUKNtZoVg=="],
|
||||
|
||||
"@remotion/compositor-darwin-x64": ["@remotion/compositor-darwin-x64@4.0.472", "", { "os": "darwin", "cpu": "x64" }, "sha512-v8unDWuK52AGIEr9r9g+9G0Q3JNQdnjlKqSw9bBXPI2YkWms4Z5LSfSR5S0BEp7vXFRmhZm7i/3biI6p3P1LDw=="],
|
||||
"@remotion/compositor-darwin-x64": ["@remotion/compositor-darwin-x64@4.0.487", "", { "os": "darwin", "cpu": "x64" }, "sha512-scdI7B5CXMWIQ+YvTedCRdV2olV91fT7PF1Zzecbt+UxTDJMIiIRbJ+O4KnfjUQzEnWFytfY/2U0cnpua07I6g=="],
|
||||
|
||||
"@remotion/compositor-linux-arm64-gnu": ["@remotion/compositor-linux-arm64-gnu@4.0.472", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nv+4zePiAn5lYAy4qZKHBTeIoRjoXkatucv72OXC54Q3spkjOravOVGalxbGm021rMMGtJw0JoL0Sq4HyNjzbg=="],
|
||||
"@remotion/compositor-linux-arm64-gnu": ["@remotion/compositor-linux-arm64-gnu@4.0.487", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZlUJXmiMbmlMK4x+FVQ3xDgOmjyuqn4fZx18ADGF4XuKcsZEJkI4wMB14Eqx+2x+N9ZXJd6h19CJtflKCFFEMQ=="],
|
||||
|
||||
"@remotion/compositor-linux-arm64-musl": ["@remotion/compositor-linux-arm64-musl@4.0.472", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zal+fYYQQTaymg8IxaZKQzotSWfGYfucNyX0M+uRJZzZ0PIfSqSbk6NyfTqIq3Fvc9r1Lb95ZXvWOGPZxAWdJQ=="],
|
||||
"@remotion/compositor-linux-arm64-musl": ["@remotion/compositor-linux-arm64-musl@4.0.487", "", { "os": "linux", "cpu": "arm64" }, "sha512-GJesTGibT/qeet6EivfKWkGE0wd4/pRP/Je8cEnjoXZ6DGye39TaTh0StzpeLAnh0GtOAeSAFhZYfgmRmrqLcQ=="],
|
||||
|
||||
"@remotion/compositor-linux-x64-gnu": ["@remotion/compositor-linux-x64-gnu@4.0.472", "", { "os": "linux", "cpu": "x64" }, "sha512-cxjPknG9xeqvHX3eDxcOwKjpF3Yghtpel7QIJkZGZ0pZn0CHA8Q90pHIfWbX5vZ2PuBY9VMd84KafdCi+iXofQ=="],
|
||||
"@remotion/compositor-linux-x64-gnu": ["@remotion/compositor-linux-x64-gnu@4.0.487", "", { "os": "linux", "cpu": "x64" }, "sha512-8BCUsu0v3G5ZH3TaSRt+1WO5IdWu/2VuoMi1JVqyKS05sL1IWL5iApa1/7slnCYCyuJDUvHuyekWIFcZ4tHy6A=="],
|
||||
|
||||
"@remotion/compositor-linux-x64-musl": ["@remotion/compositor-linux-x64-musl@4.0.472", "", { "os": "linux", "cpu": "x64" }, "sha512-b5005WFifrfxsViyReGBEn04I2B/rLe7RQQpNNhl+qr/X3l0ouWf9rqZxB5IU5Sut1iLRCHrPdlAW6M3HgaFCw=="],
|
||||
"@remotion/compositor-linux-x64-musl": ["@remotion/compositor-linux-x64-musl@4.0.487", "", { "os": "linux", "cpu": "x64" }, "sha512-jYqDZm4Flq1hyQpjE07W/WyVuo6VA4ENyMX43DaBpezObezqD6+7Ll6sx3J0Sxk+Wu9i672PsHP8aDz7bWdCQw=="],
|
||||
|
||||
"@remotion/compositor-win32-x64-msvc": ["@remotion/compositor-win32-x64-msvc@4.0.472", "", { "os": "win32", "cpu": "x64" }, "sha512-hvWIh4qWujTBr8SxJhbzPMSLYoWqjQShWtc3zRfORtziLulc1lf61MdXGPaUYn5RGJMF1BgoVo0pctmJ5ZAdeA=="],
|
||||
"@remotion/compositor-win32-x64-msvc": ["@remotion/compositor-win32-x64-msvc@4.0.487", "", { "os": "win32", "cpu": "x64" }, "sha512-vzB5H5K2pCjyApem6tfKk1XJB7uLCHvV8L5ICqVrWDA7WhiIkKsq4tOmDV+uWH9TtcHEmddnt9s81NqXC97nNg=="],
|
||||
|
||||
"@remotion/licensing": ["@remotion/licensing@4.0.472", "", {}, "sha512-U4HDRXZHA+Jm7n8SErFBIwfUvKm72DB5w9tuddRmPTBbvZGvl6XCmdndDcO6Wy/1QHAPbjIfhy912nWUVz5HGg=="],
|
||||
"@remotion/licensing": ["@remotion/licensing@4.0.487", "", {}, "sha512-X2yUOZ76YdMVLzCet7H4C9haeAHCceCS6rHZHpuTNb011aZEwJ552AUzc0MZQ0BkWffiJMOxbAT7ImSmcICdJQ=="],
|
||||
|
||||
"@remotion/media-parser": ["@remotion/media-parser@4.0.472", "", {}, "sha512-tgk83JXQCkrZDToYR3uaSxmWcy1jFuy0B5zWYZB58ocskuR4ZxJwFjjyJT7cPlQd7gWZG9RTET2xtdsffycKYg=="],
|
||||
"@remotion/media-parser": ["@remotion/media-parser@4.0.487", "", {}, "sha512-X9umS/HyINqxfF22juNNgdtBierqnigFSC8gpb7IggD7ru2lL33SwlTDHgDKf3nA2UFKA558yAbXvD30FdS6ig=="],
|
||||
|
||||
"@remotion/media-utils": ["@remotion/media-utils@4.0.472", "", { "dependencies": { "mediabunny": "1.45.0", "remotion": "4.0.472" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-Hz2Ght6qiNg95Uzw/FovybhExHnS0WZPT/rxt4z/y0HrunemyCrSE8XOXNdXxqnVwHGRdfxJ+wlJflXuZQpX1w=="],
|
||||
"@remotion/media-utils": ["@remotion/media-utils@4.0.487", "", { "dependencies": { "mediabunny": "1.50.7", "remotion": "4.0.487" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-IDXP1Dt3lwJUwyyN2LuoIdb7AH1Q94HyOBr2giuTvRJ6SltdVNLDrdGE2DjMZOg2FIwUjE4BtsC0r9XZVHboiA=="],
|
||||
|
||||
"@remotion/player": ["@remotion/player@4.0.472", "", { "dependencies": { "remotion": "4.0.472" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-YVQDIKzIpLLgHoufoTn9FogLfwrOOvm2UWvFHVLVKNtXp6LFjWaRPoa7vv/a4kZdqyv14Y0r2/1N93CpzoHEZQ=="],
|
||||
"@remotion/player": ["@remotion/player@4.0.487", "", { "dependencies": { "remotion": "4.0.487" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-H30556hXY+hSIuTR2NatMaBo5U5YzhHsXzbQaN71fItP5/e7hE3HQoLPXAGYd9LxDbl3mjRd4URm1C4jaBAc2A=="],
|
||||
|
||||
"@remotion/renderer": ["@remotion/renderer@4.0.472", "", { "dependencies": { "@remotion/licensing": "4.0.472", "@remotion/streaming": "4.0.472", "execa": "5.1.1", "remotion": "4.0.472", "source-map": "0.8.0-beta.0", "ws": "8.20.1" }, "optionalDependencies": { "@remotion/compositor-darwin-arm64": "4.0.472", "@remotion/compositor-darwin-x64": "4.0.472", "@remotion/compositor-linux-arm64-gnu": "4.0.472", "@remotion/compositor-linux-arm64-musl": "4.0.472", "@remotion/compositor-linux-x64-gnu": "4.0.472", "@remotion/compositor-linux-x64-musl": "4.0.472", "@remotion/compositor-win32-x64-msvc": "4.0.472" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-SdYGr2Qw2PYTN6OCdssU41fs8i8ql3mv9A7KISc7rVK43dKjl0QUmgKxnbqng8E0mTFOufUMHkH8O+7eRXaYgQ=="],
|
||||
"@remotion/renderer": ["@remotion/renderer@4.0.487", "", { "dependencies": { "@remotion/licensing": "4.0.487", "@remotion/streaming": "4.0.487", "execa": "5.1.1", "remotion": "4.0.487", "source-map": "0.8.0-beta.0", "ws": "8.21.0" }, "optionalDependencies": { "@remotion/compositor-darwin-arm64": "4.0.487", "@remotion/compositor-darwin-x64": "4.0.487", "@remotion/compositor-linux-arm64-gnu": "4.0.487", "@remotion/compositor-linux-arm64-musl": "4.0.487", "@remotion/compositor-linux-x64-gnu": "4.0.487", "@remotion/compositor-linux-x64-musl": "4.0.487", "@remotion/compositor-win32-x64-msvc": "4.0.487" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-URihID0oP9PP7wNwfcKg1eut4RfWd4eF7s5s9AJ8pC8lHrdLWh+QqD3OkR3GiM7w45HvMvMCIl6KAJx5ln/85Q=="],
|
||||
|
||||
"@remotion/streaming": ["@remotion/streaming@4.0.472", "", {}, "sha512-sx2fhVhPwYj6mVoqP4WZUbTC0Pl7xpuiftentSSPtQtmmZ3NP9kPlsBL850JooDHVOS2q5KZPfp+aGkZnIEQ0w=="],
|
||||
"@remotion/streaming": ["@remotion/streaming@4.0.487", "", {}, "sha512-BH7i1YXuQagAsOuhsuj7FccCYj8jriAPkaPFJ/SaApCj/yqoiUkBibY0OJ94nQbFXxDEnAuQYYd+MYanNpLUFQ=="],
|
||||
|
||||
"@remotion/studio": ["@remotion/studio@4.0.472", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@remotion/media-utils": "4.0.472", "@remotion/player": "4.0.472", "@remotion/renderer": "4.0.472", "@remotion/studio-shared": "4.0.472", "@remotion/timeline-utils": "4.0.472", "@remotion/web-renderer": "4.0.472", "@remotion/zod-types": "4.0.472", "mediabunny": "1.45.0", "memfs": "3.4.3", "open": "8.4.2", "remotion": "4.0.472", "semver": "7.5.3", "zod": "4.3.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-BJnZI1BG9v4D1x3MNY/3AvqtjuBWPt77+Y4zK4PTX4Ap2HSnT2H/bu7bxEv1mmBF/7fusFV+BmdQL18IMqGB/A=="],
|
||||
"@remotion/studio": ["@remotion/studio@4.0.487", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@remotion/canvas-capture": "4.0.487", "@remotion/media-utils": "4.0.487", "@remotion/player": "4.0.487", "@remotion/renderer": "4.0.487", "@remotion/studio-shared": "4.0.487", "@remotion/timeline-utils": "4.0.487", "@remotion/web-renderer": "4.0.487", "@remotion/zod-types": "4.0.487", "mediabunny": "1.50.7", "memfs": "3.4.3", "open": "8.4.2", "remotion": "4.0.487", "semver": "7.5.3", "zod": "4.3.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-q31aGiPuHOP8FGDTdHNdc2UkEgkybSEi0cCo5OgyZOjoQVVrQz333NYrDHlgDnISuuhksksSoy0O3ugGREHhYg=="],
|
||||
|
||||
"@remotion/studio-server": ["@remotion/studio-server@4.0.472", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/bundler": "4.0.472", "@remotion/renderer": "4.0.472", "@remotion/studio-shared": "4.0.472", "memfs": "3.4.3", "open": "8.4.2", "prettier": "3.8.1", "recast": "0.23.11", "remotion": "4.0.472", "semver": "7.5.3" } }, "sha512-GI78aqsM9ntaiFeitYn+B7mOMVMQwIJnyaN/jExZ+tw8/HiQSn1qjt6mvznvMnCTeQRp6wgPmyThoXleBTgoCQ=="],
|
||||
"@remotion/studio-server": ["@remotion/studio-server@4.0.487", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/bundler": "4.0.487", "@remotion/renderer": "4.0.487", "@remotion/studio-shared": "4.0.487", "memfs": "3.4.3", "open": "8.4.2", "prettier": "3.8.1", "recast": "0.23.11", "remotion": "4.0.487", "semver": "7.5.3" } }, "sha512-F5/ct2R855EtQwJw9RRt4PUVGB88SbAa4cICXY37zznxSNbS0mF2/1i6kVlq18S7BskHvosVVfC1PMojGgy3+g=="],
|
||||
|
||||
"@remotion/studio-shared": ["@remotion/studio-shared@4.0.472", "", { "dependencies": { "remotion": "4.0.472" } }, "sha512-JP5SDmfJ+WPbYTpXauZ7OF2Sk0ppKdKSk2NGLE6EASiLTVg9edt1wxat7aP5eHAnkpHbEicCQrYVvSN0lPQORg=="],
|
||||
"@remotion/studio-shared": ["@remotion/studio-shared@4.0.487", "", { "dependencies": { "remotion": "4.0.487" } }, "sha512-yW1oSiP/5Djt1rBEGb3fg6vkg5bGyjNodruT+hiw0tLkVkj4RJnenfl3nzqC0mt0xFxYMeeUtCF8vaCffkWXFg=="],
|
||||
|
||||
"@remotion/timeline-utils": ["@remotion/timeline-utils@4.0.472", "", { "dependencies": { "mediabunny": "1.45.0" } }, "sha512-MIJ5+45qvEHubFh5Lb5Jt15CagsEatLRXYhpV/re1DC2GkdnAkdbHqk50yUuw10zYKa8qZyDQrrVD0qM5HL5kQ=="],
|
||||
"@remotion/timeline-utils": ["@remotion/timeline-utils@4.0.487", "", { "dependencies": { "mediabunny": "1.50.7" } }, "sha512-M7WzKzeJ35ni5h/inzukbbNzq5LfElW5KuM3eurj+IESU/RYhSvMgXYItD+lR1wpXA3KoQ5n7SkGGEuMgO+/uQ=="],
|
||||
|
||||
"@remotion/web-renderer": ["@remotion/web-renderer@4.0.472", "", { "dependencies": { "@mediabunny/aac-encoder": "1.45.0", "@mediabunny/flac-encoder": "1.45.0", "@mediabunny/mp3-encoder": "1.45.0", "@remotion/licensing": "4.0.472", "mediabunny": "1.45.0", "remotion": "4.0.472" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ufiFmgQ4dcF8MuKEIZ3WJHU8PyvAQgcPoyOsWUNXHEZoVz9cD0t6efDF67HEOb/sl6sd/1n2li+CRzvmBi+YUA=="],
|
||||
"@remotion/web-renderer": ["@remotion/web-renderer@4.0.487", "", { "dependencies": { "@mediabunny/aac-encoder": "1.50.7", "@mediabunny/flac-encoder": "1.50.7", "@mediabunny/mp3-encoder": "1.50.7", "@remotion/licensing": "4.0.487", "mediabunny": "1.50.7", "remotion": "4.0.487" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ndmbbC+cNEQauuhoyR/rNj92wfBpNHUB2J1IBfsxSyaoDQPLmXmwDAsqnPvBip6b+pbKi7gqwOpL4gwFSTws0w=="],
|
||||
|
||||
"@remotion/zod-types": ["@remotion/zod-types@4.0.472", "", { "dependencies": { "remotion": "4.0.472" }, "peerDependencies": { "zod": "4.3.6" } }, "sha512-5exoKjC+7gl2Xaht8uKnhUlwBpDBLvaX6NTzVzhUOvMbWd19JWKFDyjRxZiHKGmz5P2STaN/p4oCU3dNKFmi5w=="],
|
||||
"@remotion/zod-types": ["@remotion/zod-types@4.0.487", "", { "dependencies": { "remotion": "4.0.487" } }, "sha512-dlfSVprnYFmKvYiQIKmBNk0ZvEd8b8oA07OfxSSq5/GlpefvQceFQpV3iqGR6gHAQj8E7/o305nXtgR6zxUTGg=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
|
||||
|
||||
@@ -276,29 +276,29 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="],
|
||||
|
||||
"@rspack/binding": ["@rspack/binding@1.7.6", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.6", "@rspack/binding-darwin-x64": "1.7.6", "@rspack/binding-linux-arm64-gnu": "1.7.6", "@rspack/binding-linux-arm64-musl": "1.7.6", "@rspack/binding-linux-x64-gnu": "1.7.6", "@rspack/binding-linux-x64-musl": "1.7.6", "@rspack/binding-wasm32-wasi": "1.7.6", "@rspack/binding-win32-arm64-msvc": "1.7.6", "@rspack/binding-win32-ia32-msvc": "1.7.6", "@rspack/binding-win32-x64-msvc": "1.7.6" } }, "sha512-/NrEcfo8Gx22hLGysanrV6gHMuqZSxToSci/3M4kzEQtF5cPjfOv5pqeLK/+B6cr56ul/OmE96cCdWcXeVnFjQ=="],
|
||||
"@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="],
|
||||
|
||||
"@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NZ9AWtB1COLUX1tA9HQQvWpTy07NSFfKBU8A6ylWd5KH8AePZztpNgLLAVPTuNO4CZXYpwcoclf8jG/luJcQdQ=="],
|
||||
"@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="],
|
||||
|
||||
"@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-J2g6xk8ZS7uc024dNTGTHxoFzFovAZIRixUG7PiciLKTMP78svbSSWrmW6N8oAsAkzYfJWwQpVgWfFNRHvYxSw=="],
|
||||
"@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="],
|
||||
|
||||
"@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w=="],
|
||||
"@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="],
|
||||
|
||||
"@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA=="],
|
||||
"@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="],
|
||||
|
||||
"@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.6", "", { "os": "linux", "cpu": "x64" }, "sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og=="],
|
||||
"@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="],
|
||||
|
||||
"@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.6", "", { "os": "linux", "cpu": "x64" }, "sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA=="],
|
||||
"@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="],
|
||||
|
||||
"@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.6", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg=="],
|
||||
"@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="],
|
||||
|
||||
"@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-INj7aVXjBvlZ84kEhSK4kJ484ub0i+BzgnjDWOWM1K+eFYDZjLdAsQSS3fGGXwVc3qKbPIssFfnftATDMTEJHQ=="],
|
||||
"@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="],
|
||||
|
||||
"@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-lXGvC+z67UMcw58In12h8zCa9IyYRmuptUBMItQJzu+M278aMuD1nETyGLL7e4+OZ2lvrnnBIcjXN1hfw2yRzw=="],
|
||||
"@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="],
|
||||
|
||||
"@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.6", "", { "os": "win32", "cpu": "x64" }, "sha512-zeUxEc0ZaPpmaYlCeWcjSJUPuRRySiSHN23oJ2Xyw0jsQ01Qm4OScPdr0RhEOFuK/UE+ANyRtDo4zJsY52Hadw=="],
|
||||
"@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="],
|
||||
|
||||
"@rspack/core": ["@rspack/core@1.7.6", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.6", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q=="],
|
||||
"@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="],
|
||||
|
||||
"@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="],
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
|
||||
"@types/pluralize": ["@types/pluralize@0.0.33", "", {}, "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="],
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
@@ -356,25 +356,25 @@
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.60.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.0", "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.62.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.62.1", "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="],
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.62.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.60.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.0", "@typescript-eslint/tsconfig-utils": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g=="],
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.62.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.62.1", "@typescript-eslint/tsconfig-utils": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="],
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g=="],
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
|
||||
|
||||
@@ -414,19 +414,19 @@
|
||||
|
||||
"@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@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="],
|
||||
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="],
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="],
|
||||
"@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="],
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="],
|
||||
"@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
|
||||
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
|
||||
|
||||
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
||||
|
||||
@@ -616,23 +616,21 @@
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.4.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.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", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw=="],
|
||||
"eslint": ["eslint@10.6.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.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", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
|
||||
|
||||
"eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@4.4.5", "", { "dependencies": { "debug": "^4.4.1", "eslint-import-context": "^0.1.8", "get-tsconfig": "^4.10.1", "is-bun-module": "^2.0.0", "stable-hash-x": "^0.2.0", "tinyglobby": "^0.2.14", "unrs-resolver": "^1.7.11" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw=="],
|
||||
|
||||
"eslint-plugin-import-newlines": ["eslint-plugin-import-newlines@2.0.0", "", { "peerDependencies": { "eslint": ">=10.0.0" }, "bin": { "import-linter": "lib/index.js" } }, "sha512-xKcuSkpQvkAHWCvAysqCk8GAD+rabLokiK4rmeJjCB+CQtGn6Ptgs909miphvN51JyZxOJWz4reGMsoHSbjbIg=="],
|
||||
|
||||
"eslint-plugin-import-x": ["eslint-plugin-import-x@4.16.2", "", { "dependencies": { "@package-json/types": "^0.0.12", "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.1.2", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw=="],
|
||||
"eslint-plugin-import-x": ["eslint-plugin-import-x@4.17.1", "", { "dependencies": { "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.1.2", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -714,7 +712,7 @@
|
||||
|
||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||
|
||||
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
|
||||
"globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
||||
@@ -880,7 +878,7 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||
"linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="],
|
||||
|
||||
"loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="],
|
||||
|
||||
@@ -896,13 +894,13 @@
|
||||
|
||||
"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.1", "", { "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-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||
"markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||
|
||||
"mediabunny": ["mediabunny@1.45.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-oK3sMMYbucoF6LUX62L/2M9d+p9ve6KDQgL87kNfhsB0/XmTe9iRLUcgQgg9Gpgvi8Sb96zYfOUL6i17y0bdNg=="],
|
||||
"mediabunny": ["mediabunny@1.50.7", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-mv+FXkQNOobx4b43GJV1t0n8cx26d8nZpBMtCTaNKk6xzVpNPrwrbDq+dggm7Zpjvtx2ycgtYciFcySUsO5QGA=="],
|
||||
|
||||
"memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="],
|
||||
|
||||
@@ -1024,7 +1022,7 @@
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"remotion": ["remotion@4.0.472", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-HxzK5lakpZZyb5Gc+0awg9GLPLsRJo+5uomHknZFbfBWHsFRmdGvEYCiYlvwVZpg/iJNgU1wbSZLuyjCJGxg5A=="],
|
||||
"remotion": ["remotion@4.0.487", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-nhLgHDDxlMhCRpuYI9+bTnqXcY0ui7ZjagXdITKc1j1+6o3myEi8QRCl9fFYlhtgNf0D5dJ5Dzl8pmYCK39KXg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
@@ -1158,11 +1156,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.19", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.5", "yaml": "^2.8.3" }, "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 || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw=="],
|
||||
"typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "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 || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.60.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.60.0", "@typescript-eslint/parser": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw=="],
|
||||
"typescript-eslint": ["typescript-eslint@8.62.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.62.1", "@typescript-eslint/parser": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/utils": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw=="],
|
||||
|
||||
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||
|
||||
@@ -1180,7 +1178,7 @@
|
||||
|
||||
"vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="],
|
||||
|
||||
"vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.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.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="],
|
||||
"vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.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.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
|
||||
|
||||
"watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="],
|
||||
|
||||
@@ -1214,7 +1212,7 @@
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
@@ -1248,7 +1246,7 @@
|
||||
|
||||
"@eslint/config-array/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@remotion/renderer/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
|
||||
"@remotion/renderer/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"@remotion/studio/semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="],
|
||||
|
||||
@@ -1262,33 +1260,33 @@
|
||||
|
||||
"@rspack/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1" } }, "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.62.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1" } }, "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
|
||||
|
||||
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
|
||||
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="],
|
||||
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.62.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
@@ -1304,13 +1302,9 @@
|
||||
|
||||
"eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-import-resolver-node/resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"eslint-import-resolver-typescript/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"eslint-plugin-import-x/@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
|
||||
"eslint-plugin-import-x/@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="],
|
||||
|
||||
"eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
@@ -1338,7 +1332,7 @@
|
||||
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.62.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.25.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.0", "@esbuild/android-arm": "0.25.0", "@esbuild/android-arm64": "0.25.0", "@esbuild/android-x64": "0.25.0", "@esbuild/darwin-arm64": "0.25.0", "@esbuild/darwin-x64": "0.25.0", "@esbuild/freebsd-arm64": "0.25.0", "@esbuild/freebsd-x64": "0.25.0", "@esbuild/linux-arm": "0.25.0", "@esbuild/linux-arm64": "0.25.0", "@esbuild/linux-ia32": "0.25.0", "@esbuild/linux-loong64": "0.25.0", "@esbuild/linux-mips64el": "0.25.0", "@esbuild/linux-ppc64": "0.25.0", "@esbuild/linux-riscv64": "0.25.0", "@esbuild/linux-s390x": "0.25.0", "@esbuild/linux-x64": "0.25.0", "@esbuild/netbsd-arm64": "0.25.0", "@esbuild/netbsd-x64": "0.25.0", "@esbuild/openbsd-arm64": "0.25.0", "@esbuild/openbsd-x64": "0.25.0", "@esbuild/sunos-x64": "0.25.0", "@esbuild/win32-arm64": "0.25.0", "@esbuild/win32-ia32": "0.25.0", "@esbuild/win32-x64": "0.25.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw=="],
|
||||
|
||||
@@ -1352,21 +1346,19 @@
|
||||
|
||||
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
|
||||
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1" } }, "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="],
|
||||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
@@ -1378,9 +1370,9 @@
|
||||
|
||||
"schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1" } }, "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="],
|
||||
|
||||
@@ -1436,8 +1428,6 @@
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +57,20 @@ bun run docs
|
||||
|
||||
If you use a custom Claude config location, set `CLAUDE_CONFIG_DIR` and ccstatusline will read/write that path instead of `~/.claude`.
|
||||
|
||||
Settings saves are atomic and preserve symlinked `settings.json` files by writing through the resolved target. Invalid or unreadable settings are never overwritten during load; `loadSettings()` returns in-memory defaults, records `getConfigLoadError()`, and renderer paths surface that state with an invalid-config warning badge. The TUI captures that load error, keeps a visible warning active, and guards both save paths with an overwrite confirmation until a valid configuration is saved.
|
||||
|
||||
Usage-fetch tests spawn subprocess probes. Keep those probes sandboxed by setting `HOME`, `USERPROFILE`, `CLAUDE_CONFIG_DIR`, and proxy variables explicitly so tests cannot read or write a developer's live ccstatusline usage cache.
|
||||
|
||||
## Widget Data Sources
|
||||
|
||||
- **Cache Timer** reads the transcript tail directly on every render. It expands the read backward when a trailing JSONL record exceeds the initial window, ignores sidechain and synthetic API-error rows, and anchors the countdown only on assistant requests with cache activity. It does not create a separate cache file.
|
||||
- **Git CI Status** extends the cached Git PR lookup with GitHub's `statusCheckRollup`. If the authenticated `gh` token cannot read checks, the lookup retries with PR metadata only so the Git PR widget still works.
|
||||
- **Sandbox Status** reads `sandbox.enabled` from Claude Code's layered project-local, project, user-local, and user settings on every refresh. This reflects `/sandbox` file updates but remains a best-effort indicator when managed or CLI settings take precedence.
|
||||
|
||||
## Build Notes
|
||||
|
||||
- Build target is Node.js 14+ (`dist/ccstatusline.js`)
|
||||
- `postbuild` replaces the bundled `__PACKAGE_VERSION__` placeholder from `package.json`; `ccstatusline --version` reads that value and exits before mode detection
|
||||
- During install, `ink@6.2.0` is patched to fix backspace handling on macOS terminals
|
||||
- React and React DOM are exact-version pins; dependency refreshes should update `package.json` and `bun.lock` together
|
||||
|
||||
|
||||
+65
-13
@@ -10,6 +10,7 @@ Once configured, `ccstatusline` automatically formats your Claude Code status li
|
||||
|
||||
- **Interactive mode (TUI)**: Launches when there is no stdin input
|
||||
- **Piped mode (renderer)**: Parses Claude Code status JSON from stdin and prints one or more formatted lines
|
||||
- **Version mode**: Prints the installed ccstatusline package version and exits when passed `--version`
|
||||
|
||||
```bash
|
||||
# Interactive TUI
|
||||
@@ -17,6 +18,9 @@ bun run start
|
||||
|
||||
# Piped mode with example payload
|
||||
bun run example
|
||||
|
||||
# Print the installed package version
|
||||
ccstatusline --version
|
||||
```
|
||||
|
||||
## Available Widgets
|
||||
@@ -26,12 +30,14 @@ bun run example
|
||||
- **Model** / **Output Style** / **Version** - Show the active Claude model, output style, and Claude Code CLI version. Model names omit trailing context suffixes like `(1M context)`; use **Context Window** when you want the total window size shown.
|
||||
- **Claude Session ID** / **Session Name** / **Claude Account Email** - Show session identifiers plus the currently signed-in Claude account email.
|
||||
- **Voice Status** - Show whether Claude Code voice input is enabled. It can render as an icon, icon plus text, plain text, or `voice on/off`, with optional Nerd Font microphone icons.
|
||||
- **Thinking Effort** / **Vim Mode** / **Skills** - Show Claude thinking effort, the current vim editing mode, and skill activity from hook data. Thinking Effort reads live status JSON first, then `/model` or `/effort` transcript output, then settings fallback; it supports `low`, `medium`, `high`, `xhigh`, and `max`, shows `default` when no effort is set, and marks unknown future values with `?`.
|
||||
- **Sandbox Status** - Show the effective `sandbox.enabled` value from Claude Code's layered project and user settings. It can render as a glyph, `SB: ON/OFF`, or `Sandbox: ON/OFF`, with optional Nerd Font lock icons. The value is refreshed after `/sandbox` changes, but is best effort when managed or CLI settings override files or sandbox initialization fails.
|
||||
- **Thinking Effort** / **Vim Mode** / **Skills** - Show Claude thinking effort, the current vim editing mode, and skill activity from hook data. Thinking Effort reads live status JSON first, then `/model` or `/effort` transcript output, then settings fallback; it supports `low`, `medium`, `high`, `xhigh`, and `max`, shows `default` when no effort is set, and marks unknown future values with `?`. Claude Code reports Ultracode as `xhigh` in status line data; it does not expose Ultracode as a separate effort level.
|
||||
- **Session Clock** / **Session Cost** - Show elapsed session time and the current session cost in USD.
|
||||
|
||||
### Git
|
||||
|
||||
- **Git Branch** / **Git Root Dir** / **Git PR** - Show the current branch, repository root directory, and PR/MR details for the current branch with optional links. Works with GitHub (`gh`) and GitLab (`glab`); SSH remote aliases are resolved with `ssh -G` before provider detection, while canonical GitHub/GitLab remotes keep their original forge hosts. For self-hosted hosts whose name contains neither token, whichever CLI is authenticated against that host (`gh auth status --hostname <h>` / `glab auth status --hostname <h>`) is used.
|
||||
- **Git Branch** / **Git Root Dir** / **Git PR** - Show the current branch, repository root directory, and PR/MR details for the current branch with optional links. Git Branch and Git Root Dir can cap their visible labels to a per-widget maximum width; truncation keeps OSC 8 hyperlink targets intact. Works with GitHub (`gh`) and GitLab (`glab`); SSH remote aliases are resolved with `ssh -G` before provider detection, while canonical GitHub/GitLab remotes keep their original forge hosts. For self-hosted hosts whose name contains neither token, whichever CLI is authenticated against that host (`gh auth status --hostname <h>` / `glab auth status --hostname <h>`) is used.
|
||||
- **Git CI Status** - Summarize GitHub checks for the current branch's pull request as failing (`✗`), pending (`●`), and successful (`✓`) counts. Raw-value mode renders `failing`, `pending`, or `passing`; `-` means no pull request or readable check rollup. This widget is GitHub-only and uses the same cached `gh` lookup as Git PR.
|
||||
- **Git Changes** / **Git Insertions** / **Git Deletions** - Show aggregate file-change counts and dedicated insertion/deletion counts.
|
||||
- **Git Status** / **Git Staged** / **Git Unstaged** / **Git Untracked** / **Git Ahead/Behind** / **Git Conflicts** / **Git SHA** - Show compact repo-state indicators, upstream divergence, merge-conflict count, and the current short commit SHA.
|
||||
- **Git Staged Files** / **Git Unstaged Files** / **Git Untracked Files** / **Git Clean Status** - Show file-level status counts and clean/dirty state.
|
||||
@@ -43,16 +49,17 @@ bun run example
|
||||
|
||||
- **Tokens Input** / **Tokens Output** / **Tokens Cached** / **Tokens Total** - Show current-session token counts. Input/output prefer cumulative transcript metrics and fall back to `context_window.total_input_tokens` / `context_window.total_output_tokens` when transcript metrics are unavailable; cached/total use transcript metrics.
|
||||
- **Cache Hit Rate** / **Cache Read** / **Cache Write** - Show prompt-cache efficiency. Cache Hit Rate uses cache reads divided by cache reads plus cache writes; Cache Read and Cache Write include each value's share of prompt context. They default to the latest turn from `context_window.current_usage`, can switch to cumulative session totals, and can hide when empty.
|
||||
- **Cache Timer** - Estimate time remaining before the current prompt-cache entry expires. It shows `HOT` while a main-chain turn is active, then counts down from the latest assistant request with cache activity and becomes `COLD` just before expiry. The default TTL is 5 minutes; it can switch to 1 hour, hide when no cache anchor is available, and customize the glyph for each state. Because Claude Code transcripts expose cache token activity rather than the actual expiry timestamp, the countdown is best effort.
|
||||
- **Input Speed** / **Output Speed** / **Total Speed** - Show session-average token throughput with an optional per-widget rolling window (`0-120` seconds; `0` = full-session average).
|
||||
- **Context Length** / **Context Window** / **Context %** / **Context % (usable)** / **Context Bar** - Show current context length, total context window size, used/remaining percentage, usable-window percentage, or a progress bar.
|
||||
- **Compaction Counter** - Show how many context compactions have been detected in the current session by scanning transcript compaction markers. It can render as icon plus number, text plus number, or number-only, and can hide while the count is zero. Two optional, independent per-item add-ons toggle extra detail: a trigger split (`↻ 3 (2 auto, 1 manual)`; a compaction whose trigger is missing or unrecognized is bucketed as `unknown`) and tokens reclaimed (`↻ 3 ↓887.0k`, each compaction's `preTokens - postTokens` floored at 0 and summed, shown only when greater than 0 — so very old transcripts predating the `postTokens` field display nothing).
|
||||
- **Session Usage** / **Weekly Usage** / **Weekly Sonnet Usage** / **Weekly Opus Usage** / **Extra Usage Utilization** / **Extra Usage Remaining** / **Extra Usage Used** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages, monthly pay-as-you-go overage usage, and current block/reset timing. The all-models weekly bar covers `seven_day` from the usage API; the per-model variants surface the `seven_day_sonnet` and `seven_day_opus` buckets that Claude Code's own `/usage` panel shows. Extra usage widgets accept known extra-usage state as complete when an account has no monthly limit configured, avoid repeated refetches and stale `[Timeout]` output, and format amounts with the API-reported billing currency when available. Session and weekly usage bars can show a time cursor; reset timers can show remaining time, progress, or exact reset date/time with timezone and locale controls.
|
||||
- **Context Length** / **Context Window** / **Context %** / **Context % (usable)** / **Context Bar** - Show current context length, total context window size, used/remaining percentage, usable-window percentage, or a progress bar. The window size is taken from Claude Code's reported `context_window.context_window_size` when present, then from a model-name hint (e.g. a `[1m]` suffix), and finally from a fixed fallback. Set `CCSTATUSLINE_CONTEXT_SIZE_FALLBACK` to a positive integer to override that last-resort fallback (defaults to `200000`) — useful when an older Claude Code does not report the window size for a 1M-context model, so the bar would otherwise read against 200k.
|
||||
- **Compaction Counter** - Show how many context compactions have been detected in the current session by scanning transcript compaction markers. It can render as icon plus number, text plus number, or number-only, and can hide while the count is zero. Two optional, independent per-item add-ons toggle extra detail: a trigger split (`↻ 3 (2 auto, 1 manual)`; a compaction whose trigger is missing or unrecognized is bucketed as `unknown`) and tokens reclaimed (`↻ 3 ↓887.0k`, each compaction's `preTokens - postTokens` floored at 0 and summed, shown only when greater than 0 — so very old transcripts predating the `postTokens` field display nothing). Its value selector can instead render the total count, one trigger count (`auto`, `manual`, or `unknown`), or reclaimed tokens as a standalone value; hide-when-zero applies to the selected value.
|
||||
- **Session Usage** / **Weekly Usage** / **Weekly Sonnet Usage** / **Weekly Opus Usage** / **Extra Usage Utilization** / **Extra Usage Remaining** / **Extra Usage Used** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages, monthly pay-as-you-go overage usage, and current block/reset timing. The all-models weekly bar covers `seven_day` from the usage API; the per-model variants surface the `seven_day_sonnet` and `seven_day_opus` buckets that Claude Code's own `/usage` panel shows. Session Usage, the weekly percentage widgets, and Extra Usage Utilization can show either used or remaining percentage in every display mode. Session and weekly usage bars can also show a time cursor. Extra usage widgets accept known extra-usage state as complete when an account has no monthly limit configured, avoid repeated refetches and stale `[Timeout]` output, and format amounts with the API-reported billing currency when available. Reset timers can show remaining time, progress, or exact reset date/time with timezone and locale controls; while reset data is still arriving at startup, they show a labeled loading placeholder instead of a transient API error.
|
||||
|
||||
### Environment, Layout & Custom
|
||||
|
||||
- **Current Working Dir** / **Terminal Width** / **Memory Usage** - Show the current working directory, detected terminal width, and system memory usage.
|
||||
- **Current Working Dir** / **Terminal Width** / **Memory Usage** - Show the current working directory, detected terminal width, and system memory usage. Current Working Dir can prepend an optional custom glyph, including when raw-value mode replaces the `cwd:` label.
|
||||
- **Custom Text** / **Custom Symbol** / **Custom Command** / **Link** - Add user-defined text, a single symbol or emoji, custom command output, or a clickable OSC 8 hyperlink.
|
||||
- **Separator** / **Flex Separator** - Add a manual divider or a width-filling flexible spacer (available when Powerline mode is off).
|
||||
- **Separator** / **Flex Separator** - Add a manual divider or a width-filling flexible spacer. Manual separators are disabled in Powerline mode, but flex separators still work there as layout spacers.
|
||||
|
||||
## Terminal Width Options
|
||||
|
||||
@@ -61,6 +68,8 @@ These settings affect where long lines are truncated, and where right-alignment
|
||||
- **Full width minus 40** - Reserves 40 characters for auto-compact message to prevent wrapping (default)
|
||||
- **Full width until compact** - Dynamically switches between full width and minus 40 based on context percentage threshold (configurable, default 60%)
|
||||
|
||||
Flex separators expand against the detected width in both regular and Powerline rendering. If width detection is unavailable, they render like normal separators until a terminal width is available.
|
||||
|
||||
If ccstatusline cannot detect your terminal width, set `CCSTATUSLINE_WIDTH` to a positive integer to override probing:
|
||||
|
||||
```bash
|
||||
@@ -69,6 +78,10 @@ CCSTATUSLINE_WIDTH=160 ccstatusline
|
||||
|
||||
The override is checked before automatic width detection, so it also works in wrapper processes, IDE integrations, nested PTYs, and Windows environments where probing may be unavailable. Invalid values such as `0`, negative numbers, or non-numeric strings are ignored and ccstatusline falls back to normal detection.
|
||||
|
||||
## Powerline Auto-Alignment
|
||||
|
||||
Powerline Setup can align widgets into shared columns across multiple status lines; press `a` there to toggle **Align Widgets**. When auto-alignment makes a naturally wide value stretch later columns, select that widget in the line editor and press `x` (**exclude align**). The selected widget and everything after it on that line keep their natural widths, while earlier columns remain aligned. This control is available only when Powerline auto-alignment is enabled and the selected widget is not merged into the previous widget.
|
||||
|
||||
## Global Options
|
||||
|
||||
Configure global formatting preferences that apply to all widgets:
|
||||
@@ -77,9 +90,11 @@ Configure global formatting preferences that apply to all widgets:
|
||||
|
||||
### Default Padding & Separators
|
||||
|
||||
- **Default Padding** - Add consistent padding to the left and right of each widget
|
||||
- **Default Padding** - Add consistent padding around each widget
|
||||
- **Padding Side** - Choose whether default padding applies to **Both** sides (default), **Left only**, or **Right only**
|
||||
- **Default Separator** - Automatically insert a separator between all widgets
|
||||
- Press **(p)** to edit padding
|
||||
- Press **(d)** to cycle padding side
|
||||
- Press **(s)** to edit separator
|
||||
- Manual separators collapse around widgets that render empty, so hide-when-empty widgets do not leave dangling dividers.
|
||||
|
||||
@@ -106,6 +121,16 @@ Configure global formatting preferences that apply to all widgets:
|
||||
|
||||
> ⚠️ **VSCode Users:** If colors appear incorrect in the VSCode integrated terminal, the "Terminal › Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors.
|
||||
|
||||
## Widget Styling
|
||||
|
||||
The color editor can adjust foreground color, background color, bold, dim, and gradients per widget:
|
||||
|
||||
- Use `←` / `→` to cycle the selected foreground or background color.
|
||||
- Press `f` to switch between foreground and background editing.
|
||||
- Press `b` to toggle bold.
|
||||
- Press `d` to cycle dim styling: off → whole widget → parenthesized text only → off.
|
||||
- Press `r` to reset styling on the selected widget, or `c` to clear styling on every widget in the line.
|
||||
|
||||
## Gradient Colors
|
||||
|
||||
A foreground color can be a multi-stop **gradient** instead of a solid. Colors interpolate in OKLab for perceptually even blends. A gradient value takes one of three forms, all prefixed `gradient:`:
|
||||
@@ -125,6 +150,10 @@ Gradients self-degrade where they can't render: at Basic or No Color levels, gra
|
||||
|
||||
When ccstatusline is installed in Claude Code, the main menu includes **Configure Status Line**. Claude Code versions >=2.1.97 support `statusLine.refreshInterval`; ccstatusline can set it to `1-60` seconds, defaults fresh supported installs to `10` seconds, and removes the setting when the input is left empty.
|
||||
|
||||
## Settings Recovery
|
||||
|
||||
If `settings.json` is unreadable or invalid, ccstatusline leaves the file unchanged, renders with built-in defaults for that run, and prepends an invalid-config warning badge to the status line. The TUI shows the same warning and asks for confirmation before either **Save & Exit** or `Ctrl+S` replaces the invalid file. Fix the JSON to preserve its contents, or confirm the save to replace it with the configuration currently shown in the TUI.
|
||||
|
||||
## Block Timer Widget
|
||||
|
||||
The Block Timer widget helps you track your progress through Claude Code's 5-hour conversation blocks:
|
||||
@@ -166,6 +195,7 @@ Common controls in the line editor:
|
||||
- `Space` cycle a manual separator character
|
||||
- `r` toggle raw value (supported widgets)
|
||||
- `m` cycle merge mode (`off` → `merge` → `merge no padding`)
|
||||
- `x` exclude the selected widget and the rest of its line from shared Powerline column widths (shown only when Powerline auto-alignment is enabled)
|
||||
- `Esc` go back
|
||||
|
||||
Widget picker:
|
||||
@@ -178,22 +208,24 @@ The keybind footer in the TUI only shows shortcuts that apply to the currently s
|
||||
Widget-specific shortcuts:
|
||||
- **Git widgets with empty-state toggles**: `h` hide `no git` / empty output where supported
|
||||
- **Glyph widgets** (Git Branch, Git Worktree, Git Worktree Mode, Git Staged, Git Unstaged, Git Untracked, Git Conflicts, Git Ahead/Behind, Git Status, JJ Bookmarks, JJ Workspace): `g` set custom glyphs for the widget's symbols; Backspace in the editor renders without one, and multi-symbol widgets (Ahead/Behind, Status) edit each part in one list
|
||||
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted)
|
||||
- **Git Root Dir**: `l` cycle IDE links (`off` → `VS Code` → `Cursor`)
|
||||
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted), `w` set a maximum visible width (blank removes the limit)
|
||||
- **Git Root Dir**: `l` cycle IDE links (`off` → `VS Code` → `Cursor`), `w` set a maximum visible width (blank removes the limit)
|
||||
- **Git PR**: `h` hide empty/no-PR/MR output, `s` toggle review status, `t` toggle title (renders "MR" for GitLab origins)
|
||||
- **Git remote widgets** (`Git Origin*` / `Git Upstream*`): `h` hide when no remote, `l` toggle clickable repo links
|
||||
- **Git Origin Owner/Repo**: `o` show only the owner when the repo is a fork
|
||||
- **Git Is Fork**: `h` hide when the repo is not a fork
|
||||
- **Context % widgets**: `u` toggle used vs remaining display, `p` cycle percentage/short bar/short bar only
|
||||
- **Session Usage / Weekly Usage / Weekly Sonnet Usage / Weekly Opus Usage**: `p` cycle percentage/full bar/medium bar/short bar/short bar only, `v` invert fill in progress mode, `t` toggle the time cursor in bar modes
|
||||
- **Session Usage / Weekly Usage / Weekly Sonnet Usage / Weekly Opus Usage / Extra Usage Utilization**: `p` cycle percentage/full bar/medium bar/short bar/short bar only and `u` switch between used and remaining percentage in every display mode. The editor row labels the current direction as `used` or `remaining`, while the `u` helper names the direction it will switch to. Session and weekly usage widgets use `t` to toggle the time cursor in bar modes; Extra Usage Utilization uses `h` to hide itself when extra usage is disabled.
|
||||
- **Block Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `v` invert fill in progress mode
|
||||
- **Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
|
||||
- **Weekly Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle hours-only in time mode or 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
|
||||
- **Context Bar**: `p` cycle medium/full/short/short-only progress bar
|
||||
- **Compaction Counter**: `f` cycle format, `n` toggle Nerd Font icon in icon mode, `s` toggle trigger split (auto/manual/unknown), `t` toggle tokens reclaimed, `h` hide when zero
|
||||
- **Compaction Counter**: `v` cycle value (count/auto/manual/unknown/reclaimed), `f` cycle format, `n` toggle Nerd Font icon in icon mode, `s` toggle trigger split (auto/manual/unknown), `t` toggle tokens reclaimed, `h` hide when zero
|
||||
- **Cache widgets** (Cache Hit Rate, Cache Read, Cache Write): `t` toggle turn/session scope, `h` hide when empty
|
||||
- **Cache Timer**: `t` cycle 5-minute/1-hour TTL, `h` hide when no cache anchor is available, `g` customize the working/fresh/draining/urgent/cold glyphs
|
||||
- **Sandbox Status**: `f` cycle glyph/text/word format, `n` toggle Nerd Font lock icons in glyph mode
|
||||
- **Voice Status**: `f` cycle format, `n` toggle Nerd Font microphone icons
|
||||
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
|
||||
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path, `g` optional leading glyph (off by default; pair with raw value to replace the `cwd:` label with the glyph)
|
||||
- **Skills**: `v` cycle view mode, `h` hide when empty, `l` edit list limit in list mode
|
||||
- **Input Speed / Output Speed / Total Speed**: `w` edit the rolling window in seconds
|
||||
- **Custom Text / Custom Symbol**: `e` edit text or symbol
|
||||
@@ -234,6 +266,7 @@ Execute shell commands and display their output dynamically:
|
||||
- `date +%H:%M` - Display current time
|
||||
- `curl -s wttr.in?format="%t"` - Show current temperature
|
||||
- `npx -y ccusage@latest statusline` - Display Claude usage metrics (set timeout: 5000ms)
|
||||
- `cc-session-num` - Show current session rank (`#1`, `#2`, …) from [ccsessions](https://github.com/treebird7/ccsessions)
|
||||
|
||||
> ⚠️ **Important:** Commands should complete quickly to avoid delays. Long-running commands will be killed after the configured timeout. If you're not seeing output from your custom command, try increasing the timeout value (press 't' in the editor).
|
||||
|
||||
@@ -277,6 +310,25 @@ When every tracked service is operational the command prints nothing, so the wid
|
||||
|
||||
> 📄 **Variants:** See [ai-watch.dev/#statusline](https://ai-watch.dev/#statusline) for count-only, compact, provider-scoped, and clickable OSC 8 link presets.
|
||||
|
||||
## Integration Example: ccsessions
|
||||
|
||||
[ccsessions](https://github.com/treebird7/ccsessions) is a CLI session manager for Claude Code. Its companion script `cc-session-num` shows the current session's rank (`#1`, `#2`, …) matched against the same mtime-sorted list that `ccsessions` uses.
|
||||
|
||||
1. Install `cc-session-num`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/treebird7/ccsessions/main/cc-session-num \
|
||||
-o ~/.local/bin/cc-session-num && chmod +x ~/.local/bin/cc-session-num
|
||||
```
|
||||
|
||||
2. Add a Custom Command widget
|
||||
3. Set command: `cc-session-num`
|
||||
4. Leave timeout at default (the script reads `~/.claude/projects/` locally and returns in milliseconds)
|
||||
|
||||
The widget renders nothing when the current session isn't found, so manual separators collapse around it cleanly.
|
||||
|
||||
> 📄 **How it works:** `cc-session-num` reads `CLAUDE_CODE_SESSION_ID` from the environment (set by Claude Code), then ranks `~/.claude/projects/*/*.jsonl` files by modification time — the same sort order `ccsessions` uses — and prints the matching position.
|
||||
|
||||
## Smart Truncation
|
||||
|
||||
When terminal width is detected, status lines automatically truncate with ellipsis (`...`) if they exceed the available width, preventing line wrapping.
|
||||
|
||||
@@ -71,6 +71,17 @@ The `bunx` and `npx` examples above follow `@latest`. If you choose **Pinned glo
|
||||
|
||||
## Windows-Specific Features
|
||||
|
||||
### GitHub CI Status
|
||||
|
||||
The **Git CI Status** widget requires the GitHub CLI to be installed and authenticated. It is GitHub-only and reads checks from the pull request associated with the current branch.
|
||||
|
||||
```powershell
|
||||
winget install --id GitHub.cli --source winget
|
||||
gh auth login
|
||||
```
|
||||
|
||||
The widget displays `-` when the branch has no pull request, the pull request has no checks, or the authenticated token cannot read the check rollup.
|
||||
|
||||
### Powerline Font Support
|
||||
|
||||
For optimal Powerline rendering on Windows:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ccstatusline",
|
||||
"version": "2.2.21",
|
||||
"version": "2.2.25",
|
||||
"bugs": {
|
||||
"url": "https://github.com/sirmalloc/ccstatusline/issues"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
+63
-5
@@ -17,10 +17,15 @@ import {
|
||||
getCompactionStats
|
||||
} from './utils/compaction';
|
||||
import {
|
||||
getConfigLoadError,
|
||||
initConfigPath,
|
||||
loadSettings,
|
||||
saveSettings
|
||||
} from './utils/config';
|
||||
import {
|
||||
GIT_REVIEW_REFRESH_FLAG,
|
||||
refreshGitReviewCacheFromCli
|
||||
} from './utils/git-review-cache';
|
||||
import { handleHookInput } from './utils/hook-handler';
|
||||
import {
|
||||
getSessionDuration,
|
||||
@@ -29,7 +34,9 @@ import {
|
||||
} from './utils/jsonl';
|
||||
import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index';
|
||||
import {
|
||||
buildConfigWarningBadge,
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
countPowerlineStartCapSlots,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine
|
||||
} from './utils/renderer';
|
||||
@@ -39,7 +46,10 @@ import {
|
||||
getWidgetSpeedWindowSeconds,
|
||||
isWidgetSpeedWindowEnabled
|
||||
} from './utils/speed-window';
|
||||
import { getTerminalWidth } from './utils/terminal';
|
||||
import {
|
||||
getPackageVersion,
|
||||
getTerminalWidth
|
||||
} from './utils/terminal';
|
||||
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
|
||||
|
||||
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
|
||||
@@ -91,6 +101,7 @@ async function ensureWindowsUtf8CodePage() {
|
||||
|
||||
async function renderMultipleLines(data: StatusJSON) {
|
||||
const settings = await loadSettings();
|
||||
const configError = getConfigLoadError();
|
||||
|
||||
// Set global chalk level based on settings
|
||||
chalk.level = settings.colorLevel;
|
||||
@@ -163,7 +174,8 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
terminalWidth: getTerminalWidth(),
|
||||
isPreview: false,
|
||||
minimalist: settings.minimalistMode,
|
||||
gitCacheTtlSeconds: settings.gitCacheTtlSeconds
|
||||
gitCacheTtlSeconds: settings.gitCacheTtlSeconds,
|
||||
gitReviewNeedsChecks: lines.some(line => line.some(item => item.type === 'git-ci-status'))
|
||||
};
|
||||
|
||||
// Always pre-render all widgets once (for efficiency)
|
||||
@@ -173,6 +185,8 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
// Render each line using pre-rendered content
|
||||
let globalSeparatorIndex = 0;
|
||||
let globalPowerlineThemeIndex = 0;
|
||||
let globalPowerlineStartCapIndex = 0;
|
||||
let configBadgePrepended = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineItems = lines[i];
|
||||
if (lineItems && lineItems.length > 0) {
|
||||
@@ -181,14 +195,21 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
...context,
|
||||
lineIndex: i,
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex
|
||||
globalPowerlineThemeIndex,
|
||||
globalPowerlineStartCapIndex
|
||||
};
|
||||
const line = renderStatusLine(lineItems, settings, lineContext, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
let line = renderStatusLine(lineItems, settings, lineContext, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
|
||||
// Only output the line if it has content (not just ANSI codes)
|
||||
// Strip ANSI codes to check if there's actual text
|
||||
const strippedLine = getVisibleText(line).trim();
|
||||
if (strippedLine.length > 0) {
|
||||
if (configError && !configBadgePrepended) {
|
||||
// On the error path settings are always inMemoryDefaults(), whose separators render as ' | '.
|
||||
line = `${buildConfigWarningBadge(settings.colorLevel)} | ${line}`;
|
||||
configBadgePrepended = true;
|
||||
}
|
||||
|
||||
// Replace all spaces with non-breaking spaces to prevent VSCode trimming
|
||||
let outputLine = line.replace(/ /g, '\u00A0');
|
||||
|
||||
@@ -196,7 +217,10 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
outputLine = '\x1b[0m' + outputLine;
|
||||
console.log(outputLine);
|
||||
|
||||
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
|
||||
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems, preRenderedWidgets);
|
||||
if (settings.powerline.enabled) {
|
||||
globalPowerlineStartCapIndex += countPowerlineStartCapSlots(lineItems, preRenderedWidgets);
|
||||
}
|
||||
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
|
||||
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
|
||||
}
|
||||
@@ -204,6 +228,11 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive fallback: if no content line was emitted, ensure the warning is not lost
|
||||
if (configError && !configBadgePrepended) {
|
||||
console.log('\x1b[0m' + buildConfigWarningBadge(settings.colorLevel).replace(/ /g, '\u00A0'));
|
||||
}
|
||||
|
||||
// Check if there's an update message to display
|
||||
if (settings.updatemessage?.message
|
||||
&& settings.updatemessage.message.trim() !== ''
|
||||
@@ -252,7 +281,36 @@ async function handleHook(): Promise<void> {
|
||||
handleHookInput(input);
|
||||
}
|
||||
|
||||
function handleGitReviewRefresh(): boolean {
|
||||
const flagIndex = process.argv.indexOf(GIT_REVIEW_REFRESH_FLAG);
|
||||
if (flagIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cwd = process.argv[flagIndex + 1];
|
||||
const mode = process.argv[flagIndex + 2];
|
||||
const lockPath = process.argv[flagIndex + 3];
|
||||
if (!cwd || (mode !== 'metadata' && mode !== 'checks') || !lockPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
refreshGitReviewCacheFromCli(cwd, { includeChecks: mode === 'checks' }, lockPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Detached cache refreshes re-enter this executable without reading stdin
|
||||
// or loading user settings. This mode intentionally emits no output.
|
||||
if (handleGitReviewRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Print version and exit (#461). Standard CLI behavior, runs before any other mode.
|
||||
if (process.argv.includes('--version')) {
|
||||
console.log(getPackageVersion());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Parse --config before anything else
|
||||
initConfigPath(parseConfigArg());
|
||||
|
||||
|
||||
+110
-16
@@ -34,6 +34,7 @@ import {
|
||||
} from '../utils/claude-settings';
|
||||
import { cloneSettings } from '../utils/clone-settings';
|
||||
import {
|
||||
getConfigLoadError,
|
||||
getConfigPath,
|
||||
isCustomConfigPath,
|
||||
loadSettings,
|
||||
@@ -122,7 +123,7 @@ type AppScreen = 'main'
|
||||
|
||||
type PinnedVersionMismatchAction = 'update' | 'exit';
|
||||
|
||||
interface ConfirmDialogState {
|
||||
export interface ConfirmDialogState {
|
||||
message: string;
|
||||
action: () => Promise<void>;
|
||||
cancelScreen?: Exclude<AppScreen, 'confirm'>;
|
||||
@@ -413,11 +414,38 @@ export function clearInstallMenuSelection(menuSelections: Record<string, number>
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildConfigLoadWarning(configLoadError: string | null): string | null {
|
||||
if (!configLoadError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `⚠ ${configLoadError} — showing defaults; saving here overwrites the file.`;
|
||||
}
|
||||
|
||||
export function buildInvalidConfigSaveConfirm(
|
||||
configLoadError: string | null,
|
||||
onConfirm: () => void
|
||||
): ConfirmDialogState | null {
|
||||
if (!configLoadError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
message: `${configLoadError} and is preserved on disk. Saving replaces it with the current configuration. Continue?`,
|
||||
action: () => {
|
||||
onConfirm();
|
||||
return Promise.resolve();
|
||||
},
|
||||
cancelScreen: 'main'
|
||||
};
|
||||
}
|
||||
|
||||
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 [configLoadError, setConfigLoadError] = useState<string | null>(null);
|
||||
const [screen, setScreen] = useState<AppScreen>('main');
|
||||
const [selectedLine, setSelectedLine] = useState(0);
|
||||
const [menuSelections, setMenuSelections] = useState<Record<string, number>>({});
|
||||
@@ -458,6 +486,10 @@ export const App: React.FC = () => {
|
||||
chalk.level = loadedSettings.colorLevel;
|
||||
setSettings(loadedSettings);
|
||||
setOriginalSettings(cloneSettings(loadedSettings));
|
||||
// Capture why settings.json was rejected (if at all) so the TUI can warn and
|
||||
// guard saves. Read it here, in the load callback: the module-scoped signal is
|
||||
// reset by any later loadSettings/saveInstallationMetadata call.
|
||||
setConfigLoadError(getConfigLoadError());
|
||||
});
|
||||
void isInstalled()
|
||||
.then(setIsClaudeInstalled)
|
||||
@@ -508,7 +540,7 @@ export const App: React.FC = () => {
|
||||
exit();
|
||||
}
|
||||
// Global save shortcut
|
||||
if (key.ctrl && input === 's' && settings) {
|
||||
if (key.ctrl && input === 's' && settings && screen !== 'confirm') {
|
||||
const installation = getCurrentInstallation(isClaudeInstalled, existingStatusLine, settings);
|
||||
const activeCommand = installation.method === 'pinned' || installation.method === 'self-managed'
|
||||
? inspectActiveGlobalCommand({ commandAvailability })
|
||||
@@ -519,15 +551,41 @@ export const App: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(cloneSettings(settings));
|
||||
setHasChanges(false);
|
||||
setFlashMessage({
|
||||
text: '✓ Configuration saved',
|
||||
color: 'green'
|
||||
});
|
||||
})();
|
||||
const performSave = () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(cloneSettings(settings));
|
||||
setHasChanges(false);
|
||||
// File is valid again after an explicit save → clear the banner + guard.
|
||||
setConfigLoadError(null);
|
||||
setFlashMessage({
|
||||
text: '✓ Configuration saved',
|
||||
color: 'green'
|
||||
});
|
||||
} catch {
|
||||
setFlashMessage({
|
||||
text: '✗ Could not save configuration',
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const saveGuard = buildInvalidConfigSaveConfirm(configLoadError, () => {
|
||||
// The confirm dialog doesn't self-dismiss; its action must navigate away
|
||||
// (matching the other confirm flows in this file). Return to the main menu
|
||||
// before saving so the success flash isn't hidden behind the dialog.
|
||||
setConfirmDialog(null);
|
||||
setScreen('main');
|
||||
performSave();
|
||||
});
|
||||
if (saveGuard) {
|
||||
setConfirmDialog(saveGuard);
|
||||
setScreen('confirm');
|
||||
} else {
|
||||
performSave();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -573,6 +631,10 @@ export const App: React.FC = () => {
|
||||
installationMetadata: selection.metadata
|
||||
});
|
||||
|
||||
// Install re-ran loadSettings internally — re-sync the captured
|
||||
// config-load error so the banner/guard reflect the file's current state.
|
||||
setConfigLoadError(getConfigLoadError());
|
||||
|
||||
const installedStatusLineState = await loadClaudeStatusLineState();
|
||||
setIsClaudeInstalled(true);
|
||||
setExistingStatusLine(installedStatusLineState.existingStatusLine ?? finalCommand);
|
||||
@@ -660,6 +722,7 @@ export const App: React.FC = () => {
|
||||
};
|
||||
|
||||
await saveInstallationMetadata(installation);
|
||||
setConfigLoadError(getConfigLoadError());
|
||||
setSettings(prev => prev
|
||||
? { ...prev, installation }
|
||||
: prev);
|
||||
@@ -726,6 +789,7 @@ export const App: React.FC = () => {
|
||||
};
|
||||
|
||||
await saveInstallationMetadata(installation);
|
||||
setConfigLoadError(getConfigLoadError());
|
||||
setSettings(prev => prev
|
||||
? { ...prev, installation }
|
||||
: prev);
|
||||
@@ -767,6 +831,7 @@ export const App: React.FC = () => {
|
||||
|
||||
try {
|
||||
await uninstallStatusLine();
|
||||
setConfigLoadError(getConfigLoadError());
|
||||
removedClaudeSettings = true;
|
||||
|
||||
for (const packageManager of selection.packageManagers) {
|
||||
@@ -894,12 +959,36 @@ export const App: React.FC = () => {
|
||||
});
|
||||
setScreen('confirm');
|
||||
break;
|
||||
case 'save':
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(cloneSettings(settings)); // Update original after save
|
||||
setHasChanges(false);
|
||||
exit();
|
||||
case 'save': {
|
||||
const saveAndExit = async () => {
|
||||
try {
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(cloneSettings(settings));
|
||||
setHasChanges(false);
|
||||
exit();
|
||||
} catch {
|
||||
setFlashMessage({
|
||||
text: '✗ Could not save configuration',
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Save & Exit is the second explicit-save route (besides Ctrl+S); guard it
|
||||
// the same way so an invalid settings.json isn't overwritten without consent.
|
||||
const saveGuard = buildInvalidConfigSaveConfirm(configLoadError, () => {
|
||||
setConfirmDialog(null);
|
||||
setScreen('main');
|
||||
void saveAndExit();
|
||||
});
|
||||
if (saveGuard) {
|
||||
setConfirmDialog(saveGuard);
|
||||
setScreen('confirm');
|
||||
} else {
|
||||
await saveAndExit();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'exit':
|
||||
exit();
|
||||
break;
|
||||
@@ -951,6 +1040,8 @@ export const App: React.FC = () => {
|
||||
setScreen('items');
|
||||
};
|
||||
|
||||
const configWarning = buildConfigLoadWarning(configLoadError);
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Box marginBottom={1}>
|
||||
@@ -968,6 +1059,9 @@ export const App: React.FC = () => {
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{configWarning && (
|
||||
<Text color='red' wrap='wrap'>{configWarning}</Text>
|
||||
)}
|
||||
{isCustomConfigPath() && (
|
||||
<Text dimColor>{`Config: ${getConfigPath()}`}</Text>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
type InstallationMetadata
|
||||
} from '../../types/Settings';
|
||||
import {
|
||||
buildConfigLoadWarning,
|
||||
buildInvalidConfigSaveConfirm,
|
||||
clearInstallMenuSelection,
|
||||
getConfirmCancelScreen,
|
||||
getCurrentInstallation,
|
||||
@@ -250,3 +253,38 @@ describe('Main menu structure', () => {
|
||||
)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid-config TUI guards', () => {
|
||||
it('returns null when there is no config load error', () => {
|
||||
expect(buildConfigLoadWarning(null)).toBeNull();
|
||||
expect(buildInvalidConfigSaveConfirm(null, vi.fn())).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a banner that names the reason and warns about overwriting', () => {
|
||||
const warning = buildConfigLoadWarning('settings.json is not valid JSON');
|
||||
expect(warning).toContain('settings.json is not valid JSON');
|
||||
expect(warning).toContain('overwrites the file');
|
||||
});
|
||||
|
||||
it('builds a save-guard confirm dialog that returns to main on cancel', () => {
|
||||
const guard = buildInvalidConfigSaveConfirm('settings.json could not be read', vi.fn());
|
||||
expect(guard).not.toBeNull();
|
||||
expect(guard?.cancelScreen).toBe('main');
|
||||
expect(guard?.message).toContain('preserved');
|
||||
expect(guard?.message).toContain('could not be read');
|
||||
});
|
||||
|
||||
it('invokes the provided onConfirm when the guard action runs', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const guard = buildInvalidConfigSaveConfirm('settings.json is not valid JSON', onConfirm);
|
||||
await guard?.action();
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reflects the specific load-error reason in the save-guard message', () => {
|
||||
expect(buildInvalidConfigSaveConfirm('settings.json is not valid JSON', vi.fn())?.message)
|
||||
.toContain('settings.json is not valid JSON');
|
||||
expect(buildInvalidConfigSaveConfirm('settings.json is not in a valid format', vi.fn())?.message)
|
||||
.toContain('not in a valid format');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
clearAllWidgetStyling,
|
||||
cycleWidgetColor,
|
||||
cycleWidgetDim,
|
||||
resetWidgetStyling,
|
||||
setWidgetColor,
|
||||
toggleWidgetBold
|
||||
@@ -268,6 +269,15 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
onUpdate(newItems);
|
||||
}
|
||||
}
|
||||
} else if (input === 'd' || input === 'D') {
|
||||
if (highlightedItemId && highlightedItemId !== 'back') {
|
||||
// Cycle dim for the highlighted item: off -> whole -> parens -> off
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
const newItems = cycleWidgetDim(widgets, selectedWidget.id);
|
||||
onUpdate(newItems);
|
||||
}
|
||||
}
|
||||
} else if (input === 'r' || input === 'R') {
|
||||
if (highlightedItemId && highlightedItemId !== 'back') {
|
||||
// Reset all styling (color, background, and bold) for the highlighted item
|
||||
@@ -347,7 +357,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
defaultColor = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level);
|
||||
const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level, widget.dim);
|
||||
return {
|
||||
label: styledLabel,
|
||||
value: widget.id
|
||||
@@ -431,6 +441,11 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
colorDisplay = applyColors(displayName, currentColor, undefined, false, level);
|
||||
}
|
||||
}
|
||||
const styleIndicators = [
|
||||
selectedWidget?.bold ? '[BOLD]' : null,
|
||||
selectedWidget?.dim === true ? '[DIM]' : null,
|
||||
selectedWidget?.dim === 'parens' ? '[DIM ()]' : null
|
||||
].filter(indicator => indicator !== null).join(' ');
|
||||
|
||||
// Gradient selection mode takes over the whole view
|
||||
if (gradientMode) {
|
||||
@@ -573,7 +588,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
↑↓ to select, ←→ to cycle
|
||||
{' '}
|
||||
{editingBackground ? 'background' : 'foreground'}
|
||||
, (f) to toggle bg/fg, (b)old,
|
||||
, (f) to toggle bg/fg, (b)old, (d)im,
|
||||
{settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''}
|
||||
{!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''}
|
||||
{' '}
|
||||
@@ -597,7 +612,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
):
|
||||
{' '}
|
||||
{colorDisplay}
|
||||
{selectedWidget.bold && chalk.bold(' [BOLD]')}
|
||||
{styleIndicators && ` ${styleIndicators}`}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { getColorLevelString } from '../../types/ColorLevel';
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import {
|
||||
DefaultPaddingSideSchema,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
import {
|
||||
COLOR_MAP,
|
||||
applyColors,
|
||||
@@ -231,6 +234,16 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
overrideForegroundColor: undefined
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
} else if (input === 'd' || input === 'D') {
|
||||
// Cycle through padding sides: both -> left -> right -> both
|
||||
const paddingSides = DefaultPaddingSideSchema.options;
|
||||
const currentIndex = paddingSides.indexOf(settings.defaultPaddingSide);
|
||||
const nextSide = paddingSides[(currentIndex + 1) % paddingSides.length] ?? 'both';
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
defaultPaddingSide: nextSide
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -298,7 +311,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
{editingPadding ? (
|
||||
<Box flexDirection='column'>
|
||||
<Box>
|
||||
<Text>Enter default padding (applied to left and right of each widget): </Text>
|
||||
<Text>Enter default padding (applied per the Padding Side setting): </Text>
|
||||
<Text color='cyan'>{paddingInput ? `"${paddingInput}"` : '(empty)'}</Text>
|
||||
</Box>
|
||||
<Text dimColor>Press Enter to save, ESC to cancel</Text>
|
||||
@@ -365,6 +378,12 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
<Text dimColor> - Press (p) to edit</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text> Padding Side: </Text>
|
||||
<Text color='cyan'>{settings.defaultPaddingSide === 'left' ? 'Left only' : settings.defaultPaddingSide === 'right' ? 'Right only' : 'Both'}</Text>
|
||||
<Text dimColor> - Press (d) to cycle</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text>Override FG Color: </Text>
|
||||
{(() => {
|
||||
@@ -442,6 +461,9 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
<Text dimColor wrap='wrap'>
|
||||
Note: These settings are applied during rendering and don't add widgets to your widget list.
|
||||
</Text>
|
||||
<Text dimColor wrap='wrap'>
|
||||
• Padding Side: Choose whether default padding applies to both sides, left only, or right only
|
||||
</Text>
|
||||
<Text dimColor wrap='wrap'>
|
||||
• Inherit colors: Separators will use colors from the preceding widget
|
||||
</Text>
|
||||
|
||||
@@ -42,6 +42,14 @@ export interface ItemsEditorProps {
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
function isMergedIntoPreviousWidget(widgets: WidgetItem[], index: number): boolean {
|
||||
if (index <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(widgets[index - 1]?.merge);
|
||||
}
|
||||
|
||||
export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onBack, lineNumber, settings }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [moveMode, setMoveMode] = useState(false);
|
||||
@@ -152,6 +160,30 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
setWidgetPicker(null);
|
||||
};
|
||||
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
const isSeparator = currentWidget?.type === 'separator';
|
||||
const isFlexSeparator = currentWidget?.type === 'flex-separator';
|
||||
|
||||
// Check if widget supports raw value using registry
|
||||
let canToggleRaw = false;
|
||||
let customKeybinds: CustomKeybind[] = [];
|
||||
if (currentWidget && !isSeparator && !isFlexSeparator) {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (widgetImpl) {
|
||||
canToggleRaw = widgetImpl.supportsRawValue();
|
||||
// Get custom keybinds from the widget
|
||||
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
|
||||
} else {
|
||||
canToggleRaw = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
|
||||
const canExcludeAlign = Boolean(currentWidget) && !isSeparator && !isFlexSeparator
|
||||
&& settings.powerline.enabled && settings.powerline.autoAlign
|
||||
&& !isMergedIntoPreviousWidget(widgets, selectedIndex);
|
||||
const hasWidgets = widgets.length > 0;
|
||||
|
||||
useInput((input, key) => {
|
||||
// Skip input if custom editor is active
|
||||
if (customEditorWidget) {
|
||||
@@ -193,6 +225,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
canExcludeAlign,
|
||||
separatorChars,
|
||||
onBack,
|
||||
onUpdate,
|
||||
@@ -251,28 +284,6 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0])
|
||||
: null;
|
||||
|
||||
// Build dynamic help text based on selected item
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
const isSeparator = currentWidget?.type === 'separator';
|
||||
const isFlexSeparator = currentWidget?.type === 'flex-separator';
|
||||
|
||||
// Check if widget supports raw value using registry
|
||||
let canToggleRaw = false;
|
||||
let customKeybinds: CustomKeybind[] = [];
|
||||
if (currentWidget && !isSeparator && !isFlexSeparator) {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (widgetImpl) {
|
||||
canToggleRaw = widgetImpl.supportsRawValue();
|
||||
// Get custom keybinds from the widget
|
||||
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
|
||||
} else {
|
||||
canToggleRaw = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
|
||||
const hasWidgets = widgets.length > 0;
|
||||
|
||||
// Build main help text (without custom keybinds)
|
||||
let helpText = hasWidgets
|
||||
? '↑↓ select, ←→ open type picker'
|
||||
@@ -289,6 +300,9 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
if (canMerge) {
|
||||
helpText += ', (m)erge';
|
||||
}
|
||||
if (canExcludeAlign) {
|
||||
helpText += ', e(x)clude align';
|
||||
}
|
||||
helpText += ', ESC back';
|
||||
|
||||
// Build custom keybinds text
|
||||
@@ -359,7 +373,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
⚠
|
||||
{' '}
|
||||
{settings.powerline.enabled
|
||||
? 'Powerline mode active: separators controlled by powerline settings'
|
||||
? 'Powerline mode active: manual separators disabled'
|
||||
: 'Default separator active: manual separators disabled'}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -547,6 +561,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
{supportsRawValue && widget.rawValue && <Text dimColor> (raw value)</Text>}
|
||||
{widget.merge === true && <Text dimColor> (merged→)</Text>}
|
||||
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
|
||||
{widget.excludeFromAutoAlign && settings.powerline.enabled && settings.powerline.autoAlign && !isMergedIntoPreviousWidget(widgets, index) && <Text dimColor> (no-align)</Text>}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -166,8 +166,8 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
const [confirmingEnable, setConfirmingEnable] = useState(false);
|
||||
const [confirmingFontInstall, setConfirmingFontInstall] = useState(false);
|
||||
|
||||
const hasSeparatorItems = settings.lines.some(line => line.some(
|
||||
item => item.type === 'separator' || item.type === 'flex-separator'
|
||||
const hasManualSeparatorItems = settings.lines.some(line => line.some(
|
||||
item => item.type === 'separator'
|
||||
));
|
||||
const hasGlobalFgOverride = Boolean(settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none');
|
||||
const globalOverrideMessage = hasGlobalFgOverride ? '⚠ Global override for FG active' : null;
|
||||
@@ -189,7 +189,7 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
onBack();
|
||||
} else if (input === 't' || input === 'T') {
|
||||
if (!powerlineConfig.enabled) {
|
||||
if (hasSeparatorItems) {
|
||||
if (hasManualSeparatorItems) {
|
||||
setConfirmingEnable(true);
|
||||
} else {
|
||||
onUpdate(buildEnabledPowerlineSettings(settings, false));
|
||||
@@ -347,17 +347,17 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
</Box>
|
||||
) : confirmingEnable ? (
|
||||
<Box flexDirection='column' marginTop={1}>
|
||||
{hasSeparatorItems && (
|
||||
{hasManualSeparatorItems && (
|
||||
<>
|
||||
<Box>
|
||||
<Text color='yellow'>⚠ Warning: Enabling Powerline mode will remove all existing separators and flex-separators from your status lines.</Text>
|
||||
<Text color='yellow'>⚠ Warning: Enabling Powerline mode will remove all existing manual separators from your status lines.</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>Powerline mode uses its own separator system and is incompatible with manual separators.</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box marginTop={hasSeparatorItems ? 1 : 0}>
|
||||
<Box marginTop={hasManualSeparatorItems ? 1 : 0}>
|
||||
<Text>Do you want to continue? </Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { advanceGlobalPowerlineThemeIndex } from '../../utils/powerline-theme-index';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
countPowerlineStartCapSlots,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLineWithInfo,
|
||||
type PreRenderedWidget,
|
||||
@@ -37,6 +38,7 @@ const renderSingleLine = (
|
||||
lineIndex: number,
|
||||
globalSeparatorIndex: number,
|
||||
globalPowerlineThemeIndex: number,
|
||||
globalPowerlineStartCapIndex: number,
|
||||
preRenderedWidgets: PreRenderedWidget[],
|
||||
preCalculatedMaxWidths: number[]
|
||||
): RenderResult => {
|
||||
@@ -48,7 +50,8 @@ const renderSingleLine = (
|
||||
gitCacheTtlSeconds: settings.gitCacheTtlSeconds,
|
||||
lineIndex,
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex
|
||||
globalPowerlineThemeIndex,
|
||||
globalPowerlineStartCapIndex
|
||||
};
|
||||
|
||||
return renderStatusLineWithInfo(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
@@ -80,6 +83,7 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
|
||||
let globalSeparatorIndex = 0;
|
||||
let globalPowerlineThemeIndex = 0;
|
||||
let globalPowerlineStartCapIndex = 0;
|
||||
const result: string[] = [];
|
||||
let truncated = false;
|
||||
|
||||
@@ -94,6 +98,7 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
i,
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex,
|
||||
globalPowerlineStartCapIndex,
|
||||
preRenderedWidgets,
|
||||
preCalculatedMaxWidths
|
||||
);
|
||||
@@ -102,7 +107,10 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
|
||||
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems, preRenderedWidgets);
|
||||
if (settings.powerline.enabled) {
|
||||
globalPowerlineStartCapIndex += countPowerlineStartCapSlots(lineItems, preRenderedWidgets);
|
||||
}
|
||||
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
|
||||
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../../types/Settings';
|
||||
import type { WidgetItem } from '../../../types/Widget';
|
||||
import { ColorMenu } from '../ColorMenu';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 160;
|
||||
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 chunks.join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('ColorMenu', () => {
|
||||
it('keeps bold and dim indicators on the current-style row', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'cache-hit-rate' },
|
||||
{
|
||||
id: '2',
|
||||
type: 'cache-read',
|
||||
color: 'hex:ABB2BF',
|
||||
backgroundColor: 'bgBrightBlack',
|
||||
bold: true,
|
||||
dim: 'parens'
|
||||
},
|
||||
{ id: '3', type: 'cache-write' },
|
||||
{ id: '4', type: 'tokens-cached' }
|
||||
];
|
||||
|
||||
const instance = render(
|
||||
React.createElement(ColorMenu, {
|
||||
widgets,
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
colorLevel: 3,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
onUpdate: vi.fn(),
|
||||
onBack: vi.fn()
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('\x1B[B');
|
||||
await flushInk();
|
||||
|
||||
const latestFrame = stdout.getOutput().split('Configure Colors').at(-1) ?? '';
|
||||
const currentStyleLine = latestFrame
|
||||
.split('\n')
|
||||
.find(line => line.includes('Current foreground')) ?? '';
|
||||
|
||||
expect(currentStyleLine).toContain('[BOLD] [DIM ()]');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,84 @@ describe('GlobalOverridesMenu', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('displays padding side as "Both" by default', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
|
||||
const instance = render(
|
||||
React.createElement(GlobalOverridesMenu, {
|
||||
settings: DEFAULT_SETTINGS,
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
expect(stdout.getOutput()).toContain('Padding Side:');
|
||||
expect(stdout.getOutput()).toContain('Both');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ starting: 'both' as const, expected: 'left' as const },
|
||||
{ starting: 'left' as const, expected: 'right' as const },
|
||||
{ starting: 'right' as const, expected: 'both' as const }
|
||||
])('cycles padding side from "$starting" to "$expected" when (d) is pressed', async ({ starting, expected }) => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
|
||||
const instance = render(
|
||||
React.createElement(GlobalOverridesMenu, {
|
||||
settings: { ...DEFAULT_SETTINGS, defaultPaddingSide: starting },
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('d');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ defaultPaddingSide: expected }));
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows foreground override gradient and clear controls on the same line', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
|
||||
@@ -1,12 +1,68 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type Settings
|
||||
} from '../../../types/Settings';
|
||||
import type { WidgetItem } from '../../../types/Widget';
|
||||
import { getVisibleWidth } from '../../../utils/ansi';
|
||||
import { renderOsc8Link } from '../../../utils/hyperlink';
|
||||
import { preparePreviewLineForTerminal } from '../StatusLinePreview';
|
||||
import {
|
||||
StatusLinePreview,
|
||||
preparePreviewLineForTerminal
|
||||
} from '../StatusLinePreview';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 160;
|
||||
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 chunks.join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('StatusLinePreview helpers', () => {
|
||||
it('strips OSC links and clamps preview lines to the terminal width', () => {
|
||||
@@ -21,4 +77,80 @@ describe('StatusLinePreview helpers', () => {
|
||||
expect(prepared.endsWith('...')).toBe(true);
|
||||
expect(getVisibleWidth(` ${prepared}`)).toBeLessThanOrEqual(40);
|
||||
});
|
||||
|
||||
it('keeps parens dim scoped in the Ink preview when global bold is active', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const settings: Settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
colorLevel: 3,
|
||||
globalBold: true,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
theme: 'custom',
|
||||
separators: ['\uE0B0'],
|
||||
separatorInvertBackground: [false]
|
||||
}
|
||||
};
|
||||
const lines: WidgetItem[][] = [[
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'Cache Hit: 87.0%',
|
||||
color: 'hex:282C34',
|
||||
backgroundColor: 'hex:61AFEF'
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
type: 'custom-text',
|
||||
customText: 'Cache Read: 12k (64.0%)',
|
||||
color: 'hex:ABB2BF',
|
||||
backgroundColor: 'hex:3E4452',
|
||||
dim: 'parens'
|
||||
},
|
||||
{
|
||||
id: 'w3',
|
||||
type: 'custom-text',
|
||||
customText: 'Cache Write: 3k (16.0%)',
|
||||
color: 'hex:282C34',
|
||||
backgroundColor: 'hex:98C379'
|
||||
}
|
||||
]];
|
||||
|
||||
const instance = render(
|
||||
React.createElement(StatusLinePreview, {
|
||||
lines,
|
||||
terminalWidth: 160,
|
||||
settings
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
const output = stdout.getOutput();
|
||||
const dimIndex = output.indexOf('\x1b[2m(64.0%)');
|
||||
const resetIndex = output.indexOf('\x1b[22;1m', dimIndex);
|
||||
const nextWidgetIndex = output.indexOf('Cache Write');
|
||||
|
||||
expect(dimIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(resetIndex).toBeGreaterThan(dimIndex);
|
||||
expect(resetIndex).toBeLessThan(nextWidgetIndex);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { WidgetItem } from '../../../../types/Widget';
|
||||
import {
|
||||
clearAllWidgetStyling,
|
||||
cycleWidgetColor,
|
||||
cycleWidgetDim,
|
||||
resetWidgetStyling,
|
||||
toggleWidgetBold,
|
||||
updateWidgetById
|
||||
@@ -41,14 +42,31 @@ describe('color-menu mutations', () => {
|
||||
expect(updated[1]?.bold).toBe(false);
|
||||
});
|
||||
|
||||
it('resetWidgetStyling removes color, backgroundColor, and bold from one widget', () => {
|
||||
it('cycleWidgetDim cycles off, whole widget, parens, then off for the selected widget only', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' }
|
||||
];
|
||||
|
||||
const whole = cycleWidgetDim(widgets, '1');
|
||||
const parens = cycleWidgetDim(whole, '1');
|
||||
const off = cycleWidgetDim(parens, '1');
|
||||
|
||||
expect(whole[0]?.dim).toBe(true);
|
||||
expect(parens[0]?.dim).toBe('parens');
|
||||
expect(off[0]).toEqual({ id: '1', type: 'tokens-input' });
|
||||
expect(whole[1]?.dim).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resetWidgetStyling removes color, backgroundColor, bold, and dim from one widget', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tokens-input',
|
||||
color: 'red',
|
||||
backgroundColor: 'blue',
|
||||
bold: true
|
||||
bold: true,
|
||||
dim: 'parens'
|
||||
},
|
||||
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
|
||||
];
|
||||
@@ -66,9 +84,10 @@ describe('color-menu mutations', () => {
|
||||
type: 'tokens-input',
|
||||
color: 'red',
|
||||
backgroundColor: 'blue',
|
||||
bold: true
|
||||
bold: true,
|
||||
dim: true
|
||||
},
|
||||
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
|
||||
{ id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens' }
|
||||
];
|
||||
|
||||
const updated = clearAllWidgetStyling(widgets);
|
||||
|
||||
@@ -37,17 +37,42 @@ export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): Widge
|
||||
}));
|
||||
}
|
||||
|
||||
export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, (widget) => {
|
||||
// Cycle: off -> whole widget -> (...) spans only -> off
|
||||
if (widget.dim === true) {
|
||||
return {
|
||||
...widget,
|
||||
dim: 'parens' as const
|
||||
};
|
||||
}
|
||||
|
||||
if (widget.dim === 'parens') {
|
||||
const { dim, ...restWidget } = widget;
|
||||
void dim; // Intentionally unused
|
||||
return restWidget;
|
||||
}
|
||||
|
||||
return {
|
||||
...widget,
|
||||
dim: true
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, (widget) => {
|
||||
const {
|
||||
color,
|
||||
backgroundColor,
|
||||
bold,
|
||||
dim,
|
||||
...restWidget
|
||||
} = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
void dim; // Intentionally unused
|
||||
return restWidget;
|
||||
});
|
||||
}
|
||||
@@ -58,11 +83,13 @@ export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] {
|
||||
color,
|
||||
backgroundColor,
|
||||
bold,
|
||||
dim,
|
||||
...restWidget
|
||||
} = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
void dim; // Intentionally unused
|
||||
return restWidget;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -532,6 +532,59 @@ describe('items-editor input handlers', () => {
|
||||
expect(updated?.[0]?.character).toBe('-');
|
||||
});
|
||||
|
||||
it('toggles auto-align exclusion when the editor marks it available', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'x',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
canExcludeAlign: true,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.excludeFromAutoAlign).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores the auto-align exclusion shortcut when the editor marks it unavailable', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'x',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
canExcludeAlign: false,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies custom widget keybind actions in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'session-usage' }
|
||||
@@ -698,6 +751,32 @@ describe('items-editor input handlers', () => {
|
||||
expect(updated?.[0]?.metadata?.mode).toBe('count');
|
||||
});
|
||||
|
||||
it('uses v to cycle compaction counter metric', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'compaction-counter' }
|
||||
];
|
||||
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(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.metadata?.metric).toBe('auto');
|
||||
});
|
||||
|
||||
it('opens custom editor for skills list limit action', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'skills', metadata: { mode: 'list' } }
|
||||
|
||||
@@ -338,6 +338,7 @@ export interface HandleNormalInputModeArgs {
|
||||
key: InputKey;
|
||||
widgets: WidgetItem[];
|
||||
selectedIndex: number;
|
||||
canExcludeAlign?: boolean;
|
||||
separatorChars: string[];
|
||||
onBack: () => void;
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
@@ -355,6 +356,7 @@ export function handleNormalInputMode({
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
canExcludeAlign = false,
|
||||
separatorChars,
|
||||
onBack,
|
||||
onUpdate,
|
||||
@@ -455,6 +457,19 @@ export function handleNormalInputMode({
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'x' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (canExcludeAlign && currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const newWidgets = [...widgets];
|
||||
if (currentWidget.excludeFromAutoAlign) {
|
||||
const { excludeFromAutoAlign, ...rest } = currentWidget;
|
||||
void excludeFromAutoAlign; // Intentionally unused
|
||||
newWidgets[selectedIndex] = rest;
|
||||
} else {
|
||||
newWidgets[selectedIndex] = { ...currentWidget, excludeFromAutoAlign: true };
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onBack();
|
||||
} else if (widgets.length > 0) {
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface RenderContext {
|
||||
isPreview?: boolean;
|
||||
minimalist?: boolean;
|
||||
gitCacheTtlSeconds?: number;
|
||||
gitReviewNeedsChecks?: boolean;
|
||||
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
|
||||
globalSeparatorIndex?: number; // Global separator index that continues across lines
|
||||
|
||||
@@ -54,4 +55,5 @@ export interface RenderContext {
|
||||
deletions?: number;
|
||||
};
|
||||
globalPowerlineThemeIndex?: number; // Global powerline theme index that continues across lines
|
||||
globalPowerlineStartCapIndex?: number; // Global start cap index across powerline flex segments and lines
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import { WidgetItemSchema } from './Widget';
|
||||
// Current version - bump this when making breaking changes to the schema
|
||||
export const CURRENT_VERSION = 3;
|
||||
|
||||
// Which side(s) of a widget the default padding is applied to
|
||||
export const DefaultPaddingSideSchema = z.enum(['both', 'left', 'right']);
|
||||
export type DefaultPaddingSide = z.infer<typeof DefaultPaddingSideSchema>;
|
||||
|
||||
export const InstallationMetadataSchema = z.discriminatedUnion('method', [
|
||||
z.object({
|
||||
method: z.literal('auto-update'),
|
||||
@@ -64,6 +68,7 @@ export const SettingsSchema = z.object({
|
||||
colorLevel: ColorLevelSchema.default(2),
|
||||
defaultSeparator: z.string().optional(),
|
||||
defaultPadding: z.string().optional(),
|
||||
defaultPaddingSide: DefaultPaddingSideSchema.default('both'),
|
||||
inheritSeparatorColors: z.boolean().default(false),
|
||||
overrideBackgroundColor: z.string().optional(),
|
||||
overrideForegroundColor: z.string().optional(),
|
||||
|
||||
@@ -10,6 +10,7 @@ export const WidgetItemSchema = z.object({
|
||||
color: z.string().optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
bold: z.boolean().optional(),
|
||||
dim: z.union([z.boolean(), z.literal('parens')]).optional(),
|
||||
character: z.string().optional(),
|
||||
rawValue: z.boolean().optional(),
|
||||
customText: z.string().optional(),
|
||||
@@ -20,6 +21,7 @@ export const WidgetItemSchema = z.object({
|
||||
timeout: z.number().optional(),
|
||||
merge: z.union([z.boolean(), z.literal('no-padding')]).optional(),
|
||||
hide: z.boolean().optional(),
|
||||
excludeFromAutoAlign: z.boolean().optional(),
|
||||
metadata: z.record(z.string(), z.string()).optional()
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getClaudeSettingsPath,
|
||||
getExistingStatusLine,
|
||||
getRefreshInterval,
|
||||
getSandboxConfig,
|
||||
getVoiceConfig,
|
||||
installStatusLine,
|
||||
isClaudeCodeVersionAtLeast,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
setRefreshInterval,
|
||||
uninstallStatusLine
|
||||
} from '../claude-settings';
|
||||
import { initConfigPath } from '../config';
|
||||
import * as config from '../config';
|
||||
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
let testClaudeConfigDir = '';
|
||||
@@ -60,11 +61,12 @@ function writeRawClaudeSettings(content: string): void {
|
||||
beforeEach(() => {
|
||||
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-settings-'));
|
||||
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
|
||||
initConfigPath(path.join(testClaudeConfigDir, 'ccstatusline-settings.json'));
|
||||
config.initConfigPath(path.join(testClaudeConfigDir, 'ccstatusline-settings.json'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
initConfigPath();
|
||||
vi.restoreAllMocks();
|
||||
config.initConfigPath();
|
||||
if (testClaudeConfigDir) {
|
||||
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -198,49 +200,55 @@ describe('Claude config paths', () => {
|
||||
|
||||
describe('buildCommand via installStatusLine', () => {
|
||||
it('should use base command when no custom config path', async () => {
|
||||
initConfigPath();
|
||||
config.initConfigPath();
|
||||
await installStatusLine({ commandMode: 'auto-npx' });
|
||||
expect(readInstalledCommand()).toBe(CCSTATUSLINE_COMMANDS.NPM);
|
||||
});
|
||||
|
||||
it('should append --config with simple path (no quoting needed)', async () => {
|
||||
initConfigPath('/tmp/settings.json');
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
vi.spyOn(config, 'getConfigPath').mockReturnValue('/tmp/settings.json');
|
||||
await installStatusLine({ commandMode: 'auto-npx' });
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`);
|
||||
});
|
||||
|
||||
it('should quote path with spaces', async () => {
|
||||
initConfigPath('/my path/settings.json');
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my path/settings.json');
|
||||
await installStatusLine({ commandMode: 'auto-npx' });
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should quote path with parentheses', async () => {
|
||||
initConfigPath('/my(path)/settings.json');
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my(path)/settings.json');
|
||||
await installStatusLine({ commandMode: 'auto-npx' });
|
||||
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');
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my\'path/settings.json');
|
||||
await installStatusLine({ commandMode: 'auto-npx' });
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my'\\''path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should use bunx command when commandMode is auto-bunx', async () => {
|
||||
initConfigPath('/my path/settings.json');
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my path/settings.json');
|
||||
await installStatusLine({ commandMode: 'auto-bunx' });
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should generate global command with custom config path', () => {
|
||||
initConfigPath('/my path/settings.json');
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my path/settings.json');
|
||||
expect(buildStatusLineCommand('global')).toBe(`${CCSTATUSLINE_COMMANDS.GLOBAL} --config '/my path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should install global command for pinned installs and save metadata', async () => {
|
||||
const configPath = path.join(testClaudeConfigDir, 'pinned-settings.json');
|
||||
initConfigPath(configPath);
|
||||
config.initConfigPath(configPath);
|
||||
|
||||
await installStatusLine({
|
||||
commandMode: 'global',
|
||||
@@ -260,7 +268,7 @@ describe('buildCommand via installStatusLine', () => {
|
||||
|
||||
it('should sync hooks on install when settings include hook-enabled widgets', async () => {
|
||||
const configPath = path.join(testClaudeConfigDir, 'ccstatusline-settings.json');
|
||||
initConfigPath(configPath);
|
||||
config.initConfigPath(configPath);
|
||||
const settingsWithSkills = {
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [[{ id: 'skills-1', type: 'skills' }], [], []]
|
||||
@@ -290,7 +298,7 @@ describe('buildCommand via installStatusLine', () => {
|
||||
|
||||
it('should sync hooks from the final global statusline command', async () => {
|
||||
const configPath = path.join(testClaudeConfigDir, 'global-settings.json');
|
||||
initConfigPath(configPath);
|
||||
config.initConfigPath(configPath);
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [[{ id: 'skills-1', type: 'skills' }], [], []]
|
||||
@@ -320,13 +328,13 @@ describe('buildCommand via installStatusLine', () => {
|
||||
|
||||
describe('installStatusLine refreshInterval', () => {
|
||||
it('should set refreshInterval to 10 when version is supported', async () => {
|
||||
initConfigPath();
|
||||
config.initConfigPath();
|
||||
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: true });
|
||||
expect(readInstalledRefreshInterval()).toBe(10);
|
||||
});
|
||||
|
||||
it('should not set refreshInterval when version is unsupported', async () => {
|
||||
initConfigPath();
|
||||
config.initConfigPath();
|
||||
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: false });
|
||||
expect(readInstalledRefreshInterval()).toBeUndefined();
|
||||
});
|
||||
@@ -805,3 +813,113 @@ describe('getVoiceConfig', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSandboxConfig', () => {
|
||||
let testSandboxProjectDir = '';
|
||||
|
||||
function writeRawUserLocalSettings(content: string): void {
|
||||
const settingsPath = path.join(testClaudeConfigDir, 'settings.local.json');
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function writeRawProjectSettings(content: string): void {
|
||||
const settingsPath = path.join(testSandboxProjectDir, '.claude', 'settings.json');
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function writeRawProjectLocalSettings(content: string): void {
|
||||
const settingsPath = path.join(testSandboxProjectDir, '.claude', 'settings.local.json');
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testSandboxProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-sandbox-project-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (testSandboxProjectDir) {
|
||||
fs.rmSync(testSandboxProjectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when no candidate file exists', () => {
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns { enabled: false } when settings.json has no sandbox field', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ effortLevel: 'high' }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('returns { enabled: true } when sandbox.enabled is true', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('returns { enabled: false } when sandbox.enabled is false', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: false } }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('returns { enabled: false } when sandbox exists but enabled is missing', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: { network: { allowAll: true } } }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('treats malformed JSON as "no override"', () => {
|
||||
writeRawClaudeSettings('{ not json');
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('treats an unexpected sandbox shape as "no override"', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: 'on' }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('respects CLAUDE_CONFIG_DIR env var', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
|
||||
expect(getClaudeSettingsPath().startsWith(testClaudeConfigDir)).toBe(true);
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('project-local overrides project (where /sandbox writes)', () => {
|
||||
writeRawProjectSettings(JSON.stringify({ sandbox: { enabled: false } }));
|
||||
writeRawProjectLocalSettings(JSON.stringify({ sandbox: { enabled: true } }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('project overrides user-global', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: false } }));
|
||||
writeRawProjectSettings(JSON.stringify({ sandbox: { enabled: true } }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('user-local overrides user-global', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
|
||||
writeRawUserLocalSettings(JSON.stringify({ sandbox: { enabled: false } }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('a layer without sandbox.enabled does not clobber a lower layer', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
|
||||
writeRawProjectSettings(JSON.stringify({ sandbox: { network: {} } }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('a malformed higher-priority layer does not clobber a defined lower layer', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
|
||||
writeRawProjectLocalSettings('{ corrupt');
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('falls through layers without sandbox.enabled until it finds a defined value', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } }));
|
||||
writeRawUserLocalSettings(JSON.stringify({ effortLevel: 'high' }));
|
||||
writeRawProjectSettings(JSON.stringify({ sandbox: { network: {} } }));
|
||||
writeRawProjectLocalSettings(JSON.stringify({ effortLevel: 'low' }));
|
||||
expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('initConfigPath / getConfigPath', () => {
|
||||
|
||||
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(getConfigPath()).toBe(path.resolve('/tmp/my-ccsl/settings.json'));
|
||||
expect(isCustomConfigPath()).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
CURRENT_VERSION,
|
||||
DEFAULT_SETTINGS,
|
||||
type InstallationMetadata,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
|
||||
@@ -24,6 +25,8 @@ 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 getConfigLoadError: () => string | null;
|
||||
let saveInstallationMetadata: (metadata: InstallationMetadata | undefined) => Promise<void>;
|
||||
let consoleErrorSpy: MockInstance<typeof console.error>;
|
||||
|
||||
function getSettingsPaths(): { configDir: string; settingsPath: string; backupPath: string } {
|
||||
@@ -45,6 +48,8 @@ describe('config utilities', () => {
|
||||
loadSettings = configModule.loadSettings;
|
||||
saveSettings = configModule.saveSettings;
|
||||
initConfigPath = configModule.initConfigPath;
|
||||
getConfigLoadError = configModule.getConfigLoadError;
|
||||
saveInstallationMetadata = configModule.saveInstallationMetadata;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -90,50 +95,79 @@ describe('config utilities', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('backs up invalid JSON and recovers with defaults', async () => {
|
||||
it('uses defaults in memory and preserves invalid JSON without overwriting', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, '{ invalid json', 'utf-8');
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
// Defaults are returned in memory.
|
||||
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);
|
||||
// The invalid file is left exactly as the user wrote it (not overwritten).
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
|
||||
|
||||
// No backup is created: recovery is non-destructive, so the original is the backup.
|
||||
expect(fs.existsSync(backupPath)).toBe(false);
|
||||
|
||||
// A diagnostic is still emitted.
|
||||
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')
|
||||
expect.stringContaining('Failed to parse settings.json')
|
||||
);
|
||||
});
|
||||
|
||||
it('backs up invalid v1 payloads and recovers with defaults', async () => {
|
||||
it('uses defaults in memory and preserves an invalid v1 payload', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ flexMode: 123 }), 'utf-8');
|
||||
const original = JSON.stringify({ flexMode: 123 });
|
||||
fs.writeFileSync(settingsPath, original, '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(fs.readFileSync(settingsPath, 'utf-8')).toBe(original);
|
||||
expect(fs.existsSync(backupPath)).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Invalid v1 settings format:',
|
||||
expect.stringContaining('Invalid v1 settings format'),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('uses defaults in memory when schema validation fails', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
// Has a version (skips v1 branch), version === CURRENT_VERSION (no migration),
|
||||
// but `lines: 42` is not an array, so SettingsSchema validation fails.
|
||||
const original = JSON.stringify({ version: CURRENT_VERSION, lines: 42 });
|
||||
fs.writeFileSync(settingsPath, original, 'utf-8');
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(original);
|
||||
expect(fs.existsSync(backupPath)).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Bad settings backed up to')
|
||||
expect.stringContaining('Failed to parse settings, using defaults'),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('uses defaults in memory when the settings file cannot be read', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
// Make settings.json a directory so readFile throws (EISDIR) -> outer catch path.
|
||||
fs.mkdirSync(settingsPath, { recursive: true });
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
// The path is left as-is (still a directory) — nothing was written over it.
|
||||
expect(fs.statSync(settingsPath).isDirectory()).toBe(true);
|
||||
expect(fs.existsSync(backupPath)).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Default settings written to')
|
||||
expect.stringContaining('Error loading settings'),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -161,6 +195,55 @@ describe('config utilities', () => {
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not overwrite the file when a migration produces an invalid result', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
// A v2 config whose migrated v3 form fails schema validation: the v2->v3 migration
|
||||
// copies fields through, so `lines: 42` survives into the v3 result and fails the schema.
|
||||
const original = JSON.stringify({ version: 2, lines: 42 });
|
||||
fs.writeFileSync(settingsPath, original, 'utf-8');
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
// Falls back to defaults in memory.
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
// The original file is preserved — the invalid migration was NOT written over it.
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(original);
|
||||
// No backup, and no temp residue from an aborted write.
|
||||
expect(fs.existsSync(backupPath)).toBe(false);
|
||||
expect(fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'))).toEqual([]);
|
||||
// The failure is recorded so the statusline can warn.
|
||||
expect(getConfigLoadError()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not overwrite an unreadable settings.json when recording installation metadata', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const original = '{ invalid json';
|
||||
fs.writeFileSync(settingsPath, original, 'utf-8');
|
||||
|
||||
await saveInstallationMetadata({ method: 'pinned' });
|
||||
|
||||
// The unreadable file is preserved, not overwritten with defaults+metadata.
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(original);
|
||||
expect(fs.existsSync(backupPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('records installation metadata when settings.json is valid', async () => {
|
||||
const { settingsPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({ version: CURRENT_VERSION, lines: [[], [], []] }),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
await saveInstallationMetadata({ method: 'pinned' });
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { installation?: { method?: string } };
|
||||
expect(saved.installation?.method).toBe('pinned');
|
||||
});
|
||||
|
||||
it('always saves current version in saveSettings', async () => {
|
||||
const { settingsPath } = getSettingsPaths();
|
||||
|
||||
@@ -174,6 +257,126 @@ describe('config utilities', () => {
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves settings without leaving a temp file behind', async () => {
|
||||
const { settingsPath, configDir } = getSettingsPaths();
|
||||
|
||||
await saveSettings({ ...DEFAULT_SETTINGS });
|
||||
|
||||
// Final file is complete and valid.
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
|
||||
expect(saved.version).toBe(CURRENT_VERSION);
|
||||
|
||||
// No temporary write-file is left in the config directory.
|
||||
const leftovers = fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('saves through a symlinked settings file without replacing the link', async () => {
|
||||
const { settingsPath, configDir } = getSettingsPaths();
|
||||
const targetDir = path.join(MOCK_HOME_DIR, 'dotfiles', 'ccstatusline');
|
||||
const targetPath = path.join(targetDir, 'settings.json');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
targetPath,
|
||||
JSON.stringify({ version: CURRENT_VERSION, lines: [[], [], []], flexMode: 'full' }),
|
||||
'utf-8'
|
||||
);
|
||||
fs.symlinkSync(targetPath, settingsPath);
|
||||
|
||||
await saveSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
flexMode: 'full-minus-40'
|
||||
});
|
||||
|
||||
expect(fs.lstatSync(settingsPath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.realpathSync(settingsPath)).toBe(fs.realpathSync(targetPath));
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as {
|
||||
flexMode?: string;
|
||||
version?: number;
|
||||
};
|
||||
expect(saved.version).toBe(CURRENT_VERSION);
|
||||
expect(saved.flexMode).toBe('full-minus-40');
|
||||
expect(fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'))).toEqual([]);
|
||||
expect(fs.readdirSync(targetDir).filter(name => name.endsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('migration write-back leaves no temp file behind', 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'
|
||||
);
|
||||
|
||||
await loadSettings();
|
||||
|
||||
const migrated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
|
||||
expect(migrated.version).toBe(CURRENT_VERSION);
|
||||
const leftovers = fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans up the temp file and rethrows when the write cannot be renamed into place', async () => {
|
||||
const { settingsPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
// Make the target a directory so the final rename always fails, exercising
|
||||
// the cleanup-on-error path in writeSettingsJson.
|
||||
fs.mkdirSync(settingsPath, { recursive: true });
|
||||
|
||||
await expect(saveSettings({ ...DEFAULT_SETTINGS })).rejects.toThrow();
|
||||
|
||||
// The target is untouched and no temp file is left behind.
|
||||
expect(fs.statSync(settingsPath).isDirectory()).toBe(true);
|
||||
const leftovers = fs.readdirSync(configDir).filter(name => name.endsWith('.tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets getConfigLoadError when settings.json contains invalid JSON', async () => {
|
||||
const { configDir, settingsPath } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, '{ bad json', 'utf-8');
|
||||
|
||||
await loadSettings();
|
||||
|
||||
const err = getConfigLoadError();
|
||||
expect(err).not.toBeNull();
|
||||
expect(err).toContain('settings.json');
|
||||
});
|
||||
|
||||
it('sets getConfigLoadError when settings.json has invalid schema', async () => {
|
||||
const { configDir, settingsPath } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ version: CURRENT_VERSION, lines: 42 }), 'utf-8');
|
||||
|
||||
await loadSettings();
|
||||
|
||||
expect(getConfigLoadError()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears getConfigLoadError after loading a valid current-version config', async () => {
|
||||
const { configDir, settingsPath } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
// Write a valid config so we don't go through first-run path
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ version: CURRENT_VERSION, lines: [[], [], []] }), 'utf-8');
|
||||
|
||||
await loadSettings();
|
||||
|
||||
expect(getConfigLoadError()).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves getConfigLoadError null on first-run (no file)', async () => {
|
||||
// No file written — loadSettings triggers writeDefaultSettings
|
||||
await loadSettings();
|
||||
|
||||
expect(getConfigLoadError()).toBeNull();
|
||||
});
|
||||
|
||||
it('silently rewrites legacy git-pr widget type to git-review on load', async () => {
|
||||
const { settingsPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { computeCiRollup } from '../git-review-cache';
|
||||
|
||||
const pass = { status: 'COMPLETED', conclusion: 'SUCCESS' };
|
||||
const fail = { status: 'COMPLETED', conclusion: 'FAILURE' };
|
||||
const running = { status: 'IN_PROGRESS', conclusion: '' };
|
||||
const neutral = { status: 'COMPLETED', conclusion: 'NEUTRAL' };
|
||||
const skipped = { status: 'COMPLETED', conclusion: 'SKIPPED' };
|
||||
const statusPass = { state: 'SUCCESS' };
|
||||
const statusFail = { state: 'FAILURE' };
|
||||
const statusPending = { state: 'PENDING' };
|
||||
|
||||
describe('computeCiRollup', () => {
|
||||
it.each([
|
||||
['all passing check runs', [pass, pass, pass], { state: 'passing', failing: 0, pending: 0, success: 3 }],
|
||||
['a failure makes it failing', [pass, fail, pass], { state: 'failing', failing: 1, pending: 0, success: 2 }],
|
||||
['a pending run makes it pending', [pass, running], { state: 'pending', failing: 0, pending: 1, success: 1 }],
|
||||
['failure takes precedence over pending', [fail, running, pass], { state: 'failing', failing: 1, pending: 1, success: 1 }],
|
||||
['neutral and skipped are ignored (not counted as success)', [pass, neutral, skipped], { state: 'passing', failing: 0, pending: 0, success: 1 }],
|
||||
['StatusContext success counts as success', [statusPass, statusPass], { state: 'passing', failing: 0, pending: 0, success: 2 }],
|
||||
['StatusContext failure counts as failing', [statusPass, statusFail], { state: 'failing', failing: 1, pending: 0, success: 1 }],
|
||||
['StatusContext pending counts as pending', [statusPass, statusPending], { state: 'pending', failing: 0, pending: 1, success: 1 }],
|
||||
['the screenshot mix (1 fail, 1 neutral, 1 pending, 2 skipped, 4 success)', [fail, neutral, running, skipped, skipped, pass, pass, pass, pass], { state: 'failing', failing: 1, pending: 1, success: 4 }]
|
||||
])('%s', (_label, rollup, expected) => {
|
||||
expect(computeCiRollup(rollup)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty array', []],
|
||||
['non-array', null],
|
||||
['undefined', undefined],
|
||||
['string', 'nope']
|
||||
])('returns null for %s', (_label, input) => {
|
||||
expect(computeCiRollup(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
|
||||
import {
|
||||
fetchGitReviewData,
|
||||
getCachedGitReviewData,
|
||||
refreshGitReviewCacheFromCli,
|
||||
type GitReviewCacheDeps
|
||||
} from '../git-review-cache';
|
||||
|
||||
@@ -18,8 +20,11 @@ interface PrCacheHarness {
|
||||
cacheFiles: Map<string, FakeCacheFile>;
|
||||
deps: GitReviewCacheDeps;
|
||||
execCalls: { args: string[]; cmd: string; cwd?: string }[];
|
||||
ghDurations: number[];
|
||||
ghResponses: (Error | string)[];
|
||||
glabResponses: (Error | string)[];
|
||||
spawnCalls: { args: string[]; command: string }[];
|
||||
advanceNow: (milliseconds: number) => void;
|
||||
setCurrentRef: (ref: string) => void;
|
||||
setOriginRemoteUrl: (url: string) => void;
|
||||
setGlabAvailable: (available: boolean) => void;
|
||||
@@ -30,9 +35,11 @@ interface PrCacheHarness {
|
||||
function createHarness(): PrCacheHarness {
|
||||
const cacheFiles = new Map<string, FakeCacheFile>();
|
||||
const execCalls: { args: string[]; cmd: string; cwd?: string }[] = [];
|
||||
const ghDurations: number[] = [];
|
||||
const ghResponses: (Error | string)[] = [];
|
||||
const glabResponses: (Error | string)[] = [];
|
||||
const now = 1_700_000_000_000;
|
||||
const spawnCalls: { args: string[]; command: string }[] = [];
|
||||
let now = 1_700_000_000_000;
|
||||
let currentRef = 'feature/cache-a';
|
||||
let originRemoteUrl: string | null = null;
|
||||
let glabAvailable = false;
|
||||
@@ -43,6 +50,7 @@ function createHarness(): PrCacheHarness {
|
||||
const sshHostAliases = new Map<string, string>();
|
||||
|
||||
const deps: GitReviewCacheDeps = {
|
||||
closeSync: () => undefined,
|
||||
execFileSync: ((cmd, args, options) => {
|
||||
const commandArgs = Array.isArray(args)
|
||||
? args.map(arg => String(arg))
|
||||
@@ -81,6 +89,7 @@ function createHarness(): PrCacheHarness {
|
||||
return '';
|
||||
}
|
||||
if (cmd === 'gh' && commandArgs[0] === 'pr') {
|
||||
now += ghDurations.shift() ?? 0;
|
||||
const response = ghResponses.shift();
|
||||
if (response instanceof Error)
|
||||
throw response;
|
||||
@@ -110,11 +119,33 @@ function createHarness(): PrCacheHarness {
|
||||
throw new Error(`Unexpected command: ${cmd} ${commandArgs.join(' ')}`);
|
||||
}) as GitReviewCacheDeps['execFileSync'],
|
||||
existsSync: filePath => cacheFiles.has(String(filePath)),
|
||||
getExecPath: () => '/usr/bin/node',
|
||||
getHomedir: () => '/tmp/home',
|
||||
mkdirSync: () => undefined,
|
||||
openSync: (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (cacheFiles.has(normalizedPath)) {
|
||||
throw new Error('EEXIST');
|
||||
}
|
||||
cacheFiles.set(normalizedPath, { content: '', mtimeMs: now });
|
||||
return 42;
|
||||
},
|
||||
now: () => now,
|
||||
readFileSync: (filePath => cacheFiles.get(String(filePath))?.content ?? '') as GitReviewCacheDeps['readFileSync'],
|
||||
getScriptPath: () => '/app/ccstatusline.js',
|
||||
spawn: ((command, args) => {
|
||||
spawnCalls.push({
|
||||
args: Array.isArray(args) ? args.map(arg => String(arg)) : [],
|
||||
command
|
||||
});
|
||||
return { unref: () => undefined };
|
||||
}) as GitReviewCacheDeps['spawn'],
|
||||
statSync: (filePath => ({ mtimeMs: cacheFiles.get(String(filePath))?.mtimeMs ?? now })) as GitReviewCacheDeps['statSync'],
|
||||
unlinkSync: (filePath) => {
|
||||
if (!cacheFiles.delete(String(filePath))) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
},
|
||||
writeFileSync: (filePath, content) => {
|
||||
const normalizedContent = typeof content === 'string'
|
||||
? content
|
||||
@@ -132,8 +163,13 @@ function createHarness(): PrCacheHarness {
|
||||
cacheFiles,
|
||||
deps,
|
||||
execCalls,
|
||||
ghDurations,
|
||||
ghResponses,
|
||||
glabResponses,
|
||||
spawnCalls,
|
||||
advanceNow: (milliseconds) => {
|
||||
now += milliseconds;
|
||||
},
|
||||
setCurrentRef: (ref: string) => {
|
||||
currentRef = ref;
|
||||
},
|
||||
@@ -156,23 +192,194 @@ function createHarness(): PrCacheHarness {
|
||||
};
|
||||
}
|
||||
|
||||
function prepareCachePath(harness: PrCacheHarness): string {
|
||||
getCachedGitReviewData('/tmp/repo', {}, harness.deps);
|
||||
const lockPath = [...harness.cacheFiles.keys()].find(filePath => filePath.endsWith('.lock'));
|
||||
if (!lockPath) {
|
||||
throw new Error('Expected a refresh lock');
|
||||
}
|
||||
harness.cacheFiles.delete(lockPath);
|
||||
harness.spawnCalls.length = 0;
|
||||
return lockPath.slice(0, -'.lock'.length);
|
||||
}
|
||||
|
||||
describe('git-review-cache', () => {
|
||||
it('negative-caches failed gh PR lookups', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
|
||||
harness.ghResponses.push(new Error('no pull request found'));
|
||||
harness.ghResponses.push(new Error('no pull request found'));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
|
||||
|
||||
const ghCallsAfterFirstRender = harness.execCalls.filter(call => call.cmd === 'gh');
|
||||
expect(ghCallsAfterFirstRender).toHaveLength(2);
|
||||
expect(ghCallsAfterFirstRender).toHaveLength(3);
|
||||
const ghPrCalls = ghCallsAfterFirstRender.filter(call => call.args[0] === 'pr');
|
||||
expect(ghPrCalls).toHaveLength(2);
|
||||
expect(ghPrCalls[0]?.args).not.toContain('--repo');
|
||||
expect(ghPrCalls[1]?.args).toContain('--repo');
|
||||
expect(ghPrCalls.every(call => call.args.at(-1) === 'url,number,title,state,reviewDecision')).toBe(true);
|
||||
|
||||
const cachedMissEntry = [...harness.cacheFiles.values()].at(0);
|
||||
expect(cachedMissEntry?.content).toBe('');
|
||||
expect(JSON.parse(cachedMissEntry?.content ?? '')).toEqual({
|
||||
checksQueried: true,
|
||||
data: null,
|
||||
version: 1
|
||||
});
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
|
||||
|
||||
const ghCallsAfterSecondRender = harness.execCalls.filter(call => call.cmd === 'gh');
|
||||
expect(ghCallsAfterSecondRender).toHaveLength(2);
|
||||
expect(ghCallsAfterSecondRender).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('does not retry metadata-only for ordinary CI lookup failures', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
|
||||
harness.ghResponses.push(new Error('no pull request found'));
|
||||
harness.ghResponses.push(new Error('no pull request found'));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toBeNull();
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(2);
|
||||
expect(ghPrCalls[0]?.args).not.toContain('--repo');
|
||||
expect(ghPrCalls[1]?.args).toContain('--repo');
|
||||
expect(ghPrCalls.every(call => call.args.at(-1)?.includes('statusCheckRollup'))).toBe(true);
|
||||
});
|
||||
|
||||
it('shares one deadline across unpinned and pinned CI lookups', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
|
||||
harness.ghDurations.push(5_000);
|
||||
harness.ghResponses.push(new Error('timed out'));
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 42,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Too late',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toBeNull();
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns immediately on a cache miss and schedules one metadata refresh', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
|
||||
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
|
||||
|
||||
expect(harness.execCalls.filter(call => call.cmd === 'gh')).toHaveLength(0);
|
||||
expect(harness.spawnCalls).toHaveLength(1);
|
||||
expect(harness.spawnCalls[0]?.command).toBe('/usr/bin/node');
|
||||
expect(harness.spawnCalls[0]?.args.slice(0, 4)).toEqual([
|
||||
'/app/ccstatusline.js',
|
||||
'--internal-refresh-git-review-cache',
|
||||
'/tmp/repo',
|
||||
'metadata'
|
||||
]);
|
||||
});
|
||||
|
||||
it('refreshes through the detached CLI mode and releases its lock', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 42,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Background result',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
}));
|
||||
|
||||
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
|
||||
const lockPath = [...harness.cacheFiles.keys()].find(filePath => filePath.endsWith('.lock'));
|
||||
expect(lockPath).toBeDefined();
|
||||
refreshGitReviewCacheFromCli('/tmp/repo', {}, lockPath ?? '', harness.deps);
|
||||
|
||||
expect([...harness.cacheFiles.keys()].some(filePath => filePath.endsWith('.lock'))).toBe(false);
|
||||
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)?.title).toBe('Background result');
|
||||
expect(harness.spawnCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns stale data while scheduling a refresh', () => {
|
||||
const harness = createHarness();
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 42,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Stale but useful',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
}));
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)?.title).toBe('Stale but useful');
|
||||
harness.advanceNow(30_001);
|
||||
|
||||
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)?.title).toBe('Stale but useful');
|
||||
expect(harness.spawnCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('recovers a stale refresh lock', () => {
|
||||
const harness = createHarness();
|
||||
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
|
||||
expect(harness.spawnCalls).toHaveLength(1);
|
||||
|
||||
harness.advanceNow(30_001);
|
||||
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toBeNull();
|
||||
expect(harness.spawnCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reads legacy metadata cache files and upgrades them when CI is requested', () => {
|
||||
const harness = createHarness();
|
||||
const cachePath = prepareCachePath(harness);
|
||||
const legacyData = {
|
||||
number: 42,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Legacy cache',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
};
|
||||
harness.cacheFiles.set(cachePath, {
|
||||
content: JSON.stringify(legacyData),
|
||||
mtimeMs: harness.deps.now()
|
||||
});
|
||||
|
||||
expect(getCachedGitReviewData('/tmp/repo', {}, harness.deps)).toEqual(legacyData);
|
||||
expect(harness.spawnCalls).toHaveLength(0);
|
||||
expect(getCachedGitReviewData('/tmp/repo', { includeChecks: true }, harness.deps)).toEqual(legacyData);
|
||||
expect(harness.spawnCalls).toHaveLength(1);
|
||||
expect(harness.spawnCalls[0]?.args[3]).toBe('checks');
|
||||
});
|
||||
|
||||
it('accepts legacy empty negative-cache files without refreshing them while fresh', () => {
|
||||
const harness = createHarness();
|
||||
const cachePath = prepareCachePath(harness);
|
||||
harness.cacheFiles.set(cachePath, { content: '', mtimeMs: harness.deps.now() });
|
||||
|
||||
expect(getCachedGitReviewData('/tmp/repo', { includeChecks: true }, harness.deps)).toBeNull();
|
||||
expect(harness.spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('records an empty CI rollup as queried so it is not fetched repeatedly', () => {
|
||||
const harness = createHarness();
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 42,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
statusCheckRollup: [],
|
||||
title: 'No checks configured',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })?.checks).toBeUndefined();
|
||||
expect(getCachedGitReviewData('/tmp/repo', { includeChecks: true }, harness.deps)?.title).toBe('No checks configured');
|
||||
expect(harness.spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses a different cache entry for each checked-out branch', () => {
|
||||
@@ -279,18 +486,28 @@ describe('git-review-cache', () => {
|
||||
expect(data?.state).toBe('MERGED');
|
||||
});
|
||||
|
||||
it('uses gh\'s default repo resolution when it succeeds (no --repo pin needed)', () => {
|
||||
it('uses gh\'s default repo resolution and includes CI checks in one query', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 42,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
statusCheckRollup: [
|
||||
{ conclusion: 'SUCCESS', status: 'COMPLETED' },
|
||||
{ conclusion: '', status: 'IN_PROGRESS' }
|
||||
],
|
||||
title: 'Standard PR',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toEqual({
|
||||
checks: {
|
||||
failing: 0,
|
||||
pending: 1,
|
||||
state: 'pending',
|
||||
success: 1
|
||||
},
|
||||
number: 42,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
@@ -304,6 +521,55 @@ describe('git-review-cache', () => {
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(1);
|
||||
expect(ghPrCalls[0]?.args).not.toContain('--repo');
|
||||
expect(ghPrCalls[0]?.args.at(-1)).toBe(
|
||||
'url,number,title,state,reviewDecision,statusCheckRollup'
|
||||
);
|
||||
});
|
||||
|
||||
it('retries metadata-only when gh cannot query CI checks', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
|
||||
harness.ghResponses.push(new Error('statusCheckRollup is unavailable'));
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 42,
|
||||
reviewDecision: 'APPROVED',
|
||||
state: 'OPEN',
|
||||
title: 'Restricted token PR',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
}));
|
||||
|
||||
const expected = {
|
||||
number: 42,
|
||||
provider: 'gh' as const,
|
||||
reviewDecision: 'APPROVED',
|
||||
state: 'OPEN',
|
||||
title: 'Restricted token PR',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
};
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toEqual(expected);
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(2);
|
||||
expect(ghPrCalls[0]?.args.slice(0, -2)).toEqual(ghPrCalls[1]?.args.slice(0, -2));
|
||||
expect(ghPrCalls[0]?.args.at(-1)).toBe(
|
||||
'url,number,title,state,reviewDecision,statusCheckRollup'
|
||||
);
|
||||
expect(ghPrCalls[1]?.args.at(-1)).toBe('url,number,title,state,reviewDecision');
|
||||
|
||||
const cachedEntry = [...harness.cacheFiles.values()].at(0);
|
||||
expect(JSON.parse(cachedEntry?.content ?? '')).toEqual({
|
||||
checksQueried: true,
|
||||
data: expected,
|
||||
version: 1
|
||||
});
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toEqual(expected);
|
||||
const cachedGhPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(cachedGhPrCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('falls back to --repo <origin> for forked GitHub repos when gh\'s default resolves elsewhere', () => {
|
||||
@@ -337,6 +603,39 @@ describe('git-review-cache', () => {
|
||||
expect(ghPrCalls[1]?.args).toContain('feature/cache-a');
|
||||
});
|
||||
|
||||
it('reuses the pinned PR target for the metadata-only compatibility retry', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/fork-owner/example-repo.git');
|
||||
harness.ghResponses.push('');
|
||||
harness.ghResponses.push(new Error('statusCheckRollup is unavailable'));
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 1,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Forked PR',
|
||||
url: 'https://github.com/fork-owner/example-repo/pull/1'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps, { includeChecks: true })).toEqual({
|
||||
number: 1,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Forked PR',
|
||||
url: 'https://github.com/fork-owner/example-repo/pull/1'
|
||||
});
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(3);
|
||||
expect(ghPrCalls[1]?.args.slice(0, -2)).toEqual(ghPrCalls[2]?.args.slice(0, -2));
|
||||
expect(ghPrCalls[1]?.args).toContain('feature/cache-a');
|
||||
expect(ghPrCalls[1]?.args).toContain('--repo');
|
||||
expect(ghPrCalls[1]?.args).toContain('https://github.com/fork-owner/example-repo');
|
||||
expect(ghPrCalls[2]?.args.at(-1)).toBe('url,number,title,state,reviewDecision');
|
||||
});
|
||||
|
||||
it('resolves SSH host aliases before selecting GitHub and pinning --repo', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@mygit:owner/repo.git');
|
||||
@@ -561,7 +860,11 @@ describe('git-review-cache', () => {
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(0);
|
||||
const cachedMissEntry = [...harness.cacheFiles.values()].at(0);
|
||||
expect(cachedMissEntry?.content).toBe('');
|
||||
expect(JSON.parse(cachedMissEntry?.content ?? '')).toEqual({
|
||||
checksQueried: true,
|
||||
data: null,
|
||||
version: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers glab over gh for unknown host when both CLIs are authed', () => {
|
||||
|
||||
@@ -71,6 +71,32 @@ describe('global command resolution', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('silences child stderr on best-effort probes so failures cannot leak to the terminal', () => {
|
||||
const execFileSyncSpy = mockExecFileSync({
|
||||
'which -a ccstatusline': '/home/alice/.bun/bin/ccstatusline\n',
|
||||
'bun pm bin -g': '/home/alice/.bun/bin\n'
|
||||
});
|
||||
|
||||
inspectGlobalCommandResolution('bun', { platform: 'linux' });
|
||||
|
||||
expect(execFileSyncSpy).toHaveBeenCalled();
|
||||
for (const call of execFileSyncSpy.mock.calls) {
|
||||
const options = call[2] as { stdio?: string[] };
|
||||
expect(options.stdio).toEqual(['ignore', 'pipe', 'ignore']);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a probe that throws with stderr output as not found without surfacing an error', () => {
|
||||
vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => {
|
||||
throw new Error('error: No package.json was found for directory "C:\\Users\\alice\\.bun\\install\\global"');
|
||||
});
|
||||
|
||||
const resolution = inspectGlobalCommandResolution('bun', { platform: 'win32' });
|
||||
|
||||
expect(resolution.resolvedPaths).toEqual([]);
|
||||
expect(resolution.expectedBinDir).toBeNull();
|
||||
});
|
||||
|
||||
it('warns when multiple PATH directories contain ccstatusline', () => {
|
||||
mockExecFileSync({
|
||||
'which -a ccstatusline': '/home/alice/.bun/bin/ccstatusline\n/usr/local/bin/ccstatusline\n',
|
||||
|
||||
@@ -10,8 +10,14 @@ import {
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import { syncWidgetHooks } from '../hooks';
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
SettingsSchema
|
||||
} from '../../types/Settings';
|
||||
import {
|
||||
removeManagedHooks,
|
||||
syncWidgetHooks
|
||||
} from '../hooks';
|
||||
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
let testClaudeConfigDir = '';
|
||||
@@ -76,4 +82,166 @@ describe('syncWidgetHooks', () => {
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('heals legacy untagged ccstatusline hooks instead of leaving duplicates', async () => {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const command = '/Users/test/.bun/bin/ccstatusline';
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({
|
||||
statusLine: { type: 'command', command },
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' }]
|
||||
},
|
||||
{
|
||||
matcher: 'Other',
|
||||
hooks: [{ type: 'command', command: 'keep-command' }]
|
||||
}
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{ hooks: [{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' }] }
|
||||
]
|
||||
}
|
||||
}, null, 2), 'utf-8');
|
||||
|
||||
const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] });
|
||||
|
||||
await syncWidgetHooks(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' }]
|
||||
},
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: `${command} --hook` }]
|
||||
}
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
hooks: [{ type: 'command', command: `${command} --hook` }]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves other commands in mixed legacy hook entries while syncing', async () => {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const command = '/Users/test/.bun/bin/ccstatusline';
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({
|
||||
statusLine: { type: 'command', command },
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Skill',
|
||||
hooks: [
|
||||
{ type: 'command', command: 'npx ccstatusline --hook' },
|
||||
{ type: 'command', command: 'keep-command' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}, null, 2), 'utf-8');
|
||||
|
||||
const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] });
|
||||
|
||||
await syncWidgetHooks(settings);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record<string, unknown[]> };
|
||||
expect(saved.hooks).toEqual({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: 'keep-command' }]
|
||||
},
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: `${command} --hook` }]
|
||||
}
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
hooks: [{ type: 'command', command: `${command} --hook` }]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves other commands in mixed legacy hook entries while removing managed hooks', async () => {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Skill',
|
||||
hooks: [
|
||||
{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' },
|
||||
{ type: 'command', command: 'keep-command' }
|
||||
]
|
||||
},
|
||||
{
|
||||
matcher: 'LegacyOnly',
|
||||
hooks: [{ type: 'command', command: 'npx ccstatusline --hook' }]
|
||||
},
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Managed',
|
||||
hooks: [{ type: 'command', command: '/Users/test/.bun/bin/ccstatusline --hook' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}, null, 2), 'utf-8');
|
||||
|
||||
await removeManagedHooks();
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record<string, unknown[]> };
|
||||
expect(saved.hooks).toEqual({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: 'keep-command' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('is idempotent — repeated syncs heal legacy hooks without accumulating', async () => {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const command = '/Users/test/.bun/bin/ccstatusline';
|
||||
// Seed a legacy untagged hook so the first sync exercises the heal (regex) path,
|
||||
// not just the tagged-entry path.
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({
|
||||
statusLine: { type: 'command', command },
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: 'npx ccstatusline --hook' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}, null, 2), 'utf-8');
|
||||
|
||||
const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] });
|
||||
|
||||
await syncWidgetHooks(settings);
|
||||
const afterFirst = fs.readFileSync(settingsPath, 'utf-8');
|
||||
|
||||
await syncWidgetHooks(settings);
|
||||
const afterSecond = fs.readFileSync(settingsPath, 'utf-8');
|
||||
|
||||
expect(afterSecond).toEqual(afterFirst);
|
||||
|
||||
const saved = JSON.parse(afterSecond) as { hooks?: Record<string, unknown[]> };
|
||||
expect(saved.hooks?.PreToolUse).toHaveLength(1);
|
||||
expect(saved.hooks?.UserPromptSubmit).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
@@ -126,6 +127,58 @@ describe('getContextConfig', () => {
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CCSTATUSLINE_CONTEXT_SIZE_FALLBACK override', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CCSTATUSLINE_CONTEXT_SIZE_FALLBACK;
|
||||
});
|
||||
|
||||
it('uses the env value as the fallback when no window size is otherwise known', () => {
|
||||
process.env.CCSTATUSLINE_CONTEXT_SIZE_FALLBACK = '1000000';
|
||||
const config = getContextConfig('claude-sonnet-4-5-20250929');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('uses the env value as the fallback when the model is unknown', () => {
|
||||
process.env.CCSTATUSLINE_CONTEXT_SIZE_FALLBACK = '500000';
|
||||
const config = getContextConfig(undefined);
|
||||
|
||||
expect(config.maxTokens).toBe(500000);
|
||||
expect(config.usableTokens).toBe(400000);
|
||||
});
|
||||
|
||||
it('falls back to 200k when the env value is non-numeric', () => {
|
||||
process.env.CCSTATUSLINE_CONTEXT_SIZE_FALLBACK = 'not-a-number';
|
||||
const config = getContextConfig('claude-3-5-sonnet-20241022');
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
|
||||
it('falls back to 200k when the env value is zero or negative', () => {
|
||||
process.env.CCSTATUSLINE_CONTEXT_SIZE_FALLBACK = '0';
|
||||
expect(getContextConfig(undefined).maxTokens).toBe(200000);
|
||||
|
||||
process.env.CCSTATUSLINE_CONTEXT_SIZE_FALLBACK = '-5';
|
||||
expect(getContextConfig(undefined).maxTokens).toBe(200000);
|
||||
});
|
||||
|
||||
it('does not override the live context_window_size from the status JSON', () => {
|
||||
process.env.CCSTATUSLINE_CONTEXT_SIZE_FALLBACK = '500000';
|
||||
const config = getContextConfig('claude-3-5-sonnet-20241022', 1000000);
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
});
|
||||
|
||||
it('does not override a [1m] model-name hint', () => {
|
||||
process.env.CCSTATUSLINE_CONTEXT_SIZE_FALLBACK = '500000';
|
||||
const config = getContextConfig('claude-sonnet-4-5-20250929[1m]');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelContextIdentifier', () => {
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('powerline settings helpers', () => {
|
||||
expect(updated.powerline.theme).toBe('catppuccin');
|
||||
});
|
||||
|
||||
it('removes manual separators when requested', () => {
|
||||
it('removes manual separators while preserving flex separators when requested', () => {
|
||||
const line: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'separator' },
|
||||
@@ -53,7 +53,7 @@ describe('powerline settings helpers', () => {
|
||||
};
|
||||
|
||||
const updated = buildEnabledPowerlineSettings(settings, true);
|
||||
expect(updated.lines[0]?.map(item => item.type)).toEqual(['model', 'context-length']);
|
||||
expect(updated.lines[0]?.map(item => item.type)).toEqual(['model', 'context-length', 'flex-separator']);
|
||||
});
|
||||
|
||||
it('keeps manual separators when removal is not requested', () => {
|
||||
|
||||
@@ -41,6 +41,18 @@ describe('powerline theme index utils', () => {
|
||||
expect(countPowerlineThemeSlots(entries)).toBe(2);
|
||||
});
|
||||
|
||||
it('treats separators as powerline theme merge boundaries', () => {
|
||||
const entries: PowerlineThemeSlotEntry[] = [
|
||||
entry({ id: '1', type: 'model', merge: true }),
|
||||
entry({ id: '2', type: 'flex-separator' }),
|
||||
entry({ id: '3', type: 'context-length', merge: true }),
|
||||
entry({ id: '4', type: 'separator' }),
|
||||
entry({ id: '5', type: 'git-branch' })
|
||||
];
|
||||
|
||||
expect(countPowerlineThemeSlots(entries)).toBe(3);
|
||||
});
|
||||
|
||||
it('advances a running global theme index', () => {
|
||||
const firstLine: PowerlineThemeSlotEntry[] = [
|
||||
entry({ id: '1', type: 'model' }),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { updateColorMap } from '../colors';
|
||||
import { buildConfigWarningBadge } from '../renderer';
|
||||
|
||||
describe('buildConfigWarningBadge', () => {
|
||||
const originalLevel = chalk.level;
|
||||
|
||||
afterEach(() => {
|
||||
chalk.level = originalLevel;
|
||||
updateColorMap();
|
||||
});
|
||||
|
||||
it('returns plain text with no ANSI escapes when colorLevel is 0', () => {
|
||||
chalk.level = 0;
|
||||
updateColorMap();
|
||||
const result = buildConfigWarningBadge(0);
|
||||
expect(result).toBe('⚠ invalid config');
|
||||
expect(result).not.toContain('\x1b');
|
||||
});
|
||||
|
||||
it('contains the text and ANSI escapes when colorLevel is 2', () => {
|
||||
chalk.level = 2;
|
||||
updateColorMap();
|
||||
const result = buildConfigWarningBadge(2);
|
||||
expect(result).toContain('invalid config');
|
||||
expect(result).toContain('\x1b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
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 {
|
||||
getVisibleText,
|
||||
getVisibleWidth
|
||||
} from '../ansi';
|
||||
import {
|
||||
applyColors,
|
||||
applyParensDim
|
||||
} from '../colors';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine
|
||||
} from '../renderer';
|
||||
|
||||
const DIM = '\x1b[2m';
|
||||
const BOLD = '\x1b[1m';
|
||||
const INTENSITY_RESET = '\x1b[22m';
|
||||
const INTENSITY_RESET_BOLD = '\x1b[22;1m';
|
||||
const TRUECOLOR_CODE = /\x1b\[38;2;\d+;\d+;\d+m/g;
|
||||
|
||||
function createSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
flexMode: 'full',
|
||||
...overrides,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
...(overrides.powerline ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderLine(
|
||||
widgets: WidgetItem[],
|
||||
options: { settings?: Partial<Settings>; terminalWidth?: number } = {}
|
||||
): string {
|
||||
const settings = createSettings(options.settings);
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: options.terminalWidth
|
||||
};
|
||||
|
||||
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
const preRenderedWidgets = preRenderedLines[0] ?? [];
|
||||
|
||||
return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
}
|
||||
|
||||
describe('applyColors dim handling', () => {
|
||||
it('dims the whole text with a single intensity reset', () => {
|
||||
expect(applyColors('ctx', undefined, undefined, false, 'ansi16', true)).toBe(`${DIM}ctx${INTENSITY_RESET}`);
|
||||
});
|
||||
|
||||
it('emits one intensity reset when bold and dim are combined', () => {
|
||||
expect(applyColors('ctx', undefined, undefined, true, 'ansi16', true)).toBe(`${BOLD}${DIM}ctx${INTENSITY_RESET}`);
|
||||
});
|
||||
|
||||
it('dims only parenthesized spans in parens mode', () => {
|
||||
expect(applyColors('42k (21%)', undefined, undefined, false, 'ansi16', 'parens')).toBe(`42k ${DIM}(21%)${INTENSITY_RESET}`);
|
||||
});
|
||||
|
||||
it('re-asserts bold after each parens span when bold is active', () => {
|
||||
expect(applyColors('42k (21%)', undefined, undefined, true, 'ansi16', 'parens')).toBe(`${BOLD}42k ${DIM}(21%)${INTENSITY_RESET_BOLD}${INTENSITY_RESET}`);
|
||||
});
|
||||
|
||||
it('leaves text without parens untouched', () => {
|
||||
expect(applyParensDim('plain text')).toBe('plain text');
|
||||
});
|
||||
|
||||
it('dims multiple parens spans independently', () => {
|
||||
expect(applyParensDim('(a) mid (b)')).toBe(`${DIM}(a)${INTENSITY_RESET} mid ${DIM}(b)${INTENSITY_RESET}`);
|
||||
});
|
||||
|
||||
it('preserves parens dim when applying a foreground gradient', () => {
|
||||
const line = applyColors('ctx (42%)', 'gradient:FF0000-0000FF', undefined, false, 'truecolor', 'parens');
|
||||
const dimIndex = line.indexOf(DIM);
|
||||
const openParenIndex = line.indexOf('(');
|
||||
const closeParenIndex = line.indexOf(')');
|
||||
const resetIndex = line.indexOf(INTENSITY_RESET, closeParenIndex);
|
||||
|
||||
expect(getVisibleText(line)).toBe('ctx (42%)');
|
||||
expect(dimIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(dimIndex).toBeLessThan(openParenIndex);
|
||||
expect(resetIndex).toBeGreaterThan(closeParenIndex);
|
||||
expect(line.match(TRUECOLOR_CODE)).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderer dim styling', () => {
|
||||
it('dims a whole widget in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'hello',
|
||||
dim: true
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets);
|
||||
expect(line.indexOf(DIM)).toBeGreaterThanOrEqual(0);
|
||||
expect(line.indexOf(DIM)).toBeLessThan(line.indexOf('hello'));
|
||||
expect(line.indexOf(INTENSITY_RESET)).toBeGreaterThan(line.indexOf('hello'));
|
||||
});
|
||||
|
||||
it('dims only the parens span in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'ctx (42%)',
|
||||
dim: 'parens'
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets);
|
||||
expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET}`);
|
||||
expect(line.indexOf('ctx')).toBeLessThan(line.indexOf(DIM));
|
||||
});
|
||||
|
||||
it('keeps surrounding bold across a parens span', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'ctx (42%)',
|
||||
bold: true,
|
||||
dim: 'parens'
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets);
|
||||
expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET_BOLD}`);
|
||||
});
|
||||
|
||||
it('does not change the visible text or width', () => {
|
||||
const plain: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'ctx (42%)'
|
||||
}
|
||||
];
|
||||
const dimmed: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'ctx (42%)',
|
||||
bold: true,
|
||||
dim: 'parens'
|
||||
}
|
||||
];
|
||||
|
||||
const plainLine = renderLine(plain);
|
||||
const dimmedLine = renderLine(dimmed);
|
||||
expect(getVisibleText(dimmedLine)).toBe(getVisibleText(plainLine));
|
||||
expect(getVisibleWidth(dimmedLine)).toBe(getVisibleWidth(plainLine));
|
||||
});
|
||||
|
||||
it('applies dim in powerline mode and resets before the separator', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'head',
|
||||
color: 'white',
|
||||
backgroundColor: 'bgBlue',
|
||||
dim: true
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
type: 'custom-text',
|
||||
customText: 'tail',
|
||||
color: 'white',
|
||||
backgroundColor: 'bgGreen'
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets, {
|
||||
settings: {
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B0'],
|
||||
separatorInvertBackground: [false]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(line.indexOf(DIM)).toBeGreaterThanOrEqual(0);
|
||||
expect(line.indexOf(DIM)).toBeLessThan(line.indexOf('head'));
|
||||
expect(line.indexOf(INTENSITY_RESET)).toBeGreaterThan(line.indexOf('head'));
|
||||
expect(line.indexOf(INTENSITY_RESET)).toBeLessThan(line.indexOf('\uE0B0'));
|
||||
expect(line.indexOf(INTENSITY_RESET)).toBeLessThan(line.indexOf('tail'));
|
||||
});
|
||||
|
||||
it('does not leak dim past a middle powerline widget with customized colors', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'head',
|
||||
color: 'hex:ECEFF4',
|
||||
backgroundColor: 'hex:5E81AC'
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
type: 'custom-text',
|
||||
customText: 'mid',
|
||||
color: 'hex:ECEFF4',
|
||||
backgroundColor: 'hex:A3BE8C',
|
||||
dim: true
|
||||
},
|
||||
{
|
||||
id: 'w3',
|
||||
type: 'custom-text',
|
||||
customText: 'tail',
|
||||
color: 'hex:ECEFF4',
|
||||
backgroundColor: 'hex:BF616A'
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets, {
|
||||
settings: {
|
||||
colorLevel: 3,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
theme: 'custom',
|
||||
separators: ['\uE0B0'],
|
||||
separatorInvertBackground: [false]
|
||||
}
|
||||
}
|
||||
});
|
||||
const dimIndex = line.indexOf(DIM);
|
||||
const midIndex = line.indexOf('mid');
|
||||
const separatorAfterMidIndex = line.indexOf('\uE0B0', midIndex);
|
||||
const tailIndex = line.indexOf('tail');
|
||||
const resetAfterMidIndex = line.indexOf(INTENSITY_RESET, midIndex);
|
||||
|
||||
expect(dimIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(dimIndex).toBeLessThan(midIndex);
|
||||
expect(resetAfterMidIndex).toBeGreaterThan(midIndex);
|
||||
expect(resetAfterMidIndex).toBeLessThan(separatorAfterMidIndex);
|
||||
expect(resetAfterMidIndex).toBeLessThan(tailIndex);
|
||||
});
|
||||
|
||||
it('dims parens spans in powerline mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'ctx (42%)',
|
||||
color: 'white',
|
||||
backgroundColor: 'bgBlue',
|
||||
dim: 'parens'
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets, {
|
||||
settings: {
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B0'],
|
||||
separatorInvertBackground: [false]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET}`);
|
||||
});
|
||||
|
||||
it('dims parens spans in powerline mode with a global foreground gradient', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: 'ctx (42%)',
|
||||
color: 'white',
|
||||
backgroundColor: 'bgBlue',
|
||||
dim: 'parens'
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets, {
|
||||
settings: {
|
||||
colorLevel: 3,
|
||||
overrideForegroundColor: 'gradient:FF0000-0000FF',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B0'],
|
||||
separatorInvertBackground: [false]
|
||||
}
|
||||
}
|
||||
});
|
||||
const dimIndex = line.indexOf(DIM);
|
||||
const openParenIndex = line.indexOf('(');
|
||||
const closeParenIndex = line.indexOf(')');
|
||||
const resetIndex = line.indexOf(INTENSITY_RESET, closeParenIndex);
|
||||
|
||||
expect(getVisibleText(line)).toContain('ctx (42%)');
|
||||
expect(dimIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(dimIndex).toBeLessThan(openParenIndex);
|
||||
expect(resetIndex).toBeGreaterThan(closeParenIndex);
|
||||
expect(line.match(TRUECOLOR_CODE)?.length ?? 0).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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,
|
||||
type PreRenderedWidget
|
||||
} from '../renderer';
|
||||
|
||||
function createSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
defaultPadding: '',
|
||||
flexMode: 'full',
|
||||
...overrides,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
...(overrides.powerline ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function pre(content: string, extra: Partial<WidgetItem> = {}): PreRenderedWidget {
|
||||
return { content, plainLength: content.length, widget: { id: content, type: 'custom-text', ...extra } };
|
||||
}
|
||||
|
||||
function text(content: string, extra: Partial<WidgetItem> = {}): WidgetItem {
|
||||
return { id: content, type: 'custom-text', customText: content, ...extra };
|
||||
}
|
||||
|
||||
describe('calculateMaxWidthsFromPreRendered with excludeFromAutoAlign', () => {
|
||||
it.each([
|
||||
{ name: 'lets a wide widget inflate the shared column by default', exclude: false, expected: [5, 14] },
|
||||
{ name: 'drops an excluded widget and the rest of its line', exclude: true, expected: [5, 1] }
|
||||
])('$name', ({ exclude, expected }) => {
|
||||
const lines = [
|
||||
[pre('short'), pre('VERYLONGWIDGET', exclude ? { excludeFromAutoAlign: true } : {})],
|
||||
[pre('x'), pre('y')]
|
||||
];
|
||||
|
||||
expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual(expected);
|
||||
});
|
||||
|
||||
it('keeps columns before the excluded widget aligned', () => {
|
||||
const lines = [
|
||||
[pre('a'), pre('wide', { excludeFromAutoAlign: true }), pre('tail')],
|
||||
[pre('AAAAA'), pre('BBBBB'), pre('CCCCC')]
|
||||
];
|
||||
|
||||
expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([5, 5, 5]);
|
||||
});
|
||||
|
||||
it('ignores exclusions on widgets merged into a previous widget', () => {
|
||||
const linesWithoutExclude = [
|
||||
[pre('a', { merge: true }), pre('VERYLONGWIDGET')],
|
||||
[pre('x'), pre('y')]
|
||||
];
|
||||
const linesWithMergedExclude = [
|
||||
[pre('a', { merge: true }), pre('VERYLONGWIDGET', { excludeFromAutoAlign: true })],
|
||||
[pre('x'), pre('y')]
|
||||
];
|
||||
|
||||
expect(calculateMaxWidthsFromPreRendered(linesWithMergedExclude, createSettings()))
|
||||
.toEqual(calculateMaxWidthsFromPreRendered(linesWithoutExclude, createSettings()));
|
||||
});
|
||||
|
||||
it('honors exclusions on the first widget in a merged chain', () => {
|
||||
const lines = [
|
||||
[pre('a', { merge: true, excludeFromAutoAlign: true }), pre('VERYLONGWIDGET')],
|
||||
[pre('x'), pre('y')]
|
||||
];
|
||||
|
||||
expect(calculateMaxWidthsFromPreRendered(lines, createSettings())).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderStatusLine auto-align exemption', () => {
|
||||
const settings = createSettings({ powerline: { ...DEFAULT_SETTINGS.powerline, enabled: true, autoAlign: true } });
|
||||
|
||||
function renderFirstLine(exclude: boolean): string {
|
||||
const lines = [
|
||||
[text('a'), text('y', exclude ? { excludeFromAutoAlign: true } : {}), text('z')],
|
||||
[text('AAAAA'), text('BBBBB'), text('CCCCC')]
|
||||
];
|
||||
const context: RenderContext = { isPreview: false, terminalWidth: 200, lineIndex: 0 };
|
||||
const preRendered = preRenderAllWidgets(lines, settings, context);
|
||||
const maxWidths = calculateMaxWidthsFromPreRendered(preRendered, settings);
|
||||
return renderStatusLine(lines[0] ?? [], settings, context, preRendered[0] ?? [], maxWidths);
|
||||
}
|
||||
|
||||
it('exempts the excluded widget and the rest of its line from alignment padding', () => {
|
||||
expect(getVisibleWidth(renderFirstLine(false))).toBeGreaterThan(getVisibleWidth(renderFirstLine(true)));
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,18 @@ import {
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { getVisibleWidth } from '../ansi';
|
||||
import {
|
||||
getVisibleWidth,
|
||||
stripSgrCodes
|
||||
} from '../ansi';
|
||||
import { getColorAnsiCode } from '../colors';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
countPowerlineStartCapSlots,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine
|
||||
} from '../renderer';
|
||||
import { advanceGlobalSeparatorIndex } from '../separator-index';
|
||||
|
||||
function createSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
@@ -111,3 +117,516 @@ describe('renderer flex width behavior', () => {
|
||||
expect(line.endsWith('...')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flex-separator widget', () => {
|
||||
const leftWidget: WidgetItem = {
|
||||
id: 'left',
|
||||
type: 'custom-text',
|
||||
customText: 'LEFT',
|
||||
backgroundColor: 'bgBlue',
|
||||
color: 'white'
|
||||
};
|
||||
const rightWidget: WidgetItem = {
|
||||
id: 'right',
|
||||
type: 'custom-text',
|
||||
customText: 'RIGHT',
|
||||
backgroundColor: 'bgGreen',
|
||||
color: 'white'
|
||||
};
|
||||
const flexWidget: WidgetItem = { id: 'flex', type: 'flex-separator' };
|
||||
|
||||
it('keeps the flex position between visible widgets when a preceding widget renders empty', () => {
|
||||
const hiddenWidget: WidgetItem = {
|
||||
id: 'hidden',
|
||||
type: 'custom-text',
|
||||
customText: '',
|
||||
backgroundColor: 'bgRed',
|
||||
color: 'white'
|
||||
};
|
||||
const line = renderLine([leftWidget, hiddenWidget, flexWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true
|
||||
}
|
||||
}, { terminalWidth: 50 });
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(50 - 6);
|
||||
expect(plainLine.endsWith('RIGHT')).toBe(true);
|
||||
expect(plainLine).not.toMatch(/RIGHT\s+$/);
|
||||
});
|
||||
|
||||
it('distributes remaining width across a flex-separator in powerline mode', () => {
|
||||
const line = renderLine([leftWidget, flexWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true
|
||||
}
|
||||
}, { terminalWidth: 50 });
|
||||
|
||||
// flexMode 'full' reserves 6 columns for trailing UI, so the effective
|
||||
// render width is terminalWidth - 6.
|
||||
expect(getVisibleWidth(line)).toBe(50 - 6);
|
||||
});
|
||||
|
||||
it('distributes space across multiple flex-separators in powerline mode', () => {
|
||||
const middleWidget: WidgetItem = {
|
||||
id: 'middle',
|
||||
type: 'custom-text',
|
||||
customText: 'MID',
|
||||
backgroundColor: 'bgYellow',
|
||||
color: 'black'
|
||||
};
|
||||
const line = renderLine([leftWidget, flexWidget, middleWidget, flexWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true
|
||||
}
|
||||
}, { terminalWidth: 60 });
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(60 - 6);
|
||||
});
|
||||
|
||||
it('uses the next configured start cap for each segment after a flex-separator', () => {
|
||||
const middleWidget: WidgetItem = {
|
||||
id: 'middle',
|
||||
type: 'custom-text',
|
||||
customText: 'MID',
|
||||
backgroundColor: 'bgYellow',
|
||||
color: 'black'
|
||||
};
|
||||
const line = renderLine([leftWidget, flexWidget, middleWidget, flexWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
startCaps: ['\uE0B2', '\uE0B6', '\uE0BA']
|
||||
}
|
||||
}, { terminalWidth: 60 });
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
const firstCapIndex = plainLine.indexOf('\uE0B2');
|
||||
const leftIndex = plainLine.indexOf('LEFT');
|
||||
const secondCapIndex = plainLine.indexOf('\uE0B6');
|
||||
const middleIndex = plainLine.indexOf('MID');
|
||||
const thirdCapIndex = plainLine.indexOf('\uE0BA');
|
||||
const rightIndex = plainLine.indexOf('RIGHT');
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(60 - 6);
|
||||
expect(firstCapIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(firstCapIndex).toBeLessThan(leftIndex);
|
||||
expect(secondCapIndex).toBeGreaterThan(leftIndex);
|
||||
expect(secondCapIndex).toBeLessThan(middleIndex);
|
||||
expect(thirdCapIndex).toBeGreaterThan(middleIndex);
|
||||
expect(thirdCapIndex).toBeLessThan(rightIndex);
|
||||
});
|
||||
|
||||
it('uses matching end caps for each flex-delimited powerline segment', () => {
|
||||
const middleWidget: WidgetItem = {
|
||||
id: 'middle',
|
||||
type: 'custom-text',
|
||||
customText: 'MID',
|
||||
backgroundColor: 'bgYellow',
|
||||
color: 'black'
|
||||
};
|
||||
const line = renderLine([leftWidget, flexWidget, middleWidget, flexWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
startCaps: ['<', '[', '{'],
|
||||
endCaps: ['>', ']', '}']
|
||||
}
|
||||
}, { terminalWidth: 60 });
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
const firstStartIndex = plainLine.indexOf('<');
|
||||
const leftIndex = plainLine.indexOf('LEFT');
|
||||
const firstEndIndex = plainLine.indexOf('>');
|
||||
const secondStartIndex = plainLine.indexOf('[');
|
||||
const middleIndex = plainLine.indexOf('MID');
|
||||
const secondEndIndex = plainLine.indexOf(']');
|
||||
const thirdStartIndex = plainLine.indexOf('{');
|
||||
const rightIndex = plainLine.indexOf('RIGHT');
|
||||
const thirdEndIndex = plainLine.indexOf('}');
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(60 - 6);
|
||||
expect(firstStartIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(firstEndIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(secondStartIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(secondEndIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(thirdStartIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(thirdEndIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(firstStartIndex).toBeLessThan(leftIndex);
|
||||
expect(leftIndex).toBeLessThan(firstEndIndex);
|
||||
expect(firstEndIndex).toBeLessThan(secondStartIndex);
|
||||
expect(secondStartIndex).toBeLessThan(middleIndex);
|
||||
expect(middleIndex).toBeLessThan(secondEndIndex);
|
||||
expect(secondEndIndex).toBeLessThan(thirdStartIndex);
|
||||
expect(thirdStartIndex).toBeLessThan(rightIndex);
|
||||
expect(rightIndex).toBeLessThan(thirdEndIndex);
|
||||
expect(plainLine.endsWith('}')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not consume start or end cap slots for leading flex separators', () => {
|
||||
const widgets = [flexWidget, rightWidget];
|
||||
const settings = createSettings({
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
startCaps: ['<', '['],
|
||||
endCaps: ['>', ']']
|
||||
}
|
||||
});
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: 50
|
||||
};
|
||||
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
|
||||
const preRenderedWidgets = preRenderedLines[0] ?? [];
|
||||
const line = renderStatusLine(
|
||||
widgets,
|
||||
settings,
|
||||
context,
|
||||
preRenderedWidgets,
|
||||
calculateMaxWidthsFromPreRendered(preRenderedLines, settings)
|
||||
);
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
expect(plainLine).toContain('<RIGHT>');
|
||||
expect(plainLine).not.toContain('[RIGHT]');
|
||||
expect(countPowerlineStartCapSlots(widgets, preRenderedWidgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('coalesces consecutive flex separators for cap sequencing', () => {
|
||||
const widgets = [leftWidget, flexWidget, flexWidget, rightWidget];
|
||||
const settings = createSettings({
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
startCaps: ['<', '[', '{'],
|
||||
endCaps: ['>', ']', '}']
|
||||
}
|
||||
});
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: 50
|
||||
};
|
||||
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
|
||||
const preRenderedWidgets = preRenderedLines[0] ?? [];
|
||||
const line = renderStatusLine(
|
||||
widgets,
|
||||
settings,
|
||||
context,
|
||||
preRenderedWidgets,
|
||||
calculateMaxWidthsFromPreRendered(preRenderedLines, settings)
|
||||
);
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
expect(plainLine).toContain('<LEFT>');
|
||||
expect(plainLine).toContain('[RIGHT]');
|
||||
expect(plainLine).not.toContain('{RIGHT');
|
||||
expect(countPowerlineStartCapSlots(widgets, preRenderedWidgets)).toBe(2);
|
||||
});
|
||||
|
||||
it('does not consume configured separator glyphs as flex segment caps', () => {
|
||||
const line = renderLine([leftWidget, flexWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['1', '2', '3'],
|
||||
startCaps: ['<', '['],
|
||||
endCaps: ['>', ']']
|
||||
}
|
||||
}, { terminalWidth: 50 });
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
expect(plainLine).toContain('<LEFT>');
|
||||
expect(plainLine).toContain('[RIGHT]');
|
||||
expect(plainLine).not.toContain('1');
|
||||
expect(plainLine).not.toContain('2');
|
||||
expect(plainLine).not.toContain('3');
|
||||
});
|
||||
|
||||
it('uses the next separator glyph only for separators rendered inside flex-delimited segments', () => {
|
||||
const middleWidget: WidgetItem = {
|
||||
id: 'middle',
|
||||
type: 'custom-text',
|
||||
customText: 'MID',
|
||||
backgroundColor: 'bgYellow',
|
||||
color: 'black'
|
||||
};
|
||||
const line = renderLine([leftWidget, flexWidget, middleWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['1', '2'],
|
||||
startCaps: ['<', '['],
|
||||
endCaps: ['>', ']']
|
||||
}
|
||||
}, { terminalWidth: 50 });
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
const middleIndex = plainLine.indexOf('MID');
|
||||
const separatorIndex = plainLine.indexOf('1');
|
||||
const rightIndex = plainLine.indexOf('RIGHT');
|
||||
|
||||
expect(separatorIndex).toBeGreaterThan(middleIndex);
|
||||
expect(separatorIndex).toBeLessThan(rightIndex);
|
||||
expect(plainLine).not.toContain('2');
|
||||
});
|
||||
|
||||
it('cycles separator glyphs across lines after widgets that render empty', () => {
|
||||
const hiddenWidget: WidgetItem = {
|
||||
id: 'hidden',
|
||||
type: 'custom-text',
|
||||
customText: '',
|
||||
backgroundColor: 'bgRed',
|
||||
color: 'white'
|
||||
};
|
||||
const firstLineWidgets = [leftWidget, hiddenWidget, rightWidget];
|
||||
const secondLineWidgets = [leftWidget, rightWidget];
|
||||
const settings = createSettings({
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['1', '2']
|
||||
}
|
||||
});
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: 70
|
||||
};
|
||||
const preRenderedLines = preRenderAllWidgets([firstLineWidgets, secondLineWidgets], settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
const firstPreRenderedWidgets = preRenderedLines[0] ?? [];
|
||||
const secondLine = renderStatusLine(
|
||||
secondLineWidgets,
|
||||
settings,
|
||||
{
|
||||
...context,
|
||||
lineIndex: 1,
|
||||
globalSeparatorIndex: advanceGlobalSeparatorIndex(0, firstLineWidgets, firstPreRenderedWidgets)
|
||||
},
|
||||
preRenderedLines[1] ?? [],
|
||||
preCalculatedMaxWidths
|
||||
);
|
||||
const plainSecondLine = stripSgrCodes(secondLine);
|
||||
|
||||
expect(advanceGlobalSeparatorIndex(0, firstLineWidgets, firstPreRenderedWidgets)).toBe(1);
|
||||
expect(plainSecondLine).toContain('LEFT2RIGHT');
|
||||
expect(plainSecondLine).not.toContain('LEFT1RIGHT');
|
||||
});
|
||||
|
||||
it('cycles separator glyphs across lines after flex-delimited segments', () => {
|
||||
const firstLineWidgets = [leftWidget, rightWidget, flexWidget, leftWidget, rightWidget];
|
||||
const secondLineWidgets = [leftWidget, rightWidget];
|
||||
const settings = createSettings({
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['1', '2'],
|
||||
startCaps: ['<'],
|
||||
endCaps: ['>']
|
||||
}
|
||||
});
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: 70
|
||||
};
|
||||
const preRenderedLines = preRenderAllWidgets([firstLineWidgets, secondLineWidgets], settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
const firstPreRenderedWidgets = preRenderedLines[0] ?? [];
|
||||
const secondLine = renderStatusLine(
|
||||
secondLineWidgets,
|
||||
settings,
|
||||
{
|
||||
...context,
|
||||
lineIndex: 1,
|
||||
globalSeparatorIndex: advanceGlobalSeparatorIndex(0, firstLineWidgets, firstPreRenderedWidgets)
|
||||
},
|
||||
preRenderedLines[1] ?? [],
|
||||
preCalculatedMaxWidths
|
||||
);
|
||||
const plainSecondLine = stripSgrCodes(secondLine);
|
||||
|
||||
expect(advanceGlobalSeparatorIndex(0, firstLineWidgets, firstPreRenderedWidgets)).toBe(2);
|
||||
expect(plainSecondLine).toContain('LEFT1RIGHT');
|
||||
expect(plainSecondLine).not.toContain('LEFT2RIGHT');
|
||||
});
|
||||
|
||||
it('continues start cap selection across lines after flex-created segments', () => {
|
||||
const settings = createSettings({
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
startCaps: ['\uE0B2', '\uE0B6', '\uE0BA']
|
||||
}
|
||||
});
|
||||
const firstLineWidgets = [leftWidget, flexWidget, rightWidget];
|
||||
const secondLineWidgets: WidgetItem[] = [{
|
||||
id: 'second-line',
|
||||
type: 'custom-text',
|
||||
customText: 'SECOND',
|
||||
backgroundColor: 'bgYellow',
|
||||
color: 'black'
|
||||
}];
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: 50,
|
||||
globalPowerlineStartCapIndex: 0
|
||||
};
|
||||
const preRenderedLines = preRenderAllWidgets([firstLineWidgets, secondLineWidgets], settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
const firstPreRenderedWidgets = preRenderedLines[0] ?? [];
|
||||
const secondPreRenderedWidgets = preRenderedLines[1] ?? [];
|
||||
const firstLine = renderStatusLine(
|
||||
firstLineWidgets,
|
||||
settings,
|
||||
context,
|
||||
firstPreRenderedWidgets,
|
||||
preCalculatedMaxWidths
|
||||
);
|
||||
const nextStartCapIndex = countPowerlineStartCapSlots(firstLineWidgets, firstPreRenderedWidgets);
|
||||
const secondLine = renderStatusLine(
|
||||
secondLineWidgets,
|
||||
settings,
|
||||
{
|
||||
...context,
|
||||
lineIndex: 1,
|
||||
globalPowerlineStartCapIndex: nextStartCapIndex
|
||||
},
|
||||
secondPreRenderedWidgets,
|
||||
preCalculatedMaxWidths
|
||||
);
|
||||
const firstPlainLine = stripSgrCodes(firstLine);
|
||||
const secondPlainLine = stripSgrCodes(secondLine);
|
||||
|
||||
expect(firstPlainLine.indexOf('\uE0B2')).toBeLessThan(firstPlainLine.indexOf('LEFT'));
|
||||
expect(firstPlainLine.indexOf('\uE0B6')).toBeLessThan(firstPlainLine.indexOf('RIGHT'));
|
||||
expect(nextStartCapIndex).toBe(2);
|
||||
expect(secondPlainLine.indexOf('\uE0BA')).toBeLessThan(secondPlainLine.indexOf('SECOND'));
|
||||
expect(secondPlainLine).not.toContain('\uE0B6');
|
||||
});
|
||||
|
||||
it('reserves end cap width before distributing flex space in powerline mode', () => {
|
||||
const line = renderLine([leftWidget, flexWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
endCaps: ['\uE0B0']
|
||||
}
|
||||
}, { terminalWidth: 50 });
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(50 - 6);
|
||||
expect(plainLine.endsWith('\uE0B0')).toBe(true);
|
||||
expect(line).not.toContain('...');
|
||||
});
|
||||
|
||||
it('uses a single visible space for a powerline flex separator when terminal width is unknown', () => {
|
||||
const line = renderLine([leftWidget, flexWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true
|
||||
}
|
||||
}, { terminalWidth: 0 });
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
// No marker characters should leak into the output.
|
||||
expect(line).not.toContain('FLEX');
|
||||
expect(line).not.toContain('\x01');
|
||||
expect(plainLine).toBe('LEFT RIGHT');
|
||||
});
|
||||
|
||||
it('does not merge padding across a powerline flex separator', () => {
|
||||
const line = renderLine([{ ...leftWidget, merge: 'no-padding' }, flexWidget, rightWidget], {
|
||||
defaultPadding: ' ',
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
startCaps: ['[', '['],
|
||||
endCaps: [']', ']']
|
||||
}
|
||||
}, { terminalWidth: 0 });
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
expect(plainLine).toBe('[ LEFT ] [ RIGHT ]');
|
||||
});
|
||||
|
||||
it('does not merge padding across an explicit separator in powerline mode', () => {
|
||||
const line = renderLine([
|
||||
{ ...leftWidget, merge: 'no-padding' },
|
||||
{ id: 'separator', type: 'separator' },
|
||||
rightWidget
|
||||
], {
|
||||
defaultPadding: ' ',
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['|']
|
||||
}
|
||||
}, { terminalWidth: 0 });
|
||||
const plainLine = stripSgrCodes(line);
|
||||
|
||||
expect(plainLine).toBe(' LEFT | RIGHT ');
|
||||
});
|
||||
|
||||
it('does not reuse theme colors across a powerline flex separator', () => {
|
||||
const line = renderLine([{ ...leftWidget, merge: true }, flexWidget, rightWidget], {
|
||||
colorLevel: 3,
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
theme: 'nord-aurora'
|
||||
}
|
||||
}, { terminalWidth: 0 });
|
||||
|
||||
expect(line).toContain(getColorAnsiCode('hex:BF616A', 'truecolor', true));
|
||||
expect(line).toContain(getColorAnsiCode('hex:EBCB8B', 'truecolor', true));
|
||||
});
|
||||
|
||||
it('does not group auto-align widths across a powerline flex separator', () => {
|
||||
const widgets = [{ ...leftWidget, merge: true }, flexWidget, rightWidget];
|
||||
const settings = createSettings({
|
||||
defaultPadding: ' ',
|
||||
flexMode: 'full',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
autoAlign: true
|
||||
}
|
||||
});
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: 50
|
||||
};
|
||||
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
|
||||
|
||||
expect(calculateMaxWidthsFromPreRendered(preRenderedLines, settings)).toEqual([6, 7]);
|
||||
});
|
||||
|
||||
it('still works in non-powerline mode (no regression)', () => {
|
||||
const line = renderLine([leftWidget, flexWidget, rightWidget], {
|
||||
flexMode: 'full',
|
||||
powerline: { ...DEFAULT_SETTINGS.powerline, enabled: false }
|
||||
}, { terminalWidth: 50 });
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(50 - 6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,4 +33,11 @@ describe('formatTokens', () => {
|
||||
it('still renders just below the boundary as k', () => {
|
||||
expect(formatTokens(999949)).toBe('999.9k');
|
||||
});
|
||||
|
||||
it('uses whole-number k and rolls up to M at decimals=0', () => {
|
||||
expect(formatTokens(711000, 0)).toBe('711k');
|
||||
expect(formatTokens(999499, 0)).toBe('999k');
|
||||
expect(formatTokens(999500, 0)).toBe('1.0M');
|
||||
expect(formatTokens(1000000, 0)).toBe('1.0M');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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 { stripSgrCodes } from '../ansi';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
renderStatusLine,
|
||||
type PreRenderedWidget
|
||||
} from '../renderer';
|
||||
|
||||
function createSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
colorLevel: 0,
|
||||
defaultPadding: '.',
|
||||
...overrides,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
...(overrides.powerline ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function pre(content: string, extra: Partial<WidgetItem> = {}): PreRenderedWidget {
|
||||
return { content, plainLength: content.length, widget: { id: content, type: 'custom-text', ...extra } };
|
||||
}
|
||||
|
||||
function text(content: string, extra: Partial<WidgetItem> = {}): WidgetItem {
|
||||
return { id: content, type: 'custom-text', customText: content, ...extra };
|
||||
}
|
||||
|
||||
function powerlineSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return createSettings({
|
||||
...overrides,
|
||||
powerline: { ...DEFAULT_SETTINGS.powerline, enabled: true, separators: ['|'] }
|
||||
});
|
||||
}
|
||||
|
||||
// Assertions below only care about visible content and spacing, never
|
||||
// styling, so every render() call returns SGR-stripped output. This matches
|
||||
// the repo convention (see stripSgrCodes usage in renderer-flex-width.test.ts
|
||||
// and the separator-collapse tests) and keeps assertions stable regardless of
|
||||
// chalk's ambient color-support detection (e.g. under FORCE_COLOR/NO_COLOR),
|
||||
// which is independent of the `colorLevel` set on Settings.
|
||||
function render(widgets: WidgetItem[], contentByIndex: Record<number, string>, settings: Settings): string {
|
||||
const context: RenderContext = { isPreview: false, terminalWidth: 200 };
|
||||
const preRenderedWidgets = widgets.map((widget, i) => {
|
||||
const content = contentByIndex[i] ?? '';
|
||||
return { content, plainLength: content.length, widget };
|
||||
});
|
||||
return stripSgrCodes(renderStatusLine(widgets, settings, context, preRenderedWidgets, []));
|
||||
}
|
||||
|
||||
describe('defaultPaddingSide', () => {
|
||||
it('defaults to "both", preserving existing behavior', () => {
|
||||
expect(DEFAULT_SETTINGS.defaultPaddingSide).toBe('both');
|
||||
});
|
||||
|
||||
describe('standard (non-powerline) rendering', () => {
|
||||
it('applies padding to both sides by default', () => {
|
||||
const settings = createSettings();
|
||||
const out = render([text('a')], { 0: 'A' }, settings);
|
||||
expect(out).toBe('.A.');
|
||||
});
|
||||
|
||||
it('applies padding to the left only when side is "left"', () => {
|
||||
const settings = createSettings({ defaultPaddingSide: 'left' });
|
||||
const out = render([text('a')], { 0: 'A' }, settings);
|
||||
expect(out).toBe('.A');
|
||||
});
|
||||
|
||||
it('applies padding to the right only when side is "right"', () => {
|
||||
const settings = createSettings({ defaultPaddingSide: 'right' });
|
||||
const out = render([text('a')], { 0: 'A' }, settings);
|
||||
expect(out).toBe('A.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('powerline rendering', () => {
|
||||
it('applies padding to both sides by default', () => {
|
||||
const out = render([text('a')], { 0: 'A' }, powerlineSettings());
|
||||
expect(out).toContain('.A.');
|
||||
});
|
||||
|
||||
it('applies padding to the left only when side is "left"', () => {
|
||||
const settings = powerlineSettings({ defaultPaddingSide: 'left' });
|
||||
const widgets = [text('a'), text('b')];
|
||||
const out = render(widgets, { 0: 'A', 1: 'B' }, settings);
|
||||
expect(out).toContain('.A');
|
||||
expect(out).not.toContain('A.');
|
||||
});
|
||||
|
||||
it('applies padding to the right only when side is "right"', () => {
|
||||
const settings = powerlineSettings({ defaultPaddingSide: 'right' });
|
||||
const widgets = [text('a'), text('b')];
|
||||
const out = render(widgets, { 0: 'A', 1: 'B' }, settings);
|
||||
expect(out).toContain('A.');
|
||||
expect(out).not.toContain('.A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge: "no-padding" takes precedence over the padding-side setting', () => {
|
||||
it.each([
|
||||
{
|
||||
side: 'left' as const,
|
||||
// Side 'left' means B's own leading pad would already be '.', so
|
||||
// the no-padding merge must be what suppresses it (glue "AB").
|
||||
withoutMerge: '.A.B',
|
||||
withMerge: '.AB'
|
||||
},
|
||||
{
|
||||
side: 'right' as const,
|
||||
// Side 'right' means A's own trailing pad would already be '.', so
|
||||
// the no-padding merge must be what suppresses it (glue "AB").
|
||||
withoutMerge: 'A.B.',
|
||||
withMerge: 'AB.'
|
||||
}
|
||||
])('standard mode, side "$side": no double-pad and correct glue across a no-padding merge boundary', ({ side, withoutMerge, withMerge }) => {
|
||||
const settings = createSettings({ defaultPaddingSide: side });
|
||||
|
||||
const baseline = render([text('a'), text('b')], { 0: 'A', 1: 'B' }, settings);
|
||||
expect(baseline).toBe(withoutMerge);
|
||||
|
||||
const merged = render([text('a', { merge: 'no-padding' }), text('b')], { 0: 'A', 1: 'B' }, settings);
|
||||
expect(merged).toBe(withMerge);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ side: 'left' as const },
|
||||
{ side: 'right' as const }
|
||||
])('powerline mode, side "$side": no double-pad, no separator, and correct glue across a no-padding merge boundary', ({ side }) => {
|
||||
const settings = powerlineSettings({ defaultPaddingSide: side });
|
||||
|
||||
const baseline = render([text('a'), text('b')], { 0: 'A', 1: 'B' }, settings);
|
||||
// Without the merge, A and B are separate segments joined by the
|
||||
// powerline separator.
|
||||
expect(baseline).toContain('|');
|
||||
|
||||
const merged = render([text('a', { merge: 'no-padding' }), text('b')], { 0: 'A', 1: 'B' }, settings);
|
||||
// With the no-padding merge, A and B glue directly together: no
|
||||
// separator, and no padding character sits between them.
|
||||
expect(merged).not.toContain('|');
|
||||
expect(merged).toContain('AB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateMaxWidthsFromPreRendered width accounting', () => {
|
||||
it('counts padding on both sides by default', () => {
|
||||
const lines = [[pre('AB')]];
|
||||
const settings = createSettings({ defaultPadding: '..' });
|
||||
// 'AB' (2) + 2 leading + 2 trailing = 6
|
||||
expect(calculateMaxWidthsFromPreRendered(lines, settings)).toEqual([6]);
|
||||
});
|
||||
|
||||
it('counts padding only once when side is "left"', () => {
|
||||
const lines = [[pre('AB')]];
|
||||
const settings = createSettings({ defaultPadding: '..', defaultPaddingSide: 'left' });
|
||||
// 'AB' (2) + 2 leading + 0 trailing = 4
|
||||
expect(calculateMaxWidthsFromPreRendered(lines, settings)).toEqual([4]);
|
||||
});
|
||||
|
||||
it('counts padding only once when side is "right"', () => {
|
||||
const lines = [[pre('AB')]];
|
||||
const settings = createSettings({ defaultPadding: '..', defaultPaddingSide: 'right' });
|
||||
// 'AB' (2) + 0 leading + 2 trailing = 4
|
||||
expect(calculateMaxWidthsFromPreRendered(lines, settings)).toEqual([4]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,16 @@ import {
|
||||
countSeparatorSlots
|
||||
} from '../separator-index';
|
||||
|
||||
function preRendered(
|
||||
widgets: WidgetItem[],
|
||||
contentByIndex: Record<number, string>
|
||||
) {
|
||||
return widgets.map((widget, index) => ({
|
||||
content: contentByIndex[index] ?? 'x',
|
||||
widget
|
||||
}));
|
||||
}
|
||||
|
||||
describe('separator index utils', () => {
|
||||
it('returns zero for empty and single-item lines', () => {
|
||||
expect(countSeparatorSlots([])).toBe(0);
|
||||
@@ -47,6 +57,76 @@ describe('separator index utils', () => {
|
||||
expect(countSeparatorSlots(widgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not count flex separators as separator slots', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: 'flex', type: 'flex-separator' },
|
||||
{ id: '2', type: 'context-length' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts separator slots independently within flex-delimited segments', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'context-length' },
|
||||
{ id: 'flex', type: 'flex-separator' },
|
||||
{ id: '3', type: 'version' },
|
||||
{ id: '4', type: 'session-cost' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores explicit separator widgets for powerline separator indexing', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: 'separator', type: 'separator' },
|
||||
{ id: '2', type: 'context-length' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('treats explicit separators as merge boundaries for powerline separator indexing', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model', merge: true },
|
||||
{ id: 'separator', type: 'separator' },
|
||||
{ id: '2', type: 'context-length' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('counts only widgets that rendered content when pre-render data is available', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: 'hidden', type: 'custom-text' },
|
||||
{ id: '2', type: 'context-length' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets, preRendered(widgets, {
|
||||
0: 'model',
|
||||
1: '',
|
||||
2: 'context'
|
||||
}))).toBe(1);
|
||||
});
|
||||
|
||||
it('honors merge state on the previous rendered widget across hidden widgets', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model', merge: true },
|
||||
{ id: 'hidden', type: 'custom-text' },
|
||||
{ id: '2', type: 'context-length' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets, preRendered(widgets, {
|
||||
0: 'model',
|
||||
1: '',
|
||||
2: 'context'
|
||||
}))).toBe(0);
|
||||
});
|
||||
|
||||
it('advances a running global separator index', () => {
|
||||
const firstLine: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
@@ -65,4 +145,18 @@ describe('separator index utils', () => {
|
||||
expect(afterFirst).toBe(2);
|
||||
expect(afterSecond).toBe(3);
|
||||
});
|
||||
|
||||
it('advances a running global separator index from rendered separator slots', () => {
|
||||
const line: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: 'hidden', type: 'custom-text' },
|
||||
{ id: '2', type: 'context-length' }
|
||||
];
|
||||
|
||||
expect(advanceGlobalSeparatorIndex(3, line, preRendered(line, {
|
||||
0: 'model',
|
||||
1: '',
|
||||
2: 'context'
|
||||
}))).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,18 @@ describe('terminal utils', () => {
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
// process.platform is read by the width probe. Pin it with defineProperty
|
||||
// and restore after each test; vi.spyOn on the getter does not reliably
|
||||
// re-apply across tests. Probing is disabled on win32, so the
|
||||
// ancestor-walk/stty/tput tests pin POSIX and the win32 tests pin win32.
|
||||
const ORIGINAL_PLATFORM = process.platform;
|
||||
const setPlatform = (value: NodeJS.Platform): void => {
|
||||
Object.defineProperty(process, 'platform', { value, configurable: true, writable: true, enumerable: true });
|
||||
};
|
||||
const pinPosixPlatform = (): void => {
|
||||
setPlatform('darwin');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
@@ -36,9 +48,11 @@ describe('terminal utils', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.CCSTATUSLINE_WIDTH;
|
||||
setPlatform(ORIGINAL_PLATFORM);
|
||||
});
|
||||
|
||||
it('returns width from the immediate parent tty when available', () => {
|
||||
pinPosixPlatform();
|
||||
mockExecSync.mockImplementation((command: string) => {
|
||||
if (command === `ps -o ppid= -p ${process.pid}`) {
|
||||
return '1234\n';
|
||||
@@ -64,6 +78,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('walks ancestor processes until it finds a valid tty', () => {
|
||||
pinPosixPlatform();
|
||||
mockExecSync.mockImplementation((command: string) => {
|
||||
if (command === `ps -o ppid= -p ${process.pid}`) {
|
||||
return '1234\n';
|
||||
@@ -92,6 +107,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('falls back through stty variants when the first form returns no value', () => {
|
||||
pinPosixPlatform();
|
||||
// Simulates BSD/macOS, where `stty -F` exits with an error and yields
|
||||
// empty output via the `2>/dev/null | awk` pipeline; `stty -f` succeeds.
|
||||
mockExecSync.mockImplementation((command: string) => {
|
||||
@@ -118,6 +134,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('falls back to tput cols when ancestor probing fails', () => {
|
||||
pinPosixPlatform();
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('ps unavailable'); });
|
||||
mockExecSync.mockReturnValueOnce('90\n');
|
||||
|
||||
@@ -126,6 +143,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('returns null when ancestor and fallback probes fail', () => {
|
||||
pinPosixPlatform();
|
||||
mockExecSync.mockImplementation((command: string) => {
|
||||
if (command === `ps -o ppid= -p ${process.pid}`) {
|
||||
return '1234\n';
|
||||
@@ -156,6 +174,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('detects availability when an ancestor tty probe succeeds', () => {
|
||||
pinPosixPlatform();
|
||||
mockExecSync.mockImplementation((command: string) => {
|
||||
if (command === `ps -o ppid= -p ${process.pid}`) {
|
||||
return '1234\n';
|
||||
@@ -184,6 +203,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('returns false for availability when all probes fail', () => {
|
||||
pinPosixPlatform();
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
|
||||
|
||||
@@ -198,6 +218,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('ignores a non-positive CCSTATUSLINE_WIDTH and falls back to probing', () => {
|
||||
pinPosixPlatform();
|
||||
process.env.CCSTATUSLINE_WIDTH = '0';
|
||||
|
||||
mockExecSync.mockImplementation((command: string) => {
|
||||
@@ -220,6 +241,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('ignores a non-numeric CCSTATUSLINE_WIDTH and falls back to probing', () => {
|
||||
pinPosixPlatform();
|
||||
process.env.CCSTATUSLINE_WIDTH = 'wide';
|
||||
|
||||
mockExecSync.mockImplementation((command: string) => {
|
||||
@@ -242,7 +264,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('CCSTATUSLINE_WIDTH override applies on Windows where probing is disabled', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
||||
setPlatform('win32');
|
||||
process.env.CCSTATUSLINE_WIDTH = '180';
|
||||
|
||||
expect(getTerminalWidth()).toBe(180);
|
||||
@@ -251,7 +273,7 @@ describe('terminal utils', () => {
|
||||
});
|
||||
|
||||
it('disables width detection on Windows', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
||||
setPlatform('win32');
|
||||
|
||||
expect(getTerminalWidth()).toBeNull();
|
||||
expect(canDetectTerminalWidth()).toBe(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type * as childProcess from 'child_process';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import * as os from 'os';
|
||||
@@ -357,6 +358,23 @@ describe('fetchUsageData error handling', () => {
|
||||
disabled_reason: null
|
||||
}
|
||||
});
|
||||
// Mirrors a real Enterprise-account response: every rate-limit window is
|
||||
// null because Enterprise plans have no 5-hour/7-day windows (#343).
|
||||
const enterpriseNullWindowsResponseBody = JSON.stringify({
|
||||
five_hour: null,
|
||||
seven_day: null,
|
||||
seven_day_oauth_apps: null,
|
||||
seven_day_sonnet: null,
|
||||
seven_day_opus: null,
|
||||
extra_usage: {
|
||||
is_enabled: true,
|
||||
monthly_limit: 50000,
|
||||
used_credits: 0,
|
||||
utilization: null,
|
||||
currency: 'USD',
|
||||
disabled_reason: null
|
||||
}
|
||||
});
|
||||
const rateLimitedResponseBody = JSON.stringify({
|
||||
error: {
|
||||
message: 'Rate limited. Please try again later.',
|
||||
@@ -656,11 +674,17 @@ describe('fetchUsageData error handling', () => {
|
||||
expect(result.second).toEqual(result.first);
|
||||
expect(result.requestCount).toBe(1);
|
||||
|
||||
// The probe writes usage.json with a real-wall-clock mtime, so derive
|
||||
// 'now' from it (not the mocked epoch) to keep the cache within
|
||||
// CACHE_MAX_AGE. This exercises the file-cache fast path a real later
|
||||
// render takes, rather than depending on a lingering lock to suppress
|
||||
// the refetch.
|
||||
const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs;
|
||||
const cachedResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: nowMs + 10000,
|
||||
nowMs: cacheMtimeMs + 10000,
|
||||
pathDir: home.bin,
|
||||
requiredFields
|
||||
});
|
||||
@@ -673,6 +697,197 @@ describe('fetchUsageData error handling', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the in-flight lock after a successful fetch', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('success-clears-lock');
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
// fetchUsageData writes a short 'timeout' lock before the request as an
|
||||
// in-flight guard. A successful fetch must remove it; otherwise the lock
|
||||
// lingers for LOCK_MAX_AGE and a later cache miss (e.g. an account switch
|
||||
// invalidating the fingerprint) reports a spurious [Timeout] while the
|
||||
// API is healthy.
|
||||
expect(result.cacheExists).toBe(true);
|
||||
expect(result.lockExists).toBe(false);
|
||||
expect(result.lockContents).toBeNull();
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the in-flight lock after a successful fetch missing required fields', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('success-missing-required-fields');
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
requiredFields: ['weeklySonnetUsage'],
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z'
|
||||
});
|
||||
expect(result.second).toEqual({ error: 'timeout' });
|
||||
expect(result.cacheExists).toBe(true);
|
||||
expect(result.requestCount).toBe(1);
|
||||
expect(parseLockContents(result.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(nowMs / 1000) + 30,
|
||||
error: 'timeout'
|
||||
});
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('refetches a fresh cache when the token fingerprint changes (account switch)', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('account-switch');
|
||||
const cacheDir = path.join(home.home, '.cache', 'ccstatusline');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, 'usage.json');
|
||||
// A complete cache written under a different account's token.
|
||||
fs.writeFileSync(cacheFile, JSON.stringify({ sessionUsage: 5, tokenHash: 'deadbeefdeadbeef' }));
|
||||
const seededMtimeMs = fs.statSync(cacheFile).mtimeMs;
|
||||
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs: seededMtimeMs + 5000,
|
||||
pathDir: home.bin,
|
||||
requiredFields: ['sessionUsage'],
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
// The cached fingerprint mismatches the live token, so the still-fresh
|
||||
// cache is rejected and the API is hit once for the new account.
|
||||
expect(result.requestCount).toBe(1);
|
||||
expect(result.first.sessionUsage).toBe(42);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not serve a mismatched account cache during an active lock', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('account-switch-active-lock');
|
||||
const cacheDir = path.join(home.home, '.cache', 'ccstatusline');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, 'usage.json');
|
||||
const lockFile = path.join(cacheDir, 'usage.lock');
|
||||
fs.writeFileSync(cacheFile, JSON.stringify({ sessionUsage: 5, tokenHash: 'deadbeefdeadbeef' }));
|
||||
|
||||
const seededMtimeMs = fs.statSync(cacheFile).mtimeMs;
|
||||
const lockedNowMs = seededMtimeMs + 5000;
|
||||
fs.writeFileSync(lockFile, JSON.stringify({
|
||||
blockedUntil: Math.floor(lockedNowMs / 1000) + 30,
|
||||
error: 'timeout'
|
||||
}));
|
||||
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: lockedNowMs,
|
||||
pathDir: home.bin,
|
||||
requiredFields: ['sessionUsage']
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({ error: 'timeout' });
|
||||
expect(result.second).toEqual({ error: 'timeout' });
|
||||
expect(result.requestCount).toBe(0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not serve a mismatched account cache during a rate-limit backoff', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('account-switch-rate-limit');
|
||||
const cacheDir = path.join(home.home, '.cache', 'ccstatusline');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, 'usage.json');
|
||||
fs.writeFileSync(cacheFile, JSON.stringify({ sessionUsage: 5, tokenHash: 'deadbeefdeadbeef' }));
|
||||
|
||||
const seededMtimeMs = fs.statSync(cacheFile).mtimeMs;
|
||||
const rateLimitedNowMs = seededMtimeMs + 5000;
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs: rateLimitedNowMs,
|
||||
pathDir: home.bin,
|
||||
requiredFields: ['sessionUsage'],
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': '3600' },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({ error: 'rate-limited' });
|
||||
expect(result.second).toEqual({ error: 'rate-limited' });
|
||||
expect(result.requestCount).toBe(1);
|
||||
expect(parseLockContents(result.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(rateLimitedNowMs / 1000) + 3600,
|
||||
error: 'rate-limited'
|
||||
});
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('serves a fresh cache whose token fingerprint matches (same account)', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('account-same');
|
||||
const cacheDir = path.join(home.home, '.cache', 'ccstatusline');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, 'usage.json');
|
||||
const matchingHash = createHash('sha256').update('test-token').digest('hex').slice(0, 16);
|
||||
fs.writeFileSync(cacheFile, JSON.stringify({ sessionUsage: 5, tokenHash: matchingHash }));
|
||||
const seededMtimeMs = fs.statSync(cacheFile).mtimeMs;
|
||||
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: seededMtimeMs + 5000,
|
||||
pathDir: home.bin,
|
||||
requiredFields: ['sessionUsage']
|
||||
});
|
||||
|
||||
// Fingerprint matches and the cache is fresh, so it is served with no API call.
|
||||
expect(result.requestCount).toBe(0);
|
||||
expect(result.first.sessionUsage).toBe(5);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats enabled extra usage without a monthly limit as complete for extra usage widget fields', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
@@ -700,11 +915,69 @@ describe('fetchUsageData error handling', () => {
|
||||
expect(result.second).toEqual(result.first);
|
||||
expect(result.requestCount).toBe(1);
|
||||
|
||||
// The probe writes usage.json with a real-wall-clock mtime, so derive
|
||||
// 'now' from it (not the mocked epoch) to keep the cache within
|
||||
// CACHE_MAX_AGE. This exercises the file-cache fast path a real later
|
||||
// render takes, rather than depending on a lingering lock to suppress
|
||||
// the refetch.
|
||||
const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs;
|
||||
const cachedResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: nowMs + 10000,
|
||||
nowMs: cacheMtimeMs + 10000,
|
||||
pathDir: home.bin,
|
||||
requiredFields
|
||||
});
|
||||
|
||||
expect(cachedResult.first).toEqual(result.first);
|
||||
expect(cachedResult.second).toEqual(result.first);
|
||||
expect(cachedResult.requestCount).toBe(0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats null rate-limit windows as complete for window reset fields', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('enterprise-null-windows');
|
||||
const requiredFields = ['sessionUsage', 'sessionResetAt', 'weeklyUsage', 'weeklyResetAt', 'weeklySonnetResetAt', 'weeklyOpusResetAt'];
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
requiredFields,
|
||||
responseBody: enterpriseNullWindowsResponseBody
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({
|
||||
sessionUsage: 0,
|
||||
weeklyUsage: 0,
|
||||
weeklySonnetUsage: 0,
|
||||
weeklyOpusUsage: 0,
|
||||
extraUsageEnabled: true,
|
||||
extraUsageLimit: 50000,
|
||||
extraUsageCurrency: 'USD',
|
||||
extraUsageUsed: 0
|
||||
});
|
||||
expect(result.second).toEqual(result.first);
|
||||
expect(result.requestCount).toBe(1);
|
||||
|
||||
// The probe writes usage.json with a real-wall-clock mtime, so derive
|
||||
// 'now' from it (not the mocked epoch) to keep the cache within
|
||||
// CACHE_MAX_AGE. This exercises the file-cache fast path a real later
|
||||
// render takes, rather than depending on a lingering lock to suppress
|
||||
// the refetch.
|
||||
const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs;
|
||||
const cachedResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: cacheMtimeMs + 10000,
|
||||
pathDir: home.bin,
|
||||
requiredFields
|
||||
});
|
||||
|
||||
@@ -277,6 +277,45 @@ describe('usage prefetch', () => {
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('suppresses API errors when reset timer widgets are only missing reset data', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ error: 'rate-limited' });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'reset-timer' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 42 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(usageData).toEqual({
|
||||
sessionUsage: 42,
|
||||
weeklyUsage: 15,
|
||||
weeklyResetAt: epochToIso(1774540000)
|
||||
});
|
||||
expect(mockFetchUsageData.mock.calls).toEqual([
|
||||
[{ requiredFields: ['sessionResetAt'] }]
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no usage data instead of a rate-limit error for reset-only startup fetches', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ error: 'rate-limited' });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'weekly-reset-timer' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, {});
|
||||
|
||||
expect(usageData).toBeNull();
|
||||
expect(mockFetchUsageData.mock.calls).toEqual([
|
||||
[{ requiredFields: ['weeklyResetAt'] }]
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps statusline usage when cursor metadata requires a missing reset and API has no credentials', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ error: 'no-credentials' });
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { Mock } from 'vitest';
|
||||
import {
|
||||
afterEach,
|
||||
@@ -22,7 +23,7 @@ vi.mock('child_process', () => ({
|
||||
spawnSync: vi.fn()
|
||||
}));
|
||||
|
||||
const CREDENTIALS_FILE = '/fake/claude/.credentials.json';
|
||||
const CREDENTIALS_FILE = path.join('/fake/claude', '.credentials.json');
|
||||
const mockedExecFileSync = execFileSync as unknown as Mock;
|
||||
|
||||
function makeTokenPayload(token: string): string {
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('widget catalog', () => {
|
||||
expect(types.has('flex-separator')).toBe(true);
|
||||
});
|
||||
|
||||
it('hides both separator types in powerline mode', () => {
|
||||
it('hides manual separator but keeps flex separator in powerline mode', () => {
|
||||
const catalog = getWidgetCatalog({
|
||||
...baseSettings,
|
||||
powerline: {
|
||||
@@ -83,7 +83,7 @@ describe('widget catalog', () => {
|
||||
|
||||
const types = new Set(catalog.map(entry => entry.type));
|
||||
expect(types.has('separator')).toBe(false);
|
||||
expect(types.has('flex-separator')).toBe(false);
|
||||
expect(types.has('flex-separator')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unique categories in discovery order', () => {
|
||||
|
||||
@@ -505,7 +505,7 @@ export async function setRefreshInterval(interval: number | null): Promise<void>
|
||||
|
||||
const VoiceConfigSchema = z.object({ enabled: z.boolean().optional() });
|
||||
|
||||
function getVoiceConfigCandidatePathsByPriority(cwd: string): string[] {
|
||||
function getLayeredSettingsCandidatePathsByPriority(cwd: string): string[] {
|
||||
const userDir = getClaudeConfigDir();
|
||||
const projectDir = path.join(cwd, '.claude');
|
||||
// Highest priority first — `getVoiceConfig` returns on the first defined override.
|
||||
@@ -571,7 +571,7 @@ function tryReadVoiceLayer(filePath: string): VoiceLayerResult {
|
||||
*/
|
||||
export function getVoiceConfig(cwd: string = process.cwd()): { enabled: boolean } | null {
|
||||
let anyFileExisted = false;
|
||||
for (const filePath of getVoiceConfigCandidatePathsByPriority(cwd)) {
|
||||
for (const filePath of getLayeredSettingsCandidatePathsByPriority(cwd)) {
|
||||
const layer = tryReadVoiceLayer(filePath);
|
||||
if (layer.fileExisted) {
|
||||
anyFileExisted = true;
|
||||
@@ -583,6 +583,61 @@ export function getVoiceConfig(cwd: string = process.cwd()): { enabled: boolean
|
||||
return anyFileExisted ? { enabled: false } : null;
|
||||
}
|
||||
|
||||
const SandboxConfigSchema = z.object({ enabled: z.boolean().optional() });
|
||||
|
||||
function tryReadSandboxLayer(filePath: string): { fileExisted: boolean; enabled: boolean | undefined } {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
// ENOENT is the common case (file just doesn't exist on this layer);
|
||||
// any other I/O error is treated the same — caller has no recovery path.
|
||||
const isMissing = (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
return { fileExisted: !isMissing, enabled: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { sandbox?: unknown };
|
||||
const sandbox = parsed.sandbox;
|
||||
if (sandbox === undefined || sandbox === null) {
|
||||
return { fileExisted: true, enabled: undefined };
|
||||
}
|
||||
const result = SandboxConfigSchema.safeParse(sandbox);
|
||||
return { fileExisted: true, enabled: result.success ? result.data.enabled : undefined };
|
||||
} catch {
|
||||
// Malformed JSON — file exists but contributes no override.
|
||||
return { fileExisted: true, enabled: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the effective `sandbox.enabled` setting — Claude Code's bash sandbox mode —
|
||||
* from the same layered configuration as `getVoiceConfig` (project-local → project →
|
||||
* user-local → user, highest priority first; user dir respects `CLAUDE_CONFIG_DIR`).
|
||||
*
|
||||
* `/sandbox` persists its toggle to `<cwd>/.claude/settings.local.json` (the
|
||||
* highest-priority layer), so re-reading on each status refresh reflects runtime
|
||||
* toggles, not just the configured default.
|
||||
*
|
||||
* - Returns `null` if no candidate settings file exists (Claude Code never initialised).
|
||||
* - Returns `{ enabled: false }` if files exist but none defines `sandbox.enabled`
|
||||
* (Claude Code's default — sandbox disabled).
|
||||
* - Returns `{ enabled: <bool> }` reflecting the highest-priority override otherwise.
|
||||
*/
|
||||
export function getSandboxConfig(cwd: string = process.cwd()): { enabled: boolean } | null {
|
||||
let anyFileExisted = false;
|
||||
for (const filePath of getLayeredSettingsCandidatePathsByPriority(cwd)) {
|
||||
const layer = tryReadSandboxLayer(filePath);
|
||||
if (layer.fileExisted) {
|
||||
anyFileExisted = true;
|
||||
}
|
||||
if (layer.enabled !== undefined) {
|
||||
return { enabled: layer.enabled };
|
||||
}
|
||||
}
|
||||
return anyFileExisted ? { enabled: false } : null;
|
||||
}
|
||||
|
||||
const RemoteSessionFileSchema = z.object({
|
||||
sessionId: z.string().optional(),
|
||||
bridgeSessionId: z.string().nullable().optional()
|
||||
|
||||
+22
-6
@@ -128,15 +128,25 @@ export function getChalkColor(colorName: string | undefined, colorLevel: 'ansi16
|
||||
}
|
||||
}
|
||||
|
||||
// Dim each (...) span within the text. \x1b[22m clears bold along with dim,
|
||||
// so bold is re-asserted after each span when the surrounding text is bold.
|
||||
export function applyParensDim(text: string, bold?: boolean): string {
|
||||
const intensityReset = bold ? '\x1b[22;1m' : '\x1b[22m';
|
||||
return text.replace(/\([^()]*\)/g, span => `\x1b[2m${span}${intensityReset}`);
|
||||
}
|
||||
|
||||
export function applyColors(
|
||||
text: string,
|
||||
foregroundColor?: string,
|
||||
backgroundColor?: string,
|
||||
bold?: boolean,
|
||||
colorLevel: 'ansi16' | 'ansi256' | 'truecolor' = 'ansi16'
|
||||
colorLevel: 'ansi16' | 'ansi256' | 'truecolor' = 'ansi16',
|
||||
dim?: boolean | 'parens'
|
||||
): string {
|
||||
if (!foregroundColor && !backgroundColor && !bold) {
|
||||
return text;
|
||||
const styledText = dim === 'parens' ? applyParensDim(text, bold) : text;
|
||||
|
||||
if (!foregroundColor && !backgroundColor && !bold && dim !== true) {
|
||||
return styledText;
|
||||
}
|
||||
|
||||
// Use raw ANSI codes for precise reset sequencing.
|
||||
@@ -144,9 +154,15 @@ export function applyColors(
|
||||
let prefix = '';
|
||||
let suffix = '';
|
||||
|
||||
// Apply bold first so it can be reset independently before color resets.
|
||||
// Apply bold/dim first so they can be reset independently before color
|
||||
// resets. A single \x1b[22m clears both attributes.
|
||||
if (bold) {
|
||||
prefix += '\x1b[1m';
|
||||
}
|
||||
if (dim === true) {
|
||||
prefix += '\x1b[2m';
|
||||
}
|
||||
if (bold || dim === true) {
|
||||
suffix = '\x1b[22m' + suffix;
|
||||
}
|
||||
|
||||
@@ -168,7 +184,7 @@ export function applyColors(
|
||||
// as the prefix guard.
|
||||
const gradientStops = parseGradientSpec(foregroundColor);
|
||||
if (gradientStops && colorLevel !== 'ansi16') {
|
||||
return prefix + applyGradientToText(text, gradientStops, colorLevel) + '\x1b[39m' + suffix;
|
||||
return prefix + applyGradientToText(styledText, gradientStops, colorLevel) + '\x1b[39m' + suffix;
|
||||
}
|
||||
|
||||
const fgCode = getColorAnsiCode(foregroundColor, colorLevel, false);
|
||||
@@ -178,7 +194,7 @@ export function applyColors(
|
||||
}
|
||||
}
|
||||
|
||||
return prefix + text + suffix;
|
||||
return prefix + styledText + suffix;
|
||||
}
|
||||
|
||||
// Get raw ANSI codes for a color without the reset codes
|
||||
|
||||
+129
-45
@@ -20,10 +20,20 @@ import { upgradeLegacyWidgetTypes } from './widgets';
|
||||
const readFile = fs.promises.readFile;
|
||||
const writeFile = fs.promises.writeFile;
|
||||
const mkdir = fs.promises.mkdir;
|
||||
const rename = fs.promises.rename;
|
||||
const unlink = fs.promises.unlink;
|
||||
const lstat = fs.promises.lstat;
|
||||
const readlink = fs.promises.readlink;
|
||||
const realpath = fs.promises.realpath;
|
||||
|
||||
const DEFAULT_SETTINGS_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json');
|
||||
|
||||
let settingsPath = DEFAULT_SETTINGS_PATH;
|
||||
let lastLoadError: string | null = null;
|
||||
|
||||
export function getConfigLoadError(): string | null {
|
||||
return lastLoadError;
|
||||
}
|
||||
|
||||
export function initConfigPath(filePath?: string): void {
|
||||
settingsPath = filePath ? path.resolve(filePath) : DEFAULT_SETTINGS_PATH;
|
||||
@@ -40,49 +50,99 @@ export function isCustomConfigPath(): boolean {
|
||||
interface SettingsPaths {
|
||||
configDir: string;
|
||||
settingsPath: string;
|
||||
settingsBackupPath: string;
|
||||
}
|
||||
|
||||
interface AtomicWriteTarget {
|
||||
targetPath: string;
|
||||
tempDir: 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)
|
||||
configDir: path.dirname(settingsPath),
|
||||
settingsPath
|
||||
};
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
return typeof error === 'object' && error !== null && 'code' in error
|
||||
? String(error.code)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function resolveSymlinkTarget(linkPath: string): Promise<string> {
|
||||
try {
|
||||
return await realpath(linkPath);
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const linkTarget = await readlink(linkPath);
|
||||
return path.resolve(path.dirname(linkPath), linkTarget);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAtomicWriteTarget(paths: SettingsPaths): Promise<AtomicWriteTarget> {
|
||||
try {
|
||||
const stats = await lstat(paths.settingsPath);
|
||||
if (!stats.isSymbolicLink()) {
|
||||
return {
|
||||
targetPath: paths.settingsPath,
|
||||
tempDir: paths.configDir
|
||||
};
|
||||
}
|
||||
|
||||
const targetPath = await resolveSymlinkTarget(paths.settingsPath);
|
||||
return {
|
||||
targetPath,
|
||||
tempDir: path.dirname(targetPath)
|
||||
};
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) === 'ENOENT') {
|
||||
return {
|
||||
targetPath: paths.settingsPath,
|
||||
tempDir: paths.configDir
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
// Write to a unique temp file in the same directory, then atomically rename
|
||||
// over the target. A concurrent reader (e.g. the statusline render path firing
|
||||
// mid-save) sees either the complete old file or the complete new file, never a
|
||||
// torn write. Same idiom as git.ts:writePersistentCache.
|
||||
const writeTarget = await resolveAtomicWriteTarget(paths);
|
||||
const tempPath = path.join(
|
||||
writeTarget.tempDir,
|
||||
`${path.basename(writeTarget.targetPath)}.${process.pid}.${Date.now()}.tmp`
|
||||
);
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
await writeFile(tempPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
await rename(tempPath, writeTarget.targetPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to backup bad settings:', error);
|
||||
try {
|
||||
await unlink(tempPath);
|
||||
} catch { /* best-effort cleanup; ignore */ }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDefaultSettings(paths: SettingsPaths): Promise<Settings> {
|
||||
const defaults = SettingsSchema.parse({});
|
||||
const settingsWithVersion = {
|
||||
...defaults,
|
||||
version: CURRENT_VERSION
|
||||
};
|
||||
function inMemoryDefaults(): Settings {
|
||||
// Defaults held in memory only (version included via the schema default).
|
||||
// Returned on recovery without writing, so a malformed file is preserved.
|
||||
return SettingsSchema.parse({});
|
||||
}
|
||||
|
||||
async function writeDefaultSettings(paths: SettingsPaths): Promise<Settings> {
|
||||
const defaults = inMemoryDefaults();
|
||||
try {
|
||||
await writeSettingsJson(settingsWithVersion, paths);
|
||||
await writeSettingsJson(defaults, paths);
|
||||
console.error(`Default settings written to ${paths.settingsPath}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to write default settings:', error);
|
||||
@@ -91,12 +151,18 @@ async function writeDefaultSettings(paths: SettingsPaths): Promise<Settings> {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
async function recoverWithDefaults(paths: SettingsPaths): Promise<Settings> {
|
||||
await backupBadSettings(paths);
|
||||
return await writeDefaultSettings(paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load ccstatusline settings from disk.
|
||||
*
|
||||
* Recovery contract: if the file cannot be read or fails validation, loadSettings
|
||||
* NEVER overwrites it — it returns built-in defaults in memory, records the reason
|
||||
* (see getConfigLoadError), and leaves the file untouched for the user to fix. The
|
||||
* file is written only when it is missing (first run), or when a readable config is
|
||||
* migrated to the current version AND the migrated result validates first. All writes
|
||||
* go through writeSettingsJson, which is atomic (temp file + rename).
|
||||
*/
|
||||
export async function loadSettings(): Promise<Settings> {
|
||||
lastLoadError = null;
|
||||
const paths = getSettingsPaths();
|
||||
|
||||
try {
|
||||
@@ -110,36 +176,45 @@ export async function loadSettings(): Promise<Settings> {
|
||||
try {
|
||||
rawData = JSON.parse(content);
|
||||
} catch {
|
||||
// If we can't parse the JSON, backup and write defaults
|
||||
console.error('Failed to parse settings.json, backing up and using defaults');
|
||||
return await recoverWithDefaults(paths);
|
||||
console.error('Failed to parse settings.json, using defaults (file left unchanged)');
|
||||
lastLoadError = 'settings.json is not valid JSON';
|
||||
return inMemoryDefaults();
|
||||
}
|
||||
|
||||
// Check if this is a v1 config (no version field)
|
||||
const hasVersion = typeof rawData === 'object' && rawData !== null && 'version' in rawData;
|
||||
let migrated = false;
|
||||
if (!hasVersion) {
|
||||
// Parse as v1 to validate before migration
|
||||
const v1Result = SettingsSchema_v1.safeParse(rawData);
|
||||
if (!v1Result.success) {
|
||||
console.error('Invalid v1 settings format:', v1Result.error);
|
||||
return await recoverWithDefaults(paths);
|
||||
console.error('Invalid v1 settings format, using defaults (file left unchanged):', v1Result.error);
|
||||
lastLoadError = 'settings.json is not in a valid format';
|
||||
return inMemoryDefaults();
|
||||
}
|
||||
|
||||
// Migrate v1 to current version and save the migrated settings back to disk
|
||||
// Migrate v1 to the current version (persisted below, only once it validates)
|
||||
rawData = migrateConfig(rawData, CURRENT_VERSION);
|
||||
await writeSettingsJson(rawData, paths);
|
||||
migrated = true;
|
||||
} else if (needsMigration(rawData, CURRENT_VERSION)) {
|
||||
// Handle migrations for versioned configs (v2+) and save the migrated settings back to disk
|
||||
// Migrate versioned configs (v2+) to current (persisted below, only once it validates)
|
||||
rawData = migrateConfig(rawData, CURRENT_VERSION);
|
||||
await writeSettingsJson(rawData, paths);
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
// At this point, data should be in current format with version field
|
||||
// Parse with main schema which will apply all defaults
|
||||
const result = SettingsSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
console.error('Failed to parse settings:', result.error);
|
||||
return await recoverWithDefaults(paths);
|
||||
console.error('Failed to parse settings, using defaults (file left unchanged):', result.error);
|
||||
lastLoadError = 'settings.json is not in a valid format';
|
||||
return inMemoryDefaults();
|
||||
}
|
||||
|
||||
// Persist a migration only after the migrated result validates, so a faulty
|
||||
// migration can never overwrite the user's original file.
|
||||
if (migrated) {
|
||||
await writeSettingsJson(rawData, paths);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -147,9 +222,9 @@ export async function loadSettings(): Promise<Settings> {
|
||||
lines: upgradeLegacyWidgetTypes(result.data.lines)
|
||||
};
|
||||
} catch (error) {
|
||||
// Any other error, backup and write defaults
|
||||
console.error('Error loading settings:', error);
|
||||
return await recoverWithDefaults(paths);
|
||||
console.error('Error loading settings, using defaults:', error);
|
||||
lastLoadError = 'settings.json could not be read';
|
||||
return inMemoryDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +253,15 @@ export async function saveInstallationMetadata(metadata: InstallationMetadata |
|
||||
}
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
// If the existing settings.json couldn't be read, don't overwrite it just to
|
||||
// record installation metadata — that would discard the user's (recoverable)
|
||||
// file. Metadata is non-critical and is persisted on the next clean save.
|
||||
if (getConfigLoadError() !== null) {
|
||||
console.error('Skipping installation-metadata write: settings.json is unreadable (left unchanged).');
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsWithVersion: Settings & { version: number } = {
|
||||
...settings,
|
||||
version: CURRENT_VERSION
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export function formatTokens(count: number): string {
|
||||
// 999_950+ rounds to "1000.0k" at one decimal place; render it as "1.0M" instead.
|
||||
if (count >= 999950)
|
||||
// Format a token count with `decimals` places in the "k" range. Once the k
|
||||
// value would round up to "1000" at that precision (within half a displayed
|
||||
// unit of 1M), promote to "1.0M" instead — at decimals=1 that boundary is
|
||||
// 999_950 ("1000.0k" -> "1.0M"), at decimals=0 it is 999_500 ("1000k" -> "1.0M").
|
||||
// decimals defaults to 1; callers wanting a compact whole-number k pass 0.
|
||||
export function formatTokens(count: number, decimals = 1): string {
|
||||
if (count >= 1000000 - 500 / 10 ** decimals)
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000)
|
||||
return `${(count / 1000).toFixed(1)}k`;
|
||||
return `${(count / 1000).toFixed(decimals)}k`;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
+405
-46
@@ -1,9 +1,15 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import {
|
||||
execFileSync,
|
||||
spawn
|
||||
} from 'child_process';
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -14,6 +20,15 @@ import { parseRemoteUrl } from './git-remote';
|
||||
|
||||
export type GitReviewProvider = 'gh' | 'glab';
|
||||
|
||||
export type GitCiState = 'passing' | 'failing' | 'pending';
|
||||
|
||||
export interface GitCiChecks {
|
||||
state: GitCiState;
|
||||
failing: number;
|
||||
pending: number;
|
||||
success: number;
|
||||
}
|
||||
|
||||
export interface GitReviewData {
|
||||
number: number;
|
||||
url: string;
|
||||
@@ -21,29 +36,118 @@ export interface GitReviewData {
|
||||
state: string;
|
||||
reviewDecision: string;
|
||||
provider?: GitReviewProvider;
|
||||
checks?: GitCiChecks;
|
||||
}
|
||||
|
||||
export interface GitReviewFetchOptions { includeChecks?: boolean }
|
||||
|
||||
interface StoredGitReviewCache {
|
||||
version: 1;
|
||||
data: GitReviewData | null;
|
||||
checksQueried: boolean;
|
||||
}
|
||||
|
||||
interface CachedGitReviewData {
|
||||
data: GitReviewData | null;
|
||||
checksQueried: boolean;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
type CiCheckKind = 'success' | 'failed' | 'pending' | 'ignored';
|
||||
|
||||
function readField(entry: Record<string, unknown>, key: string): string {
|
||||
const value = entry[key];
|
||||
return typeof value === 'string' ? value.toUpperCase() : '';
|
||||
}
|
||||
|
||||
// Classify a single gh statusCheckRollup entry. CheckRun entries carry a
|
||||
// `status` (COMPLETED once done) plus a `conclusion`; older StatusContext
|
||||
// entries carry only a `state`. NEUTRAL/SKIPPED are non-blocking noise and
|
||||
// map to `ignored` so they drop out of the displayed counts.
|
||||
function classifyCheck(entry: Record<string, unknown>): CiCheckKind {
|
||||
if (typeof entry.status === 'string') {
|
||||
if (entry.status.toUpperCase() !== 'COMPLETED')
|
||||
return 'pending';
|
||||
const conclusion = readField(entry, 'conclusion');
|
||||
if (conclusion === 'SUCCESS')
|
||||
return 'success';
|
||||
if (conclusion === 'NEUTRAL' || conclusion === 'SKIPPED')
|
||||
return 'ignored';
|
||||
return 'failed';
|
||||
}
|
||||
const state = readField(entry, 'state');
|
||||
if (state === 'SUCCESS')
|
||||
return 'success';
|
||||
if (state === 'PENDING' || state === 'EXPECTED')
|
||||
return 'pending';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
export function computeCiRollup(rollup: unknown): GitCiChecks | null {
|
||||
if (!Array.isArray(rollup) || rollup.length === 0)
|
||||
return null;
|
||||
|
||||
let failing = 0;
|
||||
let pending = 0;
|
||||
let success = 0;
|
||||
let seen = 0;
|
||||
for (const entry of rollup) {
|
||||
if (typeof entry !== 'object' || entry === null)
|
||||
continue;
|
||||
seen++;
|
||||
const kind = classifyCheck(entry as Record<string, unknown>);
|
||||
if (kind === 'failed')
|
||||
failing++;
|
||||
else if (kind === 'pending')
|
||||
pending++;
|
||||
else if (kind === 'success')
|
||||
success++;
|
||||
}
|
||||
|
||||
if (seen === 0)
|
||||
return null;
|
||||
|
||||
const state: GitCiState = failing > 0 ? 'failing' : pending > 0 ? 'pending' : 'passing';
|
||||
return { state, failing, pending, success };
|
||||
}
|
||||
|
||||
const GIT_REVIEW_CACHE_TTL = 30_000;
|
||||
const CLI_TIMEOUT = 5_000;
|
||||
const REFRESH_LOCK_STALE_MS = 30_000;
|
||||
const DEFAULT_TITLE_MAX_WIDTH = 30;
|
||||
const GH_PR_METADATA_FIELDS = 'url,number,title,state,reviewDecision';
|
||||
const GH_PR_WITH_CHECKS_FIELDS = `${GH_PR_METADATA_FIELDS},statusCheckRollup`;
|
||||
export const GIT_REVIEW_REFRESH_FLAG = '--internal-refresh-git-review-cache';
|
||||
|
||||
export interface GitReviewCacheDeps {
|
||||
closeSync: typeof closeSync;
|
||||
execFileSync: typeof execFileSync;
|
||||
existsSync: typeof existsSync;
|
||||
getExecPath: () => string;
|
||||
mkdirSync: typeof mkdirSync;
|
||||
openSync: typeof openSync;
|
||||
readFileSync: typeof readFileSync;
|
||||
getScriptPath: () => string | undefined;
|
||||
spawn: typeof spawn;
|
||||
statSync: typeof statSync;
|
||||
unlinkSync: typeof unlinkSync;
|
||||
writeFileSync: typeof writeFileSync;
|
||||
getHomedir: typeof os.homedir;
|
||||
now: typeof Date.now;
|
||||
}
|
||||
|
||||
const DEFAULT_GIT_REVIEW_CACHE_DEPS: GitReviewCacheDeps = {
|
||||
closeSync,
|
||||
execFileSync,
|
||||
existsSync,
|
||||
getExecPath: () => process.execPath,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
getScriptPath: () => process.argv[1],
|
||||
spawn,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
getHomedir: os.homedir,
|
||||
now: Date.now
|
||||
@@ -100,36 +204,84 @@ function getCachePath(cwd: string, ref: string, deps: GitReviewCacheDeps): strin
|
||||
return path.join(getGitReviewCacheDir(deps), `git-review-${hash}.json`);
|
||||
}
|
||||
|
||||
function readCache(cachePath: string, deps: GitReviewCacheDeps): GitReviewData | null | 'miss' {
|
||||
function isGitReviewData(value: unknown): value is GitReviewData {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Partial<GitReviewData>;
|
||||
return typeof candidate.number === 'number' && typeof candidate.url === 'string';
|
||||
}
|
||||
|
||||
function decodeCache(content: string): Omit<CachedGitReviewData, 'stale'> | 'miss' {
|
||||
if (content.length === 0) {
|
||||
// v2.2.24 and earlier represented a cached "no PR" result as an
|
||||
// empty file. A missing PR also implies that no CI checks can exist.
|
||||
return { data: null, checksQueried: true };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const stored = parsed as Partial<StoredGitReviewCache>;
|
||||
if (stored.version === 1
|
||||
&& typeof stored.checksQueried === 'boolean'
|
||||
&& (stored.data === null || isGitReviewData(stored.data))) {
|
||||
return {
|
||||
data: stored.data,
|
||||
checksQueried: stored.data === null || stored.checksQueried
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isGitReviewData(parsed)) {
|
||||
// Legacy cache files stored GitReviewData directly. The presence of
|
||||
// checks proves the old lookup included CI data; absence is treated
|
||||
// as metadata-only because an empty rollup was previously omitted.
|
||||
return {
|
||||
data: parsed,
|
||||
checksQueried: parsed.checks !== undefined
|
||||
};
|
||||
}
|
||||
|
||||
return 'miss';
|
||||
}
|
||||
|
||||
function readCache(cachePath: string, deps: GitReviewCacheDeps): CachedGitReviewData | 'miss' {
|
||||
try {
|
||||
if (!deps.existsSync(cachePath)) {
|
||||
return 'miss';
|
||||
}
|
||||
const age = deps.now() - deps.statSync(cachePath).mtimeMs;
|
||||
if (age > GIT_REVIEW_CACHE_TTL) {
|
||||
return 'miss';
|
||||
}
|
||||
const content = deps.readFileSync(cachePath, 'utf-8').trim();
|
||||
if (content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const data = JSON.parse(content) as GitReviewData;
|
||||
if (typeof data.number !== 'number' || typeof data.url !== 'string') {
|
||||
const decoded = decodeCache(content);
|
||||
if (decoded === 'miss') {
|
||||
return 'miss';
|
||||
}
|
||||
return data;
|
||||
return {
|
||||
...decoded,
|
||||
stale: age > GIT_REVIEW_CACHE_TTL
|
||||
};
|
||||
} catch {
|
||||
return 'miss';
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(cachePath: string, data: GitReviewData | null, deps: GitReviewCacheDeps): void {
|
||||
function writeCache(
|
||||
cachePath: string,
|
||||
data: GitReviewData | null,
|
||||
checksQueried: boolean,
|
||||
deps: GitReviewCacheDeps
|
||||
): void {
|
||||
try {
|
||||
const cacheDir = getGitReviewCacheDir(deps);
|
||||
if (!deps.existsSync(cacheDir)) {
|
||||
deps.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
deps.writeFileSync(cachePath, data ? JSON.stringify(data) : '', 'utf-8');
|
||||
const stored: StoredGitReviewCache = {
|
||||
version: 1,
|
||||
data,
|
||||
checksQueried: data === null || checksQueried
|
||||
};
|
||||
deps.writeFileSync(cachePath, JSON.stringify(stored), 'utf-8');
|
||||
} catch {
|
||||
// Best-effort caching
|
||||
}
|
||||
@@ -230,11 +382,21 @@ function getProviderCandidates(cwd: string, deps: GitReviewCacheDeps): GitReview
|
||||
return authed;
|
||||
}
|
||||
|
||||
function isCliAvailable(cli: GitReviewProvider, deps: GitReviewCacheDeps): boolean {
|
||||
class GitReviewDeadlineError extends Error {}
|
||||
|
||||
function getRemainingTimeout(deadline: number, deps: GitReviewCacheDeps): number {
|
||||
const remaining = deadline - deps.now();
|
||||
if (remaining <= 0) {
|
||||
throw new GitReviewDeadlineError('Git review lookup deadline exceeded');
|
||||
}
|
||||
return Math.max(1, Math.min(CLI_TIMEOUT, remaining));
|
||||
}
|
||||
|
||||
function isCliAvailable(cli: GitReviewProvider, deadline: number, deps: GitReviewCacheDeps): boolean {
|
||||
try {
|
||||
deps.execFileSync(cli, ['--version'], {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
timeout: CLI_TIMEOUT,
|
||||
timeout: getRemainingTimeout(deadline, deps),
|
||||
windowsHide: true
|
||||
});
|
||||
return true;
|
||||
@@ -268,7 +430,57 @@ function mapGlabState(state: string): string {
|
||||
return state.toUpperCase();
|
||||
}
|
||||
|
||||
function fetchFromGh(cwd: string, repoRef: string | null, deps: GitReviewCacheDeps): GitReviewData | null {
|
||||
function errorText(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const stderr = 'stderr' in error
|
||||
? (error as Error & { stderr?: Buffer | string }).stderr
|
||||
: undefined;
|
||||
const stderrText = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : (stderr ?? '');
|
||||
return `${error.message}\n${stderrText}`.toLowerCase();
|
||||
}
|
||||
|
||||
function isCiFieldUnavailableError(error: unknown): boolean {
|
||||
const text = errorText(error);
|
||||
return text.includes('statuscheckrollup')
|
||||
|| text.includes('resource not accessible by integration');
|
||||
}
|
||||
|
||||
function queryGhPr(
|
||||
cwd: string,
|
||||
args: string[],
|
||||
fields: string,
|
||||
deadline: number,
|
||||
deps: GitReviewCacheDeps
|
||||
): Record<string, unknown> | null {
|
||||
const output = deps.execFileSync(
|
||||
'gh',
|
||||
[...args, '--json', fields],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd,
|
||||
timeout: getRemainingTimeout(deadline, deps),
|
||||
windowsHide: true
|
||||
}
|
||||
).trim();
|
||||
|
||||
if (output.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(output) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function fetchFromGh(
|
||||
cwd: string,
|
||||
repoRef: string | null,
|
||||
includeChecks: boolean,
|
||||
deadline: number,
|
||||
deps: GitReviewCacheDeps
|
||||
): GitReviewData | null {
|
||||
const args = ['pr', 'view'];
|
||||
if (repoRef) {
|
||||
// `--repo` disables branch auto-resolution, so pass the branch explicitly.
|
||||
@@ -278,25 +490,24 @@ function fetchFromGh(cwd: string, repoRef: string | null, deps: GitReviewCacheDe
|
||||
}
|
||||
args.push(branch, '--repo', repoRef);
|
||||
}
|
||||
args.push('--json', 'url,number,title,state,reviewDecision');
|
||||
|
||||
const output = deps.execFileSync(
|
||||
'gh',
|
||||
args,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
cwd,
|
||||
timeout: CLI_TIMEOUT,
|
||||
windowsHide: true
|
||||
let parsed: Record<string, unknown> | null;
|
||||
if (includeChecks) {
|
||||
try {
|
||||
parsed = queryGhPr(cwd, args, GH_PR_WITH_CHECKS_FIELDS, deadline, deps);
|
||||
} catch (error) {
|
||||
if (!isCiFieldUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
parsed = queryGhPr(cwd, args, GH_PR_METADATA_FIELDS, deadline, deps);
|
||||
}
|
||||
).trim();
|
||||
|
||||
if (output.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
parsed = queryGhPr(cwd, args, GH_PR_METADATA_FIELDS, deadline, deps);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(output) as Record<string, unknown>;
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed.number !== 'number' || typeof parsed.url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -306,11 +517,17 @@ function fetchFromGh(cwd: string, repoRef: string | null, deps: GitReviewCacheDe
|
||||
title: typeof parsed.title === 'string' ? parsed.title : '',
|
||||
state: typeof parsed.state === 'string' ? parsed.state : '',
|
||||
reviewDecision: typeof parsed.reviewDecision === 'string' ? parsed.reviewDecision : '',
|
||||
provider: 'gh'
|
||||
provider: 'gh',
|
||||
checks: computeCiRollup(parsed.statusCheckRollup) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
function fetchFromGlab(cwd: string, repoRef: string | null, deps: GitReviewCacheDeps): GitReviewData | null {
|
||||
function fetchFromGlab(
|
||||
cwd: string,
|
||||
repoRef: string | null,
|
||||
deadline: number,
|
||||
deps: GitReviewCacheDeps
|
||||
): GitReviewData | null {
|
||||
const args = ['mr', 'view'];
|
||||
if (repoRef) {
|
||||
// `--repo` disables branch auto-resolution, so pass the branch explicitly.
|
||||
@@ -329,7 +546,7 @@ function fetchFromGlab(cwd: string, repoRef: string | null, deps: GitReviewCache
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
cwd,
|
||||
timeout: CLI_TIMEOUT,
|
||||
timeout: getRemainingTimeout(deadline, deps),
|
||||
windowsHide: true
|
||||
}
|
||||
).trim();
|
||||
@@ -354,48 +571,190 @@ function fetchFromGlab(cwd: string, repoRef: string | null, deps: GitReviewCache
|
||||
|
||||
// First try the CLI's own repo resolution, then fall back to pinning `--repo`
|
||||
// to origin. The pinned pass catches forks where the CLI resolves to upstream.
|
||||
function fetchFromProvider(provider: GitReviewProvider, cwd: string, repoRef: string | null, deps: GitReviewCacheDeps): GitReviewData | null {
|
||||
const fetchFn = provider === 'gh' ? fetchFromGh : fetchFromGlab;
|
||||
function fetchFromProvider(
|
||||
provider: GitReviewProvider,
|
||||
cwd: string,
|
||||
repoRef: string | null,
|
||||
includeChecks: boolean,
|
||||
deadline: number,
|
||||
deps: GitReviewCacheDeps
|
||||
): GitReviewData | null {
|
||||
const fetch = (targetRepoRef: string | null): GitReviewData | null => provider === 'gh'
|
||||
? fetchFromGh(cwd, targetRepoRef, includeChecks, deadline, deps)
|
||||
: fetchFromGlab(cwd, targetRepoRef, deadline, deps);
|
||||
|
||||
try {
|
||||
const unpinned = fetchFn(cwd, null, deps);
|
||||
const unpinned = fetch(null);
|
||||
if (unpinned) {
|
||||
return unpinned;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
if (repoRef) {
|
||||
return fetchFn(cwd, repoRef, deps);
|
||||
return fetch(repoRef);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function fetchGitReviewData(cwd: string, deps: GitReviewCacheDeps = DEFAULT_GIT_REVIEW_CACHE_DEPS): GitReviewData | null {
|
||||
export function fetchGitReviewData(
|
||||
cwd: string,
|
||||
deps: GitReviewCacheDeps = DEFAULT_GIT_REVIEW_CACHE_DEPS,
|
||||
options: GitReviewFetchOptions = {}
|
||||
): GitReviewData | null {
|
||||
const includeChecks = options.includeChecks ?? false;
|
||||
const cachePath = getCachePath(cwd, getCacheRef(cwd, deps), deps);
|
||||
const cached = readCache(cachePath, deps);
|
||||
if (cached !== 'miss') {
|
||||
return cached;
|
||||
if (cached !== 'miss'
|
||||
&& !cached.stale
|
||||
&& (!includeChecks || cached.checksQueried)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const repoRef = getOriginRepoRef(cwd, deps);
|
||||
const deadline = deps.now() + CLI_TIMEOUT;
|
||||
|
||||
for (const provider of getProviderCandidates(cwd, deps)) {
|
||||
if (!isCliAvailable(provider, deps)) {
|
||||
if (!isCliAvailable(provider, deadline, deps)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const data = fetchFromProvider(provider, cwd, repoRef, deps);
|
||||
const data = fetchFromProvider(provider, cwd, repoRef, includeChecks, deadline, deps);
|
||||
if (data) {
|
||||
writeCache(cachePath, data, deps);
|
||||
writeCache(cachePath, data, includeChecks, deps);
|
||||
return data;
|
||||
}
|
||||
} catch { /* try next provider */ }
|
||||
}
|
||||
|
||||
writeCache(cachePath, null, deps);
|
||||
// Keep useful stale data on transient refresh failures. A later statusline
|
||||
// invocation will schedule another refresh because its mtime stays stale.
|
||||
if (cached !== 'miss' && cached.data !== null) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
writeCache(cachePath, null, true, deps);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRefreshLockPath(cachePath: string): string {
|
||||
return `${cachePath}.lock`;
|
||||
}
|
||||
|
||||
function releaseRefreshLock(lockPath: string, deps: GitReviewCacheDeps): void {
|
||||
try {
|
||||
deps.unlinkSync(lockPath);
|
||||
} catch {
|
||||
// Another process may already have cleaned up a stale lock.
|
||||
}
|
||||
}
|
||||
|
||||
function createRefreshLock(cachePath: string, deps: GitReviewCacheDeps): string | null {
|
||||
const cacheDir = getGitReviewCacheDir(deps);
|
||||
try {
|
||||
if (!deps.existsSync(cacheDir)) {
|
||||
deps.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lockPath = getRefreshLockPath(cachePath);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const descriptor = deps.openSync(lockPath, 'wx');
|
||||
deps.closeSync(descriptor);
|
||||
return lockPath;
|
||||
} catch {
|
||||
try {
|
||||
const age = deps.now() - deps.statSync(lockPath).mtimeMs;
|
||||
if (age <= REFRESH_LOCK_STALE_MS) {
|
||||
return null;
|
||||
}
|
||||
deps.unlinkSync(lockPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scheduleRefresh(
|
||||
cwd: string,
|
||||
cachePath: string,
|
||||
includeChecks: boolean,
|
||||
deps: GitReviewCacheDeps
|
||||
): void {
|
||||
const scriptPath = deps.getScriptPath();
|
||||
if (!scriptPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lockPath = createRefreshLock(cachePath, deps);
|
||||
if (!lockPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const child = deps.spawn(
|
||||
deps.getExecPath(),
|
||||
[
|
||||
scriptPath,
|
||||
GIT_REVIEW_REFRESH_FLAG,
|
||||
cwd,
|
||||
includeChecks ? 'checks' : 'metadata',
|
||||
lockPath
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
}
|
||||
);
|
||||
child.unref();
|
||||
} catch {
|
||||
releaseRefreshLock(lockPath, deps);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedGitReviewData(
|
||||
cwd: string,
|
||||
options: GitReviewFetchOptions = {},
|
||||
deps: GitReviewCacheDeps = DEFAULT_GIT_REVIEW_CACHE_DEPS
|
||||
): GitReviewData | null {
|
||||
const includeChecks = options.includeChecks ?? false;
|
||||
const cachePath = getCachePath(cwd, getCacheRef(cwd, deps), deps);
|
||||
const cached = readCache(cachePath, deps);
|
||||
const needsRefresh = cached === 'miss'
|
||||
|| cached.stale
|
||||
|| (includeChecks && !cached.checksQueried);
|
||||
|
||||
if (needsRefresh) {
|
||||
scheduleRefresh(cwd, cachePath, includeChecks, deps);
|
||||
}
|
||||
|
||||
return cached === 'miss' ? null : cached.data;
|
||||
}
|
||||
|
||||
export function refreshGitReviewCacheFromCli(
|
||||
cwd: string,
|
||||
options: GitReviewFetchOptions,
|
||||
lockPath: string,
|
||||
deps: GitReviewCacheDeps = DEFAULT_GIT_REVIEW_CACHE_DEPS
|
||||
): void {
|
||||
const expectedLockPath = getRefreshLockPath(
|
||||
getCachePath(cwd, getCacheRef(cwd, deps), deps)
|
||||
);
|
||||
try {
|
||||
fetchGitReviewData(cwd, deps, options);
|
||||
} finally {
|
||||
// Only unlink the path derived from the supplied repository. This
|
||||
// keeps the internal CLI mode from becoming an arbitrary file delete.
|
||||
if (lockPath === expectedLockPath) {
|
||||
releaseRefreshLock(lockPath, deps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getGitReviewStatusLabel(state: string, reviewDecision: string): string {
|
||||
if (state === 'MERGED')
|
||||
return 'MERGED';
|
||||
|
||||
@@ -55,12 +55,14 @@ export function getCommandResolutionPaths(
|
||||
? execFileSync('where', [command], {
|
||||
encoding: 'utf-8',
|
||||
timeout: COMMAND_LOOKUP_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
: execFileSync('which', ['-a', command], {
|
||||
encoding: 'utf-8',
|
||||
timeout: COMMAND_LOOKUP_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
});
|
||||
|
||||
return splitCommandOutput(output);
|
||||
@@ -76,6 +78,7 @@ function getNpmGlobalBinDir(platform: NodeJS.Platform): string | null {
|
||||
encoding: 'utf-8',
|
||||
timeout: COMMAND_LOOKUP_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
...getPackageManagerShellOptions(executable, platform)
|
||||
}).trim();
|
||||
|
||||
@@ -93,10 +96,14 @@ function getNpmGlobalBinDir(platform: NodeJS.Platform): string | null {
|
||||
|
||||
function getBunGlobalBinDir(): string | null {
|
||||
try {
|
||||
// bun writes an error to stderr when its global dir was never
|
||||
// initialized (no `bun add -g` ever run); silence it so best-effort
|
||||
// probing cannot leak into the TUI terminal.
|
||||
const binDir = execFileSync('bun', ['pm', 'bin', '-g'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: COMMAND_LOOKUP_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
|
||||
return binDir || null;
|
||||
|
||||
@@ -183,6 +183,7 @@ function getNpmGlobalPackageVersion(platform: NodeJS.Platform): string | null {
|
||||
encoding: 'utf-8',
|
||||
timeout: VERSION_LOOKUP_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
...getPackageManagerShellOptions(executable, platform)
|
||||
}).trim();
|
||||
|
||||
|
||||
+44
-4
@@ -15,6 +15,13 @@ export interface WidgetHookDef {
|
||||
|
||||
const HOOK_TAG = 'ccstatusline-managed';
|
||||
|
||||
// Matches ccstatusline hook commands written by any install method
|
||||
// (global binary, `bunx ccstatusline@latest --hook`, `npx ccstatusline --hook`, …).
|
||||
// Used to heal legacy/untagged hooks that predate HOOK_TAG so they do not
|
||||
// accumulate alongside the managed set on every sync. The space before `--hook`
|
||||
// and trailing boundary avoid matching unrelated `--hook*` substrings.
|
||||
const CCSTATUSLINE_HOOK_PATTERN = /ccstatusline.* --hook(?:\s|$)/;
|
||||
|
||||
interface HookEntry {
|
||||
_tag?: string;
|
||||
matcher?: string;
|
||||
@@ -27,12 +34,45 @@ function hasWidgetHooks(widget: Widget | null): widget is WidgetWithHooks {
|
||||
return Boolean(widget && 'getHooks' in widget && typeof widget.getHooks === 'function');
|
||||
}
|
||||
|
||||
function isCcstatuslineManagedEntry(entry: HookEntry): boolean {
|
||||
return entry._tag === HOOK_TAG;
|
||||
}
|
||||
|
||||
function isLegacyCcstatuslineHookCommand(hook: { type: string; command: string }): boolean {
|
||||
return CCSTATUSLINE_HOOK_PATTERN.test(hook.command);
|
||||
}
|
||||
|
||||
function stripManagedHookEntry(entry: HookEntry): HookEntry | null {
|
||||
if (isCcstatuslineManagedEntry(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!entry.hooks) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const remainingHooks = entry.hooks.filter(hook => !isLegacyCcstatuslineHookCommand(hook));
|
||||
if (remainingHooks.length === entry.hooks.length) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (remainingHooks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
hooks: remainingHooks
|
||||
};
|
||||
}
|
||||
|
||||
function stripManagedHooks(hooks: Record<string, HookEntry[]>): void {
|
||||
for (const event of Object.keys(hooks)) {
|
||||
hooks[event] = (hooks[event] ?? []).filter(entry => entry._tag !== HOOK_TAG);
|
||||
hooks[event] = (hooks[event] ?? [])
|
||||
.map(stripManagedHookEntry)
|
||||
.filter((entry): entry is HookEntry => entry !== null);
|
||||
if (hooks[event].length === 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete hooks[event];
|
||||
Reflect.deleteProperty(hooks, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +103,7 @@ export async function syncWidgetHooks(settings: Settings): Promise<void> {
|
||||
const claudeSettings = await loadClaudeSettings({ logErrors: false });
|
||||
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;
|
||||
|
||||
// Remove all ccstatusline-managed hooks
|
||||
// Remove tagged entries and legacy untagged ccstatusline hook commands
|
||||
stripManagedHooks(hooks);
|
||||
|
||||
const statusCommand = await getExistingStatusLine();
|
||||
|
||||
@@ -10,6 +10,22 @@ interface ModelIdentifier {
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW_SIZE = 200000;
|
||||
const USABLE_CONTEXT_RATIO = 0.8;
|
||||
const CONTEXT_SIZE_FALLBACK_ENV_VAR = 'CCSTATUSLINE_CONTEXT_SIZE_FALLBACK';
|
||||
|
||||
// User-configurable last-resort fallback window size. Mirrors CCSTATUSLINE_WIDTH:
|
||||
// a positive integer read from the environment, ignored when unset or invalid.
|
||||
// Defaults to 200k so behavior is unchanged unless the user opts in.
|
||||
function getFallbackContextWindowSize(): number {
|
||||
const raw = process.env[CONTEXT_SIZE_FALLBACK_ENV_VAR];
|
||||
if (raw) {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_CONTEXT_WINDOW_SIZE;
|
||||
}
|
||||
|
||||
function toValidWindowSize(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
@@ -82,10 +98,13 @@ export function getContextConfig(modelIdentifier?: string, contextWindowSize?: n
|
||||
};
|
||||
}
|
||||
|
||||
// Default to 200k for older models
|
||||
// Last-resort fallback when neither the live status window size nor a
|
||||
// model-name hint is available. Defaults to 200k, overridable via
|
||||
// CCSTATUSLINE_CONTEXT_SIZE_FALLBACK.
|
||||
const fallbackWindowSize = getFallbackContextWindowSize();
|
||||
const defaultConfig = {
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW_SIZE,
|
||||
usableTokens: Math.floor(DEFAULT_CONTEXT_WINDOW_SIZE * USABLE_CONTEXT_RATIO)
|
||||
maxTokens: fallbackWindowSize,
|
||||
usableTokens: Math.floor(fallbackWindowSize * USABLE_CONTEXT_RATIO)
|
||||
};
|
||||
|
||||
if (!modelIdentifier) {
|
||||
|
||||
@@ -13,7 +13,7 @@ function resolveEnabledPowerlineTheme(theme: string | undefined): string {
|
||||
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.map(line => line.filter(item => item.type !== 'separator'))
|
||||
: settings.lines;
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,12 @@ export function countPowerlineThemeSlots(entries: PowerlineThemeSlotEntry[]): nu
|
||||
let slotCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.content || entry.widget.type === 'separator' || entry.widget.type === 'flex-separator') {
|
||||
if (entry.widget.type === 'separator' || entry.widget.type === 'flex-separator') {
|
||||
previousVisibleWidget = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+366
-76
@@ -4,8 +4,14 @@ import type {
|
||||
RenderContext,
|
||||
WidgetItem
|
||||
} from '../types';
|
||||
import { getColorLevelString } from '../types/ColorLevel';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import {
|
||||
getColorLevelString,
|
||||
type ColorLevel
|
||||
} from '../types/ColorLevel';
|
||||
import type {
|
||||
DefaultPaddingSide,
|
||||
Settings
|
||||
} from '../types/Settings';
|
||||
|
||||
import {
|
||||
applyLineGradient,
|
||||
@@ -17,6 +23,7 @@ import {
|
||||
} from './ansi';
|
||||
import {
|
||||
applyColors,
|
||||
applyParensDim,
|
||||
bgToFg,
|
||||
getColorAnsiCode,
|
||||
getPowerlineTheme
|
||||
@@ -31,6 +38,12 @@ import { getWidget } from './widgets';
|
||||
|
||||
export { formatTokens } from './format-tokens';
|
||||
|
||||
// Build a red warning badge indicating that settings.json could not be loaded.
|
||||
// Passes colorLevel through so color is suppressed when the terminal has no color support.
|
||||
export function buildConfigWarningBadge(colorLevel: ColorLevel): string {
|
||||
return applyColors('⚠ invalid config', 'red', undefined, false, getColorLevelString(colorLevel));
|
||||
}
|
||||
|
||||
// Paint a foreground gradient across a finished line when overrideForegroundColor
|
||||
// is a gradient spec (e.g. "gradient:hex:FF0000,hex:0000FF"); a no-op otherwise.
|
||||
// Applied as the final step, after truncation, so the trailing reset is not sliced
|
||||
@@ -44,6 +57,16 @@ function maybeApplyForegroundGradient(
|
||||
return stops ? applyLineGradient(line, stops, colorLevel) : line;
|
||||
}
|
||||
|
||||
// Split the default padding string into the leading/trailing pieces that
|
||||
// actually get applied, based on which side(s) padding is configured for.
|
||||
function resolvePaddingSides(padding: string, side: DefaultPaddingSide | undefined): { leading: string; trailing: string } {
|
||||
if (side === 'left')
|
||||
return { leading: padding, trailing: '' };
|
||||
if (side === 'right')
|
||||
return { leading: '', trailing: padding };
|
||||
return { leading: padding, trailing: padding };
|
||||
}
|
||||
|
||||
function resolveEffectiveTerminalWidth(
|
||||
detectedWidth: number | null,
|
||||
settings: Settings,
|
||||
@@ -109,8 +132,13 @@ function renderPowerlineStatusLine(
|
||||
|
||||
// Get the cap for this line (cycle through if more lines than caps)
|
||||
const capLineIndex = context.lineIndex ?? lineIndex;
|
||||
const startCap = startCaps.length > 0 ? startCaps[capLineIndex % startCaps.length] : '';
|
||||
const endCap = endCaps.length > 0 ? endCaps[capLineIndex % endCaps.length] : '';
|
||||
const startCapIndex = context.globalPowerlineStartCapIndex ?? capLineIndex;
|
||||
const getStartCap = (segmentOffset: number): string => (
|
||||
startCaps.length > 0 ? (startCaps[(startCapIndex + segmentOffset) % startCaps.length] ?? '') : ''
|
||||
);
|
||||
const getEndCap = (segmentOffset: number): string => (
|
||||
endCaps.length > 0 ? (endCaps[(startCapIndex + segmentOffset) % endCaps.length] ?? '') : ''
|
||||
);
|
||||
|
||||
// Get theme colors if a theme is set and not 'custom'
|
||||
const themeName = config.theme as string | undefined;
|
||||
@@ -128,11 +156,19 @@ function renderPowerlineStatusLine(
|
||||
// Get color level from settings
|
||||
const colorLevel = getColorLevelString(settings.colorLevel);
|
||||
const overrideForegroundGradientStops = parseGradientSpec(settings.overrideForegroundColor);
|
||||
const isSeparatorBoundary = (widget: WidgetItem | undefined): boolean => (
|
||||
widget?.type === 'separator' || widget?.type === 'flex-separator'
|
||||
);
|
||||
|
||||
// Filter out separator and flex-separator widgets in powerline mode
|
||||
const filteredWidgets = widgets.filter(widget => widget.type !== 'separator' && widget.type !== 'flex-separator'
|
||||
);
|
||||
|
||||
// Sentinel inserted into the rendered string at each flex position. The
|
||||
// SOH control char is essentially never present in real widget
|
||||
// content, so a plain split works for the post-render pass.
|
||||
const FLEX_SENTINEL = '\x01FLEX_SEP\x01';
|
||||
|
||||
if (filteredWidgets.length === 0)
|
||||
return '';
|
||||
|
||||
@@ -142,9 +178,52 @@ function renderPowerlineStatusLine(
|
||||
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 }[] = [];
|
||||
const widgetElements: {
|
||||
content: string;
|
||||
bgColor?: string;
|
||||
fgColor?: string;
|
||||
mergesWithNext: boolean;
|
||||
originalIndex: number;
|
||||
widget: WidgetItem;
|
||||
}[] = [];
|
||||
let widgetColorIndex = continueThemeAcrossLines ? globalThemeColorOffset : 0;
|
||||
|
||||
const hasNextRenderedWidgetBeforeSeparator = (originalIndex: number): boolean => {
|
||||
for (let j = originalIndex + 1; j < widgets.length; j++) {
|
||||
const nextWidget = widgets[j];
|
||||
if (!nextWidget)
|
||||
continue;
|
||||
if (isSeparatorBoundary(nextWidget))
|
||||
return false;
|
||||
if (preRenderedWidgets[j]?.content)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const canMergeWithNextRenderedWidget = (originalIndex: number | undefined): boolean => {
|
||||
if (originalIndex === undefined)
|
||||
return false;
|
||||
|
||||
const widget = widgets[originalIndex];
|
||||
return Boolean(widget?.merge && hasNextRenderedWidgetBeforeSeparator(originalIndex));
|
||||
};
|
||||
|
||||
const findPreviousRenderedWidgetIndexBeforeSeparator = (originalIndex: number): number | null => {
|
||||
for (let j = originalIndex - 1; j >= 0; j--) {
|
||||
const previousWidget = widgets[j];
|
||||
if (!previousWidget)
|
||||
continue;
|
||||
if (isSeparatorBoundary(previousWidget))
|
||||
return null;
|
||||
if (preRenderedWidgets[j]?.content)
|
||||
return j;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Create a mapping from filteredWidgets to preRenderedWidgets indices
|
||||
// This is needed because filteredWidgets excludes separators but preRenderedWidgets includes all widgets
|
||||
const preRenderedIndices: number[] = [];
|
||||
@@ -183,6 +262,7 @@ function renderPowerlineStatusLine(
|
||||
if (widgetText) {
|
||||
// Apply default padding from settings
|
||||
const padding = settings.defaultPadding ?? '';
|
||||
const { leading: sideLeadingPadding, trailing: sideTrailingPadding } = resolvePaddingSides(padding, settings.defaultPaddingSide);
|
||||
|
||||
// If override FG color is set and this is a custom command with preserveColors,
|
||||
// we need to strip the ANSI codes from the widget text
|
||||
@@ -193,13 +273,19 @@ function renderPowerlineStatusLine(
|
||||
}
|
||||
|
||||
// Check if padding should be omitted due to no-padding merge
|
||||
const prevItem = i > 0 ? filteredWidgets[i - 1] : null;
|
||||
const nextItem = i < filteredWidgets.length - 1 ? filteredWidgets[i + 1] : null;
|
||||
const omitLeadingPadding = prevItem?.merge === 'no-padding';
|
||||
const omitTrailingPadding = widget.merge === 'no-padding' && nextItem;
|
||||
const previousRenderedIndex = actualPreRenderedIndex !== undefined
|
||||
? findPreviousRenderedWidgetIndexBeforeSeparator(actualPreRenderedIndex)
|
||||
: null;
|
||||
const previousRenderedWidget = previousRenderedIndex !== null
|
||||
? widgets[previousRenderedIndex]
|
||||
: undefined;
|
||||
const mergesWithNext = canMergeWithNextRenderedWidget(actualPreRenderedIndex);
|
||||
const omitLeadingPadding = previousRenderedWidget?.merge === 'no-padding'
|
||||
&& canMergeWithNextRenderedWidget(previousRenderedIndex ?? undefined);
|
||||
const omitTrailingPadding = widget.merge === 'no-padding' && mergesWithNext;
|
||||
|
||||
const leadingPadding = omitLeadingPadding ? '' : padding;
|
||||
const trailingPadding = omitTrailingPadding ? '' : padding;
|
||||
const leadingPadding = omitLeadingPadding ? '' : sideLeadingPadding;
|
||||
const trailingPadding = omitTrailingPadding ? '' : sideTrailingPadding;
|
||||
const paddedText = `${leadingPadding}${widgetText}${trailingPadding}`;
|
||||
|
||||
// Determine colors
|
||||
@@ -218,7 +304,7 @@ function renderPowerlineStatusLine(
|
||||
|
||||
// Only increment color index if this widget is not merged with the next one
|
||||
// This ensures merged widgets share the same color
|
||||
if (!widget.merge) {
|
||||
if (!mergesWithNext) {
|
||||
widgetColorIndex++;
|
||||
}
|
||||
}
|
||||
@@ -235,6 +321,8 @@ function renderPowerlineStatusLine(
|
||||
content: paddedText,
|
||||
bgColor: bgColor ?? undefined, // Make sure undefined, not empty string
|
||||
fgColor: fgColor,
|
||||
mergesWithNext,
|
||||
originalIndex: actualPreRenderedIndex ?? -1,
|
||||
widget: widget
|
||||
});
|
||||
}
|
||||
@@ -243,6 +331,55 @@ function renderPowerlineStatusLine(
|
||||
if (widgetElements.length === 0)
|
||||
return '';
|
||||
|
||||
const renderedElementIndexByOriginalIndex = new Map<number, number>();
|
||||
widgetElements.forEach((element, index) => {
|
||||
renderedElementIndexByOriginalIndex.set(element.originalIndex, index);
|
||||
});
|
||||
|
||||
// Track flex-separators against rendered widget positions. Some widgets
|
||||
// intentionally render empty and are omitted from widgetElements, so using
|
||||
// filtered config indices would move flex slots to the wrong visible side.
|
||||
const flexAfterIndex = new Map<number, number>();
|
||||
const startCapBeforeIndex = new Map<number, number>();
|
||||
const segmentOffsetByRenderedIndex = new Map<number, number>();
|
||||
let leadingFlexCount = 0;
|
||||
let totalFlexCount = 0;
|
||||
let lastRenderedIndex: number | null = null;
|
||||
let pendingFlexCount = 0;
|
||||
let segmentOffset = 0;
|
||||
let hasRenderedSegment = false;
|
||||
for (let i = 0; i < widgets.length; i++) {
|
||||
const widget = widgets[i];
|
||||
if (!widget || widget.type === 'separator')
|
||||
continue;
|
||||
|
||||
if (widget.type === 'flex-separator') {
|
||||
totalFlexCount++;
|
||||
if (lastRenderedIndex === null) {
|
||||
leadingFlexCount++;
|
||||
} else {
|
||||
pendingFlexCount++;
|
||||
flexAfterIndex.set(lastRenderedIndex, (flexAfterIndex.get(lastRenderedIndex) ?? 0) + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const renderedIndex = renderedElementIndexByOriginalIndex.get(i);
|
||||
if (renderedIndex !== undefined) {
|
||||
if (!hasRenderedSegment) {
|
||||
startCapBeforeIndex.set(renderedIndex, segmentOffset);
|
||||
pendingFlexCount = 0;
|
||||
hasRenderedSegment = true;
|
||||
} else if (pendingFlexCount > 0) {
|
||||
segmentOffset++;
|
||||
startCapBeforeIndex.set(renderedIndex, segmentOffset);
|
||||
pendingFlexCount = 0;
|
||||
}
|
||||
segmentOffsetByRenderedIndex.set(renderedIndex, segmentOffset);
|
||||
lastRenderedIndex = renderedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply auto-alignment if enabled
|
||||
const autoAlign = config.autoAlign as boolean | undefined;
|
||||
if (autoAlign) {
|
||||
@@ -253,9 +390,14 @@ function renderPowerlineStatusLine(
|
||||
if (!element)
|
||||
continue;
|
||||
|
||||
// An excluded widget, and everything after it on this line, keeps its
|
||||
// natural width (mirrors the skip in calculateMaxWidthsFromPreRendered).
|
||||
if (element.widget.excludeFromAutoAlign)
|
||||
break;
|
||||
|
||||
// Check if previous widget was merged with this one
|
||||
const prevWidget = i > 0 ? widgetElements[i - 1] : null;
|
||||
const isPreviousMerged = prevWidget?.widget.merge;
|
||||
const isPreviousMerged = prevWidget?.mergesWithNext;
|
||||
|
||||
// Only apply alignment to non-merged widgets (widgets that follow a merge are excluded)
|
||||
if (!isPreviousMerged) {
|
||||
@@ -264,7 +406,7 @@ function renderPowerlineStatusLine(
|
||||
// Calculate combined width if this widget merges with following ones
|
||||
let combinedLength = getVisibleWidth(element.content);
|
||||
let j = i;
|
||||
while (j < widgetElements.length - 1 && widgetElements[j]?.widget.merge) {
|
||||
while (j < widgetElements.length - 1 && widgetElements[j]?.mergesWithNext) {
|
||||
j++;
|
||||
const nextElement = widgetElements[j];
|
||||
if (nextElement) {
|
||||
@@ -300,20 +442,12 @@ function renderPowerlineStatusLine(
|
||||
// Build the final powerline string
|
||||
let result = '';
|
||||
|
||||
// Add start cap if specified
|
||||
if (startCap && widgetElements.length > 0) {
|
||||
const firstWidget = widgetElements[0];
|
||||
if (firstWidget?.bgColor) {
|
||||
// Start cap uses first widget's background as foreground (converted)
|
||||
const capFg = bgToFg(firstWidget.bgColor);
|
||||
const fgCode = getColorAnsiCode(capFg, colorLevel, false);
|
||||
result += fgCode + startCap + '\x1b[39m';
|
||||
} else {
|
||||
result += startCap;
|
||||
}
|
||||
if (leadingFlexCount > 0) {
|
||||
result += FLEX_SENTINEL.repeat(leadingFlexCount);
|
||||
}
|
||||
|
||||
// Render widgets with powerline separators
|
||||
let localSeparatorIndex = 0;
|
||||
for (let i = 0; i < widgetElements.length; i++) {
|
||||
const widget = widgetElements[i];
|
||||
const nextWidget = widgetElements[i + 1];
|
||||
@@ -324,9 +458,29 @@ function renderPowerlineStatusLine(
|
||||
// Apply colors to widget content using raw ANSI codes for powerline mode
|
||||
// This avoids reset codes that interfere with separator rendering
|
||||
const shouldBold = (settings.globalBold) || widget.widget.bold;
|
||||
const shouldDim = widget.widget.dim === true;
|
||||
|
||||
// Check if we need a separator after this widget
|
||||
const needsSeparator = i < widgetElements.length - 1 && separators.length > 0 && nextWidget && !widget.widget.merge;
|
||||
const needsSeparator = i < widgetElements.length - 1 && separators.length > 0 && nextWidget !== undefined && !widget.mergesWithNext;
|
||||
const flexCountAfter = flexAfterIndex.get(i) ?? 0;
|
||||
const currentSegmentOffset = segmentOffsetByRenderedIndex.get(i) ?? 0;
|
||||
const isLastWidget = i === widgetElements.length - 1;
|
||||
const hasEndCapAfterWidget = Boolean(getEndCap(currentSegmentOffset)) && (flexCountAfter > 0 || isLastWidget);
|
||||
|
||||
const startCapSegmentOffset = startCapBeforeIndex.get(i);
|
||||
if (startCapSegmentOffset !== undefined) {
|
||||
const segmentStartCap = getStartCap(startCapSegmentOffset);
|
||||
if (segmentStartCap) {
|
||||
if (widget.bgColor) {
|
||||
// Start cap uses this segment's first widget background as foreground.
|
||||
const capFg = bgToFg(widget.bgColor);
|
||||
const fgCode = getColorAnsiCode(capFg, colorLevel, false);
|
||||
result += fgCode + segmentStartCap + '\x1b[39m';
|
||||
} else {
|
||||
result += segmentStartCap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let widgetContent = '';
|
||||
|
||||
@@ -336,9 +490,15 @@ function renderPowerlineStatusLine(
|
||||
if (shouldBold && !isPreserveColors) {
|
||||
widgetContent += '\x1b[1m';
|
||||
}
|
||||
if (shouldDim && !isPreserveColors) {
|
||||
widgetContent += '\x1b[2m';
|
||||
}
|
||||
const textGradientStops = !isPreserveColors && powerlineGradientWidth > 1
|
||||
? overrideForegroundGradientStops
|
||||
: null;
|
||||
const styledContent = widget.widget.dim === 'parens' && !isPreserveColors
|
||||
? applyParensDim(widget.content, shouldBold)
|
||||
: widget.content;
|
||||
|
||||
if (widget.fgColor && !isPreserveColors && !textGradientStops) {
|
||||
widgetContent += getColorAnsiCode(widget.fgColor, colorLevel, false);
|
||||
@@ -349,7 +509,7 @@ function renderPowerlineStatusLine(
|
||||
}
|
||||
if (textGradientStops) {
|
||||
const gradientResult = applyLineGradientSegment(
|
||||
widget.content,
|
||||
styledContent,
|
||||
textGradientStops,
|
||||
colorLevel,
|
||||
powerlineGradientColumn,
|
||||
@@ -358,7 +518,7 @@ function renderPowerlineStatusLine(
|
||||
widgetContent += gradientResult.text;
|
||||
powerlineGradientColumn = gradientResult.nextColumn;
|
||||
} else {
|
||||
widgetContent += widget.content;
|
||||
widgetContent += styledContent;
|
||||
}
|
||||
// Reset colors after content
|
||||
// For custom commands with preserveColors, also reset text attributes like dim
|
||||
@@ -367,22 +527,48 @@ function renderPowerlineStatusLine(
|
||||
widgetContent += '\x1b[0m';
|
||||
} else {
|
||||
widgetContent += '\x1b[49m\x1b[39m';
|
||||
// Only reset bold if there's no separator following AND no end cap
|
||||
const isLastWidget = i === widgetElements.length - 1;
|
||||
const hasEndCap = endCaps.length > 0 && endCaps[capLineIndex % endCaps.length];
|
||||
if (shouldBold && !needsSeparator && !(isLastWidget && hasEndCap)) {
|
||||
// Dim should be scoped to the widget text only. Reset before
|
||||
// separators/end caps so faint intensity cannot leak forward.
|
||||
const shouldRestoreBoldForBoundary = shouldDim && shouldBold && (needsSeparator || hasEndCapAfterWidget);
|
||||
if (shouldRestoreBoldForBoundary) {
|
||||
widgetContent += '\x1b[22;1m';
|
||||
} else if (shouldDim || (shouldBold && !needsSeparator && !hasEndCapAfterWidget)) {
|
||||
widgetContent += '\x1b[22m';
|
||||
}
|
||||
}
|
||||
|
||||
result += widgetContent;
|
||||
|
||||
// If a flex-separator originally sat between widget i and i+1, emit
|
||||
// the current segment's end cap followed by a FLEX_SENTINEL. The
|
||||
// post-render pass replaces the sentinel with the correct number of
|
||||
// spaces. The regular between-widgets separator is suppressed since
|
||||
// the flex space separates powerline segments rather than adjacent
|
||||
// widgets inside a segment.
|
||||
if (flexCountAfter > 0) {
|
||||
const segmentEndCap = getEndCap(currentSegmentOffset);
|
||||
if (segmentEndCap) {
|
||||
if (widget.bgColor) {
|
||||
const capFg = bgToFg(widget.bgColor);
|
||||
const fgCode = getColorAnsiCode(capFg, colorLevel, false);
|
||||
result += fgCode + segmentEndCap + '\x1b[39m';
|
||||
} else {
|
||||
result += segmentEndCap;
|
||||
}
|
||||
}
|
||||
if (shouldBold) {
|
||||
result += '\x1b[22m';
|
||||
}
|
||||
result += FLEX_SENTINEL.repeat(flexCountAfter);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add separator between widgets (not after last one, and not if current widget is merged with next)
|
||||
if (needsSeparator) {
|
||||
// Determine which separator to use based on global position
|
||||
// Use separators in order, using the last one for all remaining positions
|
||||
const globalIndex = globalSeparatorOffset + i;
|
||||
const separatorIndex = Math.min(globalIndex, separators.length - 1);
|
||||
// Use separators in order, cycling across rendered separator slots.
|
||||
const globalIndex = globalSeparatorOffset + localSeparatorIndex;
|
||||
const separatorIndex = globalIndex % separators.length;
|
||||
const separator = separators[separatorIndex] ?? '\uE0B0';
|
||||
const shouldInvert = invertBgs[separatorIndex] ?? false;
|
||||
|
||||
@@ -461,30 +647,62 @@ function renderPowerlineStatusLine(
|
||||
|
||||
result += separatorOutput;
|
||||
|
||||
// Reset bold after separator if it was set
|
||||
if (shouldBold) {
|
||||
// Reset bold/dim after separator if either was set
|
||||
if (shouldBold || shouldDim) {
|
||||
result += '\x1b[22m';
|
||||
}
|
||||
localSeparatorIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add end cap if specified
|
||||
if (widgetElements.length > 0) {
|
||||
const lastWidgetIndex = widgetElements.length - 1;
|
||||
const lastWidget = widgetElements[lastWidgetIndex];
|
||||
const lastWidgetBold = (settings.globalBold) || lastWidget?.widget.bold;
|
||||
const lastWidgetDim = lastWidget?.widget.dim === true;
|
||||
const lastSegmentOffset = segmentOffsetByRenderedIndex.get(lastWidgetIndex) ?? 0;
|
||||
const lastWidgetHasFlexAfter = (flexAfterIndex.get(lastWidgetIndex) ?? 0) > 0;
|
||||
const segmentEndCap = getEndCap(lastSegmentOffset);
|
||||
|
||||
if (segmentEndCap && !lastWidgetHasFlexAfter) {
|
||||
if (lastWidget?.bgColor) {
|
||||
// End cap uses last widget's background as foreground (converted)
|
||||
const capFg = bgToFg(lastWidget.bgColor);
|
||||
const fgCode = getColorAnsiCode(capFg, colorLevel, false);
|
||||
result += fgCode + segmentEndCap + '\x1b[39m';
|
||||
} else {
|
||||
result += segmentEndCap;
|
||||
}
|
||||
|
||||
// Reset bold/dim after end cap if needed
|
||||
if (lastWidgetBold || lastWidgetDim) {
|
||||
result += '\x1b[22m';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add end cap if specified
|
||||
if (endCap && widgetElements.length > 0) {
|
||||
const lastWidget = widgetElements[widgetElements.length - 1];
|
||||
|
||||
if (lastWidget?.bgColor) {
|
||||
// End cap uses last widget's background as foreground (converted)
|
||||
const capFg = bgToFg(lastWidget.bgColor);
|
||||
const fgCode = getColorAnsiCode(capFg, colorLevel, false);
|
||||
result += fgCode + endCap + '\x1b[39m';
|
||||
// If any flex-separators were configured, replace each FLEX_SENTINEL with
|
||||
// the appropriate number of spaces so the rendered line expands to fill
|
||||
// the terminal width. End caps are already present here so their width is
|
||||
// reserved before flex space is distributed.
|
||||
if (totalFlexCount > 0) {
|
||||
if (terminalWidth && terminalWidth > 0) {
|
||||
const parts = result.split(FLEX_SENTINEL);
|
||||
const totalContentWidth = parts.reduce((sum, p) => sum + getVisibleWidth(p), 0);
|
||||
const flexCount = parts.length - 1;
|
||||
const totalSpace = Math.max(0, terminalWidth - totalContentWidth);
|
||||
const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0;
|
||||
const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0;
|
||||
let newResult = parts[0] ?? '';
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const flexSize = spacePerFlex + (i - 1 < extraSpace ? 1 : 0);
|
||||
newResult += ' '.repeat(flexSize) + (parts[i] ?? '');
|
||||
}
|
||||
result = newResult;
|
||||
} else {
|
||||
result += endCap;
|
||||
}
|
||||
|
||||
// Reset bold after end cap if needed
|
||||
const lastWidgetBold = (settings.globalBold) || lastWidget?.widget.bold;
|
||||
if (lastWidgetBold) {
|
||||
result += '\x1b[22m';
|
||||
// No terminal width detected - keep a visible break between segments.
|
||||
result = result.split(FLEX_SENTINEL).join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +745,41 @@ export interface PreRenderedWidget {
|
||||
widget: WidgetItem; // Original widget config
|
||||
}
|
||||
|
||||
export function countPowerlineStartCapSlots(
|
||||
widgets: WidgetItem[],
|
||||
preRenderedWidgets: PreRenderedWidget[]
|
||||
): number {
|
||||
let pendingFlexAfterRenderedSegment = false;
|
||||
let hasRenderedSegment = false;
|
||||
let renderedSegmentCount = 0;
|
||||
|
||||
for (let i = 0; i < widgets.length; i++) {
|
||||
const widget = widgets[i];
|
||||
if (!widget || widget.type === 'separator')
|
||||
continue;
|
||||
|
||||
if (widget.type === 'flex-separator') {
|
||||
if (hasRenderedSegment) {
|
||||
pendingFlexAfterRenderedSegment = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!preRenderedWidgets[i]?.content)
|
||||
continue;
|
||||
|
||||
if (!hasRenderedSegment) {
|
||||
hasRenderedSegment = true;
|
||||
renderedSegmentCount = 1;
|
||||
} else if (pendingFlexAfterRenderedSegment) {
|
||||
renderedSegmentCount++;
|
||||
pendingFlexAfterRenderedSegment = false;
|
||||
}
|
||||
}
|
||||
|
||||
return renderedSegmentCount;
|
||||
}
|
||||
|
||||
// Pre-render all widgets once and cache the results
|
||||
export function preRenderAllWidgets(
|
||||
allLinesWidgets: WidgetItem[][],
|
||||
@@ -552,7 +805,12 @@ export function preRenderAllWidgets(
|
||||
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (!widgetImpl) {
|
||||
// Unknown widget type - skip it entirely
|
||||
// Preserve index alignment with the configured widgets while skipping unknown output.
|
||||
preRenderedLine.push({
|
||||
content: '',
|
||||
plainLength: 0,
|
||||
widget
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -582,35 +840,62 @@ export function calculateMaxWidthsFromPreRendered(
|
||||
): number[] {
|
||||
const maxWidths: number[] = [];
|
||||
const defaultPadding = settings.defaultPadding ?? '';
|
||||
const paddingLength = defaultPadding.length;
|
||||
const { leading: sideLeadingPadding, trailing: sideTrailingPadding } = resolvePaddingSides(defaultPadding, settings.defaultPaddingSide);
|
||||
const paddingPairLength = sideLeadingPadding.length + sideTrailingPadding.length;
|
||||
|
||||
for (const preRenderedLine of preRenderedLines) {
|
||||
const filteredWidgets = preRenderedLine.filter(
|
||||
w => w.widget.type !== 'separator' && w.widget.type !== 'flex-separator' && w.content
|
||||
const isSeparatorBoundary = (entry: PreRenderedWidget | undefined): boolean => (
|
||||
entry?.widget.type === 'separator' || entry?.widget.type === 'flex-separator'
|
||||
);
|
||||
const hasNextRenderedWidgetBeforeSeparator = (originalIndex: number): boolean => {
|
||||
for (let j = originalIndex + 1; j < preRenderedLine.length; j++) {
|
||||
const nextEntry = preRenderedLine[j];
|
||||
if (!nextEntry)
|
||||
continue;
|
||||
if (isSeparatorBoundary(nextEntry))
|
||||
return false;
|
||||
if (nextEntry.content)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderedWidgets = preRenderedLine
|
||||
.map((entry, originalIndex) => ({
|
||||
...entry,
|
||||
mergesWithNext: Boolean(entry.widget.merge && hasNextRenderedWidgetBeforeSeparator(originalIndex))
|
||||
}))
|
||||
.filter(entry => !isSeparatorBoundary(entry) && entry.content);
|
||||
|
||||
let alignmentPos = 0;
|
||||
for (let i = 0; i < filteredWidgets.length; i++) {
|
||||
const widget = filteredWidgets[i];
|
||||
for (let i = 0; i < renderedWidgets.length; i++) {
|
||||
const widget = renderedWidgets[i];
|
||||
if (!widget)
|
||||
continue;
|
||||
|
||||
// An excluded widget opts itself and the rest of the line out of the
|
||||
// shared column widths. This only applies to merge-group heads;
|
||||
// widgets merged into a previous widget keep the group's width.
|
||||
if (widget.widget.excludeFromAutoAlign)
|
||||
break;
|
||||
|
||||
// Calculate the total width for this alignment position
|
||||
// If this widget is merged with the next, accumulate their widths
|
||||
let totalWidth = widget.plainLength + (paddingLength * 2);
|
||||
let totalWidth = widget.plainLength + paddingPairLength;
|
||||
|
||||
// Check if this widget merges with the next one(s)
|
||||
let j = i;
|
||||
while (j < filteredWidgets.length - 1 && filteredWidgets[j]?.widget.merge) {
|
||||
while (j < renderedWidgets.length - 1 && renderedWidgets[j]?.mergesWithNext) {
|
||||
j++;
|
||||
const nextWidget = filteredWidgets[j];
|
||||
const nextWidget = renderedWidgets[j];
|
||||
if (nextWidget) {
|
||||
// For merged widgets, add width but account for padding adjustments
|
||||
// When merging with 'no-padding', don't count padding between widgets
|
||||
if (filteredWidgets[j - 1]?.widget.merge === 'no-padding') {
|
||||
if (renderedWidgets[j - 1]?.widget.merge === 'no-padding') {
|
||||
totalWidth += nextWidget.plainLength;
|
||||
} else {
|
||||
totalWidth += nextWidget.plainLength + (paddingLength * 2);
|
||||
totalWidth += nextWidget.plainLength + paddingPairLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,8 +960,8 @@ export function renderStatusLine(
|
||||
preCalculatedMaxWidths
|
||||
);
|
||||
|
||||
// Helper to apply colors with optional background and bold override
|
||||
const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean): string => {
|
||||
// Helper to apply colors with optional background, bold, and dim
|
||||
const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean, dim?: boolean | 'parens'): string => {
|
||||
// Override foreground color takes precedence over EVERYTHING, including passed foreground
|
||||
// color — except a gradient: spec, which is not a solid color. The gradient is applied as a
|
||||
// whole-line pass after assembly, so when it will render (color levels above ansi16) we emit
|
||||
@@ -699,7 +984,7 @@ export function renderStatusLine(
|
||||
}
|
||||
|
||||
const shouldBold = (settings.globalBold) || bold;
|
||||
return applyColors(text, fgColor, bgColor, shouldBold, colorLevel);
|
||||
return applyColors(text, fgColor, bgColor, shouldBold, colorLevel, dim);
|
||||
};
|
||||
|
||||
const detectedWidth = context.terminalWidth ?? getTerminalWidth();
|
||||
@@ -744,6 +1029,7 @@ export function renderStatusLine(
|
||||
let separatorColor = widget.color ?? 'gray';
|
||||
let separatorBg = widget.backgroundColor;
|
||||
let separatorBold = widget.bold;
|
||||
let separatorDim = widget.dim;
|
||||
|
||||
if (settings.inheritSeparatorColors && i > 0 && !widget.color && !widget.backgroundColor) {
|
||||
// Only inherit if the separator doesn't have explicit colors set
|
||||
@@ -758,10 +1044,11 @@ export function renderStatusLine(
|
||||
separatorColor = widgetColor;
|
||||
separatorBg = prevWidget.backgroundColor;
|
||||
separatorBold = prevWidget.bold;
|
||||
separatorDim = prevWidget.dim;
|
||||
}
|
||||
}
|
||||
|
||||
elements.push({ content: applyColorsWithOverride(formattedSep, separatorColor, separatorBg, separatorBold), type: 'separator', widget });
|
||||
elements.push({ content: applyColorsWithOverride(formattedSep, separatorColor, separatorBg, separatorBold, separatorDim), type: 'separator', widget });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -803,7 +1090,7 @@ export function renderStatusLine(
|
||||
} else {
|
||||
// Normal widget rendering with colors
|
||||
elements.push({
|
||||
content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold),
|
||||
content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, widget.dim),
|
||||
type: widget.type,
|
||||
widget
|
||||
});
|
||||
@@ -826,6 +1113,7 @@ export function renderStatusLine(
|
||||
// Apply default padding and separators
|
||||
const finalElements: string[] = [];
|
||||
const padding = settings.defaultPadding ?? '';
|
||||
const { leading: sideLeadingPadding, trailing: sideTrailingPadding } = resolvePaddingSides(padding, settings.defaultPaddingSide);
|
||||
const defaultSep = settings.defaultSeparator ? formatSeparator(settings.defaultSeparator) : '';
|
||||
|
||||
elements.forEach((elem, index) => {
|
||||
@@ -848,7 +1136,7 @@ export function renderStatusLine(
|
||||
const widgetImpl = getWidget(prevElem.widget.type);
|
||||
widgetColor = widgetImpl ? widgetImpl.getDefaultColor() : 'white';
|
||||
}
|
||||
const coloredSep = applyColorsWithOverride(defaultSep, widgetColor, prevElem.widget.backgroundColor, prevElem.widget.bold);
|
||||
const coloredSep = applyColorsWithOverride(defaultSep, widgetColor, prevElem.widget.backgroundColor, prevElem.widget.bold, prevElem.widget.dim);
|
||||
finalElements.push(coloredSep);
|
||||
} else {
|
||||
finalElements.push(defaultSep);
|
||||
@@ -870,7 +1158,10 @@ export function renderStatusLine(
|
||||
// Check if padding should be omitted due to no-padding merge
|
||||
const nextElem = index < elements.length - 1 ? elements[index + 1] : null;
|
||||
const omitLeadingPadding = prevElem?.widget?.merge === 'no-padding';
|
||||
const omitTrailingPadding = elem.widget?.merge === 'no-padding' && nextElem;
|
||||
const omitTrailingPadding = elem.widget?.merge === 'no-padding'
|
||||
&& nextElem
|
||||
&& nextElem.type !== 'separator'
|
||||
&& nextElem.type !== 'flex-separator';
|
||||
|
||||
// Apply padding with colors (using overrides if set)
|
||||
const hasColorOverride = Boolean(settings.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none')
|
||||
@@ -878,16 +1169,15 @@ export function renderStatusLine(
|
||||
|
||||
if (padding && (elem.widget?.backgroundColor || hasColorOverride)) {
|
||||
// Apply colors to padding - applyColorsWithOverride will handle the overrides
|
||||
const leadingPadding = omitLeadingPadding ? '' : applyColorsWithOverride(padding, undefined, elem.widget?.backgroundColor);
|
||||
const trailingPadding = omitTrailingPadding ? '' : applyColorsWithOverride(padding, undefined, elem.widget?.backgroundColor);
|
||||
const leadingPadding = omitLeadingPadding || !sideLeadingPadding ? '' : applyColorsWithOverride(sideLeadingPadding, undefined, elem.widget?.backgroundColor);
|
||||
const trailingPadding = omitTrailingPadding || !sideTrailingPadding ? '' : applyColorsWithOverride(sideTrailingPadding, undefined, elem.widget?.backgroundColor);
|
||||
const paddedContent = leadingPadding + elem.content + trailingPadding;
|
||||
finalElements.push(paddedContent);
|
||||
} else if (padding) {
|
||||
// Wrap padding in ANSI reset codes to prevent trimming
|
||||
// This ensures leading spaces aren't trimmed by terminals
|
||||
const protectedPadding = chalk.reset(padding);
|
||||
const leadingPadding = omitLeadingPadding ? '' : protectedPadding;
|
||||
const trailingPadding = omitTrailingPadding ? '' : protectedPadding;
|
||||
const leadingPadding = omitLeadingPadding || !sideLeadingPadding ? '' : chalk.reset(sideLeadingPadding);
|
||||
const trailingPadding = omitTrailingPadding || !sideTrailingPadding ? '' : chalk.reset(sideTrailingPadding);
|
||||
finalElements.push(leadingPadding + elem.content + trailingPadding);
|
||||
} else {
|
||||
// No padding
|
||||
|
||||
@@ -1,10 +1,62 @@
|
||||
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 interface SeparatorSlotEntry {
|
||||
content: string;
|
||||
widget: WidgetItem;
|
||||
}
|
||||
|
||||
export function advanceGlobalSeparatorIndex(currentIndex: number, widgets: WidgetItem[]): number {
|
||||
return currentIndex + countSeparatorSlots(widgets);
|
||||
function hasRenderedContent(
|
||||
widgetIndex: number,
|
||||
preRenderedWidgets?: SeparatorSlotEntry[]
|
||||
): boolean {
|
||||
return preRenderedWidgets ? Boolean(preRenderedWidgets[widgetIndex]?.content) : true;
|
||||
}
|
||||
|
||||
export function countSeparatorSlots(
|
||||
widgets: WidgetItem[],
|
||||
preRenderedWidgets?: SeparatorSlotEntry[]
|
||||
): number {
|
||||
let count = 0;
|
||||
let hasPreviousRenderableWidget = false;
|
||||
let previousRenderableWidgetMergesWithNext = false;
|
||||
|
||||
for (let i = 0; i < widgets.length; i++) {
|
||||
const widget = widgets[i];
|
||||
if (!widget) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (widget.type === 'separator') {
|
||||
if (hasPreviousRenderableWidget) {
|
||||
previousRenderableWidgetMergesWithNext = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (widget.type === 'flex-separator') {
|
||||
hasPreviousRenderableWidget = false;
|
||||
previousRenderableWidgetMergesWithNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasRenderedContent(i, preRenderedWidgets)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasPreviousRenderableWidget && !previousRenderableWidgetMergesWithNext) {
|
||||
count++;
|
||||
}
|
||||
hasPreviousRenderableWidget = true;
|
||||
previousRenderableWidgetMergesWithNext = Boolean(widget.merge);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function advanceGlobalSeparatorIndex(
|
||||
currentIndex: number,
|
||||
widgets: WidgetItem[],
|
||||
preRenderedWidgets?: SeparatorSlotEntry[]
|
||||
): number {
|
||||
return currentIndex + countSeparatorSlots(widgets, preRenderedWidgets);
|
||||
}
|
||||
|
||||
+83
-14
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as https from 'https';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
@@ -33,6 +34,17 @@ const EXTRA_USAGE_DETAIL_FIELDS = new Set<UsageDataField>([
|
||||
'extraUsageUtilization'
|
||||
]);
|
||||
|
||||
// Maps each window reset field to the utilization field parsed from the same
|
||||
// API bucket. A null bucket (Enterprise accounts have no rate-limit windows,
|
||||
// #343) parses to utilization 0 with no resets_at, so once the utilization is
|
||||
// cached the missing timestamp is conclusive and refetching cannot produce it.
|
||||
const WINDOW_RESET_FIELD_SENTINELS: Partial<Record<UsageDataField, UsageDataField>> = {
|
||||
sessionResetAt: 'sessionUsage',
|
||||
weeklyResetAt: 'weeklyUsage',
|
||||
weeklySonnetResetAt: 'weeklySonnetUsage',
|
||||
weeklyOpusResetAt: 'weeklyOpusUsage'
|
||||
};
|
||||
|
||||
const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
|
||||
const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited', 'parse-error']);
|
||||
const UsageLockSchema = z.object({
|
||||
@@ -57,6 +69,8 @@ const CachedUsageDataSchema = z.object({
|
||||
error: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
const CachedTokenHashSchema = z.object({ tokenHash: z.string().optional() });
|
||||
|
||||
const UsageApiBucketSchema = z.looseObject({
|
||||
utilization: z.number().nullable().optional(),
|
||||
resets_at: z.string().nullable().optional()
|
||||
@@ -122,6 +136,27 @@ function parseCachedUsageData(rawJson: string): UsageData | null {
|
||||
};
|
||||
}
|
||||
|
||||
// One-way fingerprint of the usage token, persisted alongside the cache so a
|
||||
// login switch (e.g. enterprise<->personal, a different token) invalidates the
|
||||
// cache immediately instead of waiting out the TTL. A truncated SHA-256 is a
|
||||
// stable identifier, not the token itself, so it is safe to write to disk.
|
||||
function fingerprintUsageToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function readCachedTokenHash(rawJson: string): string | undefined {
|
||||
return parseJsonWithSchema(rawJson, CachedTokenHashSchema)?.tokenHash;
|
||||
}
|
||||
|
||||
function tokenHashMatches(cachedHash: string | undefined, currentHash: string | null): boolean {
|
||||
// With no current token we cannot fingerprint-gate, so fall through to the
|
||||
// existing no-token handling rather than discarding an otherwise usable cache.
|
||||
if (currentHash === null) {
|
||||
return true;
|
||||
}
|
||||
return cachedHash === currentHash;
|
||||
}
|
||||
|
||||
function parseUsageApiResponse(rawJson: string): UsageData | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, UsageApiResponseSchema);
|
||||
if (!parsed) {
|
||||
@@ -185,6 +220,11 @@ function hasRequiredUsageField(data: UsageData, field: UsageDataField): boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
const windowSentinel = WINDOW_RESET_FIELD_SENTINELS[field];
|
||||
if (windowSentinel !== undefined && data[windowSentinel] !== undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Once the API has reported the extra usage state, missing detail fields are
|
||||
// conclusive: accounts without a configured monthly limit never report
|
||||
// monthly_limit/utilization, so refetching cannot produce them (#413).
|
||||
@@ -198,10 +238,11 @@ function hasRequiredUsageFields(data: UsageData, requiredFields: readonly UsageD
|
||||
function getStaleUsageOrError(
|
||||
error: UsageError,
|
||||
now: number,
|
||||
currentTokenHash: string | null,
|
||||
errorCacheMaxAge = LOCK_MAX_AGE,
|
||||
requiredFields: readonly UsageDataField[] = []
|
||||
): UsageData {
|
||||
const stale = readStaleUsageCache();
|
||||
const stale = readStaleUsageCache(currentTokenHash);
|
||||
if (stale && !stale.error && hasRequiredUsageFields(stale, requiredFields)) {
|
||||
return cacheUsageData(stale, now);
|
||||
}
|
||||
@@ -366,9 +407,13 @@ export function getUsageToken(): string | null {
|
||||
?? readUsageTokenFromCredentialsFile();
|
||||
}
|
||||
|
||||
function readStaleUsageCache(): UsageData | null {
|
||||
function readStaleUsageCache(currentTokenHash: string | null): UsageData | null {
|
||||
try {
|
||||
return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
const rawCache = fs.readFileSync(CACHE_FILE, 'utf8');
|
||||
if (!tokenHashMatches(readCachedTokenHash(rawCache), currentTokenHash)) {
|
||||
return null;
|
||||
}
|
||||
return parseCachedUsageData(rawCache);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -383,6 +428,14 @@ function writeUsageLock(blockedUntil: number, error: UsageLockError): void {
|
||||
}
|
||||
}
|
||||
|
||||
function clearUsageLock(): void {
|
||||
try {
|
||||
fs.rmSync(LOCK_FILE, { force: true });
|
||||
} catch {
|
||||
// Ignore lock file errors
|
||||
}
|
||||
}
|
||||
|
||||
function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageLockError } | null {
|
||||
let hasValidJsonLock = false;
|
||||
|
||||
@@ -545,13 +598,23 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the token up front (before lock/rate-limit checks so auth
|
||||
// failures are not masked as timeout) and fingerprint it so the file cache
|
||||
// can be invalidated on an account switch: a different token, written by a
|
||||
// logout/login, no longer matches the cached fingerprint.
|
||||
const token = getUsageToken();
|
||||
const currentTokenHash = token ? fingerprintUsageToken(token) : null;
|
||||
|
||||
// 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 && hasRequiredUsageFields(fileData, requiredFields)) {
|
||||
const rawCache = fs.readFileSync(CACHE_FILE, 'utf8');
|
||||
const fileData = parseCachedUsageData(rawCache);
|
||||
if (fileData && !fileData.error
|
||||
&& tokenHashMatches(readCachedTokenHash(rawCache), currentTokenHash)
|
||||
&& hasRequiredUsageFields(fileData, requiredFields)) {
|
||||
return cacheUsageData(fileData, now);
|
||||
}
|
||||
}
|
||||
@@ -559,10 +622,8 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
|
||||
// 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, LOCK_MAX_AGE, requiredFields);
|
||||
return getStaleUsageOrError('no-credentials', now, currentTokenHash, LOCK_MAX_AGE, requiredFields);
|
||||
}
|
||||
|
||||
const activeLock = readActiveUsageLock(now);
|
||||
@@ -570,6 +631,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
|
||||
return getStaleUsageOrError(
|
||||
activeLock.error,
|
||||
now,
|
||||
currentTokenHash,
|
||||
Math.max(1, activeLock.blockedUntil - now),
|
||||
requiredFields
|
||||
);
|
||||
@@ -583,36 +645,43 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
|
||||
|
||||
if (response.kind === 'rate-limited') {
|
||||
writeUsageLock(now + response.retryAfterSeconds, 'rate-limited');
|
||||
return getStaleUsageOrError('rate-limited', now, response.retryAfterSeconds, requiredFields);
|
||||
return getStaleUsageOrError('rate-limited', now, currentTokenHash, response.retryAfterSeconds, requiredFields);
|
||||
}
|
||||
|
||||
if (response.kind === 'error') {
|
||||
return getStaleUsageOrError('api-error', now, LOCK_MAX_AGE, requiredFields);
|
||||
return getStaleUsageOrError('api-error', now, currentTokenHash, LOCK_MAX_AGE, requiredFields);
|
||||
}
|
||||
|
||||
const usageData = parseUsageApiResponse(response.body);
|
||||
if (!usageData) {
|
||||
writeUsageLock(now + LOCK_MAX_AGE, 'parse-error');
|
||||
return getStaleUsageOrError('parse-error', now, LOCK_MAX_AGE, requiredFields);
|
||||
return getStaleUsageOrError('parse-error', now, currentTokenHash, LOCK_MAX_AGE, requiredFields);
|
||||
}
|
||||
|
||||
// Validate we got actual data
|
||||
if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) {
|
||||
writeUsageLock(now + LOCK_MAX_AGE, 'parse-error');
|
||||
return getStaleUsageOrError('parse-error', now, LOCK_MAX_AGE, requiredFields);
|
||||
return getStaleUsageOrError('parse-error', now, currentTokenHash, LOCK_MAX_AGE, requiredFields);
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
try {
|
||||
ensureCacheDirExists();
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData));
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify({ ...usageData, tokenHash: currentTokenHash ?? undefined }));
|
||||
} catch {
|
||||
// Ignore cache write errors
|
||||
}
|
||||
|
||||
// Clear the in-flight lock written above only once this response satisfies
|
||||
// the caller's requested fields. Incomplete 200 responses are cached but
|
||||
// still need the short throttle so later renders do not refetch every time.
|
||||
if (hasRequiredUsageFields(usageData, requiredFields)) {
|
||||
clearUsageLock();
|
||||
}
|
||||
|
||||
return cacheUsageData(usageData, now);
|
||||
} catch {
|
||||
writeUsageLock(now + LOCK_MAX_AGE, 'parse-error');
|
||||
return getStaleUsageOrError('parse-error', now, LOCK_MAX_AGE, requiredFields);
|
||||
return getStaleUsageOrError('parse-error', now, currentTokenHash, LOCK_MAX_AGE, requiredFields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const USAGE_DATA_FIELDS: UsageDataField[] = [
|
||||
interface UsageFieldRequirement {
|
||||
alternatives?: UsageDataField[];
|
||||
field: UsageDataField;
|
||||
suppressFetchError?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_USAGE_REQUIREMENTS: UsageFieldRequirement[] = [];
|
||||
@@ -47,9 +48,9 @@ const USAGE_WIDGET_REQUIREMENTS: Record<string, UsageFieldRequirement[]> = {
|
||||
'weekly-usage': [{ field: 'weeklyUsage' }],
|
||||
'weekly-sonnet-usage': [{ field: 'weeklySonnetUsage' }],
|
||||
'weekly-opus-usage': [{ field: 'weeklyOpusUsage' }],
|
||||
'block-timer': [{ field: 'sessionResetAt' }],
|
||||
'reset-timer': [{ field: 'sessionResetAt' }],
|
||||
'weekly-reset-timer': [{ field: 'weeklyResetAt' }],
|
||||
'block-timer': [{ field: 'sessionResetAt', suppressFetchError: true }],
|
||||
'reset-timer': [{ field: 'sessionResetAt', suppressFetchError: true }],
|
||||
'weekly-reset-timer': [{ field: 'weeklyResetAt', suppressFetchError: true }],
|
||||
'extra-usage-utilization': [
|
||||
{ field: 'extraUsageEnabled' },
|
||||
{ field: 'extraUsageUtilization' }
|
||||
@@ -109,16 +110,26 @@ function isUsageRequirementSatisfied(data: UsageData | null, requirement: UsageF
|
||||
return requirement.alternatives?.some(field => hasUsageDataField(data, field)) ?? false;
|
||||
}
|
||||
|
||||
function getMissingFetchFields(data: UsageData | null, requirements: UsageFieldRequirement[]): UsageDataField[] {
|
||||
function getMissingFetchRequirements(
|
||||
data: UsageData | null,
|
||||
requirements: UsageFieldRequirement[]
|
||||
): { fields: UsageDataField[]; suppressFetchError: boolean } {
|
||||
const missing = new Set<UsageDataField>();
|
||||
let hasUnsuppressedMissingRequirement = false;
|
||||
|
||||
for (const requirement of requirements) {
|
||||
if (!isUsageRequirementSatisfied(data, requirement)) {
|
||||
missing.add(requirement.field);
|
||||
if (!requirement.suppressFetchError) {
|
||||
hasUnsuppressedMissingRequirement = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(missing);
|
||||
return {
|
||||
fields: Array.from(missing),
|
||||
suppressFetchError: missing.size > 0 && !hasUnsuppressedMissingRequirement
|
||||
};
|
||||
}
|
||||
|
||||
function hasAnyUsageDataField(data: UsageData | null | undefined): boolean {
|
||||
@@ -195,12 +206,17 @@ export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][], data?: St
|
||||
|
||||
const rateLimitsData = extractUsageDataFromRateLimits(data?.rate_limits);
|
||||
const requirements = getUsageFieldRequirements(lines);
|
||||
const missingFields = getMissingFetchFields(rateLimitsData, requirements);
|
||||
const missingRequirements = getMissingFetchRequirements(rateLimitsData, requirements);
|
||||
const missingFields = missingRequirements.fields;
|
||||
|
||||
if (missingFields.length === 0) {
|
||||
return rateLimitsData;
|
||||
}
|
||||
|
||||
const apiData = await fetchUsageData({ requiredFields: missingFields });
|
||||
if (apiData.error && missingRequirements.suppressFetchError) {
|
||||
return rateLimitsData;
|
||||
}
|
||||
|
||||
return mergeUsageData(rateLimitsData, apiData);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
|
||||
{ type: 'git-clean-status', create: () => new widgets.GitCleanStatusWidget() },
|
||||
{ type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() },
|
||||
{ type: 'git-review', create: () => new widgets.GitPrWidget() },
|
||||
{ type: 'git-ci-status', create: () => new widgets.GitCiStatusWidget() },
|
||||
{ type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() },
|
||||
{ type: 'git-status', create: () => new widgets.GitStatusWidget() },
|
||||
{ type: 'git-staged', create: () => new widgets.GitStagedWidget() },
|
||||
@@ -78,6 +79,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
|
||||
{ type: 'link', create: () => new widgets.LinkWidget() },
|
||||
{ type: 'claude-session-id', create: () => new widgets.ClaudeSessionIdWidget() },
|
||||
{ type: 'claude-account-email', create: () => new widgets.ClaudeAccountEmailWidget() },
|
||||
{ type: 'sandbox-status', create: () => new widgets.SandboxStatusWidget() },
|
||||
{ type: 'session-name', create: () => new widgets.SessionNameWidget() },
|
||||
{ type: 'free-memory', create: () => new widgets.FreeMemoryWidget() },
|
||||
{ type: 'session-usage', create: () => new widgets.SessionUsageWidget() },
|
||||
@@ -99,7 +101,8 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
|
||||
{ type: 'worktree-name', create: () => new widgets.GitWorktreeNameWidget() },
|
||||
{ type: 'worktree-branch', create: () => new widgets.GitWorktreeBranchWidget() },
|
||||
{ type: 'worktree-original-branch', create: () => new widgets.GitWorktreeOriginalBranchWidget() },
|
||||
{ type: 'compaction-counter', create: () => new widgets.CompactionCounterWidget() }
|
||||
{ type: 'compaction-counter', create: () => new widgets.CompactionCounterWidget() },
|
||||
{ type: 'cache-timer', create: () => new widgets.CacheTimerWidget() }
|
||||
];
|
||||
|
||||
export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [
|
||||
|
||||
@@ -43,12 +43,10 @@ export function getAllWidgetTypes(settings: Settings): WidgetItemType[] {
|
||||
const allTypes = WIDGET_MANIFEST.map(entry => entry.type);
|
||||
|
||||
// Add separator types based on settings
|
||||
if (!settings.powerline.enabled) {
|
||||
if (!settings.defaultSeparator) {
|
||||
allTypes.push('separator');
|
||||
}
|
||||
allTypes.push('flex-separator');
|
||||
if (!settings.powerline.enabled && !settings.defaultSeparator) {
|
||||
allTypes.push('separator');
|
||||
}
|
||||
allTypes.push('flex-separator');
|
||||
|
||||
return allTypes;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ function makeTimerProgressBar(percent: number, width: number): string {
|
||||
}
|
||||
|
||||
const BLOCK_RESET_PREVIEW_AT = '2026-03-12T08:30:00.000Z';
|
||||
const USAGE_TIMER_LOADING_MESSAGE = '[Loading]';
|
||||
|
||||
export class BlockResetTimerWidget implements Widget {
|
||||
getDefaultColor(): string { return 'brightBlue'; }
|
||||
@@ -137,7 +138,7 @@ export class BlockResetTimerWidget implements Widget {
|
||||
return getUsageErrorMessage(usageData.error);
|
||||
}
|
||||
|
||||
return null;
|
||||
return formatRawOrLabeledValue(item, 'Reset: ', USAGE_TIMER_LOADING_MESSAGE);
|
||||
}
|
||||
|
||||
if (isUsageProgressMode(displayMode)) {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetEditorProps,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
|
||||
import { makeModifierText } from './shared/editor-display';
|
||||
import {
|
||||
isMetadataFlagEnabled,
|
||||
removeMetadataKeys,
|
||||
toggleMetadataFlag
|
||||
} from './shared/metadata';
|
||||
import { formatRawOrLabeledValue } from './shared/raw-or-labeled';
|
||||
import {
|
||||
getSlotSymbol,
|
||||
getSymbolKeybind,
|
||||
renderSymbolSlotsEditor,
|
||||
type SymbolSlot
|
||||
} from './shared/symbol-override';
|
||||
|
||||
const HIDE_WHEN_EMPTY_KEY = 'hideWhenEmpty';
|
||||
const TOGGLE_HIDE_ACTION = 'toggle-hide';
|
||||
|
||||
// Anthropic's ephemeral prompt cache defaults to a 5-minute TTL, but Claude Code
|
||||
// also writes 1-hour breakpoints (cache_control ttl: "1h") for the stable prefix.
|
||||
// The expiry itself is never exposed (the transcript only records token counts),
|
||||
// so this is a best-effort countdown from the last turn; the TTL is configurable
|
||||
// to match whichever tier the user cares about.
|
||||
const TTL_METADATA_KEY = 'ttlSeconds';
|
||||
const DEFAULT_TTL_SECONDS = 300;
|
||||
const TTL_OPTIONS = [300, 3600] as const; // 5 minutes, 1 hour
|
||||
const TOGGLE_TTL_ACTION = 'toggle-ttl';
|
||||
|
||||
const SAFETY_MARGIN = 5; // display as COLD 5s before actual expiry
|
||||
|
||||
// One editable glyph per display state, so nerd-font / ASCII users can replace
|
||||
// the emoji (which ignore the widget's color) with symbols that respect it.
|
||||
const HOT_SLOT: SymbolSlot = { id: 'symbolHot', label: 'Working', defaultSymbol: '🔥' };
|
||||
const FRESH_SLOT: SymbolSlot = { id: 'symbolFresh', label: 'Fresh', defaultSymbol: '🟢' };
|
||||
const DRAINING_SLOT: SymbolSlot = { id: 'symbolDraining', label: 'Draining', defaultSymbol: '🟡' };
|
||||
const URGENT_SLOT: SymbolSlot = { id: 'symbolUrgent', label: 'Urgent', defaultSymbol: '🔴' };
|
||||
const COLD_SLOT: SymbolSlot = { id: 'symbolCold', label: 'Cold', defaultSymbol: '❄️' };
|
||||
const SYMBOL_SLOTS: SymbolSlot[] = [HOT_SLOT, FRESH_SLOT, DRAINING_SLOT, URGENT_SLOT, COLD_SLOT];
|
||||
|
||||
interface TranscriptEntry {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
isSidechain?: boolean;
|
||||
isApiErrorMessage?: boolean;
|
||||
message?: {
|
||||
usage?: {
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Whether this assistant row's request actually read or wrote the prompt
|
||||
// cache. Rows without usage data cannot be classified and are assumed to be
|
||||
// cache events so older transcript formats keep driving the countdown.
|
||||
function hasCacheActivity(entry: TranscriptEntry): boolean {
|
||||
const usage = entry.message?.usage;
|
||||
if (!usage) {
|
||||
return true;
|
||||
}
|
||||
return (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) > 0;
|
||||
}
|
||||
|
||||
// A single transcript record can exceed the initial tail read (pasted prompts
|
||||
// and tool results reach hundreds of KiB), leaving only an unparseable
|
||||
// fragment in view, so the read doubles until the state resolves or the whole
|
||||
// file has been scanned — the same worst case as the full-file transcript
|
||||
// reads the token widgets already do every render.
|
||||
const INITIAL_TAIL_BYTES = 32768;
|
||||
|
||||
/**
|
||||
* Read the last N bytes of a file, reporting whether the read reached back to
|
||||
* the start of the file. Avoids loading large transcript files entirely.
|
||||
*/
|
||||
function readFileTail(filePath: string, bytes: number): { text: string; isComplete: boolean } | null {
|
||||
try {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const size = fs.fstatSync(fd).size;
|
||||
const readSize = Math.min(bytes, size);
|
||||
const buf = Buffer.alloc(readSize);
|
||||
fs.readSync(fd, buf, 0, readSize, size - readSize);
|
||||
return { text: buf.toString('utf-8'), isComplete: readSize === size };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type TranscriptState = { isWorking: true } | { isWorking: false; lastAssistant: Date | null };
|
||||
|
||||
/**
|
||||
* Find the cache state from the newest main-chain rows in the transcript tail.
|
||||
* A trailing user-role row (a prompt or a tool result, both recorded as role
|
||||
* 'user' by Claude Code) means a turn is in flight and the cache is being
|
||||
* refreshed, so report { isWorking: true }. Once an assistant row has ended
|
||||
* the turn, the countdown anchors on the newest assistant row whose request
|
||||
* actually read or wrote the cache.
|
||||
* The tail read grows until a relevant record fits in view, so a trailing
|
||||
* record larger than the initial read still resolves to a state.
|
||||
*/
|
||||
function getTranscriptState(transcriptPath: string): TranscriptState {
|
||||
for (let bytes = INITIAL_TAIL_BYTES; ; bytes *= 2) {
|
||||
const tail = readFileTail(transcriptPath, bytes);
|
||||
if (!tail || tail.text.length === 0) {
|
||||
return { isWorking: false, lastAssistant: null };
|
||||
}
|
||||
const state = scanTailForState(tail.text);
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
if (tail.isComplete) {
|
||||
return { isWorking: false, lastAssistant: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan the tail's lines newest-first for the state; null means no relevant
|
||||
// record was found (so a larger tail may still surface one).
|
||||
function scanTailForState(tail: string): TranscriptState | null {
|
||||
const lines = tail.split('\n').reverse();
|
||||
// Set once an assistant row is seen: the turn is over, so any older user
|
||||
// row belongs to a previous exchange and must not report HOT while the
|
||||
// scan keeps looking for the newest row with real cache activity.
|
||||
let turnFinished = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const entry = JSON.parse(trimmed) as TranscriptEntry;
|
||||
// Sidechain (subagent) traffic runs against its own prompt prefix
|
||||
// and never touches this conversation's cache.
|
||||
if (entry.isSidechain === true) {
|
||||
continue;
|
||||
}
|
||||
if (entry.type === 'assistant') {
|
||||
turnFinished = true;
|
||||
// Synthetic API-error rows and requests with no cache reads
|
||||
// or writes (caching disabled or unsupported) refreshed
|
||||
// nothing: they end the in-flight state but must not anchor
|
||||
// the countdown. A malformed timestamp is likewise no anchor.
|
||||
if (entry.isApiErrorMessage !== true && hasCacheActivity(entry) && entry.timestamp) {
|
||||
const parsed = new Date(entry.timestamp);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return { isWorking: false, lastAssistant: parsed };
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.type === 'user' && !turnFinished) {
|
||||
return { isWorking: true };
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The configured TTL in seconds. Defaults to 5 minutes; the (t)tl keybind cycles
|
||||
// 5m/1h, and any other positive value can be set directly in settings.json.
|
||||
function getTtlSeconds(item: WidgetItem): number {
|
||||
const raw = item.metadata?.[TTL_METADATA_KEY];
|
||||
if (raw === undefined) {
|
||||
return DEFAULT_TTL_SECONDS;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > SAFETY_MARGIN ? parsed : DEFAULT_TTL_SECONDS;
|
||||
}
|
||||
|
||||
function cycleTtl(item: WidgetItem): WidgetItem {
|
||||
const current = getTtlSeconds(item);
|
||||
const index = (TTL_OPTIONS as readonly number[]).indexOf(current);
|
||||
const next = TTL_OPTIONS[(index + 1) % TTL_OPTIONS.length] ?? DEFAULT_TTL_SECONDS;
|
||||
if (next === DEFAULT_TTL_SECONDS) {
|
||||
return removeMetadataKeys(item, [TTL_METADATA_KEY]);
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
[TTL_METADATA_KEY]: String(next)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function formatTtlLabel(ttlSeconds: number): string {
|
||||
return ttlSeconds % 3600 === 0 ? `${ttlSeconds / 3600}h` : `${Math.round(ttlSeconds / 60)}m`;
|
||||
}
|
||||
|
||||
function getRemainingSeconds(lastAssistant: Date, ttlSeconds: number): number {
|
||||
const elapsedSeconds = (Date.now() - lastAssistant.getTime()) / 1000;
|
||||
return ttlSeconds - SAFETY_MARGIN - elapsedSeconds;
|
||||
}
|
||||
|
||||
function formatCountdown(remaining: number): string {
|
||||
if (remaining <= 0) {
|
||||
return 'COLD';
|
||||
}
|
||||
const m = Math.floor(remaining / 60);
|
||||
const s = Math.floor(remaining % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// The glyph for the current drain state (excluding HOT, handled in render).
|
||||
function getStateSymbol(item: WidgetItem, remaining: number, ttlSeconds: number): string {
|
||||
if (remaining <= 0) {
|
||||
return getSlotSymbol(item, COLD_SLOT);
|
||||
}
|
||||
const pct = remaining / (ttlSeconds - SAFETY_MARGIN);
|
||||
if (pct > 0.5) {
|
||||
return getSlotSymbol(item, FRESH_SLOT);
|
||||
}
|
||||
if (pct > 0.2) {
|
||||
return getSlotSymbol(item, DRAINING_SLOT);
|
||||
}
|
||||
return getSlotSymbol(item, URGENT_SLOT);
|
||||
}
|
||||
|
||||
// Joins a glyph to its countdown; a blanked glyph collapses the leading space.
|
||||
function withGlyph(symbol: string, text: string): string {
|
||||
return symbol.length > 0 ? `${symbol} ${text}` : text;
|
||||
}
|
||||
|
||||
export class CacheTimerWidget implements Widget {
|
||||
getDefaultColor(): string { return 'brightCyan'; }
|
||||
getDescription(): string { return 'Shows time remaining on the prompt cache TTL (5m by default, 1h configurable)'; }
|
||||
getDisplayName(): string { return 'Cache Timer'; }
|
||||
getCategory(): string { return 'Session'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const modifiers: string[] = [];
|
||||
|
||||
const ttlSeconds = getTtlSeconds(item);
|
||||
if (ttlSeconds !== DEFAULT_TTL_SECONDS) {
|
||||
modifiers.push(`ttl ${formatTtlLabel(ttlSeconds)}`);
|
||||
}
|
||||
if (isMetadataFlagEnabled(item, HIDE_WHEN_EMPTY_KEY)) {
|
||||
modifiers.push('hide when empty');
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: makeModifierText(modifiers)
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === TOGGLE_HIDE_ACTION) {
|
||||
return toggleMetadataFlag(item, HIDE_WHEN_EMPTY_KEY);
|
||||
}
|
||||
|
||||
if (action === TOGGLE_TTL_ACTION) {
|
||||
return cycleTtl(item);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
|
||||
const hideWhenEmpty = isMetadataFlagEnabled(item, HIDE_WHEN_EMPTY_KEY);
|
||||
|
||||
if (context.isPreview) {
|
||||
return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(getSlotSymbol(item, FRESH_SLOT), '4:52'));
|
||||
}
|
||||
|
||||
const transcriptPath = context.data?.transcript_path;
|
||||
if (!transcriptPath) {
|
||||
return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache: ', 'n/a');
|
||||
}
|
||||
|
||||
const state = getTranscriptState(transcriptPath);
|
||||
|
||||
if (state.isWorking) {
|
||||
return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(getSlotSymbol(item, HOT_SLOT), 'HOT'));
|
||||
}
|
||||
|
||||
const { lastAssistant } = state;
|
||||
if (!lastAssistant) {
|
||||
return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache: ', 'n/a');
|
||||
}
|
||||
|
||||
const ttlSeconds = getTtlSeconds(item);
|
||||
const remaining = getRemainingSeconds(lastAssistant, ttlSeconds);
|
||||
const glyph = getStateSymbol(item, remaining, ttlSeconds);
|
||||
|
||||
return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(glyph, formatCountdown(remaining)));
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 't', label: '(t)tl', action: TOGGLE_TTL_ACTION },
|
||||
{ key: 'h', label: '(h)ide when empty', action: TOGGLE_HIDE_ACTION },
|
||||
getSymbolKeybind()
|
||||
];
|
||||
}
|
||||
|
||||
renderEditor(props: WidgetEditorProps) {
|
||||
return renderSymbolSlotsEditor(props, SYMBOL_SLOTS);
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(_item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetEditorProps,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import { ZERO_COMPACTION_STATS } from '../utils/compaction';
|
||||
@@ -16,6 +17,12 @@ import {
|
||||
isMetadataFlagEnabled,
|
||||
toggleMetadataFlag
|
||||
} from './shared/metadata';
|
||||
import {
|
||||
getSlotSymbol,
|
||||
getSymbolKeybind,
|
||||
renderSymbolSlotsEditor,
|
||||
type SymbolSlot
|
||||
} from './shared/symbol-override';
|
||||
|
||||
const COMPACTION_ICON = '↻';
|
||||
const COMPACTION_NERD_FONT_ICON = '\uF021';
|
||||
@@ -32,6 +39,16 @@ const TOGGLE_TRIGGERS_ACTION = 'toggle-triggers';
|
||||
const SHOW_TRIGGERS_METADATA_KEY = 'showTriggers';
|
||||
const TOGGLE_RECLAIMED_ACTION = 'toggle-reclaimed';
|
||||
const SHOW_RECLAIMED_METADATA_KEY = 'showReclaimed';
|
||||
// Selectable metric. The default 'count' keeps the full composite display
|
||||
// (icon, count, optional trigger split, optional reclaimed). The other metrics
|
||||
// render just that one value as a raw number, so several instances can be
|
||||
// composed with custom separators/symbols into a layout like "2 · 1a 1m · ↓2M".
|
||||
const METRICS = ['count', 'auto', 'manual', 'unknown', 'reclaimed'] as const;
|
||||
type CompactionMetric = typeof METRICS[number];
|
||||
const DEFAULT_METRIC: CompactionMetric = 'count';
|
||||
const METRIC_METADATA_KEY = 'metric';
|
||||
const CYCLE_METRIC_ACTION = 'cycle-metric';
|
||||
const RECLAIMED_SLOT: SymbolSlot = { id: 'symbolReclaimed', label: 'Reclaimed', defaultSymbol: '↓' };
|
||||
const SAMPLE_STATS: CompactionData = Object.freeze({
|
||||
count: 2,
|
||||
byTrigger: Object.freeze({ auto: 1, manual: 1, unknown: 0 }),
|
||||
@@ -94,8 +111,47 @@ function toggleHideZero(item: WidgetItem): WidgetItem {
|
||||
};
|
||||
}
|
||||
|
||||
function formatReclaimedSuffix(tokensReclaimed: number): string {
|
||||
return tokensReclaimed > 0 ? ` ↓${formatTokens(tokensReclaimed)}` : '';
|
||||
function getMetric(item: WidgetItem): CompactionMetric {
|
||||
const metric = item.metadata?.[METRIC_METADATA_KEY];
|
||||
return (METRICS as readonly string[]).includes(metric ?? '') ? (metric as CompactionMetric) : DEFAULT_METRIC;
|
||||
}
|
||||
|
||||
function setMetric(item: WidgetItem, metric: CompactionMetric): WidgetItem {
|
||||
if (metric === DEFAULT_METRIC) {
|
||||
const { [METRIC_METADATA_KEY]: removedMetric, ...restMetadata } = item.metadata ?? {};
|
||||
void removedMetric;
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
[METRIC_METADATA_KEY]: metric
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getMetricValue(data: CompactionData, metric: CompactionMetric): number {
|
||||
switch (metric) {
|
||||
case 'count': return data.count;
|
||||
case 'auto': return data.byTrigger.auto;
|
||||
case 'manual': return data.byTrigger.manual;
|
||||
case 'unknown': return data.byTrigger.unknown;
|
||||
case 'reclaimed': return data.tokensReclaimed;
|
||||
}
|
||||
}
|
||||
|
||||
function formatReclaimedSuffix(tokensReclaimed: number, item: WidgetItem): string {
|
||||
if (tokensReclaimed <= 0) {
|
||||
return '';
|
||||
}
|
||||
const symbol = getSlotSymbol(item, RECLAIMED_SLOT);
|
||||
return symbol.length > 0 ? ` ${symbol}${formatTokens(tokensReclaimed)}` : ` ${formatTokens(tokensReclaimed)}`;
|
||||
}
|
||||
|
||||
function formatTriggerSuffix(byTrigger: CompactionData['byTrigger']): string {
|
||||
@@ -118,7 +174,7 @@ function formatStats(data: CompactionData, item: WidgetItem, icon: string): stri
|
||||
out += formatTriggerSuffix(data.byTrigger);
|
||||
}
|
||||
if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) {
|
||||
out += formatReclaimedSuffix(data.tokensReclaimed);
|
||||
out += formatReclaimedSuffix(data.tokensReclaimed, item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -157,7 +213,9 @@ function formatCount(count: number, format: CompactionCounterFormat, icon: strin
|
||||
* compaction has occurred by counting compact_boundary markers in the transcript.
|
||||
*
|
||||
* Shows ↻ N by default, including ↻ 0 before compaction occurs. Can be
|
||||
* configured to hide when count is 0.
|
||||
* configured to hide when count is 0. A `metric` selector switches it to emit a
|
||||
* single raw value (count, auto, manual, unknown, or reclaimed) so several
|
||||
* instances can be composed into a custom layout.
|
||||
*/
|
||||
export class CompactionCounterWidget implements Widget {
|
||||
getDefaultColor(): string { return 'yellow'; }
|
||||
@@ -165,15 +223,22 @@ export class CompactionCounterWidget implements Widget {
|
||||
getDisplayName(): string { return 'Compaction Counter'; }
|
||||
getCategory(): string { return 'Context'; }
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const modifiers: string[] = [getFormat(item)];
|
||||
if (isNerdFontEnabled(item)) {
|
||||
modifiers.push('nerd font');
|
||||
}
|
||||
if (isMetadataFlagEnabled(item, SHOW_TRIGGERS_METADATA_KEY)) {
|
||||
modifiers.push('trigger split');
|
||||
}
|
||||
if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) {
|
||||
modifiers.push('reclaimed');
|
||||
const metric = getMetric(item);
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (metric !== DEFAULT_METRIC) {
|
||||
modifiers.push(`${metric} value`);
|
||||
} else {
|
||||
modifiers.push(getFormat(item));
|
||||
if (isNerdFontEnabled(item)) {
|
||||
modifiers.push('nerd font');
|
||||
}
|
||||
if (isMetadataFlagEnabled(item, SHOW_TRIGGERS_METADATA_KEY)) {
|
||||
modifiers.push('trigger split');
|
||||
}
|
||||
if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) {
|
||||
modifiers.push('reclaimed');
|
||||
}
|
||||
}
|
||||
if (isHideZeroEnabled(item)) {
|
||||
modifiers.push('hide zero');
|
||||
@@ -186,6 +251,13 @@ export class CompactionCounterWidget implements Widget {
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === CYCLE_METRIC_ACTION) {
|
||||
const currentMetric = getMetric(item);
|
||||
const nextMetric = METRICS[(METRICS.indexOf(currentMetric) + 1) % METRICS.length] ?? DEFAULT_METRIC;
|
||||
|
||||
return setMetric(item, nextMetric);
|
||||
}
|
||||
|
||||
if (action === CYCLE_FORMAT_ACTION) {
|
||||
const currentFormat = getFormat(item);
|
||||
const nextFormat = FORMATS[(FORMATS.indexOf(currentFormat) + 1) % FORMATS.length] ?? DEFAULT_FORMAT;
|
||||
@@ -214,35 +286,53 @@ export class CompactionCounterWidget implements Widget {
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
void settings;
|
||||
const icon = isNerdFontEnabled(item) ? COMPACTION_NERD_FONT_ICON : COMPACTION_ICON;
|
||||
const data = context.isPreview ? SAMPLE_STATS : (context.compactionData ?? ZERO_COMPACTION_STATS);
|
||||
const metric = getMetric(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
return formatStats(SAMPLE_STATS, item, icon);
|
||||
if (metric !== DEFAULT_METRIC) {
|
||||
const value = getMetricValue(data, metric);
|
||||
if (value === 0 && isHideZeroEnabled(item) && !context.isPreview) {
|
||||
return null;
|
||||
}
|
||||
return metric === 'reclaimed' ? formatTokens(value) : String(value);
|
||||
}
|
||||
|
||||
const data = context.compactionData ?? ZERO_COMPACTION_STATS;
|
||||
if (data.count === 0 && isHideZeroEnabled(item))
|
||||
if (data.count === 0 && isHideZeroEnabled(item) && !context.isPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = isNerdFontEnabled(item) ? COMPACTION_NERD_FONT_ICON : COMPACTION_ICON;
|
||||
return formatStats(data, item, icon);
|
||||
}
|
||||
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
const keybinds: CustomKeybind[] = [
|
||||
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }
|
||||
{ key: 'v', label: '(v)alue', action: CYCLE_METRIC_ACTION }
|
||||
];
|
||||
|
||||
// The format / glyph / trigger toggles only shape the composite 'count'
|
||||
// display; a single-metric value just needs the metric and hide-zero.
|
||||
if (item !== undefined && getMetric(item) !== DEFAULT_METRIC) {
|
||||
keybinds.push({ key: 'h', label: '(h)ide when zero', action: TOGGLE_HIDE_ZERO_ACTION });
|
||||
return keybinds;
|
||||
}
|
||||
|
||||
keybinds.push({ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION });
|
||||
if (item === undefined || getFormat(item) === DEFAULT_FORMAT) {
|
||||
keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION });
|
||||
}
|
||||
|
||||
keybinds.push({ key: 's', label: '(s)plit by trigger', action: TOGGLE_TRIGGERS_ACTION });
|
||||
keybinds.push({ key: 't', label: '(t)okens reclaimed', action: TOGGLE_RECLAIMED_ACTION });
|
||||
keybinds.push({ key: 'h', label: '(h)ide when zero', action: TOGGLE_HIDE_ZERO_ACTION });
|
||||
keybinds.push(getSymbolKeybind());
|
||||
|
||||
return keybinds;
|
||||
}
|
||||
|
||||
renderEditor(props: WidgetEditorProps) {
|
||||
return renderSymbolSlotsEditor(props, [RECLAIMED_SLOT]);
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return false; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getContextConfig,
|
||||
getModelContextIdentifier
|
||||
} from '../utils/model-context';
|
||||
import { formatTokens } from '../utils/renderer';
|
||||
import { makeUsageProgressBar } from '../utils/usage';
|
||||
|
||||
import { makeSliderBar } from './shared/usage-display';
|
||||
@@ -110,17 +111,17 @@ export class ContextBarWidget implements Widget {
|
||||
|
||||
const percent = (used / total) * 100;
|
||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||
const usedK = Math.round(used / 1000);
|
||||
const totalK = Math.round(total / 1000);
|
||||
const usedDisplay = formatTokens(used, 0);
|
||||
const totalDisplay = formatTokens(total, 0);
|
||||
|
||||
if (isBarSliderMode(displayMode)) {
|
||||
const slider = makeSliderBar(clampedPercent);
|
||||
const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedK}k/${totalK}k (${Math.round(clampedPercent)}%)` : slider;
|
||||
const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${Math.round(clampedPercent)}%)` : slider;
|
||||
return item.rawValue ? sliderDisplay : `Context: ${sliderDisplay}`;
|
||||
}
|
||||
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedK}k/${totalK}k (${Math.round(clampedPercent)}%)`;
|
||||
const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${Math.round(clampedPercent)}%)`;
|
||||
|
||||
return item.rawValue ? display : `Context: ${display}`;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ import type {
|
||||
} from '../types/Widget';
|
||||
import { shouldInsertInput } from '../utils/input-guards';
|
||||
|
||||
import {
|
||||
SYMBOL_OVERRIDE_ACTION,
|
||||
formatSymbolPrefix,
|
||||
getSymbolKeybind,
|
||||
renderSymbolOverrideEditor
|
||||
} from './shared/symbol-override';
|
||||
|
||||
export class CurrentWorkingDirWidget implements Widget {
|
||||
getDefaultColor(): string { return 'blue'; }
|
||||
getDescription(): string { return 'Shows the current working directory'; }
|
||||
@@ -108,6 +115,7 @@ export class CurrentWorkingDirWidget implements Widget {
|
||||
const segments = item.metadata?.segments ? parseInt(item.metadata.segments, 10) : undefined;
|
||||
const fishStyle = item.metadata?.fishStyle === 'true';
|
||||
const abbreviateHome = item.metadata?.abbreviateHome === 'true';
|
||||
const symbolPrefix = formatSymbolPrefix(item, '');
|
||||
|
||||
if (context.isPreview) {
|
||||
let previewPath: string;
|
||||
@@ -132,7 +140,7 @@ export class CurrentWorkingDirWidget implements Widget {
|
||||
previewPath = '/Users/example/Documents/Projects/my-project';
|
||||
}
|
||||
|
||||
return item.rawValue ? previewPath : `cwd: ${previewPath}`;
|
||||
return item.rawValue ? `${symbolPrefix}${previewPath}` : `${symbolPrefix}cwd: ${previewPath}`;
|
||||
}
|
||||
|
||||
const cwd = context.data?.cwd;
|
||||
@@ -169,18 +177,22 @@ export class CurrentWorkingDirWidget implements Widget {
|
||||
}
|
||||
}
|
||||
|
||||
return item.rawValue ? displayPath : `cwd: ${displayPath}`;
|
||||
return item.rawValue ? `${symbolPrefix}${displayPath}` : `${symbolPrefix}cwd: ${displayPath}`;
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'h', label: '(h)ome ~', action: 'toggle-abbreviate-home' },
|
||||
{ key: 's', label: '(s)egments', action: 'edit-segments' },
|
||||
{ key: 'f', label: '(f)ish style', action: 'toggle-fish-style' }
|
||||
{ key: 'f', label: '(f)ish style', action: 'toggle-fish-style' },
|
||||
getSymbolKeybind()
|
||||
];
|
||||
}
|
||||
|
||||
renderEditor(props: WidgetEditorProps): React.ReactElement {
|
||||
if (props.action === SYMBOL_OVERRIDE_ACTION) {
|
||||
return renderSymbolOverrideEditor(props, '');
|
||||
}
|
||||
return <CurrentWorkingDirEditor {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ export class ExtraUsageUtilizationWidget implements Widget {
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: appendHideDisabledModifier(getUsageDisplayModifierText(item), item)
|
||||
modifierText: appendHideDisabledModifier(
|
||||
getUsageDisplayModifierText(item, { showUsageDirection: true }),
|
||||
item
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,7 +52,7 @@ export class ExtraUsageUtilizationWidget implements Widget {
|
||||
}
|
||||
|
||||
if (action === 'toggle-progress') {
|
||||
return cycleUsageDisplayMode(item, [], true);
|
||||
return cycleUsageDisplayMode(item, [], true, true);
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
@@ -79,7 +82,7 @@ export class ExtraUsageUtilizationWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Overage: ', sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, 'Overage: ', `${previewPercent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, 'Overage: ', `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
const data = context.usageData ?? {};
|
||||
@@ -110,11 +113,11 @@ export class ExtraUsageUtilizationWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Overage: ', sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, 'Overage: ', `${percent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, 'Overage: ', `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
return [...getUsagePercentCustomKeybinds(item), getHideExtraUsageDisabledKeybind()];
|
||||
return [...getUsagePercentCustomKeybinds(item, false), getHideExtraUsageDisabledKeybind()];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
handleToggleNoGitAction,
|
||||
isHideNoGitEnabled
|
||||
} from './shared/git-no-git';
|
||||
import {
|
||||
MAX_WIDTH_ACTION,
|
||||
applyMaxWidth,
|
||||
getMaxWidthKeybind,
|
||||
getMaxWidthModifier,
|
||||
renderMaxWidthEditor
|
||||
} from './shared/max-width';
|
||||
import { isMetadataFlagEnabled } from './shared/metadata';
|
||||
import {
|
||||
formatSymbolPrefix,
|
||||
@@ -78,6 +85,9 @@ export class GitBranchWidget implements Widget {
|
||||
modifiers.push('hide \'no git\'');
|
||||
if (isLink)
|
||||
modifiers.push('repo link');
|
||||
const maxWidthText = getMaxWidthModifier(item);
|
||||
if (maxWidthText)
|
||||
modifiers.push(maxWidthText);
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: makeModifierText(modifiers)
|
||||
@@ -111,7 +121,7 @@ export class GitBranchWidget implements Widget {
|
||||
return hideNoGit ? null : `${prefix}no git`;
|
||||
}
|
||||
|
||||
const displayText = item.rawValue ? branch : `${prefix}${branch}`;
|
||||
const displayText = applyMaxWidth(item.rawValue ? branch : `${prefix}${branch}`, item.maxWidth);
|
||||
|
||||
if (isLink) {
|
||||
const origin = getRemoteInfo('origin', context);
|
||||
@@ -134,11 +144,15 @@ export class GitBranchWidget implements Widget {
|
||||
return [
|
||||
...getHideNoGitKeybinds(),
|
||||
{ key: 'l', label: '(l)ink to repo', action: TOGGLE_LINK_ACTION },
|
||||
getMaxWidthKeybind(),
|
||||
getSymbolKeybind()
|
||||
];
|
||||
}
|
||||
|
||||
renderEditor(props: WidgetEditorProps) {
|
||||
if (props.action === MAX_WIDTH_ACTION) {
|
||||
return renderMaxWidthEditor(props);
|
||||
}
|
||||
return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
isInsideGitWorkTree,
|
||||
resolveGitCwd
|
||||
} from '../utils/git';
|
||||
import type { GitCiChecks } from '../utils/git-review-cache';
|
||||
import { getCachedGitReviewData } from '../utils/git-review-cache';
|
||||
|
||||
import {
|
||||
getHideNoGitKeybinds,
|
||||
getHideNoGitModifierText,
|
||||
handleToggleNoGitAction,
|
||||
isHideNoGitEnabled
|
||||
} from './shared/git-no-git';
|
||||
|
||||
const NO_CHECKS = '-';
|
||||
const SYMBOLS = {
|
||||
success: '✓',
|
||||
passing: '✓',
|
||||
failing: '✗',
|
||||
pending: '●'
|
||||
} as const;
|
||||
|
||||
export interface GitCiStatusWidgetDeps {
|
||||
getCachedGitReviewData: typeof getCachedGitReviewData;
|
||||
getProcessCwd: typeof process.cwd;
|
||||
isInsideGitWorkTree: typeof isInsideGitWorkTree;
|
||||
resolveGitCwd: typeof resolveGitCwd;
|
||||
}
|
||||
|
||||
const DEFAULT_DEPS: GitCiStatusWidgetDeps = {
|
||||
getCachedGitReviewData,
|
||||
getProcessCwd: () => process.cwd(),
|
||||
isInsideGitWorkTree,
|
||||
resolveGitCwd
|
||||
};
|
||||
|
||||
const PREVIEW_CHECKS: GitCiChecks = {
|
||||
state: 'failing',
|
||||
failing: 1,
|
||||
pending: 1,
|
||||
success: 5
|
||||
};
|
||||
|
||||
function buildDisplay(checks: GitCiChecks, rawValue: boolean): string {
|
||||
if (rawValue)
|
||||
return checks.state;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (checks.failing > 0)
|
||||
parts.push(`${SYMBOLS.failing}${checks.failing}`);
|
||||
if (checks.pending > 0)
|
||||
parts.push(`${SYMBOLS.pending}${checks.pending}`);
|
||||
if (checks.success > 0)
|
||||
parts.push(`${SYMBOLS.success}${checks.success}`);
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : `${SYMBOLS[checks.state]}0`;
|
||||
}
|
||||
|
||||
export class GitCiStatusWidget implements Widget {
|
||||
constructor(private readonly deps: GitCiStatusWidgetDeps = DEFAULT_DEPS) {}
|
||||
|
||||
getDefaultColor(): string {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Shows CI check status for the current branch\'s PR (GitHub only)';
|
||||
}
|
||||
|
||||
getDisplayName(): string {
|
||||
return 'Git CI Status';
|
||||
}
|
||||
|
||||
getCategory(): string {
|
||||
return 'Git';
|
||||
}
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: getHideNoGitModifierText(item)
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
return handleToggleNoGitAction(action, item);
|
||||
}
|
||||
|
||||
render(
|
||||
item: WidgetItem,
|
||||
context: RenderContext,
|
||||
_settings: Settings
|
||||
): string | null {
|
||||
const rawValue = item.rawValue ?? false;
|
||||
|
||||
if (context.isPreview) {
|
||||
return buildDisplay(PREVIEW_CHECKS, rawValue);
|
||||
}
|
||||
|
||||
if (!this.deps.isInsideGitWorkTree(context)) {
|
||||
return isHideNoGitEnabled(item) ? null : '(no git)';
|
||||
}
|
||||
|
||||
const cwd = this.deps.resolveGitCwd(context) ?? this.deps.getProcessCwd();
|
||||
const checks = this.deps.getCachedGitReviewData(cwd, { includeChecks: true })?.checks;
|
||||
if (!checks) {
|
||||
return NO_CHECKS;
|
||||
}
|
||||
|
||||
return buildDisplay(checks, rawValue);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return getHideNoGitKeybinds();
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
supportsColors(_item: WidgetItem): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { getRemoteInfo } from '../utils/git-remote';
|
||||
import type { GitReviewData } from '../utils/git-review-cache';
|
||||
import {
|
||||
fetchGitReviewData,
|
||||
getCachedGitReviewData,
|
||||
getGitReviewStatusLabel,
|
||||
truncateTitle
|
||||
} from '../utils/git-review-cache';
|
||||
@@ -37,7 +37,7 @@ const TOGGLE_STATUS_ACTION = 'toggle-status';
|
||||
const TOGGLE_TITLE_ACTION = 'toggle-title';
|
||||
|
||||
export interface GitPrWidgetDeps {
|
||||
fetchGitReviewData: typeof fetchGitReviewData;
|
||||
getCachedGitReviewData: typeof getCachedGitReviewData;
|
||||
getProcessCwd: typeof process.cwd;
|
||||
getRemoteInfo: typeof getRemoteInfo;
|
||||
isInsideGitWorkTree: typeof isInsideGitWorkTree;
|
||||
@@ -45,7 +45,7 @@ export interface GitPrWidgetDeps {
|
||||
}
|
||||
|
||||
const DEFAULT_GIT_PR_WIDGET_DEPS: GitPrWidgetDeps = {
|
||||
fetchGitReviewData,
|
||||
getCachedGitReviewData,
|
||||
getProcessCwd: () => process.cwd(),
|
||||
getRemoteInfo,
|
||||
isInsideGitWorkTree,
|
||||
@@ -153,7 +153,7 @@ export class GitPrWidget implements Widget {
|
||||
}
|
||||
|
||||
const cwd = this.deps.resolveGitCwd(context) ?? this.deps.getProcessCwd();
|
||||
const prData = this.deps.fetchGitReviewData(cwd);
|
||||
const prData = this.deps.getCachedGitReviewData(cwd, { includeChecks: context.gitReviewNeedsChecks ?? false });
|
||||
if (!prData) {
|
||||
return hideNoGit ? null : `(no ${resolvePrNoun(null, context, this.deps)})`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetEditorProps,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
@@ -24,6 +25,13 @@ import {
|
||||
handleToggleNoGitAction,
|
||||
isHideNoGitEnabled
|
||||
} from './shared/git-no-git';
|
||||
import {
|
||||
MAX_WIDTH_ACTION,
|
||||
applyMaxWidth,
|
||||
getMaxWidthKeybind,
|
||||
getMaxWidthModifier,
|
||||
renderMaxWidthEditor
|
||||
} from './shared/max-width';
|
||||
import { isMetadataFlagEnabled } from './shared/metadata';
|
||||
|
||||
const IDE_LINK_KEY = 'linkToIDE';
|
||||
@@ -47,6 +55,9 @@ export class GitRootDirWidget implements Widget {
|
||||
modifiers.push('hide \'no git\'');
|
||||
if (ideLinkMode)
|
||||
modifiers.push(IDE_LINK_LABELS[ideLinkMode]);
|
||||
const maxWidthText = getMaxWidthModifier(item);
|
||||
if (maxWidthText)
|
||||
modifiers.push(maxWidthText);
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: makeModifierText(modifiers)
|
||||
@@ -78,7 +89,7 @@ export class GitRootDirWidget implements Widget {
|
||||
return hideNoGit ? null : 'no git';
|
||||
}
|
||||
|
||||
const name = this.getRootDirName(rootDir);
|
||||
const name = applyMaxWidth(this.getRootDirName(rootDir), item.maxWidth);
|
||||
|
||||
if (ideLinkMode) {
|
||||
return renderOsc8Link(buildIdeFileUrl(rootDir, ideLinkMode), name);
|
||||
@@ -102,10 +113,18 @@ export class GitRootDirWidget implements Widget {
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
...getHideNoGitKeybinds(),
|
||||
{ key: 'l', label: '(l)ink to IDE', action: TOGGLE_LINK_ACTION }
|
||||
{ key: 'l', label: '(l)ink to IDE', action: TOGGLE_LINK_ACTION },
|
||||
getMaxWidthKeybind()
|
||||
];
|
||||
}
|
||||
|
||||
renderEditor(props: WidgetEditorProps) {
|
||||
if (props.action === MAX_WIDTH_ACTION) {
|
||||
return renderMaxWidthEditor(props);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return false; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
|
||||
|
||||
@@ -31,31 +31,54 @@ function getFormat(item: WidgetItem): RemoteFormat {
|
||||
return (FORMATS as readonly string[]).includes(f ?? '') ? (f as RemoteFormat) : DEFAULT_FORMAT;
|
||||
}
|
||||
|
||||
function canUseNerdFont(item: WidgetItem): boolean {
|
||||
const format = getFormat(item);
|
||||
return format === 'icon' || (format === 'icon-text' && !item.rawValue);
|
||||
}
|
||||
|
||||
function removeNerdFont(item: WidgetItem): WidgetItem {
|
||||
const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {};
|
||||
void removedNerdFont;
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function setFormat(item: WidgetItem, format: RemoteFormat): WidgetItem {
|
||||
let updatedItem: WidgetItem;
|
||||
|
||||
if (format === DEFAULT_FORMAT) {
|
||||
const { format: removedFormat, ...restMetadata } = item.metadata ?? {};
|
||||
void removedFormat;
|
||||
|
||||
return {
|
||||
updatedItem = {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
} else {
|
||||
updatedItem = {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
format
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
format
|
||||
}
|
||||
};
|
||||
return canUseNerdFont(updatedItem) ? updatedItem : removeNerdFont(updatedItem);
|
||||
}
|
||||
|
||||
function isNerdFontEnabled(item: WidgetItem): boolean {
|
||||
return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true';
|
||||
return canUseNerdFont(item) && item.metadata?.[NERD_FONT_METADATA_KEY] === 'true';
|
||||
}
|
||||
|
||||
function toggleNerdFont(item: WidgetItem): WidgetItem {
|
||||
if (!canUseNerdFont(item)) {
|
||||
return removeNerdFont(item);
|
||||
}
|
||||
|
||||
if (!isNerdFontEnabled(item)) {
|
||||
return {
|
||||
...item,
|
||||
@@ -66,16 +89,10 @@ function toggleNerdFont(item: WidgetItem): WidgetItem {
|
||||
};
|
||||
}
|
||||
|
||||
const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {};
|
||||
void removedNerdFont;
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
return removeNerdFont(item);
|
||||
}
|
||||
|
||||
function formatStatus(enabled: boolean, format: RemoteFormat, nerdFont: boolean): string {
|
||||
function formatStatus(enabled: boolean, format: RemoteFormat, nerdFont: boolean, rawValue: boolean): string {
|
||||
const stateText = enabled ? 'on' : 'off';
|
||||
const stateDot = enabled ? STATE_DOT_ON : STATE_DOT_OFF;
|
||||
const icon = nerdFont
|
||||
@@ -84,17 +101,17 @@ function formatStatus(enabled: boolean, format: RemoteFormat, nerdFont: boolean)
|
||||
|
||||
switch (format) {
|
||||
case 'icon':
|
||||
return nerdFont ? icon : `${icon} ${stateDot}`;
|
||||
return nerdFont ? icon : (rawValue ? stateDot : `${icon} ${stateDot}`);
|
||||
case 'icon-text':
|
||||
return `${icon} ${stateText}`;
|
||||
return rawValue ? stateText : `${icon} ${stateText}`;
|
||||
case 'text':
|
||||
return stateText;
|
||||
case 'word':
|
||||
return `remote ${stateText}`;
|
||||
return rawValue ? stateText : `remote ${stateText}`;
|
||||
case 'label-check':
|
||||
return `remote ${enabled ? CHECK_EMOJI : CROSS_EMOJI}`;
|
||||
return rawValue ? (enabled ? CHECK_EMOJI : CROSS_EMOJI) : `remote ${enabled ? CHECK_EMOJI : CROSS_EMOJI}`;
|
||||
case 'label-mark':
|
||||
return `remote ${enabled ? CHECK_MARK : CROSS_MARK}`;
|
||||
return rawValue ? (enabled ? CHECK_MARK : CROSS_MARK) : `remote ${enabled ? CHECK_MARK : CROSS_MARK}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,10 +153,7 @@ export class RemoteControlStatusWidget implements Widget {
|
||||
const nerdFont = isNerdFontEnabled(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
if (item.rawValue) {
|
||||
return 'on';
|
||||
}
|
||||
return formatStatus(true, format, nerdFont);
|
||||
return formatStatus(true, format, nerdFont, item.rawValue ?? false);
|
||||
}
|
||||
|
||||
const status = getRemoteControlStatus(context.data?.session_id);
|
||||
@@ -147,18 +161,17 @@ export class RemoteControlStatusWidget implements Widget {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.rawValue) {
|
||||
return status.enabled ? 'on' : 'off';
|
||||
}
|
||||
|
||||
return formatStatus(status.enabled, format, nerdFont);
|
||||
return formatStatus(status.enabled, format, nerdFont, item.rawValue ?? false);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION },
|
||||
{ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION }
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
const keybinds: CustomKeybind[] = [
|
||||
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }
|
||||
];
|
||||
if (item === undefined || canUseNerdFont(item)) {
|
||||
keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION });
|
||||
}
|
||||
return keybinds;
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import { getSandboxConfig } from '../utils/claude-settings';
|
||||
|
||||
const DOT_ON = '●';
|
||||
const DOT_OFF = '○';
|
||||
const LOCK_NERD_FONT = '';
|
||||
const UNLOCK_NERD_FONT = '';
|
||||
|
||||
const FORMATS = ['glyph', 'text', 'word'] as const;
|
||||
type SandboxFormat = typeof FORMATS[number];
|
||||
|
||||
const DEFAULT_FORMAT: SandboxFormat = 'glyph';
|
||||
const CYCLE_FORMAT_ACTION = 'cycle-format';
|
||||
const TOGGLE_NERD_FONT_ACTION = 'toggle-nerd-font';
|
||||
const NERD_FONT_METADATA_KEY = 'nerdFont';
|
||||
|
||||
function getFormat(item: WidgetItem): SandboxFormat {
|
||||
const f = item.metadata?.format;
|
||||
return (FORMATS as readonly string[]).includes(f ?? '') ? (f as SandboxFormat) : DEFAULT_FORMAT;
|
||||
}
|
||||
|
||||
function canUseNerdFont(item: WidgetItem): boolean {
|
||||
return getFormat(item) === 'glyph';
|
||||
}
|
||||
|
||||
function removeNerdFont(item: WidgetItem): WidgetItem {
|
||||
const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {};
|
||||
void removedNerdFont;
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function setFormat(item: WidgetItem, format: SandboxFormat): WidgetItem {
|
||||
let updatedItem: WidgetItem;
|
||||
|
||||
if (format === DEFAULT_FORMAT) {
|
||||
const { format: removedFormat, ...restMetadata } = item.metadata ?? {};
|
||||
void removedFormat;
|
||||
|
||||
updatedItem = {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
} else {
|
||||
updatedItem = {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
format
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return canUseNerdFont(updatedItem) ? updatedItem : removeNerdFont(updatedItem);
|
||||
}
|
||||
|
||||
function isNerdFontEnabled(item: WidgetItem): boolean {
|
||||
return canUseNerdFont(item) && item.metadata?.[NERD_FONT_METADATA_KEY] === 'true';
|
||||
}
|
||||
|
||||
function toggleNerdFont(item: WidgetItem): WidgetItem {
|
||||
if (!canUseNerdFont(item)) {
|
||||
return removeNerdFont(item);
|
||||
}
|
||||
|
||||
if (!isNerdFontEnabled(item)) {
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
[NERD_FONT_METADATA_KEY]: 'true'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return removeNerdFont(item);
|
||||
}
|
||||
|
||||
function formatStatus(enabled: boolean, format: SandboxFormat, nerdFont: boolean, rawValue: boolean): string {
|
||||
const stateText = enabled ? 'ON' : 'OFF';
|
||||
const glyph = nerdFont
|
||||
? (enabled ? LOCK_NERD_FONT : UNLOCK_NERD_FONT)
|
||||
: (enabled ? DOT_ON : DOT_OFF);
|
||||
|
||||
switch (format) {
|
||||
case 'glyph':
|
||||
return rawValue ? glyph : `SB: ${glyph}`;
|
||||
case 'text':
|
||||
return rawValue ? stateText : `SB: ${stateText}`;
|
||||
case 'word':
|
||||
return rawValue ? stateText : `Sandbox: ${stateText}`;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSandboxConfigCwd(context: RenderContext): string | undefined {
|
||||
const candidates = [
|
||||
context.data?.workspace?.project_dir,
|
||||
context.data?.cwd,
|
||||
context.data?.workspace?.current_dir
|
||||
];
|
||||
|
||||
return candidates.find(candidate => typeof candidate === 'string' && candidate.trim().length > 0);
|
||||
}
|
||||
|
||||
export class SandboxStatusWidget implements Widget {
|
||||
getDefaultColor(): string { return 'green'; }
|
||||
getDescription(): string {
|
||||
return [
|
||||
'Shows whether Claude Code bash sandbox mode is enabled',
|
||||
'Best effort: may not reflect active sandboxing when managed or CLI settings override it, or when sandbox initialization fails.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
getDisplayName(): string { return 'Sandbox Status'; }
|
||||
getCategory(): string { return 'Core'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const modifiers: string[] = [getFormat(item)];
|
||||
if (isNerdFontEnabled(item)) {
|
||||
modifiers.push('nerd font');
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: `(${modifiers.join(', ')})`
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === CYCLE_FORMAT_ACTION) {
|
||||
const currentFormat = getFormat(item);
|
||||
const nextFormat = FORMATS[(FORMATS.indexOf(currentFormat) + 1) % FORMATS.length] ?? DEFAULT_FORMAT;
|
||||
|
||||
return setFormat(item, nextFormat);
|
||||
}
|
||||
|
||||
if (action === TOGGLE_NERD_FONT_ACTION) {
|
||||
return toggleNerdFont(item);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
|
||||
const format = getFormat(item);
|
||||
const nerdFont = isNerdFontEnabled(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
return formatStatus(true, format, nerdFont, item.rawValue ?? false);
|
||||
}
|
||||
|
||||
const config = getSandboxConfig(resolveSandboxConfigCwd(context));
|
||||
if (config === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatStatus(config.enabled, format, nerdFont, item.rawValue ?? false);
|
||||
}
|
||||
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
const keybinds: CustomKeybind[] = [
|
||||
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }
|
||||
];
|
||||
if (item === undefined || canUseNerdFont(item)) {
|
||||
keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION });
|
||||
}
|
||||
return keybinds;
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(_item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -37,13 +37,13 @@ export class SessionUsageWidget implements Widget {
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: getUsageDisplayModifierText(item)
|
||||
modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true })
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
return cycleUsageDisplayMode(item, [], true);
|
||||
return cycleUsageDisplayMode(item, [], true, true);
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
@@ -79,7 +79,7 @@ export class SessionUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Session: ', sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, 'Session: ', `${previewPercent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, 'Session: ', `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
const data = context.usageData ?? {};
|
||||
@@ -114,7 +114,7 @@ export class SessionUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Session: ', sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, 'Session: ', `${percent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, 'Session: ', `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
|
||||
@@ -55,7 +55,7 @@ function formatEffort(resolved: ResolvedThinkingEffort | null): string {
|
||||
|
||||
export class ThinkingEffortWidget implements Widget {
|
||||
getDefaultColor(): string { return 'magenta'; }
|
||||
getDescription(): string { return 'Displays the current thinking effort level (low, medium, high, xhigh, max).\nUnknown levels are shown with a trailing "?" (e.g. "super-max?").\nMay be incorrect when multiple Claude Code sessions are running due to current Claude Code limitations.'; }
|
||||
getDescription(): string { return 'Displays the current thinking effort level (low, medium, high, xhigh, max).\nClaude Code reports Ultracode as xhigh in status line data; Ultracode is not exposed as a separate effort level.\nUnknown levels are shown with a trailing "?" (e.g. "super-max?").\nMay be incorrect when multiple Claude Code sessions are running due to current Claude Code limitations.'; }
|
||||
getDisplayName(): string { return 'Thinking Effort'; }
|
||||
getCategory(): string { return 'Core'; }
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
|
||||
+40
-20
@@ -23,31 +23,54 @@ function getFormat(item: WidgetItem): VimFormat {
|
||||
return (FORMATS as readonly string[]).includes(f ?? '') ? (f as VimFormat) : DEFAULT_FORMAT;
|
||||
}
|
||||
|
||||
function canUseNerdFont(item: WidgetItem): boolean {
|
||||
const format = getFormat(item);
|
||||
return format === 'icon-dash-letter' || format === 'icon-letter' || format === 'icon';
|
||||
}
|
||||
|
||||
function removeNerdFont(item: WidgetItem): WidgetItem {
|
||||
const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {};
|
||||
void removedNerdFont;
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function setFormat(item: WidgetItem, format: VimFormat): WidgetItem {
|
||||
let updatedItem: WidgetItem;
|
||||
|
||||
if (format === DEFAULT_FORMAT) {
|
||||
const { format: removedFormat, ...restMetadata } = item.metadata ?? {};
|
||||
void removedFormat;
|
||||
|
||||
return {
|
||||
updatedItem = {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
} else {
|
||||
updatedItem = {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
format
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
format
|
||||
}
|
||||
};
|
||||
return canUseNerdFont(updatedItem) ? updatedItem : removeNerdFont(updatedItem);
|
||||
}
|
||||
|
||||
function isNerdFontEnabled(item: WidgetItem): boolean {
|
||||
return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true';
|
||||
return canUseNerdFont(item) && item.metadata?.[NERD_FONT_METADATA_KEY] === 'true';
|
||||
}
|
||||
|
||||
function toggleNerdFont(item: WidgetItem): WidgetItem {
|
||||
if (!canUseNerdFont(item)) {
|
||||
return removeNerdFont(item);
|
||||
}
|
||||
|
||||
if (!isNerdFontEnabled(item)) {
|
||||
return {
|
||||
...item,
|
||||
@@ -58,13 +81,7 @@ function toggleNerdFont(item: WidgetItem): WidgetItem {
|
||||
};
|
||||
}
|
||||
|
||||
const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {};
|
||||
void removedNerdFont;
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
return removeNerdFont(item);
|
||||
}
|
||||
|
||||
function formatMode(mode: string, format: VimFormat, icon: string): string {
|
||||
@@ -125,11 +142,14 @@ export class VimModeWidget implements Widget {
|
||||
return formatMode(mode, format, icon);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION },
|
||||
{ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION }
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
const keybinds: CustomKeybind[] = [
|
||||
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }
|
||||
];
|
||||
if (item === undefined || canUseNerdFont(item)) {
|
||||
keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION });
|
||||
}
|
||||
return keybinds;
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return false; }
|
||||
|
||||
+46
-33
@@ -27,31 +27,54 @@ function getFormat(item: WidgetItem): VoiceFormat {
|
||||
return (FORMATS as readonly string[]).includes(f ?? '') ? (f as VoiceFormat) : DEFAULT_FORMAT;
|
||||
}
|
||||
|
||||
function canUseNerdFont(item: WidgetItem): boolean {
|
||||
const format = getFormat(item);
|
||||
return format === 'icon' || (format === 'icon-text' && !item.rawValue);
|
||||
}
|
||||
|
||||
function removeNerdFont(item: WidgetItem): WidgetItem {
|
||||
const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {};
|
||||
void removedNerdFont;
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function setFormat(item: WidgetItem, format: VoiceFormat): WidgetItem {
|
||||
let updatedItem: WidgetItem;
|
||||
|
||||
if (format === DEFAULT_FORMAT) {
|
||||
const { format: removedFormat, ...restMetadata } = item.metadata ?? {};
|
||||
void removedFormat;
|
||||
|
||||
return {
|
||||
updatedItem = {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
} else {
|
||||
updatedItem = {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
format
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
format
|
||||
}
|
||||
};
|
||||
return canUseNerdFont(updatedItem) ? updatedItem : removeNerdFont(updatedItem);
|
||||
}
|
||||
|
||||
function isNerdFontEnabled(item: WidgetItem): boolean {
|
||||
return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true';
|
||||
return canUseNerdFont(item) && item.metadata?.[NERD_FONT_METADATA_KEY] === 'true';
|
||||
}
|
||||
|
||||
function toggleNerdFont(item: WidgetItem): WidgetItem {
|
||||
if (!canUseNerdFont(item)) {
|
||||
return removeNerdFont(item);
|
||||
}
|
||||
|
||||
if (!isNerdFontEnabled(item)) {
|
||||
return {
|
||||
...item,
|
||||
@@ -62,16 +85,10 @@ function toggleNerdFont(item: WidgetItem): WidgetItem {
|
||||
};
|
||||
}
|
||||
|
||||
const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {};
|
||||
void removedNerdFont;
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
||||
};
|
||||
return removeNerdFont(item);
|
||||
}
|
||||
|
||||
function formatStatus(enabled: boolean, format: VoiceFormat, nerdFont: boolean): string {
|
||||
function formatStatus(enabled: boolean, format: VoiceFormat, nerdFont: boolean, rawValue: boolean): string {
|
||||
const stateText = enabled ? 'on' : 'off';
|
||||
const stateDot = enabled ? STATE_DOT_ON : STATE_DOT_OFF;
|
||||
const icon = nerdFont
|
||||
@@ -80,13 +97,13 @@ function formatStatus(enabled: boolean, format: VoiceFormat, nerdFont: boolean):
|
||||
|
||||
switch (format) {
|
||||
case 'icon':
|
||||
return nerdFont ? icon : `${icon} ${stateDot}`;
|
||||
return nerdFont ? icon : (rawValue ? stateDot : `${icon} ${stateDot}`);
|
||||
case 'icon-text':
|
||||
return `${icon} ${stateText}`;
|
||||
return rawValue ? stateText : `${icon} ${stateText}`;
|
||||
case 'text':
|
||||
return stateText;
|
||||
case 'word':
|
||||
return `voice ${stateText}`;
|
||||
return rawValue ? stateText : `voice ${stateText}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +155,7 @@ export class VoiceStatusWidget implements Widget {
|
||||
const nerdFont = isNerdFontEnabled(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
if (item.rawValue) {
|
||||
return 'on';
|
||||
}
|
||||
return formatStatus(true, format, nerdFont);
|
||||
return formatStatus(true, format, nerdFont, item.rawValue ?? false);
|
||||
}
|
||||
|
||||
const config = getVoiceConfig(resolveVoiceConfigCwd(context));
|
||||
@@ -149,18 +163,17 @@ export class VoiceStatusWidget implements Widget {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.rawValue) {
|
||||
return config.enabled ? 'on' : 'off';
|
||||
}
|
||||
|
||||
return formatStatus(config.enabled, format, nerdFont);
|
||||
return formatStatus(config.enabled, format, nerdFont, item.rawValue ?? false);
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION },
|
||||
{ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION }
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
const keybinds: CustomKeybind[] = [
|
||||
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }
|
||||
];
|
||||
if (item === undefined || canUseNerdFont(item)) {
|
||||
keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION });
|
||||
}
|
||||
return keybinds;
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
|
||||
@@ -39,13 +39,13 @@ export class WeeklyOpusUsageWidget implements Widget {
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: getUsageDisplayModifierText(item)
|
||||
modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true })
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
return cycleUsageDisplayMode(item, [], true);
|
||||
return cycleUsageDisplayMode(item, [], true, true);
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
@@ -81,7 +81,7 @@ export class WeeklyOpusUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, LABEL, sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, LABEL, `${previewPercent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
const data = context.usageData ?? {};
|
||||
@@ -116,7 +116,7 @@ export class WeeklyOpusUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, LABEL, sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, LABEL, `${percent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
|
||||
@@ -63,6 +63,7 @@ function makeTimerProgressBar(percent: number, width: number): string {
|
||||
|
||||
const WEEKLY_PREVIEW_DURATION_MS = 36.5 * 60 * 60 * 1000;
|
||||
const WEEKLY_RESET_PREVIEW_AT = '2026-03-15T08:30:00.000Z';
|
||||
const USAGE_TIMER_LOADING_MESSAGE = '[Loading]';
|
||||
|
||||
function isWeeklyResetHoursOnly(item: WidgetItem): boolean {
|
||||
return isMetadataFlagEnabled(item, 'hours');
|
||||
@@ -221,7 +222,7 @@ export class WeeklyResetTimerWidget implements Widget {
|
||||
return getUsageErrorMessage(usageData.error);
|
||||
}
|
||||
|
||||
return null;
|
||||
return formatRawOrLabeledValue(item, 'Weekly Reset: ', USAGE_TIMER_LOADING_MESSAGE);
|
||||
}
|
||||
|
||||
if (isUsageProgressMode(displayMode)) {
|
||||
|
||||
@@ -39,13 +39,13 @@ export class WeeklySonnetUsageWidget implements Widget {
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: getUsageDisplayModifierText(item)
|
||||
modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true })
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
return cycleUsageDisplayMode(item, [], true);
|
||||
return cycleUsageDisplayMode(item, [], true, true);
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
@@ -81,7 +81,7 @@ export class WeeklySonnetUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, LABEL, sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, LABEL, `${previewPercent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
const data = context.usageData ?? {};
|
||||
@@ -116,7 +116,7 @@ export class WeeklySonnetUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, LABEL, sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, LABEL, `${percent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
|
||||
@@ -37,13 +37,13 @@ export class WeeklyUsageWidget implements Widget {
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: getUsageDisplayModifierText(item)
|
||||
modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true })
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
return cycleUsageDisplayMode(item, [], true);
|
||||
return cycleUsageDisplayMode(item, [], true, true);
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
@@ -79,7 +79,7 @@ export class WeeklyUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Weekly: ', sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, 'Weekly: ', `${previewPercent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, 'Weekly: ', `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
const data = context.usageData ?? {};
|
||||
@@ -114,7 +114,7 @@ export class WeeklyUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Weekly: ', sliderDisplay);
|
||||
}
|
||||
|
||||
return formatRawOrLabeledValue(item, 'Weekly: ', `${percent.toFixed(1)}%`);
|
||||
return formatRawOrLabeledValue(item, 'Weekly: ', `${renderedPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
|
||||
|
||||
@@ -90,12 +90,13 @@ describe('BlockResetTimerWidget', () => {
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]');
|
||||
});
|
||||
|
||||
it('returns null when neither timer data nor usage error exists', () => {
|
||||
it('shows loading when neither timer data nor usage error exists', () => {
|
||||
const widget = new BlockResetTimerWidget();
|
||||
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue(null);
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: {} })).toBeNull();
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: {} })).toBe('Reset: [Loading]');
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true }, { usageData: {} })).toBe('[Loading]');
|
||||
});
|
||||
|
||||
it('shows raw value without label in time mode', () => {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { CacheTimerWidget } from '../CacheTimer';
|
||||
|
||||
const item = (extra: Partial<WidgetItem> = {}): WidgetItem => ({ id: 'cache-timer', type: 'cache-timer', ...extra });
|
||||
const hidden: Partial<WidgetItem> = { metadata: { hideWhenEmpty: 'true' } };
|
||||
|
||||
const isoAgo = (seconds: number): string => new Date(Date.now() - seconds * 1000).toISOString();
|
||||
const assistant = (seconds: number): string => JSON.stringify({ type: 'assistant', timestamp: isoAgo(seconds) });
|
||||
const pendingUser = JSON.stringify({ type: 'user' });
|
||||
const sidechain = (type: string, seconds: number): string => JSON.stringify({ type, timestamp: isoAgo(seconds), isSidechain: true });
|
||||
const apiError = (seconds: number): string => JSON.stringify({ type: 'assistant', timestamp: isoAgo(seconds), isApiErrorMessage: true });
|
||||
const assistantUsage = (seconds: number, usage: object): string => JSON.stringify({ type: 'assistant', timestamp: isoAgo(seconds), message: { usage } });
|
||||
const noCacheUsage = { cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
||||
|
||||
describe('CacheTimer widget', () => {
|
||||
let tmpDir: string;
|
||||
let fileCounter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-cache-timer-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const transcriptContext = (lines: string[]): RenderContext => {
|
||||
const file = path.join(tmpDir, `transcript-${++fileCounter}.jsonl`);
|
||||
fs.writeFileSync(file, lines.join('\n'), 'utf8');
|
||||
return { data: { transcript_path: file } };
|
||||
};
|
||||
|
||||
it('renders the preview as a labeled or raw sample', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item(), { isPreview: true }, DEFAULT_SETTINGS)).toBe('Cache: 🟢 4:52');
|
||||
expect(widget.render(item({ rawValue: true }), { isPreview: true }, DEFAULT_SETTINGS)).toBe('🟢 4:52');
|
||||
});
|
||||
|
||||
it('renders n/a when no transcript is available by default', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item(), {}, DEFAULT_SETTINGS)).toBe('Cache: n/a');
|
||||
expect(widget.render(item({ rawValue: true }), {}, DEFAULT_SETTINGS)).toBe('n/a');
|
||||
});
|
||||
|
||||
it('hides the widget when there is no data and hide-when-empty is enabled', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item(hidden), {}, DEFAULT_SETTINGS)).toBeNull();
|
||||
expect(widget.render(item(hidden), transcriptContext([]), DEFAULT_SETTINGS)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders n/a for an empty transcript by default', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item(), transcriptContext([]), DEFAULT_SETTINGS)).toBe('Cache: n/a');
|
||||
});
|
||||
|
||||
it('shows HOT while a turn is in flight, regardless of hide-when-empty', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
const context = transcriptContext([assistant(60), pendingUser]);
|
||||
expect(widget.render(item(), context, DEFAULT_SETTINGS)).toBe('Cache: 🔥 HOT');
|
||||
expect(widget.render(item(hidden), context, DEFAULT_SETTINGS)).toBe('Cache: 🔥 HOT');
|
||||
});
|
||||
|
||||
const buckets = [
|
||||
{ label: 'fresh', elapsed: 10, icon: '🟢' },
|
||||
{ label: 'draining', elapsed: 180, icon: '🟡' },
|
||||
{ label: 'almost cold', elapsed: 260, icon: '🔴' }
|
||||
];
|
||||
for (const { label, elapsed, icon } of buckets) {
|
||||
it(`renders the ${label} countdown with the ${icon} icon`, () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
const out = widget.render(item(), transcriptContext([assistant(elapsed)]), DEFAULT_SETTINGS);
|
||||
expect(out).toMatch(new RegExp(`^Cache: ${icon} \\d+:\\d{2}$`));
|
||||
});
|
||||
}
|
||||
|
||||
it('renders COLD once the TTL has elapsed', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item(), transcriptContext([assistant(400)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
|
||||
});
|
||||
|
||||
it('renders a raw countdown without the label', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
const out = widget.render(item({ rawValue: true }), transcriptContext([assistant(10)]), DEFAULT_SETTINGS);
|
||||
expect(out).toMatch(/^🟢 \d+:\d{2}$/);
|
||||
});
|
||||
|
||||
it('ignores sidechain rows when deriving the cache state', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
// A trailing sidechain user row must not report HOT...
|
||||
expect(widget.render(item(), transcriptContext([assistant(400), sidechain('user', 5)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
|
||||
// ...and a trailing sidechain assistant row must not restart the countdown.
|
||||
expect(widget.render(item(), transcriptContext([assistant(400), sidechain('assistant', 5)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
|
||||
});
|
||||
|
||||
it('ignores synthetic API-error rows when deriving the cache state', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
// A failed request refreshes nothing, so the prior event still drives the countdown...
|
||||
expect(widget.render(item(), transcriptContext([assistant(400), apiError(5)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
|
||||
// ...and with no prior main-chain row there is no cache event to report.
|
||||
expect(widget.render(item(), transcriptContext([apiError(5)]), DEFAULT_SETTINGS)).toBe('Cache: n/a');
|
||||
});
|
||||
|
||||
it('skips assistant rows whose request had no cache activity', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
// The prior row that actually touched the cache still drives the countdown...
|
||||
const cached = assistantUsage(400, { cache_read_input_tokens: 100, cache_creation_input_tokens: 0 });
|
||||
expect(widget.render(item(), transcriptContext([cached, assistantUsage(10, noCacheUsage)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
|
||||
// ...and when caching never happened at all there is nothing to count down.
|
||||
expect(widget.render(item(), transcriptContext([assistantUsage(10, noCacheUsage)]), DEFAULT_SETTINGS)).toBe('Cache: n/a');
|
||||
});
|
||||
|
||||
it('does not report HOT for a finished turn whose response had no cache activity', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
// The user row that started the turn precedes the zero-cache response,
|
||||
// as in a real transcript; the finished turn must not read as in-flight.
|
||||
expect(widget.render(item(), transcriptContext([pendingUser, assistantUsage(10, noCacheUsage)]), DEFAULT_SETTINGS)).toBe('Cache: n/a');
|
||||
// An older cache event still drives the countdown instead.
|
||||
const cached = assistantUsage(400, { cache_read_input_tokens: 100, cache_creation_input_tokens: 0 });
|
||||
expect(widget.render(item(), transcriptContext([cached, pendingUser, assistantUsage(10, noCacheUsage)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
|
||||
});
|
||||
|
||||
it('does not report HOT for a turn that ended in an API error', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item(), transcriptContext([pendingUser, apiError(5)]), DEFAULT_SETTINGS)).toBe('Cache: n/a');
|
||||
expect(widget.render(item(), transcriptContext([assistant(400), pendingUser, apiError(5)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
|
||||
});
|
||||
|
||||
it('starts the countdown from rows with cache reads or cache writes', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item(), transcriptContext([assistantUsage(10, { cache_read_input_tokens: 1234 })]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
|
||||
expect(widget.render(item(), transcriptContext([assistantUsage(10, { cache_creation_input_tokens: 55 })]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
|
||||
});
|
||||
|
||||
it('finds the trailing record even when it exceeds the initial 32 KiB tail read', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
// A pending user row bigger than the initial tail (e.g. a pasted prompt
|
||||
// or large tool result) must still report HOT...
|
||||
const bigUser = JSON.stringify({ type: 'user', content: 'x'.repeat(64 * 1024) });
|
||||
expect(widget.render(item(), transcriptContext([assistant(400), bigUser]), DEFAULT_SETTINGS)).toBe('Cache: 🔥 HOT');
|
||||
// ...and an oversized trailing assistant row must still drive the countdown.
|
||||
const bigAssistant = JSON.stringify({ type: 'assistant', timestamp: isoAgo(10), content: 'x'.repeat(64 * 1024) });
|
||||
expect(widget.render(item(), transcriptContext([bigAssistant]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
|
||||
});
|
||||
|
||||
it('finds a valid trailing record larger than 1 MiB', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
const huge = JSON.stringify({ type: 'assistant', timestamp: isoAgo(10), message: { usage: { cache_read_input_tokens: 42 } }, content: 'x'.repeat(2 * 1024 * 1024) });
|
||||
expect(widget.render(item(), transcriptContext([huge]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
|
||||
});
|
||||
|
||||
it('renders n/a after scanning a file with no parseable records', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item(), transcriptContext(['x'.repeat(2 * 1024 * 1024)]), DEFAULT_SETTINGS)).toBe('Cache: n/a');
|
||||
});
|
||||
|
||||
it('treats a malformed assistant timestamp as no data instead of rendering NaN', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
const context = transcriptContext([JSON.stringify({ type: 'assistant', timestamp: 'not-a-date' })]);
|
||||
expect(widget.render(item(), context, DEFAULT_SETTINGS)).toBe('Cache: n/a');
|
||||
expect(widget.render(item(hidden), context, DEFAULT_SETTINGS)).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes a hide-when-empty keybind and toggles the flag', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.getCustomKeybinds()).toEqual([
|
||||
{ key: 't', label: '(t)tl', action: 'toggle-ttl' },
|
||||
{ key: 'h', label: '(h)ide when empty', action: 'toggle-hide' },
|
||||
{ key: 'g', label: '(g)lyph', action: 'edit-symbol-override' }
|
||||
]);
|
||||
expect(widget.handleEditorAction('toggle-hide', item())?.metadata?.hideWhenEmpty).toBe('true');
|
||||
expect(widget.handleEditorAction('unknown', item())).toBeNull();
|
||||
});
|
||||
|
||||
it('annotates the editor only when hide-when-empty is enabled', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.getEditorDisplay(item()).displayText).toBe('Cache Timer');
|
||||
expect(widget.getEditorDisplay(item()).modifierText).toBeUndefined();
|
||||
expect(widget.getEditorDisplay(item(hidden)).modifierText).toBe('(hide when empty)');
|
||||
});
|
||||
|
||||
it('renders custom state glyphs from metadata overrides', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item({ metadata: { symbolCold: 'X' } }), transcriptContext([assistant(400)]), DEFAULT_SETTINGS)).toBe('Cache: X COLD');
|
||||
expect(widget.render(item({ metadata: { symbolFresh: '*' } }), transcriptContext([assistant(10)]), DEFAULT_SETTINGS)).toMatch(/^Cache: \* \d+:\d{2}$/);
|
||||
expect(widget.render(item({ metadata: { symbolHot: '>' } }), transcriptContext([assistant(60), pendingUser]), DEFAULT_SETTINGS)).toBe('Cache: > HOT');
|
||||
});
|
||||
|
||||
it('drops the glyph and its space when an override is blanked', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item({ metadata: { symbolFresh: '' } }), transcriptContext([assistant(10)]), DEFAULT_SETTINGS)).toMatch(/^Cache: \d+:\d{2}$/);
|
||||
});
|
||||
|
||||
it('reflects a custom fresh glyph in the preview', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item({ metadata: { symbolFresh: '#' } }), { isPreview: true }, DEFAULT_SETTINGS)).toBe('Cache: # 4:52');
|
||||
});
|
||||
|
||||
it('extends the countdown window when the TTL is set to 1 hour', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
// 600s in is COLD at the default 5-minute TTL...
|
||||
expect(widget.render(item(), transcriptContext([assistant(600)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
|
||||
// ...but still fresh under a 1-hour TTL.
|
||||
expect(widget.render(item({ metadata: { ttlSeconds: '3600' } }), transcriptContext([assistant(600)]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
|
||||
});
|
||||
|
||||
it('falls back to the default TTL for a malformed value', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.render(item({ metadata: { ttlSeconds: 'abc' } }), transcriptContext([assistant(600)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
|
||||
});
|
||||
|
||||
it('cycles the TTL between 5m and 1h via the keybind', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
const toOneHour = widget.handleEditorAction('toggle-ttl', item());
|
||||
expect(toOneHour?.metadata?.ttlSeconds).toBe('3600');
|
||||
const backToDefault = widget.handleEditorAction('toggle-ttl', toOneHour ?? item());
|
||||
expect(backToDefault?.metadata?.ttlSeconds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('annotates the editor with a non-default TTL', () => {
|
||||
const widget = new CacheTimerWidget();
|
||||
expect(widget.getEditorDisplay(item({ metadata: { ttlSeconds: '3600' } })).modifierText).toBe('(ttl 1h)');
|
||||
expect(widget.getEditorDisplay(item({ metadata: { ttlSeconds: '3600', hideWhenEmpty: 'true' } })).modifierText).toBe('(ttl 1h, hide when empty)');
|
||||
});
|
||||
});
|
||||
@@ -235,16 +235,32 @@ describe('CompactionCounterWidget', () => {
|
||||
item: { ...ITEM, metadata: { showReclaimed: 'true' } }
|
||||
})).toBe('↻ 2 ↓120.0k');
|
||||
});
|
||||
|
||||
it('renders a custom reclaimed glyph from the symbolReclaimed override', () => {
|
||||
expect(render({
|
||||
compactionData: { count: 2, tokensReclaimed: 887000 },
|
||||
item: { ...ITEM, metadata: { showReclaimed: 'true', symbolReclaimed: 'X' } }
|
||||
})).toBe('↻ 2 X887.0k');
|
||||
});
|
||||
|
||||
it('drops the reclaimed glyph but keeps the space when the override is empty', () => {
|
||||
expect(render({
|
||||
compactionData: { count: 2, tokensReclaimed: 887000 },
|
||||
item: { ...ITEM, metadata: { showReclaimed: 'true', symbolReclaimed: '' } }
|
||||
})).toBe('↻ 2 887.0k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor', () => {
|
||||
it('uses f and n as keybinds for the default format', () => {
|
||||
it('uses metric, format, and toggle keybinds in count mode', () => {
|
||||
expect(new CompactionCounterWidget().getCustomKeybinds(ITEM)).toEqual([
|
||||
{ key: 'v', label: '(v)alue', action: 'cycle-metric' },
|
||||
{ key: 'f', label: '(f)ormat', action: 'cycle-format' },
|
||||
{ key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' },
|
||||
{ key: 's', label: '(s)plit by trigger', action: 'toggle-triggers' },
|
||||
{ key: 't', label: '(t)okens reclaimed', action: 'toggle-reclaimed' },
|
||||
{ key: 'h', label: '(h)ide when zero', action: 'toggle-hide-zero' }
|
||||
{ key: 'h', label: '(h)ide when zero', action: 'toggle-hide-zero' },
|
||||
{ key: 'g', label: '(g)lyph', action: 'edit-symbol-override' }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -253,10 +269,12 @@ describe('CompactionCounterWidget', () => {
|
||||
...ITEM,
|
||||
metadata: { format: 'text-and-number' }
|
||||
})).toEqual([
|
||||
{ key: 'v', label: '(v)alue', action: 'cycle-metric' },
|
||||
{ key: 'f', label: '(f)ormat', action: 'cycle-format' },
|
||||
{ key: 's', label: '(s)plit by trigger', action: 'toggle-triggers' },
|
||||
{ key: 't', label: '(t)okens reclaimed', action: 'toggle-reclaimed' },
|
||||
{ key: 'h', label: '(h)ide when zero', action: 'toggle-hide-zero' }
|
||||
{ key: 'h', label: '(h)ide when zero', action: 'toggle-hide-zero' },
|
||||
{ key: 'g', label: '(g)lyph', action: 'edit-symbol-override' }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -394,4 +412,101 @@ describe('CompactionCounterWidget', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('metric selector', () => {
|
||||
it('renders only the auto-trigger count when metric is auto', () => {
|
||||
expect(render({
|
||||
compactionData: { count: 5, byTrigger: { auto: 3, manual: 2, unknown: 0 } },
|
||||
item: { ...ITEM, metadata: { metric: 'auto' } }
|
||||
})).toBe('3');
|
||||
});
|
||||
|
||||
it('renders only the manual-trigger count when metric is manual', () => {
|
||||
expect(render({
|
||||
compactionData: { count: 5, byTrigger: { auto: 3, manual: 2, unknown: 0 } },
|
||||
item: { ...ITEM, metadata: { metric: 'manual' } }
|
||||
})).toBe('2');
|
||||
});
|
||||
|
||||
it('renders only the unknown-trigger count when metric is unknown', () => {
|
||||
expect(render({
|
||||
compactionData: { count: 5, byTrigger: { auto: 3, manual: 1, unknown: 1 } },
|
||||
item: { ...ITEM, metadata: { metric: 'unknown' } }
|
||||
})).toBe('1');
|
||||
});
|
||||
|
||||
it('renders the reclaimed tokens formatted when metric is reclaimed', () => {
|
||||
expect(render({
|
||||
compactionData: { count: 2, tokensReclaimed: 887000 },
|
||||
item: { ...ITEM, metadata: { metric: 'reclaimed' } }
|
||||
})).toBe('887.0k');
|
||||
});
|
||||
|
||||
it('emits a raw value, ignoring format and icon settings', () => {
|
||||
expect(render({
|
||||
compactionData: { count: 5, byTrigger: { auto: 3, manual: 2, unknown: 0 } },
|
||||
item: { ...ITEM, metadata: { metric: 'auto', format: 'icon-space-number', nerdFont: 'true' } }
|
||||
})).toBe('3');
|
||||
});
|
||||
|
||||
it('hides a zero metric value when hide zero is enabled', () => {
|
||||
expect(render({
|
||||
compactionData: { count: 4, byTrigger: { auto: 0, manual: 4, unknown: 0 } },
|
||||
item: { ...ITEM, metadata: { metric: 'auto', hideZero: 'true' } }
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('still shows a zero metric value when hide zero is off', () => {
|
||||
expect(render({
|
||||
compactionData: { count: 4, byTrigger: { auto: 0, manual: 4, unknown: 0 } },
|
||||
item: { ...ITEM, metadata: { metric: 'auto' } }
|
||||
})).toBe('0');
|
||||
});
|
||||
|
||||
it('shows the sample metric value in preview mode, ignoring hide zero', () => {
|
||||
expect(render({
|
||||
isPreview: true,
|
||||
item: { ...ITEM, metadata: { metric: 'unknown', hideZero: 'true' } }
|
||||
})).toBe('0');
|
||||
expect(render({
|
||||
isPreview: true,
|
||||
item: { ...ITEM, metadata: { metric: 'reclaimed' } }
|
||||
})).toBe('120.0k');
|
||||
});
|
||||
|
||||
it('shows the metric in the editor display', () => {
|
||||
expect(new CompactionCounterWidget().getEditorDisplay({
|
||||
...ITEM,
|
||||
metadata: { metric: 'reclaimed', hideZero: 'true' }
|
||||
})).toEqual({
|
||||
displayText: 'Compaction Counter',
|
||||
modifierText: '(reclaimed value, hide zero)'
|
||||
});
|
||||
});
|
||||
|
||||
it('uses only metric and hide-zero keybinds in metric mode', () => {
|
||||
expect(new CompactionCounterWidget().getCustomKeybinds({
|
||||
...ITEM,
|
||||
metadata: { metric: 'auto' }
|
||||
})).toEqual([
|
||||
{ key: 'v', label: '(v)alue', action: 'cycle-metric' },
|
||||
{ key: 'h', label: '(h)ide when zero', action: 'toggle-hide-zero' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('cycles count -> auto -> manual -> unknown -> reclaimed -> count', () => {
|
||||
const widget = new CompactionCounterWidget();
|
||||
const auto = widget.handleEditorAction('cycle-metric', ITEM);
|
||||
const manual = widget.handleEditorAction('cycle-metric', auto ?? ITEM);
|
||||
const unknown = widget.handleEditorAction('cycle-metric', manual ?? ITEM);
|
||||
const reclaimed = widget.handleEditorAction('cycle-metric', unknown ?? ITEM);
|
||||
const count = widget.handleEditorAction('cycle-metric', reclaimed ?? ITEM);
|
||||
|
||||
expect(auto?.metadata?.metric).toBe('auto');
|
||||
expect(manual?.metadata?.metric).toBe('manual');
|
||||
expect(unknown?.metadata?.metric).toBe('unknown');
|
||||
expect(reclaimed?.metadata?.metric).toBe('reclaimed');
|
||||
expect(count?.metadata?.metric).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ContextBarWidget', () => {
|
||||
};
|
||||
const widget = new ContextBarWidget();
|
||||
|
||||
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:5.0:16] 50k/1000k (5%)');
|
||||
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:5.0:16] 50k/1.0M (5%)');
|
||||
});
|
||||
|
||||
it('uses 1M in parentheses model IDs in fallback mode', () => {
|
||||
@@ -87,7 +87,7 @@ describe('ContextBarWidget', () => {
|
||||
};
|
||||
const widget = new ContextBarWidget();
|
||||
|
||||
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:5.0:16] 50k/1000k (5%)');
|
||||
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:5.0:16] 50k/1.0M (5%)');
|
||||
});
|
||||
|
||||
it('clamps usage percentage to 100 when context length exceeds total', () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('CurrentWorkingDirWidget', () => {
|
||||
compactThreshold: 60,
|
||||
colorLevel: 2,
|
||||
defaultPadding: ' ',
|
||||
defaultPaddingSide: 'both',
|
||||
inheritSeparatorColors: false,
|
||||
globalBold: false,
|
||||
gitCacheTtlSeconds: 5,
|
||||
@@ -56,13 +57,15 @@ describe('CurrentWorkingDirWidget', () => {
|
||||
|
||||
const createItem = (
|
||||
metadata?: Record<string, string>,
|
||||
rawValue = false
|
||||
rawValue = false,
|
||||
character?: string
|
||||
): WidgetItem => ({
|
||||
id: 'test',
|
||||
type: 'current-working-dir',
|
||||
backgroundColor: 'bgBlue',
|
||||
rawValue,
|
||||
metadata
|
||||
metadata,
|
||||
character
|
||||
});
|
||||
|
||||
describe('abbreviateHome', () => {
|
||||
@@ -215,5 +218,44 @@ describe('CurrentWorkingDirWidget', () => {
|
||||
expect(homeKeybind?.label).toBe('(h)ome ~');
|
||||
expect(homeKeybind?.action).toBe('toggle-abbreviate-home');
|
||||
});
|
||||
|
||||
it('should expose a (g)lyph symbol-override keybind', () => {
|
||||
const keybinds = widget.getCustomKeybinds();
|
||||
const glyphKeybind = keybinds.find(k => k.key === 'g');
|
||||
expect(glyphKeybind).toBeDefined();
|
||||
expect(glyphKeybind?.action).toBe('edit-symbol-override');
|
||||
});
|
||||
});
|
||||
|
||||
describe('glyph symbol override', () => {
|
||||
it('renders no glyph prefix by default, leaving the cwd: label intact', () => {
|
||||
const item = createItem(undefined, false);
|
||||
const result = widget.render(item, createContext('/var/www/site'), defaultSettings);
|
||||
expect(result).toBe('cwd: /var/www/site');
|
||||
});
|
||||
|
||||
it('prefixes the labeled output with the configured glyph', () => {
|
||||
const item = createItem(undefined, false, '📁');
|
||||
const result = widget.render(item, createContext('/var/www/site'), defaultSettings);
|
||||
expect(result).toBe('📁 cwd: /var/www/site');
|
||||
});
|
||||
|
||||
it('keeps the glyph in raw value mode while dropping the cwd: label', () => {
|
||||
const item = createItem(undefined, true, '📁');
|
||||
const result = widget.render(item, createContext('/var/www/site'), defaultSettings);
|
||||
expect(result).toBe('📁 /var/www/site');
|
||||
});
|
||||
|
||||
it('composes the glyph with raw value and segment truncation', () => {
|
||||
const item = createItem({ segments: '2' }, true, '📁');
|
||||
const result = widget.render(item, createContext('/var/www/html/my-project'), defaultSettings);
|
||||
expect(result).toBe('📁 .../html/my-project');
|
||||
});
|
||||
|
||||
it('shows the glyph in preview mode', () => {
|
||||
const item = createItem(undefined, true, '📁');
|
||||
const result = widget.render(item, createContext(undefined, true), defaultSettings);
|
||||
expect(result).toBe('📁 /Users/example/Documents/Projects/my-project');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('CustomCommandWidget', () => {
|
||||
compactThreshold: 60,
|
||||
colorLevel: 2,
|
||||
defaultPadding: ' ',
|
||||
defaultPaddingSide: 'both',
|
||||
inheritSeparatorColors: false,
|
||||
globalBold: false,
|
||||
gitCacheTtlSeconds: 5,
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('ExtraUsageUtilizationWidget', () => {
|
||||
|
||||
expect(widget.getCustomKeybinds(baseItem)).toEqual([
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'u', label: '(u) show remaining', action: 'toggle-invert' },
|
||||
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }
|
||||
]);
|
||||
expect(widget.getCustomKeybinds({
|
||||
@@ -81,19 +82,30 @@ describe('ExtraUsageUtilizationWidget', () => {
|
||||
metadata: { display: 'progress' }
|
||||
})).toEqual([
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
|
||||
{ key: 't', label: '(t)ime cursor', action: 'toggle-cursor' },
|
||||
{ key: 'u', label: '(u) show remaining', action: 'toggle-invert' },
|
||||
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }
|
||||
]);
|
||||
expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined();
|
||||
expect(widget.getCustomKeybinds({
|
||||
...baseItem,
|
||||
metadata: { invert: 'true' }
|
||||
})).toEqual([
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'u', label: '(u) show used', action: 'toggle-invert' },
|
||||
{ key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }
|
||||
]);
|
||||
expect(widget.getEditorDisplay(baseItem).modifierText).toBe('(used)');
|
||||
expect(widget.getEditorDisplay({
|
||||
...baseItem,
|
||||
metadata: { invert: 'true' }
|
||||
}).modifierText).toBe('(remaining)');
|
||||
|
||||
const hidden = widget.handleEditorAction('toggle-hide-disabled', baseItem);
|
||||
expect(hidden?.metadata?.hideIfDisabled).toBe('true');
|
||||
expect(widget.getEditorDisplay(hidden ?? baseItem).modifierText).toBe('(hide if disabled)');
|
||||
expect(widget.getEditorDisplay(hidden ?? baseItem).modifierText).toBe('(used, hide if disabled)');
|
||||
expect(widget.getEditorDisplay({
|
||||
...baseItem,
|
||||
metadata: { display: 'progress', hideIfDisabled: 'true' }
|
||||
}).modifierText).toBe('(long bar, hide if disabled)');
|
||||
}).modifierText).toBe('(long bar, used, hide if disabled)');
|
||||
|
||||
const shown = widget.handleEditorAction('toggle-hide-disabled', hidden ?? baseItem);
|
||||
expect(shown?.metadata?.hideIfDisabled).toBe('false');
|
||||
@@ -154,4 +166,27 @@ describe('ExtraUsageUtilizationWidget', () => {
|
||||
}
|
||||
})).toBe('Overage: [████████████░░░░] 75.0%');
|
||||
});
|
||||
|
||||
it('inverts plain text and preview rendering', () => {
|
||||
const widget = new ExtraUsageUtilizationWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'extra',
|
||||
metadata: { invert: 'true' },
|
||||
type: 'extra-usage-utilization'
|
||||
};
|
||||
|
||||
expect(render(widget, item, {
|
||||
usageData: {
|
||||
extraUsageEnabled: true,
|
||||
extraUsageUtilization: 25
|
||||
}
|
||||
})).toBe('Overage: 75.0%');
|
||||
expect(render(widget, { ...item, rawValue: true }, {
|
||||
usageData: {
|
||||
extraUsageEnabled: true,
|
||||
extraUsageUtilization: 25
|
||||
}
|
||||
})).toBe('75.0%');
|
||||
expect(render(widget, item, { isPreview: true })).toBe('Overage: 97.4%');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ function render(options: {
|
||||
hideNoGit?: boolean;
|
||||
isPreview?: boolean;
|
||||
linkToRepo?: boolean;
|
||||
maxWidth?: number;
|
||||
metadata?: Record<string, string>;
|
||||
rawValue?: boolean;
|
||||
} = {}) {
|
||||
@@ -50,6 +51,7 @@ function render(options: {
|
||||
id: 'git-branch',
|
||||
type: 'git-branch',
|
||||
rawValue: options.rawValue,
|
||||
maxWidth: options.maxWidth,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
|
||||
};
|
||||
|
||||
@@ -174,6 +176,44 @@ describe('GitBranchWidget', () => {
|
||||
expect(render({ metadata: { linkToRepo: 'false', linkToGitHub: 'true' } })).toBe('⎇ main');
|
||||
});
|
||||
|
||||
describe('max width', () => {
|
||||
const widget = new GitBranchWidget();
|
||||
|
||||
it.each([
|
||||
{ name: 'truncates the branch with an ellipsis', maxWidth: 10, expected: 'feature...' },
|
||||
{ name: 'leaves the branch untouched when it fits', maxWidth: 100, expected: 'feature/worktree' }
|
||||
])('$name', ({ maxWidth, expected }) => {
|
||||
mockExecFileSync.mockReturnValueOnce('true\n');
|
||||
mockExecFileSync.mockReturnValueOnce('feature/worktree');
|
||||
|
||||
expect(render({ rawValue: true, maxWidth })).toBe(expected);
|
||||
});
|
||||
|
||||
it('truncates the visible link label but keeps the full link target', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('true\n');
|
||||
mockExecFileSync.mockReturnValueOnce('feature/worktree');
|
||||
mockExecFileSync.mockReturnValueOnce('git@github.com:owner/repo.git');
|
||||
|
||||
expect(render({ rawValue: true, linkToRepo: true, maxWidth: 10 })).toBe(renderOsc8Link(
|
||||
'https://github.com/owner/repo/tree/feature/worktree',
|
||||
'feature...'
|
||||
));
|
||||
});
|
||||
|
||||
it('shows the max-width modifier in the editor display', () => {
|
||||
expect(widget.getEditorDisplay({ id: 'git-branch', type: 'git-branch', maxWidth: 12 }).modifierText)
|
||||
.toBe('(max:12)');
|
||||
});
|
||||
|
||||
it('exposes the width keybind', () => {
|
||||
expect(widget.getCustomKeybinds()).toContainEqual({
|
||||
key: 'w',
|
||||
label: '(w)idth',
|
||||
action: 'edit-max-width'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle action', () => {
|
||||
const widget = new GitBranchWidget();
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import type { GitCiState } from '../../utils/git-review-cache';
|
||||
import {
|
||||
GitCiStatusWidget,
|
||||
type GitCiStatusWidgetDeps
|
||||
} from '../GitCiStatus';
|
||||
|
||||
function prWithChecks(state: GitCiState, failing: number, pending: number, success: number) {
|
||||
return {
|
||||
number: 123,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Fix authentication bug',
|
||||
url: 'https://github.com/owner/repo/pull/123',
|
||||
checks: { state, failing, pending, success }
|
||||
};
|
||||
}
|
||||
|
||||
const PASSING_PR = prWithChecks('passing', 0, 0, 5);
|
||||
|
||||
function createDeps(overrides: Partial<GitCiStatusWidgetDeps> = {}): GitCiStatusWidgetDeps {
|
||||
return {
|
||||
getCachedGitReviewData: () => PASSING_PR,
|
||||
getProcessCwd: () => '/tmp/process-cwd',
|
||||
isInsideGitWorkTree: () => true,
|
||||
resolveGitCwd: context => context.data?.cwd,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function render(
|
||||
options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolean; rawValue?: boolean } = {},
|
||||
depOverrides: Partial<GitCiStatusWidgetDeps> = {}
|
||||
): string | null {
|
||||
const widget = new GitCiStatusWidget(createDeps(depOverrides));
|
||||
const context: RenderContext = {
|
||||
data: options.cwd ? { cwd: options.cwd } : undefined,
|
||||
isPreview: options.isPreview
|
||||
};
|
||||
const item: WidgetItem = {
|
||||
id: 'git-ci-status',
|
||||
metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined,
|
||||
rawValue: options.rawValue,
|
||||
type: 'git-ci-status'
|
||||
};
|
||||
return widget.render(item, context, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
describe('GitCiStatusWidget', () => {
|
||||
it('renders preview', () => {
|
||||
expect(render({ isPreview: true })).toBe('✗1 ●1 ✓5');
|
||||
});
|
||||
|
||||
it('renders preview rawValue as the state word', () => {
|
||||
expect(render({ isPreview: true, rawValue: true })).toBe('failing');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['all green', prWithChecks('passing', 0, 0, 5), '✓5'],
|
||||
['failing only', prWithChecks('failing', 1, 0, 4), '✗1 ✓4'],
|
||||
['pending only', prWithChecks('pending', 0, 3, 2), '●3 ✓2'],
|
||||
['mixed', prWithChecks('failing', 1, 1, 97), '✗1 ●1 ✓97'],
|
||||
['zeros are hidden', prWithChecks('failing', 2, 0, 0), '✗2']
|
||||
])('renders %s as non-zero glyph + count', (_label, pr, expected) => {
|
||||
expect(render({ cwd: '/tmp/repo' }, { getCachedGitReviewData: () => pr })).toBe(expected);
|
||||
});
|
||||
|
||||
it('falls back to ✓0 when only skipped/neutral checks exist', () => {
|
||||
const allIgnored = prWithChecks('passing', 0, 0, 0);
|
||||
expect(render({ cwd: '/tmp/repo' }, { getCachedGitReviewData: () => allIgnored })).toBe('✓0');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['passing', prWithChecks('passing', 0, 0, 5), 'passing'],
|
||||
['failing', prWithChecks('failing', 1, 0, 4), 'failing'],
|
||||
['pending', prWithChecks('pending', 0, 3, 2), 'pending']
|
||||
])('renders rawValue %s as the state word', (_label, pr, expected) => {
|
||||
expect(render({ cwd: '/tmp/repo', rawValue: true }, { getCachedGitReviewData: () => pr })).toBe(expected);
|
||||
});
|
||||
|
||||
it('renders "-" when no PR exists', () => {
|
||||
expect(render({ cwd: '/tmp/repo' }, { getCachedGitReviewData: () => null })).toBe('-');
|
||||
});
|
||||
|
||||
it('renders "-" when the PR has no checks', () => {
|
||||
const noChecks = { ...PASSING_PR, checks: undefined };
|
||||
expect(render({ cwd: '/tmp/repo' }, { getCachedGitReviewData: () => noChecks })).toBe('-');
|
||||
});
|
||||
|
||||
it('returns (no git) when not in a git repo', () => {
|
||||
expect(render({ cwd: '/x' }, { isInsideGitWorkTree: () => false })).toBe('(no git)');
|
||||
});
|
||||
|
||||
it('returns null when hideNoGit and not in a git repo', () => {
|
||||
expect(render({ cwd: '/x', hideNoGit: true }, { isInsideGitWorkTree: () => false })).toBeNull();
|
||||
});
|
||||
|
||||
it('uses process cwd when repo path is omitted', () => {
|
||||
const getCachedGitReviewData = vi.fn(() => PASSING_PR);
|
||||
render({}, { getCachedGitReviewData, resolveGitCwd: () => undefined });
|
||||
expect(getCachedGitReviewData).toHaveBeenCalledWith('/tmp/process-cwd', { includeChecks: true });
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ const SAMPLE_PR = {
|
||||
|
||||
function createDeps(overrides: Partial<GitPrWidgetDeps> = {}): GitPrWidgetDeps {
|
||||
return {
|
||||
fetchGitReviewData: () => SAMPLE_PR,
|
||||
getCachedGitReviewData: () => SAMPLE_PR,
|
||||
getProcessCwd: () => '/tmp/process-cwd',
|
||||
getRemoteInfo: () => null,
|
||||
isInsideGitWorkTree: () => true,
|
||||
@@ -40,6 +40,7 @@ function render(
|
||||
hideStatus?: boolean;
|
||||
hideTitle?: boolean;
|
||||
isPreview?: boolean;
|
||||
needsChecks?: boolean;
|
||||
rawValue?: boolean;
|
||||
} = {},
|
||||
depOverrides: Partial<GitPrWidgetDeps> = {}
|
||||
@@ -47,6 +48,7 @@ function render(
|
||||
const widget = new GitPrWidget(createDeps(depOverrides));
|
||||
const context: RenderContext = {
|
||||
data: options.cwd ? { cwd: options.cwd } : undefined,
|
||||
gitReviewNeedsChecks: options.needsChecks,
|
||||
isPreview: options.isPreview
|
||||
};
|
||||
const metadata: Record<string, string> = {};
|
||||
@@ -113,16 +115,16 @@ describe('GitPrWidget', () => {
|
||||
|
||||
it('should return (no PR) when PR lookup returns null', () => {
|
||||
expect(render({}, {
|
||||
fetchGitReviewData: () => null,
|
||||
getCachedGitReviewData: () => null,
|
||||
resolveGitCwd: () => undefined
|
||||
})).toBe('(no PR)');
|
||||
});
|
||||
|
||||
it('should use process cwd when repo paths are omitted', () => {
|
||||
const fetchGitReviewData = vi.fn(() => SAMPLE_PR);
|
||||
const getCachedGitReviewData = vi.fn(() => SAMPLE_PR);
|
||||
|
||||
const result = render({}, {
|
||||
fetchGitReviewData,
|
||||
getCachedGitReviewData,
|
||||
getProcessCwd: () => '/tmp/process-cwd',
|
||||
resolveGitCwd: () => undefined
|
||||
});
|
||||
@@ -130,7 +132,15 @@ describe('GitPrWidget', () => {
|
||||
expect(result).toBe(
|
||||
`${renderOsc8Link('https://github.com/owner/repo/pull/123', 'PR #123')} OPEN Fix authentication bug`
|
||||
);
|
||||
expect(fetchGitReviewData).toHaveBeenCalledWith('/tmp/process-cwd');
|
||||
expect(getCachedGitReviewData).toHaveBeenCalledWith('/tmp/process-cwd', { includeChecks: false });
|
||||
});
|
||||
|
||||
it('should request checks when a CI widget shares the render context', () => {
|
||||
const getCachedGitReviewData = vi.fn(() => SAMPLE_PR);
|
||||
|
||||
render({ cwd: '/tmp/repo', needsChecks: true }, { getCachedGitReviewData });
|
||||
|
||||
expect(getCachedGitReviewData).toHaveBeenCalledWith('/tmp/repo', { includeChecks: true });
|
||||
});
|
||||
|
||||
it('should truncate long titles', () => {
|
||||
@@ -139,17 +149,17 @@ describe('GitPrWidget', () => {
|
||||
title: 'This is a very long pull request title that exceeds the default limit'
|
||||
};
|
||||
|
||||
const result = render({ cwd: '/tmp/repo' }, { fetchGitReviewData: () => longPr });
|
||||
const result = render({ cwd: '/tmp/repo' }, { getCachedGitReviewData: () => longPr });
|
||||
expect(result).toContain('This is a very long pull requ\u2026');
|
||||
});
|
||||
|
||||
it('should render MERGED status', () => {
|
||||
expect(render({ cwd: '/tmp/repo' }, { fetchGitReviewData: () => ({ ...SAMPLE_PR, state: 'MERGED' }) })).toContain('MERGED');
|
||||
expect(render({ cwd: '/tmp/repo' }, { getCachedGitReviewData: () => ({ ...SAMPLE_PR, state: 'MERGED' }) })).toContain('MERGED');
|
||||
});
|
||||
|
||||
it('should render APPROVED status', () => {
|
||||
expect(render({ cwd: '/tmp/repo' }, {
|
||||
fetchGitReviewData: () => ({
|
||||
getCachedGitReviewData: () => ({
|
||||
...SAMPLE_PR,
|
||||
reviewDecision: 'APPROVED',
|
||||
state: 'OPEN'
|
||||
@@ -159,7 +169,7 @@ describe('GitPrWidget', () => {
|
||||
|
||||
it('should render CHANGES_REQ status', () => {
|
||||
expect(render({ cwd: '/tmp/repo' }, {
|
||||
fetchGitReviewData: () => ({
|
||||
getCachedGitReviewData: () => ({
|
||||
...SAMPLE_PR,
|
||||
reviewDecision: 'CHANGES_REQUESTED',
|
||||
state: 'OPEN'
|
||||
@@ -172,7 +182,7 @@ describe('GitPrWidget', () => {
|
||||
...SAMPLE_PR,
|
||||
url: 'https://gitlab.com/owner/repo/-/merge_requests/123'
|
||||
};
|
||||
expect(render({ cwd: '/tmp/repo' }, { fetchGitReviewData: () => gitlabPr })).toBe(
|
||||
expect(render({ cwd: '/tmp/repo' }, { getCachedGitReviewData: () => gitlabPr })).toBe(
|
||||
`${renderOsc8Link('https://gitlab.com/owner/repo/-/merge_requests/123', 'MR #123')} OPEN Fix authentication bug`
|
||||
);
|
||||
});
|
||||
@@ -182,14 +192,14 @@ describe('GitPrWidget', () => {
|
||||
...SAMPLE_PR,
|
||||
url: 'https://gitlab.com/owner/repo/-/merge_requests/123'
|
||||
};
|
||||
expect(render({ cwd: '/tmp/repo', rawValue: true }, { fetchGitReviewData: () => gitlabPr })).toBe(
|
||||
expect(render({ cwd: '/tmp/repo', rawValue: true }, { getCachedGitReviewData: () => gitlabPr })).toBe(
|
||||
`${renderOsc8Link('https://gitlab.com/owner/repo/-/merge_requests/123', '#123')} OPEN Fix authentication bug`
|
||||
);
|
||||
});
|
||||
|
||||
it('should return (no MR) when origin is GitLab and no MR exists', () => {
|
||||
expect(render({ cwd: '/tmp/repo' }, {
|
||||
fetchGitReviewData: () => null,
|
||||
getCachedGitReviewData: () => null,
|
||||
getRemoteInfo: () => ({
|
||||
name: 'origin',
|
||||
url: 'git@gitlab.com:owner/repo.git',
|
||||
@@ -214,7 +224,7 @@ describe('GitPrWidget', () => {
|
||||
title: 'Add optional wallet type field',
|
||||
url: 'https://git.example.com/group/project/-/merge_requests/1626'
|
||||
};
|
||||
expect(render({ cwd: '/tmp/repo' }, { fetchGitReviewData: () => legacyCacheEntry })).toBe(
|
||||
expect(render({ cwd: '/tmp/repo' }, { getCachedGitReviewData: () => legacyCacheEntry })).toBe(
|
||||
`${renderOsc8Link('https://git.example.com/group/project/-/merge_requests/1626', 'MR #1626')} OPEN Add optional wallet type field`
|
||||
);
|
||||
});
|
||||
@@ -226,14 +236,14 @@ describe('GitPrWidget', () => {
|
||||
url: 'https://git.example.com/team/repo/-/merge_requests/7',
|
||||
provider: 'glab' as const
|
||||
};
|
||||
expect(render({ cwd: '/tmp/repo' }, { fetchGitReviewData: () => selfHostedMr })).toBe(
|
||||
expect(render({ cwd: '/tmp/repo' }, { getCachedGitReviewData: () => selfHostedMr })).toBe(
|
||||
`${renderOsc8Link('https://git.example.com/team/repo/-/merge_requests/7', 'MR #7')} OPEN Fix authentication bug`
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to (no PR) when the origin host name does not identify the forge', () => {
|
||||
expect(render({ cwd: '/tmp/repo' }, {
|
||||
fetchGitReviewData: () => null,
|
||||
getCachedGitReviewData: () => null,
|
||||
getRemoteInfo: () => ({
|
||||
name: 'origin',
|
||||
url: 'git@git.example.com:team/repo.git',
|
||||
|
||||
@@ -31,7 +31,7 @@ const mockExecFileSync = execFileSync as unknown as {
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
function render(options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolean } = {}) {
|
||||
function render(options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolean; maxWidth?: number } = {}) {
|
||||
const widget = new GitRootDirWidget();
|
||||
const context: RenderContext = {
|
||||
isPreview: options.isPreview,
|
||||
@@ -40,6 +40,7 @@ function render(options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolea
|
||||
const item: WidgetItem = {
|
||||
id: 'git-root-dir',
|
||||
type: 'git-root-dir',
|
||||
maxWidth: options.maxWidth,
|
||||
metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined
|
||||
};
|
||||
|
||||
@@ -202,4 +203,55 @@ describe('GitRootDirWidget', () => {
|
||||
|
||||
expect(widget.supportsRawValue()).toBe(false);
|
||||
});
|
||||
|
||||
describe('max width', () => {
|
||||
const widget = new GitRootDirWidget();
|
||||
|
||||
it.each([
|
||||
{ name: 'should truncate the directory name to the configured width', maxWidth: 10, expected: 'my-very...' },
|
||||
{ name: 'should leave the directory name untouched when it fits', maxWidth: 100, expected: 'my-very-long-repo-name' }
|
||||
])('$name', ({ maxWidth, expected }) => {
|
||||
mockExecFileSync.mockReturnValueOnce('true\n');
|
||||
mockExecFileSync.mockReturnValueOnce('/some/path/my-very-long-repo-name');
|
||||
|
||||
expect(render({ maxWidth })).toBe(expected);
|
||||
});
|
||||
|
||||
it('should truncate the visible IDE link label but keep the full link target', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('true\n');
|
||||
mockExecFileSync.mockReturnValueOnce('/some/path/my-very-long-repo-name');
|
||||
|
||||
expect(widget.render({
|
||||
id: 'git-root-dir',
|
||||
type: 'git-root-dir',
|
||||
maxWidth: 10,
|
||||
metadata: { linkToIDE: 'vscode' }
|
||||
}, {}, DEFAULT_SETTINGS)).toBe(renderOsc8Link(
|
||||
buildIdeFileUrl('/some/path/my-very-long-repo-name', 'vscode'),
|
||||
'my-very...'
|
||||
));
|
||||
});
|
||||
|
||||
it('should show the max-width modifier in the editor display', () => {
|
||||
expect(widget.getEditorDisplay({ id: 'git-root-dir', type: 'git-root-dir', maxWidth: 12 }).modifierText)
|
||||
.toBe('(max:12)');
|
||||
});
|
||||
|
||||
it('should expose the width keybind', () => {
|
||||
expect(widget.getCustomKeybinds()).toContainEqual({
|
||||
key: 'w',
|
||||
label: '(w)idth',
|
||||
action: 'edit-max-width'
|
||||
});
|
||||
});
|
||||
|
||||
it('should open the width editor for the width action', () => {
|
||||
expect(widget.renderEditor({
|
||||
widget: { id: 'git-root-dir', type: 'git-root-dir' },
|
||||
onComplete: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
action: 'edit-max-width'
|
||||
})).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,25 @@ describe('RemoteControlStatusWidget', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the Nerd Font keybind only when its icon is visible', () => {
|
||||
const widget = new RemoteControlStatusWidget();
|
||||
const nerdFontKeybind = { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' };
|
||||
|
||||
expect(widget.getCustomKeybinds(ITEM)).toContainEqual(nerdFontKeybind);
|
||||
expect(widget.getCustomKeybinds({ ...ITEM, rawValue: true })).toContainEqual(nerdFontKeybind);
|
||||
expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format: 'icon-text' } }))
|
||||
.toContainEqual(nerdFontKeybind);
|
||||
expect(widget.getCustomKeybinds({
|
||||
...ITEM,
|
||||
rawValue: true,
|
||||
metadata: { format: 'icon-text' }
|
||||
})).not.toContainEqual(nerdFontKeybind);
|
||||
for (const format of ['text', 'word', 'label-check', 'label-mark']) {
|
||||
expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format } }))
|
||||
.not.toContainEqual(nerdFontKeybind);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to icon in the editor display', () => {
|
||||
expect(new RemoteControlStatusWidget().getEditorDisplay(ITEM)).toEqual({
|
||||
displayText: 'Remote Control Status',
|
||||
@@ -76,13 +95,60 @@ describe('RemoteControlStatusWidget', () => {
|
||||
it('shows the configured format and nerd font in the editor display', () => {
|
||||
expect(new RemoteControlStatusWidget().getEditorDisplay({
|
||||
...ITEM,
|
||||
metadata: { format: 'word', nerdFont: 'true' }
|
||||
metadata: { format: 'icon-text', nerdFont: 'true' }
|
||||
})).toEqual({
|
||||
displayText: 'Remote Control Status',
|
||||
modifierText: '(word, nerd font)'
|
||||
modifierText: '(icon-text, nerd font)'
|
||||
});
|
||||
});
|
||||
|
||||
it('hides stale Nerd Font metadata when raw or non-icon modes remove its icon', () => {
|
||||
const widget = new RemoteControlStatusWidget();
|
||||
|
||||
expect(widget.getEditorDisplay({
|
||||
...ITEM,
|
||||
rawValue: true,
|
||||
metadata: { format: 'icon-text', nerdFont: 'true' }
|
||||
}).modifierText).toBe('(icon-text)');
|
||||
expect(widget.getEditorDisplay({
|
||||
...ITEM,
|
||||
metadata: { format: 'label-check', nerdFont: 'true' }
|
||||
}).modifierText).toBe('(label-check)');
|
||||
});
|
||||
|
||||
it('keeps Nerd Font through icon formats and clears it before text formats', () => {
|
||||
const widget = new RemoteControlStatusWidget();
|
||||
const item: WidgetItem = { ...ITEM, metadata: { nerdFont: 'true' } };
|
||||
const iconText = widget.handleEditorAction('cycle-format', item);
|
||||
const text = widget.handleEditorAction('cycle-format', iconText ?? ITEM);
|
||||
|
||||
expect(iconText?.metadata).toEqual({ format: 'icon-text', nerdFont: 'true' });
|
||||
expect(text?.metadata).toEqual({ format: 'text' });
|
||||
});
|
||||
|
||||
it('clears Nerd Font when raw mode makes icon-text text-only', () => {
|
||||
const widget = new RemoteControlStatusWidget();
|
||||
const item: WidgetItem = { ...ITEM, rawValue: true, metadata: { nerdFont: 'true' } };
|
||||
|
||||
expect(widget.handleEditorAction('cycle-format', item)?.metadata)
|
||||
.toEqual({ format: 'icon-text' });
|
||||
});
|
||||
|
||||
it('does not toggle Nerd Font when no configurable icon is visible', () => {
|
||||
const widget = new RemoteControlStatusWidget();
|
||||
const textItem: WidgetItem = { ...ITEM, metadata: { format: 'text' } };
|
||||
const rawIconTextItem: WidgetItem = {
|
||||
...ITEM,
|
||||
rawValue: true,
|
||||
metadata: { format: 'icon-text', nerdFont: 'true' }
|
||||
};
|
||||
|
||||
expect(widget.handleEditorAction('toggle-nerd-font', textItem)?.metadata)
|
||||
.toEqual({ format: 'text' });
|
||||
expect(widget.handleEditorAction('toggle-nerd-font', rawIconTextItem)?.metadata)
|
||||
.toEqual({ format: 'icon-text' });
|
||||
});
|
||||
|
||||
it('cycles icon -> icon-text -> text -> word -> label-check -> label-mark -> icon', () => {
|
||||
const widget = new RemoteControlStatusWidget();
|
||||
const iconText = widget.handleEditorAction('cycle-format', ITEM);
|
||||
@@ -246,20 +312,65 @@ describe('RemoteControlStatusWidget', () => {
|
||||
|
||||
describe('render() - raw value', () => {
|
||||
const RAW_ITEM: WidgetItem = { ...ITEM, rawValue: true };
|
||||
const cases: { name: string; item: WidgetItem; off: string; on: string }[] = [
|
||||
{ name: 'icon', item: RAW_ITEM, off: '○', on: '◉' },
|
||||
{
|
||||
name: 'icon with Nerd Font',
|
||||
item: { ...RAW_ITEM, metadata: { nerdFont: 'true' } },
|
||||
off: '\uF6AC',
|
||||
on: '\uF1EB'
|
||||
},
|
||||
{
|
||||
name: 'icon-text',
|
||||
item: { ...RAW_ITEM, metadata: { format: 'icon-text' } },
|
||||
off: 'off',
|
||||
on: 'on'
|
||||
},
|
||||
{
|
||||
name: 'icon-text with Nerd Font',
|
||||
item: { ...RAW_ITEM, metadata: { format: 'icon-text', nerdFont: 'true' } },
|
||||
off: 'off',
|
||||
on: 'on'
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
item: { ...RAW_ITEM, metadata: { format: 'text' } },
|
||||
off: 'off',
|
||||
on: 'on'
|
||||
},
|
||||
{
|
||||
name: 'word',
|
||||
item: { ...RAW_ITEM, metadata: { format: 'word' } },
|
||||
off: 'off',
|
||||
on: 'on'
|
||||
},
|
||||
{
|
||||
name: 'label-check',
|
||||
item: { ...RAW_ITEM, metadata: { format: 'label-check' } },
|
||||
off: '❌',
|
||||
on: '✅'
|
||||
},
|
||||
{
|
||||
name: 'label-mark',
|
||||
item: { ...RAW_ITEM, metadata: { format: 'label-mark' } },
|
||||
off: '✗',
|
||||
on: '✓'
|
||||
}
|
||||
];
|
||||
|
||||
it('returns "on" when ON', () => {
|
||||
vi.spyOn(claudeSettings, 'getRemoteControlStatus').mockReturnValue({ enabled: true });
|
||||
expect(new RemoteControlStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('on');
|
||||
it.each(cases)('removes only the label from $name format', ({ item, off, on }) => {
|
||||
const statusSpy = vi.spyOn(claudeSettings, 'getRemoteControlStatus');
|
||||
|
||||
statusSpy.mockReturnValue({ enabled: false });
|
||||
expect(new RemoteControlStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(off);
|
||||
|
||||
statusSpy.mockReturnValue({ enabled: true });
|
||||
expect(new RemoteControlStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(on);
|
||||
});
|
||||
|
||||
it('returns "off" when OFF', () => {
|
||||
vi.spyOn(claudeSettings, 'getRemoteControlStatus').mockReturnValue({ enabled: false });
|
||||
expect(new RemoteControlStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('off');
|
||||
});
|
||||
|
||||
it('returns "on" in preview mode', () => {
|
||||
it('preserves the default icon format in preview mode', () => {
|
||||
expect(new RemoteControlStatusWidget().render(RAW_ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS))
|
||||
.toBe('on');
|
||||
.toBe('◉');
|
||||
});
|
||||
|
||||
it('returns null when manifest lookup is null', () => {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type {
|
||||
RenderContext,
|
||||
WidgetItem
|
||||
} from '../../types';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import * as claudeSettings from '../../utils/claude-settings';
|
||||
import { SandboxStatusWidget } from '../SandboxStatus';
|
||||
|
||||
const ITEM: WidgetItem = { id: 'sandbox-status', type: 'sandbox-status' };
|
||||
|
||||
const LOCK = '';
|
||||
const UNLOCK = '';
|
||||
|
||||
function makeContext(overrides: Partial<RenderContext> = {}): RenderContext {
|
||||
return { ...overrides };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('SandboxStatusWidget', () => {
|
||||
describe('metadata', () => {
|
||||
it('has correct display name', () => {
|
||||
expect(new SandboxStatusWidget().getDisplayName()).toBe('Sandbox Status');
|
||||
});
|
||||
|
||||
it('has correct description', () => {
|
||||
expect(new SandboxStatusWidget().getDescription()).toBe([
|
||||
'Shows whether Claude Code bash sandbox mode is enabled',
|
||||
'Best effort: may not reflect active sandboxing when managed or CLI settings override it, or when sandbox initialization fails.'
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('has correct category', () => {
|
||||
expect(new SandboxStatusWidget().getCategory()).toBe('Core');
|
||||
});
|
||||
|
||||
it('has green default color', () => {
|
||||
expect(new SandboxStatusWidget().getDefaultColor()).toBe('green');
|
||||
});
|
||||
|
||||
it('supports raw value', () => {
|
||||
expect(new SandboxStatusWidget().supportsRawValue()).toBe(true);
|
||||
});
|
||||
|
||||
it('supports colors', () => {
|
||||
expect(new SandboxStatusWidget().supportsColors(ITEM)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor configuration', () => {
|
||||
it('exposes f and n keybinds', () => {
|
||||
expect(new SandboxStatusWidget().getCustomKeybinds()).toEqual([
|
||||
{ key: 'f', label: '(f)ormat', action: 'cycle-format' },
|
||||
{ key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the Nerd Font keybind only for glyph format', () => {
|
||||
const widget = new SandboxStatusWidget();
|
||||
const nerdFontKeybind = { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' };
|
||||
|
||||
expect(widget.getCustomKeybinds(ITEM)).toContainEqual(nerdFontKeybind);
|
||||
expect(widget.getCustomKeybinds({ ...ITEM, rawValue: true })).toContainEqual(nerdFontKeybind);
|
||||
expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format: 'text' } }))
|
||||
.not.toContainEqual(nerdFontKeybind);
|
||||
expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format: 'word' } }))
|
||||
.not.toContainEqual(nerdFontKeybind);
|
||||
});
|
||||
|
||||
it('defaults to glyph in the editor display', () => {
|
||||
expect(new SandboxStatusWidget().getEditorDisplay(ITEM)).toEqual({
|
||||
displayText: 'Sandbox Status',
|
||||
modifierText: '(glyph)'
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the configured format and nerd font in the editor display', () => {
|
||||
expect(new SandboxStatusWidget().getEditorDisplay({
|
||||
...ITEM,
|
||||
metadata: { nerdFont: 'true' }
|
||||
})).toEqual({
|
||||
displayText: 'Sandbox Status',
|
||||
modifierText: '(glyph, nerd font)'
|
||||
});
|
||||
});
|
||||
|
||||
it('hides stale Nerd Font metadata in text-only editor displays', () => {
|
||||
expect(new SandboxStatusWidget().getEditorDisplay({
|
||||
...ITEM,
|
||||
metadata: { format: 'word', nerdFont: 'true' }
|
||||
})).toEqual({
|
||||
displayText: 'Sandbox Status',
|
||||
modifierText: '(word)'
|
||||
});
|
||||
});
|
||||
|
||||
it('cycles glyph -> text -> word -> glyph', () => {
|
||||
const widget = new SandboxStatusWidget();
|
||||
const text = widget.handleEditorAction('cycle-format', ITEM);
|
||||
const word = widget.handleEditorAction('cycle-format', text ?? ITEM);
|
||||
const back = widget.handleEditorAction('cycle-format', word ?? ITEM);
|
||||
|
||||
expect(text?.metadata?.format).toBe('text');
|
||||
expect(word?.metadata?.format).toBe('word');
|
||||
expect(back?.metadata?.format).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears Nerd Font metadata when cycling to text format', () => {
|
||||
const item: WidgetItem = { ...ITEM, metadata: { nerdFont: 'true' } };
|
||||
const text = new SandboxStatusWidget().handleEditorAction('cycle-format', item);
|
||||
|
||||
expect(text?.metadata).toEqual({ format: 'text' });
|
||||
});
|
||||
|
||||
it('toggles nerd font metadata on and off', () => {
|
||||
const widget = new SandboxStatusWidget();
|
||||
const enabled = widget.handleEditorAction('toggle-nerd-font', ITEM);
|
||||
const disabled = widget.handleEditorAction('toggle-nerd-font', enabled ?? ITEM);
|
||||
|
||||
expect(enabled?.metadata?.nerdFont).toBe('true');
|
||||
expect(disabled?.metadata?.nerdFont).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not toggle Nerd Font in text-only formats', () => {
|
||||
const widget = new SandboxStatusWidget();
|
||||
const textItem: WidgetItem = { ...ITEM, metadata: { format: 'text' } };
|
||||
const staleWordItem: WidgetItem = {
|
||||
...ITEM,
|
||||
metadata: { format: 'word', nerdFont: 'true' }
|
||||
};
|
||||
|
||||
expect(widget.handleEditorAction('toggle-nerd-font', textItem)?.metadata)
|
||||
.toEqual({ format: 'text' });
|
||||
expect(widget.handleEditorAction('toggle-nerd-font', staleWordItem)?.metadata)
|
||||
.toEqual({ format: 'word' });
|
||||
});
|
||||
|
||||
it('returns null for unknown editor actions', () => {
|
||||
expect(new SandboxStatusWidget().handleEditorAction('unknown', ITEM)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('render() - format glyph (default), standard glyphs', () => {
|
||||
it('renders SB: ○ when OFF', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false });
|
||||
expect(new SandboxStatusWidget().render(ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('SB: ○');
|
||||
});
|
||||
|
||||
it('renders SB: ● when ON', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true });
|
||||
expect(new SandboxStatusWidget().render(ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('SB: ●');
|
||||
});
|
||||
});
|
||||
|
||||
describe('render() - format glyph, Nerd Font', () => {
|
||||
const NERD_ITEM: WidgetItem = { ...ITEM, metadata: { nerdFont: 'true' } };
|
||||
|
||||
it('renders the unlock glyph when OFF', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false });
|
||||
expect(new SandboxStatusWidget().render(NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(`SB: ${UNLOCK}`);
|
||||
});
|
||||
|
||||
it('renders the lock glyph when ON', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true });
|
||||
expect(new SandboxStatusWidget().render(NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(`SB: ${LOCK}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render() - format text', () => {
|
||||
const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'text' } };
|
||||
|
||||
it('renders SB: OFF when OFF', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false });
|
||||
expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('SB: OFF');
|
||||
});
|
||||
|
||||
it('renders SB: ON when ON', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true });
|
||||
expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('SB: ON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('render() - format word', () => {
|
||||
const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'word' } };
|
||||
|
||||
it('renders Sandbox: OFF when OFF', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false });
|
||||
expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('Sandbox: OFF');
|
||||
});
|
||||
|
||||
it('renders Sandbox: ON when ON', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true });
|
||||
expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('Sandbox: ON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('render() - sandbox config cwd', () => {
|
||||
it('uses the project dir for project-local Claude settings', () => {
|
||||
const context = makeContext({
|
||||
data: {
|
||||
cwd: '/repo/subdir',
|
||||
workspace: {
|
||||
current_dir: '/repo/current-dir',
|
||||
project_dir: '/repo'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
new SandboxStatusWidget().render(ITEM, context, DEFAULT_SETTINGS);
|
||||
|
||||
expect(claudeSettings.getSandboxConfig).toHaveBeenCalledWith('/repo');
|
||||
});
|
||||
|
||||
it('falls back to cwd when project dir is missing', () => {
|
||||
const context = makeContext({
|
||||
data: {
|
||||
cwd: '/repo/subdir',
|
||||
workspace: { current_dir: '/repo/current-dir' }
|
||||
}
|
||||
});
|
||||
|
||||
new SandboxStatusWidget().render(ITEM, context, DEFAULT_SETTINGS);
|
||||
|
||||
expect(claudeSettings.getSandboxConfig).toHaveBeenCalledWith('/repo/subdir');
|
||||
});
|
||||
|
||||
it('falls back to workspace.current_dir when project dir and cwd are missing', () => {
|
||||
const context = makeContext({ data: { workspace: { current_dir: '/repo/current-dir' } } });
|
||||
|
||||
new SandboxStatusWidget().render(ITEM, context, DEFAULT_SETTINGS);
|
||||
|
||||
expect(claudeSettings.getSandboxConfig).toHaveBeenCalledWith('/repo/current-dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('render() - preview mode', () => {
|
||||
it('renders the ON state for the default format', () => {
|
||||
expect(new SandboxStatusWidget().render(ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('SB: ●');
|
||||
});
|
||||
});
|
||||
|
||||
describe('render() - missing config', () => {
|
||||
it('returns null when getSandboxConfig returns null', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue(null);
|
||||
expect(new SandboxStatusWidget().render(ITEM, makeContext(), DEFAULT_SETTINGS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('render() - raw value', () => {
|
||||
const RAW_ITEM: WidgetItem = { ...ITEM, rawValue: true };
|
||||
const cases: { name: string; item: WidgetItem; off: string; on: string }[] = [
|
||||
{ name: 'glyph', item: RAW_ITEM, off: '○', on: '●' },
|
||||
{
|
||||
name: 'glyph with Nerd Font',
|
||||
item: { ...RAW_ITEM, metadata: { nerdFont: 'true' } },
|
||||
off: UNLOCK,
|
||||
on: LOCK
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
item: { ...RAW_ITEM, metadata: { format: 'text' } },
|
||||
off: 'OFF',
|
||||
on: 'ON'
|
||||
},
|
||||
{
|
||||
name: 'word',
|
||||
item: { ...RAW_ITEM, metadata: { format: 'word' } },
|
||||
off: 'OFF',
|
||||
on: 'ON'
|
||||
}
|
||||
];
|
||||
|
||||
it.each(cases)('removes only the label from $name format', ({ item, off, on }) => {
|
||||
const configSpy = vi.spyOn(claudeSettings, 'getSandboxConfig');
|
||||
|
||||
configSpy.mockReturnValue({ enabled: false });
|
||||
expect(new SandboxStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(off);
|
||||
|
||||
configSpy.mockReturnValue({ enabled: true });
|
||||
expect(new SandboxStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(on);
|
||||
});
|
||||
|
||||
it('preserves the default glyph format in preview mode', () => {
|
||||
expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('●');
|
||||
});
|
||||
|
||||
it('returns null when config is null', () => {
|
||||
vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue(null);
|
||||
expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,8 +68,11 @@ describe('SessionUsageWidget', () => {
|
||||
baseItem: { id: 'session', type: 'session-usage' },
|
||||
createWidget: () => new SessionUsageWidget(),
|
||||
errorMessageMock: usageErrorMessageMock,
|
||||
expectedModifierText: '(medium bar, inverted)',
|
||||
expectedInvertedTime: 'Session: 76.5%',
|
||||
expectedModifierText: '(medium bar, remaining)',
|
||||
expectedPreviewInvertedTime: 'Session: 80.0%',
|
||||
expectedProgress: 'Session: [████████████░░░░] 76.5%',
|
||||
expectedRawInvertedTime: '76.5%',
|
||||
expectedRawProgress: '[████████░░░░░░░░░░░░░░░░░░░░░░░░] 23.4%',
|
||||
expectedRawTime: '23.4%',
|
||||
expectedTime: 'Session: 23.4%',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user