Compare commits
126 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 | |||
| 1f26e52e80 | |||
| 3b0a26e3a9 | |||
| bd20bbb265 | |||
| 78836d74f3 | |||
| a95059ba2a | |||
| cccf448518 | |||
| 89a0a78467 | |||
| 48bc4208fb | |||
| b4c19ec46f | |||
| 9c8b6c2781 | |||
| c2a77c38d3 | |||
| 316d727c0f | |||
| 17938a98fc | |||
| eb1f0d17f0 | |||
| 6f875631da | |||
| cef29e128a | |||
| afbbf66b3a | |||
| c5a209b687 | |||
| 5a08aeed69 | |||
| 9f779db141 | |||
| 933d1f7852 | |||
| eef365945d | |||
| 8996d8aaf6 | |||
| 1ecc240ce8 | |||
| 298c6716e3 | |||
| efc7e8a06c | |||
| 49e5511f4a | |||
| ad91b190b1 | |||
| 5e80b5834f | |||
| ccc180301d | |||
| 67151c61d4 |
@@ -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,6 +47,87 @@
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
### v2.2.22 - v2.2.24 - Powerline flex mode, cache/CI/sandbox visibility, layout controls, composable metrics, and safer config
|
||||
|
||||

|
||||
|
||||
- **⏳ Prompt cache timer** - Added a `Cache Timer` widget with live `HOT` state, TTL countdown, configurable 5-minute/1-hour windows, and customizable state glyphs.
|
||||
- **✅ GitHub CI status** - Added a `Git CI Status` widget that summarizes failing, pending, and successful checks for the current branch's pull request.
|
||||
- **🔒 Sandbox status** - Added a `Sandbox Status` widget with glyph, text, and Nerd Font formats that follows Claude Code's layered sandbox setting on each refresh.
|
||||
- **↔️ One-sided default padding** - Default widget padding can now apply to both sides, the left only, or the right only in standard and Powerline layouts.
|
||||
- **⚡ Powerline flex mode** - Flex separators now work in Powerline mode, letting Powerline status lines right-align content or absorb available width.
|
||||
- **🌗 Per-widget dim styling** - The color editor can dim an entire widget or only parenthesized text, with reset and clear-all actions covering dim state.
|
||||
- **🧯 Safer settings recovery** - Invalid `settings.json` files are left untouched, defaults render in memory, and the status line shows an invalid-config warning.
|
||||
- **🧭 Selective Powerline alignment** - Press `x` in the line editor to let a widget and the rest of its line keep their natural widths while earlier Powerline columns stay auto-aligned.
|
||||
- **📏 Git widget width limits** - `Git Branch` and `Git Root Dir` can cap their visible width with ellipsis-safe truncation while preserving hyperlink targets.
|
||||
- **🔣 Current directory glyphs** - `Current Working Dir` can prepend an optional custom glyph, including in raw-value mode.
|
||||
- **🧠 Configurable context fallback** - `CCSTATUSLINE_CONTEXT_SIZE_FALLBACK` overrides the default 200k last-resort context window when Claude Code and the model name do not report one.
|
||||
- **🧩 Composable compaction metrics** - `Compaction Counter` can render count, auto, manual, unknown, or reclaimed-token values independently for custom layouts.
|
||||
- **🏷️ CLI version flag** - `ccstatusline --version` now prints the installed package version and exits.
|
||||
- **🛡️ Guarded invalid-config saves** - The TUI warns when it loaded defaults for an invalid settings file and requires confirmation before replacing that file.
|
||||
- **🔄 Usage display and cache fixes** - Used/remaining direction now works in every percentage mode, account switches invalidate cached usage, and fetch locks no longer cause repeated requests or stale timeout output.
|
||||
- **⏱️ Calmer reset timer startup** - Reset timers show labeled loading placeholders instead of transient rate-limit errors while Claude Code's embedded usage-window data is still arriving.
|
||||
- **🔇 Quieter install detection** - Best-effort npm and Bun probes no longer leak expected package-manager errors into the TUI.
|
||||
|
||||
### v2.2.21 - Cache widgets, compaction details, extra usage currency, and package fixes
|
||||
|
||||
- **🔣 Custom widget glyphs** - Git and JJ symbol widgets can override or suppress their built-in glyphs from the TUI.
|
||||
- **🔁 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.
|
||||
- **🏷️ 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.
|
||||
- **🧱 More resilient Git helpers** - Git widgets handle missing or unusual git command output more defensively.
|
||||
|
||||
### v2.2.8 - Git widgets, smarter picker search, and minimalist mode
|
||||
|
||||
- **🔀 New Git PR widget** - Added a `Git PR` widget with clickable PR links plus optional status and title display for the current branch.
|
||||
@@ -57,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**.
|
||||
@@ -71,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.
|
||||
@@ -165,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
|
||||
@@ -177,7 +258,7 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **📊 Real-time Metrics** - Display model name, git branch, token usage, session duration, 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
|
||||
@@ -211,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>
|
||||
@@ -220,6 +303,7 @@ The interactive configuration tool provides a terminal UI where you can:
|
||||
- Add/remove/reorder status line widgets
|
||||
- Customize colors for each widget
|
||||
- Configure flex separator behavior
|
||||
- Configure Claude Code status line refresh interval when supported
|
||||
- Edit custom text widgets
|
||||
- Install/uninstall to Claude Code settings
|
||||
- Preview your status line in real-time
|
||||
@@ -248,15 +332,20 @@ When you install from the TUI, ccstatusline writes a `statusLine` command object
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "npx -y ccstatusline@latest",
|
||||
"padding": 0
|
||||
"padding": 0,
|
||||
"refreshInterval": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`refreshInterval` is written only when your Claude Code version supports it (>=2.1.97). The TUI can set it to `1-60` seconds, or remove it by leaving the input empty.
|
||||
|
||||
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
|
||||
@@ -291,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
|
||||
|
||||
|
||||
@@ -51,13 +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/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
|
||||
|
||||
|
||||
+132
-21
@@ -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,38 +18,48 @@ bun run start
|
||||
|
||||
# Piped mode with example payload
|
||||
bun run example
|
||||
|
||||
# Print the installed package version
|
||||
ccstatusline --version
|
||||
```
|
||||
|
||||
## Available Widgets
|
||||
|
||||
### Claude & Session
|
||||
|
||||
- **Model** / **Output Style** / **Version** - Show the active Claude model, output style, and Claude Code CLI version.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### Git
|
||||
|
||||
- **Git Branch** / **Git Root Dir** / **Git PR** - Show the current branch, repository root directory, and PR details for the current branch with optional links.
|
||||
- **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.
|
||||
- **Git Origin Owner** / **Git Origin Repo** / **Git Origin Owner/Repo** - Show parsed `origin` remote metadata.
|
||||
- **Git Upstream Owner** / **Git Upstream Repo** / **Git Upstream Owner/Repo** / **Git Is Fork** - Show upstream remote metadata and whether the current repo is a fork.
|
||||
- **Git Worktree** / **Git Worktree Mode** / **Git Worktree Name** / **Git Worktree Branch** / **Git Worktree Original Branch** - Show worktree status plus the active worktree's name and branch metadata.
|
||||
|
||||
### 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 %** / **Context % (usable)** / **Context Bar** - Show model context size, usage percentage, usable-window percentage, or a progress bar.
|
||||
- **Session Usage** / **Weekly Usage** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages plus current block/reset timing.
|
||||
- **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
|
||||
|
||||
@@ -57,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:
|
||||
@@ -65,10 +90,13 @@ 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.
|
||||
|
||||
<details>
|
||||
<summary><b>Global Formatting Options</b></summary>
|
||||
@@ -79,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
|
||||
@@ -92,6 +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:
|
||||
@@ -123,14 +185,17 @@ Some widgets support "raw value" mode which displays just the value without a la
|
||||
Common controls in the line editor:
|
||||
- `↑/↓` select widget
|
||||
- `←/→` open the type picker for the selected widget
|
||||
- navigation wraps at list boundaries, including move/reorder mode
|
||||
- `a` add widget via the picker
|
||||
- `i` insert widget via the picker
|
||||
- `k` clone the selected widget
|
||||
- `Enter` enter/exit move mode
|
||||
- `d` delete selected widget
|
||||
- `c` clear the current line
|
||||
- `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:
|
||||
@@ -142,18 +207,25 @@ 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 GitHub branch links
|
||||
- **Git Root Dir**: `l` cycle IDE links (`off` → `VS Code` → `Cursor`)
|
||||
- **Git PR**: `h` hide empty/no-PR output, `s` toggle review status, `t` toggle title
|
||||
- **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
|
||||
- **Session Usage / Weekly Usage**: `p` cycle percentage/full bar/short bar, `v` invert fill in progress mode
|
||||
- **Block Timer / Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `v` invert fill in progress mode
|
||||
- **Weekly Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `h` toggle hours-only, `v` invert fill in progress mode
|
||||
- **Context Bar**: `p` toggle full-width vs short progress bar
|
||||
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
|
||||
- **Context % widgets**: `u` toggle used vs remaining display, `p` cycle percentage/short bar/short bar only
|
||||
- **Session Usage / Weekly Usage / Weekly Sonnet Usage / Weekly Opus Usage / 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**: `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
|
||||
- **Custom Text / Custom Symbol**: `e` edit text or symbol
|
||||
@@ -182,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
|
||||
@@ -193,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
|
||||
|
||||
@@ -218,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.
|
||||
|
||||
+32
-9
@@ -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:
|
||||
@@ -50,7 +46,8 @@ $env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bunx -y ccstatusline@latest",
|
||||
"padding": 0
|
||||
"padding": 0,
|
||||
"refreshInterval": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -62,13 +59,29 @@ $env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "npx -y ccstatusline@latest",
|
||||
"padding": 0
|
||||
"padding": 0,
|
||||
"refreshInterval": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`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:
|
||||
@@ -104,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
|
||||
@@ -190,4 +213,4 @@ For the best experience, configure Windows Terminal with these recommended setti
|
||||
|
||||
`ccstatusline` includes Windows-specific runtime behavior:
|
||||
- **UTF-8 piped output fix**: In piped mode, it attempts to set code page `65001` for reliable symbol rendering
|
||||
- **Path compatibility**: Git and CWD widgets handle both `/` and `\` separators
|
||||
- **Path compatibility**: Git and CWD widgets handle both `/` and `\` separators
|
||||
|
||||
+12
-12
@@ -1,14 +1,14 @@
|
||||
import js from '@eslint/js';
|
||||
import ts from 'typescript-eslint';
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import { importX } from 'eslint-plugin-import-x';
|
||||
import importNewlinesPlugin from 'eslint-plugin-import-newlines';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
|
||||
const importResolverSettings = {
|
||||
'import/resolver': {
|
||||
'import-x/resolver': {
|
||||
typescript: {
|
||||
project: ['./tsconfig.json'],
|
||||
alwaysTryTypes: true,
|
||||
@@ -18,10 +18,10 @@ const importResolverSettings = {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
|
||||
}
|
||||
},
|
||||
'import/parsers': {
|
||||
'import-x/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx']
|
||||
},
|
||||
'import/external-module-folders': ['node_modules', 'node_modules/@types']
|
||||
'import-x/external-module-folders': ['node_modules', 'node_modules/@types']
|
||||
};
|
||||
|
||||
export default ts.config([
|
||||
@@ -29,14 +29,14 @@ export default ts.config([
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
plugins: {
|
||||
stylistic,
|
||||
importPlugin,
|
||||
'import-x': importX,
|
||||
'import-newlines': importNewlinesPlugin
|
||||
},
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
ts.configs.strictTypeChecked,
|
||||
ts.configs.stylisticTypeChecked,
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importX.flatConfigs.recommended,
|
||||
stylistic.configs.customize({
|
||||
quotes: 'single',
|
||||
semi: true,
|
||||
@@ -60,7 +60,7 @@ export default ts.config([
|
||||
rules: {
|
||||
'no-control-regex': 'off', // We intentionally match ANSI escape sequences
|
||||
'eqeqeq': 'error',
|
||||
'import/order': ['error', {
|
||||
'import-x/order': ['error', {
|
||||
alphabetize: {
|
||||
'order': 'asc',
|
||||
'orderImportKind': 'asc',
|
||||
@@ -106,13 +106,13 @@ export default ts.config([
|
||||
'@stylistic/nonblock-statement-body-position': ['error', 'below'],
|
||||
'@stylistic/object-curly-newline': ['error', { 'multiline': true }],
|
||||
'@stylistic/switch-colon-spacing': 'error',
|
||||
'@stylistic/eol-last': ['error', 'never'],
|
||||
'@stylistic/eol-last': ['error', 'always'],
|
||||
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
|
||||
'@stylistic/multiline-ternary': 'off',
|
||||
'import/no-unresolved': ['error'],
|
||||
'import/no-named-as-default': 'off',
|
||||
'import/no-named-as-default-member': 'off',
|
||||
'import/default': 'off'
|
||||
'import-x/no-unresolved': ['error'],
|
||||
'import-x/no-named-as-default': 'off',
|
||||
'import-x/no-named-as-default-member': 'off',
|
||||
'import-x/default': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+26
-7
@@ -1,11 +1,22 @@
|
||||
{
|
||||
"name": "ccstatusline",
|
||||
"version": "2.2.8",
|
||||
"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,29 +38,33 @@
|
||||
},
|
||||
"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",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-import-newlines": "^2.0.0",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.3.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"ink": "6.2.0",
|
||||
"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",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vitest": "^4.0.18",
|
||||
"zod": "^4.0.17"
|
||||
|
||||
@@ -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 |
@@ -25,4 +25,4 @@ bundledContent = bundledContent.replace(/__PACKAGE_VERSION__/g, version);
|
||||
// Write back the modified content
|
||||
writeFileSync(bundledFilePath, bundledContent);
|
||||
|
||||
console.log(`✓ Replaced version placeholder with ${version}`);
|
||||
console.log(`✓ Replaced version placeholder with ${version}`);
|
||||
|
||||
+78
-54
@@ -13,10 +13,20 @@ import { StatusJSONSchema } from './types/StatusJSON';
|
||||
import { getVisibleText } from './utils/ansi';
|
||||
import { updateColorMap } from './utils/colors';
|
||||
import {
|
||||
ZERO_COMPACTION_STATS,
|
||||
getCompactionStats
|
||||
} from './utils/compaction';
|
||||
import {
|
||||
getConfigLoadError,
|
||||
initConfigPath,
|
||||
loadSettings,
|
||||
saveSettings
|
||||
} from './utils/config';
|
||||
import {
|
||||
GIT_REVIEW_REFRESH_FLAG,
|
||||
refreshGitReviewCacheFromCli
|
||||
} from './utils/git-review-cache';
|
||||
import { handleHookInput } from './utils/hook-handler';
|
||||
import {
|
||||
getSessionDuration,
|
||||
getSpeedMetricsCollection,
|
||||
@@ -24,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 {
|
||||
@@ -80,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.
|
||||
}
|
||||
@@ -88,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;
|
||||
@@ -141,6 +155,12 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
skillsMetrics = getSkillsMetrics(data.session_id);
|
||||
}
|
||||
|
||||
// Compaction stats — parse compact_boundary markers in this session's transcript
|
||||
const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter'));
|
||||
const compactionData = hasCompactionWidget
|
||||
? (data.transcript_path ? await getCompactionStats(data.transcript_path) : ZERO_COMPACTION_STATS)
|
||||
: null;
|
||||
|
||||
// Create render context
|
||||
const context: RenderContext = {
|
||||
data,
|
||||
@@ -150,8 +170,12 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
usageData,
|
||||
sessionDuration,
|
||||
skillsMetrics,
|
||||
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)
|
||||
@@ -161,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) {
|
||||
@@ -169,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');
|
||||
|
||||
@@ -184,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);
|
||||
}
|
||||
@@ -192,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() !== ''
|
||||
@@ -235,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());
|
||||
|
||||
@@ -333,4 +357,4 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
void main();
|
||||
|
||||
+883
-64
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,11 +54,237 @@ 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 };
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { saveClaudeSettings } from '../../utils/claude-settings';
|
||||
import { loadClaudeStatusLineState } from '../claude-status';
|
||||
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
let testClaudeConfigDir = '';
|
||||
|
||||
beforeEach(() => {
|
||||
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-status-'));
|
||||
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (testClaudeConfigDir) {
|
||||
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
describe('loadClaudeStatusLineState', () => {
|
||||
it('loads both the installed command and refresh interval from Claude settings', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'npx -y ccstatusline@latest',
|
||||
padding: 0,
|
||||
refreshInterval: 10
|
||||
}
|
||||
});
|
||||
|
||||
await expect(loadClaudeStatusLineState()).resolves.toEqual({
|
||||
existingStatusLine: 'npx -y ccstatusline@latest',
|
||||
refreshInterval: 10
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null refreshInterval when Claude settings do not define one', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'npx -y ccstatusline@latest',
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
|
||||
await expect(loadClaudeStatusLineState()).resolves.toEqual({
|
||||
existingStatusLine: 'npx -y ccstatusline@latest',
|
||||
refreshInterval: null
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
getExistingStatusLine,
|
||||
getRefreshInterval
|
||||
} from '../utils/claude-settings';
|
||||
|
||||
export interface ClaudeStatusLineState {
|
||||
existingStatusLine: string | null;
|
||||
refreshInterval: number | null;
|
||||
}
|
||||
|
||||
export async function loadClaudeStatusLineState(): Promise<ClaudeStatusLineState> {
|
||||
const [
|
||||
existingStatusLine,
|
||||
refreshInterval
|
||||
] = await Promise.all([
|
||||
getExistingStatusLine(),
|
||||
getRefreshInterval()
|
||||
]);
|
||||
|
||||
return {
|
||||
existingStatusLine,
|
||||
refreshInterval
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
@@ -504,4 +662,4 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -71,4 +71,4 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm
|
||||
</Box>
|
||||
</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>
|
||||
@@ -331,4 +481,4 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,8 +232,8 @@ 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,
|
||||
@@ -201,7 +234,8 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
setShowClearConfirm,
|
||||
openWidgetPicker,
|
||||
getCustomKeybindsForWidget,
|
||||
setCustomEditorWidget
|
||||
setCustomEditorWidget,
|
||||
getUniqueBackgroundColor
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,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'
|
||||
@@ -280,7 +292,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
helpText += ', Space edit separator';
|
||||
}
|
||||
if (hasWidgets) {
|
||||
helpText += ', Enter to move, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line';
|
||||
helpText += ', Enter to move, (a)dd via picker, (i)nsert via picker, (k) clone, (d)elete, (c)lear line';
|
||||
}
|
||||
if (canToggleRaw) {
|
||||
helpText += ', (r)aw value';
|
||||
@@ -288,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
|
||||
@@ -358,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>
|
||||
@@ -546,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>
|
||||
);
|
||||
})}
|
||||
@@ -572,4 +588,4 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -97,26 +97,28 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
}
|
||||
|
||||
if (moveMode) {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
if (key.upArrow && localLines.length > 1) {
|
||||
const newLines = [...localLines];
|
||||
const targetIndex = selectedIndex - 1 < 0 ? localLines.length - 1 : selectedIndex - 1;
|
||||
const temp = newLines[selectedIndex];
|
||||
const prev = newLines[selectedIndex - 1];
|
||||
const prev = newLines[targetIndex];
|
||||
if (temp && prev) {
|
||||
[newLines[selectedIndex], newLines[selectedIndex - 1]] = [prev, temp];
|
||||
[newLines[selectedIndex], newLines[targetIndex]] = [prev, temp];
|
||||
}
|
||||
setLocalLines(newLines);
|
||||
onLinesUpdate(newLines);
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < localLines.length - 1) {
|
||||
setSelectedIndex(targetIndex);
|
||||
} else if (key.downArrow && localLines.length > 1) {
|
||||
const newLines = [...localLines];
|
||||
const targetIndex = selectedIndex + 1 > localLines.length - 1 ? 0 : selectedIndex + 1;
|
||||
const temp = newLines[selectedIndex];
|
||||
const next = newLines[selectedIndex + 1];
|
||||
const next = newLines[targetIndex];
|
||||
if (temp && next) {
|
||||
[newLines[selectedIndex], newLines[selectedIndex + 1]] = [next, temp];
|
||||
[newLines[selectedIndex], newLines[targetIndex]] = [next, temp];
|
||||
}
|
||||
setLocalLines(newLines);
|
||||
onLinesUpdate(newLines);
|
||||
setSelectedIndex(selectedIndex + 1);
|
||||
setSelectedIndex(targetIndex);
|
||||
} else if (key.escape || key.return) {
|
||||
setMoveMode(false);
|
||||
}
|
||||
@@ -307,4 +309,4 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export { LineSelector, type LineSelectorProps };
|
||||
export { LineSelector, type LineSelectorProps };
|
||||
|
||||
@@ -39,7 +39,7 @@ export function List<V = string | number>({
|
||||
initialSelection = 0,
|
||||
showBackButton,
|
||||
color,
|
||||
wrapNavigation = false,
|
||||
wrapNavigation = true,
|
||||
...boxProps
|
||||
}: ListProps<V>) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
|
||||
@@ -167,4 +167,4 @@ export function ListItem({
|
||||
|
||||
export function ListSeparator() {
|
||||
return <Text> </Text>;
|
||||
}
|
||||
}
|
||||
|
||||
+115
-29
@@ -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,9 @@ export type MainMenuOption = 'lines'
|
||||
| 'terminalConfig'
|
||||
| 'globalOverrides'
|
||||
| 'install'
|
||||
| 'manageInstallation'
|
||||
| 'checkUpdates'
|
||||
| 'configureStatusLine'
|
||||
| 'starGithub'
|
||||
| 'save'
|
||||
| 'exit';
|
||||
@@ -26,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',
|
||||
@@ -62,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',
|
||||
@@ -74,20 +113,20 @@ export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
description:
|
||||
'Set global padding, separators, and color overrides that apply to all widgets'
|
||||
},
|
||||
'-' as const,
|
||||
{
|
||||
label: isClaudeInstalled
|
||||
? '🔌 Uninstall from Claude Code'
|
||||
: '📦 Install to Claude Code',
|
||||
value: 'install',
|
||||
description: isClaudeInstalled
|
||||
? 'Remove ccstatusline from your Claude Code settings'
|
||||
: 'Add ccstatusline to your Claude Code settings for automatic status line rendering'
|
||||
}
|
||||
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',
|
||||
@@ -98,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',
|
||||
@@ -107,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',
|
||||
@@ -121,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';
|
||||
@@ -152,4 +238,4 @@ export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -146,10 +146,10 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
// Normal mode
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.upArrow && separators.length > 0) {
|
||||
setSelectedIndex(selectedIndex - 1 < 0 ? separators.length - 1 : selectedIndex - 1);
|
||||
} else if (key.downArrow && separators.length > 0) {
|
||||
setSelectedIndex(Math.min(separators.length - 1, selectedIndex + 1));
|
||||
setSelectedIndex(selectedIndex + 1 > separators.length - 1 ? 0 : selectedIndex + 1);
|
||||
} else if ((key.leftArrow || key.rightArrow) && separators.length > 0) {
|
||||
// Cycle through preset separators
|
||||
const currentChar = separators[selectedIndex] ?? '\uE0B0';
|
||||
@@ -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>
|
||||
|
||||
@@ -322,4 +321,4 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
)}
|
||||
</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
|
||||
@@ -459,4 +469,4 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -232,4 +232,4 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { shouldInsertInput } from '../../utils/input-guards';
|
||||
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
type ConfigureStatusLineValue = 'refreshInterval' | 'gitCacheTtl';
|
||||
|
||||
function getRefreshInputValue(interval: number | null): string {
|
||||
return interval === null ? '' : String(interval);
|
||||
}
|
||||
|
||||
function getRefreshIntervalSublabel(interval: number | null, supported: boolean): string {
|
||||
if (!supported) {
|
||||
return '(requires Claude Code >=2.1.97)';
|
||||
}
|
||||
|
||||
if (interval === null) {
|
||||
return '(not set)';
|
||||
}
|
||||
|
||||
return `(${interval}s)`;
|
||||
}
|
||||
|
||||
function getGitCacheTtlSublabel(ttlSeconds: number): string {
|
||||
return ttlSeconds === 0
|
||||
? '(mtime only)'
|
||||
: `(${ttlSeconds}s)`;
|
||||
}
|
||||
|
||||
export function buildConfigureStatusLineItems(
|
||||
refreshInterval: number | null,
|
||||
supportsRefreshInterval: boolean,
|
||||
gitCacheTtlSeconds: number
|
||||
): ListEntry<ConfigureStatusLineValue>[] {
|
||||
return [
|
||||
{
|
||||
label: '🔄 Refresh Interval',
|
||||
sublabel: getRefreshIntervalSublabel(refreshInterval, supportsRefreshInterval),
|
||||
value: 'refreshInterval',
|
||||
disabled: !supportsRefreshInterval,
|
||||
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.'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export function validateRefreshIntervalInput(value: string): string | null {
|
||||
if (value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseInt(value, 10);
|
||||
|
||||
if (isNaN(parsed)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
|
||||
if (parsed < 1) {
|
||||
return `Minimum interval is 1s (you entered ${parsed}s)`;
|
||||
}
|
||||
|
||||
if (parsed > 60) {
|
||||
return `Maximum interval is 60s (you entered ${parsed}s)`;
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (editingRefreshInterval) {
|
||||
if (key.return) {
|
||||
if (refreshInput === '') {
|
||||
onUpdate(null);
|
||||
setEditingRefreshInterval(false);
|
||||
setValidationError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const error = validateRefreshIntervalInput(refreshInput);
|
||||
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
} else {
|
||||
const value = parseInt(refreshInput, 10);
|
||||
onUpdate(value);
|
||||
setEditingRefreshInterval(false);
|
||||
setValidationError(null);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
setRefreshInput(getRefreshInputValue(currentInterval));
|
||||
setEditingRefreshInterval(false);
|
||||
setValidationError(null);
|
||||
} else if (key.backspace) {
|
||||
setRefreshInput(refreshInput.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 = refreshInput + input;
|
||||
if (newValue.length <= 2) {
|
||||
setRefreshInput(newValue);
|
||||
setValidationError(null);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Configure Status Line</Text>
|
||||
<Text color='white'>Configure Claude Code status line settings</Text>
|
||||
|
||||
{editingRefreshInterval ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text>
|
||||
Enter refresh interval in seconds (1-60):
|
||||
{' '}
|
||||
{refreshInput}
|
||||
{refreshInput.length > 0 ? 's' : ''}
|
||||
</Text>
|
||||
{validationError ? (
|
||||
<Text color='red'>{validationError}</Text>
|
||||
) : (
|
||||
<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, gitCacheTtlSeconds)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'refreshInterval') {
|
||||
setRefreshInput(getRefreshInputValue(currentInterval));
|
||||
setEditingRefreshInterval(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setGitCacheTtlInput(String(gitCacheTtlSeconds));
|
||||
setEditingGitCacheTtl(true);
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -8,9 +8,15 @@ import React from 'react';
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
getVisibleWidth,
|
||||
stripOscCodes,
|
||||
truncateStyledText
|
||||
} from '../../utils/ansi';
|
||||
import { advanceGlobalPowerlineThemeIndex } from '../../utils/powerline-theme-index';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
countPowerlineStartCapSlots,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLineWithInfo,
|
||||
type PreRenderedWidget,
|
||||
@@ -32,6 +38,7 @@ const renderSingleLine = (
|
||||
lineIndex: number,
|
||||
globalSeparatorIndex: number,
|
||||
globalPowerlineThemeIndex: number,
|
||||
globalPowerlineStartCapIndex: number,
|
||||
preRenderedWidgets: PreRenderedWidget[],
|
||||
preCalculatedMaxWidths: number[]
|
||||
): RenderResult => {
|
||||
@@ -40,14 +47,24 @@ const renderSingleLine = (
|
||||
terminalWidth,
|
||||
isPreview: true,
|
||||
minimalist: settings.minimalistMode,
|
||||
gitCacheTtlSeconds: settings.gitCacheTtlSeconds,
|
||||
lineIndex,
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex
|
||||
globalPowerlineThemeIndex,
|
||||
globalPowerlineStartCapIndex
|
||||
};
|
||||
|
||||
return renderStatusLineWithInfo(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
};
|
||||
|
||||
const PREVIEW_LINE_INDENT = ' ';
|
||||
|
||||
export function preparePreviewLineForTerminal(line: string, terminalWidth: number): string {
|
||||
const printableLine = stripOscCodes(line);
|
||||
const availableWidth = Math.max(0, terminalWidth - getVisibleWidth(PREVIEW_LINE_INDENT));
|
||||
return truncateStyledText(printableLine, availableWidth, { ellipsis: true });
|
||||
}
|
||||
|
||||
export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings, onTruncationChange }) => {
|
||||
// Render each configured line
|
||||
// Pass the full terminal width - the renderer will handle preview adjustments
|
||||
@@ -56,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;
|
||||
|
||||
@@ -75,6 +98,7 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
i,
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex,
|
||||
globalPowerlineStartCapIndex,
|
||||
preRenderedWidgets,
|
||||
preCalculatedMaxWidths
|
||||
);
|
||||
@@ -83,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);
|
||||
}
|
||||
@@ -107,12 +134,12 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
</Text>
|
||||
</Box>
|
||||
{renderedLines.map((line, index) => (
|
||||
<Text key={index}>
|
||||
{' '}
|
||||
{line}
|
||||
<Text key={index} wrap='truncate'>
|
||||
{PREVIEW_LINE_INDENT}
|
||||
{preparePreviewLineForTerminal(line, terminalWidth)}
|
||||
{chalk.reset('')}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -171,4 +171,4 @@ export const getColorLevelLabel = (level?: 0 | 1 | 2 | 3): string => {
|
||||
case 3: return 'Truecolor';
|
||||
default: return '256 Color (default)';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -174,4 +174,4 @@ export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,10 @@ 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();
|
||||
@@ -132,4 +140,131 @@ describe('InstallMenu', () => {
|
||||
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();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,4 +157,4 @@ describe('PowerlineThemeSelector helpers', () => {
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
RefreshIntervalMenu,
|
||||
buildConfigureStatusLineItems,
|
||||
validateGitCacheTtlInput,
|
||||
validateRefreshIntervalInput
|
||||
} from '../RefreshIntervalMenu';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): CapturedWriteStream {
|
||||
const stream = new MockTtyStream();
|
||||
const chunks: string[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
return Object.assign(stream as unknown as NodeJS.WriteStream, {
|
||||
getOutput() {
|
||||
return chunks.join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('validateRefreshIntervalInput', () => {
|
||||
it('should accept empty string (remove interval)', () => {
|
||||
expect(validateRefreshIntervalInput('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept valid values within range', () => {
|
||||
expect(validateRefreshIntervalInput('1')).toBeNull();
|
||||
expect(validateRefreshIntervalInput('10')).toBeNull();
|
||||
expect(validateRefreshIntervalInput('30')).toBeNull();
|
||||
expect(validateRefreshIntervalInput('60')).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject values below minimum', () => {
|
||||
expect(validateRefreshIntervalInput('0')).toContain('Minimum');
|
||||
});
|
||||
|
||||
it('should reject values above maximum', () => {
|
||||
expect(validateRefreshIntervalInput('61')).toContain('Maximum');
|
||||
});
|
||||
|
||||
it('should reject non-numeric input', () => {
|
||||
expect(validateRefreshIntervalInput('abc')).toContain('valid number');
|
||||
});
|
||||
});
|
||||
|
||||
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, 5);
|
||||
expect(items[0]?.sublabel).toBe('(not set)');
|
||||
});
|
||||
|
||||
it('should show seconds for set intervals', () => {
|
||||
const items = buildConfigureStatusLineItems(10, true, 5);
|
||||
expect(items[0]?.sublabel).toBe('(10s)');
|
||||
});
|
||||
|
||||
it('should show seconds for small values', () => {
|
||||
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, 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, 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', () => {
|
||||
it('keeps an unset interval empty when reopening the editor', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(RefreshIntervalMenu, {
|
||||
currentInterval: null,
|
||||
supportsRefreshInterval: true,
|
||||
gitCacheTtlSeconds: 5,
|
||||
onUpdate,
|
||||
onGitCacheTtlUpdate: vi.fn(),
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('Enter refresh interval in seconds (1-60):');
|
||||
expect(stdout.getOutput()).not.toContain('10s');
|
||||
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(null);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
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 {
|
||||
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', () => {
|
||||
const line = `${renderOsc8Link(
|
||||
'https://github.com/owner/repo/pull/42',
|
||||
'PR #42'
|
||||
)} OPEN ${'Example PR title '.repeat(8)}`;
|
||||
|
||||
const prepared = preparePreviewLineForTerminal(line, 40);
|
||||
|
||||
expect(prepared).not.toContain('github.com');
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -41,4 +41,4 @@ describe('TerminalOptionsMenu helpers', () => {
|
||||
});
|
||||
expect(items[1]?.description).toContain('Truecolor');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,4 +166,4 @@ describe('TerminalWidthMenu helpers', () => {
|
||||
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);
|
||||
@@ -133,4 +152,4 @@ describe('color-menu mutations', () => {
|
||||
expect(defaultCycle[0]?.color).toBe('red');
|
||||
expect(dimCycle[0]?.color).toBe('red');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -145,4 +172,4 @@ export function cycleWidgetColor({
|
||||
color: nextColor
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +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 './TerminalWidthMenu';
|
||||
export * from './UpdateCheckerMenu';
|
||||
|
||||
@@ -178,6 +178,176 @@ describe('items-editor input handlers', () => {
|
||||
expect(pickerState.get()?.level).toBe('category');
|
||||
});
|
||||
|
||||
it('wraps to last category when pressing up at first category', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'tokens-input', displayName: 'Tokens Input', category: 'Tokens' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git', 'Tokens'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: null
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { upArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedCategory).toBe('Tokens');
|
||||
});
|
||||
|
||||
it('wraps to first category when pressing down at last category', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'tokens-input', displayName: 'Tokens Input', category: 'Tokens' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git', 'Tokens'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'Tokens',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: null
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { downArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedCategory).toBe('All');
|
||||
});
|
||||
|
||||
it('wraps to last widget when pressing up at first widget in picker', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' },
|
||||
{ type: 'git-insertions', displayName: 'Git Insertions', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'widget',
|
||||
selectedCategory: 'Git',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { upArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('git-insertions');
|
||||
});
|
||||
|
||||
it('wraps to first widget when pressing down at last widget in picker', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' },
|
||||
{ type: 'git-insertions', displayName: 'Git Insertions', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'widget',
|
||||
selectedCategory: 'Git',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-insertions'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { downArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('git-branch');
|
||||
});
|
||||
|
||||
it('wraps to last search result when pressing up at first in top-level search', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: 'git',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { upArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('git-changes');
|
||||
});
|
||||
|
||||
it('wraps to first search result when pressing down at last in top-level search', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: 'git',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-changes'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { downArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('git-branch');
|
||||
});
|
||||
|
||||
it('moves selected widget up in move mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
@@ -204,6 +374,112 @@ describe('items-editor input handlers', () => {
|
||||
expect(setMoveMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps to last widget when pressing up at first position in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '3', type: 'git-branch' }
|
||||
];
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: '',
|
||||
key: { upArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('wraps to first widget when pressing down at last position in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '3', type: 'git-branch' }
|
||||
];
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: '',
|
||||
key: { downArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 2,
|
||||
separatorChars: ['|'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('wraps to last position when moving widget up from first position', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '3', type: 'git-branch' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleMoveInputMode({
|
||||
key: { upArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn()
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: '3', type: 'git-branch' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
]);
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('wraps to first position when moving widget down from last position', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '3', type: 'git-branch' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleMoveInputMode({
|
||||
key: { downArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 2,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn()
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: '3', type: 'git-branch' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
]);
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('toggles raw value in normal mode for supported widgets', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
@@ -256,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' }
|
||||
@@ -282,6 +611,120 @@ describe('items-editor input handlers', () => {
|
||||
expect(updated?.[0]?.metadata?.display).toBe('progress');
|
||||
});
|
||||
|
||||
it('uses t to toggle reset timer date mode in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'reset-timer' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 't',
|
||||
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?.absolute).toBe('true');
|
||||
});
|
||||
|
||||
it('uses h to toggle reset timer hour format in timestamp mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'h',
|
||||
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?.hour12).toBe('true');
|
||||
});
|
||||
|
||||
it('opens custom editor for reset timer timezone action', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setCustomEditorWidget = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'z',
|
||||
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
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
|
||||
| { action?: string; widget?: WidgetItem }
|
||||
| undefined;
|
||||
expect(customEditorState?.action).toBe('edit-timezone');
|
||||
expect(customEditorState?.widget?.type).toBe('reset-timer');
|
||||
});
|
||||
|
||||
it('opens custom editor for reset timer locale action', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setCustomEditorWidget = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'l',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
|
||||
| { action?: string; widget?: WidgetItem }
|
||||
| undefined;
|
||||
expect(customEditorState?.action).toBe('edit-locale');
|
||||
expect(customEditorState?.widget?.type).toBe('reset-timer');
|
||||
});
|
||||
|
||||
it('uses v to cycle skills widget mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'skills' }
|
||||
@@ -308,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,4 +807,200 @@ describe('items-editor input handlers', () => {
|
||||
expect(customEditorState?.action).toBe('edit-list-limit');
|
||||
expect(customEditorState?.widget?.type).toBe('skills');
|
||||
});
|
||||
});
|
||||
|
||||
describe('k shortcut - clone widget', () => {
|
||||
it('inserts clone after source and moves selection to clone', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: 'a', type: 'tokens-input' },
|
||||
{ id: 'b', type: 'tokens-output' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
expect(updated).toHaveLength(3);
|
||||
expect(updated[0]?.id).toBe('a');
|
||||
expect(updated[1]?.type).toBe('tokens-input');
|
||||
expect(updated[2]?.id).toBe('b');
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('copies all primitive properties of source to clone', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: 'src', type: 'tokens-input', color: 'green', bold: true, rawValue: true, backgroundColor: 'blue' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
const clone = updated[1];
|
||||
expect(clone?.color).toBe('green');
|
||||
expect(clone?.bold).toBe(true);
|
||||
expect(clone?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('generates a different id for the clone', () => {
|
||||
const widgets: WidgetItem[] = [{ id: 'src', type: 'tokens-input' }];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
expect(updated[1]?.id).not.toBe('src');
|
||||
expect(typeof updated[1]?.id).toBe('string');
|
||||
expect(updated[1]?.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shallow-clones metadata so mutating clone does not affect source', () => {
|
||||
const sourceMeta = { display: 'progress' };
|
||||
const widgets: WidgetItem[] = [{ id: 'src', type: 'session-usage', metadata: sourceMeta }];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
const cloneMeta = updated[1]?.metadata as Record<string, unknown> | undefined;
|
||||
expect(cloneMeta).toBeDefined();
|
||||
expect(cloneMeta).not.toBe(sourceMeta);
|
||||
expect(cloneMeta?.display).toBe('progress');
|
||||
|
||||
if (cloneMeta) {
|
||||
cloneMeta.display = 'changed';
|
||||
}
|
||||
expect(sourceMeta.display).toBe('progress');
|
||||
});
|
||||
|
||||
it('uses getUniqueBackgroundColor result as backgroundColor in powerline mode', () => {
|
||||
const widgets: WidgetItem[] = [{ id: 'src', type: 'tokens-input', backgroundColor: 'red' }];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn(),
|
||||
getUniqueBackgroundColor: () => 'cyan'
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
expect(updated[1]?.backgroundColor).toBe('cyan');
|
||||
});
|
||||
|
||||
it('preserves source backgroundColor when getUniqueBackgroundColor returns undefined', () => {
|
||||
const widgets: WidgetItem[] = [{ id: 'src', type: 'tokens-input', backgroundColor: 'red' }];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn(),
|
||||
getUniqueBackgroundColor: () => undefined
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
expect(updated[1]?.backgroundColor).toBe('red');
|
||||
});
|
||||
|
||||
it('does nothing when widget list is empty', () => {
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets: [],
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
expect(setSelectedIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
WidgetItem,
|
||||
WidgetItemType
|
||||
} from '../../../types/Widget';
|
||||
import { generateGuid } from '../../../utils/guid';
|
||||
import {
|
||||
filterWidgetCatalog,
|
||||
getWidget,
|
||||
@@ -190,8 +191,8 @@ export function handlePickerInputMode({
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
? (currentIndex + 1 > topLevelSearchEntries.length - 1 ? 0 : currentIndex + 1)
|
||||
: (currentIndex - 1 < 0 ? topLevelSearchEntries.length - 1 : currentIndex - 1);
|
||||
const nextType = topLevelSearchEntries[nextIndex]?.type ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
@@ -208,8 +209,8 @@ export function handlePickerInputMode({
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(filteredCategories.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
? (currentIndex + 1 > filteredCategories.length - 1 ? 0 : currentIndex + 1)
|
||||
: (currentIndex - 1 < 0 ? filteredCategories.length - 1 : currentIndex - 1);
|
||||
const nextCategory = filteredCategories[nextIndex] ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
@@ -262,8 +263,8 @@ export function handlePickerInputMode({
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(filteredWidgets.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
? (currentIndex + 1 > filteredWidgets.length - 1 ? 0 : currentIndex + 1)
|
||||
: (currentIndex - 1 < 0 ? filteredWidgets.length - 1 : currentIndex - 1);
|
||||
const nextType = filteredWidgets[nextIndex]?.type ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
@@ -307,24 +308,26 @@ export function handleMoveInputMode({
|
||||
setSelectedIndex,
|
||||
setMoveMode
|
||||
}: HandleMoveInputModeArgs): void {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
if (key.upArrow && widgets.length > 1) {
|
||||
const newWidgets = [...widgets];
|
||||
const targetIndex = selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1;
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const prev = newWidgets[selectedIndex - 1];
|
||||
const prev = newWidgets[targetIndex];
|
||||
if (temp && prev) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
|
||||
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [prev, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
|
||||
setSelectedIndex(targetIndex);
|
||||
} else if (key.downArrow && widgets.length > 1) {
|
||||
const newWidgets = [...widgets];
|
||||
const targetIndex = selectedIndex + 1 > widgets.length - 1 ? 0 : selectedIndex + 1;
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const next = newWidgets[selectedIndex + 1];
|
||||
const next = newWidgets[targetIndex];
|
||||
if (temp && next) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
|
||||
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [next, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex + 1);
|
||||
setSelectedIndex(targetIndex);
|
||||
} else if (key.escape || key.return) {
|
||||
setMoveMode(false);
|
||||
}
|
||||
@@ -335,6 +338,7 @@ export interface HandleNormalInputModeArgs {
|
||||
key: InputKey;
|
||||
widgets: WidgetItem[];
|
||||
selectedIndex: number;
|
||||
canExcludeAlign?: boolean;
|
||||
separatorChars: string[];
|
||||
onBack: () => void;
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
@@ -344,6 +348,7 @@ export interface HandleNormalInputModeArgs {
|
||||
openWidgetPicker: (action: WidgetPickerAction) => void;
|
||||
getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
|
||||
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
|
||||
getUniqueBackgroundColor?: (insertIndex: number) => string | undefined;
|
||||
}
|
||||
|
||||
export function handleNormalInputMode({
|
||||
@@ -351,6 +356,7 @@ export function handleNormalInputMode({
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
canExcludeAlign = false,
|
||||
separatorChars,
|
||||
onBack,
|
||||
onUpdate,
|
||||
@@ -359,12 +365,13 @@ export function handleNormalInputMode({
|
||||
setShowClearConfirm,
|
||||
openWidgetPicker,
|
||||
getCustomKeybindsForWidget,
|
||||
setCustomEditorWidget
|
||||
setCustomEditorWidget,
|
||||
getUniqueBackgroundColor
|
||||
}: HandleNormalInputModeArgs): void {
|
||||
if (key.upArrow && widgets.length > 0) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1);
|
||||
} else if (key.downArrow && widgets.length > 0) {
|
||||
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
|
||||
setSelectedIndex(selectedIndex + 1 > widgets.length - 1 ? 0 : selectedIndex + 1);
|
||||
} else if (key.leftArrow && widgets.length > 0) {
|
||||
openWidgetPicker('change');
|
||||
} else if (key.rightArrow && widgets.length > 0) {
|
||||
@@ -381,6 +388,26 @@ export function handleNormalInputMode({
|
||||
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
}
|
||||
} else if (input === 'k' && widgets.length > 0) {
|
||||
const source = widgets[selectedIndex];
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
const insertIndex = selectedIndex + 1;
|
||||
const newBg = getUniqueBackgroundColor?.(insertIndex);
|
||||
const clone: WidgetItem = {
|
||||
...source,
|
||||
id: generateGuid(),
|
||||
...(source.metadata && { metadata: { ...source.metadata } }),
|
||||
...(newBg && { backgroundColor: newBg })
|
||||
};
|
||||
const newWidgets = [
|
||||
...widgets.slice(0, insertIndex),
|
||||
clone,
|
||||
...widgets.slice(insertIndex)
|
||||
];
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(insertIndex);
|
||||
} else if (input === 'c') {
|
||||
if (widgets.length > 0) {
|
||||
setShowClearConfirm(true);
|
||||
@@ -430,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) {
|
||||
@@ -459,4 +499,4 @@ export function handleNormalInputMode({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export { runTUI } from './App';
|
||||
export { runTUI } from './App';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface BlockMetrics {
|
||||
startTime: Date;
|
||||
lastActivity: Date;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { TranscriptThinkingEffort } from '../utils/jsonl-metadata';
|
||||
|
||||
export interface ClaudeSettings {
|
||||
effortLevel?: 'low' | 'medium' | 'high' | 'max';
|
||||
effortLevel?: TranscriptThinkingEffort;
|
||||
permissions?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
@@ -8,6 +10,7 @@ export interface ClaudeSettings {
|
||||
type: string;
|
||||
command: string;
|
||||
padding?: number;
|
||||
refreshInterval?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ export interface ColorEntry {
|
||||
ansi16: ChalkInstance;
|
||||
ansi256: ChalkInstance;
|
||||
truecolor: ChalkInstance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,4 @@ export function getColorLevelString(level: ColorLevel | undefined): ColorLevelSt
|
||||
default:
|
||||
return 'ansi256';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ import { z } from 'zod';
|
||||
export const FlexModeSchema = z.enum(['full', 'full-minus-40', 'full-until-compact']);
|
||||
|
||||
// Inferred type from schema
|
||||
export type FlexMode = z.infer<typeof FlexModeSchema>;
|
||||
export type FlexMode = z.infer<typeof FlexModeSchema>;
|
||||
|
||||
@@ -13,4 +13,4 @@ export const PowerlineConfigSchema = z.object({
|
||||
});
|
||||
|
||||
// Inferred type from schema
|
||||
export type PowerlineConfig = z.infer<typeof PowerlineConfigSchema>;
|
||||
export type PowerlineConfig = z.infer<typeof PowerlineConfigSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface PowerlineFontStatus {
|
||||
installed: boolean;
|
||||
checkedSymbol?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,24 @@ 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;
|
||||
byTrigger: { auto: number; manual: number; unknown: number };
|
||||
tokensReclaimed: number;
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
data?: StatusJSON;
|
||||
tokenMetrics?: TokenMetrics | null;
|
||||
@@ -28,9 +39,12 @@ export interface RenderContext {
|
||||
sessionDuration?: string | null;
|
||||
blockMetrics?: BlockMetrics | null;
|
||||
skillsMetrics?: SkillsMetrics | null;
|
||||
compactionData?: CompactionData | 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
|
||||
|
||||
@@ -41,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
|
||||
}
|
||||
|
||||
+32
-2
@@ -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({});
|
||||
export const DEFAULT_SETTINGS: Settings = SettingsSchema.parse({});
|
||||
|
||||
@@ -9,4 +9,4 @@ export interface SkillsMetrics {
|
||||
totalInvocations: number;
|
||||
uniqueSkills: string[];
|
||||
lastSkill: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ export interface SpeedMetrics {
|
||||
|
||||
/** Number of assistant usage entries included in speed aggregation */
|
||||
requestCount: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const StatusJSONSchema = z.looseObject({
|
||||
}).optional(),
|
||||
version: z.string().optional(),
|
||||
output_style: z.object({ name: z.string().optional() }).optional(),
|
||||
effort: z.object({ level: z.string().nullable().optional() }).nullable().optional(),
|
||||
cost: z.object({
|
||||
total_cost_usd: CoercedNumberSchema.optional(),
|
||||
total_duration_ms: CoercedNumberSchema.optional(),
|
||||
@@ -70,8 +71,10 @@ 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()
|
||||
});
|
||||
|
||||
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
|
||||
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface TokenUsage {
|
||||
}
|
||||
|
||||
export interface TranscriptLine {
|
||||
message?: { usage?: TokenUsage };
|
||||
message?: { usage?: TokenUsage; stop_reason?: string | null };
|
||||
isSidechain?: boolean;
|
||||
timestamp?: string;
|
||||
isApiErrorMessage?: boolean;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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()
|
||||
});
|
||||
|
||||
@@ -58,4 +60,4 @@ export interface CustomKeybind {
|
||||
key: string;
|
||||
label: string;
|
||||
action: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,4 +111,4 @@ describe('StatusJSONSchema numeric coercion', () => {
|
||||
expect(result.data.rate_limits?.five_hour?.used_percentage).toBe(42);
|
||||
expect(result.data.rate_limits?.five_hour?.resets_at).toBe(1774020000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -19,4 +19,4 @@ export type { ClaudeSettings } from './ClaudeSettings';
|
||||
export type { ColorEntry } from './ColorEntry';
|
||||
export type { BlockMetrics } from './BlockMetrics';
|
||||
export type { SpeedMetrics } from './SpeedMetrics';
|
||||
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
|
||||
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
|
||||
|
||||
@@ -288,4 +288,4 @@ describe('getCachedBlockMetrics integration', () => {
|
||||
// Should return null because cache is fresh but scoped to a different profile
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as childProcess from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -14,16 +15,25 @@ import {
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import {
|
||||
CCSTATUSLINE_COMMANDS,
|
||||
buildStatusLineCommand,
|
||||
classifyInstallation,
|
||||
getClaudeCodeVersion,
|
||||
getClaudeJsonPath,
|
||||
getClaudeSettingsPath,
|
||||
getExistingStatusLine,
|
||||
getRefreshInterval,
|
||||
getSandboxConfig,
|
||||
getVoiceConfig,
|
||||
installStatusLine,
|
||||
isClaudeCodeVersionAtLeast,
|
||||
isInstalled,
|
||||
isKnownCommand,
|
||||
loadClaudeSettings,
|
||||
saveClaudeSettings,
|
||||
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 = '';
|
||||
@@ -35,6 +45,13 @@ function readInstalledCommand(): string {
|
||||
return data.statusLine?.command ?? '';
|
||||
}
|
||||
|
||||
function readInstalledRefreshInterval(): number | undefined {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const data = JSON.parse(content) as { statusLine?: { refreshInterval?: number } };
|
||||
return data.statusLine?.refreshInterval;
|
||||
}
|
||||
|
||||
function writeRawClaudeSettings(content: string): void {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
@@ -44,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 });
|
||||
}
|
||||
@@ -106,55 +124,158 @@ describe('isKnownCommand', () => {
|
||||
it('should not match prefix that is a substring', () => {
|
||||
expect(isKnownCommand('npx -y ccstatusline@latestFOO')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match command containing ccstatusline.ts', () => {
|
||||
expect(isKnownCommand('bun run /home/user/ccstatusline/src/ccstatusline.ts')).toBe(true);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('should resolve .claude.json inside CLAUDE_CONFIG_DIR when configured', () => {
|
||||
expect(getClaudeJsonPath()).toBe(path.join(testClaudeConfigDir, '.claude.json'));
|
||||
});
|
||||
|
||||
it('should resolve .claude.json beside the default Claude config dir when CLAUDE_CONFIG_DIR is unset', () => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
expect(getClaudeJsonPath()).toBe(path.join(os.homedir(), '.claude.json'));
|
||||
});
|
||||
|
||||
it('should use default .claude.json path when CLAUDE_CONFIG_DIR points to a file', () => {
|
||||
const invalidConfigDir = path.join(testClaudeConfigDir, 'not-a-dir');
|
||||
fs.writeFileSync(invalidConfigDir, 'not a directory', 'utf-8');
|
||||
process.env.CLAUDE_CONFIG_DIR = invalidConfigDir;
|
||||
|
||||
expect(getClaudeJsonPath()).toBe(path.join(os.homedir(), '.claude.json'));
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -174,6 +295,131 @@ 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 () => {
|
||||
config.initConfigPath();
|
||||
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: true });
|
||||
expect(readInstalledRefreshInterval()).toBe(10);
|
||||
});
|
||||
|
||||
it('should not set refreshInterval when version is unsupported', async () => {
|
||||
config.initConfigPath();
|
||||
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: false });
|
||||
expect(readInstalledRefreshInterval()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve existing refreshInterval on re-install', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0,
|
||||
refreshInterval: 5
|
||||
}
|
||||
}));
|
||||
await installStatusLine({ commandMode: 'auto-npx', supportsRefreshInterval: true });
|
||||
expect(readInstalledRefreshInterval()).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshInterval', () => {
|
||||
it('getRefreshInterval should return null when no settings exist', async () => {
|
||||
await expect(getRefreshInterval()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('getRefreshInterval should return null when statusLine has no refreshInterval', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
await expect(getRefreshInterval()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('getRefreshInterval should return the configured value', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0,
|
||||
refreshInterval: 5
|
||||
}
|
||||
});
|
||||
await expect(getRefreshInterval()).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it('setRefreshInterval should set the value on existing statusLine', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
|
||||
await setRefreshInterval(15);
|
||||
|
||||
const settings = await loadClaudeSettings();
|
||||
expect(settings.statusLine?.refreshInterval).toBe(15);
|
||||
});
|
||||
|
||||
it('setRefreshInterval with null should remove refreshInterval', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0,
|
||||
refreshInterval: 10
|
||||
}
|
||||
});
|
||||
|
||||
await setRefreshInterval(null);
|
||||
|
||||
const settings = await loadClaudeSettings();
|
||||
expect(settings.statusLine?.refreshInterval).toBeUndefined();
|
||||
});
|
||||
|
||||
it('setRefreshInterval should do nothing when no statusLine exists', async () => {
|
||||
await saveClaudeSettings({});
|
||||
|
||||
await setRefreshInterval(10);
|
||||
|
||||
const settings = await loadClaudeSettings();
|
||||
expect(settings.statusLine).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup and error handling behavior', () => {
|
||||
@@ -212,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);
|
||||
@@ -254,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');
|
||||
@@ -355,4 +601,325 @@ describe('backup and error handling behavior', () => {
|
||||
|
||||
await expect(isInstalled()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('isInstalled should accept quoted local development commands when padding is undefined', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'bun run "/Users/Jane Doe/ccstatusline/src/ccstatusline.ts"'
|
||||
}
|
||||
});
|
||||
|
||||
await expect(isInstalled()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClaudeCodeVersion', () => {
|
||||
it('should parse version from claude --version output', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.97 (Claude Code)\n');
|
||||
expect(getClaudeCodeVersion()).toBe('2.1.97');
|
||||
});
|
||||
|
||||
it('should parse version without suffix text', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('3.0.0\n');
|
||||
expect(getClaudeCodeVersion()).toBe('3.0.0');
|
||||
});
|
||||
|
||||
it('should return null when claude is not installed', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockImplementation(() => { throw new Error('not found'); });
|
||||
expect(getClaudeCodeVersion()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unexpected output', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('unknown output');
|
||||
expect(getClaudeCodeVersion()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isClaudeCodeVersionAtLeast', () => {
|
||||
it('should return true when version equals minimum', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.97 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when patch is higher', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.100 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when minor is higher', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.2.0 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when major is higher', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('3.0.0 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when version is lower', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.96 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when minor is lower', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.0.100 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when claude is not installed', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockImplementation(() => { throw new Error('not found'); });
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,4 +37,4 @@ describe('cloneSettings', () => {
|
||||
expect(originalMetadata.key).toBe('value');
|
||||
expect(clonedMetadata.key).toBe('changed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
ZERO_COMPACTION_STATS,
|
||||
computeCompactionStats,
|
||||
getCompactionStats
|
||||
} from '../compaction';
|
||||
|
||||
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('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('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('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('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('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('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('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('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('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('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('getCompactionStats', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'compaction-stats-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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('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('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);
|
||||
});
|
||||
|
||||
@@ -45,4 +45,4 @@ describe('initConfigPath / getConfigPath', () => {
|
||||
expect(getConfigPath()).toBe(DEFAULT_PATH);
|
||||
expect(isCustomConfigPath()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -171,4 +256,159 @@ describe('config utilities', () => {
|
||||
expect(saved.version).toBe(CURRENT_VERSION);
|
||||
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 });
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
version: CURRENT_VERSION,
|
||||
lines: [
|
||||
[
|
||||
{ id: 'widget-1', type: 'model' },
|
||||
{ id: 'widget-2', type: 'git-pr' }
|
||||
],
|
||||
[],
|
||||
[]
|
||||
]
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
// In-memory rewrite: legacy string is gone.
|
||||
const types = settings.lines[0]?.map(item => item.type);
|
||||
expect(types).toEqual(['model', 'git-review']);
|
||||
|
||||
// Load does not eagerly persist; the rewrite lands on next save.
|
||||
const onDiskBeforeSave = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { lines: { type: string }[][] };
|
||||
expect(onDiskBeforeSave.lines[0]?.[1]?.type).toBe('git-pr');
|
||||
|
||||
await saveSettings(settings);
|
||||
|
||||
const onDiskAfterSave = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { lines: { type: string }[][] };
|
||||
expect(onDiskAfterSave.lines[0]?.[1]?.type).toBe('git-review');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types';
|
||||
import { calculateContextPercentage } from '../context-percentage';
|
||||
import {
|
||||
calculateContextPercentage,
|
||||
calculateContextPercentageMetrics
|
||||
} from '../context-percentage';
|
||||
|
||||
describe('calculateContextPercentage', () => {
|
||||
describe('Status JSON context_window', () => {
|
||||
@@ -31,6 +34,20 @@ describe('calculateContextPercentage', () => {
|
||||
expect(percentage).toBe(12.5);
|
||||
});
|
||||
|
||||
it('should infer the window size for raw percentage metrics when size is missing', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-sonnet-4-5-20250929[1m]' },
|
||||
context_window: { used_percentage: 4.2 }
|
||||
}
|
||||
};
|
||||
|
||||
expect(calculateContextPercentageMetrics(context)).toEqual({
|
||||
usedPercentage: 4.2,
|
||||
windowSize: 1000000
|
||||
});
|
||||
});
|
||||
|
||||
it('should derive percentage from current usage and window size when used_percentage is missing', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
@@ -50,6 +67,27 @@ describe('calculateContextPercentage', () => {
|
||||
expect(percentage).toBe(20);
|
||||
});
|
||||
|
||||
it('should return derived percentage metrics from current usage when used_percentage is missing', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 20000,
|
||||
output_tokens: 10000,
|
||||
cache_creation_input_tokens: 5000,
|
||||
cache_read_input_tokens: 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(calculateContextPercentageMetrics(context)).toEqual({
|
||||
usedPercentage: 20,
|
||||
windowSize: 200000
|
||||
});
|
||||
});
|
||||
|
||||
it('should use context_window_size as denominator when falling back to token metrics', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
@@ -68,6 +106,27 @@ describe('calculateContextPercentage', () => {
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
|
||||
it('should return token-metric fallback metrics with the denominator used', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-3-5-sonnet-20241022' },
|
||||
context_window: { context_window_size: 1000000 }
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
expect(calculateContextPercentageMetrics(context)).toEqual({
|
||||
usedPercentage: 4.2,
|
||||
windowSize: 1000000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sonnet 4.5 with 1M context window', () => {
|
||||
@@ -179,6 +238,7 @@ describe('calculateContextPercentage', () => {
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(0);
|
||||
expect(calculateContextPercentageMetrics(context)).toBeNull();
|
||||
});
|
||||
|
||||
it('should use default 200k context when model ID is undefined', () => {
|
||||
@@ -196,4 +256,4 @@ describe('calculateContextPercentage', () => {
|
||||
expect(percentage).toBe(21.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,4 +72,4 @@ describe('getContextWindowMetrics', () => {
|
||||
totalTokens: 6000
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
fetchPrData,
|
||||
type PrCacheDeps
|
||||
} from '../gh-pr-cache';
|
||||
|
||||
interface FakeCacheFile {
|
||||
content: string;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
interface PrCacheHarness {
|
||||
cacheFiles: Map<string, FakeCacheFile>;
|
||||
deps: PrCacheDeps;
|
||||
execCalls: { args: string[]; cmd: string; cwd?: string }[];
|
||||
ghResponses: (Error | string)[];
|
||||
setCurrentRef: (ref: string) => void;
|
||||
}
|
||||
|
||||
function createHarness(): PrCacheHarness {
|
||||
const cacheFiles = new Map<string, FakeCacheFile>();
|
||||
const execCalls: { args: string[]; cmd: string; cwd?: string }[] = [];
|
||||
const ghResponses: (Error | string)[] = [];
|
||||
const now = 1_700_000_000_000;
|
||||
let currentRef = 'feature/cache-a';
|
||||
|
||||
const deps: PrCacheDeps = {
|
||||
execFileSync: ((cmd, args, options) => {
|
||||
const commandArgs = Array.isArray(args)
|
||||
? args.map(arg => String(arg))
|
||||
: [];
|
||||
execCalls.push({
|
||||
args: commandArgs,
|
||||
cmd,
|
||||
cwd: typeof options === 'object' && 'cwd' in options
|
||||
? String(options.cwd)
|
||||
: undefined
|
||||
});
|
||||
|
||||
if (cmd === 'git' && commandArgs[0] === 'branch')
|
||||
return `${currentRef}\n`;
|
||||
if (cmd === 'git' && commandArgs[0] === 'rev-parse')
|
||||
return 'abc123\n';
|
||||
if (cmd === 'gh' && commandArgs[0] === '--version')
|
||||
return 'gh version 2.0.0\n';
|
||||
if (cmd === 'gh' && commandArgs[0] === 'pr') {
|
||||
const response = ghResponses.shift();
|
||||
if (response instanceof Error)
|
||||
throw response;
|
||||
return response ?? '';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command: ${cmd} ${commandArgs.join(' ')}`);
|
||||
}) as PrCacheDeps['execFileSync'],
|
||||
existsSync: (filePath => cacheFiles.has(String(filePath))) as PrCacheDeps['existsSync'],
|
||||
getHomedir: () => '/tmp/home',
|
||||
mkdirSync: (() => undefined) as PrCacheDeps['mkdirSync'],
|
||||
now: () => now,
|
||||
readFileSync: (filePath => cacheFiles.get(String(filePath))?.content ?? '') as PrCacheDeps['readFileSync'],
|
||||
statSync: (filePath => ({ mtimeMs: cacheFiles.get(String(filePath))?.mtimeMs ?? now })) as PrCacheDeps['statSync'],
|
||||
writeFileSync: ((filePath, content) => {
|
||||
const normalizedContent = typeof content === 'string'
|
||||
? content
|
||||
: Buffer.isBuffer(content)
|
||||
? content.toString('utf8')
|
||||
: '';
|
||||
cacheFiles.set(String(filePath), {
|
||||
content: normalizedContent,
|
||||
mtimeMs: now
|
||||
});
|
||||
}) as PrCacheDeps['writeFileSync']
|
||||
};
|
||||
|
||||
return {
|
||||
cacheFiles,
|
||||
deps,
|
||||
execCalls,
|
||||
ghResponses,
|
||||
setCurrentRef: (ref: string) => {
|
||||
currentRef = ref;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('gh-pr-cache', () => {
|
||||
it('negative-caches failed gh PR lookups', () => {
|
||||
const harness = createHarness();
|
||||
harness.ghResponses.push(new Error('no pull request found'));
|
||||
|
||||
expect(fetchPrData('/tmp/repo', harness.deps)).toBeNull();
|
||||
|
||||
const ghCallsAfterFirstRender = harness.execCalls.filter(call => call.cmd === 'gh');
|
||||
expect(ghCallsAfterFirstRender).toHaveLength(2);
|
||||
|
||||
const cachedMissEntry = [...harness.cacheFiles.values()].at(0);
|
||||
expect(cachedMissEntry?.content).toBe('');
|
||||
|
||||
expect(fetchPrData('/tmp/repo', harness.deps)).toBeNull();
|
||||
|
||||
const ghCallsAfterSecondRender = harness.execCalls.filter(call => call.cmd === 'gh');
|
||||
expect(ghCallsAfterSecondRender).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses a different cache entry for each checked-out branch', () => {
|
||||
const harness = createHarness();
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 123,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'First PR',
|
||||
url: 'https://github.com/owner/repo/pull/123'
|
||||
}));
|
||||
|
||||
expect(fetchPrData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 123,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'First PR',
|
||||
url: 'https://github.com/owner/repo/pull/123'
|
||||
});
|
||||
|
||||
harness.setCurrentRef('feature/cache-b');
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 456,
|
||||
reviewDecision: 'APPROVED',
|
||||
state: 'OPEN',
|
||||
title: 'Second PR',
|
||||
url: 'https://github.com/owner/repo/pull/456'
|
||||
}));
|
||||
|
||||
expect(fetchPrData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 456,
|
||||
reviewDecision: 'APPROVED',
|
||||
state: 'OPEN',
|
||||
title: 'Second PR',
|
||||
url: 'https://github.com/owner/repo/pull/456'
|
||||
});
|
||||
|
||||
const writtenCachePaths = [...harness.cacheFiles.keys()];
|
||||
expect(writtenCachePaths.length).toBe(2);
|
||||
expect(writtenCachePaths[0]).not.toBe(writtenCachePaths[1]);
|
||||
expect(writtenCachePaths[0]).toContain('/.cache/ccstatusline/pr/pr-');
|
||||
expect(writtenCachePaths[1]).toContain('/.cache/ccstatusline/pr/pr-');
|
||||
|
||||
harness.setCurrentRef('feature/cache-a');
|
||||
expect(fetchPrData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 123,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'First PR',
|
||||
url: 'https://github.com/owner/repo/pull/123'
|
||||
});
|
||||
expect(harness.cacheFiles.size).toBe(2);
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -18,9 +18,15 @@ import {
|
||||
parseRemoteUrl
|
||||
} from '../git-remote';
|
||||
|
||||
vi.mock('child_process', () => ({ execSync: vi.fn() }));
|
||||
import { expectGitExecOptions } from './git-test-helpers';
|
||||
|
||||
const mockExecSync = execSync as unknown as {
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
spawnSync: vi.fn()
|
||||
}));
|
||||
|
||||
const mockExecFileSync = execFileSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockImplementation: (impl: () => never) => void;
|
||||
mockImplementationOnce: (impl: () => never) => void;
|
||||
@@ -125,6 +131,14 @@ describe('git-remote utils', () => {
|
||||
repo: 'project'
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves non-default HTTPS ports', () => {
|
||||
expect(parseRemoteUrl('https://git.example.com:8443/team/repo.git')).toEqual({
|
||||
host: 'git.example.com:8443',
|
||||
owner: 'team',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh:// protocol format', () => {
|
||||
@@ -143,6 +157,14 @@ describe('git-remote utils', () => {
|
||||
repo: 'project'
|
||||
});
|
||||
});
|
||||
|
||||
it('omits ssh:// transport ports from the parsed host', () => {
|
||||
expect(parseRemoteUrl('ssh://git@git.example.com:2222/team/repo.git')).toEqual({
|
||||
host: 'git.example.com',
|
||||
owner: 'team',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('git:// protocol format', () => {
|
||||
@@ -188,7 +210,7 @@ describe('git-remote utils', () => {
|
||||
|
||||
describe('getRemoteInfo', () => {
|
||||
it('returns remote info for valid remote', () => {
|
||||
mockExecSync.mockReturnValue('https://github.com/hangie/ccstatusline.git\n');
|
||||
mockExecFileSync.mockReturnValue('https://github.com/hangie/ccstatusline.git\n');
|
||||
const context: RenderContext = { data: { cwd: '/tmp/repo' } };
|
||||
|
||||
const result = getRemoteInfo('origin', context);
|
||||
@@ -202,14 +224,25 @@ describe('git-remote utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes remote name as a literal git argument', () => {
|
||||
mockExecFileSync.mockReturnValue('https://github.com/hangie/ccstatusline.git\n');
|
||||
const remoteName = 'foo$(touch /tmp/pwn)';
|
||||
|
||||
getRemoteInfo(remoteName, {});
|
||||
|
||||
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', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('No such remote'); });
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('No such remote'); });
|
||||
|
||||
expect(getRemoteInfo('nonexistent', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when URL cannot be parsed', () => {
|
||||
mockExecSync.mockReturnValue('invalid-url\n');
|
||||
mockExecFileSync.mockReturnValue('invalid-url\n');
|
||||
|
||||
expect(getRemoteInfo('origin', {})).toBeNull();
|
||||
});
|
||||
@@ -217,7 +250,7 @@ describe('git-remote utils', () => {
|
||||
|
||||
describe('getUpstreamRemoteInfo', () => {
|
||||
it('prefers a literal upstream remote when present', () => {
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/upstream-owner/repo.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/upstream-owner/repo.git\n');
|
||||
|
||||
expect(getUpstreamRemoteInfo({})).toEqual({
|
||||
name: 'upstream',
|
||||
@@ -229,10 +262,10 @@ describe('git-remote utils', () => {
|
||||
});
|
||||
|
||||
it('falls back to the tracking remote when upstream is not a remote name', () => {
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
|
||||
mockExecSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
|
||||
mockExecSync.mockReturnValueOnce('origin\nhangie\n');
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n');
|
||||
mockExecFileSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
|
||||
mockExecFileSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
|
||||
mockExecFileSync.mockReturnValueOnce('origin\nhangie\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n');
|
||||
|
||||
expect(getUpstreamRemoteInfo({})).toEqual({
|
||||
name: 'hangie',
|
||||
@@ -244,10 +277,10 @@ describe('git-remote utils', () => {
|
||||
});
|
||||
|
||||
it('matches the longest remote prefix when remote names contain slashes', () => {
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
|
||||
mockExecSync.mockReturnValueOnce('team/upstream/feature/worktree\n');
|
||||
mockExecSync.mockReturnValueOnce('origin\nteam\nteam/upstream\n');
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/team/upstream-repo.git\n');
|
||||
mockExecFileSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
|
||||
mockExecFileSync.mockReturnValueOnce('team/upstream/feature/worktree\n');
|
||||
mockExecFileSync.mockReturnValueOnce('origin\nteam\nteam/upstream\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/team/upstream-repo.git\n');
|
||||
|
||||
expect(getUpstreamRemoteInfo({})).toEqual({
|
||||
name: 'team/upstream',
|
||||
@@ -259,9 +292,9 @@ describe('git-remote utils', () => {
|
||||
});
|
||||
|
||||
it('returns null when the tracking remote cannot be resolved', () => {
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
|
||||
mockExecSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
|
||||
mockExecSync.mockReturnValueOnce('origin\n');
|
||||
mockExecFileSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
|
||||
mockExecFileSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
|
||||
mockExecFileSync.mockReturnValueOnce('origin\n');
|
||||
|
||||
expect(getUpstreamRemoteInfo({})).toBeNull();
|
||||
});
|
||||
@@ -269,8 +302,8 @@ describe('git-remote utils', () => {
|
||||
|
||||
describe('getForkStatus', () => {
|
||||
it('detects fork when origin and upstream differ', () => {
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n');
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/sirmalloc/ccstatusline.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/sirmalloc/ccstatusline.git\n');
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
@@ -280,8 +313,8 @@ describe('git-remote utils', () => {
|
||||
});
|
||||
|
||||
it('detects fork when repos have different names', () => {
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/hangie/my-fork.git\n');
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/hangie/original.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/my-fork.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/original.git\n');
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
@@ -289,8 +322,8 @@ describe('git-remote utils', () => {
|
||||
});
|
||||
|
||||
it('returns not a fork when only origin exists', () => {
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
|
||||
mockExecSync.mockImplementation(() => { throw new Error('No such remote'); });
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('No such remote'); });
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
@@ -300,8 +333,8 @@ describe('git-remote utils', () => {
|
||||
});
|
||||
|
||||
it('returns not a fork when origin equals upstream', () => {
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
|
||||
mockExecSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
@@ -309,7 +342,7 @@ describe('git-remote utils', () => {
|
||||
});
|
||||
|
||||
it('returns not a fork when no remotes exist', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('No such remote'); });
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('No such remote'); });
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
@@ -321,19 +354,19 @@ describe('git-remote utils', () => {
|
||||
|
||||
describe('listRemotes', () => {
|
||||
it('returns list of remote names', () => {
|
||||
mockExecSync.mockReturnValue('origin\nupstream\n');
|
||||
mockExecFileSync.mockReturnValue('origin\nupstream\n');
|
||||
|
||||
expect(listRemotes({})).toEqual(['origin', 'upstream']);
|
||||
});
|
||||
|
||||
it('returns empty array when no remotes', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('Not a git repo'); });
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('Not a git repo'); });
|
||||
|
||||
expect(listRemotes({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters empty lines', () => {
|
||||
mockExecSync.mockReturnValue('origin\n\nupstream\n\n');
|
||||
mockExecFileSync.mockReturnValue('origin\n\nupstream\n\n');
|
||||
|
||||
expect(listRemotes({})).toEqual(['origin', 'upstream']);
|
||||
});
|
||||
@@ -376,4 +409,4 @@ describe('git-remote utils', () => {
|
||||
expect(buildRepoWebUrl(remote)).toBe('https://gitlab.com/group/subgroup/project');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,901 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
fetchGitReviewData,
|
||||
getCachedGitReviewData,
|
||||
refreshGitReviewCacheFromCli,
|
||||
type GitReviewCacheDeps
|
||||
} from '../git-review-cache';
|
||||
|
||||
interface FakeCacheFile {
|
||||
content: string;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
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 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;
|
||||
const authedHosts: Record<'gh' | 'glab', Set<string>> = {
|
||||
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))
|
||||
: [];
|
||||
execCalls.push({
|
||||
args: commandArgs,
|
||||
cmd,
|
||||
cwd: typeof options === 'object' && 'cwd' in options
|
||||
? String(options.cwd)
|
||||
: undefined
|
||||
});
|
||||
|
||||
if (cmd === 'git' && commandArgs[0] === 'remote') {
|
||||
if (originRemoteUrl === null) {
|
||||
throw new Error('no origin configured');
|
||||
}
|
||||
return `${originRemoteUrl}\n`;
|
||||
}
|
||||
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') {
|
||||
const hostIdx = commandArgs.indexOf('--hostname');
|
||||
const host = hostIdx >= 0 ? commandArgs[hostIdx + 1] : undefined;
|
||||
if (!host || !authedHosts.gh.has(host))
|
||||
throw new Error(`gh not authed for ${host ?? '<unspecified>'}`);
|
||||
return '';
|
||||
}
|
||||
if (cmd === 'gh' && commandArgs[0] === 'pr') {
|
||||
now += ghDurations.shift() ?? 0;
|
||||
const response = ghResponses.shift();
|
||||
if (response instanceof Error)
|
||||
throw response;
|
||||
return response ?? '';
|
||||
}
|
||||
if (cmd === 'glab' && commandArgs[0] === '--version') {
|
||||
if (!glabAvailable)
|
||||
throw new Error('glab not installed');
|
||||
return 'glab 1.44.0\n';
|
||||
}
|
||||
if (cmd === 'glab' && commandArgs[0] === 'auth' && commandArgs[1] === 'status') {
|
||||
if (!glabAvailable)
|
||||
throw new Error('glab not installed');
|
||||
const hostIdx = commandArgs.indexOf('--hostname');
|
||||
const host = hostIdx >= 0 ? commandArgs[hostIdx + 1] : undefined;
|
||||
if (!host || !authedHosts.glab.has(host))
|
||||
throw new Error(`glab not authed for ${host ?? '<unspecified>'}`);
|
||||
return '';
|
||||
}
|
||||
if (cmd === 'glab' && commandArgs[0] === 'mr') {
|
||||
const response = glabResponses.shift();
|
||||
if (response instanceof Error)
|
||||
throw response;
|
||||
return response ?? '';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command: ${cmd} ${commandArgs.join(' ')}`);
|
||||
}) as GitReviewCacheDeps['execFileSync'],
|
||||
existsSync: filePath => cacheFiles.has(String(filePath)),
|
||||
getExecPath: () => '/usr/bin/node',
|
||||
getHomedir: () => '/tmp/home',
|
||||
mkdirSync: () => undefined,
|
||||
openSync: (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (cacheFiles.has(normalizedPath)) {
|
||||
throw new Error('EEXIST');
|
||||
}
|
||||
cacheFiles.set(normalizedPath, { content: '', mtimeMs: now });
|
||||
return 42;
|
||||
},
|
||||
now: () => now,
|
||||
readFileSync: (filePath => cacheFiles.get(String(filePath))?.content ?? '') as GitReviewCacheDeps['readFileSync'],
|
||||
getScriptPath: () => '/app/ccstatusline.js',
|
||||
spawn: ((command, args) => {
|
||||
spawnCalls.push({
|
||||
args: Array.isArray(args) ? args.map(arg => String(arg)) : [],
|
||||
command
|
||||
});
|
||||
return { unref: () => undefined };
|
||||
}) as GitReviewCacheDeps['spawn'],
|
||||
statSync: (filePath => ({ mtimeMs: cacheFiles.get(String(filePath))?.mtimeMs ?? now })) as GitReviewCacheDeps['statSync'],
|
||||
unlinkSync: (filePath) => {
|
||||
if (!cacheFiles.delete(String(filePath))) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
},
|
||||
writeFileSync: (filePath, content) => {
|
||||
const normalizedContent = typeof content === 'string'
|
||||
? content
|
||||
: Buffer.isBuffer(content)
|
||||
? content.toString('utf8')
|
||||
: '';
|
||||
cacheFiles.set(String(filePath), {
|
||||
content: normalizedContent,
|
||||
mtimeMs: now
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cacheFiles,
|
||||
deps,
|
||||
execCalls,
|
||||
ghDurations,
|
||||
ghResponses,
|
||||
glabResponses,
|
||||
spawnCalls,
|
||||
advanceNow: (milliseconds) => {
|
||||
now += milliseconds;
|
||||
},
|
||||
setCurrentRef: (ref: string) => {
|
||||
currentRef = ref;
|
||||
},
|
||||
setOriginRemoteUrl: (url: string) => {
|
||||
originRemoteUrl = url;
|
||||
},
|
||||
setGlabAvailable: (available: boolean) => {
|
||||
glabAvailable = available;
|
||||
},
|
||||
setCliAuthedForHost: (cli: 'gh' | 'glab', host: string, authed: boolean) => {
|
||||
if (authed) {
|
||||
authedHosts[cli].add(host);
|
||||
} 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(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(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(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', () => {
|
||||
const harness = createHarness();
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 123,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'First PR',
|
||||
url: 'https://github.com/owner/repo/pull/123'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 123,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'First PR',
|
||||
url: 'https://github.com/owner/repo/pull/123'
|
||||
});
|
||||
|
||||
harness.setCurrentRef('feature/cache-b');
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 456,
|
||||
reviewDecision: 'APPROVED',
|
||||
state: 'OPEN',
|
||||
title: 'Second PR',
|
||||
url: 'https://github.com/owner/repo/pull/456'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 456,
|
||||
provider: 'gh',
|
||||
reviewDecision: 'APPROVED',
|
||||
state: 'OPEN',
|
||||
title: 'Second PR',
|
||||
url: 'https://github.com/owner/repo/pull/456'
|
||||
});
|
||||
|
||||
const writtenCachePaths = [...harness.cacheFiles.keys()];
|
||||
expect(writtenCachePaths.length).toBe(2);
|
||||
expect(writtenCachePaths[0]).not.toBe(writtenCachePaths[1]);
|
||||
const normalize = (filePath: string): string => filePath.replace(/\\/g, '/');
|
||||
expect(normalize(writtenCachePaths[0] ?? '')).toContain('/.cache/ccstatusline/git-review/git-review-');
|
||||
expect(normalize(writtenCachePaths[1] ?? '')).toContain('/.cache/ccstatusline/git-review/git-review-');
|
||||
|
||||
harness.setCurrentRef('feature/cache-a');
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 123,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'First PR',
|
||||
url: 'https://github.com/owner/repo/pull/123'
|
||||
});
|
||||
expect(harness.cacheFiles.size).toBe(2);
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fetches merge request data from glab for GitLab remotes', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@gitlab.com:owner/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 77,
|
||||
state: 'opened',
|
||||
title: 'GitLab MR',
|
||||
web_url: 'https://gitlab.com/owner/repo/-/merge_requests/77'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 77,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'GitLab MR',
|
||||
url: 'https://gitlab.com/owner/repo/-/merge_requests/77'
|
||||
});
|
||||
|
||||
const ghCalls = harness.execCalls.filter(call => call.cmd === 'gh');
|
||||
expect(ghCalls).toHaveLength(0);
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('maps glab merged state to MERGED', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://gitlab.example.com/owner/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 12,
|
||||
state: 'merged',
|
||||
title: 'Done',
|
||||
web_url: 'https://gitlab.example.com/owner/repo/-/merge_requests/12'
|
||||
}));
|
||||
|
||||
const data = fetchGitReviewData('/tmp/repo', harness.deps);
|
||||
expect(data?.state).toBe('MERGED');
|
||||
});
|
||||
|
||||
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, { includeChecks: true })).toEqual({
|
||||
checks: {
|
||||
failing: 0,
|
||||
pending: 1,
|
||||
state: 'pending',
|
||||
success: 1
|
||||
},
|
||||
number: 42,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Standard PR',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
});
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
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', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/fork-owner/example-repo.git');
|
||||
harness.ghResponses.push('');
|
||||
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)).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(2);
|
||||
expect(ghPrCalls[0]?.args).not.toContain('--repo');
|
||||
expect(ghPrCalls[1]?.args).toContain('--repo');
|
||||
expect(ghPrCalls[1]?.args).toContain('https://github.com/fork-owner/example-repo');
|
||||
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');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.glabResponses.push('');
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 9,
|
||||
state: 'opened',
|
||||
title: 'Forked MR',
|
||||
web_url: 'https://gitlab.com/fork-owner/example-fork/-/merge_requests/9'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 9,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Forked MR',
|
||||
url: 'https://gitlab.com/fork-owner/example-fork/-/merge_requests/9'
|
||||
});
|
||||
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(2);
|
||||
expect(glabMrCalls[0]?.args).not.toContain('--repo');
|
||||
expect(glabMrCalls[1]?.args).toContain('--repo');
|
||||
expect(glabMrCalls[1]?.args).toContain('https://gitlab.com/fork-owner/example-fork');
|
||||
expect(glabMrCalls[1]?.args).toContain('feature/cache-a');
|
||||
});
|
||||
|
||||
it('uses glab for unknown host when only glab is authed', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.setCliAuthedForHost('glab', 'git.self-hosted.example', true);
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 5,
|
||||
state: 'opened',
|
||||
title: 'Self-hosted MR',
|
||||
web_url: 'https://git.self-hosted.example/team/repo/-/merge_requests/5'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 5,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Self-hosted MR',
|
||||
url: 'https://git.self-hosted.example/team/repo/-/merge_requests/5'
|
||||
});
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(0);
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves non-default ports when probing and pinning self-hosted GitLab repos', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://git.self-hosted.example:8443/team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.setCliAuthedForHost('glab', 'git.self-hosted.example:8443', true);
|
||||
harness.glabResponses.push('');
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 8,
|
||||
state: 'opened',
|
||||
title: 'Port-hosted MR',
|
||||
web_url: 'https://git.self-hosted.example:8443/team/repo/-/merge_requests/8'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 8,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Port-hosted MR',
|
||||
url: 'https://git.self-hosted.example:8443/team/repo/-/merge_requests/8'
|
||||
});
|
||||
|
||||
const glabAuthCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'auth'
|
||||
);
|
||||
expect(glabAuthCalls[0]?.args).toEqual([
|
||||
'auth',
|
||||
'status',
|
||||
'--hostname',
|
||||
'git.self-hosted.example:8443'
|
||||
]);
|
||||
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(2);
|
||||
expect(glabMrCalls[1]?.args).toContain('--repo');
|
||||
expect(glabMrCalls[1]?.args).toContain('https://git.self-hosted.example:8443/team/repo');
|
||||
});
|
||||
|
||||
it('uses gh for unknown host when only gh is authed (no wasted glab mr calls)', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.setCliAuthedForHost('gh', 'git.self-hosted.example', true);
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 7,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Self-hosted GHE PR',
|
||||
url: 'https://git.self-hosted.example/team/repo/pull/7'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 7,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Self-hosted GHE PR',
|
||||
url: 'https://git.self-hosted.example/team/repo/pull/7'
|
||||
});
|
||||
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(0);
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null for unknown host when neither CLI is authed', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
|
||||
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(0);
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(0);
|
||||
const cachedMissEntry = [...harness.cacheFiles.values()].at(0);
|
||||
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', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.setCliAuthedForHost('glab', 'git.self-hosted.example', true);
|
||||
harness.setCliAuthedForHost('gh', 'git.self-hosted.example', true);
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 3,
|
||||
state: 'opened',
|
||||
title: 'Ambiguous MR',
|
||||
web_url: 'https://git.self-hosted.example/team/repo/-/merge_requests/3'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 3,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Ambiguous MR',
|
||||
url: 'https://git.self-hosted.example/team/repo/-/merge_requests/3'
|
||||
});
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(0);
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
+331
-48
@@ -1,5 +1,9 @@
|
||||
import { execSync } from 'child_process';
|
||||
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,
|
||||
@@ -11,27 +15,100 @@ import type { RenderContext } from '../../types/RenderContext';
|
||||
import {
|
||||
clearGitCache,
|
||||
getGitChangeCounts,
|
||||
getGitFileStatusCounts,
|
||||
getGitStatus,
|
||||
isInsideGitWorkTree,
|
||||
resolveGitCwd,
|
||||
runGit
|
||||
} from '../git';
|
||||
|
||||
vi.mock('child_process', () => ({ execSync: vi.fn() }));
|
||||
import { expectGitExecOptions } from './git-test-helpers';
|
||||
|
||||
const mockExecSync = execSync as unknown as {
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
spawnSync: vi.fn()
|
||||
}));
|
||||
|
||||
const mockExecFileSync = execFileSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockImplementation: (impl: () => never) => void;
|
||||
mockReturnValue: (value: string) => void;
|
||||
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 = {
|
||||
@@ -87,54 +164,160 @@ describe('git utils', () => {
|
||||
|
||||
describe('runGit', () => {
|
||||
it('runs git command with resolved cwd and trims trailing whitespace', () => {
|
||||
mockExecSync.mockReturnValueOnce('feature/worktree\n');
|
||||
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(mockExecSync.mock.calls[0]?.[0]).toBe('git branch --show-current');
|
||||
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
cwd: '/tmp/repo'
|
||||
});
|
||||
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('git');
|
||||
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', () => {
|
||||
mockExecSync.mockReturnValueOnce('true\n');
|
||||
mockExecFileSync.mockReturnValueOnce('true\n');
|
||||
|
||||
const result = runGit('rev-parse --is-inside-work-tree', {});
|
||||
|
||||
expect(result).toBe('true');
|
||||
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
expectGitExecOptions(mockExecFileSync.mock.calls[0]?.[2]);
|
||||
});
|
||||
|
||||
it('returns null when the command fails', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
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', () => {
|
||||
it('returns true when git reports true', () => {
|
||||
mockExecSync.mockReturnValueOnce('true\n');
|
||||
mockExecFileSync.mockReturnValueOnce('true\n');
|
||||
|
||||
expect(isInsideGitWorkTree({})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when git reports false', () => {
|
||||
mockExecSync.mockReturnValueOnce('false\n');
|
||||
mockExecFileSync.mockReturnValueOnce('false\n');
|
||||
|
||||
expect(isInsideGitWorkTree({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when git command fails', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(isInsideGitWorkTree({})).toBe(false);
|
||||
});
|
||||
@@ -142,8 +325,8 @@ describe('git utils', () => {
|
||||
|
||||
describe('getGitChangeCounts', () => {
|
||||
it('sums staged and unstaged insertions/deletions', () => {
|
||||
mockExecSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)');
|
||||
mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)');
|
||||
mockExecFileSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)');
|
||||
mockExecFileSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)');
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 5,
|
||||
@@ -152,8 +335,8 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('handles singular insertion/deletion forms', () => {
|
||||
mockExecSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
mockExecFileSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
|
||||
mockExecFileSync.mockReturnValueOnce('');
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 1,
|
||||
@@ -162,7 +345,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('returns zero counts when git diff commands fail', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 0,
|
||||
@@ -173,7 +356,7 @@ describe('git utils', () => {
|
||||
|
||||
describe('getGitStatus', () => {
|
||||
it('returns all false when no git output', () => {
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
mockExecFileSync.mockReturnValueOnce('');
|
||||
|
||||
expect(getGitStatus({})).toEqual({
|
||||
staged: false,
|
||||
@@ -184,7 +367,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects staged modification', () => {
|
||||
mockExecSync.mockReturnValueOnce('M file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('M file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
@@ -193,7 +376,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects unstaged modification', () => {
|
||||
mockExecSync.mockReturnValueOnce(' M file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce(' M file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(false);
|
||||
@@ -202,7 +385,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects both staged and unstaged modification', () => {
|
||||
mockExecSync.mockReturnValueOnce('MM file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('MM file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
@@ -211,7 +394,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects unstaged deletion', () => {
|
||||
mockExecSync.mockReturnValueOnce(' D file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce(' D file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(false);
|
||||
@@ -220,7 +403,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects staged deletion', () => {
|
||||
mockExecSync.mockReturnValueOnce('D file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('D file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
@@ -229,7 +412,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects untracked files', () => {
|
||||
mockExecSync.mockReturnValueOnce('?? newfile.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('?? newfile.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.untracked).toBe(true);
|
||||
@@ -239,7 +422,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects merge conflict: both modified (UU)', () => {
|
||||
mockExecSync.mockReturnValueOnce('UU file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('UU file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
@@ -248,7 +431,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects merge conflict: added by us (AU)', () => {
|
||||
mockExecSync.mockReturnValueOnce('AU file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('AU file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
@@ -257,7 +440,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects merge conflict: deleted by us (DU)', () => {
|
||||
mockExecSync.mockReturnValueOnce('DU file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('DU file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
@@ -266,7 +449,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects merge conflict: both added (AA)', () => {
|
||||
mockExecSync.mockReturnValueOnce('AA file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('AA file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
@@ -275,7 +458,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects merge conflict: added by them (UA)', () => {
|
||||
mockExecSync.mockReturnValueOnce('UA file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('UA file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
@@ -284,7 +467,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects merge conflict: deleted by them (UD)', () => {
|
||||
mockExecSync.mockReturnValueOnce('UD file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('UD file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
@@ -293,7 +476,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects merge conflict: both deleted (DD)', () => {
|
||||
mockExecSync.mockReturnValueOnce('DD file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('DD file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
@@ -302,7 +485,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects renamed file in index (staged)', () => {
|
||||
mockExecSync.mockReturnValueOnce('R oldname.txt -> newname.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('R oldname.txt -> newname.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
@@ -311,7 +494,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects copied file in index (staged)', () => {
|
||||
mockExecSync.mockReturnValueOnce('C original.txt -> copy.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('C original.txt -> copy.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
@@ -320,7 +503,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('ignores rename source path in porcelain -z output', () => {
|
||||
mockExecSync.mockReturnValueOnce('R new-name.txt\0DUCK.txt\0');
|
||||
mockExecFileSync.mockReturnValueOnce('R new-name.txt\0DUCK.txt\0');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
@@ -329,7 +512,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('ignores copy source path in porcelain -z output', () => {
|
||||
mockExecSync.mockReturnValueOnce('C copy.txt\0MOUSE.txt\0');
|
||||
mockExecFileSync.mockReturnValueOnce('C copy.txt\0MOUSE.txt\0');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
@@ -337,8 +520,26 @@ describe('git utils', () => {
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores unstaged rename source path in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' R new-name.txt\0ANT.txt\0');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(false);
|
||||
expect(result.unstaged).toBe(true);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores unstaged copy source path in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' C copy.txt\0MOUSE.txt\0');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(false);
|
||||
expect(result.unstaged).toBe(true);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects type changed file in index (staged)', () => {
|
||||
mockExecSync.mockReturnValueOnce('T file.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('T file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
@@ -347,7 +548,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects mixed status with multiple files', () => {
|
||||
mockExecSync.mockReturnValueOnce('M staged.txt\0 M unstaged.txt\0?? untracked.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('M staged.txt\0 M unstaged.txt\0?? untracked.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
@@ -357,7 +558,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('detects mixed status with conflicts', () => {
|
||||
mockExecSync.mockReturnValueOnce('UU conflict.txt\0M staged.txt\0 M unstaged.txt\0?? untracked.txt');
|
||||
mockExecFileSync.mockReturnValueOnce('UU conflict.txt\0M staged.txt\0 M unstaged.txt\0?? untracked.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
@@ -367,7 +568,7 @@ describe('git utils', () => {
|
||||
});
|
||||
|
||||
it('handles git command failure', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(getGitStatus({})).toEqual({
|
||||
staged: false,
|
||||
@@ -377,4 +578,86 @@ describe('git utils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitFileStatusCounts', () => {
|
||||
it('counts staged, unstaged, and untracked files from porcelain status', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('M staged-a.ts\0A staged-b.ts\0 M unstaged-a.ts\0?? new-a.ts\0?? new-b.ts\0');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 2,
|
||||
unstaged: 1,
|
||||
untracked: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('counts files with both staged and unstaged changes in both totals', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('MM file.ts');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 1,
|
||||
unstaged: 1,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counts when there are no matching files', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores rename source paths in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('R new-name.ts\0old-name.ts\0?? new-file.ts\0');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 1,
|
||||
unstaged: 0,
|
||||
untracked: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores copy source paths in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('C copy.ts\0original.ts\0 M changed.ts');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 1,
|
||||
unstaged: 1,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unstaged rename source paths in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' R new-name.ts\0A-old-name.ts\0?? new-file.ts\0');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 0,
|
||||
unstaged: 1,
|
||||
untracked: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unstaged copy source paths in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' C copy.ts\0M-original.ts\0 M changed.ts');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 0,
|
||||
unstaged: 2,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counts when git commands fail', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,28 +6,9 @@ import {
|
||||
|
||||
import {
|
||||
buildIdeFileUrl,
|
||||
encodeGitRefForUrlPath,
|
||||
parseGitHubBaseUrl
|
||||
encodeGitRefForUrlPath
|
||||
} from '../hyperlink';
|
||||
|
||||
describe('parseGitHubBaseUrl', () => {
|
||||
it('supports scp-style SSH remotes', () => {
|
||||
expect(parseGitHubBaseUrl('git@github.com:owner/repo.git')).toBe('https://github.com/owner/repo');
|
||||
});
|
||||
|
||||
it('supports ssh URL remotes', () => {
|
||||
expect(parseGitHubBaseUrl('ssh://git@github.com/owner/repo.git')).toBe('https://github.com/owner/repo');
|
||||
});
|
||||
|
||||
it('supports credentialed HTTPS remotes', () => {
|
||||
expect(parseGitHubBaseUrl('https://token@github.com/owner/repo.git')).toBe('https://github.com/owner/repo');
|
||||
});
|
||||
|
||||
it('rejects non-GitHub remotes', () => {
|
||||
expect(parseGitHubBaseUrl('https://gitlab.com/owner/repo.git')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeGitRefForUrlPath', () => {
|
||||
it('encodes reserved characters while preserving branch separators', () => {
|
||||
expect(encodeGitRefForUrlPath('feature/issue#1')).toBe('feature/issue%231');
|
||||
@@ -46,4 +27,4 @@ describe('buildIdeFileUrl', () => {
|
||||
it('builds IDE links for UNC paths', () => {
|
||||
expect(buildIdeFileUrl('\\\\server\\share\\my repo', 'cursor')).toBe('cursor://file//server/share/my%20repo');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,4 +28,4 @@ describe('shouldInsertInput', () => {
|
||||
expect(shouldInsertInput('\u0013', {})).toBe(false);
|
||||
expect(shouldInsertInput('🙂', {})).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,4 +85,4 @@ describe('jsonl block metrics integration', () => {
|
||||
|
||||
expect(metrics).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,12 +23,14 @@ function makeUsageLine(params: {
|
||||
cacheCreate?: number;
|
||||
isSidechain?: boolean;
|
||||
isApiErrorMessage?: boolean;
|
||||
stopReason?: string | null;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
timestamp: params.timestamp,
|
||||
isSidechain: params.isSidechain,
|
||||
isApiErrorMessage: params.isApiErrorMessage,
|
||||
message: {
|
||||
stop_reason: params.stopReason,
|
||||
usage: {
|
||||
input_tokens: params.input,
|
||||
output_tokens: params.output,
|
||||
@@ -154,17 +156,230 @@ describe('jsonl transcript metrics', () => {
|
||||
inputTokens: 1799,
|
||||
outputTokens: 141,
|
||||
cachedTokens: 92,
|
||||
cacheReadTokens: 56,
|
||||
cacheCreationTokens: 36,
|
||||
totalTokens: 2032,
|
||||
contextLength: 250
|
||||
});
|
||||
});
|
||||
|
||||
it('skips intermediate streaming entries and only counts final entries per API call', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'streaming.jsonl');
|
||||
|
||||
// Simulate two API calls, each with intermediate streaming entries (stop_reason: null)
|
||||
// and a final entry (stop_reason: "tool_use" or "end_turn")
|
||||
const lines = [
|
||||
// API call 1: two intermediates + one final
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
input: 1,
|
||||
output: 30,
|
||||
cacheRead: 12000,
|
||||
cacheCreate: 11000,
|
||||
stopReason: null
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
input: 1,
|
||||
output: 30,
|
||||
cacheRead: 12000,
|
||||
cacheCreate: 11000,
|
||||
stopReason: null
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:01.000Z',
|
||||
input: 1,
|
||||
output: 150,
|
||||
cacheRead: 12000,
|
||||
cacheCreate: 11000,
|
||||
stopReason: 'tool_use'
|
||||
}),
|
||||
// API call 2: one intermediate + one final
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:02.000Z',
|
||||
input: 1,
|
||||
output: 25,
|
||||
cacheRead: 23000,
|
||||
cacheCreate: 500,
|
||||
stopReason: null
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
input: 1,
|
||||
output: 400,
|
||||
cacheRead: 23000,
|
||||
cacheCreate: 500,
|
||||
stopReason: 'end_turn'
|
||||
})
|
||||
];
|
||||
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const metrics = await getTokenMetrics(transcriptPath);
|
||||
|
||||
// Should only count the two final entries, not the three intermediates
|
||||
expect(metrics).toEqual({
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
it('counts the latest in-progress streaming entry once when no finalized row exists yet', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'streaming-in-progress.jsonl');
|
||||
|
||||
const lines = [
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
input: 4,
|
||||
output: 40,
|
||||
cacheRead: 1000,
|
||||
cacheCreate: 200,
|
||||
stopReason: null
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:01.000Z',
|
||||
input: 4,
|
||||
output: 90,
|
||||
cacheRead: 1000,
|
||||
cacheCreate: 200,
|
||||
stopReason: null
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:02.000Z',
|
||||
input: 4,
|
||||
output: 140,
|
||||
cacheRead: 1000,
|
||||
cacheCreate: 200,
|
||||
stopReason: null
|
||||
})
|
||||
];
|
||||
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const metrics = await getTokenMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 4,
|
||||
outputTokens: 140,
|
||||
cachedTokens: 1200,
|
||||
cacheReadTokens: 1000,
|
||||
cacheCreationTokens: 200,
|
||||
totalTokens: 1344,
|
||||
contextLength: 1204
|
||||
});
|
||||
});
|
||||
|
||||
it('counts finalized streaming entries plus the latest unfinished one during live updates', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'streaming-live-update.jsonl');
|
||||
|
||||
const lines = [
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
input: 2,
|
||||
output: 25,
|
||||
cacheRead: 100,
|
||||
cacheCreate: 50,
|
||||
stopReason: null
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:01.000Z',
|
||||
input: 2,
|
||||
output: 80,
|
||||
cacheRead: 100,
|
||||
cacheCreate: 50,
|
||||
stopReason: 'end_turn'
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:02.000Z',
|
||||
input: 3,
|
||||
output: 30,
|
||||
cacheRead: 200,
|
||||
cacheCreate: 25,
|
||||
stopReason: null
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
input: 3,
|
||||
output: 120,
|
||||
cacheRead: 200,
|
||||
cacheCreate: 25,
|
||||
stopReason: null
|
||||
})
|
||||
];
|
||||
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const metrics = await getTokenMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 5,
|
||||
outputTokens: 200,
|
||||
cachedTokens: 375,
|
||||
cacheReadTokens: 300,
|
||||
cacheCreationTokens: 75,
|
||||
totalTokens: 580,
|
||||
contextLength: 228
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to counting all entries when no stop_reason data is present', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'legacy.jsonl');
|
||||
|
||||
// Older transcript format without stop_reason
|
||||
const lines = [
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
input: 100,
|
||||
output: 50,
|
||||
cacheRead: 20,
|
||||
cacheCreate: 10
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:00:00.000Z',
|
||||
input: 200,
|
||||
output: 80,
|
||||
cacheRead: 30,
|
||||
cacheCreate: 20
|
||||
})
|
||||
];
|
||||
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const metrics = await getTokenMetrics(transcriptPath);
|
||||
|
||||
// Should count all entries since none have stop_reason
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 300,
|
||||
outputTokens: 130,
|
||||
cachedTokens: 80,
|
||||
cacheReadTokens: 50,
|
||||
cacheCreationTokens: 30,
|
||||
totalTokens: 510,
|
||||
contextLength: 250
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zeroed token metrics when file is missing', async () => {
|
||||
const metrics = await getTokenMetrics('/tmp/ccstatusline-jsonl-metrics-missing.jsonl');
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 0
|
||||
});
|
||||
@@ -883,4 +1098,4 @@ describe('jsonl transcript metrics', () => {
|
||||
requestCount: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,4 +261,4 @@ describe('Block Detection Algorithm', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_RESET_LOCALE,
|
||||
canonicalizeLocale,
|
||||
filterLocaleOptions,
|
||||
getLocaleMatchSegments,
|
||||
getLocaleOptions,
|
||||
getSystemLocale,
|
||||
isValidLocale
|
||||
} from '../locales';
|
||||
|
||||
describe('locale helpers', () => {
|
||||
it('includes the default locale first and curated common locales', () => {
|
||||
const options = getLocaleOptions();
|
||||
|
||||
expect(options[0]?.value).toBe(DEFAULT_RESET_LOCALE);
|
||||
expect(options.some(option => option.value === 'ja-JP')).toBe(true);
|
||||
expect(options.some(option => option.value === 'en-US')).toBe(true);
|
||||
expect(options.some(option => option.value === 'en-CA')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes the system locale when it differs from the default', () => {
|
||||
const systemLocale = getSystemLocale();
|
||||
const options = getLocaleOptions();
|
||||
|
||||
expect(systemLocale === null || options.some(option => option.value === systemLocale)).toBe(true);
|
||||
});
|
||||
|
||||
it('includes the configured locale when it is valid and uncommon', () => {
|
||||
const configuredLocale = canonicalizeLocale('en-AU');
|
||||
if (!configuredLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = getLocaleOptions(configuredLocale);
|
||||
|
||||
expect(options.some(option => option.value === configuredLocale && option.description === 'Configured locale')).toBe(true);
|
||||
});
|
||||
|
||||
it('filters locale options with fuzzy matching', () => {
|
||||
const matches = filterLocaleOptions(getLocaleOptions(), 'japan');
|
||||
|
||||
expect(matches[0]?.value).toBe('ja-JP');
|
||||
});
|
||||
|
||||
it('adds a custom locale option for valid typed locales outside the curated list', () => {
|
||||
const customLocale = canonicalizeLocale('en-AU');
|
||||
if (!customLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = filterLocaleOptions(getLocaleOptions(), 'en-au');
|
||||
|
||||
expect(matches[0]).toMatchObject({
|
||||
value: customLocale,
|
||||
displayName: `Use ${customLocale}`,
|
||||
description: 'Custom locale'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add a custom locale option for invalid typed locales', () => {
|
||||
const matches = filterLocaleOptions(getLocaleOptions(), 'not-a-locale');
|
||||
|
||||
expect(matches.some(option => option.description === 'Custom locale')).toBe(false);
|
||||
});
|
||||
|
||||
it('highlights locale match segments', () => {
|
||||
const segments = getLocaleMatchSegments('ja-JP', 'ja');
|
||||
|
||||
expect(segments.some(segment => segment.text === 'ja' && segment.matched)).toBe(true);
|
||||
});
|
||||
|
||||
it('canonicalizes and validates BCP 47 locale tags', () => {
|
||||
expect(canonicalizeLocale('ja-jp')).toBe('ja-JP');
|
||||
expect(isValidLocale('fr-ca')).toBe(true);
|
||||
expect(isValidLocale('not-a-locale')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -84,4 +84,4 @@ describe('migrations', () => {
|
||||
expect(updateMessage.message).toContain('v2.0.2');
|
||||
expect(updateMessage.remaining).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -148,4 +201,4 @@ describe('getModelContextIdentifier', () => {
|
||||
expect(getModelContextIdentifier(undefined)).toBeUndefined();
|
||||
expect(getModelContextIdentifier({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user