Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfa009e7df | |||
| db950f3f7a | |||
| fe9cc15818 | |||
| 34fa512778 | |||
| e63591492a | |||
| 7a0b979b01 | |||
| eaab442794 | |||
| 78d6dcc28c | |||
| 53c8200c5a | |||
| 7ca953400c | |||
| 591cb60459 | |||
| c4d0f4b5ad | |||
| 97d7491c12 | |||
| 8049e28667 | |||
| a056183a67 | |||
| fceab59ec6 | |||
| 19eb83fb24 | |||
| f2764362fd | |||
| 990f2703b1 | |||
| 59d43589c9 | |||
| 78c7bc16f0 | |||
| 2956842911 | |||
| 5c0d211ddf | |||
| 9c0f5feab6 | |||
| 7925ec3e9a | |||
| 87fac953aa | |||
| d79eefb043 | |||
| 79b8e4e2f5 | |||
| fc09688879 | |||
| a34a632b82 | |||
| 25b69228dd | |||
| 7e2ad30f77 | |||
| b90840f68c | |||
| b13ea14a8c | |||
| da7eaffe03 | |||
| 9aea4c1265 | |||
| 2d05b3b891 | |||
| 6edc1b4277 | |||
| 0c6bf4b02c | |||
| eff030a9d8 | |||
| 9ddcb0003b | |||
| 4459379038 | |||
| 8b1ec580d9 | |||
| a03b7c2238 | |||
| 801c5f1b38 | |||
| fc3934097b | |||
| a8122844ff | |||
| 3d43476d6b | |||
| b6426bf707 | |||
| ecd8d0b6cc | |||
| 3abc41f122 | |||
| 20691a70b0 | |||
| 2e0b321d41 | |||
| f83e7d2f6d | |||
| 35b4c0caa0 | |||
| 62903d8183 | |||
| 0092c241a6 | |||
| 7cf85dffb1 | |||
| d025c707e6 | |||
| 76d9af84a5 | |||
| 09e1d723ac | |||
| 3050aefc1c | |||
| 879255523b | |||
| d457e55913 | |||
| 03d19692ab | |||
| 79a954c16e |
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: sirmalloc
|
||||
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: bun
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
ignore:
|
||||
- dependency-name: "ink"
|
||||
open-pull-requests-limit: 10
|
||||
cooldown:
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
production-dependencies:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
@@ -0,0 +1,36 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint & Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun run lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun test
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun install
|
||||
- run: bun run build
|
||||
- run: test -f dist/ccstatusline.js
|
||||
Vendored
+1
@@ -21,6 +21,7 @@
|
||||
"ccstatusline",
|
||||
"Powerline",
|
||||
"statusline",
|
||||
"sublabel",
|
||||
"Worktree",
|
||||
"worktrees"
|
||||
]
|
||||
|
||||
@@ -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 - 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
|
||||
@@ -1,148 +0,0 @@
|
||||
# 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 with auto-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 - Claude Code metadata display
|
||||
- GitBranch, GitChanges, 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**: Only run via `bun run lint` command, never using `npx eslint` or `eslint` directly. Never run `tsx`, `bun tsc` or any other variation
|
||||
- **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
|
||||
@@ -46,6 +46,44 @@
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
### v2.2.0 - v2.2.2 - Token Speed + Skills widget 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.
|
||||
- **🤝 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
|
||||
@@ -54,7 +92,7 @@
|
||||
|
||||
### 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 'l' key.
|
||||
- **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
|
||||
|
||||
@@ -123,6 +161,7 @@
|
||||
- **⚡ 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, background)
|
||||
- **🚀 Cross-platform** - Works seamlessly with both Bun and Node.js
|
||||
- **🔧 Flexible Configuration** - Supports custom Claude Code config directory via `CLAUDE_CONFIG_DIR` environment variable
|
||||
@@ -137,10 +176,10 @@
|
||||
|
||||
```bash
|
||||
# Run the configuration TUI with npm
|
||||
npx ccstatusline@latest
|
||||
npx -y ccstatusline@latest
|
||||
|
||||
# Or with Bun (faster)
|
||||
bunx ccstatusline@latest
|
||||
bunx -y ccstatusline@latest
|
||||
```
|
||||
|
||||
### Configure ccstatusline
|
||||
@@ -165,6 +204,26 @@ The interactive configuration tool provides a terminal UI where you can:
|
||||
> $env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
|
||||
> ```
|
||||
|
||||
> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
|
||||
|
||||
### Claude Code settings.json format
|
||||
|
||||
When you install from the TUI, ccstatusline writes a `statusLine` command object to your Claude Code settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "npx -y ccstatusline@latest",
|
||||
"padding": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Other supported command values are:
|
||||
- `bunx -y ccstatusline@latest`
|
||||
- `ccstatusline` (for self-managed/global installs)
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Windows Support
|
||||
@@ -179,13 +238,13 @@ ccstatusline works seamlessly on Windows with full feature compatibility across
|
||||
irm bun.sh/install.ps1 | iex
|
||||
|
||||
# Run ccstatusline
|
||||
bunx ccstatusline@latest
|
||||
bunx -y ccstatusline@latest
|
||||
```
|
||||
|
||||
#### Option 2: Using Node.js
|
||||
```powershell
|
||||
# Using npm
|
||||
npx ccstatusline@latest
|
||||
npx -y ccstatusline@latest
|
||||
|
||||
# Or with Yarn
|
||||
yarn dlx ccstatusline@latest
|
||||
@@ -246,7 +305,7 @@ winget install Git.Git
|
||||
**Issue**: Permission errors during installation
|
||||
```powershell
|
||||
# Use non-global installation (recommended)
|
||||
npx ccstatusline@latest
|
||||
npx -y ccstatusline@latest
|
||||
|
||||
# Or run PowerShell as Administrator for global install
|
||||
```
|
||||
@@ -274,7 +333,7 @@ ccstatusline works perfectly in WSL environments:
|
||||
# Install in WSL Ubuntu/Debian
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
source ~/.bashrc
|
||||
bunx ccstatusline@latest
|
||||
bunx -y ccstatusline@latest
|
||||
```
|
||||
|
||||
**WSL Benefits**:
|
||||
@@ -311,14 +370,22 @@ Configure ccstatusline in your Claude Code settings:
|
||||
**For Bun users**:
|
||||
```json
|
||||
{
|
||||
"statusLine": "bunx ccstatusline@latest"
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bunx -y ccstatusline@latest",
|
||||
"padding": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For npm users**:
|
||||
```json
|
||||
{
|
||||
"statusLine": "npx ccstatusline@latest"
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "npx -y ccstatusline@latest",
|
||||
"padding": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -326,11 +393,10 @@ Configure ccstatusline in your Claude Code settings:
|
||||
|
||||
### Performance on Windows
|
||||
|
||||
ccstatusline is optimized for Windows performance:
|
||||
- **Bun runtime**: Significantly faster startup times on Windows
|
||||
- **Caching**: Intelligent caching of git status and file operations
|
||||
- **Async operations**: Non-blocking command execution
|
||||
- **Memory efficient**: Minimal resource usage
|
||||
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
|
||||
- **Block timer cache**: Cached block metrics reduce repeated JSONL scanning
|
||||
|
||||
### Windows-Specific Widget Behavior
|
||||
|
||||
@@ -347,30 +413,59 @@ Some widgets have Windows-specific optimizations:
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# Interactive TUI
|
||||
bun run start
|
||||
|
||||
# Piped mode with example payload
|
||||
bun run example
|
||||
```
|
||||
|
||||
### 📊 Available Widgets
|
||||
|
||||
- **Model Name** - Shows the current Claude model (e.g., "Claude 3.5 Sonnet")
|
||||
- **Git Branch** - Displays current git branch name
|
||||
- **Git Changes** - Shows uncommitted insertions/deletions (e.g., "+42,-10")
|
||||
- **Git Worktree** - Shows the name of the current git worktree
|
||||
- **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m")
|
||||
- **Session Cost** - Shows total session cost in USD (e.g., "$1.23")
|
||||
- **Block Timer** - Shows time elapsed in current 5-hour block or progress bar
|
||||
- **Current Working Directory** - Shows current working directory with configurable path segments
|
||||
- **Version** - Shows Claude Code version
|
||||
- **Output Style** - Shows the currently set output style in Claude Code
|
||||
- **Tokens Input** - Shows input tokens used
|
||||
- **Tokens Output** - Shows output tokens used
|
||||
- **Tokens Cached** - Shows cached tokens used
|
||||
- **Tokens Total** - Shows total tokens used
|
||||
- **Context Length** - Shows current context length in tokens
|
||||
- **Context Percentage** - Shows percentage of context limit used (dynamic: 1M for Sonnet 4.5 with `[1m]` suffix, 200k otherwise)
|
||||
- **Context Percentage (usable)** - Shows percentage of usable context (dynamic: 800k for Sonnet 4.5 with `[1m]` suffix, 160k otherwise, accounting for auto-compact at 80%)
|
||||
- **Terminal Width** - Shows detected terminal width (for debugging)
|
||||
- **Custom Text** - Add your own custom text to the status line
|
||||
- **Custom Command** - Execute shell commands and display their output (refreshes whenever the statusline is updated by Claude Code)
|
||||
- **Separator** - Visual divider between widgets (customizable: |, -, comma, space)
|
||||
- **Flex Separator** - Expands to fill available space
|
||||
- **Model** - Displays the Claude model name (e.g., "Claude 3.5 Sonnet")
|
||||
- **Output Style** - Shows the current Claude Code output style
|
||||
- **Git Branch** - Shows the current git branch name
|
||||
- **Git Changes** - Shows git changes count (`+insertions`, `-deletions`)
|
||||
- **Git Insertions** - Shows git insertions count
|
||||
- **Git Deletions** - Shows git deletions count
|
||||
- **Git Root Dir** - Shows the git repository root directory name
|
||||
- **Git Worktree** - Shows the current git worktree name
|
||||
- **Current Working Dir** - Shows current working directory with segment limit, fish-style abbreviation, and optional `~` home abbreviation
|
||||
- **Tokens Input** - Shows input token count for the current session
|
||||
- **Tokens Output** - Shows output token count for the current session
|
||||
- **Tokens Cached** - Shows cached token count for the current session
|
||||
- **Tokens Total** - Shows total token count (`input + output + cache`) for the current session
|
||||
- **Input Speed** - Shows session-average input token speed (`tokens/sec`) with optional per-widget window (`0-120` seconds; `0` = full-session average)
|
||||
- **Output Speed** - Shows session-average output token speed (`tokens/sec`) with optional per-widget window (`0-120` seconds; `0` = full-session average)
|
||||
- **Total Speed** - Shows session-average total token speed (`tokens/sec`) with optional per-widget window (`0-120` seconds; `0` = full-session average)
|
||||
- **Context Length** - Shows the current context window size in tokens
|
||||
- **Context %** - Shows percentage of context window used or remaining
|
||||
- **Context % (usable)** - Shows percentage of usable context used or remaining (80% of max before auto-compact)
|
||||
- **Session Clock** - Shows elapsed time since current session started
|
||||
- **Session Cost** - Shows the total session cost in USD
|
||||
- **Block Timer** - Shows current 5-hour block elapsed time or progress
|
||||
- **Terminal Width** - Shows current terminal width in columns
|
||||
- **Version** - Shows Claude Code CLI version number
|
||||
- **Custom Text** - Displays user-defined custom text
|
||||
- **Custom Command** - Executes a custom shell command and displays output (refreshes whenever Claude Code updates the status line)
|
||||
- **Link** - Displays a clickable terminal hyperlink using OSC 8
|
||||
- **Claude Session ID** - Shows the current Claude Code session ID from status JSON
|
||||
- **Session Name** - Shows the session name set via `/rename` in Claude Code
|
||||
- **Memory Usage** - Shows system memory usage (used/total)
|
||||
- **Session Usage** - Shows daily/session API usage percentage
|
||||
- **Weekly Usage** - Shows weekly API usage percentage
|
||||
- **Block Reset Timer** - Shows time remaining until current 5-hour block reset window
|
||||
- **Weekly Reset Timer** - Shows time remaining until weekly usage reset
|
||||
- **Context Bar** - Shows context usage as a progress bar with short/full display modes
|
||||
- **Skills** - Shows skill activity as last used, total count, or unique list (with optional list limit and hide-when-empty toggle)
|
||||
- **Separator** - Visual divider between widgets (available when Powerline mode is off and no default separator is configured)
|
||||
- **Flex Separator** - Expands to fill available space (available when Powerline mode is off)
|
||||
|
||||
---
|
||||
|
||||
@@ -440,6 +535,26 @@ Some widgets support "raw value" mode which displays just the value without a la
|
||||
- Normal: `Block: 3hr 45m` → Raw: `3hr 45m`
|
||||
- Normal: `Ctx: 18.6k` → Raw: `18.6k`
|
||||
|
||||
### ⌨️ Widget Editor Keybinds
|
||||
|
||||
Common controls in the line editor:
|
||||
- `a` add widget
|
||||
- `i` insert widget
|
||||
- `Enter` enter/exit move mode
|
||||
- `d` delete selected widget
|
||||
- `r` toggle raw value (supported widgets)
|
||||
- `m` cycle merge mode (`off` → `merge` → `merge no padding`)
|
||||
|
||||
Widget-specific shortcuts:
|
||||
- **Git widgets**: `h` toggle hide `no git` output
|
||||
- **Context % widgets**: `u` toggle used vs remaining display
|
||||
- **Block Timer**: `p` cycle display mode (time/full bar/short bar)
|
||||
- **Block Reset Timer**: `p` cycle display mode (time/full bar/short bar)
|
||||
- **Weekly Reset Timer**: `p` cycle display mode (time/full bar/short bar)
|
||||
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
|
||||
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
|
||||
- **Link**: `u` URL, `e` link text
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Custom Widgets
|
||||
@@ -456,6 +571,8 @@ Execute shell commands and display their output dynamically:
|
||||
- Receives the full Claude Code JSON data via stdin (model info, session ID, transcript path, etc.)
|
||||
- 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
|
||||
@@ -468,6 +585,12 @@ Execute shell commands and display their output dynamically:
|
||||
|
||||
> 💡 **Tip:** Custom commands can be other Claude Code compatible status line formatters! They receive the same JSON via stdin that ccstatusline receives from Claude Code, allowing you to chain or combine multiple status line tools.
|
||||
|
||||
#### 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
|
||||
@@ -486,6 +609,7 @@ Execute shell commands and display their output dynamically:
|
||||
### ✂️ 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -527,7 +651,7 @@ The documentation will be generated in the `docs/` directory and can be viewed b
|
||||
|
||||
- [Bun](https://bun.sh) (v1.0+)
|
||||
- Git
|
||||
- Node.js 18+ (optional, for npm publishing)
|
||||
- Node.js 14+ (optional, for running the built `dist/ccstatusline.js` binary or npm publishing)
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -543,13 +667,41 @@ bun install
|
||||
### Development Commands
|
||||
|
||||
```bash
|
||||
# Run in TUI mode (configuration)
|
||||
bun run src/ccstatusline.ts
|
||||
# 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)
|
||||
|
||||
If you use a custom Claude config location, set `CLAUDE_CONFIG_DIR` and ccstatusline will read/write that path instead of `~/.claude`.
|
||||
|
||||
### Build Notes
|
||||
|
||||
- Build target is Node.js 14+ (`dist/ccstatusline.js`)
|
||||
- During install, `ink@6.2.0` is patched to fix backspace handling on macOS terminals
|
||||
|
||||
### 📁 Project Structure
|
||||
|
||||
```
|
||||
@@ -600,6 +752,14 @@ Contributions are welcome! Please feel free to submit 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ccstatusline",
|
||||
@@ -15,19 +16,20 @@
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-import-newlines": "^1.4.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"ink": "^6.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"ink": "6.2.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-select-input": "^6.2.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react-devtools-core": "^6.1.5",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"typedoc": "^0.28.12",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest": "^4.0.18",
|
||||
"zod": "^4.0.17",
|
||||
},
|
||||
},
|
||||
@@ -41,6 +43,38 @@
|
||||
"packages": {
|
||||
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
|
||||
@@ -99,25 +133,25 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.9", "", { "os": "win32", "cpu": "x64" }, "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.3.1", "", {}, "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA=="],
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="],
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.33.0", "", {}, "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A=="],
|
||||
"@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="],
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.12.2", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.12.2", "@shikijs/langs": "^3.12.2", "@shikijs/themes": "^3.12.2", "@shikijs/types": "^3.12.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HKZPmO8OSSAAo20H2B3xgJdxZaLTwtlMwxg0967scnrDlPwe6j5+ULGHyIqwgTbFCn9yv/ff8CmfWZLE9YKBzA=="],
|
||||
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
@@ -127,16 +161,18 @@
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w=="],
|
||||
@@ -179,17 +215,19 @@
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w=="],
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2" } }, "sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww=="],
|
||||
"@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2" } }, "sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A=="],
|
||||
"@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@3.12.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q=="],
|
||||
"@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.2.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/types": "^8.38.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-oY7GVkJGVMI5benlBDCaRrSC1qPasafyv5dOBLLv5MTilMGnErKhO6ziEfodDDIZbo5QxPUNW360VudJOFODMw=="],
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.9.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-FqqSkvDMYJReydrMhlugc71M76yLLQWNfmGq+SIlLa7N3kHp8Qq8i2PyWrVNAfjOyOIY+xv9XaaYwvVW7vroMA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
|
||||
|
||||
@@ -213,31 +251,31 @@
|
||||
|
||||
"@types/pluralize": ["@types/pluralize@0.0.33", "", {}, "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/tinycolor2": ["@types/tinycolor2@1.4.6", "", {}, "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/type-utils": "8.39.1", "@typescript-eslint/utils": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.1", "@typescript-eslint/types": "^8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1" } }, "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw=="],
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="],
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.1", "@typescript-eslint/tsconfig-utils": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw=="],
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg=="],
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="],
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
|
||||
|
||||
@@ -277,31 +315,33 @@
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
|
||||
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
|
||||
"@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
|
||||
"@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
"@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
|
||||
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
@@ -321,8 +361,6 @@
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||
@@ -331,14 +369,14 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
@@ -347,11 +385,11 @@
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
|
||||
|
||||
"chalk": ["chalk@5.5.0", "", {}, "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg=="],
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
@@ -367,11 +405,13 @@
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
||||
@@ -381,8 +421,6 @@
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
@@ -393,6 +431,8 @@
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
@@ -417,13 +457,15 @@
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.39.8", "", {}, "sha512-A8QO9TfF+rltS8BXpdu8OS+rpGgEdnRhqIVxO/ZmNvnXBYgOdSsxukT55ELyP94gZIntWJ+Li9QRrT2u1Kitpg=="],
|
||||
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.33.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.33.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA=="],
|
||||
"eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="],
|
||||
|
||||
"eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="],
|
||||
|
||||
@@ -435,11 +477,11 @@
|
||||
|
||||
"eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
|
||||
|
||||
"eslint-plugin-import-newlines": ["eslint-plugin-import-newlines@1.4.0", "", { "peerDependencies": { "eslint": ">=6.0.0" }, "bin": { "import-linter": "lib/index.js" } }, "sha512-+Cz1x2xBLtI9gJbmuYEpvY7F8K75wskBmJ7rk4VRObIJo+jklUJaejFJgtnWeL0dCFWabGEkhausrikXaNbtoQ=="],
|
||||
"eslint-plugin-import-newlines": ["eslint-plugin-import-newlines@1.4.1", "", { "peerDependencies": { "eslint": ">=6.0.0 < 10.0.0" }, "bin": { "import-linter": "lib/index.js" } }, "sha512-C9PQEJ4jS5tKoE9k0yY/5j4l1bxkxmVjrWvAFc1EToCnuQuwZGl1kS1JBlqYNF8svp0pnXLlkSdjByYa3JALcg=="],
|
||||
|
||||
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
@@ -461,22 +503,16 @@
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
@@ -493,6 +529,8 @@
|
||||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
@@ -513,8 +551,6 @@
|
||||
|
||||
"gradient-string": ["gradient-string@2.0.2", "", { "dependencies": { "chalk": "^4.1.2", "tinygradient": "^1.1.5" } }, "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw=="],
|
||||
|
||||
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
@@ -529,6 +565,12 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
@@ -579,8 +621,6 @@
|
||||
|
||||
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
@@ -613,6 +653,8 @@
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
@@ -635,11 +677,11 @@
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
|
||||
|
||||
@@ -647,10 +689,6 @@
|
||||
|
||||
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
@@ -665,6 +703,8 @@
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
@@ -681,6 +721,8 @@
|
||||
|
||||
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
@@ -703,8 +745,6 @@
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
@@ -723,11 +763,9 @@
|
||||
|
||||
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
||||
|
||||
"react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
|
||||
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
@@ -745,12 +783,8 @@
|
||||
|
||||
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
@@ -785,7 +819,7 @@
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
|
||||
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
@@ -795,7 +829,7 @@
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
@@ -811,14 +845,12 @@
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
@@ -827,23 +859,17 @@
|
||||
|
||||
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
|
||||
"tinyspy": ["tinyspy@4.0.3", "", {}, "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
||||
|
||||
@@ -861,11 +887,11 @@
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
|
||||
|
||||
"typedoc": ["typedoc@0.28.12", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.12.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.8.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-H5ODu4f7N+myG4MfuSp2Vh6wV+WLoZaEYxKPt2y8hmmqNEMVrH69DAjjdmYivF4tP/C2jrIZCZhPalZlTU/ipA=="],
|
||||
"typedoc": ["typedoc@0.28.17", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.17.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.8.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.39.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.39.1", "@typescript-eslint/parser": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg=="],
|
||||
"typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="],
|
||||
|
||||
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||
|
||||
@@ -875,13 +901,13 @@
|
||||
|
||||
"unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"vite": ["vite@7.1.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
|
||||
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
@@ -903,23 +929,41 @@
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
"@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
"@typescript-eslint/project-service/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
|
||||
|
||||
@@ -929,36 +973,56 @@
|
||||
|
||||
"eslint-import-resolver-node/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
|
||||
"eslint-import-resolver-typescript/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
"eslint-plugin-react-hooks/zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
|
||||
|
||||
"gradient-string/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
"ink-gradient/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||
|
||||
"react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "get-east-asian-width": "^1.0.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="],
|
||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
"string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"typedoc/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
"vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
|
||||
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"gradient-string/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"ink-gradient/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
|
||||
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"typedoc/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
}
|
||||
}
|
||||
|
||||
+20
-32
@@ -7,6 +7,23 @@ import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
|
||||
const importResolverSettings = {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: ['./tsconfig.json'],
|
||||
alwaysTryTypes: true,
|
||||
noWarnOnMultipleProjects: true
|
||||
},
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
|
||||
}
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx']
|
||||
},
|
||||
'import/external-module-folders': ['node_modules', 'node_modules/@types']
|
||||
};
|
||||
|
||||
export default ts.config([
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
@@ -38,21 +55,7 @@ export default ts.config([
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: ['./tsconfig.json'],
|
||||
alwaysTryTypes: true,
|
||||
noWarnOnMultipleProjects: true
|
||||
},
|
||||
parcel2: {},
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
|
||||
}
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx']
|
||||
},
|
||||
'import/external-module-folders': ['node_modules', 'node_modules/@types']
|
||||
...importResolverSettings
|
||||
},
|
||||
rules: {
|
||||
'no-control-regex': 'off', // We intentionally match ANSI escape sequences
|
||||
@@ -119,22 +122,7 @@ export default ts.config([
|
||||
'react-hooks': reactHooksPlugin
|
||||
},
|
||||
settings: {
|
||||
...{
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: ['./tsconfig.json'],
|
||||
alwaysTryTypes: true,
|
||||
noWarnOnMultipleProjects: true
|
||||
},
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
|
||||
}
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx']
|
||||
},
|
||||
'import/external-module-folders': ['node_modules', 'node_modules/@types']
|
||||
},
|
||||
...importResolverSettings,
|
||||
react: {
|
||||
version: 'detect'
|
||||
}
|
||||
@@ -156,4 +144,4 @@ export default ts.config([
|
||||
'!eslint.config.js'
|
||||
]
|
||||
}
|
||||
]);
|
||||
]);
|
||||
|
||||
+9
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ccstatusline",
|
||||
"version": "2.0.22",
|
||||
"version": "2.2.3",
|
||||
"description": "A customizable status line formatter for Claude Code CLI",
|
||||
"module": "src/ccstatusline.ts",
|
||||
"type": "module",
|
||||
@@ -16,8 +16,8 @@
|
||||
"postbuild": "bun run scripts/replace-version.ts",
|
||||
"example": "cat scripts/payload.example.json | bun start",
|
||||
"prepublishOnly": "bun run build",
|
||||
"lint": "bun tsc --noEmit; eslint . --config eslint.config.js --max-warnings=999999 --fix",
|
||||
"test": "bun vitest",
|
||||
"lint": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0",
|
||||
"lint:fix": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0 --fix",
|
||||
"docs": "typedoc",
|
||||
"docs:clean": "rm -rf docs"
|
||||
},
|
||||
@@ -33,19 +33,20 @@
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-import-newlines": "^1.4.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"ink": "^6.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"ink": "6.2.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-select-input": "^6.2.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react-devtools-core": "^6.1.5",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"typedoc": "^0.28.12",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest": "^4.0.18",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -70,4 +71,4 @@
|
||||
"patchedDependencies": {
|
||||
"ink@6.2.0": "patches/ink@6.2.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+144
-19
@@ -3,20 +3,23 @@ import chalk from 'chalk';
|
||||
|
||||
import { runTUI } from './tui';
|
||||
import type {
|
||||
BlockMetrics,
|
||||
SkillsMetrics,
|
||||
SpeedMetrics,
|
||||
TokenMetrics
|
||||
} from './types';
|
||||
import type { RenderContext } from './types/RenderContext';
|
||||
import type { StatusJSON } from './types/StatusJSON';
|
||||
import { StatusJSONSchema } from './types/StatusJSON';
|
||||
import { getVisibleText } from './utils/ansi';
|
||||
import { updateColorMap } from './utils/colors';
|
||||
import {
|
||||
initConfigPath,
|
||||
loadSettings,
|
||||
saveSettings
|
||||
} from './utils/config';
|
||||
import {
|
||||
getBlockMetrics,
|
||||
getSessionDuration,
|
||||
getSpeedMetricsCollection,
|
||||
getTokenMetrics
|
||||
} from './utils/jsonl';
|
||||
import {
|
||||
@@ -24,6 +27,21 @@ import {
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine
|
||||
} from './utils/renderer';
|
||||
import { advanceGlobalSeparatorIndex } from './utils/separator-index';
|
||||
import {
|
||||
getSkillsFilePath,
|
||||
getSkillsMetrics
|
||||
} from './utils/skills';
|
||||
import {
|
||||
getWidgetSpeedWindowSeconds,
|
||||
isWidgetSpeedWindowEnabled
|
||||
} from './utils/speed-window';
|
||||
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
|
||||
|
||||
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
|
||||
const durationMs = data.cost?.total_duration_ms;
|
||||
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
|
||||
@@ -54,6 +72,19 @@ async function readStdin(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWindowsUtf8CodePage() {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { execFileSync } = await import('child_process');
|
||||
execFileSync('chcp.com', ['65001'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignore failures to preserve statusline output even in restricted shells.
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMultipleLines(data: StatusJSON) {
|
||||
const settings = await loadSettings();
|
||||
|
||||
@@ -66,36 +97,58 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
// Get all lines to render
|
||||
const lines = settings.lines;
|
||||
|
||||
// Get token metrics if needed (check all lines)
|
||||
const hasTokenItems = lines.some(line => line.some(item => ['tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'context-percentage-usable'].includes(item.type)));
|
||||
|
||||
// Check if session clock is needed
|
||||
const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock'));
|
||||
|
||||
// Check if block timer is needed
|
||||
const hasBlockTimer = lines.some(line => line.some(item => item.type === 'block-timer'));
|
||||
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 (hasTokenItems && data.transcript_path) {
|
||||
if (data.transcript_path) {
|
||||
tokenMetrics = await getTokenMetrics(data.transcript_path);
|
||||
}
|
||||
|
||||
let sessionDuration: string | null = null;
|
||||
if (hasSessionClock && data.transcript_path) {
|
||||
if (hasSessionClock && !hasSessionDurationInStatusJson(data) && data.transcript_path) {
|
||||
sessionDuration = await getSessionDuration(data.transcript_path);
|
||||
}
|
||||
|
||||
let blockMetrics: BlockMetrics | null = null;
|
||||
if (hasBlockTimer) {
|
||||
blockMetrics = getBlockMetrics();
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines);
|
||||
|
||||
let speedMetrics: SpeedMetrics | null = null;
|
||||
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
|
||||
if (hasSpeedItems && data.transcript_path) {
|
||||
const speedMetricsCollection = await getSpeedMetricsCollection(data.transcript_path, {
|
||||
includeSubagents: true,
|
||||
windowSeconds: Array.from(requestedSpeedWindows)
|
||||
});
|
||||
|
||||
speedMetrics = speedMetricsCollection.sessionAverage;
|
||||
windowedSpeedMetrics = speedMetricsCollection.windowed;
|
||||
}
|
||||
|
||||
let skillsMetrics: SkillsMetrics | null = null;
|
||||
if (data.session_id) {
|
||||
skillsMetrics = getSkillsMetrics(data.session_id);
|
||||
}
|
||||
|
||||
// Create render context
|
||||
const context: RenderContext = {
|
||||
data,
|
||||
tokenMetrics,
|
||||
speedMetrics,
|
||||
windowedSpeedMetrics,
|
||||
usageData,
|
||||
sessionDuration,
|
||||
blockMetrics,
|
||||
skillsMetrics,
|
||||
isPreview: false
|
||||
};
|
||||
|
||||
@@ -114,19 +167,16 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
|
||||
// Only output the line if it has content (not just ANSI codes)
|
||||
// Strip ANSI codes to check if there's actual text
|
||||
const strippedLine = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
||||
const strippedLine = getVisibleText(line).trim();
|
||||
if (strippedLine.length > 0) {
|
||||
// Count separators used in this line (widgets - 1, excluding merged widgets)
|
||||
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
|
||||
if (nonMergedWidgets.length > 1)
|
||||
globalSeparatorIndex += nonMergedWidgets.length - 1;
|
||||
|
||||
// Replace all spaces with non-breaking spaces to prevent VSCode trimming
|
||||
let outputLine = line.replace(/ /g, '\u00A0');
|
||||
|
||||
// Add reset code at the beginning to override Claude Code's dim setting
|
||||
outputLine = '\x1b[0m' + outputLine;
|
||||
console.log(outputLine);
|
||||
|
||||
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,9 +211,84 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfigArg(): string | undefined {
|
||||
const idx = process.argv.indexOf('--config');
|
||||
if (idx === -1)
|
||||
return undefined;
|
||||
const configPath = process.argv[idx + 1];
|
||||
if (!configPath || configPath.startsWith('--')) {
|
||||
console.error('--config requires a file path argument');
|
||||
process.exit(1);
|
||||
}
|
||||
process.argv.splice(idx, 2);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
interface HookInput {
|
||||
session_id?: string;
|
||||
hook_event_name?: string;
|
||||
tool_name?: string;
|
||||
tool_input?: { skill?: string };
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
async function handleHook(): Promise<void> {
|
||||
const input = await readStdin();
|
||||
if (!input) {
|
||||
console.log('{}');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(input) as HookInput;
|
||||
const sessionId = data.session_id;
|
||||
if (!sessionId) {
|
||||
console.log('{}');
|
||||
return;
|
||||
}
|
||||
|
||||
let skillName = '';
|
||||
if (data.hook_event_name === 'PreToolUse' && data.tool_name === 'Skill') {
|
||||
skillName = data.tool_input?.skill ?? '';
|
||||
} else if (data.hook_event_name === 'UserPromptSubmit') {
|
||||
const match = /^\/([a-zA-Z0-9_:-]+)/.exec(data.prompt ?? '');
|
||||
if (match) {
|
||||
skillName = match[1] ?? '';
|
||||
}
|
||||
}
|
||||
if (!skillName) {
|
||||
console.log('{}');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = getSkillsFilePath(sessionId);
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const entry = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: sessionId,
|
||||
skill: skillName,
|
||||
source: data.hook_event_name
|
||||
});
|
||||
fs.appendFileSync(filePath, entry + '\n');
|
||||
} catch { /* ignore parse errors */ }
|
||||
console.log('{}');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Parse --config before anything else
|
||||
initConfigPath(parseConfigArg());
|
||||
|
||||
// Handle --hook mode (cross-platform hook handler for widgets)
|
||||
if (process.argv.includes('--hook')) {
|
||||
await handleHook();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in a piped/non-TTY environment first
|
||||
if (!process.stdin.isTTY) {
|
||||
await ensureWindowsUtf8CodePage();
|
||||
|
||||
// We're receiving piped input
|
||||
const input = await readStdin();
|
||||
if (input && input.trim() !== '') {
|
||||
|
||||
+138
-64
@@ -22,12 +22,17 @@ import {
|
||||
installStatusLine,
|
||||
isBunxAvailable,
|
||||
isInstalled,
|
||||
isKnownCommand,
|
||||
uninstallStatusLine
|
||||
} from '../utils/claude-settings';
|
||||
import { cloneSettings } from '../utils/clone-settings';
|
||||
import {
|
||||
getConfigPath,
|
||||
isCustomConfigPath,
|
||||
loadSettings,
|
||||
saveSettings
|
||||
} from '../utils/config';
|
||||
import { openExternalUrl } from '../utils/open-url';
|
||||
import {
|
||||
checkPowerlineFonts,
|
||||
checkPowerlineFontsAsync,
|
||||
@@ -47,25 +52,65 @@ import {
|
||||
PowerlineSetup,
|
||||
StatusLinePreview,
|
||||
TerminalOptionsMenu,
|
||||
TerminalWidthMenu
|
||||
TerminalWidthMenu,
|
||||
type MainMenuOption
|
||||
} from './components';
|
||||
|
||||
const GITHUB_REPO_URL = 'https://github.com/sirmalloc/ccstatusline';
|
||||
|
||||
interface FlashMessage {
|
||||
text: string;
|
||||
color: 'green' | 'red';
|
||||
}
|
||||
|
||||
type AppScreen = 'main'
|
||||
| 'lines'
|
||||
| 'items'
|
||||
| 'colorLines'
|
||||
| 'colors'
|
||||
| 'terminalWidth'
|
||||
| 'terminalConfig'
|
||||
| 'globalOverrides'
|
||||
| 'confirm'
|
||||
| 'powerline'
|
||||
| 'install';
|
||||
|
||||
interface ConfirmDialogState {
|
||||
message: string;
|
||||
action: () => Promise<void>;
|
||||
cancelScreen?: Exclude<AppScreen, 'confirm'>;
|
||||
}
|
||||
|
||||
export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude<AppScreen, 'confirm'> {
|
||||
return confirmDialog?.cancelScreen ?? 'main';
|
||||
}
|
||||
|
||||
export function clearInstallMenuSelection(menuSelections: Record<string, number>): Record<string, number> {
|
||||
if (menuSelections.install === undefined) {
|
||||
return menuSelections;
|
||||
}
|
||||
|
||||
const next = { ...menuSelections };
|
||||
delete next.install;
|
||||
return next;
|
||||
}
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const { exit } = useApp();
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [originalSettings, setOriginalSettings] = useState<Settings | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colorLines' | 'colors' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' | 'confirm' | 'powerline' | 'install'>('main');
|
||||
const [screen, setScreen] = useState<AppScreen>('main');
|
||||
const [selectedLine, setSelectedLine] = useState(0);
|
||||
const [menuSelections, setMenuSelections] = useState<Record<string, number>>({});
|
||||
const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise<void> } | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null);
|
||||
const [isClaudeInstalled, setIsClaudeInstalled] = useState(false);
|
||||
const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
|
||||
const [powerlineFontStatus, setPowerlineFontStatus] = useState<PowerlineFontStatus>({ installed: false });
|
||||
const [installingFonts, setInstallingFonts] = useState(false);
|
||||
const [fontInstallMessage, setFontInstallMessage] = useState<string | null>(null);
|
||||
const [existingStatusLine, setExistingStatusLine] = useState<string | null>(null);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
const [flashMessage, setFlashMessage] = useState<FlashMessage | null>(null);
|
||||
const [previewIsTruncated, setPreviewIsTruncated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,7 +121,7 @@ export const App: React.FC = () => {
|
||||
// Set global chalk level based on settings (default to 256 colors for compatibility)
|
||||
chalk.level = loadedSettings.colorLevel;
|
||||
setSettings(loadedSettings);
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings)) as Settings); // Deep copy
|
||||
setOriginalSettings(cloneSettings(loadedSettings));
|
||||
});
|
||||
void isInstalled().then(setIsClaudeInstalled);
|
||||
|
||||
@@ -107,15 +152,15 @@ export const App: React.FC = () => {
|
||||
}
|
||||
}, [settings, originalSettings]);
|
||||
|
||||
// Clear save message after 2 seconds
|
||||
// Clear header message after 2 seconds
|
||||
useEffect(() => {
|
||||
if (saveMessage) {
|
||||
if (flashMessage) {
|
||||
const timer = setTimeout(() => {
|
||||
setSaveMessage(null);
|
||||
setFlashMessage(null);
|
||||
}, 2000);
|
||||
return () => { clearTimeout(timer); };
|
||||
}
|
||||
}, [saveMessage]);
|
||||
}, [flashMessage]);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.ctrl && input === 'c') {
|
||||
@@ -125,16 +170,19 @@ export const App: React.FC = () => {
|
||||
if (key.ctrl && input === 's' && settings) {
|
||||
void (async () => {
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings);
|
||||
setOriginalSettings(cloneSettings(settings));
|
||||
setHasChanges(false);
|
||||
setSaveMessage('✓ Configuration saved');
|
||||
setFlashMessage({
|
||||
text: '✓ Configuration saved',
|
||||
color: 'green'
|
||||
});
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
const handleInstallSelection = useCallback((command: string, displayName: string, useBunx: boolean) => {
|
||||
void getExistingStatusLine().then((existing) => {
|
||||
const isAlreadyInstalled = [CCSTATUSLINE_COMMANDS.NPM, CCSTATUSLINE_COMMANDS.BUNX, CCSTATUSLINE_COMMANDS.SELF_MANAGED].includes(existing ?? '');
|
||||
const isAlreadyInstalled = isKnownCommand(existing ?? '');
|
||||
let message: string;
|
||||
|
||||
if (existing && !isAlreadyInstalled) {
|
||||
@@ -147,6 +195,7 @@ export const App: React.FC = () => {
|
||||
|
||||
setConfirmDialog({
|
||||
message,
|
||||
cancelScreen: 'install',
|
||||
action: async () => {
|
||||
await installStatusLine(useBunx);
|
||||
setIsClaudeInstalled(true);
|
||||
@@ -160,13 +209,20 @@ export const App: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const handleNpxInstall = useCallback(() => {
|
||||
setMenuSelections(prev => ({ ...prev, install: 0 }));
|
||||
handleInstallSelection(CCSTATUSLINE_COMMANDS.NPM, 'npx', false);
|
||||
}, [handleInstallSelection]);
|
||||
|
||||
const handleBunxInstall = useCallback(() => {
|
||||
setMenuSelections(prev => ({ ...prev, install: 1 }));
|
||||
handleInstallSelection(CCSTATUSLINE_COMMANDS.BUNX, 'bunx', true);
|
||||
}, [handleInstallSelection]);
|
||||
|
||||
const handleInstallMenuCancel = useCallback(() => {
|
||||
setMenuSelections(clearInstallMenuSelection);
|
||||
setScreen('main');
|
||||
}, []);
|
||||
|
||||
if (!settings) {
|
||||
return <Text>Loading settings...</Text>;
|
||||
}
|
||||
@@ -191,35 +247,58 @@ export const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMainMenuSelect = async (value: string) => {
|
||||
const handleMainMenuSelect = async (value: MainMenuOption) => {
|
||||
switch (value) {
|
||||
case 'lines':
|
||||
setScreen('lines');
|
||||
break;
|
||||
case 'colors':
|
||||
setScreen('colorLines');
|
||||
break;
|
||||
case 'terminalConfig':
|
||||
setScreen('terminalConfig');
|
||||
break;
|
||||
case 'globalOverrides':
|
||||
setScreen('globalOverrides');
|
||||
break;
|
||||
case 'powerline':
|
||||
setScreen('powerline');
|
||||
break;
|
||||
case 'install':
|
||||
handleInstallUninstall();
|
||||
break;
|
||||
case 'save':
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings); // Update original after save
|
||||
setHasChanges(false);
|
||||
exit();
|
||||
break;
|
||||
case 'exit':
|
||||
exit();
|
||||
break;
|
||||
case 'lines':
|
||||
setScreen('lines');
|
||||
break;
|
||||
case 'colors':
|
||||
setScreen('colorLines');
|
||||
break;
|
||||
case 'terminalConfig':
|
||||
setScreen('terminalConfig');
|
||||
break;
|
||||
case 'globalOverrides':
|
||||
setScreen('globalOverrides');
|
||||
break;
|
||||
case 'powerline':
|
||||
setScreen('powerline');
|
||||
break;
|
||||
case 'install':
|
||||
handleInstallUninstall();
|
||||
break;
|
||||
case 'starGithub':
|
||||
setConfirmDialog({
|
||||
message: `Open the ccstatusline GitHub repository in your browser?\n\n${GITHUB_REPO_URL}`,
|
||||
action: () => {
|
||||
const result = openExternalUrl(GITHUB_REPO_URL);
|
||||
if (result.success) {
|
||||
setFlashMessage({
|
||||
text: '✓ Opened GitHub repository in browser',
|
||||
color: 'green'
|
||||
});
|
||||
} else {
|
||||
setFlashMessage({
|
||||
text: `✗ Could not open browser. Visit: ${GITHUB_REPO_URL}`,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
setScreen('main');
|
||||
setConfirmDialog(null);
|
||||
return Promise.resolve();
|
||||
}
|
||||
});
|
||||
setScreen('confirm');
|
||||
break;
|
||||
case 'save':
|
||||
await saveSettings(settings);
|
||||
setOriginalSettings(cloneSettings(settings)); // Update original after save
|
||||
setHasChanges(false);
|
||||
exit();
|
||||
break;
|
||||
case 'exit':
|
||||
exit();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,12 +328,15 @@ export const App: React.FC = () => {
|
||||
<Text bold>
|
||||
{` | ${getPackageVersion() && `v${getPackageVersion()}`}`}
|
||||
</Text>
|
||||
{saveMessage && (
|
||||
<Text color='green' bold>
|
||||
{` ${saveMessage}`}
|
||||
{flashMessage && (
|
||||
<Text color={flashMessage.color} bold>
|
||||
{` ${flashMessage.text}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{isCustomConfigPath() && (
|
||||
<Text dimColor>{`Config: ${getConfigPath()}`}</Text>
|
||||
)}
|
||||
|
||||
<StatusLinePreview
|
||||
lines={settings.lines}
|
||||
@@ -266,19 +348,12 @@ export const App: React.FC = () => {
|
||||
<Box marginTop={1}>
|
||||
{screen === 'main' && (
|
||||
<MainMenu
|
||||
onSelect={(value) => {
|
||||
onSelect={(value, index) => {
|
||||
// Only persist menu selection if not exiting
|
||||
if (value !== 'save' && value !== 'exit') {
|
||||
const menuMap: Record<string, number> = {
|
||||
lines: 0,
|
||||
colors: 1,
|
||||
powerline: 2,
|
||||
terminalConfig: 3,
|
||||
globalOverrides: 4,
|
||||
install: 5
|
||||
};
|
||||
setMenuSelections({ ...menuSelections, main: menuMap[value] ?? 0 });
|
||||
setMenuSelections(prev => ({ ...prev, main: index }));
|
||||
}
|
||||
|
||||
void handleMainMenuSelect(value);
|
||||
}}
|
||||
isClaudeInstalled={isClaudeInstalled}
|
||||
@@ -293,14 +368,14 @@ export const App: React.FC = () => {
|
||||
<LineSelector
|
||||
lines={settings.lines}
|
||||
onSelect={(line) => {
|
||||
setMenuSelections({ ...menuSelections, lines: line });
|
||||
setMenuSelections(prev => ({ ...prev, lines: line }));
|
||||
handleLineSelect(line);
|
||||
}}
|
||||
onLinesUpdate={updateLines}
|
||||
onBack={() => {
|
||||
// Save that we came from 'lines' menu (index 0)
|
||||
// Clear the line selection so it resets next time we enter
|
||||
setMenuSelections({ ...menuSelections, main: 0 });
|
||||
setMenuSelections(prev => ({ ...prev, main: 0 }));
|
||||
setScreen('main');
|
||||
}}
|
||||
initialSelection={menuSelections.lines}
|
||||
@@ -314,7 +389,7 @@ export const App: React.FC = () => {
|
||||
onUpdate={(widgets) => { updateLine(selectedLine, widgets); }}
|
||||
onBack={() => {
|
||||
// When going back to lines menu, preserve which line was selected
|
||||
setMenuSelections({ ...menuSelections, lines: selectedLine });
|
||||
setMenuSelections(prev => ({ ...prev, lines: selectedLine }));
|
||||
setScreen('lines');
|
||||
}}
|
||||
lineNumber={selectedLine + 1}
|
||||
@@ -326,13 +401,13 @@ export const App: React.FC = () => {
|
||||
lines={settings.lines}
|
||||
onLinesUpdate={updateLines}
|
||||
onSelect={(line) => {
|
||||
setMenuSelections({ ...menuSelections, lines: line });
|
||||
setMenuSelections(prev => ({ ...prev, lines: line }));
|
||||
setSelectedLine(line);
|
||||
setScreen('colors');
|
||||
}}
|
||||
onBack={() => {
|
||||
// Save that we came from 'colors' menu (index 1)
|
||||
setMenuSelections({ ...menuSelections, main: 1 });
|
||||
setMenuSelections(prev => ({ ...prev, main: 1 }));
|
||||
setScreen('main');
|
||||
}}
|
||||
initialSelection={menuSelections.lines}
|
||||
@@ -370,7 +445,7 @@ export const App: React.FC = () => {
|
||||
setScreen('terminalWidth');
|
||||
} else {
|
||||
// Save that we came from 'terminalConfig' menu (index 3)
|
||||
setMenuSelections({ ...menuSelections, main: 3 });
|
||||
setMenuSelections(prev => ({ ...prev, main: 3 }));
|
||||
setScreen('main');
|
||||
}
|
||||
}}
|
||||
@@ -395,7 +470,7 @@ export const App: React.FC = () => {
|
||||
}}
|
||||
onBack={() => {
|
||||
// Save that we came from 'globalOverrides' menu (index 4)
|
||||
setMenuSelections({ ...menuSelections, main: 4 });
|
||||
setMenuSelections(prev => ({ ...prev, main: 4 }));
|
||||
setScreen('main');
|
||||
}}
|
||||
/>
|
||||
@@ -405,7 +480,7 @@ export const App: React.FC = () => {
|
||||
message={confirmDialog.message}
|
||||
onConfirm={() => void confirmDialog.action()}
|
||||
onCancel={() => {
|
||||
setScreen('main');
|
||||
setScreen(getConfirmCancelScreen(confirmDialog));
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
/>
|
||||
@@ -416,9 +491,8 @@ export const App: React.FC = () => {
|
||||
existingStatusLine={existingStatusLine}
|
||||
onSelectNpx={handleNpxInstall}
|
||||
onSelectBunx={handleBunxInstall}
|
||||
onCancel={() => {
|
||||
setScreen('main');
|
||||
}}
|
||||
onCancel={handleInstallMenuCancel}
|
||||
initialSelection={menuSelections.install}
|
||||
/>
|
||||
)}
|
||||
{screen === 'powerline' && (
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
clearInstallMenuSelection,
|
||||
getConfirmCancelScreen
|
||||
} from '../App';
|
||||
|
||||
describe('App confirm navigation helpers', () => {
|
||||
it('defaults confirmation cancel navigation to the main menu', () => {
|
||||
expect(getConfirmCancelScreen(null)).toBe('main');
|
||||
expect(getConfirmCancelScreen({
|
||||
message: 'Confirm install?',
|
||||
action: () => Promise.resolve()
|
||||
})).toBe('main');
|
||||
});
|
||||
|
||||
it('returns to the install menu when the confirm dialog requests it', () => {
|
||||
expect(getConfirmCancelScreen({
|
||||
message: 'Confirm install?',
|
||||
action: () => Promise.resolve(),
|
||||
cancelScreen: 'install'
|
||||
})).toBe('install');
|
||||
});
|
||||
|
||||
it('clears saved install selection when leaving the install menu', () => {
|
||||
expect(clearInstallMenuSelection({
|
||||
main: 5,
|
||||
install: 1
|
||||
})).toEqual({ main: 5 });
|
||||
|
||||
const menuSelections = { main: 5 };
|
||||
|
||||
expect(clearInstallMenuSelection(menuSelections)).toBe(menuSelections);
|
||||
});
|
||||
});
|
||||
@@ -15,9 +15,17 @@ import {
|
||||
getAvailableBackgroundColorsForUI,
|
||||
getAvailableColorsForUI
|
||||
} from '../../utils/colors';
|
||||
import { shouldInsertInput } from '../../utils/input-guards';
|
||||
import { getWidget } from '../../utils/widgets';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
clearAllWidgetStyling,
|
||||
cycleWidgetColor,
|
||||
resetWidgetStyling,
|
||||
setWidgetColor,
|
||||
toggleWidgetBold
|
||||
} from './color-menu/mutations';
|
||||
|
||||
export interface ColorMenuProps {
|
||||
widgets: WidgetItem[];
|
||||
@@ -79,17 +87,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
const hexColor = `hex:${hexInput}`;
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
// IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === highlightedItemId) {
|
||||
if (editingBackground) {
|
||||
return { ...widget, backgroundColor: hexColor };
|
||||
} else {
|
||||
return { ...widget, color: hexColor };
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
const newItems = setWidgetColor(widgets, selectedWidget.id, hexColor, editingBackground);
|
||||
onUpdate(newItems);
|
||||
}
|
||||
setHexInputMode(false);
|
||||
@@ -97,7 +95,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setHexInput(hexInput.slice(0, -1));
|
||||
} else if (input && hexInput.length < 6) {
|
||||
} 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)) {
|
||||
@@ -125,17 +123,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
|
||||
if (selectedWidget) {
|
||||
// IMPORTANT: Update ALL items (not just colorableWidgets) to maintain proper indexing
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === highlightedItemId) {
|
||||
if (editingBackground) {
|
||||
return { ...widget, backgroundColor: ansiColor };
|
||||
} else {
|
||||
return { ...widget, color: ansiColor };
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
const newItems = setWidgetColor(widgets, selectedWidget.id, ansiColor, editingBackground);
|
||||
|
||||
onUpdate(newItems);
|
||||
setAnsi256InputMode(false);
|
||||
@@ -144,7 +132,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setAnsi256Input(ansi256Input.slice(0, -1));
|
||||
} else if (input && ansi256Input.length < 3) {
|
||||
} else if (shouldInsertInput(input, key) && ansi256Input.length < 3) {
|
||||
// Only accept numeric characters (0-9)
|
||||
if (/^[0-9]$/.test(input)) {
|
||||
const newInput = ansi256Input + input;
|
||||
@@ -198,12 +186,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
// Toggle bold for the highlighted item
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === selectedWidget.id) {
|
||||
return { ...widget, bold: !widget.bold };
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
const newItems = toggleWidgetBold(widgets, selectedWidget.id);
|
||||
onUpdate(newItems);
|
||||
}
|
||||
}
|
||||
@@ -212,17 +195,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
// Reset all styling (color, background, and bold) for the highlighted item
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === selectedWidget.id) {
|
||||
// Remove color, backgroundColor, and bold properties
|
||||
const { color, backgroundColor, bold, ...restWidget } = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
return restWidget;
|
||||
}
|
||||
return widget;
|
||||
});
|
||||
const newItems = resetWidgetStyling(widgets, selectedWidget.id);
|
||||
onUpdate(newItems);
|
||||
}
|
||||
}
|
||||
@@ -234,52 +207,13 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
if (highlightedItemId && highlightedItemId !== 'back') {
|
||||
const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId);
|
||||
if (selectedWidget) {
|
||||
const newItems = widgets.map((widget) => {
|
||||
if (widget.id === selectedWidget.id) {
|
||||
if (editingBackground) {
|
||||
const currentBgColor = widget.backgroundColor ?? ''; // Empty string for 'none'
|
||||
let currentBgColorIndex = bgColors.indexOf(currentBgColor);
|
||||
// If color not found, start from beginning
|
||||
if (currentBgColorIndex === -1)
|
||||
currentBgColorIndex = 0;
|
||||
|
||||
let nextBgColorIndex;
|
||||
if (key.rightArrow) {
|
||||
nextBgColorIndex = (currentBgColorIndex + 1) % bgColors.length;
|
||||
} else {
|
||||
nextBgColorIndex = currentBgColorIndex === 0 ? bgColors.length - 1 : currentBgColorIndex - 1;
|
||||
}
|
||||
const nextBgColor = bgColors[nextBgColorIndex];
|
||||
return { ...widget, backgroundColor: nextBgColor === '' ? undefined : nextBgColor };
|
||||
} else {
|
||||
let defaultColor = 'white';
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
defaultColor = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
let currentColor = widget.color ?? defaultColor;
|
||||
// If color is 'dim', treat as if no color was set
|
||||
if (currentColor === 'dim') {
|
||||
currentColor = defaultColor;
|
||||
}
|
||||
let currentColorIndex = colors.indexOf(currentColor);
|
||||
// If color not found, start from beginning
|
||||
if (currentColorIndex === -1)
|
||||
currentColorIndex = 0;
|
||||
|
||||
let nextColorIndex;
|
||||
if (key.rightArrow) {
|
||||
nextColorIndex = (currentColorIndex + 1) % colors.length;
|
||||
} else {
|
||||
nextColorIndex = currentColorIndex === 0 ? colors.length - 1 : currentColorIndex - 1;
|
||||
}
|
||||
const nextColor = colors[nextColorIndex];
|
||||
return { ...widget, color: nextColor };
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
const newItems = cycleWidgetColor({
|
||||
widgets,
|
||||
widgetId: selectedWidget.id,
|
||||
direction: key.rightArrow ? 'right' : 'left',
|
||||
editingBackground,
|
||||
colors,
|
||||
backgroundColors: bgColors
|
||||
});
|
||||
onUpdate(newItems);
|
||||
}
|
||||
@@ -429,15 +363,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
<ConfirmDialog
|
||||
inline={true}
|
||||
onConfirm={() => {
|
||||
// Clear all colors from all widgets
|
||||
const newItems = widgets.map((widget) => {
|
||||
// Remove color, backgroundColor, and bold properties
|
||||
const { color, backgroundColor, bold, ...restWidget } = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
return restWidget;
|
||||
});
|
||||
const newItems = clearAllWidgetStyling(widgets);
|
||||
onUpdate(newItems);
|
||||
setShowClearConfirm(false);
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,12 @@ import {
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
message?: string;
|
||||
@@ -12,52 +17,57 @@ export interface ConfirmDialogProps {
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm, onCancel, inline = false }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0); // Default to "Yes"
|
||||
const CONFIRM_OPTIONS: ListEntry<boolean>[] = [
|
||||
{
|
||||
label: 'Yes',
|
||||
value: true
|
||||
},
|
||||
{
|
||||
label: 'No',
|
||||
value: false
|
||||
}
|
||||
];
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(1, selectedIndex + 1));
|
||||
} else if (key.return) {
|
||||
if (selectedIndex === 0) {
|
||||
onConfirm();
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
} else if (key.escape) {
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm, onCancel, inline = false }) => {
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
const renderOptions = () => {
|
||||
const yesStyle = selectedIndex === 0 ? { color: 'cyan' } : {};
|
||||
const noStyle = selectedIndex === 1 ? { color: 'cyan' } : {};
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text {...yesStyle}>
|
||||
{selectedIndex === 0 ? '▶ ' : ' '}
|
||||
Yes
|
||||
</Text>
|
||||
<Text {...noStyle}>
|
||||
{selectedIndex === 1 ? '▶ ' : ' '}
|
||||
No
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (inline) {
|
||||
return renderOptions();
|
||||
return (
|
||||
<List
|
||||
items={CONFIRM_OPTIONS}
|
||||
onSelect={(confirmed) => {
|
||||
if (confirmed) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
onCancel();
|
||||
}}
|
||||
color='cyan'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text>{message}</Text>
|
||||
<Box marginTop={1}>
|
||||
{renderOptions()}
|
||||
<List
|
||||
items={CONFIRM_OPTIONS}
|
||||
onSelect={(confirmed) => {
|
||||
if (confirmed) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
onCancel();
|
||||
}}
|
||||
color='cyan'
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getChalkColor,
|
||||
getColorDisplayName
|
||||
} from '../../utils/colors';
|
||||
import { shouldInsertInput } from '../../utils/input-guards';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
|
||||
@@ -57,7 +58,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
setPaddingInput(paddingInput.slice(0, -1));
|
||||
} else if (key.delete) {
|
||||
// For simple text inputs without cursor, forward delete does nothing
|
||||
} else if (input) {
|
||||
} else if (shouldInsertInput(input, key)) {
|
||||
setPaddingInput(paddingInput + input);
|
||||
}
|
||||
} else if (editingSeparator) {
|
||||
@@ -86,7 +87,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
setSeparatorInput(separatorInput.slice(0, -1));
|
||||
} else if (key.delete) {
|
||||
// For simple text inputs without cursor, forward delete does nothing
|
||||
} else if (input) {
|
||||
} else if (shouldInsertInput(input, key)) {
|
||||
setSeparatorInput(separatorInput + input);
|
||||
}
|
||||
} else if (confirmingSeparator) {
|
||||
|
||||
@@ -3,16 +3,19 @@ import {
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { getClaudeSettingsPath } from '../../utils/claude-settings';
|
||||
|
||||
import { List } from './List';
|
||||
|
||||
export interface InstallMenuProps {
|
||||
bunxAvailable: boolean;
|
||||
existingStatusLine: string | null;
|
||||
onSelectNpx: () => void;
|
||||
onSelectBunx: () => void;
|
||||
onCancel: () => void;
|
||||
initialSelection?: number;
|
||||
}
|
||||
|
||||
export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
@@ -20,39 +23,44 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
existingStatusLine,
|
||||
onSelectNpx,
|
||||
onSelectBunx,
|
||||
onCancel
|
||||
onCancel,
|
||||
initialSelection = 0
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const maxIndex = 2; // npx, bunx (if available), and back
|
||||
|
||||
useInput((input, key) => {
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onCancel();
|
||||
} else if (key.upArrow) {
|
||||
if (selectedIndex === 2) {
|
||||
setSelectedIndex(bunxAvailable ? 1 : 0); // Skip bunx if not available
|
||||
} else {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
}
|
||||
} else if (key.downArrow) {
|
||||
if (selectedIndex === 0) {
|
||||
setSelectedIndex(bunxAvailable ? 1 : 2); // Skip bunx if not available
|
||||
} else if (selectedIndex === 1 && bunxAvailable) {
|
||||
setSelectedIndex(2);
|
||||
} else {
|
||||
setSelectedIndex(Math.min(maxIndex, selectedIndex + 1));
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (selectedIndex === 0) {
|
||||
onSelectNpx();
|
||||
} else if (selectedIndex === 1 && bunxAvailable) {
|
||||
onSelectBunx();
|
||||
} else if (selectedIndex === 2) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function onSelect(value: string) {
|
||||
switch (value) {
|
||||
case 'npx':
|
||||
onSelectNpx();
|
||||
break;
|
||||
case 'bunx':
|
||||
if (bunxAvailable) {
|
||||
onSelectBunx();
|
||||
}
|
||||
break;
|
||||
case 'back':
|
||||
onCancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const listItems = [
|
||||
{
|
||||
label: 'npx - Node Package Execute',
|
||||
value: 'npx'
|
||||
},
|
||||
{
|
||||
label: 'bunx - Bun Package Execute',
|
||||
sublabel: bunxAvailable ? undefined : '(not installed)',
|
||||
value: 'bunx',
|
||||
disabled: !bunxAvailable
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Install ccstatusline to Claude Code</Text>
|
||||
@@ -71,29 +79,21 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
<Text dimColor>Select package manager to use:</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box>
|
||||
<Text color={selectedIndex === 0 ? 'blue' : undefined}>
|
||||
{selectedIndex === 0 ? '▶ ' : ' '}
|
||||
npx - Node Package Execute
|
||||
</Text>
|
||||
</Box>
|
||||
<List
|
||||
color='blue'
|
||||
marginTop={1}
|
||||
items={listItems}
|
||||
onSelect={(line) => {
|
||||
if (line === 'back') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
<Box>
|
||||
<Text color={selectedIndex === 1 && bunxAvailable ? 'blue' : undefined} dimColor={!bunxAvailable}>
|
||||
{selectedIndex === 1 && bunxAvailable ? '▶ ' : ' '}
|
||||
bunx - Bun Package Execute
|
||||
{!bunxAvailable && ' (not installed)'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedIndex === 2 ? 'blue' : undefined}>
|
||||
{selectedIndex === 2 ? '▶ ' : ' '}
|
||||
← Back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
onSelect(line);
|
||||
}}
|
||||
initialSelection={initialSelection}
|
||||
showBackButton={true}
|
||||
/>
|
||||
|
||||
<Box marginTop={2}>
|
||||
<Text dimColor>
|
||||
|
||||
+363
-268
@@ -7,6 +7,7 @@ import React, { useState } from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetItem,
|
||||
WidgetItemType
|
||||
@@ -15,10 +16,24 @@ import { getBackgroundColorsForPowerline } from '../../utils/colors';
|
||||
import { generateGuid } from '../../utils/guid';
|
||||
import { canDetectTerminalWidth } from '../../utils/terminal';
|
||||
import {
|
||||
getAllWidgetTypes,
|
||||
getWidget
|
||||
filterWidgetCatalog,
|
||||
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';
|
||||
import { shouldShowCustomKeybind } from './items-editor/keybind-visibility';
|
||||
|
||||
export interface ItemsEditorProps {
|
||||
widgets: WidgetItem[];
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
@@ -30,31 +45,13 @@ export interface ItemsEditorProps {
|
||||
export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onBack, lineNumber, settings }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [moveMode, setMoveMode] = useState(false);
|
||||
const [customEditorWidget, setCustomEditorWidget] = useState<{ widget: WidgetItem; impl: Widget; action?: string } | null>(null);
|
||||
const [customEditorWidget, setCustomEditorWidget] = useState<CustomEditorWidgetState | null>(null);
|
||||
const [widgetPicker, setWidgetPicker] = useState<WidgetPickerState | null>(null);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const separatorChars = ['|', '-', ',', ' '];
|
||||
|
||||
// Determine which item types are allowed based on settings
|
||||
const getAllowedTypes = (): WidgetItemType[] => {
|
||||
let allowedTypes = getAllWidgetTypes(settings);
|
||||
|
||||
// Remove separator if default separator is set
|
||||
if (settings.defaultSeparator) {
|
||||
allowedTypes = allowedTypes.filter(t => t !== 'separator');
|
||||
}
|
||||
|
||||
// Remove both separator and flex-separator if powerline mode is enabled
|
||||
if (settings.powerline.enabled) {
|
||||
allowedTypes = allowedTypes.filter(t => t !== 'separator' && t !== 'flex-separator');
|
||||
}
|
||||
|
||||
return allowedTypes;
|
||||
};
|
||||
|
||||
// Get the default type for new widgets (first non-separator type)
|
||||
const getDefaultItemType = (): WidgetItemType => {
|
||||
const allowedTypes = getAllowedTypes();
|
||||
return allowedTypes.includes('model') ? 'model' : (allowedTypes[0] ?? 'model');
|
||||
};
|
||||
const widgetCatalog = getWidgetCatalog(settings);
|
||||
const widgetCategories = ['All', ...getWidgetCatalogCategories(widgetCatalog)];
|
||||
|
||||
// Get a unique background color for powerline mode
|
||||
const getUniqueBackgroundColor = (insertIndex: number): string | undefined => {
|
||||
@@ -98,200 +95,114 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
setCustomEditorWidget(null);
|
||||
};
|
||||
|
||||
const getVisibleCustomKeybinds = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
|
||||
if (!widgetImpl.getCustomKeybinds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return widgetImpl.getCustomKeybinds().filter(keybind => shouldShowCustomKeybind(widget, keybind));
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
useInput((input, key) => {
|
||||
// Skip input if custom editor is active
|
||||
if (customEditorWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (moveMode) {
|
||||
// In move mode, use up/down to move the selected item
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
const newWidgets = [...widgets];
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const prev = newWidgets[selectedIndex - 1];
|
||||
if (temp && prev) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
|
||||
const newWidgets = [...widgets];
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const next = newWidgets[selectedIndex + 1];
|
||||
if (temp && next) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex + 1);
|
||||
} else if (key.escape || key.return) {
|
||||
// Exit move mode
|
||||
setMoveMode(false);
|
||||
}
|
||||
} else {
|
||||
// Normal mode
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
|
||||
} else if (key.leftArrow && widgets.length > 0) {
|
||||
// Toggle item type backwards
|
||||
const types = getAllowedTypes();
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget) {
|
||||
const currentType = currentWidget.type;
|
||||
let currentIndex = types.indexOf(currentType);
|
||||
// If current type is not in allowed types (e.g., separator when disabled), find a valid type
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
const prevIndex = currentIndex === 0 ? types.length - 1 : currentIndex - 1;
|
||||
const newWidgets = [...widgets];
|
||||
const prevType = types[prevIndex];
|
||||
if (prevType) {
|
||||
newWidgets[selectedIndex] = { ...currentWidget, type: prevType };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
}
|
||||
} else if (key.rightArrow && widgets.length > 0) {
|
||||
// Toggle item type forwards
|
||||
const types = getAllowedTypes();
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget) {
|
||||
const currentType = currentWidget.type;
|
||||
let currentIndex = types.indexOf(currentType);
|
||||
// If current type is not in allowed types (e.g., separator when disabled), find a valid type
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
const nextIndex = (currentIndex + 1) % types.length;
|
||||
const newWidgets = [...widgets];
|
||||
const nextType = types[nextIndex];
|
||||
if (nextType) {
|
||||
newWidgets[selectedIndex] = { ...currentWidget, type: nextType };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
}
|
||||
} else if (key.return && widgets.length > 0) {
|
||||
// Enter move mode
|
||||
setMoveMode(true);
|
||||
} else if (input === 'a') {
|
||||
// Add widget after selected
|
||||
const insertIndex = widgets.length > 0 ? selectedIndex + 1 : 0;
|
||||
const backgroundColor = getUniqueBackgroundColor(insertIndex);
|
||||
const newWidget: WidgetItem = {
|
||||
id: generateGuid(),
|
||||
type: getDefaultItemType(),
|
||||
...(backgroundColor && { backgroundColor })
|
||||
};
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets.splice(insertIndex, 0, newWidget);
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(insertIndex); // Move selection to new widget
|
||||
} else if (input === 'i') {
|
||||
// Insert item before selected
|
||||
const insertIndex = selectedIndex;
|
||||
const backgroundColor = getUniqueBackgroundColor(insertIndex);
|
||||
const newWidget: WidgetItem = {
|
||||
id: generateGuid(),
|
||||
type: getDefaultItemType(),
|
||||
...(backgroundColor && { backgroundColor })
|
||||
};
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets.splice(insertIndex, 0, newWidget);
|
||||
onUpdate(newWidgets);
|
||||
// Keep selection on the new widget (which is now at selectedIndex)
|
||||
} else if (input === 'd' && widgets.length > 0) {
|
||||
// Delete selected item
|
||||
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
|
||||
onUpdate(newWidgets);
|
||||
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
}
|
||||
} else if (input === 'c') {
|
||||
// Clear entire line
|
||||
onUpdate([]);
|
||||
setSelectedIndex(0);
|
||||
} else if (input === ' ' && widgets.length > 0) {
|
||||
// Space key - cycle separator character for separator types only (not flex)
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type === 'separator') {
|
||||
const currentChar = currentWidget.character ?? '|';
|
||||
const currentCharIndex = separatorChars.indexOf(currentChar);
|
||||
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'r' && widgets.length > 0) {
|
||||
// Toggle raw value for non-separator items
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator' && currentWidget.type !== 'custom-text') {
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'm' && widgets.length > 0) {
|
||||
// Cycle through merge states: undefined -> true -> 'no-padding' -> undefined
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
// Don't allow merge on the last item or on separators
|
||||
if (currentWidget && selectedIndex < widgets.length - 1
|
||||
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const newWidgets = [...widgets];
|
||||
let nextMergeState: boolean | 'no-padding' | undefined;
|
||||
|
||||
if (currentWidget.merge === undefined) {
|
||||
nextMergeState = true;
|
||||
} else if (currentWidget.merge === true) {
|
||||
nextMergeState = 'no-padding';
|
||||
} else {
|
||||
nextMergeState = undefined;
|
||||
}
|
||||
|
||||
if (nextMergeState === undefined) {
|
||||
const { merge, ...rest } = currentWidget;
|
||||
void merge; // Intentionally unused
|
||||
newWidgets[selectedIndex] = rest;
|
||||
} else {
|
||||
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onBack();
|
||||
} else if (widgets.length > 0) {
|
||||
// Check for custom widget keybinds
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (widgetImpl) {
|
||||
if (widgetImpl.getCustomKeybinds) {
|
||||
const customKeybinds = widgetImpl.getCustomKeybinds();
|
||||
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
|
||||
|
||||
if (matchedKeybind && !key.ctrl) {
|
||||
// Check if widget handles the action directly
|
||||
if (widgetImpl.handleEditorAction) {
|
||||
// Let the widget handle the action directly
|
||||
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
|
||||
if (updatedWidget) {
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = updatedWidget;
|
||||
onUpdate(newWidgets);
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
// If handleEditorAction returned null, open the editor
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
// Open the widget's custom editor with the action
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
separatorChars,
|
||||
onBack,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode,
|
||||
setShowClearConfirm,
|
||||
openWidgetPicker,
|
||||
getVisibleCustomKeybinds,
|
||||
setCustomEditorWidget
|
||||
});
|
||||
});
|
||||
|
||||
const getWidgetDisplay = (widget: WidgetItem) => {
|
||||
@@ -318,6 +229,26 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
|
||||
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 dynamic help text based on selected item
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
@@ -326,28 +257,31 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
|
||||
// Check if widget supports raw value using registry
|
||||
let canToggleRaw = false;
|
||||
let customKeybinds: { key: string; label: string; action: string }[] = [];
|
||||
let customKeybinds: CustomKeybind[] = [];
|
||||
if (currentWidget && !isSeparator && !isFlexSeparator) {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (widgetImpl) {
|
||||
canToggleRaw = widgetImpl.supportsRawValue();
|
||||
// Get custom keybinds from the widget
|
||||
if (widgetImpl.getCustomKeybinds) {
|
||||
customKeybinds = widgetImpl.getCustomKeybinds();
|
||||
}
|
||||
customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
|
||||
} else {
|
||||
canToggleRaw = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
|
||||
const hasWidgets = widgets.length > 0;
|
||||
|
||||
// Build main help text (without custom keybinds)
|
||||
let helpText = '↑↓ select, ←→ change type';
|
||||
let helpText = hasWidgets
|
||||
? '↑↓ select, ←→ open type picker'
|
||||
: '(a)dd via picker, (i)nsert via picker';
|
||||
if (isSeparator) {
|
||||
helpText += ', Space edit separator';
|
||||
}
|
||||
helpText += ', Enter to move, (a)dd, (i)nsert, (d)elete, (c)lear line';
|
||||
if (hasWidgets) {
|
||||
helpText += ', Enter to move, (a)dd via picker, (i)nsert via picker, (d)elete, (c)lear line';
|
||||
}
|
||||
if (canToggleRaw) {
|
||||
helpText += ', (r)aw value';
|
||||
}
|
||||
@@ -358,6 +292,11 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
|
||||
// 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) {
|
||||
@@ -369,6 +308,39 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -379,6 +351,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
{' '}
|
||||
</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'>
|
||||
@@ -395,6 +368,37 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
<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>
|
||||
@@ -407,58 +411,149 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
<Text dimColor> Flex separators will act as normal separators until width detection is available.</Text>
|
||||
</Box>
|
||||
)}
|
||||
<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) };
|
||||
|
||||
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>
|
||||
{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;
|
||||
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}. ${entry.displayName}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{selectedTopLevelSearchEntry && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor>{selectedTopLevelSearchEntry.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{widget.rawValue && <Text dimColor> (raw value)</Text>}
|
||||
{widget.merge === true && <Text dimColor> (merged→)</Text>}
|
||||
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
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;
|
||||
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}. ${entry.displayName}`}
|
||||
</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>}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{/* 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>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import type { Settings } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { List } from './List';
|
||||
|
||||
interface LineSelectorProps {
|
||||
lines: WidgetItem[][];
|
||||
@@ -40,12 +41,17 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
}) => {
|
||||
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]
|
||||
@@ -59,7 +65,7 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
};
|
||||
|
||||
const deleteLine = (lineIndex: number) => {
|
||||
// Don't allow deleting the last remaining line
|
||||
// Don't allow deleting the last remaining line
|
||||
if (localLines.length <= 1) {
|
||||
return;
|
||||
}
|
||||
@@ -90,31 +96,53 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
switch (input) {
|
||||
case 'a':
|
||||
if (allowEditing) {
|
||||
appendLine();
|
||||
}
|
||||
return;
|
||||
case 'd':
|
||||
if (allowEditing && localLines.length > 1) {
|
||||
setShowDeleteDialog(true);
|
||||
if (moveMode) {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
const newLines = [...localLines];
|
||||
const temp = newLines[selectedIndex];
|
||||
const prev = newLines[selectedIndex - 1];
|
||||
if (temp && prev) {
|
||||
[newLines[selectedIndex], newLines[selectedIndex - 1]] = [prev, temp];
|
||||
}
|
||||
setLocalLines(newLines);
|
||||
onLinesUpdate(newLines);
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < localLines.length - 1) {
|
||||
const newLines = [...localLines];
|
||||
const temp = newLines[selectedIndex];
|
||||
const next = newLines[selectedIndex + 1];
|
||||
if (temp && next) {
|
||||
[newLines[selectedIndex], newLines[selectedIndex + 1]] = [next, temp];
|
||||
}
|
||||
setLocalLines(newLines);
|
||||
onLinesUpdate(newLines);
|
||||
setSelectedIndex(selectedIndex + 1);
|
||||
} 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();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(localLines.length, selectedIndex + 1));
|
||||
} else if (key.return) {
|
||||
if (selectedIndex === localLines.length) {
|
||||
onBack();
|
||||
} else {
|
||||
onSelect(selectedIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -164,7 +192,6 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
<Text>
|
||||
<Text>
|
||||
☰ Line
|
||||
{' '}
|
||||
{selectedIndex + 1}
|
||||
</Text>
|
||||
{' '}
|
||||
@@ -195,57 +222,86 @@ const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const lineItems = localLines.map((line, index) => ({
|
||||
label: `☰ Line ${index + 1}`,
|
||||
sublabel: `(${line.length > 0 ? pluralize('widget', line.length, true) : 'empty'})`,
|
||||
value: index
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>{title ?? 'Select Line to Edit'}</Text>
|
||||
<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>
|
||||
<Text dimColor>
|
||||
{allowEditing ? (
|
||||
localLines.length > 1
|
||||
? '(a) to append new line, (d) to delete line, ESC to go back'
|
||||
: '(a) to append new line, ESC to go back'
|
||||
) : 'ESC to go back'}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{localLines.map((line, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const suffix = line.length
|
||||
? pluralize('widget', line.length, true)
|
||||
: 'empty';
|
||||
{moveMode ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{localLines.map((line, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const suffix = line.length
|
||||
? pluralize('widget', line.length, true)
|
||||
: 'empty';
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
<Text>{isSelected ? '▶ ' : ' '}</Text>
|
||||
<Text>
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Text color={isSelected ? 'blue' : undefined}>
|
||||
<Text>{isSelected ? '◆ ' : ' '}</Text>
|
||||
<Text>
|
||||
☰ Line
|
||||
<Text>
|
||||
☰ Line
|
||||
{' '}
|
||||
{index + 1}
|
||||
</Text>
|
||||
{' '}
|
||||
{index + 1}
|
||||
</Text>
|
||||
{' '}
|
||||
<Text dimColor={!isSelected}>
|
||||
(
|
||||
{suffix}
|
||||
)
|
||||
<Text dimColor={!isSelected}>
|
||||
(
|
||||
{suffix}
|
||||
)
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedIndex === localLines.length ? 'green' : undefined}>
|
||||
{selectedIndex === localLines.length ? '▶ ' : ' '}
|
||||
← Back
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { ForegroundColorName } from 'chalk';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput,
|
||||
type BoxProps
|
||||
} from 'ink';
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren
|
||||
} from 'react';
|
||||
|
||||
export interface ListEntry<V = string | number> {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
value: V;
|
||||
props?: BoxProps;
|
||||
}
|
||||
|
||||
interface ListProps<V = string | number> extends BoxProps {
|
||||
items: (ListEntry<V> | '-')[];
|
||||
onSelect: (value: V | 'back', index: number) => void;
|
||||
onSelectionChange?: (value: V | 'back', index: number) => void;
|
||||
initialSelection?: number;
|
||||
showBackButton?: boolean;
|
||||
color?: ForegroundColorName;
|
||||
wrapNavigation?: boolean;
|
||||
}
|
||||
|
||||
export function List<V = string | number>({
|
||||
items,
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
initialSelection = 0,
|
||||
showBackButton,
|
||||
color,
|
||||
wrapNavigation = false,
|
||||
...boxProps
|
||||
}: ListProps<V>) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
|
||||
const latestOnSelectionChangeRef = useRef(onSelectionChange);
|
||||
|
||||
const _items = useMemo(() => {
|
||||
if (showBackButton) {
|
||||
return [...items, '-' as const, { label: '← Back', value: 'back' as V }];
|
||||
}
|
||||
return items;
|
||||
}, [items, showBackButton]);
|
||||
|
||||
const selectableItems = _items.filter(item => item !== '-' && !item.disabled) as ListEntry<V>[];
|
||||
const selectedItem = selectableItems[selectedIndex];
|
||||
const selectedValue = selectedItem?.value;
|
||||
const actualIndex = _items.findIndex(item => item === selectedItem);
|
||||
|
||||
useEffect(() => {
|
||||
latestOnSelectionChangeRef.current = onSelectionChange;
|
||||
}, [onSelectionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxIndex = Math.max(selectableItems.length - 1, 0);
|
||||
setSelectedIndex(Math.min(initialSelection, maxIndex));
|
||||
}, [initialSelection, selectableItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedValue !== undefined) {
|
||||
latestOnSelectionChangeRef.current?.(selectedValue, selectedIndex);
|
||||
}
|
||||
}, [selectedIndex, selectedValue]);
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.upArrow) {
|
||||
const prev = selectedIndex - 1;
|
||||
const prevIndex = prev < 0
|
||||
? (wrapNavigation ? selectableItems.length - 1 : 0)
|
||||
: prev;
|
||||
|
||||
setSelectedIndex(prevIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
const next = selectedIndex + 1;
|
||||
const nextIndex = next > selectableItems.length - 1
|
||||
? (wrapNavigation ? 0 : selectableItems.length - 1)
|
||||
: next;
|
||||
|
||||
setSelectedIndex(nextIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return && selectedItem) {
|
||||
onSelect(selectedItem.value, selectedIndex);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection='column' {...boxProps}>
|
||||
{_items.map((item, index) => {
|
||||
if (item === '-') {
|
||||
return <ListSeparator key={index} />;
|
||||
}
|
||||
|
||||
const isSelected = index === actualIndex;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={index}
|
||||
isSelected={isSelected}
|
||||
color={color}
|
||||
disabled={item.disabled}
|
||||
{...item.props}
|
||||
>
|
||||
<Text>
|
||||
<Text>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.sublabel && (
|
||||
<Text dimColor={!isSelected}>
|
||||
{' '}
|
||||
{item.sublabel}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedItem?.description && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor wrap='wrap'>
|
||||
{selectedItem.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListItemProps extends PropsWithChildren, BoxProps {
|
||||
isSelected: boolean;
|
||||
color?: ForegroundColorName;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ListItem({
|
||||
children,
|
||||
isSelected,
|
||||
color = 'green',
|
||||
disabled,
|
||||
...boxProps
|
||||
}: ListItemProps) {
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
<Text color={isSelected ? color : undefined} dimColor={disabled}>
|
||||
<Text>{isSelected ? '▶ ' : ' '}</Text>
|
||||
<Text>{children}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListSeparator() {
|
||||
return <Text> </Text>;
|
||||
}
|
||||
+118
-83
@@ -1,15 +1,26 @@
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput
|
||||
Text
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import { type PowerlineFontStatus } from '../../utils/powerline';
|
||||
|
||||
import { List } from './List';
|
||||
|
||||
export type MainMenuOption = 'lines'
|
||||
| 'colors'
|
||||
| 'powerline'
|
||||
| 'terminalConfig'
|
||||
| 'globalOverrides'
|
||||
| 'install'
|
||||
| 'starGithub'
|
||||
| 'save'
|
||||
| 'exit';
|
||||
|
||||
export interface MainMenuProps {
|
||||
onSelect: (value: string) => void;
|
||||
onSelect: (value: MainMenuOption, index: number) => void;
|
||||
isClaudeInstalled: boolean;
|
||||
hasChanges: boolean;
|
||||
initialSelection?: number;
|
||||
@@ -18,103 +29,127 @@ export interface MainMenuProps {
|
||||
previewIsTruncated?: boolean;
|
||||
}
|
||||
|
||||
export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled, hasChanges, initialSelection = 0, powerlineFontStatus, settings, previewIsTruncated }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
|
||||
|
||||
export const MainMenu: React.FC<MainMenuProps> = ({
|
||||
onSelect,
|
||||
isClaudeInstalled,
|
||||
hasChanges,
|
||||
initialSelection = 0,
|
||||
powerlineFontStatus,
|
||||
settings,
|
||||
previewIsTruncated
|
||||
}) => {
|
||||
// Build menu structure with visual gaps
|
||||
const menuItems = [
|
||||
{ label: '📝 Edit Lines', value: 'lines', selectable: true },
|
||||
{ label: '🎨 Edit Colors', value: 'colors', selectable: true },
|
||||
{ label: '⚡ Powerline Setup', value: 'powerline', selectable: true },
|
||||
{ label: '', value: '_gap1', selectable: false }, // Visual gap
|
||||
{ label: '💻 Terminal Options', value: 'terminalConfig', selectable: true },
|
||||
{ label: '🌐 Global Overrides', value: 'globalOverrides', selectable: true },
|
||||
{ label: '', value: '_gap2', selectable: false }, // Visual gap
|
||||
{ label: isClaudeInstalled ? '🔌 Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install', selectable: true }
|
||||
const menuItems: ({
|
||||
label: string;
|
||||
value: MainMenuOption;
|
||||
description: string;
|
||||
} | '-')[] = [
|
||||
{
|
||||
label: '📝 Edit Lines',
|
||||
value: 'lines',
|
||||
description:
|
||||
'Configure any number of status lines with various widgets like model info, git status, and token usage'
|
||||
},
|
||||
{
|
||||
label: '🎨 Edit Colors',
|
||||
value: 'colors',
|
||||
description:
|
||||
'Customize colors for each widget including foreground, background, and bold styling'
|
||||
},
|
||||
{
|
||||
label: '⚡ Powerline Setup',
|
||||
value: 'powerline',
|
||||
description:
|
||||
'Install Powerline fonts for enhanced visual separators and symbols in your status line'
|
||||
},
|
||||
'-' as const,
|
||||
{
|
||||
label: '💻 Terminal Options',
|
||||
value: 'terminalConfig',
|
||||
description: 'Configure terminal-specific settings for optimal display'
|
||||
},
|
||||
{
|
||||
label: '🌐 Global Overrides',
|
||||
value: 'globalOverrides',
|
||||
description:
|
||||
'Set global padding, separators, and color overrides that apply to all widgets'
|
||||
},
|
||||
'-' as const,
|
||||
{
|
||||
label: isClaudeInstalled
|
||||
? '🔌 Uninstall from Claude Code'
|
||||
: '📦 Install to Claude Code',
|
||||
value: 'install',
|
||||
description: isClaudeInstalled
|
||||
? 'Remove ccstatusline from your Claude Code settings'
|
||||
: 'Add ccstatusline to your Claude Code settings for automatic status line rendering'
|
||||
}
|
||||
];
|
||||
|
||||
if (hasChanges) {
|
||||
menuItems.push(
|
||||
{ label: '💾 Save & Exit', value: 'save', selectable: true },
|
||||
{ label: '❌ Exit without saving', value: 'exit', selectable: true }
|
||||
{
|
||||
label: '💾 Save & Exit',
|
||||
value: 'save',
|
||||
description: 'Save all changes and exit the configuration tool'
|
||||
},
|
||||
{
|
||||
label: '❌ Exit without saving',
|
||||
value: 'exit',
|
||||
description: 'Exit without saving your changes'
|
||||
},
|
||||
'-' as const,
|
||||
{
|
||||
label: '⭐ Like ccstatusline? Star us on GitHub',
|
||||
value: 'starGithub',
|
||||
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
|
||||
}
|
||||
);
|
||||
} else {
|
||||
menuItems.push({ label: '🚪 Exit', value: 'exit', selectable: true });
|
||||
menuItems.push(
|
||||
{
|
||||
label: '🚪 Exit',
|
||||
value: 'exit',
|
||||
description: 'Exit the configuration tool'
|
||||
},
|
||||
'-' as const,
|
||||
{
|
||||
label: '⭐ Like ccstatusline? Star us on GitHub',
|
||||
value: 'starGithub',
|
||||
description: 'Open the ccstatusline GitHub repository in your browser so you can star the project'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get only selectable items for navigation
|
||||
const selectableItems = menuItems.filter(item => item.selectable);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(selectableItems.length - 1, selectedIndex + 1));
|
||||
} else if (key.return) {
|
||||
const item = selectableItems[selectedIndex];
|
||||
if (item) {
|
||||
onSelect(item.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get description for selected item
|
||||
const getDescription = (value: string): string => {
|
||||
const descriptions: Record<string, string> = {
|
||||
lines: 'Configure any number of status lines with various widgets like model info, git status, and token usage',
|
||||
colors: 'Customize colors for each widget including foreground, background, and bold styling',
|
||||
powerline: 'Install Powerline fonts for enhanced visual separators and symbols in your status line',
|
||||
globalOverrides: 'Set global padding, separators, and color overrides that apply to all widgets',
|
||||
install: isClaudeInstalled
|
||||
? 'Remove ccstatusline from your Claude Code settings'
|
||||
: 'Add ccstatusline to your Claude Code settings for automatic status line rendering',
|
||||
terminalConfig: 'Configure terminal-specific settings for optimal display',
|
||||
save: 'Save all changes and exit the configuration tool',
|
||||
exit: hasChanges
|
||||
? 'Exit without saving your changes'
|
||||
: 'Exit the configuration tool'
|
||||
};
|
||||
return descriptions[value] ?? '';
|
||||
};
|
||||
|
||||
const selectedItem = selectableItems[selectedIndex];
|
||||
const description = selectedItem ? getDescription(selectedItem.value) : '';
|
||||
|
||||
// Check if we should show the truncation warning
|
||||
const showTruncationWarning = previewIsTruncated && settings?.flexMode === 'full-minus-40';
|
||||
const showTruncationWarning
|
||||
= previewIsTruncated && settings?.flexMode === 'full-minus-40';
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
{showTruncationWarning && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color='yellow'>⚠ Some lines are truncated, see Terminal Options → Terminal Width for info</Text>
|
||||
<Text color='yellow'>
|
||||
⚠ Some lines are truncated, see Terminal Options → Terminal Width
|
||||
for info
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text bold>Main Menu</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{menuItems.map((item, idx) => {
|
||||
if (!item.selectable && item.value.startsWith('_gap')) {
|
||||
return <Text key={item.value}> </Text>;
|
||||
}
|
||||
const selectableIdx = selectableItems.indexOf(item);
|
||||
const isSelected = selectableIdx === selectedIndex;
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={item.value}
|
||||
color={isSelected ? 'green' : undefined}
|
||||
>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{description && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor wrap='wrap'>{description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text bold>Main Menu</Text>
|
||||
|
||||
<List
|
||||
items={menuItems}
|
||||
marginTop={1}
|
||||
onSelect={(value, index) => {
|
||||
if (value === 'back') {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(value, index);
|
||||
}}
|
||||
initialSelection={initialSelection}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import { shouldInsertInput } from '../../utils/input-guards';
|
||||
|
||||
export type EditorMode = 'separator' | 'startCap' | 'endCap';
|
||||
|
||||
@@ -27,12 +28,12 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
// Get the appropriate array based on mode
|
||||
const getItems = () => {
|
||||
switch (mode) {
|
||||
case 'separator':
|
||||
return powerlineConfig.separators;
|
||||
case 'startCap':
|
||||
return powerlineConfig.startCaps;
|
||||
case 'endCap':
|
||||
return powerlineConfig.endCaps;
|
||||
case 'separator':
|
||||
return powerlineConfig.separators;
|
||||
case 'startCap':
|
||||
return powerlineConfig.startCaps;
|
||||
case 'endCap':
|
||||
return powerlineConfig.endCaps;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,24 +83,25 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
const inversionText = mode === 'separator' && invertBg ? ' [Inverted]' : '';
|
||||
return `${preset.char} - ${preset.name}${inversionText}`;
|
||||
}
|
||||
const hexCode = char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
|
||||
return `${char} - Custom (\\u${hexCode})${invertBg ? ' [Inverted]' : ''}`;
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
const hexCode = codePoint.toString(16).toUpperCase().padStart(4, '0');
|
||||
return `${char} - Custom (U+${hexCode})${invertBg ? ' [Inverted]' : ''}`;
|
||||
};
|
||||
|
||||
const updateSeparators = (newSeparators: string[], newInvertBgs?: boolean[]) => {
|
||||
const updatedPowerline = { ...powerlineConfig };
|
||||
|
||||
switch (mode) {
|
||||
case 'separator':
|
||||
updatedPowerline.separators = newSeparators;
|
||||
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
|
||||
break;
|
||||
case 'startCap':
|
||||
updatedPowerline.startCaps = newSeparators;
|
||||
break;
|
||||
case 'endCap':
|
||||
updatedPowerline.endCaps = newSeparators;
|
||||
break;
|
||||
case 'separator':
|
||||
updatedPowerline.separators = newSeparators;
|
||||
updatedPowerline.separatorInvertBackground = newInvertBgs ?? newSeparators.map((_, i) => invertBgs[i] ?? false);
|
||||
break;
|
||||
case 'startCap':
|
||||
updatedPowerline.startCaps = newSeparators;
|
||||
break;
|
||||
case 'endCap':
|
||||
updatedPowerline.endCaps = newSeparators;
|
||||
break;
|
||||
}
|
||||
|
||||
onUpdate({
|
||||
@@ -116,24 +118,27 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
setHexInput('');
|
||||
setCursorPos(0);
|
||||
} else if (key.return) {
|
||||
if (hexInput.length === 4) {
|
||||
const char = String.fromCharCode(parseInt(hexInput, 16));
|
||||
const newSeparators = [...separators];
|
||||
if (separators.length === 0) {
|
||||
// Add new item if list is empty
|
||||
newSeparators.push(char);
|
||||
} else {
|
||||
newSeparators[selectedIndex] = char;
|
||||
if (hexInput.length >= 4 && hexInput.length <= 6) {
|
||||
const codePoint = parseInt(hexInput, 16);
|
||||
if (codePoint >= 0 && codePoint <= 0x10FFFF) {
|
||||
const char = String.fromCodePoint(codePoint);
|
||||
const newSeparators = [...separators];
|
||||
if (separators.length === 0) {
|
||||
// Add new item if list is empty
|
||||
newSeparators.push(char);
|
||||
} else {
|
||||
newSeparators[selectedIndex] = char;
|
||||
}
|
||||
updateSeparators(newSeparators);
|
||||
setHexInputMode(false);
|
||||
setHexInput('');
|
||||
setCursorPos(0);
|
||||
}
|
||||
updateSeparators(newSeparators);
|
||||
setHexInputMode(false);
|
||||
setHexInput('');
|
||||
setCursorPos(0);
|
||||
}
|
||||
} else if (key.backspace && cursorPos > 0) {
|
||||
setHexInput(hexInput.slice(0, cursorPos - 1) + hexInput.slice(cursorPos));
|
||||
setCursorPos(cursorPos - 1);
|
||||
} else if (input && /[0-9a-fA-F]/.test(input) && hexInput.length < 4) {
|
||||
} else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 6) {
|
||||
setHexInput(hexInput.slice(0, cursorPos) + input.toUpperCase() + hexInput.slice(cursorPos));
|
||||
setCursorPos(cursorPos + 1);
|
||||
}
|
||||
@@ -256,12 +261,12 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
|
||||
const getTitle = () => {
|
||||
switch (mode) {
|
||||
case 'separator':
|
||||
return 'Powerline Separator Configuration';
|
||||
case 'startCap':
|
||||
return 'Powerline Start Cap Configuration';
|
||||
case 'endCap':
|
||||
return 'Powerline End Cap Configuration';
|
||||
case 'separator':
|
||||
return 'Powerline Separator Configuration';
|
||||
case 'startCap':
|
||||
return 'Powerline Start Cap Configuration';
|
||||
case 'endCap':
|
||||
return 'Powerline End Cap Configuration';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -275,20 +280,21 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
{hexInputMode ? (
|
||||
<Box marginTop={2} flexDirection='column'>
|
||||
<Text>
|
||||
Enter 4-digit hex code for
|
||||
Enter hex code (4-6 digits) for
|
||||
{' '}
|
||||
{mode === 'separator' ? 'separator' : 'cap'}
|
||||
{separators.length > 0 ? ` ${selectedIndex + 1}` : ''}
|
||||
:
|
||||
</Text>
|
||||
<Text>
|
||||
\u
|
||||
U+
|
||||
{hexInput.slice(0, cursorPos)}
|
||||
<Text backgroundColor='gray' color='black'>{hexInput[cursorPos] ?? '_'}</Text>
|
||||
{hexInput.slice(cursorPos + 1)}
|
||||
{hexInput.length < 4 && hexInput.length === cursorPos && <Text dimColor>{'_'.repeat(4 - hexInput.length - 1)}</Text>}
|
||||
{hexInput.length < 6 && hexInput.length === cursorPos && <Text dimColor>{'_'.repeat(6 - hexInput.length - 1)}</Text>}
|
||||
</Text>
|
||||
<Text dimColor>Enter 4 hex digits (0-9, A-F), then press Enter. ESC to cancel.</Text>
|
||||
<Text dimColor>Enter 4-6 hex digits (0-9, A-F) for a Unicode code point, then press Enter. ESC to cancel.</Text>
|
||||
<Text dimColor>Examples: E0B0 (powerline), 1F984 (🦄), 2764 (❤)</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -6,14 +6,139 @@ import {
|
||||
import * as os from 'os';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { PowerlineConfig } from '../../types/PowerlineConfig';
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import { getDefaultPowerlineTheme } from '../../utils/colors';
|
||||
import { type PowerlineFontStatus } from '../../utils/powerline';
|
||||
import { buildEnabledPowerlineSettings } from '../../utils/powerline-settings';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
import { PowerlineSeparatorEditor } from './PowerlineSeparatorEditor';
|
||||
import { PowerlineThemeSelector } from './PowerlineThemeSelector';
|
||||
|
||||
type PowerlineMenuValue = 'separator' | 'startCap' | 'endCap' | 'themes';
|
||||
type Screen = 'menu' | PowerlineMenuValue;
|
||||
const POWERLINE_MENU_LABEL_WIDTH = 11;
|
||||
|
||||
function formatPowerlineMenuLabel(label: string): string {
|
||||
return label.padEnd(POWERLINE_MENU_LABEL_WIDTH, ' ');
|
||||
}
|
||||
|
||||
export function getSeparatorDisplay(powerlineConfig: PowerlineConfig): string {
|
||||
const seps = powerlineConfig.separators;
|
||||
|
||||
if (seps.length > 1) {
|
||||
return 'multiple';
|
||||
}
|
||||
|
||||
const sep = seps[0] ?? '\uE0B0';
|
||||
const presets = [
|
||||
{ char: '\uE0B0', name: 'Triangle Right' },
|
||||
{ char: '\uE0B2', name: 'Triangle Left' },
|
||||
{ char: '\uE0B4', name: 'Round Right' },
|
||||
{ char: '\uE0B6', name: 'Round Left' }
|
||||
];
|
||||
const preset = presets.find(item => item.char === sep);
|
||||
|
||||
if (preset) {
|
||||
return `${preset.char} - ${preset.name}`;
|
||||
}
|
||||
|
||||
return `${sep} - Custom`;
|
||||
}
|
||||
|
||||
export function getCapDisplay(
|
||||
powerlineConfig: PowerlineConfig,
|
||||
type: 'start' | 'end'
|
||||
): string {
|
||||
const caps = type === 'start'
|
||||
? powerlineConfig.startCaps
|
||||
: powerlineConfig.endCaps;
|
||||
|
||||
if (caps.length === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (caps.length > 1) {
|
||||
return 'multiple';
|
||||
}
|
||||
|
||||
const cap = caps[0];
|
||||
|
||||
if (!cap) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const presets = type === 'start' ? [
|
||||
{ char: '\uE0B2', name: 'Triangle' },
|
||||
{ char: '\uE0B6', name: 'Round' },
|
||||
{ char: '\uE0BA', name: 'Lower Triangle' },
|
||||
{ char: '\uE0BE', name: 'Diagonal' }
|
||||
] : [
|
||||
{ char: '\uE0B0', name: 'Triangle' },
|
||||
{ char: '\uE0B4', name: 'Round' },
|
||||
{ char: '\uE0B8', name: 'Lower Triangle' },
|
||||
{ char: '\uE0BC', name: 'Diagonal' }
|
||||
];
|
||||
const preset = presets.find(item => item.char === cap);
|
||||
|
||||
if (preset) {
|
||||
return `${preset.char} - ${preset.name}`;
|
||||
}
|
||||
|
||||
return `${cap} - Custom`;
|
||||
}
|
||||
|
||||
export function getThemeDisplay(powerlineConfig: PowerlineConfig): string {
|
||||
const theme = powerlineConfig.theme;
|
||||
|
||||
if (!theme || theme === 'custom') {
|
||||
return 'Custom';
|
||||
}
|
||||
|
||||
return theme.charAt(0).toUpperCase() + theme.slice(1);
|
||||
}
|
||||
|
||||
export function buildPowerlineSetupMenuItems(
|
||||
powerlineConfig: PowerlineConfig
|
||||
): ListEntry<PowerlineMenuValue>[] {
|
||||
const disabled = !powerlineConfig.enabled;
|
||||
|
||||
return [
|
||||
{
|
||||
label: formatPowerlineMenuLabel('Separator'),
|
||||
sublabel: `(${getSeparatorDisplay(powerlineConfig)})`,
|
||||
value: 'separator',
|
||||
disabled,
|
||||
description: 'Choose the glyph used between powerline segments.'
|
||||
},
|
||||
{
|
||||
label: formatPowerlineMenuLabel('Start Cap'),
|
||||
sublabel: `(${getCapDisplay(powerlineConfig, 'start')})`,
|
||||
value: 'startCap',
|
||||
disabled,
|
||||
description: 'Configure the cap glyph that appears at the start of each powerline line.'
|
||||
},
|
||||
{
|
||||
label: formatPowerlineMenuLabel('End Cap'),
|
||||
sublabel: `(${getCapDisplay(powerlineConfig, 'end')})`,
|
||||
value: 'endCap',
|
||||
disabled,
|
||||
description: 'Configure the cap glyph that appears at the end of each powerline line.'
|
||||
},
|
||||
{
|
||||
label: formatPowerlineMenuLabel('Themes'),
|
||||
sublabel: `(${getThemeDisplay(powerlineConfig)})`,
|
||||
value: 'themes',
|
||||
disabled,
|
||||
description: 'Preview built-in powerline themes or copy a theme into custom widget colors.'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export interface PowerlineSetupProps {
|
||||
settings: Settings;
|
||||
powerlineFontStatus: PowerlineFontStatus;
|
||||
@@ -25,8 +150,6 @@ export interface PowerlineSetupProps {
|
||||
onClearMessage: () => void;
|
||||
}
|
||||
|
||||
type Screen = 'menu' | 'separator' | 'startCap' | 'endCap' | 'themes';
|
||||
|
||||
export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
settings,
|
||||
powerlineFontStatus,
|
||||
@@ -43,155 +166,55 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
const [confirmingEnable, setConfirmingEnable] = useState(false);
|
||||
const [confirmingFontInstall, setConfirmingFontInstall] = useState(false);
|
||||
|
||||
// Check if there are any separators or flex-separators in the current configuration
|
||||
const hasSeparatorItems = settings.lines.some(line => line.some(item => item.type === 'separator' || item.type === 'flex-separator'));
|
||||
|
||||
// Menu items for navigation
|
||||
const menuItems = [
|
||||
{ label: 'Separator', value: 'separator' },
|
||||
{ label: 'Start Cap', value: 'startCap' },
|
||||
{ label: 'End Cap', value: 'endCap' },
|
||||
{ label: 'Themes', value: 'themes' },
|
||||
{ label: '← Back', value: 'back' }
|
||||
];
|
||||
|
||||
// Helper functions for display
|
||||
const getSeparatorDisplay = (): string => {
|
||||
const seps = powerlineConfig.separators;
|
||||
if (seps.length > 1) {
|
||||
return 'multiple';
|
||||
}
|
||||
const sep = seps[0] ?? '\uE0B0';
|
||||
const presets = [
|
||||
{ char: '\uE0B0', name: 'Triangle Right' },
|
||||
{ char: '\uE0B2', name: 'Triangle Left' },
|
||||
{ char: '\uE0B4', name: 'Round Right' },
|
||||
{ char: '\uE0B6', name: 'Round Left' }
|
||||
];
|
||||
const preset = presets.find(p => p.char === sep);
|
||||
if (preset) {
|
||||
return `${preset.char} - ${preset.name}`;
|
||||
}
|
||||
return `${sep} - Custom`;
|
||||
};
|
||||
|
||||
const getCapDisplay = (type: 'start' | 'end'): string => {
|
||||
const caps = type === 'start'
|
||||
? powerlineConfig.startCaps
|
||||
: powerlineConfig.endCaps;
|
||||
|
||||
if (caps.length === 0)
|
||||
return 'none';
|
||||
if (caps.length > 1)
|
||||
return 'multiple';
|
||||
|
||||
const cap = caps[0];
|
||||
if (!cap)
|
||||
return 'none';
|
||||
|
||||
const presets = type === 'start' ? [
|
||||
{ char: '\uE0B2', name: 'Triangle' },
|
||||
{ char: '\uE0B6', name: 'Round' },
|
||||
{ char: '\uE0BA', name: 'Lower Triangle' },
|
||||
{ char: '\uE0BE', name: 'Diagonal' }
|
||||
] : [
|
||||
{ char: '\uE0B0', name: 'Triangle' },
|
||||
{ char: '\uE0B4', name: 'Round' },
|
||||
{ char: '\uE0B8', name: 'Lower Triangle' },
|
||||
{ char: '\uE0BC', name: 'Diagonal' }
|
||||
];
|
||||
|
||||
const preset = presets.find(c => c.char === cap);
|
||||
if (preset) {
|
||||
return `${preset.char} - ${preset.name}`;
|
||||
}
|
||||
return `${cap} - Custom`;
|
||||
};
|
||||
|
||||
const getThemeDisplay = (): string => {
|
||||
const theme = powerlineConfig.theme;
|
||||
if (!theme || theme === 'custom')
|
||||
return 'Custom';
|
||||
return theme.charAt(0).toUpperCase() + theme.slice(1);
|
||||
};
|
||||
const hasSeparatorItems = settings.lines.some(line => line.some(
|
||||
item => item.type === 'separator' || item.type === 'flex-separator'
|
||||
));
|
||||
|
||||
useInput((input, key) => {
|
||||
// Block all input handling when font installation message is shown or installing
|
||||
if (fontInstallMessage || installingFonts) {
|
||||
// Only clear message on non-escape keys when message is shown
|
||||
if (fontInstallMessage && !key.escape) {
|
||||
onClearMessage();
|
||||
}
|
||||
// Always return early to prevent any other input handling
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip input handling when confirmations are active - let ConfirmDialog handle it
|
||||
if (confirmingFontInstall || confirmingEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (screen === 'menu') {
|
||||
// Menu navigation mode
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedMenuItem(Math.max(0, selectedMenuItem - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedMenuItem(Math.min(menuItems.length - 1, selectedMenuItem + 1));
|
||||
} else if (key.return) {
|
||||
const selected = menuItems[selectedMenuItem];
|
||||
if (selected) {
|
||||
if (selected.value === 'back') {
|
||||
onBack();
|
||||
} else if (powerlineConfig.enabled) {
|
||||
setScreen(selected.value as Screen);
|
||||
}
|
||||
}
|
||||
} else if (input === 't' || input === 'T') {
|
||||
// Toggle powerline mode
|
||||
if (!powerlineConfig.enabled) {
|
||||
// Only show confirmation when enabling if there are separators to remove
|
||||
if (hasSeparatorItems) {
|
||||
setConfirmingEnable(true);
|
||||
} else {
|
||||
// Set to nord theme if currently custom or undefined (first time enabling)
|
||||
const theme = (!powerlineConfig.theme || powerlineConfig.theme === 'custom')
|
||||
? getDefaultPowerlineTheme()
|
||||
: powerlineConfig.theme;
|
||||
|
||||
// Enable directly without confirmation since there are no separators
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
enabled: true,
|
||||
theme,
|
||||
// Separators are already initialized by Zod
|
||||
separators: powerlineConfig.separators,
|
||||
separatorInvertBackground: powerlineConfig.separatorInvertBackground
|
||||
},
|
||||
defaultPadding: ' ' // Set padding to space when enabling powerline
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
onUpdate(buildEnabledPowerlineSettings(settings, false));
|
||||
}
|
||||
} else {
|
||||
// Disable without confirmation
|
||||
const newConfig = { ...powerlineConfig, enabled: false };
|
||||
onUpdate({ ...settings, powerline: newConfig });
|
||||
onUpdate({
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
enabled: false
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (input === 'i' || input === 'I') {
|
||||
// Show font installation consent prompt
|
||||
setConfirmingFontInstall(true);
|
||||
} else if ((input === 'a' || input === 'A') && powerlineConfig.enabled) {
|
||||
// Toggle autoAlign when powerline is enabled
|
||||
const newConfig = { ...powerlineConfig, autoAlign: !powerlineConfig.autoAlign };
|
||||
onUpdate({ ...settings, powerline: newConfig });
|
||||
onUpdate({
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
autoAlign: !powerlineConfig.autoAlign
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Render sub-screens
|
||||
if (screen === 'separator') {
|
||||
return (
|
||||
<PowerlineSeparatorEditor
|
||||
@@ -235,7 +258,6 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Main menu screen
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
{!confirmingFontInstall && !installingFonts && !fontInstallMessage && (
|
||||
@@ -324,28 +346,7 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
<ConfirmDialog
|
||||
inline={true}
|
||||
onConfirm={() => {
|
||||
// Set to nord theme if currently custom or undefined (first time enabling)
|
||||
const theme = (!powerlineConfig.theme || powerlineConfig.theme === 'custom')
|
||||
? getDefaultPowerlineTheme()
|
||||
: powerlineConfig.theme;
|
||||
|
||||
// Remove all separators and flex-separators from lines
|
||||
// Also set default padding to a space when enabling powerline
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
enabled: true,
|
||||
theme,
|
||||
// Separators are already initialized by Zod
|
||||
separators: powerlineConfig.separators,
|
||||
separatorInvertBackground: powerlineConfig.separatorInvertBackground
|
||||
},
|
||||
defaultPadding: ' ', // Set padding to space when enabling powerline
|
||||
lines: settings.lines.map(line => line.filter(item => item.type !== 'separator' && item.type !== 'flex-separator')
|
||||
)
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
onUpdate(buildEnabledPowerlineSettings(settings, true));
|
||||
setConfirmingEnable(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
@@ -412,62 +413,29 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{powerlineConfig.enabled ? (
|
||||
<>
|
||||
{menuItems.map((item, index) => {
|
||||
const isSelected = index === selectedMenuItem;
|
||||
let displayValue = '';
|
||||
{!powerlineConfig.enabled && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Enable Powerline mode to configure separators, caps, and themes.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
switch (item.value) {
|
||||
case 'separator':
|
||||
displayValue = getSeparatorDisplay();
|
||||
break;
|
||||
case 'startCap':
|
||||
displayValue = getCapDisplay('start');
|
||||
break;
|
||||
case 'endCap':
|
||||
displayValue = getCapDisplay('end');
|
||||
break;
|
||||
case 'themes':
|
||||
displayValue = getThemeDisplay();
|
||||
break;
|
||||
case 'back':
|
||||
displayValue = '';
|
||||
break;
|
||||
}
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildPowerlineSetupMenuItems(powerlineConfig)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.value === 'back') {
|
||||
return (
|
||||
<Box key={item.value} marginTop={1}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={item.value}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
{item.label.padEnd(11, ' ')}
|
||||
<Text dimColor>
|
||||
{displayValue && `(${displayValue})`}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
// When powerline is disabled, show ESC to go back message
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press ESC to go back</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
setScreen(value);
|
||||
}}
|
||||
onSelectionChange={(_, index) => {
|
||||
setSelectedMenuItem(index);
|
||||
}}
|
||||
initialSelection={selectedMenuItem}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
@@ -16,6 +18,74 @@ import {
|
||||
} from '../../utils/colors';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
export function buildPowerlineThemeItems(
|
||||
themes: string[],
|
||||
originalTheme: string
|
||||
): ListEntry<string>[] {
|
||||
return themes.map((themeName) => {
|
||||
const theme = getPowerlineTheme(themeName);
|
||||
|
||||
return {
|
||||
label: theme?.name ?? themeName,
|
||||
sublabel: themeName === originalTheme ? '(original)' : undefined,
|
||||
value: themeName,
|
||||
description: theme?.description ?? ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function applyCustomPowerlineTheme(
|
||||
settings: Settings,
|
||||
themeName: string
|
||||
): Settings | null {
|
||||
const theme = getPowerlineTheme(themeName);
|
||||
|
||||
if (!theme || themeName === 'custom') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colorLevel = getColorLevelString(settings.colorLevel);
|
||||
const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3';
|
||||
const themeColors = theme[colorLevelKey];
|
||||
|
||||
if (!themeColors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = settings.lines.map((line) => {
|
||||
let widgetColorIndex = 0;
|
||||
|
||||
return line.map((widget) => {
|
||||
if (widget.type === 'separator' || widget.type === 'flex-separator') {
|
||||
return widget;
|
||||
}
|
||||
|
||||
const fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length];
|
||||
const bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length];
|
||||
widgetColorIndex++;
|
||||
|
||||
return {
|
||||
...widget,
|
||||
color: fgColor,
|
||||
backgroundColor: bgColor
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...settings,
|
||||
powerline: {
|
||||
...settings.powerline,
|
||||
theme: 'custom'
|
||||
},
|
||||
lines
|
||||
};
|
||||
}
|
||||
|
||||
export interface PowerlineThemeSelectorProps {
|
||||
settings: Settings;
|
||||
@@ -28,120 +98,63 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
|
||||
onUpdate,
|
||||
onBack
|
||||
}) => {
|
||||
const themes = getPowerlineThemes();
|
||||
const themes = useMemo(() => getPowerlineThemes(), []);
|
||||
const currentTheme = settings.powerline.theme ?? 'custom';
|
||||
const [selectedIndex, setSelectedIndex] = useState(Math.max(0, themes.indexOf(currentTheme)));
|
||||
const [showCustomizeConfirm, setShowCustomizeConfirm] = useState(false);
|
||||
const originalThemeRef = useRef(currentTheme);
|
||||
const originalSettingsRef = useRef(settings);
|
||||
const latestSettingsRef = useRef(settings);
|
||||
const latestOnUpdateRef = useRef(onUpdate);
|
||||
const didHandleInitialSelectionRef = useRef(false);
|
||||
|
||||
const applyTheme = (themeName: string) => {
|
||||
// Simply change the theme setting, don't modify widget colors
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
useEffect(() => {
|
||||
latestSettingsRef.current = settings;
|
||||
latestOnUpdateRef.current = onUpdate;
|
||||
}, [settings, onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
const themeName = themes[selectedIndex];
|
||||
|
||||
if (!themeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!didHandleInitialSelectionRef.current) {
|
||||
didHandleInitialSelectionRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
latestOnUpdateRef.current({
|
||||
...latestSettingsRef.current,
|
||||
powerline: {
|
||||
...settings.powerline,
|
||||
...latestSettingsRef.current.powerline,
|
||||
theme: themeName
|
||||
}
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
};
|
||||
|
||||
const customizeTheme = () => {
|
||||
// Copy current theme's colors to widgets and switch to custom theme
|
||||
const currentThemeName = themes[selectedIndex];
|
||||
if (!currentThemeName) {
|
||||
return;
|
||||
}
|
||||
const theme = getPowerlineTheme(currentThemeName);
|
||||
|
||||
if (!theme || currentThemeName === 'custom') {
|
||||
// If already on custom, just go back
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
const colorLevel = getColorLevelString(settings.colorLevel);
|
||||
const colorLevelKey = colorLevel === 'ansi16' ? '1' : colorLevel === 'ansi256' ? '2' : '3';
|
||||
const themeColors = theme[colorLevelKey];
|
||||
|
||||
if (themeColors) {
|
||||
// Apply theme colors to widgets
|
||||
const newLines = settings.lines.map((line) => {
|
||||
let widgetColorIndex = 0;
|
||||
return line.map((widget) => {
|
||||
// Skip separators
|
||||
if (widget.type === 'separator' || widget.type === 'flex-separator') {
|
||||
return widget;
|
||||
}
|
||||
|
||||
const fgColor = themeColors.fg[widgetColorIndex % themeColors.fg.length];
|
||||
const bgColor = themeColors.bg[widgetColorIndex % themeColors.bg.length];
|
||||
widgetColorIndex++;
|
||||
|
||||
return {
|
||||
...widget,
|
||||
color: fgColor,
|
||||
backgroundColor: bgColor
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
powerline: {
|
||||
...settings.powerline,
|
||||
theme: 'custom'
|
||||
},
|
||||
lines: newLines
|
||||
};
|
||||
|
||||
onUpdate(updatedSettings);
|
||||
}
|
||||
|
||||
onBack();
|
||||
};
|
||||
});
|
||||
}, [selectedIndex, themes]);
|
||||
|
||||
useInput((input, key) => {
|
||||
// Skip input handling when confirmation is active - let ConfirmDialog handle it
|
||||
if (showCustomizeConfirm) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
// Normal input handling
|
||||
if (key.escape) {
|
||||
// Restore original settings completely when canceling
|
||||
onUpdate(originalSettingsRef.current);
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
const newIndex = Math.max(0, selectedIndex - 1);
|
||||
setSelectedIndex(newIndex);
|
||||
const newTheme = themes[newIndex];
|
||||
if (newTheme) {
|
||||
applyTheme(newTheme);
|
||||
}
|
||||
} else if (key.downArrow) {
|
||||
const newIndex = Math.min(themes.length - 1, selectedIndex + 1);
|
||||
setSelectedIndex(newIndex);
|
||||
const newTheme = themes[newIndex];
|
||||
if (newTheme) {
|
||||
applyTheme(newTheme);
|
||||
}
|
||||
} else if (key.return) {
|
||||
// User confirmed their selection, so we keep the current theme
|
||||
onBack();
|
||||
} else if (input === 'c' || input === 'C') {
|
||||
// Customize theme - copy theme colors to widgets
|
||||
const currentThemeName = themes[selectedIndex];
|
||||
if (currentThemeName && currentThemeName !== 'custom') {
|
||||
setShowCustomizeConfirm(true);
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onUpdate(originalSettingsRef.current);
|
||||
onBack();
|
||||
} else if (input === 'c' || input === 'C') {
|
||||
const currentThemeName = themes[selectedIndex];
|
||||
if (currentThemeName && currentThemeName !== 'custom') {
|
||||
setShowCustomizeConfirm(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const selectedThemeName = themes[selectedIndex];
|
||||
const selectedTheme = selectedThemeName ? getPowerlineTheme(selectedThemeName) : undefined;
|
||||
const themeItems = useMemo(
|
||||
() => buildPowerlineThemeItems(themes, originalThemeRef.current),
|
||||
[themes]
|
||||
);
|
||||
|
||||
if (showCustomizeConfirm) {
|
||||
return (
|
||||
@@ -159,8 +172,14 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
|
||||
<ConfirmDialog
|
||||
inline={true}
|
||||
onConfirm={() => {
|
||||
customizeTheme();
|
||||
if (selectedThemeName) {
|
||||
const updatedSettings = applyCustomPowerlineTheme(settings, selectedThemeName);
|
||||
if (updatedSettings) {
|
||||
onUpdate(updatedSettings);
|
||||
}
|
||||
}
|
||||
setShowCustomizeConfirm(false);
|
||||
onBack();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowCustomizeConfirm(false);
|
||||
@@ -185,40 +204,30 @@ export const PowerlineThemeSelector: React.FC<PowerlineThemeSelectorProps> = ({
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{themes.map((themeName, index) => {
|
||||
const theme = getPowerlineTheme(themeName);
|
||||
const isSelected = index === selectedIndex;
|
||||
const isOriginal = themeName === originalThemeRef.current;
|
||||
<List
|
||||
marginTop={1}
|
||||
items={themeItems}
|
||||
onSelect={() => {
|
||||
onBack();
|
||||
}}
|
||||
onSelectionChange={(themeName, index) => {
|
||||
if (themeName === 'back') {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={themeName}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
{theme?.name ?? themeName}
|
||||
{isOriginal && <Text dimColor> (original)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
setSelectedIndex(index);
|
||||
}}
|
||||
initialSelection={selectedIndex}
|
||||
/>
|
||||
|
||||
{selectedTheme && (
|
||||
<Box marginTop={2} flexDirection='column'>
|
||||
<Text dimColor>Description:</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text>{selectedTheme.description}</Text>
|
||||
</Box>
|
||||
{selectedThemeName && selectedThemeName !== 'custom' && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press (c) to customize this theme - copies colors to widgets</Text>
|
||||
</Box>
|
||||
)}
|
||||
{settings.colorLevel === 1 && (
|
||||
<Box>
|
||||
<Text color='yellow'>⚠ 16 color mode themes have a very limited palette, we recommend switching color level in Terminal Options</Text>
|
||||
</Box>
|
||||
)}
|
||||
{selectedThemeName && selectedThemeName !== 'custom' && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press (c) to customize this theme - copies colors to widgets</Text>
|
||||
</Box>
|
||||
)}
|
||||
{settings.colorLevel === 1 && (
|
||||
<Box marginTop={1}>
|
||||
<Text color='yellow'>⚠ 16 color mode themes have a very limited palette, we recommend switching color level in Terminal Options</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type PreRenderedWidget,
|
||||
type RenderResult
|
||||
} from '../../utils/renderer';
|
||||
import { canDetectTerminalWidth } from '../../utils/terminal';
|
||||
import { advanceGlobalSeparatorIndex } from '../../utils/separator-index';
|
||||
|
||||
export interface StatusLinePreviewProps {
|
||||
lines: WidgetItem[][];
|
||||
@@ -27,7 +27,6 @@ export interface StatusLinePreviewProps {
|
||||
const renderSingleLine = (
|
||||
widgets: WidgetItem[],
|
||||
terminalWidth: number,
|
||||
widthDetectionAvailable: boolean,
|
||||
settings: Settings,
|
||||
lineIndex: number,
|
||||
globalSeparatorIndex: number,
|
||||
@@ -46,8 +45,6 @@ const renderSingleLine = (
|
||||
};
|
||||
|
||||
export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings, onTruncationChange }) => {
|
||||
const widthDetectionAvailable = React.useMemo(() => canDetectTerminalWidth(), []);
|
||||
|
||||
// Render each configured line
|
||||
// Pass the full terminal width - the renderer will handle preview adjustments
|
||||
const { renderedLines, anyTruncated } = React.useMemo(() => {
|
||||
@@ -66,22 +63,18 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
const lineItems = lines[i];
|
||||
if (lineItems && lineItems.length > 0) {
|
||||
const preRenderedWidgets = preRenderedLines[i] ?? [];
|
||||
const renderResult = renderSingleLine(lineItems, terminalWidth, widthDetectionAvailable, settings, i, globalSeparatorIndex, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
const renderResult = renderSingleLine(lineItems, terminalWidth, settings, i, globalSeparatorIndex, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
result.push(renderResult.line);
|
||||
if (renderResult.wasTruncated) {
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
// Count separators used in this line (widgets - 1, excluding merged widgets)
|
||||
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
|
||||
if (nonMergedWidgets.length > 1) {
|
||||
globalSeparatorIndex += nonMergedWidgets.length - 1;
|
||||
}
|
||||
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
|
||||
}
|
||||
}
|
||||
|
||||
return { renderedLines: result, anyTruncated: truncated };
|
||||
}, [lines, terminalWidth, widthDetectionAvailable, settings]);
|
||||
}, [lines, terminalWidth, settings]);
|
||||
|
||||
// Notify parent when truncation status changes
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -7,10 +7,56 @@ import {
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { getWidget } from '../../utils/widgets';
|
||||
import {
|
||||
hasCustomWidgetColors,
|
||||
sanitizeLinesForColorLevel
|
||||
} from '../../utils/color-sanitize';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
type TerminalOptionsValue = 'width' | 'colorLevel';
|
||||
|
||||
export function getNextColorLevel(level: 0 | 1 | 2 | 3): 0 | 1 | 2 | 3 {
|
||||
return ((level + 1) % 4) as 0 | 1 | 2 | 3;
|
||||
}
|
||||
|
||||
export function shouldWarnOnColorLevelChange(
|
||||
currentLevel: 0 | 1 | 2 | 3,
|
||||
nextLevel: 0 | 1 | 2 | 3,
|
||||
hasCustomColors: boolean
|
||||
): boolean {
|
||||
return hasCustomColors
|
||||
&& ((currentLevel === 2 && nextLevel !== 2)
|
||||
|| (currentLevel === 3 && nextLevel !== 3));
|
||||
}
|
||||
|
||||
export function buildTerminalOptionsItems(
|
||||
colorLevel: 0 | 1 | 2 | 3
|
||||
): ListEntry<TerminalOptionsValue>[] {
|
||||
return [
|
||||
{
|
||||
label: '◱ Terminal Width',
|
||||
value: 'width',
|
||||
description: 'Configure how the status line uses available terminal width and when it should compact.'
|
||||
},
|
||||
{
|
||||
label: '▓ Color Level',
|
||||
sublabel: `(${getColorLevelLabel(colorLevel)})`,
|
||||
value: 'colorLevel',
|
||||
description: [
|
||||
'Color level affects how colors are rendered:',
|
||||
'• Truecolor: Full 24-bit RGB colors (16.7M colors)',
|
||||
'• 256 Color: Extended color palette (256 colors)',
|
||||
'• Basic: Standard 16-color terminal palette',
|
||||
'• No Color: Disables all color output'
|
||||
].join('\n')
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export interface TerminalOptionsMenuProps {
|
||||
settings: Settings;
|
||||
@@ -18,124 +64,51 @@ export interface TerminalOptionsMenuProps {
|
||||
onBack: (target?: string) => void;
|
||||
}
|
||||
|
||||
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settings, onUpdate, onBack }) => {
|
||||
export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({
|
||||
settings,
|
||||
onUpdate,
|
||||
onBack
|
||||
}) => {
|
||||
const [showColorWarning, setShowColorWarning] = useState(false);
|
||||
const [pendingColorLevel, setPendingColorLevel] = useState<0 | 1 | 2 | 3 | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const handleSelect = () => {
|
||||
if (selectedIndex === 2) {
|
||||
// Back button
|
||||
const handleSelect = (value: TerminalOptionsValue | 'back') => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
} else if (selectedIndex === 0) {
|
||||
// Terminal Width Options
|
||||
onBack('width');
|
||||
} else if (selectedIndex === 1) {
|
||||
// Color Level
|
||||
// Check if there are any custom colors that would be lost
|
||||
const hasCustomColors = settings.lines.some((line: WidgetItem[]) => line.some((widget: WidgetItem) => Boolean(widget.color && (widget.color.startsWith('ansi256:') || widget.color.startsWith('hex:')))
|
||||
|| Boolean(widget.backgroundColor && (widget.backgroundColor.startsWith('ansi256:') || widget.backgroundColor.startsWith('hex:')))
|
||||
)
|
||||
);
|
||||
|
||||
const currentLevel = settings.colorLevel;
|
||||
const nextLevel = ((currentLevel + 1) % 4) as 0 | 1 | 2 | 3;
|
||||
|
||||
// Warn if switching away from mode that supports custom colors
|
||||
if (hasCustomColors
|
||||
&& ((currentLevel === 2 && nextLevel !== 2) // Switching from 256 color mode
|
||||
|| (currentLevel === 3 && nextLevel !== 3))) { // Switching from truecolor mode
|
||||
setShowColorWarning(true);
|
||||
setPendingColorLevel(nextLevel);
|
||||
} else {
|
||||
// Update chalk level immediately
|
||||
chalk.level = nextLevel;
|
||||
|
||||
// Clean up incompatible custom colors even when no warning is shown
|
||||
const cleanedLines = settings.lines.map(line => line.map((widget) => {
|
||||
const newWidget = { ...widget };
|
||||
// Remove custom colors incompatible with the new mode
|
||||
if (nextLevel === 2) {
|
||||
// Switching to 256 color mode - remove hex colors
|
||||
if (widget.color?.startsWith('hex:')) {
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
newWidget.color = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.backgroundColor?.startsWith('hex:')) {
|
||||
newWidget.backgroundColor = undefined;
|
||||
}
|
||||
} else if (nextLevel === 3) {
|
||||
// Switching to truecolor mode - remove ansi256 colors
|
||||
if (widget.color?.startsWith('ansi256:')) {
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
newWidget.color = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.backgroundColor?.startsWith('ansi256:')) {
|
||||
newWidget.backgroundColor = undefined;
|
||||
}
|
||||
} else {
|
||||
// Switching to 16 color mode - remove all custom colors
|
||||
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
newWidget.color = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
|
||||
newWidget.backgroundColor = undefined;
|
||||
}
|
||||
}
|
||||
return newWidget;
|
||||
})
|
||||
);
|
||||
|
||||
onUpdate({
|
||||
...settings,
|
||||
lines: cleanedLines,
|
||||
colorLevel: nextLevel
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'width') {
|
||||
onBack('width');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCustomColors = hasCustomWidgetColors(settings.lines);
|
||||
const currentLevel = settings.colorLevel;
|
||||
const nextLevel = getNextColorLevel(currentLevel);
|
||||
|
||||
if (shouldWarnOnColorLevelChange(currentLevel, nextLevel, hasCustomColors)) {
|
||||
setShowColorWarning(true);
|
||||
setPendingColorLevel(nextLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
chalk.level = nextLevel;
|
||||
|
||||
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, nextLevel);
|
||||
|
||||
onUpdate({
|
||||
...settings,
|
||||
lines: cleanedLines,
|
||||
colorLevel: nextLevel
|
||||
});
|
||||
};
|
||||
|
||||
const handleColorConfirm = () => {
|
||||
// Proceed with color level change and clean up custom colors
|
||||
if (pendingColorLevel !== null) {
|
||||
chalk.level = pendingColorLevel;
|
||||
|
||||
// Clean up custom colors if switching away from modes that support them
|
||||
const cleanedLines = settings.lines.map(line => line.map((widget) => {
|
||||
const newWidget = { ...widget };
|
||||
// Remove custom colors if switching to a mode that doesn't support them
|
||||
if ((pendingColorLevel !== 2 && pendingColorLevel !== 3)
|
||||
|| (pendingColorLevel === 2 && (widget.color?.startsWith('hex:') || widget.backgroundColor?.startsWith('hex:')))
|
||||
|| (pendingColorLevel === 3 && (widget.color?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('ansi256:')))) {
|
||||
// Reset custom colors to defaults
|
||||
if (widget.color?.startsWith('ansi256:') || widget.color?.startsWith('hex:')) {
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (widgetImpl) {
|
||||
newWidget.color = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.backgroundColor?.startsWith('ansi256:') || widget.backgroundColor?.startsWith('hex:')) {
|
||||
newWidget.backgroundColor = undefined;
|
||||
}
|
||||
}
|
||||
return newWidget;
|
||||
})
|
||||
);
|
||||
const cleanedLines = sanitizeLinesForColorLevel(settings.lines, pendingColorLevel);
|
||||
|
||||
onUpdate({
|
||||
...settings,
|
||||
@@ -152,19 +125,9 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
|
||||
setPendingColorLevel(null);
|
||||
};
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
if (!showColorWarning) {
|
||||
onBack();
|
||||
}
|
||||
} else if (!showColorWarning) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(2, selectedIndex + 1));
|
||||
} else if (key.return) {
|
||||
handleSelect();
|
||||
}
|
||||
useInput((_, key) => {
|
||||
if (key.escape && !showColorWarning) {
|
||||
onBack();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -187,39 +150,12 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
|
||||
) : (
|
||||
<>
|
||||
<Text color='white'>Configure terminal-specific settings for optimal display</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box>
|
||||
<Text color={selectedIndex === 0 ? 'green' : undefined}>
|
||||
{selectedIndex === 0 ? '▶ ' : ' '}
|
||||
◱ Terminal Width
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={selectedIndex === 1 ? 'green' : undefined}>
|
||||
{selectedIndex === 1 ? '▶ ' : ' '}
|
||||
▓ Color Level:
|
||||
{' '}
|
||||
{getColorLevelLabel(settings.colorLevel)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedIndex === 2 ? 'green' : undefined}>
|
||||
{selectedIndex === 2 ? '▶ ' : ' '}
|
||||
← Back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{selectedIndex === 1 && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text dimColor>Color level affects how colors are rendered:</Text>
|
||||
<Text dimColor>• Truecolor: Full 24-bit RGB colors (16.7M colors)</Text>
|
||||
<Text dimColor>• 256 Color: Extended color palette (256 colors)</Text>
|
||||
<Text dimColor>• Basic: Standard 16-color terminal palette</Text>
|
||||
<Text dimColor>• No Color: Disables all color output</Text>
|
||||
</Box>
|
||||
)}
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildTerminalOptionsItems(settings.colorLevel)}
|
||||
onSelect={handleSelect}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -228,11 +164,11 @@ export const TerminalOptionsMenu: React.FC<TerminalOptionsMenuProps> = ({ settin
|
||||
|
||||
export const getColorLevelLabel = (level?: 0 | 1 | 2 | 3): string => {
|
||||
switch (level) {
|
||||
case 0: return 'No Color';
|
||||
case 1: return 'Basic';
|
||||
case 2:
|
||||
case undefined: return '256 Color (default)';
|
||||
case 3: return 'Truecolor';
|
||||
default: return '256 Color (default)';
|
||||
case 0: return 'No Color';
|
||||
case 1: return 'Basic';
|
||||
case 2:
|
||||
case undefined: return '256 Color (default)';
|
||||
case 3: return 'Truecolor';
|
||||
default: return '256 Color (default)';
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,62 @@ 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;
|
||||
@@ -14,32 +70,28 @@ export interface TerminalWidthMenuProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings, onUpdate, onBack }) => {
|
||||
export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({
|
||||
settings,
|
||||
onUpdate,
|
||||
onBack
|
||||
}) => {
|
||||
const [selectedOption, setSelectedOption] = useState<FlexMode>(settings.flexMode);
|
||||
const [compactThreshold, setCompactThreshold] = useState(settings.compactThreshold);
|
||||
const [editingThreshold, setEditingThreshold] = useState(false);
|
||||
const [thresholdInput, setThresholdInput] = useState(String(settings.compactThreshold));
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
// For manual navigation: 0-2 for options, 3 for back
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => {
|
||||
const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
|
||||
return options.indexOf(settings.flexMode);
|
||||
});
|
||||
|
||||
const options: FlexMode[] = ['full', 'full-minus-40', 'full-until-compact'];
|
||||
|
||||
useInput((input, key) => {
|
||||
if (editingThreshold) {
|
||||
if (key.return) {
|
||||
const value = parseInt(thresholdInput, 10);
|
||||
if (isNaN(value)) {
|
||||
setValidationError('Please enter a valid number');
|
||||
} else if (value < 1 || value > 99) {
|
||||
setValidationError(`Value must be between 1 and 99 (you entered ${value})`);
|
||||
const error = validateCompactThresholdInput(thresholdInput);
|
||||
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
} else {
|
||||
const value = parseInt(thresholdInput, 10);
|
||||
setCompactThreshold(value);
|
||||
// Update settings with both flexMode and the new threshold
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
flexMode: selectedOption,
|
||||
@@ -58,66 +110,21 @@ export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings,
|
||||
setValidationError(null);
|
||||
} else if (key.delete) {
|
||||
// For simple number inputs, forward delete does nothing since there's no cursor position
|
||||
} else if (input && /\d/.test(input)) {
|
||||
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
|
||||
const newValue = thresholdInput + input;
|
||||
if (newValue.length <= 2) {
|
||||
setThresholdInput(newValue);
|
||||
setValidationError(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(3, selectedIndex + 1)); // 0-2 for options, 3 for back
|
||||
} else if (key.return) {
|
||||
if (selectedIndex === 3) {
|
||||
onBack();
|
||||
} else if (selectedIndex >= 0 && selectedIndex < options.length) {
|
||||
const mode = options[selectedIndex];
|
||||
if (mode) {
|
||||
setSelectedOption(mode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update settings
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
flexMode: mode,
|
||||
compactThreshold: compactThreshold
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
|
||||
if (mode === 'full-until-compact') {
|
||||
// Prompt for threshold editing
|
||||
setEditingThreshold(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
}
|
||||
});
|
||||
|
||||
const optionDetails = [
|
||||
{
|
||||
value: 'full' as FlexMode,
|
||||
label: 'Full width always',
|
||||
description: 'Uses the full terminal width minus 4 characters for terminal padding. If the auto-compact message appears, it may cause the line to wrap.\n\nNOTE: If /ide integration is enabled, it\'s not recommended to use this mode.'
|
||||
},
|
||||
{
|
||||
value: 'full-minus-40' as FlexMode,
|
||||
label: 'Full width minus 40 (default)',
|
||||
description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.'
|
||||
},
|
||||
{
|
||||
value: 'full-until-compact' as FlexMode,
|
||||
label: 'Full width until compact',
|
||||
description: `Dynamically adjusts width based on context usage. When context reaches ${compactThreshold}%, it switches to leaving space for the auto-compact message.\n\nNOTE: If /ide integration is enabled, it's not recommended to use this mode.`
|
||||
}
|
||||
];
|
||||
|
||||
const currentOption = selectedIndex < 3 ? optionDetails[selectedIndex] : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Terminal Width</Text>
|
||||
@@ -139,38 +146,31 @@ export const TerminalWidthMenu: React.FC<TerminalWidthMenuProps> = ({ settings,
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{optionDetails.map((opt, index) => (
|
||||
<Box key={opt.value}>
|
||||
<Text color={selectedIndex === index ? 'green' : undefined}>
|
||||
{selectedIndex === index ? '▶ ' : ' '}
|
||||
{opt.label}
|
||||
{opt.value === selectedOption ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildTerminalWidthItems(selectedOption, compactThreshold)}
|
||||
initialSelection={getTerminalWidthSelectionIndex(selectedOption)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedIndex === 3 ? 'green' : undefined}>
|
||||
{selectedIndex === 3 ? '▶ ' : ' '}
|
||||
← Back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
setSelectedOption(value);
|
||||
|
||||
{currentOption && (
|
||||
<Box marginTop={1} marginBottom={1} borderStyle='round' borderColor='dim' paddingX={1}>
|
||||
<Box flexDirection='column'>
|
||||
<Text>
|
||||
<Text color='yellow'>{currentOption.label}</Text>
|
||||
{currentOption.value === 'full-until-compact' && ` | Current threshold: ${compactThreshold}%`}
|
||||
</Text>
|
||||
<Text dimColor wrap='wrap'>{currentOption.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
flexMode: value,
|
||||
compactThreshold
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
|
||||
if (value === 'full-until-compact') {
|
||||
setEditingThreshold(true);
|
||||
}
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { InstallMenu } from '../InstallMenu';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): CapturedWriteStream {
|
||||
const stream = new MockTtyStream();
|
||||
const chunks: string[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
return Object.assign(stream as unknown as NodeJS.WriteStream, {
|
||||
getOutput() {
|
||||
return stripAnsi(chunks.join(''));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('InstallMenu', () => {
|
||||
it('calls onCancel when escape is pressed', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onCancel = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
bunxAvailable: true,
|
||||
existingStatusLine: null,
|
||||
onSelectNpx: vi.fn(),
|
||||
onSelectBunx: vi.fn(),
|
||||
onCancel
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
|
||||
stdin.write('\u001B');
|
||||
await flushInk();
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('respects the provided initial selection', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
bunxAvailable: true,
|
||||
existingStatusLine: null,
|
||||
onSelectNpx: vi.fn(),
|
||||
onSelectBunx: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
initialSelection: 1
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('▶ bunx - Bun Package Execute');
|
||||
expect(stdout.getOutput()).not.toContain('▶ npx - Node Package Execute');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../../types/Settings';
|
||||
import {
|
||||
buildPowerlineSetupMenuItems,
|
||||
getCapDisplay,
|
||||
getSeparatorDisplay,
|
||||
getThemeDisplay
|
||||
} from '../PowerlineSetup';
|
||||
|
||||
describe('PowerlineSetup helpers', () => {
|
||||
it('formats separator, cap, and theme display values', () => {
|
||||
const config = {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B4'],
|
||||
startCaps: ['\uE0B2'],
|
||||
endCaps: ['\uE0B0'],
|
||||
theme: 'gruvbox'
|
||||
};
|
||||
|
||||
expect(getSeparatorDisplay(config)).toBe('\uE0B4 - Round Right');
|
||||
expect(getCapDisplay(config, 'start')).toBe('\uE0B2 - Triangle');
|
||||
expect(getCapDisplay(config, 'end')).toBe('\uE0B0 - Triangle');
|
||||
expect(getThemeDisplay(config)).toBe('Gruvbox');
|
||||
});
|
||||
|
||||
it('builds powerline setup items with disabled states and sublabels', () => {
|
||||
const disabledItems = buildPowerlineSetupMenuItems({
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: false
|
||||
});
|
||||
|
||||
expect(disabledItems.every(item => item.disabled)).toBe(true);
|
||||
|
||||
const enabledItems = buildPowerlineSetupMenuItems({
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B0', '\uE0B4'],
|
||||
startCaps: [],
|
||||
endCaps: ['\uE0BC'],
|
||||
theme: undefined
|
||||
});
|
||||
|
||||
expect(enabledItems[0]).toMatchObject({
|
||||
label: 'Separator ',
|
||||
sublabel: '(multiple)',
|
||||
disabled: false
|
||||
});
|
||||
expect(enabledItems[1]).toMatchObject({
|
||||
label: 'Start Cap ',
|
||||
sublabel: '(none)'
|
||||
});
|
||||
expect(enabledItems[2]).toMatchObject({
|
||||
label: 'End Cap ',
|
||||
sublabel: '(\uE0BC - Diagonal)'
|
||||
});
|
||||
expect(enabledItems[3]).toMatchObject({
|
||||
label: 'Themes ',
|
||||
sublabel: '(Custom)'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../../types/Settings';
|
||||
import { getPowerlineThemes } from '../../../utils/colors';
|
||||
import {
|
||||
PowerlineThemeSelector,
|
||||
applyCustomPowerlineTheme,
|
||||
buildPowerlineThemeItems,
|
||||
type PowerlineThemeSelectorProps
|
||||
} from '../PowerlineThemeSelector';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): NodeJS.WriteStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.WriteStream;
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('PowerlineThemeSelector helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds powerline theme list items with original theme sublabels', () => {
|
||||
const items = buildPowerlineThemeItems(['gruvbox', 'onedark'], 'onedark');
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({
|
||||
label: 'Gruvbox',
|
||||
value: 'gruvbox'
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
label: 'One Dark',
|
||||
sublabel: '(original)',
|
||||
value: 'onedark'
|
||||
});
|
||||
});
|
||||
|
||||
it('copies a built-in theme into widget colors and switches to custom mode', () => {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
colorLevel: 2 as const,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
theme: 'gruvbox'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedSettings = applyCustomPowerlineTheme(settings, 'gruvbox');
|
||||
|
||||
expect(updatedSettings).not.toBeNull();
|
||||
expect(updatedSettings?.powerline.theme).toBe('custom');
|
||||
expect(updatedSettings?.lines[0]?.[0]).toMatchObject({
|
||||
color: 'ansi256:16',
|
||||
backgroundColor: 'ansi256:167'
|
||||
});
|
||||
expect(updatedSettings?.lines[0]?.[1]).toEqual(settings.lines[0]?.[1]);
|
||||
expect(updatedSettings?.lines[0]?.[2]).toMatchObject({
|
||||
color: 'ansi256:235',
|
||||
backgroundColor: 'ansi256:214'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the requested theme cannot be customized', () => {
|
||||
expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'custom')).toBeNull();
|
||||
expect(applyCustomPowerlineTheme(DEFAULT_SETTINGS, 'missing-theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('previews the highlighted theme once without triggering update-depth warnings', async () => {
|
||||
const themes = getPowerlineThemes();
|
||||
|
||||
expect(themes.length).toBeGreaterThan(1);
|
||||
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn<PowerlineThemeSelectorProps['onUpdate']>();
|
||||
const onBack = vi.fn();
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const instance = render(
|
||||
React.createElement(PowerlineThemeSelector, {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
theme: themes[0]
|
||||
}
|
||||
},
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
|
||||
stdin.write('\u001B[B');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(onUpdate.mock.calls[0]?.[0]?.powerline.theme).toBe(themes[1]);
|
||||
|
||||
const maximumUpdateDepthWarnings = consoleErrorSpy.mock.calls.filter((call) => {
|
||||
return call.some(arg => typeof arg === 'string' && arg.includes('Maximum update depth exceeded'));
|
||||
});
|
||||
|
||||
expect(maximumUpdateDepthWarnings).toHaveLength(0);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
buildTerminalOptionsItems,
|
||||
getNextColorLevel,
|
||||
shouldWarnOnColorLevelChange
|
||||
} from '../TerminalOptionsMenu';
|
||||
|
||||
describe('TerminalOptionsMenu helpers', () => {
|
||||
it('cycles color levels in order', () => {
|
||||
expect(getNextColorLevel(0)).toBe(1);
|
||||
expect(getNextColorLevel(1)).toBe(2);
|
||||
expect(getNextColorLevel(2)).toBe(3);
|
||||
expect(getNextColorLevel(3)).toBe(0);
|
||||
});
|
||||
|
||||
it('warns only when custom colors would be lost', () => {
|
||||
expect(shouldWarnOnColorLevelChange(2, 3, true)).toBe(true);
|
||||
expect(shouldWarnOnColorLevelChange(3, 0, true)).toBe(true);
|
||||
expect(shouldWarnOnColorLevelChange(2, 2, true)).toBe(false);
|
||||
expect(shouldWarnOnColorLevelChange(1, 2, true)).toBe(false);
|
||||
expect(shouldWarnOnColorLevelChange(3, 0, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds terminal options list items with the current color level label', () => {
|
||||
const items = buildTerminalOptionsItems(2);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({
|
||||
label: '◱ Terminal Width',
|
||||
value: 'width'
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
label: '▓ Color Level',
|
||||
sublabel: '(256 Color (default))',
|
||||
value: 'colorLevel'
|
||||
});
|
||||
expect(items[1]?.description).toContain('Truecolor');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../../types/Settings';
|
||||
import {
|
||||
TerminalWidthMenu,
|
||||
buildTerminalWidthItems,
|
||||
getTerminalWidthSelectionIndex,
|
||||
validateCompactThresholdInput
|
||||
} from '../TerminalWidthMenu';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedWriteStream extends NodeJS.WriteStream {
|
||||
clearOutput: () => void;
|
||||
getOutput: () => string;
|
||||
}
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): CapturedWriteStream {
|
||||
const stream = new MockTtyStream();
|
||||
const chunks: string[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
return Object.assign(stream as unknown as NodeJS.WriteStream, {
|
||||
clearOutput() {
|
||||
chunks.length = 0;
|
||||
},
|
||||
getOutput() {
|
||||
return chunks.join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('TerminalWidthMenu helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('validates compact threshold input', () => {
|
||||
expect(validateCompactThresholdInput('')).toBe('Please enter a valid number');
|
||||
expect(validateCompactThresholdInput('0')).toBe('Value must be between 1 and 99 (you entered 0)');
|
||||
expect(validateCompactThresholdInput('100')).toBe('Value must be between 1 and 99 (you entered 100)');
|
||||
expect(validateCompactThresholdInput('42')).toBeNull();
|
||||
});
|
||||
|
||||
it('builds terminal width menu items with active and threshold sublabels', () => {
|
||||
const items = buildTerminalWidthItems('full-until-compact', 60);
|
||||
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]).toMatchObject({
|
||||
label: 'Full width always',
|
||||
value: 'full'
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
label: 'Full width minus 40',
|
||||
sublabel: '(default)',
|
||||
value: 'full-minus-40'
|
||||
});
|
||||
expect(items[2]).toMatchObject({
|
||||
label: 'Full width until compact',
|
||||
sublabel: '(threshold 60%, active)',
|
||||
value: 'full-until-compact'
|
||||
});
|
||||
expect(items[2]?.description).toContain('60%');
|
||||
});
|
||||
|
||||
it('returns the current option index for list selection', () => {
|
||||
expect(getTerminalWidthSelectionIndex('full')).toBe(0);
|
||||
expect(getTerminalWidthSelectionIndex('full-minus-40')).toBe(1);
|
||||
expect(getTerminalWidthSelectionIndex('full-until-compact')).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps full-until-compact selected after confirming the threshold prompt', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(TerminalWidthMenu, {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
flexMode: 'full',
|
||||
compactThreshold: 60
|
||||
},
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('\u001B[B');
|
||||
await flushInk();
|
||||
stdin.write('\u001B[B');
|
||||
await flushInk();
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('Enter compact threshold (1-99):');
|
||||
|
||||
stdout.clearOutput();
|
||||
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
flexMode: 'full-until-compact',
|
||||
compactThreshold: 60
|
||||
}));
|
||||
|
||||
const output = stdout.getOutput();
|
||||
|
||||
expect(output).toContain('▶ Full width until compact');
|
||||
expect(output).not.toContain('▶ Full width always');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../../../types/Widget';
|
||||
import {
|
||||
clearAllWidgetStyling,
|
||||
cycleWidgetColor,
|
||||
resetWidgetStyling,
|
||||
toggleWidgetBold,
|
||||
updateWidgetById
|
||||
} from '../mutations';
|
||||
|
||||
describe('color-menu mutations', () => {
|
||||
it('updateWidgetById only updates the matching widget', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input', color: 'blue' },
|
||||
{ id: '2', type: 'tokens-output', color: 'white' }
|
||||
];
|
||||
|
||||
const updated = updateWidgetById(widgets, '1', widget => ({
|
||||
...widget,
|
||||
color: 'red'
|
||||
}));
|
||||
|
||||
expect(updated[0]?.color).toBe('red');
|
||||
expect(updated[1]?.color).toBe('white');
|
||||
});
|
||||
|
||||
it('toggleWidgetBold flips bold state for the selected widget only', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input', bold: true },
|
||||
{ id: '2', type: 'tokens-output', bold: false }
|
||||
];
|
||||
|
||||
const updated = toggleWidgetBold(widgets, '1');
|
||||
|
||||
expect(updated[0]?.bold).toBe(false);
|
||||
expect(updated[1]?.bold).toBe(false);
|
||||
});
|
||||
|
||||
it('resetWidgetStyling removes color, backgroundColor, and bold from one widget', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tokens-input',
|
||||
color: 'red',
|
||||
backgroundColor: 'blue',
|
||||
bold: true
|
||||
},
|
||||
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
|
||||
];
|
||||
|
||||
const updated = resetWidgetStyling(widgets, '1');
|
||||
|
||||
expect(updated[0]).toEqual({ id: '1', type: 'tokens-input' });
|
||||
expect(updated[1]).toEqual({ id: '2', type: 'tokens-output', color: 'white', bold: true });
|
||||
});
|
||||
|
||||
it('clearAllWidgetStyling strips styling fields from every widget', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'tokens-input',
|
||||
color: 'red',
|
||||
backgroundColor: 'blue',
|
||||
bold: true
|
||||
},
|
||||
{ id: '2', type: 'tokens-output', color: 'white', bold: true }
|
||||
];
|
||||
|
||||
const updated = clearAllWidgetStyling(widgets);
|
||||
|
||||
expect(updated).toEqual([
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('cycles background colors and maps empty background to undefined', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input', backgroundColor: 'bg:red' }
|
||||
];
|
||||
|
||||
const right = cycleWidgetColor({
|
||||
widgets,
|
||||
widgetId: '1',
|
||||
direction: 'right',
|
||||
editingBackground: true,
|
||||
colors: ['blue', 'red'],
|
||||
backgroundColors: ['bg:red', '']
|
||||
});
|
||||
const left = cycleWidgetColor({
|
||||
widgets: right,
|
||||
widgetId: '1',
|
||||
direction: 'left',
|
||||
editingBackground: true,
|
||||
colors: ['blue', 'red'],
|
||||
backgroundColors: ['bg:red', '']
|
||||
});
|
||||
|
||||
expect(right[0]?.backgroundColor).toBeUndefined();
|
||||
expect(left[0]?.backgroundColor).toBe('bg:red');
|
||||
});
|
||||
|
||||
it('cycles foreground colors from widget default and treats dim as default', () => {
|
||||
const fromDefault: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
];
|
||||
const fromDim: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input', color: 'dim' }
|
||||
];
|
||||
|
||||
const defaultCycle = cycleWidgetColor({
|
||||
widgets: fromDefault,
|
||||
widgetId: '1',
|
||||
direction: 'right',
|
||||
editingBackground: false,
|
||||
colors: ['blue', 'red'],
|
||||
backgroundColors: ['bg:red', '']
|
||||
});
|
||||
const dimCycle = cycleWidgetColor({
|
||||
widgets: fromDim,
|
||||
widgetId: '1',
|
||||
direction: 'right',
|
||||
editingBackground: false,
|
||||
colors: ['blue', 'red'],
|
||||
backgroundColors: ['bg:red', '']
|
||||
});
|
||||
|
||||
expect(defaultCycle[0]?.color).toBe('red');
|
||||
expect(dimCycle[0]?.color).toBe('red');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { WidgetItem } from '../../../types/Widget';
|
||||
import { getWidget } from '../../../utils/widgets';
|
||||
|
||||
export function updateWidgetById(
|
||||
widgets: WidgetItem[],
|
||||
widgetId: string,
|
||||
updater: (widget: WidgetItem) => WidgetItem
|
||||
): WidgetItem[] {
|
||||
return widgets.map(widget => widget.id === widgetId ? updater(widget) : widget);
|
||||
}
|
||||
|
||||
export function setWidgetColor(
|
||||
widgets: WidgetItem[],
|
||||
widgetId: string,
|
||||
color: string,
|
||||
editingBackground: boolean
|
||||
): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, (widget) => {
|
||||
if (editingBackground) {
|
||||
return {
|
||||
...widget,
|
||||
backgroundColor: color
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...widget,
|
||||
color
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, widget => ({
|
||||
...widget,
|
||||
bold: !widget.bold
|
||||
}));
|
||||
}
|
||||
|
||||
export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, (widget) => {
|
||||
const {
|
||||
color,
|
||||
backgroundColor,
|
||||
bold,
|
||||
...restWidget
|
||||
} = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
return restWidget;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] {
|
||||
return widgets.map((widget) => {
|
||||
const {
|
||||
color,
|
||||
backgroundColor,
|
||||
bold,
|
||||
...restWidget
|
||||
} = widget;
|
||||
void color; // Intentionally unused
|
||||
void backgroundColor; // Intentionally unused
|
||||
void bold; // Intentionally unused
|
||||
return restWidget;
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultForegroundColor(widget: WidgetItem): string {
|
||||
if (widget.type === 'separator' || widget.type === 'flex-separator') {
|
||||
return 'white';
|
||||
}
|
||||
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
return widgetImpl ? widgetImpl.getDefaultColor() : 'white';
|
||||
}
|
||||
|
||||
function getNextIndex(currentIndex: number, length: number, direction: 'left' | 'right'): number {
|
||||
if (direction === 'right') {
|
||||
return (currentIndex + 1) % length;
|
||||
}
|
||||
|
||||
return currentIndex === 0 ? length - 1 : currentIndex - 1;
|
||||
}
|
||||
|
||||
export interface CycleWidgetColorOptions {
|
||||
widgets: WidgetItem[];
|
||||
widgetId: string;
|
||||
direction: 'left' | 'right';
|
||||
editingBackground: boolean;
|
||||
colors: string[];
|
||||
backgroundColors: string[];
|
||||
}
|
||||
|
||||
export function cycleWidgetColor({
|
||||
widgets,
|
||||
widgetId,
|
||||
direction,
|
||||
editingBackground,
|
||||
colors,
|
||||
backgroundColors
|
||||
}: CycleWidgetColorOptions): WidgetItem[] {
|
||||
return updateWidgetById(widgets, widgetId, (widget) => {
|
||||
if (editingBackground) {
|
||||
if (backgroundColors.length === 0) {
|
||||
return widget;
|
||||
}
|
||||
|
||||
const currentBgColor = widget.backgroundColor ?? '';
|
||||
let currentBgColorIndex = backgroundColors.indexOf(currentBgColor);
|
||||
if (currentBgColorIndex === -1) {
|
||||
currentBgColorIndex = 0;
|
||||
}
|
||||
|
||||
const nextBgColorIndex = getNextIndex(currentBgColorIndex, backgroundColors.length, direction);
|
||||
const nextBgColor = backgroundColors[nextBgColorIndex];
|
||||
|
||||
return {
|
||||
...widget,
|
||||
backgroundColor: nextBgColor === '' ? undefined : nextBgColor
|
||||
};
|
||||
}
|
||||
|
||||
if (colors.length === 0) {
|
||||
return widget;
|
||||
}
|
||||
|
||||
const defaultColor = getDefaultForegroundColor(widget);
|
||||
let currentColor = widget.color ?? defaultColor;
|
||||
if (currentColor === 'dim') {
|
||||
currentColor = defaultColor;
|
||||
}
|
||||
|
||||
let currentColorIndex = colors.indexOf(currentColor);
|
||||
if (currentColorIndex === -1) {
|
||||
currentColorIndex = 0;
|
||||
}
|
||||
|
||||
const nextColorIndex = getNextIndex(currentColorIndex, colors.length, direction);
|
||||
const nextColor = colors[nextColorIndex];
|
||||
|
||||
return {
|
||||
...widget,
|
||||
color: nextColor
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../../../types/Widget';
|
||||
import type { WidgetCatalogEntry } from '../../../../utils/widgets';
|
||||
import {
|
||||
handleMoveInputMode,
|
||||
handleNormalInputMode,
|
||||
handlePickerInputMode,
|
||||
normalizePickerState,
|
||||
type WidgetPickerState
|
||||
} from '../input-handlers';
|
||||
|
||||
function createStateSetter<T>(initial: T) {
|
||||
let state = initial;
|
||||
|
||||
return {
|
||||
get: () => state,
|
||||
set: (value: T | ((prev: T) => T)) => {
|
||||
state = typeof value === 'function'
|
||||
? (value as (prev: T) => T)(state)
|
||||
: value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function requireState<T>(value: T | null): T {
|
||||
if (!value) {
|
||||
throw new Error('Expected state value');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function createCatalog(entries: (Partial<WidgetCatalogEntry> & Pick<WidgetCatalogEntry, 'type'>)[]): WidgetCatalogEntry[] {
|
||||
return entries.map(entry => ({
|
||||
type: entry.type,
|
||||
displayName: entry.displayName ?? entry.type,
|
||||
description: entry.description ?? entry.type,
|
||||
category: entry.category ?? 'Other',
|
||||
searchText: `${entry.displayName ?? entry.type} ${entry.description ?? entry.type} ${entry.type}`.toLowerCase()
|
||||
}));
|
||||
}
|
||||
|
||||
describe('items-editor input handlers', () => {
|
||||
it('normalizes picker state with valid fallback category and selected type', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const state: WidgetPickerState = {
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'Missing',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: null
|
||||
};
|
||||
|
||||
const normalized = normalizePickerState(state, widgetCatalog, widgetCategories);
|
||||
|
||||
expect(normalized.selectedCategory).toBe('All');
|
||||
expect(normalized.selectedType).toBe('git-branch');
|
||||
});
|
||||
|
||||
it('applies top-level category search selection on Enter', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: 'git',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
const applySelection = vi.fn();
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { return: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: applySelection
|
||||
});
|
||||
|
||||
expect(applySelection).toHaveBeenCalledWith('git-branch');
|
||||
});
|
||||
|
||||
it('returns to category level from widget picker on escape when widget query is empty', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'widget',
|
||||
selectedCategory: 'Git',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { escape: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.level).toBe('category');
|
||||
});
|
||||
|
||||
it('moves selected widget up in move mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
const setMoveMode = vi.fn();
|
||||
|
||||
handleMoveInputMode({
|
||||
key: { upArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 1,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
]);
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(0);
|
||||
expect(setMoveMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles raw value in normal mode for supported widgets', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'r',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('cycles separator character in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'separator', character: '|' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: ' ',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.character).toBe('-');
|
||||
});
|
||||
|
||||
it('applies custom widget keybind actions in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'session-usage' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'p',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.metadata?.display).toBe('progress');
|
||||
});
|
||||
|
||||
it('uses v to cycle skills widget mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'skills' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'v',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.metadata?.mode).toBe('count');
|
||||
});
|
||||
|
||||
it('opens custom editor for skills list limit action', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'skills', metadata: { mode: 'list' } }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setCustomEditorWidget = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'l',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [],
|
||||
setCustomEditorWidget
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
|
||||
| { action?: string; widget?: WidgetItem }
|
||||
| undefined;
|
||||
expect(customEditorState?.action).toBe('edit-list-limit');
|
||||
expect(customEditorState?.widget?.type).toBe('skills');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type {
|
||||
CustomKeybind,
|
||||
WidgetItem
|
||||
} from '../../../../types/Widget';
|
||||
import { shouldShowCustomKeybind } from '../keybind-visibility';
|
||||
|
||||
const TOGGLE_COMPACT_KEYBIND: CustomKeybind = { key: 's', label: '(s)hort time', action: 'toggle-compact' };
|
||||
const TOGGLE_INVERT_KEYBIND: CustomKeybind = { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' };
|
||||
const EDIT_LIST_LIMIT_KEYBIND: CustomKeybind = { key: 'l', label: '(l)imit list', action: 'edit-list-limit' };
|
||||
|
||||
function createWidget(type: string, metadata?: Record<string, string>): WidgetItem {
|
||||
return {
|
||||
id: 'widget',
|
||||
type,
|
||||
metadata
|
||||
};
|
||||
}
|
||||
|
||||
describe('shouldShowCustomKeybind', () => {
|
||||
it('shows invert only in progress modes', () => {
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer'), TOGGLE_INVERT_KEYBIND)).toBe(false);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress' }), TOGGLE_INVERT_KEYBIND)).toBe(true);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress-short' }), TOGGLE_INVERT_KEYBIND)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides short time in progress modes', () => {
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer'), TOGGLE_COMPACT_KEYBIND)).toBe(true);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'time' }), TOGGLE_COMPACT_KEYBIND)).toBe(true);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress' }), TOGGLE_COMPACT_KEYBIND)).toBe(false);
|
||||
expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress-short' }), TOGGLE_COMPACT_KEYBIND)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows list limit only for skills list mode', () => {
|
||||
expect(shouldShowCustomKeybind(createWidget('skills'), EDIT_LIST_LIMIT_KEYBIND)).toBe(false);
|
||||
expect(shouldShowCustomKeybind(createWidget('skills', { mode: 'count' }), EDIT_LIST_LIMIT_KEYBIND)).toBe(false);
|
||||
expect(shouldShowCustomKeybind(createWidget('skills', { mode: 'list' }), EDIT_LIST_LIMIT_KEYBIND)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetItem,
|
||||
WidgetItemType
|
||||
} from '../../../types/Widget';
|
||||
import {
|
||||
filterWidgetCatalog,
|
||||
getWidget,
|
||||
type WidgetCatalogEntry
|
||||
} from '../../../utils/widgets';
|
||||
|
||||
export type WidgetPickerAction = 'change' | 'add' | 'insert';
|
||||
export type WidgetPickerLevel = 'category' | 'widget';
|
||||
|
||||
export interface WidgetPickerState {
|
||||
action: WidgetPickerAction;
|
||||
level: WidgetPickerLevel;
|
||||
selectedCategory: string | null;
|
||||
categoryQuery: string;
|
||||
widgetQuery: string;
|
||||
selectedType: WidgetItemType | null;
|
||||
}
|
||||
|
||||
export interface CustomEditorWidgetState {
|
||||
widget: WidgetItem;
|
||||
impl: Widget;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface InputKey {
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
tab?: boolean;
|
||||
shift?: boolean;
|
||||
upArrow?: boolean;
|
||||
downArrow?: boolean;
|
||||
leftArrow?: boolean;
|
||||
rightArrow?: boolean;
|
||||
return?: boolean;
|
||||
escape?: boolean;
|
||||
backspace?: boolean;
|
||||
delete?: boolean;
|
||||
}
|
||||
|
||||
type Setter<T> = (value: T | ((prev: T) => T)) => void;
|
||||
|
||||
function setPickerState(
|
||||
setWidgetPicker: Setter<WidgetPickerState | null>,
|
||||
normalizeState: (state: WidgetPickerState) => WidgetPickerState,
|
||||
updater: (prev: WidgetPickerState) => WidgetPickerState
|
||||
): void {
|
||||
setWidgetPicker((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return normalizeState(updater(prev));
|
||||
});
|
||||
}
|
||||
|
||||
function getPickerCategories(widgetCategories: string[]): string[] {
|
||||
return [...widgetCategories];
|
||||
}
|
||||
|
||||
export function normalizePickerState(
|
||||
state: WidgetPickerState,
|
||||
widgetCatalog: WidgetCatalogEntry[],
|
||||
widgetCategories: string[]
|
||||
): WidgetPickerState {
|
||||
const filteredCategories = getPickerCategories(widgetCategories);
|
||||
const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory)
|
||||
? state.selectedCategory
|
||||
: (filteredCategories[0] ?? null);
|
||||
|
||||
const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0;
|
||||
const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All');
|
||||
const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery;
|
||||
const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery);
|
||||
const hasSelectedType = state.selectedType
|
||||
? filteredWidgets.some(entry => entry.type === state.selectedType)
|
||||
: false;
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedCategory,
|
||||
selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null)
|
||||
};
|
||||
}
|
||||
|
||||
interface PickerViewState {
|
||||
filteredCategories: string[];
|
||||
selectedCategory: string | null;
|
||||
hasTopLevelSearch: boolean;
|
||||
topLevelSearchEntries: WidgetCatalogEntry[];
|
||||
topLevelSelectedEntry: WidgetCatalogEntry | undefined;
|
||||
filteredWidgets: WidgetCatalogEntry[];
|
||||
selectedEntry: WidgetCatalogEntry | undefined;
|
||||
}
|
||||
|
||||
function getPickerViewState(
|
||||
widgetPicker: WidgetPickerState,
|
||||
widgetCatalog: WidgetCatalogEntry[],
|
||||
widgetCategories: string[]
|
||||
): PickerViewState {
|
||||
const filteredCategories = getPickerCategories(widgetCategories);
|
||||
const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory)
|
||||
? widgetPicker.selectedCategory
|
||||
: (filteredCategories[0] ?? null);
|
||||
const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0;
|
||||
const topLevelSearchEntries = hasTopLevelSearch
|
||||
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
|
||||
: [];
|
||||
const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0];
|
||||
const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery);
|
||||
const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0];
|
||||
|
||||
return {
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
hasTopLevelSearch,
|
||||
topLevelSearchEntries,
|
||||
topLevelSelectedEntry,
|
||||
filteredWidgets,
|
||||
selectedEntry
|
||||
};
|
||||
}
|
||||
|
||||
export interface HandlePickerInputModeArgs {
|
||||
input: string;
|
||||
key: InputKey;
|
||||
widgetPicker: WidgetPickerState;
|
||||
widgetCatalog: WidgetCatalogEntry[];
|
||||
widgetCategories: string[];
|
||||
setWidgetPicker: Setter<WidgetPickerState | null>;
|
||||
applyWidgetPickerSelection: (selectedType: WidgetItemType) => void;
|
||||
}
|
||||
|
||||
export function handlePickerInputMode({
|
||||
input,
|
||||
key,
|
||||
widgetPicker,
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker,
|
||||
applyWidgetPickerSelection
|
||||
}: HandlePickerInputModeArgs): void {
|
||||
const normalizeState = (state: WidgetPickerState) => normalizePickerState(state, widgetCatalog, widgetCategories);
|
||||
const {
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
hasTopLevelSearch,
|
||||
topLevelSearchEntries,
|
||||
topLevelSelectedEntry,
|
||||
filteredWidgets,
|
||||
selectedEntry
|
||||
} = getPickerViewState(widgetPicker, widgetCatalog, widgetCategories);
|
||||
|
||||
if (widgetPicker.level === 'category') {
|
||||
if (key.escape) {
|
||||
if (widgetPicker.categoryQuery.length > 0) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
categoryQuery: ''
|
||||
}));
|
||||
} else {
|
||||
setWidgetPicker(null);
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (hasTopLevelSearch) {
|
||||
if (topLevelSelectedEntry) {
|
||||
applyWidgetPickerSelection(topLevelSelectedEntry.type);
|
||||
}
|
||||
} else if (selectedCategory) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
level: 'widget',
|
||||
selectedCategory
|
||||
}));
|
||||
}
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
if (hasTopLevelSearch) {
|
||||
if (topLevelSearchEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(topLevelSearchEntries.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
const nextType = topLevelSearchEntries[nextIndex]?.type ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
selectedType: nextType
|
||||
}));
|
||||
} else {
|
||||
if (filteredCategories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = filteredCategories.findIndex(category => category === selectedCategory);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(filteredCategories.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
const nextCategory = filteredCategories[nextIndex] ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
selectedCategory: nextCategory
|
||||
}));
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
categoryQuery: prev.categoryQuery.slice(0, -1)
|
||||
}));
|
||||
} else if (
|
||||
input
|
||||
&& !key.ctrl
|
||||
&& !key.meta
|
||||
&& !key.tab
|
||||
) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
categoryQuery: prev.categoryQuery + input
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if (key.escape) {
|
||||
if (widgetPicker.widgetQuery.length > 0) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
widgetQuery: ''
|
||||
}));
|
||||
} else {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
level: 'category'
|
||||
}));
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (selectedEntry) {
|
||||
applyWidgetPickerSelection(selectedEntry.type);
|
||||
}
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
if (filteredWidgets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? Math.min(filteredWidgets.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1);
|
||||
const nextType = filteredWidgets[nextIndex]?.type ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
selectedType: nextType
|
||||
}));
|
||||
} else if (key.backspace || key.delete) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
widgetQuery: prev.widgetQuery.slice(0, -1)
|
||||
}));
|
||||
} else if (
|
||||
input
|
||||
&& !key.ctrl
|
||||
&& !key.meta
|
||||
&& !key.tab
|
||||
) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
widgetQuery: prev.widgetQuery + input
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface HandleMoveInputModeArgs {
|
||||
key: InputKey;
|
||||
widgets: WidgetItem[];
|
||||
selectedIndex: number;
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
setMoveMode: (moveMode: boolean) => void;
|
||||
}
|
||||
|
||||
export function handleMoveInputMode({
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode
|
||||
}: HandleMoveInputModeArgs): void {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
const newWidgets = [...widgets];
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const prev = newWidgets[selectedIndex - 1];
|
||||
if (temp && prev) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex - 1]] = [prev, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < widgets.length - 1) {
|
||||
const newWidgets = [...widgets];
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const next = newWidgets[selectedIndex + 1];
|
||||
if (temp && next) {
|
||||
[newWidgets[selectedIndex], newWidgets[selectedIndex + 1]] = [next, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(selectedIndex + 1);
|
||||
} else if (key.escape || key.return) {
|
||||
setMoveMode(false);
|
||||
}
|
||||
}
|
||||
|
||||
export interface HandleNormalInputModeArgs {
|
||||
input: string;
|
||||
key: InputKey;
|
||||
widgets: WidgetItem[];
|
||||
selectedIndex: number;
|
||||
separatorChars: string[];
|
||||
onBack: () => void;
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
setMoveMode: (moveMode: boolean) => void;
|
||||
setShowClearConfirm: (show: boolean) => void;
|
||||
openWidgetPicker: (action: WidgetPickerAction) => void;
|
||||
getVisibleCustomKeybinds: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
|
||||
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
|
||||
}
|
||||
|
||||
export function handleNormalInputMode({
|
||||
input,
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
separatorChars,
|
||||
onBack,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode,
|
||||
setShowClearConfirm,
|
||||
openWidgetPicker,
|
||||
getVisibleCustomKeybinds,
|
||||
setCustomEditorWidget
|
||||
}: HandleNormalInputModeArgs): void {
|
||||
if (key.upArrow && widgets.length > 0) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow && widgets.length > 0) {
|
||||
setSelectedIndex(Math.min(widgets.length - 1, selectedIndex + 1));
|
||||
} else if (key.leftArrow && widgets.length > 0) {
|
||||
openWidgetPicker('change');
|
||||
} else if (key.rightArrow && widgets.length > 0) {
|
||||
openWidgetPicker('change');
|
||||
} else if (key.return && widgets.length > 0) {
|
||||
setMoveMode(true);
|
||||
} else if (input === 'a') {
|
||||
openWidgetPicker('add');
|
||||
} else if (input === 'i') {
|
||||
openWidgetPicker('insert');
|
||||
} else if (input === 'd' && widgets.length > 0) {
|
||||
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
|
||||
onUpdate(newWidgets);
|
||||
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
}
|
||||
} else if (input === 'c') {
|
||||
if (widgets.length > 0) {
|
||||
setShowClearConfirm(true);
|
||||
}
|
||||
} else if (input === ' ' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget?.type === 'separator') {
|
||||
const currentChar = currentWidget.character ?? '|';
|
||||
const currentCharIndex = separatorChars.indexOf(currentChar);
|
||||
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'r' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (!widgetImpl?.supportsRawValue()) {
|
||||
return;
|
||||
}
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'm' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && selectedIndex < widgets.length - 1
|
||||
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const newWidgets = [...widgets];
|
||||
let nextMergeState: boolean | 'no-padding' | undefined;
|
||||
|
||||
if (currentWidget.merge === undefined) {
|
||||
nextMergeState = true;
|
||||
} else if (currentWidget.merge === true) {
|
||||
nextMergeState = 'no-padding';
|
||||
} else {
|
||||
nextMergeState = undefined;
|
||||
}
|
||||
|
||||
if (nextMergeState === undefined) {
|
||||
const { merge, ...rest } = currentWidget;
|
||||
void merge; // Intentionally unused
|
||||
newWidgets[selectedIndex] = rest;
|
||||
} else {
|
||||
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onBack();
|
||||
} else if (widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (!widgetImpl?.getCustomKeybinds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
|
||||
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
|
||||
|
||||
if (matchedKeybind && !key.ctrl) {
|
||||
if (widgetImpl.handleEditorAction) {
|
||||
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
|
||||
if (updatedWidget) {
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = updatedWidget;
|
||||
onUpdate(newWidgets);
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
CustomKeybind,
|
||||
WidgetItem
|
||||
} from '../../../types/Widget';
|
||||
|
||||
function isProgressMode(widget: WidgetItem): boolean {
|
||||
const mode = widget.metadata?.display;
|
||||
return mode === 'progress' || mode === 'progress-short';
|
||||
}
|
||||
|
||||
export function shouldShowCustomKeybind(widget: WidgetItem, keybind: CustomKeybind): boolean {
|
||||
if (keybind.action === 'edit-list-limit') {
|
||||
return widget.type === 'skills' && widget.metadata?.mode === 'list';
|
||||
}
|
||||
|
||||
if (keybind.action === 'toggle-invert') {
|
||||
return isProgressMode(widget);
|
||||
}
|
||||
|
||||
if (keybind.action === 'toggle-compact') {
|
||||
return !isProgressMode(widget);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -17,13 +17,13 @@ export type ColorLevelString = 'ansi16' | 'ansi256' | 'truecolor';
|
||||
// Helper to get color level as string for chalk
|
||||
export function getColorLevelString(level: ColorLevel | undefined): ColorLevelString {
|
||||
switch (level) {
|
||||
case 0:
|
||||
case 1:
|
||||
return 'ansi16';
|
||||
case 3:
|
||||
return 'truecolor';
|
||||
case 2:
|
||||
default:
|
||||
return 'ansi256';
|
||||
case 0:
|
||||
case 1:
|
||||
return 'ansi16';
|
||||
case 3:
|
||||
return 'truecolor';
|
||||
case 2:
|
||||
default:
|
||||
return 'ansi256';
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,33 @@
|
||||
import type { BlockMetrics } from '../types';
|
||||
import type {
|
||||
BlockMetrics,
|
||||
SkillsMetrics
|
||||
} from '../types';
|
||||
|
||||
import type { SpeedMetrics } from './SpeedMetrics';
|
||||
import type { StatusJSON } from './StatusJSON';
|
||||
import type { TokenMetrics } from './TokenMetrics';
|
||||
|
||||
export interface RenderUsageData {
|
||||
sessionUsage?: number;
|
||||
sessionResetAt?: string;
|
||||
weeklyUsage?: number;
|
||||
weeklyResetAt?: string;
|
||||
extraUsageEnabled?: boolean;
|
||||
extraUsageLimit?: number;
|
||||
extraUsageUsed?: number;
|
||||
extraUsageUtilization?: number;
|
||||
error?: 'no-credentials' | 'timeout' | 'rate-limited' | 'api-error' | 'parse-error';
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
data?: StatusJSON;
|
||||
tokenMetrics?: TokenMetrics | null;
|
||||
speedMetrics?: SpeedMetrics | null;
|
||||
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
|
||||
usageData?: RenderUsageData | null;
|
||||
sessionDuration?: string | null;
|
||||
blockMetrics?: BlockMetrics | null;
|
||||
skillsMetrics?: SkillsMetrics | null;
|
||||
terminalWidth?: number | null;
|
||||
isPreview?: boolean;
|
||||
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface SkillInvocation {
|
||||
timestamp: string;
|
||||
session_id: string;
|
||||
skill: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface SkillsMetrics {
|
||||
totalInvocations: number;
|
||||
uniqueSkills: string[];
|
||||
lastSkill: string | null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Speed metrics for calculating token processing rates.
|
||||
* Provides time-based data needed for speed calculations.
|
||||
*/
|
||||
export interface SpeedMetrics {
|
||||
/** Active processing duration in milliseconds (sum of user request → assistant response times) */
|
||||
totalDurationMs: number;
|
||||
|
||||
/** Total input tokens across all requests */
|
||||
inputTokens: number;
|
||||
|
||||
/** Total output tokens across all requests */
|
||||
outputTokens: number;
|
||||
|
||||
/** Total tokens (input + output) */
|
||||
totalTokens: number;
|
||||
|
||||
/** Number of assistant usage entries included in speed aggregation */
|
||||
requestCount: number;
|
||||
}
|
||||
+43
-10
@@ -1,14 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const CoercedNumberSchema = z.preprocess((value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}, z.number());
|
||||
|
||||
export const StatusJSONSchema = z.looseObject({
|
||||
hook_event_name: z.string().optional(),
|
||||
session_id: z.string().optional(),
|
||||
transcript_path: z.string().optional(),
|
||||
cwd: z.string().optional(),
|
||||
model: z.object({
|
||||
id: z.string().optional(),
|
||||
display_name: z.string().optional()
|
||||
}).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()
|
||||
@@ -16,12 +33,28 @@ export const StatusJSONSchema = z.looseObject({
|
||||
version: z.string().optional(),
|
||||
output_style: z.object({ name: z.string().optional() }).optional(),
|
||||
cost: z.object({
|
||||
total_cost_usd: z.number().optional(),
|
||||
total_duration_ms: z.number().optional(),
|
||||
total_api_duration_ms: z.number().optional(),
|
||||
total_lines_added: z.number().optional(),
|
||||
total_lines_removed: z.number().optional()
|
||||
}).optional()
|
||||
total_cost_usd: CoercedNumberSchema.optional(),
|
||||
total_duration_ms: CoercedNumberSchema.optional(),
|
||||
total_api_duration_ms: CoercedNumberSchema.optional(),
|
||||
total_lines_added: CoercedNumberSchema.optional(),
|
||||
total_lines_removed: CoercedNumberSchema.optional()
|
||||
}).optional(),
|
||||
context_window: z.object({
|
||||
context_window_size: 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()
|
||||
});
|
||||
|
||||
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
|
||||
@@ -10,6 +10,7 @@ export interface TranscriptLine {
|
||||
isSidechain?: boolean;
|
||||
timestamp?: string;
|
||||
isApiErrorMessage?: boolean;
|
||||
type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot';
|
||||
}
|
||||
|
||||
export interface TokenMetrics {
|
||||
|
||||
@@ -34,6 +34,7 @@ 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?(): CustomKeybind[];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { StatusJSONSchema } from '../StatusJSON';
|
||||
|
||||
describe('StatusJSONSchema numeric coercion', () => {
|
||||
it('coerces numeric strings to numbers', () => {
|
||||
const result = StatusJSONSchema.safeParse({
|
||||
cost: {
|
||||
total_cost_usd: '1.25',
|
||||
total_duration_ms: '12345',
|
||||
total_api_duration_ms: '2345',
|
||||
total_lines_added: '12',
|
||||
total_lines_removed: '3'
|
||||
},
|
||||
context_window: {
|
||||
context_window_size: '200000',
|
||||
total_input_tokens: '1200',
|
||||
total_output_tokens: '340',
|
||||
current_usage: {
|
||||
input_tokens: '100',
|
||||
output_tokens: '50',
|
||||
cache_creation_input_tokens: '20',
|
||||
cache_read_input_tokens: '10'
|
||||
},
|
||||
used_percentage: '9.3',
|
||||
remaining_percentage: '90.7'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.data.cost?.total_duration_ms).toBe(12345);
|
||||
expect(result.data.context_window?.context_window_size).toBe(200000);
|
||||
expect(result.data.context_window?.current_usage).toEqual({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 20,
|
||||
cache_read_input_tokens: 10
|
||||
});
|
||||
expect(result.data.context_window?.used_percentage).toBe(9.3);
|
||||
});
|
||||
|
||||
it('keeps invalid numeric strings rejected', () => {
|
||||
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: 'not-a-number' } });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps empty numeric strings rejected', () => {
|
||||
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: '' } });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -17,4 +17,6 @@ export type { RenderContext } from './RenderContext';
|
||||
export type { PowerlineFontStatus } from './PowerlineFontStatus';
|
||||
export type { ClaudeSettings } from './ClaudeSettings';
|
||||
export type { ColorEntry } from './ColorEntry';
|
||||
export type { BlockMetrics } from './BlockMetrics';
|
||||
export type { BlockMetrics } from './BlockMetrics';
|
||||
export type { SpeedMetrics } from './SpeedMetrics';
|
||||
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import {
|
||||
CCSTATUSLINE_COMMANDS,
|
||||
getClaudeSettingsPath,
|
||||
getExistingStatusLine,
|
||||
installStatusLine,
|
||||
isInstalled,
|
||||
isKnownCommand,
|
||||
loadClaudeSettings,
|
||||
saveClaudeSettings,
|
||||
uninstallStatusLine
|
||||
} from '../claude-settings';
|
||||
import { initConfigPath } from '../config';
|
||||
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
let testClaudeConfigDir = '';
|
||||
|
||||
function readInstalledCommand(): string {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const data = JSON.parse(content) as { statusLine?: { command?: string } };
|
||||
return data.statusLine?.command ?? '';
|
||||
}
|
||||
|
||||
function writeRawClaudeSettings(content: string): void {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-settings-'));
|
||||
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
|
||||
initConfigPath();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
initConfigPath();
|
||||
if (testClaudeConfigDir) {
|
||||
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
describe('isKnownCommand', () => {
|
||||
it('should match exact NPM command', () => {
|
||||
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.NPM)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match exact BUNX command', () => {
|
||||
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.BUNX)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match exact SELF_MANAGED command', () => {
|
||||
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.SELF_MANAGED)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match NPM command with --config and simple path', () => {
|
||||
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match BUNX command with --config and quoted path with spaces', () => {
|
||||
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match command with --config and quoted path with parens', () => {
|
||||
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match command with --config and double-quoted Windows path', () => {
|
||||
expect(isKnownCommand(`${CCSTATUSLINE_COMMANDS.NPM} --config "C:\\Users\\Alice\\My Settings\\settings.json"`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match unknown commands', () => {
|
||||
expect(isKnownCommand('some-other-command')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match empty string', () => {
|
||||
expect(isKnownCommand('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match partial prefix', () => {
|
||||
expect(isKnownCommand('npx -y ccstatusline')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match prefix that is a substring', () => {
|
||||
expect(isKnownCommand('npx -y ccstatusline@latestFOO')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommand via installStatusLine', () => {
|
||||
it('should use base command when no custom config path', async () => {
|
||||
initConfigPath();
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(CCSTATUSLINE_COMMANDS.NPM);
|
||||
});
|
||||
|
||||
it('should append --config with simple path (no quoting needed)', async () => {
|
||||
initConfigPath('/tmp/settings.json');
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`);
|
||||
});
|
||||
|
||||
it('should quote path with spaces', async () => {
|
||||
initConfigPath('/my path/settings.json');
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should quote path with parentheses', async () => {
|
||||
initConfigPath('/my(path)/settings.json');
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my(path)/settings.json'`);
|
||||
});
|
||||
|
||||
it('should escape embedded single quotes in path', async () => {
|
||||
initConfigPath('/my\'path/settings.json');
|
||||
await installStatusLine(false);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.NPM} --config '/my'\\''path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should use bunx command when useBunx is true', async () => {
|
||||
initConfigPath('/my path/settings.json');
|
||||
await installStatusLine(true);
|
||||
expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`);
|
||||
});
|
||||
|
||||
it('should sync hooks on install when settings include hook-enabled widgets', async () => {
|
||||
const configPath = path.join(testClaudeConfigDir, 'ccstatusline-settings.json');
|
||||
initConfigPath(configPath);
|
||||
const settingsWithSkills = {
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [[{ id: 'skills-1', type: 'skills' }], [], []]
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(settingsWithSkills, null, 2), 'utf-8');
|
||||
|
||||
await installStatusLine(false);
|
||||
|
||||
const installedCommand = `${CCSTATUSLINE_COMMANDS.NPM} --config ${configPath}`;
|
||||
const claudeSettings = await loadClaudeSettings();
|
||||
expect(claudeSettings.statusLine?.command).toBe(installedCommand);
|
||||
const hooks = (claudeSettings.hooks ?? {}) as Record<string, unknown[]>;
|
||||
expect(hooks.PreToolUse).toEqual([
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
|
||||
}
|
||||
]);
|
||||
expect(hooks.UserPromptSubmit).toEqual([
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
hooks: [{ type: 'command', command: `${installedCommand} --hook` }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup and error handling behavior', () => {
|
||||
it('saveClaudeSettings should create .bak backup before overwrite', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'preexisting-command',
|
||||
padding: 1
|
||||
}
|
||||
}));
|
||||
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string } };
|
||||
expect(saved.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
|
||||
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true);
|
||||
|
||||
const backup = JSON.parse(fs.readFileSync(`${settingsPath}.bak`, 'utf-8')) as { statusLine?: { command?: string } };
|
||||
expect(backup.statusLine?.command).toBe('preexisting-command');
|
||||
});
|
||||
|
||||
it('installStatusLine should create .orig backup before updating settings', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'old-command',
|
||||
padding: 1
|
||||
}
|
||||
}));
|
||||
|
||||
await installStatusLine(false);
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
|
||||
|
||||
const orig = JSON.parse(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')) as { statusLine?: { command?: string } };
|
||||
expect(orig.statusLine?.command).toBe('old-command');
|
||||
});
|
||||
|
||||
it('loadClaudeSettings should return empty object when settings file is missing', async () => {
|
||||
await expect(loadClaudeSettings()).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('loadClaudeSettings should log and throw when settings file is invalid JSON', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await expect(loadClaudeSettings()).rejects.toThrow();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to load Claude settings:',
|
||||
expect.anything()
|
||||
);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('isInstalled should return false when settings cannot be loaded', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await expect(isInstalled()).resolves.toBe(false);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('installStatusLine should warn and recover when existing settings are invalid', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await installStatusLine(false);
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const installed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string; padding?: number } };
|
||||
expect(installed.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
|
||||
expect(installed.statusLine?.padding).toBe(0);
|
||||
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
|
||||
expect(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')).toBe('{ invalid json');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`Warning: Could not read existing Claude settings. A backup exists at ${settingsPath}.orig.`
|
||||
);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uninstallStatusLine should warn and return without modifying invalid settings', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await uninstallStatusLine();
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
|
||||
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Warning: Could not read existing Claude settings.'
|
||||
);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uninstallStatusLine should remove all managed hooks', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0
|
||||
},
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
|
||||
},
|
||||
{
|
||||
matcher: 'Other',
|
||||
hooks: [{ type: 'command', command: 'keep-me' }]
|
||||
}
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
await uninstallStatusLine();
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const updated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
statusLine?: unknown;
|
||||
hooks?: Record<string, unknown[]>;
|
||||
};
|
||||
expect(updated.statusLine).toBeUndefined();
|
||||
expect(updated.hooks).toEqual({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Other',
|
||||
hooks: [{ type: 'command', command: 'keep-me' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('getExistingStatusLine should return null when settings cannot be loaded', async () => {
|
||||
writeRawClaudeSettings('{ invalid json');
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
await expect(getExistingStatusLine()).resolves.toBeNull();
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('isInstalled should accept known commands with --config and undefined padding', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: `${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json`
|
||||
}
|
||||
});
|
||||
|
||||
await expect(isInstalled()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import { cloneSettings } from '../clone-settings';
|
||||
|
||||
describe('cloneSettings', () => {
|
||||
it('creates a deep clone that is independent from source', () => {
|
||||
const original = {
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [
|
||||
[
|
||||
{ id: '1', type: 'model', metadata: { key: 'value' } }
|
||||
]
|
||||
]
|
||||
};
|
||||
|
||||
const cloned = cloneSettings(original);
|
||||
const originalWidget = original.lines[0]?.[0];
|
||||
const clonedWidget = cloned.lines[0]?.[0];
|
||||
|
||||
expect(originalWidget).toBeDefined();
|
||||
expect(clonedWidget).toBeDefined();
|
||||
|
||||
if (!originalWidget || !clonedWidget) {
|
||||
throw new Error('Expected cloned settings to include widget entries');
|
||||
}
|
||||
|
||||
const originalMetadata = originalWidget.metadata as Record<string, string>;
|
||||
const clonedMetadata = (clonedWidget.metadata ?? {});
|
||||
clonedWidget.metadata = clonedMetadata;
|
||||
clonedMetadata.key = 'changed';
|
||||
|
||||
expect(originalMetadata.key).toBe('value');
|
||||
expect(clonedMetadata.key).toBe('changed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
hasCustomWidgetColors,
|
||||
sanitizeLinesForColorLevel
|
||||
} from '../color-sanitize';
|
||||
|
||||
describe('color sanitize helpers', () => {
|
||||
it('detects custom ansi256/hex colors in foreground and background', () => {
|
||||
const lines: WidgetItem[][] = [
|
||||
[
|
||||
{ id: '1', type: 'model', color: 'ansi256:120' }
|
||||
],
|
||||
[
|
||||
{ id: '2', type: 'context-length', backgroundColor: 'hex:AA00BB' }
|
||||
]
|
||||
];
|
||||
|
||||
expect(hasCustomWidgetColors(lines)).toBe(true);
|
||||
expect(hasCustomWidgetColors([[{ id: '3', type: 'model', color: 'cyan' }]])).toBe(false);
|
||||
});
|
||||
|
||||
it('sanitizes hex colors when moving to ansi256 mode', () => {
|
||||
const lines: WidgetItem[][] = [[
|
||||
{ id: '1', type: 'model', color: 'hex:FF00AA', backgroundColor: 'hex:112233' },
|
||||
{ id: '2', type: 'context-length', color: 'ansi256:111', backgroundColor: 'ansi256:24' }
|
||||
]];
|
||||
|
||||
const sanitized = sanitizeLinesForColorLevel(lines, 2);
|
||||
|
||||
expect(sanitized[0]?.[0]?.color).toBe('cyan');
|
||||
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
|
||||
expect(sanitized[0]?.[1]?.color).toBe('ansi256:111');
|
||||
expect(sanitized[0]?.[1]?.backgroundColor).toBe('ansi256:24');
|
||||
});
|
||||
|
||||
it('sanitizes ansi256 colors when moving to truecolor mode', () => {
|
||||
const lines: WidgetItem[][] = [[
|
||||
{ id: '1', type: 'model', color: 'ansi256:120', backgroundColor: 'ansi256:244' },
|
||||
{ id: '2', type: 'context-length', color: 'hex:AA11BB', backgroundColor: 'hex:112233' }
|
||||
]];
|
||||
|
||||
const sanitized = sanitizeLinesForColorLevel(lines, 3);
|
||||
|
||||
expect(sanitized[0]?.[0]?.color).toBe('cyan');
|
||||
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
|
||||
expect(sanitized[0]?.[1]?.color).toBe('hex:AA11BB');
|
||||
expect(sanitized[0]?.[1]?.backgroundColor).toBe('hex:112233');
|
||||
});
|
||||
|
||||
it('sanitizes all custom colors when moving to basic/no-color modes', () => {
|
||||
const lines: WidgetItem[][] = [[
|
||||
{ id: '1', type: 'model', color: 'ansi256:99', backgroundColor: 'hex:123456' },
|
||||
{ id: '2', type: 'separator', color: 'hex:ABCDEF', backgroundColor: 'ansi256:2' }
|
||||
]];
|
||||
|
||||
const sanitized = sanitizeLinesForColorLevel(lines, 1);
|
||||
|
||||
expect(sanitized[0]?.[0]?.color).toBe('cyan');
|
||||
expect(sanitized[0]?.[0]?.backgroundColor).toBeUndefined();
|
||||
// Preserve existing behavior: separator foreground is not reset by current logic.
|
||||
expect(sanitized[0]?.[1]?.color).toBe('hex:ABCDEF');
|
||||
expect(sanitized[0]?.[1]?.backgroundColor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
initConfigPath,
|
||||
isCustomConfigPath
|
||||
} from '../config';
|
||||
|
||||
const DEFAULT_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json');
|
||||
|
||||
describe('initConfigPath / getConfigPath', () => {
|
||||
beforeEach(() => {
|
||||
initConfigPath();
|
||||
});
|
||||
|
||||
it('should return the default settings path when no arg is provided', () => {
|
||||
initConfigPath();
|
||||
expect(getConfigPath()).toBe(DEFAULT_PATH);
|
||||
expect(isCustomConfigPath()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return a custom settings path when a file path is provided', () => {
|
||||
initConfigPath('/tmp/my-ccsl/settings.json');
|
||||
expect(getConfigPath()).toBe('/tmp/my-ccsl/settings.json');
|
||||
expect(isCustomConfigPath()).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve relative paths', () => {
|
||||
initConfigPath('relative/settings.json');
|
||||
expect(path.isAbsolute(getConfigPath())).toBe(true);
|
||||
expect(getConfigPath()).toBe(path.resolve('relative/settings.json'));
|
||||
});
|
||||
|
||||
it('should reset to default when called with undefined', () => {
|
||||
initConfigPath('/tmp/custom.json');
|
||||
expect(isCustomConfigPath()).toBe(true);
|
||||
initConfigPath(undefined);
|
||||
expect(getConfigPath()).toBe(DEFAULT_PATH);
|
||||
expect(isCustomConfigPath()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
CURRENT_VERSION,
|
||||
DEFAULT_SETTINGS,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
|
||||
const MOCK_HOME_DIR = '/tmp/ccstatusline-config-test-home';
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
let loadSettings: () => Promise<Settings>;
|
||||
let saveSettings: (settings: Settings) => Promise<void>;
|
||||
let initConfigPath: (filePath?: string) => void;
|
||||
let consoleErrorSpy: MockInstance<typeof console.error>;
|
||||
|
||||
function getSettingsPaths(): { configDir: string; settingsPath: string; backupPath: string } {
|
||||
const configDir = path.join(MOCK_HOME_DIR, '.config', 'ccstatusline');
|
||||
return {
|
||||
configDir,
|
||||
settingsPath: path.join(configDir, 'settings.json'),
|
||||
backupPath: path.join(configDir, 'settings.bak')
|
||||
};
|
||||
}
|
||||
|
||||
function getClaudeConfigDir(): string {
|
||||
return path.join(MOCK_HOME_DIR, '.claude');
|
||||
}
|
||||
|
||||
describe('config utilities', () => {
|
||||
beforeAll(async () => {
|
||||
const configModule = await import('../config');
|
||||
loadSettings = configModule.loadSettings;
|
||||
saveSettings = configModule.saveSettings;
|
||||
initConfigPath = configModule.initConfigPath;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
|
||||
process.env.CLAUDE_CONFIG_DIR = getClaudeConfigDir();
|
||||
const { settingsPath } = getSettingsPaths();
|
||||
initConfigPath(settingsPath);
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
initConfigPath();
|
||||
});
|
||||
|
||||
it('writes defaults when settings file does not exist', async () => {
|
||||
const { settingsPath } = getSettingsPaths();
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
version?: number;
|
||||
lines?: unknown[];
|
||||
};
|
||||
expect(onDisk.version).toBe(CURRENT_VERSION);
|
||||
expect(Array.isArray(onDisk.lines)).toBe(true);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Default settings written to')
|
||||
);
|
||||
});
|
||||
|
||||
it('backs up invalid JSON and recovers with defaults', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, '{ invalid json', 'utf-8');
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
expect(fs.existsSync(backupPath)).toBe(true);
|
||||
expect(fs.readFileSync(backupPath, 'utf-8')).toBe('{ invalid json');
|
||||
|
||||
const recovered = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
|
||||
expect(recovered.version).toBe(CURRENT_VERSION);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse settings.json, backing up and using defaults'
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Bad settings backed up to')
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Default settings written to')
|
||||
);
|
||||
});
|
||||
|
||||
it('backs up invalid v1 payloads and recovers with defaults', async () => {
|
||||
const { settingsPath, backupPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ flexMode: 123 }), 'utf-8');
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
expect(fs.existsSync(backupPath)).toBe(true);
|
||||
const recovered = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
|
||||
expect(recovered.version).toBe(CURRENT_VERSION);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Invalid v1 settings format:',
|
||||
expect.anything()
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Bad settings backed up to')
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Default settings written to')
|
||||
);
|
||||
});
|
||||
|
||||
it('migrates older versioned settings and persists migrated result', async () => {
|
||||
const { settingsPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
lines: [[{ id: 'widget-1', type: 'model' }]]
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
expect(settings.version).toBe(CURRENT_VERSION);
|
||||
const migrated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
version?: number;
|
||||
updatemessage?: { message?: string };
|
||||
};
|
||||
expect(migrated.version).toBe(CURRENT_VERSION);
|
||||
expect(migrated.updatemessage?.message).toContain('v2.0.2');
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('always saves current version in saveSettings', async () => {
|
||||
const { settingsPath } = getSettingsPaths();
|
||||
|
||||
await saveSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
version: 1
|
||||
});
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number };
|
||||
expect(saved.version).toBe(CURRENT_VERSION);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,68 @@ import type { RenderContext } from '../../types';
|
||||
import { calculateContextPercentage } 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 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 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sonnet 4.5 with 1M context window', () => {
|
||||
it('should calculate percentage using 1M denominator with [1m] suffix', () => {
|
||||
const context: RenderContext = {
|
||||
@@ -40,6 +102,59 @@ describe('calculateContextPercentage', () => {
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(100);
|
||||
});
|
||||
|
||||
it('should calculate percentage using 1M denominator with 1M context label', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'Opus 4.6 (1M context)' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
|
||||
it('should calculate percentage using 1M denominator with 1M in parentheses', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'Opus 4.6 (1M)' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
|
||||
it('should calculate percentage from display_name when model id lacks context size suffix', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: {
|
||||
id: 'claude-opus-4-6',
|
||||
display_name: 'Opus 4.6 (1M context)'
|
||||
}
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Older models with 200k context window', () => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { execSync } from 'child_process';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import {
|
||||
getGitChangeCounts,
|
||||
isInsideGitWorkTree,
|
||||
resolveGitCwd,
|
||||
runGit
|
||||
} from '../git';
|
||||
|
||||
vi.mock('child_process', () => ({ execSync: vi.fn() }));
|
||||
|
||||
const mockExecSync = execSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockImplementation: (impl: () => never) => void;
|
||||
mockReturnValue: (value: string) => void;
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
describe('git utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('resolveGitCwd', () => {
|
||||
it('prefers context.data.cwd when available', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
cwd: '/repo/from/cwd',
|
||||
workspace: {
|
||||
current_dir: '/repo/from/current-dir',
|
||||
project_dir: '/repo/from/project-dir'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(resolveGitCwd(context)).toBe('/repo/from/cwd');
|
||||
});
|
||||
|
||||
it('falls back to workspace.current_dir', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
workspace: {
|
||||
current_dir: '/repo/from/current-dir',
|
||||
project_dir: '/repo/from/project-dir'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(resolveGitCwd(context)).toBe('/repo/from/current-dir');
|
||||
});
|
||||
|
||||
it('falls back to workspace.project_dir', () => {
|
||||
const context: RenderContext = { data: { workspace: { project_dir: '/repo/from/project-dir' } } };
|
||||
|
||||
expect(resolveGitCwd(context)).toBe('/repo/from/project-dir');
|
||||
});
|
||||
|
||||
it('skips empty candidate values', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
cwd: ' ',
|
||||
workspace: {
|
||||
current_dir: '',
|
||||
project_dir: '/repo/from/project-dir'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(resolveGitCwd(context)).toBe('/repo/from/project-dir');
|
||||
});
|
||||
|
||||
it('returns undefined when no candidates are available', () => {
|
||||
expect(resolveGitCwd({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGit', () => {
|
||||
it('runs git command with resolved cwd and trims output', () => {
|
||||
mockExecSync.mockReturnValue(' feature/worktree \n');
|
||||
const context: RenderContext = { data: { cwd: '/tmp/repo' } };
|
||||
|
||||
const result = runGit('branch --show-current', context);
|
||||
|
||||
expect(result).toBe('feature/worktree');
|
||||
expect(mockExecSync.mock.calls[0]?.[0]).toBe('git branch --show-current');
|
||||
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
cwd: '/tmp/repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('runs git command without cwd when no context directory exists', () => {
|
||||
mockExecSync.mockReturnValue('true\n');
|
||||
|
||||
const result = runGit('rev-parse --is-inside-work-tree', {});
|
||||
|
||||
expect(result).toBe('true');
|
||||
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the command fails', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(runGit('status --short', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsideGitWorkTree', () => {
|
||||
it('returns true when git reports true', () => {
|
||||
mockExecSync.mockReturnValue('true\n');
|
||||
|
||||
expect(isInsideGitWorkTree({})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when git reports false', () => {
|
||||
mockExecSync.mockReturnValue('false\n');
|
||||
|
||||
expect(isInsideGitWorkTree({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when git command fails', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(isInsideGitWorkTree({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitChangeCounts', () => {
|
||||
it('sums staged and unstaged insertions/deletions', () => {
|
||||
mockExecSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)');
|
||||
mockExecSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)');
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 5,
|
||||
deletions: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('handles singular insertion/deletion forms', () => {
|
||||
mockExecSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 1,
|
||||
deletions: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counts when git diff commands fail', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 0,
|
||||
deletions: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import { syncWidgetHooks } from '../hooks';
|
||||
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
let testClaudeConfigDir = '';
|
||||
|
||||
function getClaudeSettingsPath(): string {
|
||||
return path.join(testClaudeConfigDir, 'settings.json');
|
||||
}
|
||||
|
||||
describe('syncWidgetHooks', () => {
|
||||
beforeEach(() => {
|
||||
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-hooks-'));
|
||||
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (testClaudeConfigDir) {
|
||||
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
it('removes managed hooks and persists cleanup when status line is unset', async () => {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
matcher: 'Skill',
|
||||
hooks: [{ type: 'command', command: 'old-command --hook' }]
|
||||
},
|
||||
{
|
||||
matcher: 'Other',
|
||||
hooks: [{ type: 'command', command: 'keep-command' }]
|
||||
}
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
_tag: 'ccstatusline-managed',
|
||||
hooks: [{ type: 'command', command: 'old-command --hook' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}, null, 2), 'utf-8');
|
||||
|
||||
await syncWidgetHooks(DEFAULT_SETTINGS);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record<string, unknown[]> };
|
||||
expect(saved.hooks).toEqual({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Other',
|
||||
hooks: [{ type: 'command', command: 'keep-command' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { shouldInsertInput } from '../input-guards';
|
||||
|
||||
describe('shouldInsertInput', () => {
|
||||
it('allows regular printable input without modifiers', () => {
|
||||
expect(shouldInsertInput('s', {})).toBe(true);
|
||||
expect(shouldInsertInput('S', { shift: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks ctrl chords', () => {
|
||||
expect(shouldInsertInput('s', { ctrl: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks meta chords', () => {
|
||||
expect(shouldInsertInput('s', { meta: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks tab-based input', () => {
|
||||
expect(shouldInsertInput('\t', { tab: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks control characters and allows unicode text', () => {
|
||||
expect(shouldInsertInput('\u0013', {})).toBe(false);
|
||||
expect(shouldInsertInput('🙂', {})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getBlockMetrics } from '../jsonl';
|
||||
|
||||
function floorToHourUtc(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
|
||||
function makeUsageLine(timestamp: Date): string {
|
||||
return JSON.stringify({
|
||||
timestamp: timestamp.toISOString(),
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('jsonl block metrics integration', () => {
|
||||
let tempClaudeDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempClaudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-blocks-'));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempClaudeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
}
|
||||
fs.rmSync(tempClaudeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the current block start for recent activity after an older session gap', () => {
|
||||
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
const transcriptPath = path.join(projectsDir, 'session.jsonl');
|
||||
|
||||
const now = new Date();
|
||||
const oldActivity = new Date(now.getTime() - (10 * 60 * 60 * 1000));
|
||||
const currentBlockStartSource = new Date(now.getTime() - (2 * 60 * 60 * 1000) - (10 * 60 * 1000));
|
||||
const recentActivity = new Date(now.getTime() - (40 * 60 * 1000));
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeUsageLine(oldActivity),
|
||||
makeUsageLine(currentBlockStartSource),
|
||||
makeUsageLine(recentActivity)
|
||||
].join('\n'));
|
||||
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
expect(metrics).not.toBeNull();
|
||||
expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(currentBlockStartSource).toISOString());
|
||||
expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString());
|
||||
});
|
||||
|
||||
it('returns null when the most recent activity is older than the session window', () => {
|
||||
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
const transcriptPath = path.join(projectsDir, 'stale-session.jsonl');
|
||||
|
||||
const now = new Date();
|
||||
const staleActivity = new Date(now.getTime() - (6 * 60 * 60 * 1000));
|
||||
|
||||
fs.writeFileSync(transcriptPath, makeUsageLine(staleActivity));
|
||||
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
expect(metrics).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,886 @@
|
||||
import * as fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getSessionDuration,
|
||||
getSpeedMetrics,
|
||||
getSpeedMetricsCollection,
|
||||
getTokenMetrics
|
||||
} from '../jsonl';
|
||||
|
||||
function makeUsageLine(params: {
|
||||
timestamp: string;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead?: number;
|
||||
cacheCreate?: number;
|
||||
isSidechain?: boolean;
|
||||
isApiErrorMessage?: boolean;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
timestamp: params.timestamp,
|
||||
isSidechain: params.isSidechain,
|
||||
isApiErrorMessage: params.isApiErrorMessage,
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: params.input,
|
||||
output_tokens: params.output,
|
||||
cache_read_input_tokens: params.cacheRead,
|
||||
cache_creation_input_tokens: params.cacheCreate
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function makeTranscriptLine(params: {
|
||||
timestamp: string;
|
||||
type: 'user' | 'assistant';
|
||||
input?: number;
|
||||
output?: number;
|
||||
isSidechain?: boolean;
|
||||
isApiErrorMessage?: boolean;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
timestamp: params.timestamp,
|
||||
type: params.type,
|
||||
isSidechain: params.isSidechain,
|
||||
isApiErrorMessage: params.isApiErrorMessage,
|
||||
message: typeof params.input === 'number' || typeof params.output === 'number'
|
||||
? {
|
||||
usage: {
|
||||
input_tokens: params.input ?? 0,
|
||||
output_tokens: params.output ?? 0
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
}
|
||||
|
||||
describe('jsonl transcript metrics', () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (tempRoots.length > 0) {
|
||||
const root = tempRoots.pop();
|
||||
if (root) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('formats session duration as <1m for sub-minute transcripts', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'short.jsonl');
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }),
|
||||
JSON.stringify({ timestamp: '2026-01-01T10:00:30.000Z' })
|
||||
].join('\n'));
|
||||
|
||||
const duration = await getSessionDuration(transcriptPath);
|
||||
|
||||
expect(duration).toBe('<1m');
|
||||
});
|
||||
|
||||
it('formats multi-hour session durations', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'long.jsonl');
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }),
|
||||
JSON.stringify({ timestamp: '2026-01-01T12:05:00.000Z' })
|
||||
].join('\n'));
|
||||
|
||||
const duration = await getSessionDuration(transcriptPath);
|
||||
|
||||
expect(duration).toBe('2hr 5m');
|
||||
});
|
||||
|
||||
it('returns null for missing transcript files', async () => {
|
||||
const duration = await getSessionDuration('/tmp/ccstatusline-jsonl-metrics-missing.jsonl');
|
||||
expect(duration).toBeNull();
|
||||
});
|
||||
|
||||
it('aggregates token totals and computes context length from the latest main-chain non-error entry', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'tokens.jsonl');
|
||||
|
||||
const lines = [
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
input: 100,
|
||||
output: 50,
|
||||
cacheRead: 20,
|
||||
cacheCreate: 10
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:00:00.000Z',
|
||||
input: 200,
|
||||
output: 80,
|
||||
cacheRead: 30,
|
||||
cacheCreate: 20
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:30:00.000Z',
|
||||
input: 500,
|
||||
output: 10,
|
||||
cacheRead: 5,
|
||||
cacheCreate: 5,
|
||||
isSidechain: true
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:45:00.000Z',
|
||||
input: 999,
|
||||
output: 1,
|
||||
cacheRead: 1,
|
||||
cacheCreate: 1,
|
||||
isApiErrorMessage: true
|
||||
})
|
||||
];
|
||||
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const metrics = await getTokenMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 1799,
|
||||
outputTokens: 141,
|
||||
cachedTokens: 92,
|
||||
totalTokens: 2032,
|
||||
contextLength: 250
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zeroed token metrics when file is missing', async () => {
|
||||
const metrics = await getTokenMetrics('/tmp/ccstatusline-jsonl-metrics-missing.jsonl');
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates speed metrics from user-to-assistant processing windows', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'assistant',
|
||||
input: 200,
|
||||
output: 100
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:08.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 50
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:01:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:01:04.000Z',
|
||||
type: 'assistant',
|
||||
input: 300,
|
||||
output: 150
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 12000,
|
||||
inputTokens: 600,
|
||||
outputTokens: 300,
|
||||
totalTokens: 900,
|
||||
requestCount: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates windowed speed metrics from recent requests only', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-window.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 50
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:01:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:01:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 200,
|
||||
output: 100
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:02:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:02:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 300,
|
||||
output: 150
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { windowSeconds: 70 });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 20000,
|
||||
inputTokens: 500,
|
||||
outputTokens: 250,
|
||||
totalTokens: 750,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('returns session and windowed speed metrics in one collection call', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-window-collection.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 50
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:40.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:50.000Z',
|
||||
type: 'assistant',
|
||||
input: 200,
|
||||
output: 100
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metricsCollection = await getSpeedMetricsCollection(transcriptPath, { windowSeconds: [30, 90] });
|
||||
|
||||
expect(metricsCollection.sessionAverage).toEqual({
|
||||
totalDurationMs: 20000,
|
||||
inputTokens: 300,
|
||||
outputTokens: 150,
|
||||
totalTokens: 450,
|
||||
requestCount: 2
|
||||
});
|
||||
expect(metricsCollection.windowed['30']).toEqual({
|
||||
totalDurationMs: 10000,
|
||||
inputTokens: 200,
|
||||
outputTokens: 100,
|
||||
totalTokens: 300,
|
||||
requestCount: 1
|
||||
});
|
||||
expect(metricsCollection.windowed['90']).toEqual({
|
||||
totalDurationMs: 20000,
|
||||
inputTokens: 300,
|
||||
outputTokens: 150,
|
||||
totalTokens: 450,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores sidechain and API error entries in speed metrics', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-filtering.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:01.000Z',
|
||||
type: 'assistant',
|
||||
input: 999,
|
||||
output: 999,
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:02.000Z',
|
||||
type: 'assistant',
|
||||
input: 500,
|
||||
output: 500,
|
||||
isApiErrorMessage: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 50
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 3000,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('does not parse subagent transcripts unless includeSubagents is enabled', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
const subagentTranscriptPath = path.join(subagentsDir, 'agent-1.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:04.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: '1' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(subagentTranscriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:01.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:11.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 200,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 4000,
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalTokens: 30,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates subagent speed metrics with merged active windows when enabled', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-with-subagents.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 50,
|
||||
output: 100
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'a' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'b' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:15.000Z',
|
||||
type: 'assistant',
|
||||
input: 150,
|
||||
output: 300,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-b.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:20.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:25.000Z',
|
||||
type: 'assistant',
|
||||
input: 25,
|
||||
output: 50,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 20000,
|
||||
inputTokens: 225,
|
||||
outputTokens: 450,
|
||||
totalTokens: 675,
|
||||
requestCount: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('applies window filtering to aggregated subagent speed metrics', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-subagent-windowed.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:04.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'a' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:10.000Z',
|
||||
type: 'assistant',
|
||||
input: 30,
|
||||
output: 60,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, {
|
||||
includeSubagents: true,
|
||||
windowSeconds: 4
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 4000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 60,
|
||||
totalTokens: 90,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('includes only referenced subagent transcripts from the parent transcript', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-referenced-subagents.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'assistant',
|
||||
input: 20,
|
||||
output: 30
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'referenced-agent' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-referenced-agent.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:06.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:08.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-unrelated-agent.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:06.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:18.000Z',
|
||||
type: 'assistant',
|
||||
input: 500,
|
||||
output: 900,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 7000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 50,
|
||||
totalTokens: 80,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('finds subagents in session-directory layout used by Claude transcripts', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const sessionId = 'session-123';
|
||||
const transcriptPath = path.join(root, `${sessionId}.jsonl`);
|
||||
const subagentsDir = path.join(root, sessionId, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:04.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'layout-agent' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-layout-agent.jsonl'), [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:05.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:08.000Z',
|
||||
type: 'assistant',
|
||||
input: 15,
|
||||
output: 25,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 7000,
|
||||
inputTokens: 25,
|
||||
outputTokens: 45,
|
||||
totalTokens: 70,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to main transcript metrics when subagent folder cannot be listed', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-discovery-failure.jsonl');
|
||||
const subagentsPath = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'assistant',
|
||||
input: 30,
|
||||
output: 60
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'unreadable' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
// Create a regular file where the subagents directory is expected.
|
||||
fs.writeFileSync(subagentsPath, 'not-a-directory');
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 3000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 60,
|
||||
totalTokens: 90,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed subagent lines without failing', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-malformed-subagent.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:02.000Z',
|
||||
type: 'assistant',
|
||||
input: 10,
|
||||
output: 20
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'malformed' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subagentsDir, 'agent-malformed.jsonl'), [
|
||||
'not-json',
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:07.000Z',
|
||||
type: 'assistant',
|
||||
input: 5,
|
||||
output: 15,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 6000,
|
||||
inputTokens: 15,
|
||||
outputTokens: 35,
|
||||
totalTokens: 50,
|
||||
requestCount: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to main transcript metrics when subagents directory is missing', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-no-subagents.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'assistant',
|
||||
input: 30,
|
||||
output: 60
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { agentId: 'unreadable' }
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 3000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 60,
|
||||
totalTokens: 90,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unreadable subagent transcript files without failing', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(true).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-main-unreadable-subagent.jsonl');
|
||||
const subagentsDir = path.join(root, 'subagents');
|
||||
const unreadableSubagentPath = path.join(subagentsDir, 'agent-unreadable.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
type: 'user'
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:03.000Z',
|
||||
type: 'assistant',
|
||||
input: 30,
|
||||
output: 60
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(unreadableSubagentPath, [
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:04.000Z',
|
||||
type: 'user',
|
||||
isSidechain: true
|
||||
}),
|
||||
makeTranscriptLine({
|
||||
timestamp: '2026-01-01T10:00:06.000Z',
|
||||
type: 'assistant',
|
||||
input: 100,
|
||||
output: 200,
|
||||
isSidechain: true
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
fs.chmodSync(unreadableSubagentPath, 0o000);
|
||||
const metrics = await (async () => {
|
||||
try {
|
||||
return await getSpeedMetrics(transcriptPath, { includeSubagents: true });
|
||||
} finally {
|
||||
fs.chmodSync(unreadableSubagentPath, 0o600);
|
||||
}
|
||||
})();
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 3000,
|
||||
inputTokens: 30,
|
||||
outputTokens: 60,
|
||||
totalTokens: 90,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty speed metrics when transcript path points to an unreadable directory', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'not-a-jsonl-file');
|
||||
|
||||
fs.mkdirSync(transcriptPath);
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
requestCount: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('counts assistant tokens without timestamps while keeping active duration at zero', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'speed-missing-timestamps.jsonl');
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 7,
|
||||
output_tokens: 9
|
||||
}
|
||||
}
|
||||
})
|
||||
].join('\n'));
|
||||
|
||||
const metrics = await getSpeedMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 0,
|
||||
inputTokens: 7,
|
||||
outputTokens: 9,
|
||||
totalTokens: 16,
|
||||
requestCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty speed metrics when transcript is missing', async () => {
|
||||
const metrics = await getSpeedMetrics('/tmp/ccstatusline-jsonl-speed-missing.jsonl');
|
||||
expect(metrics).toEqual({
|
||||
totalDurationMs: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
requestCount: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
detectVersion,
|
||||
migrateConfig,
|
||||
needsMigration
|
||||
} from '../migrations';
|
||||
|
||||
describe('migrations', () => {
|
||||
it('detects version for unknown data and versioned objects', () => {
|
||||
expect(detectVersion(null)).toBe(1);
|
||||
expect(detectVersion('invalid')).toBe(1);
|
||||
expect(detectVersion({})).toBe(1);
|
||||
expect(detectVersion({ version: 2 })).toBe(2);
|
||||
});
|
||||
|
||||
it('reports whether migration is needed', () => {
|
||||
expect(needsMigration({ version: 2 }, 3)).toBe(true);
|
||||
expect(needsMigration({ version: 3 }, 3)).toBe(false);
|
||||
expect(needsMigration({}, 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns original value for non-record migration input', () => {
|
||||
expect(migrateConfig('invalid', 3)).toBe('invalid');
|
||||
expect(migrateConfig(123, 3)).toBe(123);
|
||||
});
|
||||
|
||||
it('migrates v1 to v2 by copying known fields and assigning ids', () => {
|
||||
const migrated = migrateConfig({
|
||||
lines: [[
|
||||
{ type: 'model', color: 'cyan' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'git-branch' }
|
||||
]],
|
||||
flexMode: 'full',
|
||||
compactThreshold: 70,
|
||||
colorLevel: 3,
|
||||
defaultSeparator: '|',
|
||||
defaultPadding: ' ',
|
||||
inheritSeparatorColors: true,
|
||||
overrideBackgroundColor: 'black',
|
||||
overrideForegroundColor: 'white',
|
||||
globalBold: true,
|
||||
unknownField: 'ignored'
|
||||
}, 2) as Record<string, unknown>;
|
||||
|
||||
expect(migrated.version).toBe(2);
|
||||
expect(migrated.flexMode).toBe('full');
|
||||
expect(migrated.compactThreshold).toBe(70);
|
||||
expect(migrated.colorLevel).toBe(3);
|
||||
expect(migrated.defaultSeparator).toBe('|');
|
||||
expect(migrated.defaultPadding).toBe(' ');
|
||||
expect(migrated.inheritSeparatorColors).toBe(true);
|
||||
expect(migrated.overrideBackgroundColor).toBe('black');
|
||||
expect(migrated.overrideForegroundColor).toBe('white');
|
||||
expect(migrated.globalBold).toBe(true);
|
||||
expect(migrated.unknownField).toBeUndefined();
|
||||
|
||||
const lines = migrated.lines as Record<string, unknown>[][];
|
||||
const firstLine = lines[0];
|
||||
expect(Array.isArray(firstLine)).toBe(true);
|
||||
expect(firstLine?.map(item => item.type)).toEqual(['model', 'git-branch']);
|
||||
expect(typeof firstLine?.[0]?.id).toBe('string');
|
||||
expect(typeof firstLine?.[1]?.id).toBe('string');
|
||||
|
||||
const updateMessage = migrated.updatemessage as { message?: string; remaining?: number };
|
||||
expect(updateMessage.message).toContain('v2.0.0');
|
||||
expect(updateMessage.remaining).toBe(12);
|
||||
});
|
||||
|
||||
it('applies sequential migrations to reach target version', () => {
|
||||
const migrated = migrateConfig({
|
||||
lines: [[
|
||||
{ type: 'model' }
|
||||
]]
|
||||
}, 3) as Record<string, unknown>;
|
||||
|
||||
expect(migrated.version).toBe(3);
|
||||
const updateMessage = migrated.updatemessage as { message?: string; remaining?: number };
|
||||
expect(updateMessage.message).toContain('v2.0.2');
|
||||
expect(updateMessage.remaining).toBe(12);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getContextConfig } from "../model-context";
|
||||
import {
|
||||
getContextConfig,
|
||||
getModelContextIdentifier
|
||||
} from '../model-context';
|
||||
|
||||
describe("getContextConfig", () => {
|
||||
describe("Sonnet 4.5 models with [1m] suffix", () => {
|
||||
it("should return 1M context window for claude-sonnet-4-5 with [1m] suffix", () => {
|
||||
const config = getContextConfig("claude-sonnet-4-5-20250929[1m]");
|
||||
describe('getContextConfig', () => {
|
||||
describe('Status JSON context window size override', () => {
|
||||
it('should use context_window_size as max tokens when provided', () => {
|
||||
const config = getContextConfig('claude-3-5-sonnet-20241022', 1000000);
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should prioritize context_window_size over [1m] model suffix', () => {
|
||||
const config = getContextConfig('claude-sonnet-4-5-20250929[1m]', 200000);
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 1M context window for AWS Bedrock format with [1m] suffix", () => {
|
||||
const config = getContextConfig(
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0[1m]"
|
||||
);
|
||||
describe('Models with [1m] suffix', () => {
|
||||
it('should return 1M context window for claude-sonnet-4-5 with [1m] suffix', () => {
|
||||
const config = getContextConfig('claude-sonnet-4-5-20250929[1m]');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for claude-opus-4-6 with [1m] suffix', () => {
|
||||
const config = getContextConfig('claude-opus-4-6[1m]');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for AWS Bedrock format with [1m] suffix', () => {
|
||||
const config = getContextConfig(
|
||||
'us.anthropic.claude-sonnet-4-5-20250929-v1:0[1m]'
|
||||
);
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window with uppercase [1M] suffix', () => {
|
||||
const config = getContextConfig('claude-sonnet-4-5-20250929[1M]');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for model IDs with 1M context label', () => {
|
||||
const config = getContextConfig('Opus 4.6 (1M context)');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for model IDs with 1M token context label', () => {
|
||||
const config = getContextConfig('Claude Opus 4.6 - 1M token context');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for model IDs with 1M in parentheses', () => {
|
||||
const config = getContextConfig('Opus 4.6 (1M)');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should return 1M context window for model IDs with 1M in square brackets', () => {
|
||||
const config = getContextConfig('Opus 4.5 [1M]');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 1M context window with uppercase [1M] suffix", () => {
|
||||
const config = getContextConfig("claude-sonnet-4-5-20250929[1M]");
|
||||
describe('Models without [1m] suffix', () => {
|
||||
it('should return 200k context window for claude-sonnet-4-5 without [1m] suffix', () => {
|
||||
const config = getContextConfig('claude-sonnet-4-5-20250929');
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
});
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
|
||||
describe("Sonnet 4.5 models without [1m] suffix", () => {
|
||||
it("should return 200k context window for claude-sonnet-4-5 without [1m] suffix", () => {
|
||||
const config = getContextConfig("claude-sonnet-4-5-20250929");
|
||||
it('should return 200k context window for AWS Bedrock format without [1m] suffix', () => {
|
||||
const config = getContextConfig(
|
||||
'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
|
||||
);
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 200k context window for AWS Bedrock format without [1m] suffix", () => {
|
||||
const config = getContextConfig(
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
);
|
||||
describe('Older/default models', () => {
|
||||
it('should return 200k context window for older Sonnet 3.5 model', () => {
|
||||
const config = getContextConfig('claude-3-5-sonnet-20241022');
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
|
||||
it('should return 200k context window when model ID is undefined', () => {
|
||||
const config = getContextConfig(undefined);
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
|
||||
it('should return 200k context window for unknown model ID', () => {
|
||||
const config = getContextConfig('claude-unknown-model');
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Older/default models", () => {
|
||||
it("should return 200k context window for older Sonnet 3.5 model", () => {
|
||||
const config = getContextConfig("claude-3-5-sonnet-20241022");
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
|
||||
it("should return 200k context window when model ID is undefined", () => {
|
||||
const config = getContextConfig(undefined);
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
|
||||
it("should return 200k context window for unknown model ID", () => {
|
||||
const config = getContextConfig("claude-unknown-model");
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelContextIdentifier', () => {
|
||||
it('returns string model identifier unchanged', () => {
|
||||
expect(getModelContextIdentifier('claude-sonnet-4-5-20250929[1m]')).toBe('claude-sonnet-4-5-20250929[1m]');
|
||||
});
|
||||
|
||||
it('prefers both id and display name when available', () => {
|
||||
expect(getModelContextIdentifier({
|
||||
id: 'claude-opus-4-6',
|
||||
display_name: 'Opus 4.6 (1M context)'
|
||||
})).toBe('claude-opus-4-6 Opus 4.6 (1M context)');
|
||||
});
|
||||
|
||||
it('returns display name when id is missing', () => {
|
||||
expect(getModelContextIdentifier({ display_name: 'Opus 4.6 (1M context)' })).toBe('Opus 4.6 (1M context)');
|
||||
});
|
||||
|
||||
it('returns undefined when no model value exists', () => {
|
||||
expect(getModelContextIdentifier(undefined)).toBeUndefined();
|
||||
expect(getModelContextIdentifier({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { openExternalUrl } from '../open-url';
|
||||
|
||||
vi.mock('child_process', () => ({ spawnSync: vi.fn() }));
|
||||
|
||||
const mockSpawnSync = spawnSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockReturnValue: (value: unknown) => void;
|
||||
mockReturnValueOnce: (value: unknown) => void;
|
||||
};
|
||||
|
||||
describe('openExternalUrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses open on macOS', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('open');
|
||||
expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['https://github.com/sirmalloc/ccstatusline']);
|
||||
});
|
||||
|
||||
it('uses cmd start on Windows', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('cmd');
|
||||
expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['/c', 'start', '', 'https://github.com/sirmalloc/ccstatusline']);
|
||||
});
|
||||
|
||||
it('uses xdg-open on Linux when available', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('xdg-open');
|
||||
expect(mockSpawnSync.mock.calls[0]?.[1]).toEqual(['https://github.com/sirmalloc/ccstatusline']);
|
||||
});
|
||||
|
||||
it('falls back to gio open when xdg-open fails', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
mockSpawnSync.mockReturnValueOnce({ status: 1 });
|
||||
mockSpawnSync.mockReturnValueOnce({ status: 0 });
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockSpawnSync.mock.calls.length).toBe(2);
|
||||
expect(mockSpawnSync.mock.calls[0]?.[0]).toBe('xdg-open');
|
||||
expect(mockSpawnSync.mock.calls[1]?.[0]).toBe('gio');
|
||||
expect(mockSpawnSync.mock.calls[1]?.[1]).toEqual(['open', 'https://github.com/sirmalloc/ccstatusline']);
|
||||
});
|
||||
|
||||
it('returns failure when Linux openers fail', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
mockSpawnSync.mockReturnValueOnce({ status: 1 });
|
||||
mockSpawnSync.mockReturnValueOnce({ status: 2 });
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('xdg-open failed');
|
||||
expect(result.error).toContain('gio open failed');
|
||||
});
|
||||
|
||||
it('rejects non-http URL protocols', () => {
|
||||
const result = openExternalUrl('file:///tmp/ccstatusline');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Only http(s) URLs are supported'
|
||||
});
|
||||
expect(mockSpawnSync.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
const result = openExternalUrl('not-a-valid-url');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid URL'
|
||||
});
|
||||
expect(mockSpawnSync.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns command spawn error details', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
mockSpawnSync.mockReturnValue({ error: new Error('spawn failed') });
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'spawn failed'
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves status-based error formatting when signal is present', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
mockSpawnSync.mockReturnValue({
|
||||
status: null,
|
||||
signal: 'SIGTERM'
|
||||
});
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Command exited with status null'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unsupported platform error', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('freebsd');
|
||||
|
||||
const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Unsupported platform: freebsd'
|
||||
});
|
||||
expect(mockSpawnSync.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { buildEnabledPowerlineSettings } from '../powerline-settings';
|
||||
|
||||
describe('powerline settings helpers', () => {
|
||||
it('enables powerline with default theme and default padding', () => {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: false,
|
||||
theme: undefined
|
||||
}
|
||||
};
|
||||
|
||||
const updated = buildEnabledPowerlineSettings(settings, false);
|
||||
|
||||
expect(updated.powerline.enabled).toBe(true);
|
||||
expect(updated.powerline.theme).toBe('nord-aurora');
|
||||
expect(updated.defaultPadding).toBe(' ');
|
||||
});
|
||||
|
||||
it('preserves non-custom theme when enabling powerline', () => {
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: false,
|
||||
theme: 'catppuccin'
|
||||
}
|
||||
};
|
||||
|
||||
const updated = buildEnabledPowerlineSettings(settings, false);
|
||||
expect(updated.powerline.theme).toBe('catppuccin');
|
||||
});
|
||||
|
||||
it('removes manual separators when requested', () => {
|
||||
const line: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'separator' },
|
||||
{ id: '3', type: 'context-length' },
|
||||
{ id: '4', type: 'flex-separator' }
|
||||
];
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [line]
|
||||
};
|
||||
|
||||
const updated = buildEnabledPowerlineSettings(settings, true);
|
||||
expect(updated.lines[0]?.map(item => item.type)).toEqual(['model', 'context-length']);
|
||||
});
|
||||
|
||||
it('keeps manual separators when removal is not requested', () => {
|
||||
const line: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'separator' },
|
||||
{ id: '3', type: 'context-length' }
|
||||
];
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
lines: [line]
|
||||
};
|
||||
|
||||
const updated = buildEnabledPowerlineSettings(settings, false);
|
||||
expect(updated.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
getVisibleText,
|
||||
getVisibleWidth,
|
||||
truncateStyledText
|
||||
} from '../ansi';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine
|
||||
} from '../renderer';
|
||||
|
||||
const OSC8_OPEN = '\x1b]8;;https://example.com/docs\x1b\\';
|
||||
const OSC8_CLOSE = '\x1b]8;;\x1b\\';
|
||||
const OSC8_OPEN_WITH_PARAMS = '\x1b]8;id=abc;https://example.com/docs\x1b\\';
|
||||
const OSC8_CLOSE_WITH_PARAMS = '\x1b]8;id=abc;\x1b\\';
|
||||
const BEL_OSC8_OPEN = '\x1b]8;;https://example.com/docs\x07';
|
||||
const BEL_OSC8_CLOSE = '\x1b]8;;\x07';
|
||||
|
||||
function createSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
flexMode: 'full',
|
||||
...overrides,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
...(overrides.powerline ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderLine(
|
||||
widgets: WidgetItem[],
|
||||
options: { settings?: Partial<Settings>; terminalWidth?: number } = {}
|
||||
): string {
|
||||
const settings = createSettings(options.settings);
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: options.terminalWidth
|
||||
};
|
||||
|
||||
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
const preRenderedWidgets = preRenderedLines[0] ?? [];
|
||||
|
||||
return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
}
|
||||
|
||||
describe('renderer ANSI/OSC handling', () => {
|
||||
it('treats OSC 8 wrappers as non-visible text for width calculations', () => {
|
||||
const text = `A ${OSC8_OPEN}click${OSC8_CLOSE} B`;
|
||||
expect(getVisibleText(text)).toBe('A click B');
|
||||
expect(getVisibleWidth(text)).toBe(getVisibleWidth('A click B'));
|
||||
});
|
||||
|
||||
it('closes open OSC 8 hyperlinks when truncating styled text', () => {
|
||||
const text = `${OSC8_OPEN}very-long-link-text${OSC8_CLOSE}`;
|
||||
const truncated = truncateStyledText(text, 10, { ellipsis: true });
|
||||
|
||||
expect(truncated.endsWith('...')).toBe(true);
|
||||
expect(truncated).toContain(OSC8_CLOSE);
|
||||
expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('keeps OSC 8 links well-formed in normal renderer truncation', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: `${OSC8_OPEN}very-long-link-text${OSC8_CLOSE}`
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets, { terminalWidth: 12 });
|
||||
expect(line.endsWith('...')).toBe(true);
|
||||
expect(line).toContain(OSC8_CLOSE);
|
||||
expect(getVisibleWidth(line)).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
it('keeps OSC 8 links well-formed in powerline truncation', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
type: 'custom-text',
|
||||
customText: `${OSC8_OPEN}very-long-link-text${OSC8_CLOSE}`,
|
||||
color: 'white',
|
||||
backgroundColor: 'bgBlue'
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
type: 'custom-text',
|
||||
customText: 'tail',
|
||||
color: 'white',
|
||||
backgroundColor: 'bgGreen'
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets, {
|
||||
terminalWidth: 16,
|
||||
settings: {
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B0'],
|
||||
separatorInvertBackground: [false]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(line).toContain(OSC8_CLOSE);
|
||||
expect(getVisibleWidth(line)).toBeLessThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('truncates custom-command preserveColors output without breaking OSC 8', () => {
|
||||
const widget: WidgetItem = {
|
||||
id: 'cmd1',
|
||||
type: 'custom-command',
|
||||
preserveColors: true,
|
||||
maxWidth: 6
|
||||
};
|
||||
const settings = createSettings();
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: 200
|
||||
};
|
||||
const content = `${OSC8_OPEN}abcdefghij${OSC8_CLOSE}`;
|
||||
const preRenderedWidgets = [{
|
||||
content,
|
||||
plainLength: getVisibleWidth(content),
|
||||
widget
|
||||
}];
|
||||
|
||||
const line = renderStatusLine([widget], settings, context, preRenderedWidgets, []);
|
||||
expect(line).toContain(OSC8_CLOSE);
|
||||
expect(getVisibleWidth(line)).toBe(6);
|
||||
});
|
||||
|
||||
it('uses visible width for flex separator alignment when OSC 8 text is present', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{
|
||||
id: 'left',
|
||||
type: 'custom-text',
|
||||
customText: `${OSC8_OPEN}A${OSC8_CLOSE}`
|
||||
},
|
||||
{
|
||||
id: 'flex',
|
||||
type: 'flex-separator'
|
||||
},
|
||||
{
|
||||
id: 'right',
|
||||
type: 'custom-text',
|
||||
customText: 'B'
|
||||
}
|
||||
];
|
||||
|
||||
const line = renderLine(widgets, { terminalWidth: 26 });
|
||||
const visible = getVisibleText(line);
|
||||
|
||||
expect(visible.startsWith('A')).toBe(true);
|
||||
expect(visible.endsWith('B')).toBe(true);
|
||||
expect(visible.includes('...')).toBe(false);
|
||||
expect(getVisibleWidth(line)).toBe(20);
|
||||
});
|
||||
|
||||
it('handles BEL-terminated OSC 8 sequences during truncation', () => {
|
||||
const line = `${BEL_OSC8_OPEN}bel-link-text${BEL_OSC8_CLOSE}`;
|
||||
const truncated = truncateStyledText(line, 8, { ellipsis: true });
|
||||
|
||||
expect(truncated.endsWith('...')).toBe(true);
|
||||
expect(truncated).toContain(BEL_OSC8_CLOSE);
|
||||
expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('handles OSC 8 sequences with params during truncation', () => {
|
||||
const line = `${OSC8_OPEN_WITH_PARAMS}param-link-text${OSC8_CLOSE_WITH_PARAMS}`;
|
||||
const truncated = truncateStyledText(line, 8, { ellipsis: true });
|
||||
|
||||
expect(truncated.endsWith('...')).toBe(true);
|
||||
expect(truncated).toContain(OSC8_CLOSE);
|
||||
expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { getVisibleWidth } from '../ansi';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLine
|
||||
} from '../renderer';
|
||||
|
||||
function createSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...overrides,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
...(overrides.powerline ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderLine(
|
||||
widgets: WidgetItem[],
|
||||
settingsOverrides: Partial<Settings>,
|
||||
contextOverrides: Partial<RenderContext> = {}
|
||||
): string {
|
||||
const settings = createSettings(settingsOverrides);
|
||||
const context: RenderContext = {
|
||||
isPreview: false,
|
||||
terminalWidth: 50,
|
||||
...contextOverrides
|
||||
};
|
||||
|
||||
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
const preRenderedWidgets = preRenderedLines[0] ?? [];
|
||||
|
||||
return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
}
|
||||
|
||||
describe('renderer flex width behavior', () => {
|
||||
const longTextWidget: WidgetItem = {
|
||||
id: 'text',
|
||||
type: 'custom-text',
|
||||
customText: 'abcdefghijklmnopqrstuvwxyz1234567890'
|
||||
};
|
||||
|
||||
it('uses full-minus-40 width in normal mode', () => {
|
||||
const line = renderLine([longTextWidget], { flexMode: 'full-minus-40' });
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(10);
|
||||
expect(line.endsWith('...')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses full width in full-until-compact when under threshold', () => {
|
||||
const line = renderLine([longTextWidget], {
|
||||
flexMode: 'full-until-compact',
|
||||
compactThreshold: 60
|
||||
}, { data: { context_window: { used_percentage: 20 } } });
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(longTextWidget.customText?.length);
|
||||
expect(line.endsWith('...')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses compact width in full-until-compact when above threshold', () => {
|
||||
const line = renderLine([longTextWidget], {
|
||||
flexMode: 'full-until-compact',
|
||||
compactThreshold: 60
|
||||
}, { data: { context_window: { used_percentage: 80 } } });
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(10);
|
||||
expect(line.endsWith('...')).toBe(true);
|
||||
});
|
||||
|
||||
it('always uses full preview width in full-until-compact preview mode', () => {
|
||||
const line = renderLine([longTextWidget], {
|
||||
flexMode: 'full-until-compact',
|
||||
compactThreshold: 60
|
||||
}, {
|
||||
isPreview: true,
|
||||
data: { context_window: { used_percentage: 99 } }
|
||||
});
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(longTextWidget.customText?.length);
|
||||
expect(line.endsWith('...')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies the same width behavior in powerline mode', () => {
|
||||
const line = renderLine([{
|
||||
...longTextWidget,
|
||||
backgroundColor: 'bgBlue',
|
||||
color: 'white'
|
||||
}], {
|
||||
flexMode: 'full-minus-40',
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(getVisibleWidth(line)).toBe(10);
|
||||
expect(line.endsWith('...')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
advanceGlobalSeparatorIndex,
|
||||
countSeparatorSlots
|
||||
} from '../separator-index';
|
||||
|
||||
describe('separator index utils', () => {
|
||||
it('returns zero for empty and single-item lines', () => {
|
||||
expect(countSeparatorSlots([])).toBe(0);
|
||||
|
||||
const single: WidgetItem[] = [{ id: '1', type: 'model' }];
|
||||
expect(countSeparatorSlots(single)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts one separator slot between two non-merged items', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'context-length' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not count separator slots for merged items', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model', merge: true },
|
||||
{ id: '2', type: 'context-length' },
|
||||
{ id: '3', type: 'version' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('treats no-padding merge the same as merged', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'model', merge: 'no-padding' },
|
||||
{ id: '2', type: 'context-length' },
|
||||
{ id: '3', type: 'version' }
|
||||
];
|
||||
|
||||
expect(countSeparatorSlots(widgets)).toBe(1);
|
||||
});
|
||||
|
||||
it('advances a running global separator index', () => {
|
||||
const firstLine: WidgetItem[] = [
|
||||
{ id: '1', type: 'model' },
|
||||
{ id: '2', type: 'context-length' },
|
||||
{ id: '3', type: 'version' }
|
||||
];
|
||||
const secondLine: WidgetItem[] = [
|
||||
{ id: '4', type: 'git-branch', merge: true },
|
||||
{ id: '5', type: 'git-changes' },
|
||||
{ id: '6', type: 'session-cost' }
|
||||
];
|
||||
|
||||
const afterFirst = advanceGlobalSeparatorIndex(0, firstLine);
|
||||
const afterSecond = advanceGlobalSeparatorIndex(afterFirst, secondLine);
|
||||
|
||||
expect(afterFirst).toBe(2);
|
||||
expect(afterSecond).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getSkillsFilePath,
|
||||
getSkillsMetrics
|
||||
} from '../skills';
|
||||
|
||||
let testHomeDir = '';
|
||||
|
||||
function writeSkillsLog(sessionId: string, lines: string[]): void {
|
||||
const skillsPath = getSkillsFilePath(sessionId);
|
||||
fs.mkdirSync(path.dirname(skillsPath), { recursive: true });
|
||||
fs.writeFileSync(skillsPath, lines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
describe('skills metrics', () => {
|
||||
beforeEach(() => {
|
||||
testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-home-'));
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(testHomeDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (testHomeDir) {
|
||||
fs.rmSync(testHomeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses ~/.cache/ccstatusline/skills path for skill logs', () => {
|
||||
expect(getSkillsFilePath('session-1')).toBe(
|
||||
path.join(testHomeDir, '.cache', 'ccstatusline', 'skills', 'skills-session-1.jsonl')
|
||||
);
|
||||
});
|
||||
|
||||
it('returns total, unique (most-recent-first), and last skill from a valid log', () => {
|
||||
writeSkillsLog('session-1', [
|
||||
JSON.stringify({ skill: 'commit', session_id: 'session-1' }),
|
||||
JSON.stringify({ skill: 'review-pr', session_id: 'session-1' }),
|
||||
JSON.stringify({ skill: 'lint', session_id: 'session-1' }),
|
||||
JSON.stringify({ skill: 'commit', session_id: 'session-1' })
|
||||
]);
|
||||
|
||||
expect(getSkillsMetrics('session-1')).toEqual({
|
||||
totalInvocations: 4,
|
||||
uniqueSkills: ['commit', 'lint', 'review-pr'],
|
||||
lastSkill: 'commit'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { SpeedMetrics } from '../../types/SpeedMetrics';
|
||||
import {
|
||||
calculateInputSpeed,
|
||||
calculateOutputSpeed,
|
||||
calculateTotalSpeed,
|
||||
formatSpeed
|
||||
} from '../speed-metrics';
|
||||
|
||||
function createMetrics(overrides: Partial<SpeedMetrics> = {}): SpeedMetrics {
|
||||
return {
|
||||
totalDurationMs: 10000,
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
requestCount: 5,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('speed metrics calculations', () => {
|
||||
it('calculateOutputSpeed returns null when duration is zero', () => {
|
||||
const result = calculateOutputSpeed(createMetrics({ totalDurationMs: 0 }));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('calculateOutputSpeed computes output tokens per second', () => {
|
||||
const result = calculateOutputSpeed(createMetrics({ outputTokens: 750, totalDurationMs: 15000 }));
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
|
||||
it('calculateInputSpeed returns null when duration is zero', () => {
|
||||
const result = calculateInputSpeed(createMetrics({ totalDurationMs: 0 }));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('calculateInputSpeed computes input tokens per second', () => {
|
||||
const result = calculateInputSpeed(createMetrics({ inputTokens: 1200, totalDurationMs: 6000 }));
|
||||
expect(result).toBe(200);
|
||||
});
|
||||
|
||||
it('calculateTotalSpeed returns null when duration is zero', () => {
|
||||
const result = calculateTotalSpeed(createMetrics({ totalDurationMs: 0 }));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('calculateTotalSpeed computes total tokens per second from totalTokens', () => {
|
||||
const result = calculateTotalSpeed(createMetrics({ totalTokens: 3000, totalDurationMs: 12000 }));
|
||||
expect(result).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSpeed', () => {
|
||||
it('formats null as an em dash placeholder', () => {
|
||||
expect(formatSpeed(null)).toBe('\u2014');
|
||||
});
|
||||
|
||||
it('formats sub-1000 speeds with one decimal place', () => {
|
||||
expect(formatSpeed(42.54)).toBe('42.5 t/s');
|
||||
});
|
||||
|
||||
it('formats exact threshold values in k notation', () => {
|
||||
expect(formatSpeed(1000)).toBe('1.0k t/s');
|
||||
});
|
||||
|
||||
it('formats high speeds in k notation with one decimal place', () => {
|
||||
expect(formatSpeed(1250)).toBe('1.3k t/s');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
DEFAULT_SPEED_WINDOW_SECONDS,
|
||||
clampSpeedWindowSeconds,
|
||||
getWidgetSpeedWindowSeconds,
|
||||
isWidgetSpeedWindowEnabled,
|
||||
withWidgetSpeedWindowSeconds
|
||||
} from '../speed-window';
|
||||
|
||||
function createWidget(metadata?: Record<string, string>): WidgetItem {
|
||||
return {
|
||||
id: 'speed-widget',
|
||||
type: 'total-speed',
|
||||
metadata
|
||||
};
|
||||
}
|
||||
|
||||
describe('speed-window helpers', () => {
|
||||
it('clamps values to the supported range', () => {
|
||||
expect(clampSpeedWindowSeconds(-1)).toBe(0);
|
||||
expect(clampSpeedWindowSeconds(0)).toBe(0);
|
||||
expect(clampSpeedWindowSeconds(90)).toBe(90);
|
||||
expect(clampSpeedWindowSeconds(300)).toBe(120);
|
||||
});
|
||||
|
||||
it('returns default window seconds when metadata is missing or invalid', () => {
|
||||
expect(getWidgetSpeedWindowSeconds(createWidget())).toBe(DEFAULT_SPEED_WINDOW_SECONDS);
|
||||
expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: 'abc' }))).toBe(DEFAULT_SPEED_WINDOW_SECONDS);
|
||||
});
|
||||
|
||||
it('parses and clamps widget metadata window seconds', () => {
|
||||
expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: '45' }))).toBe(45);
|
||||
expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: '999' }))).toBe(120);
|
||||
});
|
||||
|
||||
it('stores clamped window seconds in metadata while preserving existing keys', () => {
|
||||
const updated = withWidgetSpeedWindowSeconds(createWidget({ keep: 'true' }), -3);
|
||||
expect(updated.metadata).toEqual({
|
||||
keep: 'true',
|
||||
windowSeconds: '0'
|
||||
});
|
||||
});
|
||||
|
||||
it('treats zero as disabled and positive values as enabled', () => {
|
||||
expect(isWidgetSpeedWindowEnabled(createWidget())).toBe(false);
|
||||
expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '0' }))).toBe(false);
|
||||
expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '30' }))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { execSync } from 'child_process';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
canDetectTerminalWidth,
|
||||
getTerminalWidth
|
||||
} from '../terminal';
|
||||
|
||||
vi.mock('child_process', () => ({ execSync: vi.fn() }));
|
||||
|
||||
describe('terminal utils', () => {
|
||||
const mockExecSync = execSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockImplementationOnce: (impl: () => never) => void;
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns width from tty probe when available', () => {
|
||||
mockExecSync.mockReturnValueOnce('ttys001\n');
|
||||
mockExecSync.mockReturnValueOnce('120\n');
|
||||
|
||||
expect(getTerminalWidth()).toBe(120);
|
||||
expect(mockExecSync.mock.calls[0]?.[0]).toContain('ps -o tty=');
|
||||
expect(mockExecSync.mock.calls[1]?.[0]).toContain('stty size < /dev/ttys001');
|
||||
});
|
||||
|
||||
it('falls back to tput cols when tty probe fails', () => {
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
|
||||
mockExecSync.mockReturnValueOnce('90\n');
|
||||
|
||||
expect(getTerminalWidth()).toBe(90);
|
||||
expect(mockExecSync.mock.calls[1]?.[0]).toBe('tput cols 2>/dev/null');
|
||||
});
|
||||
|
||||
it('returns null when width probes fail', () => {
|
||||
mockExecSync.mockReturnValueOnce('ttys001\n');
|
||||
mockExecSync.mockReturnValueOnce('not-a-number\n');
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
|
||||
|
||||
expect(getTerminalWidth()).toBeNull();
|
||||
});
|
||||
|
||||
it('detects availability when tty probe succeeds', () => {
|
||||
mockExecSync.mockReturnValueOnce('ttys001\n');
|
||||
mockExecSync.mockReturnValueOnce('80\n');
|
||||
|
||||
expect(canDetectTerminalWidth()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for availability when all probes fail', () => {
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
|
||||
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
|
||||
|
||||
expect(canDetectTerminalWidth()).toBe(false);
|
||||
});
|
||||
|
||||
it('disables width detection on Windows', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
||||
|
||||
expect(getTerminalWidth()).toBeNull();
|
||||
expect(canDetectTerminalWidth()).toBe(false);
|
||||
expect(mockExecSync.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
interface UsageProbeResult {
|
||||
first: Record<string, unknown>;
|
||||
second: Record<string, unknown>;
|
||||
lockExists: boolean;
|
||||
cacheExists: boolean;
|
||||
requestCount: number;
|
||||
proxyAgentConfigured: boolean;
|
||||
requestHost: string | null;
|
||||
lockContents: string | null;
|
||||
}
|
||||
|
||||
interface TokenHome {
|
||||
bin: string;
|
||||
claudeConfig: string;
|
||||
home: string;
|
||||
}
|
||||
|
||||
interface ProbeOptions {
|
||||
claudeConfigDir?: string;
|
||||
home: string;
|
||||
httpsProxy?: string;
|
||||
lowercaseHttpsProxy?: string;
|
||||
mode?: 'error' | 'status' | 'success' | 'unexpected';
|
||||
nowMs: number;
|
||||
pathDir?: string;
|
||||
responseBody?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
function createProbeHarness() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-'));
|
||||
const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs');
|
||||
const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url));
|
||||
|
||||
const probeScript = `
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const https = require('https');
|
||||
const mode = process.env.TEST_REQUEST_MODE || 'success';
|
||||
const responseBody = process.env.TEST_RESPONSE_BODY || '';
|
||||
const responseHeaders = JSON.parse(process.env.TEST_RESPONSE_HEADERS_JSON || '{}');
|
||||
const statusCode = Number(process.env.TEST_STATUS_CODE || (mode === 'success' ? '200' : '500'));
|
||||
let requestCount = 0;
|
||||
let proxyAgentConfigured = false;
|
||||
let requestHost = null;
|
||||
|
||||
https.request = (...args) => {
|
||||
requestCount += 1;
|
||||
const callback = args.find(value => typeof value === 'function');
|
||||
const options = args.find(value => value && typeof value === 'object' && !Buffer.isBuffer(value));
|
||||
proxyAgentConfigured = Boolean(options?.agent);
|
||||
requestHost = options?.hostname ?? null;
|
||||
const requestHandlers = new Map();
|
||||
const responseHandlers = new Map();
|
||||
|
||||
const response = {
|
||||
headers: responseHeaders,
|
||||
statusCode,
|
||||
setEncoding() {},
|
||||
on(event, handler) {
|
||||
const existing = responseHandlers.get(event) || [];
|
||||
existing.push(handler);
|
||||
responseHandlers.set(event, existing);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
const request = {
|
||||
on(event, handler) {
|
||||
const existing = requestHandlers.get(event) || [];
|
||||
existing.push(handler);
|
||||
requestHandlers.set(event, existing);
|
||||
return request;
|
||||
},
|
||||
destroy() {},
|
||||
end() {
|
||||
if (mode === 'error') {
|
||||
const handlers = requestHandlers.get('error') || [];
|
||||
for (const handler of handlers) {
|
||||
handler(new Error('mock request failure'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'unexpected') {
|
||||
const handlers = requestHandlers.get('error') || [];
|
||||
for (const handler of handlers) {
|
||||
handler(new Error('unexpected request'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(response);
|
||||
}
|
||||
|
||||
if (responseBody !== '') {
|
||||
const dataHandlers = responseHandlers.get('data') || [];
|
||||
for (const handler of dataHandlers) {
|
||||
handler(responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
const endHandlers = responseHandlers.get('end') || [];
|
||||
for (const handler of endHandlers) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
const { fetchUsageData } = await import(${JSON.stringify(usageModulePath)});
|
||||
|
||||
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock');
|
||||
const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json');
|
||||
const nowMs = Number(process.env.TEST_NOW_MS || Date.now());
|
||||
Date.now = () => nowMs;
|
||||
|
||||
const first = await fetchUsageData();
|
||||
const second = await fetchUsageData();
|
||||
process.stdout.write(JSON.stringify({
|
||||
first,
|
||||
second,
|
||||
lockExists: fs.existsSync(lockFile),
|
||||
cacheExists: fs.existsSync(cacheFile),
|
||||
requestCount,
|
||||
proxyAgentConfigured,
|
||||
requestHost,
|
||||
lockContents: fs.existsSync(lockFile) ? fs.readFileSync(lockFile, 'utf8') : null
|
||||
}));
|
||||
`;
|
||||
|
||||
fs.writeFileSync(probeScriptPath, probeScript);
|
||||
|
||||
function createEmptyHome(name: string): { home: string } {
|
||||
const home = path.join(tempRoot, `home-${name}`);
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
return { home };
|
||||
}
|
||||
|
||||
function createTokenHome(name: string): TokenHome {
|
||||
const home = path.join(tempRoot, `home-${name}`);
|
||||
const bin = path.join(tempRoot, `bin-${name}`);
|
||||
const claudeConfig = path.join(tempRoot, `claude-${name}`);
|
||||
const securityScript = path.join(bin, 'security');
|
||||
const credentialsFile = path.join(claudeConfig, '.credentials.json');
|
||||
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
fs.mkdirSync(claudeConfig, { recursive: true });
|
||||
|
||||
fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
|
||||
fs.chmodSync(securityScript, 0o755);
|
||||
fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));
|
||||
|
||||
return {
|
||||
bin,
|
||||
claudeConfig,
|
||||
home
|
||||
};
|
||||
}
|
||||
|
||||
function runProbe(options: ProbeOptions): UsageProbeResult {
|
||||
const output = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: options.home,
|
||||
PATH: options.pathDir ?? '/nonexistent',
|
||||
TEST_NOW_MS: String(options.nowMs),
|
||||
TEST_REQUEST_MODE: options.mode ?? 'success',
|
||||
TEST_RESPONSE_BODY: options.responseBody ?? '',
|
||||
TEST_RESPONSE_HEADERS_JSON: JSON.stringify(options.responseHeaders ?? {}),
|
||||
TEST_STATUS_CODE: String(options.statusCode ?? (options.mode === 'success' ? 200 : 500)),
|
||||
...(options.claudeConfigDir ? { CLAUDE_CONFIG_DIR: options.claudeConfigDir } : {}),
|
||||
...(options.httpsProxy !== undefined ? { HTTPS_PROXY: options.httpsProxy } : {}),
|
||||
...(options.lowercaseHttpsProxy !== undefined ? { https_proxy: options.lowercaseHttpsProxy } : {})
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.parse(output) as UsageProbeResult;
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
createEmptyHome,
|
||||
createTokenHome,
|
||||
runProbe
|
||||
};
|
||||
}
|
||||
|
||||
function parseLockContents(lockContents: string | null): { blockedUntil: number; error?: string } | null {
|
||||
return lockContents ? JSON.parse(lockContents) as { blockedUntil: number; error?: string } : null;
|
||||
}
|
||||
|
||||
describe('fetchUsageData error handling', () => {
|
||||
const nowMs = 2200000000000;
|
||||
const successResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 17,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
const updatedSuccessResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 55,
|
||||
resets_at: '2030-01-02T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 21,
|
||||
resets_at: '2030-01-08T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
const rateLimitedResponseBody = JSON.stringify({
|
||||
error: {
|
||||
message: 'Rate limited. Please try again later.',
|
||||
type: 'rate_limit_error'
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves root errors within a process and keeps existing proxy and cache behavior', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const noCredentialsHome = harness.createEmptyHome('no-credentials');
|
||||
const apiErrorHome = harness.createTokenHome('api-error');
|
||||
const successHome = harness.createTokenHome('success');
|
||||
const invalidProxyHome = harness.createTokenHome('invalid-proxy');
|
||||
const proxyHome = harness.createTokenHome('proxy');
|
||||
const blankProxyHome = harness.createTokenHome('blank-proxy');
|
||||
const lowercaseProxyHome = harness.createTokenHome('lowercase-proxy');
|
||||
|
||||
const noCredentialsResult = harness.runProbe({
|
||||
home: noCredentialsHome.home,
|
||||
mode: 'unexpected',
|
||||
nowMs
|
||||
});
|
||||
|
||||
expect(noCredentialsResult.first).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.second).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.lockExists).toBe(false);
|
||||
expect(noCredentialsResult.cacheExists).toBe(false);
|
||||
expect(noCredentialsResult.requestCount).toBe(0);
|
||||
expect(noCredentialsResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const apiErrorResult = harness.runProbe({
|
||||
claudeConfigDir: apiErrorHome.claudeConfig,
|
||||
home: apiErrorHome.home,
|
||||
mode: 'error',
|
||||
nowMs,
|
||||
pathDir: apiErrorHome.bin
|
||||
});
|
||||
|
||||
expect(apiErrorResult.first).toEqual({ error: 'api-error' });
|
||||
expect(apiErrorResult.second).toEqual({ error: 'api-error' });
|
||||
expect(apiErrorResult.cacheExists).toBe(false);
|
||||
expect(apiErrorResult.requestCount).toBe(1);
|
||||
expect(apiErrorResult.proxyAgentConfigured).toBe(false);
|
||||
expect(apiErrorResult.requestHost).toBe('api.anthropic.com');
|
||||
expect(parseLockContents(apiErrorResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(nowMs / 1000) + 30,
|
||||
error: 'timeout'
|
||||
});
|
||||
|
||||
const genericLockResult = harness.runProbe({
|
||||
claudeConfigDir: apiErrorHome.claudeConfig,
|
||||
home: apiErrorHome.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: apiErrorHome.bin
|
||||
});
|
||||
|
||||
expect(genericLockResult.first).toEqual({ error: 'timeout' });
|
||||
expect(genericLockResult.second).toEqual({ error: 'timeout' });
|
||||
expect(genericLockResult.requestCount).toBe(0);
|
||||
|
||||
const successResult = harness.runProbe({
|
||||
claudeConfigDir: successHome.claudeConfig,
|
||||
home: successHome.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: successHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(successResult.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z'
|
||||
});
|
||||
expect(successResult.second).toEqual(successResult.first);
|
||||
expect(successResult.cacheExists).toBe(true);
|
||||
expect(successResult.requestCount).toBe(1);
|
||||
expect(successResult.proxyAgentConfigured).toBe(false);
|
||||
expect(successResult.requestHost).toBe('api.anthropic.com');
|
||||
|
||||
const httpsProxyResult = harness.runProbe({
|
||||
claudeConfigDir: proxyHome.claudeConfig,
|
||||
home: proxyHome.home,
|
||||
httpsProxy: 'http://proxy.local:8080',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: proxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(httpsProxyResult.first).toEqual(successResult.first);
|
||||
expect(httpsProxyResult.second).toEqual(successResult.first);
|
||||
expect(httpsProxyResult.requestCount).toBe(1);
|
||||
expect(httpsProxyResult.proxyAgentConfigured).toBe(true);
|
||||
expect(httpsProxyResult.requestHost).toBe('api.anthropic.com');
|
||||
|
||||
const lowercaseProxyResult = harness.runProbe({
|
||||
claudeConfigDir: lowercaseProxyHome.claudeConfig,
|
||||
home: lowercaseProxyHome.home,
|
||||
lowercaseHttpsProxy: 'http://proxy.local:8080',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: lowercaseProxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(lowercaseProxyResult.first).toEqual(successResult.first);
|
||||
expect(lowercaseProxyResult.second).toEqual(successResult.first);
|
||||
expect(lowercaseProxyResult.requestCount).toBe(1);
|
||||
expect(lowercaseProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const blankProxyResult = harness.runProbe({
|
||||
claudeConfigDir: blankProxyHome.claudeConfig,
|
||||
home: blankProxyHome.home,
|
||||
httpsProxy: ' ',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: blankProxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(blankProxyResult.first).toEqual(successResult.first);
|
||||
expect(blankProxyResult.second).toEqual(successResult.first);
|
||||
expect(blankProxyResult.requestCount).toBe(1);
|
||||
expect(blankProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const invalidProxyResult = harness.runProbe({
|
||||
claudeConfigDir: invalidProxyHome.claudeConfig,
|
||||
home: invalidProxyHome.home,
|
||||
httpsProxy: '://bad-proxy',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: invalidProxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(invalidProxyResult.first).toEqual({ error: 'api-error' });
|
||||
expect(invalidProxyResult.second).toEqual({ error: 'api-error' });
|
||||
expect(invalidProxyResult.requestCount).toBe(0);
|
||||
expect(invalidProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const staleProxyResult = harness.runProbe({
|
||||
claudeConfigDir: successHome.claudeConfig,
|
||||
home: successHome.home,
|
||||
httpsProxy: '://bad-proxy',
|
||||
mode: 'success',
|
||||
nowMs: nowMs + 181000,
|
||||
pathDir: successHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(staleProxyResult.first).toEqual(successResult.first);
|
||||
expect(staleProxyResult.second).toEqual(successResult.first);
|
||||
expect(staleProxyResult.requestCount).toBe(0);
|
||||
expect(staleProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const cachedSuccessResult = harness.runProbe({
|
||||
claudeConfigDir: successHome.claudeConfig,
|
||||
home: successHome.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: successHome.bin
|
||||
});
|
||||
|
||||
expect(cachedSuccessResult.first).toEqual(successResult.first);
|
||||
expect(cachedSuccessResult.second).toEqual(successResult.first);
|
||||
expect(cachedSuccessResult.cacheExists).toBe(true);
|
||||
expect(cachedSuccessResult.requestCount).toBe(0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses stale cached data during a numeric Retry-After backoff and retries after expiry', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('rate-limited-with-cache');
|
||||
const rateLimitNowMs = nowMs + 31000;
|
||||
const successResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
const rateLimitedResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs: rateLimitNowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': '3600' },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(rateLimitedResult.first).toEqual(successResult.first);
|
||||
expect(rateLimitedResult.second).toEqual(successResult.first);
|
||||
expect(rateLimitedResult.requestCount).toBe(1);
|
||||
expect(parseLockContents(rateLimitedResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(rateLimitNowMs / 1000) + 3600,
|
||||
error: 'rate-limited'
|
||||
});
|
||||
|
||||
const activeBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: rateLimitNowMs + 600000,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(activeBackoffResult.first).toEqual(successResult.first);
|
||||
expect(activeBackoffResult.second).toEqual(successResult.first);
|
||||
expect(activeBackoffResult.requestCount).toBe(0);
|
||||
|
||||
const postBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs: rateLimitNowMs + 3601000,
|
||||
pathDir: home.bin,
|
||||
responseBody: updatedSuccessResponseBody
|
||||
});
|
||||
|
||||
expect(postBackoffResult.first).toEqual({
|
||||
sessionUsage: 55,
|
||||
sessionResetAt: '2030-01-02T00:00:00.000Z',
|
||||
weeklyUsage: 21,
|
||||
weeklyResetAt: '2030-01-08T00:00:00.000Z'
|
||||
});
|
||||
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
|
||||
expect(postBackoffResult.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns rate-limited without stale cache and falls back to the default backoff when Retry-After is invalid', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('rate-limited-no-cache');
|
||||
const firstRateLimitedResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': 'not-a-number' },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(firstRateLimitedResult.first).toEqual({ error: 'rate-limited' });
|
||||
expect(firstRateLimitedResult.second).toEqual({ error: 'rate-limited' });
|
||||
expect(firstRateLimitedResult.requestCount).toBe(1);
|
||||
expect(parseLockContents(firstRateLimitedResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(nowMs / 1000) + 300,
|
||||
error: 'rate-limited'
|
||||
});
|
||||
|
||||
const activeBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: nowMs + 299000,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(activeBackoffResult.first).toEqual({ error: 'rate-limited' });
|
||||
expect(activeBackoffResult.second).toEqual({ error: 'rate-limited' });
|
||||
expect(activeBackoffResult.requestCount).toBe(0);
|
||||
|
||||
const postBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs: nowMs + 301000,
|
||||
pathDir: home.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(postBackoffResult.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z'
|
||||
});
|
||||
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
|
||||
expect(postBackoffResult.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('parses HTTP-date Retry-After headers', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('rate-limited-http-date');
|
||||
const retryAt = new Date(nowMs + 900000).toUTCString();
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': retryAt },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({ error: 'rate-limited' });
|
||||
expect(result.second).toEqual({ error: 'rate-limited' });
|
||||
expect(parseLockContents(result.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor((nowMs + 900000) / 1000),
|
||||
error: 'rate-limited'
|
||||
});
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports the legacy empty lock file fallback', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('legacy-lock');
|
||||
const lockDir = path.join(home.home, '.cache', 'ccstatusline');
|
||||
const lockFile = path.join(lockDir, 'usage.lock');
|
||||
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(lockFile, '');
|
||||
fs.utimesSync(lockFile, new Date(nowMs), new Date(nowMs));
|
||||
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({ error: 'timeout' });
|
||||
expect(result.second).toEqual({ error: 'timeout' });
|
||||
expect(result.requestCount).toBe(0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import * as usage from '../usage';
|
||||
import {
|
||||
hasUsageDependentWidgets,
|
||||
prefetchUsageDataIfNeeded
|
||||
} from '../usage-prefetch';
|
||||
import type { UsageData } from '../usage-types';
|
||||
|
||||
function makeLines(...lineItems: WidgetItem[][]): WidgetItem[][] {
|
||||
return lineItems;
|
||||
}
|
||||
|
||||
describe('usage prefetch', () => {
|
||||
let mockFetchUsageData: {
|
||||
mock: { calls: unknown[][] };
|
||||
mockResolvedValue: (value: UsageData) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockFetchUsageData = vi.spyOn(usage, 'fetchUsageData');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
expected: true,
|
||||
lines: makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'block-timer' }]
|
||||
),
|
||||
name: 'detects when usage widgets are present'
|
||||
},
|
||||
{
|
||||
expected: false,
|
||||
lines: makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'git-branch' }]
|
||||
),
|
||||
name: 'does not detect usage requirement for non-usage widgets'
|
||||
}
|
||||
])('$name', ({ expected, lines }) => {
|
||||
expect(hasUsageDependentWidgets(lines)).toBe(expected);
|
||||
});
|
||||
|
||||
it('fetches usage data once when at least one usage widget exists', async () => {
|
||||
mockFetchUsageData.mockResolvedValue({ sessionUsage: 12.3 });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'session-usage' }, { id: '3', type: 'weekly-usage' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines);
|
||||
|
||||
expect(usageData).toEqual({ sessionUsage: 12.3 });
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not fetch usage data when no usage widgets exist', async () => {
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'git-branch' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines);
|
||||
|
||||
expect(usageData).toBeNull();
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getUsageErrorMessage } from '../usage-windows';
|
||||
|
||||
describe('getUsageErrorMessage', () => {
|
||||
it('returns the rate-limited label', () => {
|
||||
expect(getUsageErrorMessage('rate-limited')).toBe('[Rate limited]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { BlockMetrics } from '../../types';
|
||||
import * as jsonl from '../jsonl';
|
||||
import {
|
||||
FIVE_HOUR_BLOCK_MS,
|
||||
SEVEN_DAY_WINDOW_MS
|
||||
} from '../usage-types';
|
||||
import {
|
||||
formatUsageDuration,
|
||||
getUsageWindowFromResetAt,
|
||||
getWeeklyUsageWindowFromResetAt,
|
||||
resolveUsageWindowWithFallback,
|
||||
resolveWeeklyUsageWindow
|
||||
} from '../usage-windows';
|
||||
|
||||
describe('usage window helpers', () => {
|
||||
let mockGetCachedBlockMetrics: {
|
||||
mock: { calls: unknown[][] };
|
||||
mockReturnValue: (value: BlockMetrics | null) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockGetCachedBlockMetrics = vi.spyOn(jsonl, 'getCachedBlockMetrics');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('parses usage reset timestamp into elapsed and remaining metrics', () => {
|
||||
const nowMs = Date.parse('2026-03-02T20:00:00.000Z');
|
||||
const resetAt = '2026-03-02T22:00:00.000Z';
|
||||
|
||||
const window = getUsageWindowFromResetAt(resetAt, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(3 * 60 * 60 * 1000);
|
||||
expect(window?.remainingMs).toBe(2 * 60 * 60 * 1000);
|
||||
expect(window?.elapsedPercent).toBeCloseTo(60, 5);
|
||||
expect(window?.remainingPercent).toBeCloseTo(40, 5);
|
||||
expect(window?.sessionDurationMs).toBe(FIVE_HOUR_BLOCK_MS);
|
||||
});
|
||||
|
||||
it('uses usage data first and does not parse JSONL when reset timestamp exists', () => {
|
||||
const nowMs = Date.parse('2026-03-02T20:00:00.000Z');
|
||||
const fallbackMetrics: BlockMetrics = {
|
||||
startTime: new Date('2026-03-02T15:00:00.000Z'),
|
||||
lastActivity: new Date('2026-03-02T20:00:00.000Z')
|
||||
};
|
||||
|
||||
mockGetCachedBlockMetrics.mockReturnValue(fallbackMetrics);
|
||||
|
||||
const window = resolveUsageWindowWithFallback({ sessionResetAt: '2026-03-02T22:00:00.000Z' }, undefined, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(3 * 60 * 60 * 1000);
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('uses provided block metrics fallback without parsing JSONL', () => {
|
||||
const nowMs = Date.parse('2026-03-02T18:30:00.000Z');
|
||||
const providedMetrics: BlockMetrics = {
|
||||
startTime: new Date('2026-03-02T15:00:00.000Z'),
|
||||
lastActivity: new Date('2026-03-02T18:30:00.000Z')
|
||||
};
|
||||
|
||||
const window = resolveUsageWindowWithFallback({}, providedMetrics, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(3.5 * 60 * 60 * 1000);
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('parses JSONL fallback only when usage reset data is missing', () => {
|
||||
const nowMs = Date.parse('2026-03-02T18:00:00.000Z');
|
||||
const fallbackMetrics: BlockMetrics = {
|
||||
startTime: new Date('2026-03-02T15:00:00.000Z'),
|
||||
lastActivity: new Date('2026-03-02T18:00:00.000Z')
|
||||
};
|
||||
|
||||
mockGetCachedBlockMetrics.mockReturnValue(fallbackMetrics);
|
||||
|
||||
const window = resolveUsageWindowWithFallback({}, undefined, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(3 * 60 * 60 * 1000);
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null when neither usage reset data nor JSONL fallback is available', () => {
|
||||
mockGetCachedBlockMetrics.mockReturnValue(null);
|
||||
|
||||
const window = resolveUsageWindowWithFallback({}, undefined, Date.now());
|
||||
|
||||
expect(window).toBeNull();
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('parses weekly reset timestamp into elapsed and remaining metrics', () => {
|
||||
const nowMs = Date.parse('2026-03-04T20:00:00.000Z');
|
||||
const resetAt = '2026-03-09T20:00:00.000Z';
|
||||
|
||||
const window = getWeeklyUsageWindowFromResetAt(resetAt, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(2 * 24 * 60 * 60 * 1000);
|
||||
expect(window?.remainingMs).toBe(5 * 24 * 60 * 60 * 1000);
|
||||
expect(window?.elapsedPercent).toBeCloseTo((2 / 7) * 100, 5);
|
||||
expect(window?.remainingPercent).toBeCloseTo((5 / 7) * 100, 5);
|
||||
expect(window?.sessionDurationMs).toBe(SEVEN_DAY_WINDOW_MS);
|
||||
});
|
||||
|
||||
it('returns null for missing or invalid weekly reset timestamps', () => {
|
||||
expect(getWeeklyUsageWindowFromResetAt(undefined, Date.now())).toBeNull();
|
||||
expect(getWeeklyUsageWindowFromResetAt('not-a-date', Date.now())).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves weekly window directly from usage data without JSONL fallback', () => {
|
||||
const nowMs = Date.parse('2026-03-04T20:00:00.000Z');
|
||||
const window = resolveWeeklyUsageWindow({ weeklyResetAt: '2026-03-09T20:00:00.000Z' }, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.remainingMs).toBe(5 * 24 * 60 * 60 * 1000);
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('formats duration in block timer style', () => {
|
||||
expect(formatUsageDuration(0)).toBe('0hr');
|
||||
expect(formatUsageDuration(3 * 60 * 60 * 1000)).toBe('3hr');
|
||||
expect(formatUsageDuration(3.5 * 60 * 60 * 1000)).toBe('3hr 30m');
|
||||
expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000)).toBe('4hr 5m');
|
||||
});
|
||||
|
||||
it('formats duration in compact style', () => {
|
||||
expect(formatUsageDuration(0, true)).toBe('0h');
|
||||
expect(formatUsageDuration(3 * 60 * 60 * 1000, true)).toBe('3h');
|
||||
expect(formatUsageDuration(3.5 * 60 * 60 * 1000, true)).toBe('3h30m');
|
||||
expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000, true)).toBe('4h5m');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type Settings
|
||||
} from '../../types/Settings';
|
||||
import type { WidgetItemType } from '../../types/Widget';
|
||||
import {
|
||||
filterWidgetCatalog,
|
||||
getAllWidgetTypes,
|
||||
getWidget,
|
||||
getWidgetCatalog,
|
||||
getWidgetCatalogCategories,
|
||||
isKnownWidgetType,
|
||||
type WidgetCatalogEntry
|
||||
} from '../widgets';
|
||||
|
||||
describe('widget catalog', () => {
|
||||
const baseSettings: Settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: { ...DEFAULT_SETTINGS.powerline }
|
||||
};
|
||||
|
||||
it('builds catalog entries with categories from widget definitions', () => {
|
||||
const catalog = getWidgetCatalog(baseSettings);
|
||||
|
||||
const model = catalog.find(entry => entry.type === 'model');
|
||||
const separator = catalog.find(entry => entry.type === 'separator');
|
||||
const link = catalog.find(entry => entry.type === 'link');
|
||||
const gitInsertions = catalog.find(entry => entry.type === 'git-insertions');
|
||||
const gitDeletions = catalog.find(entry => entry.type === 'git-deletions');
|
||||
const inputSpeed = catalog.find(entry => entry.type === 'input-speed');
|
||||
const outputSpeed = catalog.find(entry => entry.type === 'output-speed');
|
||||
const totalSpeed = catalog.find(entry => entry.type === 'total-speed');
|
||||
const resetTimer = catalog.find(entry => entry.type === 'reset-timer');
|
||||
const weeklyResetTimer = catalog.find(entry => entry.type === 'weekly-reset-timer');
|
||||
|
||||
expect(model?.displayName).toBe('Model');
|
||||
expect(model?.category).toBe('Core');
|
||||
expect(separator?.displayName).toBe('Separator');
|
||||
expect(separator?.category).toBe('Layout');
|
||||
expect(link?.displayName).toBe('Link');
|
||||
expect(link?.category).toBe('Custom');
|
||||
expect(gitInsertions?.displayName).toBe('Git Insertions');
|
||||
expect(gitInsertions?.category).toBe('Git');
|
||||
expect(gitDeletions?.displayName).toBe('Git Deletions');
|
||||
expect(gitDeletions?.category).toBe('Git');
|
||||
expect(inputSpeed?.displayName).toBe('Input Speed');
|
||||
expect(inputSpeed?.category).toBe('Token Speed');
|
||||
expect(outputSpeed?.displayName).toBe('Output Speed');
|
||||
expect(outputSpeed?.category).toBe('Token Speed');
|
||||
expect(totalSpeed?.displayName).toBe('Total Speed');
|
||||
expect(totalSpeed?.category).toBe('Token Speed');
|
||||
expect(resetTimer?.displayName).toBe('Block Reset Timer');
|
||||
expect(resetTimer?.category).toBe('Usage');
|
||||
expect(weeklyResetTimer?.displayName).toBe('Weekly Reset Timer');
|
||||
expect(weeklyResetTimer?.category).toBe('Usage');
|
||||
});
|
||||
|
||||
it('hides manual separator when default separator is configured', () => {
|
||||
const catalog = getWidgetCatalog({
|
||||
...baseSettings,
|
||||
defaultSeparator: '|'
|
||||
});
|
||||
|
||||
const types = new Set(catalog.map(entry => entry.type));
|
||||
expect(types.has('separator')).toBe(false);
|
||||
expect(types.has('flex-separator')).toBe(true);
|
||||
});
|
||||
|
||||
it('hides both separator types in powerline mode', () => {
|
||||
const catalog = getWidgetCatalog({
|
||||
...baseSettings,
|
||||
powerline: {
|
||||
...baseSettings.powerline,
|
||||
enabled: true
|
||||
}
|
||||
});
|
||||
|
||||
const types = new Set(catalog.map(entry => entry.type));
|
||||
expect(types.has('separator')).toBe(false);
|
||||
expect(types.has('flex-separator')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns unique categories in discovery order', () => {
|
||||
const categories = getWidgetCatalogCategories(getWidgetCatalog(baseSettings));
|
||||
|
||||
expect(categories).toContain('Core');
|
||||
expect(categories).toContain('Git');
|
||||
expect(categories).toContain('Context');
|
||||
expect(categories).toContain('Tokens');
|
||||
expect(categories).toContain('Token Speed');
|
||||
expect(categories).toContain('Session');
|
||||
expect(categories).toContain('Usage');
|
||||
expect(categories).toContain('Environment');
|
||||
expect(categories).toContain('Custom');
|
||||
expect(categories).toContain('Layout');
|
||||
});
|
||||
|
||||
it('returns runtime widget instances for non-layout widget types', () => {
|
||||
const runtimeTypes = getAllWidgetTypes(baseSettings).filter(
|
||||
type => type !== 'separator' && type !== 'flex-separator'
|
||||
);
|
||||
|
||||
for (const type of runtimeTypes) {
|
||||
const widget = getWidget(type);
|
||||
expect(widget).not.toBeNull();
|
||||
expect(widget?.getDisplayName().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('recognizes known widget and layout types', () => {
|
||||
expect(isKnownWidgetType('model')).toBe(true);
|
||||
expect(isKnownWidgetType('separator')).toBe(true);
|
||||
expect(isKnownWidgetType('flex-separator')).toBe(true);
|
||||
expect(isKnownWidgetType('unknown-widget-type')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('widget catalog filtering', () => {
|
||||
const catalog = getWidgetCatalog({
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: { ...DEFAULT_SETTINGS.powerline }
|
||||
});
|
||||
|
||||
it('matches display name with case-insensitive partial search', () => {
|
||||
const results = filterWidgetCatalog(catalog, 'All', 'gIt br');
|
||||
expect(results[0]?.type).toBe('git-branch');
|
||||
});
|
||||
|
||||
it('matches type string search such as git-worktree', () => {
|
||||
const results = filterWidgetCatalog(catalog, 'All', 'git-worktree');
|
||||
expect(results[0]?.type).toBe('git-worktree');
|
||||
});
|
||||
|
||||
it('matches description search', () => {
|
||||
const results = filterWidgetCatalog(catalog, 'All', 'working directory');
|
||||
expect(results.some(entry => entry.type === 'current-working-dir')).toBe(true);
|
||||
});
|
||||
|
||||
it('applies category and query filters together', () => {
|
||||
const results = filterWidgetCatalog(catalog, 'Git', 'context');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('prioritizes name match before type and description matches', () => {
|
||||
const rankingCatalog: WidgetCatalogEntry[] = [
|
||||
{
|
||||
type: 'alpha' as WidgetItemType,
|
||||
displayName: 'Git Branch',
|
||||
description: 'Primary match',
|
||||
category: 'Core',
|
||||
searchText: 'git branch primary match alpha'
|
||||
},
|
||||
{
|
||||
type: 'git-type-only' as WidgetItemType,
|
||||
displayName: 'Branch',
|
||||
description: 'Type fallback match',
|
||||
category: 'Core',
|
||||
searchText: 'branch type fallback match git-type-only'
|
||||
},
|
||||
{
|
||||
type: 'desc-only' as WidgetItemType,
|
||||
displayName: 'Branch',
|
||||
description: 'Description contains git',
|
||||
category: 'Core',
|
||||
searchText: 'branch description contains git desc-only'
|
||||
}
|
||||
];
|
||||
|
||||
const results = filterWidgetCatalog(rankingCatalog, 'All', 'git');
|
||||
expect(results.map(entry => entry.type)).toEqual(['alpha', 'git-type-only', 'desc-only']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import stringWidth from 'string-width';
|
||||
|
||||
const ESC = '\x1b';
|
||||
const BEL = '\x07';
|
||||
const C1_CSI = '\x9b';
|
||||
const C1_OSC = '\x9d';
|
||||
const ST = '\x9c';
|
||||
|
||||
const SGR_REGEX = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
type Osc8Action = 'open' | 'close';
|
||||
type OscTerminator = 'bel' | 'st';
|
||||
|
||||
interface ParsedEscapeSequence {
|
||||
nextIndex: number;
|
||||
sequence: string;
|
||||
osc8Action?: Osc8Action;
|
||||
osc8Terminator?: OscTerminator;
|
||||
}
|
||||
|
||||
function isCsiFinalByte(codePoint: number): boolean {
|
||||
return codePoint >= 0x40 && codePoint <= 0x7e;
|
||||
}
|
||||
|
||||
function parseCsi(input: string, start: number, bodyStart: number): ParsedEscapeSequence {
|
||||
let index = bodyStart;
|
||||
while (index < input.length) {
|
||||
const codePoint = input.charCodeAt(index);
|
||||
if (isCsiFinalByte(codePoint)) {
|
||||
const end = index + 1;
|
||||
return {
|
||||
nextIndex: end,
|
||||
sequence: input.slice(start, end)
|
||||
};
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return {
|
||||
nextIndex: input.length,
|
||||
sequence: input.slice(start)
|
||||
};
|
||||
}
|
||||
|
||||
function getOsc8Action(body: string): Osc8Action | undefined {
|
||||
if (!body.startsWith('8;')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const urlStart = body.indexOf(';', 2);
|
||||
if (urlStart === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = body.slice(urlStart + 1);
|
||||
return url.length > 0 ? 'open' : 'close';
|
||||
}
|
||||
|
||||
function parseOsc(
|
||||
input: string,
|
||||
start: number,
|
||||
bodyStart: number
|
||||
): ParsedEscapeSequence {
|
||||
let index = bodyStart;
|
||||
|
||||
while (index < input.length) {
|
||||
const current = input[index];
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (current === BEL) {
|
||||
const end = index + 1;
|
||||
const body = input.slice(bodyStart, index);
|
||||
return {
|
||||
nextIndex: end,
|
||||
sequence: input.slice(start, end),
|
||||
osc8Action: getOsc8Action(body),
|
||||
osc8Terminator: 'bel'
|
||||
};
|
||||
}
|
||||
|
||||
if (current === ST) {
|
||||
const end = index + 1;
|
||||
const body = input.slice(bodyStart, index);
|
||||
return {
|
||||
nextIndex: end,
|
||||
sequence: input.slice(start, end),
|
||||
osc8Action: getOsc8Action(body),
|
||||
osc8Terminator: 'st'
|
||||
};
|
||||
}
|
||||
|
||||
if (current === ESC && input[index + 1] === '\\') {
|
||||
const end = index + 2;
|
||||
const body = input.slice(bodyStart, index);
|
||||
return {
|
||||
nextIndex: end,
|
||||
sequence: input.slice(start, end),
|
||||
osc8Action: getOsc8Action(body),
|
||||
osc8Terminator: 'st'
|
||||
};
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return {
|
||||
nextIndex: input.length,
|
||||
sequence: input.slice(start)
|
||||
};
|
||||
}
|
||||
|
||||
function parseEscapeSequence(input: string, index: number): ParsedEscapeSequence | null {
|
||||
const current = input[index];
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current === ESC) {
|
||||
const next = input[index + 1];
|
||||
if (next === '[') {
|
||||
return parseCsi(input, index, index + 2);
|
||||
}
|
||||
if (next === ']') {
|
||||
return parseOsc(input, index, index + 2);
|
||||
}
|
||||
if (next) {
|
||||
return {
|
||||
nextIndex: index + 2,
|
||||
sequence: input.slice(index, index + 2)
|
||||
};
|
||||
}
|
||||
return {
|
||||
nextIndex: input.length,
|
||||
sequence: current
|
||||
};
|
||||
}
|
||||
|
||||
if (current === C1_CSI) {
|
||||
return parseCsi(input, index, index + 1);
|
||||
}
|
||||
|
||||
if (current === C1_OSC) {
|
||||
return parseOsc(input, index, index + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOsc8CloseSequence(terminator: OscTerminator): string {
|
||||
if (terminator === 'bel') {
|
||||
return `${ESC}]8;;${BEL}`;
|
||||
}
|
||||
return `${ESC}]8;;${ESC}\\`;
|
||||
}
|
||||
|
||||
export function stripSgrCodes(text: string): string {
|
||||
return text.replace(SGR_REGEX, '');
|
||||
}
|
||||
|
||||
export function getVisibleText(text: string): string {
|
||||
let result = '';
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
const escape = parseEscapeSequence(text, index);
|
||||
if (escape) {
|
||||
index = escape.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const codePoint = text.codePointAt(index);
|
||||
if (codePoint === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const character = String.fromCodePoint(codePoint);
|
||||
result += character;
|
||||
index += character.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getVisibleWidth(text: string): number {
|
||||
return stringWidth(getVisibleText(text));
|
||||
}
|
||||
|
||||
interface TruncateOptions { ellipsis?: boolean }
|
||||
|
||||
export function truncateStyledText(
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
options: TruncateOptions = {}
|
||||
): string {
|
||||
if (maxWidth <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (getVisibleWidth(text) <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const addEllipsis = options.ellipsis ?? true;
|
||||
const ellipsis = addEllipsis ? '...' : '';
|
||||
const ellipsisWidth = addEllipsis ? stringWidth(ellipsis) : 0;
|
||||
|
||||
if (addEllipsis && maxWidth <= ellipsisWidth) {
|
||||
return '.'.repeat(maxWidth);
|
||||
}
|
||||
|
||||
const targetWidth = Math.max(0, maxWidth - ellipsisWidth);
|
||||
let output = '';
|
||||
let currentWidth = 0;
|
||||
let index = 0;
|
||||
let didTruncate = false;
|
||||
let openOsc8Terminator: OscTerminator | null = null;
|
||||
|
||||
while (index < text.length) {
|
||||
const escape = parseEscapeSequence(text, index);
|
||||
if (escape) {
|
||||
output += escape.sequence;
|
||||
index = escape.nextIndex;
|
||||
|
||||
if (escape.osc8Action === 'open') {
|
||||
openOsc8Terminator = escape.osc8Terminator ?? 'st';
|
||||
} else if (escape.osc8Action === 'close') {
|
||||
openOsc8Terminator = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const codePoint = text.codePointAt(index);
|
||||
if (codePoint === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const character = String.fromCodePoint(codePoint);
|
||||
const charWidth = stringWidth(character);
|
||||
|
||||
if (currentWidth + charWidth > targetWidth) {
|
||||
didTruncate = true;
|
||||
break;
|
||||
}
|
||||
|
||||
output += character;
|
||||
currentWidth += charWidth;
|
||||
index += character.length;
|
||||
}
|
||||
|
||||
if (!didTruncate) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (openOsc8Terminator) {
|
||||
output += getOsc8CloseSequence(openOsc8Terminator);
|
||||
}
|
||||
|
||||
return output + ellipsis;
|
||||
}
|
||||
+151
-24
@@ -4,6 +4,15 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { ClaudeSettings } from '../types/ClaudeSettings';
|
||||
import {
|
||||
SettingsSchema,
|
||||
type Settings
|
||||
} from '../types/Settings';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
isCustomConfigPath
|
||||
} from './config';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { ClaudeSettings };
|
||||
@@ -19,6 +28,32 @@ export const CCSTATUSLINE_COMMANDS = {
|
||||
SELF_MANAGED: 'ccstatusline'
|
||||
};
|
||||
|
||||
export function isKnownCommand(command: string): boolean {
|
||||
const prefixes = [CCSTATUSLINE_COMMANDS.NPM, CCSTATUSLINE_COMMANDS.BUNX, CCSTATUSLINE_COMMANDS.SELF_MANAGED];
|
||||
return prefixes.some(prefix => command === prefix || command.startsWith(`${prefix} --config `));
|
||||
}
|
||||
|
||||
function needsQuoting(filePath: string): boolean {
|
||||
if (process.platform === 'win32') {
|
||||
// cmd.exe-safe set of characters that require quoting.
|
||||
return /[\s&()<>|^"]/.test(filePath);
|
||||
}
|
||||
|
||||
return /[\s()[\];&#|'"\\$`]/.test(filePath);
|
||||
}
|
||||
|
||||
function quotePathIfNeeded(filePath: string): string {
|
||||
if (!needsQuoting(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return `"${filePath.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return `'${filePath.replace(/'/g, '\'\\\'\'')}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the Claude config directory, checking CLAUDE_CONFIG_DIR environment variable first,
|
||||
* then falling back to the default ~/.claude directory.
|
||||
@@ -58,16 +93,44 @@ export function getClaudeSettingsPath(): string {
|
||||
return path.join(getClaudeConfigDir(), 'settings.json');
|
||||
}
|
||||
|
||||
export async function loadClaudeSettings(): Promise<ClaudeSettings> {
|
||||
/**
|
||||
* Creates a backup of the current Claude settings file.
|
||||
*/
|
||||
async function backupClaudeSettings(suffix = '.bak'): Promise<string | null> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const backupPath = settingsPath + suffix;
|
||||
try {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const content = await readFile(settingsPath, 'utf-8');
|
||||
await writeFile(backupPath, content, 'utf-8');
|
||||
return backupPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to backup Claude settings:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface LoadClaudeSettingsOptions { logErrors?: boolean }
|
||||
|
||||
export async function loadClaudeSettings(options: LoadClaudeSettingsOptions = {}): Promise<ClaudeSettings> {
|
||||
const { logErrors = true } = options;
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
|
||||
// File doesn't exist - return empty object
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(settingsPath, 'utf-8');
|
||||
return JSON.parse(content) as ClaudeSettings;
|
||||
} catch {
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (logErrors) {
|
||||
console.error('Failed to load Claude settings:', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,23 +139,26 @@ export async function saveClaudeSettings(
|
||||
): Promise<void> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
|
||||
// Backup settings before overwriting
|
||||
await backupClaudeSettings();
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function isInstalled(): Promise<boolean> {
|
||||
const settings = await loadClaudeSettings();
|
||||
// Check if command is either npx or bunx version AND padding is 0 (or undefined for new installs)
|
||||
const validCommands = [
|
||||
// Default autoinstalled npm command
|
||||
CCSTATUSLINE_COMMANDS.NPM,
|
||||
// Default autoinstalled bunx command
|
||||
CCSTATUSLINE_COMMANDS.BUNX,
|
||||
// Self managed installation command
|
||||
CCSTATUSLINE_COMMANDS.SELF_MANAGED
|
||||
];
|
||||
let settings: ClaudeSettings;
|
||||
|
||||
try {
|
||||
settings = await loadClaudeSettings({ logErrors: false });
|
||||
} catch {
|
||||
return false; // Can't determine if installed, assume not
|
||||
}
|
||||
const command = settings.statusLine?.command ?? '';
|
||||
|
||||
return (
|
||||
validCommands.includes(settings.statusLine?.command ?? '')
|
||||
isKnownCommand(command)
|
||||
&& (settings.statusLine?.padding === 0
|
||||
|| settings.statusLine?.padding === undefined)
|
||||
);
|
||||
@@ -109,31 +175,92 @@ export function isBunxAvailable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function buildCommand(baseCommand: string): string {
|
||||
if (isCustomConfigPath()) {
|
||||
return `${baseCommand} --config ${quotePathIfNeeded(getConfigPath())}`;
|
||||
}
|
||||
return baseCommand;
|
||||
}
|
||||
|
||||
async function loadSavedSettingsForHookSync(): Promise<Settings | null> {
|
||||
const configPath = getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
const result = SettingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installStatusLine(useBunx = false): Promise<void> {
|
||||
const settings = await loadClaudeSettings();
|
||||
let settings: ClaudeSettings;
|
||||
|
||||
const backupPath = await backupClaudeSettings('.orig');
|
||||
try {
|
||||
settings = await loadClaudeSettings({ logErrors: false });
|
||||
} catch {
|
||||
const fallbackBackupPath = `${getClaudeSettingsPath()}.orig`;
|
||||
console.error(`Warning: Could not read existing Claude settings. A backup exists at ${backupPath ?? fallbackBackupPath}.`);
|
||||
settings = {};
|
||||
}
|
||||
|
||||
const baseCommand = useBunx
|
||||
? CCSTATUSLINE_COMMANDS.BUNX
|
||||
: CCSTATUSLINE_COMMANDS.NPM;
|
||||
|
||||
// Update settings with our status line (confirmation already handled in TUI)
|
||||
settings.statusLine = {
|
||||
type: 'command',
|
||||
command: useBunx
|
||||
? CCSTATUSLINE_COMMANDS.BUNX
|
||||
: CCSTATUSLINE_COMMANDS.NPM,
|
||||
command: buildCommand(baseCommand),
|
||||
padding: 0
|
||||
};
|
||||
|
||||
await saveClaudeSettings(settings);
|
||||
|
||||
const savedSettings = await loadSavedSettingsForHookSync();
|
||||
if (savedSettings) {
|
||||
const { syncWidgetHooks } = await import('./hooks');
|
||||
await syncWidgetHooks(savedSettings);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallStatusLine(): Promise<void> {
|
||||
const settings = await loadClaudeSettings();
|
||||
let settings: ClaudeSettings;
|
||||
|
||||
try {
|
||||
settings = await loadClaudeSettings({ logErrors: false });
|
||||
} catch {
|
||||
console.error('Warning: Could not read existing Claude settings.');
|
||||
return; // if we can't read, return... what are we uninstalling?
|
||||
}
|
||||
|
||||
if (settings.statusLine) {
|
||||
delete settings.statusLine;
|
||||
await saveClaudeSettings(settings);
|
||||
}
|
||||
|
||||
try {
|
||||
const { removeManagedHooks } = await import('./hooks');
|
||||
await removeManagedHooks();
|
||||
} catch {
|
||||
// Ignore hook cleanup failures during uninstall
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExistingStatusLine(): Promise<string | null> {
|
||||
const settings = await loadClaudeSettings();
|
||||
return settings.statusLine?.command ?? null;
|
||||
try {
|
||||
const settings = await loadClaudeSettings({ logErrors: false });
|
||||
return settings.statusLine?.command ?? null;
|
||||
} catch {
|
||||
return null; // Can't read settings, return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Settings } from '../types/Settings';
|
||||
|
||||
export function cloneSettings(settings: Settings): Settings {
|
||||
const cloneFn = globalThis.structuredClone;
|
||||
if (typeof cloneFn === 'function') {
|
||||
return cloneFn(settings);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(settings)) as Settings;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { WidgetItem } from '../types/Widget';
|
||||
|
||||
import { getWidget } from './widgets';
|
||||
|
||||
function isCustomColor(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.startsWith('ansi256:') || value.startsWith('hex:');
|
||||
}
|
||||
|
||||
function isIncompatibleForLevel(value: string | undefined, nextLevel: 0 | 1 | 2 | 3): boolean {
|
||||
if (!isCustomColor(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextLevel === 2) {
|
||||
return Boolean(value?.startsWith('hex:'));
|
||||
}
|
||||
|
||||
if (nextLevel === 3) {
|
||||
return Boolean(value?.startsWith('ansi256:'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetWidgetForegroundToDefault(widget: WidgetItem, nextWidget: WidgetItem): WidgetItem {
|
||||
if (widget.type === 'separator' || widget.type === 'flex-separator') {
|
||||
return nextWidget;
|
||||
}
|
||||
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
if (!widgetImpl) {
|
||||
return nextWidget;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextWidget,
|
||||
color: widgetImpl.getDefaultColor()
|
||||
};
|
||||
}
|
||||
|
||||
export function hasCustomWidgetColors(lines: WidgetItem[][]): boolean {
|
||||
return lines.some(line => line.some(widget => isCustomColor(widget.color) || isCustomColor(widget.backgroundColor)));
|
||||
}
|
||||
|
||||
export function sanitizeLinesForColorLevel(lines: WidgetItem[][], nextLevel: 0 | 1 | 2 | 3): WidgetItem[][] {
|
||||
return lines.map(line => line.map((widget) => {
|
||||
let nextWidget: WidgetItem = { ...widget };
|
||||
|
||||
if (isIncompatibleForLevel(widget.color, nextLevel)) {
|
||||
nextWidget = resetWidgetForegroundToDefault(widget, nextWidget);
|
||||
}
|
||||
|
||||
if (isIncompatibleForLevel(widget.backgroundColor, nextLevel)) {
|
||||
nextWidget = {
|
||||
...nextWidget,
|
||||
backgroundColor: undefined
|
||||
};
|
||||
}
|
||||
|
||||
return nextWidget;
|
||||
}));
|
||||
}
|
||||
+42
-37
@@ -111,13 +111,13 @@ export function getChalkColor(colorName: string | undefined, colorLevel: 'ansi16
|
||||
}
|
||||
|
||||
switch (colorLevel) {
|
||||
case 'ansi256':
|
||||
return colorEntry.ansi256;
|
||||
case 'truecolor':
|
||||
return colorEntry.truecolor;
|
||||
case 'ansi16':
|
||||
default:
|
||||
return colorEntry.ansi16;
|
||||
case 'ansi256':
|
||||
return colorEntry.ansi256;
|
||||
case 'truecolor':
|
||||
return colorEntry.truecolor;
|
||||
case 'ansi16':
|
||||
default:
|
||||
return colorEntry.ansi16;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,31 +132,36 @@ export function applyColors(
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = text;
|
||||
// Use raw ANSI codes for precise reset sequencing.
|
||||
// This avoids style leakage (for example, bold affecting later widgets).
|
||||
let prefix = '';
|
||||
let suffix = '';
|
||||
|
||||
// Apply background color first
|
||||
// This ensures the background is properly established before other styles
|
||||
if (backgroundColor) {
|
||||
const bgChalk = getChalkColor(backgroundColor, colorLevel, true);
|
||||
if (bgChalk) {
|
||||
result = bgChalk(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply foreground color second
|
||||
if (foregroundColor) {
|
||||
const fgChalk = getChalkColor(foregroundColor, colorLevel, false);
|
||||
if (fgChalk) {
|
||||
result = fgChalk(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply bold last if needed
|
||||
// Apply bold first so it can be reset independently before color resets.
|
||||
if (bold) {
|
||||
result = chalk.bold(result);
|
||||
prefix += '\x1b[1m';
|
||||
suffix = '\x1b[22m' + suffix;
|
||||
}
|
||||
|
||||
return result;
|
||||
// Apply background color
|
||||
if (backgroundColor) {
|
||||
const bgCode = getColorAnsiCode(backgroundColor, colorLevel, true);
|
||||
if (bgCode) {
|
||||
prefix += bgCode;
|
||||
suffix = '\x1b[49m' + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply foreground color
|
||||
if (foregroundColor) {
|
||||
const fgCode = getColorAnsiCode(foregroundColor, colorLevel, false);
|
||||
if (fgCode) {
|
||||
prefix += fgCode;
|
||||
suffix = '\x1b[39m' + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
return prefix + text + suffix;
|
||||
}
|
||||
|
||||
// Get raw ANSI codes for a color without the reset codes
|
||||
@@ -193,15 +198,15 @@ export function getColorAnsiCode(colorName: string | undefined, colorLevel: 'ans
|
||||
// Now that chalk.level is set correctly, we can use chalk to generate the codes
|
||||
let chalkFn: ChalkInstance;
|
||||
switch (colorLevel) {
|
||||
case 'ansi256':
|
||||
chalkFn = colorEntry.ansi256;
|
||||
break;
|
||||
case 'truecolor':
|
||||
chalkFn = colorEntry.truecolor;
|
||||
break;
|
||||
default:
|
||||
chalkFn = colorEntry.ansi16;
|
||||
break;
|
||||
case 'ansi256':
|
||||
chalkFn = colorEntry.ansi256;
|
||||
break;
|
||||
case 'truecolor':
|
||||
chalkFn = colorEntry.truecolor;
|
||||
break;
|
||||
default:
|
||||
chalkFn = colorEntry.ansi16;
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply the color and extract the opening ANSI code
|
||||
|
||||
+72
-29
@@ -19,23 +19,60 @@ const readFile = fs.promises.readFile;
|
||||
const writeFile = fs.promises.writeFile;
|
||||
const mkdir = fs.promises.mkdir;
|
||||
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccstatusline');
|
||||
const SETTINGS_PATH = path.join(CONFIG_DIR, 'settings.json');
|
||||
const SETTINGS_BACKUP_PATH = path.join(CONFIG_DIR, 'settings.bak');
|
||||
const DEFAULT_SETTINGS_PATH = path.join(os.homedir(), '.config', 'ccstatusline', 'settings.json');
|
||||
|
||||
async function backupBadSettings(): Promise<void> {
|
||||
let settingsPath = DEFAULT_SETTINGS_PATH;
|
||||
|
||||
export function initConfigPath(filePath?: string): void {
|
||||
settingsPath = filePath ? path.resolve(filePath) : DEFAULT_SETTINGS_PATH;
|
||||
}
|
||||
|
||||
export function getConfigPath(): string {
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
export function isCustomConfigPath(): boolean {
|
||||
return settingsPath !== DEFAULT_SETTINGS_PATH;
|
||||
}
|
||||
|
||||
interface SettingsPaths {
|
||||
configDir: string;
|
||||
settingsPath: string;
|
||||
settingsBackupPath: string;
|
||||
}
|
||||
|
||||
function getSettingsPaths(): SettingsPaths {
|
||||
const configDir = path.dirname(settingsPath);
|
||||
const parsedPath = path.parse(settingsPath);
|
||||
const backupBaseName = parsedPath.ext
|
||||
? `${parsedPath.name}.bak`
|
||||
: `${parsedPath.base}.bak`;
|
||||
|
||||
return {
|
||||
configDir,
|
||||
settingsPath,
|
||||
settingsBackupPath: path.join(configDir, backupBaseName)
|
||||
};
|
||||
}
|
||||
|
||||
async function writeSettingsJson(settings: unknown, paths: SettingsPaths): Promise<void> {
|
||||
await mkdir(paths.configDir, { recursive: true });
|
||||
await writeFile(paths.settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async function backupBadSettings(paths: SettingsPaths): Promise<void> {
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_PATH)) {
|
||||
const content = await readFile(SETTINGS_PATH, 'utf-8');
|
||||
await writeFile(SETTINGS_BACKUP_PATH, content, 'utf-8');
|
||||
console.error(`Bad settings backed up to ${SETTINGS_BACKUP_PATH}`);
|
||||
if (fs.existsSync(paths.settingsPath)) {
|
||||
const content = await readFile(paths.settingsPath, 'utf-8');
|
||||
await writeFile(paths.settingsBackupPath, content, 'utf-8');
|
||||
console.error(`Bad settings backed up to ${paths.settingsBackupPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to backup bad settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDefaultSettings(): Promise<Settings> {
|
||||
async function writeDefaultSettings(paths: SettingsPaths): Promise<Settings> {
|
||||
const defaults = SettingsSchema.parse({});
|
||||
const settingsWithVersion = {
|
||||
...defaults,
|
||||
@@ -43,9 +80,8 @@ async function writeDefaultSettings(): Promise<Settings> {
|
||||
};
|
||||
|
||||
try {
|
||||
await mkdir(CONFIG_DIR, { recursive: true });
|
||||
await writeFile(SETTINGS_PATH, JSON.stringify(settingsWithVersion, null, 2), 'utf-8');
|
||||
console.error(`Default settings written to ${SETTINGS_PATH}`);
|
||||
await writeSettingsJson(settingsWithVersion, paths);
|
||||
console.error(`Default settings written to ${paths.settingsPath}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to write default settings:', error);
|
||||
}
|
||||
@@ -53,13 +89,20 @@ async function writeDefaultSettings(): Promise<Settings> {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
async function recoverWithDefaults(paths: SettingsPaths): Promise<Settings> {
|
||||
await backupBadSettings(paths);
|
||||
return await writeDefaultSettings(paths);
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<Settings> {
|
||||
const paths = getSettingsPaths();
|
||||
|
||||
try {
|
||||
// Check if settings file exists
|
||||
if (!fs.existsSync(SETTINGS_PATH))
|
||||
return await writeDefaultSettings();
|
||||
if (!fs.existsSync(paths.settingsPath))
|
||||
return await writeDefaultSettings(paths);
|
||||
|
||||
const content = await readFile(SETTINGS_PATH, 'utf-8');
|
||||
const content = await readFile(paths.settingsPath, 'utf-8');
|
||||
let rawData: unknown;
|
||||
|
||||
try {
|
||||
@@ -67,8 +110,7 @@ export async function loadSettings(): Promise<Settings> {
|
||||
} catch {
|
||||
// If we can't parse the JSON, backup and write defaults
|
||||
console.error('Failed to parse settings.json, backing up and using defaults');
|
||||
await backupBadSettings();
|
||||
return await writeDefaultSettings();
|
||||
return await recoverWithDefaults(paths);
|
||||
}
|
||||
|
||||
// Check if this is a v1 config (no version field)
|
||||
@@ -78,17 +120,16 @@ export async function loadSettings(): Promise<Settings> {
|
||||
const v1Result = SettingsSchema_v1.safeParse(rawData);
|
||||
if (!v1Result.success) {
|
||||
console.error('Invalid v1 settings format:', v1Result.error);
|
||||
await backupBadSettings();
|
||||
return await writeDefaultSettings();
|
||||
return await recoverWithDefaults(paths);
|
||||
}
|
||||
|
||||
// Migrate v1 to current version and save the migrated settings back to disk
|
||||
rawData = migrateConfig(rawData, CURRENT_VERSION);
|
||||
await writeFile(SETTINGS_PATH, JSON.stringify(rawData, null, 2), 'utf-8');
|
||||
await writeSettingsJson(rawData, paths);
|
||||
} else if (needsMigration(rawData, CURRENT_VERSION)) {
|
||||
// Handle migrations for versioned configs (v2+) and save the migrated settings back to disk
|
||||
rawData = migrateConfig(rawData, CURRENT_VERSION);
|
||||
await writeFile(SETTINGS_PATH, JSON.stringify(rawData, null, 2), 'utf-8');
|
||||
await writeSettingsJson(rawData, paths);
|
||||
}
|
||||
|
||||
// At this point, data should be in current format with version field
|
||||
@@ -96,22 +137,19 @@ export async function loadSettings(): Promise<Settings> {
|
||||
const result = SettingsSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
console.error('Failed to parse settings:', result.error);
|
||||
await backupBadSettings();
|
||||
return await writeDefaultSettings();
|
||||
return await recoverWithDefaults(paths);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
// Any other error, backup and write defaults
|
||||
console.error('Error loading settings:', error);
|
||||
await backupBadSettings();
|
||||
return await writeDefaultSettings();
|
||||
return await recoverWithDefaults(paths);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSettings(settings: Settings): Promise<void> {
|
||||
// Ensure config directory exists
|
||||
await mkdir(CONFIG_DIR, { recursive: true });
|
||||
const paths = getSettingsPaths();
|
||||
|
||||
// Always include version when saving
|
||||
const settingsWithVersion = {
|
||||
@@ -119,6 +157,11 @@ export async function saveSettings(settings: Settings): Promise<void> {
|
||||
version: CURRENT_VERSION
|
||||
};
|
||||
|
||||
// Write settings using Node.js-compatible API
|
||||
await writeFile(SETTINGS_PATH, JSON.stringify(settingsWithVersion, null, 2), 'utf-8');
|
||||
await writeSettingsJson(settingsWithVersion, paths);
|
||||
|
||||
// Sync widget hooks to Claude settings
|
||||
try {
|
||||
const { syncWidgetHooks } = await import('./hooks');
|
||||
await syncWidgetHooks(settings);
|
||||
} catch { /* ignore hook sync failures */ }
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
import type { RenderContext } from '../types';
|
||||
|
||||
import { getContextConfig } from './model-context';
|
||||
import { getContextWindowMetrics } from './context-window';
|
||||
import {
|
||||
getContextConfig,
|
||||
getModelContextIdentifier
|
||||
} from './model-context';
|
||||
|
||||
/**
|
||||
* Calculate context window usage percentage based on model's max tokens
|
||||
*/
|
||||
export function calculateContextPercentage(context: RenderContext): number {
|
||||
const contextWindowMetrics = getContextWindowMetrics(context.data);
|
||||
if (contextWindowMetrics.usedPercentage !== null) {
|
||||
return contextWindowMetrics.usedPercentage;
|
||||
}
|
||||
|
||||
if (!context.tokenMetrics) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const modelId = context.data?.model?.id;
|
||||
const contextConfig = getContextConfig(modelId);
|
||||
const modelIdentifier = getModelContextIdentifier(context.data?.model);
|
||||
const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize);
|
||||
|
||||
return Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { StatusJSON } from '../types/StatusJSON';
|
||||
|
||||
export interface ContextWindowMetrics {
|
||||
windowSize: number | null;
|
||||
usedTokens: number | null;
|
||||
contextLengthTokens: number | null;
|
||||
usedPercentage: number | null;
|
||||
remainingPercentage: number | null;
|
||||
totalInputTokens: number | null;
|
||||
totalOutputTokens: number | null;
|
||||
cachedTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
}
|
||||
|
||||
function toFiniteNonNegativeNumber(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, value);
|
||||
}
|
||||
|
||||
function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
export function getContextWindowMetrics(data?: StatusJSON): ContextWindowMetrics {
|
||||
const contextWindow = data?.context_window;
|
||||
|
||||
if (!contextWindow) {
|
||||
return {
|
||||
windowSize: null,
|
||||
usedTokens: null,
|
||||
contextLengthTokens: null,
|
||||
usedPercentage: null,
|
||||
remainingPercentage: null,
|
||||
totalInputTokens: null,
|
||||
totalOutputTokens: null,
|
||||
cachedTokens: null,
|
||||
totalTokens: null
|
||||
};
|
||||
}
|
||||
|
||||
const rawWindowSize = toFiniteNonNegativeNumber(contextWindow.context_window_size);
|
||||
const windowSize = rawWindowSize !== null && rawWindowSize > 0 ? rawWindowSize : null;
|
||||
const totalInputTokens = toFiniteNonNegativeNumber(contextWindow.total_input_tokens);
|
||||
const totalOutputTokens = toFiniteNonNegativeNumber(contextWindow.total_output_tokens);
|
||||
|
||||
let currentUsageTotalTokens: number | null = null;
|
||||
let contextLengthTokens: number | null = null;
|
||||
let cachedTokens: number | null = null;
|
||||
|
||||
if (typeof contextWindow.current_usage === 'number') {
|
||||
currentUsageTotalTokens = toFiniteNonNegativeNumber(contextWindow.current_usage);
|
||||
contextLengthTokens = currentUsageTotalTokens;
|
||||
} else if (contextWindow.current_usage && typeof contextWindow.current_usage === 'object') {
|
||||
const usage = contextWindow.current_usage;
|
||||
const inputTokens = toFiniteNonNegativeNumber(usage.input_tokens) ?? 0;
|
||||
const outputTokens = toFiniteNonNegativeNumber(usage.output_tokens) ?? 0;
|
||||
const cacheCreationTokens = toFiniteNonNegativeNumber(usage.cache_creation_input_tokens) ?? 0;
|
||||
const cacheReadTokens = toFiniteNonNegativeNumber(usage.cache_read_input_tokens) ?? 0;
|
||||
|
||||
currentUsageTotalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
contextLengthTokens = inputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
cachedTokens = cacheCreationTokens + cacheReadTokens;
|
||||
}
|
||||
|
||||
const rawUsedPercentage = toFiniteNonNegativeNumber(contextWindow.used_percentage);
|
||||
const rawRemainingPercentage = toFiniteNonNegativeNumber(contextWindow.remaining_percentage);
|
||||
const usedTokensFromPercentage = rawUsedPercentage !== null && windowSize !== null
|
||||
? (rawUsedPercentage / 100) * windowSize
|
||||
: null;
|
||||
|
||||
const usedTokens = currentUsageTotalTokens ?? usedTokensFromPercentage;
|
||||
|
||||
const usedPercentage = rawUsedPercentage !== null
|
||||
? clampPercentage(rawUsedPercentage)
|
||||
: usedTokens !== null && windowSize !== null && windowSize > 0
|
||||
? clampPercentage((usedTokens / windowSize) * 100)
|
||||
: null;
|
||||
|
||||
const remainingPercentage = rawRemainingPercentage !== null
|
||||
? clampPercentage(rawRemainingPercentage)
|
||||
: usedPercentage !== null
|
||||
? 100 - usedPercentage
|
||||
: null;
|
||||
|
||||
const totalTokens = currentUsageTotalTokens
|
||||
?? (totalInputTokens !== null && totalOutputTokens !== null
|
||||
? totalInputTokens + totalOutputTokens
|
||||
: null);
|
||||
|
||||
return {
|
||||
windowSize,
|
||||
usedTokens,
|
||||
contextLengthTokens: contextLengthTokens ?? usedTokens,
|
||||
usedPercentage,
|
||||
remainingPercentage,
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
cachedTokens,
|
||||
totalTokens
|
||||
};
|
||||
}
|
||||
|
||||
export function getContextWindowInputTotalTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).totalInputTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowOutputTotalTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).totalOutputTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowCachedTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).cachedTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowTotalTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).totalTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowContextLengthTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).contextLengthTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowUsedTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).usedTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowUsedPercentage(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).usedPercentage;
|
||||
}
|
||||
|
||||
export function getContextWindowSize(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).windowSize;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
|
||||
export interface GitChangeCounts {
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export function resolveGitCwd(context: RenderContext): string | undefined {
|
||||
const candidates = [
|
||||
context.data?.cwd,
|
||||
context.data?.workspace?.current_dir,
|
||||
context.data?.workspace?.project_dir
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function runGit(command: string, context: RenderContext): string | null {
|
||||
try {
|
||||
const cwd = resolveGitCwd(context);
|
||||
const output = execSync(`git ${command}`, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
...(cwd ? { cwd } : {})
|
||||
}).trim();
|
||||
|
||||
return output.length > 0 ? output : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isInsideGitWorkTree(context: RenderContext): boolean {
|
||||
return runGit('rev-parse --is-inside-work-tree', context) === 'true';
|
||||
}
|
||||
|
||||
function parseDiffShortStat(stat: string): GitChangeCounts {
|
||||
const insertMatch = /(\d+)\s+insertions?/.exec(stat);
|
||||
const deleteMatch = /(\d+)\s+deletions?/.exec(stat);
|
||||
|
||||
return {
|
||||
insertions: insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0,
|
||||
deletions: deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0
|
||||
};
|
||||
}
|
||||
|
||||
export function getGitChangeCounts(context: RenderContext): GitChangeCounts {
|
||||
const unstagedStat = runGit('diff --shortstat', context) ?? '';
|
||||
const stagedStat = runGit('diff --cached --shortstat', context) ?? '';
|
||||
const unstagedCounts = parseDiffShortStat(unstagedStat);
|
||||
const stagedCounts = parseDiffShortStat(stagedStat);
|
||||
|
||||
return {
|
||||
insertions: unstagedCounts.insertions + stagedCounts.insertions,
|
||||
deletions: unstagedCounts.deletions + stagedCounts.deletions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type { Widget } from '../types/Widget';
|
||||
|
||||
import {
|
||||
getExistingStatusLine,
|
||||
loadClaudeSettings,
|
||||
saveClaudeSettings
|
||||
} from './claude-settings';
|
||||
import { getWidget } from './widgets';
|
||||
|
||||
export interface WidgetHookDef {
|
||||
event: string;
|
||||
matcher?: string;
|
||||
}
|
||||
|
||||
const HOOK_TAG = 'ccstatusline-managed';
|
||||
|
||||
interface HookEntry {
|
||||
_tag?: string;
|
||||
matcher?: string;
|
||||
hooks?: { type: string; command: string }[];
|
||||
}
|
||||
|
||||
function stripManagedHooks(hooks: Record<string, HookEntry[]>): void {
|
||||
for (const event of Object.keys(hooks)) {
|
||||
hooks[event] = (hooks[event] ?? []).filter(entry => entry._tag !== HOOK_TAG);
|
||||
if (hooks[event].length === 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete hooks[event];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveHookDefs(settings: Settings): WidgetHookDef[] {
|
||||
const seen = new Set<string>();
|
||||
const defs: WidgetHookDef[] = [];
|
||||
for (const line of settings.lines) {
|
||||
for (const item of line) {
|
||||
const widget = getWidget(item.type) as (Widget & { getHooks?: () => WidgetHookDef[] }) | null;
|
||||
if (!widget?.getHooks) {
|
||||
continue;
|
||||
}
|
||||
for (const hook of widget.getHooks()) {
|
||||
const key = `${hook.event}:${hook.matcher ?? ''}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
defs.push(hook);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
export async function syncWidgetHooks(settings: Settings): Promise<void> {
|
||||
const needed = getActiveHookDefs(settings);
|
||||
const claudeSettings = await loadClaudeSettings({ logErrors: false });
|
||||
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;
|
||||
|
||||
// Remove all ccstatusline-managed hooks
|
||||
stripManagedHooks(hooks);
|
||||
|
||||
const statusCommand = await getExistingStatusLine();
|
||||
if (!statusCommand) {
|
||||
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
|
||||
await saveClaudeSettings(claudeSettings);
|
||||
return;
|
||||
}
|
||||
const hookCommand = `${statusCommand} --hook`;
|
||||
|
||||
// Add needed hooks
|
||||
for (const def of needed) {
|
||||
const entry: HookEntry = {
|
||||
_tag: HOOK_TAG,
|
||||
hooks: [{ type: 'command', command: hookCommand }]
|
||||
};
|
||||
if (def.matcher) {
|
||||
entry.matcher = def.matcher;
|
||||
}
|
||||
const list = hooks[def.event] ??= [];
|
||||
list.push(entry);
|
||||
}
|
||||
|
||||
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
|
||||
await saveClaudeSettings(claudeSettings);
|
||||
}
|
||||
|
||||
export async function removeManagedHooks(): Promise<void> {
|
||||
const claudeSettings = await loadClaudeSettings({ logErrors: false });
|
||||
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;
|
||||
|
||||
stripManagedHooks(hooks);
|
||||
|
||||
claudeSettings.hooks = Object.keys(hooks).length > 0 ? hooks : undefined;
|
||||
await saveClaudeSettings(claudeSettings);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface InputKeyLike {
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
shift?: boolean;
|
||||
tab?: boolean;
|
||||
}
|
||||
|
||||
const CONTROL_CHAR_REGEX = /[\u0000-\u001F\u007F]/u;
|
||||
|
||||
export const shouldInsertInput = (input: string, key: InputKeyLike): boolean => {
|
||||
if (!input) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.ctrl || key.meta || key.tab) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !CONTROL_CHAR_REGEX.test(input);
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import {
|
||||
parseJsonlLine,
|
||||
readJsonlLinesSync
|
||||
} from './jsonl-lines';
|
||||
|
||||
const statSync = fs.statSync;
|
||||
|
||||
/**
|
||||
* Gets block metrics for the current 5-hour block from JSONL files
|
||||
*/
|
||||
export function getBlockMetrics(): BlockMetrics | null {
|
||||
const claudeDir: string | null = getClaudeConfigDir();
|
||||
|
||||
if (!claudeDir)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return findMostRecentBlockStartTime(claudeDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently finds the most recent 5-hour block start time from JSONL files
|
||||
* Uses file modification times as hints to avoid unnecessary reads
|
||||
*/
|
||||
function findMostRecentBlockStartTime(
|
||||
rootDir: string,
|
||||
sessionDurationHours = 5
|
||||
): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
||||
// Step 1: Find all JSONL files with their modification times
|
||||
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
|
||||
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
|
||||
const files = globSync([pattern], {
|
||||
absolute: true, // Ensure we get absolute paths
|
||||
cwd: rootDir // Set working directory to rootDir
|
||||
});
|
||||
|
||||
if (files.length === 0)
|
||||
return null;
|
||||
|
||||
// Step 2: Get file stats and sort by modification time (most recent first)
|
||||
const filesWithStats = files.map((file) => {
|
||||
const stats = statSync(file);
|
||||
return { file, mtime: stats.mtime };
|
||||
});
|
||||
|
||||
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
// Step 3: Progressive lookback - start small and expand if needed
|
||||
// Start with 2x session duration (10 hours), expand to 48 hours if needed
|
||||
const lookbackChunks = [
|
||||
10, // 2x session duration - catches most cases
|
||||
20, // 4x session duration - catches longer sessions
|
||||
48 // Maximum lookback for marathon sessions
|
||||
];
|
||||
|
||||
let timestamps: Date[] = [];
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
let continuousWorkStart: Date | null = null;
|
||||
let foundSessionGap = false;
|
||||
|
||||
for (const lookbackHours of lookbackChunks) {
|
||||
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
|
||||
timestamps = [];
|
||||
|
||||
// Collect timestamps for this lookback period
|
||||
for (const { file, mtime } of filesWithStats) {
|
||||
if (mtime.getTime() < cutoffTime.getTime()) {
|
||||
break;
|
||||
}
|
||||
const fileTimestamps = getAllTimestampsFromFile(file);
|
||||
timestamps.push(...fileTimestamps);
|
||||
}
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
continue; // Try next chunk
|
||||
}
|
||||
|
||||
// Sort timestamps (most recent first)
|
||||
timestamps.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
// Get most recent timestamp (only set once)
|
||||
if (!mostRecentTimestamp && timestamps[0]) {
|
||||
mostRecentTimestamp = timestamps[0];
|
||||
|
||||
// Check if the most recent activity is within the current session period
|
||||
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
|
||||
if (timeSinceLastActivity > sessionDurationMs) {
|
||||
// No activity within the current session period
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for a session gap in this chunk
|
||||
continuousWorkStart = mostRecentTimestamp;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const currentTimestamp = timestamps[i];
|
||||
const previousTimestamp = timestamps[i - 1];
|
||||
|
||||
if (!currentTimestamp || !previousTimestamp)
|
||||
continue;
|
||||
|
||||
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
|
||||
|
||||
if (gap >= sessionDurationMs) {
|
||||
// Found a true session boundary
|
||||
foundSessionGap = true;
|
||||
break;
|
||||
}
|
||||
|
||||
continuousWorkStart = currentTimestamp;
|
||||
}
|
||||
|
||||
// If we found a gap, we're done
|
||||
if (foundSessionGap) {
|
||||
break;
|
||||
}
|
||||
|
||||
// If this was our last chunk, use what we have
|
||||
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mostRecentTimestamp || !continuousWorkStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build actual blocks from timestamps going forward
|
||||
const blocks: { start: Date; end: Date }[] = [];
|
||||
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
let currentBlockStart: Date | null = null;
|
||||
let currentBlockEnd: Date | null = null;
|
||||
|
||||
for (const timestamp of sortedTimestamps) {
|
||||
if (timestamp.getTime() < continuousWorkStart.getTime())
|
||||
continue;
|
||||
|
||||
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
|
||||
// Start new block
|
||||
currentBlockStart = floorToHour(timestamp);
|
||||
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
|
||||
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
|
||||
}
|
||||
}
|
||||
|
||||
// Find current block
|
||||
for (const block of blocks) {
|
||||
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
|
||||
// Verify we have activity in this block
|
||||
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
|
||||
&& t.getTime() <= block.end.getTime()
|
||||
);
|
||||
|
||||
if (hasActivity) {
|
||||
return {
|
||||
startTime: block.start,
|
||||
lastActivity: mostRecentTimestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all timestamps from a JSONL file
|
||||
*/
|
||||
function getAllTimestampsFromFile(filePath: string): Date[] {
|
||||
const timestamps: Date[] = [];
|
||||
try {
|
||||
const lines = readJsonlLinesSync(filePath);
|
||||
|
||||
for (const line of lines) {
|
||||
const json = parseJsonlLine(line) as {
|
||||
timestamp?: string;
|
||||
isSidechain?: boolean;
|
||||
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
|
||||
} | null;
|
||||
if (!json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only treat entries with real token usage as block activity
|
||||
const usage = json.message?.usage;
|
||||
if (!usage)
|
||||
continue;
|
||||
|
||||
const hasInputTokens = typeof usage.input_tokens === 'number';
|
||||
const hasOutputTokens = typeof usage.output_tokens === 'number';
|
||||
if (!hasInputTokens || !hasOutputTokens)
|
||||
continue;
|
||||
|
||||
if (json.isSidechain === true)
|
||||
continue;
|
||||
|
||||
const timestamp = json.timestamp;
|
||||
if (typeof timestamp !== 'string')
|
||||
continue;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isNaN(date.getTime()))
|
||||
timestamps.push(date);
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floors a timestamp to the beginning of the hour (matching existing logic)
|
||||
*/
|
||||
function floorToHour(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as fs from 'fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import { getBlockMetrics } from './jsonl-blocks';
|
||||
|
||||
const readFileSync = fs.readFileSync;
|
||||
const writeFileSync = fs.writeFileSync;
|
||||
const mkdirSync = fs.mkdirSync;
|
||||
const existsSync = fs.existsSync;
|
||||
|
||||
interface BlockCache {
|
||||
startTime: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
function normalizeConfigDir(configDir: string): string {
|
||||
return path.resolve(configDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the block cache file for a specific Claude config directory
|
||||
*/
|
||||
export function getBlockCachePath(configDir = getClaudeConfigDir()): string {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const configHash = createHash('sha256')
|
||||
.update(normalizedConfigDir)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
'.cache',
|
||||
'ccstatusline',
|
||||
`block-cache-${configHash}.json`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the block cache file and returns the cached start time
|
||||
* Returns null if cache doesn't exist or is invalid
|
||||
*/
|
||||
export function readBlockCache(expectedConfigDir?: string): Date | null {
|
||||
try {
|
||||
const normalizedExpectedConfigDir = expectedConfigDir !== undefined
|
||||
? normalizeConfigDir(expectedConfigDir)
|
||||
: undefined;
|
||||
const cachePath = getBlockCachePath(normalizedExpectedConfigDir);
|
||||
if (!existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
const content = readFileSync(cachePath, 'utf-8');
|
||||
const cache = JSON.parse(content) as BlockCache;
|
||||
if (typeof cache.startTime !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (normalizedExpectedConfigDir !== undefined) {
|
||||
if (typeof cache.configDir !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (cache.configDir !== normalizedExpectedConfigDir) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const date = new Date(cache.startTime);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the block start time to the cache file
|
||||
* Creates the cache directory if it doesn't exist
|
||||
*/
|
||||
export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void {
|
||||
try {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const cachePath = getBlockCachePath(normalizedConfigDir);
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
const cache: BlockCache = {
|
||||
startTime: startTime.toISOString(),
|
||||
configDir: normalizedConfigDir
|
||||
};
|
||||
writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - caching is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block metrics with caching support
|
||||
* Returns cached result if still valid, otherwise recalculates
|
||||
*/
|
||||
export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const activeConfigDir = getClaudeConfigDir();
|
||||
|
||||
// Check cache first
|
||||
const cachedStartTime = readBlockCache(activeConfigDir);
|
||||
if (cachedStartTime) {
|
||||
const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs);
|
||||
if (now.getTime() <= blockEndTime.getTime()) {
|
||||
// Cache is valid - return cached result
|
||||
return {
|
||||
startTime: cachedStartTime,
|
||||
lastActivity: now // We don't cache lastActivity, use current time
|
||||
};
|
||||
}
|
||||
// Cache expired - need to recalculate
|
||||
}
|
||||
|
||||
// Cache miss or expired - run full calculation
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
// Write to cache if we found a valid block
|
||||
if (metrics) {
|
||||
writeBlockCache(metrics.startTime, activeConfigDir);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as fs from 'fs';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const readFile = promisify(fs.readFile);
|
||||
const readFileSync = fs.readFileSync;
|
||||
|
||||
function splitJsonlContent(content: string): string[] {
|
||||
return content.trim().split('\n').filter(line => line.length > 0);
|
||||
}
|
||||
|
||||
export async function readJsonlLines(filePath: string): Promise<string[]> {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
return splitJsonlContent(content);
|
||||
}
|
||||
|
||||
export function readJsonlLinesSync(filePath: string): string[] {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
return splitJsonlContent(content);
|
||||
}
|
||||
|
||||
export function parseJsonlLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
SpeedMetrics,
|
||||
TokenMetrics,
|
||||
TranscriptLine
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
parseJsonlLine,
|
||||
readJsonlLines
|
||||
} from './jsonl-lines';
|
||||
|
||||
export interface SpeedMetricsOptions {
|
||||
includeSubagents?: boolean;
|
||||
windowSeconds?: number;
|
||||
}
|
||||
|
||||
interface SpeedMetricsCollectionOptions {
|
||||
includeSubagents?: boolean;
|
||||
windowSeconds?: number[];
|
||||
}
|
||||
|
||||
export interface SpeedMetricsCollection {
|
||||
sessionAverage: SpeedMetrics;
|
||||
windowed: Record<string, SpeedMetrics>;
|
||||
}
|
||||
|
||||
interface SpeedInterval {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
interface SpeedRequest {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
assistantTimestampMs: number | null;
|
||||
interval: SpeedInterval | null;
|
||||
}
|
||||
|
||||
interface CollectedSpeedMetrics {
|
||||
requests: SpeedRequest[];
|
||||
latestTimestampMs: number | null;
|
||||
}
|
||||
|
||||
function collectAgentIds(value: unknown, agentIds: Set<string>) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectAgentIds(item, agentIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
if (key === 'agentId' && typeof nestedValue === 'string' && nestedValue.trim() !== '') {
|
||||
agentIds.add(nestedValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
collectAgentIds(nestedValue, agentIds);
|
||||
}
|
||||
}
|
||||
|
||||
function getReferencedSubagentIds(lines: string[]): Set<string> {
|
||||
const agentIds = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line);
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
collectAgentIds(data, agentIds);
|
||||
}
|
||||
|
||||
return agentIds;
|
||||
}
|
||||
|
||||
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
|
||||
try {
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = await readJsonlLines(transcriptPath);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let firstTimestamp: Date | null = null;
|
||||
let lastTimestamp: Date | null = null;
|
||||
|
||||
// Find first valid timestamp
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line) as { timestamp?: string } | null;
|
||||
if (data?.timestamp) {
|
||||
firstTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find last valid timestamp (iterate backwards)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = parseJsonlLine(line) as { timestamp?: string } | null;
|
||||
if (data?.timestamp) {
|
||||
lastTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstTimestamp || !lastTimestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate duration in milliseconds
|
||||
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
|
||||
|
||||
// Convert to minutes
|
||||
const totalMinutes = Math.floor(durationMs / (1000 * 60));
|
||||
|
||||
if (totalMinutes < 1) {
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}m`;
|
||||
} else if (minutes === 0) {
|
||||
return `${hours}hr`;
|
||||
} else {
|
||||
return `${hours}hr ${minutes}m`;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
|
||||
try {
|
||||
// Use Node.js-compatible file reading
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
|
||||
const lines = await readJsonlLines(transcriptPath);
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
let contextLength = 0;
|
||||
|
||||
// Parse each line and sum up token usage for totals
|
||||
let mostRecentMainChainEntry: TranscriptLine | null = null;
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line) as TranscriptLine | null;
|
||||
if (data?.message?.usage) {
|
||||
inputTokens += data.message.usage.input_tokens || 0;
|
||||
outputTokens += data.message.usage.output_tokens || 0;
|
||||
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
|
||||
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
|
||||
|
||||
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
|
||||
// Also skip API error messages (synthetic messages with 0 tokens)
|
||||
if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) {
|
||||
const entryTime = new Date(data.timestamp);
|
||||
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
|
||||
mostRecentTimestamp = entryTime;
|
||||
mostRecentMainChainEntry = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate context length from the most recent main chain message
|
||||
if (mostRecentMainChainEntry?.message?.usage) {
|
||||
const usage = mostRecentMainChainEntry.message.usage;
|
||||
contextLength = (usage.input_tokens || 0)
|
||||
+ (usage.cache_read_input_tokens ?? 0)
|
||||
+ (usage.cache_creation_input_tokens ?? 0);
|
||||
}
|
||||
|
||||
const totalTokens = inputTokens + outputTokens + cachedTokens;
|
||||
|
||||
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
|
||||
} catch {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = new Date(value);
|
||||
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
|
||||
}
|
||||
|
||||
function mergeIntervals(intervals: SpeedInterval[]): SpeedInterval[] {
|
||||
if (intervals.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sorted = intervals
|
||||
.slice()
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
const first = sorted[0];
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
const merged: SpeedInterval[] = [{ ...first }];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const current = sorted[i];
|
||||
const last = merged[merged.length - 1];
|
||||
if (!current || !last) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.startMs <= last.endMs) {
|
||||
last.endMs = Math.max(last.endMs, current.endMs);
|
||||
} else {
|
||||
merged.push({ ...current });
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function getIntervalsDurationMs(intervals: SpeedInterval[]): number {
|
||||
return intervals.reduce((total, interval) => total + (interval.endMs - interval.startMs), 0);
|
||||
}
|
||||
|
||||
function createEmptySpeedMetrics(): SpeedMetrics {
|
||||
return {
|
||||
totalDurationMs: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
requestCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWindowSeconds(value: number | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = Math.trunc(value);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function collectSpeedMetricsFromLines(lines: string[], ignoreSidechain: boolean): CollectedSpeedMetrics {
|
||||
const requests: SpeedRequest[] = [];
|
||||
|
||||
let lastUserTimestamp: Date | null = null;
|
||||
let latestTimestampMs: number | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line) as TranscriptLine | null;
|
||||
if (!data || data.isApiErrorMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoreSidechain && data.isSidechain === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryTimestamp = parseTimestamp(data.timestamp);
|
||||
if (entryTimestamp) {
|
||||
const entryTimestampMs = entryTimestamp.getTime();
|
||||
if (latestTimestampMs === null || entryTimestampMs > latestTimestampMs) {
|
||||
latestTimestampMs = entryTimestampMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'user' && entryTimestamp) {
|
||||
lastUserTimestamp = entryTimestamp;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === 'assistant' && data.message?.usage) {
|
||||
const inputTokens = data.message.usage.input_tokens || 0;
|
||||
const outputTokens = data.message.usage.output_tokens || 0;
|
||||
let interval: SpeedInterval | null = null;
|
||||
if (entryTimestamp && lastUserTimestamp) {
|
||||
const startMs = lastUserTimestamp.getTime();
|
||||
const endMs = entryTimestamp.getTime();
|
||||
if (endMs > startMs) {
|
||||
interval = { startMs, endMs };
|
||||
}
|
||||
}
|
||||
|
||||
requests.push({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
assistantTimestampMs: entryTimestamp ? entryTimestamp.getTime() : null,
|
||||
interval
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requests,
|
||||
latestTimestampMs
|
||||
};
|
||||
}
|
||||
|
||||
function mergeCollectedSpeedMetrics(parts: CollectedSpeedMetrics[]): CollectedSpeedMetrics {
|
||||
const requests: SpeedRequest[] = [];
|
||||
let latestTimestampMs: number | null = null;
|
||||
|
||||
for (const part of parts) {
|
||||
requests.push(...part.requests);
|
||||
|
||||
if (part.latestTimestampMs !== null && (latestTimestampMs === null || part.latestTimestampMs > latestTimestampMs)) {
|
||||
latestTimestampMs = part.latestTimestampMs;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requests,
|
||||
latestTimestampMs
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpeedMetrics(
|
||||
collected: CollectedSpeedMetrics,
|
||||
windowSeconds?: number
|
||||
): SpeedMetrics {
|
||||
const normalizedWindowSeconds = normalizeWindowSeconds(windowSeconds);
|
||||
if (normalizedWindowSeconds !== null && collected.latestTimestampMs === null) {
|
||||
return createEmptySpeedMetrics();
|
||||
}
|
||||
|
||||
const windowEndMs = normalizedWindowSeconds !== null && collected.latestTimestampMs !== null
|
||||
? collected.latestTimestampMs
|
||||
: null;
|
||||
const windowStartMs = normalizedWindowSeconds !== null && windowEndMs !== null
|
||||
? windowEndMs - (normalizedWindowSeconds * 1000)
|
||||
: null;
|
||||
|
||||
const selectedRequests = normalizedWindowSeconds !== null && windowStartMs !== null && windowEndMs !== null
|
||||
? collected.requests.filter(request => request.assistantTimestampMs !== null
|
||||
&& request.assistantTimestampMs >= windowStartMs
|
||||
&& request.assistantTimestampMs <= windowEndMs
|
||||
)
|
||||
: collected.requests;
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
const intervals: SpeedInterval[] = [];
|
||||
|
||||
for (const request of selectedRequests) {
|
||||
inputTokens += request.inputTokens;
|
||||
outputTokens += request.outputTokens;
|
||||
|
||||
if (!request.interval) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (windowStartMs === null || windowEndMs === null) {
|
||||
intervals.push(request.interval);
|
||||
continue;
|
||||
}
|
||||
|
||||
const clippedStartMs = Math.max(request.interval.startMs, windowStartMs);
|
||||
const clippedEndMs = Math.min(request.interval.endMs, windowEndMs);
|
||||
if (clippedEndMs > clippedStartMs) {
|
||||
intervals.push({
|
||||
startMs: clippedStartMs,
|
||||
endMs: clippedEndMs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mergedIntervals = mergeIntervals(intervals);
|
||||
const totalDurationMs = getIntervalsDurationMs(mergedIntervals);
|
||||
|
||||
return {
|
||||
totalDurationMs,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
requestCount: selectedRequests.length
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyWindowedMetrics(windowSeconds: number[]): Record<string, SpeedMetrics> {
|
||||
const windowed: Record<string, SpeedMetrics> = {};
|
||||
for (const window of windowSeconds) {
|
||||
windowed[window.toString()] = createEmptySpeedMetrics();
|
||||
}
|
||||
return windowed;
|
||||
}
|
||||
|
||||
function getSubagentTranscriptPaths(transcriptPath: string, referencedAgentIds: Set<string>): string[] {
|
||||
if (referencedAgentIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const transcriptDir = path.dirname(transcriptPath);
|
||||
const transcriptStem = path.parse(transcriptPath).name;
|
||||
const candidateDirs = [
|
||||
path.join(transcriptDir, 'subagents'),
|
||||
path.join(transcriptDir, transcriptStem, 'subagents')
|
||||
];
|
||||
const seenPaths = new Set<string>();
|
||||
const matchedPaths: string[] = [];
|
||||
|
||||
for (const subagentsDir of candidateDirs) {
|
||||
if (!fs.existsSync(subagentsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dirEntries = fs.readdirSync(subagentsDir, { withFileTypes: true });
|
||||
for (const entry of dirEntries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = /^agent-(.+)\.jsonl$/.exec(entry.name);
|
||||
if (!match?.[1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!referencedAgentIds.has(match[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(subagentsDir, entry.name);
|
||||
if (seenPaths.has(fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenPaths.add(fullPath);
|
||||
matchedPaths.push(fullPath);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return matchedPaths;
|
||||
}
|
||||
|
||||
export async function getSpeedMetricsCollection(
|
||||
transcriptPath: string,
|
||||
options: SpeedMetricsCollectionOptions = {}
|
||||
): Promise<SpeedMetricsCollection> {
|
||||
const normalizedWindows = Array.from(
|
||||
new Set(
|
||||
(options.windowSeconds ?? [])
|
||||
.map(window => normalizeWindowSeconds(window))
|
||||
.filter((window): window is number => window !== null)
|
||||
)
|
||||
);
|
||||
const emptyWindowedMetrics = buildEmptyWindowedMetrics(normalizedWindows);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return {
|
||||
sessionAverage: createEmptySpeedMetrics(),
|
||||
windowed: emptyWindowedMetrics
|
||||
};
|
||||
}
|
||||
|
||||
const mainLines = await readJsonlLines(transcriptPath);
|
||||
const allCollected: CollectedSpeedMetrics[] = [
|
||||
collectSpeedMetricsFromLines(mainLines, true)
|
||||
];
|
||||
|
||||
if (options.includeSubagents === true) {
|
||||
const referencedSubagentIds = getReferencedSubagentIds(mainLines);
|
||||
const subagentPaths = getSubagentTranscriptPaths(transcriptPath, referencedSubagentIds);
|
||||
const subagentMetricsResults = await Promise.all(subagentPaths.map(async (subagentPath) => {
|
||||
try {
|
||||
const subagentLines = await readJsonlLines(subagentPath);
|
||||
return collectSpeedMetricsFromLines(subagentLines, false);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
for (const subagentMetrics of subagentMetricsResults) {
|
||||
if (!subagentMetrics) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allCollected.push(subagentMetrics);
|
||||
}
|
||||
}
|
||||
|
||||
const combined = mergeCollectedSpeedMetrics(allCollected);
|
||||
const windowed: Record<string, SpeedMetrics> = {};
|
||||
for (const window of normalizedWindows) {
|
||||
windowed[window.toString()] = buildSpeedMetrics(combined, window);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionAverage: buildSpeedMetrics(combined),
|
||||
windowed
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
sessionAverage: createEmptySpeedMetrics(),
|
||||
windowed: emptyWindowedMetrics
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSpeedMetrics(
|
||||
transcriptPath: string,
|
||||
options: SpeedMetricsOptions = {}
|
||||
): Promise<SpeedMetrics> {
|
||||
const requestedWindow = normalizeWindowSeconds(options.windowSeconds);
|
||||
const metricsCollection = await getSpeedMetricsCollection(transcriptPath, {
|
||||
includeSubagents: options.includeSubagents,
|
||||
windowSeconds: requestedWindow ? [requestedWindow] : []
|
||||
});
|
||||
|
||||
if (requestedWindow === null) {
|
||||
return metricsCollection.sessionAverage;
|
||||
}
|
||||
|
||||
return metricsCollection.windowed[requestedWindow.toString()] ?? createEmptySpeedMetrics();
|
||||
}
|
||||
+13
-370
@@ -1,370 +1,13 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import type {
|
||||
BlockMetrics,
|
||||
TokenMetrics,
|
||||
TranscriptLine
|
||||
} from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
|
||||
// Ensure fs.promises compatibility for older Node versions
|
||||
const readFile = promisify(fs.readFile);
|
||||
const readFileSync = fs.readFileSync;
|
||||
const statSync = fs.statSync;
|
||||
|
||||
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
|
||||
try {
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await readFile(transcriptPath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter((line: string) => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let firstTimestamp: Date | null = null;
|
||||
let lastTimestamp: Date | null = null;
|
||||
|
||||
// Find first valid timestamp
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line) as { timestamp?: string };
|
||||
if (data.timestamp) {
|
||||
firstTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
// Find last valid timestamp (iterate backwards)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const data = JSON.parse(lines[i] ?? '') as { timestamp?: string };
|
||||
if (data.timestamp) {
|
||||
lastTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstTimestamp || !lastTimestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate duration in milliseconds
|
||||
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
|
||||
|
||||
// Convert to minutes
|
||||
const totalMinutes = Math.floor(durationMs / (1000 * 60));
|
||||
|
||||
if (totalMinutes < 1) {
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}m`;
|
||||
} else if (minutes === 0) {
|
||||
return `${hours}hr`;
|
||||
} else {
|
||||
return `${hours}hr ${minutes}m`;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
|
||||
try {
|
||||
// Use Node.js-compatible file reading
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
|
||||
const content = await readFile(transcriptPath, 'utf-8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
let contextLength = 0;
|
||||
|
||||
// Parse each line and sum up token usage for totals
|
||||
let mostRecentMainChainEntry: TranscriptLine | null = null;
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line) as TranscriptLine;
|
||||
if (data.message?.usage) {
|
||||
inputTokens += data.message.usage.input_tokens || 0;
|
||||
outputTokens += data.message.usage.output_tokens || 0;
|
||||
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
|
||||
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
|
||||
|
||||
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
|
||||
// Also skip API error messages (synthetic messages with 0 tokens)
|
||||
if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) {
|
||||
const entryTime = new Date(data.timestamp);
|
||||
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
|
||||
mostRecentTimestamp = entryTime;
|
||||
mostRecentMainChainEntry = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate context length from the most recent main chain message
|
||||
if (mostRecentMainChainEntry?.message?.usage) {
|
||||
const usage = mostRecentMainChainEntry.message.usage;
|
||||
contextLength = (usage.input_tokens || 0)
|
||||
+ (usage.cache_read_input_tokens ?? 0)
|
||||
+ (usage.cache_creation_input_tokens ?? 0);
|
||||
}
|
||||
|
||||
const totalTokens = inputTokens + outputTokens + cachedTokens;
|
||||
|
||||
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
|
||||
} catch {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block metrics for the current 5-hour block from JSONL files
|
||||
*/
|
||||
export function getBlockMetrics(): BlockMetrics | null {
|
||||
const claudeDir: string | null = getClaudeConfigDir();
|
||||
|
||||
if (!claudeDir)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return findMostRecentBlockStartTime(claudeDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently finds the most recent 5-hour block start time from JSONL files
|
||||
* Uses file modification times as hints to avoid unnecessary reads
|
||||
*/
|
||||
function findMostRecentBlockStartTime(
|
||||
rootDir: string,
|
||||
sessionDurationHours = 5
|
||||
): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
||||
// Step 1: Find all JSONL files with their modification times
|
||||
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
|
||||
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
|
||||
const files = globSync([pattern], {
|
||||
absolute: true, // Ensure we get absolute paths
|
||||
cwd: rootDir // Set working directory to rootDir
|
||||
});
|
||||
|
||||
if (files.length === 0)
|
||||
return null;
|
||||
|
||||
// Step 2: Get file stats and sort by modification time (most recent first)
|
||||
const filesWithStats = files.map((file) => {
|
||||
const stats = statSync(file);
|
||||
return { file, mtime: stats.mtime };
|
||||
});
|
||||
|
||||
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
// Step 3: Progressive lookback - start small and expand if needed
|
||||
// Start with 2x session duration (10 hours), expand to 48 hours if needed
|
||||
const lookbackChunks = [
|
||||
10, // 2x session duration - catches most cases
|
||||
20, // 4x session duration - catches longer sessions
|
||||
48 // Maximum lookback for marathon sessions
|
||||
];
|
||||
|
||||
let timestamps: Date[] = [];
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
let continuousWorkStart: Date | null = null;
|
||||
let foundSessionGap = false;
|
||||
|
||||
for (const lookbackHours of lookbackChunks) {
|
||||
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
|
||||
timestamps = [];
|
||||
|
||||
// Collect timestamps for this lookback period
|
||||
for (const { file, mtime } of filesWithStats) {
|
||||
if (mtime.getTime() < cutoffTime.getTime()) {
|
||||
break;
|
||||
}
|
||||
const fileTimestamps = getAllTimestampsFromFile(file);
|
||||
timestamps.push(...fileTimestamps);
|
||||
}
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
continue; // Try next chunk
|
||||
}
|
||||
|
||||
// Sort timestamps (most recent first)
|
||||
timestamps.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
// Get most recent timestamp (only set once)
|
||||
if (!mostRecentTimestamp && timestamps[0]) {
|
||||
mostRecentTimestamp = timestamps[0];
|
||||
|
||||
// Check if the most recent activity is within the current session period
|
||||
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
|
||||
if (timeSinceLastActivity > sessionDurationMs) {
|
||||
// No activity within the current session period
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for a session gap in this chunk
|
||||
continuousWorkStart = mostRecentTimestamp;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const currentTimestamp = timestamps[i];
|
||||
const previousTimestamp = timestamps[i - 1];
|
||||
|
||||
if (!currentTimestamp || !previousTimestamp)
|
||||
continue;
|
||||
|
||||
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
|
||||
|
||||
if (gap >= sessionDurationMs) {
|
||||
// Found a true session boundary
|
||||
foundSessionGap = true;
|
||||
break;
|
||||
}
|
||||
|
||||
continuousWorkStart = currentTimestamp;
|
||||
}
|
||||
|
||||
// If we found a gap, we're done
|
||||
if (foundSessionGap) {
|
||||
break;
|
||||
}
|
||||
|
||||
// If this was our last chunk, use what we have
|
||||
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mostRecentTimestamp || !continuousWorkStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build actual blocks from timestamps going forward
|
||||
const blocks: { start: Date; end: Date }[] = [];
|
||||
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
let currentBlockStart: Date | null = null;
|
||||
let currentBlockEnd: Date | null = null;
|
||||
|
||||
for (const timestamp of sortedTimestamps) {
|
||||
if (timestamp.getTime() < continuousWorkStart.getTime())
|
||||
continue;
|
||||
|
||||
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
|
||||
// Start new block
|
||||
currentBlockStart = floorToHour(timestamp);
|
||||
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
|
||||
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
|
||||
}
|
||||
}
|
||||
|
||||
// Find current block
|
||||
for (const block of blocks) {
|
||||
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
|
||||
// Verify we have activity in this block
|
||||
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
|
||||
&& t.getTime() <= block.end.getTime()
|
||||
);
|
||||
|
||||
if (hasActivity) {
|
||||
return {
|
||||
startTime: block.start,
|
||||
lastActivity: mostRecentTimestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all timestamps from a JSONL file
|
||||
*/
|
||||
function getAllTimestampsFromFile(filePath: string): Date[] {
|
||||
const timestamps: Date[] = [];
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter(line => line.length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line) as {
|
||||
timestamp?: string;
|
||||
isSidechain?: boolean;
|
||||
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
|
||||
};
|
||||
|
||||
// Only treat entries with real token usage as block activity
|
||||
const usage = json.message?.usage;
|
||||
if (!usage)
|
||||
continue;
|
||||
|
||||
const hasInputTokens = typeof usage.input_tokens === 'number';
|
||||
const hasOutputTokens = typeof usage.output_tokens === 'number';
|
||||
if (!hasInputTokens || !hasOutputTokens)
|
||||
continue;
|
||||
|
||||
if (json.isSidechain === true)
|
||||
continue;
|
||||
|
||||
const timestamp = json.timestamp;
|
||||
if (typeof timestamp !== 'string')
|
||||
continue;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isNaN(date.getTime()))
|
||||
timestamps.push(date);
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floors a timestamp to the beginning of the hour (matching existing logic)
|
||||
*/
|
||||
function floorToHour(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
export {
|
||||
getBlockCachePath,
|
||||
getCachedBlockMetrics,
|
||||
readBlockCache,
|
||||
writeBlockCache
|
||||
} from './jsonl-cache';
|
||||
export { getBlockMetrics } from './jsonl-blocks';
|
||||
export {
|
||||
getSessionDuration,
|
||||
getSpeedMetrics,
|
||||
getSpeedMetricsCollection,
|
||||
getTokenMetrics
|
||||
} from './jsonl-metrics';
|
||||
+108
-49
@@ -10,11 +10,116 @@ interface Migration {
|
||||
migrate: (data: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
type V1MigratedField
|
||||
= | 'flexMode'
|
||||
| 'compactThreshold'
|
||||
| 'colorLevel'
|
||||
| 'defaultSeparator'
|
||||
| 'defaultPadding'
|
||||
| 'inheritSeparatorColors'
|
||||
| 'overrideBackgroundColor'
|
||||
| 'overrideForegroundColor'
|
||||
| 'globalBold';
|
||||
|
||||
interface V1FieldRule {
|
||||
key: V1MigratedField;
|
||||
isValid: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
// Type guards for checking data structure
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const V1_FIELD_RULES: V1FieldRule[] = [
|
||||
{
|
||||
key: 'flexMode',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'compactThreshold',
|
||||
isValid: value => typeof value === 'number'
|
||||
},
|
||||
{
|
||||
key: 'colorLevel',
|
||||
isValid: value => typeof value === 'number'
|
||||
},
|
||||
{
|
||||
key: 'defaultSeparator',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'defaultPadding',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'inheritSeparatorColors',
|
||||
isValid: value => typeof value === 'boolean'
|
||||
},
|
||||
{
|
||||
key: 'overrideBackgroundColor',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'overrideForegroundColor',
|
||||
isValid: value => typeof value === 'string'
|
||||
},
|
||||
{
|
||||
key: 'globalBold',
|
||||
isValid: value => typeof value === 'boolean'
|
||||
}
|
||||
];
|
||||
|
||||
function toWidgetLine(line: unknown[], stripSeparators: boolean): WidgetItem[] {
|
||||
const lineToProcess = stripSeparators
|
||||
? line.filter((item) => {
|
||||
if (isRecord(item)) {
|
||||
return item.type !== 'separator';
|
||||
}
|
||||
return true;
|
||||
})
|
||||
: line;
|
||||
|
||||
const typedLine: WidgetItem[] = [];
|
||||
for (const item of lineToProcess) {
|
||||
if (isRecord(item) && typeof item.type === 'string') {
|
||||
typedLine.push({
|
||||
...item,
|
||||
id: generateGuid(),
|
||||
type: item.type
|
||||
} as WidgetItem);
|
||||
}
|
||||
}
|
||||
|
||||
return typedLine;
|
||||
}
|
||||
|
||||
function migrateV1Lines(data: Record<string, unknown>): WidgetItem[][] | undefined {
|
||||
if (!Array.isArray(data.lines)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripSeparators = Boolean(data.defaultSeparator);
|
||||
const processedLines: WidgetItem[][] = [];
|
||||
|
||||
for (const line of data.lines) {
|
||||
if (Array.isArray(line)) {
|
||||
processedLines.push(toWidgetLine(line, stripSeparators));
|
||||
}
|
||||
}
|
||||
|
||||
return processedLines;
|
||||
}
|
||||
|
||||
function copyV1Fields(data: Record<string, unknown>, target: Record<string, unknown>): void {
|
||||
for (const rule of V1_FIELD_RULES) {
|
||||
const value = data[rule.key];
|
||||
if (rule.isValid(value)) {
|
||||
target[rule.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define all migrations here
|
||||
export const migrations: Migration[] = [
|
||||
{
|
||||
@@ -26,59 +131,13 @@ export const migrations: Migration[] = [
|
||||
const migrated: Record<string, unknown> = {};
|
||||
|
||||
// Process lines: strip separators if needed and assign GUIDs
|
||||
if (data.lines && Array.isArray(data.lines)) {
|
||||
const processedLines: WidgetItem[][] = [];
|
||||
|
||||
for (const line of data.lines) {
|
||||
if (Array.isArray(line)) {
|
||||
// Filter out separators if defaultSeparator is enabled
|
||||
let processedLine = line;
|
||||
if (data.defaultSeparator) {
|
||||
processedLine = line.filter((item: unknown) => {
|
||||
if (isRecord(item)) {
|
||||
return item.type !== 'separator';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Assign GUIDs to all items and build typed array
|
||||
const typedLine: WidgetItem[] = [];
|
||||
for (const item of processedLine) {
|
||||
if (isRecord(item) && typeof item.type === 'string') {
|
||||
typedLine.push({
|
||||
...item,
|
||||
id: generateGuid(),
|
||||
type: item.type
|
||||
} as WidgetItem);
|
||||
}
|
||||
}
|
||||
processedLines.push(typedLine);
|
||||
}
|
||||
}
|
||||
|
||||
const processedLines = migrateV1Lines(data);
|
||||
if (processedLines) {
|
||||
migrated.lines = processedLines;
|
||||
}
|
||||
|
||||
// Copy all v1 fields that exist
|
||||
if (typeof data.flexMode === 'string')
|
||||
migrated.flexMode = data.flexMode;
|
||||
if (typeof data.compactThreshold === 'number')
|
||||
migrated.compactThreshold = data.compactThreshold;
|
||||
if (typeof data.colorLevel === 'number')
|
||||
migrated.colorLevel = data.colorLevel;
|
||||
if (typeof data.defaultSeparator === 'string')
|
||||
migrated.defaultSeparator = data.defaultSeparator;
|
||||
if (typeof data.defaultPadding === 'string')
|
||||
migrated.defaultPadding = data.defaultPadding;
|
||||
if (typeof data.inheritSeparatorColors === 'boolean')
|
||||
migrated.inheritSeparatorColors = data.inheritSeparatorColors;
|
||||
if (typeof data.overrideBackgroundColor === 'string')
|
||||
migrated.overrideBackgroundColor = data.overrideBackgroundColor;
|
||||
if (typeof data.overrideForegroundColor === 'string')
|
||||
migrated.overrideForegroundColor = data.overrideForegroundColor;
|
||||
if (typeof data.globalBold === 'boolean')
|
||||
migrated.globalBold = data.globalBold;
|
||||
copyV1Fields(data, migrated);
|
||||
|
||||
// Add version field for v2
|
||||
migrated.version = 2;
|
||||
|
||||
+96
-21
@@ -1,29 +1,104 @@
|
||||
interface ModelContextConfig {
|
||||
maxTokens: number;
|
||||
usableTokens: number;
|
||||
maxTokens: number;
|
||||
usableTokens: number;
|
||||
}
|
||||
|
||||
export function getContextConfig(modelId?: string): ModelContextConfig {
|
||||
// Default to 200k for older models
|
||||
const defaultConfig = {
|
||||
maxTokens: 200000,
|
||||
usableTokens: 160000,
|
||||
};
|
||||
interface ModelIdentifier {
|
||||
id?: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
if (!modelId) return defaultConfig;
|
||||
const DEFAULT_CONTEXT_WINDOW_SIZE = 200000;
|
||||
const USABLE_CONTEXT_RATIO = 0.8;
|
||||
|
||||
// Sonnet 4.5 variants with 1M context (requires [1m] suffix for long context beta)
|
||||
if (
|
||||
modelId.includes("claude-sonnet-4-5") &&
|
||||
modelId.toLowerCase().includes("[1m]")
|
||||
) {
|
||||
return {
|
||||
maxTokens: 1000000,
|
||||
usableTokens: 800000, // 80% of 1M
|
||||
function toValidWindowSize(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseContextWindowSize(modelIdentifier: string): number | null {
|
||||
const delimitedMatch = /(?:\(|\[)\s*(\d+(?:[,_]\d+)*(?:\.\d+)?)\s*([km])\s*(?:\)|\])/i.exec(modelIdentifier);
|
||||
if (delimitedMatch) {
|
||||
const delimitedValue = delimitedMatch[1];
|
||||
const delimitedUnit = delimitedMatch[2];
|
||||
if (!delimitedValue || !delimitedUnit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(delimitedValue.replace(/[,_]/g, ''));
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.round(parsed * (delimitedUnit.toLowerCase() === 'm' ? 1000000 : 1000));
|
||||
}
|
||||
}
|
||||
|
||||
const contextMatch = /\b(\d+(?:[,_]\d+)*(?:\.\d+)?)\s*([km])(?:\s*(?:token\s*)?context)?\b/i.exec(modelIdentifier);
|
||||
if (!contextMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contextValue = contextMatch[1];
|
||||
const contextUnit = contextMatch[2];
|
||||
if (!contextValue || !contextUnit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(contextValue.replace(/[,_]/g, ''));
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(parsed * (contextUnit.toLowerCase() === 'm' ? 1000000 : 1000));
|
||||
}
|
||||
|
||||
export function getModelContextIdentifier(model?: string | ModelIdentifier): string | undefined {
|
||||
if (typeof model === 'string') {
|
||||
const trimmed = model.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const id = model.id?.trim();
|
||||
const displayName = model.display_name?.trim();
|
||||
|
||||
if (id && displayName) {
|
||||
return `${id} ${displayName}`;
|
||||
}
|
||||
|
||||
return id ?? displayName;
|
||||
}
|
||||
|
||||
export function getContextConfig(modelIdentifier?: string, contextWindowSize?: number | null): ModelContextConfig {
|
||||
const statusWindowSize = toValidWindowSize(contextWindowSize);
|
||||
if (statusWindowSize !== null) {
|
||||
return {
|
||||
maxTokens: statusWindowSize,
|
||||
usableTokens: Math.floor(statusWindowSize * USABLE_CONTEXT_RATIO)
|
||||
};
|
||||
}
|
||||
|
||||
// Default to 200k for older models
|
||||
const defaultConfig = {
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW_SIZE,
|
||||
usableTokens: Math.floor(DEFAULT_CONTEXT_WINDOW_SIZE * USABLE_CONTEXT_RATIO)
|
||||
};
|
||||
}
|
||||
|
||||
// Add future models here as needed
|
||||
if (!modelIdentifier) {
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
const inferredWindowSize = parseContextWindowSize(modelIdentifier);
|
||||
if (inferredWindowSize !== null) {
|
||||
return {
|
||||
maxTokens: inferredWindowSize,
|
||||
usableTokens: Math.floor(inferredWindowSize * USABLE_CONTEXT_RATIO)
|
||||
};
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as os from 'os';
|
||||
|
||||
export interface OpenExternalUrlResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OpenCommandPlan {
|
||||
command: string;
|
||||
args: (url: string) => string[];
|
||||
errorPrefix?: string;
|
||||
}
|
||||
|
||||
function runOpenCommand(command: string, args: string[]): string | null {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return result.error.message;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
return `Command exited with status ${result.status}`;
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
return `Command terminated by signal ${result.signal}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const PLATFORM_OPEN_PLANS: Record<string, OpenCommandPlan[]> = {
|
||||
darwin: [
|
||||
{
|
||||
command: 'open',
|
||||
args: url => [url]
|
||||
}
|
||||
],
|
||||
win32: [
|
||||
{
|
||||
command: 'cmd',
|
||||
args: url => ['/c', 'start', '', url]
|
||||
}
|
||||
],
|
||||
linux: [
|
||||
{
|
||||
command: 'xdg-open',
|
||||
args: url => [url],
|
||||
errorPrefix: 'xdg-open failed: '
|
||||
},
|
||||
{
|
||||
command: 'gio',
|
||||
args: url => ['open', url],
|
||||
errorPrefix: 'gio open failed: '
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function openExternalUrl(url: string): OpenExternalUrlResult {
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid URL'
|
||||
};
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Only http(s) URLs are supported'
|
||||
};
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
const plans = PLATFORM_OPEN_PLANS[platform];
|
||||
if (!plans) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Unsupported platform: ${platform}`
|
||||
};
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const plan of plans) {
|
||||
const commandError = runOpenCommand(plan.command, plan.args(url));
|
||||
if (!commandError) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (plan.errorPrefix) {
|
||||
errors.push(`${plan.errorPrefix}${commandError}`);
|
||||
} else {
|
||||
errors.push(commandError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errors.join('; ')
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Settings } from '../types/Settings';
|
||||
|
||||
import { getDefaultPowerlineTheme } from './colors';
|
||||
|
||||
function resolveEnabledPowerlineTheme(theme: string | undefined): string {
|
||||
if (!theme || theme === 'custom') {
|
||||
return getDefaultPowerlineTheme();
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
export function buildEnabledPowerlineSettings(settings: Settings, removeManualSeparators: boolean): Settings {
|
||||
const powerlineConfig = settings.powerline;
|
||||
const lines = removeManualSeparators
|
||||
? settings.lines.map(line => line.filter(item => item.type !== 'separator' && item.type !== 'flex-separator'))
|
||||
: settings.lines;
|
||||
|
||||
return {
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
enabled: true,
|
||||
theme: resolveEnabledPowerlineTheme(powerlineConfig.theme),
|
||||
// Separators are initialized by schema defaults, preserve existing values.
|
||||
separators: powerlineConfig.separators,
|
||||
separatorInvertBackground: powerlineConfig.separatorInvertBackground
|
||||
},
|
||||
defaultPadding: ' ',
|
||||
lines
|
||||
};
|
||||
}
|
||||
+59
-161
@@ -1,8 +1,4 @@
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
|
||||
// ANSI escape sequence for stripping color codes
|
||||
const ANSI_REGEX = new RegExp(`\\x1b\\[[0-9;]*m`, 'g');
|
||||
|
||||
import type {
|
||||
RenderContext,
|
||||
@@ -11,6 +7,11 @@ import type {
|
||||
import { getColorLevelString } from '../types/ColorLevel';
|
||||
import type { Settings } from '../types/Settings';
|
||||
|
||||
import {
|
||||
getVisibleWidth,
|
||||
stripSgrCodes,
|
||||
truncateStyledText
|
||||
} from './ansi';
|
||||
import {
|
||||
applyColors,
|
||||
bgToFg,
|
||||
@@ -30,6 +31,47 @@ export function formatTokens(count: number): string {
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
function resolveEffectiveTerminalWidth(
|
||||
detectedWidth: number | null,
|
||||
settings: Settings,
|
||||
context: RenderContext
|
||||
): number | null {
|
||||
if (!detectedWidth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flexMode = settings.flexMode as string;
|
||||
|
||||
if (context.isPreview) {
|
||||
if (flexMode === 'full') {
|
||||
return detectedWidth - 6;
|
||||
}
|
||||
if (flexMode === 'full-minus-40') {
|
||||
return detectedWidth - 40;
|
||||
}
|
||||
if (flexMode === 'full-until-compact') {
|
||||
return detectedWidth - 6;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (flexMode === 'full') {
|
||||
return detectedWidth - 6;
|
||||
}
|
||||
if (flexMode === 'full-minus-40') {
|
||||
return detectedWidth - 40;
|
||||
}
|
||||
if (flexMode === 'full-until-compact') {
|
||||
const threshold = settings.compactThreshold;
|
||||
const contextPercentage = calculateContextPercentage(context);
|
||||
return contextPercentage >= threshold
|
||||
? detectedWidth - 40
|
||||
: detectedWidth - 6;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPowerlineStatusLine(
|
||||
widgets: WidgetItem[],
|
||||
settings: Settings,
|
||||
@@ -81,37 +123,7 @@ function renderPowerlineStatusLine(
|
||||
const detectedWidth = context.terminalWidth ?? getTerminalWidth();
|
||||
|
||||
// Calculate terminal width based on flex mode settings
|
||||
let terminalWidth: number | null = null;
|
||||
if (detectedWidth) {
|
||||
const flexMode = settings.flexMode as string;
|
||||
|
||||
if (context.isPreview) {
|
||||
// In preview mode, account for box borders and padding (6 chars total)
|
||||
if (flexMode === 'full') {
|
||||
terminalWidth = detectedWidth - 6;
|
||||
} else if (flexMode === 'full-minus-40') {
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else if (flexMode === 'full-until-compact') {
|
||||
terminalWidth = detectedWidth - 6;
|
||||
}
|
||||
} else {
|
||||
// In actual rendering mode
|
||||
if (flexMode === 'full') {
|
||||
terminalWidth = detectedWidth - 6;
|
||||
} else if (flexMode === 'full-minus-40') {
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else if (flexMode === 'full-until-compact') {
|
||||
const threshold = settings.compactThreshold;
|
||||
const contextPercentage = calculateContextPercentage(context);
|
||||
|
||||
if (contextPercentage >= threshold) {
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else {
|
||||
terminalWidth = detectedWidth - 6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const terminalWidth = resolveEffectiveTerminalWidth(detectedWidth, settings, context);
|
||||
|
||||
// Build widget elements (similar to regular mode but without separators)
|
||||
const widgetElements: { content: string; bgColor?: string; fgColor?: string; widget: WidgetItem }[] = [];
|
||||
@@ -161,7 +173,7 @@ function renderPowerlineStatusLine(
|
||||
if (settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none'
|
||||
&& widget.type === 'custom-command' && widget.preserveColors) {
|
||||
// Strip ANSI color codes when override is active
|
||||
widgetText = widgetText.replace(ANSI_REGEX, '');
|
||||
widgetText = stripSgrCodes(widgetText);
|
||||
}
|
||||
|
||||
// Check if padding should be omitted due to no-padding merge
|
||||
@@ -231,13 +243,13 @@ function renderPowerlineStatusLine(
|
||||
const maxWidth = preCalculatedMaxWidths[alignmentPos];
|
||||
if (maxWidth !== undefined) {
|
||||
// Calculate combined width if this widget merges with following ones
|
||||
let combinedLength = stringWidth(element.content.replace(ANSI_REGEX, ''));
|
||||
let combinedLength = getVisibleWidth(element.content);
|
||||
let j = i;
|
||||
while (j < widgetElements.length - 1 && widgetElements[j]?.widget.merge) {
|
||||
j++;
|
||||
const nextElement = widgetElements[j];
|
||||
if (nextElement) {
|
||||
combinedLength += stringWidth(nextElement.content.replace(ANSI_REGEX, ''));
|
||||
combinedLength += getVisibleWidth(nextElement.content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,33 +450,9 @@ function renderPowerlineStatusLine(
|
||||
|
||||
// Handle truncation if terminal width is known
|
||||
if (terminalWidth && terminalWidth > 0) {
|
||||
const plainLength = result.replace(ANSI_REGEX, '').length;
|
||||
const plainLength = getVisibleWidth(result);
|
||||
if (plainLength > terminalWidth) {
|
||||
// Truncate to terminal width
|
||||
let truncated = '';
|
||||
let currentLength = 0;
|
||||
let inAnsiCode = false;
|
||||
|
||||
for (const char of result) {
|
||||
if (char === '\x1b') {
|
||||
inAnsiCode = true;
|
||||
truncated += char;
|
||||
} else if (inAnsiCode) {
|
||||
truncated += char;
|
||||
if (char === 'm') {
|
||||
inAnsiCode = false;
|
||||
}
|
||||
} else {
|
||||
if (currentLength < terminalWidth - 3) {
|
||||
truncated += char;
|
||||
currentLength++;
|
||||
} else {
|
||||
truncated += '...';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result = truncated;
|
||||
result = truncateStyledText(result, terminalWidth, { ellipsis: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,7 +517,7 @@ export function preRenderAllWidgets(
|
||||
|
||||
// Store the rendered content without padding (padding is applied later)
|
||||
// Use stringWidth to properly calculate Unicode character display width
|
||||
const plainLength = stringWidth(widgetText.replace(ANSI_REGEX, ''));
|
||||
const plainLength = getVisibleWidth(widgetText);
|
||||
preRenderedLine.push({
|
||||
content: widgetText,
|
||||
plainLength,
|
||||
@@ -655,43 +643,7 @@ export function renderStatusLine(
|
||||
const detectedWidth = context.terminalWidth ?? getTerminalWidth();
|
||||
|
||||
// Calculate terminal width based on flex mode settings
|
||||
let terminalWidth: number | null = null;
|
||||
if (detectedWidth) {
|
||||
const flexMode = settings.flexMode as string;
|
||||
|
||||
if (context.isPreview) {
|
||||
// In preview mode, account for box borders and padding (6 chars total)
|
||||
if (flexMode === 'full') {
|
||||
terminalWidth = detectedWidth - 6; // Subtract 6 for box borders and padding in preview
|
||||
} else if (flexMode === 'full-minus-40') {
|
||||
terminalWidth = detectedWidth - 40; // -40 for auto-compact + 3 for preview
|
||||
} else if (flexMode === 'full-until-compact') {
|
||||
// For preview, always show full width minus preview padding
|
||||
terminalWidth = detectedWidth - 6;
|
||||
}
|
||||
} else {
|
||||
// In actual rendering mode
|
||||
if (flexMode === 'full') {
|
||||
// Use full width minus 4 for terminal padding
|
||||
terminalWidth = detectedWidth - 6;
|
||||
} else if (flexMode === 'full-minus-40') {
|
||||
// Always subtract 41 for auto-compact message
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else if (flexMode === 'full-until-compact') {
|
||||
// Check context percentage to decide
|
||||
const threshold = settings.compactThreshold;
|
||||
const contextPercentage = calculateContextPercentage(context);
|
||||
|
||||
if (contextPercentage >= threshold) {
|
||||
// Context is high, leave space for auto-compact
|
||||
terminalWidth = detectedWidth - 40;
|
||||
} else {
|
||||
// Context is low, use full width minus 4 for padding
|
||||
terminalWidth = detectedWidth - 6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const terminalWidth = resolveEffectiveTerminalWidth(detectedWidth, settings, context);
|
||||
|
||||
const elements: { content: string; type: string; widget?: WidgetItem }[] = [];
|
||||
let hasFlexSeparator = false;
|
||||
@@ -776,35 +728,9 @@ export function renderStatusLine(
|
||||
// Handle max width truncation for commands with ANSI codes
|
||||
let finalOutput = widgetText;
|
||||
if (widget.maxWidth && widget.maxWidth > 0) {
|
||||
const plainLength = widgetText.replace(ANSI_REGEX, '').length;
|
||||
const plainLength = getVisibleWidth(widgetText);
|
||||
if (plainLength > widget.maxWidth) {
|
||||
// Truncate while preserving ANSI codes
|
||||
let truncated = '';
|
||||
let currentLength = 0;
|
||||
let inAnsiCode = false;
|
||||
let ansiBuffer = '';
|
||||
|
||||
for (const char of widgetText) {
|
||||
if (char === '\x1b') {
|
||||
inAnsiCode = true;
|
||||
ansiBuffer = char;
|
||||
} else if (inAnsiCode) {
|
||||
ansiBuffer += char;
|
||||
if (char === 'm') {
|
||||
truncated += ansiBuffer;
|
||||
inAnsiCode = false;
|
||||
ansiBuffer = '';
|
||||
}
|
||||
} else {
|
||||
if (currentLength < widget.maxWidth) {
|
||||
truncated += char;
|
||||
currentLength++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finalOutput = truncated;
|
||||
finalOutput = truncateStyledText(widgetText, widget.maxWidth, { ellipsis: false });
|
||||
}
|
||||
}
|
||||
// Preserve original colors from command output
|
||||
@@ -925,7 +851,7 @@ export function renderStatusLine(
|
||||
// Calculate total length of all non-flex content
|
||||
const partLengths = parts.map((part) => {
|
||||
const joined = part.join('');
|
||||
return joined.replace(ANSI_REGEX, '').length;
|
||||
return getVisibleWidth(joined);
|
||||
});
|
||||
const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0);
|
||||
|
||||
@@ -964,38 +890,10 @@ export function renderStatusLine(
|
||||
const maxWidth = terminalWidth ?? detectedWidth;
|
||||
if (maxWidth && maxWidth > 0) {
|
||||
// Remove ANSI escape codes to get actual length
|
||||
const plainLength = statusLine.replace(ANSI_REGEX, '').length;
|
||||
const plainLength = getVisibleWidth(statusLine);
|
||||
|
||||
if (plainLength > maxWidth) {
|
||||
// Need to truncate - preserve ANSI codes while truncating
|
||||
let truncated = '';
|
||||
let currentLength = 0;
|
||||
let inAnsiCode = false;
|
||||
let ansiBuffer = '';
|
||||
const targetLength = context.isPreview ? maxWidth - 3 : maxWidth - 3; // Reserve 3 chars for ellipsis
|
||||
|
||||
for (const char of statusLine) {
|
||||
if (char === '\x1b') {
|
||||
inAnsiCode = true;
|
||||
ansiBuffer = char;
|
||||
} else if (inAnsiCode) {
|
||||
ansiBuffer += char;
|
||||
if (char === 'm') {
|
||||
truncated += ansiBuffer;
|
||||
inAnsiCode = false;
|
||||
ansiBuffer = '';
|
||||
}
|
||||
} else {
|
||||
if (currentLength < targetLength) {
|
||||
truncated += char;
|
||||
currentLength++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusLine = truncated + '...';
|
||||
statusLine = truncateStyledText(statusLine, maxWidth, { ellipsis: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { WidgetItem } from '../types/Widget';
|
||||
|
||||
export function countSeparatorSlots(widgets: WidgetItem[]): number {
|
||||
const nonMergedWidgets = widgets.filter((_, idx) => idx === widgets.length - 1 || !widgets[idx]?.merge);
|
||||
return Math.max(0, nonMergedWidgets.length - 1);
|
||||
}
|
||||
|
||||
export function advanceGlobalSeparatorIndex(currentIndex: number, widgets: WidgetItem[]): number {
|
||||
return currentIndex + countSeparatorSlots(widgets);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
SkillInvocation,
|
||||
SkillsMetrics
|
||||
} from '../types/SkillsMetrics';
|
||||
|
||||
const EMPTY: SkillsMetrics = { totalInvocations: 0, uniqueSkills: [], lastSkill: null };
|
||||
|
||||
function getSkillsDir(): string {
|
||||
return path.join(os.homedir(), '.cache', 'ccstatusline', 'skills');
|
||||
}
|
||||
|
||||
export function getSkillsFilePath(sessionId: string): string {
|
||||
return path.join(getSkillsDir(), `skills-${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
export function getSkillsMetrics(sessionId: string): SkillsMetrics {
|
||||
const filePath = getSkillsFilePath(sessionId);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
try {
|
||||
const invocations: SkillInvocation[] = fs.readFileSync(filePath, 'utf-8')
|
||||
.trim().split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map((line) => {
|
||||
try { return JSON.parse(line) as SkillInvocation; } catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((e): e is SkillInvocation => e !== null && typeof e.skill === 'string' && typeof e.session_id === 'string');
|
||||
if (invocations.length === 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const uniqueSkills: string[] = [];
|
||||
const seenSkills = new Set<string>();
|
||||
for (let i = invocations.length - 1; i >= 0; i--) {
|
||||
const skill = invocations[i]?.skill;
|
||||
if (skill && !seenSkills.has(skill)) {
|
||||
seenSkills.add(skill);
|
||||
uniqueSkills.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalInvocations: invocations.length,
|
||||
uniqueSkills,
|
||||
lastSkill: invocations[invocations.length - 1]?.skill ?? null
|
||||
};
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user