chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:29 +08:00
commit a77213f933
361 changed files with 59994 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
buy_me_a_coffee: sirmalloc
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
- package-ecosystem: bun
directory: /
schedule:
interval: weekly
day: monday
ignore:
- dependency-name: "ink"
open-pull-requests-limit: 10
cooldown:
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
groups:
dev-dependencies:
dependency-type: development
update-types:
- minor
- patch
production-dependencies:
dependency-type: production
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
pull_request:
branches: [main]
jobs:
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run lint
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun test
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run build
- run: test -f dist/ccstatusline.js
+36
View File
@@ -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 }}
+35
View File
@@ -0,0 +1,35 @@
# dependencies (bun install)
node_modules
# output
out
dist
typedoc
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+36
View File
@@ -0,0 +1,36 @@
# Source files
src/
scripts/
*.ts
*.tsx
# Development files
bun.lock
CLAUDE.md
.claude/
test-settings.json
# Build files
tsconfig.json
# Test and example files
*.test.*
*.spec.*
# Git files
.git/
.gitignore
# Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Node modules (in case someone uses npm instead of bun)
node_modules/
+28
View File
@@ -0,0 +1,28 @@
{
// Disable trimming trailing whitespace for markdown files to preserve ASCII art
"[markdown]": {
"files.trimTrailingWhitespace": false
},
// Keep trimming enabled for other file types
"[typescript]": {
"files.trimTrailingWhitespace": true
},
"[typescriptreact]": {
"files.trimTrailingWhitespace": true
},
"[json]": {
"files.trimTrailingWhitespace": true
},
"[javascript]": {
"files.trimTrailingWhitespace": true
},
"cSpell.words": [
"ccstatusline",
"Powerline",
"statusline",
"sublabel",
"Worktree",
"worktrees"
]
}
+151
View File
@@ -0,0 +1,151 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ccstatusline is a customizable status line formatter for Claude Code CLI that displays model info, git branch, token usage, and other metrics. It functions as both:
1. A piped command processor for Claude Code status lines
2. An interactive TUI configuration tool when run without input
## Development Commands
```bash
# Install dependencies
bun install
# Run in interactive TUI mode
bun run start
# Test with piped input (use [1m] suffix for 1M context models)
echo '{"model":{"id":"claude-sonnet-4-5-20250929[1m]"},"transcript_path":"test.jsonl"}' | bun run src/ccstatusline.ts
# Or use example payload
bun run example
# Build for npm distribution
bun run build # Creates dist/ccstatusline.js with Node.js 14+ compatibility
# Run tests
bun test
# Run tests in watch mode
bun test --watch
# Lint and type check
bun run lint # Runs TypeScript type checking and ESLint without modifying files
# Apply ESLint auto-fixes intentionally
bun run lint:fix
```
## Architecture
The project has dual runtime compatibility - works with both Bun and Node.js:
### Core Structure
- **src/ccstatusline.ts**: Main entry point that detects piped vs interactive mode
- Piped mode: Parses JSON from stdin and renders formatted status line
- Interactive mode: Launches React/Ink TUI for configuration
### TUI Components (src/tui/)
- **index.tsx**: Main TUI entry point that handles React/Ink initialization
- **App.tsx**: Root component managing navigation and state
- **components/**: Modular UI components for different configuration screens
- MainMenu, LineSelector, ItemsEditor, ColorMenu, GlobalOverridesMenu
- PowerlineSetup, TerminalOptionsMenu, StatusLinePreview
### Utilities (src/utils/)
- **config.ts**: Settings management
- Loads from `~/.config/ccstatusline/settings.json`
- Handles migration from old settings format
- Default configuration if no settings exist
- **renderer.ts**: Core rendering logic for status lines
- Handles terminal width detection and truncation
- Applies colors, padding, and separators
- Manages flex separator expansion
- **powerline.ts**: Powerline font detection and installation
- **claude-settings.ts**: Integration with Claude Code settings.json
- Respects `CLAUDE_CONFIG_DIR` environment variable with fallback to `~/.claude`
- Provides installation command constants (NPM, BUNX, self-managed)
- Detects installation status and manages settings.json updates
- Validates config directory paths with proper error handling
- **colors.ts**: Color definitions and ANSI code mapping
- **model-context.ts**: Model-to-context-window mapping
- Maps model IDs to their context window sizes based on [1m] suffix
- Sonnet 4.5 WITH [1m] suffix: 1M tokens (800k usable at 80%) - requires long context beta access
- Sonnet 4.5 WITHOUT [1m] suffix: 200k tokens (160k usable at 80%)
- Legacy models: 200k tokens (160k usable at 80%)
### Widgets (src/widgets/)
Custom widgets implementing the Widget interface defined in src/types/Widget.ts:
**Widget Interface:**
All widgets must implement:
- `getDefaultColor()`: Default color for the widget
- `getDescription()`: Description shown in TUI
- `getDisplayName()`: Display name shown in TUI
- `getEditorDisplay()`: How the widget appears in the editor
- `render()`: Core rendering logic that produces the widget output
- `supportsRawValue()`: Whether widget supports raw value mode
- `supportsColors()`: Whether widget supports color customization
- Optional: `renderEditor()`, `getCustomKeybinds()`, `handleEditorAction()`
**Widget Registry Pattern:**
- Located in src/utils/widgets.ts
- Uses a Map-based registry (`widgetRegistry`) that maps widget type strings to widget instances
- `getWidget(type)`: Retrieves widget instance by type
- `getAllWidgetTypes()`: Returns all available widget types
- `isKnownWidgetType()`: Validates if a type is registered
**Available Widgets:**
- 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)
- BlockTimer, SessionClock, SessionCost - Time and cost tracking
- CurrentWorkingDir, TerminalWidth - Environment info
- CustomText, CustomCommand - User-defined widgets
## Key Implementation Details
- **Cross-platform stdin reading**: Detects Bun vs Node.js environment and uses appropriate stdin API
- **Token metrics**: Parses Claude Code transcript files (JSONL format) to calculate token usage
- **Git integration**: Uses child_process.execSync to get current branch and changes
- **Terminal width management**: Three modes for handling width (full, full-minus-40, full-until-compact)
- **Flex separators**: Special separator type that expands to fill available space
- **Powerline mode**: Optional Powerline-style rendering with arrow separators
- **Custom commands**: Execute shell commands and display output in status line
- **Mergeable items**: Items can be merged together with or without padding
## Bun Usage Preferences
Default to using Bun instead of Node.js:
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun install` instead of `npm install`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bun build` with appropriate options for building
- Bun automatically loads .env, so don't use dotenv
## Important Notes
- **ink@6.2.0 patch**: The project uses a patch for ink@6.2.0 to fix backspace key handling on macOS
- Issue: ink treats `\x7f` (backspace on macOS) as delete key instead of backspace
- Fix: Patches `build/parse-keypress.js` to correctly map `\x7f` to backspace
- Applied automatically during `bun install` via `patchedDependencies` in package.json
- Patch file: `patches/ink@6.2.0.patch`
- **Build process**: Two-step build using `bun run build`
1. `bun build`: Bundles src/ccstatusline.ts into dist/ccstatusline.js targeting Node.js 14+
2. `postbuild`: Runs scripts/replace-version.ts to replace `__PACKAGE_VERSION__` placeholder with actual version from package.json
- **ESLint configuration**: Uses flat config format (eslint.config.js) with TypeScript and React plugins
- **Dependencies**: All runtime dependencies are bundled using `--packages=external` for npm package
- **Type checking and linting**: Run checks via `bun run lint` and use `bun run lint:fix` only when you intentionally want ESLint auto-fixes. Never use `npx eslint`, `eslint`, `tsx`, `bun tsc`, or any other variation directly
- **Lint rules**: Never disable a lint rule via a comment, no matter how benign the lint warning or error may seem
- **Testing**: Uses Vitest (via Bun) with 6 test files and ~40 test cases covering:
- Model context detection and token calculation (src/utils/__tests__/model-context.test.ts)
- Context percentage calculations (src/utils/__tests__/context-percentage.test.ts)
- JSONL transcript parsing (src/utils/__tests__/jsonl.test.ts)
- Widget rendering (src/widgets/__tests__/*.test.ts)
- Run tests with `bun test` or `bun test --watch` for watch mode
- Test configuration: vitest.config.ts
- Manual testing also available via piped input and TUI interaction
+6
View File
@@ -0,0 +1,6 @@
Matthew Breedlove <https://github.com/sirmalloc>
---
Original author: Matthew Breedlove (https://github.com/sirmalloc)
Official repository: https://github.com/sirmalloc/ccstatusline
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Matthew Breedlove (https://github.com/sirmalloc)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
ccstatusline
Copyright (c) 2025 Matthew Breedlove (https://github.com/sirmalloc)
This project is licensed under the MIT License.
See the LICENSE file for details.
Original repository: https://github.com/sirmalloc/ccstatusline
+427
View File
@@ -0,0 +1,427 @@
<div align="center">
<pre>
_ _ _ _
___ ___ ___| |_ __ _| |_ _ _ ___| (_)_ __ ___
/ __/ __/ __| __/ _` | __| | | / __| | | '_ \ / _ \
| (_| (__\__ \ || (_| | |_| |_| \__ \ | | | | | __/
\___\___|___/\__\__,_|\__|\__,_|___/_|_|_| |_|\___|
</pre>
# ccstatusline
**🎨 A highly customizable status line formatter for Claude Code CLI**
*Display model info, git branch, token usage, and other metrics in your terminal*
[![npm version](https://img.shields.io/npm/v/ccstatusline.svg)](https://www.npmjs.com/package/ccstatusline)
[![npm downloads](https://img.shields.io/npm/dm/ccstatusline.svg)](https://www.npmjs.com/package/ccstatusline)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/sirmalloc/ccstatusline/blob/main/LICENSE)
[![Node.js Version](https://img.shields.io/node/v/ccstatusline.svg)](https://nodejs.org)
[![install size](https://packagephobia.com/badge?p=ccstatusline)](https://packagephobia.com/result?p=ccstatusline)
[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/sirmalloc/ccstatusline/graphs/commit-activity)
[![Mentioned in Awesome Claude Code](https://awesome.re/mentioned-badge.svg)](https://github.com/hesreallyhim/awesome-claude-code)
[![ClaudeLog - A comprehensive knowledge base for Claude](https://claudelog.com/img/claude_log_badge.svg)](https://claudelog.com/)
![Demo](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/demo.gif)
</div>
<br />
## 📚 Table of Contents
- [Recent Updates](#-recent-updates)
- [Features](#-features)
- [Localizations](#-localizations)
- [Quick Start](#-quick-start)
- [Windows Support](docs/WINDOWS.md)
- [Usage](docs/USAGE.md)
- [Development](docs/DEVELOPMENT.md)
- [Contributing](#-contributing)
- [License](#-license)
- [Related Projects](#-related-projects)
<br />
## 🆕 Recent Updates
### v2.2.22 - v2.2.23 - Powerline flex mode, layout controls, composable metrics, and safer config
![Powerline Flex Mode](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/powerline-flex.png)
- **⚡ 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.
- **🧰 Major Git widget expansion** - Added `Git Status`, `Git Staged`, `Git Unstaged`, `Git Untracked`, `Git Ahead/Behind`, `Git Conflicts`, `Git SHA`, `Git Origin Owner`, `Git Origin Repo`, `Git Origin Owner/Repo`, `Git Upstream Owner`, `Git Upstream Repo`, `Git Upstream Owner/Repo`, `Git Is Fork`, `Git Worktree Mode`, `Git Worktree Name`, `Git Worktree Branch`, `Git Worktree Original Branch`, and `Custom Symbol`.
- **👤 Claude Account Email widget** - Added a session widget that reads the signed-in Claude account email from `~/.claude.json` while respecting `CLAUDE_CONFIG_DIR`.
- **🧼 Global Minimalist Mode** - Added a global toggle in `Global Overrides` that forces widgets into raw-value mode for a cleaner, label-free status line.
- **🔎 Smarter widget picker search** - The add/change widget picker now supports substring, initialism, and fuzzy matching, with ranked results and live match highlighting.
- **📏 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**.
- Each speed widget supports a configurable window of `0-120` seconds in the widget editor (`w` key).
- `0` disables window mode and uses a full-session average speed.
- `1-120` calculates recent speed over the selected rolling window.
- **🧩 New Skills widget controls (v2.2.1)** - Added configurable Skills modes (last/count/list), optional hide-when-empty behavior, and list-size limiting with most-recent-first ordering.
- **🌐 Usage API proxy support (v2.2.2)** - Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
- **🧠 New Thinking Effort widget (v2.2.4)** - Added a widget that shows the current Claude Code thinking effort level.
- **🍎 Better macOS usage lookup reliability (v2.2.5)** - Improved reliability when loading usage API tokens on macOS.
- **⌨️ New Vim Mode widget (v2.2.5)** - Added a widget that shows the current vim mode, with ASCII and optional Nerd Font icon display.
- **🔗 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.
### v2.1.0 - v2.1.10 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Block Reset Timer**, and **Context Bar** widgets.
- **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts.
- **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file.
- **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support.
- ** New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`).
- ** New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`).
- **🧠 Context format fallback fix (v2.1.6)** - When `context_window_size` is missing, context widgets now infer 1M models from long-context labels such as `[1m]` and `1M context` in model identifiers.
- **⏳ Weekly reset timer split (v2.1.7)** - Added a separate `Weekly Reset Timer` widget.
- **⚙️ Custom config file flag (v2.1.8)** - Added `--config <path>` support so ccstatusline can load/save settings from a custom file location.
- **🔣 Unicode separator hex input upgrade (v2.1.9)** - Powerline separator hex input now supports 4-6 digits (full Unicode code points up to `U+10FFFF`).
- **🌳 Bare repo worktree detection fix (v2.1.10)** - `Git Worktree` now correctly detects linked worktrees created from bare repositories.
### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements
- **🧠 Memory Usage widget (v2.0.29)** - Added a new widget that shows current system memory usage (`Mem: used/total`).
- **⚡ Block timer cache (v2.0.28)** - Cache block timer metrics to reduce JSONL parsing on every render, with per-config hashed cache files and automatic 5-hour block invalidation.
- **🧱 Git widget command refactor (v2.0.28)** - Refactored git widgets to use shared git command helpers and expanded coverage for failure and edge-case tests.
- **🪟 Windows UTF-8 piped output fix (v2.0.28)** - Sets the Windows UTF-8 code page for piped status line rendering.
- **📁 Git Root Dir widget (v2.0.27)** - Added a new Git widget that shows the repository root directory name.
- **🏷️ Session Name widget (v2.0.26)** - Added a new widget that shows the current Claude Code session name from `/rename`.
- **🏠 Current Working Directory home abbreviation (v2.0.26)** - Added a `~` abbreviation option for CWD display in both preview and live rendering.
- **🧠 Context model suffix fix (v2.0.26)** - Context widgets now recognize the `[1m]` suffix across models, not just a single model path.
- **🧭 Widget picker UX updates (v2.0.26)** - Improved widget discovery/navigation and added clearer, safer clear-line behavior.
- **⌨️ TUI editor input fix (v2.0.26)** - Prevented shortcut/input leakage into widget editor flows.
- **📄 Repo docs update (v2.0.26)** - Migrated guidance from `CLAUDE.md` to `AGENTS.md` (with symlink compatibility).
### v2.0.16 - Add fish style path abbreviation toggle to Current Working Directory widget
### v2.0.15 - Block Timer calculation fixes
- Fix miscalculation in the block timer
### v2.0.14 - Add remaining mode toggle to Context Percentage widgets
- **Remaining Mode** - You can now toggle the Context Percentage widgets between usage percentage and remaining percentage when configuring them in the TUI by pressing the 'u' key.
### v2.0.12 - Custom Text widget now supports emojis
- **👾 Emoji Support** - You can now paste emoji into the custom text widget. You can also turn on the merge option to get emoji labels for your widgets like this:
![Emoji Support](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/emojiSupport.png)
### v2.0.11 - Unlimited Status Lines
- **🚀 No Line Limit** - Configure as many status lines as you need - the 3-line limitation has been removed
### v2.0.10 - Git Updates
- **🌳 Git Worktree widget** - Shows the active worktree name when working with git worktrees
- **👻 Hide 'no git' message toggle** - Git widgets now support hiding the 'no git' message when not in a repository (toggle with 'h' key while editing the widget)
### v2.0.8 - Powerline Auto-Alignment
![Powerline Auto-Alignment](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/autoAlign.png)
- **🎯 Widget Alignment** - Auto-align widgets across multiple status lines in Powerline mode for a clean, columnar layout (toggle with 'a' in Powerline Setup)
### v2.0.7 - Current Working Directory & Session Cost
![Current Working Directory and Session Cost](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/cwdAndSessionCost.png)
- **📁 Current Working Directory** - Display the current working directory with configurable segment display
- Set the number of path segments to show (e.g., show only last 2 segments: `.../Personal/ccstatusline`)
- Supports raw value mode for compact display
- Automatically truncates long paths with ellipsis
- **💰 Session Cost Widget** - Track your Claude Code session costs (requires Claude Code 1.0.85+)
- Displays total session cost in USD
- Supports raw value mode (shows just `$X.YZ` vs `Cost: $X.YZ`)
- Real-time cost tracking from Claude Code session data
- Note: Cost may not update properly when using `/resume` (Claude Code limitation)
- **🐛 Bug Fixes**
- Fixed Block Timer calculations for accurate time tracking across block boundaries
- Improved widget editor stability with proper Ctrl+S handling
- Enhanced cursor display in numeric input fields
### v2.0.2 - Block Timer Widget
![Block Timer](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/blockTimerSmall.png)
- **⏱️ Block Timer** - Track your progress through 5-hour Claude Code blocks
- Displays time elapsed in current block as hours/minutes (e.g., "3hr 45m")
- Progress bar mode shows visual completion percentage
- Two progress bar styles: full width (32 chars) or compact (16 chars)
- Automatically detects block boundaries from transcript timestamps
### 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, 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
- **🚀 Auto Font Install** - Automatic Powerline font installation with user consent
</details>
<br />
## ✨ Features
- **📊 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
- **🖥️ Interactive TUI** - Built-in configuration interface using React/Ink
- **🔎 Fast Widget Picker** - Add/change widgets by category with search and ranked matching
- **⚙️ Global Options** - Apply consistent formatting across all widgets (padding, separators, bold, minimalist mode, and color overrides)
- **🚀 Cross-platform** - Works seamlessly with both Bun and Node.js
- **🔧 Flexible Configuration** - Supports custom Claude Code config directory via `CLAUDE_CONFIG_DIR` environment variable
- **📏 Smart Width Detection** - Automatically adapts to terminal width with flex separators
- **⚡ Zero Config** - Sensible defaults that work out of the box
<br />
## 🌐 Localizations
The localizations in this section are third-party forks maintained outside this repository. They are not maintained, reviewed, or endorsed by this repository, so review their code and releases before using them.
- 🌏 **中文版 (Chinese):** [ccstatusline-zh](https://github.com/huangguang1999/ccstatusline-zh)
<br />
## 🚀 Quick Start
### No installation needed! Use directly with npx or bunx:
```bash
# Run the configuration TUI with npm
npx -y ccstatusline@latest
# Or with Bun (faster)
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>
The interactive configuration tool provides a terminal UI where you can:
- Configure multiple separate status lines
- 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
> 💡 **Tip:** Your settings are automatically saved to `~/.config/ccstatusline/settings.json`
> 🔧 **Custom Claude Config:** If your Claude Code configuration is in a non-standard location, set the `CLAUDE_CONFIG_DIR` environment variable:
> ```bash
> # Linux/macOS
> export CLAUDE_CONFIG_DIR=/custom/path/to/.claude
> ```
> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
> 🪟 **Windows Support:** PowerShell examples, installation notes, fonts, troubleshooting, WSL, and Windows Terminal configuration are in [docs/WINDOWS.md](docs/WINDOWS.md).
</details>
<details>
<summary><b>Claude Code settings.json format</b></summary>
When you install from the TUI, ccstatusline writes a `statusLine` command object to your Claude Code settings:
```json
{
"statusLine": {
"type": "command",
"command": "npx -y ccstatusline@latest",
"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
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## Support
If ccstatusline is useful to you, consider buying me a coffee:
<a href="https://www.buymeacoffee.com/sirmalloc" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
## 📄 License
[MIT](LICENSE) © Matthew Breedlove
## 👤 Author
**Matthew Breedlove**
- GitHub: [@sirmalloc](https://github.com/sirmalloc)
## 🔗 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.
- [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.
- [crispy-recall](https://github.com/TheSylvester/crispy-recall) - Searchable memory for your Claude Code and Codex sessions. Local, fast, no daemon.
## 🙏 Acknowledgments
- Built for use with [Claude Code CLI](https://claude.ai/code) by Anthropic
- Powered by [Ink](https://github.com/vadimdemedes/ink) for the terminal UI
- Made with ❤️ for the Claude Code community
<br />
## Star History
<a href="https://www.star-history.com/#sirmalloc/ccstatusline&Timeline">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=sirmalloc/ccstatusline&type=Timeline&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=sirmalloc/ccstatusline&type=Timeline" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=sirmalloc/ccstatusline&type=Timeline" />
</picture>
</a>
<div align="center">
### 🌟 Show Your Support
Give a ⭐ if this project helped you!
[![GitHub stars](https://img.shields.io/github/stars/sirmalloc/ccstatusline?style=social)](https://github.com/sirmalloc/ccstatusline/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/sirmalloc/ccstatusline?style=social)](https://github.com/sirmalloc/ccstatusline/network/members)
[![GitHub watchers](https://img.shields.io/github/watchers/sirmalloc/ccstatusline?style=social)](https://github.com/sirmalloc/ccstatusline/watchers)
[![npm version](https://img.shields.io/npm/v/ccstatusline.svg)](https://www.npmjs.com/package/ccstatusline)
[![npm downloads](https://img.shields.io/npm/dm/ccstatusline.svg)](https://www.npmjs.com/package/ccstatusline)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/sirmalloc/ccstatusline/blob/main/LICENSE)
[![Made with Bun](https://img.shields.io/badge/Made%20with-Bun-000000.svg?logo=bun)](https://bun.sh)
[![Issues](https://img.shields.io/github/issues/sirmalloc/ccstatusline)](https://github.com/sirmalloc/ccstatusline/issues)
[![Pull Requests](https://img.shields.io/github/issues-pr/sirmalloc/ccstatusline)](https://github.com/sirmalloc/ccstatusline/pulls)
[![Contributors](https://img.shields.io/github/contributors/sirmalloc/ccstatusline)](https://github.com/sirmalloc/ccstatusline/graphs/contributors)
### 💬 Connect
[Report Bug](https://github.com/sirmalloc/ccstatusline/issues) · [Request Feature](https://github.com/sirmalloc/ccstatusline/issues) · [Discussions](https://github.com/sirmalloc/ccstatusline/discussions)
</div>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`sirmalloc/ccstatusline`
- 原始仓库:https://github.com/sirmalloc/ccstatusline
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+1433
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
# Development
Development setup, project structure, and API documentation for `ccstatusline`.
If you want the main project overview, return to [README.md](../README.md).
## Prerequisites
- [Bun](https://bun.sh) (v1.0+)
- Git
- Node.js 14+ (optional, for running the built `dist/ccstatusline.js` binary or npm publishing)
## Setup
```bash
# Clone the repository
git clone https://github.com/sirmalloc/ccstatusline.git
cd ccstatusline
# Install dependencies
bun install
```
## Development Commands
```bash
# Run in TUI mode
bun run start
# Test piped mode with example payload
bun run example
# Run tests
bun test
# Run typecheck + eslint checks without modifying files
bun run lint
# Apply ESLint auto-fixes intentionally
bun run lint:fix
# Build for distribution
bun run build
# Generate TypeDoc documentation
bun run docs
```
## Configuration Files
- `~/.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.
## 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
Complete API documentation is generated using TypeDoc and includes detailed information about:
- **Core Types**: Configuration interfaces, widget definitions, and render contexts
- **Widget System**: All available widgets and their customization options
- **Utility Functions**: Helper functions for rendering, configuration, and terminal handling
- **Status Line Rendering**: Core rendering engine and formatting options
### Generating Documentation
To generate the API documentation locally:
```bash
# Generate documentation
bun run docs
# Clean generated documentation
bun run docs:clean
```
The documentation will be generated in the `typedoc/` directory and can be viewed by opening `typedoc/index.html` in your web browser.
### Documentation Structure
- **Types**: Core TypeScript interfaces and type definitions
- **Widgets**: Individual widget implementations and their APIs
- **Utils**: Utility functions for configuration, rendering, and terminal operations
- **Main Module**: Primary entry point and orchestration functions
## Project Structure
```text
ccstatusline/
├── src/
│ ├── ccstatusline.ts # Main entry point
│ ├── tui/ # React/Ink configuration UI
│ │ ├── App.tsx # Root TUI component
│ │ ├── index.tsx # TUI entry point
│ │ └── components/ # UI components
│ │ ├── MainMenu.tsx
│ │ ├── LineSelector.tsx
│ │ ├── ItemsEditor.tsx
│ │ ├── ColorMenu.tsx
│ │ ├── PowerlineSetup.tsx
│ │ └── ...
│ ├── widgets/ # Status line widget implementations
│ │ ├── Model.ts
│ │ ├── GitBranch.ts
│ │ ├── TokensTotal.ts
│ │ ├── OutputStyle.ts
│ │ └── ...
│ ├── utils/ # Utility functions
│ │ ├── config.ts # Settings management
│ │ ├── renderer.ts # Core rendering logic
│ │ ├── powerline.ts # Powerline font utilities
│ │ ├── colors.ts # Color definitions
│ │ └── claude-settings.ts # Claude Code integration (supports CLAUDE_CONFIG_DIR)
│ └── types/ # TypeScript type definitions
│ ├── Settings.ts
│ ├── Widget.ts
│ ├── PowerlineConfig.ts
│ └── ...
├── dist/ # Built files (generated)
├── docs/ # Hand-written repository docs
├── typedoc/ # Generated API docs
├── package.json
├── tsconfig.json
└── README.md
```
+308
View File
@@ -0,0 +1,308 @@
# Usage
Usage documentation for `ccstatusline`.
If you want the main project overview, return to [README.md](../README.md).
Once configured, `ccstatusline` automatically formats your Claude Code status line. The status line appears at the bottom of your terminal during Claude Code sessions.
## Runtime Modes
- **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
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 names omit trailing context suffixes like `(1M context)`; use **Context Window** when you want the total window size shown.
- **Claude Session ID** / **Session Name** / **Claude Account Email** - Show session identifiers plus the currently signed-in Claude account email.
- **Voice Status** - Show whether Claude Code voice input is enabled. It can render as an icon, icon plus text, plain text, or `voice on/off`, with optional Nerd Font microphone icons.
- **Thinking Effort** / **Vim Mode** / **Skills** - Show Claude thinking effort, the current vim editing mode, and skill activity from hook data. Thinking Effort reads live status JSON first, then `/model` or `/effort` transcript output, then settings fallback; it supports `low`, `medium`, `high`, `xhigh`, and `max`, shows `default` when no effort is set, and marks unknown future values with `?`. Claude Code reports Ultracode as `xhigh` in status line data; it does not expose Ultracode as a separate effort level.
- **Session Clock** / **Session Cost** - Show elapsed session time and the current session cost in USD.
### Git
- **Git Branch** / **Git Root Dir** / **Git PR** - Show the current branch, repository root directory, and PR/MR details for the current branch with optional links. 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 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. 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.
- **Input Speed** / **Output Speed** / **Total Speed** - Show session-average token throughput with an optional per-widget rolling window (`0-120` seconds; `0` = full-session average).
- **Context Length** / **Context Window** / **Context %** / **Context % (usable)** / **Context Bar** - Show current context length, total context window size, used/remaining percentage, usable-window percentage, or a progress bar. 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 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. Manual separators are disabled in Powerline mode, but flex separators still work there as layout spacers.
## Terminal Width Options
These settings affect where long lines are truncated, and where right-alignment occurs when using flex separators:
- **Full width always** - Uses full terminal width (may wrap if auto-compact message appears or IDE integration adds text)
- **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:
![Global Options](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/global.png)
### Default Padding & Separators
- **Default Padding** - Add consistent padding to the left and right of each widget
- **Default Separator** - Automatically insert a separator between all widgets
- Press **(p)** to edit padding
- 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>
- **Inherit Colors** - Default separators inherit foreground and background colors from the preceding widget
- Press **(i)** to toggle
- **Global Bold** - Apply bold formatting to all text regardless of individual widget settings
- 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, or a whole-line **gradient** (see below)
- Press **(f)** to cycle through colors
- 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
</details>
> 💡 **Note:** These settings are applied during rendering and don't add widgets to your widget list. They provide a consistent look across your entire status line without modifying individual widget configurations.
> ⚠️ **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:
![Block Timer](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/blockTimer.png)
**Display Modes:**
- **Time Display** - Shows elapsed time as "3hr 45m" (default)
- **Progress Bar** - Full width 32-character progress bar with percentage
- **Progress Bar (Short)** - Compact 16-character progress bar with percentage
**Features:**
- Automatically detects block boundaries from transcript timestamps
- Floors block start time to the hour for consistent tracking
- Shows "Block: 3hr 45m" in normal mode or just "3hr 45m" in raw value mode
- Progress bars show completion percentage (e.g., "[████████████████████████░░░░░░░░] 73.9%")
- Use **(p)** to cycle time/full bar/short bar, **(s)** for compact time mode, and **(v)** to invert fill in progress mode
## Raw Value Mode
Some widgets support "raw value" mode which displays just the value without a label:
- Normal: `Model: Claude 3.5 Sonnet` → Raw: `Claude 3.5 Sonnet`
- Normal: `Session: 2hr 15m` → Raw: `2hr 15m`
- Normal: `Block: 3hr 45m` → Raw: `3hr 45m`
- Normal: `Ctx: 18.6k` → Raw: `18.6k`
## Widget Editor Keybinds
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:
- type to search categories and widgets
- supports substring, initialism, and fuzzy matching
- `↑/↓` change selection, `Enter` continue/apply, `Esc` clear search/back/cancel
The keybind footer in the TUI only shows shortcuts that apply to the currently selected widget.
Widget-specific shortcuts:
- **Git widgets with empty-state toggles**: `h` hide `no git` / empty output where supported
- **Glyph widgets** (Git Branch, Git Worktree, Git Worktree Mode, Git Staged, Git Unstaged, Git Untracked, Git Conflicts, Git Ahead/Behind, Git Status, JJ Bookmarks, JJ Workspace): `g` set custom glyphs for the widget's symbols; Backspace in the editor renders without one, and multi-symbol widgets (Ahead/Behind, Status) edit each part in one list
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted), `w` set a maximum visible width (blank removes the limit)
- **Git Root Dir**: `l` cycle IDE links (`off``VS Code``Cursor`), `w` set a maximum visible width (blank removes the limit)
- **Git PR**: `h` hide empty/no-PR/MR output, `s` toggle review status, `t` toggle title (renders "MR" for GitLab origins)
- **Git remote widgets** (`Git Origin*` / `Git Upstream*`): `h` hide when no remote, `l` toggle clickable repo links
- **Git Origin Owner/Repo**: `o` show only the owner when the repo is a fork
- **Git Is Fork**: `h` hide when the repo is not a fork
- **Context % widgets**: `u` toggle used vs remaining display, `p` cycle percentage/short bar/short bar only
- **Session Usage / Weekly Usage / Weekly Sonnet Usage / Weekly Opus Usage / 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
- **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
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
- **Link**: `u` URL, `e` link text
- **Vim Mode**: `f` cycle format, `n` toggle Nerd Font icons
## Custom Widgets
### Custom Text Widget
Add static text to your status line. Perfect for:
- Project identifiers
- Environment indicators (dev/prod)
- Personal labels or reminders
### Custom Symbol Widget
Add a single symbol or emoji to your status line when you want a compact visual marker:
- Nerd Font or Powerline-friendly glyphs
- Status markers like `●`, `✓`, or `⚠`
- Emoji shorthand for environments, workflows, or attention cues
### Custom Command Widget
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
- Optional ANSI color preservation (`preserve colors`)
- Examples:
- `pwd | xargs basename` - Show current directory name
- `node -v` - Display Node.js version
- `git rev-parse --short HEAD` - Show current commit hash
- `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)
> ⚠️ **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 (augmented with a `terminal_width` field), allowing you to chain or combine multiple status line tools.
### Link Widget
Create clickable links in terminals that support OSC 8 hyperlinks:
- `metadata.url` - target URL (http/https)
- `metadata.text` - optional display text (defaults to URL)
- Falls back to plain text when URL is missing or unsupported
## Integration Example: ccusage
[ccusage](https://github.com/ryoppippi/ccusage) is a tool that tracks and displays Claude Code usage metrics. You can integrate it directly into your status line:
1. Add a Custom Command widget
2. Set command: `npx -y ccusage@latest statusline`
3. Set timeout: `5000` (5 seconds for initial download)
4. Enable "preserve colors" to keep ccusage's color formatting
![ccusage integration](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/ccusage.png)
> 📄 **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.
## Smart Truncation
When terminal width is detected, status lines automatically truncate with ellipsis (`...`) if they exceed the available width, preventing line wrapping.
Truncation is ANSI/OSC-aware, so preserved color output and OSC 8 hyperlinks remain well-formed.
+205
View File
@@ -0,0 +1,205 @@
# Windows Support
`ccstatusline` works on Windows across PowerShell (5.1+ and 7+), Command Prompt, and Windows Subsystem for Linux (WSL).
If you want the main project overview, return to [README.md](../README.md).
## Installation on Windows
### Option 1: Using Bun (Recommended)
```powershell
# Install Bun for Windows
irm bun.sh/install.ps1 | iex
# Run ccstatusline
bunx -y ccstatusline@latest
```
### Option 2: Using Node.js
```powershell
# Using npm
npx -y 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:
**Settings location:**
- Default: `%USERPROFILE%\.claude\settings.json`
- Custom: set `CLAUDE_CONFIG_DIR` to use a different directory
**PowerShell custom config example:**
```powershell
$env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
```
**For Bun users:**
```json
{
"statusLine": {
"type": "command",
"command": "bunx -y ccstatusline@latest",
"padding": 0,
"refreshInterval": 10
}
}
```
**For npm users:**
```json
{
"statusLine": {
"type": "command",
"command": "npx -y ccstatusline@latest",
"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
### Powerline Font Support
For optimal Powerline rendering on Windows:
**Windows Terminal** (Recommended):
- Supports Powerline fonts natively
- Download from [Microsoft Store](https://aka.ms/terminal)
- Auto-detects compatible fonts
**PowerShell/Command Prompt**:
```powershell
# Install JetBrains Mono Nerd Font via winget
winget install DEVCOM.JetBrainsMonoNerdFont
# Or download manually from: https://www.nerdfonts.com/font-downloads
# Alternative: Download and install base JetBrains Mono font
# from [JetBrains](https://www.jetbrains.com/lp/mono/)
# or [GitHub](https://github.com/JetBrains/JetBrainsMono)
# or [Google Fonts](https://fonts.google.com/specimen/JetBrains+Mono)
```
### Path Handling
`ccstatusline` automatically handles Windows-specific paths:
- Git repositories work with both `/` and `\` path separators
- Current Working Directory widget displays Windows-style paths correctly
- Full support for mapped network drives and UNC paths
- Handles Windows drive letters (C:, D:, etc.)
## Windows Troubleshooting
### 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
# Solution: Install a compatible Nerd Font
winget install DEVCOM.JetBrainsMonoNerdFont
# Then set the font in your terminal settings
```
**Issue**: Git commands not recognized
```powershell
# Check if Git is installed and in PATH
git --version
# If not found, install Git:
winget install Git.Git
# Or download from: https://git-scm.com/download/win
```
**Issue**: Permission errors during installation
```powershell
# Use non-global installation (recommended)
npx -y ccstatusline@latest
# Or run PowerShell as Administrator for global install
```
**Issue**: "Execution Policy" errors in PowerShell
```powershell
# Temporarily allow script execution
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
**Issue**: Windows Defender blocking execution
```powershell
# If Windows Defender flags the binary:
# 1. Open Windows Security
# 2. Go to "Virus & threat protection"
# 3. Add exclusion for the ccstatusline binary location
# Or use temporary bypass (not recommended for production):
Add-MpPreference -ExclusionPath "$env:USERPROFILE\.bun\bin"
```
## Windows Subsystem for Linux (WSL)
`ccstatusline` works well in WSL environments:
```bash
# Install in WSL Ubuntu/Debian
curl -fsSL https://bun.sh/install | bash
source ~/.bashrc
bunx -y ccstatusline@latest
```
**WSL benefits:**
- Native Unix-style path handling
- Better font rendering in WSL terminals
- Seamless integration with Linux development workflows
## Windows Terminal Configuration
For the best experience, configure Windows Terminal with these recommended settings:
### Terminal Settings (`settings.json`)
```json
{
"profiles": {
"defaults": {
"font": {
"face": "JetBrainsMono Nerd Font",
"size": 12
},
"colorScheme": "One Half Dark"
}
}
}
```
## Performance on Windows
`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
+147
View File
@@ -0,0 +1,147 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import stylistic from '@stylistic/eslint-plugin';
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-x/resolver': {
typescript: {
project: ['./tsconfig.json'],
alwaysTryTypes: true,
noWarnOnMultipleProjects: true
},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
}
},
'import-x/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import-x/external-module-folders': ['node_modules', 'node_modules/@types']
};
export default ts.config([
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
stylistic,
'import-x': importX,
'import-newlines': importNewlinesPlugin
},
extends: [
js.configs.recommended,
ts.configs.strictTypeChecked,
ts.configs.stylisticTypeChecked,
importX.flatConfigs.recommended,
stylistic.configs.customize({
quotes: 'single',
semi: true,
indent: 4,
jsx: true
})
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname
},
globals: {
...globals.node,
...globals.es2021
}
},
settings: {
...importResolverSettings
},
rules: {
'no-control-regex': 'off', // We intentionally match ANSI escape sequences
'eqeqeq': 'error',
'import-x/order': ['error', {
alphabetize: {
'order': 'asc',
'orderImportKind': 'asc',
'caseInsensitive': false
},
named: {
'enabled': true,
'types': 'types-last'
},
pathGroupsExcludedImportTypes: ["builtin"],
groups: [
['builtin', 'external'],
'internal',
'parent',
'sibling',
'index',
'unknown'
],
'newlines-between': 'always'
}],
'import-newlines/enforce': ['error', {
items: 1,
semi: true
}],
'@typescript-eslint/no-unused-vars': ['error', { 'args': 'none' }],
'@typescript-eslint/no-empty-function': ['error', { 'allow': ['private-constructors'] }],
'@typescript-eslint/array-type': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-inferrable-types': ['error', { 'ignoreProperties': true }],
'@typescript-eslint/restrict-template-expressions': 'off',
'@stylistic/indent': ['error', 4, {
'ImportDeclaration': 1
}],
'@stylistic/key-spacing': 'off',
'@stylistic/comma-dangle': ['error', 'never'],
'@stylistic/no-multi-spaces': 'off',
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'@stylistic/max-statements-per-line': ['error', { 'max': 2 }],
'@stylistic/operator-linebreak': ['error', 'before'],
'@stylistic/padding-line-between-statements': 'error',
'@stylistic/implicit-arrow-linebreak': ['error', 'beside'],
'@stylistic/no-extra-semi': 'error',
'@stylistic/nonblock-statement-body-position': ['error', 'below'],
'@stylistic/object-curly-newline': ['error', { 'multiline': true }],
'@stylistic/switch-colon-spacing': 'error',
'@stylistic/eol-last': ['error', 'always'],
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
'@stylistic/multiline-ternary': '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'
}
},
{
files: ['**/*.tsx', '**/*.jsx'],
plugins: {
react: reactPlugin,
'react-hooks': reactHooksPlugin
},
settings: {
...importResolverSettings,
react: {
version: 'detect'
}
},
rules: {
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
'react/prop-types': 'off',
'react/react-in-jsx-scope': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn'
}
},
{
ignores: [
'**/dist/',
'**/node_modules/',
'**/*.js',
'!eslint.config.js'
]
}
]);
+94
View File
@@ -0,0 +1,94 @@
{
"name": "ccstatusline",
"version": "2.2.23",
"bugs": {
"url": "https://github.com/sirmalloc/ccstatusline/issues"
},
"description": "A customizable status line formatter for Claude Code CLI",
"main": "./dist/ccstatusline.js",
"module": "./dist/ccstatusline.js",
"type": "module",
"bin": {
"ccstatusline": "./dist/ccstatusline.js"
},
"exports": {
".": {
"import": "./dist/ccstatusline.js",
"default": "./dist/ccstatusline.js"
},
"./package.json": "./package.json"
},
"files": [
"dist/"
],
"scripts": {
"start": "bun run src/ccstatusline.ts",
"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",
"docs": "typedoc",
"docs:clean": "rm -rf typedoc"
},
"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-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": "^8.0.0",
"ink": "6.2.0",
"ink-gradient": "^4.0.0",
"ink-select-input": "^6.2.0",
"pluralize": "^8.0.0",
"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": "^6.0.2",
"typescript-eslint": "^8.39.1",
"vitest": "^4.0.18",
"zod": "^4.0.17"
},
"keywords": [
"claude",
"claude-code",
"cli",
"status-line",
"terminal"
],
"author": "",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/sirmalloc/ccstatusline.git"
},
"engines": {
"node": ">=14.0.0"
},
"trustedDependencies": [
"unrs-resolver"
],
"patchedDependencies": {
"ink@6.2.0": "patches/ink@6.2.0.patch"
}
}
+17
View File
@@ -0,0 +1,17 @@
diff --git a/build/parse-keypress.js b/build/parse-keypress.js
index 1e13e819f59d259a3476510db822695576a9d93c..e100611d8661187c4ca191e89621cd5e480fcacc 100644
--- a/build/parse-keypress.js
+++ b/build/parse-keypress.js
@@ -161,9 +161,9 @@ const parseKeypress = (s = '') => {
key.meta = s.charAt(0) === '\x1b';
}
else if (s === '\x7f' || s === '\x1b\x7f') {
- // TODO(vadimdemedes): `enquirer` detects delete key as backspace, but I had to split them up to avoid breaking changes in Ink. Merge them back together in the next major version.
- // delete
- key.name = 'delete';
+ // On macOS, \x7f is what the backspace key sends, not delete
+ // The actual forward delete sends escape sequences like \x1b[3~
+ key.name = 'backspace';
key.meta = s.charAt(0) === '\x1b';
}
else if (s === '\x1b' || s === '\x1b\x1b') {
+5
View File
@@ -0,0 +1,5 @@
import { registerRoot } from 'remotion';
import { RemotionRoot } from './root';
registerRoot(RemotionRoot);
+16
View File
@@ -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}
/>
);
};
+2724
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+2
View File
@@ -0,0 +1,2 @@
settings.json
payload.json
+53
View File
@@ -0,0 +1,53 @@
{
"hook_event_name": "Status",
"session_id": "abc123...",
"transcript_path": "/path/to/transcript.json",
"cwd": "/current/working/directory",
"model": {
"id": "claude-opus-4-6[1m]",
"display_name": "Opus 4.6 (1M context)"
},
"workspace": {
"current_dir": "/current/working/directory",
"project_dir": "/original/project/directory",
"added_dirs": []
},
"version": "2.1.80",
"output_style": {
"name": "default"
},
"cost": {
"total_cost_usd": 0.01234,
"total_duration_ms": 45000,
"total_api_duration_ms": 2300,
"total_lines_added": 156,
"total_lines_removed": 23
},
"context_window": {
"total_input_tokens": 50113,
"total_output_tokens": 10462,
"context_window_size": 1000000,
"current_usage": {
"input_tokens": 8500,
"output_tokens": 1200,
"cache_creation_input_tokens": 5000,
"cache_read_input_tokens": 2000
},
"used_percentage": 8,
"remaining_percentage": 92
},
"exceeds_200k_tokens": false,
"rate_limits": {
"five_hour": {
"used_percentage": 42,
"resets_at": 1774020000
},
"seven_day": {
"used_percentage": 15,
"resets_at": 1774540000
}
},
"vim": {
"mode": "NORMAL"
}
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bun
import {
readFileSync,
writeFileSync
} from 'fs';
import { join } from 'path';
interface PackageJson {
version: string;
[key: string]: unknown;
}
// Read package.json to get version
const packageJson = JSON.parse(readFileSync('package.json', 'utf-8')) as PackageJson;
const version = packageJson.version;
// Read the bundled file
const bundledFilePath = join('dist', 'ccstatusline.js');
let bundledContent = readFileSync(bundledFilePath, 'utf-8');
// Replace the placeholder with the actual version
bundledContent = bundledContent.replace(/__PACKAGE_VERSION__/g, version);
// Write back the modified content
writeFileSync(bundledFilePath, bundledContent);
console.log(`✓ Replaced version placeholder with ${version}`);
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { runTUI } from './tui';
import type {
SkillsMetrics,
SpeedMetrics,
TokenMetrics
} from './types';
import type { RenderContext } from './types/RenderContext';
import type { StatusJSON } from './types/StatusJSON';
import { StatusJSONSchema } from './types/StatusJSON';
import { getVisibleText } from './utils/ansi';
import { updateColorMap } from './utils/colors';
import {
ZERO_COMPACTION_STATS,
getCompactionStats
} from './utils/compaction';
import {
getConfigLoadError,
initConfigPath,
loadSettings,
saveSettings
} from './utils/config';
import { handleHookInput } from './utils/hook-handler';
import {
getSessionDuration,
getSpeedMetricsCollection,
getTokenMetrics
} 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 { 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 {
const durationMs = data.cost?.total_duration_ms;
return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0;
}
async function readStdin(): Promise<string | null> {
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
if (process.stdin.isTTY) {
return null;
}
const chunks: string[] = [];
try {
// Use Node.js compatible approach
if (typeof Bun !== 'undefined') {
// Bun environment
const decoder = new TextDecoder();
for await (const chunk of Bun.stdin.stream()) {
chunks.push(decoder.decode(chunk));
}
} else {
// Node.js environment
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) {
chunks.push(chunk as string);
}
}
return chunks.join('');
} catch {
return null;
}
}
async function ensureWindowsUtf8CodePage() {
if (process.platform !== 'win32') {
return;
}
try {
const { execFileSync } = await import('child_process');
execFileSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true });
} catch {
// Ignore failures to preserve statusline output even in restricted shells.
}
}
async function renderMultipleLines(data: StatusJSON) {
const settings = await loadSettings();
const configError = getConfigLoadError();
// Set global chalk level based on settings
chalk.level = settings.colorLevel;
// Update color map after setting chalk level
updateColorMap();
// Get all lines to render
const lines = settings.lines;
// Check if session clock is needed
const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock'));
const speedWidgetTypes = new Set(['output-speed', 'input-speed', 'total-speed']);
const hasSpeedItems = lines.some(line => line.some(item => speedWidgetTypes.has(item.type)));
const requestedSpeedWindows = new Set<number>();
for (const line of lines) {
for (const item of line) {
if (speedWidgetTypes.has(item.type) && isWidgetSpeedWindowEnabled(item)) {
requestedSpeedWindows.add(getWidgetSpeedWindowSeconds(item));
}
}
}
let tokenMetrics: TokenMetrics | null = null;
if (data.transcript_path) {
tokenMetrics = await getTokenMetrics(data.transcript_path);
}
let sessionDuration: string | null = null;
if (hasSessionClock && !hasSessionDurationInStatusJson(data) && data.transcript_path) {
sessionDuration = await getSessionDuration(data.transcript_path);
}
const usageData = await prefetchUsageDataIfNeeded(lines, data);
let speedMetrics: SpeedMetrics | null = null;
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
if (hasSpeedItems && data.transcript_path) {
const speedMetricsCollection = await getSpeedMetricsCollection(data.transcript_path, {
includeSubagents: true,
windowSeconds: Array.from(requestedSpeedWindows)
});
speedMetrics = speedMetricsCollection.sessionAverage;
windowedSpeedMetrics = speedMetricsCollection.windowed;
}
let skillsMetrics: SkillsMetrics | null = null;
if (data.session_id) {
skillsMetrics = getSkillsMetrics(data.session_id);
}
// 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,
tokenMetrics,
speedMetrics,
windowedSpeedMetrics,
usageData,
sessionDuration,
skillsMetrics,
compactionData,
terminalWidth: getTerminalWidth(),
isPreview: false,
minimalist: settings.minimalistMode,
gitCacheTtlSeconds: settings.gitCacheTtlSeconds
};
// Always pre-render all widgets once (for efficiency)
const preRenderedLines = preRenderAllWidgets(lines, settings, context);
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
// 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) {
const preRenderedWidgets = preRenderedLines[i] ?? [];
const lineContext = {
...context,
lineIndex: i,
globalSeparatorIndex,
globalPowerlineThemeIndex,
globalPowerlineStartCapIndex
};
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');
// Add reset code at the beginning to override Claude Code's dim setting
outputLine = '\x1b[0m' + outputLine;
console.log(outputLine);
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems, preRenderedWidgets);
if (settings.powerline.enabled) {
globalPowerlineStartCapIndex += countPowerlineStartCapSlots(lineItems, preRenderedWidgets);
}
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
}
}
}
}
// 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() !== ''
&& settings.updatemessage.remaining
&& settings.updatemessage.remaining > 0) {
// Display the message
console.log(settings.updatemessage.message);
// Decrement the remaining count
const newRemaining = settings.updatemessage.remaining - 1;
// Update or remove the updatemessage
if (newRemaining <= 0) {
// Remove the entire updatemessage block
const { updatemessage, ...newSettings } = settings;
void updatemessage;
await saveSettings(newSettings);
} else {
// Update the remaining count
await saveSettings({
...settings,
updatemessage: {
...settings.updatemessage,
remaining: newRemaining
}
});
}
}
}
function parseConfigArg(): string | undefined {
const idx = process.argv.indexOf('--config');
if (idx === -1)
return undefined;
const configPath = process.argv[idx + 1];
if (!configPath || configPath.startsWith('--')) {
console.error('--config requires a file path argument');
process.exit(1);
}
process.argv.splice(idx, 2);
return configPath;
}
async function handleHook(): Promise<void> {
const input = await readStdin();
handleHookInput(input);
}
async function main() {
// 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());
// Handle --hook mode (cross-platform hook handler for widgets)
if (process.argv.includes('--hook')) {
await handleHook();
return;
}
// Check if we're in a piped/non-TTY environment first
if (!process.stdin.isTTY) {
await ensureWindowsUtf8CodePage();
// We're receiving piped input
const input = await readStdin();
if (input && input.trim() !== '') {
try {
// Parse and validate JSON in one step
const result = StatusJSONSchema.safeParse(JSON.parse(input));
if (!result.success) {
console.error('Invalid status JSON format:', result.error.message);
process.exit(1);
}
await renderMultipleLines(result.data);
} catch (error) {
console.error('Error parsing JSON:', error);
process.exit(1);
}
} else {
console.error('No input received');
process.exit(1);
}
} else {
// Interactive mode - run TUI
// Remove updatemessage before running TUI
const settings = await loadSettings();
if (settings.updatemessage) {
const { updatemessage, ...newSettings } = settings;
void updatemessage;
await saveSettings(newSettings);
}
runTUI();
}
}
void main();
+1356
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
DEFAULT_SETTINGS,
type InstallationMetadata
} from '../../types/Settings';
import {
buildConfigLoadWarning,
buildInvalidConfigSaveConfirm,
clearInstallMenuSelection,
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', () => {
expect(getConfirmCancelScreen(null)).toBe('main');
expect(getConfirmCancelScreen({
message: 'Confirm install?',
action: () => Promise.resolve()
})).toBe('main');
});
it('returns to the install menu when the confirm dialog requests it', () => {
expect(getConfirmCancelScreen({
message: 'Confirm install?',
action: () => Promise.resolve(),
cancelScreen: 'install'
})).toBe('install');
});
it('clears saved install selection when leaving the install menu', () => {
expect(clearInstallMenuSelection({
main: 5,
install: 1,
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');
});
});
+69
View File
@@ -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
});
});
});
+24
View File
@@ -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
};
}
+665
View File
@@ -0,0 +1,665 @@
import chalk from 'chalk';
import {
Box,
Text,
useInput
} from 'ink';
import SelectInput from 'ink-select-input';
import React, { useState } from 'react';
import { getColorLevelString } from '../../types/ColorLevel';
import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import {
applyColors,
getAvailableBackgroundColorsForUI,
getAvailableColorsForUI
} from '../../utils/colors';
import { GRADIENT_PRESET_NAMES } from '../../utils/gradient';
import { shouldInsertInput } from '../../utils/input-guards';
import { getWidget } from '../../utils/widgets';
import { ConfirmDialog } from './ConfirmDialog';
import {
clearAllWidgetStyling,
cycleWidgetColor,
cycleWidgetDim,
resetWidgetStyling,
setWidgetColor,
toggleWidgetBold
} from './color-menu/mutations';
export interface ColorMenuProps {
widgets: WidgetItem[];
lineIndex?: number;
settings: Settings;
onUpdate: (widgets: WidgetItem[]) => void;
onBack: () => void;
}
export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settings, onUpdate, onBack }) => {
const [showSeparators, setShowSeparators] = useState(false);
const [hexInputMode, setHexInputMode] = useState(false);
const [hexInput, setHexInput] = useState('');
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;
const colorableWidgets = widgets.filter((widget) => {
// Include separators only if showSeparators is true
if (widget.type === 'separator') {
return showSeparators;
}
// Use the widget's supportsColors method
const widgetInstance = getWidget(widget.type);
// Include unknown widgets (they might support colors, we just don't know)
return widgetInstance ? widgetInstance.supportsColors(widget) : true;
});
const [highlightedItemId, setHighlightedItemId] = useState(colorableWidgets[0]?.id ?? null);
const [editingBackground, setEditingBackground] = useState(false);
// Handle keyboard input
const hasNoItems = colorableWidgets.length === 0;
useInput((input, key) => {
// If no items, any key goes back
if (hasNoItems) {
onBack();
return;
}
// Skip input handling when confirmation is active - let ConfirmDialog handle it
if (showClearConfirm) {
return;
}
// Handle hex input mode
if (hexInputMode) {
// Disable arrow keys in input mode
if (key.upArrow || key.downArrow) {
return;
}
if (key.escape) {
setHexInputMode(false);
setHexInput('');
} else if (key.return) {
// Validate and apply the hex color
if (hexInput.length === 6) {
const hexColor = `hex:${hexInput}`;
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground);
onUpdate(newItems);
}
setHexInputMode(false);
setHexInput('');
}
} else if (key.backspace || key.delete) {
setHexInput(hexInput.slice(0, -1));
} else if (shouldInsertInput(input, key) && hexInput.length < 6) {
// Only accept hex characters (0-9, A-F, a-f)
const upperInput = input.toUpperCase();
if (/^[0-9A-F]$/.test(upperInput)) {
setHexInput(hexInput + upperInput);
}
}
return;
}
// Handle ansi256 input mode
if (ansi256InputMode) {
// Disable arrow keys in input mode
if (key.upArrow || key.downArrow) {
return;
}
if (key.escape) {
setAnsi256InputMode(false);
setAnsi256Input('');
} else if (key.return) {
// Validate and apply the ansi256 color
const code = parseInt(ansi256Input, 10);
if (!isNaN(code) && code >= 0 && code <= 255) {
const ansiColor = `ansi256:${code}`;
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground);
onUpdate(newItems);
setAnsi256InputMode(false);
setAnsi256Input('');
}
}
} else if (key.backspace || key.delete) {
setAnsi256Input(ansi256Input.slice(0, -1));
} else if (shouldInsertInput(input, key) && ansi256Input.length < 3) {
// Only accept numeric characters (0-9)
if (/^[0-9]$/.test(input)) {
const newInput = ansi256Input + input;
const code = parseInt(newInput, 10);
// Only allow if it won't exceed 255
if (code <= 255) {
setAnsi256Input(newInput);
}
}
}
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;
}
// Normal keyboard handling when there are items
if (key.escape) {
if (editingBackground) {
setEditingBackground(false);
} else {
onBack();
}
} else if (input === 'h' || input === 'H') {
// Enter hex input mode (only in truecolor mode)
if (highlightedItemId && highlightedItemId !== 'back' && settings.colorLevel === 3) {
setHexInputMode(true);
setHexInput('');
}
} else if (input === 'a' || input === 'A') {
// Enter ansi256 input mode (only in 256 color mode)
if (highlightedItemId && highlightedItemId !== 'back' && settings.colorLevel === 2) {
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) {
setShowSeparators(!showSeparators);
// The highlighted item ID will be maintained, and we'll recalculate
// the initial index when rendering the SelectInput
}
} else if (input === 'f' || input === 'F') {
if (colorableWidgets.length > 0) {
setEditingBackground(!editingBackground);
}
} else if (input === 'b' || input === 'B') {
if (highlightedItemId && highlightedItemId !== 'back') {
// Toggle bold for the highlighted item
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = toggleWidgetBold(widgets, selectedWidget.id);
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
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = resetWidgetStyling(widgets, selectedWidget.id);
onUpdate(newItems);
}
}
} else if (input === 'c' || input === 'C') {
// Show clear all confirmation
setShowClearConfirm(true);
} else if (key.leftArrow || key.rightArrow) {
// Cycle through colors with arrow keys
if (highlightedItemId && highlightedItemId !== 'back') {
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
if (selectedWidget) {
const newItems = cycleWidgetColor({
widgets,
widgetId: selectedWidget.id,
direction: key.rightArrow ? 'right' : 'left',
editingBackground,
colors,
backgroundColors: bgColors
});
onUpdate(newItems);
}
}
}
});
if (hasNoItems) {
return (
<Box flexDirection='column'>
<Text bold>
Configure Colors
{lineIndex !== undefined ? ` - Line ${lineIndex + 1}` : ''}
</Text>
<Box marginTop={1}><Text dimColor>No colorable widgets in the status line.</Text></Box>
<Text dimColor>Add a widget first to continue.</Text>
<Box marginTop={1}><Text>Press any key to go back...</Text></Box>
</Box>
);
}
const getItemLabel = (widget: WidgetItem) => {
if (widget.type === 'separator') {
const char = widget.character ?? '|';
return `Separator: ${char === ' ' ? 'space' : char}`;
}
if (widget.type === 'flex-separator') {
return 'Flex Separator';
}
const widgetImpl = getWidget(widget.type);
return widgetImpl ? widgetImpl.getDisplayName() : `Unknown: ${widget.type}`;
};
// Color list for cycling
// Get available colors from colors.ts
const colorOptions = getAvailableColorsForUI();
const colors = colorOptions.map(c => c.value || '');
// For background, get background colors
const bgColorOptions = getAvailableBackgroundColorsForUI();
const bgColors = bgColorOptions.map(c => c.value || '');
// Create menu items with colored labels
const menuItems = colorableWidgets.map((widget, index) => {
const label = `${index + 1}: ${getItemLabel(widget)}`;
// Apply both foreground and background colors
const level = getColorLevelString(settings.colorLevel);
let defaultColor = 'white';
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
defaultColor = widgetImpl.getDefaultColor();
}
}
const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level, widget.dim);
return {
label: styledLabel,
value: widget.id
};
});
menuItems.push({ label: '← Back', value: 'back' });
const handleSelect = (selected: { value: string }) => {
if (selected.value === 'back') {
onBack();
}
// Enter no longer cycles colors - use left/right arrow keys instead
};
const handleHighlight = (item: { value: string }) => {
setHighlightedItemId(item.value);
};
// Get current color for highlighted item
const selectedWidget = highlightedItemId && highlightedItemId !== 'back'
? colorableWidgets.find(widget => widget.id === highlightedItemId)
: null;
const currentColor = editingBackground
? (selectedWidget?.backgroundColor ?? '') // Empty string for 'none'
: (selectedWidget ? (selectedWidget.color ?? (() => {
if (selectedWidget.type !== 'separator' && selectedWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(selectedWidget.type);
return widgetImpl ? widgetImpl.getDefaultColor() : 'white';
}
return 'white';
})()) : 'white');
const colorList = editingBackground ? bgColors : colors;
const colorIndex = colorList.indexOf(currentColor);
const colorNumber = colorIndex === -1 ? 'custom' : colorIndex + 1;
let colorDisplay;
if (editingBackground) {
if (!currentColor || currentColor === '') {
colorDisplay = chalk.gray('(no background)');
} else {
// Determine display name based on format
let displayName;
if (currentColor.startsWith('ansi256:')) {
displayName = `ANSI ${currentColor.substring(8)}`;
} else if (currentColor.startsWith('hex:')) {
displayName = `#${currentColor.substring(4)}`;
} else {
const colorOption = bgColorOptions.find(c => c.value === currentColor);
displayName = colorOption ? colorOption.name : currentColor;
}
// Apply the color using our applyColors function with the current colorLevel
const level = getColorLevelString(settings.colorLevel);
colorDisplay = applyColors(` ${displayName} `, undefined, currentColor, false, level);
}
} else {
if (!currentColor || currentColor === '') {
colorDisplay = chalk.gray('(default)');
} else {
// Determine display name based on format
let displayName;
if (currentColor.startsWith('ansi256:')) {
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;
}
// Apply the color using our applyColors function with the current colorLevel
const level = getColorLevelString(settings.colorLevel);
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) {
return (
<Box flexDirection='column'>
<Text bold color='yellow'> Confirm Clear All Colors</Text>
<Box marginTop={1} flexDirection='column'>
<Text>This will reset all colors for all widgets to their defaults.</Text>
<Text color='red'>This action cannot be undone!</Text>
</Box>
<Box marginTop={2}>
<Text>Continue?</Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
const newItems = clearAllWidgetStyling(widgets);
onUpdate(newItems);
setShowClearConfirm(false);
}}
onCancel={() => {
setShowClearConfirm(false);
}}
/>
</Box>
</Box>
);
}
// Check for global overrides
// Note: When powerline is enabled, background override doesn't affect the display
// since powerline uses item-specific backgrounds for segments
const hasGlobalFgOverride = !!settings.overrideForegroundColor;
const hasGlobalBgOverride = !!settings.overrideBackgroundColor && !powerlineEnabled;
const globalOverrideMessage = hasGlobalFgOverride && hasGlobalBgOverride
? '⚠ Global override for FG and BG active'
: hasGlobalFgOverride
? '⚠ Global override for FG active'
: hasGlobalBgOverride
? '⚠ Global override for BG active'
: null;
return (
<Box flexDirection='column'>
<Box>
<Text bold>
Configure Colors
{lineIndex !== undefined ? ` - Line ${lineIndex + 1}` : ''}
{editingBackground && chalk.yellow(' [Background Mode]')}
</Text>
{globalOverrideMessage && (
<Text color='yellow' dimColor>
{'. '}
{globalOverrideMessage}
</Text>
)}
</Box>
{hexInputMode ? (
<Box flexDirection='column'>
<Text>Enter 6-digit hex color code (without #):</Text>
<Text>
#
{hexInput}
<Text dimColor>{hexInput.length < 6 ? '_'.repeat(6 - hexInput.length) : ''}</Text>
</Text>
<Text> </Text>
<Text dimColor>Press Enter when done, ESC to cancel</Text>
</Box>
) : ansi256InputMode ? (
<Box flexDirection='column'>
<Text>Enter ANSI 256 color code (0-255):</Text>
<Text>
{ansi256Input}
<Text dimColor>{ansi256Input.length === 0 ? '___' : ansi256Input.length === 1 ? '__' : ansi256Input.length === 2 ? '_' : ''}</Text>
</Text>
<Text> </Text>
<Text dimColor>Press Enter when done, ESC to cancel</Text>
</Box>
) : (
<>
<Text dimColor>
to select, to cycle
{' '}
{editingBackground ? 'background' : 'foreground'}
, (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>
{!settings.powerline.enabled && !settings.defaultSeparator && (
<Text dimColor>
(s)how separators:
{showSeparators ? chalk.green('ON') : chalk.gray('OFF')}
</Text>
)}
{selectedWidget ? (
<Box marginTop={1}>
<Text>
Current
{' '}
{editingBackground ? 'background' : 'foreground'}
{' '}
(
{colorNumber === 'custom' ? 'custom' : `${colorNumber}/${colorList.length}`}
):
{' '}
{colorDisplay}
{styleIndicators && ` ${styleIndicators}`}
</Text>
</Box>
) : (
<Box marginTop={1}>
<Text> </Text>
</Box>
)}
</>
)}
<Box marginTop={1}>
{(hexInputMode || ansi256InputMode) ? (
// Static list when in input mode - no keyboard interaction
<Box flexDirection='column'>
{menuItems.map(item => (
<Text
key={item.value}
color={item.value === highlightedItemId ? 'cyan' : 'white'}
bold={item.value === highlightedItemId}
>
{item.value === highlightedItemId ? '▶ ' : ' '}
{item.label}
</Text>
))}
</Box>
) : (
// Interactive SelectInput when not in input mode
<SelectInput
key={`${showSeparators}-${highlightedItemId}`}
items={menuItems}
onSelect={handleSelect}
onHighlight={handleHighlight}
initialIndex={Math.max(0, menuItems.findIndex(item => item.value === highlightedItemId))}
indicatorComponent={({ isSelected }) => (
<Text>{isSelected ? '▶' : ' '}</Text>
)}
itemComponent={({ isSelected, label }) => (
// The label already has ANSI codes applied via applyColors()
// We need to pass it directly as a single Text child to preserve the codes
<Text>{` ${label}`}</Text>
)}
/>
)}
</Box>
<Box marginTop={1} flexDirection='column'>
<Text color='yellow'> VSCode Users: </Text>
<Text dimColor wrap='wrap'>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.</Text>
</Box>
</Box>
);
};
+74
View File
@@ -0,0 +1,74 @@
import {
Box,
Text,
useInput
} from 'ink';
import React from 'react';
import {
List,
type ListEntry
} from './List';
export interface ConfirmDialogProps {
message?: string;
onConfirm: () => void;
onCancel: () => void;
inline?: boolean;
}
const CONFIRM_OPTIONS: ListEntry<boolean>[] = [
{
label: 'Yes',
value: true
},
{
label: 'No',
value: false
}
];
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm, onCancel, inline = false }) => {
useInput((_, key) => {
if (key.escape) {
onCancel();
}
});
if (inline) {
return (
<List
items={CONFIRM_OPTIONS}
onSelect={(confirmed) => {
if (confirmed) {
onConfirm();
return;
}
onCancel();
}}
color='cyan'
/>
);
}
return (
<Box flexDirection='column'>
<Text>{message}</Text>
<Box marginTop={1}>
<List
items={CONFIRM_OPTIONS}
onSelect={(confirmed) => {
if (confirmed) {
onConfirm();
return;
}
onCancel();
}}
color='cyan'
/>
</Box>
</Box>
);
};
+462
View File
@@ -0,0 +1,462 @@
import {
Box,
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import { getColorLevelString } from '../../types/ColorLevel';
import 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';
export interface GlobalOverridesMenuProps {
settings: Settings;
onUpdate: (settings: Settings) => void;
onBack: () => void;
}
export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settings, onUpdate, onBack }) => {
const [editingPadding, setEditingPadding] = useState(false);
const [editingSeparator, setEditingSeparator] = useState(false);
const [confirmingSeparator, setConfirmingSeparator] = useState(false);
const [paddingInput, setPaddingInput] = useState(settings.defaultPadding ?? '');
const [separatorInput, setSeparatorInput] = useState(settings.defaultSeparator ?? '');
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
const hasManualSeparators = settings.lines.some(line => line.some(item => item.type === 'separator')
);
// Get colors from COLOR_MAP
const bgColors = ['none', ...COLOR_MAP.filter(c => c.isBackground).map(c => c.name)];
const fgColors = ['none', ...COLOR_MAP.filter(c => !c.isBackground).map(c => c.name)];
const currentBgIndex = bgColors.indexOf(settings.overrideBackgroundColor ?? 'none');
const currentFgIndex = fgColors.indexOf(settings.overrideForegroundColor ?? 'none');
useInput((input, key) => {
if (editingPadding) {
if (key.return) {
const updatedSettings = {
...settings,
defaultPadding: paddingInput
};
onUpdate(updatedSettings);
setEditingPadding(false);
} else if (key.escape) {
setPaddingInput(settings.defaultPadding ?? '');
setEditingPadding(false);
} else if (key.backspace) {
setPaddingInput(paddingInput.slice(0, -1));
} else if (key.delete) {
// For simple text inputs without cursor, forward delete does nothing
} else if (shouldInsertInput(input, key)) {
setPaddingInput(paddingInput + input);
}
} else if (editingSeparator) {
if (key.return) {
// Only show confirmation if setting a non-empty separator AND there are manual separators
if (separatorInput && hasManualSeparators) {
setEditingSeparator(false);
setConfirmingSeparator(true);
} else {
// Apply directly without confirmation
const updatedSettings = {
...settings,
defaultSeparator: separatorInput || undefined,
// Only remove manual separators if we're setting a non-empty default
lines: separatorInput
? settings.lines.map(line => line.filter(item => item.type !== 'separator'))
: settings.lines
};
onUpdate(updatedSettings);
setEditingSeparator(false);
}
} else if (key.escape) {
setSeparatorInput(settings.defaultSeparator ?? '');
setEditingSeparator(false);
} else if (key.backspace) {
setSeparatorInput(separatorInput.slice(0, -1));
} else if (key.delete) {
// For simple text inputs without cursor, forward delete does nothing
} else if (shouldInsertInput(input, key)) {
setSeparatorInput(separatorInput + input);
}
} 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();
} else if (input === 'p' || input === 'P') {
setEditingPadding(true);
} else if ((input === 's' || input === 'S') && !isPowerlineEnabled && !key.ctrl) {
setEditingSeparator(true);
} else if ((input === 'i' || input === 'I') && !isPowerlineEnabled) {
const newInheritColors = !inheritColors;
setInheritColors(newInheritColors);
const updatedSettings = {
...settings,
inheritSeparatorColors: newInheritColors
};
onUpdate(updatedSettings);
} else if ((input === 'b' || input === 'B') && !isPowerlineEnabled) {
// Cycle through background colors
const nextIndex = (currentBgIndex + 1) % bgColors.length;
const nextBgColor = bgColors[nextIndex];
const updatedSettings = {
...settings,
overrideBackgroundColor: nextBgColor === 'none' ? undefined : nextBgColor
};
onUpdate(updatedSettings);
} else if ((input === 'c' || input === 'C') && !isPowerlineEnabled) {
// Clear override background color
const updatedSettings = {
...settings,
overrideBackgroundColor: undefined
};
onUpdate(updatedSettings);
} else if (input === 'o' || input === 'O') {
// Toggle global bold
const newGlobalBold = !globalBold;
setGlobalBold(newGlobalBold);
const updatedSettings = {
...settings,
globalBold: newGlobalBold
};
onUpdate(updatedSettings);
} else if (input === 'm' || input === 'M') {
// Toggle minimalist mode
const newMinimalistMode = !minimalistMode;
setMinimalistMode(newMinimalistMode);
const updatedSettings = {
...settings,
minimalistMode: newMinimalistMode
};
onUpdate(updatedSettings);
} else if (input === 'f' || input === 'F') {
// Cycle through foreground colors
const nextIndex = (currentFgIndex + 1) % fgColors.length;
const nextFgColor = fgColors[nextIndex];
const updatedSettings = {
...settings,
overrideForegroundColor: nextFgColor === 'none' ? undefined : nextFgColor
};
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);
}
}
});
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>
<Text dimColor>Configure automatic padding and separators between widgets</Text>
{isPowerlineEnabled && (
<Box marginTop={1}>
<Text color='yellow'> Some options are disabled while Powerline mode is active</Text>
</Box>
)}
<Box marginTop={1} />
{editingPadding ? (
<Box flexDirection='column'>
<Box>
<Text>Enter default padding (applied to left and right of each widget): </Text>
<Text color='cyan'>{paddingInput ? `"${paddingInput}"` : '(empty)'}</Text>
</Box>
<Text dimColor>Press Enter to save, ESC to cancel</Text>
</Box>
) : editingSeparator ? (
<Box flexDirection='column'>
<Box>
<Text>Enter default separator (placed between widgets): </Text>
<Text color='cyan'>{separatorInput ? `"${separatorInput}"` : '(empty - no separator will be added)'}</Text>
</Box>
<Text dimColor>Press Enter to save, ESC to cancel</Text>
</Box>
) : confirmingSeparator ? (
<Box flexDirection='column'>
<Box marginBottom={1}>
<Text color='yellow'> Warning: Setting a default separator will remove all existing manual separators from your status lines.</Text>
</Box>
<Box>
<Text>New default separator: </Text>
<Text color='cyan'>{separatorInput ? `"${separatorInput}"` : '(empty)'}</Text>
</Box>
<Box marginTop={1}>
<Text>Do you want to continue? </Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
// Remove all manual separators from lines
const updatedSettings = {
...settings,
defaultSeparator: separatorInput,
lines: settings.lines.map(line => line.filter(item => item.type !== 'separator')
)
};
onUpdate(updatedSettings);
setConfirmingSeparator(false);
}}
onCancel={() => {
// Cancel without applying changes
setSeparatorInput(settings.defaultSeparator ?? '');
setConfirmingSeparator(false);
}}
/>
</Box>
</Box>
) : (
<>
<Box>
<Text> Global Bold: </Text>
<Text color={globalBold ? 'green' : 'red'}>{globalBold ? '✓ Enabled' : '✗ Disabled'}</Text>
<Text dimColor> - Press (o) to toggle</Text>
</Box>
<Box>
<Text> Minimalist Mode: </Text>
<Text color={minimalistMode ? 'green' : 'red'}>{minimalistMode ? '✓ Enabled' : '✗ Disabled'}</Text>
<Text dimColor> - Press (m) to toggle</Text>
</Box>
<Box>
<Text> Default Padding: </Text>
<Text color='cyan'>{settings.defaultPadding ? `"${settings.defaultPadding}"` : '(none)'}</Text>
<Text dimColor> - Press (p) to edit</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);
const display = fgChalk ? fgChalk(displayName) : displayName;
return <Text>{display}</Text>;
}
})()}
<Text dimColor> - (f) cycle, (g) gradient, (x) clear</Text>
</Box>
<Box>
<Text>Override BG Color: </Text>
{isPowerlineEnabled ? (
<Text dimColor>[disabled - Powerline active]</Text>
) : (
<>
{(() => {
const bgColor = settings.overrideBackgroundColor ?? 'none';
if (bgColor === 'none') {
return <Text color='gray'>(none)</Text>;
} else {
const displayName = getColorDisplayName(bgColor);
const bgChalk = getChalkColor(bgColor, 'ansi16', true);
const display = bgChalk ? bgChalk(` ${displayName} `) : displayName;
return <Text>{display}</Text>;
}
})()}
<Text dimColor> - (b) cycle, (c) clear</Text>
</>
)}
</Box>
<Box>
<Text> Inherit Colors: </Text>
{isPowerlineEnabled ? (
<Text dimColor>[disabled - Powerline active]</Text>
) : (
<>
<Text color={inheritColors ? 'green' : 'red'}>{inheritColors ? '✓ Enabled' : '✗ Disabled'}</Text>
<Text dimColor> - Press (i) to toggle</Text>
</>
)}
</Box>
<Box>
<Text>Default Separator: </Text>
{isPowerlineEnabled ? (
<Text dimColor>[disabled - Powerline active]</Text>
) : (
<>
<Text color='cyan'>{settings.defaultSeparator ? `"${settings.defaultSeparator}"` : '(none)'}</Text>
<Text dimColor> - Press (s) to edit</Text>
</>
)}
</Box>
<Box marginTop={2}>
<Text dimColor>Press ESC to go back</Text>
</Box>
<Box marginTop={1} flexDirection='column'>
<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'>
• Inherit colors: Separators will use colors from the preceding widget
</Text>
<Text dimColor wrap='wrap'>
• Global Bold: Makes all text bold regardless of individual settings
</Text>
<Text dimColor wrap='wrap'>
• Minimalist Mode: Strips decorative prefixes and labels from widgets
</Text>
<Text dimColor wrap='wrap'>
Override colors: All widgets will use these colors instead of their configured colors
</Text>
</Box>
</>
)}
</Box>
);
};
+239
View File
@@ -0,0 +1,239 @@
import {
Box,
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import type { InstallationMetadata } from '../../types/Settings';
import {
CCSTATUSLINE_COMMANDS,
PINNED_INSTALL_COMMANDS,
getClaudeSettingsPath,
type PackageCommandAvailability,
type StatusLineCommandMode
} from '../../utils/claude-settings';
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 {
commandAvailability: PackageCommandAvailability;
currentVersion: string;
existingStatusLine: string | null;
onSelect: (selection: InstallSelection) => void;
onCancel: () => void;
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> = ({
commandAvailability,
currentVersion,
existingStatusLine,
onSelect,
onCancel,
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();
}
});
return (
<Box flexDirection='column'>
<Text bold>Install ccstatusline to Claude Code</Text>
{existingStatusLine && (
<Box marginBottom={1}>
<Text color='yellow'>
Current status line: "
{existingStatusLine}
"
</Text>
</Box>
)}
{step === 'style' && (
<>
<Box>
<Text dimColor>Select update style:</Text>
</Box>
<List
color='blue'
marginTop={1}
items={getStyleItems(currentVersion)}
onSelect={(value) => {
if (value === 'back') {
onCancel();
return;
}
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>
The selected command will be written to
{' '}
{getClaudeSettingsPath()}
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Press Enter to select, ESC to go back</Text>
</Box>
</Box>
);
};
+591
View File
@@ -0,0 +1,591 @@
import {
Box,
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import type { Settings } from '../../types/Settings';
import type {
CustomKeybind,
Widget,
WidgetItem,
WidgetItemType
} from '../../types/Widget';
import { getBackgroundColorsForPowerline } from '../../utils/colors';
import { generateGuid } from '../../utils/guid';
import { canDetectTerminalWidth } from '../../utils/terminal';
import {
filterWidgetCatalog,
getMatchSegments,
getWidget,
getWidgetCatalog,
getWidgetCatalogCategories
} from '../../utils/widgets';
import { ConfirmDialog } from './ConfirmDialog';
import {
handleMoveInputMode,
handleNormalInputMode,
handlePickerInputMode,
normalizePickerState,
type CustomEditorWidgetState,
type WidgetPickerAction,
type WidgetPickerState
} from './items-editor/input-handlers';
export interface ItemsEditorProps {
widgets: WidgetItem[];
onUpdate: (widgets: WidgetItem[]) => void;
onBack: () => void;
lineNumber: number;
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);
const [customEditorWidget, setCustomEditorWidget] = useState<CustomEditorWidgetState | null>(null);
const [widgetPicker, setWidgetPicker] = useState<WidgetPickerState | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const separatorChars = ['|', '-', ',', ' '];
const widgetCatalog = getWidgetCatalog(settings);
const widgetCategories = ['All', ...getWidgetCatalogCategories(widgetCatalog)];
// Get a unique background color for powerline mode
const getUniqueBackgroundColor = (insertIndex: number): string | undefined => {
// Only apply background colors if powerline is enabled and NOT using custom theme
if (!settings.powerline.enabled || settings.powerline.theme === 'custom') {
return undefined;
}
// Get all available background colors (excluding black for better visibility)
const bgColors = getBackgroundColorsForPowerline();
// Get colors of adjacent items
const prevWidget = insertIndex > 0 ? widgets[insertIndex - 1] : null;
const nextWidget = insertIndex < widgets.length ? widgets[insertIndex] : null;
const prevBg = prevWidget?.backgroundColor;
const nextBg = nextWidget?.backgroundColor;
// Filter out colors that match neighbors
const availableColors = bgColors.filter(color => color !== prevBg && color !== nextBg);
// If we have available colors, pick one randomly
if (availableColors.length > 0) {
const randomIndex = Math.floor(Math.random() * availableColors.length);
return availableColors[randomIndex];
}
// Fallback: if somehow both neighbors use all 14 colors (impossible with 2 neighbors),
// just pick any color that's different from the previous
return bgColors.find(c => c !== prevBg) ?? bgColors[0];
};
const handleEditorComplete = (updatedWidget: WidgetItem) => {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = updatedWidget;
onUpdate(newWidgets);
setCustomEditorWidget(null);
};
const handleEditorCancel = () => {
setCustomEditorWidget(null);
};
const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
if (!widgetImpl.getCustomKeybinds) {
return [];
}
return widgetImpl.getCustomKeybinds(widget);
};
const openWidgetPicker = (action: WidgetPickerAction) => {
if (widgetCatalog.length === 0) {
return;
}
const currentType = widgets[selectedIndex]?.type;
const selectedType = action === 'change' ? currentType ?? null : null;
setWidgetPicker(normalizePickerState({
action,
level: 'category',
selectedCategory: 'All',
categoryQuery: '',
widgetQuery: '',
selectedType
}, widgetCatalog, widgetCategories));
};
const applyWidgetPickerSelection = (selectedType: WidgetItemType) => {
if (!widgetPicker) {
return;
}
if (widgetPicker.action === 'change') {
const currentWidget = widgets[selectedIndex];
if (currentWidget) {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, type: selectedType };
onUpdate(newWidgets);
}
} else {
const insertIndex = widgetPicker.action === 'add'
? (widgets.length > 0 ? selectedIndex + 1 : 0)
: selectedIndex;
const backgroundColor = getUniqueBackgroundColor(insertIndex);
const newWidget: WidgetItem = {
id: generateGuid(),
type: selectedType,
...(backgroundColor && { backgroundColor })
};
const newWidgets = [...widgets];
newWidgets.splice(insertIndex, 0, newWidget);
onUpdate(newWidgets);
setSelectedIndex(insertIndex);
}
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) {
return;
}
// Skip input handling when clear confirmation is active - let ConfirmDialog handle it
if (showClearConfirm) {
return;
}
if (widgetPicker) {
handlePickerInputMode({
input,
key,
widgetPicker,
widgetCatalog,
widgetCategories,
setWidgetPicker,
applyWidgetPickerSelection
});
return;
}
if (moveMode) {
handleMoveInputMode({
key,
widgets,
selectedIndex,
onUpdate,
setSelectedIndex,
setMoveMode
});
return;
}
handleNormalInputMode({
input,
key,
widgets,
selectedIndex,
canExcludeAlign,
separatorChars,
onBack,
onUpdate,
setSelectedIndex,
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getCustomKeybindsForWidget,
setCustomEditorWidget,
getUniqueBackgroundColor
});
});
const getWidgetDisplay = (widget: WidgetItem) => {
// Special handling for separators (not widgets)
if (widget.type === 'separator') {
const char = widget.character ?? '|';
const charDisplay = char === ' ' ? '(space)' : char;
return `Separator ${charDisplay}`;
}
if (widget.type === 'flex-separator') {
return 'Flex Separator';
}
// Handle regular widgets - delegate to widget for display
const widgetImpl = getWidget(widget.type);
if (widgetImpl) {
const { displayText, modifierText } = widgetImpl.getEditorDisplay(widget);
// Return plain text without colors
return displayText + (modifierText ? ` ${modifierText}` : '');
}
// Unknown widget type
return `Unknown: ${widget.type}`;
};
const hasFlexSeparator = widgets.some(widget => widget.type === 'flex-separator');
const widthDetectionAvailable = canDetectTerminalWidth();
const pickerCategories = widgetPicker
? [...widgetCategories]
: [];
const selectedPickerCategory = widgetPicker
? (widgetPicker.selectedCategory && pickerCategories.includes(widgetPicker.selectedCategory)
? widgetPicker.selectedCategory
: (pickerCategories[0] ?? null))
: null;
const topLevelSearchEntries = widgetPicker?.level === 'category' && widgetPicker.categoryQuery.trim().length > 0
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
: [];
const selectedTopLevelSearchEntry = widgetPicker
? (topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0])
: null;
const pickerEntries = widgetPicker
? filterWidgetCatalog(widgetCatalog, selectedPickerCategory ?? 'All', widgetPicker.widgetQuery)
: [];
const selectedPickerEntry = widgetPicker
? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0])
: null;
// Build main help text (without custom keybinds)
let helpText = hasWidgets
? '↑↓ select, ←→ open type picker'
: '(a)dd via picker, (i)nsert via picker';
if (isSeparator) {
helpText += ', Space edit separator';
}
if (hasWidgets) {
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';
}
if (canMerge) {
helpText += ', (m)erge';
}
if (canExcludeAlign) {
helpText += ', e(x)clude align';
}
helpText += ', ESC back';
// Build custom keybinds text
const customKeybindsText = customKeybinds.map(kb => kb.label).join(', ');
const pickerActionLabel = widgetPicker?.action === 'add'
? 'Add Widget'
: widgetPicker?.action === 'insert'
? 'Insert Widget'
: 'Change Widget Type';
// If custom editor is active, render it instead of the normal UI
if (customEditorWidget?.impl.renderEditor) {
return customEditorWidget.impl.renderEditor({
widget: customEditorWidget.widget,
onComplete: handleEditorComplete,
onCancel: handleEditorCancel,
action: customEditorWidget.action
});
}
if (showClearConfirm) {
return (
<Box flexDirection='column'>
<Text bold color='yellow'> Confirm Clear Line</Text>
<Box marginTop={1} flexDirection='column'>
<Text>
This will remove all widgets from Line
{' '}
{lineNumber}
.
</Text>
<Text color='red'>This action cannot be undone!</Text>
</Box>
<Box marginTop={2}>
<Text>Continue?</Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
onUpdate([]);
setSelectedIndex(0);
setShowClearConfirm(false);
}}
onCancel={() => {
setShowClearConfirm(false);
}}
/>
</Box>
</Box>
);
}
return (
<Box flexDirection='column'>
<Box>
<Text bold>
Edit Line
{' '}
{lineNumber}
{' '}
</Text>
{moveMode && <Text color='blue'>[MOVE MODE]</Text>}
{widgetPicker && <Text color='cyan'>{`[${pickerActionLabel.toUpperCase()}]`}</Text>}
{(settings.powerline.enabled || Boolean(settings.defaultSeparator)) && (
<Box marginLeft={2}>
<Text color='yellow'>
{' '}
{settings.powerline.enabled
? 'Powerline mode active: manual separators disabled'
: 'Default separator active: manual separators disabled'}
</Text>
</Box>
)}
</Box>
{moveMode ? (
<Box flexDirection='column' marginBottom={1}>
<Text dimColor> to move widget, ESC or Enter to exit move mode</Text>
</Box>
) : widgetPicker ? (
<Box flexDirection='column'>
{widgetPicker.level === 'category' ? (
<>
{widgetPicker.categoryQuery.trim().length > 0 ? (
<Text dimColor> select widget match, Enter apply, ESC clear/cancel</Text>
) : (
<Text dimColor> select category, type to search all widgets, Enter continue, ESC cancel</Text>
)}
<Box>
<Text dimColor>Search: </Text>
<Text color='cyan'>{widgetPicker.categoryQuery || '(none)'}</Text>
</Box>
</>
) : (
<>
<Text dimColor> select widget, type to search widgets, Enter apply, ESC back</Text>
<Box>
<Text dimColor>
Category:
{' '}
{selectedPickerCategory ?? '(none)'}
{' '}
| Search:
{' '}
</Text>
<Text color='cyan'>{widgetPicker.widgetQuery || '(none)'}</Text>
</Box>
</>
)}
</Box>
) : (
<Box flexDirection='column'>
<Text dimColor>{helpText}</Text>
<Text dimColor>{customKeybindsText || ' '}</Text>
</Box>
)}
{hasFlexSeparator && !widthDetectionAvailable && (
<Box marginTop={1}>
<Text color='yellow'> Note: Terminal width detection is currently unavailable in your environment.</Text>
<Text dimColor> Flex separators will act as normal separators until width detection is available.</Text>
</Box>
)}
{widgetPicker && (
<Box marginTop={1} flexDirection='column'>
{widgetPicker.level === 'category' ? (
widgetPicker.categoryQuery.trim().length > 0 ? (
topLevelSearchEntries.length === 0 ? (
<Text dimColor>No widgets match the search.</Text>
) : (
<>
{topLevelSearchEntries.map((entry, index) => {
const isSelected = entry.type === selectedTopLevelSearchEntry?.type;
const segments = getMatchSegments(entry.displayName, widgetPicker.categoryQuery);
return (
<Box key={entry.type} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>{`${index + 1}. `}</Text>
{segments.map((seg, i) => (
<Text
key={i}
color={isSelected ? 'green' : seg.matched ? 'yellowBright' : undefined}
bold={isSelected ? true : seg.matched}
>
{seg.text}
</Text>
))}
</Box>
);
})}
{selectedTopLevelSearchEntry && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>{selectedTopLevelSearchEntry.description}</Text>
</Box>
)}
</>
)
) : (
pickerCategories.length === 0 ? (
<Text dimColor>No categories available.</Text>
) : (
<>
{pickerCategories.map((category, index) => {
const isSelected = category === selectedPickerCategory;
return (
<Box key={category} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>
{`${index + 1}. ${category}`}
</Text>
</Box>
);
})}
{selectedPickerCategory === 'All' && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>Search across all widget categories.</Text>
</Box>
)}
</>
)
)
) : (
pickerEntries.length === 0 ? (
<Text dimColor>No widgets match the current category/search.</Text>
) : (
<>
{pickerEntries.map((entry, index) => {
const isSelected = entry.type === selectedPickerEntry?.type;
const segments = getMatchSegments(entry.displayName, widgetPicker.widgetQuery);
return (
<Box key={entry.type} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? 'green' : undefined}>
{isSelected ? '▶ ' : ' '}
</Text>
</Box>
<Text color={isSelected ? 'green' : undefined}>{`${index + 1}. `}</Text>
{segments.map((seg, i) => (
<Text
key={i}
color={isSelected ? 'green' : (seg.matched ? 'yellowBright' : undefined)}
bold={seg.matched}
>
{seg.text}
</Text>
))}
</Box>
);
})}
{selectedPickerEntry && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>{selectedPickerEntry.description}</Text>
</Box>
)}
</>
)
)}
</Box>
)}
{!widgetPicker && (
<Box marginTop={1} flexDirection='column'>
{widgets.length === 0 ? (
<Text dimColor>No widgets. Press 'a' to add one.</Text>
) : (
<>
{widgets.map((widget, index) => {
const isSelected = index === selectedIndex;
const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null;
const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) };
const supportsRawValue = widgetImpl?.supportsRawValue() ?? false;
return (
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
<Box width={3}>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}
</Text>
</Box>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
{`${index + 1}. ${displayText || getWidgetDisplay(widget)}`}
</Text>
{modifierText && (
<Text dimColor>
{' '}
{modifierText}
</Text>
)}
{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>
);
})}
{/* Display description for selected widget */}
{currentWidget && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor>
{(() => {
if (currentWidget.type === 'separator') {
return 'A separator character between status line widgets';
} else if (currentWidget.type === 'flex-separator') {
return 'Expands to fill available terminal width';
} else {
const widgetImpl = getWidget(currentWidget.type);
return widgetImpl ? widgetImpl.getDescription() : 'Unknown widget type';
}
})()}
</Text>
</Box>
)}
</>
)}
</Box>
)}
</Box>
);
};
+312
View File
@@ -0,0 +1,312 @@
import {
Box,
Text,
useInput
} from 'ink';
import pluralize from 'pluralize';
import React, {
useEffect,
useMemo,
useState
} from 'react';
import type { Settings } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { ConfirmDialog } from './ConfirmDialog';
import { List } from './List';
interface LineSelectorProps {
lines: WidgetItem[][];
onSelect: (line: number) => void;
onBack: () => void;
onLinesUpdate: (lines: WidgetItem[][]) => void;
initialSelection?: number;
title?: string;
blockIfPowerlineActive?: boolean;
settings?: Settings;
allowEditing?: boolean;
}
const LineSelector: React.FC<LineSelectorProps> = ({
lines,
onSelect,
onBack,
onLinesUpdate,
initialSelection = 0,
title,
blockIfPowerlineActive = false,
settings,
allowEditing = false
}) => {
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [moveMode, setMoveMode] = useState(false);
const [localLines, setLocalLines] = useState(lines);
useEffect(() => {
setLocalLines(lines);
}, [lines]);
useEffect(() => {
setSelectedIndex(initialSelection);
}, [initialSelection]);
const selectedLine = useMemo(
() => localLines[selectedIndex],
[localLines, selectedIndex]
);
const appendLine = () => {
const newLines = [...localLines, []];
setLocalLines(newLines);
onLinesUpdate(newLines);
setSelectedIndex(newLines.length - 1);
};
const deleteLine = (lineIndex: number) => {
// Don't allow deleting the last remaining line
if (localLines.length <= 1) {
return;
}
const newLines = [...localLines];
newLines.splice(lineIndex, 1);
setLocalLines(newLines);
onLinesUpdate(newLines);
};
// Check if powerline theme is managing colors
const powerlineEnabled = settings ? settings.powerline.enabled : false;
const powerlineTheme = settings ? settings.powerline.theme : undefined;
const isThemeManaged
= blockIfPowerlineActive
&& powerlineEnabled
&& powerlineTheme
&& powerlineTheme !== 'custom';
// Handle keyboard input
useInput((input, key) => {
if (showDeleteDialog) {
return;
}
// If theme-managed and blocking is enabled, any key goes back
if (isThemeManaged) {
onBack();
return;
}
if (moveMode) {
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[targetIndex];
if (temp && prev) {
[newLines[selectedIndex], newLines[targetIndex]] = [prev, temp];
}
setLocalLines(newLines);
onLinesUpdate(newLines);
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[targetIndex];
if (temp && next) {
[newLines[selectedIndex], newLines[targetIndex]] = [next, temp];
}
setLocalLines(newLines);
onLinesUpdate(newLines);
setSelectedIndex(targetIndex);
} else if (key.escape || key.return) {
setMoveMode(false);
}
return;
}
switch (input) {
case 'a':
if (allowEditing) {
appendLine();
}
return;
case 'd':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setShowDeleteDialog(true);
}
return;
case 'm':
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
setMoveMode(true);
}
return;
}
if (key.escape) {
onBack();
}
});
// Show powerline theme warning if applicable
if (isThemeManaged) {
return (
<Box flexDirection='column'>
<Text bold>{title ?? 'Select Line'}</Text>
<Box marginTop={1}>
<Text color='yellow'>
Colors are currently managed by the Powerline theme:
{' '
+ powerlineTheme.charAt(0).toUpperCase()
+ powerlineTheme.slice(1)}
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>To customize colors, either:</Text>
</Box>
<Box marginLeft={2}>
<Text dimColor>
Change to 'Custom' theme in Powerline Configuration Themes
</Text>
</Box>
<Box marginLeft={2}>
<Text dimColor>
Disable Powerline mode in Powerline Configuration
</Text>
</Box>
<Box marginTop={2}>
<Text>Press any key to go back...</Text>
</Box>
</Box>
);
}
if (showDeleteDialog && selectedLine) {
const suffix
= selectedLine.length > 0
? pluralize('widget', selectedLine.length, true)
: 'empty';
return (
<Box flexDirection='column'>
<Box flexDirection='column' gap={1}>
<Text bold>
<Text>
<Text>
Line
{selectedIndex + 1}
</Text>
{' '}
<Text dimColor>
(
{suffix}
)
</Text>
</Text>
</Text>
<Text bold>Are you sure you want to delete line?</Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
deleteLine(selectedIndex);
setSelectedIndex(Math.max(0, selectedIndex - 1));
setShowDeleteDialog(false);
}}
onCancel={() => {
setShowDeleteDialog(false);
}}
/>
</Box>
</Box>
);
}
const lineItems = localLines.map((line, index) => ({
label: `☰ Line ${index + 1}`,
sublabel: `(${line.length > 0 ? pluralize('widget', line.length, true) : 'empty'})`,
value: index
}));
return (
<>
<Box flexDirection='column'>
<Box>
<Text bold>
{title ?? 'Select Line to Edit'}
{' '}
</Text>
{moveMode && <Text color='blue'>[MOVE MODE]</Text>}
</Box>
<Text dimColor>
Choose which status line to configure
</Text>
{moveMode ? (
<Text dimColor> to move line, ESC or Enter to exit move mode</Text>
) : (
<Text dimColor>
{allowEditing ? (
localLines.length > 1
? '(a) to append new line, (d) to delete line, (m) to move line, ESC to go back'
: '(a) to append new line, ESC to go back'
) : 'ESC to go back'}
</Text>
)}
{moveMode ? (
<Box marginTop={1} flexDirection='column'>
{localLines.map((line, index) => {
const isSelected = selectedIndex === index;
const suffix = line.length
? pluralize('widget', line.length, true)
: 'empty';
return (
<Box key={index}>
<Text color={isSelected ? 'blue' : undefined}>
<Text>{isSelected ? '◆ ' : ' '}</Text>
<Text>
<Text>
Line
{' '}
{index + 1}
</Text>
{' '}
<Text dimColor={!isSelected}>
(
{suffix}
)
</Text>
</Text>
</Text>
</Box>
);
})}
</Box>
) : (
<List
marginTop={1}
items={lineItems}
onSelect={(line) => {
if (line === 'back') {
onBack();
return;
}
onSelect(line);
}}
onSelectionChange={(_, index) => {
setSelectedIndex(index);
}}
initialSelection={selectedIndex}
showBackButton={true}
/>
)}
</Box>
</>
);
};
export { LineSelector, type LineSelectorProps };
+170
View File
@@ -0,0 +1,170 @@
import type { ForegroundColorName } from 'chalk';
import {
Box,
Text,
useInput,
type BoxProps
} from 'ink';
import {
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren
} from 'react';
export interface ListEntry<V = string | number> {
label: string;
sublabel?: string;
disabled?: boolean;
description?: string;
value: V;
props?: BoxProps;
}
interface ListProps<V = string | number> extends BoxProps {
items: (ListEntry<V> | '-')[];
onSelect: (value: V | 'back', index: number) => void;
onSelectionChange?: (value: V | 'back', index: number) => void;
initialSelection?: number;
showBackButton?: boolean;
color?: ForegroundColorName;
wrapNavigation?: boolean;
}
export function List<V = string | number>({
items,
onSelect,
onSelectionChange,
initialSelection = 0,
showBackButton,
color,
wrapNavigation = true,
...boxProps
}: ListProps<V>) {
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
const latestOnSelectionChangeRef = useRef(onSelectionChange);
const _items = useMemo(() => {
if (showBackButton) {
return [...items, '-' as const, { label: '← Back', value: 'back' as V }];
}
return items;
}, [items, showBackButton]);
const selectableItems = _items.filter(item => item !== '-' && !item.disabled) as ListEntry<V>[];
const selectedItem = selectableItems[selectedIndex];
const selectedValue = selectedItem?.value;
const actualIndex = _items.findIndex(item => item === selectedItem);
useEffect(() => {
latestOnSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
useEffect(() => {
const maxIndex = Math.max(selectableItems.length - 1, 0);
setSelectedIndex(Math.min(initialSelection, maxIndex));
}, [initialSelection, selectableItems.length]);
useEffect(() => {
if (selectedValue !== undefined) {
latestOnSelectionChangeRef.current?.(selectedValue, selectedIndex);
}
}, [selectedIndex, selectedValue]);
useInput((_, key) => {
if (key.upArrow) {
const prev = selectedIndex - 1;
const prevIndex = prev < 0
? (wrapNavigation ? selectableItems.length - 1 : 0)
: prev;
setSelectedIndex(prevIndex);
return;
}
if (key.downArrow) {
const next = selectedIndex + 1;
const nextIndex = next > selectableItems.length - 1
? (wrapNavigation ? 0 : selectableItems.length - 1)
: next;
setSelectedIndex(nextIndex);
return;
}
if (key.return && selectedItem) {
onSelect(selectedItem.value, selectedIndex);
return;
}
});
return (
<Box flexDirection='column' {...boxProps}>
{_items.map((item, index) => {
if (item === '-') {
return <ListSeparator key={index} />;
}
const isSelected = index === actualIndex;
return (
<ListItem
key={index}
isSelected={isSelected}
color={color}
disabled={item.disabled}
{...item.props}
>
<Text>
<Text>
{item.label}
</Text>
{item.sublabel && (
<Text dimColor={!isSelected}>
{' '}
{item.sublabel}
</Text>
)}
</Text>
</ListItem>
);
})}
{selectedItem?.description && (
<Box marginTop={1} paddingLeft={2}>
<Text dimColor wrap='wrap'>
{selectedItem.description}
</Text>
</Box>
)}
</Box>
);
}
interface ListItemProps extends PropsWithChildren, BoxProps {
isSelected: boolean;
color?: ForegroundColorName;
disabled?: boolean;
}
export function ListItem({
children,
isSelected,
color = 'green',
disabled,
...boxProps
}: ListItemProps) {
return (
<Box {...boxProps}>
<Text color={isSelected ? color : undefined} dimColor={disabled}>
<Text>{isSelected ? '▶ ' : ' '}</Text>
<Text>{children}</Text>
</Text>
</Box>
);
}
export function ListSeparator() {
return <Text> </Text>;
}
+241
View File
@@ -0,0 +1,241 @@
import {
Box,
Text
} from 'ink';
import React from 'react';
import type {
InstallationMetadata,
Settings
} from '../../types/Settings';
import { type PowerlineFontStatus } from '../../utils/powerline';
import { List } from './List';
export type MainMenuOption = 'lines'
| 'colors'
| 'powerline'
| 'terminalConfig'
| 'globalOverrides'
| 'install'
| 'manageInstallation'
| 'checkUpdates'
| 'configureStatusLine'
| 'starGithub'
| 'save'
| 'exit';
export interface MainMenuProps {
onSelect: (value: MainMenuOption, index: number) => void;
isClaudeInstalled: boolean;
hasChanges: boolean;
initialSelection?: number;
powerlineFontStatus: PowerlineFontStatus;
settings: Settings | null;
installation?: InstallationMetadata;
previewIsTruncated?: boolean;
}
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',
description:
'Configure any number of status lines with various widgets like model info, git status, and token usage'
},
{
label: '🎨 Edit Colors',
value: 'colors',
description:
'Customize colors for each widget including foreground, background, and bold styling'
},
{
label: '⚡ Powerline Setup',
value: 'powerline',
description:
'Install Powerline fonts for enhanced visual separators and symbols in your status line'
},
'-',
{
label: '💻 Terminal Options',
value: 'terminalConfig',
description: 'Configure terminal-specific settings for optimal display'
},
{
label: '🌐 Global Overrides',
value: 'globalOverrides',
description:
'Set global padding, separators, and color overrides that apply to all widgets'
},
{
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',
description: 'Save all changes and exit the configuration tool'
},
{
label: '❌ Exit without saving',
value: 'exit',
description: 'Exit without saving your changes'
},
'-',
{
label: '⭐ Like ccstatusline? Star us on GitHub',
value: 'starGithub',
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
}
);
} else {
menuItems.push(
'-',
{
label: '🚪 Exit',
value: 'exit',
description: 'Exit the configuration tool'
},
'-',
{
label: '⭐ Like ccstatusline? Star us on GitHub',
value: 'starGithub',
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
}
);
}
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';
return (
<Box flexDirection='column'>
{showTruncationWarning && (
<Box marginBottom={1}>
<Text color='yellow'>
Some lines are truncated, see Terminal Options Terminal Width
for info
</Text>
</Box>
)}
<Text bold>Main Menu</Text>
<List
items={menuItems}
marginTop={1}
onSelect={(value, index) => {
if (value === 'back') {
return;
}
onSelect(value, index);
}}
initialSelection={initialSelection}
/>
</Box>
);
};
@@ -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>
);
};
@@ -0,0 +1,324 @@
import {
Box,
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import type { Settings } from '../../types/Settings';
import { shouldInsertInput } from '../../utils/input-guards';
export type EditorMode = 'separator' | 'startCap' | 'endCap';
export interface PowerlineSeparatorEditorProps {
settings: Settings;
mode: EditorMode;
onUpdate: (settings: Settings) => void;
onBack: () => void;
}
export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> = ({
settings,
mode,
onUpdate,
onBack
}) => {
const powerlineConfig = settings.powerline;
// Get the appropriate array based on mode
const getItems = () => {
switch (mode) {
case 'separator':
return powerlineConfig.separators;
case 'startCap':
return powerlineConfig.startCaps;
case 'endCap':
return powerlineConfig.endCaps;
}
};
const separators = getItems();
const invertBgs = mode === 'separator'
? powerlineConfig.separatorInvertBackground
: [];
const [selectedIndex, setSelectedIndex] = useState(0);
const [hexInputMode, setHexInputMode] = useState(false);
const [hexInput, setHexInput] = useState('');
const [cursorPos, setCursorPos] = useState(0);
// Get presets based on mode
const getPresets = () => {
if (mode === 'separator') {
return [
{ char: '\uE0B0', name: 'Triangle Right', hex: 'E0B0' },
{ char: '\uE0B2', name: 'Triangle Left', hex: 'E0B2' },
{ char: '\uE0B4', name: 'Round Right', hex: 'E0B4' },
{ char: '\uE0B6', name: 'Round Left', hex: 'E0B6' }
];
} else if (mode === 'startCap') {
return [
{ char: '\uE0B2', name: 'Triangle', hex: 'E0B2' },
{ char: '\uE0B6', name: 'Round', hex: 'E0B6' },
{ char: '\uE0BA', name: 'Lower Triangle', hex: 'E0BA' },
{ char: '\uE0BE', name: 'Diagonal', hex: 'E0BE' }
];
} else {
return [
{ char: '\uE0B0', name: 'Triangle', hex: 'E0B0' },
{ char: '\uE0B4', name: 'Round', hex: 'E0B4' },
{ char: '\uE0B8', name: 'Lower Triangle', hex: 'E0B8' },
{ char: '\uE0BC', name: 'Diagonal', hex: 'E0BC' }
];
}
};
const presetSeparators = getPresets();
const getSeparatorDisplay = (char: string, index: number): string => {
const preset = presetSeparators.find(p => p.char === char);
const invertBg = invertBgs[index] ?? false;
if (preset) {
// Show inversion status for all separators in separator mode
const inversionText = mode === 'separator' && invertBg ? ' [Inverted]' : '';
return `${preset.char} - ${preset.name}${inversionText}`;
}
const codePoint = char.codePointAt(0) ?? 0;
const hexCode = codePoint.toString(16).toUpperCase().padStart(4, '0');
return `${char} - Custom (U+${hexCode})${invertBg ? ' [Inverted]' : ''}`;
};
const updateSeparators = (newSeparators: string[], newInvertBgs?: boolean[]) => {
const updatedPowerline = { ...powerlineConfig };
switch (mode) {
case 'separator':
updatedPowerline.separators = newSeparators;
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
break;
case 'startCap':
updatedPowerline.startCaps = newSeparators;
break;
case 'endCap':
updatedPowerline.endCaps = newSeparators;
break;
}
onUpdate({
...settings,
powerline: updatedPowerline
});
};
useInput((input, key) => {
if (hexInputMode) {
// Hex input mode
if (key.escape) {
setHexInputMode(false);
setHexInput('');
setCursorPos(0);
} else if (key.return) {
if (hexInput.length >= 4 && hexInput.length <= 6) {
const codePoint = parseInt(hexInput, 16);
if (codePoint >= 0 && codePoint <= 0x10FFFF) {
const char = String.fromCodePoint(codePoint);
const newSeparators = [...separators];
if (separators.length === 0) {
// Add new item if list is empty
newSeparators.push(char);
} else {
newSeparators[selectedIndex] = char;
}
updateSeparators(newSeparators);
setHexInputMode(false);
setHexInput('');
setCursorPos(0);
}
}
} else if (key.backspace && cursorPos > 0) {
setHexInput(hexInput.slice(0, cursorPos - 1) + hexInput.slice(cursorPos));
setCursorPos(cursorPos - 1);
} else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 6) {
setHexInput(hexInput.slice(0, cursorPos) + input.toUpperCase() + hexInput.slice(cursorPos));
setCursorPos(cursorPos + 1);
}
} else {
// Normal mode
if (key.escape) {
onBack();
} else if (key.upArrow && separators.length > 0) {
setSelectedIndex(selectedIndex - 1 < 0 ? separators.length - 1 : selectedIndex - 1);
} else if (key.downArrow && separators.length > 0) {
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';
const currentPresetIndex = presetSeparators.findIndex(p => p.char === currentChar);
const newSeparators = [...separators];
const newInvertBgs = mode === 'separator' ? [...invertBgs] : [];
let newIndex;
if (currentPresetIndex !== -1) {
// It's a preset, cycle to next/prev preset
if (key.rightArrow) {
newIndex = (currentPresetIndex + 1) % presetSeparators.length;
} else {
newIndex = currentPresetIndex === 0 ? presetSeparators.length - 1 : currentPresetIndex - 1;
}
} else {
// It's a custom separator, cycle to first or last preset
if (key.rightArrow) {
newIndex = 0; // Go to first preset
} else {
newIndex = presetSeparators.length - 1; // Go to last preset
}
}
const newChar = presetSeparators[newIndex]?.char ?? presetSeparators[0]?.char ?? '\uE0B0';
newSeparators[selectedIndex] = newChar;
// Auto-set inversion for left-facing separators (Triangle Left \uE0B2 or Round Left \uE0B6)
if (mode === 'separator') {
const isLeftFacing = newChar === '\uE0B2' || newChar === '\uE0B6';
newInvertBgs[selectedIndex] = isLeftFacing;
}
updateSeparators(newSeparators, mode === 'separator' ? newInvertBgs : undefined);
} else if (input === 'a' || input === 'A') {
// Add after current
const newSeparators = [...separators];
const newInvertBgs = mode === 'separator' ? [...invertBgs] : [];
const defaultChar = presetSeparators[0]?.char ?? '\uE0B0';
const isLeftFacing = defaultChar === '\uE0B2' || defaultChar === '\uE0B6';
if (separators.length === 0) {
// If empty, just add at the beginning
newSeparators.push(defaultChar);
if (mode === 'separator') {
newInvertBgs.push(isLeftFacing);
}
updateSeparators(newSeparators, newInvertBgs);
setSelectedIndex(0);
} else {
// Add after current selected item
newSeparators.splice(selectedIndex + 1, 0, defaultChar);
if (mode === 'separator') {
newInvertBgs.splice(selectedIndex + 1, 0, isLeftFacing);
}
updateSeparators(newSeparators, newInvertBgs);
setSelectedIndex(selectedIndex + 1);
}
} else if (input === 'i' || input === 'I') {
// Insert before current
const newSeparators = [...separators];
const newInvertBgs = mode === 'separator' ? [...invertBgs] : [];
const defaultChar = presetSeparators[0]?.char ?? '\uE0B0';
const isLeftFacing = defaultChar === '\uE0B2' || defaultChar === '\uE0B6';
if (separators.length === 0) {
// If empty, just add at the beginning
newSeparators.push(defaultChar);
if (mode === 'separator') {
newInvertBgs.push(isLeftFacing);
}
updateSeparators(newSeparators, newInvertBgs);
setSelectedIndex(0);
} else {
// Insert before current selected item
newSeparators.splice(selectedIndex, 0, defaultChar);
if (mode === 'separator') {
newInvertBgs.splice(selectedIndex, 0, isLeftFacing);
}
updateSeparators(newSeparators, newInvertBgs);
// Keep selection on the newly inserted item (which is now at selectedIndex)
}
} else if ((input === 'd' || input === 'D') && (mode !== 'separator' || separators.length > 1)) {
// Delete current (min 1 for separator, no min for caps)
const newSeparators = separators.filter((_, i) => i !== selectedIndex);
const newInvertBgs = mode === 'separator' ? invertBgs.filter((_, i) => i !== selectedIndex) : [];
updateSeparators(newSeparators, newInvertBgs);
setSelectedIndex(Math.min(selectedIndex, Math.max(0, newSeparators.length - 1)));
} else if (input === 'c' || input === 'C') {
// Clear all
if (mode === 'separator') {
// Reset to default right-facing separator with no inversion
updateSeparators(['\uE0B0'], [false]);
} else {
updateSeparators([]);
}
setSelectedIndex(0);
} else if (input === 'h' || input === 'H') {
// Enter hex input mode
setHexInputMode(true);
setHexInput('');
setCursorPos(0);
} else if ((input === 't' || input === 'T') && mode === 'separator') {
// Toggle background inversion (for all separators in separator mode)
const newInvertBgs = [...invertBgs];
newInvertBgs[selectedIndex] = !(newInvertBgs[selectedIndex] ?? false);
updateSeparators(separators, newInvertBgs);
}
}
});
const getTitle = () => {
switch (mode) {
case 'separator':
return 'Powerline Separator Configuration';
case 'startCap':
return 'Powerline Start Cap Configuration';
case 'endCap':
return 'Powerline End Cap Configuration';
}
};
const canDelete = mode !== 'separator' || separators.length > 1;
return (
<Box flexDirection='column'>
<Text bold>{getTitle()}</Text>
{hexInputMode ? (
<Box marginTop={2} flexDirection='column'>
<Text>
Enter hex code (4-6 digits) for
{' '}
{mode === 'separator' ? 'separator' : 'cap'}
{separators.length > 0 ? ` ${selectedIndex + 1}` : ''}
:
</Text>
<Text>
U+
{hexInput.slice(0, cursorPos)}
<Text backgroundColor='gray' color='black'>{hexInput[cursorPos] ?? '_'}</Text>
{hexInput.slice(cursorPos + 1)}
{hexInput.length < 6 && hexInput.length === cursorPos && <Text dimColor>{'_'.repeat(6 - hexInput.length - 1)}</Text>}
</Text>
<Text dimColor>Enter 4-6 hex digits (0-9, A-F) for a Unicode code point, then press Enter. ESC to cancel.</Text>
<Text dimColor>Examples: E0B0 (powerline), 1F984 (🦄), 2764 ()</Text>
</Box>
) : (
<>
<Box>
<Text dimColor>
{`↑↓ select, ← → cycle, (a)dd, (i)nsert${canDelete ? ', (d)elete' : ''}, (c)lear, (h)ex${mode === 'separator' ? ', (t)oggle invert' : ''}, ESC back`}
</Text>
</Box>
<Box marginTop={2} flexDirection='column'>
{separators.length > 0 ? (
separators.map((sep, index) => (
<Box key={index}>
<Text color={index === selectedIndex ? 'green' : undefined}>
{index === selectedIndex ? '▶ ' : ' '}
{`${index + 1}: ${getSeparatorDisplay(sep, index)}`}
</Text>
</Box>
))
) : (
<Text dimColor>(none configured - press 'a' to add)</Text>
)}
</Box>
</>
)}
</Box>
);
};
+472
View File
@@ -0,0 +1,472 @@
import {
Box,
Text,
useInput
} from 'ink';
import * as os from 'os';
import React, { useState } from 'react';
import type { PowerlineConfig } from '../../types/PowerlineConfig';
import type { Settings } from '../../types/Settings';
import { type PowerlineFontStatus } from '../../utils/powerline';
import { buildEnabledPowerlineSettings } from '../../utils/powerline-settings';
import { ConfirmDialog } from './ConfirmDialog';
import {
List,
type ListEntry
} from './List';
import { PowerlineSeparatorEditor } from './PowerlineSeparatorEditor';
import { PowerlineThemeSelector } from './PowerlineThemeSelector';
type PowerlineMenuValue = 'separator' | 'startCap' | 'endCap' | 'themes';
type Screen = 'menu' | PowerlineMenuValue;
const POWERLINE_MENU_LABEL_WIDTH = 11;
function formatPowerlineMenuLabel(label: string): string {
return label.padEnd(POWERLINE_MENU_LABEL_WIDTH, ' ');
}
export function getSeparatorDisplay(powerlineConfig: PowerlineConfig): string {
const seps = powerlineConfig.separators;
if (seps.length > 1) {
return 'multiple';
}
const sep = seps[0] ?? '\uE0B0';
const presets = [
{ char: '\uE0B0', name: 'Triangle Right' },
{ char: '\uE0B2', name: 'Triangle Left' },
{ char: '\uE0B4', name: 'Round Right' },
{ char: '\uE0B6', name: 'Round Left' }
];
const preset = presets.find(item => item.char === sep);
if (preset) {
return `${preset.char} - ${preset.name}`;
}
return `${sep} - Custom`;
}
export function getCapDisplay(
powerlineConfig: PowerlineConfig,
type: 'start' | 'end'
): string {
const caps = type === 'start'
? powerlineConfig.startCaps
: powerlineConfig.endCaps;
if (caps.length === 0) {
return 'none';
}
if (caps.length > 1) {
return 'multiple';
}
const cap = caps[0];
if (!cap) {
return 'none';
}
const presets = type === 'start' ? [
{ char: '\uE0B2', name: 'Triangle' },
{ char: '\uE0B6', name: 'Round' },
{ char: '\uE0BA', name: 'Lower Triangle' },
{ char: '\uE0BE', name: 'Diagonal' }
] : [
{ char: '\uE0B0', name: 'Triangle' },
{ char: '\uE0B4', name: 'Round' },
{ char: '\uE0B8', name: 'Lower Triangle' },
{ char: '\uE0BC', name: 'Diagonal' }
];
const preset = presets.find(item => item.char === cap);
if (preset) {
return `${preset.char} - ${preset.name}`;
}
return `${cap} - Custom`;
}
export function getThemeDisplay(powerlineConfig: PowerlineConfig): string {
const theme = powerlineConfig.theme;
if (!theme || theme === 'custom') {
return 'Custom';
}
return theme.charAt(0).toUpperCase() + theme.slice(1);
}
export function buildPowerlineSetupMenuItems(
powerlineConfig: PowerlineConfig
): ListEntry<PowerlineMenuValue>[] {
const disabled = !powerlineConfig.enabled;
return [
{
label: formatPowerlineMenuLabel('Separator'),
sublabel: `(${getSeparatorDisplay(powerlineConfig)})`,
value: 'separator',
disabled,
description: 'Choose the glyph used between powerline segments.'
},
{
label: formatPowerlineMenuLabel('Start Cap'),
sublabel: `(${getCapDisplay(powerlineConfig, 'start')})`,
value: 'startCap',
disabled,
description: 'Configure the cap glyph that appears at the start of each powerline line.'
},
{
label: formatPowerlineMenuLabel('End Cap'),
sublabel: `(${getCapDisplay(powerlineConfig, 'end')})`,
value: 'endCap',
disabled,
description: 'Configure the cap glyph that appears at the end of each powerline line.'
},
{
label: formatPowerlineMenuLabel('Themes'),
sublabel: `(${getThemeDisplay(powerlineConfig)})`,
value: 'themes',
disabled,
description: 'Preview built-in powerline themes or copy a theme into custom widget colors.'
}
];
}
export interface PowerlineSetupProps {
settings: Settings;
powerlineFontStatus: PowerlineFontStatus;
onUpdate: (settings: Settings) => void;
onBack: () => void;
onInstallFonts: () => void;
installingFonts: boolean;
fontInstallMessage: string | null;
onClearMessage: () => void;
}
export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
settings,
powerlineFontStatus,
onUpdate,
onBack,
onInstallFonts,
installingFonts,
fontInstallMessage,
onClearMessage
}) => {
const powerlineConfig = settings.powerline;
const [screen, setScreen] = useState<Screen>('menu');
const [selectedMenuItem, setSelectedMenuItem] = useState(0);
const [confirmingEnable, setConfirmingEnable] = useState(false);
const [confirmingFontInstall, setConfirmingFontInstall] = useState(false);
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) {
if (fontInstallMessage && !key.escape) {
onClearMessage();
}
return;
}
if (confirmingFontInstall || confirmingEnable) {
return;
}
if (screen === 'menu') {
if (key.escape) {
onBack();
} else if (input === 't' || input === 'T') {
if (!powerlineConfig.enabled) {
if (hasManualSeparatorItems) {
setConfirmingEnable(true);
} else {
onUpdate(buildEnabledPowerlineSettings(settings, false));
}
} else {
onUpdate({
...settings,
powerline: {
...powerlineConfig,
enabled: false
}
});
}
} else if (input === 'i' || input === 'I') {
setConfirmingFontInstall(true);
} else if ((input === 'a' || input === 'A') && powerlineConfig.enabled) {
onUpdate({
...settings,
powerline: {
...powerlineConfig,
autoAlign: !powerlineConfig.autoAlign
}
});
} else if ((input === 'c' || input === 'C') && powerlineConfig.enabled) {
onUpdate({
...settings,
powerline: {
...powerlineConfig,
continueThemeAcrossLines: !powerlineConfig.continueThemeAcrossLines
}
});
}
}
});
if (screen === 'separator') {
return (
<PowerlineSeparatorEditor
settings={settings}
mode='separator'
onUpdate={onUpdate}
onBack={() => { setScreen('menu'); }}
/>
);
}
if (screen === 'startCap') {
return (
<PowerlineSeparatorEditor
settings={settings}
mode='startCap'
onUpdate={onUpdate}
onBack={() => { setScreen('menu'); }}
/>
);
}
if (screen === 'endCap') {
return (
<PowerlineSeparatorEditor
settings={settings}
mode='endCap'
onUpdate={onUpdate}
onBack={() => { setScreen('menu'); }}
/>
);
}
if (screen === 'themes') {
return (
<PowerlineThemeSelector
settings={settings}
onUpdate={onUpdate}
onBack={() => { setScreen('menu'); }}
/>
);
}
return (
<Box flexDirection='column'>
{!confirmingFontInstall && !installingFonts && !fontInstallMessage && (
<Box>
<Text bold>Powerline Setup</Text>
{globalOverrideMessage && (
<Text color='yellow' dimColor>
{'. '}
{globalOverrideMessage}
</Text>
)}
</Box>
)}
{confirmingFontInstall ? (
<Box flexDirection='column'>
<Box marginBottom={1}>
<Text color='cyan' bold>Font Installation</Text>
</Box>
<Box marginBottom={1} flexDirection='column'>
<Text bold>What will happen:</Text>
<Text>
<Text dimColor> Clone fonts from </Text>
<Text color='blue'>https://github.com/powerline/fonts</Text>
</Text>
{os.platform() === 'darwin' && (
<>
<Text dimColor> Run install.sh script which will:</Text>
<Text dimColor> - Copy all .ttf/.otf files to ~/Library/Fonts</Text>
<Text dimColor> - Register fonts with macOS</Text>
</>
)}
{os.platform() === 'linux' && (
<>
<Text dimColor> Run install.sh script which will:</Text>
<Text dimColor> - Copy all .ttf/.otf files to ~/.local/share/fonts</Text>
<Text dimColor> - Run fc-cache to update font cache</Text>
</>
)}
{os.platform() === 'win32' && (
<>
<Text dimColor> Copy Powerline .ttf/.otf files to:</Text>
<Text dimColor> AppData\Local\Microsoft\Windows\Fonts</Text>
</>
)}
<Text dimColor> Clean up temporary files</Text>
</Box>
<Box marginBottom={1}>
<Text color='yellow' bold>Requirements: </Text>
<Text dimColor>Git installed, Internet connection, Write permissions</Text>
</Box>
<Box marginBottom={1} flexDirection='column'>
<Text color='green' bold>After install:</Text>
<Text dimColor> Restart terminal</Text>
<Text dimColor> Select a Powerline font</Text>
<Text dimColor> (e.g. "Meslo LG S for Powerline")</Text>
</Box>
<Box marginTop={1}>
<Text>Proceed? </Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
setConfirmingFontInstall(false);
onInstallFonts();
}}
onCancel={() => {
setConfirmingFontInstall(false);
}}
/>
</Box>
</Box>
) : confirmingEnable ? (
<Box flexDirection='column' marginTop={1}>
{hasManualSeparatorItems && (
<>
<Box>
<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={hasManualSeparatorItems ? 1 : 0}>
<Text>Do you want to continue? </Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
onUpdate(buildEnabledPowerlineSettings(settings, true));
setConfirmingEnable(false);
}}
onCancel={() => {
setConfirmingEnable(false);
}}
/>
</Box>
</Box>
) : installingFonts ? (
<Box>
<Text color='yellow'>Installing Powerline fonts... This may take a moment.</Text>
</Box>
) : fontInstallMessage ? (
<Box flexDirection='column'>
<Text color={fontInstallMessage.includes('success') ? 'green' : 'red'}>
{fontInstallMessage}
</Text>
<Box marginTop={1}>
<Text dimColor>Press any key to continue...</Text>
</Box>
</Box>
) : (
<>
<Box flexDirection='column'>
<Text>
{' Font Status: '}
{powerlineFontStatus.installed ? (
<>
<Text color='green'> Installed</Text>
<Text dimColor> - Ensure fonts are active in your terminal</Text>
</>
) : (
<>
<Text color='yellow'> Not Installed</Text>
<Text dimColor> - Press (i) to install Powerline fonts</Text>
</>
)}
</Text>
</Box>
<Box>
<Text> Powerline Mode: </Text>
<Text color={powerlineConfig.enabled ? 'green' : 'red'}>
{powerlineConfig.enabled ? '✓ Enabled ' : '✗ Disabled '}
</Text>
<Text dimColor> - Press (t) to toggle</Text>
</Box>
{powerlineConfig.enabled && (
<>
<Box>
<Text> Align Widgets: </Text>
<Text color={powerlineConfig.autoAlign ? 'green' : 'red'}>
{powerlineConfig.autoAlign ? '✓ Enabled ' : '✗ Disabled '}
</Text>
<Text dimColor> - Press (a) to toggle</Text>
</Box>
<Box>
<Text> Continue Theme: </Text>
<Text color={powerlineConfig.continueThemeAcrossLines ? 'green' : 'red'}>
{powerlineConfig.continueThemeAcrossLines ? '✓ Enabled ' : '✗ Disabled '}
</Text>
<Text dimColor> - Press (c) to toggle</Text>
</Box>
<Box flexDirection='column' marginTop={1}>
<Text dimColor>
Powerline mode uses its own separator system
</Text>
<Text dimColor>
Continue Theme keeps the Powerline color sequence running across lines
</Text>
</Box>
</>
)}
{!powerlineConfig.enabled && (
<Box marginTop={1}>
<Text dimColor>Enable Powerline mode to configure separators, caps, and themes.</Text>
</Box>
)}
<List
marginTop={1}
items={buildPowerlineSetupMenuItems(powerlineConfig)}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
setScreen(value);
}}
onSelectionChange={(_, index) => {
setSelectedMenuItem(index);
}}
initialSelection={selectedMenuItem}
showBackButton={true}
/>
</>
)}
</Box>
);
};
@@ -0,0 +1,235 @@
import {
Box,
Text,
useInput
} from 'ink';
import React, {
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { getColorLevelString } from '../../types/ColorLevel';
import type { Settings } from '../../types/Settings';
import {
getPowerlineTheme,
getPowerlineThemes
} from '../../utils/colors';
import { ConfirmDialog } from './ConfirmDialog';
import {
List,
type ListEntry
} from './List';
export function buildPowerlineThemeItems(
themes: string[],
originalTheme: string
): ListEntry<string>[] {
return themes.map((themeName) => {
const theme = getPowerlineTheme(themeName);
return {
label: theme?.name ?? themeName,
sublabel: themeName === originalTheme ? '(original)' : undefined,
value: themeName,
description: theme?.description ?? ''
};
});
}
export function applyCustomPowerlineTheme(
settings: Settings,
themeName: string
): Settings | null {
const theme = getPowerlineTheme(themeName);
if (!theme || themeName === 'custom') {
return null;
}
const colorLevel = getColorLevelString(settings.colorLevel);
const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3';
const themeColors = theme[colorLevelKey];
if (!themeColors) {
return null;
}
const lines = settings.lines.map((line) => {
let widgetColorIndex = 0;
return line.map((widget) => {
if (widget.type === 'separator' || widget.type === 'flex-separator') {
return widget;
}
const fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length];
const bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length];
widgetColorIndex++;
return {
...widget,
color: fgColor,
backgroundColor: bgColor
};
});
});
return {
...settings,
powerline: {
...settings.powerline,
theme: 'custom'
},
lines
};
}
export interface PowerlineThemeSelectorProps {
settings: Settings;
onUpdate: (settings: Settings) => void;
onBack: () => void;
}
export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
settings,
onUpdate,
onBack
}) => {
const themes = useMemo(() => getPowerlineThemes(), []);
const currentTheme = settings.powerline.theme ?? 'custom';
const [selectedIndex, setSelectedIndex] = useState(Math.max(0, themes.indexOf(currentTheme)));
const [showCustomizeConfirm, setShowCustomizeConfirm] = useState(false);
const originalThemeRef = useRef(currentTheme);
const originalSettingsRef = useRef(settings);
const latestSettingsRef = useRef(settings);
const latestOnUpdateRef = useRef(onUpdate);
const didHandleInitialSelectionRef = useRef(false);
useEffect(() => {
latestSettingsRef.current = settings;
latestOnUpdateRef.current = onUpdate;
}, [settings, onUpdate]);
useEffect(() => {
const themeName = themes[selectedIndex];
if (!themeName) {
return;
}
if (!didHandleInitialSelectionRef.current) {
didHandleInitialSelectionRef.current = true;
return;
}
latestOnUpdateRef.current({
...latestSettingsRef.current,
powerline: {
...latestSettingsRef.current.powerline,
theme: themeName
}
});
}, [selectedIndex, themes]);
useInput((input, key) => {
if (showCustomizeConfirm) {
return;
}
if (key.escape) {
onUpdate(originalSettingsRef.current);
onBack();
} else if (input === 'c' || input === 'C') {
const currentThemeName = themes[selectedIndex];
if (currentThemeName && currentThemeName !== 'custom') {
setShowCustomizeConfirm(true);
}
}
});
const selectedThemeName = themes[selectedIndex];
const themeItems = useMemo(
() => buildPowerlineThemeItems(themes, originalThemeRef.current),
[themes]
);
if (showCustomizeConfirm) {
return (
<Box flexDirection='column'>
<Text bold color='yellow'> Confirm Customization</Text>
<Box marginTop={1} flexDirection='column'>
<Text>This will copy the current theme colors to your widgets</Text>
<Text>and switch to Custom theme mode.</Text>
<Text color='red'>This will overwrite any existing custom colors!</Text>
</Box>
<Box marginTop={2}>
<Text>Continue?</Text>
</Box>
<Box marginTop={1}>
<ConfirmDialog
inline={true}
onConfirm={() => {
if (selectedThemeName) {
const updatedSettings = applyCustomPowerlineTheme(settings, selectedThemeName);
if (updatedSettings) {
onUpdate(updatedSettings);
}
}
setShowCustomizeConfirm(false);
onBack();
}}
onCancel={() => {
setShowCustomizeConfirm(false);
}}
/>
</Box>
</Box>
);
}
return (
<Box flexDirection='column'>
<Text bold>
{`Powerline Theme Selection | `}
<Text dimColor>
{`Original: ${originalThemeRef.current}`}
</Text>
</Text>
<Box>
<Text dimColor>
{`↑↓ navigate, Enter apply${selectedThemeName && selectedThemeName !== 'custom' ? ', (c)ustomize theme' : ''}, ESC cancel`}
</Text>
</Box>
<List
marginTop={1}
items={themeItems}
onSelect={() => {
onBack();
}}
onSelectionChange={(themeName, index) => {
if (themeName === 'back') {
return;
}
setSelectedIndex(index);
}}
initialSelection={selectedIndex}
/>
{selectedThemeName && selectedThemeName !== 'custom' && (
<Box marginTop={1}>
<Text dimColor>Press (c) to customize this theme - copies colors to widgets</Text>
</Box>
)}
{settings.colorLevel === 1 && (
<Box marginTop={1}>
<Text color='yellow'> 16 color mode themes have a very limited palette, we recommend switching color level in Terminal Options</Text>
</Box>
)}
</Box>
);
};
+265
View File
@@ -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>
);
};
+145
View File
@@ -0,0 +1,145 @@
import chalk from 'chalk';
import {
Box,
Text
} from 'ink';
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,
type RenderResult
} from '../../utils/renderer';
import { advanceGlobalSeparatorIndex } from '../../utils/separator-index';
export interface StatusLinePreviewProps {
lines: WidgetItem[][];
terminalWidth: number;
settings?: Settings;
onTruncationChange?: (isTruncated: boolean) => void;
}
const renderSingleLine = (
widgets: WidgetItem[],
terminalWidth: number,
settings: Settings,
lineIndex: number,
globalSeparatorIndex: number,
globalPowerlineThemeIndex: number,
globalPowerlineStartCapIndex: number,
preRenderedWidgets: PreRenderedWidget[],
preCalculatedMaxWidths: number[]
): RenderResult => {
// Create render context for preview
const context: RenderContext = {
terminalWidth,
isPreview: true,
minimalist: settings.minimalistMode,
gitCacheTtlSeconds: settings.gitCacheTtlSeconds,
lineIndex,
globalSeparatorIndex,
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
const { renderedLines, anyTruncated } = React.useMemo(() => {
if (!settings)
return { renderedLines: [], anyTruncated: false };
// Always pre-render all widgets once (for efficiency)
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;
for (let i = 0; i < lines.length; i++) {
const lineItems = lines[i];
if (lineItems && lineItems.length > 0) {
const preRenderedWidgets = preRenderedLines[i] ?? [];
const renderResult = renderSingleLine(
lineItems,
terminalWidth,
settings,
i,
globalSeparatorIndex,
globalPowerlineThemeIndex,
globalPowerlineStartCapIndex,
preRenderedWidgets,
preCalculatedMaxWidths
);
result.push(renderResult.line);
if (renderResult.wasTruncated) {
truncated = true;
}
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems, preRenderedWidgets);
if (settings.powerline.enabled) {
globalPowerlineStartCapIndex += countPowerlineStartCapSlots(lineItems, preRenderedWidgets);
}
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
}
}
}
return { renderedLines: result, anyTruncated: truncated };
}, [lines, terminalWidth, settings]);
// Notify parent when truncation status changes
React.useEffect(() => {
onTruncationChange?.(anyTruncated);
}, [anyTruncated, onTruncationChange]);
return (
<Box flexDirection='column'>
<Box borderStyle='round' borderColor='gray' borderDimColor width='100%' paddingLeft={1}>
<Text>
&gt;
<Text dimColor> Preview (ctrl+s to save configuration at any time)</Text>
</Text>
</Box>
{renderedLines.map((line, index) => (
<Text key={index} wrap='truncate'>
{PREVIEW_LINE_INDENT}
{preparePreviewLineForTerminal(line, terminalWidth)}
{chalk.reset('')}
</Text>
))}
</Box>
);
};
+174
View File
@@ -0,0 +1,174 @@
import chalk from 'chalk';
import {
Box,
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import type { Settings } from '../../types/Settings';
import {
hasCustomWidgetColors,
sanitizeLinesForColorLevel
} from '../../utils/color-sanitize';
import { ConfirmDialog } from './ConfirmDialog';
import {
List,
type ListEntry
} from './List';
type TerminalOptionsValue = 'width' | 'colorLevel';
export function getNextColorLevel(level: 0 | 1 | 2 | 3): 0 | 1 | 2 | 3 {
return ((level + 1) % 4) as 0 | 1 | 2 | 3;
}
export function shouldWarnOnColorLevelChange(
currentLevel: 0 | 1 | 2 | 3,
nextLevel: 0 | 1 | 2 | 3,
hasCustomColors: boolean
): boolean {
return hasCustomColors
&& ((currentLevel === 2 && nextLevel !== 2)
|| (currentLevel === 3 && nextLevel !== 3));
}
export function buildTerminalOptionsItems(
colorLevel: 0 | 1 | 2 | 3
): ListEntry<TerminalOptionsValue>[] {
return [
{
label: '◱ Terminal Width',
value: 'width',
description: 'Configure how the status line uses available terminal width and when it should compact.'
},
{
label: '▓ Color Level',
sublabel: `(${getColorLevelLabel(colorLevel)})`,
value: 'colorLevel',
description: [
'Color level affects how colors are rendered:',
'• Truecolor: Full 24-bit RGB colors (16.7M colors)',
'• 256 Color: Extended color palette (256 colors)',
'• Basic: Standard 16-color terminal palette',
'• No Color: Disables all color output'
].join('\n')
}
];
}
export interface TerminalOptionsMenuProps {
settings: Settings;
onUpdate: (settings: Settings) => void;
onBack: (target?: string) => void;
}
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({
settings,
onUpdate,
onBack
}) => {
const [showColorWarning, setShowColorWarning] = useState(false);
const [pendingColorLevel, setPendingColorLevel] = useState<0 | 1 | 2 | 3 | null>(null);
const handleSelect = (value: TerminalOptionsValue | 'back') => {
if (value === 'back') {
onBack();
return;
}
if (value === 'width') {
onBack('width');
return;
}
const hasCustomColors = hasCustomWidgetColors(settings.lines);
const currentLevel = settings.colorLevel;
const nextLevel = getNextColorLevel(currentLevel);
if (shouldWarnOnColorLevelChange(currentLevel, nextLevel, hasCustomColors)) {
setShowColorWarning(true);
setPendingColorLevel(nextLevel);
return;
}
chalk.level = nextLevel;
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, nextLevel);
onUpdate({
...settings,
lines: cleanedLines,
colorLevel: nextLevel
});
};
const handleColorConfirm = () => {
if (pendingColorLevel !== null) {
chalk.level = pendingColorLevel;
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, pendingColorLevel);
onUpdate({
...settings,
lines: cleanedLines,
colorLevel: pendingColorLevel
});
}
setShowColorWarning(false);
setPendingColorLevel(null);
};
const handleColorCancel = () => {
setShowColorWarning(false);
setPendingColorLevel(null);
};
useInput((_, key) => {
if (key.escape && !showColorWarning) {
onBack();
}
});
return (
<Box flexDirection='column'>
<Text bold>Terminal Options</Text>
{showColorWarning ? (
<Box flexDirection='column' marginTop={1}>
<Text color='yellow'> Warning: Custom colors detected!</Text>
<Text>Switching color modes will reset custom ansi256 or hex colors to defaults.</Text>
<Box marginTop={1}>
<ConfirmDialog
message='Continue?'
onConfirm={handleColorConfirm}
onCancel={handleColorCancel}
inline
/>
</Box>
</Box>
) : (
<>
<Text color='white'>Configure terminal-specific settings for optimal display</Text>
<List
marginTop={1}
items={buildTerminalOptionsItems(settings.colorLevel)}
onSelect={handleSelect}
showBackButton={true}
/>
</>
)}
</Box>
);
};
export const getColorLevelLabel = (level?: 0 | 1 | 2 | 3): string => {
switch (level) {
case 0: return 'No Color';
case 1: return 'Basic';
case 2:
case undefined: return '256 Color (default)';
case 3: return 'Truecolor';
default: return '256 Color (default)';
}
};
+177
View File
@@ -0,0 +1,177 @@
import {
Box,
Text,
useInput
} from 'ink';
import React, { useState } from 'react';
import type { FlexMode } from '../../types/FlexMode';
import type { Settings } from '../../types/Settings';
import { shouldInsertInput } from '../../utils/input-guards';
import {
List,
type ListEntry
} from './List';
export const TERMINAL_WIDTH_OPTIONS: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
export function getTerminalWidthSelectionIndex(selectedOption: FlexMode): number {
const selectedIndex = TERMINAL_WIDTH_OPTIONS.indexOf(selectedOption);
return selectedIndex >= 0 ? selectedIndex : 0;
}
export function validateCompactThresholdInput(value: string): string | null {
const parsedValue = parseInt(value, 10);
if (isNaN(parsedValue)) {
return 'Please enter a valid number';
}
if (parsedValue < 1 || parsedValue > 99) {
return `Value must be between 1 and 99 (you entered ${parsedValue})`;
}
return null;
}
export function buildTerminalWidthItems(
selectedOption: FlexMode,
compactThreshold: number
): ListEntry<FlexMode>[] {
return [
{
value: 'full',
label: 'Full width always',
sublabel: selectedOption === 'full' ? '(active)' : undefined,
description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.'
},
{
value: 'full-minus-40',
label: 'Full width minus 40',
sublabel: selectedOption === 'full-minus-40' ? '(active)' : '(default)',
description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.'
},
{
value: 'full-until-compact',
label: 'Full width until compact',
sublabel: selectedOption === 'full-until-compact'
? `(threshold ${compactThreshold}%, active)`
: `(threshold ${compactThreshold}%)`,
description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it is not recommended to use this mode.`
}
];
}
export interface TerminalWidthMenuProps {
settings: Settings;
onUpdate: (settings: Settings) => void;
onBack: () => void;
}
export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({
settings,
onUpdate,
onBack
}) => {
const [selectedOption, setSelectedOption] = useState<FlexMode>(settings.flexMode);
const [compactThreshold, setCompactThreshold] = useState(settings.compactThreshold);
const [editingThreshold, setEditingThreshold] = useState(false);
const [thresholdInput, setThresholdInput] = useState(String(settings.compactThreshold));
const [validationError, setValidationError] = useState<string | null>(null);
useInput((input, key) => {
if (editingThreshold) {
if (key.return) {
const error = validateCompactThresholdInput(thresholdInput);
if (error) {
setValidationError(error);
} else {
const value = parseInt(thresholdInput, 10);
setCompactThreshold(value);
const updatedSettings = {
...settings,
flexMode: selectedOption,
compactThreshold: value
};
onUpdate(updatedSettings);
setEditingThreshold(false);
setValidationError(null);
}
} else if (key.escape) {
setThresholdInput(String(compactThreshold));
setEditingThreshold(false);
setValidationError(null);
} else if (key.backspace) {
setThresholdInput(thresholdInput.slice(0, -1));
setValidationError(null);
} else if (key.delete) {
// For simple number inputs, forward delete does nothing since there's no cursor position
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
const newValue = thresholdInput + input;
if (newValue.length <= 2) {
setThresholdInput(newValue);
setValidationError(null);
}
}
return;
}
if (key.escape) {
onBack();
}
});
return (
<Box flexDirection='column'>
<Text bold>Terminal Width</Text>
<Text color='white'>These settings affect where long lines are truncated, and where right-alignment occurs when using flex separators</Text>
<Text dimColor wrap='wrap'>Claude code does not currently provide an available width variable for the statusline and features like IDE integration, auto-compaction notices, etc all cause the statusline to wrap if we do not truncate it</Text>
{editingThreshold ? (
<Box marginTop={1} flexDirection='column'>
<Text>
Enter compact threshold (1-99):
{' '}
{thresholdInput}
%
</Text>
{validationError ? (
<Text color='red'>{validationError}</Text>
) : (
<Text dimColor>Press Enter to confirm, ESC to cancel</Text>
)}
</Box>
) : (
<List
marginTop={1}
items={buildTerminalWidthItems(selectedOption, compactThreshold)}
initialSelection={getTerminalWidthSelectionIndex(selectedOption)}
onSelect={(value) => {
if (value === 'back') {
onBack();
return;
}
setSelectedOption(value);
const updatedSettings = {
...settings,
flexMode: value,
compactThreshold
};
onUpdate(updatedSettings);
if (value === 'full-until-compact') {
setEditingThreshold(true);
}
}}
showBackButton={true}
/>
)}
</Box>
);
};
+233
View File
@@ -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();
}
});
});
@@ -0,0 +1,299 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import { GlobalOverridesMenu } from '../GlobalOverridesMenu';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream {
clearOutput: () => void;
getOutput: () => string;
}
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
clearOutput() {
chunks.length = 0;
},
getOutput() {
return chunks.join('');
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('GlobalOverridesMenu', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('displays minimalist mode as disabled 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('Minimalist Mode:');
expect(stdout.getOutput()).toContain('✗ Disabled');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('toggles minimalist mode on when (m) 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, minimalistMode: false },
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('m');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ minimalistMode: true }));
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('toggles minimalist mode off when (m) is pressed while enabled', 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, minimalistMode: true },
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('m');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ minimalistMode: false }));
} 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();
}
});
});
@@ -0,0 +1,270 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import stripAnsi from 'strip-ansi';
import {
describe,
expect,
it,
vi
} from 'vitest';
import { InstallMenu } from '../InstallMenu';
const ALL_AVAILABLE = {
npm: true,
npx: true,
bun: true,
bunx: true
};
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
getOutput() {
return stripAnsi(chunks.join(''));
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('InstallMenu', () => {
it('calls onCancel when escape is pressed', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onCancel = vi.fn();
const instance = render(
React.createElement(InstallMenu, {
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('\u001B');
await flushInk();
expect(onCancel).toHaveBeenCalledTimes(1);
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
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, {
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).toContain('Auto-update');
expect(output).toContain('Pinned global install');
expect(output.toLowerCase()).not.toContain('recommended');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('shows pinned global install first and selected by default', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const instance = render(
React.createElement(InstallMenu, {
commandAvailability: ALL_AVAILABLE,
currentVersion: '2.2.13',
existingStatusLine: null,
onSelect: vi.fn(),
onCancel: vi.fn()
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
const output = stdout.getOutput();
expect(output.indexOf('Pinned global install')).toBeLessThan(output.indexOf('Auto-update'));
expect(output).toContain('▶ Pinned global install');
expect(output).not.toContain('▶ Auto-update');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('shows unavailable package managers as disabled in step two', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const instance = render(
React.createElement(InstallMenu, {
commandAvailability: {
npm: true,
npx: false,
bun: true,
bunx: false
},
currentVersion: '2.2.13',
existingStatusLine: null,
onSelect: vi.fn(),
onCancel: vi.fn()
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\r');
await flushInk();
const output = stdout.getOutput();
expect(output).toContain('npm install -g ccstatusline@2.2.13');
expect(output).not.toContain('(npm not installed)');
expect(output).toContain('bun add -g ccstatusline@2.2.13');
expect(output).not.toContain('(bun not installed)');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
it('returns from package manager selection to update style on escape', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onCancel = vi.fn();
const instance = render(
React.createElement(InstallMenu, {
commandAvailability: ALL_AVAILABLE,
currentVersion: '2.2.13',
existingStatusLine: null,
onSelect: vi.fn(),
onCancel
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\r');
await flushInk();
expect(stdout.getOutput()).toContain('Select package manager');
stdin.write('\u001B');
await flushInk();
expect(onCancel).not.toHaveBeenCalled();
expect(stdout.getOutput()).toContain('Select update style');
} finally {
instance.unmount();
instance.cleanup();
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([]);
});
});
@@ -0,0 +1,292 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import {
PowerlineSeparatorEditor,
type PowerlineSeparatorEditorProps
} from '../PowerlineSeparatorEditor';
import {
PowerlineSetup,
buildPowerlineSetupMenuItems,
getCapDisplay,
getSeparatorDisplay,
getThemeDisplay,
type PowerlineSetupProps
} from '../PowerlineSetup';
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('PowerlineSetup helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('formats separator, cap, and theme display values', () => {
const config = {
...DEFAULT_SETTINGS.powerline,
enabled: true,
separators: ['\uE0B4'],
startCaps: ['\uE0B2'],
endCaps: ['\uE0B0'],
theme: 'gruvbox'
};
expect(getSeparatorDisplay(config)).toBe('\uE0B4 - Round Right');
expect(getCapDisplay(config, 'start')).toBe('\uE0B2 - Triangle');
expect(getCapDisplay(config, 'end')).toBe('\uE0B0 - Triangle');
expect(getThemeDisplay(config)).toBe('Gruvbox');
});
it('builds powerline setup items with disabled states and sublabels', () => {
const disabledItems = buildPowerlineSetupMenuItems({
...DEFAULT_SETTINGS.powerline,
enabled: false
});
expect(disabledItems.every(item => item.disabled)).toBe(true);
const enabledItems = buildPowerlineSetupMenuItems({
...DEFAULT_SETTINGS.powerline,
enabled: true,
separators: ['\uE0B0', '\uE0B4'],
startCaps: [],
endCaps: ['\uE0BC'],
theme: undefined
});
expect(enabledItems[0]).toMatchObject({
label: 'Separator ',
sublabel: '(multiple)',
disabled: false
});
expect(enabledItems[1]).toMatchObject({
label: 'Start Cap ',
sublabel: '(none)'
});
expect(enabledItems[2]).toMatchObject({
label: 'End Cap ',
sublabel: '(\uE0BC - Diagonal)'
});
expect(enabledItems[3]).toMatchObject({
label: 'Themes ',
sublabel: '(Custom)'
});
});
it('toggles continue theme across lines when (c) is pressed', 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,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true,
continueThemeAcrossLines: false
}
},
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('Continue Theme:');
stdin.write('c');
await flushInk();
const updatedSettings = onUpdate.mock.calls[0]?.[0];
expect(updatedSettings).toBeDefined();
expect(updatedSettings?.powerline.continueThemeAcrossLines).toBe(true);
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
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();
}
});
});
@@ -0,0 +1,160 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import { getPowerlineThemes } from '../../../utils/colors';
import {
PowerlineThemeSelector,
applyCustomPowerlineTheme,
buildPowerlineThemeItems,
type PowerlineThemeSelectorProps
} from '../PowerlineThemeSelector';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): NodeJS.WriteStream {
return new MockTtyStream() as unknown as NodeJS.WriteStream;
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('PowerlineThemeSelector helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('builds powerline theme list items with original theme sublabels', () => {
const items = buildPowerlineThemeItems(['gruvbox', 'onedark'], 'onedark');
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
label: 'Gruvbox',
value: 'gruvbox'
});
expect(items[1]).toMatchObject({
label: 'One Dark',
sublabel: '(original)',
value: 'onedark'
});
});
it('copies a built-in theme into widget colors and switches to custom mode', () => {
const settings = {
...DEFAULT_SETTINGS,
colorLevel: 2 as const,
powerline: {
...DEFAULT_SETTINGS.powerline,
theme: 'gruvbox'
}
};
const updatedSettings = applyCustomPowerlineTheme(settings, 'gruvbox');
expect(updatedSettings).not.toBeNull();
expect(updatedSettings?.powerline.theme).toBe('custom');
expect(updatedSettings?.lines[0]?.[0]).toMatchObject({
color: 'ansi256:16',
backgroundColor: 'ansi256:167'
});
expect(updatedSettings?.lines[0]?.[1]).toEqual(settings.lines[0]?.[1]);
expect(updatedSettings?.lines[0]?.[2]).toMatchObject({
color: 'ansi256:235',
backgroundColor: 'ansi256:214'
});
});
it('returns null when the requested theme cannot be customized', () => {
expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'custom')).toBeNull();
expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'missing-theme')).toBeNull();
});
it('previews the highlighted theme once without triggering update-depth warnings', async () => {
const themes = getPowerlineThemes();
expect(themes.length).toBeGreaterThan(1);
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn<PowerlineThemeSelectorProps['onUpdate']>();
const onBack = vi.fn();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const instance = render(
React.createElement(PowerlineThemeSelector, {
settings: {
...DEFAULT_SETTINGS,
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true,
theme: themes[0]
}
},
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
expect(onUpdate).not.toHaveBeenCalled();
stdin.write('\u001B[B');
await flushInk();
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate.mock.calls[0]?.[0]?.powerline.theme).toBe(themes[1]);
const maximumUpdateDepthWarnings = consoleErrorSpy.mock.calls.filter((call) => {
return call.some(arg => typeof arg === 'string' && arg.includes('Maximum update depth exceeded'));
});
expect(maximumUpdateDepthWarnings).toHaveLength(0);
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,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();
}
});
});
@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildTerminalOptionsItems,
getNextColorLevel,
shouldWarnOnColorLevelChange
} from '../TerminalOptionsMenu';
describe('TerminalOptionsMenu helpers', () => {
it('cycles color levels in order', () => {
expect(getNextColorLevel(0)).toBe(1);
expect(getNextColorLevel(1)).toBe(2);
expect(getNextColorLevel(2)).toBe(3);
expect(getNextColorLevel(3)).toBe(0);
});
it('warns only when custom colors would be lost', () => {
expect(shouldWarnOnColorLevelChange(2, 3, true)).toBe(true);
expect(shouldWarnOnColorLevelChange(3, 0, true)).toBe(true);
expect(shouldWarnOnColorLevelChange(2, 2, true)).toBe(false);
expect(shouldWarnOnColorLevelChange(1, 2, true)).toBe(false);
expect(shouldWarnOnColorLevelChange(3, 0, false)).toBe(false);
});
it('builds terminal options list items with the current color level label', () => {
const items = buildTerminalOptionsItems(2);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
label: '◱ Terminal Width',
value: 'width'
});
expect(items[1]).toMatchObject({
label: '▓ Color Level',
sublabel: '(256 Color (default))',
value: 'colorLevel'
});
expect(items[1]?.description).toContain('Truecolor');
});
});
@@ -0,0 +1,169 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
afterEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../../types/Settings';
import {
TerminalWidthMenu,
buildTerminalWidthItems,
getTerminalWidthSelectionIndex,
validateCompactThresholdInput
} from '../TerminalWidthMenu';
class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;
setRawMode() {
return this;
}
ref() {
return this;
}
unref() {
return this;
}
}
interface CapturedWriteStream extends NodeJS.WriteStream {
clearOutput: () => void;
getOutput: () => string;
}
function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}
function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});
return Object.assign(stream as unknown as NodeJS.WriteStream, {
clearOutput() {
chunks.length = 0;
},
getOutput() {
return chunks.join('');
}
});
}
function flushInk() {
return new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
describe('TerminalWidthMenu helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('validates compact threshold input', () => {
expect(validateCompactThresholdInput('')).toBe('Please enter a valid number');
expect(validateCompactThresholdInput('0')).toBe('Value must be between 1 and 99 (you entered 0)');
expect(validateCompactThresholdInput('100')).toBe('Value must be between 1 and 99 (you entered 100)');
expect(validateCompactThresholdInput('42')).toBeNull();
});
it('builds terminal width menu items with active and threshold sublabels', () => {
const items = buildTerminalWidthItems('full-until-compact', 60);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({
label: 'Full width always',
value: 'full'
});
expect(items[1]).toMatchObject({
label: 'Full width minus 40',
sublabel: '(default)',
value: 'full-minus-40'
});
expect(items[2]).toMatchObject({
label: 'Full width until compact',
sublabel: '(threshold 60%, active)',
value: 'full-until-compact'
});
expect(items[2]?.description).toContain('60%');
});
it('returns the current option index for list selection', () => {
expect(getTerminalWidthSelectionIndex('full')).toBe(0);
expect(getTerminalWidthSelectionIndex('full-minus-40')).toBe(1);
expect(getTerminalWidthSelectionIndex('full-until-compact')).toBe(2);
});
it('keeps full-until-compact selected after confirming the threshold prompt', async () => {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const onUpdate = vi.fn();
const onBack = vi.fn();
const instance = render(
React.createElement(TerminalWidthMenu, {
settings: {
...DEFAULT_SETTINGS,
flexMode: 'full',
compactThreshold: 60
},
onUpdate,
onBack
}),
{
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
}
);
try {
await flushInk();
stdin.write('\u001B[B');
await flushInk();
stdin.write('\u001B[B');
await flushInk();
stdin.write('\r');
await flushInk();
expect(stdout.getOutput()).toContain('Enter compact threshold (1-99):');
stdout.clearOutput();
stdin.write('\r');
await flushInk();
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
flexMode: 'full-until-compact',
compactThreshold: 60
}));
const output = stdout.getOutput();
expect(output).toContain('▶ Full width until compact');
expect(output).not.toContain('▶ Full width always');
} finally {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
});
});
@@ -0,0 +1,155 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../../../types/Widget';
import {
clearAllWidgetStyling,
cycleWidgetColor,
cycleWidgetDim,
resetWidgetStyling,
toggleWidgetBold,
updateWidgetById
} from '../mutations';
describe('color-menu mutations', () => {
it('updateWidgetById only updates the matching widget', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input', color: 'blue' },
{ id: '2', type: 'tokens-output', color: 'white' }
];
const updated = updateWidgetById(widgets, '1', widget => ({
...widget,
color: 'red'
}));
expect(updated[0]?.color).toBe('red');
expect(updated[1]?.color).toBe('white');
});
it('toggleWidgetBold flips bold state for the selected widget only', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input', bold: true },
{ id: '2', type: 'tokens-output', bold: false }
];
const updated = toggleWidgetBold(widgets, '1');
expect(updated[0]?.bold).toBe(false);
expect(updated[1]?.bold).toBe(false);
});
it('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,
dim: 'parens'
},
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
];
const updated = resetWidgetStyling(widgets, '1');
expect(updated[0]).toEqual({ id: '1', type: 'tokens-input' });
expect(updated[1]).toEqual({ id: '2', type: 'tokens-output', color: 'white', bold: true });
});
it('clearAllWidgetStyling strips styling fields from every widget', () => {
const widgets: WidgetItem[] = [
{
id: '1',
type: 'tokens-input',
color: 'red',
backgroundColor: 'blue',
bold: true,
dim: true
},
{ id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens' }
];
const updated = clearAllWidgetStyling(widgets);
expect(updated).toEqual([
{ id: '1', type: 'tokens-input' },
{ id: '2', type: 'tokens-output' }
]);
});
it('cycles background colors and maps empty background to undefined', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'tokens-input', backgroundColor: 'bg:red' }
];
const right = cycleWidgetColor({
widgets,
widgetId: '1',
direction: 'right',
editingBackground: true,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
const left = cycleWidgetColor({
widgets: right,
widgetId: '1',
direction: 'left',
editingBackground: true,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
expect(right[0]?.backgroundColor).toBeUndefined();
expect(left[0]?.backgroundColor).toBe('bg:red');
});
it('cycles foreground colors from widget default and treats dim as default', () => {
const fromDefault: WidgetItem[] = [
{ id: '1', type: 'tokens-input' }
];
const fromDim: WidgetItem[] = [
{ id: '1', type: 'tokens-input', color: 'dim' }
];
const defaultCycle = cycleWidgetColor({
widgets: fromDefault,
widgetId: '1',
direction: 'right',
editingBackground: false,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
const dimCycle = cycleWidgetColor({
widgets: fromDim,
widgetId: '1',
direction: 'right',
editingBackground: false,
colors: ['blue', 'red'],
backgroundColors: ['bg:red', '']
});
expect(defaultCycle[0]?.color).toBe('red');
expect(dimCycle[0]?.color).toBe('red');
});
});
+175
View File
@@ -0,0 +1,175 @@
import type { WidgetItem } from '../../../types/Widget';
import { getWidget } from '../../../utils/widgets';
export function updateWidgetById(
widgets: WidgetItem[],
widgetId: string,
updater: (widget: WidgetItem) => WidgetItem
): WidgetItem[] {
return widgets.map(widget => widget.id === widgetId ? updater(widget) : widget);
}
export function setWidgetColor(
widgets: WidgetItem[],
widgetId: string,
color: string,
editingBackground: boolean
): WidgetItem[] {
return updateWidgetById(widgets, widgetId, (widget) => {
if (editingBackground) {
return {
...widget,
backgroundColor: color
};
}
return {
...widget,
color
};
});
}
export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
return updateWidgetById(widgets, widgetId, widget => ({
...widget,
bold: !widget.bold
}));
}
export function 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;
});
}
export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] {
return widgets.map((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;
});
}
function getDefaultForegroundColor(widget: WidgetItem): string {
if (widget.type === 'separator' || widget.type === 'flex-separator') {
return 'white';
}
const widgetImpl = getWidget(widget.type);
return widgetImpl ? widgetImpl.getDefaultColor() : 'white';
}
function getNextIndex(currentIndex: number, length: number, direction: 'left' | 'right'): number {
if (direction === 'right') {
return (currentIndex + 1) % length;
}
return currentIndex === 0 ? length - 1 : currentIndex - 1;
}
export interface CycleWidgetColorOptions {
widgets: WidgetItem[];
widgetId: string;
direction: 'left' | 'right';
editingBackground: boolean;
colors: string[];
backgroundColors: string[];
}
export function cycleWidgetColor({
widgets,
widgetId,
direction,
editingBackground,
colors,
backgroundColors
}: CycleWidgetColorOptions): WidgetItem[] {
return updateWidgetById(widgets, widgetId, (widget) => {
if (editingBackground) {
if (backgroundColors.length === 0) {
return widget;
}
const currentBgColor = widget.backgroundColor ?? '';
let currentBgColorIndex = backgroundColors.indexOf(currentBgColor);
if (currentBgColorIndex === -1) {
currentBgColorIndex = 0;
}
const nextBgColorIndex = getNextIndex(currentBgColorIndex, backgroundColors.length, direction);
const nextBgColor = backgroundColors[nextBgColorIndex];
return {
...widget,
backgroundColor: nextBgColor === '' ? undefined : nextBgColor
};
}
if (colors.length === 0) {
return widget;
}
const defaultColor = getDefaultForegroundColor(widget);
let currentColor = widget.color ?? defaultColor;
if (currentColor === 'dim') {
currentColor = defaultColor;
}
let currentColorIndex = colors.indexOf(currentColor);
if (currentColorIndex === -1) {
currentColorIndex = 0;
}
const nextColorIndex = getNextIndex(currentColorIndex, colors.length, direction);
const nextColor = colors[nextColorIndex];
return {
...widget,
color: nextColor
};
});
}
+15
View File
@@ -0,0 +1,15 @@
// Barrel file - exports all components and their types
export * from './ColorMenu';
export * from './ConfirmDialog';
export * from './GlobalOverridesMenu';
export * from './InstallMenu';
export * from './ItemsEditor';
export * from './LineSelector';
export * from './MainMenu';
export * from './ManageInstallationMenu';
export * from './PowerlineSetup';
export * from './RefreshIntervalMenu';
export * from './StatusLinePreview';
export * from './TerminalOptionsMenu';
export * from './TerminalWidthMenu';
export * from './UpdateCheckerMenu';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,502 @@
import type {
CustomKeybind,
Widget,
WidgetItem,
WidgetItemType
} from '../../../types/Widget';
import { generateGuid } from '../../../utils/guid';
import {
filterWidgetCatalog,
getWidget,
type WidgetCatalogEntry
} from '../../../utils/widgets';
export type WidgetPickerAction = 'change' | 'add' | 'insert';
export type WidgetPickerLevel = 'category' | 'widget';
export interface WidgetPickerState {
action: WidgetPickerAction;
level: WidgetPickerLevel;
selectedCategory: string | null;
categoryQuery: string;
widgetQuery: string;
selectedType: WidgetItemType | null;
}
export interface CustomEditorWidgetState {
widget: WidgetItem;
impl: Widget;
action?: string;
}
export interface InputKey {
ctrl?: boolean;
meta?: boolean;
tab?: boolean;
shift?: boolean;
upArrow?: boolean;
downArrow?: boolean;
leftArrow?: boolean;
rightArrow?: boolean;
return?: boolean;
escape?: boolean;
backspace?: boolean;
delete?: boolean;
}
type Setter<T> = (value: T | ((prev: T) => T)) => void;
function setPickerState(
setWidgetPicker: Setter<WidgetPickerState | null>,
normalizeState: (state: WidgetPickerState) => WidgetPickerState,
updater: (prev: WidgetPickerState) => WidgetPickerState
): void {
setWidgetPicker((prev) => {
if (!prev) {
return prev;
}
return normalizeState(updater(prev));
});
}
function getPickerCategories(widgetCategories: string[]): string[] {
return [...widgetCategories];
}
export function normalizePickerState(
state: WidgetPickerState,
widgetCatalog: WidgetCatalogEntry[],
widgetCategories: string[]
): WidgetPickerState {
const filteredCategories = getPickerCategories(widgetCategories);
const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory)
? state.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0;
const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All');
const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery;
const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery);
const hasSelectedType = state.selectedType
? filteredWidgets.some(entry => entry.type === state.selectedType)
: false;
return {
...state,
selectedCategory,
selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null)
};
}
interface PickerViewState {
filteredCategories: string[];
selectedCategory: string | null;
hasTopLevelSearch: boolean;
topLevelSearchEntries: WidgetCatalogEntry[];
topLevelSelectedEntry: WidgetCatalogEntry | undefined;
filteredWidgets: WidgetCatalogEntry[];
selectedEntry: WidgetCatalogEntry | undefined;
}
function getPickerViewState(
widgetPicker: WidgetPickerState,
widgetCatalog: WidgetCatalogEntry[],
widgetCategories: string[]
): PickerViewState {
const filteredCategories = getPickerCategories(widgetCategories);
const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory)
? widgetPicker.selectedCategory
: (filteredCategories[0] ?? null);
const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0;
const topLevelSearchEntries = hasTopLevelSearch
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
: [];
const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0];
const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery);
const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0];
return {
filteredCategories,
selectedCategory,
hasTopLevelSearch,
topLevelSearchEntries,
topLevelSelectedEntry,
filteredWidgets,
selectedEntry
};
}
export interface HandlePickerInputModeArgs {
input: string;
key: InputKey;
widgetPicker: WidgetPickerState;
widgetCatalog: WidgetCatalogEntry[];
widgetCategories: string[];
setWidgetPicker: Setter<WidgetPickerState | null>;
applyWidgetPickerSelection: (selectedType: WidgetItemType) => void;
}
export function handlePickerInputMode({
input,
key,
widgetPicker,
widgetCatalog,
widgetCategories,
setWidgetPicker,
applyWidgetPickerSelection
}: HandlePickerInputModeArgs): void {
const normalizeState = (state: WidgetPickerState) => normalizePickerState(state, widgetCatalog, widgetCategories);
const {
filteredCategories,
selectedCategory,
hasTopLevelSearch,
topLevelSearchEntries,
topLevelSelectedEntry,
filteredWidgets,
selectedEntry
} = getPickerViewState(widgetPicker, widgetCatalog, widgetCategories);
if (widgetPicker.level === 'category') {
if (key.escape) {
if (widgetPicker.categoryQuery.length > 0) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: ''
}));
} else {
setWidgetPicker(null);
}
} else if (key.return) {
if (hasTopLevelSearch) {
if (topLevelSelectedEntry) {
applyWidgetPickerSelection(topLevelSelectedEntry.type);
}
} else if (selectedCategory) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
level: 'widget',
selectedCategory
}));
}
} else if (key.upArrow || key.downArrow) {
if (hasTopLevelSearch) {
if (topLevelSearchEntries.length === 0) {
return;
}
let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? (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,
selectedType: nextType
}));
} else {
if (filteredCategories.length === 0) {
return;
}
let currentIndex = filteredCategories.findIndex(category => category === selectedCategory);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? (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,
selectedCategory: nextCategory
}));
}
} else if (key.backspace || key.delete) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery.slice(0, -1),
selectedType: null
}));
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
categoryQuery: prev.categoryQuery + input,
selectedType: null
}));
}
} else {
if (key.escape) {
if (widgetPicker.widgetQuery.length > 0) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: ''
}));
} else {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
level: 'category'
}));
}
} else if (key.return) {
if (selectedEntry) {
applyWidgetPickerSelection(selectedEntry.type);
}
} else if (key.upArrow || key.downArrow) {
if (filteredWidgets.length === 0) {
return;
}
let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType);
if (currentIndex === -1) {
currentIndex = 0;
}
const nextIndex = key.downArrow
? (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,
selectedType: nextType
}));
} else if (key.backspace || key.delete) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery.slice(0, -1),
selectedType: null
}));
} else if (
input
&& !key.ctrl
&& !key.meta
&& !key.tab
) {
setPickerState(setWidgetPicker, normalizeState, prev => ({
...prev,
widgetQuery: prev.widgetQuery + input,
selectedType: null
}));
}
}
}
export interface HandleMoveInputModeArgs {
key: InputKey;
widgets: WidgetItem[];
selectedIndex: number;
onUpdate: (widgets: WidgetItem[]) => void;
setSelectedIndex: (index: number) => void;
setMoveMode: (moveMode: boolean) => void;
}
export function handleMoveInputMode({
key,
widgets,
selectedIndex,
onUpdate,
setSelectedIndex,
setMoveMode
}: HandleMoveInputModeArgs): void {
if (key.upArrow && widgets.length > 1) {
const newWidgets = [...widgets];
const targetIndex = selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1;
const temp = newWidgets[selectedIndex];
const prev = newWidgets[targetIndex];
if (temp && prev) {
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [prev, temp];
}
onUpdate(newWidgets);
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[targetIndex];
if (temp && next) {
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [next, temp];
}
onUpdate(newWidgets);
setSelectedIndex(targetIndex);
} else if (key.escape || key.return) {
setMoveMode(false);
}
}
export interface HandleNormalInputModeArgs {
input: string;
key: InputKey;
widgets: WidgetItem[];
selectedIndex: number;
canExcludeAlign?: boolean;
separatorChars: string[];
onBack: () => void;
onUpdate: (widgets: WidgetItem[]) => void;
setSelectedIndex: (index: number) => void;
setMoveMode: (moveMode: boolean) => void;
setShowClearConfirm: (show: boolean) => void;
openWidgetPicker: (action: WidgetPickerAction) => void;
getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
getUniqueBackgroundColor?: (insertIndex: number) => string | undefined;
}
export function handleNormalInputMode({
input,
key,
widgets,
selectedIndex,
canExcludeAlign = false,
separatorChars,
onBack,
onUpdate,
setSelectedIndex,
setMoveMode,
setShowClearConfirm,
openWidgetPicker,
getCustomKeybindsForWidget,
setCustomEditorWidget,
getUniqueBackgroundColor
}: HandleNormalInputModeArgs): void {
if (key.upArrow && widgets.length > 0) {
setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1);
} else if (key.downArrow && widgets.length > 0) {
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) {
openWidgetPicker('change');
} else if (key.return && widgets.length > 0) {
setMoveMode(true);
} else if (input === 'a') {
openWidgetPicker('add');
} else if (input === 'i') {
openWidgetPicker('insert');
} else if (input === 'd' && widgets.length > 0) {
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
onUpdate(newWidgets);
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
} else if (input === '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);
}
} else if (input === ' ' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget?.type === 'separator') {
const currentChar = currentWidget.character ?? '|';
const currentCharIndex = separatorChars.indexOf(currentChar);
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
onUpdate(newWidgets);
}
} else if (input === 'r' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.supportsRawValue()) {
return;
}
const newWidgets = [...widgets];
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
onUpdate(newWidgets);
}
} else if (input === 'm' && widgets.length > 0) {
const currentWidget = widgets[selectedIndex];
if (currentWidget && selectedIndex < widgets.length - 1
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const newWidgets = [...widgets];
let nextMergeState: boolean | 'no-padding' | undefined;
if (currentWidget.merge === undefined) {
nextMergeState = true;
} else if (currentWidget.merge === true) {
nextMergeState = 'no-padding';
} else {
nextMergeState = undefined;
}
if (nextMergeState === undefined) {
const { merge, ...rest } = currentWidget;
void merge; // Intentionally unused
newWidgets[selectedIndex] = rest;
} else {
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
}
onUpdate(newWidgets);
}
} else if (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) {
const currentWidget = widgets[selectedIndex];
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
const widgetImpl = getWidget(currentWidget.type);
if (!widgetImpl?.getCustomKeybinds) {
return;
}
const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
if (matchedKeybind && !key.ctrl) {
if (widgetImpl.handleEditorAction) {
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
if (updatedWidget) {
const newWidgets = [...widgets];
newWidgets[selectedIndex] = updatedWidget;
onUpdate(newWidgets);
} else if (widgetImpl.renderEditor) {
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
} else if (widgetImpl.renderEditor) {
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
export { runTUI } from './App';
+4
View File
@@ -0,0 +1,4 @@
export interface BlockMetrics {
startTime: Date;
lastActivity: Date;
}
+16
View File
@@ -0,0 +1,16 @@
import type { TranscriptThinkingEffort } from '../utils/jsonl-metadata';
export interface ClaudeSettings {
effortLevel?: TranscriptThinkingEffort;
permissions?: {
allow?: string[];
deny?: string[];
};
statusLine?: {
type: string;
command: string;
padding?: number;
refreshInterval?: number;
};
[key: string]: unknown;
}
+10
View File
@@ -0,0 +1,10 @@
import type { ChalkInstance } from 'chalk';
export interface ColorEntry {
name: string;
displayName: string;
isBackground: boolean;
ansi16: ChalkInstance;
ansi256: ChalkInstance;
truecolor: ChalkInstance;
}
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod';
// Schema for chalk color level
export const ColorLevelSchema = z.union([
z.literal(0), // No colors
z.literal(1), // Basic 16 colors
z.literal(2), // 256 colors
z.literal(3) // Truecolor (16 million)
]);
// Inferred type from schema
export type ColorLevel = z.infer<typeof ColorLevelSchema>;
// String representation for different color levels
export type ColorLevelString = 'ansi16' | 'ansi256' | 'truecolor';
// Helper to get color level as string for chalk
export function getColorLevelString(level: ColorLevel | undefined): ColorLevelString {
switch (level) {
case 0:
case 1:
return 'ansi16';
case 3:
return 'truecolor';
case 2:
default:
return 'ansi256';
}
}
+7
View File
@@ -0,0 +1,7 @@
import { z } from 'zod';
// Schema for terminal width handling modes
export const FlexModeSchema = z.enum(['full', 'full-minus-40', 'full-until-compact']);
// Inferred type from schema
export type FlexMode = z.infer<typeof FlexModeSchema>;
+16
View File
@@ -0,0 +1,16 @@
import { z } from 'zod';
// Schema for powerline configuration
export const PowerlineConfigSchema = z.object({
enabled: z.boolean().default(false),
separators: z.array(z.string()).default(['\uE0B0']),
separatorInvertBackground: z.array(z.boolean()).default([false]),
startCaps: z.array(z.string()).default([]),
endCaps: z.array(z.string()).default([]),
theme: z.string().optional(),
autoAlign: z.boolean().default(false),
continueThemeAcrossLines: z.boolean().default(false)
});
// Inferred type from schema
export type PowerlineConfig = z.infer<typeof PowerlineConfigSchema>;
+4
View File
@@ -0,0 +1,4 @@
export interface PowerlineFontStatus {
installed: boolean;
checkedSymbol?: string;
}
+58
View File
@@ -0,0 +1,58 @@
import type {
BlockMetrics,
SkillsMetrics
} from '../types';
import type { SpeedMetrics } from './SpeedMetrics';
import type { StatusJSON } from './StatusJSON';
import type { TokenMetrics } from './TokenMetrics';
export interface RenderUsageData {
sessionUsage?: number;
sessionResetAt?: string;
weeklyUsage?: number;
weeklyResetAt?: string;
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;
speedMetrics?: SpeedMetrics | null;
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
usageData?: RenderUsageData | null;
sessionDuration?: string | null;
blockMetrics?: BlockMetrics | null;
skillsMetrics?: SkillsMetrics | null;
compactionData?: CompactionData | null;
terminalWidth?: number | null;
isPreview?: boolean;
minimalist?: boolean;
gitCacheTtlSeconds?: number;
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
globalSeparatorIndex?: number; // Global separator index that continues across lines
// For git widget thresholds
gitData?: {
changedFiles?: number;
insertions?: number;
deletions?: number;
};
globalPowerlineThemeIndex?: number; // Global powerline theme index that continues across lines
globalPowerlineStartCapIndex?: number; // Global start cap index across powerline flex segments and lines
}
+98
View File
@@ -0,0 +1,98 @@
import { z } from 'zod';
import { ColorLevelSchema } from './ColorLevel';
import { FlexModeSchema } from './FlexMode';
import { PowerlineConfigSchema } from './PowerlineConfig';
import { WidgetItemSchema } from './Widget';
// Current version - bump this when making breaking changes to the schema
export const CURRENT_VERSION = 3;
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(),
flexMode: FlexModeSchema.optional(),
compactThreshold: z.number().optional(),
colorLevel: ColorLevelSchema.optional(),
defaultSeparator: z.string().optional(),
defaultPadding: z.string().optional(),
inheritSeparatorColors: z.boolean().optional(),
overrideBackgroundColor: z.string().optional(),
overrideForegroundColor: z.string().optional(),
globalBold: z.boolean().optional()
});
// Main settings schema with defaults
export const SettingsSchema = z.object({
version: z.number().default(CURRENT_VERSION),
lines: z.array(z.array(WidgetItemSchema))
.min(1)
.default([
[
{ id: '1', type: 'model', color: 'cyan' },
{ id: '2', type: 'separator' },
{ id: '3', type: 'context-length', color: 'brightBlack' },
{ id: '4', type: 'separator' },
{ id: '5', type: 'git-branch', color: 'magenta' },
{ id: '6', type: 'separator' },
{ id: '7', type: 'git-changes', color: 'yellow' }
],
[],
[]
]), // Ensure max 3 lines
flexMode: FlexModeSchema.default('full-minus-40'),
compactThreshold: z.number().min(1).max(99).default(60),
colorLevel: ColorLevelSchema.default(2),
defaultSeparator: z.string().optional(),
defaultPadding: z.string().optional(),
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,
separators: ['\uE0B0'],
separatorInvertBackground: [false],
startCaps: [],
endCaps: [],
theme: undefined,
autoAlign: false,
continueThemeAcrossLines: false
}),
updatemessage: z.object({
message: z.string().nullable().optional(),
remaining: z.number().nullable().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({});
+12
View File
@@ -0,0 +1,12 @@
export interface SkillInvocation {
timestamp: string;
session_id: string;
skill: string;
source: string;
}
export interface SkillsMetrics {
totalInvocations: number;
uniqueSkills: string[];
lastSkill: string | null;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Speed metrics for calculating token processing rates.
* Provides time-based data needed for speed calculations.
*/
export interface SpeedMetrics {
/** Active processing duration in milliseconds (sum of user request → assistant response times) */
totalDurationMs: number;
/** Total input tokens across all requests */
inputTokens: number;
/** Total output tokens across all requests */
outputTokens: number;
/** Total tokens (input + output) */
totalTokens: number;
/** Number of assistant usage entries included in speed aggregation */
requestCount: number;
}
+80
View File
@@ -0,0 +1,80 @@
import { z } from 'zod';
const CoercedNumberSchema = z.preprocess((value) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return value;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : value;
}, z.number());
const RateLimitPeriodSchema = z.object({
used_percentage: CoercedNumberSchema.nullable().optional(),
resets_at: CoercedNumberSchema.nullable().optional() // Unix epoch seconds
});
export const StatusJSONSchema = z.looseObject({
hook_event_name: z.string().optional(),
session_id: z.string().optional(),
transcript_path: z.string().optional(),
cwd: z.string().optional(),
model: z.union([
z.string(),
z.object({
id: z.string().optional(),
display_name: z.string().optional()
})
]).optional(),
workspace: z.object({
current_dir: z.string().optional(),
project_dir: z.string().optional()
}).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(),
total_api_duration_ms: CoercedNumberSchema.optional(),
total_lines_added: CoercedNumberSchema.optional(),
total_lines_removed: CoercedNumberSchema.optional()
}).optional(),
context_window: z.object({
context_window_size: CoercedNumberSchema.nullable().optional(),
total_input_tokens: CoercedNumberSchema.nullable().optional(),
total_output_tokens: CoercedNumberSchema.nullable().optional(),
current_usage: z.union([
CoercedNumberSchema,
z.object({
input_tokens: CoercedNumberSchema.optional(),
output_tokens: CoercedNumberSchema.optional(),
cache_creation_input_tokens: CoercedNumberSchema.optional(),
cache_read_input_tokens: CoercedNumberSchema.optional()
})
]).nullable().optional(),
used_percentage: CoercedNumberSchema.nullable().optional(),
remaining_percentage: CoercedNumberSchema.nullable().optional()
}).nullable().optional(),
vim: z.object({ mode: z.string().optional() }).nullable().optional(),
worktree: z.object({
name: z.string().optional(),
path: z.string().optional(),
branch: z.string().optional(),
original_cwd: z.string().optional(),
original_branch: z.string().optional()
}).nullable().optional(),
rate_limits: z.object({
five_hour: 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>;
+26
View File
@@ -0,0 +1,26 @@
export interface TokenUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
export interface TranscriptLine {
message?: { usage?: TokenUsage; stop_reason?: string | null };
isSidechain?: boolean;
timestamp?: string;
isApiErrorMessage?: boolean;
type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot';
}
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;
}
+63
View File
@@ -0,0 +1,63 @@
import { z } from 'zod';
import type { RenderContext } from './RenderContext';
import type { Settings } from './Settings';
// Widget item schema - accepts any string type for forward compatibility
export const WidgetItemSchema = z.object({
id: z.string(),
type: z.string(),
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(),
customSymbol: z.string().optional(),
commandPath: z.string().optional(),
maxWidth: z.number().optional(),
preserveColors: z.boolean().optional(),
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()
});
// Inferred types from Zod schemas
export type WidgetItem = z.infer<typeof WidgetItemSchema>;
export type WidgetItemType = string; // Allow any string for forward compatibility
export interface WidgetEditorDisplay {
displayText: string;
modifierText?: string;
}
export interface Widget {
getDefaultColor(): string;
getDescription(): string;
getDisplayName(): string;
getCategory(): string;
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay;
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null;
getCustomKeybinds?(item?: WidgetItem): CustomKeybind[];
renderEditor?(props: WidgetEditorProps): React.ReactElement | null;
supportsRawValue(): boolean;
supportsColors(item: WidgetItem): boolean;
handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null;
getNumericValue?(context: RenderContext, item: WidgetItem): number | null;
}
export interface WidgetEditorProps {
widget: WidgetItem;
onComplete: (updatedWidget: WidgetItem) => void;
onCancel: () => void;
action?: string;
}
export interface CustomKeybind {
key: string;
label: string;
action: string;
}
+114
View File
@@ -0,0 +1,114 @@
import {
describe,
expect,
it
} from 'vitest';
import { StatusJSONSchema } from '../StatusJSON';
describe('StatusJSONSchema numeric coercion', () => {
it('coerces numeric strings to numbers', () => {
const result = StatusJSONSchema.safeParse({
cost: {
total_cost_usd: '1.25',
total_duration_ms: '12345',
total_api_duration_ms: '2345',
total_lines_added: '12',
total_lines_removed: '3'
},
context_window: {
context_window_size: '200000',
total_input_tokens: '1200',
total_output_tokens: '340',
current_usage: {
input_tokens: '100',
output_tokens: '50',
cache_creation_input_tokens: '20',
cache_read_input_tokens: '10'
},
used_percentage: '9.3',
remaining_percentage: '90.7'
}
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.cost?.total_duration_ms).toBe(12345);
expect(result.data.context_window?.context_window_size).toBe(200000);
expect(result.data.context_window?.current_usage).toEqual({
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 10
});
expect(result.data.context_window?.used_percentage).toBe(9.3);
});
it('keeps invalid numeric strings rejected', () => {
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: 'not-a-number' } });
expect(result.success).toBe(false);
});
it('keeps empty numeric strings rejected', () => {
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: '' } });
expect(result.success).toBe(false);
});
it('accepts null vim payloads', () => {
const result = StatusJSONSchema.safeParse({ vim: null });
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.vim).toBeNull();
});
it('parses rate_limits with valid data', () => {
const result = StatusJSONSchema.safeParse({
rate_limits: {
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
}
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.rate_limits?.five_hour?.used_percentage).toBe(42);
expect(result.data.rate_limits?.five_hour?.resets_at).toBe(1774020000);
expect(result.data.rate_limits?.seven_day?.used_percentage).toBe(15);
expect(result.data.rate_limits?.seven_day?.resets_at).toBe(1774540000);
});
it('accepts null rate_limits', () => {
const result = StatusJSONSchema.safeParse({ rate_limits: null });
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.rate_limits).toBeNull();
});
it('coerces rate_limits string numbers', () => {
const result = StatusJSONSchema.safeParse({ rate_limits: { five_hour: { used_percentage: '42', resets_at: '1774020000' } } });
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.data.rate_limits?.five_hour?.used_percentage).toBe(42);
expect(result.data.rate_limits?.five_hour?.resets_at).toBe(1774020000);
});
});
+22
View File
@@ -0,0 +1,22 @@
// Central export file for all types
export type {
CustomKeybind,
Widget,
WidgetEditorProps,
WidgetItem,
WidgetItemType
} from './Widget';
export type { Settings } from './Settings';
export type { FlexMode } from './FlexMode';
export type { PowerlineConfig } from './PowerlineConfig';
export type { ColorLevel, ColorLevelString } from './ColorLevel';
export { getColorLevelString } from './ColorLevel';
export type { StatusJSON } from './StatusJSON';
export type { TokenMetrics, TokenUsage, TranscriptLine } from './TokenMetrics';
export type { RenderContext } from './RenderContext';
export type { PowerlineFontStatus } from './PowerlineFontStatus';
export type { ClaudeSettings } from './ClaudeSettings';
export type { ColorEntry } from './ColorEntry';
export type { BlockMetrics } from './BlockMetrics';
export type { SpeedMetrics } from './SpeedMetrics';
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
+291
View File
@@ -0,0 +1,291 @@
import * as fs from 'fs';
import { createHash } from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
getBlockCachePath,
readBlockCache,
writeBlockCache
} from '../jsonl';
function getExpectedCachePath(homeDir: string, configDir: string): string {
const normalizedConfigDir = path.resolve(configDir);
const configHash = createHash('sha256')
.update(normalizedConfigDir)
.digest('hex')
.slice(0, 16);
return path.join(homeDir, '.cache', 'ccstatusline', `block-cache-${configHash}.json`);
}
describe('Block Cache Functions', () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
beforeEach(() => {
// Create a temp directory for test isolation
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-test-'));
// Mock os.homedir to use temp directory
vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-default');
});
afterEach(() => {
vi.restoreAllMocks();
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
}
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('getBlockCachePath', () => {
it('should return the correct cache path', () => {
const cachePath = getBlockCachePath();
expect(cachePath).toBe(getExpectedCachePath(tempDir, path.join(tempDir, '.claude-default')));
});
it('should return different cache paths for different config directories', () => {
const profileA = path.join(tempDir, '.claude-profile-a');
const profileB = path.join(tempDir, '.claude-profile-b');
const pathA = getBlockCachePath(profileA);
const pathB = getBlockCachePath(profileB);
expect(pathA).toBe(getExpectedCachePath(tempDir, profileA));
expect(pathB).toBe(getExpectedCachePath(tempDir, profileB));
expect(pathA).not.toBe(pathB);
});
});
describe('readBlockCache', () => {
it('should return null when cache file does not exist', () => {
const result = readBlockCache();
expect(result).toBeNull();
});
it('should return cached date when cache file is valid', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({ startTime: testDate.toISOString() }));
const result = readBlockCache();
expect(result).toEqual(testDate);
});
it('should return cached date when configDir matches expected value', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const configDir = path.join(tempDir, '.claude-profile-a');
const cachePath = getBlockCachePath(configDir);
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
cachePath,
JSON.stringify({ startTime: testDate.toISOString(), configDir })
);
const result = readBlockCache(configDir);
expect(result).toEqual(testDate);
});
it('should return null when cache configDir does not match expected value', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const expectedConfigDir = path.join(tempDir, '.claude-profile-b');
const cachePath = getBlockCachePath(expectedConfigDir);
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
cachePath,
JSON.stringify({
startTime: testDate.toISOString(),
configDir: path.join(tempDir, '.claude-profile-a')
})
);
const result = readBlockCache(expectedConfigDir);
expect(result).toBeNull();
});
it('should return null when expected configDir is provided but cache has legacy schema', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const expectedConfigDir = path.join(tempDir, '.claude-profile-a');
const cachePath = getBlockCachePath(expectedConfigDir);
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({ startTime: testDate.toISOString() }));
const result = readBlockCache(expectedConfigDir);
expect(result).toBeNull();
});
it('should return null when cache file has invalid JSON', () => {
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, 'not valid json');
const result = readBlockCache();
expect(result).toBeNull();
});
it('should return null when cache file has missing startTime', () => {
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({}));
const result = readBlockCache();
expect(result).toBeNull();
});
it('should return null when startTime is not a string', () => {
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({ startTime: 12345 }));
const result = readBlockCache();
expect(result).toBeNull();
});
it('should return null when startTime is invalid date string', () => {
const cachePath = getBlockCachePath();
const cacheDir = path.dirname(cachePath);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify({ startTime: 'not a date' }));
const result = readBlockCache();
expect(result).toBeNull();
});
});
describe('writeBlockCache', () => {
it('should create directory and write cache file', () => {
const testDate = new Date('2025-01-26T14:00:00.000Z');
const configDir = path.join(tempDir, '.claude-profile-a');
writeBlockCache(testDate, configDir);
const cachePath = getBlockCachePath(configDir);
expect(fs.existsSync(cachePath)).toBe(true);
const content = fs.readFileSync(cachePath, 'utf-8');
const parsed = JSON.parse(content) as { startTime: string; configDir: string };
expect(parsed.startTime).toBe(testDate.toISOString());
expect(parsed.configDir).toBe(path.resolve(configDir));
});
it('should overwrite existing cache file', () => {
const firstDate = new Date('2025-01-26T14:00:00.000Z');
const secondDate = new Date('2025-01-26T16:00:00.000Z');
const configDir = path.join(tempDir, '.claude-profile-a');
writeBlockCache(firstDate, configDir);
writeBlockCache(secondDate, configDir);
const cachePath = getBlockCachePath(configDir);
const content = fs.readFileSync(cachePath, 'utf-8');
const parsed = JSON.parse(content) as { startTime: string; configDir: string };
expect(parsed.startTime).toBe(secondDate.toISOString());
expect(parsed.configDir).toBe(path.resolve(configDir));
});
});
});
describe('getCachedBlockMetrics integration', () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-test-'));
vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
// Mock CLAUDE_CONFIG_DIR to point to a non-existent directory
// This ensures getBlockMetrics returns null when cache is expired
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-nonexistent');
});
afterEach(() => {
vi.restoreAllMocks();
// Restore original CLAUDE_CONFIG_DIR
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should return cached result when cache is within block duration', async () => {
const { getCachedBlockMetrics, writeBlockCache, getBlockCachePath } = await import('../jsonl');
// Set up a cache with a start time 2 hours ago (within 5-hour block)
const testStartTime = new Date();
testStartTime.setHours(testStartTime.getHours() - 2);
writeBlockCache(testStartTime);
// Verify cache was written
expect(fs.existsSync(getBlockCachePath())).toBe(true);
const result = getCachedBlockMetrics();
expect(result).not.toBeNull();
expect(result?.startTime.getTime()).toBe(testStartTime.getTime());
});
it('should recalculate when cache is expired (beyond block duration)', async () => {
const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl');
// Set up a cache with a start time 6 hours ago (beyond 5-hour block)
const expiredStartTime = new Date();
expiredStartTime.setHours(expiredStartTime.getHours() - 6);
writeBlockCache(expiredStartTime);
const result = getCachedBlockMetrics();
// Should return null because cache is expired and no real JSONL files exist
expect(result).toBeNull();
});
it('should recalculate when no cache exists', async () => {
const { getCachedBlockMetrics } = await import('../jsonl');
const result = getCachedBlockMetrics();
// Should return null because no cache and no real JSONL files exist
expect(result).toBeNull();
});
it('should recalculate when cache belongs to a different config directory', async () => {
const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl');
const profileA = path.join(tempDir, '.claude-profile-a');
const profileB = path.join(tempDir, '.claude-profile-b');
const testStartTime = new Date();
testStartTime.setHours(testStartTime.getHours() - 2);
process.env.CLAUDE_CONFIG_DIR = profileA;
writeBlockCache(testStartTime);
process.env.CLAUDE_CONFIG_DIR = profileB;
const result = getCachedBlockMetrics();
// Should return null because cache is fresh but scoped to a different profile
expect(result).toBeNull();
});
});
+814
View File
@@ -0,0 +1,814 @@
import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import {
CCSTATUSLINE_COMMANDS,
buildStatusLineCommand,
classifyInstallation,
getClaudeCodeVersion,
getClaudeJsonPath,
getClaudeSettingsPath,
getExistingStatusLine,
getRefreshInterval,
getVoiceConfig,
installStatusLine,
isClaudeCodeVersionAtLeast,
isInstalled,
isKnownCommand,
loadClaudeSettings,
saveClaudeSettings,
setRefreshInterval,
uninstallStatusLine
} from '../claude-settings';
import * as config from '../config';
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
let testClaudeConfigDir = '';
function readInstalledCommand(): string {
const settingsPath = getClaudeSettingsPath();
const content = fs.readFileSync(settingsPath, 'utf-8');
const data = JSON.parse(content) as { statusLine?: { command?: string } };
return data.statusLine?.command ?? '';
}
function 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 });
fs.writeFileSync(settingsPath, content, 'utf-8');
}
beforeEach(() => {
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-settings-'));
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
config.initConfigPath(path.join(testClaudeConfigDir, 'ccstatusline-settings.json'));
});
afterEach(() => {
vi.restoreAllMocks();
config.initConfigPath();
if (testClaudeConfigDir) {
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
}
});
afterAll(() => {
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
}
});
describe('isKnownCommand', () => {
it('should match exact NPM command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.NPM)).toBe(true);
});
it('should match exact BUNX command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.BUNX)).toBe(true);
});
it('should match exact SELF_MANAGED command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.SELF_MANAGED)).toBe(true);
});
it('should match NPM command with --config and simple path', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`)).toBe(true);
});
it('should match BUNX command with --config and quoted path with spaces', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`)).toBe(true);
});
it('should match command with --config and quoted path with parens', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`)).toBe(true);
});
it('should match command with --config and double-quoted Windows path', () => {
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config "C:\\Users\\Alice\\My Settings\\settings.json"`)).toBe(true);
});
it('should not match unknown commands', () => {
expect(isKnownCommand('some-other-command')).toBe(false);
});
it('should not match empty string', () => {
expect(isKnownCommand('')).toBe(false);
});
it('should not match partial prefix', () => {
expect(isKnownCommand('npx -y ccstatusline')).toBe(false);
});
it('should not match prefix that is a substring', () => {
expect(isKnownCommand('npx -y ccstatusline@latestFOO')).toBe(false);
});
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 () => {
config.initConfigPath();
await installStatusLine({ commandMode: 'auto-npx' });
expect(readInstalledCommand()).toBe(CCSTATUSLINE_COMMANDS.NPM);
});
it('should append --config with simple path (no quoting needed)', async () => {
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 () => {
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 () => {
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 () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
vi.spyOn(config, 'getConfigPath').mockReturnValue('/my\'path/settings.json');
await installStatusLine({ commandMode: 'auto-npx' });
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my'\\''path/settings.json'`);
});
it('should use bunx command when commandMode is auto-bunx', async () => {
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');
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({ commandMode: 'auto-npx' });
const installedCommand = `${CCSTATUSLINE_COMMANDS.NPM} --config ${configPath}`;
const claudeSettings = await loadClaudeSettings();
expect(claudeSettings.statusLine?.command).toBe(installedCommand);
const hooks = (claudeSettings.hooks ?? {}) as Record<string, unknown[]>;
expect(hooks.PreToolUse).toEqual([
{
_tag: 'ccstatusline-managed',
matcher: 'Skill',
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
}
]);
expect(hooks.UserPromptSubmit).toEqual([
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
}
]);
});
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', () => {
it('saveClaudeSettings should create .bak backup before overwrite', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: 'preexisting-command',
padding: 1
}
}));
await saveClaudeSettings({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
}
});
const settingsPath = getClaudeSettingsPath();
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string } };
expect(saved.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true);
const backup = JSON.parse(fs.readFileSync(`${settingsPath}.bak`, 'utf-8')) as { statusLine?: { command?: string } };
expect(backup.statusLine?.command).toBe('preexisting-command');
});
it('installStatusLine should create .orig backup before updating settings', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: 'old-command',
padding: 1
}
}));
await installStatusLine({ commandMode: 'auto-npx' });
const settingsPath = getClaudeSettingsPath();
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
const orig = JSON.parse(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')) as { statusLine?: { command?: string } };
expect(orig.statusLine?.command).toBe('old-command');
});
it('loadClaudeSettings should return empty object when settings file is missing', async () => {
await expect(loadClaudeSettings()).resolves.toEqual({});
});
it('loadClaudeSettings should log and throw when settings file is invalid JSON', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(loadClaudeSettings()).rejects.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load Claude settings:',
expect.anything()
);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('isInstalled should return false when settings cannot be loaded', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(isInstalled()).resolves.toBe(false);
expect(consoleErrorSpy).not.toHaveBeenCalled();
} finally {
consoleErrorSpy.mockRestore();
}
});
it('installStatusLine should warn and recover when existing settings are invalid', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await installStatusLine({ 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(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');
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Warning: Could not read existing Claude settings. A backup exists at ${settingsPath}.orig.`
);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('uninstallStatusLine should warn and return without modifying invalid settings', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await uninstallStatusLine();
const settingsPath = getClaudeSettingsPath();
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Warning: Could not read existing Claude settings.'
);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('uninstallStatusLine should remove all managed hooks', async () => {
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
},
hooks: {
PreToolUse: [
{
_tag: 'ccstatusline-managed',
matcher: 'Skill',
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
},
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-me' }]
}
],
UserPromptSubmit: [
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
}
]
}
}));
await uninstallStatusLine();
const settingsPath = getClaudeSettingsPath();
const updated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
statusLine?: unknown;
hooks?: Record<string, unknown[]>;
};
expect(updated.statusLine).toBeUndefined();
expect(updated.hooks).toEqual({
PreToolUse: [
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-me' }]
}
]
});
});
it('getExistingStatusLine should return null when settings cannot be loaded', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(getExistingStatusLine()).resolves.toBeNull();
expect(consoleErrorSpy).not.toHaveBeenCalled();
} finally {
consoleErrorSpy.mockRestore();
}
});
it('isInstalled should accept known commands with --config and undefined padding', async () => {
await saveClaudeSettings({
statusLine: {
type: 'command',
command: `${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`
}
});
await expect(isInstalled()).resolves.toBe(true);
});
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 });
});
});
});
@@ -0,0 +1,40 @@
import {
describe,
expect,
it
} from 'vitest';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import { cloneSettings } from '../clone-settings';
describe('cloneSettings', () => {
it('creates a deep clone that is independent from source', () => {
const original = {
...DEFAULT_SETTINGS,
lines: [
[
{ id: '1', type: 'model', metadata: { key: 'value' } }
]
]
};
const cloned = cloneSettings(original);
const originalWidget = original.lines[0]?.[0];
const clonedWidget = cloned.lines[0]?.[0];
expect(originalWidget).toBeDefined();
expect(clonedWidget).toBeDefined();
if (!originalWidget || !clonedWidget) {
throw new Error('Expected cloned settings to include widget entries');
}
const originalMetadata = originalWidget.metadata as Record<string, string>;
const clonedMetadata = (clonedWidget.metadata ?? {});
clonedWidget.metadata = clonedMetadata;
clonedMetadata.key = 'changed';
expect(originalMetadata.key).toBe('value');
expect(clonedMetadata.key).toBe('changed');
});
});
@@ -0,0 +1,86 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../types/Widget';
import {
hasCustomWidgetColors,
sanitizeLinesForColorLevel
} from '../color-sanitize';
describe('color sanitize helpers', () => {
it('detects custom ansi256/hex colors in foreground and background', () => {
const lines: WidgetItem[][] = [
[
{ id: '1', type: 'model', color: 'ansi256:120' }
],
[
{ id: '2', type: 'context-length', backgroundColor: 'hex:AA00BB' }
]
];
expect(hasCustomWidgetColors(lines)).toBe(true);
expect(hasCustomWidgetColors([[{ id: '3', type: 'model', color: 'cyan' }]])).toBe(false);
});
it('sanitizes hex colors when moving to ansi256 mode', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'hex:FF00AA', backgroundColor: 'hex:112233' },
{ id: '2', type: 'context-length', color: 'ansi256:111', backgroundColor: 'ansi256:24' }
]];
const sanitized = sanitizeLinesForColorLevel(lines, 2);
expect(sanitized[0]?.[0]?.color).toBe('cyan');
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
expect(sanitized[0]?.[1]?.color).toBe('ansi256:111');
expect(sanitized[0]?.[1]?.backgroundColor).toBe('ansi256:24');
});
it('sanitizes ansi256 colors when moving to truecolor mode', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'ansi256:120', backgroundColor: 'ansi256:244' },
{ id: '2', type: 'context-length', color: 'hex:AA11BB', backgroundColor: 'hex:112233' }
]];
const sanitized = sanitizeLinesForColorLevel(lines, 3);
expect(sanitized[0]?.[0]?.color).toBe('cyan');
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
expect(sanitized[0]?.[1]?.color).toBe('hex:AA11BB');
expect(sanitized[0]?.[1]?.backgroundColor).toBe('hex:112233');
});
it('sanitizes all custom colors when moving to basic/no-color modes', () => {
const lines: WidgetItem[][] = [[
{ id: '1', type: 'model', color: 'ansi256:99', backgroundColor: 'hex:123456' },
{ id: '2', type: 'separator', color: 'hex:ABCDEF', backgroundColor: 'ansi256:2' }
]];
const sanitized = sanitizeLinesForColorLevel(lines, 1);
expect(sanitized[0]?.[0]?.color).toBe('cyan');
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
// Preserve existing behavior: separator foreground is not reset by current logic.
expect(sanitized[0]?.[1]?.color).toBe('hex:ABCDEF');
expect(sanitized[0]?.[1]?.backgroundColor).toBeUndefined();
});
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('');
});
});
+159
View File
@@ -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);
});
});
+48
View File
@@ -0,0 +1,48 @@
import * as os from 'os';
import * as path from 'path';
import {
beforeEach,
describe,
expect,
it
} from 'vitest';
import {
getConfigPath,
initConfigPath,
isCustomConfigPath
} from '../config';
const DEFAULT_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json');
describe('initConfigPath / getConfigPath', () => {
beforeEach(() => {
initConfigPath();
});
it('should return the default settings path when no arg is provided', () => {
initConfigPath();
expect(getConfigPath()).toBe(DEFAULT_PATH);
expect(isCustomConfigPath()).toBe(false);
});
it('should return a custom settings path when a file path is provided', () => {
initConfigPath('/tmp/my-ccsl/settings.json');
expect(getConfigPath()).toBe(path.resolve('/tmp/my-ccsl/settings.json'));
expect(isCustomConfigPath()).toBe(true);
});
it('should resolve relative paths', () => {
initConfigPath('relative/settings.json');
expect(path.isAbsolute(getConfigPath())).toBe(true);
expect(getConfigPath()).toBe(path.resolve('relative/settings.json'));
});
it('should reset to default when called with undefined', () => {
initConfigPath('/tmp/custom.json');
expect(isCustomConfigPath()).toBe(true);
initConfigPath(undefined);
expect(getConfigPath()).toBe(DEFAULT_PATH);
expect(isCustomConfigPath()).toBe(false);
});
});
+414
View File
@@ -0,0 +1,414 @@
import * as fs from 'fs';
import path from 'path';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance
} from 'vitest';
import {
CURRENT_VERSION,
DEFAULT_SETTINGS,
type InstallationMetadata,
type Settings
} from '../../types/Settings';
const MOCK_HOME_DIR = '/tmp/ccstatusline-config-test-home';
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
let loadSettings: () => Promise<Settings>;
let saveSettings: (settings: Settings) => Promise<void>;
let initConfigPath: (filePath?: string) => void;
let getConfigLoadError: () => string | null;
let saveInstallationMetadata: (metadata: InstallationMetadata | undefined) => Promise<void>;
let consoleErrorSpy: MockInstance<typeof console.error>;
function getSettingsPaths(): { configDir: string; settingsPath: string; backupPath: string } {
const configDir = path.join(MOCK_HOME_DIR, '.config', 'ccstatusline');
return {
configDir,
settingsPath: path.join(configDir, 'settings.json'),
backupPath: path.join(configDir, 'settings.bak')
};
}
function getClaudeConfigDir(): string {
return path.join(MOCK_HOME_DIR, '.claude');
}
describe('config utilities', () => {
beforeAll(async () => {
const configModule = await import('../config');
loadSettings = configModule.loadSettings;
saveSettings = configModule.saveSettings;
initConfigPath = configModule.initConfigPath;
getConfigLoadError = configModule.getConfigLoadError;
saveInstallationMetadata = configModule.saveInstallationMetadata;
});
beforeEach(() => {
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
process.env.CLAUDE_CONFIG_DIR = getClaudeConfigDir();
const { settingsPath } = getSettingsPaths();
initConfigPath(settingsPath);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
afterAll(() => {
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
}
initConfigPath();
});
it('writes defaults when settings file does not exist', async () => {
const { settingsPath } = getSettingsPaths();
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
expect(fs.existsSync(settingsPath)).toBe(true);
const onDisk = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
version?: number;
lines?: unknown[];
};
expect(onDisk.version).toBe(CURRENT_VERSION);
expect(Array.isArray(onDisk.lines)).toBe(true);
expect(settings.gitCacheTtlSeconds).toBe(5);
expect((onDisk as { gitCacheTtlSeconds?: number }).gitCacheTtlSeconds).toBe(5);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Default settings written to')
);
});
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);
// 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(
expect.stringContaining('Failed to parse settings.json')
);
});
it('uses defaults in memory and preserves an invalid v1 payload', async () => {
const { settingsPath, backupPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
const original = JSON.stringify({ flexMode: 123 });
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('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('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('Error loading settings'),
expect.anything()
);
});
it('migrates older versioned settings and persists migrated result', async () => {
const { settingsPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
settingsPath,
JSON.stringify({
version: 2,
lines: [[{ id: 'widget-1', type: 'model' }]]
}),
'utf-8'
);
const settings = await loadSettings();
expect(settings.version).toBe(CURRENT_VERSION);
const migrated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
version?: number;
updatemessage?: { message?: string };
};
expect(migrated.version).toBe(CURRENT_VERSION);
expect(migrated.updatemessage?.message).toContain('v2.0.2');
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('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();
await saveSettings({
...DEFAULT_SETTINGS,
version: 1
});
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
expect(saved.version).toBe(CURRENT_VERSION);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
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');
});
});
@@ -0,0 +1,259 @@
import {
describe,
expect,
it
} from 'vitest';
import type { RenderContext } from '../../types';
import {
calculateContextPercentage,
calculateContextPercentageMetrics
} from '../context-percentage';
describe('calculateContextPercentage', () => {
describe('Status JSON context_window', () => {
it('should prefer context_window used_percentage over token metrics', () => {
const context: RenderContext = {
data: {
model: { id: 'claude-3-5-sonnet-20241022' },
context_window: {
context_window_size: 200000,
used_percentage: 12.5
}
},
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 100000
}
};
const percentage = calculateContextPercentage(context);
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: {
context_window: {
context_window_size: 200000,
current_usage: {
input_tokens: 20000,
output_tokens: 10000,
cache_creation_input_tokens: 5000,
cache_read_input_tokens: 5000
}
}
}
};
const percentage = calculateContextPercentage(context);
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: {
model: { id: 'claude-3-5-sonnet-20241022' },
context_window: { context_window_size: 1000000 }
},
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
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', () => {
it('should calculate percentage using 1M denominator with [1m] suffix', () => {
const context: RenderContext = {
data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should cap at 100% with [1m] suffix', () => {
const context: RenderContext = {
data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 2000000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(100);
});
it('should calculate percentage using 1M denominator with 1M context label', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M context)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage using 1M denominator with 1M in parentheses', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage from display_name when model id lacks context size suffix', () => {
const context: RenderContext = {
data: {
model: {
id: 'claude-opus-4-6',
display_name: 'Opus 4.6 (1M context)'
}
},
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
});
describe('Older models with 200k context window', () => {
it('should calculate percentage using 200k denominator', () => {
const context: RenderContext = {
data: { model: { id: 'claude-3-5-sonnet-20241022' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(21.0);
});
it('should return 0 when no token metrics', () => {
const context: RenderContext = { data: { model: { id: 'claude-3-5-sonnet-20241022' } } };
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(0);
expect(calculateContextPercentageMetrics(context)).toBeNull();
});
it('should use default 200k context when model ID is undefined', () => {
const context: RenderContext = {
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(21.0);
});
});
});
@@ -0,0 +1,75 @@
import {
describe,
expect,
it
} from 'vitest';
import { getContextWindowMetrics } from '../context-window';
describe('getContextWindowMetrics', () => {
it('returns null metrics when context_window is missing', () => {
expect(getContextWindowMetrics(undefined)).toEqual({
windowSize: null,
usedTokens: null,
contextLengthTokens: null,
usedPercentage: null,
remainingPercentage: null,
totalInputTokens: null,
totalOutputTokens: null,
cachedTokens: null,
totalTokens: null
});
});
it('extracts totals and usage from current_usage object', () => {
const metrics = getContextWindowMetrics({
context_window: {
context_window_size: 200000,
total_input_tokens: 4567,
total_output_tokens: 890,
current_usage: {
input_tokens: 1000,
output_tokens: 200,
cache_creation_input_tokens: 300,
cache_read_input_tokens: 400
}
}
});
expect(metrics).toEqual({
windowSize: 200000,
usedTokens: 1900,
contextLengthTokens: 1700,
usedPercentage: 0.95,
remainingPercentage: 99.05,
totalInputTokens: 4567,
totalOutputTokens: 890,
cachedTokens: 700,
totalTokens: 1900
});
});
it('derives token usage from used_percentage when current_usage is missing', () => {
const metrics = getContextWindowMetrics({
context_window: {
context_window_size: 200000,
used_percentage: 12.5,
remaining_percentage: 87.5,
total_input_tokens: 5000,
total_output_tokens: 1000
}
});
expect(metrics).toEqual({
windowSize: 200000,
usedTokens: 25000,
contextLengthTokens: 25000,
usedPercentage: 12.5,
remainingPercentage: 87.5,
totalInputTokens: 5000,
totalOutputTokens: 1000,
cachedTokens: null,
totalTokens: 6000
});
});
});

Some files were not shown because too many files have changed in this diff Show More