Compare commits
95 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 | |||
| 29895206d7 | |||
| bfe86ab904 | |||
| 3c1f472598 | |||
| 2fc4921b77 | |||
| 6126ff75f2 | |||
| dd820ea42f | |||
| 7db0914856 | |||
| 2d85849476 | |||
| df25207d18 | |||
| 8ae6481f25 | |||
| 887f786875 | |||
| 3df04e168e | |||
| 28d98e320c | |||
| 04b4026f1d | |||
| bc34c3a97e | |||
| 96cb608294 | |||
| 3058693a64 | |||
| a221e8ab2c | |||
| 447e0e69eb | |||
| 97108fc1ea | |||
| 54fce0903e | |||
| 9d4b6ee4f9 | |||
| cd2e41adc8 | |||
| 4b314d5fce | |||
| cc8e82f476 | |||
| 1b7cfb010b | |||
| ea97ddd69e | |||
| 6a581e6c6a | |||
| 654802e4d6 | |||
| cf5ab7be45 | |||
| 861a2a0638 | |||
| 4091574f52 | |||
| 196a370a2e | |||
| 29908c266a | |||
| b01336b1a7 | |||
| 9eccca279e | |||
| 9ab87c3f55 | |||
| 1739572a96 | |||
| b8821a7d83 | |||
| ed3ce5ba0b | |||
| ec283761d4 | |||
| f198e72a62 | |||
| 29c47242a8 | |||
| 23b01a3877 | |||
| 0ae20bea5e | |||
| 456cc46de7 | |||
| a69e09a9a9 | |||
| 0a749bb1ef | |||
| bbd19313cc | |||
| ad9519e1b9 | |||
| af87d5594b | |||
| a1a47ab9c5 | |||
| fb0be05f53 | |||
| de374741d0 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'sirmalloc/ccstatusline'
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
package-manager-cache: false
|
||||
- run: bun install
|
||||
- run: bun run lint
|
||||
- run: bun test
|
||||
- run: npm publish
|
||||
- name: Create GitHub release
|
||||
run: >
|
||||
gh release create "$GITHUB_REF_NAME"
|
||||
--title "$GITHUB_REF_NAME"
|
||||
--generate-notes
|
||||
--notes "Published to npm: https://www.npmjs.com/package/ccstatusline"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -99,7 +99,7 @@ All widgets must implement:
|
||||
- `isKnownWidgetType()`: Validates if a type is registered
|
||||
|
||||
**Available Widgets:**
|
||||
- Model, Version, OutputStyle - Claude Code metadata display
|
||||
- Model, Version, OutputStyle, VoiceStatus - Claude Code metadata display
|
||||
- GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status
|
||||
- TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics
|
||||
- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models)
|
||||
|
||||
@@ -47,24 +47,82 @@
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
### v2.3.0 - Activity widgets and transcript-driven telemetry
|
||||
### v2.2.22 - v2.2.24 - Powerline flex mode, cache/CI/sandbox visibility, layout controls, composable metrics, and safer config
|
||||
|
||||
- **🏃 New Activity widgets** - Added **All Activity**, **Tools Activity**, **Agents Activity**, and **Todo Progress** widgets for live workflow visibility.
|
||||
- **🧠 Transcript activity parsing** - Parses `tool_use`/`tool_result`, `Task` subagent calls, and todo updates (`TodoWrite`, `TaskCreate`, `TaskUpdate`) from Claude transcript JSONL.
|
||||
- **📏 Activity widget width control** - Activity widgets support per-widget max-width editing with **(w)**, including clearing truncation.
|
||||

|
||||
|
||||
### v2.2.9 - v2.2.11 - GitLab support, reset timers, context, compaction, and git widgets
|
||||
- **⏳ 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.
|
||||
- **🔁 Compaction counter details** - `Compaction Counter` now counts explicit `compact_boundary` markers and can optionally show trigger splits plus tokens reclaimed.
|
||||
- **💸 Extra usage improvements** - Added `Extra Usage Used` and formats extra usage amounts with the billing currency reported by the usage API.
|
||||
- **📏 Custom command width context** - `Custom Command` widgets receive `terminal_width` in stdin JSON when ccstatusline can detect the terminal width.
|
||||
- **🧠 Prompt cache widgets** - Added `Cache Hit Rate`, `Cache Read`, and `Cache Write` widgets with turn/session scopes and hide-when-empty behavior.
|
||||
- **🔢 Token rounding fix** - Token counts from `999950` through `999999` now render as `1.0M` instead of `1000.0k`.
|
||||
|
||||
### v2.2.20 - Gradients, token accuracy, usage reliability, and Git PR/MR fixes
|
||||
|
||||
- **🌈 Gradient colors** - Added per-widget and whole-line foreground gradients with named presets, custom hex stops, TUI picker support, and Powerline-aware rendering. Press `g` on the Edit Colors screen for widget gradients, or press `g` for Override FG Color in Global Overrides for a line-wide gradient.
|
||||
- **🎯 More accurate token counts** - `Tokens Input` and `Tokens Output` now prefer cumulative transcript metrics before falling back to context-window totals.
|
||||
- **💸 Extra usage no-limit fix** - Extra usage widgets no longer get stuck on `[Timeout]` for accounts with overage enabled but no monthly limit configured.
|
||||
- **🔁 Compaction glitch filtering** - `Compaction Counter` ignores transient below-1% context readings so incomplete status frames do not create false compaction counts.
|
||||
- **🔀 SSH alias Git PR/MR detection** - Git PR/MR detection resolves SSH host aliases while preserving canonical GitHub and GitLab hosts for CLI selection and fallback repo links.
|
||||
- **🧪 Usage test cache isolation** - Usage-fetch test probes now isolate `HOME`, `USERPROFILE`, `CLAUDE_CONFIG_DIR`, and proxy variables so local tests cannot touch the real ccstatusline cache.
|
||||
- **📦 Dependency refresh** - Refreshed React/React DOM and Bun lockfile development-tooling resolutions for the release.
|
||||
|
||||
### v2.2.14 - v2.2.19 - Version pinning, npm provenance, usage overage widgets, and Git lock avoidance
|
||||
|
||||
- **📌 Version pinning support** - Added support for pinned global installs so Claude Code can keep running a specific ccstatusline version.
|
||||
- **🔐 npm provenance attestations** - Published packages now use trusted publishing provenance so users can verify where releases were built while avoiding long-lived npm publish tokens.
|
||||
- **🔄 Moving from auto-update installs** - If you currently use an auto-updating install, use the TUI uninstall option first, then reinstall to go through the version pinning flow. Your ccstatusline settings are preserved when uninstalling.
|
||||
- **💸 Extra usage widgets** - Added Extra Usage Utilization and Extra Usage Remaining widgets for monthly pay-as-you-go overage limits, with null rate-limit buckets handled as zero usage.
|
||||
- **🔒 Git lock avoidance** - Git helpers now pass `--no-optional-locks` so background status checks avoid creating `index.lock` races.
|
||||
- **🧱 Older Git compatibility** - Git widgets avoid newer command forms so repository status works on older Git installations.
|
||||
- **⚡ Persistent Git cache** - Git command output is cached under `~/.cache/ccstatusline/git-cache` with configurable TTL and `.git/HEAD`/`.git/index` mtime checks to reduce repeated subprocess work.
|
||||
- **🧭 Install flow polish** - Pinned global install is now the default install option, with clearer wording for install and migration flows.
|
||||
- **🪟 Hidden helper processes** - Runtime child processes set `windowsHide` so helper commands do not open extra windows on Windows.
|
||||
- **📏 Terminal width override** - `CCSTATUSLINE_WIDTH` can provide an explicit terminal width when automatic probing is unavailable.
|
||||
|
||||
### v2.2.13 - Weekly model usage, voice status, hooks, and docs
|
||||
|
||||
- **📊 Weekly Sonnet/Opus usage widgets** - Added separate weekly usage widgets for Sonnet and Opus API buckets, matching Claude Code's `/usage` model split.
|
||||
- **🎤 Voice Status widget** - Added a widget that shows whether Claude Code voice input is enabled, with icon, text, word, and optional Nerd Font display modes.
|
||||
- **📉 Timer short bars** - Block Timer, Block Reset Timer, and Weekly Reset Timer now support compact short-bar progress displays.
|
||||
- **🔕 Quieter hook output** - Hook handling now suppresses no-op JSON output so non-status updates stay silent.
|
||||
|
||||
### v2.2.9 - v2.2.12 - GitLab support, reset timers, context, compaction, and git widgets
|
||||
|
||||
- **🦊 GitLab PR/MR support** - `Git Branch` and `Git PR/MR` now support GitHub, GitLab, and compatible self-hosted remotes, using `gh` or `glab` as appropriate.
|
||||
- **🔄 Status line refresh interval** - Installed configs can set Claude Code's `statusLine.refreshInterval` from the TUI when Claude Code >=2.1.97 supports it.
|
||||
- **🧭 Wrap-around TUI navigation** - Menu/list navigation and move/reorder modes now wrap at the first and last items.
|
||||
- **📋 Clone widget shortcut** - Press `k` in the item editor to duplicate the selected widget, with fresh Powerline background color for cloned Powerline items.
|
||||
- **📊 Short bar display modes** - Context percentage, Context Bar, Session Usage, Weekly Usage, Block Timer, and reset timer widgets can use compact bar variants.
|
||||
- **⏱️ Usage time cursor** - Session Usage and Weekly Usage progress bars can show the elapsed time position within the current usage window.
|
||||
- **🕒 Reset timer timestamps** - Block and Weekly Reset Timer widgets can show exact reset timestamps with compact formatting, 12/24-hour display, IANA time zones, and locale selection.
|
||||
- **🪟 Context Window widget** - Added a `Context Window` widget for total model window size, keeping `Context Length` focused on current context usage.
|
||||
- **🔁 Compaction Counter widget** - Added a `Compaction Counter` widget that tracks session context compactions, with icon/text/number formats, optional Nerd Font icon, and hide-when-zero behavior.
|
||||
- **🧮 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.
|
||||
- **🧠 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.
|
||||
- **🏷️ 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. 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.
|
||||
@@ -80,6 +138,10 @@
|
||||
- **📏 Better terminal width detection** - Flex separators and right-alignment now work more reliably when ccstatusline is launched through wrapper processes or nested PTYs.
|
||||
- **🎨 Powerline theme continuity** - Built-in Powerline themes can now continue colors cleanly across multiple status lines instead of restarting each line.
|
||||
|
||||
<br />
|
||||
<details>
|
||||
<summary><b>Older updates (v2.2.6 and earlier)</b></summary>
|
||||
|
||||
### v2.2.0 - v2.2.6 - Speed, widgets, links, and reliability updates
|
||||
|
||||
- **🚀 New Token Speed widgets** - Added three widgets: **Input Speed**, **Output Speed**, and **Total Speed**.
|
||||
@@ -94,10 +156,6 @@
|
||||
- **🔗 Git widget link modes (v2.2.6)** - `Git Branch` can render clickable GitHub branch links, and `Git Root Dir` can render clickable IDE links for VS Code and Cursor.
|
||||
- **🤝 Better subagent-aware speed reporting** - Token speed calculations continue to include referenced subagent activity so displayed speeds better reflect actual concurrent work.
|
||||
|
||||
<br />
|
||||
<details>
|
||||
<summary><b>Older updates (v2.1.10 and earlier)</b></summary>
|
||||
|
||||
### v2.1.0 - v2.1.10 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes
|
||||
|
||||
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Block Reset Timer**, and **Context Bar** widgets.
|
||||
@@ -188,7 +246,7 @@
|
||||
### v2.0.0 - Powerline Support & Enhanced Themes
|
||||
- **⚡ Powerline Mode** - Beautiful Powerline-style status lines with arrow separators and customizable caps
|
||||
- **🎨 Built-in Themes** - Multiple pre-configured themes that you can copy and customize
|
||||
- **🌈 Advanced Color Support** - Basic (16), 256-color (with custom ANSI codes), and truecolor (with hex codes) modes
|
||||
- **🌈 Advanced Color Support** - Basic (16), 256-color (with custom ANSI codes), and truecolor (with hex codes) modes, plus multi-stop **gradients** (per-widget or spanning the whole line)
|
||||
- **🔗 Widget Merging** - Merge multiple widgets together with or without padding for seamless designs
|
||||
- **📦 Easy Installation** - Install directly with `npx` or `bunx` - no global package needed
|
||||
- **🔤 Custom Separators** - Add multiple Powerline separators with custom hex codes for font support
|
||||
@@ -200,7 +258,7 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **📊 Real-time Metrics** - Display model name, git branch, token usage, session duration, compaction count, block timer, and more
|
||||
- **📊 Real-time Metrics** - Display model name, git branch, token usage, per-model weekly usage, extra usage limits, voice input state, session duration, compaction count, block timer, and more
|
||||
- **🎨 Fully Customizable** - Choose what to display and customize colors for each element
|
||||
- **⚡ Powerline Support** - Beautiful Powerline-style rendering with arrow separators, caps, and custom fonts
|
||||
- **📐 Multi-line Support** - Configure multiple independent status lines
|
||||
@@ -234,6 +292,8 @@ npx -y ccstatusline@latest
|
||||
bunx -y ccstatusline@latest
|
||||
```
|
||||
|
||||
Both commands launch the same TUI. During the initial setup flow, choose **Pinned global install** if you want Claude Code to stay on the ccstatusline version you are running instead of following `@latest`; the TUI will install that version globally with npm or Bun and write the pinned `ccstatusline` command to Claude Code settings. After a pinned install, you can run `ccstatusline` directly to launch the TUI in the future.
|
||||
|
||||
<br />
|
||||
<details>
|
||||
<summary><b>Configure ccstatusline</b></summary>
|
||||
@@ -284,6 +344,8 @@ Other supported command values are:
|
||||
- `bunx -y ccstatusline@latest`
|
||||
- `ccstatusline` (for self-managed/global installs)
|
||||
|
||||
For pinned installs, launch the TUI with `npx -y ccstatusline@latest` or `bunx -y ccstatusline@latest`, then choose **Pinned global install**. The TUI pins the active version by installing it globally and writing `"command": "ccstatusline"` to `settings.json`; afterward, you can run `ccstatusline` directly to open the TUI.
|
||||
|
||||
</details>
|
||||
|
||||
## 🤝 Contributing
|
||||
@@ -318,10 +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
|
||||
|
||||
|
||||
+15
-1
@@ -51,14 +51,28 @@ bun run docs
|
||||
- `~/.config/ccstatusline/settings.json` - ccstatusline UI/render settings
|
||||
- `~/.claude/settings.json` - Claude Code settings (`statusLine` command object)
|
||||
- `~/.cache/ccstatusline/block-cache-*.json` - block timer cache (keyed by Claude config directory hash)
|
||||
- `~/.cache/ccstatusline/compaction/compaction-*.json` - per-session compaction counter state
|
||||
- `~/.cache/ccstatusline/git-cache/git-*.json` - persistent git widget command cache
|
||||
- `~/.cache/ccstatusline/git-review/git-review-*.json` - cached Git PR/MR lookup results
|
||||
- `~/.cache/ccstatusline/usage.json` and `~/.cache/ccstatusline/usage.lock` - usage API data cache and fetch backoff lock
|
||||
|
||||
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
|
||||
|
||||
## API Documentation
|
||||
|
||||
|
||||
+117
-25
@@ -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
|
||||
@@ -25,19 +29,15 @@ 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.
|
||||
- **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 `?`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### Activity
|
||||
|
||||
- **All Activity** - Show a compact running-first summary across tools, agents, and todos, with completion counters.
|
||||
- **Tools Activity** - Show currently running and recently completed tool operations.
|
||||
- **Agents Activity** - Show running and recently completed Claude subagent tasks.
|
||||
- **Todo Progress** - Show in-progress todo focus and completion ratio from transcript tasks.
|
||||
|
||||
### 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`); 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.
|
||||
@@ -47,17 +47,19 @@ bun run example
|
||||
|
||||
### Tokens, Usage & Context
|
||||
|
||||
- **Tokens Input** / **Tokens Output** / **Tokens Cached** / **Tokens Total** - Show current-session token counts.
|
||||
- **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, usage percentage, usable-window percentage, or a progress bar.
|
||||
- **Compaction Counter** - Show how many context compactions have been detected in the current session. It can render as icon plus number, text plus number, or number-only, and can hide while the count is zero.
|
||||
- **Session Usage** / **Weekly Usage** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages plus current block/reset timing. 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
|
||||
|
||||
@@ -66,6 +68,20 @@ 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
|
||||
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:
|
||||
@@ -74,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.
|
||||
|
||||
@@ -89,9 +107,10 @@ Configure global formatting preferences that apply to all widgets:
|
||||
- Press **(o)** to toggle
|
||||
- **Minimalist Mode** - Force widgets into raw-value rendering globally for a cleaner, label-free status line
|
||||
- Press **(m)** to toggle
|
||||
- **Override Foreground Color** - Force all widgets to use the same text color
|
||||
- **Override Foreground Color** - Force all widgets to use the same text color, or a whole-line **gradient** (see below)
|
||||
- Press **(f)** to cycle through colors
|
||||
- Press **(g)** to clear override
|
||||
- Press **(g)** to choose a gradient
|
||||
- Press **(x)** to clear override
|
||||
- **Override Background Color** - Force all widgets to use the same background color
|
||||
- Press **(b)** to cycle through colors
|
||||
- Press **(c)** to clear override
|
||||
@@ -102,10 +121,39 @@ 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:`:
|
||||
|
||||
- **Named preset** — `gradient:atlas` (case-insensitive). Built-in presets: `atlas`, `cristal`, `teen`, `mind`, `morning`, `vice`, `passion`, `fruit`, `instagram`, `retro`, `summer`, `rainbow`, `pastel`.
|
||||
- **Dash stops** — `gradient:RRGGBB-RRGGBB[-RRGGBB...]` (two or more bare or `#`-prefixed hex stops).
|
||||
- **Comma stops** — `gradient:hex:RRGGBB,#RRGGBB,RRGGBB` (two or more `hex:`/`#`/bare stops).
|
||||
|
||||
Gradients apply at **two scopes**:
|
||||
|
||||
- **Per-widget** — set a widget's color to a gradient so its text carries its own self-contained sweep. In the color menu (foreground, 256-color or truecolor mode), press **(g)** to open the gradient picker, then choose a preset or enter custom start/end hex stops.
|
||||
- **Whole line** — set `overrideForegroundColor` to a gradient spec to paint the entire status line with one continuous sweep, each character colored by its column position. In the Global Overrides menu, press **(g)** on Override FG Color to open the same gradient picker, or author the value directly in `settings.json`.
|
||||
|
||||
Gradients self-degrade where they can't render: at Basic or No Color levels, gradient settings are preserved but render as plain text. In Powerline mode, global foreground gradients color widget text while separators and caps keep Powerline's normal foreground/background contrast rules; per-widget gradients collapse to their first stop when using 256-color or truecolor output.
|
||||
|
||||
## Claude Code Status Line Settings
|
||||
|
||||
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:
|
||||
@@ -147,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:
|
||||
@@ -158,23 +207,27 @@ 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
|
||||
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted)
|
||||
- **Git Root Dir**: `l` cycle IDE links (`off` → `VS Code` → `Cursor`)
|
||||
- **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), `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**: `p` cycle percentage/full bar/medium bar/short bar/short bar only, `v` invert fill in progress mode
|
||||
- **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, `h` hide when zero
|
||||
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
|
||||
- **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, `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
|
||||
- **All Activity / Tools Activity / Agents Activity / Todo Progress**: `w` edit max width (set `0` or blank to disable truncation)
|
||||
- **Custom Text / Custom Symbol**: `e` edit text or symbol
|
||||
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
|
||||
- **Link**: `u` URL, `e` link text
|
||||
@@ -201,6 +254,7 @@ Add a single symbol or emoji to your status line when you want a compact visual
|
||||
Execute shell commands and display their output dynamically:
|
||||
- Refreshes whenever the statusline is updated by Claude Code
|
||||
- Receives the full Claude Code JSON data via stdin (model info, session ID, transcript path, etc.)
|
||||
- Also includes `terminal_width` — the detected terminal width in columns, added by ccstatusline (omitted when it can't be determined) — so scripts can adapt their output to the available space
|
||||
- Displays command output inline in your status line
|
||||
- Configurable timeout (default: 1000ms)
|
||||
- Optional max-width truncation
|
||||
@@ -212,10 +266,11 @@ 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).
|
||||
|
||||
> 💡 **Tip:** Custom commands can be other Claude Code compatible status line formatters. They receive the same JSON via stdin that `ccstatusline` receives from Claude Code, allowing you to chain or combine multiple status line tools.
|
||||
> 💡 **Tip:** Custom commands can be other Claude Code compatible status line formatters. They receive the same JSON via stdin that `ccstatusline` receives from Claude Code (augmented with a `terminal_width` field), allowing you to chain or combine multiple status line tools.
|
||||
|
||||
### Link Widget
|
||||
|
||||
@@ -237,6 +292,43 @@ Create clickable links in terminals that support OSC 8 hyperlinks:
|
||||
|
||||
> 📄 **How it works:** The command receives Claude Code's JSON data via stdin, allowing ccusage to access session information, model details, and transcript data for accurate usage tracking.
|
||||
|
||||
## Integration Example: AIWatch
|
||||
|
||||
[AIWatch](https://ai-watch.dev) monitors the live status of 30+ AI APIs and apps (Claude, GPT, Gemini, …). Surfacing it in your status line answers "is Claude slow because of me, or because the API is degraded?" without leaving the terminal.
|
||||
|
||||
1. Add a Custom Command widget
|
||||
2. Set command:
|
||||
|
||||
```bash
|
||||
( curl -sf --max-time 2 https://ai-watch.dev/api/status/cached | jq -r '[.services[] | select(.status != "operational") | "🔴 " + .name] | .[0:3] | join(" ")' ) 2>/dev/null || true
|
||||
```
|
||||
|
||||
3. Set timeout: `2000` (the `curl` itself caps at 2s; the outer `|| true` keeps the widget silent on any failure)
|
||||
4. Leave "preserve colors" off — the output is a plain emoji + name list
|
||||
|
||||
When every tracked service is operational the command prints nothing, so the widget renders empty and manual separators collapse around it. The endpoint is JSON, CORS-enabled, and served with a ~5-minute cache.
|
||||
|
||||
> 📄 **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.
|
||||
|
||||
+25
-6
@@ -21,14 +21,10 @@ bunx -y ccstatusline@latest
|
||||
```powershell
|
||||
# Using npm
|
||||
npx -y ccstatusline@latest
|
||||
|
||||
# Or with Yarn
|
||||
yarn dlx ccstatusline@latest
|
||||
|
||||
# Or with pnpm
|
||||
pnpm dlx ccstatusline@latest
|
||||
```
|
||||
|
||||
These commands launch the configuration TUI. To pin the install, start the TUI with `bunx -y ccstatusline@latest` or `npx -y ccstatusline@latest`, then choose **Pinned global install**. The TUI installs the active ccstatusline version globally with Bun or npm and configures Claude Code to run `ccstatusline`. After a pinned install, you can run `ccstatusline` directly to launch the TUI in the future.
|
||||
|
||||
## Claude Code Integration
|
||||
|
||||
Configure `ccstatusline` in your Claude Code settings:
|
||||
@@ -71,8 +67,21 @@ $env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
|
||||
|
||||
`refreshInterval` is optional and only supported by Claude Code >=2.1.97. You can configure it from ccstatusline's TUI after installation.
|
||||
|
||||
The `bunx` and `npx` examples above follow `@latest`. If you choose **Pinned global install** in the TUI, it writes `"command": "ccstatusline"` instead after installing the selected version globally. You can also run `ccstatusline` directly for future TUI launches.
|
||||
|
||||
## 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:
|
||||
@@ -108,6 +117,16 @@ winget install DEVCOM.JetBrainsMonoNerdFont
|
||||
|
||||
### Common Issues & Solutions
|
||||
|
||||
**Issue**: Status lines wrap because terminal width cannot be detected
|
||||
|
||||
```powershell
|
||||
# Set an explicit width before launching Claude Code
|
||||
$env:CCSTATUSLINE_WIDTH="160"
|
||||
claude
|
||||
```
|
||||
|
||||
`CCSTATUSLINE_WIDTH` accepts a positive integer column width and is checked before automatic width detection. Set it in the same environment that starts Claude Code so the status line command inherits it. This is useful on Windows because native width probing is disabled when ccstatusline runs outside WSL.
|
||||
|
||||
**Issue**: Powerline symbols showing as question marks or boxes
|
||||
|
||||
```powershell
|
||||
|
||||
+23
-4
@@ -1,11 +1,22 @@
|
||||
{
|
||||
"name": "ccstatusline",
|
||||
"version": "2.2.11",
|
||||
"version": "2.2.25",
|
||||
"bugs": {
|
||||
"url": "https://github.com/sirmalloc/ccstatusline/issues"
|
||||
},
|
||||
"description": "A customizable status line formatter for Claude Code CLI",
|
||||
"module": "src/ccstatusline.ts",
|
||||
"main": "./dist/ccstatusline.js",
|
||||
"module": "./dist/ccstatusline.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ccstatusline": "dist/ccstatusline.js"
|
||||
"ccstatusline": "./dist/ccstatusline.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/ccstatusline.js",
|
||||
"default": "./dist/ccstatusline.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
@@ -15,6 +26,10 @@
|
||||
"build": "rm -rf dist/* ; bun build src/ccstatusline.ts --target=node --outfile=dist/ccstatusline.js --target-version=14",
|
||||
"postbuild": "bun run scripts/replace-version.ts",
|
||||
"example": "cat scripts/payload.example.json | bun start",
|
||||
"video:studio": "remotion studio remotion/index.ts",
|
||||
"video:still": "remotion still remotion/index.ts ccstatusline-tui-demo out/ccstatusline-tui-demo.png --frame=700 --scale=0.5",
|
||||
"video:render": "remotion render remotion/index.ts ccstatusline-tui-demo out/ccstatusline-tui-demo.mp4",
|
||||
"video:gif": "bun run video:render && ffmpeg -y -i out/ccstatusline-tui-demo.mp4 -filter_complex \"fps=12,scale=1322:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=256[p];[s1][p]paletteuse=dither=none:diff_mode=rectangle\" out/ccstatusline-tui-demo-unoptimized.gif && gifsicle -O3 --lossy=30 out/ccstatusline-tui-demo-unoptimized.gif -o out/ccstatusline-tui-demo.gif",
|
||||
"prepublishOnly": "bun run build",
|
||||
"lint": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0",
|
||||
"lint:fix": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0 --fix",
|
||||
@@ -23,10 +38,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@remotion/cli": "^4.0.459",
|
||||
"@stylistic/eslint-plugin": "^5.2.3",
|
||||
"@types/bun": "latest",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"chalk": "^5.5.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
@@ -40,8 +57,10 @@
|
||||
"ink-gradient": "^4.0.0",
|
||||
"ink-select-input": "^6.2.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react": "19.2.7",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
"remotion": "^4.0.459",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"typedoc": "^0.28.12",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
|
||||
import { RemotionRoot } from './root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Composition } from 'remotion';
|
||||
|
||||
import { TUIDemo } from './tuiDemo';
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Composition
|
||||
id='ccstatusline-tui-demo'
|
||||
component={TUIDemo}
|
||||
durationInFrames={1470}
|
||||
fps={30}
|
||||
width={1322}
|
||||
height={862}
|
||||
/>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 6.7 MiB After Width: | Height: | Size: 5.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
+72
-92
@@ -10,20 +10,23 @@ import type {
|
||||
import type { RenderContext } from './types/RenderContext';
|
||||
import type { StatusJSON } from './types/StatusJSON';
|
||||
import { StatusJSONSchema } from './types/StatusJSON';
|
||||
import { getActivitySnapshot } from './utils/activity';
|
||||
import { getVisibleText } from './utils/ansi';
|
||||
import { updateColorMap } from './utils/colors';
|
||||
import {
|
||||
detectCompaction,
|
||||
loadCompactionState,
|
||||
saveCompactionState
|
||||
ZERO_COMPACTION_STATS,
|
||||
getCompactionStats
|
||||
} from './utils/compaction';
|
||||
import {
|
||||
getConfigLoadError,
|
||||
initConfigPath,
|
||||
loadSettings,
|
||||
saveSettings
|
||||
} from './utils/config';
|
||||
import { calculateContextPercentageMetrics } from './utils/context-percentage';
|
||||
import {
|
||||
GIT_REVIEW_REFRESH_FLAG,
|
||||
refreshGitReviewCacheFromCli
|
||||
} from './utils/git-review-cache';
|
||||
import { handleHookInput } from './utils/hook-handler';
|
||||
import {
|
||||
getSessionDuration,
|
||||
getSpeedMetricsCollection,
|
||||
@@ -31,19 +34,22 @@ import {
|
||||
} from './utils/jsonl';
|
||||
import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index';
|
||||
import {
|
||||
buildConfigWarningBadge,
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
countPowerlineStartCapSlots,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine
|
||||
} from './utils/renderer';
|
||||
import { advanceGlobalSeparatorIndex } from './utils/separator-index';
|
||||
import {
|
||||
getSkillsFilePath,
|
||||
getSkillsMetrics
|
||||
} from './utils/skills';
|
||||
import { getSkillsMetrics } from './utils/skills';
|
||||
import {
|
||||
getWidgetSpeedWindowSeconds,
|
||||
isWidgetSpeedWindowEnabled
|
||||
} from './utils/speed-window';
|
||||
import {
|
||||
getPackageVersion,
|
||||
getTerminalWidth
|
||||
} from './utils/terminal';
|
||||
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
|
||||
|
||||
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
|
||||
@@ -51,17 +57,6 @@ function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
|
||||
return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0;
|
||||
}
|
||||
|
||||
const ACTIVITY_WIDGET_TYPES = new Set([
|
||||
'tools-activity',
|
||||
'agents-activity',
|
||||
'todo-progress',
|
||||
'activity'
|
||||
]);
|
||||
|
||||
function hasActivityWidgets(lines: { type?: string }[][]): boolean {
|
||||
return lines.some(line => line.some(item => typeof item.type === 'string' && ACTIVITY_WIDGET_TYPES.has(item.type)));
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
|
||||
if (process.stdin.isTTY) {
|
||||
@@ -98,7 +93,7 @@ async function ensureWindowsUtf8CodePage() {
|
||||
|
||||
try {
|
||||
const { execFileSync } = await import('child_process');
|
||||
execFileSync('chcp.com', ['65001'], { stdio: 'ignore' });
|
||||
execFileSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true });
|
||||
} catch {
|
||||
// Ignore failures to preserve statusline output even in restricted shells.
|
||||
}
|
||||
@@ -106,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;
|
||||
@@ -159,28 +155,10 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
skillsMetrics = getSkillsMetrics(data.session_id);
|
||||
}
|
||||
|
||||
// Compaction detection — track context percentage drops between renders
|
||||
let compactionCount = 0;
|
||||
// Compaction stats — parse compact_boundary markers in this session's transcript
|
||||
const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter'));
|
||||
if (hasCompactionWidget && data.session_id) {
|
||||
const prevState = loadCompactionState(data.session_id);
|
||||
compactionCount = prevState.count;
|
||||
const contextPercentageMetrics = calculateContextPercentageMetrics({ data, tokenMetrics });
|
||||
if (contextPercentageMetrics !== null) {
|
||||
const newState = detectCompaction(contextPercentageMetrics.usedPercentage, prevState, { windowSize: contextPercentageMetrics.windowSize });
|
||||
if (
|
||||
newState.count !== prevState.count
|
||||
|| newState.prevCtxPct !== prevState.prevCtxPct
|
||||
|| newState.prevWindowSize !== prevState.prevWindowSize
|
||||
) {
|
||||
saveCompactionState(data.session_id, newState);
|
||||
}
|
||||
compactionCount = newState.count;
|
||||
}
|
||||
}
|
||||
|
||||
const activity = hasActivityWidgets(lines)
|
||||
? getActivitySnapshot(data.transcript_path)
|
||||
const compactionData = hasCompactionWidget
|
||||
? (data.transcript_path ? await getCompactionStats(data.transcript_path) : ZERO_COMPACTION_STATS)
|
||||
: null;
|
||||
|
||||
// Create render context
|
||||
@@ -192,10 +170,12 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
usageData,
|
||||
sessionDuration,
|
||||
skillsMetrics,
|
||||
compactionData: hasCompactionWidget ? { count: compactionCount } : null,
|
||||
activity,
|
||||
compactionData,
|
||||
terminalWidth: getTerminalWidth(),
|
||||
isPreview: false,
|
||||
minimalist: settings.minimalistMode
|
||||
minimalist: settings.minimalistMode,
|
||||
gitCacheTtlSeconds: settings.gitCacheTtlSeconds,
|
||||
gitReviewNeedsChecks: lines.some(line => line.some(item => item.type === 'git-ci-status'))
|
||||
};
|
||||
|
||||
// Always pre-render all widgets once (for efficiency)
|
||||
@@ -205,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) {
|
||||
@@ -213,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');
|
||||
|
||||
@@ -228,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);
|
||||
}
|
||||
@@ -236,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() !== ''
|
||||
@@ -279,58 +276,41 @@ function parseConfigArg(): string | undefined {
|
||||
return configPath;
|
||||
}
|
||||
|
||||
interface HookInput {
|
||||
session_id?: string;
|
||||
hook_event_name?: string;
|
||||
tool_name?: string;
|
||||
tool_input?: { skill?: string };
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
async function handleHook(): Promise<void> {
|
||||
const input = await readStdin();
|
||||
if (!input) {
|
||||
console.log('{}');
|
||||
return;
|
||||
handleHookInput(input);
|
||||
}
|
||||
|
||||
function handleGitReviewRefresh(): boolean {
|
||||
const flagIndex = process.argv.indexOf(GIT_REVIEW_REFRESH_FLAG);
|
||||
if (flagIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(input) as HookInput;
|
||||
const sessionId = data.session_id;
|
||||
if (!sessionId) {
|
||||
console.log('{}');
|
||||
return;
|
||||
}
|
||||
|
||||
let skillName = '';
|
||||
if (data.hook_event_name === 'PreToolUse' && data.tool_name === 'Skill') {
|
||||
skillName = data.tool_input?.skill ?? '';
|
||||
} else if (data.hook_event_name === 'UserPromptSubmit') {
|
||||
const match = /^\/([a-zA-Z0-9_:-]+)(?:\s|$)/.exec(data.prompt ?? '');
|
||||
if (match) {
|
||||
skillName = match[1] ?? '';
|
||||
}
|
||||
}
|
||||
if (!skillName) {
|
||||
console.log('{}');
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
const filePath = getSkillsFilePath(sessionId);
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const entry = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: sessionId,
|
||||
skill: skillName,
|
||||
source: data.hook_event_name
|
||||
});
|
||||
fs.appendFileSync(filePath, entry + '\n');
|
||||
} catch { /* ignore parse errors */ }
|
||||
console.log('{}');
|
||||
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());
|
||||
|
||||
|
||||
+842
-65
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,38 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type InstallationMetadata
|
||||
} from '../../types/Settings';
|
||||
import {
|
||||
buildConfigLoadWarning,
|
||||
buildInvalidConfigSaveConfirm,
|
||||
clearInstallMenuSelection,
|
||||
getConfirmCancelScreen
|
||||
getConfirmCancelScreen,
|
||||
getCurrentInstallation,
|
||||
getPathInferredInstallation,
|
||||
getPinnedVersionMismatch
|
||||
} from '../App';
|
||||
import {
|
||||
buildMainMenuItems,
|
||||
getMainMenuInstallSelectionIndex,
|
||||
getMainMenuSelectionIndex
|
||||
} from '../components/MainMenu';
|
||||
import { buildManageInstallationItems } from '../components/ManageInstallationMenu';
|
||||
|
||||
function getMenuValues(
|
||||
isClaudeInstalled: boolean,
|
||||
hasChanges: boolean,
|
||||
installation?: InstallationMetadata
|
||||
): string[] {
|
||||
return buildMainMenuItems(isClaudeInstalled, hasChanges, installation)
|
||||
.map(item => item === '-' ? '-' : item.value);
|
||||
}
|
||||
|
||||
describe('App confirm navigation helpers', () => {
|
||||
it('defaults confirmation cancel navigation to the main menu', () => {
|
||||
@@ -29,7 +54,8 @@ describe('App confirm navigation helpers', () => {
|
||||
it('clears saved install selection when leaving the install menu', () => {
|
||||
expect(clearInstallMenuSelection({
|
||||
main: 5,
|
||||
install: 1
|
||||
install: 1,
|
||||
installPackage: 1
|
||||
})).toEqual({ main: 5 });
|
||||
|
||||
const menuSelections = { main: 5 };
|
||||
@@ -37,3 +63,228 @@ describe('App confirm navigation helpers', () => {
|
||||
expect(clearInstallMenuSelection(menuSelections)).toBe(menuSelections);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pinned version mismatch guard', () => {
|
||||
it('uses saved pinned metadata while Claude status line is still loading', () => {
|
||||
const installation: InstallationMetadata = {
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
};
|
||||
|
||||
expect(getCurrentInstallation(true, null, {
|
||||
...DEFAULT_SETTINGS,
|
||||
installation
|
||||
})).toEqual(installation);
|
||||
});
|
||||
|
||||
it('does not block auto-update or matching pinned installs', () => {
|
||||
expect(getPinnedVersionMismatch({
|
||||
method: 'auto-update',
|
||||
packageManager: 'bun'
|
||||
}, '2.3.0', 'ccstatusline')).toBeNull();
|
||||
|
||||
expect(getPinnedVersionMismatch({
|
||||
method: 'pinned',
|
||||
packageManager: 'npm',
|
||||
installedVersion: '2.3.0'
|
||||
}, '2.3.0', 'ccstatusline')).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks when the running TUI is newer than the pinned global install', () => {
|
||||
expect(getPinnedVersionMismatch({
|
||||
method: 'pinned',
|
||||
packageManager: 'bun',
|
||||
installedVersion: '2.2.13'
|
||||
}, '2.3.0', '/home/alice/.bun/bin/ccstatusline')).toEqual({
|
||||
packageManager: 'bun',
|
||||
installedVersion: '2.2.13',
|
||||
runningVersion: '2.3.0',
|
||||
relaunchCommand: '/home/alice/.bun/bin/ccstatusline',
|
||||
canUpdateToRunningVersion: true
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks without an update action when the running TUI is older than the pinned global install', () => {
|
||||
expect(getPinnedVersionMismatch({
|
||||
method: 'pinned',
|
||||
packageManager: 'npm',
|
||||
installedVersion: '2.3.0'
|
||||
}, '2.2.13', '/usr/local/bin/ccstatusline')).toEqual({
|
||||
packageManager: 'npm',
|
||||
installedVersion: '2.3.0',
|
||||
runningVersion: '2.2.13',
|
||||
relaunchCommand: '/usr/local/bin/ccstatusline',
|
||||
canUpdateToRunningVersion: false
|
||||
});
|
||||
});
|
||||
|
||||
it('infers pinned package manager from the active PATH match', () => {
|
||||
expect(getPathInferredInstallation({
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
}, {
|
||||
packageManager: 'bun',
|
||||
resolvedPath: '/Users/alice/.bun/bin/ccstatusline',
|
||||
resolvedPaths: [
|
||||
'/Users/alice/.bun/bin/ccstatusline',
|
||||
'/Users/alice/.nvm/versions/node/v24.9.0/bin/ccstatusline'
|
||||
],
|
||||
binDir: '/Users/alice/.bun/bin',
|
||||
version: null,
|
||||
warning: null
|
||||
})).toEqual({
|
||||
method: 'pinned',
|
||||
packageManager: 'bun',
|
||||
installedVersion: '2.2.13'
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the active PATH match version when available', () => {
|
||||
expect(getPathInferredInstallation({
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
}, {
|
||||
packageManager: 'bun',
|
||||
resolvedPath: '/Users/alice/.bun/bin/ccstatusline',
|
||||
resolvedPaths: ['/Users/alice/.bun/bin/ccstatusline'],
|
||||
binDir: '/Users/alice/.bun/bin',
|
||||
version: '2.2.13',
|
||||
warning: null
|
||||
})).toEqual({
|
||||
method: 'pinned',
|
||||
packageManager: 'bun',
|
||||
installedVersion: '2.2.13'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Main menu structure', () => {
|
||||
it('groups configure status line with terminal/global options when auto-update installed', () => {
|
||||
expect(getMenuValues(true, false, {
|
||||
method: 'auto-update',
|
||||
packageManager: 'npm'
|
||||
})).toEqual([
|
||||
'lines',
|
||||
'colors',
|
||||
'powerline',
|
||||
'-',
|
||||
'terminalConfig',
|
||||
'globalOverrides',
|
||||
'configureStatusLine',
|
||||
'-',
|
||||
'install',
|
||||
'-',
|
||||
'exit',
|
||||
'-',
|
||||
'starGithub'
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps install in its own section when not installed', () => {
|
||||
expect(getMenuValues(false, false)).toEqual([
|
||||
'lines',
|
||||
'colors',
|
||||
'powerline',
|
||||
'-',
|
||||
'terminalConfig',
|
||||
'globalOverrides',
|
||||
'configureStatusLine',
|
||||
'-',
|
||||
'install',
|
||||
'-',
|
||||
'exit',
|
||||
'-',
|
||||
'starGithub'
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses manage installation for pinned installs', () => {
|
||||
const installation: InstallationMetadata = {
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
};
|
||||
|
||||
expect(getMenuValues(true, false, installation)).toEqual([
|
||||
'lines',
|
||||
'colors',
|
||||
'powerline',
|
||||
'-',
|
||||
'terminalConfig',
|
||||
'globalOverrides',
|
||||
'configureStatusLine',
|
||||
'-',
|
||||
'manageInstallation',
|
||||
'-',
|
||||
'exit',
|
||||
'-',
|
||||
'starGithub'
|
||||
]);
|
||||
|
||||
const manageItem = buildMainMenuItems(true, false, installation)
|
||||
.find(item => item !== '-' && item.value === 'manageInstallation');
|
||||
|
||||
expect(manageItem).toEqual(expect.objectContaining({ label: '🧰 Manage Installation' }));
|
||||
});
|
||||
|
||||
it('uses a consistent update icon in manage installation and computes install selection indices', () => {
|
||||
const configureItem = buildMainMenuItems(false, false)
|
||||
.find(item => item !== '-' && item.value === 'configureStatusLine');
|
||||
const autoInstallation: InstallationMetadata = {
|
||||
method: 'auto-update',
|
||||
packageManager: 'npm'
|
||||
};
|
||||
const pinnedInstallation: InstallationMetadata = {
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
};
|
||||
|
||||
expect(configureItem).toEqual(expect.objectContaining({
|
||||
disabled: true,
|
||||
sublabel: '(install first)'
|
||||
}));
|
||||
expect(buildManageInstallationItems()[0]).toEqual(expect.objectContaining({ label: '🔄 Check for Updates' }));
|
||||
expect(getMainMenuInstallSelectionIndex(false)).toBe(5);
|
||||
expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(6);
|
||||
expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(6);
|
||||
expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(6);
|
||||
expect(getMainMenuSelectionIndex(
|
||||
buildMainMenuItems(true, false, pinnedInstallation),
|
||||
'manageInstallation'
|
||||
)).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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getAvailableBackgroundColorsForUI,
|
||||
getAvailableColorsForUI
|
||||
} from '../../utils/colors';
|
||||
import { GRADIENT_PRESET_NAMES } from '../../utils/gradient';
|
||||
import { shouldInsertInput } from '../../utils/input-guards';
|
||||
import { getWidget } from '../../utils/widgets';
|
||||
|
||||
@@ -22,6 +23,7 @@ import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
clearAllWidgetStyling,
|
||||
cycleWidgetColor,
|
||||
cycleWidgetDim,
|
||||
resetWidgetStyling,
|
||||
setWidgetColor,
|
||||
toggleWidgetBold
|
||||
@@ -42,6 +44,11 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
const [ansi256InputMode, setAnsi256InputMode] = useState(false);
|
||||
const [ansi256Input, setAnsi256Input] = useState('');
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [gradientMode, setGradientMode] = useState(false);
|
||||
const [gradientIndex, setGradientIndex] = useState(0);
|
||||
const [gradientCustomStep, setGradientCustomStep] = useState<'start' | 'end' | null>(null);
|
||||
const [gradientStartHex, setGradientStartHex] = useState('');
|
||||
const [gradientHexInput, setGradientHexInput] = useState('');
|
||||
|
||||
const powerlineEnabled = settings.powerline.enabled;
|
||||
|
||||
@@ -146,6 +153,69 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle gradient selection mode
|
||||
if (gradientMode) {
|
||||
const exitGradient = () => {
|
||||
setGradientMode(false);
|
||||
setGradientCustomStep(null);
|
||||
setGradientStartHex('');
|
||||
setGradientHexInput('');
|
||||
};
|
||||
|
||||
const applyGradientValue = (value: string) => {
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
onUpdate(setWidgetColor(widgets, selectedWidget.id, value, false));
|
||||
}
|
||||
exitGradient();
|
||||
};
|
||||
|
||||
// Custom start/end hex entry
|
||||
if (gradientCustomStep) {
|
||||
if (key.escape) {
|
||||
setGradientCustomStep(null);
|
||||
setGradientHexInput('');
|
||||
} else if (key.return) {
|
||||
if (gradientHexInput.length === 6) {
|
||||
if (gradientCustomStep === 'start') {
|
||||
setGradientStartHex(gradientHexInput);
|
||||
setGradientHexInput('');
|
||||
setGradientCustomStep('end');
|
||||
} else {
|
||||
applyGradientValue(`gradient:${gradientStartHex}-${gradientHexInput}`);
|
||||
}
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setGradientHexInput(gradientHexInput.slice(0, -1));
|
||||
} else if (shouldInsertInput(input, key) && gradientHexInput.length < 6) {
|
||||
const upperInput = input.toUpperCase();
|
||||
if (/^[0-9A-F]$/.test(upperInput)) {
|
||||
setGradientHexInput(gradientHexInput + upperInput);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Preset list navigation (last item is the "Custom" entry)
|
||||
const total = GRADIENT_PRESET_NAMES.length + 1;
|
||||
if (key.escape) {
|
||||
exitGradient();
|
||||
} else if (key.upArrow) {
|
||||
setGradientIndex((gradientIndex - 1 + total) % total);
|
||||
} else if (key.downArrow) {
|
||||
setGradientIndex((gradientIndex + 1) % total);
|
||||
} else if (key.return) {
|
||||
if (gradientIndex < GRADIENT_PRESET_NAMES.length) {
|
||||
applyGradientValue(`gradient:${GRADIENT_PRESET_NAMES[gradientIndex]}`);
|
||||
} else {
|
||||
setGradientStartHex('');
|
||||
setGradientHexInput('');
|
||||
setGradientCustomStep('start');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore number keys to prevent SelectInput numerical navigation
|
||||
if (input && /^[0-9]$/.test(input)) {
|
||||
return;
|
||||
@@ -170,6 +240,15 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
setAnsi256InputMode(true);
|
||||
setAnsi256Input('');
|
||||
}
|
||||
} else if (input === 'g' || input === 'G') {
|
||||
// Enter gradient selection mode (foreground only, needs a real color palette)
|
||||
if (highlightedItemId && highlightedItemId !== 'back' && !editingBackground && settings.colorLevel >= 2) {
|
||||
setGradientMode(true);
|
||||
setGradientIndex(0);
|
||||
setGradientCustomStep(null);
|
||||
setGradientStartHex('');
|
||||
setGradientHexInput('');
|
||||
}
|
||||
} else if ((input === 's' || input === 'S') && !key.ctrl) {
|
||||
// Toggle show separators (only if not in powerline mode and no default separator)
|
||||
if (!settings.powerline.enabled && !settings.defaultSeparator) {
|
||||
@@ -190,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
|
||||
@@ -269,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
|
||||
@@ -336,6 +424,13 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
displayName = `ANSI ${currentColor.substring(8)}`;
|
||||
} else if (currentColor.startsWith('hex:')) {
|
||||
displayName = `#${currentColor.substring(4)}`;
|
||||
} else if (currentColor.startsWith('gradient:')) {
|
||||
const body = currentColor.substring(9);
|
||||
if (GRADIENT_PRESET_NAMES.includes(body.toLowerCase())) {
|
||||
displayName = `Gradient: ${body.toLowerCase()}`;
|
||||
} else {
|
||||
displayName = `Gradient: ${body}`;
|
||||
}
|
||||
} else {
|
||||
const colorOption = colorOptions.find(c => c.value === currentColor);
|
||||
displayName = colorOption ? colorOption.name : currentColor;
|
||||
@@ -346,6 +441,68 @@ 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) {
|
||||
const level = getColorLevelString(settings.colorLevel);
|
||||
const widgetName = selectedWidget ? getItemLabel(selectedWidget) : '';
|
||||
|
||||
if (gradientCustomStep) {
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>
|
||||
Custom Gradient
|
||||
{widgetName ? ` - ${widgetName}` : ''}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text>{gradientCustomStep === 'start' ? 'Enter START hex color (without #):' : 'Enter END hex color (without #):'}</Text>
|
||||
{gradientCustomStep === 'end' && (
|
||||
<Text dimColor>
|
||||
Start: #
|
||||
{gradientStartHex}
|
||||
</Text>
|
||||
)}
|
||||
<Text>
|
||||
#
|
||||
{gradientHexInput}
|
||||
<Text dimColor>{gradientHexInput.length < 6 ? '_'.repeat(6 - gradientHexInput.length) : ''}</Text>
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>Press Enter when done, ESC to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>
|
||||
Select Gradient
|
||||
{widgetName ? ` - ${widgetName}` : ''}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑↓ to select, Enter to apply, ESC to cancel</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{GRADIENT_PRESET_NAMES.map((name, idx) => (
|
||||
<Text key={name}>
|
||||
{idx === gradientIndex ? '▶ ' : ' '}
|
||||
{applyColors(name, `gradient:${name}`, undefined, idx === gradientIndex, level)}
|
||||
</Text>
|
||||
))}
|
||||
<Text key='custom'>
|
||||
{gradientIndex === GRADIENT_PRESET_NAMES.length ? '▶ ' : ' '}
|
||||
Custom (enter two hex stops)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation dialog if clearing all colors
|
||||
if (showClearConfirm) {
|
||||
@@ -431,8 +588,9 @@ 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,' : ''}
|
||||
{' '}
|
||||
(r)eset, (c)lear all, ESC to go back
|
||||
</Text>
|
||||
@@ -454,7 +612,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
):
|
||||
{' '}
|
||||
{colorDisplay}
|
||||
{selectedWidget.bold && chalk.bold(' [BOLD]')}
|
||||
{styleIndicators && ` ${styleIndicators}`}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -5,12 +5,18 @@ import {
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import { getColorLevelString } from '../../types/ColorLevel';
|
||||
import {
|
||||
DefaultPaddingSideSchema,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
import {
|
||||
COLOR_MAP,
|
||||
applyColors,
|
||||
getChalkColor,
|
||||
getColorDisplayName
|
||||
} from '../../utils/colors';
|
||||
import { GRADIENT_PRESET_NAMES } from '../../utils/gradient';
|
||||
import { shouldInsertInput } from '../../utils/input-guards';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
@@ -30,6 +36,11 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors);
|
||||
const [globalBold, setGlobalBold] = useState(settings.globalBold);
|
||||
const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode);
|
||||
const [gradientMode, setGradientMode] = useState(false);
|
||||
const [gradientIndex, setGradientIndex] = useState(0);
|
||||
const [gradientCustomStep, setGradientCustomStep] = useState<'start' | 'end' | null>(null);
|
||||
const [gradientStartHex, setGradientStartHex] = useState('');
|
||||
const [gradientHexInput, setGradientHexInput] = useState('');
|
||||
const isPowerlineEnabled = settings.powerline.enabled;
|
||||
|
||||
// Check if there are any manual separators in the current configuration
|
||||
@@ -94,6 +105,63 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
} else if (confirmingSeparator) {
|
||||
// Skip input handling when confirmation is active - let ConfirmDialog handle it
|
||||
return;
|
||||
} else if (gradientMode) {
|
||||
const exitGradient = () => {
|
||||
setGradientMode(false);
|
||||
setGradientCustomStep(null);
|
||||
setGradientStartHex('');
|
||||
setGradientHexInput('');
|
||||
};
|
||||
|
||||
const applyGradientValue = (value: string) => {
|
||||
onUpdate({
|
||||
...settings,
|
||||
overrideForegroundColor: value
|
||||
});
|
||||
exitGradient();
|
||||
};
|
||||
|
||||
if (gradientCustomStep) {
|
||||
if (key.escape) {
|
||||
setGradientCustomStep(null);
|
||||
setGradientHexInput('');
|
||||
} else if (key.return) {
|
||||
if (gradientHexInput.length === 6) {
|
||||
if (gradientCustomStep === 'start') {
|
||||
setGradientStartHex(gradientHexInput);
|
||||
setGradientHexInput('');
|
||||
setGradientCustomStep('end');
|
||||
} else {
|
||||
applyGradientValue(`gradient:${gradientStartHex}-${gradientHexInput}`);
|
||||
}
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setGradientHexInput(gradientHexInput.slice(0, -1));
|
||||
} else if (shouldInsertInput(input, key) && gradientHexInput.length < 6) {
|
||||
const upperInput = input.toUpperCase();
|
||||
if (/^[0-9A-F]$/.test(upperInput)) {
|
||||
setGradientHexInput(gradientHexInput + upperInput);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const total = GRADIENT_PRESET_NAMES.length + 1;
|
||||
if (key.escape) {
|
||||
exitGradient();
|
||||
} else if (key.upArrow) {
|
||||
setGradientIndex((gradientIndex - 1 + total) % total);
|
||||
} else if (key.downArrow) {
|
||||
setGradientIndex((gradientIndex + 1) % total);
|
||||
} else if (key.return) {
|
||||
if (gradientIndex < GRADIENT_PRESET_NAMES.length) {
|
||||
applyGradientValue(`gradient:${GRADIENT_PRESET_NAMES[gradientIndex]}`);
|
||||
} else {
|
||||
setGradientStartHex('');
|
||||
setGradientHexInput('');
|
||||
setGradientCustomStep('start');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
@@ -153,16 +221,82 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
} else if (input === 'g' || input === 'G') {
|
||||
// Enter gradient selection mode
|
||||
setGradientMode(true);
|
||||
setGradientIndex(0);
|
||||
setGradientCustomStep(null);
|
||||
setGradientStartHex('');
|
||||
setGradientHexInput('');
|
||||
} else if (input === 'x' || input === 'X') {
|
||||
// Clear override foreground color
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (gradientMode) {
|
||||
const level = getColorLevelString(settings.colorLevel);
|
||||
|
||||
if (gradientCustomStep) {
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Custom Gradient - Override FG Color</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text>{gradientCustomStep === 'start' ? 'Enter START hex color (without #):' : 'Enter END hex color (without #):'}</Text>
|
||||
{gradientCustomStep === 'end' && (
|
||||
<Text dimColor>
|
||||
Start: #
|
||||
{gradientStartHex}
|
||||
</Text>
|
||||
)}
|
||||
<Text>
|
||||
#
|
||||
{gradientHexInput}
|
||||
<Text dimColor>{gradientHexInput.length < 6 ? '_'.repeat(6 - gradientHexInput.length) : ''}</Text>
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>Press Enter when done, ESC to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Select Gradient - Override FG Color</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑↓ to select, Enter to apply, ESC to cancel</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{GRADIENT_PRESET_NAMES.map((name, idx) => (
|
||||
<Text key={name}>
|
||||
{idx === gradientIndex ? '▶ ' : ' '}
|
||||
{applyColors(name, `gradient:${name}`, undefined, idx === gradientIndex, level)}
|
||||
</Text>
|
||||
))}
|
||||
<Text key='custom'>
|
||||
{gradientIndex === GRADIENT_PRESET_NAMES.length ? '▶ ' : ' '}
|
||||
Custom (enter two hex stops)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Global Overrides</Text>
|
||||
@@ -177,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>
|
||||
@@ -244,12 +378,25 @@ 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>
|
||||
{(() => {
|
||||
const fgColor = settings.overrideForegroundColor ?? 'none';
|
||||
if (fgColor === 'none') {
|
||||
return <Text color='gray'>(none)</Text>;
|
||||
} else if (fgColor.startsWith('gradient:')) {
|
||||
const body = fgColor.substring(9);
|
||||
const displayName = GRADIENT_PRESET_NAMES.includes(body.toLowerCase())
|
||||
? `Gradient: ${body.toLowerCase()}`
|
||||
: `Gradient: ${body}`;
|
||||
const level = getColorLevelString(settings.colorLevel);
|
||||
return <Text>{applyColors(displayName, fgColor, undefined, false, level)}</Text>;
|
||||
} else {
|
||||
const displayName = getColorDisplayName(fgColor);
|
||||
const fgChalk = getChalkColor(fgColor, 'ansi16', false);
|
||||
@@ -257,7 +404,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
return <Text>{display}</Text>;
|
||||
}
|
||||
})()}
|
||||
<Text dimColor> - (f) cycle, (g) clear</Text>
|
||||
<Text dimColor> - (f) cycle, (g) gradient, (x) clear</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
@@ -314,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>
|
||||
|
||||
@@ -3,64 +3,163 @@ import {
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { getClaudeSettingsPath } from '../../utils/claude-settings';
|
||||
import type { InstallationMetadata } from '../../types/Settings';
|
||||
import {
|
||||
CCSTATUSLINE_COMMANDS,
|
||||
PINNED_INSTALL_COMMANDS,
|
||||
getClaudeSettingsPath,
|
||||
type PackageCommandAvailability,
|
||||
type StatusLineCommandMode
|
||||
} from '../../utils/claude-settings';
|
||||
|
||||
import { List } from './List';
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
export type InstallUpdateStyle = 'auto-update' | 'pinned';
|
||||
export type InstallPackageManager = 'npm' | 'bun';
|
||||
|
||||
export interface InstallSelection {
|
||||
updateStyle: InstallUpdateStyle;
|
||||
packageManager: InstallPackageManager;
|
||||
commandMode: StatusLineCommandMode;
|
||||
metadata: InstallationMetadata;
|
||||
displayedCommand: string;
|
||||
globalInstallCommand?: string;
|
||||
}
|
||||
|
||||
export interface InstallMenuProps {
|
||||
bunxAvailable: boolean;
|
||||
commandAvailability: PackageCommandAvailability;
|
||||
currentVersion: string;
|
||||
existingStatusLine: string | null;
|
||||
onSelectNpx: () => void;
|
||||
onSelectBunx: () => void;
|
||||
onSelect: (selection: InstallSelection) => void;
|
||||
onCancel: () => void;
|
||||
initialSelection?: number;
|
||||
initialPackageSelection?: number;
|
||||
}
|
||||
|
||||
type InstallStep = 'style' | 'manager';
|
||||
|
||||
const AUTO_UPDATE_DESCRIPTION = 'Runs `@latest` through npx/bunx. Stays current automatically, with a small startup cost when the package runner checks or resolves the package. Because it follows the latest published package, pinned install is available if you prefer explicit updates.';
|
||||
|
||||
function getPinnedDescription(currentVersion: string): string {
|
||||
return `Installs \`ccstatusline@${currentVersion}\` globally and Claude Code runs \`ccstatusline\`. Fast on each render because Claude Code runs the installed ccstatusline binary directly. The version changes only when you update the global install.`;
|
||||
}
|
||||
|
||||
function getStyleItems(currentVersion: string): ListEntry<InstallUpdateStyle>[] {
|
||||
return [
|
||||
{
|
||||
label: 'Pinned global install',
|
||||
value: 'pinned',
|
||||
description: getPinnedDescription(currentVersion)
|
||||
},
|
||||
{
|
||||
label: 'Auto-update',
|
||||
value: 'auto-update',
|
||||
description: AUTO_UPDATE_DESCRIPTION
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getManagerItems(
|
||||
updateStyle: InstallUpdateStyle,
|
||||
commandAvailability: PackageCommandAvailability,
|
||||
currentVersion: string
|
||||
): ListEntry<InstallPackageManager>[] {
|
||||
if (updateStyle === 'auto-update') {
|
||||
return [
|
||||
{
|
||||
label: CCSTATUSLINE_COMMANDS.AUTO_NPX,
|
||||
value: 'npm',
|
||||
disabled: !commandAvailability.npx,
|
||||
sublabel: commandAvailability.npx ? undefined : '(npx not installed)'
|
||||
},
|
||||
{
|
||||
label: CCSTATUSLINE_COMMANDS.AUTO_BUNX,
|
||||
value: 'bun',
|
||||
disabled: !commandAvailability.bunx,
|
||||
sublabel: commandAvailability.bunx ? undefined : '(bunx not installed)'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: PINNED_INSTALL_COMMANDS.NPM(currentVersion),
|
||||
value: 'npm',
|
||||
disabled: !commandAvailability.npm,
|
||||
sublabel: commandAvailability.npm ? undefined : '(npm not installed)'
|
||||
},
|
||||
{
|
||||
label: PINNED_INSTALL_COMMANDS.BUN(currentVersion),
|
||||
value: 'bun',
|
||||
disabled: !commandAvailability.bun,
|
||||
sublabel: commandAvailability.bun ? undefined : '(bun not installed)'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function buildSelection(
|
||||
updateStyle: InstallUpdateStyle,
|
||||
packageManager: InstallPackageManager,
|
||||
currentVersion: string
|
||||
): InstallSelection {
|
||||
if (updateStyle === 'auto-update') {
|
||||
return {
|
||||
updateStyle,
|
||||
packageManager,
|
||||
commandMode: packageManager === 'bun' ? 'auto-bunx' : 'auto-npx',
|
||||
displayedCommand: packageManager === 'bun'
|
||||
? CCSTATUSLINE_COMMANDS.AUTO_BUNX
|
||||
: CCSTATUSLINE_COMMANDS.AUTO_NPX,
|
||||
metadata: {
|
||||
method: 'auto-update',
|
||||
packageManager
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
updateStyle,
|
||||
packageManager,
|
||||
commandMode: 'global',
|
||||
displayedCommand: packageManager === 'bun'
|
||||
? PINNED_INSTALL_COMMANDS.BUN(currentVersion)
|
||||
: PINNED_INSTALL_COMMANDS.NPM(currentVersion),
|
||||
globalInstallCommand: packageManager === 'bun'
|
||||
? PINNED_INSTALL_COMMANDS.BUN(currentVersion)
|
||||
: PINNED_INSTALL_COMMANDS.NPM(currentVersion),
|
||||
metadata: {
|
||||
method: 'pinned',
|
||||
installedVersion: currentVersion
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
bunxAvailable,
|
||||
commandAvailability,
|
||||
currentVersion,
|
||||
existingStatusLine,
|
||||
onSelectNpx,
|
||||
onSelectBunx,
|
||||
onSelect,
|
||||
onCancel,
|
||||
initialSelection = 0
|
||||
initialPackageSelection = 0
|
||||
}) => {
|
||||
const [step, setStep] = useState<InstallStep>('style');
|
||||
const [updateStyle, setUpdateStyle] = useState<InstallUpdateStyle>('pinned');
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
if (step === 'manager') {
|
||||
setStep('style');
|
||||
return;
|
||||
}
|
||||
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
function onSelect(value: string) {
|
||||
switch (value) {
|
||||
case 'npx':
|
||||
onSelectNpx();
|
||||
break;
|
||||
case 'bunx':
|
||||
if (bunxAvailable) {
|
||||
onSelectBunx();
|
||||
}
|
||||
break;
|
||||
case 'back':
|
||||
onCancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const listItems = [
|
||||
{
|
||||
label: 'npx - Node Package Execute',
|
||||
value: 'npx'
|
||||
},
|
||||
{
|
||||
label: 'bunx - Bun Package Execute',
|
||||
sublabel: bunxAvailable ? undefined : '(not installed)',
|
||||
value: 'bunx',
|
||||
disabled: !bunxAvailable
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Install ccstatusline to Claude Code</Text>
|
||||
@@ -75,25 +174,54 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text dimColor>Select package manager to use:</Text>
|
||||
</Box>
|
||||
{step === 'style' && (
|
||||
<>
|
||||
<Box>
|
||||
<Text dimColor>Select update style:</Text>
|
||||
</Box>
|
||||
|
||||
<List
|
||||
color='blue'
|
||||
marginTop={1}
|
||||
items={listItems}
|
||||
onSelect={(line) => {
|
||||
if (line === 'back') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
<List
|
||||
color='blue'
|
||||
marginTop={1}
|
||||
items={getStyleItems(currentVersion)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(line);
|
||||
}}
|
||||
initialSelection={initialSelection}
|
||||
showBackButton={true}
|
||||
/>
|
||||
setUpdateStyle(value);
|
||||
setStep('manager');
|
||||
}}
|
||||
initialSelection={0}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'manager' && (
|
||||
<>
|
||||
<Box>
|
||||
<Text dimColor>Select package manager:</Text>
|
||||
</Box>
|
||||
|
||||
<List
|
||||
color='blue'
|
||||
marginTop={1}
|
||||
items={getManagerItems(updateStyle, commandAvailability, currentVersion)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
setStep('style');
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(buildSelection(updateStyle, value, currentVersion));
|
||||
}}
|
||||
initialSelection={initialPackageSelection}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box marginTop={2}>
|
||||
<Text dimColor>
|
||||
@@ -104,7 +232,7 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press Enter to select, ESC to cancel</Text>
|
||||
<Text dimColor>Press Enter to select, ESC to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
+114
-41
@@ -4,7 +4,10 @@ import {
|
||||
} from 'ink';
|
||||
import React from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import type {
|
||||
InstallationMetadata,
|
||||
Settings
|
||||
} from '../../types/Settings';
|
||||
import { type PowerlineFontStatus } from '../../utils/powerline';
|
||||
|
||||
import { List } from './List';
|
||||
@@ -15,6 +18,8 @@ export type MainMenuOption = 'lines'
|
||||
| 'terminalConfig'
|
||||
| 'globalOverrides'
|
||||
| 'install'
|
||||
| 'manageInstallation'
|
||||
| 'checkUpdates'
|
||||
| 'configureStatusLine'
|
||||
| 'starGithub'
|
||||
| 'save'
|
||||
@@ -27,24 +32,57 @@ export interface MainMenuProps {
|
||||
initialSelection?: number;
|
||||
powerlineFontStatus: PowerlineFontStatus;
|
||||
settings: Settings | null;
|
||||
installation?: InstallationMetadata;
|
||||
previewIsTruncated?: boolean;
|
||||
}
|
||||
|
||||
export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
onSelect,
|
||||
isClaudeInstalled,
|
||||
hasChanges,
|
||||
initialSelection = 0,
|
||||
powerlineFontStatus,
|
||||
settings,
|
||||
previewIsTruncated
|
||||
}) => {
|
||||
// Build menu structure with visual gaps
|
||||
const menuItems: ({
|
||||
label: string;
|
||||
value: MainMenuOption;
|
||||
description: string;
|
||||
} | '-')[] = [
|
||||
interface MainMenuItem {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
disabled?: boolean;
|
||||
value: MainMenuOption;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type MainMenuEntry = MainMenuItem | '-';
|
||||
|
||||
function usesManageInstallation(installation?: InstallationMetadata): boolean {
|
||||
return installation?.method === 'pinned' || installation?.method === 'self-managed';
|
||||
}
|
||||
|
||||
function getInstallationMenuItem(
|
||||
isClaudeInstalled: boolean,
|
||||
installation?: InstallationMetadata
|
||||
): MainMenuItem {
|
||||
if (!isClaudeInstalled) {
|
||||
return {
|
||||
label: '📦 Install to Claude Code',
|
||||
value: 'install',
|
||||
description: 'Add ccstatusline to your Claude Code settings for automatic status line rendering'
|
||||
};
|
||||
}
|
||||
|
||||
if (usesManageInstallation(installation)) {
|
||||
return {
|
||||
label: '🧰 Manage Installation',
|
||||
value: 'manageInstallation',
|
||||
description: 'Check pinned global package updates or uninstall ccstatusline'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: '🔌 Uninstall from Claude Code',
|
||||
value: 'install',
|
||||
description: 'Remove ccstatusline from your Claude Code settings'
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMainMenuItems(
|
||||
isClaudeInstalled: boolean,
|
||||
hasChanges: boolean,
|
||||
installation?: InstallationMetadata
|
||||
): MainMenuEntry[] {
|
||||
const menuItems: MainMenuEntry[] = [
|
||||
{
|
||||
label: '📝 Edit Lines',
|
||||
value: 'lines',
|
||||
@@ -63,7 +101,7 @@ export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
description:
|
||||
'Install Powerline fonts for enhanced visual separators and symbols in your status line'
|
||||
},
|
||||
'-' as const,
|
||||
'-',
|
||||
{
|
||||
label: '💻 Terminal Options',
|
||||
value: 'terminalConfig',
|
||||
@@ -75,32 +113,20 @@ export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
description:
|
||||
'Set global padding, separators, and color overrides that apply to all widgets'
|
||||
},
|
||||
'-' as const,
|
||||
...(isClaudeInstalled
|
||||
? [
|
||||
{
|
||||
label: '🔧 Configure Status Line',
|
||||
value: 'configureStatusLine' as MainMenuOption,
|
||||
description: 'Configure Claude Code status line settings like refresh interval'
|
||||
},
|
||||
{
|
||||
label: '🔌 Uninstall from Claude Code',
|
||||
value: 'install' as MainMenuOption,
|
||||
description: 'Remove ccstatusline from your Claude Code settings'
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: '📦 Install to Claude Code',
|
||||
value: 'install' as MainMenuOption,
|
||||
description: 'Add ccstatusline to your Claude Code settings for automatic status line rendering'
|
||||
}
|
||||
]
|
||||
)
|
||||
{
|
||||
label: '🔧 Configure Status Line',
|
||||
sublabel: isClaudeInstalled ? undefined : '(install first)',
|
||||
disabled: !isClaudeInstalled,
|
||||
value: 'configureStatusLine',
|
||||
description: 'Configure Claude Code status line settings like refresh interval'
|
||||
},
|
||||
'-',
|
||||
getInstallationMenuItem(isClaudeInstalled, installation)
|
||||
];
|
||||
|
||||
if (hasChanges) {
|
||||
menuItems.push(
|
||||
'-',
|
||||
{
|
||||
label: '💾 Save & Exit',
|
||||
value: 'save',
|
||||
@@ -111,7 +137,7 @@ export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
value: 'exit',
|
||||
description: 'Exit without saving your changes'
|
||||
},
|
||||
'-' as const,
|
||||
'-',
|
||||
{
|
||||
label: '⭐ Like ccstatusline? Star us on GitHub',
|
||||
value: 'starGithub',
|
||||
@@ -120,12 +146,13 @@ export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
);
|
||||
} else {
|
||||
menuItems.push(
|
||||
'-',
|
||||
{
|
||||
label: '🚪 Exit',
|
||||
value: 'exit',
|
||||
description: 'Exit the configuration tool'
|
||||
},
|
||||
'-' as const,
|
||||
'-',
|
||||
{
|
||||
label: '⭐ Like ccstatusline? Star us on GitHub',
|
||||
value: 'starGithub',
|
||||
@@ -134,6 +161,52 @@ export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
export function getMainMenuSelectionIndex(items: MainMenuEntry[], option: MainMenuOption): number {
|
||||
let selectionIndex = 0;
|
||||
|
||||
for (const item of items) {
|
||||
if (item === '-') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.value === option) {
|
||||
return selectionIndex;
|
||||
}
|
||||
|
||||
if (!item.disabled) {
|
||||
selectionIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getMainMenuInstallSelectionIndex(
|
||||
isClaudeInstalled: boolean,
|
||||
installation?: InstallationMetadata
|
||||
): number {
|
||||
const option = isClaudeInstalled && usesManageInstallation(installation)
|
||||
? 'manageInstallation'
|
||||
: 'install';
|
||||
|
||||
return getMainMenuSelectionIndex(buildMainMenuItems(isClaudeInstalled, false, installation), option);
|
||||
}
|
||||
|
||||
export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
onSelect,
|
||||
isClaudeInstalled,
|
||||
hasChanges,
|
||||
initialSelection = 0,
|
||||
powerlineFontStatus,
|
||||
settings,
|
||||
installation,
|
||||
previewIsTruncated
|
||||
}) => {
|
||||
const menuItems = buildMainMenuItems(isClaudeInstalled, hasChanges, installation);
|
||||
|
||||
// Check if we should show the truncation warning
|
||||
const showTruncationWarning
|
||||
= previewIsTruncated && settings?.flexMode === 'full-minus-40';
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React from 'react';
|
||||
|
||||
import type { ResolvedInstallationMetadata } from '../../types/Settings';
|
||||
import type {
|
||||
ActiveGlobalCommandResolution,
|
||||
GlobalPackageInstallation,
|
||||
GlobalPackageManager
|
||||
} from '../../utils/global-package-manager';
|
||||
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
export type ManageInstallationAction = 'checkUpdates' | 'uninstall';
|
||||
|
||||
export interface UninstallSelection { packageManagers: GlobalPackageManager[] }
|
||||
|
||||
export interface ManageInstallationMenuProps {
|
||||
installation: ResolvedInstallationMetadata;
|
||||
activeCommand: ActiveGlobalCommandResolution | null;
|
||||
onSelect: (action: ManageInstallationAction) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export interface UninstallMenuProps {
|
||||
installations: GlobalPackageInstallation[];
|
||||
onSelect: (selection: UninstallSelection) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function getInstallationLabel(installation: ResolvedInstallationMetadata): string {
|
||||
if (installation.method === 'pinned') {
|
||||
const version = installation.installedVersion
|
||||
? ` ${installation.installedVersion}`
|
||||
: '';
|
||||
const manager = installation.packageManager === 'unknown'
|
||||
? ''
|
||||
: ` via ${installation.packageManager}`;
|
||||
|
||||
return `Pinned global install${manager}${version}`;
|
||||
}
|
||||
|
||||
if (installation.method === 'self-managed') {
|
||||
return 'Self-managed/global install';
|
||||
}
|
||||
|
||||
if (installation.method === 'auto-update') {
|
||||
return `Auto-update via ${installation.packageManager}`;
|
||||
}
|
||||
|
||||
return 'Unknown installation';
|
||||
}
|
||||
|
||||
function getActiveCommandLabel(activeCommand: ActiveGlobalCommandResolution | null): string | null {
|
||||
if (!activeCommand?.resolvedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeCommand.packageManager === 'unknown') {
|
||||
return `Active PATH match: ${activeCommand.resolvedPath}`;
|
||||
}
|
||||
|
||||
const version = activeCommand.version
|
||||
? ` ${activeCommand.version}`
|
||||
: '';
|
||||
|
||||
return `Active PATH match: ${activeCommand.packageManager} global${version} (${activeCommand.resolvedPath})`;
|
||||
}
|
||||
|
||||
export function buildManageInstallationItems(): ListEntry<ManageInstallationAction>[] {
|
||||
return [
|
||||
{
|
||||
label: '🔄 Check for Updates',
|
||||
value: 'checkUpdates',
|
||||
description: 'Check npm for the latest ccstatusline version and update the pinned global package'
|
||||
},
|
||||
{
|
||||
label: '🔌 Uninstall',
|
||||
value: 'uninstall',
|
||||
description: 'Remove ccstatusline from Claude Code settings, optionally removing global npm/bun packages'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function formatPackageManagers(packageManagers: GlobalPackageManager[]): string {
|
||||
return packageManagers.join(' + ');
|
||||
}
|
||||
|
||||
export function buildUninstallItems(
|
||||
installations: GlobalPackageInstallation[]
|
||||
): ListEntry<UninstallSelection>[] {
|
||||
const removableManagers = installations
|
||||
.filter(installation => installation.installed && installation.available)
|
||||
.map(installation => installation.packageManager);
|
||||
|
||||
const items: ListEntry<UninstallSelection>[] = [
|
||||
{
|
||||
label: 'Remove from Claude Code settings only',
|
||||
value: { packageManagers: [] },
|
||||
description: 'Leaves any global npm or bun ccstatusline packages installed'
|
||||
}
|
||||
];
|
||||
|
||||
for (const packageManager of removableManagers) {
|
||||
items.push({
|
||||
label: `Remove Claude settings and ${packageManager} global package`,
|
||||
value: { packageManagers: [packageManager] },
|
||||
description: `Runs ${packageManager === 'npm'
|
||||
? 'npm uninstall -g ccstatusline'
|
||||
: 'bun remove -g ccstatusline'} after removing Claude Code settings`
|
||||
});
|
||||
}
|
||||
|
||||
if (removableManagers.length > 1) {
|
||||
items.push({
|
||||
label: `Remove Claude settings and ${formatPackageManagers(removableManagers)} global packages`,
|
||||
value: { packageManagers: removableManagers },
|
||||
description: 'Removes every detected global ccstatusline package after removing Claude Code settings'
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export const ManageInstallationMenu: React.FC<ManageInstallationMenuProps> = ({
|
||||
installation,
|
||||
activeCommand,
|
||||
onSelect,
|
||||
onBack
|
||||
}) => {
|
||||
const activeCommandLabel = getActiveCommandLabel(activeCommand);
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Manage Installation</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
Current:
|
||||
{' '}
|
||||
{getInstallationLabel(installation)}
|
||||
</Text>
|
||||
</Box>
|
||||
{activeCommandLabel && (
|
||||
<Box>
|
||||
<Text dimColor>{activeCommandLabel}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{activeCommand?.warning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color='yellow' wrap='wrap'>{activeCommand.warning}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildManageInstallationItems()}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(value);
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const UninstallMenu: React.FC<UninstallMenuProps> = ({
|
||||
installations,
|
||||
onSelect,
|
||||
onBack
|
||||
}) => {
|
||||
const items = buildUninstallItems(installations);
|
||||
const detectedManagers = installations
|
||||
.filter(installation => installation.installed && installation.available)
|
||||
.map(installation => installation.packageManager);
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Uninstall ccstatusline</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Choose what to remove from this machine.
|
||||
</Text>
|
||||
</Box>
|
||||
{detectedManagers.length === 0 && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>No global npm or bun ccstatusline package was detected.</Text>
|
||||
</Box>
|
||||
)}
|
||||
<List
|
||||
marginTop={1}
|
||||
items={items}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(value);
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -184,8 +184,8 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
}
|
||||
|
||||
updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined);
|
||||
} else if ((input === 'a' || input === 'A') && (mode === 'separator' || separators.length < 3)) {
|
||||
// Add after current (max 3 for caps)
|
||||
} else if (input === 'a' || input === 'A') {
|
||||
// Add after current
|
||||
const newSeparators = [...separators];
|
||||
const newInvertBgs = mode === 'separator' ? [...invertBgs] : [];
|
||||
const defaultChar = presetSeparators[0]?.char ?? '\uE0B0';
|
||||
@@ -207,8 +207,8 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
updateSeparators(newSeparators, newInvertBgs);
|
||||
setSelectedIndex(selectedIndex + 1);
|
||||
}
|
||||
} else if ((input === 'i' || input === 'I') && (mode === 'separator' || separators.length < 3)) {
|
||||
// Insert before current (max 3 for caps)
|
||||
} else if (input === 'i' || input === 'I') {
|
||||
// Insert before current
|
||||
const newSeparators = [...separators];
|
||||
const newInvertBgs = mode === 'separator' ? [...invertBgs] : [];
|
||||
const defaultChar = presetSeparators[0]?.char ?? '\uE0B0';
|
||||
@@ -270,7 +270,6 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
}
|
||||
};
|
||||
|
||||
const canAdd = mode === 'separator' || separators.length < 3;
|
||||
const canDelete = mode !== 'separator' || separators.length > 1;
|
||||
|
||||
return (
|
||||
@@ -300,7 +299,7 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
<>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{`↑↓ select, ← → cycle${canAdd ? ', (a)dd, (i)nsert' : ''}${canDelete ? ', (d)elete' : ''}, (c)lear, (h)ex${mode === 'separator' ? ', (t)oggle invert' : ''}, ESC back`}
|
||||
{`↑↓ select, ← → cycle, (a)dd, (i)nsert${canDelete ? ', (d)elete' : ''}, (c)lear, (h)ex${mode === 'separator' ? ', (t)oggle invert' : ''}, ESC back`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -166,9 +166,11 @@ 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;
|
||||
|
||||
useInput((input, key) => {
|
||||
if (fontInstallMessage || installingFonts) {
|
||||
@@ -187,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));
|
||||
@@ -269,7 +271,15 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
{!confirmingFontInstall && !installingFonts && !fontInstallMessage && (
|
||||
<Text bold>Powerline Setup</Text>
|
||||
<Box>
|
||||
<Text bold>Powerline Setup</Text>
|
||||
{globalOverrideMessage && (
|
||||
<Text color='yellow' dimColor>
|
||||
{'. '}
|
||||
{globalOverrideMessage}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{confirmingFontInstall ? (
|
||||
@@ -337,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}>
|
||||
@@ -423,7 +433,7 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
|
||||
<Box flexDirection='column' marginTop={1}>
|
||||
<Text dimColor>
|
||||
When enabled, global overrides are disabled and powerline separators are used
|
||||
Powerline mode uses its own separator system
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Continue Theme keeps the Powerline color sequence running across lines
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
type ConfigureStatusLineValue = 'refreshInterval';
|
||||
type ConfigureStatusLineValue = 'refreshInterval' | 'gitCacheTtl';
|
||||
|
||||
function getRefreshInputValue(interval: number | null): string {
|
||||
return interval === null ? '' : String(interval);
|
||||
@@ -30,9 +30,16 @@ function getRefreshIntervalSublabel(interval: number | null, supported: boolean)
|
||||
return `(${interval}s)`;
|
||||
}
|
||||
|
||||
function getGitCacheTtlSublabel(ttlSeconds: number): string {
|
||||
return ttlSeconds === 0
|
||||
? '(mtime only)'
|
||||
: `(${ttlSeconds}s)`;
|
||||
}
|
||||
|
||||
export function buildConfigureStatusLineItems(
|
||||
refreshInterval: number | null,
|
||||
supportsRefreshInterval: boolean
|
||||
supportsRefreshInterval: boolean,
|
||||
gitCacheTtlSeconds: number
|
||||
): ListEntry<ConfigureStatusLineValue>[] {
|
||||
return [
|
||||
{
|
||||
@@ -43,6 +50,12 @@ export function buildConfigureStatusLineItems(
|
||||
description: supportsRefreshInterval
|
||||
? 'How often Claude Code refreshes the status line by re-running the command. Enter value in seconds (1-60), or leave empty to remove.'
|
||||
: 'This setting requires Claude Code version 2.1.97 or later. Please update Claude Code to use this feature.'
|
||||
},
|
||||
{
|
||||
label: '🧮 Git Cache TTL',
|
||||
sublabel: getGitCacheTtlSublabel(gitCacheTtlSeconds),
|
||||
value: 'gitCacheTtl',
|
||||
description: 'How long git widget subprocess output can be reused while .git/HEAD and .git/index are unchanged. Enter 0-60 seconds;\n0 disables age-based expiry, so cached output is reused until those git metadata mtimes change.'
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -69,21 +82,45 @@ export function validateRefreshIntervalInput(value: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateGitCacheTtlInput(value: string): string | null {
|
||||
const parsed = parseInt(value, 10);
|
||||
|
||||
if (value === '' || isNaN(parsed)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
|
||||
if (parsed < 0) {
|
||||
return `Minimum Git cache TTL is 0s (you entered ${parsed}s)`;
|
||||
}
|
||||
|
||||
if (parsed > 60) {
|
||||
return `Maximum Git cache TTL is 60s (you entered ${parsed}s)`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface RefreshIntervalMenuProps {
|
||||
currentInterval: number | null;
|
||||
supportsRefreshInterval: boolean;
|
||||
gitCacheTtlSeconds: number;
|
||||
onUpdate: (interval: number | null) => void;
|
||||
onGitCacheTtlUpdate: (ttlSeconds: number) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
|
||||
currentInterval,
|
||||
supportsRefreshInterval,
|
||||
gitCacheTtlSeconds,
|
||||
onUpdate,
|
||||
onGitCacheTtlUpdate,
|
||||
onBack
|
||||
}) => {
|
||||
const [editingRefreshInterval, setEditingRefreshInterval] = useState(false);
|
||||
const [editingGitCacheTtl, setEditingGitCacheTtl] = useState(false);
|
||||
const [refreshInput, setRefreshInput] = useState(() => getRefreshInputValue(currentInterval));
|
||||
const [gitCacheTtlInput, setGitCacheTtlInput] = useState(() => String(gitCacheTtlSeconds));
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useInput((input, key) => {
|
||||
@@ -125,6 +162,37 @@ export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingGitCacheTtl) {
|
||||
if (key.return) {
|
||||
const error = validateGitCacheTtlInput(gitCacheTtlInput);
|
||||
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
} else {
|
||||
const value = parseInt(gitCacheTtlInput, 10);
|
||||
onGitCacheTtlUpdate(value);
|
||||
setEditingGitCacheTtl(false);
|
||||
setValidationError(null);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
setGitCacheTtlInput(String(gitCacheTtlSeconds));
|
||||
setEditingGitCacheTtl(false);
|
||||
setValidationError(null);
|
||||
} else if (key.backspace) {
|
||||
setGitCacheTtlInput(gitCacheTtlInput.slice(0, -1));
|
||||
setValidationError(null);
|
||||
} else if (key.delete) {
|
||||
// No cursor position in simple input
|
||||
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
|
||||
const newValue = gitCacheTtlInput + input;
|
||||
if (newValue.length <= 2) {
|
||||
setGitCacheTtlInput(newValue);
|
||||
setValidationError(null);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
}
|
||||
@@ -149,18 +217,45 @@ export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
|
||||
<Text dimColor>Press Enter to confirm, ESC to cancel. Leave empty to remove.</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : editingGitCacheTtl ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text>
|
||||
Enter Git cache TTL in seconds (0-60):
|
||||
{' '}
|
||||
{gitCacheTtlInput}
|
||||
{gitCacheTtlInput.length > 0 ? 's' : ''}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor wrap='wrap'>
|
||||
This affects how quickly git widgets notice unstaged and untracked working-tree changes.
|
||||
</Text>
|
||||
{validationError ? (
|
||||
<Text color='red'>{validationError}</Text>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
0 disables age-based expiry; cache validity uses .git/HEAD and .git/index mtimes only.
|
||||
</Text>
|
||||
)}
|
||||
<Text dimColor>Press Enter to confirm, ESC to cancel.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildConfigureStatusLineItems(currentInterval, supportsRefreshInterval)}
|
||||
items={buildConfigureStatusLineItems(currentInterval, supportsRefreshInterval, gitCacheTtlSeconds)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
setRefreshInput(getRefreshInputValue(currentInterval));
|
||||
setEditingRefreshInterval(true);
|
||||
if (value === 'refreshInterval') {
|
||||
setRefreshInput(getRefreshInputValue(currentInterval));
|
||||
setEditingRefreshInterval(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setGitCacheTtlInput(String(gitCacheTtlSeconds));
|
||||
setEditingGitCacheTtl(true);
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
|
||||
@@ -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 => {
|
||||
@@ -45,9 +47,11 @@ const renderSingleLine = (
|
||||
terminalWidth,
|
||||
isPreview: true,
|
||||
minimalist: settings.minimalistMode,
|
||||
gitCacheTtlSeconds: settings.gitCacheTtlSeconds,
|
||||
lineIndex,
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex
|
||||
globalPowerlineThemeIndex,
|
||||
globalPowerlineStartCapIndex
|
||||
};
|
||||
|
||||
return renderStatusLineWithInfo(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
@@ -69,11 +73,17 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
return { renderedLines: [], anyTruncated: false };
|
||||
|
||||
// Always pre-render all widgets once (for efficiency)
|
||||
const preRenderedLines = preRenderAllWidgets(lines, settings, { terminalWidth, isPreview: true, minimalist: settings.minimalistMode });
|
||||
const preRenderedLines = preRenderAllWidgets(lines, settings, {
|
||||
terminalWidth,
|
||||
isPreview: true,
|
||||
minimalist: settings.minimalistMode,
|
||||
gitCacheTtlSeconds: settings.gitCacheTtlSeconds
|
||||
});
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
|
||||
let globalSeparatorIndex = 0;
|
||||
let globalPowerlineThemeIndex = 0;
|
||||
let globalPowerlineStartCapIndex = 0;
|
||||
const result: string[] = [];
|
||||
let truncated = false;
|
||||
|
||||
@@ -88,6 +98,7 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
i,
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex,
|
||||
globalPowerlineStartCapIndex,
|
||||
preRenderedWidgets,
|
||||
preCalculatedMaxWidths
|
||||
);
|
||||
@@ -96,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,233 @@
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React from 'react';
|
||||
|
||||
import type {
|
||||
UpdateAction,
|
||||
UpdateCheckResult
|
||||
} from '../../utils/update-checker';
|
||||
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
export type UpdateCheckerState = { status: 'checking' } | UpdateCheckResult;
|
||||
|
||||
export interface UpdateCheckerMenuProps {
|
||||
state: UpdateCheckerState;
|
||||
onBack: () => void;
|
||||
onRefresh: () => void;
|
||||
onRunAction: (action: UpdateAction) => void;
|
||||
}
|
||||
|
||||
type UpdateMenuAction = UpdateAction | 'refresh';
|
||||
|
||||
function getInstallationLabel(result: UpdateCheckResult): string {
|
||||
const { installation } = result;
|
||||
if (installation.method === 'auto-update') {
|
||||
return `Auto-update via ${installation.packageManager}`;
|
||||
}
|
||||
|
||||
if (installation.method === 'pinned') {
|
||||
const version = installation.installedVersion
|
||||
? ` ${installation.installedVersion}`
|
||||
: '';
|
||||
const manager = installation.packageManager === 'unknown'
|
||||
? ''
|
||||
: ` via ${installation.packageManager}`;
|
||||
|
||||
return `Pinned global install${manager}${version}`;
|
||||
}
|
||||
|
||||
if (installation.method === 'self-managed') {
|
||||
return 'Self-managed/global install';
|
||||
}
|
||||
|
||||
return 'Unknown or not installed';
|
||||
}
|
||||
|
||||
function getActionLabel(action: UpdateAction): string {
|
||||
return `Run ${action.command}`;
|
||||
}
|
||||
|
||||
function getActionSublabel(action: UpdateAction): string | undefined {
|
||||
if (action.available) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return action.packageManager === 'npm'
|
||||
? '(npm not installed)'
|
||||
: '(bun not installed)';
|
||||
}
|
||||
|
||||
function getActionItems(actions: UpdateAction[]): ListEntry<UpdateMenuAction>[] {
|
||||
return [
|
||||
...actions.map((action): ListEntry<UpdateMenuAction> => ({
|
||||
label: getActionLabel(action),
|
||||
value: action,
|
||||
disabled: !action.available,
|
||||
sublabel: getActionSublabel(action)
|
||||
})),
|
||||
{
|
||||
label: 'Check again',
|
||||
value: 'refresh'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export const UpdateCheckerMenu: React.FC<UpdateCheckerMenuProps> = ({
|
||||
state,
|
||||
onBack,
|
||||
onRefresh,
|
||||
onRunAction
|
||||
}) => {
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
}
|
||||
});
|
||||
|
||||
if (state.status === 'checking') {
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Check for Updates</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Checking npm registry...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Check for Updates</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text>
|
||||
Current:
|
||||
{' '}
|
||||
{state.currentVersion}
|
||||
</Text>
|
||||
{state.status !== 'registry-failure' && (
|
||||
<Text>
|
||||
Latest:
|
||||
{' '}
|
||||
{state.latestVersion}
|
||||
</Text>
|
||||
)}
|
||||
<Text>
|
||||
Install:
|
||||
{' '}
|
||||
{getInstallationLabel(state)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{state.status === 'registry-failure' && (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text color='red'>
|
||||
Registry check failed:
|
||||
{' '}
|
||||
{state.errorMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
<List
|
||||
marginTop={1}
|
||||
items={[{ label: 'Check again', value: 'refresh' }]}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
onRefresh();
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.status === 'up-to-date' && (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text color='green'>ccstatusline is up to date.</Text>
|
||||
</Box>
|
||||
<List
|
||||
marginTop={1}
|
||||
items={[{ label: 'Check again', value: 'refresh' }]}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
onRefresh();
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.status === 'update-available' && (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text color='yellow'>An update is available.</Text>
|
||||
</Box>
|
||||
|
||||
{state.installation.method === 'auto-update' && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text>No manual hook change is needed. Claude Code already runs @latest.</Text>
|
||||
<Text>The next @latest invocation will resolve the latest package.</Text>
|
||||
<Text>
|
||||
Launch command for a fresh TUI:
|
||||
{' '}
|
||||
{state.autoUpdateLaunchCommand}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state.actions.length > 0 && (
|
||||
<List
|
||||
marginTop={1}
|
||||
items={getActionItems(state.actions)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'refresh') {
|
||||
onRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
onRunAction(value);
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.actions.length === 0 && (
|
||||
<List
|
||||
marginTop={1}
|
||||
items={[{ label: 'Check again', value: 'refresh' }]}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
onRefresh();
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -179,4 +179,199 @@ describe('GlobalOverridesMenu', () => {
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
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();
|
||||
const output = stdout.getOutput();
|
||||
expect(output).toContain('Override FG Color:');
|
||||
expect(output).toContain('(f) cycle, (g) gradient, (x) clear');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies a foreground override gradient from the preset selector', 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, colorLevel: 3 },
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('g');
|
||||
await flushInk();
|
||||
expect(stdout.getOutput()).toContain('Select Gradient - Override FG Color');
|
||||
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ overrideForegroundColor: 'gradient:atlas' }));
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the foreground override when (x) is pressed', 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, overrideForegroundColor: 'gradient:atlas' },
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('x');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ overrideForegroundColor: undefined }));
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
|
||||
import { InstallMenu } from '../InstallMenu';
|
||||
|
||||
const ALL_AVAILABLE = {
|
||||
npm: true,
|
||||
npx: true,
|
||||
bun: true,
|
||||
bunx: true
|
||||
};
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
@@ -64,10 +71,10 @@ describe('InstallMenu', () => {
|
||||
const onCancel = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
bunxAvailable: true,
|
||||
commandAvailability: ALL_AVAILABLE,
|
||||
currentVersion: '2.2.13',
|
||||
existingStatusLine: null,
|
||||
onSelectNpx: vi.fn(),
|
||||
onSelectBunx: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
onCancel
|
||||
}),
|
||||
{
|
||||
@@ -96,18 +103,17 @@ describe('InstallMenu', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('respects the provided initial selection', async () => {
|
||||
it('renders both update styles without a recommendation label', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
bunxAvailable: true,
|
||||
commandAvailability: ALL_AVAILABLE,
|
||||
currentVersion: '2.2.13',
|
||||
existingStatusLine: null,
|
||||
onSelectNpx: vi.fn(),
|
||||
onSelectBunx: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
initialSelection: 1
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn()
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
@@ -122,8 +128,137 @@ describe('InstallMenu', () => {
|
||||
try {
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('▶ bunx - Bun Package Execute');
|
||||
expect(stdout.getOutput()).not.toContain('▶ npx - Node Package Execute');
|
||||
const output = stdout.getOutput();
|
||||
expect(output).toContain('Auto-update');
|
||||
expect(output).toContain('Pinned global install');
|
||||
expect(output.toLowerCase()).not.toContain('recommended');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows pinned global install first and selected by default', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
commandAvailability: ALL_AVAILABLE,
|
||||
currentVersion: '2.2.13',
|
||||
existingStatusLine: null,
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn()
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
|
||||
const output = stdout.getOutput();
|
||||
expect(output.indexOf('Pinned global install')).toBeLessThan(output.indexOf('Auto-update'));
|
||||
expect(output).toContain('▶ Pinned global install');
|
||||
expect(output).not.toContain('▶ Auto-update');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows unavailable package managers as disabled in step two', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
commandAvailability: {
|
||||
npm: true,
|
||||
npx: false,
|
||||
bun: true,
|
||||
bunx: false
|
||||
},
|
||||
currentVersion: '2.2.13',
|
||||
existingStatusLine: null,
|
||||
onSelect: vi.fn(),
|
||||
onCancel: vi.fn()
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
const output = stdout.getOutput();
|
||||
expect(output).toContain('npm install -g ccstatusline@2.2.13');
|
||||
expect(output).not.toContain('(npm not installed)');
|
||||
expect(output).toContain('bun add -g ccstatusline@2.2.13');
|
||||
expect(output).not.toContain('(bun not installed)');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns from package manager selection to update style on escape', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onCancel = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
commandAvailability: ALL_AVAILABLE,
|
||||
currentVersion: '2.2.13',
|
||||
existingStatusLine: null,
|
||||
onSelect: vi.fn(),
|
||||
onCancel
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
expect(stdout.getOutput()).toContain('Select package manager');
|
||||
|
||||
stdin.write('\u001B');
|
||||
await flushInk();
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(stdout.getOutput()).toContain('Select update style');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
buildManageInstallationItems,
|
||||
buildUninstallItems
|
||||
} from '../ManageInstallationMenu';
|
||||
|
||||
describe('ManageInstallationMenu helpers', () => {
|
||||
it('builds update and uninstall actions', () => {
|
||||
expect(buildManageInstallationItems().map(item => item.value)).toEqual([
|
||||
'checkUpdates',
|
||||
'uninstall'
|
||||
]);
|
||||
expect(buildManageInstallationItems()[0]?.label).toBe('🔄 Check for Updates');
|
||||
});
|
||||
|
||||
it('offers npm, bun, and combined package removal when both are installed', () => {
|
||||
const items = buildUninstallItems([
|
||||
{
|
||||
packageManager: 'npm',
|
||||
available: true,
|
||||
installed: true,
|
||||
binDir: '/usr/local/bin'
|
||||
},
|
||||
{
|
||||
packageManager: 'bun',
|
||||
available: true,
|
||||
installed: true,
|
||||
binDir: '/home/alice/.bun/bin'
|
||||
}
|
||||
]);
|
||||
|
||||
expect(items.map(item => item.value.packageManagers)).toEqual([
|
||||
[],
|
||||
['npm'],
|
||||
['bun'],
|
||||
['npm', 'bun']
|
||||
]);
|
||||
});
|
||||
|
||||
it('only offers Claude settings removal when no global package is detected', () => {
|
||||
const items = buildUninstallItems([
|
||||
{
|
||||
packageManager: 'npm',
|
||||
available: true,
|
||||
installed: false,
|
||||
binDir: '/usr/local/bin'
|
||||
},
|
||||
{
|
||||
packageManager: 'bun',
|
||||
available: false,
|
||||
installed: false,
|
||||
binDir: null
|
||||
}
|
||||
]);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.value.packageManagers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../../types/Settings';
|
||||
import {
|
||||
PowerlineSeparatorEditor,
|
||||
type PowerlineSeparatorEditorProps
|
||||
} from '../PowerlineSeparatorEditor';
|
||||
import {
|
||||
PowerlineSetup,
|
||||
buildPowerlineSetupMenuItems,
|
||||
@@ -175,4 +179,114 @@ describe('PowerlineSetup helpers', () => {
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('warns when a global foreground override is active', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn<PowerlineSetupProps['onUpdate']>();
|
||||
const onBack = vi.fn();
|
||||
const onInstallFonts = vi.fn();
|
||||
const onClearMessage = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(PowerlineSetup, {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
overrideForegroundColor: 'gradient:atlas',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
powerlineFontStatus: { installed: true },
|
||||
onUpdate,
|
||||
onBack,
|
||||
onInstallFonts,
|
||||
installingFonts: false,
|
||||
fontInstallMessage: null,
|
||||
onClearMessage
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('Powerline Setup');
|
||||
expect(stdout.getOutput()).toContain('⚠ Global override for FG active');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PowerlineSeparatorEditor', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['startCap', 'startCaps', '\uE0B2'],
|
||||
['endCap', 'endCaps', '\uE0B0']
|
||||
] as const)('allows adding more than 3 %s entries', async (mode, capKey, expectedDefaultCap) => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn<PowerlineSeparatorEditorProps['onUpdate']>();
|
||||
const onBack = vi.fn();
|
||||
const existingCaps = [expectedDefaultCap, expectedDefaultCap, expectedDefaultCap];
|
||||
const instance = render(
|
||||
React.createElement(PowerlineSeparatorEditor, {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
[capKey]: existingCaps
|
||||
}
|
||||
},
|
||||
mode,
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
expect(stdout.getOutput()).toContain('(a)dd');
|
||||
|
||||
stdin.write('a');
|
||||
await flushInk();
|
||||
|
||||
const updatedSettings = onUpdate.mock.calls[0]?.[0];
|
||||
expect(updatedSettings).toBeDefined();
|
||||
expect(updatedSettings?.powerline[capKey]).toHaveLength(4);
|
||||
expect(updatedSettings?.powerline[capKey][1]).toBe(expectedDefaultCap);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
RefreshIntervalMenu,
|
||||
buildConfigureStatusLineItems,
|
||||
validateGitCacheTtlInput,
|
||||
validateRefreshIntervalInput
|
||||
} from '../RefreshIntervalMenu';
|
||||
|
||||
@@ -84,32 +85,61 @@ describe('validateRefreshIntervalInput', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateGitCacheTtlInput', () => {
|
||||
it('should accept valid values within range', () => {
|
||||
expect(validateGitCacheTtlInput('0')).toBeNull();
|
||||
expect(validateGitCacheTtlInput('5')).toBeNull();
|
||||
expect(validateGitCacheTtlInput('60')).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject values outside the range', () => {
|
||||
expect(validateGitCacheTtlInput('-1')).toContain('Minimum');
|
||||
expect(validateGitCacheTtlInput('61')).toContain('Maximum');
|
||||
});
|
||||
|
||||
it('should reject empty and non-numeric input', () => {
|
||||
expect(validateGitCacheTtlInput('')).toContain('valid number');
|
||||
expect(validateGitCacheTtlInput('abc')).toContain('valid number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildConfigureStatusLineItems', () => {
|
||||
it('should show (not set) when interval is null and supported', () => {
|
||||
const items = buildConfigureStatusLineItems(null, true);
|
||||
const items = buildConfigureStatusLineItems(null, true, 5);
|
||||
expect(items[0]?.sublabel).toBe('(not set)');
|
||||
});
|
||||
|
||||
it('should show seconds for set intervals', () => {
|
||||
const items = buildConfigureStatusLineItems(10, true);
|
||||
const items = buildConfigureStatusLineItems(10, true, 5);
|
||||
expect(items[0]?.sublabel).toBe('(10s)');
|
||||
});
|
||||
|
||||
it('should show seconds for small values', () => {
|
||||
const items = buildConfigureStatusLineItems(1, true);
|
||||
const items = buildConfigureStatusLineItems(1, true, 5);
|
||||
expect(items[0]?.sublabel).toBe('(1s)');
|
||||
});
|
||||
|
||||
it('should show version requirement when not supported', () => {
|
||||
const items = buildConfigureStatusLineItems(null, false);
|
||||
const items = buildConfigureStatusLineItems(null, false, 5);
|
||||
expect(items[0]?.sublabel).toContain('requires Claude Code');
|
||||
expect(items[0]?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be disabled when supported', () => {
|
||||
const items = buildConfigureStatusLineItems(10, true);
|
||||
const items = buildConfigureStatusLineItems(10, true, 5);
|
||||
expect(items[0]?.disabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should show the configured Git cache TTL', () => {
|
||||
const items = buildConfigureStatusLineItems(10, true, 5);
|
||||
expect(items[1]?.label).toContain('Git Cache TTL');
|
||||
expect(items[1]?.sublabel).toBe('(5s)');
|
||||
});
|
||||
|
||||
it('should describe zero Git cache TTL as mtime-only', () => {
|
||||
const items = buildConfigureStatusLineItems(10, true, 0);
|
||||
expect(items[1]?.sublabel).toBe('(mtime only)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RefreshIntervalMenu', () => {
|
||||
@@ -123,7 +153,9 @@ describe('RefreshIntervalMenu', () => {
|
||||
React.createElement(RefreshIntervalMenu, {
|
||||
currentInterval: null,
|
||||
supportsRefreshInterval: true,
|
||||
gitCacheTtlSeconds: 5,
|
||||
onUpdate,
|
||||
onGitCacheTtlUpdate: vi.fn(),
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
@@ -156,4 +188,54 @@ describe('RefreshIntervalMenu', () => {
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows helper text while editing Git cache TTL and saves updates', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onGitCacheTtlUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(RefreshIntervalMenu, {
|
||||
currentInterval: 10,
|
||||
supportsRefreshInterval: true,
|
||||
gitCacheTtlSeconds: 0,
|
||||
onUpdate,
|
||||
onGitCacheTtlUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('\u001B[B');
|
||||
await flushInk();
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('Enter Git cache TTL in seconds (0-60):');
|
||||
expect(stdout.getOutput()).toContain('unstaged and untracked working-tree changes');
|
||||
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(onGitCacheTtlUpdate).toHaveBeenCalledWith(0);
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ export * from './InstallMenu';
|
||||
export * from './ItemsEditor';
|
||||
export * from './LineSelector';
|
||||
export * from './MainMenu';
|
||||
export * from './ManageInstallationMenu';
|
||||
export * from './PowerlineSetup';
|
||||
export * from './RefreshIntervalMenu';
|
||||
export * from './StatusLinePreview';
|
||||
export * from './TerminalOptionsMenu';
|
||||
export * from './TerminalWidthMenu';
|
||||
export * from './UpdateCheckerMenu';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
export type ActivityToolStatus = 'running' | 'completed' | 'error';
|
||||
|
||||
export type ActivityAgentStatus = 'running' | 'completed';
|
||||
|
||||
export type ActivityTodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
export interface ActivityToolEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
target?: string;
|
||||
status: ActivityToolStatus;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
export interface ActivityAgentEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
model?: string;
|
||||
description?: string;
|
||||
status: ActivityAgentStatus;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
export interface ActivityTodoItem {
|
||||
content: string;
|
||||
status: ActivityTodoStatus;
|
||||
}
|
||||
|
||||
export interface ActivitySnapshot {
|
||||
tools: ActivityToolEntry[];
|
||||
agents: ActivityAgentEntry[];
|
||||
todos: ActivityTodoItem[];
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface ActivityParseOptions {
|
||||
maxTools?: number;
|
||||
maxAgents?: number;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
SkillsMetrics
|
||||
} from '../types';
|
||||
|
||||
import type { ActivitySnapshot } from './Activity';
|
||||
import type { SpeedMetrics } from './SpeedMetrics';
|
||||
import type { StatusJSON } from './StatusJSON';
|
||||
import type { TokenMetrics } from './TokenMetrics';
|
||||
@@ -13,14 +12,23 @@ export interface RenderUsageData {
|
||||
sessionResetAt?: string;
|
||||
weeklyUsage?: number;
|
||||
weeklyResetAt?: string;
|
||||
weeklySonnetUsage?: number;
|
||||
weeklySonnetResetAt?: string;
|
||||
weeklyOpusUsage?: number;
|
||||
weeklyOpusResetAt?: string;
|
||||
extraUsageEnabled?: boolean;
|
||||
extraUsageLimit?: number;
|
||||
extraUsageUsed?: number;
|
||||
extraUsageUtilization?: number;
|
||||
extraUsageCurrency?: string;
|
||||
error?: 'no-credentials' | 'timeout' | 'rate-limited' | 'api-error' | 'parse-error';
|
||||
}
|
||||
|
||||
export interface CompactionData { count: number }
|
||||
export interface CompactionData {
|
||||
count: number;
|
||||
byTrigger: { auto: number; manual: number; unknown: number };
|
||||
tokensReclaimed: number;
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
data?: StatusJSON;
|
||||
@@ -32,10 +40,11 @@ export interface RenderContext {
|
||||
blockMetrics?: BlockMetrics | null;
|
||||
skillsMetrics?: SkillsMetrics | null;
|
||||
compactionData?: CompactionData | null;
|
||||
activity?: ActivitySnapshot | null;
|
||||
terminalWidth?: number | null;
|
||||
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
|
||||
|
||||
@@ -46,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
|
||||
}
|
||||
|
||||
+31
-1
@@ -8,6 +8,29 @@ 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'),
|
||||
packageManager: z.enum(['npm', 'bun'])
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal('pinned'),
|
||||
installedVersion: z.string().optional()
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal('self-managed'),
|
||||
packageManager: z.enum(['npm', 'bun', 'unknown']).default('unknown')
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal('unknown'),
|
||||
packageManager: z.enum(['npm', 'bun', 'unknown']).default('unknown')
|
||||
})
|
||||
]);
|
||||
|
||||
// Schema for v1 settings (before version field was added)
|
||||
export const SettingsSchema_v1 = z.object({
|
||||
lines: z.array(z.array(WidgetItemSchema)).optional(),
|
||||
@@ -45,10 +68,12 @@ 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(),
|
||||
globalBold: z.boolean().default(false),
|
||||
gitCacheTtlSeconds: z.number().min(0).max(60).default(5),
|
||||
minimalistMode: z.boolean().default(false),
|
||||
powerline: PowerlineConfigSchema.default({
|
||||
enabled: false,
|
||||
@@ -63,11 +88,16 @@ export const SettingsSchema = z.object({
|
||||
updatemessage: z.object({
|
||||
message: z.string().nullable().optional(),
|
||||
remaining: z.number().nullable().optional()
|
||||
}).optional()
|
||||
}).optional(),
|
||||
installation: InstallationMetadataSchema.optional()
|
||||
});
|
||||
|
||||
// Inferred type from schema
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
export type InstallationMetadata = z.infer<typeof InstallationMetadataSchema>;
|
||||
export type ResolvedInstallationMetadata
|
||||
= | Exclude<InstallationMetadata, { method: 'pinned' }>
|
||||
| (Extract<InstallationMetadata, { method: 'pinned' }> & { packageManager: 'npm' | 'bun' | 'unknown' });
|
||||
|
||||
// Export a default settings constant for reference
|
||||
export const DEFAULT_SETTINGS: Settings = SettingsSchema.parse({});
|
||||
|
||||
@@ -71,7 +71,9 @@ export const StatusJSONSchema = z.looseObject({
|
||||
}).nullable().optional(),
|
||||
rate_limits: z.object({
|
||||
five_hour: RateLimitPeriodSchema.optional(),
|
||||
seven_day: RateLimitPeriodSchema.optional()
|
||||
seven_day: RateLimitPeriodSchema.optional(),
|
||||
seven_day_sonnet: RateLimitPeriodSchema.nullable().optional(),
|
||||
seven_day_opus: RateLimitPeriodSchema.nullable().optional()
|
||||
}).nullable().optional()
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface TokenMetrics {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedTokens: number;
|
||||
// Hot (cache read) and cold (cache creation) split of cachedTokens.
|
||||
// Optional so existing TokenMetrics literals stay valid; getTokenMetrics always sets them.
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
totalTokens: number;
|
||||
contextLength: number;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
@@ -20,13 +20,3 @@ export type { ColorEntry } from './ColorEntry';
|
||||
export type { BlockMetrics } from './BlockMetrics';
|
||||
export type { SpeedMetrics } from './SpeedMetrics';
|
||||
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
|
||||
export type {
|
||||
ActivityAgentEntry,
|
||||
ActivityAgentStatus,
|
||||
ActivityParseOptions,
|
||||
ActivitySnapshot,
|
||||
ActivityTodoItem,
|
||||
ActivityTodoStatus,
|
||||
ActivityToolEntry,
|
||||
ActivityToolStatus
|
||||
} from './Activity';
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
clearActivitySnapshotCache,
|
||||
getActivitySnapshot,
|
||||
parseActivityContent
|
||||
} from '../activity';
|
||||
|
||||
function makeToolUseLine(id: string, name: string, input: Record<string, unknown>, timestamp: string): string {
|
||||
return JSON.stringify({
|
||||
timestamp,
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name,
|
||||
input
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function makeToolResultLine(toolUseId: string, timestamp: string, isError = false): string {
|
||||
return JSON.stringify({
|
||||
timestamp,
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
is_error: isError
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('activity parsing', () => {
|
||||
beforeEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
});
|
||||
|
||||
it('parses tool, agent, and todo activity from transcript content', () => {
|
||||
const content = [
|
||||
makeToolUseLine('tool-1', 'Read', { file_path: '/repo/src/auth.ts' }, '2026-03-03T00:00:00.000Z'),
|
||||
makeToolResultLine('tool-1', '2026-03-03T00:00:01.000Z'),
|
||||
makeToolUseLine('agent-1', 'Task', { subagent_type: 'explore', model: 'haiku', description: 'Find auth flow' }, '2026-03-03T00:00:02.000Z'),
|
||||
makeToolResultLine('agent-1', '2026-03-03T00:00:05.000Z'),
|
||||
makeToolUseLine('todo-write-1', 'TodoWrite', {
|
||||
todos: [
|
||||
{ id: 'task-1', content: 'Investigate auth bug', status: 'in_progress' },
|
||||
{ id: 'task-2', content: 'Add tests', status: 'pending' }
|
||||
]
|
||||
}, '2026-03-03T00:00:06.000Z'),
|
||||
makeToolUseLine('todo-update-1', 'TaskUpdate', {
|
||||
taskId: 'task-2',
|
||||
status: 'completed'
|
||||
}, '2026-03-03T00:00:07.000Z')
|
||||
].join('\n');
|
||||
|
||||
const snapshot = parseActivityContent(content);
|
||||
|
||||
expect(snapshot.tools).toHaveLength(1);
|
||||
expect(snapshot.tools[0]).toMatchObject({
|
||||
id: 'tool-1',
|
||||
name: 'Read',
|
||||
target: '/repo/src/auth.ts',
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
expect(snapshot.agents).toHaveLength(1);
|
||||
expect(snapshot.agents[0]).toMatchObject({
|
||||
id: 'agent-1',
|
||||
type: 'explore',
|
||||
model: 'haiku',
|
||||
description: 'Find auth flow',
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
expect(snapshot.todos).toEqual([
|
||||
{ content: 'Investigate auth bug', status: 'in_progress' },
|
||||
{ content: 'Add tests', status: 'completed' }
|
||||
]);
|
||||
expect(snapshot.updatedAt?.toISOString()).toBe('2026-03-03T00:00:07.000Z');
|
||||
});
|
||||
|
||||
it('handles malformed lines and unknown todo statuses safely', () => {
|
||||
const content = [
|
||||
'not-json',
|
||||
makeToolUseLine('todo-create-1', 'TaskCreate', { subject: 'Draft proposal', status: 'not_started' }, '2026-03-03T00:00:00.000Z'),
|
||||
makeToolUseLine('todo-update-1', 'TaskUpdate', { taskId: '1', status: 'unknown-status' }, '2026-03-03T00:00:01.000Z')
|
||||
].join('\n');
|
||||
|
||||
const snapshot = parseActivityContent(content);
|
||||
expect(snapshot.todos).toEqual([
|
||||
{ content: 'Draft proposal', status: 'pending' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects configured caps for tools and agents', () => {
|
||||
const content = [
|
||||
makeToolUseLine('tool-1', 'Read', { file_path: '/repo/1.ts' }, '2026-03-03T00:00:00.000Z'),
|
||||
makeToolUseLine('tool-2', 'Read', { file_path: '/repo/2.ts' }, '2026-03-03T00:00:01.000Z'),
|
||||
makeToolUseLine('tool-3', 'Read', { file_path: '/repo/3.ts' }, '2026-03-03T00:00:02.000Z'),
|
||||
makeToolUseLine('agent-1', 'Task', { subagent_type: 'a' }, '2026-03-03T00:00:03.000Z'),
|
||||
makeToolUseLine('agent-2', 'Task', { subagent_type: 'b' }, '2026-03-03T00:00:04.000Z')
|
||||
].join('\n');
|
||||
|
||||
const snapshot = parseActivityContent(content, { maxTools: 2, maxAgents: 1 });
|
||||
expect(snapshot.tools.map(tool => tool.id)).toEqual(['tool-2', 'tool-3']);
|
||||
expect(snapshot.agents.map(agent => agent.id)).toEqual(['agent-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activity cache', () => {
|
||||
let tempDir = '';
|
||||
|
||||
beforeEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-activity-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('uses cached snapshot when transcript file metadata is unchanged', () => {
|
||||
const line = makeToolUseLine('tool-1', 'Read', { file_path: '/repo/a.ts' }, '2026-03-03T00:00:00.000Z');
|
||||
const transcriptPath = path.join(tempDir, 'activity.jsonl');
|
||||
fs.writeFileSync(transcriptPath, `${line}\n`, 'utf-8');
|
||||
|
||||
const first = getActivitySnapshot(transcriptPath);
|
||||
const second = getActivitySnapshot(transcriptPath);
|
||||
|
||||
expect(first.tools).toHaveLength(1);
|
||||
expect(second.tools).toHaveLength(1);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('re-parses when transcript metadata changes', () => {
|
||||
const firstLine = makeToolUseLine('tool-1', 'Read', { file_path: '/repo/a.ts' }, '2026-03-03T00:00:00.000Z');
|
||||
const secondLine = makeToolUseLine('tool-2', 'Edit', { file_path: '/repo/longer-path-name-b.ts' }, '2026-03-03T00:01:00.000Z');
|
||||
const transcriptPath = path.join(tempDir, 'activity.jsonl');
|
||||
fs.writeFileSync(transcriptPath, `${firstLine}\n`, 'utf-8');
|
||||
|
||||
const first = getActivitySnapshot(transcriptPath);
|
||||
fs.writeFileSync(transcriptPath, `${secondLine}\n`, 'utf-8');
|
||||
const second = getActivitySnapshot(transcriptPath);
|
||||
|
||||
expect(first.tools[0]?.id).toBe('tool-1');
|
||||
expect(second.tools[0]?.id).toBe('tool-2');
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -15,11 +15,15 @@ import {
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import {
|
||||
CCSTATUSLINE_COMMANDS,
|
||||
buildStatusLineCommand,
|
||||
classifyInstallation,
|
||||
getClaudeCodeVersion,
|
||||
getClaudeJsonPath,
|
||||
getClaudeSettingsPath,
|
||||
getExistingStatusLine,
|
||||
getRefreshInterval,
|
||||
getSandboxConfig,
|
||||
getVoiceConfig,
|
||||
installStatusLine,
|
||||
isClaudeCodeVersionAtLeast,
|
||||
isInstalled,
|
||||
@@ -29,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 = '';
|
||||
@@ -57,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();
|
||||
config.initConfigPath(path.join(testClaudeConfigDir, 'ccstatusline-settings.json'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
initConfigPath();
|
||||
vi.restoreAllMocks();
|
||||
config.initConfigPath();
|
||||
if (testClaudeConfigDir) {
|
||||
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -127,6 +132,50 @@ describe('isKnownCommand', () => {
|
||||
it('should match command containing a quoted ccstatusline.ts path', () => {
|
||||
expect(isKnownCommand('bun run "/Users/Jane Doe/ccstatusline/src/ccstatusline.ts"')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match global command with --config', () => {
|
||||
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.GLOBAL} --config /tmp/settings.json`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyInstallation', () => {
|
||||
it('classifies existing npx latest commands as auto-update npm', () => {
|
||||
expect(classifyInstallation(CCSTATUSLINE_COMMANDS.NPM)).toEqual({
|
||||
method: 'auto-update',
|
||||
packageManager: 'npm'
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies existing bunx latest commands as auto-update bun', () => {
|
||||
expect(classifyInstallation(CCSTATUSLINE_COMMANDS.BUNX)).toEqual({
|
||||
method: 'auto-update',
|
||||
packageManager: 'bun'
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies global commands without metadata as self-managed unknown', () => {
|
||||
expect(classifyInstallation(CCSTATUSLINE_COMMANDS.GLOBAL)).toEqual({
|
||||
method: 'self-managed',
|
||||
packageManager: 'unknown'
|
||||
});
|
||||
});
|
||||
|
||||
it('uses pinned metadata for global commands', () => {
|
||||
expect(classifyInstallation(CCSTATUSLINE_COMMANDS.GLOBAL, {
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
})).toEqual({
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies local development commands as self-managed unknown', () => {
|
||||
expect(classifyInstallation('bun run /repo/src/ccstatusline.ts')).toEqual({
|
||||
method: 'self-managed',
|
||||
packageManager: 'unknown'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude config paths', () => {
|
||||
@@ -151,51 +200,82 @@ describe('Claude config paths', () => {
|
||||
|
||||
describe('buildCommand via installStatusLine', () => {
|
||||
it('should use base command when no custom config path', async () => {
|
||||
initConfigPath();
|
||||
await installStatusLine(false);
|
||||
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');
|
||||
await installStatusLine(false);
|
||||
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');
|
||||
await installStatusLine(false);
|
||||
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');
|
||||
await installStatusLine(false);
|
||||
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');
|
||||
await installStatusLine(false);
|
||||
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 useBunx is true', async () => {
|
||||
initConfigPath('/my path/settings.json');
|
||||
await installStatusLine(true);
|
||||
it('should use bunx command when commandMode is auto-bunx', async () => {
|
||||
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', () => {
|
||||
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');
|
||||
config.initConfigPath(configPath);
|
||||
|
||||
await installStatusLine({
|
||||
commandMode: 'global',
|
||||
installationMetadata: {
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
}
|
||||
});
|
||||
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.GLOBAL} --config ${configPath}`);
|
||||
const savedSettings = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as { installation?: unknown };
|
||||
expect(savedSettings.installation).toEqual({
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
});
|
||||
});
|
||||
|
||||
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' }], [], []]
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(settingsWithSkills, null, 2), 'utf-8');
|
||||
|
||||
await installStatusLine(false);
|
||||
await installStatusLine({ commandMode: 'auto-npx' });
|
||||
|
||||
const installedCommand = `${CCSTATUSLINE_COMMANDS.NPM} --config ${configPath}`;
|
||||
const claudeSettings = await loadClaudeSettings();
|
||||
@@ -215,18 +295,47 @@ describe('buildCommand via installStatusLine', () => {
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sync hooks from the final global statusline command', async () => {
|
||||
const configPath = path.join(testClaudeConfigDir, 'global-settings.json');
|
||||
config.initConfigPath(configPath);
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [[{ id: 'skills-1', type: 'skills' }], [], []]
|
||||
}, null, 2), 'utf-8');
|
||||
|
||||
await installStatusLine({
|
||||
commandMode: 'global',
|
||||
installationMetadata: {
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
}
|
||||
});
|
||||
|
||||
const installedCommand = `${CCSTATUSLINE_COMMANDS.GLOBAL} --config ${configPath}`;
|
||||
const claudeSettings = await loadClaudeSettings();
|
||||
expect(claudeSettings.statusLine?.command).toBe(installedCommand);
|
||||
const hooks = (claudeSettings.hooks ?? {}) as Record<string, unknown[]>;
|
||||
expect(hooks.PreToolUse).toEqual([
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installStatusLine refreshInterval', () => {
|
||||
it('should set refreshInterval to 10 when version is supported', async () => {
|
||||
initConfigPath();
|
||||
await installStatusLine(false, true);
|
||||
config.initConfigPath();
|
||||
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: true });
|
||||
expect(readInstalledRefreshInterval()).toBe(10);
|
||||
});
|
||||
|
||||
it('should not set refreshInterval when version is unsupported', async () => {
|
||||
initConfigPath();
|
||||
await installStatusLine(false, false);
|
||||
config.initConfigPath();
|
||||
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: false });
|
||||
expect(readInstalledRefreshInterval()).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -239,7 +348,7 @@ describe('installStatusLine refreshInterval', () => {
|
||||
refreshInterval: 5
|
||||
}
|
||||
}));
|
||||
await installStatusLine(false, true);
|
||||
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: true });
|
||||
expect(readInstalledRefreshInterval()).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -349,7 +458,7 @@ describe('backup and error handling behavior', () => {
|
||||
}
|
||||
}));
|
||||
|
||||
await installStatusLine(false);
|
||||
await installStatusLine({ commandMode: 'auto-npx' });
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
|
||||
@@ -391,11 +500,11 @@ describe('backup and error handling behavior', () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await installStatusLine(false);
|
||||
await installStatusLine({ commandMode: 'auto-npx' });
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const installed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string; padding?: number } };
|
||||
expect(installed.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
|
||||
expect(installed.statusLine?.command).toBe(buildStatusLineCommand('auto-npx'));
|
||||
expect(installed.statusLine?.padding).toBe(0);
|
||||
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
|
||||
expect(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')).toBe('{ invalid json');
|
||||
@@ -563,3 +672,254 @@ describe('isClaudeCodeVersionAtLeast', () => {
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVoiceConfig', () => {
|
||||
let testProjectDir = '';
|
||||
|
||||
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(testProjectDir, '.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(testProjectDir, '.claude', 'settings.local.json');
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-voice-project-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (testProjectDir) {
|
||||
fs.rmSync(testProjectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('user-global layer only', () => {
|
||||
it('returns null when no candidate file exists', () => {
|
||||
expect(getVoiceConfig(testProjectDir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns { enabled: false } when settings.json has no voice field', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ effortLevel: 'high' }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('returns { enabled: true } when voice.enabled is true', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true, mode: 'hold' } }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('returns { enabled: false } when voice.enabled is false', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: false, mode: 'hold' } }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('returns { enabled: false } when voice.enabled is missing but voice exists', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { mode: 'hold' } }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('treats malformed JSON as "no override"', () => {
|
||||
// Malformed file is silently skipped; with no other layers, no override is found
|
||||
// and we fall back to the Claude Code default of `enabled: false`. The file's mere
|
||||
// existence still flips the overall result away from `null`.
|
||||
writeRawClaudeSettings('{ this is not json');
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('treats unexpected voice shape as "no override"', () => {
|
||||
// voice is a string instead of an object — Zod schema fails, no override extracted.
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: 'enabled' }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('respects CLAUDE_CONFIG_DIR env var', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
|
||||
expect(getClaudeSettingsPath().startsWith(testClaudeConfigDir)).toBe(true);
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('layer precedence', () => {
|
||||
it('user-local overrides user-global', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
|
||||
writeRawUserLocalSettings(JSON.stringify({ voice: { enabled: false } }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('project overrides user-local', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: false } }));
|
||||
writeRawUserLocalSettings(JSON.stringify({ voice: { enabled: false } }));
|
||||
writeRawProjectSettings(JSON.stringify({ voice: { enabled: true } }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('project-local overrides project', () => {
|
||||
writeRawProjectSettings(JSON.stringify({ voice: { enabled: true } }));
|
||||
writeRawProjectLocalSettings(JSON.stringify({ voice: { enabled: false } }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('layer without voice.enabled does not override a lower layer', () => {
|
||||
// user-global sets enabled:true, project layer has voice but no `enabled` field
|
||||
// → project should NOT clobber the user-global value.
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
|
||||
writeRawProjectSettings(JSON.stringify({ voice: { mode: 'hold' } }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('malformed higher-priority layer does not clobber a lower layer', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
|
||||
writeRawProjectLocalSettings('{ corrupt');
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('returns { enabled: false } when only project layer exists with voice but no enabled', () => {
|
||||
writeRawProjectSettings(JSON.stringify({ voice: { mode: 'hold' } }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('returns null when no candidate file exists in any layer', () => {
|
||||
// testProjectDir is freshly created and empty, testClaudeConfigDir too
|
||||
expect(getVoiceConfig(testProjectDir)).toBeNull();
|
||||
});
|
||||
|
||||
it('full stack: project-local wins over all three lower layers', () => {
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
|
||||
writeRawUserLocalSettings(JSON.stringify({ voice: { enabled: false } }));
|
||||
writeRawProjectSettings(JSON.stringify({ voice: { enabled: true } }));
|
||||
writeRawProjectLocalSettings(JSON.stringify({ voice: { enabled: false } }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('falls through layers without voice.enabled until it finds a defined value', () => {
|
||||
// user-global defines enabled:true; the three higher-priority layers exist but
|
||||
// contribute nothing usable (no voice field, only mode, or unrelated keys).
|
||||
writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } }));
|
||||
writeRawUserLocalSettings(JSON.stringify({ effortLevel: 'high' }));
|
||||
writeRawProjectSettings(JSON.stringify({ voice: { mode: 'hold' } }));
|
||||
writeRawProjectLocalSettings(JSON.stringify({ effortLevel: 'low' }));
|
||||
expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,4 +67,20 @@ describe('color sanitize helpers', () => {
|
||||
expect(sanitized[0]?.[1]?.color).toBe('hex:ABCDEF');
|
||||
expect(sanitized[0]?.[1]?.backgroundColor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves gradient colors untouched at every color level (they self-degrade at render time)', () => {
|
||||
const lines: WidgetItem[][] = [[
|
||||
{ id: '1', type: 'model', color: 'gradient:retro' },
|
||||
{ id: '2', type: 'context-length', color: 'gradient:FF0000-0000FF' }
|
||||
]];
|
||||
|
||||
for (const level of [1, 2, 3] as const) {
|
||||
const sanitized = sanitizeLinesForColorLevel(lines, level);
|
||||
expect(sanitized[0]?.[0]?.color).toBe('gradient:retro');
|
||||
expect(sanitized[0]?.[1]?.color).toBe('gradient:FF0000-0000FF');
|
||||
}
|
||||
|
||||
// Gradients are not "custom colors" for sanitize purposes - they degrade at render.
|
||||
expect(hasCustomWidgetColors(lines)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
applyColors,
|
||||
getColorAnsiCode
|
||||
} from '../colors';
|
||||
|
||||
const TRUECOLOR_CODE = /\x1b\[38;2;\d+;\d+;\d+m/g;
|
||||
const ANSI256_CODE = /\x1b\[38;5;\d+m/g;
|
||||
|
||||
function countMatches(text: string, pattern: RegExp): number {
|
||||
return text.match(pattern)?.length ?? 0;
|
||||
}
|
||||
|
||||
describe('applyColors with a per-widget gradient foreground', () => {
|
||||
const gradient = 'gradient:FF0000-0000FF';
|
||||
|
||||
it('paints each visible character at truecolor and closes with a reset', () => {
|
||||
const out = applyColors('abcd', gradient, undefined, false, 'truecolor');
|
||||
expect(countMatches(out, TRUECOLOR_CODE)).toBe(4);
|
||||
expect(out.endsWith('\x1b[39m')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses 256-color escapes at ansi256 level', () => {
|
||||
const out = applyColors('abc', gradient, undefined, false, 'ansi256');
|
||||
expect(countMatches(out, ANSI256_CODE)).toBe(3);
|
||||
expect(countMatches(out, TRUECOLOR_CODE)).toBe(0);
|
||||
});
|
||||
|
||||
it('emits no gradient color at ansi16', () => {
|
||||
const out = applyColors('abcd', gradient, undefined, false, 'ansi16');
|
||||
expect(out).toBe('abcd');
|
||||
expect(countMatches(out, TRUECOLOR_CODE)).toBe(0);
|
||||
expect(countMatches(out, ANSI256_CODE)).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves a resolvable named preset', () => {
|
||||
const out = applyColors('hello', 'gradient:atlas', undefined, false, 'truecolor');
|
||||
expect(countMatches(out, TRUECOLOR_CODE)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getColorAnsiCode gradient first-stop fallback (powerline / ansi16 path)', () => {
|
||||
it('collapses a gradient to its first stop as a solid foreground', () => {
|
||||
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'truecolor', false)).toBe('\x1b[38;2;255;0;0m');
|
||||
});
|
||||
|
||||
it('honors the background flag', () => {
|
||||
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'truecolor', true)).toBe('\x1b[48;2;255;0;0m');
|
||||
});
|
||||
|
||||
it('maps the first stop into the 256-color palette at ansi256', () => {
|
||||
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'ansi256', false)).toBe('\x1b[38;5;196m');
|
||||
});
|
||||
|
||||
it('returns empty at ansi16 so gradients do not leak higher color levels', () => {
|
||||
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'ansi16', false)).toBe('');
|
||||
expect(getColorAnsiCode('gradient:FF0000-0000FF', 'ansi16', true)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for an unparseable gradient spec', () => {
|
||||
expect(getColorAnsiCode('gradient:not-a-color', 'truecolor', false)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -6,289 +6,154 @@ import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
detectCompaction,
|
||||
loadCompactionState,
|
||||
saveCompactionState,
|
||||
type CompactionState
|
||||
ZERO_COMPACTION_STATS,
|
||||
computeCompactionStats,
|
||||
getCompactionStats
|
||||
} from '../compaction';
|
||||
|
||||
const fresh: CompactionState = { count: 0, prevCtxPct: -1 };
|
||||
|
||||
describe('detectCompaction', () => {
|
||||
it('does not detect on first render (sentinel prevCtxPct)', () => {
|
||||
const result = detectCompaction(40, fresh);
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.prevCtxPct).toBe(40);
|
||||
describe('computeCompactionStats', () => {
|
||||
it('returns zeroed stats for no compaction markers', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } }),
|
||||
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 100 } } })
|
||||
];
|
||||
expect(computeCompactionStats(lines)).toEqual({
|
||||
count: 0,
|
||||
byTrigger: { auto: 0, manual: 0, unknown: 0 },
|
||||
tokensReclaimed: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('detects compaction when ctx drops by more than 2 points', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(30, prev);
|
||||
expect(result.count).toBe(1);
|
||||
it('counts each compact_boundary system record', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 179004 } }),
|
||||
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 20000 } } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'manual', preTokens: 837327, postTokens: 25443 } })
|
||||
];
|
||||
expect(computeCompactionStats(lines).count).toBe(2);
|
||||
});
|
||||
|
||||
it('does not detect when ctx drops by exactly 2 points', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(38, prev);
|
||||
expect(result.count).toBe(0);
|
||||
it('counts exactly one compaction despite transient 0% context frames (supersedes #370)', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 150000 } } }),
|
||||
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 0 } } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 150000, postTokens: 20000 } }),
|
||||
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 0 } } }),
|
||||
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 20000 } } })
|
||||
];
|
||||
expect(computeCompactionStats(lines).count).toBe(1);
|
||||
});
|
||||
|
||||
it('does not detect when ctx drops by 1 point (rounding noise)', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 8 };
|
||||
const result = detectCompaction(7, prev);
|
||||
expect(result.count).toBe(0);
|
||||
it('splits counts by trigger and buckets missing/unknown trigger as unknown', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 1 } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'manual', preTokens: 1 } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'manual', preTokens: 1 } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { preTokens: 1 } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'future-mode', preTokens: 1 } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary' })
|
||||
];
|
||||
const stats = computeCompactionStats(lines);
|
||||
expect(stats.byTrigger).toEqual({ auto: 1, manual: 2, unknown: 3 });
|
||||
expect(stats.count).toBe(stats.byTrigger.auto + stats.byTrigger.manual + stats.byTrigger.unknown);
|
||||
});
|
||||
|
||||
it('does not detect when ctx increases', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(45, prev);
|
||||
expect(result.count).toBe(0);
|
||||
it('sums tokensReclaimed only for markers with both pre and post tokens', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'manual', preTokens: 900000, postTokens: 20000 } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 100000, postTokens: 30000 } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 50000 } })
|
||||
];
|
||||
// (900000-20000) + (100000-30000) = 880000 + 70000 = 950000; third marker lacks postTokens -> contributes 0
|
||||
expect(computeCompactionStats(lines).tokensReclaimed).toBe(950000);
|
||||
});
|
||||
|
||||
it('does not detect when ctx stays the same', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(40, prev);
|
||||
expect(result.count).toBe(0);
|
||||
it('reports tokensReclaimed 0 when no marker has both pre and post tokens', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 50000 } })
|
||||
];
|
||||
expect(computeCompactionStats(lines).tokensReclaimed).toBe(0);
|
||||
});
|
||||
|
||||
it('detects 3-point drop on 1M window', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 8 };
|
||||
const result = detectCompaction(5, prev);
|
||||
expect(result.count).toBe(1);
|
||||
it('floors per-marker tokensReclaimed at 0 when postTokens exceeds preTokens', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto', preTokens: 10000, postTokens: 50000 } })
|
||||
];
|
||||
expect(computeCompactionStats(lines).tokensReclaimed).toBe(0);
|
||||
});
|
||||
|
||||
it('detects large compaction on 200K window', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 85 };
|
||||
const result = detectCompaction(30, prev);
|
||||
expect(result.count).toBe(1);
|
||||
it('ignores other system records and malformed lines', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'system', subtype: 'something_else' }),
|
||||
'{ this is not valid json',
|
||||
'',
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto' } })
|
||||
];
|
||||
expect(computeCompactionStats(lines).count).toBe(1);
|
||||
});
|
||||
|
||||
it('increments existing count', () => {
|
||||
const prev: CompactionState = { count: 3, prevCtxPct: 70 };
|
||||
const result = detectCompaction(40, prev);
|
||||
expect(result.count).toBe(4);
|
||||
it('does not count a non-system record that merely has the subtype string', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'user', subtype: 'compact_boundary' })
|
||||
];
|
||||
expect(computeCompactionStats(lines).count).toBe(0);
|
||||
});
|
||||
|
||||
it('updates prevCtxPct regardless of detection', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(45, prev);
|
||||
expect(result.prevCtxPct).toBe(45);
|
||||
it('excludes sidechain (subagent) records from every stat', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', isSidechain: true, compactMetadata: { trigger: 'auto', preTokens: 50000, postTokens: 10000 } })
|
||||
];
|
||||
expect(computeCompactionStats(lines)).toEqual({
|
||||
count: 0,
|
||||
byTrigger: { auto: 0, manual: 0, unknown: 0 },
|
||||
tokensReclaimed: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts custom threshold', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 10 };
|
||||
const result = detectCompaction(8, prev, 1);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('stores the current context window size when provided', () => {
|
||||
const result = detectCompaction(40, fresh, { windowSize: 200000 });
|
||||
expect(result).toEqual({ count: 0, prevCtxPct: 40, prevWindowSize: 200000 });
|
||||
});
|
||||
|
||||
it('detects compaction when the context window size is unchanged', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40, prevWindowSize: 200000 };
|
||||
const result = detectCompaction(30, prev, { windowSize: 200000 });
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.prevWindowSize).toBe(200000);
|
||||
});
|
||||
|
||||
it('resets the baseline without incrementing when the context window size changes', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40, prevWindowSize: 200000 };
|
||||
const result = detectCompaction(8, prev, { windowSize: 1000000 });
|
||||
expect(result).toEqual({ count: 0, prevCtxPct: 8, prevWindowSize: 1000000 });
|
||||
});
|
||||
|
||||
it('learns the context window size for legacy state without incrementing', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(8, prev, { windowSize: 1000000 });
|
||||
expect(result).toEqual({ count: 0, prevCtxPct: 8, prevWindowSize: 1000000 });
|
||||
});
|
||||
|
||||
it('accepts custom threshold in options', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 10, prevWindowSize: 200000 };
|
||||
const result = detectCompaction(8, prev, { dropThreshold: 1, windowSize: 200000 });
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('returns state unchanged for NaN input (no poison)', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
expect(detectCompaction(NaN, prev)).toEqual(prev);
|
||||
});
|
||||
|
||||
it('returns state unchanged for Infinity input', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
expect(detectCompaction(Infinity, prev)).toEqual(prev);
|
||||
});
|
||||
|
||||
it('returns state unchanged for negative input', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
expect(detectCompaction(-1, prev)).toEqual(prev);
|
||||
});
|
||||
|
||||
it('detects drops using non-integer percentages', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40.4 };
|
||||
// 2.8-point drop, exceeds default threshold of 2
|
||||
const result = detectCompaction(37.6, prev);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('handles a session that starts at 0% (sentinel guards first render)', () => {
|
||||
// sequence: -1 (fresh) -> 0 -> 5 -> 30 -> 10
|
||||
// First three transitions: no detection. Fourth (30 -> 10) is a real drop.
|
||||
let state = fresh;
|
||||
state = detectCompaction(0, state);
|
||||
expect(state).toEqual({ count: 0, prevCtxPct: 0 });
|
||||
state = detectCompaction(5, state);
|
||||
expect(state.count).toBe(0);
|
||||
state = detectCompaction(30, state);
|
||||
expect(state.count).toBe(0);
|
||||
state = detectCompaction(10, state);
|
||||
expect(state.count).toBe(1);
|
||||
});
|
||||
|
||||
it('detects multiple sequential compactions', () => {
|
||||
// sequence: -1 (fresh) -> 40 -> 10 -> 50 -> 20
|
||||
let state = fresh;
|
||||
state = detectCompaction(40, state);
|
||||
state = detectCompaction(10, state);
|
||||
expect(state.count).toBe(1);
|
||||
state = detectCompaction(50, state);
|
||||
state = detectCompaction(20, state);
|
||||
expect(state.count).toBe(2);
|
||||
});
|
||||
|
||||
it('with threshold 0, every strict drop counts', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 10 };
|
||||
expect(detectCompaction(9.5, prev, 0).count).toBe(1);
|
||||
it('counts a compact_boundary record with explicit isSidechain false', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', isSidechain: false, compactMetadata: { trigger: 'manual', preTokens: 100000 } })
|
||||
];
|
||||
expect(computeCompactionStats(lines).count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
let testHome: string;
|
||||
describe('getCompactionStats', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'compaction-test-'));
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(testHome);
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'compaction-stats-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips state through save and load', () => {
|
||||
const state: CompactionState = { count: 5, prevCtxPct: 42, prevWindowSize: 200000 };
|
||||
saveCompactionState('test-session', state);
|
||||
const loaded = loadCompactionState('test-session');
|
||||
expect(loaded).toEqual(state);
|
||||
it('returns zeroed stats when the transcript file does not exist', async () => {
|
||||
await expect(getCompactionStats(path.join(dir, 'missing.jsonl'))).resolves.toEqual(ZERO_COMPACTION_STATS);
|
||||
});
|
||||
|
||||
it('returns fresh state for unknown session', () => {
|
||||
const loaded = loadCompactionState('nonexistent');
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
it('computes stats from a real-shaped transcript', async () => {
|
||||
const file = path.join(dir, 'session.jsonl');
|
||||
const content = [
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: 'start' } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted', compactMetadata: { trigger: 'manual', preTokens: 837327, postTokens: 25443 }, version: '2.1.161' }),
|
||||
JSON.stringify({ type: 'assistant', message: { usage: { input_tokens: 25443 } } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted', compactMetadata: { trigger: 'auto', preTokens: 912661, postTokens: 30026 }, version: '2.1.161' })
|
||||
].join('\n') + '\n';
|
||||
fs.writeFileSync(file, content);
|
||||
await expect(getCompactionStats(file)).resolves.toEqual({
|
||||
count: 2,
|
||||
byTrigger: { auto: 1, manual: 1, unknown: 0 },
|
||||
tokensReclaimed: (837327 - 25443) + (912661 - 30026)
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes path traversal in session ID', () => {
|
||||
const malicious = '../../../../../../tmp/pwn';
|
||||
saveCompactionState(malicious, { count: 1, prevCtxPct: 50 });
|
||||
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
const files = fs.existsSync(cacheDir) ? fs.readdirSync(cacheDir) : [];
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0]).toMatch(/^compaction-[a-zA-Z0-9_-]+\.json$/);
|
||||
|
||||
expect(fs.existsSync('/tmp/pwn.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('hashes session IDs that contain only illegal characters to avoid collision', () => {
|
||||
// Without hashing, both '....' and '!!!!' would sanitize to '____' and collide.
|
||||
saveCompactionState('....', { count: 1, prevCtxPct: 10 });
|
||||
saveCompactionState('!!!!', { count: 2, prevCtxPct: 20 });
|
||||
expect(loadCompactionState('....').count).toBe(1);
|
||||
expect(loadCompactionState('!!!!').count).toBe(2);
|
||||
});
|
||||
|
||||
it('hashes empty session ID to avoid blank filename leaf', () => {
|
||||
saveCompactionState('', { count: 1, prevCtxPct: 10 });
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
const files = fs.readdirSync(cacheDir);
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0]).not.toBe('compaction-.json');
|
||||
expect(files[0]).toMatch(/^compaction-[a-f0-9]{32}\.json$/);
|
||||
});
|
||||
|
||||
it('does not throw on write failure', () => {
|
||||
vi.spyOn(os, 'homedir').mockReturnValue('/nonexistent/readonly/path');
|
||||
expect(() => {
|
||||
saveCompactionState('test', { count: 1, prevCtxPct: 50 });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns fresh state when cache file has corrupted JSON', () => {
|
||||
saveCompactionState('corrupt-test', { count: 5, prevCtxPct: 50 });
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
const cacheFile = path.join(cacheDir, fs.readdirSync(cacheDir)[0] ?? '');
|
||||
fs.writeFileSync(cacheFile, '{ this is not valid json');
|
||||
|
||||
const loaded = loadCompactionState('corrupt-test');
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
});
|
||||
|
||||
it('returns fresh state when cache file exceeds size cap', () => {
|
||||
saveCompactionState('big-test', { count: 5, prevCtxPct: 50 });
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
const cacheFile = path.join(cacheDir, fs.readdirSync(cacheDir)[0] ?? '');
|
||||
fs.writeFileSync(cacheFile, 'a'.repeat(8192));
|
||||
|
||||
const loaded = loadCompactionState('big-test');
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('returns fresh state when cache path is a symlink', () => {
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const realPath = path.join(testHome, 'real.json');
|
||||
fs.writeFileSync(realPath, JSON.stringify({ count: 99, prevCtxPct: 50 }));
|
||||
|
||||
// sessionId 'symlink-test' has only legal chars, so the cache filename
|
||||
// is deterministically compaction-symlink-test.json
|
||||
const sessionId = 'symlink-test';
|
||||
const symlinkPath = path.join(cacheDir, `compaction-${sessionId}.json`);
|
||||
fs.symlinkSync(realPath, symlinkPath);
|
||||
|
||||
const loaded = loadCompactionState(sessionId);
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
});
|
||||
|
||||
it('uses zod defaults for missing fields in cache file', () => {
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, 'compaction-partial.json');
|
||||
fs.writeFileSync(cacheFile, JSON.stringify({}));
|
||||
|
||||
const loaded = loadCompactionState('partial');
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('atomic save replaces a planted symlink rather than writing through it', () => {
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const sessionId = 'rename-test';
|
||||
const targetPath = path.join(cacheDir, `compaction-${sessionId}.json`);
|
||||
const decoyTarget = path.join(testHome, 'decoy.txt');
|
||||
fs.writeFileSync(decoyTarget, 'do not overwrite me');
|
||||
fs.symlinkSync(decoyTarget, targetPath);
|
||||
|
||||
saveCompactionState(sessionId, { count: 1, prevCtxPct: 30 });
|
||||
|
||||
// The decoy must be untouched; the cache path is now a regular file.
|
||||
expect(fs.readFileSync(decoyTarget, 'utf-8')).toBe('do not overwrite me');
|
||||
expect(fs.lstatSync(targetPath).isFile()).toBe(true);
|
||||
it('returns zeroed stats when the transcript path is not a readable file', async () => {
|
||||
await expect(getCompactionStats(dir)).resolves.toEqual(ZERO_COMPACTION_STATS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -83,55 +88,86 @@ describe('config utilities', () => {
|
||||
};
|
||||
expect(onDisk.version).toBe(CURRENT_VERSION);
|
||||
expect(Array.isArray(onDisk.lines)).toBe(true);
|
||||
expect(settings.gitCacheTtlSeconds).toBe(5);
|
||||
expect((onDisk as { gitCacheTtlSeconds?: number }).gitCacheTtlSeconds).toBe(5);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Default settings written to')
|
||||
);
|
||||
});
|
||||
|
||||
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()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,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();
|
||||
|
||||
@@ -172,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();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
parseRemoteUrl
|
||||
} from '../git-remote';
|
||||
|
||||
import { expectGitExecOptions } from './git-test-helpers';
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
@@ -230,6 +232,7 @@ describe('git-remote utils', () => {
|
||||
|
||||
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('git');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['remote', 'get-url', '--', remoteName]);
|
||||
expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2]);
|
||||
});
|
||||
|
||||
it('returns null when remote does not exist', () => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
|
||||
import {
|
||||
fetchGitReviewData,
|
||||
getCachedGitReviewData,
|
||||
refreshGitReviewCacheFromCli,
|
||||
type GitReviewCacheDeps
|
||||
} from '../git-review-cache';
|
||||
|
||||
@@ -18,20 +20,26 @@ 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;
|
||||
setCliAuthedForHost: (cli: 'gh' | 'glab', host: string, authed: boolean) => void;
|
||||
setSshHostAlias: (host: string, hostname: string) => void;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -39,8 +47,10 @@ function createHarness(): PrCacheHarness {
|
||||
gh: new Set(),
|
||||
glab: new Set()
|
||||
};
|
||||
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))
|
||||
@@ -59,10 +69,16 @@ function createHarness(): PrCacheHarness {
|
||||
}
|
||||
return `${originRemoteUrl}\n`;
|
||||
}
|
||||
if (cmd === 'git' && commandArgs[0] === 'branch')
|
||||
if (cmd === 'git' && commandArgs[0] === 'symbolic-ref')
|
||||
return `${currentRef}\n`;
|
||||
if (cmd === 'git' && commandArgs[0] === 'rev-parse')
|
||||
return 'abc123\n';
|
||||
if (cmd === 'ssh' && commandArgs[0] === '-G') {
|
||||
const host = commandArgs[1];
|
||||
if (!host)
|
||||
throw new Error('missing ssh host');
|
||||
return `hostname ${sshHostAliases.get(host) ?? host}\n`;
|
||||
}
|
||||
if (cmd === 'gh' && commandArgs[0] === '--version')
|
||||
return 'gh version 2.0.0\n';
|
||||
if (cmd === 'gh' && commandArgs[0] === 'auth' && commandArgs[1] === 'status') {
|
||||
@@ -73,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;
|
||||
@@ -101,13 +118,35 @@ function createHarness(): PrCacheHarness {
|
||||
|
||||
throw new Error(`Unexpected command: ${cmd} ${commandArgs.join(' ')}`);
|
||||
}) as GitReviewCacheDeps['execFileSync'],
|
||||
existsSync: (filePath => cacheFiles.has(String(filePath))) as GitReviewCacheDeps['existsSync'],
|
||||
existsSync: filePath => cacheFiles.has(String(filePath)),
|
||||
getExecPath: () => '/usr/bin/node',
|
||||
getHomedir: () => '/tmp/home',
|
||||
mkdirSync: (() => undefined) as GitReviewCacheDeps['mkdirSync'],
|
||||
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'],
|
||||
writeFileSync: ((filePath, content) => {
|
||||
unlinkSync: (filePath) => {
|
||||
if (!cacheFiles.delete(String(filePath))) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
},
|
||||
writeFileSync: (filePath, content) => {
|
||||
const normalizedContent = typeof content === 'string'
|
||||
? content
|
||||
: Buffer.isBuffer(content)
|
||||
@@ -117,15 +156,20 @@ function createHarness(): PrCacheHarness {
|
||||
content: normalizedContent,
|
||||
mtimeMs: now
|
||||
});
|
||||
}) as GitReviewCacheDeps['writeFileSync']
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cacheFiles,
|
||||
deps,
|
||||
execCalls,
|
||||
ghDurations,
|
||||
ghResponses,
|
||||
glabResponses,
|
||||
spawnCalls,
|
||||
advanceNow: (milliseconds) => {
|
||||
now += milliseconds;
|
||||
},
|
||||
setCurrentRef: (ref: string) => {
|
||||
currentRef = ref;
|
||||
},
|
||||
@@ -141,27 +185,201 @@ function createHarness(): PrCacheHarness {
|
||||
} else {
|
||||
authedHosts[cli].delete(host);
|
||||
}
|
||||
},
|
||||
setSshHostAlias: (host: string, hostname: string) => {
|
||||
sshHostAliases.set(host, hostname);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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', () => {
|
||||
@@ -268,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: '',
|
||||
@@ -293,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', () => {
|
||||
@@ -326,6 +603,113 @@ 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');
|
||||
harness.setSshHostAlias('mygit', 'github.com');
|
||||
harness.ghResponses.push('');
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 1485,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Alias PR',
|
||||
url: 'https://github.com/owner/repo/pull/1485'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 1485,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Alias PR',
|
||||
url: 'https://github.com/owner/repo/pull/1485'
|
||||
});
|
||||
|
||||
const sshCalls = harness.execCalls.filter(call => call.cmd === 'ssh');
|
||||
expect(sshCalls.length).toBeGreaterThan(0);
|
||||
expect(sshCalls.every(call => call.args.join(' ') === '-G mygit')).toBe(true);
|
||||
|
||||
const ghAuthCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'auth'
|
||||
);
|
||||
expect(ghAuthCalls).toHaveLength(0);
|
||||
|
||||
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[1]?.args).toContain('https://github.com/owner/repo');
|
||||
});
|
||||
|
||||
it('preserves canonical GitHub SSH hosts when SSH config points at a transport endpoint', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@github.com:owner/repo.git');
|
||||
harness.setSshHostAlias('github.com', 'ssh.github.com');
|
||||
harness.ghResponses.push('');
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 1486,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Canonical GitHub PR',
|
||||
url: 'https://github.com/owner/repo/pull/1486'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 1486,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Canonical GitHub PR',
|
||||
url: 'https://github.com/owner/repo/pull/1486'
|
||||
});
|
||||
|
||||
const sshCalls = harness.execCalls.filter(call => call.cmd === 'ssh');
|
||||
expect(sshCalls).toHaveLength(0);
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(2);
|
||||
expect(ghPrCalls[1]?.args).toContain('--repo');
|
||||
expect(ghPrCalls[1]?.args).toContain('https://github.com/owner/repo');
|
||||
expect(ghPrCalls[1]?.args).not.toContain('https://ssh.github.com/owner/repo');
|
||||
});
|
||||
|
||||
it('falls back to --repo <origin> for forked GitLab repos when glab\'s default resolves elsewhere', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@gitlab.com:fork-owner/example-fork.git');
|
||||
@@ -476,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', () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect } from 'vitest';
|
||||
|
||||
export function expectGitExecOptions(options: unknown, cwd?: string): void {
|
||||
expect(options).toEqual(expect.objectContaining({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
...(cwd ? { cwd } : {})
|
||||
}));
|
||||
|
||||
expect((options as { env?: Record<string, string | undefined> }).env?.GIT_OPTIONAL_LOCKS).toBe('0');
|
||||
|
||||
if (!cwd)
|
||||
expect(options).not.toHaveProperty('cwd');
|
||||
}
|
||||
+188
-11
@@ -1,5 +1,9 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
@@ -18,6 +22,8 @@ import {
|
||||
runGit
|
||||
} from '../git';
|
||||
|
||||
import { expectGitExecOptions } from './git-test-helpers';
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
@@ -31,12 +37,78 @@ const mockExecFileSync = execFileSync as unknown as {
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
const ORIGINAL_HOME = process.env.HOME;
|
||||
const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
|
||||
const tempPaths: string[] = [];
|
||||
|
||||
function useTempHome(): string {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-git-home-'));
|
||||
tempPaths.push(home);
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(home);
|
||||
return home;
|
||||
}
|
||||
|
||||
function createGitRepo(): { root: string; headPath: string; indexPath: string } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-git-repo-'));
|
||||
tempPaths.push(root);
|
||||
const gitDir = path.join(root, '.git');
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
const headPath = path.join(gitDir, 'HEAD');
|
||||
const indexPath = path.join(gitDir, 'index');
|
||||
fs.writeFileSync(headPath, 'ref: refs/heads/main\n', 'utf-8');
|
||||
fs.writeFileSync(indexPath, '', 'utf-8');
|
||||
return { root, headPath, indexPath };
|
||||
}
|
||||
|
||||
function touch(filePath: string, mtimeMs: number): void {
|
||||
const date = new Date(mtimeMs);
|
||||
fs.utimesSync(filePath, date, date);
|
||||
}
|
||||
|
||||
function getOnlyGitCachePath(home: string): string {
|
||||
const cacheDir = path.join(home, '.cache', 'ccstatusline', 'git-cache');
|
||||
const files = fs.readdirSync(cacheDir).filter(file => /^git-[a-f0-9]+\.json$/.test(file));
|
||||
expect(files).toHaveLength(1);
|
||||
return path.join(cacheDir, files[0] ?? '');
|
||||
}
|
||||
|
||||
function readGitCacheJson(home: string): { cwd?: unknown; entries?: Record<string, unknown> } {
|
||||
return JSON.parse(fs.readFileSync(getOnlyGitCachePath(home), 'utf-8')) as {
|
||||
cwd?: unknown;
|
||||
entries?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('git utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearGitCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearGitCache();
|
||||
vi.restoreAllMocks();
|
||||
if (ORIGINAL_HOME === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = ORIGINAL_HOME;
|
||||
}
|
||||
if (ORIGINAL_USERPROFILE === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = ORIGINAL_USERPROFILE;
|
||||
}
|
||||
|
||||
while (tempPaths.length > 0) {
|
||||
const tempPath = tempPaths.pop();
|
||||
if (tempPath) {
|
||||
fs.rmSync(tempPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('resolveGitCwd', () => {
|
||||
it('prefers context.data.cwd when available', () => {
|
||||
const context: RenderContext = {
|
||||
@@ -95,16 +167,12 @@ describe('git utils', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('feature/worktree\n');
|
||||
const context: RenderContext = { data: { cwd: '/tmp/repo' } };
|
||||
|
||||
const result = runGit('branch --show-current', context);
|
||||
const result = runGit('symbolic-ref --short HEAD', context);
|
||||
|
||||
expect(result).toBe('feature/worktree');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('git');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['branch', '--show-current']);
|
||||
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
cwd: '/tmp/repo'
|
||||
});
|
||||
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['symbolic-ref', '--short', 'HEAD']);
|
||||
expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2], '/tmp/repo');
|
||||
});
|
||||
|
||||
it('runs git command without cwd when no context directory exists', () => {
|
||||
@@ -113,10 +181,7 @@ describe('git utils', () => {
|
||||
const result = runGit('rev-parse --is-inside-work-tree', {});
|
||||
|
||||
expect(result).toBe('true');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2]);
|
||||
});
|
||||
|
||||
it('returns null when the command fails', () => {
|
||||
@@ -124,6 +189,118 @@ describe('git utils', () => {
|
||||
|
||||
expect(runGit('status --short', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('reuses in-process cache entries while repo mtimes and TTL remain valid', () => {
|
||||
useTempHome();
|
||||
const { root } = createGitRepo();
|
||||
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
|
||||
mockExecFileSync.mockReturnValueOnce('feature/cache\n');
|
||||
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('feature/cache');
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('feature/cache');
|
||||
|
||||
expect(mockExecFileSync.mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses valid persistent cache entries after in-process cache is cleared', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||||
const home = useTempHome();
|
||||
const { root } = createGitRepo();
|
||||
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
|
||||
mockExecFileSync.mockReturnValueOnce('feature/persisted\n');
|
||||
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('feature/persisted');
|
||||
expect(fs.existsSync(getOnlyGitCachePath(home))).toBe(true);
|
||||
|
||||
clearGitCache();
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('feature/persisted');
|
||||
expect(mockExecFileSync.mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stores cwd once and uses command-only persistent cache keys', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||||
const home = useTempHome();
|
||||
const { root } = createGitRepo();
|
||||
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
|
||||
mockExecFileSync.mockReturnValueOnce('1 file changed, 2 insertions(+)');
|
||||
|
||||
expect(runGit('diff --cached --shortstat', context)).toBe('1 file changed, 2 insertions(+)');
|
||||
|
||||
const cache = readGitCacheJson(home);
|
||||
expect(cache.cwd).toBe(root);
|
||||
expect(Object.keys(cache.entries ?? {})).toEqual(['diff --cached --shortstat']);
|
||||
});
|
||||
|
||||
it('expires persistent cache entries older than the configured TTL', () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||||
useTempHome();
|
||||
const { root } = createGitRepo();
|
||||
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
|
||||
mockExecFileSync.mockReturnValueOnce('old-value\n');
|
||||
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('old-value');
|
||||
|
||||
clearGitCache();
|
||||
nowSpy.mockReturnValue(7000);
|
||||
mockExecFileSync.mockReturnValueOnce('new-value\n');
|
||||
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('new-value');
|
||||
expect(mockExecFileSync.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps persistent cache entries when TTL is zero and repo mtimes match', () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||||
useTempHome();
|
||||
const { root } = createGitRepo();
|
||||
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 0 };
|
||||
mockExecFileSync.mockReturnValueOnce('old-value\n');
|
||||
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('old-value');
|
||||
|
||||
clearGitCache();
|
||||
nowSpy.mockReturnValue(600000);
|
||||
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('old-value');
|
||||
expect(mockExecFileSync.mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('invalidates cached output when HEAD or index mtimes change', () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||||
useTempHome();
|
||||
const {
|
||||
root,
|
||||
indexPath
|
||||
} = createGitRepo();
|
||||
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 60 };
|
||||
mockExecFileSync.mockReturnValueOnce('old-value\n');
|
||||
|
||||
expect(runGit('status --porcelain -z', context)).toBe('old-value');
|
||||
|
||||
clearGitCache();
|
||||
touch(indexPath, Date.now() + 10000);
|
||||
nowSpy.mockReturnValue(2000);
|
||||
mockExecFileSync.mockReturnValueOnce('new-value\n');
|
||||
|
||||
expect(runGit('status --porcelain -z', context)).toBe('new-value');
|
||||
expect(mockExecFileSync.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('falls back to git when the persistent cache file is malformed', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||||
const home = useTempHome();
|
||||
const { root } = createGitRepo();
|
||||
const context: RenderContext = { data: { cwd: root }, gitCacheTtlSeconds: 5 };
|
||||
mockExecFileSync.mockReturnValueOnce('old-value\n');
|
||||
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('old-value');
|
||||
fs.writeFileSync(getOnlyGitCachePath(home), '{ malformed json', 'utf-8');
|
||||
|
||||
clearGitCache();
|
||||
mockExecFileSync.mockReturnValueOnce('new-value\n');
|
||||
|
||||
expect(runGit('symbolic-ref --short HEAD', context)).toBe('new-value');
|
||||
expect(mockExecFileSync.mock.calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsideGitWorkTree', () => {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as childProcess from 'child_process';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getCommandResolutionPaths,
|
||||
inspectGlobalCommandResolution
|
||||
} from '../global-command-resolution';
|
||||
import {
|
||||
getPackageManagerExecutable,
|
||||
getPackageManagerShellOptions
|
||||
} from '../package-manager-executable';
|
||||
|
||||
function mockExecFileSync(responses: Record<string, string>) {
|
||||
return vi.spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
|
||||
const key = `${command} ${(args as string[]).join(' ')}`;
|
||||
const response = responses[key];
|
||||
|
||||
if (response === undefined) {
|
||||
throw new Error(`Unexpected command: ${key}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
describe('global command resolution', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses where on Windows and treats same-directory shims as one install', () => {
|
||||
const execFileSyncSpy = mockExecFileSync({
|
||||
'where ccstatusline': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd\r\nC:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.ps1\r\n',
|
||||
'npm.cmd prefix -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\r\n'
|
||||
});
|
||||
|
||||
const resolution = inspectGlobalCommandResolution('npm', { platform: 'win32' });
|
||||
|
||||
expect(resolution.resolvedPaths).toEqual([
|
||||
'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd',
|
||||
'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.ps1'
|
||||
]);
|
||||
expect(resolution.warning).toBeNull();
|
||||
expect(execFileSyncSpy).toHaveBeenCalledWith(
|
||||
'npm.cmd',
|
||||
['prefix', '-g'],
|
||||
expect.objectContaining({ shell: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the Windows npm executable shim for execFile calls', () => {
|
||||
expect(getPackageManagerExecutable('npm', 'win32')).toBe('npm.cmd');
|
||||
expect(getPackageManagerExecutable('npm', 'linux')).toBe('npm');
|
||||
expect(getPackageManagerExecutable('bun', 'win32')).toBe('bun');
|
||||
expect(getPackageManagerShellOptions('npm.cmd', 'win32')).toEqual({ shell: true });
|
||||
expect(getPackageManagerShellOptions('npm', 'linux')).toEqual({});
|
||||
expect(getPackageManagerShellOptions('bun', 'win32')).toEqual({});
|
||||
});
|
||||
|
||||
it('uses which -a on POSIX/WSL', () => {
|
||||
mockExecFileSync({ 'which -a ccstatusline': '/home/alice/.bun/bin/ccstatusline\n' });
|
||||
|
||||
expect(getCommandResolutionPaths('ccstatusline', { platform: 'linux' })).toEqual([
|
||||
'/home/alice/.bun/bin/ccstatusline'
|
||||
]);
|
||||
});
|
||||
|
||||
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',
|
||||
'bun pm bin -g': '/home/alice/.bun/bin\n'
|
||||
});
|
||||
|
||||
const resolution = inspectGlobalCommandResolution('bun', { platform: 'linux' });
|
||||
|
||||
expect(resolution.warning).toContain('Multiple ccstatusline binaries are on PATH');
|
||||
expect(resolution.warning).toContain('/home/alice/.bun/bin/ccstatusline');
|
||||
expect(resolution.warning).toContain('/usr/local/bin/ccstatusline');
|
||||
});
|
||||
|
||||
it('ignores transient bunx status line shims when resolving global commands', () => {
|
||||
mockExecFileSync({
|
||||
'which -a ccstatusline': '/var/folders/demo/T/bunx-501-ccstatusline@latest/node_modules/.bin/ccstatusline\n/Users/alice/.bun/bin/ccstatusline\n',
|
||||
'bun pm bin -g': '/Users/alice/.bun/bin\n'
|
||||
});
|
||||
|
||||
const resolution = inspectGlobalCommandResolution('bun', { platform: 'darwin' });
|
||||
|
||||
expect(resolution.firstResolvedPath).toBe('/Users/alice/.bun/bin/ccstatusline');
|
||||
expect(resolution.resolvedPaths).toEqual(['/Users/alice/.bun/bin/ccstatusline']);
|
||||
expect(resolution.warning).toBeNull();
|
||||
});
|
||||
|
||||
it('compares Windows npm prefixes with WSL /mnt paths', () => {
|
||||
mockExecFileSync({
|
||||
'which -a ccstatusline': '/mnt/c/Users/Alice/AppData/Roaming/npm/ccstatusline\n',
|
||||
'npm prefix -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\n'
|
||||
});
|
||||
|
||||
const resolution = inspectGlobalCommandResolution('npm', { platform: 'linux' });
|
||||
|
||||
expect(resolution.expectedBinDir).toBe('C:\\Users\\Alice\\AppData\\Roaming\\npm');
|
||||
expect(resolution.warning).toBeNull();
|
||||
});
|
||||
|
||||
it('warns when the first resolved binary is outside the selected manager bin directory', () => {
|
||||
mockExecFileSync({
|
||||
'which -a ccstatusline': '/usr/local/bin/ccstatusline\n',
|
||||
'bun pm bin -g': '/home/alice/.bun/bin\n'
|
||||
});
|
||||
|
||||
const resolution = inspectGlobalCommandResolution('bun', { platform: 'linux' });
|
||||
|
||||
expect(resolution.warning).toContain('outside the bun global bin directory');
|
||||
expect(resolution.warning).toContain('/usr/local/bin/ccstatusline');
|
||||
});
|
||||
|
||||
it('warns when ccstatusline is not resolvable after a global install', () => {
|
||||
mockExecFileSync({ 'npm prefix -g': '/usr/local\n' });
|
||||
|
||||
const resolution = inspectGlobalCommandResolution('npm', { platform: 'linux' });
|
||||
|
||||
expect(resolution.warning).toContain('not currently resolvable on PATH');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as childProcess from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
inspectActiveGlobalCommand,
|
||||
inspectGlobalPackageInstallations,
|
||||
runGlobalPackageUninstall
|
||||
} from '../global-package-manager';
|
||||
|
||||
function mockExecFileSync(responses: Record<string, string>) {
|
||||
return vi.spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
|
||||
const key = `${command} ${(args as string[]).join(' ')}`;
|
||||
const response = responses[key];
|
||||
|
||||
if (response === undefined) {
|
||||
throw new Error(`Unexpected command: ${key}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
describe('global package manager inspection', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('detects npm installs through WSL path variants', () => {
|
||||
mockExecFileSync({
|
||||
'which -a ccstatusline': '',
|
||||
'npm prefix -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\n'
|
||||
});
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => (
|
||||
filePath === '/mnt/c/Users/Alice/AppData/Roaming/npm/ccstatusline'
|
||||
));
|
||||
|
||||
const installations = inspectGlobalPackageInstallations({
|
||||
commandAvailability: {
|
||||
npm: true,
|
||||
bun: false
|
||||
},
|
||||
platform: 'linux'
|
||||
});
|
||||
|
||||
expect(installations).toEqual([
|
||||
{
|
||||
packageManager: 'npm',
|
||||
available: true,
|
||||
installed: true,
|
||||
binDir: 'C:\\Users\\Alice\\AppData\\Roaming\\npm'
|
||||
},
|
||||
{
|
||||
packageManager: 'bun',
|
||||
available: false,
|
||||
installed: false,
|
||||
binDir: null
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifies the active package manager and version from the first PATH match', () => {
|
||||
mockExecFileSync({
|
||||
'which -a ccstatusline': '/Users/alice/.bun/bin/ccstatusline\n/Users/alice/.nvm/versions/node/v24.9.0/bin/ccstatusline\n',
|
||||
'npm prefix -g': '/Users/alice/.nvm/versions/node/v24.9.0\n',
|
||||
'bun pm bin -g': '/Users/alice/.bun/bin\n'
|
||||
});
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => (
|
||||
filePath === '/Users/alice/.bun/install/global/node_modules/ccstatusline/package.json'
|
||||
));
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
||||
if (filePath === '/Users/alice/.bun/install/global/node_modules/ccstatusline/package.json') {
|
||||
return '{"version":"2.2.13"}';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected read: ${String(filePath)}`);
|
||||
});
|
||||
|
||||
const activeCommand = inspectActiveGlobalCommand({
|
||||
commandAvailability: {
|
||||
npm: true,
|
||||
bun: true
|
||||
},
|
||||
platform: 'darwin'
|
||||
});
|
||||
|
||||
expect(activeCommand).toEqual({
|
||||
packageManager: 'bun',
|
||||
resolvedPath: '/Users/alice/.bun/bin/ccstatusline',
|
||||
resolvedPaths: [
|
||||
'/Users/alice/.bun/bin/ccstatusline',
|
||||
'/Users/alice/.nvm/versions/node/v24.9.0/bin/ccstatusline'
|
||||
],
|
||||
binDir: '/Users/alice/.bun/bin',
|
||||
version: '2.2.13',
|
||||
warning: '⚠ Multiple ccstatusline binaries are on PATH. Claude Code will run the first match: /Users/alice/.bun/bin/ccstatusline.\nOther matches: /Users/alice/.nvm/versions/node/v24.9.0/bin/ccstatusline'
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores transient bunx status line shims when identifying the active global command', () => {
|
||||
mockExecFileSync({
|
||||
'which -a ccstatusline': '/var/folders/demo/T/bunx-501-ccstatusline@latest/node_modules/.bin/ccstatusline\n/Users/alice/.bun/bin/ccstatusline\n',
|
||||
'npm prefix -g': '/Users/alice/.nvm/versions/node/v24.9.0\n',
|
||||
'bun pm bin -g': '/Users/alice/.bun/bin\n'
|
||||
});
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => (
|
||||
filePath === '/Users/alice/.bun/install/global/node_modules/ccstatusline/package.json'
|
||||
));
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
||||
if (filePath === '/Users/alice/.bun/install/global/node_modules/ccstatusline/package.json') {
|
||||
return '{"version":"2.2.14"}';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected read: ${String(filePath)}`);
|
||||
});
|
||||
|
||||
const activeCommand = inspectActiveGlobalCommand({
|
||||
commandAvailability: {
|
||||
npm: true,
|
||||
bun: true
|
||||
},
|
||||
platform: 'darwin'
|
||||
});
|
||||
|
||||
expect(activeCommand).toEqual({
|
||||
packageManager: 'bun',
|
||||
resolvedPath: '/Users/alice/.bun/bin/ccstatusline',
|
||||
resolvedPaths: ['/Users/alice/.bun/bin/ccstatusline'],
|
||||
binDir: '/Users/alice/.bun/bin',
|
||||
version: '2.2.14',
|
||||
warning: null
|
||||
});
|
||||
});
|
||||
|
||||
it('uses npm.cmd for Windows npm version lookup', () => {
|
||||
const execFileSyncSpy = mockExecFileSync({
|
||||
'where ccstatusline': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd\r\n',
|
||||
'npm.cmd prefix -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\r\n',
|
||||
'npm.cmd root -g': 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\node_modules\r\n'
|
||||
});
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => (
|
||||
filePath === 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\node_modules\\ccstatusline\\package.json'
|
||||
));
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
||||
if (filePath === 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\node_modules\\ccstatusline\\package.json') {
|
||||
return '{"version":"2.2.13"}';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected read: ${String(filePath)}`);
|
||||
});
|
||||
|
||||
const activeCommand = inspectActiveGlobalCommand({
|
||||
commandAvailability: {
|
||||
npm: true,
|
||||
bun: false
|
||||
},
|
||||
platform: 'win32'
|
||||
});
|
||||
|
||||
expect(activeCommand).toEqual({
|
||||
packageManager: 'npm',
|
||||
resolvedPath: 'C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd',
|
||||
resolvedPaths: ['C:\\Users\\Alice\\AppData\\Roaming\\npm\\ccstatusline.cmd'],
|
||||
binDir: 'C:\\Users\\Alice\\AppData\\Roaming\\npm',
|
||||
version: '2.2.13',
|
||||
warning: null
|
||||
});
|
||||
expect(execFileSyncSpy).toHaveBeenCalledWith(
|
||||
'npm.cmd',
|
||||
['root', '-g'],
|
||||
expect.objectContaining({ shell: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses npm.cmd for Windows npm uninstalls', async () => {
|
||||
const execFileSpy = vi.spyOn(childProcess, 'execFile').mockImplementation(((...args: unknown[]) => {
|
||||
const callback = args[3] as (error: Error | null) => void;
|
||||
callback(null);
|
||||
return {};
|
||||
}) as typeof childProcess.execFile);
|
||||
|
||||
await runGlobalPackageUninstall('npm', { platform: 'win32' });
|
||||
|
||||
expect(execFileSpy.mock.calls[0]?.[0]).toBe('npm.cmd');
|
||||
expect(execFileSpy.mock.calls[0]?.[1]).toEqual(['uninstall', '-g', 'ccstatusline']);
|
||||
expect(execFileSpy.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ shell: true }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
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 {
|
||||
applyLineGradient,
|
||||
getVisibleText,
|
||||
getVisibleWidth
|
||||
} from '../ansi';
|
||||
import {
|
||||
GRADIENT_PRESET_NAMES,
|
||||
applyGradientToText,
|
||||
gradientCodeAt,
|
||||
isGradientSpec,
|
||||
parseGradientSpec,
|
||||
rgbToAnsi256,
|
||||
sampleGradient
|
||||
} from '../gradient';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine,
|
||||
renderStatusLineWithInfo
|
||||
} from '../renderer';
|
||||
|
||||
const TRUECOLOR_CODE = /\x1b\[38;2;\d+;\d+;\d+m/g;
|
||||
const ANSI256_CODE = /\x1b\[38;5;\d+m/g;
|
||||
|
||||
function countMatches(text: string, pattern: RegExp): number {
|
||||
return text.match(pattern)?.length ?? 0;
|
||||
}
|
||||
|
||||
describe('isGradientSpec', () => {
|
||||
it('is true only for gradient: prefixed values', () => {
|
||||
expect(isGradientSpec('gradient:atlas')).toBe(true);
|
||||
expect(isGradientSpec('gradient:FF0000-0000FF')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for solid colors, empty, and undefined', () => {
|
||||
expect(isGradientSpec(undefined)).toBe(false);
|
||||
expect(isGradientSpec('')).toBe(false);
|
||||
expect(isGradientSpec('hex:FF0000')).toBe(false);
|
||||
expect(isGradientSpec('ansi256:120')).toBe(false);
|
||||
expect(isGradientSpec('cyan')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGradientSpec', () => {
|
||||
it('returns null for non-gradient values', () => {
|
||||
expect(parseGradientSpec(undefined)).toBeNull();
|
||||
expect(parseGradientSpec('')).toBeNull();
|
||||
expect(parseGradientSpec('hex:FF0000')).toBeNull();
|
||||
expect(parseGradientSpec('cyan')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when fewer than two stops resolve', () => {
|
||||
expect(parseGradientSpec('gradient:hex:FF0000')).toBeNull();
|
||||
expect(parseGradientSpec('gradient:not-a-color,also-bad')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses hex, #hex, and bare hex stops (whitespace tolerant)', () => {
|
||||
const stops = parseGradientSpec('gradient: hex:FF0000 , #00FF00 , 0000FF ');
|
||||
expect(stops).toEqual([
|
||||
{ r: 255, g: 0, b: 0 },
|
||||
{ r: 0, g: 255, b: 0 },
|
||||
{ r: 0, g: 0, b: 255 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses dash-separated bare hex stops', () => {
|
||||
const stops = parseGradientSpec('gradient:FF0000-0000FF');
|
||||
expect(stops).toEqual([
|
||||
{ r: 255, g: 0, b: 0 },
|
||||
{ r: 0, g: 0, b: 255 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses dash-separated #-prefixed stops', () => {
|
||||
const stops = parseGradientSpec('gradient:#FF0000-#0000FF');
|
||||
expect(stops).toEqual([
|
||||
{ r: 255, g: 0, b: 0 },
|
||||
{ r: 0, g: 0, b: 255 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves named presets (case-insensitive) to their stop list', () => {
|
||||
const retro = parseGradientSpec('gradient:retro');
|
||||
expect(retro).toHaveLength(9);
|
||||
expect(parseGradientSpec('gradient:RAINBOW')).toHaveLength(7);
|
||||
// every shipped preset resolves to >= 2 usable stops
|
||||
for (const name of GRADIENT_PRESET_NAMES) {
|
||||
expect((parseGradientSpec(`gradient:${name}`) ?? []).length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyGradientToText', () => {
|
||||
const stops = [{ r: 255, g: 0, b: 0 }, { r: 0, g: 0, b: 255 }];
|
||||
|
||||
it('emits one code per non-whitespace character and leaves whitespace uncolored', () => {
|
||||
const out = applyGradientToText('ab cd', stops, 'truecolor');
|
||||
expect(countMatches(out, TRUECOLOR_CODE)).toBe(4);
|
||||
// the space follows the visible char directly, with no color code in between
|
||||
expect(out).toContain('b ');
|
||||
});
|
||||
|
||||
it('emits no trailing reset (the caller appends it)', () => {
|
||||
const out = applyGradientToText('abc', stops, 'truecolor');
|
||||
expect(out.endsWith('\x1b[39m')).toBe(false);
|
||||
});
|
||||
|
||||
it('restarts the sweep per call (first visible code identical for two calls)', () => {
|
||||
const first = applyGradientToText('abc', stops, 'truecolor').match(TRUECOLOR_CODE)?.[0];
|
||||
const second = applyGradientToText('xyz', stops, 'truecolor').match(TRUECOLOR_CODE)?.[0];
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('is a no-op for ansi16, empty, and blank-only text', () => {
|
||||
expect(applyGradientToText('abc', stops, 'ansi16')).toBe('abc');
|
||||
expect(applyGradientToText('', stops, 'truecolor')).toBe('');
|
||||
expect(applyGradientToText(' ', stops, 'truecolor')).toBe(' ');
|
||||
});
|
||||
|
||||
it('passes ANSI and OSC 8 escape sequences through untouched', () => {
|
||||
const openLink = '\x1b]8;;https://example.com\x1b\\';
|
||||
const closeLink = '\x1b]8;;\x1b\\';
|
||||
const styledLink = `\x1b[1m${openLink}branch${closeLink}\x1b[22m`;
|
||||
const out = applyGradientToText(styledLink, stops, 'truecolor');
|
||||
|
||||
expect(out).toContain('\x1b[1m');
|
||||
expect(out).toContain(openLink);
|
||||
expect(out).toContain(closeLink);
|
||||
expect(out).toContain('\x1b[22m');
|
||||
expect(countMatches(out, TRUECOLOR_CODE)).toBe('branch'.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sampleGradient', () => {
|
||||
it('returns the endpoints (within OKLab round-trip tolerance)', () => {
|
||||
const stops = [{ r: 10, g: 20, b: 30 }, { r: 200, g: 150, b: 100 }];
|
||||
const start = sampleGradient(stops, 0);
|
||||
const end = sampleGradient(stops, 1);
|
||||
expect(Math.abs(start.r - 10)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(end.b - 100)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('produces a neutral mid-gray at the midpoint of black->white', () => {
|
||||
const mid = sampleGradient([{ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }], 0.5);
|
||||
expect(mid.r).toBe(mid.g);
|
||||
expect(mid.g).toBe(mid.b);
|
||||
expect(mid.r).toBeGreaterThan(80);
|
||||
expect(mid.r).toBeLessThan(180);
|
||||
});
|
||||
|
||||
it('clamps out-of-range positions', () => {
|
||||
const stops = [{ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }];
|
||||
expect(sampleGradient(stops, -1)).toEqual(sampleGradient(stops, 0));
|
||||
expect(sampleGradient(stops, 2)).toEqual(sampleGradient(stops, 1));
|
||||
});
|
||||
|
||||
it('lands on the interior stop of a 3-stop gradient at its position', () => {
|
||||
// t=0.5 across three stops brackets exactly on the middle stop (green).
|
||||
const mid = sampleGradient([{ r: 255, g: 0, b: 0 }, { r: 0, g: 255, b: 0 }, { r: 0, g: 0, b: 255 }], 0.5);
|
||||
expect(Math.abs(mid.r - 0)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(mid.g - 255)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(mid.b - 0)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rgbToAnsi256', () => {
|
||||
it('maps pure colors to the expected palette indices', () => {
|
||||
expect(rgbToAnsi256({ r: 0, g: 0, b: 0 })).toBe(16);
|
||||
expect(rgbToAnsi256({ r: 255, g: 255, b: 255 })).toBe(231);
|
||||
expect(rgbToAnsi256({ r: 255, g: 0, b: 0 })).toBe(196);
|
||||
});
|
||||
|
||||
it('maps mid grays into the grayscale ramp', () => {
|
||||
const index = rgbToAnsi256({ r: 128, g: 128, b: 128 });
|
||||
expect(index).toBeGreaterThanOrEqual(232);
|
||||
expect(index).toBeLessThanOrEqual(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradientCodeAt', () => {
|
||||
const stops = [{ r: 255, g: 0, b: 0 }, { r: 0, g: 0, b: 255 }];
|
||||
|
||||
it('emits a truecolor escape at truecolor level', () => {
|
||||
expect(gradientCodeAt(stops, 0, 'truecolor')).toMatch(/^\x1b\[38;2;\d+;\d+;\d+m$/);
|
||||
});
|
||||
|
||||
it('emits a 256-color escape at ansi256 level', () => {
|
||||
expect(gradientCodeAt(stops, 0.5, 'ansi256')).toMatch(/^\x1b\[38;5;\d+m$/);
|
||||
});
|
||||
|
||||
it('falls back to the 256-color path defensively at ansi16', () => {
|
||||
// Callers degrade before this, but if reached, ansi16 must not emit truecolor.
|
||||
expect(gradientCodeAt(stops, 0.5, 'ansi16')).toMatch(/^\x1b\[38;5;\d+m$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLineGradient', () => {
|
||||
const stops = [{ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }];
|
||||
|
||||
it('preserves visible width when coloring a styled line', () => {
|
||||
const line = '\x1b[38;2;255;0;0mhello\x1b[39m world';
|
||||
const out = applyLineGradient(line, stops, 'truecolor');
|
||||
expect(getVisibleWidth(out)).toBe(getVisibleWidth(line));
|
||||
});
|
||||
|
||||
it('emits one foreground code per visible cluster', () => {
|
||||
const out = applyLineGradient('abcdef', stops, 'truecolor');
|
||||
expect(countMatches(out, TRUECOLOR_CODE)).toBe(getVisibleWidth('abcdef'));
|
||||
});
|
||||
|
||||
it('sweeps through distinct colors across the line', () => {
|
||||
const out = applyLineGradient('abcdefghij', stops, 'truecolor');
|
||||
const codes = out.match(TRUECOLOR_CODE) ?? [];
|
||||
expect(new Set(codes).size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('passes OSC 8 hyperlinks through untouched', () => {
|
||||
const link = '\x1b]8;;https://example.com\x1b\\branch\x1b]8;;\x1b\\';
|
||||
const out = applyLineGradient(`a${link}b`, stops, 'truecolor');
|
||||
expect(out).toContain('https://example.com');
|
||||
expect(out).toContain('\x1b]8;;\x1b\\');
|
||||
expect(getVisibleWidth(out)).toBe(getVisibleWidth(`a${link}b`));
|
||||
});
|
||||
|
||||
it('colors both ends of a two-cluster line (denominator of exactly 1)', () => {
|
||||
// totalWidth 2 -> denominator 1: first cluster at t=0, second at t=1.
|
||||
const out = applyLineGradient('ab', stops, 'truecolor');
|
||||
const codes = out.match(TRUECOLOR_CODE) ?? [];
|
||||
expect(codes.length).toBe(2);
|
||||
expect(codes[0]).not.toBe(codes[1]);
|
||||
expect(getVisibleWidth(out)).toBe(2);
|
||||
});
|
||||
|
||||
it('uses 256-color escapes at ansi256 level', () => {
|
||||
const out = applyLineGradient('abc', stops, 'ansi256');
|
||||
expect(countMatches(out, ANSI256_CODE)).toBe(3);
|
||||
expect(countMatches(out, TRUECOLOR_CODE)).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op for ansi16, single-character, and empty lines', () => {
|
||||
expect(applyLineGradient('abc', stops, 'ansi16')).toBe('abc');
|
||||
expect(applyLineGradient('x', stops, 'truecolor')).toBe('x');
|
||||
expect(applyLineGradient('', stops, 'truecolor')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderStatusLine with a gradient override', () => {
|
||||
function createSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
flexMode: 'full',
|
||||
colorLevel: 3,
|
||||
...overrides,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
...(overrides.powerline ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderLine(widgets: WidgetItem[], settingsOverrides: Partial<Settings> = {}, terminalWidth = 200): string {
|
||||
const settings = createSettings(settingsOverrides);
|
||||
const context: RenderContext = { isPreview: false, terminalWidth };
|
||||
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
return renderStatusLine(widgets, settings, context, preRenderedLines[0] ?? [], preCalculatedMaxWidths);
|
||||
}
|
||||
|
||||
function renderLineWithInfo(widgets: WidgetItem[], settingsOverrides: Partial<Settings> = {}, terminalWidth = 200) {
|
||||
const settings = createSettings(settingsOverrides);
|
||||
const context: RenderContext = { isPreview: false, terminalWidth };
|
||||
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
return renderStatusLineWithInfo(widgets, settings, context, preRenderedLines[0] ?? [], preCalculatedMaxWidths);
|
||||
}
|
||||
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: 'a', type: 'custom-text', customText: 'model', color: 'hex:A89278' },
|
||||
{ id: 'b', type: 'custom-text', customText: ' branch', color: 'hex:A89278' }
|
||||
];
|
||||
|
||||
it('paints the whole line with a continuous gradient', () => {
|
||||
const gradient = 'gradient:hex:dbbb6f,hex:c4808a,hex:9070d0,hex:b8cad4,hex:4a8a5e';
|
||||
const line = renderLine(widgets, { overrideForegroundColor: gradient });
|
||||
const codes = line.match(TRUECOLOR_CODE) ?? [];
|
||||
expect(codes.length).toBe(getVisibleWidth(line));
|
||||
expect(new Set(codes).size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('leaves visible width unchanged versus the non-gradient render', () => {
|
||||
const plain = renderLine(widgets);
|
||||
const gradient = renderLine(widgets, { overrideForegroundColor: 'gradient:hex:dbbb6f,hex:4a8a5e' });
|
||||
expect(getVisibleWidth(gradient)).toBe(getVisibleWidth(plain));
|
||||
});
|
||||
|
||||
it('closes with a reset even when the line is truncated (no color leak)', () => {
|
||||
// Regression: the gradient pass must run AFTER truncation. If it runs before,
|
||||
// truncateStyledText cuts from the right and slices off the trailing \x1b[39m,
|
||||
// so the last color leaks past the status line. A narrow width forces
|
||||
// truncation; the rendered line must still end with the reset.
|
||||
const gradient = 'gradient:hex:dbbb6f,hex:c4808a,hex:9070d0,hex:b8cad4,hex:4a8a5e';
|
||||
const full = renderLine(widgets, { overrideForegroundColor: gradient });
|
||||
const truncated = renderLine(widgets, { overrideForegroundColor: gradient }, 8);
|
||||
|
||||
// truncation actually happened
|
||||
expect(getVisibleWidth(truncated)).toBeLessThan(getVisibleWidth(full));
|
||||
// and the gradient's trailing reset survived
|
||||
expect(truncated.endsWith('\x1b[39m')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports truncation after the gradient colors the ellipsis', () => {
|
||||
const gradient = 'gradient:hex:dbbb6f,hex:c4808a,hex:9070d0,hex:b8cad4,hex:4a8a5e';
|
||||
const result = renderLineWithInfo(widgets, { overrideForegroundColor: gradient }, 9);
|
||||
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.line.includes('...')).toBe(false);
|
||||
expect(getVisibleText(result.line)).toContain('...');
|
||||
});
|
||||
|
||||
it('applies global foreground gradients to widget text in powerline mode', () => {
|
||||
const gradient = 'gradient:FF0000-0000FF';
|
||||
const powerlineWidgets: WidgetItem[] = [
|
||||
{ id: 'a', type: 'custom-text', customText: 'abc' },
|
||||
{ id: 'b', type: 'custom-text', customText: 'def' }
|
||||
];
|
||||
const line = renderLine(powerlineWidgets, {
|
||||
overrideForegroundColor: gradient,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true
|
||||
}
|
||||
});
|
||||
const codes = line.match(TRUECOLOR_CODE) ?? [];
|
||||
|
||||
expect(getVisibleText(line)).toContain('abc');
|
||||
expect(getVisibleText(line)).toContain('def');
|
||||
expect(codes).toHaveLength(6);
|
||||
expect(codes[0]).not.toBe(codes[3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
|
||||
import { handleHookInput } from '../hook-handler';
|
||||
import { getSkillsFilePath } from '../skills';
|
||||
|
||||
let testHomeDir = '';
|
||||
let consoleLogSpy: MockInstance<typeof console.log>;
|
||||
|
||||
function readSkillsLog(sessionId: string): Record<string, unknown>[] {
|
||||
return fs.readFileSync(getSkillsFilePath(sessionId), 'utf-8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
describe('handleHookInput', () => {
|
||||
beforeEach(() => {
|
||||
testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-hook-handler-'));
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(testHomeDir);
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (testHomeDir) {
|
||||
fs.rmSync(testHomeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not write stdout for no-op hook inputs', () => {
|
||||
handleHookInput(null);
|
||||
handleHookInput('{ invalid json');
|
||||
handleHookInput(JSON.stringify({ hook_event_name: 'PreToolUse' }));
|
||||
handleHookInput(JSON.stringify({ session_id: 'session-1', hook_event_name: 'PreToolUse' }));
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(testHomeDir, '.cache', 'ccstatusline'))).toBe(false);
|
||||
});
|
||||
|
||||
it('records PreToolUse skill hooks without writing stdout', () => {
|
||||
handleHookInput(JSON.stringify({
|
||||
session_id: 'session-1',
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Skill',
|
||||
tool_input: { skill: 'review-pr' }
|
||||
}));
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
expect(readSkillsLog('session-1')).toMatchObject([
|
||||
{
|
||||
session_id: 'session-1',
|
||||
skill: 'review-pr',
|
||||
source: 'PreToolUse'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('records slash command UserPromptSubmit hooks without writing stdout', () => {
|
||||
handleHookInput(JSON.stringify({
|
||||
session_id: 'session-1',
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: '/commit staged changes'
|
||||
}));
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
expect(readSkillsLog('session-1')).toMatchObject([
|
||||
{
|
||||
session_id: 'session-1',
|
||||
skill: 'commit',
|
||||
source: 'UserPromptSubmit'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import {
|
||||
getJjChangeCounts,
|
||||
isInsideJjRepo,
|
||||
runJjArgs
|
||||
} from '../jj';
|
||||
|
||||
vi.mock('child_process', () => ({ execFileSync: vi.fn() }));
|
||||
|
||||
const mockExecFileSync = execFileSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockImplementation: (impl: () => never) => void;
|
||||
mockReturnValue: (value: string) => void;
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
describe('jj utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('runJjArgs', () => {
|
||||
it('runs jj command with resolved cwd and trims trailing newlines', () => {
|
||||
mockExecFileSync.mockReturnValue('some-output\n');
|
||||
const context: RenderContext = { data: { cwd: '/tmp/repo' } };
|
||||
|
||||
const result = runJjArgs(['log', '--limit', '1'], context);
|
||||
|
||||
expect(result).toBe('some-output');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('jj');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['log', '--limit', '1']);
|
||||
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
cwd: '/tmp/repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('runs jj command without cwd when no context directory exists', () => {
|
||||
mockExecFileSync.mockReturnValue('/tmp/repo\n');
|
||||
|
||||
const result = runJjArgs(['root'], {});
|
||||
|
||||
expect(result).toBe('/tmp/repo');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
windowsHide: true
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when output is empty', () => {
|
||||
mockExecFileSync.mockReturnValue('');
|
||||
|
||||
expect(runJjArgs(['root'], {})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty string when allowEmpty is true and output is empty', () => {
|
||||
mockExecFileSync.mockReturnValue('');
|
||||
|
||||
expect(runJjArgs(['log'], {}, true)).toBe('');
|
||||
});
|
||||
|
||||
it('returns null when the command fails', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('jj failed'); });
|
||||
|
||||
expect(runJjArgs(['status'], {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsideJjRepo', () => {
|
||||
it('returns true when jj root succeeds', () => {
|
||||
mockExecFileSync.mockReturnValue('/tmp/repo\n');
|
||||
|
||||
expect(isInsideJjRepo({})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when jj root fails', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('jj failed'); });
|
||||
|
||||
expect(isInsideJjRepo({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJjChangeCounts', () => {
|
||||
it('parses insertions and deletions from jj diff --stat', () => {
|
||||
mockExecFileSync.mockReturnValue('2 files changed, 5 insertions(+), 3 deletions(-)');
|
||||
|
||||
expect(getJjChangeCounts({})).toEqual({
|
||||
insertions: 5,
|
||||
deletions: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('handles singular insertion/deletion forms', () => {
|
||||
mockExecFileSync.mockReturnValue('1 file changed, 1 insertion(+), 1 deletion(-)');
|
||||
|
||||
expect(getJjChangeCounts({})).toEqual({
|
||||
insertions: 1,
|
||||
deletions: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counts when jj diff --stat returns empty', () => {
|
||||
mockExecFileSync.mockReturnValue('');
|
||||
|
||||
expect(getJjChangeCounts({})).toEqual({
|
||||
insertions: 0,
|
||||
deletions: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counts when jj diff command fails', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('jj failed'); });
|
||||
|
||||
expect(getJjChangeCounts({})).toEqual({
|
||||
insertions: 0,
|
||||
deletions: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -156,6 +156,8 @@ describe('jsonl transcript metrics', () => {
|
||||
inputTokens: 1799,
|
||||
outputTokens: 141,
|
||||
cachedTokens: 92,
|
||||
cacheReadTokens: 56,
|
||||
cacheCreationTokens: 36,
|
||||
totalTokens: 2032,
|
||||
contextLength: 250
|
||||
});
|
||||
@@ -222,6 +224,8 @@ describe('jsonl transcript metrics', () => {
|
||||
inputTokens: 2, // 1 + 1
|
||||
outputTokens: 550, // 150 + 400
|
||||
cachedTokens: 46500, // (12000 + 11000) + (23000 + 500)
|
||||
cacheReadTokens: 35000, // 12000 + 23000
|
||||
cacheCreationTokens: 11500, // 11000 + 500
|
||||
totalTokens: 47052, // 2 + 550 + 46500
|
||||
contextLength: 23501 // last main-chain final entry: 1 + 23000 + 500
|
||||
});
|
||||
@@ -267,6 +271,8 @@ describe('jsonl transcript metrics', () => {
|
||||
inputTokens: 4,
|
||||
outputTokens: 140,
|
||||
cachedTokens: 1200,
|
||||
cacheReadTokens: 1000,
|
||||
cacheCreationTokens: 200,
|
||||
totalTokens: 1344,
|
||||
contextLength: 1204
|
||||
});
|
||||
@@ -320,6 +326,8 @@ describe('jsonl transcript metrics', () => {
|
||||
inputTokens: 5,
|
||||
outputTokens: 200,
|
||||
cachedTokens: 375,
|
||||
cacheReadTokens: 300,
|
||||
cacheCreationTokens: 75,
|
||||
totalTokens: 580,
|
||||
contextLength: 228
|
||||
});
|
||||
@@ -357,6 +365,8 @@ describe('jsonl transcript metrics', () => {
|
||||
inputTokens: 300,
|
||||
outputTokens: 130,
|
||||
cachedTokens: 80,
|
||||
cacheReadTokens: 50,
|
||||
cacheCreationTokens: 30,
|
||||
totalTokens: 510,
|
||||
contextLength: 250
|
||||
});
|
||||
@@ -368,6 +378,8 @@ describe('jsonl transcript metrics', () => {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 0
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { formatTokens } from '../renderer';
|
||||
|
||||
describe('formatTokens', () => {
|
||||
it('returns the bare number below 1000', () => {
|
||||
expect(formatTokens(0)).toBe('0');
|
||||
expect(formatTokens(42)).toBe('42');
|
||||
expect(formatTokens(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('formats thousands with one decimal and a k suffix', () => {
|
||||
expect(formatTokens(1000)).toBe('1.0k');
|
||||
expect(formatTokens(5160)).toBe('5.2k');
|
||||
expect(formatTokens(25443)).toBe('25.4k');
|
||||
});
|
||||
|
||||
it('formats millions with one decimal and an M suffix', () => {
|
||||
expect(formatTokens(1000000)).toBe('1.0M');
|
||||
expect(formatTokens(1147000)).toBe('1.1M');
|
||||
});
|
||||
|
||||
it('promotes to M when the k value would round up to 1000.0 (regression)', () => {
|
||||
// 999_950–999_999 previously rendered as "1000.0k" instead of "1.0M".
|
||||
expect(formatTokens(999950)).toBe('1.0M');
|
||||
expect(formatTokens(999999)).toBe('1.0M');
|
||||
});
|
||||
|
||||
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,16 +27,32 @@ 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();
|
||||
delete process.env.CCSTATUSLINE_WIDTH;
|
||||
});
|
||||
|
||||
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';
|
||||
@@ -46,7 +62,7 @@ describe('terminal utils', () => {
|
||||
return 'ttys001\n';
|
||||
}
|
||||
|
||||
if (command === `stty size < /dev/ttys001 | awk '{print $2}'`) {
|
||||
if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`) {
|
||||
return '120\n';
|
||||
}
|
||||
|
||||
@@ -57,11 +73,12 @@ describe('terminal utils', () => {
|
||||
expect(mockExecSync.mock.calls.map(([command]) => command)).toEqual([
|
||||
`ps -o ppid= -p ${process.pid}`,
|
||||
'ps -o tty= -p 1234',
|
||||
`stty size < /dev/ttys001 | awk '{print $2}'`
|
||||
`stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`
|
||||
]);
|
||||
});
|
||||
|
||||
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';
|
||||
@@ -79,7 +96,7 @@ describe('terminal utils', () => {
|
||||
return ' ttys009 \n';
|
||||
}
|
||||
|
||||
if (command === `stty size < /dev/ttys009 | awk '{print $2}'`) {
|
||||
if (command === `stty -F /dev/ttys009 size 2>/dev/null | awk '{print $2}'`) {
|
||||
return '104\n';
|
||||
}
|
||||
|
||||
@@ -89,7 +106,35 @@ describe('terminal utils', () => {
|
||||
expect(getTerminalWidth()).toBe(104);
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (command === `ps -o ppid= -p ${process.pid}`) {
|
||||
return '1234\n';
|
||||
}
|
||||
|
||||
if (command === 'ps -o tty= -p 1234') {
|
||||
return 'ttys003\n';
|
||||
}
|
||||
|
||||
if (command === `stty -F /dev/ttys003 size 2>/dev/null | awk '{print $2}'`) {
|
||||
return '\n';
|
||||
}
|
||||
|
||||
if (command === `stty -f /dev/ttys003 size 2>/dev/null | awk '{print $2}'`) {
|
||||
return '142\n';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
expect(getTerminalWidth()).toBe(142);
|
||||
});
|
||||
|
||||
it('falls back to tput cols when ancestor probing fails', () => {
|
||||
pinPosixPlatform();
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('ps unavailable'); });
|
||||
mockExecSync.mockReturnValueOnce('90\n');
|
||||
|
||||
@@ -98,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';
|
||||
@@ -107,7 +153,9 @@ describe('terminal utils', () => {
|
||||
return 'ttys001\n';
|
||||
}
|
||||
|
||||
if (command === `stty size < /dev/ttys001 | awk '{print $2}'`) {
|
||||
if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`
|
||||
|| command === `stty -f /dev/ttys001 size 2>/dev/null | awk '{print $2}'`
|
||||
|| command === `stty size < /dev/ttys001 2>/dev/null | awk '{print $2}'`) {
|
||||
return 'not-a-number\n';
|
||||
}
|
||||
|
||||
@@ -126,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';
|
||||
@@ -143,7 +192,7 @@ describe('terminal utils', () => {
|
||||
return 'ttys010\n';
|
||||
}
|
||||
|
||||
if (command === `stty size < /dev/ttys010 | awk '{print $2}'`) {
|
||||
if (command === `stty -F /dev/ttys010 size 2>/dev/null | awk '{print $2}'`) {
|
||||
return '80\n';
|
||||
}
|
||||
|
||||
@@ -154,14 +203,77 @@ 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'); });
|
||||
|
||||
expect(canDetectTerminalWidth()).toBe(false);
|
||||
});
|
||||
|
||||
it('honors CCSTATUSLINE_WIDTH override before probing', () => {
|
||||
process.env.CCSTATUSLINE_WIDTH = '220';
|
||||
|
||||
expect(getTerminalWidth()).toBe(220);
|
||||
expect(mockExecSync.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a non-positive CCSTATUSLINE_WIDTH and falls back to probing', () => {
|
||||
pinPosixPlatform();
|
||||
process.env.CCSTATUSLINE_WIDTH = '0';
|
||||
|
||||
mockExecSync.mockImplementation((command: string) => {
|
||||
if (command === `ps -o ppid= -p ${process.pid}`) {
|
||||
return '1234\n';
|
||||
}
|
||||
|
||||
if (command === 'ps -o tty= -p 1234') {
|
||||
return 'ttys001\n';
|
||||
}
|
||||
|
||||
if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`) {
|
||||
return '160\n';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
expect(getTerminalWidth()).toBe(160);
|
||||
});
|
||||
|
||||
it('ignores a non-numeric CCSTATUSLINE_WIDTH and falls back to probing', () => {
|
||||
pinPosixPlatform();
|
||||
process.env.CCSTATUSLINE_WIDTH = 'wide';
|
||||
|
||||
mockExecSync.mockImplementation((command: string) => {
|
||||
if (command === `ps -o ppid= -p ${process.pid}`) {
|
||||
return '1234\n';
|
||||
}
|
||||
|
||||
if (command === 'ps -o tty= -p 1234') {
|
||||
return 'ttys001\n';
|
||||
}
|
||||
|
||||
if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`) {
|
||||
return '160\n';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
expect(getTerminalWidth()).toBe(160);
|
||||
});
|
||||
|
||||
it('CCSTATUSLINE_WIDTH override applies on Windows where probing is disabled', () => {
|
||||
setPlatform('win32');
|
||||
process.env.CCSTATUSLINE_WIDTH = '180';
|
||||
|
||||
expect(getTerminalWidth()).toBe(180);
|
||||
expect(canDetectTerminalWidth()).toBe(true);
|
||||
expect(mockExecSync.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('disables width detection on Windows', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
||||
setPlatform('win32');
|
||||
|
||||
expect(getTerminalWidth()).toBeNull();
|
||||
expect(canDetectTerminalWidth()).toBe(false);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import * as childProcess from 'child_process';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { CCSTATUSLINE_COMMANDS } from '../claude-settings';
|
||||
import { initConfigPath } from '../config';
|
||||
import {
|
||||
buildUpdateCheckResult,
|
||||
checkForUpdates,
|
||||
runGlobalPackageInstall,
|
||||
type UpdateCheckResult
|
||||
} from '../update-checker';
|
||||
|
||||
const ALL_AVAILABLE = {
|
||||
npm: true,
|
||||
npx: true,
|
||||
bun: true,
|
||||
bunx: true
|
||||
};
|
||||
|
||||
describe('update checker', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns up-to-date when latest is not newer', () => {
|
||||
const result = buildUpdateCheckResult({
|
||||
currentVersion: '2.2.13',
|
||||
latestVersion: '2.2.13',
|
||||
installedCommand: CCSTATUSLINE_COMMANDS.NPM,
|
||||
commandAvailability: ALL_AVAILABLE
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'up-to-date',
|
||||
currentVersion: '2.2.13',
|
||||
latestVersion: '2.2.13',
|
||||
installation: {
|
||||
method: 'auto-update',
|
||||
packageManager: 'npm'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns update available for newer registry versions', () => {
|
||||
const result = buildUpdateCheckResult({
|
||||
currentVersion: '2.2.13',
|
||||
latestVersion: '2.3.0',
|
||||
installedCommand: null,
|
||||
commandAvailability: ALL_AVAILABLE
|
||||
});
|
||||
|
||||
expect(result.status).toBe('update-available');
|
||||
expect((result as UpdateCheckResult & { status: 'update-available' }).actions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns registry failure when the registry request fails', async () => {
|
||||
const result = await checkForUpdates({
|
||||
currentVersion: '2.2.13',
|
||||
installedCommand: CCSTATUSLINE_COMMANDS.NPM,
|
||||
commandAvailability: ALL_AVAILABLE,
|
||||
latestVersionFetcher: () => Promise.reject(new Error('network unavailable'))
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'registry-failure',
|
||||
currentVersion: '2.2.13',
|
||||
installation: {
|
||||
method: 'auto-update',
|
||||
packageManager: 'npm'
|
||||
},
|
||||
errorMessage: 'network unavailable'
|
||||
});
|
||||
});
|
||||
|
||||
it('offers npm global update for PATH-resolved pinned npm installs', () => {
|
||||
const result = buildUpdateCheckResult({
|
||||
currentVersion: '2.2.13',
|
||||
latestVersion: '2.3.0',
|
||||
installedCommand: CCSTATUSLINE_COMMANDS.GLOBAL,
|
||||
installationMetadata: {
|
||||
method: 'pinned',
|
||||
packageManager: 'npm',
|
||||
installedVersion: '2.2.13'
|
||||
},
|
||||
commandAvailability: ALL_AVAILABLE
|
||||
});
|
||||
|
||||
expect(result.status).toBe('update-available');
|
||||
if (result.status !== 'update-available') {
|
||||
return;
|
||||
}
|
||||
expect(result.actions).toEqual([
|
||||
{
|
||||
id: 'npm-global',
|
||||
packageManager: 'npm',
|
||||
command: 'npm install -g ccstatusline@2.3.0',
|
||||
version: '2.3.0',
|
||||
available: true
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers bun global update for PATH-resolved pinned bun installs', () => {
|
||||
const result = buildUpdateCheckResult({
|
||||
currentVersion: '2.2.13',
|
||||
latestVersion: '2.3.0',
|
||||
installedCommand: CCSTATUSLINE_COMMANDS.GLOBAL,
|
||||
installationMetadata: {
|
||||
method: 'pinned',
|
||||
packageManager: 'bun',
|
||||
installedVersion: '2.2.13'
|
||||
},
|
||||
commandAvailability: ALL_AVAILABLE
|
||||
});
|
||||
|
||||
expect(result.status).toBe('update-available');
|
||||
if (result.status !== 'update-available') {
|
||||
return;
|
||||
}
|
||||
expect(result.actions.map(action => action.command)).toEqual(['bun add -g ccstatusline@2.3.0']);
|
||||
});
|
||||
|
||||
it('offers both global update actions when pinned metadata has no resolved package manager', () => {
|
||||
const result = buildUpdateCheckResult({
|
||||
currentVersion: '2.2.13',
|
||||
latestVersion: '2.3.0',
|
||||
installedCommand: CCSTATUSLINE_COMMANDS.GLOBAL,
|
||||
installationMetadata: {
|
||||
method: 'pinned',
|
||||
installedVersion: '2.2.13'
|
||||
},
|
||||
commandAvailability: ALL_AVAILABLE
|
||||
});
|
||||
|
||||
expect(result.status).toBe('update-available');
|
||||
if (result.status !== 'update-available') {
|
||||
return;
|
||||
}
|
||||
expect(result.installation).toEqual({
|
||||
method: 'pinned',
|
||||
packageManager: 'unknown',
|
||||
installedVersion: '2.2.13'
|
||||
});
|
||||
expect(result.actions.map(action => action.packageManager)).toEqual(['npm', 'bun']);
|
||||
});
|
||||
|
||||
it('offers only the active manager for PATH-resolved self-managed installs', () => {
|
||||
const result = buildUpdateCheckResult({
|
||||
currentVersion: '2.2.13',
|
||||
latestVersion: '2.3.0',
|
||||
installedCommand: CCSTATUSLINE_COMMANDS.GLOBAL,
|
||||
installationMetadata: {
|
||||
method: 'self-managed',
|
||||
packageManager: 'npm'
|
||||
},
|
||||
commandAvailability: ALL_AVAILABLE
|
||||
});
|
||||
|
||||
expect(result.status).toBe('update-available');
|
||||
if (result.status !== 'update-available') {
|
||||
return;
|
||||
}
|
||||
expect(result.installation).toEqual({
|
||||
method: 'self-managed',
|
||||
packageManager: 'npm'
|
||||
});
|
||||
expect(result.actions.map(action => action.packageManager)).toEqual(['npm']);
|
||||
});
|
||||
|
||||
it('uses npm.cmd for Windows global npm installs', async () => {
|
||||
const execFileSpy = vi.spyOn(childProcess, 'execFile').mockImplementation(((...args: unknown[]) => {
|
||||
const callback = args[3] as (error: Error | null) => void;
|
||||
callback(null);
|
||||
return {};
|
||||
}) as typeof childProcess.execFile);
|
||||
|
||||
await runGlobalPackageInstall('npm', '2.3.0', { platform: 'win32' });
|
||||
|
||||
expect(execFileSpy.mock.calls[0]?.[0]).toBe('npm.cmd');
|
||||
expect(execFileSpy.mock.calls[0]?.[1]).toEqual(['install', '-g', 'ccstatusline@2.3.0']);
|
||||
expect(execFileSpy.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ shell: true }));
|
||||
});
|
||||
|
||||
it('does not offer global actions for auto-update installs', () => {
|
||||
initConfigPath();
|
||||
const result = buildUpdateCheckResult({
|
||||
currentVersion: '2.2.13',
|
||||
latestVersion: '2.3.0',
|
||||
installedCommand: CCSTATUSLINE_COMMANDS.BUNX,
|
||||
commandAvailability: ALL_AVAILABLE
|
||||
});
|
||||
|
||||
expect(result.status).toBe('update-available');
|
||||
if (result.status !== 'update-available') {
|
||||
return;
|
||||
}
|
||||
expect(result.actions).toEqual([]);
|
||||
expect(result.autoUpdateLaunchCommand).toBe(CCSTATUSLINE_COMMANDS.BUNX);
|
||||
});
|
||||
|
||||
it('offers both global commands for unknown global installs and marks unavailable managers', () => {
|
||||
const result = buildUpdateCheckResult({
|
||||
currentVersion: '2.2.13',
|
||||
latestVersion: '2.3.0',
|
||||
installedCommand: CCSTATUSLINE_COMMANDS.GLOBAL,
|
||||
commandAvailability: {
|
||||
...ALL_AVAILABLE,
|
||||
bun: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.status).toBe('update-available');
|
||||
if (result.status !== 'update-available') {
|
||||
return;
|
||||
}
|
||||
expect(result.installation).toEqual({
|
||||
method: 'self-managed',
|
||||
packageManager: 'unknown'
|
||||
});
|
||||
expect(result.actions).toEqual([
|
||||
{
|
||||
id: 'npm-global',
|
||||
packageManager: 'npm',
|
||||
command: 'npm install -g ccstatusline@2.3.0',
|
||||
version: '2.3.0',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'bun-global',
|
||||
packageManager: 'bun',
|
||||
command: 'bun add -g ccstatusline@2.3.0',
|
||||
version: '2.3.0',
|
||||
available: 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';
|
||||
@@ -21,6 +22,7 @@ interface UsageProbeResult {
|
||||
requestCount: number;
|
||||
proxyAgentConfigured: boolean;
|
||||
requestHost: string | null;
|
||||
homedir: string;
|
||||
lockContents: string | null;
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ interface ProbeOptions {
|
||||
mode?: 'error' | 'status' | 'success' | 'unexpected';
|
||||
nowMs: number;
|
||||
pathDir?: string;
|
||||
requiredFields?: string[];
|
||||
responseBody?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
statusCode?: number;
|
||||
@@ -136,10 +139,11 @@ const { fetchUsageData } = await import(${JSON.stringify(usageModulePath)});
|
||||
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock');
|
||||
const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json');
|
||||
const nowMs = Number(process.env.TEST_NOW_MS || Date.now());
|
||||
const requiredFields = JSON.parse(process.env.TEST_REQUIRED_FIELDS_JSON || '[]');
|
||||
Date.now = () => nowMs;
|
||||
|
||||
const first = await fetchUsageData();
|
||||
const second = await fetchUsageData();
|
||||
const first = await fetchUsageData({ requiredFields });
|
||||
const second = await fetchUsageData({ requiredFields });
|
||||
process.stdout.write(JSON.stringify({
|
||||
first,
|
||||
second,
|
||||
@@ -148,6 +152,7 @@ process.stdout.write(JSON.stringify({
|
||||
requestCount,
|
||||
proxyAgentConfigured,
|
||||
requestHost,
|
||||
homedir: os.homedir(),
|
||||
lockContents: fs.existsSync(lockFile) ? fs.readFileSync(lockFile, 'utf8') : null
|
||||
}));
|
||||
`;
|
||||
@@ -183,24 +188,50 @@ process.stdout.write(JSON.stringify({
|
||||
}
|
||||
|
||||
function runProbe(options: ProbeOptions): UsageProbeResult {
|
||||
const output = realExecFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: options.home,
|
||||
PATH: options.pathDir ?? '/nonexistent',
|
||||
TEST_NOW_MS: String(options.nowMs),
|
||||
TEST_REQUEST_MODE: options.mode ?? 'success',
|
||||
TEST_RESPONSE_BODY: options.responseBody ?? '',
|
||||
TEST_RESPONSE_HEADERS_JSON: JSON.stringify(options.responseHeaders ?? {}),
|
||||
TEST_STATUS_CODE: String(options.statusCode ?? (options.mode === 'success' ? 200 : 500)),
|
||||
...(options.claudeConfigDir ? { CLAUDE_CONFIG_DIR: options.claudeConfigDir } : {}),
|
||||
...(options.httpsProxy !== undefined ? { HTTPS_PROXY: options.httpsProxy } : {}),
|
||||
...(options.lowercaseHttpsProxy !== undefined ? { https_proxy: options.lowercaseHttpsProxy } : {})
|
||||
}
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => {
|
||||
const normalizedKey = key.toUpperCase();
|
||||
return normalizedKey !== 'CLAUDE_CONFIG_DIR' && normalizedKey !== 'HTTPS_PROXY';
|
||||
}));
|
||||
|
||||
Object.assign(env, {
|
||||
HOME: options.home,
|
||||
// os.homedir() prefers USERPROFILE on Windows; inheriting the
|
||||
// real one lets the probe escape into the user's actual home
|
||||
// and read/write the live ~/.cache/ccstatusline
|
||||
USERPROFILE: options.home,
|
||||
PATH: options.pathDir ?? '/nonexistent',
|
||||
TEST_REQUIRED_FIELDS_JSON: JSON.stringify(options.requiredFields ?? []),
|
||||
TEST_NOW_MS: String(options.nowMs),
|
||||
TEST_REQUEST_MODE: options.mode ?? 'success',
|
||||
TEST_RESPONSE_BODY: options.responseBody ?? '',
|
||||
TEST_RESPONSE_HEADERS_JSON: JSON.stringify(options.responseHeaders ?? {}),
|
||||
TEST_STATUS_CODE: String(options.statusCode ?? (options.mode === 'success' ? 200 : 500))
|
||||
});
|
||||
|
||||
return JSON.parse(output) as UsageProbeResult;
|
||||
if (options.claudeConfigDir !== undefined) {
|
||||
env.CLAUDE_CONFIG_DIR = options.claudeConfigDir;
|
||||
}
|
||||
|
||||
if (options.httpsProxy !== undefined) {
|
||||
env.HTTPS_PROXY = options.httpsProxy;
|
||||
}
|
||||
|
||||
if (options.lowercaseHttpsProxy !== undefined) {
|
||||
env.https_proxy = options.lowercaseHttpsProxy;
|
||||
}
|
||||
|
||||
const output = realExecFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env
|
||||
});
|
||||
|
||||
const result = JSON.parse(output) as UsageProbeResult;
|
||||
|
||||
// A probe resolving a different home has escaped its sandbox and would
|
||||
// read or write the real user's ~/.cache/ccstatusline
|
||||
expect(result.homedir).toBe(options.home);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
@@ -241,6 +272,109 @@ describe('fetchUsageData error handling', () => {
|
||||
resets_at: '2030-01-08T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
const perModelSuccessResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 17,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
},
|
||||
seven_day_sonnet: {
|
||||
utilization: 8,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
const nullPerModelResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 17,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
},
|
||||
seven_day_sonnet: null,
|
||||
seven_day_opus: null
|
||||
});
|
||||
const cohortResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 52,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: null,
|
||||
seven_day_oauth_apps: null,
|
||||
seven_day_sonnet: null,
|
||||
seven_day_opus: null,
|
||||
seven_day_cowork: null,
|
||||
seven_day_omelette: {
|
||||
utilization: 0,
|
||||
resets_at: null
|
||||
},
|
||||
tangelo: null,
|
||||
iguana_necktie: null,
|
||||
omelette_promotional: null,
|
||||
extra_usage: {
|
||||
is_enabled: false,
|
||||
monthly_limit: null,
|
||||
used_credits: null,
|
||||
utilization: null,
|
||||
currency: null,
|
||||
disabled_reason: null
|
||||
}
|
||||
});
|
||||
const extraUsageResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 17,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
},
|
||||
extra_usage: {
|
||||
is_enabled: true,
|
||||
monthly_limit: 400000,
|
||||
used_credits: 10600,
|
||||
utilization: 2.6,
|
||||
currency: 'EUR'
|
||||
}
|
||||
});
|
||||
const noLimitExtraUsageResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 17,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
},
|
||||
extra_usage: {
|
||||
is_enabled: true,
|
||||
monthly_limit: null,
|
||||
used_credits: 542,
|
||||
utilization: null,
|
||||
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.',
|
||||
@@ -354,7 +488,9 @@ describe('fetchUsageData error handling', () => {
|
||||
expect(lowercaseProxyResult.first).toEqual(successResult.first);
|
||||
expect(lowercaseProxyResult.second).toEqual(successResult.first);
|
||||
expect(lowercaseProxyResult.requestCount).toBe(1);
|
||||
expect(lowercaseProxyResult.proxyAgentConfigured).toBe(false);
|
||||
// Windows environment variables are case-insensitive, so a
|
||||
// lowercase https_proxy is indistinguishable from HTTPS_PROXY there
|
||||
expect(lowercaseProxyResult.proxyAgentConfigured).toBe(process.platform === 'win32');
|
||||
|
||||
const blankProxyResult = harness.runProbe({
|
||||
claudeConfigDir: blankProxyHome.claudeConfig,
|
||||
@@ -418,6 +554,526 @@ describe('fetchUsageData error handling', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('treats null API per-model buckets as zero usage', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('null-per-model');
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
requiredFields: ['weeklySonnetUsage', 'weeklyOpusUsage'],
|
||||
responseBody: nullPerModelResponseBody
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z',
|
||||
weeklySonnetUsage: 0,
|
||||
weeklyOpusUsage: 0
|
||||
});
|
||||
expect(result.second).toEqual(result.first);
|
||||
expect(result.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('parses null aggregate buckets and cohort fields from the usage API', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('cohort-fields');
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
requiredFields: ['weeklyUsage', 'weeklySonnetUsage', 'weeklyOpusUsage', 'extraUsageEnabled'],
|
||||
responseBody: cohortResponseBody
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({
|
||||
sessionUsage: 52,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 0,
|
||||
weeklySonnetUsage: 0,
|
||||
weeklyOpusUsage: 0,
|
||||
extraUsageEnabled: false
|
||||
});
|
||||
expect(result.second).toEqual(result.first);
|
||||
expect(result.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('parses extra usage budget fields from the usage API', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('extra-usage');
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
requiredFields: ['extraUsageEnabled', 'extraUsageLimit', 'extraUsageUsed', 'extraUsageUtilization'],
|
||||
responseBody: extraUsageResponseBody
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z',
|
||||
extraUsageEnabled: true,
|
||||
extraUsageLimit: 400000,
|
||||
extraUsageUsed: 10600,
|
||||
extraUsageUtilization: 2.6,
|
||||
extraUsageCurrency: 'EUR'
|
||||
});
|
||||
expect(result.second).toEqual(result.first);
|
||||
expect(result.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats disabled extra usage as complete for extra usage widget fields', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('disabled-extra-usage');
|
||||
const requiredFields = ['extraUsageEnabled', 'extraUsageLimit', 'extraUsageUsed', 'extraUsageUtilization'];
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
requiredFields,
|
||||
responseBody: cohortResponseBody
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({
|
||||
sessionUsage: 52,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 0,
|
||||
weeklySonnetUsage: 0,
|
||||
weeklyOpusUsage: 0,
|
||||
extraUsageEnabled: false
|
||||
});
|
||||
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
|
||||
});
|
||||
|
||||
expect(cachedResult.first).toEqual(result.first);
|
||||
expect(cachedResult.second).toEqual(result.first);
|
||||
expect(cachedResult.requestCount).toBe(0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('no-limit-extra-usage');
|
||||
const requiredFields = ['extraUsageEnabled', 'extraUsageLimit', 'extraUsageUsed', 'extraUsageUtilization'];
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
requiredFields,
|
||||
responseBody: noLimitExtraUsageResponseBody
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z',
|
||||
extraUsageEnabled: true,
|
||||
extraUsageUsed: 542
|
||||
});
|
||||
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
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
expect(cachedResult.first).toEqual(result.first);
|
||||
expect(cachedResult.second).toEqual(result.first);
|
||||
expect(cachedResult.requestCount).toBe(0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps parse-error locks distinct from timeout locks', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('parse-error-lock');
|
||||
const parseErrorResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: '{'
|
||||
});
|
||||
|
||||
expect(parseErrorResult.first).toEqual({ error: 'parse-error' });
|
||||
expect(parseErrorResult.second).toEqual({ error: 'parse-error' });
|
||||
expect(parseLockContents(parseErrorResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(nowMs / 1000) + 30,
|
||||
error: 'parse-error'
|
||||
});
|
||||
|
||||
const activeLockResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(activeLockResult.first).toEqual({ error: 'parse-error' });
|
||||
expect(activeLockResult.second).toEqual({ error: 'parse-error' });
|
||||
expect(activeLockResult.requestCount).toBe(0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('bypasses fresh aggregate-only cache when requested per-model fields are missing', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('required-fields');
|
||||
const aggregateOnlyResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(aggregateOnlyResult.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z'
|
||||
});
|
||||
expect(aggregateOnlyResult.requestCount).toBe(1);
|
||||
|
||||
const perModelResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs: nowMs + 31000,
|
||||
pathDir: home.bin,
|
||||
requiredFields: ['weeklySonnetUsage'],
|
||||
responseBody: perModelSuccessResponseBody
|
||||
});
|
||||
|
||||
expect(perModelResult.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z',
|
||||
weeklySonnetUsage: 8,
|
||||
weeklySonnetResetAt: '2030-01-07T00:00:00.000Z'
|
||||
});
|
||||
expect(perModelResult.second).toEqual(perModelResult.first);
|
||||
expect(perModelResult.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses stale cached data during a numeric Retry-After backoff and retries after expiry', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ function makeLines(...lineItems: WidgetItem[][]): WidgetItem[][] {
|
||||
return lineItems;
|
||||
}
|
||||
|
||||
function epochToIso(epochSeconds: number): string {
|
||||
return new Date(epochSeconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
describe('usage prefetch', () => {
|
||||
let mockFetchUsageData: {
|
||||
mock: { calls: unknown[][] };
|
||||
@@ -44,6 +48,14 @@ describe('usage prefetch', () => {
|
||||
),
|
||||
name: 'detects when usage widgets are present'
|
||||
},
|
||||
{
|
||||
expected: true,
|
||||
lines: makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'extra-usage-remaining' }]
|
||||
),
|
||||
name: 'detects when extra usage widgets are present'
|
||||
},
|
||||
{
|
||||
expected: false,
|
||||
lines: makeLines(
|
||||
@@ -114,7 +126,7 @@ describe('usage prefetch', () => {
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to API fetch when rate_limits has no usable percentages', async () => {
|
||||
it('merges reset-only rate_limits data with API usage data', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ sessionUsage: 42 });
|
||||
|
||||
const lines = makeLines(
|
||||
@@ -123,13 +135,16 @@ describe('usage prefetch', () => {
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { five_hour: { resets_at: 1774020000 } } });
|
||||
|
||||
expect(usageData).toEqual({ sessionUsage: 42 });
|
||||
expect(usageData).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: epochToIso(1774020000)
|
||||
});
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to API fetch when seven_day is absent from rate_limits', async () => {
|
||||
it('merges API weekly data under partial statusline data', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({
|
||||
sessionUsage: 50,
|
||||
sessionUsage: 99,
|
||||
sessionResetAt: '2026-03-20T12:00:00.000Z',
|
||||
weeklyUsage: 10,
|
||||
weeklyResetAt: '2026-03-27T12:00:00.000Z'
|
||||
@@ -143,13 +158,319 @@ describe('usage prefetch', () => {
|
||||
|
||||
expect(usageData).toEqual({
|
||||
sessionUsage: 50,
|
||||
sessionResetAt: '2026-03-20T12:00:00.000Z',
|
||||
sessionResetAt: epochToIso(1774020000),
|
||||
weeklyUsage: 10,
|
||||
weeklyResetAt: '2026-03-27T12:00:00.000Z'
|
||||
});
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('uses per-model rate_limits buckets when a per-model widget is present', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ sessionUsage: 99 });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'weekly-sonnet-usage' }, { id: '2', type: 'weekly-opus-usage' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 42, resets_at: 1774020000 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 },
|
||||
seven_day_sonnet: { used_percentage: 8, resets_at: 1774540001 },
|
||||
seven_day_opus: { used_percentage: 2, resets_at: 1774540002 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(usageData?.weeklySonnetUsage).toBe(8);
|
||||
expect(usageData?.weeklyOpusUsage).toBe(2);
|
||||
expect(usageData?.weeklySonnetResetAt).toBe(new Date(1774540001 * 1000).toISOString());
|
||||
expect(usageData?.weeklyOpusResetAt).toBe(new Date(1774540002 * 1000).toISOString());
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to API fetch when per-model widget is present but rate_limits lacks per-model buckets', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2026-03-20T12:00:00.000Z',
|
||||
weeklyUsage: 15,
|
||||
weeklyResetAt: '2026-03-27T12:00:00.000Z',
|
||||
weeklySonnetUsage: 8,
|
||||
weeklySonnetResetAt: '2026-03-27T12:00:00.000Z'
|
||||
});
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'weekly-sonnet-usage' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 42, resets_at: 1774020000 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(usageData?.weeklySonnetUsage).toBe(8);
|
||||
expect(usageData?.sessionUsage).toBe(42);
|
||||
expect(usageData?.sessionResetAt).toBe(epochToIso(1774020000));
|
||||
expect(usageData?.weeklyUsage).toBe(15);
|
||||
expect(usageData?.weeklyResetAt).toBe(epochToIso(1774540000));
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
apiUsageData: { weeklySonnetUsage: 8 },
|
||||
bucketName: 'seven_day_sonnet' as const,
|
||||
expectedUsage: 8,
|
||||
usageField: 'weeklySonnetUsage' as const,
|
||||
widgetType: 'weekly-sonnet-usage'
|
||||
},
|
||||
{
|
||||
apiUsageData: { weeklyOpusUsage: 2 },
|
||||
bucketName: 'seven_day_opus' as const,
|
||||
expectedUsage: 2,
|
||||
usageField: 'weeklyOpusUsage' as const,
|
||||
widgetType: 'weekly-opus-usage'
|
||||
}
|
||||
])('falls back to API fetch when $widgetType has only a reset timestamp in rate_limits', async ({
|
||||
apiUsageData,
|
||||
bucketName,
|
||||
expectedUsage,
|
||||
usageField,
|
||||
widgetType
|
||||
}) => {
|
||||
mockFetchUsageData.mockResolvedValue({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2026-03-20T12:00:00.000Z',
|
||||
weeklyUsage: 15,
|
||||
weeklyResetAt: '2026-03-27T12:00:00.000Z',
|
||||
...apiUsageData
|
||||
});
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: widgetType }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 42, resets_at: 1774020000 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 },
|
||||
[bucketName]: { resets_at: 1774540001 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(usageData?.[usageField]).toBe(expectedUsage);
|
||||
expect(usageData?.weeklySonnetResetAt ?? usageData?.weeklyOpusResetAt).toBe(epochToIso(1774540001));
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('uses reset-only rate_limits data without fetching for reset timer widgets', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ error: 'no-credentials' });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'reset-timer' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { five_hour: { resets_at: 1774020000 } } });
|
||||
|
||||
expect(usageData).toEqual({ sessionResetAt: epochToIso(1774020000) });
|
||||
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' });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'session-usage', metadata: { cursor: 'true' } }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { five_hour: { used_percentage: 42 } } });
|
||||
|
||||
expect(usageData).toEqual({
|
||||
sessionUsage: 42,
|
||||
error: 'no-credentials'
|
||||
});
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves API errors when statusline data is partial and fetch fails', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ error: 'no-credentials' });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'session-usage' }, { id: '2', type: 'weekly-sonnet-usage' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { five_hour: { used_percentage: 42 } } });
|
||||
|
||||
expect(usageData).toEqual({
|
||||
sessionUsage: 42,
|
||||
error: 'no-credentials'
|
||||
});
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['weeklySonnetUsage'] }]);
|
||||
});
|
||||
|
||||
it('preserves API errors when statusline data has no usable usage fields', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ error: 'no-credentials' });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'session-usage' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: {} });
|
||||
|
||||
expect(usageData).toEqual({ error: 'no-credentials' });
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('uses aggregate weekly reset as the per-model cursor fallback without fetching for reset data', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ weeklySonnetUsage: 8 });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'weekly-sonnet-usage', metadata: { cursor: 'true' } }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { seven_day: { resets_at: 1774540000 } } });
|
||||
|
||||
expect(usageData).toEqual({
|
||||
weeklyResetAt: epochToIso(1774540000),
|
||||
weeklySonnetUsage: 8
|
||||
});
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['weeklySonnetUsage'] }]);
|
||||
});
|
||||
|
||||
it('fetches extra usage fields while preserving statusline usage data', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({
|
||||
extraUsageEnabled: true,
|
||||
extraUsageLimit: 400000,
|
||||
extraUsageUsed: 10600,
|
||||
extraUsageUtilization: 2.6
|
||||
});
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'extra-usage-utilization' }, { id: '2', type: 'extra-usage-remaining' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { five_hour: { used_percentage: 42 } } });
|
||||
|
||||
expect(usageData).toEqual({
|
||||
sessionUsage: 42,
|
||||
extraUsageEnabled: true,
|
||||
extraUsageLimit: 400000,
|
||||
extraUsageUsed: 10600,
|
||||
extraUsageUtilization: 2.6
|
||||
});
|
||||
expect(mockFetchUsageData.mock.calls).toEqual([
|
||||
[{
|
||||
requiredFields: [
|
||||
'extraUsageEnabled',
|
||||
'extraUsageUtilization',
|
||||
'extraUsageLimit',
|
||||
'extraUsageUsed'
|
||||
]
|
||||
}]
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves API errors when extra usage fields are missing', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ error: 'no-credentials' });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'session-usage' }, { id: '2', type: 'extra-usage-remaining' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { five_hour: { used_percentage: 42 } } });
|
||||
|
||||
expect(usageData).toEqual({
|
||||
sessionUsage: 42,
|
||||
error: 'no-credentials'
|
||||
});
|
||||
expect(mockFetchUsageData.mock.calls).toEqual([
|
||||
[{
|
||||
requiredFields: [
|
||||
'extraUsageEnabled',
|
||||
'extraUsageLimit',
|
||||
'extraUsageUsed'
|
||||
]
|
||||
}]
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not require per-model buckets when only the all-models weekly widget is present', async () => {
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'weekly-usage' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 42, resets_at: 1774020000 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(usageData?.weeklyUsage).toBe(15);
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('treats null requested per-model buckets as zero usage without fetching', async () => {
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'weekly-sonnet-usage' }, { id: '2', type: 'weekly-opus-usage' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 42, resets_at: 1774020000 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 },
|
||||
seven_day_sonnet: null,
|
||||
seven_day_opus: null
|
||||
}
|
||||
});
|
||||
|
||||
expect(usageData?.weeklySonnetUsage).toBe(0);
|
||||
expect(usageData?.weeklyOpusUsage).toBe(0);
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to API fetch when sessionResetAt is missing from rate_limits', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({
|
||||
sessionUsage: 42,
|
||||
@@ -173,7 +494,7 @@ describe('usage prefetch', () => {
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2026-03-20T12:00:00.000Z',
|
||||
weeklyUsage: 15,
|
||||
weeklyResetAt: '2026-03-27T12:00:00.000Z'
|
||||
weeklyResetAt: epochToIso(1774540000)
|
||||
});
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
@@ -209,10 +530,13 @@ describe('extractUsageDataFromRateLimits', () => {
|
||||
expect(extractUsageDataFromRateLimits(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when both used_percentage values are missing', () => {
|
||||
it('extracts reset-only data when percentages are missing', () => {
|
||||
const result = extractUsageDataFromRateLimits({ five_hour: { resets_at: 1774020000 } });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.sessionUsage).toBeUndefined();
|
||||
expect(result?.sessionResetAt).toBe(epochToIso(1774020000));
|
||||
expect(result?.weeklyUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts partial data when only five_hour is present', () => {
|
||||
@@ -238,9 +562,46 @@ describe('extractUsageDataFromRateLimits', () => {
|
||||
expect(result?.sessionUsage).toBe(0);
|
||||
});
|
||||
|
||||
it('treats null used_percentage as missing and falls back', () => {
|
||||
it('treats null used_percentage as missing while keeping reset data', () => {
|
||||
const result = extractUsageDataFromRateLimits({ five_hour: { used_percentage: null, resets_at: 1774020000 } });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(result?.sessionUsage).toBeUndefined();
|
||||
expect(result?.sessionResetAt).toBe(epochToIso(1774020000));
|
||||
});
|
||||
|
||||
it('extracts per-model weekly buckets when present', () => {
|
||||
const result = extractUsageDataFromRateLimits({
|
||||
five_hour: { used_percentage: 42, resets_at: 1774020000 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 },
|
||||
seven_day_sonnet: { used_percentage: 8, resets_at: 1774540001 },
|
||||
seven_day_opus: { used_percentage: 2, resets_at: 1774540002 }
|
||||
});
|
||||
|
||||
expect(result?.weeklySonnetUsage).toBe(8);
|
||||
expect(result?.weeklyOpusUsage).toBe(2);
|
||||
expect(result?.weeklySonnetResetAt).toBe(new Date(1774540001 * 1000).toISOString());
|
||||
expect(result?.weeklyOpusResetAt).toBe(new Date(1774540002 * 1000).toISOString());
|
||||
});
|
||||
|
||||
it('leaves per-model fields undefined when buckets are absent', () => {
|
||||
const result = extractUsageDataFromRateLimits({
|
||||
five_hour: { used_percentage: 42, resets_at: 1774020000 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 }
|
||||
});
|
||||
|
||||
expect(result?.weeklySonnetUsage).toBeUndefined();
|
||||
expect(result?.weeklyOpusUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats null per-model buckets as zero usage', () => {
|
||||
const result = extractUsageDataFromRateLimits({
|
||||
five_hour: { used_percentage: 42, resets_at: 1774020000 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 },
|
||||
seven_day_sonnet: null,
|
||||
seven_day_opus: null
|
||||
});
|
||||
|
||||
expect(result?.weeklySonnetUsage).toBe(0);
|
||||
expect(result?.weeklyOpusUsage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -229,6 +229,31 @@ describe('usage window helpers', () => {
|
||||
expect(result).toMatch(/^2026-04-27 19:00 \S+$/);
|
||||
});
|
||||
|
||||
it('formats reset timestamps with weekday in UTC', () => {
|
||||
expect(formatUsageResetAt('2026-03-15T08:30:00.000Z', false, 'UTC', false, false, true)).toBe('Sun 08:30 UTC');
|
||||
expect(formatUsageResetAt('2026-03-15T08:30:00.000Z', true, 'UTC', false, false, true)).toBe('Sun 08:30Z');
|
||||
});
|
||||
|
||||
it('formats reset timestamps with weekday and 12-hour clock in UTC', () => {
|
||||
expect(formatUsageResetAt('2026-03-15T08:30:00.000Z', false, 'UTC', true, false, true)).toBe('Sun 8:30 AM UTC');
|
||||
expect(formatUsageResetAt('2026-03-15T20:30:00.000Z', false, 'UTC', true, false, true)).toBe('Sun 8:30 PM UTC');
|
||||
expect(formatUsageResetAt('2026-03-15T08:30:00.000Z', true, 'UTC', true, false, true)).toBe('Sun 8:30 AMZ');
|
||||
});
|
||||
|
||||
it('formats reset timestamps with weekday in a specific IANA timezone', () => {
|
||||
const result = formatUsageResetAt('2026-03-15T08:30:00.000Z', false, 'Asia/Tokyo', 'en-US', false, true);
|
||||
expect(result).toMatch(/^Sun 17:30 /);
|
||||
const compactResult = formatUsageResetAt('2026-03-15T08:30:00.000Z', true, 'Asia/Tokyo', 'en-US', false, true);
|
||||
expect(compactResult).toBe('Sun 17:30');
|
||||
});
|
||||
|
||||
it('formats reset timestamps with weekday and 12-hour clock in a timezone', () => {
|
||||
const result = formatUsageResetAt('2026-03-15T08:30:00.000Z', false, 'Asia/Tokyo', 'en-US', true, true);
|
||||
expect(result).toMatch(/^Sun 5:30 PM /);
|
||||
const compactResult = formatUsageResetAt('2026-03-15T08:30:00.000Z', true, 'Asia/Tokyo', 'en-US', true, true);
|
||||
expect(compactResult).toBe('Sun 5:30 PM');
|
||||
});
|
||||
|
||||
it('formats duration with days in compact style when >= 24h', () => {
|
||||
expect(formatUsageDuration(25 * 60 * 60 * 1000, true)).toBe('1d1h');
|
||||
expect(formatUsageDuration(36.5 * 60 * 60 * 1000, true)).toBe('1d12h30m');
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
DEFAULT_SETTINGS,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
import type { WidgetItemType } from '../../types/Widget';
|
||||
import {
|
||||
filterWidgetCatalog,
|
||||
getAllWidgetTypes,
|
||||
@@ -73,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: {
|
||||
@@ -84,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', () => {
|
||||
@@ -92,16 +91,15 @@ describe('widget catalog', () => {
|
||||
|
||||
expect(categories).toContain('Core');
|
||||
expect(categories).toContain('Git');
|
||||
expect(categories).toContain('Jujutsu');
|
||||
expect(categories).toContain('Context');
|
||||
expect(categories).toContain('Tokens');
|
||||
expect(categories).toContain('Token Speed');
|
||||
expect(categories).toContain('Session');
|
||||
expect(categories).toContain('Activity');
|
||||
expect(categories).toContain('Usage');
|
||||
expect(categories).toContain('Environment');
|
||||
expect(categories).toContain('Custom');
|
||||
expect(categories).toContain('Layout');
|
||||
expect(categories.indexOf('Activity')).toBe(categories.indexOf('Session') + 1);
|
||||
});
|
||||
|
||||
it('returns runtime widget instances for non-layout widget types', () => {
|
||||
@@ -198,14 +196,14 @@ describe('widget catalog filtering', () => {
|
||||
it('ranks exact substring matches above fuzzy matches', () => {
|
||||
const rankingCatalog: WidgetCatalogEntry[] = [
|
||||
{
|
||||
type: 'exact-match' as WidgetItemType,
|
||||
type: 'exact-match',
|
||||
displayName: 'Git Branch',
|
||||
description: 'Exact substring match',
|
||||
category: 'Core',
|
||||
searchText: 'git branch exact substring match exact-match'
|
||||
},
|
||||
{
|
||||
type: 'fuzzy-match' as WidgetItemType,
|
||||
type: 'fuzzy-match',
|
||||
displayName: 'Global Input Timer',
|
||||
description: 'Fuzzy-only match',
|
||||
category: 'Core',
|
||||
@@ -218,33 +216,28 @@ describe('widget catalog filtering', () => {
|
||||
});
|
||||
|
||||
it('returns no results when query chars cannot form a subsequence in any entry', () => {
|
||||
const results = filterWidgetCatalog(catalog, 'All', 'zzz');
|
||||
const results = filterWidgetCatalog(catalog, 'All', 'zzzz');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps all activity at the top within the Activity category', () => {
|
||||
const results = filterWidgetCatalog(catalog, 'Activity', '');
|
||||
expect(results[0]?.type).toBe('activity');
|
||||
});
|
||||
|
||||
it('prioritizes name match before type and description matches', () => {
|
||||
const rankingCatalog: WidgetCatalogEntry[] = [
|
||||
{
|
||||
type: 'alpha' as WidgetItemType,
|
||||
type: 'alpha',
|
||||
displayName: 'Git Branch',
|
||||
description: 'Primary match',
|
||||
category: 'Core',
|
||||
searchText: 'git branch primary match alpha'
|
||||
},
|
||||
{
|
||||
type: 'git-type-only' as WidgetItemType,
|
||||
type: 'git-type-only',
|
||||
displayName: 'Branch',
|
||||
description: 'Type fallback match',
|
||||
category: 'Core',
|
||||
searchText: 'branch type fallback match git-type-only'
|
||||
},
|
||||
{
|
||||
type: 'desc-only' as WidgetItemType,
|
||||
type: 'desc-only',
|
||||
displayName: 'Branch',
|
||||
description: 'Description contains git',
|
||||
category: 'Core',
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
ActivityAgentEntry,
|
||||
ActivityParseOptions,
|
||||
ActivitySnapshot,
|
||||
ActivityTodoItem,
|
||||
ActivityTodoStatus,
|
||||
ActivityToolEntry
|
||||
} from '../types/Activity';
|
||||
|
||||
const DEFAULT_MAX_TOOLS = 20;
|
||||
const DEFAULT_MAX_AGENTS = 10;
|
||||
|
||||
interface TranscriptLine {
|
||||
timestamp?: string;
|
||||
message?: { content?: TranscriptContent[] };
|
||||
}
|
||||
|
||||
interface TranscriptContent {
|
||||
type?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
tool_use_id?: string;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
interface ActivityCacheEntry {
|
||||
path: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
snapshot: ActivitySnapshot;
|
||||
}
|
||||
|
||||
let activityCacheEntry: ActivityCacheEntry | null = null;
|
||||
|
||||
function createEmptySnapshot(): ActivitySnapshot {
|
||||
return {
|
||||
tools: [],
|
||||
agents: [],
|
||||
todos: [],
|
||||
updatedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
function parseDate(timestamp: unknown): Date | null {
|
||||
if (typeof timestamp !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
function extractToolTarget(name: string, input?: Record<string, unknown>): string | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'Read':
|
||||
case 'Write':
|
||||
case 'Edit': {
|
||||
const filePath = input.file_path;
|
||||
if (typeof filePath === 'string' && filePath.length > 0) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const pathValue = input.path;
|
||||
if (typeof pathValue === 'string' && pathValue.length > 0) {
|
||||
return pathValue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case 'Glob':
|
||||
case 'Grep': {
|
||||
const pattern = input.pattern;
|
||||
return typeof pattern === 'string' && pattern.length > 0 ? pattern : undefined;
|
||||
}
|
||||
case 'Bash': {
|
||||
const command = input.command;
|
||||
if (typeof command !== 'string' || command.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return truncate(command, 30);
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTodoStatus(status: unknown): ActivityTodoStatus | null {
|
||||
if (typeof status !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'not_started':
|
||||
return 'pending';
|
||||
case 'in_progress':
|
||||
case 'running':
|
||||
return 'in_progress';
|
||||
case 'completed':
|
||||
case 'complete':
|
||||
case 'done':
|
||||
return 'completed';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTaskIdentifier(input: Record<string, unknown>, fallbackId: string): string {
|
||||
const rawTaskId = input.taskId;
|
||||
if (typeof rawTaskId === 'string' || typeof rawTaskId === 'number') {
|
||||
return String(rawTaskId);
|
||||
}
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
function resolveTaskIndex(
|
||||
taskId: unknown,
|
||||
taskIdToIndex: Map<string, number>,
|
||||
todos: ActivityTodoItem[]
|
||||
): number | null {
|
||||
if (typeof taskId === 'string' || typeof taskId === 'number') {
|
||||
const taskKey = String(taskId);
|
||||
const mappedIndex = taskIdToIndex.get(taskKey);
|
||||
if (typeof mappedIndex === 'number') {
|
||||
return mappedIndex;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(taskKey)) {
|
||||
const oneBasedIndex = Number.parseInt(taskKey, 10);
|
||||
const zeroBasedIndex = oneBasedIndex - 1;
|
||||
if (zeroBasedIndex >= 0 && zeroBasedIndex < todos.length) {
|
||||
return zeroBasedIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyTodoWrite(input: Record<string, unknown>, todos: ActivityTodoItem[], taskIdToIndex: Map<string, number>): void {
|
||||
const inputTodos = input.todos;
|
||||
if (!Array.isArray(inputTodos)) {
|
||||
return;
|
||||
}
|
||||
|
||||
todos.length = 0;
|
||||
taskIdToIndex.clear();
|
||||
|
||||
for (let index = 0; index < inputTodos.length; index++) {
|
||||
const todo = inputTodos[index] as unknown;
|
||||
if (!todo || typeof todo !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const todoRecord = todo as Record<string, unknown>;
|
||||
const content = typeof todoRecord.content === 'string' ? todoRecord.content : '';
|
||||
const normalizedStatus = normalizeTodoStatus(todoRecord.status) ?? 'pending';
|
||||
|
||||
todos.push({
|
||||
content: content || 'Untitled task',
|
||||
status: normalizedStatus
|
||||
});
|
||||
|
||||
const taskId = typeof todoRecord.id === 'string' || typeof todoRecord.id === 'number'
|
||||
? String(todoRecord.id)
|
||||
: String(index + 1);
|
||||
taskIdToIndex.set(taskId, todos.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTaskCreate(
|
||||
blockId: string,
|
||||
input: Record<string, unknown>,
|
||||
todos: ActivityTodoItem[],
|
||||
taskIdToIndex: Map<string, number>
|
||||
): void {
|
||||
const subject = typeof input.subject === 'string' ? input.subject : '';
|
||||
const description = typeof input.description === 'string' ? input.description : '';
|
||||
const content = subject || description || 'Untitled task';
|
||||
const status = normalizeTodoStatus(input.status) ?? 'pending';
|
||||
|
||||
todos.push({
|
||||
content,
|
||||
status
|
||||
});
|
||||
|
||||
taskIdToIndex.set(getTaskIdentifier(input, blockId), todos.length - 1);
|
||||
}
|
||||
|
||||
function applyTaskUpdate(
|
||||
input: Record<string, unknown>,
|
||||
todos: ActivityTodoItem[],
|
||||
taskIdToIndex: Map<string, number>
|
||||
): void {
|
||||
const index = resolveTaskIndex(input.taskId, taskIdToIndex, todos);
|
||||
if (index === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const todo = todos[index];
|
||||
if (!todo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = normalizeTodoStatus(input.status);
|
||||
if (status) {
|
||||
todo.status = status;
|
||||
}
|
||||
|
||||
const subject = typeof input.subject === 'string' ? input.subject : '';
|
||||
const description = typeof input.description === 'string' ? input.description : '';
|
||||
const content = subject || description;
|
||||
if (content) {
|
||||
todo.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseActivityContent(content: string, options: ActivityParseOptions = {}): ActivitySnapshot {
|
||||
const snapshot = createEmptySnapshot();
|
||||
const toolsById = new Map<string, ActivityToolEntry>();
|
||||
const agentsById = new Map<string, ActivityAgentEntry>();
|
||||
const todos: ActivityTodoItem[] = [];
|
||||
const taskIdToIndex = new Map<string, number>();
|
||||
const maxTools = options.maxTools ?? DEFAULT_MAX_TOOLS;
|
||||
const maxAgents = options.maxAgents ?? DEFAULT_MAX_AGENTS;
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry: TranscriptLine;
|
||||
try {
|
||||
entry = JSON.parse(line) as TranscriptLine;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryTimestamp = parseDate(entry.timestamp);
|
||||
if (entryTimestamp) {
|
||||
const previousTimestamp = snapshot.updatedAt;
|
||||
if (!previousTimestamp || entryTimestamp.getTime() > previousTimestamp.getTime()) {
|
||||
snapshot.updatedAt = entryTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = entry.message?.content;
|
||||
if (!Array.isArray(blocks)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = entryTimestamp ?? new Date();
|
||||
|
||||
for (const rawBlock of blocks as unknown[]) {
|
||||
if (!rawBlock || typeof rawBlock !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const block = rawBlock as TranscriptContent;
|
||||
|
||||
if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
|
||||
if (block.name === 'Task') {
|
||||
const input = block.input ?? {};
|
||||
const agentEntry: ActivityAgentEntry = {
|
||||
id: block.id,
|
||||
type: typeof input.subagent_type === 'string' ? input.subagent_type : 'unknown',
|
||||
model: typeof input.model === 'string' ? input.model : undefined,
|
||||
description: typeof input.description === 'string' ? input.description : undefined,
|
||||
status: 'running',
|
||||
startTime: timestamp
|
||||
};
|
||||
agentsById.set(block.id, agentEntry);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.name === 'TodoWrite') {
|
||||
applyTodoWrite(block.input ?? {}, todos, taskIdToIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.name === 'TaskCreate') {
|
||||
applyTaskCreate(block.id, block.input ?? {}, todos, taskIdToIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.name === 'TaskUpdate') {
|
||||
applyTaskUpdate(block.input ?? {}, todos, taskIdToIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
toolsById.set(block.id, {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
target: extractToolTarget(block.name, block.input),
|
||||
status: 'running',
|
||||
startTime: timestamp
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
|
||||
const tool = toolsById.get(block.tool_use_id);
|
||||
if (tool) {
|
||||
tool.status = block.is_error ? 'error' : 'completed';
|
||||
tool.endTime = timestamp;
|
||||
}
|
||||
|
||||
const agent = agentsById.get(block.tool_use_id);
|
||||
if (agent) {
|
||||
agent.status = 'completed';
|
||||
agent.endTime = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot.tools = Array.from(toolsById.values()).slice(-maxTools);
|
||||
snapshot.agents = Array.from(agentsById.values()).slice(-maxAgents);
|
||||
snapshot.todos = todos;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function getActivitySnapshot(transcriptPath?: string, options: ActivityParseOptions = {}): ActivitySnapshot {
|
||||
if (!transcriptPath || transcriptPath.trim() === '') {
|
||||
return createEmptySnapshot();
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(transcriptPath);
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
return createEmptySnapshot();
|
||||
}
|
||||
} catch {
|
||||
return createEmptySnapshot();
|
||||
}
|
||||
|
||||
const cached = activityCacheEntry;
|
||||
if (
|
||||
cached?.path === resolvedPath
|
||||
&& cached.mtimeMs === stats.mtimeMs
|
||||
&& cached.size === stats.size
|
||||
) {
|
||||
return cached.snapshot;
|
||||
}
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(resolvedPath, 'utf-8');
|
||||
} catch {
|
||||
return createEmptySnapshot();
|
||||
}
|
||||
|
||||
const snapshot = parseActivityContent(content, options);
|
||||
activityCacheEntry = {
|
||||
path: resolvedPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
snapshot
|
||||
};
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function clearActivitySnapshotCache(): void {
|
||||
activityCacheEntry = null;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import stringWidth from 'string-width';
|
||||
|
||||
import {
|
||||
gradientCodeAt,
|
||||
type Rgb
|
||||
} from './gradient';
|
||||
|
||||
const ESC = '\x1b';
|
||||
const BEL = '\x07';
|
||||
const C1_CSI = '\x9b';
|
||||
@@ -394,6 +399,11 @@ export function getVisibleWidth(text: string): number {
|
||||
|
||||
interface TruncateOptions { ellipsis?: boolean }
|
||||
|
||||
export interface LineGradientSegmentResult {
|
||||
text: string;
|
||||
nextColumn: number;
|
||||
}
|
||||
|
||||
export function truncateStyledText(
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
@@ -474,3 +484,79 @@ export function truncateStyledText(
|
||||
|
||||
return output + ellipsis;
|
||||
}
|
||||
|
||||
// Paint a foreground gradient across the visible characters of a styled line,
|
||||
// assigning each display cluster a color based on its column position so the
|
||||
// gradient spans the whole line. Escape sequences (SGR, OSC-8 hyperlinks) pass
|
||||
// through untouched, and visible width is unchanged, so flex/powerline layout
|
||||
// is unaffected. ansi16 has too few colors for a gradient and is left as-is.
|
||||
export function applyLineGradientSegment(
|
||||
text: string,
|
||||
stops: Rgb[],
|
||||
colorLevel: 'ansi16' | 'ansi256' | 'truecolor',
|
||||
startColumn: number,
|
||||
totalWidth: number
|
||||
): LineGradientSegmentResult {
|
||||
const visibleWidth = getVisibleWidth(text);
|
||||
if (stops.length === 0 || colorLevel === 'ansi16') {
|
||||
return {
|
||||
text,
|
||||
nextColumn: startColumn + visibleWidth
|
||||
};
|
||||
}
|
||||
|
||||
if (totalWidth <= 1) {
|
||||
return {
|
||||
text,
|
||||
nextColumn: startColumn + visibleWidth
|
||||
};
|
||||
}
|
||||
|
||||
const denominator = totalWidth - 1;
|
||||
let output = '';
|
||||
let column = startColumn;
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
const escape = parseEscapeSequence(text, index);
|
||||
if (escape) {
|
||||
output += escape.sequence;
|
||||
index = escape.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cluster = consumeDisplayCluster(text, index);
|
||||
if (!cluster) {
|
||||
break;
|
||||
}
|
||||
|
||||
output += gradientCodeAt(stops, column / denominator, colorLevel) + cluster.text;
|
||||
column += getClusterWidth(cluster.text);
|
||||
index = cluster.nextIndex;
|
||||
}
|
||||
|
||||
return {
|
||||
text: output,
|
||||
nextColumn: column
|
||||
};
|
||||
}
|
||||
|
||||
// Paint a foreground gradient across the visible characters of a styled line,
|
||||
// assigning each display cluster a color based on its column position so the
|
||||
// gradient spans the whole line. Escape sequences (SGR, OSC-8 hyperlinks) pass
|
||||
// through untouched, and visible width is unchanged, so flex/powerline layout
|
||||
// is unaffected. ansi16 has too few colors for a gradient and is left as-is.
|
||||
export function applyLineGradient(
|
||||
text: string,
|
||||
stops: Rgb[],
|
||||
colorLevel: 'ansi16' | 'ansi256' | 'truecolor'
|
||||
): string {
|
||||
const totalWidth = getVisibleWidth(text);
|
||||
const result = applyLineGradientSegment(text, stops, colorLevel, 0, totalWidth);
|
||||
|
||||
if (result.text === text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${result.text}\x1b[39m`;
|
||||
}
|
||||
|
||||
+345
-13
@@ -2,16 +2,19 @@ import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ClaudeSettings } from '../types/ClaudeSettings';
|
||||
import {
|
||||
SettingsSchema,
|
||||
type InstallationMetadata,
|
||||
type Settings
|
||||
} from '../types/Settings';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
isCustomConfigPath
|
||||
isCustomConfigPath,
|
||||
saveInstallationMetadata
|
||||
} from './config';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
@@ -23,13 +26,37 @@ const writeFile = fs.promises.writeFile;
|
||||
const mkdir = fs.promises.mkdir;
|
||||
|
||||
export const CCSTATUSLINE_COMMANDS = {
|
||||
AUTO_NPX: 'npx -y ccstatusline@latest',
|
||||
AUTO_BUNX: 'bunx -y ccstatusline@latest',
|
||||
GLOBAL: 'ccstatusline',
|
||||
// Backward-compatible names for existing callers/tests.
|
||||
NPM: 'npx -y ccstatusline@latest',
|
||||
BUNX: 'bunx -y ccstatusline@latest',
|
||||
SELF_MANAGED: 'ccstatusline'
|
||||
};
|
||||
|
||||
export const PINNED_INSTALL_COMMANDS = {
|
||||
NPM: (version: string) => `npm install -g ccstatusline@${version}`,
|
||||
BUN: (version: string) => `bun add -g ccstatusline@${version}`
|
||||
};
|
||||
|
||||
export type StatusLineCommandMode = 'auto-npx' | 'auto-bunx' | 'global';
|
||||
|
||||
export interface InstallStatusLineOptions {
|
||||
commandMode: StatusLineCommandMode;
|
||||
supportsRefreshInterval?: boolean;
|
||||
installationMetadata?: InstallationMetadata;
|
||||
}
|
||||
|
||||
export interface PackageCommandAvailability {
|
||||
npm: boolean;
|
||||
npx: boolean;
|
||||
bun: boolean;
|
||||
bunx: boolean;
|
||||
}
|
||||
|
||||
export function isKnownCommand(command: string): boolean {
|
||||
const prefixes = [CCSTATUSLINE_COMMANDS.NPM, CCSTATUSLINE_COMMANDS.BUNX, CCSTATUSLINE_COMMANDS.SELF_MANAGED];
|
||||
const prefixes = [CCSTATUSLINE_COMMANDS.AUTO_NPX, CCSTATUSLINE_COMMANDS.AUTO_BUNX, CCSTATUSLINE_COMMANDS.GLOBAL];
|
||||
// Also match local development commands (e.g., "bun run /path/to/ccstatusline.ts")
|
||||
return prefixes.some(prefix => command === prefix || command.startsWith(`${prefix} --config `))
|
||||
|| /(?:^|[\s"'\\/])ccstatusline\.ts(?=$|[\s"'])/.test(command);
|
||||
@@ -203,20 +230,49 @@ export async function isInstalled(): Promise<boolean> {
|
||||
);
|
||||
}
|
||||
|
||||
export function isBunxAvailable(): boolean {
|
||||
function isExecutableAvailable(executable: string): boolean {
|
||||
try {
|
||||
// Use platform-appropriate command to check for bunx availability
|
||||
const command = process.platform === 'win32' ? 'where bunx' : 'which bunx';
|
||||
execSync(command, { stdio: 'ignore' });
|
||||
const command = process.platform === 'win32' ? `where ${executable}` : `which ${executable}`;
|
||||
execSync(command, { stdio: 'ignore', windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isNpmAvailable(): boolean {
|
||||
return isExecutableAvailable('npm');
|
||||
}
|
||||
|
||||
export function isNpxAvailable(): boolean {
|
||||
return isExecutableAvailable('npx');
|
||||
}
|
||||
|
||||
export function isBunAvailable(): boolean {
|
||||
return isExecutableAvailable('bun');
|
||||
}
|
||||
|
||||
export function isBunxAvailable(): boolean {
|
||||
return isExecutableAvailable('bunx');
|
||||
}
|
||||
|
||||
export function getPackageCommandAvailability(): PackageCommandAvailability {
|
||||
return {
|
||||
npm: isNpmAvailable(),
|
||||
npx: isNpxAvailable(),
|
||||
bun: isBunAvailable(),
|
||||
bunx: isBunxAvailable()
|
||||
};
|
||||
}
|
||||
|
||||
export function getClaudeCodeVersion(): string | null {
|
||||
try {
|
||||
const output = execSync('claude --version', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000 }).trim();
|
||||
const output = execSync('claude --version', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
timeout: 5000,
|
||||
windowsHide: true
|
||||
}).trim();
|
||||
// Output is like "2.1.97 (Claude Code)" — extract the version number
|
||||
const match = /^(\d+\.\d+\.\d+)/.exec(output);
|
||||
return match?.[1] ?? null;
|
||||
@@ -255,6 +311,73 @@ function buildCommand(baseCommand: string): string {
|
||||
return baseCommand;
|
||||
}
|
||||
|
||||
export function getBaseCommandForMode(commandMode: StatusLineCommandMode): string {
|
||||
switch (commandMode) {
|
||||
case 'auto-npx':
|
||||
return CCSTATUSLINE_COMMANDS.AUTO_NPX;
|
||||
case 'auto-bunx':
|
||||
return CCSTATUSLINE_COMMANDS.AUTO_BUNX;
|
||||
case 'global':
|
||||
return CCSTATUSLINE_COMMANDS.GLOBAL;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStatusLineCommand(commandMode: StatusLineCommandMode): string {
|
||||
return buildCommand(getBaseCommandForMode(commandMode));
|
||||
}
|
||||
|
||||
function matchesCommandBase(command: string, baseCommand: string): boolean {
|
||||
return command === baseCommand || command.startsWith(`${baseCommand} --config `);
|
||||
}
|
||||
|
||||
function isLocalDevelopmentCommand(command: string): boolean {
|
||||
return /(?:^|[\s"'\\/])ccstatusline\.ts(?=$|[\s"'])/.test(command);
|
||||
}
|
||||
|
||||
export function classifyInstallation(
|
||||
command: string | null | undefined,
|
||||
metadata?: InstallationMetadata
|
||||
): InstallationMetadata {
|
||||
const statusLineCommand = command ?? '';
|
||||
|
||||
if (matchesCommandBase(statusLineCommand, CCSTATUSLINE_COMMANDS.AUTO_NPX)) {
|
||||
return {
|
||||
method: 'auto-update',
|
||||
packageManager: 'npm'
|
||||
};
|
||||
}
|
||||
|
||||
if (matchesCommandBase(statusLineCommand, CCSTATUSLINE_COMMANDS.AUTO_BUNX)) {
|
||||
return {
|
||||
method: 'auto-update',
|
||||
packageManager: 'bun'
|
||||
};
|
||||
}
|
||||
|
||||
if (matchesCommandBase(statusLineCommand, CCSTATUSLINE_COMMANDS.GLOBAL)) {
|
||||
if (metadata?.method === 'pinned') {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return {
|
||||
method: 'self-managed',
|
||||
packageManager: 'unknown'
|
||||
};
|
||||
}
|
||||
|
||||
if (isLocalDevelopmentCommand(statusLineCommand)) {
|
||||
return {
|
||||
method: 'self-managed',
|
||||
packageManager: 'unknown'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
method: 'unknown',
|
||||
packageManager: 'unknown'
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSavedSettingsForHookSync(): Promise<Settings | null> {
|
||||
const configPath = getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
@@ -274,7 +397,11 @@ async function loadSavedSettingsForHookSync(): Promise<Settings | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function installStatusLine(useBunx = false, supportsRefreshInterval = false): Promise<void> {
|
||||
export async function installStatusLine({
|
||||
commandMode,
|
||||
supportsRefreshInterval = false,
|
||||
installationMetadata
|
||||
}: InstallStatusLineOptions): Promise<void> {
|
||||
let settings: ClaudeSettings;
|
||||
|
||||
const backupPath = await backupClaudeSettings('.orig');
|
||||
@@ -286,15 +413,11 @@ export async function installStatusLine(useBunx = false, supportsRefreshInterval
|
||||
settings = {};
|
||||
}
|
||||
|
||||
const baseCommand = useBunx
|
||||
? CCSTATUSLINE_COMMANDS.BUNX
|
||||
: CCSTATUSLINE_COMMANDS.NPM;
|
||||
|
||||
// Update settings with our status line (confirmation already handled in TUI)
|
||||
const existingRefreshInterval = settings.statusLine?.refreshInterval;
|
||||
settings.statusLine = {
|
||||
type: 'command',
|
||||
command: buildCommand(baseCommand),
|
||||
command: buildStatusLineCommand(commandMode),
|
||||
padding: 0
|
||||
};
|
||||
|
||||
@@ -304,6 +427,9 @@ export async function installStatusLine(useBunx = false, supportsRefreshInterval
|
||||
}
|
||||
|
||||
await saveClaudeSettings(settings);
|
||||
if (installationMetadata !== undefined) {
|
||||
await saveInstallationMetadata(installationMetadata);
|
||||
}
|
||||
|
||||
const savedSettings = await loadSavedSettingsForHookSync();
|
||||
if (savedSettings) {
|
||||
@@ -327,6 +453,8 @@ export async function uninstallStatusLine(): Promise<void> {
|
||||
await saveClaudeSettings(settings);
|
||||
}
|
||||
|
||||
await saveInstallationMetadata(undefined);
|
||||
|
||||
try {
|
||||
const { removeManagedHooks } = await import('./hooks');
|
||||
await removeManagedHooks();
|
||||
@@ -374,3 +502,207 @@ export async function setRefreshInterval(interval: number | null): Promise<void>
|
||||
|
||||
await saveClaudeSettings(settings);
|
||||
}
|
||||
|
||||
const VoiceConfigSchema = z.object({ enabled: z.boolean().optional() });
|
||||
|
||||
function getLayeredSettingsCandidatePathsByPriority(cwd: string): string[] {
|
||||
const userDir = getClaudeConfigDir();
|
||||
const projectDir = path.join(cwd, '.claude');
|
||||
// Highest priority first — `getVoiceConfig` returns on the first defined override.
|
||||
const candidates = [
|
||||
path.join(projectDir, 'settings.local.json'),
|
||||
path.join(projectDir, 'settings.json'),
|
||||
path.join(userDir, 'settings.local.json'),
|
||||
path.join(userDir, 'settings.json')
|
||||
];
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
interface VoiceLayerResult {
|
||||
fileExisted: boolean;
|
||||
enabled: boolean | undefined;
|
||||
}
|
||||
|
||||
function tryReadVoiceLayer(filePath: string): VoiceLayerResult {
|
||||
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 { voice?: unknown };
|
||||
const voice = parsed.voice;
|
||||
if (voice === undefined || voice === null) {
|
||||
return { fileExisted: true, enabled: undefined };
|
||||
}
|
||||
const result = VoiceConfigSchema.safeParse(voice);
|
||||
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 `voice.enabled` setting from Claude Code's layered configuration.
|
||||
*
|
||||
* Claude Code merges settings from up to four files, in increasing order of priority:
|
||||
* 1. <user>/settings.json
|
||||
* 2. <user>/settings.local.json
|
||||
* 3. <cwd>/.claude/settings.json
|
||||
* 4. <cwd>/.claude/settings.local.json
|
||||
*
|
||||
* The user dir respects `CLAUDE_CONFIG_DIR` (fallback `~/.claude`).
|
||||
* Lookup walks layers from highest priority to lowest and returns on the first
|
||||
* one that defines `voice.enabled` — so the typical case (one file with the field)
|
||||
* costs a single read instead of four.
|
||||
*
|
||||
* - Returns `null` if no candidate file exists (Claude Code never initialised).
|
||||
* - Returns `{ enabled: false }` if files exist but none defines `voice.enabled`
|
||||
* (Claude Code's default — `/voice` not yet touched).
|
||||
* - Returns `{ enabled: <bool> }` reflecting the highest-priority override otherwise.
|
||||
*
|
||||
* The `voice.mode` (`hold` / `toggle`) field is not exposed; widgets only need the on/off state.
|
||||
*/
|
||||
export function getVoiceConfig(cwd: string = process.cwd()): { enabled: boolean } | null {
|
||||
let anyFileExisted = false;
|
||||
for (const filePath of getLayeredSettingsCandidatePathsByPriority(cwd)) {
|
||||
const layer = tryReadVoiceLayer(filePath);
|
||||
if (layer.fileExisted) {
|
||||
anyFileExisted = true;
|
||||
}
|
||||
if (layer.enabled !== undefined) {
|
||||
return { enabled: layer.enabled };
|
||||
}
|
||||
}
|
||||
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()
|
||||
});
|
||||
|
||||
/**
|
||||
* Reads the per-PID session manifests Claude Code writes to `<config>/sessions/<pid>.json`
|
||||
* and returns whether the session matching `sessionId` currently has a remote-control
|
||||
* bridge attached.
|
||||
*
|
||||
* Claude Code writes one file per running interactive session. The `bridgeSessionId`
|
||||
* field is populated when remote control (e.g. the mobile/web bridge) is connected
|
||||
* for that session. Matching by `sessionId` ensures the result reflects the *current*
|
||||
* session rather than any other concurrent Claude Code process.
|
||||
*
|
||||
* Claude Code's observed behavior on disconnect: the field is set to `null` (not removed)
|
||||
* and the file is rewritten within ~1s, so the on-disconnect transition is reflected
|
||||
* promptly at the next status-line refresh.
|
||||
*
|
||||
* - Returns `null` when the sessions directory is missing or no manifest matches —
|
||||
* widgets should hide themselves in that case rather than render a misleading "off".
|
||||
* - Returns `{ enabled: false }` when the manifest exists but `bridgeSessionId` is
|
||||
* missing, `null`, or empty (disconnected).
|
||||
* - Returns `{ enabled: true }` when a non-empty `bridgeSessionId` is set.
|
||||
*/
|
||||
export function getRemoteControlStatus(sessionId: string | undefined): { enabled: boolean } | null {
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionsDir = path.join(getClaudeConfigDir(), 'sessions');
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(sessionsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(path.join(sessionsDir, entry), 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = RemoteSessionFileSchema.safeParse(parsed);
|
||||
if (!result.success || result.data.sessionId !== sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bridge = result.data.bridgeSessionId;
|
||||
return { enabled: typeof bridge === 'string' && bridge.length > 0 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+59
-5
@@ -2,6 +2,13 @@ import chalk, { type ChalkInstance } from 'chalk';
|
||||
|
||||
import type { ColorEntry } from '../types/ColorEntry';
|
||||
|
||||
import {
|
||||
applyGradientToText,
|
||||
isGradientSpec,
|
||||
parseGradientSpec,
|
||||
rgbToAnsi256
|
||||
} from './gradient';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { ColorEntry };
|
||||
|
||||
@@ -121,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.
|
||||
@@ -137,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;
|
||||
}
|
||||
|
||||
@@ -154,6 +177,16 @@ export function applyColors(
|
||||
|
||||
// Apply foreground color
|
||||
if (foregroundColor) {
|
||||
// Per-character gradient foreground. Only representable with a real color
|
||||
// palette; at ansi16 (or for an unparseable spec) we fall through to
|
||||
// getColorAnsiCode below, which suppresses gradient specs at ansi16.
|
||||
// parseGradientSpec returns null for non-gradient values, so it doubles
|
||||
// as the prefix guard.
|
||||
const gradientStops = parseGradientSpec(foregroundColor);
|
||||
if (gradientStops && colorLevel !== 'ansi16') {
|
||||
return prefix + applyGradientToText(styledText, gradientStops, colorLevel) + '\x1b[39m' + suffix;
|
||||
}
|
||||
|
||||
const fgCode = getColorAnsiCode(foregroundColor, colorLevel, false);
|
||||
if (fgCode) {
|
||||
prefix += fgCode;
|
||||
@@ -161,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
|
||||
@@ -169,6 +202,27 @@ export function getColorAnsiCode(colorName: string | undefined, colorLevel: 'ans
|
||||
if (!colorName)
|
||||
return '';
|
||||
|
||||
// Handle gradient:<name> / gradient:RRGGBB-RRGGBB / gradient:hex:…,… formats.
|
||||
// A single ANSI code cannot represent a gradient, so collapse to the first
|
||||
// stop as a solid color. The per-character gradient is produced in applyColors();
|
||||
// this path is what the powerline renderer (which calls getColorAnsiCode
|
||||
// directly) sees. At ansi16, return no code so Basic/No Color modes do not
|
||||
// receive truecolor escapes from a stored gradient setting.
|
||||
if (isGradientSpec(colorName)) {
|
||||
const stops = parseGradientSpec(colorName);
|
||||
const first = stops?.[0];
|
||||
if (!first)
|
||||
return '';
|
||||
if (colorLevel === 'ansi16') {
|
||||
return '';
|
||||
}
|
||||
if (colorLevel === 'ansi256') {
|
||||
const code = rgbToAnsi256(first);
|
||||
return isBackground ? `\x1b[48;5;${code}m` : `\x1b[38;5;${code}m`;
|
||||
}
|
||||
return isBackground ? `\x1b[48;2;${first.r};${first.g};${first.b}m` : `\x1b[38;2;${first.r};${first.g};${first.b}m`;
|
||||
}
|
||||
|
||||
// Handle ansi256:X format
|
||||
if (colorName.startsWith('ansi256:')) {
|
||||
const code = parseInt(colorName.substring(8), 10);
|
||||
|
||||
+60
-139
@@ -1,162 +1,83 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const DEFAULT_DROP_THRESHOLD = 2;
|
||||
const FRESH_PREV_CTX_PCT = -1;
|
||||
const MAX_CACHE_FILE_BYTES = 4096;
|
||||
const SESSION_ID_HASH_HEX_LEN = 32;
|
||||
import type { CompactionData } from '../types/RenderContext';
|
||||
|
||||
export interface CompactionState {
|
||||
count: number;
|
||||
prevCtxPct: number;
|
||||
prevWindowSize?: number | null;
|
||||
}
|
||||
import {
|
||||
parseJsonlLine,
|
||||
readJsonlLines
|
||||
} from './jsonl-lines';
|
||||
|
||||
const FRESH: CompactionState = { count: 0, prevCtxPct: FRESH_PREV_CTX_PCT };
|
||||
|
||||
const CompactionStateSchema = z.object({
|
||||
count: z.number().int().nonnegative().default(0),
|
||||
prevCtxPct: z.number().default(FRESH_PREV_CTX_PCT),
|
||||
prevWindowSize: z.number().positive().nullable().optional()
|
||||
/** Shared zeroed stats for missing/unreadable transcripts and as a render fallback. Treat as read-only. */
|
||||
export const ZERO_COMPACTION_STATS: CompactionData = Object.freeze({
|
||||
count: 0,
|
||||
byTrigger: Object.freeze({ auto: 0, manual: 0, unknown: 0 }),
|
||||
tokensReclaimed: 0
|
||||
});
|
||||
|
||||
interface DetectCompactionOptions {
|
||||
dropThreshold?: number;
|
||||
windowSize?: number | null;
|
||||
}
|
||||
|
||||
function normalizeWindowSize(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
function isCompactBoundary(record: unknown): boolean {
|
||||
if (typeof record !== 'object' || record === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeOptions(options: number | DetectCompactionOptions): Required<Pick<DetectCompactionOptions, 'dropThreshold'>> & Pick<DetectCompactionOptions, 'windowSize'> {
|
||||
if (typeof options === 'number') {
|
||||
return { dropThreshold: options, windowSize: null };
|
||||
}
|
||||
|
||||
const dropThreshold = typeof options.dropThreshold === 'number' && Number.isFinite(options.dropThreshold)
|
||||
? options.dropThreshold
|
||||
: DEFAULT_DROP_THRESHOLD;
|
||||
|
||||
return { dropThreshold, windowSize: options.windowSize ?? null };
|
||||
const r = record as { type?: unknown; subtype?: unknown; isSidechain?: unknown };
|
||||
return r.type === 'system' && r.subtype === 'compact_boundary' && r.isSidechain !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect context compaction events.
|
||||
* Count context-compaction events and summarize their `compactMetadata` by
|
||||
* scanning the transcript for `{type:'system', subtype:'compact_boundary'}`
|
||||
* markers Claude Code writes on every compaction. Exact and immune to transient
|
||||
* context-percentage noise. Sidechain (subagent) records are excluded.
|
||||
*
|
||||
* Within the same context window size, context only grows until compaction, so
|
||||
* any percentage drop beyond the threshold indicates Claude Code compacted the
|
||||
* conversation. The threshold filters rounding noise and cache accounting
|
||||
* wobble - a drop must exceed the threshold (default: more than 2 points) to
|
||||
* count. When a known context window size changes, the previous percentage
|
||||
* baseline is reset instead of counted as a compaction.
|
||||
*
|
||||
* Returns state unchanged when currentCtxPct is non-finite or negative,
|
||||
* preventing NaN from poisoning persistent state. The fresh-state sentinel
|
||||
* for prevCtxPct is -1, so a session that legitimately starts at 0% is
|
||||
* still detected correctly.
|
||||
* `trigger` missing or unrecognized is counted under `unknown` (never guessed).
|
||||
* `tokensReclaimed` sums `preTokens - postTokens` only for markers where both
|
||||
* are finite numbers; older markers without `postTokens` contribute 0.
|
||||
*/
|
||||
export function detectCompaction(
|
||||
currentCtxPct: number,
|
||||
state: CompactionState,
|
||||
options: number | DetectCompactionOptions = DEFAULT_DROP_THRESHOLD
|
||||
): CompactionState {
|
||||
if (!Number.isFinite(currentCtxPct) || currentCtxPct < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { dropThreshold, windowSize } = normalizeOptions(options);
|
||||
const currentWindowSize = normalizeWindowSize(windowSize);
|
||||
const prevWindowSize = normalizeWindowSize(state.prevWindowSize);
|
||||
let { count } = state;
|
||||
const { prevCtxPct } = state;
|
||||
const hasKnownWindowChange = currentWindowSize !== null && prevWindowSize !== null && currentWindowSize !== prevWindowSize;
|
||||
const isLearningWindowSize = currentWindowSize !== null && prevWindowSize === null && prevCtxPct >= 0;
|
||||
|
||||
if (!hasKnownWindowChange && !isLearningWindowSize && prevCtxPct >= 0 && currentCtxPct < prevCtxPct - dropThreshold) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
prevCtxPct: currentCtxPct,
|
||||
...(currentWindowSize !== null ? { prevWindowSize: currentWindowSize } : {})
|
||||
export function computeCompactionStats(lines: string[]): CompactionData {
|
||||
const stats: CompactionData = {
|
||||
count: 0,
|
||||
byTrigger: { auto: 0, manual: 0, unknown: 0 },
|
||||
tokensReclaimed: 0
|
||||
};
|
||||
}
|
||||
for (const line of lines) {
|
||||
const record = parseJsonlLine(line);
|
||||
if (!isCompactBoundary(record)) {
|
||||
continue;
|
||||
}
|
||||
stats.count += 1;
|
||||
|
||||
function getCacheDir(): string {
|
||||
return path.join(os.homedir(), '.cache', 'ccstatusline', 'compaction');
|
||||
}
|
||||
const meta = (record as { compactMetadata?: unknown }).compactMetadata;
|
||||
const metaRecord = (typeof meta === 'object' && meta !== null) ? meta as Record<string, unknown> : null;
|
||||
|
||||
function sanitizeSessionId(sessionId: string): string {
|
||||
const sanitized = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
// Hash if input was empty or contained any disallowed character — prevents
|
||||
// distinct sessions with all-illegal characters from collapsing to the same
|
||||
// cache filename, and prevents an empty leaf like "compaction-.json".
|
||||
if (!sanitized || sanitized !== sessionId) {
|
||||
return crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, SESSION_ID_HASH_HEX_LEN);
|
||||
const trigger = metaRecord?.trigger;
|
||||
if (trigger === 'auto') {
|
||||
stats.byTrigger.auto += 1;
|
||||
} else if (trigger === 'manual') {
|
||||
stats.byTrigger.manual += 1;
|
||||
} else {
|
||||
stats.byTrigger.unknown += 1;
|
||||
}
|
||||
|
||||
const pre = metaRecord?.preTokens;
|
||||
const post = metaRecord?.postTokens;
|
||||
if (typeof pre === 'number' && typeof post === 'number') {
|
||||
const reclaimed = pre - post;
|
||||
if (Number.isFinite(reclaimed)) {
|
||||
stats.tokensReclaimed += Math.max(0, reclaimed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
return stats;
|
||||
}
|
||||
|
||||
function getStatePath(sessionId: string): string {
|
||||
return path.join(getCacheDir(), `compaction-${sanitizeSessionId(sessionId)}.json`);
|
||||
}
|
||||
|
||||
export function loadCompactionState(sessionId: string): CompactionState {
|
||||
const statePath = getStatePath(sessionId);
|
||||
let fd: number | null = null;
|
||||
/** Best-effort: returns zeroed stats when the transcript is missing or unreadable. */
|
||||
export async function getCompactionStats(transcriptPath: string): Promise<CompactionData> {
|
||||
try {
|
||||
// O_NOFOLLOW makes opening a symlinked path fail with ELOOP rather
|
||||
// than reading through the symlink. Combined with fstat on the open
|
||||
// fd (rather than a separate lstat-then-read), this also closes the
|
||||
// TOCTOU window that would otherwise let the path be swapped between
|
||||
// the stat and the read.
|
||||
fd = fs.openSync(statePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
||||
const stats = fs.fstatSync(fd);
|
||||
if (!stats.isFile() || stats.size > MAX_CACHE_FILE_BYTES) {
|
||||
return FRESH;
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return ZERO_COMPACTION_STATS;
|
||||
}
|
||||
const raw: unknown = JSON.parse(fs.readFileSync(fd, 'utf-8'));
|
||||
const result = CompactionStateSchema.safeParse(raw);
|
||||
return result.success ? result.data : FRESH;
|
||||
const lines = await readJsonlLines(transcriptPath);
|
||||
return computeCompactionStats(lines);
|
||||
} catch {
|
||||
return FRESH;
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCompactionState(sessionId: string, state: CompactionState): void {
|
||||
let tmpPath: string | null = null;
|
||||
try {
|
||||
const dir = getCacheDir();
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const targetPath = getStatePath(sessionId);
|
||||
// Write to a temp file in the same directory then rename. Rename on
|
||||
// POSIX replaces the target atomically and does not follow symlinks
|
||||
// at the destination — so a planted symlink at targetPath gets
|
||||
// replaced with the real file rather than written through.
|
||||
tmpPath = `${targetPath}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(state) + '\n');
|
||||
fs.renameSync(tmpPath, targetPath);
|
||||
tmpPath = null;
|
||||
} catch {
|
||||
// Best-effort — cache write failure should not break status line rendering.
|
||||
// Clean up an orphan temp file if rename failed after write succeeded.
|
||||
if (tmpPath !== null) {
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
return ZERO_COMPACTION_STATS;
|
||||
}
|
||||
}
|
||||
|
||||
+151
-45
@@ -6,6 +6,7 @@ import {
|
||||
CURRENT_VERSION,
|
||||
SettingsSchema,
|
||||
SettingsSchema_v1,
|
||||
type InstallationMetadata,
|
||||
type Settings
|
||||
} from '../types/Settings';
|
||||
|
||||
@@ -19,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;
|
||||
@@ -39,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);
|
||||
@@ -90,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 {
|
||||
@@ -109,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 {
|
||||
@@ -146,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,3 +245,33 @@ export async function saveSettings(settings: Settings): Promise<void> {
|
||||
await syncWidgetHooks(settings);
|
||||
} catch { /* ignore hook sync failures */ }
|
||||
}
|
||||
|
||||
export async function saveInstallationMetadata(metadata: InstallationMetadata | undefined): Promise<void> {
|
||||
const paths = getSettingsPaths();
|
||||
if (!metadata && !fs.existsSync(paths.settingsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
if (metadata) {
|
||||
settingsWithVersion.installation = metadata;
|
||||
} else {
|
||||
delete settingsWithVersion.installation;
|
||||
}
|
||||
|
||||
await writeSettingsJson(settingsWithVersion, paths);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,29 @@ function toFiniteNonNegativeNumber(value: unknown): number | null {
|
||||
return Math.max(0, value);
|
||||
}
|
||||
|
||||
interface CurrentUsageObject {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
}
|
||||
|
||||
interface CurrentUsageTokens {
|
||||
input: number;
|
||||
output: number;
|
||||
creation: number;
|
||||
read: number;
|
||||
}
|
||||
|
||||
function parseCurrentUsageTokens(usage: CurrentUsageObject): CurrentUsageTokens {
|
||||
return {
|
||||
input: toFiniteNonNegativeNumber(usage.input_tokens) ?? 0,
|
||||
output: toFiniteNonNegativeNumber(usage.output_tokens) ?? 0,
|
||||
creation: toFiniteNonNegativeNumber(usage.cache_creation_input_tokens) ?? 0,
|
||||
read: toFiniteNonNegativeNumber(usage.cache_read_input_tokens) ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
@@ -54,15 +77,11 @@ export function getContextWindowMetrics(data?: StatusJSON): ContextWindowMetrics
|
||||
currentUsageTotalTokens = toFiniteNonNegativeNumber(contextWindow.current_usage);
|
||||
contextLengthTokens = currentUsageTotalTokens;
|
||||
} else if (contextWindow.current_usage && typeof contextWindow.current_usage === 'object') {
|
||||
const usage = contextWindow.current_usage;
|
||||
const inputTokens = toFiniteNonNegativeNumber(usage.input_tokens) ?? 0;
|
||||
const outputTokens = toFiniteNonNegativeNumber(usage.output_tokens) ?? 0;
|
||||
const cacheCreationTokens = toFiniteNonNegativeNumber(usage.cache_creation_input_tokens) ?? 0;
|
||||
const cacheReadTokens = toFiniteNonNegativeNumber(usage.cache_read_input_tokens) ?? 0;
|
||||
const { input, output, creation, read } = parseCurrentUsageTokens(contextWindow.current_usage);
|
||||
|
||||
currentUsageTotalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
contextLengthTokens = inputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
cachedTokens = cacheCreationTokens + cacheReadTokens;
|
||||
currentUsageTotalTokens = input + output + creation + read;
|
||||
contextLengthTokens = input + creation + read;
|
||||
cachedTokens = creation + read;
|
||||
}
|
||||
|
||||
const rawUsedPercentage = toFiniteNonNegativeNumber(contextWindow.used_percentage);
|
||||
@@ -134,3 +153,22 @@ export function getContextWindowUsedPercentage(data?: StatusJSON): number | null
|
||||
export function getContextWindowSize(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).windowSize;
|
||||
}
|
||||
|
||||
export interface TurnCacheTokens {
|
||||
read: number;
|
||||
creation: number;
|
||||
input: number;
|
||||
}
|
||||
|
||||
// Cache read/creation/input tokens for the most recent turn ("last action"),
|
||||
// taken directly from Claude Code's live status JSON (context_window.current_usage).
|
||||
// Returns null when the object form of current_usage is unavailable.
|
||||
export function getContextWindowTurnCacheTokens(data?: StatusJSON): TurnCacheTokens | null {
|
||||
const usage = data?.context_window?.current_usage;
|
||||
if (!usage || typeof usage !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { input, creation, read } = parseCurrentUsageTokens(usage);
|
||||
return { read, creation, input };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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(decimals)}k`;
|
||||
return count.toString();
|
||||
}
|
||||
+466
-57
@@ -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
|
||||
@@ -63,7 +167,8 @@ function runGitForCache(args: string[], cwd: string, deps: GitReviewCacheDeps):
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
cwd,
|
||||
timeout: CLI_TIMEOUT
|
||||
timeout: CLI_TIMEOUT,
|
||||
windowsHide: true
|
||||
}).trim();
|
||||
} catch {
|
||||
return '';
|
||||
@@ -71,7 +176,7 @@ function runGitForCache(args: string[], cwd: string, deps: GitReviewCacheDeps):
|
||||
}
|
||||
|
||||
function getCurrentBranch(cwd: string, deps: GitReviewCacheDeps): string | null {
|
||||
const branch = runGitForCache(['branch', '--show-current'], cwd, deps);
|
||||
const branch = runGitForCache(['symbolic-ref', '--short', 'HEAD'], cwd, deps);
|
||||
return branch.length > 0 ? branch : null;
|
||||
}
|
||||
|
||||
@@ -99,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
|
||||
}
|
||||
@@ -139,26 +292,73 @@ function getOriginUrl(cwd: string, deps: GitReviewCacheDeps): string | null {
|
||||
return url.length > 0 ? url : null;
|
||||
}
|
||||
|
||||
function isSshRemoteUrl(url: string): boolean {
|
||||
const trimmed = url.trim().toLowerCase();
|
||||
return trimmed.startsWith('ssh://') || !trimmed.includes('://');
|
||||
}
|
||||
|
||||
function resolveSshHostAlias(host: string, deps: GitReviewCacheDeps): string {
|
||||
try {
|
||||
const output = deps.execFileSync('ssh', ['-G', host], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
timeout: CLI_TIMEOUT,
|
||||
windowsHide: true
|
||||
}).trim();
|
||||
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const match = /^hostname\s+(.+)$/i.exec(line.trim());
|
||||
if (match?.[1]) {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Leave the parsed remote host unchanged when ssh is unavailable or
|
||||
// cannot resolve the alias.
|
||||
}
|
||||
|
||||
return host.toLowerCase();
|
||||
}
|
||||
|
||||
function getNamedForgeProvider(host: string): GitReviewProvider | null {
|
||||
if (host.includes('github')) {
|
||||
return 'gh';
|
||||
}
|
||||
if (host.includes('gitlab')) {
|
||||
return 'glab';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEffectiveRemoteHost(url: string, host: string, deps: GitReviewCacheDeps): string {
|
||||
const normalizedHost = host.toLowerCase();
|
||||
if (!isSshRemoteUrl(url) || getNamedForgeProvider(normalizedHost)) {
|
||||
return normalizedHost;
|
||||
}
|
||||
|
||||
return resolveSshHostAlias(normalizedHost, deps);
|
||||
}
|
||||
|
||||
function getOriginHost(cwd: string, deps: GitReviewCacheDeps): string | null {
|
||||
const url = getOriginUrl(cwd, deps);
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseRemoteUrl(url);
|
||||
return parsed ? parsed.host.toLowerCase() : null;
|
||||
return parsed ? getEffectiveRemoteHost(url, parsed.host, deps) : null;
|
||||
}
|
||||
|
||||
function toHttpsRepoRef(url: string): string | null {
|
||||
function toHttpsRepoRef(url: string, deps: GitReviewCacheDeps): string | null {
|
||||
const parsed = parseRemoteUrl(url);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return `https://${parsed.host}/${parsed.owner}/${parsed.repo}`;
|
||||
return `https://${getEffectiveRemoteHost(url, parsed.host, deps)}/${parsed.owner}/${parsed.repo}`;
|
||||
}
|
||||
|
||||
function getOriginRepoRef(cwd: string, deps: GitReviewCacheDeps): string | null {
|
||||
const url = getOriginUrl(cwd, deps);
|
||||
return url ? toHttpsRepoRef(url) : null;
|
||||
return url ? toHttpsRepoRef(url, deps) : null;
|
||||
}
|
||||
|
||||
// Self-hosted hosts that name neither forge are resolved by probing each CLI's
|
||||
@@ -168,11 +368,9 @@ function getProviderCandidates(cwd: string, deps: GitReviewCacheDeps): GitReview
|
||||
if (!host) {
|
||||
return ['gh', 'glab'];
|
||||
}
|
||||
if (host.includes('github')) {
|
||||
return ['gh'];
|
||||
}
|
||||
if (host.includes('gitlab')) {
|
||||
return ['glab'];
|
||||
const namedForgeProvider = getNamedForgeProvider(host);
|
||||
if (namedForgeProvider) {
|
||||
return [namedForgeProvider];
|
||||
}
|
||||
const authed: GitReviewProvider[] = [];
|
||||
if (isCliAuthedForHost('glab', host, deps)) {
|
||||
@@ -184,11 +382,22 @@ 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;
|
||||
} catch {
|
||||
@@ -200,7 +409,8 @@ function isCliAuthedForHost(cli: GitReviewProvider, host: string, deps: GitRevie
|
||||
try {
|
||||
deps.execFileSync(cli, ['auth', 'status', '--hostname', host], {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
timeout: CLI_TIMEOUT
|
||||
timeout: CLI_TIMEOUT,
|
||||
windowsHide: true
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
@@ -220,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.
|
||||
@@ -230,24 +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
|
||||
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;
|
||||
}
|
||||
@@ -257,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.
|
||||
@@ -280,7 +546,8 @@ 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();
|
||||
|
||||
@@ -304,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';
|
||||
|
||||
+294
-9
@@ -1,4 +1,8 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
|
||||
@@ -13,8 +17,265 @@ export interface GitFileStatusCounts {
|
||||
untracked: number;
|
||||
}
|
||||
|
||||
// Cache for git commands - key is "command|cwd"
|
||||
const gitCommandCache = new Map<string, string | null>();
|
||||
interface GitRepoMetadata {
|
||||
cachePath: string;
|
||||
headMtimeMs: number | null;
|
||||
indexMtimeMs: number | null;
|
||||
}
|
||||
|
||||
interface GitCacheEntry {
|
||||
output: string | null;
|
||||
createdAt: number;
|
||||
headMtimeMs: number | null;
|
||||
indexMtimeMs: number | null;
|
||||
}
|
||||
|
||||
interface PersistentGitCache {
|
||||
version: 1;
|
||||
cwd: string | null;
|
||||
entries: Record<string, GitCacheEntry>;
|
||||
}
|
||||
|
||||
const DEFAULT_GIT_CACHE_TTL_SECONDS = 5;
|
||||
const GIT_CACHE_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
// In-process cache keeps cwd in the key; the persistent cache stores cwd once
|
||||
// at the file level and keys entries by command.
|
||||
const gitCommandCache = new Map<string, GitCacheEntry>();
|
||||
|
||||
function getCacheDir(): string {
|
||||
return path.join(os.homedir(), '.cache', 'ccstatusline');
|
||||
}
|
||||
|
||||
function getCachePath(gitDir: string): string {
|
||||
const repoHash = createHash('sha256')
|
||||
.update(gitDir)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
return path.join(getCacheDir(), 'git-cache', `git-${repoHash}.json`);
|
||||
}
|
||||
|
||||
function getMtimeMs(filePath: string): number | null {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDirectory(candidate: string): string | null {
|
||||
try {
|
||||
const resolved = path.resolve(candidate);
|
||||
const stats = fs.statSync(resolved);
|
||||
return stats.isDirectory()
|
||||
? resolved
|
||||
: path.dirname(resolved);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readGitDirFile(gitFilePath: string): string | null {
|
||||
try {
|
||||
const content = fs.readFileSync(gitFilePath, 'utf-8').trim();
|
||||
const match = /^gitdir:\s*(.+)$/i.exec(content);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.resolve(path.dirname(gitFilePath), match[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function discoverGitDir(startDir: string): string | null {
|
||||
let current = startDir;
|
||||
|
||||
for (;;) {
|
||||
const gitPath = path.join(current, '.git');
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(gitPath);
|
||||
if (stats.isDirectory()) {
|
||||
return gitPath;
|
||||
}
|
||||
if (stats.isFile()) {
|
||||
return readGitDirFile(gitPath);
|
||||
}
|
||||
} catch {
|
||||
// Keep walking up.
|
||||
}
|
||||
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function getGitRepoMetadata(cwd: string | undefined): GitRepoMetadata | null {
|
||||
if (!cwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startDir = normalizeDirectory(cwd);
|
||||
if (!startDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gitDir = discoverGitDir(startDir);
|
||||
if (!gitDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
cachePath: getCachePath(gitDir),
|
||||
headMtimeMs: getMtimeMs(path.join(gitDir, 'HEAD')),
|
||||
indexMtimeMs: getMtimeMs(path.join(gitDir, 'index'))
|
||||
};
|
||||
}
|
||||
|
||||
function getGitCacheTtlMs(context: RenderContext): number {
|
||||
const ttlSeconds = context.gitCacheTtlSeconds;
|
||||
if (typeof ttlSeconds !== 'number' || !Number.isFinite(ttlSeconds)) {
|
||||
return DEFAULT_GIT_CACHE_TTL_SECONDS * 1000;
|
||||
}
|
||||
|
||||
return Math.min(60, Math.max(0, ttlSeconds)) * 1000;
|
||||
}
|
||||
|
||||
function isCacheEntry(value: unknown): value is GitCacheEntry {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = value as Record<string, unknown>;
|
||||
return (typeof entry.output === 'string' || entry.output === null)
|
||||
&& typeof entry.createdAt === 'number'
|
||||
&& (typeof entry.headMtimeMs === 'number' || entry.headMtimeMs === null)
|
||||
&& (typeof entry.indexMtimeMs === 'number' || entry.indexMtimeMs === null);
|
||||
}
|
||||
|
||||
function isCacheEntryFresh(
|
||||
entry: GitCacheEntry,
|
||||
metadata: GitRepoMetadata | null,
|
||||
ttlMs: number,
|
||||
now: number
|
||||
): boolean {
|
||||
if (metadata) {
|
||||
if (entry.headMtimeMs !== metadata.headMtimeMs || entry.indexMtimeMs !== metadata.indexMtimeMs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return ttlMs === 0 || now - entry.createdAt <= ttlMs;
|
||||
}
|
||||
|
||||
function readPersistentCache(cachePath: string): PersistentGitCache | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = parsed as { version?: unknown; cwd?: unknown; entries?: unknown };
|
||||
if (
|
||||
data.version !== GIT_CACHE_SCHEMA_VERSION
|
||||
|| (typeof data.cwd !== 'string' && data.cwd !== null)
|
||||
|| typeof data.entries !== 'object'
|
||||
|| data.entries === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries: Record<string, GitCacheEntry> = {};
|
||||
for (const [key, value] of Object.entries(data.entries)) {
|
||||
if (isCacheEntry(value)) {
|
||||
entries[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: GIT_CACHE_SCHEMA_VERSION,
|
||||
cwd: data.cwd,
|
||||
entries
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistentCache(cachePath: string, cache: PersistentGitCache): void {
|
||||
try {
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const tempPath = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(cache), 'utf-8');
|
||||
fs.renameSync(tempPath, cachePath);
|
||||
} catch {
|
||||
// Best-effort cache; statusline rendering should never fail because of it.
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistentCacheEntry(
|
||||
metadata: GitRepoMetadata | null,
|
||||
cacheKey: string,
|
||||
cwd: string | undefined,
|
||||
ttlMs: number,
|
||||
now: number
|
||||
): GitCacheEntry | null {
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cache = readPersistentCache(metadata.cachePath);
|
||||
if (cache?.cwd !== (cwd ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = cache.entries[cacheKey];
|
||||
if (!entry || !isCacheEntryFresh(entry, metadata, ttlMs, now)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function writePersistentCacheEntry(
|
||||
metadata: GitRepoMetadata | null,
|
||||
cacheKey: string,
|
||||
cwd: string | undefined,
|
||||
entry: GitCacheEntry
|
||||
): void {
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheCwd = cwd ?? null;
|
||||
const existingCache = readPersistentCache(metadata.cachePath);
|
||||
const cache: PersistentGitCache = existingCache?.cwd === cacheCwd
|
||||
? existingCache
|
||||
: {
|
||||
version: GIT_CACHE_SCHEMA_VERSION,
|
||||
cwd: cacheCwd,
|
||||
entries: {}
|
||||
};
|
||||
|
||||
cache.entries[cacheKey] = entry;
|
||||
writePersistentCache(metadata.cachePath, cache);
|
||||
}
|
||||
|
||||
function createCacheEntry(output: string | null, metadata: GitRepoMetadata | null, now: number): GitCacheEntry {
|
||||
return {
|
||||
output,
|
||||
createdAt: now,
|
||||
headMtimeMs: metadata?.headMtimeMs ?? null,
|
||||
indexMtimeMs: metadata?.indexMtimeMs ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGitCwd(context: RenderContext): string | undefined {
|
||||
const candidates = [
|
||||
@@ -40,25 +301,49 @@ export function runGit(command: string, context: RenderContext): string | null {
|
||||
export function runGitArgs(args: string[], context: RenderContext, cacheCommand?: string): string | null {
|
||||
const cwd = resolveGitCwd(context);
|
||||
const cacheToken = cacheCommand ?? args.join('\0');
|
||||
const cacheKey = `${cacheToken}|${cwd ?? ''}`;
|
||||
const memoryCacheKey = `${cacheToken}|${cwd ?? ''}`;
|
||||
const persistentCacheKey = cacheToken;
|
||||
const metadata = getGitRepoMetadata(cwd);
|
||||
const ttlMs = getGitCacheTtlMs(context);
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
if (gitCommandCache.has(cacheKey)) {
|
||||
return gitCommandCache.get(cacheKey) ?? null;
|
||||
const memoryEntry = gitCommandCache.get(memoryCacheKey);
|
||||
if (memoryEntry && isCacheEntryFresh(memoryEntry, metadata, ttlMs, now)) {
|
||||
return memoryEntry.output;
|
||||
}
|
||||
|
||||
const persistentEntry = readPersistentCacheEntry(metadata, persistentCacheKey, cwd, ttlMs, now);
|
||||
if (persistentEntry) {
|
||||
gitCommandCache.set(memoryCacheKey, persistentEntry);
|
||||
return persistentEntry.output;
|
||||
}
|
||||
|
||||
// --no-optional-locks (or GIT_OPTIONAL_LOCKS=0) prevents read-only commands
|
||||
// (diff, status, rev-list, ...) from racing on .git/index.lock when another
|
||||
// git process is writing it.
|
||||
// We use the environment variable instead of the CLI flag because older Git
|
||||
// versions (like 2.10.1) fail with "Unknown option: --no-optional-locks".
|
||||
// See https://git-scm.com/docs/git#Documentation/git.txt---no-optional-locks
|
||||
|
||||
try {
|
||||
const output = execFileSync('git', args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
windowsHide: true,
|
||||
...(cwd ? { cwd } : {})
|
||||
}).trimEnd();
|
||||
|
||||
const result = output.length > 0 ? output : null;
|
||||
gitCommandCache.set(cacheKey, result);
|
||||
const entry = createCacheEntry(result, metadata, now);
|
||||
gitCommandCache.set(memoryCacheKey, entry);
|
||||
writePersistentCacheEntry(metadata, persistentCacheKey, cwd, entry);
|
||||
return result;
|
||||
} catch {
|
||||
gitCommandCache.set(cacheKey, null);
|
||||
const entry = createCacheEntry(null, metadata, now);
|
||||
gitCommandCache.set(memoryCacheKey, entry);
|
||||
writePersistentCacheEntry(metadata, persistentCacheKey, cwd, entry);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -108,7 +393,7 @@ export interface GitStatus {
|
||||
}
|
||||
|
||||
export function getGitStatus(context: RenderContext): GitStatus {
|
||||
const output = runGit('--no-optional-locks status --porcelain -z', context);
|
||||
const output = runGit('status --porcelain -z', context);
|
||||
|
||||
if (!output) {
|
||||
return { staged: false, unstaged: false, untracked: false, conflicts: false };
|
||||
@@ -146,7 +431,7 @@ export function getGitStatus(context: RenderContext): GitStatus {
|
||||
}
|
||||
|
||||
export function getGitFileStatusCounts(context: RenderContext): GitFileStatusCounts {
|
||||
const output = runGit('--no-optional-locks status --porcelain -z', context);
|
||||
const output = runGit('status --porcelain -z', context);
|
||||
|
||||
if (!output) {
|
||||
return { staged: 0, unstaged: 0, untracked: 0 };
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
getPackageManagerExecutable,
|
||||
getPackageManagerShellOptions
|
||||
} from './package-manager-executable';
|
||||
|
||||
export type GlobalPackageManager = 'npm' | 'bun';
|
||||
|
||||
interface ExecOptions { platform?: NodeJS.Platform }
|
||||
|
||||
export interface GlobalCommandResolution {
|
||||
resolvedPaths: string[];
|
||||
firstResolvedPath: string | null;
|
||||
expectedBinDir: string | null;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
const COMMAND_LOOKUP_TIMEOUT_MS = 5000;
|
||||
|
||||
function splitCommandOutput(output: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const candidate = line.trim();
|
||||
if (!candidate || seen.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(candidate);
|
||||
paths.push(candidate);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isTransientBunxStatusLinePath(filePath: string): boolean {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
|
||||
return /(?:^|\/)bunx-[^/]*ccstatusline@[^/]+\/node_modules\/\.bin\/ccstatusline(?:\.(?:cmd|ps1))?$/i.test(normalized);
|
||||
}
|
||||
|
||||
export function getPersistentCommandResolutionPaths(paths: string[]): string[] {
|
||||
return paths.filter(path => !isTransientBunxStatusLinePath(path));
|
||||
}
|
||||
|
||||
export function getCommandResolutionPaths(
|
||||
command: string,
|
||||
{ platform = process.platform }: ExecOptions = {}
|
||||
): string[] {
|
||||
try {
|
||||
const output = platform === 'win32'
|
||||
? execFileSync('where', [command], {
|
||||
encoding: 'utf-8',
|
||||
timeout: COMMAND_LOOKUP_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
: execFileSync('which', ['-a', command], {
|
||||
encoding: 'utf-8',
|
||||
timeout: COMMAND_LOOKUP_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
});
|
||||
|
||||
return splitCommandOutput(output);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getNpmGlobalBinDir(platform: NodeJS.Platform): string | null {
|
||||
try {
|
||||
const executable = getPackageManagerExecutable('npm', platform);
|
||||
const prefix = execFileSync(executable, ['prefix', '-g'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: COMMAND_LOOKUP_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
...getPackageManagerShellOptions(executable, platform)
|
||||
}).trim();
|
||||
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return platform === 'win32' || /^[a-z]:[\\/]/i.test(prefix)
|
||||
? prefix
|
||||
: path.join(prefix, 'bin');
|
||||
} catch {
|
||||
return 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,
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
|
||||
return binDir || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getExpectedGlobalBinDir(
|
||||
packageManager: GlobalPackageManager,
|
||||
{ platform = process.platform }: ExecOptions = {}
|
||||
): string | null {
|
||||
return packageManager === 'npm'
|
||||
? getNpmGlobalBinDir(platform)
|
||||
: getBunGlobalBinDir();
|
||||
}
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
if (/^[a-z]:\/$/i.test(value) || value === '/') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function normalizePathForComparison(filePath: string): string {
|
||||
const normalized = trimTrailingSlashes(filePath.trim().replace(/\\/g, '/'));
|
||||
|
||||
return /^[a-z]:\//i.test(normalized) || normalized.startsWith('/mnt/')
|
||||
? normalized.toLowerCase()
|
||||
: normalized;
|
||||
}
|
||||
|
||||
export function getPathComparisonVariants(filePath: string): string[] {
|
||||
const normalized = normalizePathForComparison(filePath);
|
||||
const variants = new Set([normalized]);
|
||||
|
||||
const driveMatch = /^([a-z]):\/(.*)$/i.exec(normalized);
|
||||
if (driveMatch) {
|
||||
variants.add(`/mnt/${driveMatch[1]?.toLowerCase()}/${driveMatch[2] ?? ''}`);
|
||||
}
|
||||
|
||||
const wslMountMatch = /^\/mnt\/([a-z])\/(.*)$/i.exec(normalized);
|
||||
if (wslMountMatch) {
|
||||
variants.add(`${wslMountMatch[1]?.toLowerCase()}:/${wslMountMatch[2] ?? ''}`);
|
||||
}
|
||||
|
||||
return Array.from(variants);
|
||||
}
|
||||
|
||||
function getDirectoryName(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const lastSlashIndex = normalized.lastIndexOf('/');
|
||||
|
||||
return lastSlashIndex === -1
|
||||
? ''
|
||||
: normalized.slice(0, lastSlashIndex);
|
||||
}
|
||||
|
||||
function getUniqueResolvedDirs(resolvedPaths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const dirs: string[] = [];
|
||||
|
||||
for (const resolvedPath of resolvedPaths) {
|
||||
const dir = getDirectoryName(resolvedPath);
|
||||
const comparableDir = normalizePathForComparison(dir);
|
||||
if (seen.has(comparableDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(comparableDir);
|
||||
dirs.push(dir);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
export function isPathInsideDir(filePath: string, dir: string): boolean {
|
||||
const pathVariants = getPathComparisonVariants(filePath);
|
||||
const dirVariants = getPathComparisonVariants(dir);
|
||||
|
||||
return pathVariants.some(pathVariant => dirVariants.some((dirVariant) => {
|
||||
const withSlash = dirVariant.endsWith('/') ? dirVariant : `${dirVariant}/`;
|
||||
return pathVariant === dirVariant || pathVariant.startsWith(withSlash);
|
||||
}));
|
||||
}
|
||||
|
||||
function formatPathList(paths: string[]): string {
|
||||
return paths.join(', ');
|
||||
}
|
||||
|
||||
function getResolutionWarning(
|
||||
packageManager: GlobalPackageManager,
|
||||
resolvedPaths: string[],
|
||||
expectedBinDir: string | null
|
||||
): string | null {
|
||||
const firstResolvedPath = resolvedPaths[0] ?? null;
|
||||
|
||||
if (!firstResolvedPath) {
|
||||
return '⚠ ccstatusline is not currently resolvable on PATH. Claude Code runs ccstatusline, so restart your shell or update PATH if it cannot launch.';
|
||||
}
|
||||
|
||||
const resolvedDirs = getUniqueResolvedDirs(resolvedPaths);
|
||||
if (resolvedDirs.length > 1) {
|
||||
return `⚠ Multiple ccstatusline binaries are on PATH. Claude Code will run the first match: ${firstResolvedPath}.\nOther matches: ${formatPathList(resolvedPaths.slice(1))}`;
|
||||
}
|
||||
|
||||
if (expectedBinDir && !isPathInsideDir(firstResolvedPath, expectedBinDir)) {
|
||||
return `⚠ ccstatusline resolves to ${firstResolvedPath}, which is outside the ${packageManager} global bin directory (${expectedBinDir}). Claude Code will run the first PATH match.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inspectGlobalCommandResolution(
|
||||
packageManager: GlobalPackageManager,
|
||||
options: ExecOptions = {}
|
||||
): GlobalCommandResolution {
|
||||
const resolvedPaths = getPersistentCommandResolutionPaths(getCommandResolutionPaths('ccstatusline', options));
|
||||
const expectedBinDir = getExpectedGlobalBinDir(packageManager, options);
|
||||
|
||||
return {
|
||||
resolvedPaths,
|
||||
firstResolvedPath: resolvedPaths[0] ?? null,
|
||||
expectedBinDir,
|
||||
warning: getResolutionWarning(packageManager, resolvedPaths, expectedBinDir)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
execFile,
|
||||
execFileSync
|
||||
} from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import type { PackageCommandAvailability } from './claude-settings';
|
||||
import {
|
||||
getCommandResolutionPaths,
|
||||
getExpectedGlobalBinDir,
|
||||
getPersistentCommandResolutionPaths,
|
||||
isPathInsideDir,
|
||||
type GlobalPackageManager
|
||||
} from './global-command-resolution';
|
||||
import {
|
||||
getPackageManagerExecutable,
|
||||
getPackageManagerShellOptions
|
||||
} from './package-manager-executable';
|
||||
|
||||
export type { GlobalPackageManager };
|
||||
|
||||
export interface GlobalPackageInstallation {
|
||||
packageManager: GlobalPackageManager;
|
||||
available: boolean;
|
||||
installed: boolean;
|
||||
binDir: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveGlobalCommandResolution {
|
||||
packageManager: GlobalPackageManager | 'unknown';
|
||||
resolvedPath: string | null;
|
||||
resolvedPaths: string[];
|
||||
binDir: string | null;
|
||||
version: string | null;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export interface InspectGlobalPackageInstallationsOptions {
|
||||
commandAvailability: Pick<PackageCommandAvailability, 'npm' | 'bun'>;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
export interface InspectActiveGlobalCommandOptions {
|
||||
commandAvailability: Pick<PackageCommandAvailability, 'npm' | 'bun'>;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
export interface RunGlobalPackageUninstallOptions { platform?: NodeJS.Platform }
|
||||
|
||||
const GLOBAL_PACKAGE_TIMEOUT_MS = 120000;
|
||||
const VERSION_LOOKUP_TIMEOUT_MS = 5000;
|
||||
const WINDOWS_SHIM_EXTENSIONS = [
|
||||
'',
|
||||
'.cmd',
|
||||
'.ps1'
|
||||
];
|
||||
|
||||
function isWindowsStylePath(filePath: string): boolean {
|
||||
return /^[a-z]:[\\/]/i.test(filePath);
|
||||
}
|
||||
|
||||
function trimTrailingSeparators(filePath: string): string {
|
||||
return filePath.replace(/[\\/]+$/, '');
|
||||
}
|
||||
|
||||
function appendPathSegment(dir: string, segment: string): string {
|
||||
const separator = dir.includes('\\') && !dir.includes('/')
|
||||
? '\\'
|
||||
: '/';
|
||||
|
||||
return `${trimTrailingSeparators(dir)}${separator}${segment}`;
|
||||
}
|
||||
|
||||
function toWindowsPath(filePath: string): string | null {
|
||||
const match = /^\/mnt\/([a-z])\/(.*)$/i.exec(filePath.replace(/\\/g, '/'));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${match[1]?.toUpperCase()}:\\${(match[2] ?? '').replace(/\//g, '\\')}`;
|
||||
}
|
||||
|
||||
function toWslPath(filePath: string): string | null {
|
||||
const match = /^([a-z]):[\\/](.*)$/i.exec(filePath);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/mnt/${match[1]?.toLowerCase()}/${(match[2] ?? '').replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
function getFilesystemPathVariants(filePath: string): string[] {
|
||||
const variants = new Set<string>([filePath]);
|
||||
const windowsPath = toWindowsPath(filePath);
|
||||
const wslPath = toWslPath(filePath);
|
||||
|
||||
if (windowsPath) {
|
||||
variants.add(windowsPath);
|
||||
}
|
||||
|
||||
if (wslPath) {
|
||||
variants.add(wslPath);
|
||||
}
|
||||
|
||||
return Array.from(variants);
|
||||
}
|
||||
|
||||
function getBinaryPathCandidates(binDir: string, platform: NodeJS.Platform): string[] {
|
||||
const extensions = platform === 'win32' || isWindowsStylePath(binDir)
|
||||
? WINDOWS_SHIM_EXTENSIONS
|
||||
: [''];
|
||||
|
||||
return extensions.map(extension => appendPathSegment(binDir, `ccstatusline${extension}`));
|
||||
}
|
||||
|
||||
function hasBinaryOnDisk(binDir: string, platform: NodeJS.Platform): boolean {
|
||||
return getBinaryPathCandidates(binDir, platform)
|
||||
.some(candidate => getFilesystemPathVariants(candidate).some(variant => fs.existsSync(variant)));
|
||||
}
|
||||
|
||||
function hasResolvedBinaryInDir(resolvedPaths: string[], binDir: string): boolean {
|
||||
return resolvedPaths.some(resolvedPath => isPathInsideDir(resolvedPath, binDir));
|
||||
}
|
||||
|
||||
function getDirectoryName(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const lastSlashIndex = normalized.lastIndexOf('/');
|
||||
|
||||
return lastSlashIndex === -1
|
||||
? ''
|
||||
: normalized.slice(0, lastSlashIndex);
|
||||
}
|
||||
|
||||
function getComparablePath(filePath: string): string {
|
||||
return filePath.replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getUniqueResolvedDirs(resolvedPaths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const dirs: string[] = [];
|
||||
|
||||
for (const resolvedPath of resolvedPaths) {
|
||||
const dir = getDirectoryName(resolvedPath);
|
||||
const comparableDir = getComparablePath(dir);
|
||||
if (seen.has(comparableDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(comparableDir);
|
||||
dirs.push(dir);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function formatPathList(paths: string[]): string {
|
||||
return paths.join(', ');
|
||||
}
|
||||
|
||||
function readPackageVersion(packageJsonPath: string): string | null {
|
||||
for (const variant of getFilesystemPathVariants(packageJsonPath)) {
|
||||
try {
|
||||
if (!fs.existsSync(variant)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(variant, 'utf-8')) as { version?: unknown };
|
||||
return typeof packageJson.version === 'string'
|
||||
? packageJson.version
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNpmGlobalPackageVersion(platform: NodeJS.Platform): string | null {
|
||||
try {
|
||||
const executable = getPackageManagerExecutable('npm', platform);
|
||||
const rootDir = execFileSync(executable, ['root', '-g'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: VERSION_LOOKUP_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
...getPackageManagerShellOptions(executable, platform)
|
||||
}).trim();
|
||||
|
||||
return rootDir
|
||||
? readPackageVersion(appendPathSegment(appendPathSegment(rootDir, 'ccstatusline'), 'package.json'))
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBunInstallRoot(binDir: string): string {
|
||||
const normalized = trimTrailingSeparators(binDir.replace(/\\/g, '/'));
|
||||
const lower = normalized.toLowerCase();
|
||||
|
||||
return lower.endsWith('/bin')
|
||||
? normalized.slice(0, -4)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function getBunGlobalPackageVersion(binDir: string | null): string | null {
|
||||
if (!binDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readPackageVersion(
|
||||
appendPathSegment(
|
||||
appendPathSegment(
|
||||
appendPathSegment(
|
||||
appendPathSegment(
|
||||
appendPathSegment(getBunInstallRoot(binDir), 'install'),
|
||||
'global'
|
||||
),
|
||||
'node_modules'
|
||||
),
|
||||
'ccstatusline'
|
||||
),
|
||||
'package.json'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getGlobalPackageVersion(
|
||||
packageManager: GlobalPackageManager | 'unknown',
|
||||
binDir: string | null,
|
||||
platform: NodeJS.Platform
|
||||
): string | null {
|
||||
if (packageManager === 'npm') {
|
||||
return getNpmGlobalPackageVersion(platform);
|
||||
}
|
||||
|
||||
if (packageManager === 'bun') {
|
||||
return getBunGlobalPackageVersion(binDir);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function inspectPackageManager(
|
||||
packageManager: GlobalPackageManager,
|
||||
available: boolean,
|
||||
resolvedPaths: string[],
|
||||
platform: NodeJS.Platform
|
||||
): GlobalPackageInstallation {
|
||||
const binDir = available
|
||||
? getExpectedGlobalBinDir(packageManager, { platform })
|
||||
: null;
|
||||
|
||||
return {
|
||||
packageManager,
|
||||
available,
|
||||
installed: !!binDir && (hasBinaryOnDisk(binDir, platform) || hasResolvedBinaryInDir(resolvedPaths, binDir)),
|
||||
binDir
|
||||
};
|
||||
}
|
||||
|
||||
function getManagerBinDir(
|
||||
packageManager: GlobalPackageManager,
|
||||
available: boolean,
|
||||
platform: NodeJS.Platform
|
||||
): string | null {
|
||||
return available
|
||||
? getExpectedGlobalBinDir(packageManager, { platform })
|
||||
: null;
|
||||
}
|
||||
|
||||
function getActivePackageManager(
|
||||
resolvedPath: string,
|
||||
managerBins: Record<GlobalPackageManager, string | null>
|
||||
): Pick<ActiveGlobalCommandResolution, 'packageManager' | 'binDir'> {
|
||||
if (managerBins.bun && isPathInsideDir(resolvedPath, managerBins.bun)) {
|
||||
return {
|
||||
packageManager: 'bun',
|
||||
binDir: managerBins.bun
|
||||
};
|
||||
}
|
||||
|
||||
if (managerBins.npm && isPathInsideDir(resolvedPath, managerBins.npm)) {
|
||||
return {
|
||||
packageManager: 'npm',
|
||||
binDir: managerBins.npm
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
packageManager: 'unknown',
|
||||
binDir: null
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveResolutionWarning(
|
||||
resolvedPaths: string[],
|
||||
active: Pick<ActiveGlobalCommandResolution, 'packageManager' | 'resolvedPath'>
|
||||
): string | null {
|
||||
if (!active.resolvedPath) {
|
||||
return '⚠ ccstatusline is not currently resolvable on PATH. Claude Code runs ccstatusline, so restart your shell or update PATH if it cannot launch.';
|
||||
}
|
||||
|
||||
const resolvedDirs = getUniqueResolvedDirs(resolvedPaths);
|
||||
if (resolvedDirs.length > 1) {
|
||||
return `⚠ Multiple ccstatusline binaries are on PATH. Claude Code will run the first match: ${active.resolvedPath}.\nOther matches: ${formatPathList(resolvedPaths.slice(1))}`;
|
||||
}
|
||||
|
||||
if (active.packageManager === 'unknown') {
|
||||
return `⚠ ccstatusline resolves to ${active.resolvedPath}, but it is outside the detected npm and bun global bin directories.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inspectActiveGlobalCommand({
|
||||
commandAvailability,
|
||||
platform = process.platform
|
||||
}: InspectActiveGlobalCommandOptions): ActiveGlobalCommandResolution {
|
||||
const resolvedPaths = getPersistentCommandResolutionPaths(getCommandResolutionPaths('ccstatusline', { platform }));
|
||||
const resolvedPath = resolvedPaths[0] ?? null;
|
||||
const managerBins: Record<GlobalPackageManager, string | null> = {
|
||||
npm: getManagerBinDir('npm', commandAvailability.npm, platform),
|
||||
bun: getManagerBinDir('bun', commandAvailability.bun, platform)
|
||||
};
|
||||
const active = resolvedPath
|
||||
? getActivePackageManager(resolvedPath, managerBins)
|
||||
: { packageManager: 'unknown' as const, binDir: null };
|
||||
|
||||
return {
|
||||
...active,
|
||||
resolvedPath,
|
||||
resolvedPaths,
|
||||
version: resolvedPath ? getGlobalPackageVersion(active.packageManager, active.binDir, platform) : null,
|
||||
warning: getActiveResolutionWarning(resolvedPaths, {
|
||||
packageManager: active.packageManager,
|
||||
resolvedPath
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectGlobalPackageInstallations({
|
||||
commandAvailability,
|
||||
platform = process.platform
|
||||
}: InspectGlobalPackageInstallationsOptions): GlobalPackageInstallation[] {
|
||||
const resolvedPaths = getPersistentCommandResolutionPaths(getCommandResolutionPaths('ccstatusline', { platform }));
|
||||
return [
|
||||
inspectPackageManager('npm', commandAvailability.npm, resolvedPaths, platform),
|
||||
inspectPackageManager('bun', commandAvailability.bun, resolvedPaths, platform)
|
||||
];
|
||||
}
|
||||
|
||||
export function runGlobalPackageUninstall(
|
||||
packageManager: GlobalPackageManager,
|
||||
{ platform = process.platform }: RunGlobalPackageUninstallOptions = {}
|
||||
): Promise<void> {
|
||||
const executable = getPackageManagerExecutable(packageManager, platform);
|
||||
const args = packageManager === 'npm'
|
||||
? ['uninstall', '-g', 'ccstatusline']
|
||||
: ['remove', '-g', 'ccstatusline'];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
executable,
|
||||
args,
|
||||
{
|
||||
timeout: GLOBAL_PACKAGE_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
...getPackageManagerShellOptions(executable, platform)
|
||||
},
|
||||
(error) => {
|
||||
if (error) {
|
||||
reject(error instanceof Error ? error : new Error('Global uninstall command failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
export interface Rgb {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
interface Oklab {
|
||||
L: number;
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
const GRADIENT_PREFIX = 'gradient:';
|
||||
const HEX_PATTERN = /^[0-9A-Fa-f]{6}$/;
|
||||
const ESC = '\x1b';
|
||||
const BEL = '\x07';
|
||||
const C1_CSI = '\x9b';
|
||||
const C1_OSC = '\x9d';
|
||||
const ST = '\x9c';
|
||||
|
||||
interface ParsedEscapeSequence {
|
||||
nextIndex: number;
|
||||
sequence: string;
|
||||
}
|
||||
|
||||
// Named gradient presets, addressable as `gradient:<name>`. The stop lists mirror
|
||||
// the named gradients shipped by `gradient-string` (pulled in transitively through
|
||||
// `ink-gradient`), reproduced here as raw hex so the renderer can interpolate them
|
||||
// without going through the React/Ink layer.
|
||||
// Source: gradient-string (MIT) - https://github.com/bokub/gradient-string
|
||||
//
|
||||
// `gradient-string` renders `rainbow` and `pastel` via a full HSV hue-spin. We
|
||||
// interpolate in OKLab (no hue-spin), so those two are re-expressed as explicit
|
||||
// multi-stop hue wheels that sweep the spectrum directly.
|
||||
export const GRADIENT_PRESETS: Record<string, string[]> = {
|
||||
atlas: ['#feac5e', '#c779d0', '#4bc0c8'],
|
||||
cristal: ['#bdfff3', '#4ac29a'],
|
||||
teen: ['#77a1d3', '#79cbca', '#e684ae'],
|
||||
mind: ['#473b7b', '#3584a7', '#30d2be'],
|
||||
morning: ['#ff5f6d', '#ffc371'],
|
||||
vice: ['#5ee7df', '#b490ca'],
|
||||
passion: ['#f43b47', '#453a94'],
|
||||
fruit: ['#ff4e50', '#f9d423'],
|
||||
instagram: ['#833ab4', '#fd1d1d', '#fcb045'],
|
||||
retro: ['#3f51b1', '#5a55ae', '#7b5fac', '#8f6aae', '#a86aa4', '#cc6b8e', '#f18271', '#f3a469', '#f7c978'],
|
||||
summer: ['#fdbb2d', '#22c1c3'],
|
||||
rainbow: ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff', '#ff0000'],
|
||||
pastel: ['#aee9d8', '#cdeeb0', '#f6f0a8', '#f7c8a8', '#f3aecb', '#c3b6f0', '#aee9d8']
|
||||
};
|
||||
|
||||
export const GRADIENT_PRESET_NAMES: string[] = Object.keys(GRADIENT_PRESETS);
|
||||
|
||||
// True when a color value is a gradient spec (`gradient:…`). Centralizes the
|
||||
// prefix check that callers use to branch before parsing.
|
||||
export function isGradientSpec(value: string | undefined): boolean {
|
||||
return value?.startsWith(GRADIENT_PREFIX) ?? false;
|
||||
}
|
||||
|
||||
// Parse a gradient foreground spec into RGB color stops. Three forms are
|
||||
// accepted, all prefixed `gradient:`
|
||||
// - named preset `gradient:atlas` (case-insensitive)
|
||||
// - dash-separated stops `gradient:RRGGBB-RRGGBB[-…]`
|
||||
// - comma-separated stops `gradient:RRGGBB,RRGGBB[,…]`
|
||||
// The comma vs dash choice is just the delimiter: a body containing a comma splits
|
||||
// on commas, otherwise on dashes. Each stop is resolved by `resolveStopToRgb`, so
|
||||
// `hex:RRGGBB`, `#RRGGBB`, and bare `RRGGBB` are all valid stop syntaxes in EITHER
|
||||
// form (e.g. `gradient:hex:FF0000-hex:0000FF` parses fine) — the comment examples
|
||||
// just show the common pairings. Returns null when the value is not a gradient spec
|
||||
// or resolves to fewer than two usable stops.
|
||||
export function parseGradientSpec(value: string | undefined): Rgb[] | null {
|
||||
if (!value?.startsWith(GRADIENT_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = value.slice(GRADIENT_PREFIX.length).trim();
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preset = GRADIENT_PRESETS[body.toLowerCase()];
|
||||
const rawStops = preset ?? body.split(body.includes(',') ? ',' : '-');
|
||||
|
||||
const stops = rawStops
|
||||
.map(stop => stop.trim())
|
||||
.filter(stop => stop.length > 0)
|
||||
.map(resolveStopToRgb)
|
||||
.filter((rgb): rgb is Rgb => rgb !== null);
|
||||
|
||||
return stops.length >= 2 ? stops : null;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): Rgb | null {
|
||||
if (!HEX_PATTERN.test(hex)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16),
|
||||
g: parseInt(hex.slice(2, 4), 16),
|
||||
b: parseInt(hex.slice(4, 6), 16)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStopToRgb(stop: string): Rgb | null {
|
||||
if (stop.startsWith('hex:')) {
|
||||
return hexToRgb(stop.slice(4));
|
||||
}
|
||||
if (stop.startsWith('#')) {
|
||||
return hexToRgb(stop.slice(1));
|
||||
}
|
||||
return hexToRgb(stop);
|
||||
}
|
||||
|
||||
function srgbToLinear(channel: number): number {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.04045
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function linearToSrgb(channel: number): number {
|
||||
const value = channel <= 0.0031308
|
||||
? 12.92 * channel
|
||||
: 1.055 * (channel ** (1 / 2.4)) - 0.055;
|
||||
return Math.round(Math.min(1, Math.max(0, value)) * 255);
|
||||
}
|
||||
|
||||
// sRGB -> OKLab. Interpolating in OKLab keeps blends perceptually even and
|
||||
// avoids the muddy mid-tones of naive sRGB interpolation.
|
||||
function rgbToOklab(rgb: Rgb): Oklab {
|
||||
const lr = srgbToLinear(rgb.r);
|
||||
const lg = srgbToLinear(rgb.g);
|
||||
const lb = srgbToLinear(rgb.b);
|
||||
|
||||
const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
|
||||
const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
|
||||
const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
|
||||
|
||||
const lCbrt = Math.cbrt(l);
|
||||
const mCbrt = Math.cbrt(m);
|
||||
const sCbrt = Math.cbrt(s);
|
||||
|
||||
return {
|
||||
L: 0.2104542553 * lCbrt + 0.7936177850 * mCbrt - 0.0040720468 * sCbrt,
|
||||
a: 1.9779984951 * lCbrt - 2.4285922050 * mCbrt + 0.4505937099 * sCbrt,
|
||||
b: 0.0259040371 * lCbrt + 0.7827717662 * mCbrt - 0.8086757660 * sCbrt
|
||||
};
|
||||
}
|
||||
|
||||
function oklabToRgb(lab: Oklab): Rgb {
|
||||
const lCbrt = lab.L + 0.3963377774 * lab.a + 0.2158037573 * lab.b;
|
||||
const mCbrt = lab.L - 0.1055613458 * lab.a - 0.0638541728 * lab.b;
|
||||
const sCbrt = lab.L - 0.0894841775 * lab.a - 1.2914855480 * lab.b;
|
||||
|
||||
const l = lCbrt ** 3;
|
||||
const m = mCbrt ** 3;
|
||||
const s = sCbrt ** 3;
|
||||
|
||||
return {
|
||||
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)
|
||||
};
|
||||
}
|
||||
|
||||
// Sample the gradient at position t in [0, 1], interpolating in OKLab between
|
||||
// the two bracketing stops.
|
||||
export function sampleGradient(stops: Rgb[], t: number): Rgb {
|
||||
const first = stops[0];
|
||||
if (first === undefined) {
|
||||
return { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
if (stops.length === 1) {
|
||||
return first;
|
||||
}
|
||||
|
||||
const clamped = Math.min(1, Math.max(0, t));
|
||||
const scaled = clamped * (stops.length - 1);
|
||||
const lowerIndex = Math.min(stops.length - 2, Math.floor(scaled));
|
||||
const lower = stops[lowerIndex];
|
||||
const upper = stops[lowerIndex + 1];
|
||||
if (lower === undefined || upper === undefined) {
|
||||
return first;
|
||||
}
|
||||
|
||||
const fraction = scaled - lowerIndex;
|
||||
const labLower = rgbToOklab(lower);
|
||||
const labUpper = rgbToOklab(upper);
|
||||
|
||||
return oklabToRgb({
|
||||
L: labLower.L + (labUpper.L - labLower.L) * fraction,
|
||||
a: labLower.a + (labUpper.a - labLower.a) * fraction,
|
||||
b: labLower.b + (labUpper.b - labLower.b) * fraction
|
||||
});
|
||||
}
|
||||
|
||||
// Map an RGB color to the nearest xterm-256 palette index (6x6x6 cube plus the
|
||||
// grayscale ramp) for ansi256 terminals.
|
||||
export function rgbToAnsi256(rgb: Rgb): number {
|
||||
if (rgb.r === rgb.g && rgb.g === rgb.b) {
|
||||
if (rgb.r < 8) {
|
||||
return 16;
|
||||
}
|
||||
if (rgb.r > 248) {
|
||||
return 231;
|
||||
}
|
||||
return Math.round(((rgb.r - 8) / 247) * 24) + 232;
|
||||
}
|
||||
|
||||
return 16
|
||||
+ 36 * Math.round((rgb.r / 255) * 5)
|
||||
+ 6 * Math.round((rgb.g / 255) * 5)
|
||||
+ Math.round((rgb.b / 255) * 5);
|
||||
}
|
||||
|
||||
// Build the SGR foreground escape for the gradient color at position t. ansi16
|
||||
// has too few colors for a gradient, so callers should degrade before this; it
|
||||
// falls back to the 256-color path defensively.
|
||||
export function gradientCodeAt(
|
||||
stops: Rgb[],
|
||||
t: number,
|
||||
colorLevel: 'ansi16' | 'ansi256' | 'truecolor'
|
||||
): string {
|
||||
const rgb = sampleGradient(stops, t);
|
||||
if (colorLevel === 'truecolor') {
|
||||
return `\x1b[38;2;${rgb.r};${rgb.g};${rgb.b}m`;
|
||||
}
|
||||
return `\x1b[38;5;${rgbToAnsi256(rgb)}m`;
|
||||
}
|
||||
|
||||
const WHITESPACE = /\s/;
|
||||
|
||||
function isCsiFinalByte(codePoint: number): boolean {
|
||||
return codePoint >= 0x40 && codePoint <= 0x7e;
|
||||
}
|
||||
|
||||
function consumeCsi(input: string, start: number, bodyStart: number): ParsedEscapeSequence {
|
||||
let index = bodyStart;
|
||||
while (index < input.length) {
|
||||
const codePoint = input.charCodeAt(index);
|
||||
if (isCsiFinalByte(codePoint)) {
|
||||
const end = index + 1;
|
||||
return {
|
||||
nextIndex: end,
|
||||
sequence: input.slice(start, end)
|
||||
};
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return {
|
||||
nextIndex: input.length,
|
||||
sequence: input.slice(start)
|
||||
};
|
||||
}
|
||||
|
||||
function consumeOsc(input: string, start: number, bodyStart: number): ParsedEscapeSequence {
|
||||
let index = bodyStart;
|
||||
while (index < input.length) {
|
||||
const current = input[index];
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (current === BEL || current === ST) {
|
||||
const end = index + 1;
|
||||
return {
|
||||
nextIndex: end,
|
||||
sequence: input.slice(start, end)
|
||||
};
|
||||
}
|
||||
|
||||
if (current === ESC && input[index + 1] === '\\') {
|
||||
const end = index + 2;
|
||||
return {
|
||||
nextIndex: end,
|
||||
sequence: input.slice(start, end)
|
||||
};
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return {
|
||||
nextIndex: input.length,
|
||||
sequence: input.slice(start)
|
||||
};
|
||||
}
|
||||
|
||||
function consumeEscapeSequence(input: string, index: number): ParsedEscapeSequence | null {
|
||||
const current = input[index];
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current === ESC) {
|
||||
const next = input[index + 1];
|
||||
if (next === '[') {
|
||||
return consumeCsi(input, index, index + 2);
|
||||
}
|
||||
if (next === ']') {
|
||||
return consumeOsc(input, index, index + 2);
|
||||
}
|
||||
if (next) {
|
||||
return {
|
||||
nextIndex: index + 2,
|
||||
sequence: input.slice(index, index + 2)
|
||||
};
|
||||
}
|
||||
return {
|
||||
nextIndex: input.length,
|
||||
sequence: current
|
||||
};
|
||||
}
|
||||
|
||||
if (current === C1_CSI) {
|
||||
return consumeCsi(input, index, index + 1);
|
||||
}
|
||||
|
||||
if (current === C1_OSC) {
|
||||
return consumeOsc(input, index, index + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Apply a gradient across the visible characters of a single widget's text,
|
||||
// emitting one opening color code per non-whitespace character. Whitespace is
|
||||
// passed through uncolored and does not consume a gradient step, and ANSI/OSC
|
||||
// escape sequences pass through untouched. The gradient restarts at t=0 for
|
||||
// each call, so every widget spans its own self-contained sweep.
|
||||
//
|
||||
// No trailing reset is emitted here - the caller appends `\x1b[39m`. At ansi16
|
||||
// (or for empty/blank text) the input is returned unchanged.
|
||||
//
|
||||
// KNOWN LIMITATION — code points, not grapheme clusters.
|
||||
// This walks `text` with `for…of`, which iterates Unicode *code points*, whereas
|
||||
// the whole-line `applyLineGradient` (in ansi.ts) walks *display clusters* via
|
||||
// `consumeDisplayCluster`. For plain text (ASCII, single-code-point emoji) the two
|
||||
// agree. They diverge only for multi-code-point grapheme clusters — ZWJ sequences
|
||||
// (👩👩👧), variation selectors (✏️), regional-indicator flag pairs (🇺🇸): this
|
||||
// function assigns a separate gradient step to each code point in the cluster
|
||||
// (compressing the sweep there) and emits color codes onto zero-width joiner /
|
||||
// selector code points that render nothing. The visible glyph still draws; the
|
||||
// cosmetic effect is a locally faster sweep plus a few inert escape bytes.
|
||||
//
|
||||
// This divergence is deliberately tolerated rather than fixed:
|
||||
// 1. Per-widget gradient text is short, author-controlled widget content — ZWJ
|
||||
// emoji are vanishingly rare there (the whole-line path, which is far more
|
||||
// likely to carry arbitrary cwd/branch text, IS cluster-correct).
|
||||
// 2. The correct walker (`consumeDisplayCluster`) lives in ansi.ts, and ansi.ts
|
||||
// already imports from this module (gradient.ts) — depending on it back would
|
||||
// introduce a circular import. Unifying the two would require first extracting
|
||||
// the cluster walker into a third, dependency-free module. That refactor is a
|
||||
// tracked follow-up, not a blocker for correct rendering of real status lines.
|
||||
export function applyGradientToText(
|
||||
text: string,
|
||||
stops: Rgb[],
|
||||
colorLevel: 'ansi16' | 'ansi256' | 'truecolor'
|
||||
): string {
|
||||
if (colorLevel === 'ansi16' || text.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let visibleCount = 0;
|
||||
let scanIndex = 0;
|
||||
while (scanIndex < text.length) {
|
||||
const escape = consumeEscapeSequence(text, scanIndex);
|
||||
if (escape) {
|
||||
scanIndex = escape.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const codePoint = text.codePointAt(scanIndex);
|
||||
if (codePoint === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const ch = String.fromCodePoint(codePoint);
|
||||
if (!WHITESPACE.test(ch)) {
|
||||
visibleCount++;
|
||||
}
|
||||
scanIndex += ch.length;
|
||||
}
|
||||
if (visibleCount === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const denominator = Math.max(1, visibleCount - 1);
|
||||
let result = '';
|
||||
let index = 0;
|
||||
let textIndex = 0;
|
||||
while (textIndex < text.length) {
|
||||
const escape = consumeEscapeSequence(text, textIndex);
|
||||
if (escape) {
|
||||
result += escape.sequence;
|
||||
textIndex = escape.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const codePoint = text.codePointAt(textIndex);
|
||||
if (codePoint === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const ch = String.fromCodePoint(codePoint);
|
||||
if (WHITESPACE.test(ch)) {
|
||||
result += ch;
|
||||
textIndex += ch.length;
|
||||
continue;
|
||||
}
|
||||
result += gradientCodeAt(stops, index / denominator, colorLevel) + ch;
|
||||
index++;
|
||||
textIndex += ch.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getSkillsFilePath } from './skills';
|
||||
|
||||
interface HookInput {
|
||||
session_id?: string;
|
||||
hook_event_name?: string;
|
||||
tool_name?: string;
|
||||
tool_input?: { skill?: string };
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export function handleHookInput(input: string | null): void {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(input) as HookInput;
|
||||
const sessionId = data.session_id;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let skillName = '';
|
||||
if (data.hook_event_name === 'PreToolUse' && data.tool_name === 'Skill') {
|
||||
skillName = data.tool_input?.skill ?? '';
|
||||
} else if (data.hook_event_name === 'UserPromptSubmit') {
|
||||
const match = /^\/([a-zA-Z0-9_:-]+)(?:\s|$)/.exec(data.prompt ?? '');
|
||||
if (match) {
|
||||
skillName = match[1] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!skillName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = getSkillsFilePath(sessionId);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const entry = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: sessionId,
|
||||
skill: skillName,
|
||||
source: data.hook_event_name
|
||||
});
|
||||
fs.appendFileSync(filePath, entry + '\n');
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
+52
-6
@@ -15,18 +15,64 @@ 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;
|
||||
hooks?: { type: string; command: string }[];
|
||||
}
|
||||
|
||||
type WidgetWithHooks = Widget & { getHooks(): WidgetHookDef[] };
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,8 +82,8 @@ function getActiveHookDefs(settings: Settings): WidgetHookDef[] {
|
||||
const defs: WidgetHookDef[] = [];
|
||||
for (const line of settings.lines) {
|
||||
for (const item of line) {
|
||||
const widget = getWidget(item.type) as (Widget & { getHooks?: () => WidgetHookDef[] }) | null;
|
||||
if (!widget?.getHooks) {
|
||||
const widget = getWidget(item.type);
|
||||
if (!hasWidgetHooks(widget)) {
|
||||
continue;
|
||||
}
|
||||
for (const hook of widget.getHooks()) {
|
||||
@@ -57,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();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
|
||||
import { resolveGitCwd } from './git';
|
||||
|
||||
export interface JjChangeCounts {
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export function runJjArgs(args: string[], context: RenderContext, allowEmpty = false): string | null {
|
||||
try {
|
||||
const cwd = resolveGitCwd(context);
|
||||
const output = execFileSync('jj', args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
...(cwd ? { cwd } : {})
|
||||
}).trimEnd();
|
||||
|
||||
return (allowEmpty || output.length > 0) ? output : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isInsideJjRepo(context: RenderContext): boolean {
|
||||
return runJjArgs(['root'], context) !== null;
|
||||
}
|
||||
|
||||
function parseDiffStat(stat: string): JjChangeCounts {
|
||||
const insertMatch = /(\d+)\s+insertions?/.exec(stat);
|
||||
const deleteMatch = /(\d+)\s+deletions?/.exec(stat);
|
||||
|
||||
return {
|
||||
insertions: insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0,
|
||||
deletions: deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0
|
||||
};
|
||||
}
|
||||
|
||||
export function getJjChangeCounts(context: RenderContext): JjChangeCounts {
|
||||
return parseDiffStat(runJjArgs(['diff', '--stat'], context) ?? '');
|
||||
}
|
||||
@@ -152,14 +152,15 @@ export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetr
|
||||
try {
|
||||
// Use Node.js-compatible file reading
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
|
||||
const lines = await readJsonlLines(transcriptPath);
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let contextLength = 0;
|
||||
|
||||
// Parse each line and sum up token usage for totals.
|
||||
@@ -200,8 +201,8 @@ export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetr
|
||||
|
||||
inputTokens += usage.input_tokens || 0;
|
||||
outputTokens += usage.output_tokens || 0;
|
||||
cachedTokens += usage.cache_read_input_tokens ?? 0;
|
||||
cachedTokens += usage.cache_creation_input_tokens ?? 0;
|
||||
cacheReadTokens += usage.cache_read_input_tokens ?? 0;
|
||||
cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
|
||||
|
||||
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
|
||||
// Also skip API error messages (synthetic messages with 0 tokens)
|
||||
@@ -222,11 +223,12 @@ export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetr
|
||||
+ (usage.cache_creation_input_tokens ?? 0);
|
||||
}
|
||||
|
||||
const cachedTokens = cacheReadTokens + cacheCreationTokens;
|
||||
const totalTokens = inputTokens + outputTokens + cachedTokens;
|
||||
|
||||
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
|
||||
return { inputTokens, outputTokens, cachedTokens, cacheReadTokens, cacheCreationTokens, totalTokens, contextLength };
|
||||
} catch {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user