Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 458a45ac91 | |||
| 1f26e52e80 | |||
| 3b0a26e3a9 | |||
| bd20bbb265 | |||
| 78836d74f3 | |||
| a95059ba2a | |||
| cccf448518 | |||
| 89a0a78467 | |||
| 48bc4208fb | |||
| b4c19ec46f | |||
| 9c8b6c2781 | |||
| c2a77c38d3 | |||
| 316d727c0f | |||
| 17938a98fc | |||
| eb1f0d17f0 | |||
| 6f875631da | |||
| cef29e128a | |||
| afbbf66b3a | |||
| c5a209b687 | |||
| 5a08aeed69 | |||
| 9f779db141 | |||
| 933d1f7852 | |||
| eef365945d | |||
| 8996d8aaf6 | |||
| 1ecc240ce8 | |||
| 298c6716e3 | |||
| efc7e8a06c | |||
| 49e5511f4a | |||
| ad91b190b1 | |||
| 5e80b5834f | |||
| ccc180301d | |||
| 67151c61d4 | |||
| 497b30e0bf | |||
| ad51a8762c | |||
| 70d1d5261f | |||
| dfe05312bd | |||
| e87ce2e815 | |||
| 00ee862200 | |||
| 315cd1128d | |||
| 23ac1f795c | |||
| 88003245e7 | |||
| 02c4eecca3 | |||
| 1206479145 | |||
| f0cffeb619 | |||
| aed41dc410 | |||
| 4af5d780bc | |||
| 7a2f032037 | |||
| 8d8819af73 | |||
| 3ff7cb7502 | |||
| e31c7908cd | |||
| d916afc2ec | |||
| e31a78add1 | |||
| 687de09eb4 | |||
| 823c45f054 | |||
| 5b49868de7 | |||
| d1e35aa832 | |||
| 1e69ae6898 | |||
| a9e4e3ea2d | |||
| 01766b79e5 | |||
| abb8038c12 | |||
| fcc5734b8d | |||
| 49c1750c19 | |||
| d1634c113c | |||
| 36a6169f4e | |||
| 60bf9b8c50 | |||
| 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 | |||
| bdcf6aac33 | |||
| fa455a30e4 | |||
| da325930ad | |||
| 8477dc5e6b | |||
| 929b004c22 | |||
| 9f26a071a7 | |||
| 9feebf9a09 | |||
| 1ad9304039 | |||
| 055bf22308 | |||
| bb73dd63c0 | |||
| 2053a5577d | |||
| 8e6fcadbcd | |||
| 19a8724989 | |||
| 36054e6b0b | |||
| 7140c3faa5 | |||
| 5c4521d014 | |||
| 798b637c58 | |||
| d9ee110fc7 | |||
| d23af975b2 | |||
| 0a5f0e58b9 | |||
| bf269e1f63 | |||
| 3010addb70 | |||
| 2842f856e9 | |||
| 2ecaf023b7 | |||
| 3bc8e225f4 | |||
| 7bb53d11e5 | |||
| b36ac6c202 | |||
| 5942afe563 | |||
| c52e4d7042 | |||
| ab198be3c0 | |||
| 5b351548a4 | |||
| 66214c950c | |||
| c106958f36 | |||
| 679bdac86e | |||
| 0ee9f7f7e2 | |||
| 17b2576cca | |||
| dade396de1 | |||
| d5b2da2a47 | |||
| 9e97e003d3 | |||
| 6cd32a5b82 | |||
| 698bdb7f1a | |||
| 0a81688010 | |||
| 8b5f6c7fb6 | |||
| 7886e1f376 | |||
| 8c89b0271e | |||
| 9354dbc9a3 | |||
| 9767fc6399 | |||
| 077dc0f5eb | |||
| 19d739c7fd | |||
| c5d9243174 | |||
| 015c1f0b1c | |||
| ad85f951a4 | |||
| 174dba2832 | |||
| b46319c4e2 | |||
| bbf50be1b0 | |||
| 8e6b1013a1 | |||
| ce768e541a | |||
| f218a6a445 | |||
| 3aea75403f | |||
| f9a113a9d2 | |||
| 539be98470 | |||
| dfaea8b6aa | |||
| 39dbc2b70b |
@@ -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
|
||||
@@ -4,6 +4,7 @@ node_modules
|
||||
# output
|
||||
out
|
||||
dist
|
||||
typedoc
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Source files
|
||||
src/
|
||||
scripts/
|
||||
*.ts
|
||||
*.tsx
|
||||
|
||||
|
||||
Vendored
+9
-1
@@ -16,5 +16,13 @@
|
||||
},
|
||||
"[javascript]": {
|
||||
"files.trimTrailingWhitespace": true
|
||||
}
|
||||
},
|
||||
"cSpell.words": [
|
||||
"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
|
||||
@@ -0,0 +1,6 @@
|
||||
Matthew Breedlove <https://github.com/sirmalloc>
|
||||
|
||||
---
|
||||
|
||||
Original author: Matthew Breedlove (https://github.com/sirmalloc)
|
||||
Official repository: https://github.com/sirmalloc/ccstatusline
|
||||
@@ -1,90 +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 with patch (TUI mode)
|
||||
bun run start
|
||||
|
||||
# Run directly (TUI mode)
|
||||
bun run statusline
|
||||
|
||||
# Test with piped input
|
||||
echo '{"model":{"display_name":"Claude 3.5 Sonnet"},"transcript_path":"test.jsonl"}' | bun run src/ccstatusline.ts
|
||||
|
||||
# Build for npm distribution
|
||||
bun run build # Creates dist/ccstatusline.js with Node.js 14+ compatibility
|
||||
|
||||
# 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
|
||||
- **colors.ts**: Color definitions and ANSI code mapping
|
||||
|
||||
## 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
|
||||
|
||||
- **patch-package**: The project uses patch-package to fix ink-gradient compatibility. Always run `bun run patch` before starting development
|
||||
- **ESLint configuration**: Uses flat config format (eslint.config.js) with TypeScript and React plugins
|
||||
- **Build target**: When building for distribution, target Node.js 14+ for maximum compatibility
|
||||
- **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**: No test framework is currently configured. Manual testing is done via piped input and TUI interaction
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 ccstatusline contributors
|
||||
Copyright (c) 2025 Matthew Breedlove (https://github.com/sirmalloc)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ccstatusline
|
||||
|
||||
Copyright (c) 2025 Matthew Breedlove (https://github.com/sirmalloc)
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
See the LICENSE file for details.
|
||||
|
||||
Original repository: https://github.com/sirmalloc/ccstatusline
|
||||
@@ -22,26 +22,159 @@
|
||||
[](https://github.com/sirmalloc/ccstatusline/graphs/commit-activity)
|
||||
|
||||
[](https://github.com/hesreallyhim/awesome-claude-code)
|
||||
[](https://claudelog.com/)
|
||||
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
<br />
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [Recent Updates](#-recent-updates)
|
||||
- [Features](#-features)
|
||||
- [Localizations](#-localizations)
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Usage](#-usage)
|
||||
- [Development](#️-development)
|
||||
- [Windows Support](docs/WINDOWS.md)
|
||||
- [Usage](docs/USAGE.md)
|
||||
- [Development](docs/DEVELOPMENT.md)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
- [Related Projects](#-related-projects)
|
||||
|
||||
---
|
||||
<br />
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
### v2.3.0 - Activity widgets and transcript-driven telemetry
|
||||
|
||||
- **🏃 New Activity widgets** - Added **All Activity**, **Tools Activity**, **Agents Activity**, and **Todo Progress** widgets for live workflow visibility.
|
||||
- **🧠 Transcript activity parsing** - Parses `tool_use`/`tool_result`, `Task` subagent calls, and todo updates (`TodoWrite`, `TaskCreate`, `TaskUpdate`) from Claude transcript JSONL.
|
||||
- **📏 Activity widget width control** - Activity widgets support per-widget max-width editing with **(w)**, including clearing truncation.
|
||||
|
||||
### v2.2.9 - v2.2.11 - GitLab support, reset timers, context, compaction, and git widgets
|
||||
|
||||
- **🦊 GitLab PR/MR support** - `Git Branch` and `Git PR/MR` now support GitHub, GitLab, and compatible self-hosted remotes, using `gh` or `glab` as appropriate.
|
||||
- **🔄 Status line refresh interval** - Installed configs can set Claude Code's `statusLine.refreshInterval` from the TUI when Claude Code >=2.1.97 supports it.
|
||||
- **🧭 Wrap-around TUI navigation** - Menu/list navigation and move/reorder modes now wrap at the first and last items.
|
||||
- **📋 Clone widget shortcut** - Press `k` in the item editor to duplicate the selected widget, with fresh Powerline background color for cloned Powerline items.
|
||||
- **📊 Short bar display modes** - Context percentage, Context Bar, Session Usage, Weekly Usage, Block Timer, and reset timer widgets can use compact bar variants.
|
||||
- **🕒 Reset timer timestamps** - Block and Weekly Reset Timer widgets can show exact reset timestamps with compact formatting, 12/24-hour display, IANA time zones, and locale selection.
|
||||
- **🪟 Context Window widget** - Added a `Context Window` widget for total model window size, keeping `Context Length` focused on current context usage.
|
||||
- **🔁 Compaction Counter widget** - Added a `Compaction Counter` widget that tracks session context compactions, with icon/text/number formats, optional Nerd Font icon, and hide-when-zero behavior.
|
||||
- **🧮 Git file status widgets** - Added `Git Staged Files`, `Git Unstaged Files`, `Git Untracked Files`, and `Git Clean Status` for file counts and clean/dirty state.
|
||||
- **🧠 Thinking Effort updates** - Added `xhigh`, show `default` when no effort is set, mark unknown future effort levels with `?`, and track live status JSON plus `/effort` command changes.
|
||||
- **🧮 More accurate token counts** - Streaming duplicate JSONL entries are deduped so token widgets do not overcount live Claude Code output.
|
||||
- **🏷️ Cleaner model display** - The Model widget strips trailing context suffixes like `(1M context)`; use `Context Window` when you want the total window size shown.
|
||||
- **🧹 Cleaner empty-widget separators** - Manual separators now collapse around widgets that render empty, avoiding dangling separators when hide-when-empty widgets disappear.
|
||||
- **🧱 More resilient Git helpers** - Git widgets handle missing or unusual git command output more defensively.
|
||||
|
||||
### v2.2.8 - Git widgets, smarter picker search, and minimalist mode
|
||||
|
||||
- **🔀 New Git PR widget** - Added a `Git PR` widget with clickable PR links plus optional status and title display for the current branch.
|
||||
- **🧰 Major Git widget expansion** - Added `Git Status`, `Git Staged`, `Git Unstaged`, `Git Untracked`, `Git Ahead/Behind`, `Git Conflicts`, `Git SHA`, `Git Origin Owner`, `Git Origin Repo`, `Git Origin Owner/Repo`, `Git Upstream Owner`, `Git Upstream Repo`, `Git Upstream Owner/Repo`, `Git Is Fork`, `Git Worktree Mode`, `Git Worktree Name`, `Git Worktree Branch`, `Git Worktree Original Branch`, and `Custom Symbol`.
|
||||
- **👤 Claude Account Email widget** - Added a session widget that reads the signed-in Claude account email from `~/.claude.json` while respecting `CLAUDE_CONFIG_DIR`.
|
||||
- **🧼 Global Minimalist Mode** - Added a global toggle in `Global Overrides` that forces widgets into raw-value mode for a cleaner, label-free status line.
|
||||
- **🔎 Smarter widget picker search** - The add/change widget picker now supports substring, initialism, and fuzzy matching, with ranked results and live match highlighting.
|
||||
- **📏 Better terminal width detection** - Flex separators and right-alignment now work more reliably when ccstatusline is launched through wrapper processes or nested PTYs.
|
||||
- **🎨 Powerline theme continuity** - Built-in Powerline themes can now continue colors cleanly across multiple status lines instead of restarting each line.
|
||||
|
||||
### v2.2.0 - v2.2.6 - Speed, widgets, links, and reliability updates
|
||||
|
||||
- **🚀 New Token Speed widgets** - Added three widgets: **Input Speed**, **Output Speed**, and **Total Speed**.
|
||||
- Each speed widget supports a configurable window of `0-120` seconds in the widget editor (`w` key).
|
||||
- `0` disables window mode and uses a full-session average speed.
|
||||
- `1-120` calculates recent speed over the selected rolling window.
|
||||
- **🧩 New Skills widget controls (v2.2.1)** - Added configurable Skills modes (last/count/list), optional hide-when-empty behavior, and list-size limiting with most-recent-first ordering.
|
||||
- **🌐 Usage API proxy support (v2.2.2)** - Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
|
||||
- **🧠 New Thinking Effort widget (v2.2.4)** - Added a widget that shows the current Claude Code thinking effort level.
|
||||
- **🍎 Better macOS usage lookup reliability (v2.2.5)** - Improved reliability when loading usage API tokens on macOS.
|
||||
- **⌨️ New Vim Mode widget (v2.2.5)** - Added a widget that shows the current vim mode, with ASCII and optional Nerd Font icon display.
|
||||
- **🔗 Git widget link modes (v2.2.6)** - `Git Branch` can render clickable GitHub branch links, and `Git Root Dir` can render clickable IDE links for VS Code and Cursor.
|
||||
- **🤝 Better subagent-aware speed reporting** - Token speed calculations continue to include referenced subagent activity so displayed speeds better reflect actual concurrent work.
|
||||
|
||||
<br />
|
||||
<details>
|
||||
<summary><b>Older updates (v2.1.10 and earlier)</b></summary>
|
||||
|
||||
### v2.1.0 - v2.1.10 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes
|
||||
|
||||
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Block Reset Timer**, and **Context Bar** widgets.
|
||||
- **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts.
|
||||
- **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file.
|
||||
- **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support.
|
||||
- **➕ New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`).
|
||||
- **➖ New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`).
|
||||
- **🧠 Context format fallback fix (v2.1.6)** - When `context_window_size` is missing, context widgets now infer 1M models from long-context labels such as `[1m]` and `1M context` in model identifiers.
|
||||
- **⏳ Weekly reset timer split (v2.1.7)** - Added a separate `Weekly Reset Timer` widget.
|
||||
- **⚙️ Custom config file flag (v2.1.8)** - Added `--config <path>` support so ccstatusline can load/save settings from a custom file location.
|
||||
- **🔣 Unicode separator hex input upgrade (v2.1.9)** - Powerline separator hex input now supports 4-6 digits (full Unicode code points up to `U+10FFFF`).
|
||||
- **🌳 Bare repo worktree detection fix (v2.1.10)** - `Git Worktree` now correctly detects linked worktrees created from bare repositories.
|
||||
|
||||
### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements
|
||||
|
||||
- **🧠 Memory Usage widget (v2.0.29)** - Added a new widget that shows current system memory usage (`Mem: used/total`).
|
||||
- **⚡ Block timer cache (v2.0.28)** - Cache block timer metrics to reduce JSONL parsing on every render, with per-config hashed cache files and automatic 5-hour block invalidation.
|
||||
- **🧱 Git widget command refactor (v2.0.28)** - Refactored git widgets to use shared git command helpers and expanded coverage for failure and edge-case tests.
|
||||
- **🪟 Windows UTF-8 piped output fix (v2.0.28)** - Sets the Windows UTF-8 code page for piped status line rendering.
|
||||
- **📁 Git Root Dir widget (v2.0.27)** - Added a new Git widget that shows the repository root directory name.
|
||||
- **🏷️ Session Name widget (v2.0.26)** - Added a new widget that shows the current Claude Code session name from `/rename`.
|
||||
- **🏠 Current Working Directory home abbreviation (v2.0.26)** - Added a `~` abbreviation option for CWD display in both preview and live rendering.
|
||||
- **🧠 Context model suffix fix (v2.0.26)** - Context widgets now recognize the `[1m]` suffix across models, not just a single model path.
|
||||
- **🧭 Widget picker UX updates (v2.0.26)** - Improved widget discovery/navigation and added clearer, safer clear-line behavior.
|
||||
- **⌨️ TUI editor input fix (v2.0.26)** - Prevented shortcut/input leakage into widget editor flows.
|
||||
- **📄 Repo docs update (v2.0.26)** - Migrated guidance from `CLAUDE.md` to `AGENTS.md` (with symlink compatibility).
|
||||
|
||||
### v2.0.16 - Add fish style path abbreviation toggle to Current Working Directory widget
|
||||
|
||||
### v2.0.15 - Block Timer calculation fixes
|
||||
|
||||
- Fix miscalculation in the block timer
|
||||
|
||||
### v2.0.14 - Add remaining mode toggle to Context Percentage widgets
|
||||
|
||||
- **Remaining Mode** - You can now toggle the Context Percentage widgets between usage percentage and remaining percentage when configuring them in the TUI by pressing the 'u' key.
|
||||
|
||||
### v2.0.12 - Custom Text widget now supports emojis
|
||||
|
||||
- **👾 Emoji Support** - You can now paste emoji into the custom text widget. You can also turn on the merge option to get emoji labels for your widgets like this:
|
||||
|
||||

|
||||
|
||||
### v2.0.11 - Unlimited Status Lines
|
||||
|
||||
- **🚀 No Line Limit** - Configure as many status lines as you need - the 3-line limitation has been removed
|
||||
|
||||
### v2.0.10 - Git Updates
|
||||
|
||||
- **🌳 Git Worktree widget** - Shows the active worktree name when working with git worktrees
|
||||
- **👻 Hide 'no git' message toggle** - Git widgets now support hiding the 'no git' message when not in a repository (toggle with 'h' key while editing the widget)
|
||||
|
||||
### v2.0.8 - Powerline Auto-Alignment
|
||||
|
||||

|
||||
|
||||
- **🎯 Widget Alignment** - Auto-align widgets across multiple status lines in Powerline mode for a clean, columnar layout (toggle with 'a' in Powerline Setup)
|
||||
|
||||
### v2.0.7 - Current Working Directory & Session Cost
|
||||
|
||||

|
||||
|
||||
- **📁 Current Working Directory** - Display the current working directory with configurable segment display
|
||||
- Set the number of path segments to show (e.g., show only last 2 segments: `.../Personal/ccstatusline`)
|
||||
- Supports raw value mode for compact display
|
||||
- Automatically truncates long paths with ellipsis
|
||||
- **💰 Session Cost Widget** - Track your Claude Code session costs (requires Claude Code 1.0.85+)
|
||||
- Displays total session cost in USD
|
||||
- Supports raw value mode (shows just `$X.YZ` vs `Cost: $X.YZ`)
|
||||
- Real-time cost tracking from Claude Code session data
|
||||
- Note: Cost may not update properly when using `/resume` (Claude Code limitation)
|
||||
- **🐛 Bug Fixes**
|
||||
- Fixed Block Timer calculations for accurate time tracking across block boundaries
|
||||
- Improved widget editor stability with proper Ctrl+S handling
|
||||
- Enhanced cursor display in numeric input fields
|
||||
|
||||
### v2.0.2 - Block Timer Widget
|
||||
|
||||

|
||||
@@ -61,21 +194,33 @@
|
||||
- **🔤 Custom Separators** - Add multiple Powerline separators with custom hex codes for font support
|
||||
- **🚀 Auto Font Install** - Automatic Powerline font installation with user consent
|
||||
|
||||
---
|
||||
</details>
|
||||
|
||||
<br />
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **📊 Real-time Metrics** - Display model name, git branch, token usage, session duration, block timer, and more
|
||||
- **📊 Real-time Metrics** - Display model name, git branch, token usage, session duration, compaction count, block timer, and more
|
||||
- **🎨 Fully Customizable** - Choose what to display and customize colors for each element
|
||||
- **⚡ Powerline Support** - Beautiful Powerline-style rendering with arrow separators, caps, and custom fonts
|
||||
- **📐 Multi-line Support** - Configure up to 3 independent status lines
|
||||
- **📐 Multi-line Support** - Configure multiple independent status lines
|
||||
- **🖥️ Interactive TUI** - Built-in configuration interface using React/Ink
|
||||
- **⚙️ Global Options** - Apply consistent formatting across all items (padding, separators, bold, background)
|
||||
- **🔎 Fast Widget Picker** - Add/change widgets by category with search and ranked matching
|
||||
- **⚙️ Global Options** - Apply consistent formatting across all widgets (padding, separators, bold, minimalist mode, and color overrides)
|
||||
- **🚀 Cross-platform** - Works seamlessly with both Bun and Node.js
|
||||
- **🔧 Flexible Configuration** - Supports custom Claude Code config directory via `CLAUDE_CONFIG_DIR` environment variable
|
||||
- **📏 Smart Width Detection** - Automatically adapts to terminal width with flex separators
|
||||
- **⚡ Zero Config** - Sensible defaults that work out of the box
|
||||
|
||||
---
|
||||
<br />
|
||||
|
||||
## 🌐 Localizations
|
||||
|
||||
The localizations in this section are third-party forks maintained outside this repository. They are not maintained, reviewed, or endorsed by this repository, so review their code and releases before using them.
|
||||
|
||||
- 🌏 **中文版 (Chinese):** [ccstatusline-zh](https://github.com/huangguang1999/ccstatusline-zh)
|
||||
|
||||
<br />
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
@@ -83,237 +228,64 @@
|
||||
|
||||
```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
|
||||
<br />
|
||||
<details>
|
||||
<summary><b>Configure ccstatusline</b></summary>
|
||||
|
||||
The interactive configuration tool provides a terminal UI where you can:
|
||||
- Configure up to 3 separate status lines
|
||||
- Add/remove/reorder status line items
|
||||
- Customize colors for each element
|
||||
- Configure multiple separate status lines
|
||||
- Add/remove/reorder status line widgets
|
||||
- Customize colors for each widget
|
||||
- Configure flex separator behavior
|
||||
- Edit custom text items
|
||||
- Configure Claude Code status line refresh interval when supported
|
||||
- Edit custom text widgets
|
||||
- Install/uninstall to Claude Code settings
|
||||
- Preview your status line in real-time
|
||||
|
||||
> 💡 **Tip:** Your settings are automatically saved to `~/.config/ccstatusline/settings.json`
|
||||
|
||||
---
|
||||
> 🔧 **Custom Claude Config:** If your Claude Code configuration is in a non-standard location, set the `CLAUDE_CONFIG_DIR` environment variable:
|
||||
> ```bash
|
||||
> # Linux/macOS
|
||||
> export CLAUDE_CONFIG_DIR=/custom/path/to/.claude
|
||||
> ```
|
||||
|
||||
## 📖 Usage
|
||||
> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
|
||||
|
||||
Once configured, ccstatusline automatically formats your Claude Code status line. The status line appears at the bottom of your terminal during Claude Code sessions.
|
||||
|
||||
### 📊 Available Status Items
|
||||
|
||||
- **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")
|
||||
- **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m")
|
||||
- **Block Timer** - Shows time elapsed in current 5-hour block or progress bar
|
||||
- **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 (out of 200k)
|
||||
- **Context Percentage (usable)** - Shows percentage of usable context (out of 160k, 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 items (customizable: |, -, comma, space)
|
||||
- **Flex Separator** - Expands to fill available space
|
||||
|
||||
---
|
||||
|
||||
### Terminal Width Options
|
||||
These settings affect where long lines are truncated, and where right-alignment occurs when using flex separators:
|
||||
- **Full width always** - Uses full terminal width (may wrap if auto-compact message appears or IDE integration adds text)
|
||||
- **Full width minus 40** - Reserves 40 characters for auto-compact message to prevent wrapping (default)
|
||||
- **Full width until compact** - Dynamically switches between full width and minus 40 based on context percentage threshold (configurable, default 60%)
|
||||
|
||||
---
|
||||
|
||||
### ⚙️ Global Options
|
||||
|
||||
Configure global formatting preferences that apply to all status items:
|
||||
|
||||

|
||||
|
||||
#### Default Padding & Separators
|
||||
- **Default Padding** - Add consistent padding to the left and right of each item
|
||||
- **Default Separator** - Automatically insert a separator between all items
|
||||
- Press **(p)** to edit padding
|
||||
- Press **(s)** to edit separator
|
||||
|
||||
<details>
|
||||
<summary><b>Global Formatting Options</b></summary>
|
||||
|
||||
- **Inherit Colors** - Default separators inherit foreground and background colors from the preceding widget
|
||||
- Press **(i)** to toggle
|
||||
- **Global Bold** - Apply bold formatting to all text regardless of individual item settings
|
||||
- Press **(o)** to toggle
|
||||
- **Override Foreground Color** - Force all items to use the same text color
|
||||
- Press **(f)** to cycle through colors
|
||||
- Press **(g)** to clear override
|
||||
- **Override Background Color** - Force all items to use the same background color
|
||||
- Press **(b)** to cycle through colors
|
||||
- Press **(c)** to clear override
|
||||
> 🪟 **Windows Support:** PowerShell examples, installation notes, fonts, troubleshooting, WSL, and Windows Terminal configuration are in [docs/WINDOWS.md](docs/WINDOWS.md).
|
||||
|
||||
</details>
|
||||
|
||||
> 💡 **Note:** These settings are applied during rendering and don't add items to your widget list. They provide a consistent look across your entire status line without modifying individual item configurations.
|
||||
<details>
|
||||
<summary><b>Claude Code settings.json format</b></summary>
|
||||
|
||||
> ⚠️ **VSCode Users:** If colors appear incorrect in the VSCode integrated terminal, the "Terminal › Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors.
|
||||
When you install from the TUI, ccstatusline writes a `statusLine` command object to your Claude Code settings:
|
||||
|
||||
### ⏱️ Block Timer Widget
|
||||
|
||||
The Block Timer widget helps you track your progress through Claude Code's 5-hour conversation blocks:
|
||||
|
||||

|
||||
|
||||
**Display Modes:**
|
||||
- **Time Display** - Shows elapsed time as "3hr 45m" (default)
|
||||
- **Progress Bar** - Full width 32-character progress bar with percentage
|
||||
- **Progress Bar (Short)** - Compact 16-character progress bar with percentage
|
||||
|
||||
**Features:**
|
||||
- Automatically detects block boundaries from transcript timestamps
|
||||
- Floors block start time to the hour for consistent tracking
|
||||
- Shows "Block: 3hr 45m" in normal mode or just "3hr 45m" in raw value mode
|
||||
- Progress bars show completion percentage (e.g., "[████████████████████████░░░░░░░░] 73.9%")
|
||||
- Toggle between modes with the **(p)** key in the items editor
|
||||
|
||||
### 🔤 Raw Value Mode
|
||||
|
||||
Some items support "raw value" mode which displays just the value without a label:
|
||||
- Normal: `Model: Claude 3.5 Sonnet` → Raw: `Claude 3.5 Sonnet`
|
||||
- Normal: `Session: 2hr 15m` → Raw: `2hr 15m`
|
||||
- Normal: `Block: 3hr 45m` → Raw: `3hr 45m`
|
||||
- Normal: `Ctx: 18.6k` → Raw: `18.6k`
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Custom Widgets
|
||||
|
||||
#### Custom Text Widget
|
||||
Add static text to your status line. Perfect for:
|
||||
- Project identifiers
|
||||
- Environment indicators (dev/prod)
|
||||
- Personal labels or reminders
|
||||
|
||||
#### Custom Command Widget
|
||||
Execute shell commands and display their output dynamically:
|
||||
- Refreshes whenever the statusline is updated by Claude Code
|
||||
- Receives the full Claude Code JSON data via stdin (model info, session ID, transcript path, etc.)
|
||||
- Displays command output inline in your status line
|
||||
- Configurable timeout (default: 1000ms)
|
||||
- Examples:
|
||||
- `pwd | xargs basename` - Show current directory name
|
||||
- `node -v` - Display Node.js version
|
||||
- `git rev-parse --short HEAD` - Show current commit hash
|
||||
- `date +%H:%M` - Display current time
|
||||
- `curl -s wttr.in?format="%t"` - Show current temperature
|
||||
- `npx -y ccusage@latest statusline` - Display Claude usage metrics (set timeout: 5000ms)
|
||||
|
||||
> ⚠️ **Important:** Commands should complete quickly to avoid delays. Long-running commands will be killed after the configured timeout. If you're not seeing output from your custom command, try increasing the timeout value (press 't' in the editor).
|
||||
|
||||
> 💡 **Tip:** Custom commands can be other Claude Code compatible status line formatters! They receive the same JSON via stdin that ccstatusline receives from Claude Code, allowing you to chain or combine multiple status line tools.
|
||||
|
||||
---
|
||||
|
||||
### 🔗 Integration Example: ccusage
|
||||
|
||||
[ccusage](https://github.com/ryoppippi/ccusage) is a tool that tracks and displays Claude Code usage metrics. You can integrate it directly into your status line:
|
||||
|
||||
1. Add a Custom Command widget
|
||||
2. Set command: `npx -y ccusage@latest statusline`
|
||||
3. Set timeout: `5000` (5 seconds for initial download)
|
||||
4. Enable "preserve colors" to keep ccusage's color formatting
|
||||
|
||||

|
||||
|
||||
> 📄 **How it works:** The command receives Claude Code's JSON data via stdin, allowing ccusage to access session information, model details, and transcript data for accurate usage tracking.
|
||||
|
||||
### ✂️ Smart Truncation
|
||||
|
||||
When terminal width is detected, status lines automatically truncate with ellipsis (...) if they exceed the available width, preventing line wrapping.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh) (v1.0+)
|
||||
- Git
|
||||
- Node.js 18+ (optional, for npm publishing)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/ccstatusline.git
|
||||
cd ccstatusline
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
```json
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "npx -y ccstatusline@latest",
|
||||
"padding": 0,
|
||||
"refreshInterval": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
`refreshInterval` is written only when your Claude Code version supports it (>=2.1.97). The TUI can set it to `1-60` seconds, or remove it by leaving the input empty.
|
||||
|
||||
```bash
|
||||
# Run in TUI mode (configuration)
|
||||
bun run src/ccstatusline.ts
|
||||
Other supported command values are:
|
||||
- `bunx -y ccstatusline@latest`
|
||||
- `ccstatusline` (for self-managed/global installs)
|
||||
|
||||
# Build for distribution
|
||||
bun run build
|
||||
```
|
||||
</details>
|
||||
|
||||
### 📁 Project Structure
|
||||
|
||||
```
|
||||
ccstatusline/
|
||||
├── src/
|
||||
│ ├── ccstatusline.ts # Main entry point
|
||||
│ ├── tui/ # React/Ink configuration UI
|
||||
│ │ ├── App.tsx # Root TUI component
|
||||
│ │ ├── index.tsx # TUI entry point
|
||||
│ │ └── components/ # UI components
|
||||
│ │ ├── MainMenu.tsx
|
||||
│ │ ├── LineSelector.tsx
|
||||
│ │ ├── ItemsEditor.tsx
|
||||
│ │ ├── ColorMenu.tsx
|
||||
│ │ ├── PowerlineSetup.tsx
|
||||
│ │ └── ...
|
||||
│ ├── widgets/ # Status line widget implementations
|
||||
│ │ ├── Model.ts
|
||||
│ │ ├── GitBranch.ts
|
||||
│ │ ├── TokensTotal.ts
|
||||
│ │ ├── OutputStyle.ts
|
||||
│ │ └── ...
|
||||
│ ├── utils/ # Utility functions
|
||||
│ │ ├── config.ts # Settings management
|
||||
│ │ ├── renderer.ts # Core rendering logic
|
||||
│ │ ├── powerline.ts # Powerline font utilities
|
||||
│ │ ├── colors.ts # Color definitions
|
||||
│ │ └── claude-settings.ts # Claude Code integration
|
||||
│ └── types/ # TypeScript type definitions
|
||||
│ ├── Settings.ts
|
||||
│ ├── Widget.ts
|
||||
│ ├── PowerlineConfig.ts
|
||||
│ └── ...
|
||||
├── dist/ # Built files (generated)
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
@@ -324,13 +296,18 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If ccstatusline is useful to you, consider buying me a coffee:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/sirmalloc" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](LICENSE) © Matthew Breedlove
|
||||
|
||||
---
|
||||
|
||||
## 👤 Author
|
||||
|
||||
@@ -338,14 +315,13 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
- GitHub: [@sirmalloc](https://github.com/sirmalloc)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Projects
|
||||
|
||||
- [tweakcc](https://github.com/Piebald-AI/tweakcc) - Customize Claude Code themes, thinking verbs, and more.
|
||||
- [ccusage](https://github.com/samuelint/ccusage) - Track and display Claude Code usage metrics.
|
||||
- [ccusage](https://github.com/ryoppippi/ccusage) - Track and display Claude Code usage metrics.
|
||||
- [codachi](https://github.com/vincent-k2026/codachi) - A tamagotchi-style statusline pet that grows with your context window.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
@@ -353,7 +329,7 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
- Powered by [Ink](https://github.com/vadimdemedes/ink) for the terminal UI
|
||||
- Made with ❤️ for the Claude Code community
|
||||
|
||||
---
|
||||
<br />
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Development
|
||||
|
||||
Development setup, project structure, and API documentation for `ccstatusline`.
|
||||
|
||||
If you want the main project overview, return to [README.md](../README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh) (v1.0+)
|
||||
- Git
|
||||
- Node.js 14+ (optional, for running the built `dist/ccstatusline.js` binary or npm publishing)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/sirmalloc/ccstatusline.git
|
||||
cd ccstatusline
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Run in TUI mode
|
||||
bun run start
|
||||
|
||||
# Test piped mode with example payload
|
||||
bun run example
|
||||
|
||||
# Run tests
|
||||
bun test
|
||||
|
||||
# Run typecheck + eslint checks without modifying files
|
||||
bun run lint
|
||||
|
||||
# Apply ESLint auto-fixes intentionally
|
||||
bun run lint:fix
|
||||
|
||||
# Build for distribution
|
||||
bun run build
|
||||
|
||||
# Generate TypeDoc documentation
|
||||
bun run docs
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- `~/.config/ccstatusline/settings.json` - ccstatusline UI/render settings
|
||||
- `~/.claude/settings.json` - Claude Code settings (`statusLine` command object)
|
||||
- `~/.cache/ccstatusline/block-cache-*.json` - block timer cache (keyed by Claude config directory hash)
|
||||
- `~/.cache/ccstatusline/compaction/compaction-*.json` - per-session compaction counter state
|
||||
|
||||
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
|
||||
|
||||
## API Documentation
|
||||
|
||||
Complete API documentation is generated using TypeDoc and includes detailed information about:
|
||||
|
||||
- **Core Types**: Configuration interfaces, widget definitions, and render contexts
|
||||
- **Widget System**: All available widgets and their customization options
|
||||
- **Utility Functions**: Helper functions for rendering, configuration, and terminal handling
|
||||
- **Status Line Rendering**: Core rendering engine and formatting options
|
||||
|
||||
### Generating Documentation
|
||||
|
||||
To generate the API documentation locally:
|
||||
|
||||
```bash
|
||||
# Generate documentation
|
||||
bun run docs
|
||||
|
||||
# Clean generated documentation
|
||||
bun run docs:clean
|
||||
```
|
||||
|
||||
The documentation will be generated in the `typedoc/` directory and can be viewed by opening `typedoc/index.html` in your web browser.
|
||||
|
||||
### Documentation Structure
|
||||
|
||||
- **Types**: Core TypeScript interfaces and type definitions
|
||||
- **Widgets**: Individual widget implementations and their APIs
|
||||
- **Utils**: Utility functions for configuration, rendering, and terminal operations
|
||||
- **Main Module**: Primary entry point and orchestration functions
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
ccstatusline/
|
||||
├── src/
|
||||
│ ├── ccstatusline.ts # Main entry point
|
||||
│ ├── tui/ # React/Ink configuration UI
|
||||
│ │ ├── App.tsx # Root TUI component
|
||||
│ │ ├── index.tsx # TUI entry point
|
||||
│ │ └── components/ # UI components
|
||||
│ │ ├── MainMenu.tsx
|
||||
│ │ ├── LineSelector.tsx
|
||||
│ │ ├── ItemsEditor.tsx
|
||||
│ │ ├── ColorMenu.tsx
|
||||
│ │ ├── PowerlineSetup.tsx
|
||||
│ │ └── ...
|
||||
│ ├── widgets/ # Status line widget implementations
|
||||
│ │ ├── Model.ts
|
||||
│ │ ├── GitBranch.ts
|
||||
│ │ ├── TokensTotal.ts
|
||||
│ │ ├── OutputStyle.ts
|
||||
│ │ └── ...
|
||||
│ ├── utils/ # Utility functions
|
||||
│ │ ├── config.ts # Settings management
|
||||
│ │ ├── renderer.ts # Core rendering logic
|
||||
│ │ ├── powerline.ts # Powerline font utilities
|
||||
│ │ ├── colors.ts # Color definitions
|
||||
│ │ └── claude-settings.ts # Claude Code integration (supports CLAUDE_CONFIG_DIR)
|
||||
│ └── types/ # TypeScript type definitions
|
||||
│ ├── Settings.ts
|
||||
│ ├── Widget.ts
|
||||
│ ├── PowerlineConfig.ts
|
||||
│ └── ...
|
||||
├── dist/ # Built files (generated)
|
||||
├── docs/ # Hand-written repository docs
|
||||
├── typedoc/ # Generated API docs
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
# Usage
|
||||
|
||||
Usage documentation for `ccstatusline`.
|
||||
|
||||
If you want the main project overview, return to [README.md](../README.md).
|
||||
|
||||
Once configured, `ccstatusline` automatically formats your Claude Code status line. The status line appears at the bottom of your terminal during Claude Code sessions.
|
||||
|
||||
## Runtime Modes
|
||||
|
||||
- **Interactive mode (TUI)**: Launches when there is no stdin input
|
||||
- **Piped mode (renderer)**: Parses Claude Code status JSON from stdin and prints one or more formatted lines
|
||||
|
||||
```bash
|
||||
# Interactive TUI
|
||||
bun run start
|
||||
|
||||
# Piped mode with example payload
|
||||
bun run example
|
||||
```
|
||||
|
||||
## Available Widgets
|
||||
|
||||
### Claude & Session
|
||||
|
||||
- **Model** / **Output Style** / **Version** - Show the active Claude model, output style, and Claude Code CLI version. Model names omit trailing context suffixes like `(1M context)`; use **Context Window** when you want the total window size shown.
|
||||
- **Claude Session ID** / **Session Name** / **Claude Account Email** - Show session identifiers plus the currently signed-in Claude account email.
|
||||
- **Thinking Effort** / **Vim Mode** / **Skills** - Show Claude thinking effort, the current vim editing mode, and skill activity from hook data. Thinking Effort reads live status JSON first, then `/model` or `/effort` transcript output, then settings fallback; it supports `low`, `medium`, `high`, `xhigh`, and `max`, shows `default` when no effort is set, and marks unknown future values with `?`.
|
||||
- **Session Clock** / **Session Cost** - Show elapsed session time and the current session cost in USD.
|
||||
|
||||
### Activity
|
||||
|
||||
- **All Activity** - Show a compact running-first summary across tools, agents, and todos, with completion counters.
|
||||
- **Tools Activity** - Show currently running and recently completed tool operations.
|
||||
- **Agents Activity** - Show running and recently completed Claude subagent tasks.
|
||||
- **Todo Progress** - Show in-progress todo focus and completion ratio from transcript tasks.
|
||||
|
||||
### Git
|
||||
|
||||
- **Git Branch** / **Git Root Dir** / **Git PR** - Show the current branch, repository root directory, and PR/MR details for the current branch with optional links. Works with GitHub (`gh`) and GitLab (`glab`); for self-hosted hosts whose name contains neither token, whichever CLI is authenticated against that host (`gh auth status --hostname <h>` / `glab auth status --hostname <h>`) is used.
|
||||
- **Git Changes** / **Git Insertions** / **Git Deletions** - Show aggregate file-change counts and dedicated insertion/deletion counts.
|
||||
- **Git Status** / **Git Staged** / **Git Unstaged** / **Git Untracked** / **Git Ahead/Behind** / **Git Conflicts** / **Git SHA** - Show compact repo-state indicators, upstream divergence, merge-conflict count, and the current short commit SHA.
|
||||
- **Git Staged Files** / **Git Unstaged Files** / **Git Untracked Files** / **Git Clean Status** - Show file-level status counts and clean/dirty state.
|
||||
- **Git Origin Owner** / **Git Origin Repo** / **Git Origin Owner/Repo** - Show parsed `origin` remote metadata.
|
||||
- **Git Upstream Owner** / **Git Upstream Repo** / **Git Upstream Owner/Repo** / **Git Is Fork** - Show upstream remote metadata and whether the current repo is a fork.
|
||||
- **Git Worktree** / **Git Worktree Mode** / **Git Worktree Name** / **Git Worktree Branch** / **Git Worktree Original Branch** - Show worktree status plus the active worktree's name and branch metadata.
|
||||
|
||||
### Tokens, Usage & Context
|
||||
|
||||
- **Tokens Input** / **Tokens Output** / **Tokens Cached** / **Tokens Total** - Show current-session token counts.
|
||||
- **Input Speed** / **Output Speed** / **Total Speed** - Show session-average token throughput with an optional per-widget rolling window (`0-120` seconds; `0` = full-session average).
|
||||
- **Context Length** / **Context Window** / **Context %** / **Context % (usable)** / **Context Bar** - Show current context length, total context window size, usage percentage, usable-window percentage, or a progress bar.
|
||||
- **Compaction Counter** - Show how many context compactions have been detected in the current session. It can render as icon plus number, text plus number, or number-only, and can hide while the count is zero.
|
||||
- **Session Usage** / **Weekly Usage** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages plus current block/reset timing. Reset timers can show remaining time, progress, or exact reset date/time with timezone and locale controls.
|
||||
|
||||
### Environment, Layout & Custom
|
||||
|
||||
- **Current Working Dir** / **Terminal Width** / **Memory Usage** - Show the current working directory, detected terminal width, and system memory usage.
|
||||
- **Custom Text** / **Custom Symbol** / **Custom Command** / **Link** - Add user-defined text, a single symbol or emoji, custom command output, or a clickable OSC 8 hyperlink.
|
||||
- **Separator** / **Flex Separator** - Add a manual divider or a width-filling flexible spacer (available when Powerline mode is off).
|
||||
|
||||
## Terminal Width Options
|
||||
|
||||
These settings affect where long lines are truncated, and where right-alignment occurs when using flex separators:
|
||||
- **Full width always** - Uses full terminal width (may wrap if auto-compact message appears or IDE integration adds text)
|
||||
- **Full width minus 40** - Reserves 40 characters for auto-compact message to prevent wrapping (default)
|
||||
- **Full width until compact** - Dynamically switches between full width and minus 40 based on context percentage threshold (configurable, default 60%)
|
||||
|
||||
## Global Options
|
||||
|
||||
Configure global formatting preferences that apply to all widgets:
|
||||
|
||||

|
||||
|
||||
### Default Padding & Separators
|
||||
|
||||
- **Default Padding** - Add consistent padding to the left and right of each widget
|
||||
- **Default Separator** - Automatically insert a separator between all widgets
|
||||
- Press **(p)** to edit padding
|
||||
- Press **(s)** to edit separator
|
||||
- Manual separators collapse around widgets that render empty, so hide-when-empty widgets do not leave dangling dividers.
|
||||
|
||||
<details>
|
||||
<summary><b>Global Formatting Options</b></summary>
|
||||
|
||||
- **Inherit Colors** - Default separators inherit foreground and background colors from the preceding widget
|
||||
- Press **(i)** to toggle
|
||||
- **Global Bold** - Apply bold formatting to all text regardless of individual widget settings
|
||||
- Press **(o)** to toggle
|
||||
- **Minimalist Mode** - Force widgets into raw-value rendering globally for a cleaner, label-free status line
|
||||
- Press **(m)** to toggle
|
||||
- **Override Foreground Color** - Force all widgets to use the same text color
|
||||
- Press **(f)** to cycle through colors
|
||||
- Press **(g)** to clear override
|
||||
- **Override Background Color** - Force all widgets to use the same background color
|
||||
- Press **(b)** to cycle through colors
|
||||
- Press **(c)** to clear override
|
||||
|
||||
</details>
|
||||
|
||||
> 💡 **Note:** These settings are applied during rendering and don't add widgets to your widget list. They provide a consistent look across your entire status line without modifying individual widget configurations.
|
||||
|
||||
> ⚠️ **VSCode Users:** If colors appear incorrect in the VSCode integrated terminal, the "Terminal › Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors.
|
||||
|
||||
## Claude Code Status Line Settings
|
||||
|
||||
When ccstatusline is installed in Claude Code, the main menu includes **Configure Status Line**. Claude Code versions >=2.1.97 support `statusLine.refreshInterval`; ccstatusline can set it to `1-60` seconds, defaults fresh supported installs to `10` seconds, and removes the setting when the input is left empty.
|
||||
|
||||
## Block Timer Widget
|
||||
|
||||
The Block Timer widget helps you track your progress through Claude Code's 5-hour conversation blocks:
|
||||
|
||||

|
||||
|
||||
**Display Modes:**
|
||||
- **Time Display** - Shows elapsed time as "3hr 45m" (default)
|
||||
- **Progress Bar** - Full width 32-character progress bar with percentage
|
||||
- **Progress Bar (Short)** - Compact 16-character progress bar with percentage
|
||||
|
||||
**Features:**
|
||||
- Automatically detects block boundaries from transcript timestamps
|
||||
- Floors block start time to the hour for consistent tracking
|
||||
- Shows "Block: 3hr 45m" in normal mode or just "3hr 45m" in raw value mode
|
||||
- Progress bars show completion percentage (e.g., "[████████████████████████░░░░░░░░] 73.9%")
|
||||
- Use **(p)** to cycle time/full bar/short bar, **(s)** for compact time mode, and **(v)** to invert fill in progress mode
|
||||
|
||||
## Raw Value Mode
|
||||
|
||||
Some widgets support "raw value" mode which displays just the value without a label:
|
||||
- Normal: `Model: Claude 3.5 Sonnet` → Raw: `Claude 3.5 Sonnet`
|
||||
- Normal: `Session: 2hr 15m` → Raw: `2hr 15m`
|
||||
- Normal: `Block: 3hr 45m` → Raw: `3hr 45m`
|
||||
- Normal: `Ctx: 18.6k` → Raw: `18.6k`
|
||||
|
||||
## Widget Editor Keybinds
|
||||
|
||||
Common controls in the line editor:
|
||||
- `↑/↓` select widget
|
||||
- `←/→` open the type picker for the selected widget
|
||||
- navigation wraps at list boundaries, including move/reorder mode
|
||||
- `a` add widget via the picker
|
||||
- `i` insert widget via the picker
|
||||
- `k` clone the selected widget
|
||||
- `Enter` enter/exit move mode
|
||||
- `d` delete selected widget
|
||||
- `c` clear the current line
|
||||
- `Space` cycle a manual separator character
|
||||
- `r` toggle raw value (supported widgets)
|
||||
- `m` cycle merge mode (`off` → `merge` → `merge no padding`)
|
||||
- `Esc` go back
|
||||
|
||||
Widget picker:
|
||||
- type to search categories and widgets
|
||||
- supports substring, initialism, and fuzzy matching
|
||||
- `↑/↓` change selection, `Enter` continue/apply, `Esc` clear search/back/cancel
|
||||
|
||||
The keybind footer in the TUI only shows shortcuts that apply to the currently selected widget.
|
||||
|
||||
Widget-specific shortcuts:
|
||||
- **Git widgets with empty-state toggles**: `h` hide `no git` / empty output where supported
|
||||
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted)
|
||||
- **Git Root Dir**: `l` cycle IDE links (`off` → `VS Code` → `Cursor`)
|
||||
- **Git PR**: `h` hide empty/no-PR/MR output, `s` toggle review status, `t` toggle title (renders "MR" for GitLab origins)
|
||||
- **Git remote widgets** (`Git Origin*` / `Git Upstream*`): `h` hide when no remote, `l` toggle clickable repo links
|
||||
- **Git Origin Owner/Repo**: `o` show only the owner when the repo is a fork
|
||||
- **Git Is Fork**: `h` hide when the repo is not a fork
|
||||
- **Context % widgets**: `u` toggle used vs remaining display, `p` cycle percentage/short bar/short bar only
|
||||
- **Session Usage / Weekly Usage**: `p` cycle percentage/full bar/medium bar/short bar/short bar only, `v` invert fill in progress mode
|
||||
- **Block Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `v` invert fill in progress mode
|
||||
- **Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
|
||||
- **Weekly Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle hours-only in time mode or 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
|
||||
- **Context Bar**: `p` cycle medium/full/short/short-only progress bar
|
||||
- **Compaction Counter**: `f` cycle format, `n` toggle Nerd Font icon in icon mode, `h` hide when zero
|
||||
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
|
||||
- **Skills**: `v` cycle view mode, `h` hide when empty, `l` edit list limit in list mode
|
||||
- **Input Speed / Output Speed / Total Speed**: `w` edit the rolling window in seconds
|
||||
- **All Activity / Tools Activity / Agents Activity / Todo Progress**: `w` edit max width (set `0` or blank to disable truncation)
|
||||
- **Custom Text / Custom Symbol**: `e` edit text or symbol
|
||||
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
|
||||
- **Link**: `u` URL, `e` link text
|
||||
- **Vim Mode**: `f` cycle format, `n` toggle Nerd Font icons
|
||||
|
||||
## Custom Widgets
|
||||
|
||||
### Custom Text Widget
|
||||
|
||||
Add static text to your status line. Perfect for:
|
||||
- Project identifiers
|
||||
- Environment indicators (dev/prod)
|
||||
- Personal labels or reminders
|
||||
|
||||
### Custom Symbol Widget
|
||||
|
||||
Add a single symbol or emoji to your status line when you want a compact visual marker:
|
||||
- Nerd Font or Powerline-friendly glyphs
|
||||
- Status markers like `●`, `✓`, or `⚠`
|
||||
- Emoji shorthand for environments, workflows, or attention cues
|
||||
|
||||
### Custom Command Widget
|
||||
|
||||
Execute shell commands and display their output dynamically:
|
||||
- Refreshes whenever the statusline is updated by Claude Code
|
||||
- Receives the full Claude Code JSON data via stdin (model info, session ID, transcript path, etc.)
|
||||
- Displays command output inline in your status line
|
||||
- Configurable timeout (default: 1000ms)
|
||||
- Optional max-width truncation
|
||||
- Optional ANSI color preservation (`preserve colors`)
|
||||
- Examples:
|
||||
- `pwd | xargs basename` - Show current directory name
|
||||
- `node -v` - Display Node.js version
|
||||
- `git rev-parse --short HEAD` - Show current commit hash
|
||||
- `date +%H:%M` - Display current time
|
||||
- `curl -s wttr.in?format="%t"` - Show current temperature
|
||||
- `npx -y ccusage@latest statusline` - Display Claude usage metrics (set timeout: 5000ms)
|
||||
|
||||
> ⚠️ **Important:** Commands should complete quickly to avoid delays. Long-running commands will be killed after the configured timeout. If you're not seeing output from your custom command, try increasing the timeout value (press 't' in the editor).
|
||||
|
||||
> 💡 **Tip:** Custom commands can be other Claude Code compatible status line formatters. They receive the same JSON via stdin that `ccstatusline` receives from Claude Code, allowing you to chain or combine multiple status line tools.
|
||||
|
||||
### Link Widget
|
||||
|
||||
Create clickable links in terminals that support OSC 8 hyperlinks:
|
||||
- `metadata.url` - target URL (http/https)
|
||||
- `metadata.text` - optional display text (defaults to URL)
|
||||
- Falls back to plain text when URL is missing or unsupported
|
||||
|
||||
## Integration Example: ccusage
|
||||
|
||||
[ccusage](https://github.com/ryoppippi/ccusage) is a tool that tracks and displays Claude Code usage metrics. You can integrate it directly into your status line:
|
||||
|
||||
1. Add a Custom Command widget
|
||||
2. Set command: `npx -y ccusage@latest statusline`
|
||||
3. Set timeout: `5000` (5 seconds for initial download)
|
||||
4. Enable "preserve colors" to keep ccusage's color formatting
|
||||
|
||||

|
||||
|
||||
> 📄 **How it works:** The command receives Claude Code's JSON data via stdin, allowing ccusage to access session information, model details, and transcript data for accurate usage tracking.
|
||||
|
||||
## 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.
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
# Windows Support
|
||||
|
||||
`ccstatusline` works on Windows across PowerShell (5.1+ and 7+), Command Prompt, and Windows Subsystem for Linux (WSL).
|
||||
|
||||
If you want the main project overview, return to [README.md](../README.md).
|
||||
|
||||
## Installation on Windows
|
||||
|
||||
### Option 1: Using Bun (Recommended)
|
||||
|
||||
```powershell
|
||||
# Install Bun for Windows
|
||||
irm bun.sh/install.ps1 | iex
|
||||
|
||||
# Run ccstatusline
|
||||
bunx -y ccstatusline@latest
|
||||
```
|
||||
|
||||
### Option 2: Using Node.js
|
||||
|
||||
```powershell
|
||||
# Using npm
|
||||
npx -y ccstatusline@latest
|
||||
|
||||
# Or with Yarn
|
||||
yarn dlx ccstatusline@latest
|
||||
|
||||
# Or with pnpm
|
||||
pnpm dlx ccstatusline@latest
|
||||
```
|
||||
|
||||
## Claude Code Integration
|
||||
|
||||
Configure `ccstatusline` in your Claude Code settings:
|
||||
|
||||
**Settings location:**
|
||||
- Default: `%USERPROFILE%\.claude\settings.json`
|
||||
- Custom: set `CLAUDE_CONFIG_DIR` to use a different directory
|
||||
|
||||
**PowerShell custom config example:**
|
||||
|
||||
```powershell
|
||||
$env:CLAUDE_CONFIG_DIR="C:\custom\path\.claude"
|
||||
```
|
||||
|
||||
**For Bun users:**
|
||||
|
||||
```json
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bunx -y ccstatusline@latest",
|
||||
"padding": 0,
|
||||
"refreshInterval": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For npm users:**
|
||||
|
||||
```json
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "npx -y ccstatusline@latest",
|
||||
"padding": 0,
|
||||
"refreshInterval": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`refreshInterval` is optional and only supported by Claude Code >=2.1.97. You can configure it from ccstatusline's TUI after installation.
|
||||
|
||||
## Windows-Specific Features
|
||||
|
||||
### Powerline Font Support
|
||||
|
||||
For optimal Powerline rendering on Windows:
|
||||
|
||||
**Windows Terminal** (Recommended):
|
||||
- Supports Powerline fonts natively
|
||||
- Download from [Microsoft Store](https://aka.ms/terminal)
|
||||
- Auto-detects compatible fonts
|
||||
|
||||
**PowerShell/Command Prompt**:
|
||||
|
||||
```powershell
|
||||
# Install JetBrains Mono Nerd Font via winget
|
||||
winget install DEVCOM.JetBrainsMonoNerdFont
|
||||
|
||||
# Or download manually from: https://www.nerdfonts.com/font-downloads
|
||||
|
||||
# Alternative: Download and install base JetBrains Mono font
|
||||
# from [JetBrains](https://www.jetbrains.com/lp/mono/)
|
||||
# or [GitHub](https://github.com/JetBrains/JetBrainsMono)
|
||||
# or [Google Fonts](https://fonts.google.com/specimen/JetBrains+Mono)
|
||||
```
|
||||
|
||||
### Path Handling
|
||||
|
||||
`ccstatusline` automatically handles Windows-specific paths:
|
||||
- Git repositories work with both `/` and `\` path separators
|
||||
- Current Working Directory widget displays Windows-style paths correctly
|
||||
- Full support for mapped network drives and UNC paths
|
||||
- Handles Windows drive letters (C:, D:, etc.)
|
||||
|
||||
## Windows Troubleshooting
|
||||
|
||||
### Common Issues & Solutions
|
||||
|
||||
**Issue**: Powerline symbols showing as question marks or boxes
|
||||
|
||||
```powershell
|
||||
# Solution: Install a compatible Nerd Font
|
||||
winget install DEVCOM.JetBrainsMonoNerdFont
|
||||
# Then set the font in your terminal settings
|
||||
```
|
||||
|
||||
**Issue**: Git commands not recognized
|
||||
|
||||
```powershell
|
||||
# Check if Git is installed and in PATH
|
||||
git --version
|
||||
|
||||
# If not found, install Git:
|
||||
winget install Git.Git
|
||||
# Or download from: https://git-scm.com/download/win
|
||||
```
|
||||
|
||||
**Issue**: Permission errors during installation
|
||||
|
||||
```powershell
|
||||
# Use non-global installation (recommended)
|
||||
npx -y ccstatusline@latest
|
||||
|
||||
# Or run PowerShell as Administrator for global install
|
||||
```
|
||||
|
||||
**Issue**: "Execution Policy" errors in PowerShell
|
||||
|
||||
```powershell
|
||||
# Temporarily allow script execution
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
**Issue**: Windows Defender blocking execution
|
||||
|
||||
```powershell
|
||||
# If Windows Defender flags the binary:
|
||||
# 1. Open Windows Security
|
||||
# 2. Go to "Virus & threat protection"
|
||||
# 3. Add exclusion for the ccstatusline binary location
|
||||
# Or use temporary bypass (not recommended for production):
|
||||
Add-MpPreference -ExclusionPath "$env:USERPROFILE\.bun\bin"
|
||||
```
|
||||
|
||||
## Windows Subsystem for Linux (WSL)
|
||||
|
||||
`ccstatusline` works well in WSL environments:
|
||||
|
||||
```bash
|
||||
# Install in WSL Ubuntu/Debian
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
source ~/.bashrc
|
||||
bunx -y ccstatusline@latest
|
||||
```
|
||||
|
||||
**WSL benefits:**
|
||||
- Native Unix-style path handling
|
||||
- Better font rendering in WSL terminals
|
||||
- Seamless integration with Linux development workflows
|
||||
|
||||
## Windows Terminal Configuration
|
||||
|
||||
For the best experience, configure Windows Terminal with these recommended settings:
|
||||
|
||||
### Terminal Settings (`settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"defaults": {
|
||||
"font": {
|
||||
"face": "JetBrainsMono Nerd Font",
|
||||
"size": 12
|
||||
},
|
||||
"colorScheme": "One Half Dark"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance on Windows
|
||||
|
||||
`ccstatusline` includes Windows-specific runtime behavior:
|
||||
- **UTF-8 piped output fix**: In piped mode, it attempts to set code page `65001` for reliable symbol rendering
|
||||
- **Path compatibility**: Git and CWD widgets handle both `/` and `\` separators
|
||||
+29
-41
@@ -1,25 +1,42 @@
|
||||
import js from '@eslint/js';
|
||||
import ts from 'typescript-eslint';
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import { importX } from 'eslint-plugin-import-x';
|
||||
import importNewlinesPlugin from 'eslint-plugin-import-newlines';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
|
||||
const importResolverSettings = {
|
||||
'import-x/resolver': {
|
||||
typescript: {
|
||||
project: ['./tsconfig.json'],
|
||||
alwaysTryTypes: true,
|
||||
noWarnOnMultipleProjects: true
|
||||
},
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
|
||||
}
|
||||
},
|
||||
'import-x/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx']
|
||||
},
|
||||
'import-x/external-module-folders': ['node_modules', 'node_modules/@types']
|
||||
};
|
||||
|
||||
export default ts.config([
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
plugins: {
|
||||
stylistic,
|
||||
importPlugin,
|
||||
'import-x': importX,
|
||||
'import-newlines': importNewlinesPlugin
|
||||
},
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
ts.configs.strictTypeChecked,
|
||||
ts.configs.stylisticTypeChecked,
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importX.flatConfigs.recommended,
|
||||
stylistic.configs.customize({
|
||||
quotes: 'single',
|
||||
semi: true,
|
||||
@@ -38,26 +55,12 @@ 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
|
||||
'eqeqeq': 'error',
|
||||
'import/order': ['error', {
|
||||
'import-x/order': ['error', {
|
||||
alphabetize: {
|
||||
'order': 'asc',
|
||||
'orderImportKind': 'asc',
|
||||
@@ -103,13 +106,13 @@ export default ts.config([
|
||||
'@stylistic/nonblock-statement-body-position': ['error', 'below'],
|
||||
'@stylistic/object-curly-newline': ['error', { 'multiline': true }],
|
||||
'@stylistic/switch-colon-spacing': 'error',
|
||||
'@stylistic/eol-last': ['error', 'never'],
|
||||
'@stylistic/eol-last': ['error', 'always'],
|
||||
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
|
||||
'@stylistic/multiline-ternary': 'off',
|
||||
'import/no-unresolved': ['error'],
|
||||
'import/no-named-as-default': 'off',
|
||||
'import/no-named-as-default-member': 'off',
|
||||
'import/default': 'off'
|
||||
'import-x/no-unresolved': ['error'],
|
||||
'import-x/no-named-as-default': 'off',
|
||||
'import-x/no-named-as-default-member': 'off',
|
||||
'import-x/default': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
]);
|
||||
]);
|
||||
|
||||
+27
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ccstatusline",
|
||||
"version": "2.0.5",
|
||||
"version": "2.2.11",
|
||||
"description": "A customizable status line formatter for Claude Code CLI",
|
||||
"module": "src/ccstatusline.ts",
|
||||
"type": "module",
|
||||
@@ -11,36 +11,43 @@
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "bun run patch && bun run src/ccstatusline.ts",
|
||||
"statusline": "bun run src/ccstatusline.ts",
|
||||
"patch": "patch-package",
|
||||
"build": "bun run patch && rm -rf dist/* && bun build src/ccstatusline.ts --target=node --outfile=dist/ccstatusline.js --target-version=14",
|
||||
"start": "bun run src/ccstatusline.ts",
|
||||
"build": "rm -rf dist/* ; bun build src/ccstatusline.ts --target=node --outfile=dist/ccstatusline.js --target-version=14",
|
||||
"postbuild": "bun run scripts/replace-version.ts",
|
||||
"example": "cat scripts/payload.example.json | bun start",
|
||||
"prepublishOnly": "bun run build",
|
||||
"lint": "bun tsc --noEmit; eslint . --config eslint.config.js --max-warnings=999999 --fix"
|
||||
"lint": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0",
|
||||
"lint:fix": "bun tsc --noEmit && eslint . --config eslint.config.js --max-warnings=0 --fix",
|
||||
"docs": "typedoc",
|
||||
"docs:clean": "rm -rf typedoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.2.3",
|
||||
"@types/bun": "latest",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "^19.1.10",
|
||||
"chalk": "^5.5.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-import-newlines": "^1.4.0",
|
||||
"eslint-plugin-import-newlines": "^2.0.0",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"ink": "^6.2.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.3.0",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"ink": "6.2.0",
|
||||
"ink-gradient": "^4.0.0",
|
||||
"ink-select-input": "^6.2.0",
|
||||
"patch-package": "^8.0.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",
|
||||
"typescript": "^5.9.2",
|
||||
"typedoc": "^0.28.12",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vitest": "^4.0.18",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -61,5 +68,8 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"unrs-resolver"
|
||||
]
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"ink@6.2.0": "patches/ink@6.2.0.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
diff --git a/node_modules/ink/build/parse-keypress.js b/node_modules/ink/build/parse-keypress.js
|
||||
index 1e13e81..e100611 100644
|
||||
--- a/node_modules/ink/build/parse-keypress.js
|
||||
+++ b/node_modules/ink/build/parse-keypress.js
|
||||
diff --git a/build/parse-keypress.js b/build/parse-keypress.js
|
||||
index 1e13e819f59d259a3476510db822695576a9d93c..e100611d8661187c4ca191e89621cd5e480fcacc 100644
|
||||
--- a/build/parse-keypress.js
|
||||
+++ b/build/parse-keypress.js
|
||||
@@ -161,9 +161,9 @@ const parseKeypress = (s = '') => {
|
||||
key.meta = s.charAt(0) === '\x1b';
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,2 @@
|
||||
settings.json
|
||||
payload.json
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"hook_event_name": "Status",
|
||||
"session_id": "abc123...",
|
||||
"transcript_path": "/path/to/transcript.json",
|
||||
"cwd": "/current/working/directory",
|
||||
"model": {
|
||||
"id": "claude-opus-4-6[1m]",
|
||||
"display_name": "Opus 4.6 (1M context)"
|
||||
},
|
||||
"workspace": {
|
||||
"current_dir": "/current/working/directory",
|
||||
"project_dir": "/original/project/directory",
|
||||
"added_dirs": []
|
||||
},
|
||||
"version": "2.1.80",
|
||||
"output_style": {
|
||||
"name": "default"
|
||||
},
|
||||
"cost": {
|
||||
"total_cost_usd": 0.01234,
|
||||
"total_duration_ms": 45000,
|
||||
"total_api_duration_ms": 2300,
|
||||
"total_lines_added": 156,
|
||||
"total_lines_removed": 23
|
||||
},
|
||||
"context_window": {
|
||||
"total_input_tokens": 50113,
|
||||
"total_output_tokens": 10462,
|
||||
"context_window_size": 1000000,
|
||||
"current_usage": {
|
||||
"input_tokens": 8500,
|
||||
"output_tokens": 1200,
|
||||
"cache_creation_input_tokens": 5000,
|
||||
"cache_read_input_tokens": 2000
|
||||
},
|
||||
"used_percentage": 8,
|
||||
"remaining_percentage": 92
|
||||
},
|
||||
"exceeds_200k_tokens": false,
|
||||
"rate_limits": {
|
||||
"five_hour": {
|
||||
"used_percentage": 42,
|
||||
"resets_at": 1774020000
|
||||
},
|
||||
"seven_day": {
|
||||
"used_percentage": 15,
|
||||
"resets_at": 1774540000
|
||||
}
|
||||
},
|
||||
"vim": {
|
||||
"mode": "NORMAL"
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,4 @@ bundledContent = bundledContent.replace(/__PACKAGE_VERSION__/g, version);
|
||||
// Write back the modified content
|
||||
writeFileSync(bundledFilePath, bundledContent);
|
||||
|
||||
console.log(`✓ Replaced version placeholder with ${version}`);
|
||||
console.log(`✓ Replaced version placeholder with ${version}`);
|
||||
|
||||
+218
-27
@@ -3,25 +3,64 @@ 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 { getActivitySnapshot } from './utils/activity';
|
||||
import { getVisibleText } from './utils/ansi';
|
||||
import { updateColorMap } from './utils/colors';
|
||||
import {
|
||||
detectCompaction,
|
||||
loadCompactionState,
|
||||
saveCompactionState
|
||||
} from './utils/compaction';
|
||||
import {
|
||||
initConfigPath,
|
||||
loadSettings,
|
||||
saveSettings
|
||||
} from './utils/config';
|
||||
import { calculateContextPercentageMetrics } from './utils/context-percentage';
|
||||
import {
|
||||
getBlockMetrics,
|
||||
getSessionDuration,
|
||||
getSpeedMetricsCollection,
|
||||
getTokenMetrics
|
||||
} from './utils/jsonl';
|
||||
import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index';
|
||||
import {
|
||||
renderStatusLine,
|
||||
type RenderContext,
|
||||
type StatusJSON
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
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;
|
||||
}
|
||||
|
||||
const ACTIVITY_WIDGET_TYPES = new Set([
|
||||
'tools-activity',
|
||||
'agents-activity',
|
||||
'todo-progress',
|
||||
'activity'
|
||||
]);
|
||||
|
||||
function hasActivityWidgets(lines: { type?: string }[][]): boolean {
|
||||
return lines.some(line => line.some(item => typeof item.type === 'string' && ACTIVITY_WIDGET_TYPES.has(item.type)));
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
|
||||
@@ -52,70 +91,147 @@ 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();
|
||||
|
||||
// Set global chalk level based on settings
|
||||
chalk.level = settings.colorLevel;
|
||||
|
||||
// Update color map after setting chalk level
|
||||
updateColorMap();
|
||||
|
||||
// Get all lines to render
|
||||
const lines = settings.lines;
|
||||
|
||||
// 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 && data.transcript_path)
|
||||
blockMetrics = getBlockMetrics(data.transcript_path);
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines, data);
|
||||
|
||||
let speedMetrics: SpeedMetrics | null = null;
|
||||
let windowedSpeedMetrics: Record<string, SpeedMetrics> | null = null;
|
||||
if (hasSpeedItems && data.transcript_path) {
|
||||
const speedMetricsCollection = await getSpeedMetricsCollection(data.transcript_path, {
|
||||
includeSubagents: true,
|
||||
windowSeconds: Array.from(requestedSpeedWindows)
|
||||
});
|
||||
|
||||
speedMetrics = speedMetricsCollection.sessionAverage;
|
||||
windowedSpeedMetrics = speedMetricsCollection.windowed;
|
||||
}
|
||||
|
||||
let skillsMetrics: SkillsMetrics | null = null;
|
||||
if (data.session_id) {
|
||||
skillsMetrics = getSkillsMetrics(data.session_id);
|
||||
}
|
||||
|
||||
// Compaction detection — track context percentage drops between renders
|
||||
let compactionCount = 0;
|
||||
const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter'));
|
||||
if (hasCompactionWidget && data.session_id) {
|
||||
const prevState = loadCompactionState(data.session_id);
|
||||
compactionCount = prevState.count;
|
||||
const contextPercentageMetrics = calculateContextPercentageMetrics({ data, tokenMetrics });
|
||||
if (contextPercentageMetrics !== null) {
|
||||
const newState = detectCompaction(contextPercentageMetrics.usedPercentage, prevState, { windowSize: contextPercentageMetrics.windowSize });
|
||||
if (
|
||||
newState.count !== prevState.count
|
||||
|| newState.prevCtxPct !== prevState.prevCtxPct
|
||||
|| newState.prevWindowSize !== prevState.prevWindowSize
|
||||
) {
|
||||
saveCompactionState(data.session_id, newState);
|
||||
}
|
||||
compactionCount = newState.count;
|
||||
}
|
||||
}
|
||||
|
||||
const activity = hasActivityWidgets(lines)
|
||||
? getActivitySnapshot(data.transcript_path)
|
||||
: null;
|
||||
|
||||
// Create render context
|
||||
const context: RenderContext = {
|
||||
data,
|
||||
tokenMetrics,
|
||||
speedMetrics,
|
||||
windowedSpeedMetrics,
|
||||
usageData,
|
||||
sessionDuration,
|
||||
blockMetrics,
|
||||
isPreview: false
|
||||
skillsMetrics,
|
||||
compactionData: hasCompactionWidget ? { count: compactionCount } : null,
|
||||
activity,
|
||||
isPreview: false,
|
||||
minimalist: settings.minimalistMode
|
||||
};
|
||||
|
||||
// Render each line
|
||||
// Always pre-render all widgets once (for efficiency)
|
||||
const preRenderedLines = preRenderAllWidgets(lines, settings, context);
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
|
||||
// Render each line using pre-rendered content
|
||||
let globalSeparatorIndex = 0;
|
||||
let globalPowerlineThemeIndex = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineItems = lines[i];
|
||||
if (lineItems && lineItems.length > 0) {
|
||||
const lineContext = { ...context, lineIndex: i, globalSeparatorIndex };
|
||||
const line = renderStatusLine(lineItems, settings, lineContext);
|
||||
const preRenderedWidgets = preRenderedLines[i] ?? [];
|
||||
const lineContext = {
|
||||
...context,
|
||||
lineIndex: i,
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex
|
||||
};
|
||||
const line = renderStatusLine(lineItems, settings, lineContext, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
|
||||
// Only output the line if it has content (not just ANSI codes)
|
||||
// Strip ANSI codes to check if there's actual text
|
||||
const strippedLine = 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);
|
||||
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
|
||||
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,9 +266,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_:-]+)(?:\s|$)/.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() !== '') {
|
||||
@@ -186,4 +377,4 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
void main();
|
||||
|
||||
+229
-121
@@ -8,6 +8,7 @@ import {
|
||||
} from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState
|
||||
} from 'react';
|
||||
@@ -15,16 +16,25 @@ import React, {
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type { WidgetItem } from '../types/Widget';
|
||||
import {
|
||||
CCSTATUSLINE_COMMANDS,
|
||||
getClaudeSettingsPath,
|
||||
getExistingStatusLine,
|
||||
installStatusLine,
|
||||
isBunxAvailable,
|
||||
isClaudeCodeVersionAtLeast,
|
||||
isInstalled,
|
||||
isKnownCommand,
|
||||
setRefreshInterval,
|
||||
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,
|
||||
@@ -33,6 +43,7 @@ import {
|
||||
} from '../utils/powerline';
|
||||
import { getPackageVersion } from '../utils/terminal';
|
||||
|
||||
import { loadClaudeStatusLineState } from './claude-status';
|
||||
import {
|
||||
ColorMenu,
|
||||
ConfirmDialog,
|
||||
@@ -42,42 +53,83 @@ import {
|
||||
LineSelector,
|
||||
MainMenu,
|
||||
PowerlineSetup,
|
||||
RefreshIntervalMenu,
|
||||
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'
|
||||
| 'refreshInterval';
|
||||
|
||||
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);
|
||||
const [currentRefreshInterval, setCurrentRefreshInterval] = useState<number | null>(null);
|
||||
const [supportsRefreshInterval] = useState(() => isClaudeCodeVersionAtLeast('2.1.97'));
|
||||
|
||||
useEffect(() => {
|
||||
// Load existing status line
|
||||
void getExistingStatusLine().then(setExistingStatusLine);
|
||||
|
||||
void loadClaudeStatusLineState().then((statusLineState) => {
|
||||
setExistingStatusLine(statusLineState.existingStatusLine);
|
||||
setCurrentRefreshInterval(statusLineState.refreshInterval);
|
||||
});
|
||||
void loadSettings().then((loadedSettings) => {
|
||||
// Set global chalk level based on settings (default to 256 colors for compatibility)
|
||||
chalk.level = loadedSettings.colorLevel;
|
||||
// Ensure lines array has 3 slots
|
||||
while (loadedSettings.lines.length < 3) {
|
||||
loadedSettings.lines.push([]);
|
||||
}
|
||||
setSettings(loadedSettings);
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings)) as Settings); // Deep copy
|
||||
setOriginalSettings(cloneSettings(loadedSettings));
|
||||
});
|
||||
void isInstalled().then(setIsClaudeInstalled);
|
||||
|
||||
@@ -108,15 +160,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') {
|
||||
@@ -126,13 +178,61 @@ 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 = isKnownCommand(existing ?? '');
|
||||
let message: string;
|
||||
|
||||
if (existing && !isAlreadyInstalled) {
|
||||
message = `This will modify ${getClaudeSettingsPath()}\n\nA status line is already configured: "${existing}"\nReplace it with ${command}?`;
|
||||
} else if (isAlreadyInstalled) {
|
||||
message = `ccstatusline is already installed in ${getClaudeSettingsPath()}\nUpdate it with ${command}?`;
|
||||
} else {
|
||||
message = `This will modify ${getClaudeSettingsPath()} to add ccstatusline with ${displayName}.\nContinue?`;
|
||||
}
|
||||
|
||||
setConfirmDialog({
|
||||
message,
|
||||
cancelScreen: 'install',
|
||||
action: async () => {
|
||||
await installStatusLine(useBunx, supportsRefreshInterval);
|
||||
const installedStatusLineState = await loadClaudeStatusLineState();
|
||||
setIsClaudeInstalled(true);
|
||||
setExistingStatusLine(installedStatusLineState.existingStatusLine ?? command);
|
||||
setCurrentRefreshInterval(installedStatusLineState.refreshInterval);
|
||||
setScreen('main');
|
||||
setConfirmDialog(null);
|
||||
}
|
||||
});
|
||||
setScreen('confirm');
|
||||
});
|
||||
}, [supportsRefreshInterval]);
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -141,11 +241,12 @@ export const App: React.FC = () => {
|
||||
if (isClaudeInstalled) {
|
||||
// Uninstall
|
||||
setConfirmDialog({
|
||||
message: 'This will remove ccstatusline from ~/.claude/settings.json. Continue?',
|
||||
message: `This will remove ccstatusline from ${getClaudeSettingsPath()}. Continue?`,
|
||||
action: async () => {
|
||||
await uninstallStatusLine();
|
||||
setIsClaudeInstalled(false);
|
||||
setExistingStatusLine(null);
|
||||
setCurrentRefreshInterval(null);
|
||||
setScreen('main');
|
||||
setConfirmDialog(null);
|
||||
}
|
||||
@@ -157,35 +258,61 @@ 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 'configureStatusLine':
|
||||
setScreen('refreshInterval');
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -195,13 +322,17 @@ export const App: React.FC = () => {
|
||||
setSettings({ ...settings, lines: newLines });
|
||||
};
|
||||
|
||||
const updateLines = (newLines: WidgetItem[][]) => {
|
||||
setSettings({ ...settings, lines: newLines });
|
||||
};
|
||||
|
||||
const handleLineSelect = (lineIndex: number) => {
|
||||
setSelectedLine(lineIndex);
|
||||
setScreen('items');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection='column' padding={1}>
|
||||
<Box flexDirection='column'>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>
|
||||
<Gradient name='retro'>
|
||||
@@ -211,12 +342,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}
|
||||
@@ -228,19 +362,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}
|
||||
@@ -255,17 +382,19 @@ 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}
|
||||
title='Select Line to Edit Items'
|
||||
allowEditing={true}
|
||||
/>
|
||||
)}
|
||||
{screen === 'items' && (
|
||||
@@ -274,7 +403,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}
|
||||
@@ -284,20 +413,22 @@ export const App: React.FC = () => {
|
||||
{screen === 'colorLines' && (
|
||||
<LineSelector
|
||||
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}
|
||||
title='Select Line to Edit Colors'
|
||||
blockIfPowerlineActive={true}
|
||||
settings={settings}
|
||||
allowEditing={false}
|
||||
/>
|
||||
)}
|
||||
{screen === 'colors' && (
|
||||
@@ -328,7 +459,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');
|
||||
}
|
||||
}}
|
||||
@@ -353,7 +484,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');
|
||||
}}
|
||||
/>
|
||||
@@ -363,7 +494,7 @@ export const App: React.FC = () => {
|
||||
message={confirmDialog.message}
|
||||
onConfirm={() => void confirmDialog.action()}
|
||||
onCancel={() => {
|
||||
setScreen('main');
|
||||
setScreen(getConfirmCancelScreen(confirmDialog));
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
/>
|
||||
@@ -372,59 +503,36 @@ export const App: React.FC = () => {
|
||||
<InstallMenu
|
||||
bunxAvailable={isBunxAvailable()}
|
||||
existingStatusLine={existingStatusLine}
|
||||
onSelectNpx={() => {
|
||||
void getExistingStatusLine().then((existing) => {
|
||||
const isAlreadyInstalled = ['npx -y ccstatusline@latest', 'bunx -y ccstatusline@latest'].includes(existing ?? '');
|
||||
let message: string;
|
||||
|
||||
if (existing && !isAlreadyInstalled) {
|
||||
message = `This will modify ~/.claude/settings.json\n\nA status line is already configured: "${existing}"\nReplace it with npx -y ccstatusline@latest?`;
|
||||
} else if (isAlreadyInstalled) {
|
||||
message = 'ccstatusline is already installed in ~/.claude/settings.json\nUpdate it with npx -y ccstatusline@latest?';
|
||||
} else {
|
||||
message = 'This will modify ~/.claude/settings.json to add ccstatusline with npx.\nContinue?';
|
||||
}
|
||||
|
||||
setConfirmDialog({
|
||||
message,
|
||||
action: async () => {
|
||||
await installStatusLine(false);
|
||||
setIsClaudeInstalled(true);
|
||||
setExistingStatusLine('npx -y ccstatusline@latest');
|
||||
setScreen('main');
|
||||
setConfirmDialog(null);
|
||||
}
|
||||
onSelectNpx={handleNpxInstall}
|
||||
onSelectBunx={handleBunxInstall}
|
||||
onCancel={handleInstallMenuCancel}
|
||||
initialSelection={menuSelections.install}
|
||||
/>
|
||||
)}
|
||||
{screen === 'refreshInterval' && (
|
||||
<RefreshIntervalMenu
|
||||
currentInterval={currentRefreshInterval}
|
||||
supportsRefreshInterval={supportsRefreshInterval}
|
||||
onUpdate={(interval) => {
|
||||
const previous = currentRefreshInterval;
|
||||
setCurrentRefreshInterval(interval);
|
||||
void setRefreshInterval(interval)
|
||||
.then(() => {
|
||||
setFlashMessage({
|
||||
text: '✓ Refresh interval updated',
|
||||
color: 'green'
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setCurrentRefreshInterval(previous);
|
||||
setFlashMessage({
|
||||
text: '✗ Failed to save refresh interval',
|
||||
color: 'red'
|
||||
});
|
||||
});
|
||||
setScreen('confirm');
|
||||
});
|
||||
setScreen('main');
|
||||
}}
|
||||
onSelectBunx={() => {
|
||||
void getExistingStatusLine().then((existing) => {
|
||||
const isAlreadyInstalled = ['npx -y ccstatusline@latest', 'bunx -y ccstatusline@latest'].includes(existing ?? '');
|
||||
let message: string;
|
||||
|
||||
if (existing && !isAlreadyInstalled) {
|
||||
message = `This will modify ~/.claude/settings.json\n\nA status line is already configured: "${existing}"\nReplace it with bunx -y ccstatusline@latest?`;
|
||||
} else if (isAlreadyInstalled) {
|
||||
message = 'ccstatusline is already installed in ~/.claude/settings.json\nUpdate it with bunx -y ccstatusline@latest?';
|
||||
} else {
|
||||
message = 'This will modify ~/.claude/settings.json to add ccstatusline with bunx.\nContinue?';
|
||||
}
|
||||
|
||||
setConfirmDialog({
|
||||
message,
|
||||
action: async () => {
|
||||
await installStatusLine(true);
|
||||
setIsClaudeInstalled(true);
|
||||
setExistingStatusLine('bunx -y ccstatusline@latest');
|
||||
setScreen('main');
|
||||
setConfirmDialog(null);
|
||||
}
|
||||
});
|
||||
setScreen('confirm');
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
onBack={() => {
|
||||
setScreen('main');
|
||||
}}
|
||||
/>
|
||||
@@ -468,4 +576,4 @@ export function runTUI() {
|
||||
// Clear the terminal before starting the TUI
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
render(<App />);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { saveClaudeSettings } from '../../utils/claude-settings';
|
||||
import { loadClaudeStatusLineState } from '../claude-status';
|
||||
|
||||
const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
||||
let testClaudeConfigDir = '';
|
||||
|
||||
beforeEach(() => {
|
||||
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-status-'));
|
||||
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (testClaudeConfigDir) {
|
||||
fs.rmSync(testClaudeConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
describe('loadClaudeStatusLineState', () => {
|
||||
it('loads both the installed command and refresh interval from Claude settings', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'npx -y ccstatusline@latest',
|
||||
padding: 0,
|
||||
refreshInterval: 10
|
||||
}
|
||||
});
|
||||
|
||||
await expect(loadClaudeStatusLineState()).resolves.toEqual({
|
||||
existingStatusLine: 'npx -y ccstatusline@latest',
|
||||
refreshInterval: 10
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null refreshInterval when Claude settings do not define one', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'npx -y ccstatusline@latest',
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
|
||||
await expect(loadClaudeStatusLineState()).resolves.toEqual({
|
||||
existingStatusLine: 'npx -y ccstatusline@latest',
|
||||
refreshInterval: null
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
getExistingStatusLine,
|
||||
getRefreshInterval
|
||||
} from '../utils/claude-settings';
|
||||
|
||||
export interface ClaudeStatusLineState {
|
||||
existingStatusLine: string | null;
|
||||
refreshInterval: number | null;
|
||||
}
|
||||
|
||||
export async function loadClaudeStatusLineState(): Promise<ClaudeStatusLineState> {
|
||||
const [
|
||||
existingStatusLine,
|
||||
refreshInterval
|
||||
] = await Promise.all([
|
||||
getExistingStatusLine(),
|
||||
getRefreshInterval()
|
||||
]);
|
||||
|
||||
return {
|
||||
existingStatusLine,
|
||||
refreshInterval
|
||||
};
|
||||
}
|
||||
@@ -15,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[];
|
||||
@@ -43,15 +51,11 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
return showSeparators;
|
||||
}
|
||||
// Use the widget's supportsColors method
|
||||
try {
|
||||
const widgetInstance = getWidget(widget.type);
|
||||
return widgetInstance.supportsColors(widget);
|
||||
} catch {
|
||||
// If widget type is not found, exclude it
|
||||
return false;
|
||||
}
|
||||
const widgetInstance = getWidget(widget.type);
|
||||
// Include unknown widgets (they might support colors, we just don't know)
|
||||
return widgetInstance ? widgetInstance.supportsColors(widget) : true;
|
||||
});
|
||||
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(colorableWidgets[0]?.id ?? null);
|
||||
const [highlightedItemId, setHighlightedItemId] = useState(colorableWidgets[0]?.id ?? null);
|
||||
const [editingBackground, setEditingBackground] = useState(false);
|
||||
|
||||
// Handle keyboard input
|
||||
@@ -83,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);
|
||||
@@ -101,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)) {
|
||||
@@ -129,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);
|
||||
@@ -148,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;
|
||||
@@ -202,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);
|
||||
}
|
||||
}
|
||||
@@ -216,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);
|
||||
}
|
||||
}
|
||||
@@ -238,50 +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);
|
||||
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);
|
||||
}
|
||||
@@ -296,8 +228,8 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
Configure Colors
|
||||
{lineIndex !== undefined ? ` - Line ${lineIndex + 1}` : ''}
|
||||
</Text>
|
||||
<Box marginTop={1}><Text dimColor>No colorable items in the status line.</Text></Box>
|
||||
<Text dimColor>Add a Model or Git Branch item first.</Text>
|
||||
<Box marginTop={1}><Text dimColor>No colorable widgets in the status line.</Text></Box>
|
||||
<Text dimColor>Add a widget first to continue.</Text>
|
||||
<Box marginTop={1}><Text>Press any key to go back...</Text></Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -313,7 +245,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
}
|
||||
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
return widgetImpl.getDisplayName();
|
||||
return widgetImpl ? widgetImpl.getDisplayName() : `Unknown: ${widget.type}`;
|
||||
};
|
||||
|
||||
// Color list for cycling
|
||||
@@ -333,7 +265,9 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
let defaultColor = 'white';
|
||||
if (widget.type !== 'separator' && widget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
defaultColor = widgetImpl.getDefaultColor();
|
||||
if (widgetImpl) {
|
||||
defaultColor = widgetImpl.getDefaultColor();
|
||||
}
|
||||
}
|
||||
const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level);
|
||||
return {
|
||||
@@ -363,7 +297,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
: (selectedWidget ? (selectedWidget.color ?? (() => {
|
||||
if (selectedWidget.type !== 'separator' && selectedWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(selectedWidget.type);
|
||||
return widgetImpl.getDefaultColor();
|
||||
return widgetImpl ? widgetImpl.getDefaultColor() : 'white';
|
||||
}
|
||||
return 'white';
|
||||
})()) : 'white');
|
||||
@@ -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);
|
||||
}}
|
||||
@@ -578,4 +504,4 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,53 +17,58 @@ 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';
|
||||
|
||||
@@ -28,6 +29,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
const [separatorInput, setSeparatorInput] = useState(settings.defaultSeparator ?? '');
|
||||
const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors);
|
||||
const [globalBold, setGlobalBold] = useState(settings.globalBold);
|
||||
const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode);
|
||||
const isPowerlineEnabled = settings.powerline.enabled;
|
||||
|
||||
// Check if there are any manual separators in the current configuration
|
||||
@@ -57,7 +59,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 +88,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) {
|
||||
@@ -132,6 +134,15 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
globalBold: newGlobalBold
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
} else if (input === 'm' || input === 'M') {
|
||||
// Toggle minimalist mode
|
||||
const newMinimalistMode = !minimalistMode;
|
||||
setMinimalistMode(newMinimalistMode);
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
minimalistMode: newMinimalistMode
|
||||
};
|
||||
onUpdate(updatedSettings);
|
||||
} else if (input === 'f' || input === 'F') {
|
||||
// Cycle through foreground colors
|
||||
const nextIndex = (currentFgIndex + 1) % fgColors.length;
|
||||
@@ -155,7 +166,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Global Overrides</Text>
|
||||
<Text dimColor>Configure automatic padding and separators between items</Text>
|
||||
<Text dimColor>Configure automatic padding and separators between widgets</Text>
|
||||
{isPowerlineEnabled && (
|
||||
<Box marginTop={1}>
|
||||
<Text color='yellow'>⚠ Some options are disabled while Powerline mode is active</Text>
|
||||
@@ -166,7 +177,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
{editingPadding ? (
|
||||
<Box flexDirection='column'>
|
||||
<Box>
|
||||
<Text>Enter default padding (applied to left and right of each item): </Text>
|
||||
<Text>Enter default padding (applied to left and right of each widget): </Text>
|
||||
<Text color='cyan'>{paddingInput ? `"${paddingInput}"` : '(empty)'}</Text>
|
||||
</Box>
|
||||
<Text dimColor>Press Enter to save, ESC to cancel</Text>
|
||||
@@ -174,7 +185,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
) : editingSeparator ? (
|
||||
<Box flexDirection='column'>
|
||||
<Box>
|
||||
<Text>Enter default separator (placed between items): </Text>
|
||||
<Text>Enter default separator (placed between widgets): </Text>
|
||||
<Text color='cyan'>{separatorInput ? `"${separatorInput}"` : '(empty - no separator will be added)'}</Text>
|
||||
</Box>
|
||||
<Text dimColor>Press Enter to save, ESC to cancel</Text>
|
||||
@@ -221,6 +232,12 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
<Text dimColor> - Press (o) to toggle</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text> Minimalist Mode: </Text>
|
||||
<Text color={minimalistMode ? 'green' : 'red'}>{minimalistMode ? '✓ Enabled' : '✗ Disabled'}</Text>
|
||||
<Text dimColor> - Press (m) to toggle</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text> Default Padding: </Text>
|
||||
<Text color='cyan'>{settings.defaultPadding ? `"${settings.defaultPadding}"` : '(none)'}</Text>
|
||||
@@ -295,7 +312,7 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text dimColor wrap='wrap'>
|
||||
Note: These settings are applied during rendering and don't add items to your widget list.
|
||||
Note: These settings are applied during rendering and don't add widgets to your widget list.
|
||||
</Text>
|
||||
<Text dimColor wrap='wrap'>
|
||||
• Inherit colors: Separators will use colors from the preceding widget
|
||||
@@ -304,11 +321,14 @@ export const GlobalOverridesMenu: React.FC<GlobalOverridesMenuProps> = ({ settin
|
||||
• Global Bold: Makes all text bold regardless of individual settings
|
||||
</Text>
|
||||
<Text dimColor wrap='wrap'>
|
||||
• Override colors: All items will use these colors instead of their configured colors
|
||||
• Minimalist Mode: Strips decorative prefixes and labels from widgets
|
||||
</Text>
|
||||
<Text dimColor wrap='wrap'>
|
||||
• Override colors: All widgets will use these colors instead of their configured colors
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,11 @@ 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;
|
||||
@@ -11,6 +15,7 @@ export interface InstallMenuProps {
|
||||
onSelectNpx: () => void;
|
||||
onSelectBunx: () => void;
|
||||
onCancel: () => void;
|
||||
initialSelection?: number;
|
||||
}
|
||||
|
||||
export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
@@ -18,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>
|
||||
@@ -69,33 +79,27 @@ 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>
|
||||
The selected command will be written to ~/.claude/settings.json
|
||||
The selected command will be written to
|
||||
{' '}
|
||||
{getClaudeSettingsPath()}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -104,4 +108,4 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
+391
-282
@@ -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,
|
||||
getMatchSegments,
|
||||
getWidget,
|
||||
getWidgetCatalog,
|
||||
getWidgetCatalogCategories
|
||||
} from '../../utils/widgets';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import {
|
||||
handleMoveInputMode,
|
||||
handleNormalInputMode,
|
||||
handlePickerInputMode,
|
||||
normalizePickerState,
|
||||
type CustomEditorWidgetState,
|
||||
type WidgetPickerAction,
|
||||
type WidgetPickerState
|
||||
} from './items-editor/input-handlers';
|
||||
|
||||
export interface ItemsEditorProps {
|
||||
widgets: WidgetItem[];
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
@@ -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,202 +95,115 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
setCustomEditorWidget(null);
|
||||
};
|
||||
|
||||
const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
|
||||
if (!widgetImpl.getCustomKeybinds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return widgetImpl.getCustomKeybinds(widget);
|
||||
};
|
||||
|
||||
const openWidgetPicker = (action: WidgetPickerAction) => {
|
||||
if (widgetCatalog.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentType = widgets[selectedIndex]?.type;
|
||||
const selectedType = action === 'change' ? currentType ?? null : null;
|
||||
|
||||
setWidgetPicker(normalizePickerState({
|
||||
action,
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType
|
||||
}, widgetCatalog, widgetCategories));
|
||||
};
|
||||
|
||||
const applyWidgetPickerSelection = (selectedType: WidgetItemType) => {
|
||||
if (!widgetPicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (widgetPicker.action === 'change') {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget) {
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, type: selectedType };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else {
|
||||
const insertIndex = widgetPicker.action === 'add'
|
||||
? (widgets.length > 0 ? selectedIndex + 1 : 0)
|
||||
: selectedIndex;
|
||||
const backgroundColor = getUniqueBackgroundColor(insertIndex);
|
||||
const newWidget: WidgetItem = {
|
||||
id: generateGuid(),
|
||||
type: selectedType,
|
||||
...(backgroundColor && { backgroundColor })
|
||||
};
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets.splice(insertIndex, 0, newWidget);
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(insertIndex);
|
||||
}
|
||||
|
||||
setWidgetPicker(null);
|
||||
};
|
||||
|
||||
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') {
|
||||
try {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (widgetImpl.getCustomKeybinds) {
|
||||
const customKeybinds = widgetImpl.getCustomKeybinds();
|
||||
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
|
||||
|
||||
if (matchedKeybind) {
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Widget not found or doesn't support custom keybinds
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
getCustomKeybindsForWidget,
|
||||
setCustomEditorWidget,
|
||||
getUniqueBackgroundColor
|
||||
});
|
||||
});
|
||||
|
||||
const getWidgetDisplay = (widget: WidgetItem) => {
|
||||
@@ -309,14 +219,37 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
|
||||
// Handle regular widgets - delegate to widget for display
|
||||
const widgetImpl = getWidget(widget.type);
|
||||
const { displayText, modifierText } = widgetImpl.getEditorDisplay(widget);
|
||||
|
||||
// Return plain text without colors
|
||||
return displayText + (modifierText ? ` ${modifierText}` : '');
|
||||
if (widgetImpl) {
|
||||
const { displayText, modifierText } = widgetImpl.getEditorDisplay(widget);
|
||||
// Return plain text without colors
|
||||
return displayText + (modifierText ? ` ${modifierText}` : '');
|
||||
}
|
||||
// Unknown widget type
|
||||
return `Unknown: ${widget.type}`;
|
||||
};
|
||||
|
||||
const hasFlexSeparator = widgets.some(widget => widget.type === 'flex-separator');
|
||||
const widthDetectionAvailable = canDetectTerminalWidth();
|
||||
const pickerCategories = widgetPicker
|
||||
? [...widgetCategories]
|
||||
: [];
|
||||
const selectedPickerCategory = widgetPicker
|
||||
? (widgetPicker.selectedCategory && pickerCategories.includes(widgetPicker.selectedCategory)
|
||||
? widgetPicker.selectedCategory
|
||||
: (pickerCategories[0] ?? null))
|
||||
: null;
|
||||
const topLevelSearchEntries = widgetPicker?.level === 'category' && widgetPicker.categoryQuery.trim().length > 0
|
||||
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
|
||||
: [];
|
||||
const selectedTopLevelSearchEntry = widgetPicker
|
||||
? (topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0])
|
||||
: null;
|
||||
const pickerEntries = widgetPicker
|
||||
? filterWidgetCatalog(widgetCatalog, selectedPickerCategory ?? 'All', widgetPicker.widgetQuery)
|
||||
: [];
|
||||
const selectedPickerEntry = widgetPicker
|
||||
? (pickerEntries.find(entry => entry.type === widgetPicker.selectedType) ?? pickerEntries[0])
|
||||
: null;
|
||||
|
||||
// Build dynamic help text based on selected item
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
@@ -325,28 +258,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) {
|
||||
try {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (widgetImpl) {
|
||||
canToggleRaw = widgetImpl.supportsRawValue();
|
||||
// Get custom keybinds from the widget
|
||||
if (widgetImpl.getCustomKeybinds) {
|
||||
customKeybinds = widgetImpl.getCustomKeybinds();
|
||||
}
|
||||
} catch {
|
||||
customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
|
||||
} else {
|
||||
canToggleRaw = false;
|
||||
}
|
||||
}
|
||||
|
||||
const canMerge = currentWidget && selectedIndex < widgets.length - 1 && !isSeparator && !isFlexSeparator;
|
||||
const hasWidgets = widgets.length > 0;
|
||||
|
||||
// Build main help text (without custom keybinds)
|
||||
let helpText = '↑↓ 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, (k) clone, (d)elete, (c)lear line';
|
||||
}
|
||||
if (canToggleRaw) {
|
||||
helpText += ', (r)aw value';
|
||||
}
|
||||
@@ -357,6 +293,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) {
|
||||
@@ -368,6 +309,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>
|
||||
@@ -378,6 +352,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'>
|
||||
@@ -392,7 +367,38 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
</Box>
|
||||
{moveMode ? (
|
||||
<Box flexDirection='column' marginBottom={1}>
|
||||
<Text dimColor>↑↓ to move item, ESC or Enter to exit move mode</Text>
|
||||
<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'>
|
||||
@@ -406,62 +412,165 @@ 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 items. 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;
|
||||
const segments = getMatchSegments(entry.displayName, widgetPicker.categoryQuery);
|
||||
return (
|
||||
<Box key={entry.type} flexDirection='row' flexWrap='nowrap'>
|
||||
<Box width={3}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={isSelected ? 'green' : undefined}>{`${index + 1}. `}</Text>
|
||||
{segments.map((seg, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
color={isSelected ? 'green' : seg.matched ? 'yellowBright' : undefined}
|
||||
bold={isSelected ? true : seg.matched}
|
||||
>
|
||||
{seg.text}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{selectedTopLevelSearchEntry && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor>{selectedTopLevelSearchEntry.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{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 items';
|
||||
} else if (currentWidget.type === 'flex-separator') {
|
||||
return 'Expands to fill available terminal width';
|
||||
} else {
|
||||
try {
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
pickerCategories.length === 0 ? (
|
||||
<Text dimColor>No categories available.</Text>
|
||||
) : (
|
||||
<>
|
||||
{pickerCategories.map((category, index) => {
|
||||
const isSelected = category === selectedPickerCategory;
|
||||
return (
|
||||
<Box key={category} flexDirection='row' flexWrap='nowrap'>
|
||||
<Box width={3}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{`${index + 1}. ${category}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{selectedPickerCategory === 'All' && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor>Search across all widget categories.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
pickerEntries.length === 0 ? (
|
||||
<Text dimColor>No widgets match the current category/search.</Text>
|
||||
) : (
|
||||
<>
|
||||
{pickerEntries.map((entry, index) => {
|
||||
const isSelected = entry.type === selectedPickerEntry?.type;
|
||||
const segments = getMatchSegments(entry.displayName, widgetPicker.widgetQuery);
|
||||
return (
|
||||
<Box key={entry.type} flexDirection='row' flexWrap='nowrap'>
|
||||
<Box width={3}>
|
||||
<Text color={isSelected ? 'green' : undefined}>
|
||||
{isSelected ? '▶ ' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={isSelected ? 'green' : undefined}>{`${index + 1}. `}</Text>
|
||||
{segments.map((seg, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
color={isSelected ? 'green' : (seg.matched ? 'yellowBright' : undefined)}
|
||||
bold={seg.matched}
|
||||
>
|
||||
{seg.text}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{selectedPickerEntry && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor>{selectedPickerEntry.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!widgetPicker && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{widgets.length === 0 ? (
|
||||
<Text dimColor>No widgets. Press 'a' to add one.</Text>
|
||||
) : (
|
||||
<>
|
||||
{widgets.map((widget, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null;
|
||||
const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) };
|
||||
const supportsRawValue = widgetImpl?.supportsRawValue() ?? false;
|
||||
|
||||
return (
|
||||
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
|
||||
<Box width={3}>
|
||||
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
|
||||
{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
|
||||
{`${index + 1}. ${displayText || getWidgetDisplay(widget)}`}
|
||||
</Text>
|
||||
{modifierText && (
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
{modifierText}
|
||||
</Text>
|
||||
)}
|
||||
{supportsRawValue && widget.rawValue && <Text dimColor> (raw value)</Text>}
|
||||
{widget.merge === true && <Text dimColor> (merged→)</Text>}
|
||||
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
|
||||
</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.getDescription();
|
||||
} catch {
|
||||
return 'Widget description not available';
|
||||
return widgetImpl ? widgetImpl.getDescription() : 'Unknown widget type';
|
||||
}
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
})()}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,49 +3,148 @@ import {
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
import pluralize from 'pluralize';
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { List } from './List';
|
||||
|
||||
interface LineSelectorProps {
|
||||
lines: WidgetItem[][];
|
||||
onSelect: (line: number) => void;
|
||||
onBack: () => void;
|
||||
onLinesUpdate: (lines: WidgetItem[][]) => void;
|
||||
initialSelection?: number;
|
||||
title?: string;
|
||||
blockIfPowerlineActive?: boolean;
|
||||
settings?: Settings;
|
||||
allowEditing?: boolean;
|
||||
}
|
||||
|
||||
const LineSelector: React.FC<LineSelectorProps> = ({ lines, onSelect, onBack, initialSelection = 0, title, blockIfPowerlineActive = false, settings }) => {
|
||||
const LineSelector: React.FC<LineSelectorProps> = ({
|
||||
lines,
|
||||
onSelect,
|
||||
onBack,
|
||||
onLinesUpdate,
|
||||
initialSelection = 0,
|
||||
title,
|
||||
blockIfPowerlineActive = false,
|
||||
settings,
|
||||
allowEditing = false
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [moveMode, setMoveMode] = useState(false);
|
||||
const [localLines, setLocalLines] = useState(lines);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalLines(lines);
|
||||
}, [lines]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(initialSelection);
|
||||
}, [initialSelection]);
|
||||
|
||||
const selectedLine = useMemo(
|
||||
() => localLines[selectedIndex],
|
||||
[localLines, selectedIndex]
|
||||
);
|
||||
|
||||
const appendLine = () => {
|
||||
const newLines = [...localLines, []];
|
||||
setLocalLines(newLines);
|
||||
onLinesUpdate(newLines);
|
||||
setSelectedIndex(newLines.length - 1);
|
||||
};
|
||||
|
||||
const deleteLine = (lineIndex: number) => {
|
||||
// Don't allow deleting the last remaining line
|
||||
if (localLines.length <= 1) {
|
||||
return;
|
||||
}
|
||||
const newLines = [...localLines];
|
||||
newLines.splice(lineIndex, 1);
|
||||
setLocalLines(newLines);
|
||||
onLinesUpdate(newLines);
|
||||
};
|
||||
|
||||
// Check if powerline theme is managing colors
|
||||
const powerlineEnabled = settings ? settings.powerline.enabled : false;
|
||||
const powerlineTheme = settings ? settings.powerline.theme : undefined;
|
||||
const isThemeManaged = blockIfPowerlineActive && powerlineEnabled && powerlineTheme && powerlineTheme !== 'custom';
|
||||
const isThemeManaged
|
||||
= blockIfPowerlineActive
|
||||
&& powerlineEnabled
|
||||
&& powerlineTheme
|
||||
&& powerlineTheme !== 'custom';
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
if (showDeleteDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If theme-managed and blocking is enabled, any key goes back
|
||||
if (isThemeManaged) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (moveMode) {
|
||||
if (key.upArrow && localLines.length > 1) {
|
||||
const newLines = [...localLines];
|
||||
const targetIndex = selectedIndex - 1 < 0 ? localLines.length - 1 : selectedIndex - 1;
|
||||
const temp = newLines[selectedIndex];
|
||||
const prev = newLines[targetIndex];
|
||||
if (temp && prev) {
|
||||
[newLines[selectedIndex], newLines[targetIndex]] = [prev, temp];
|
||||
}
|
||||
setLocalLines(newLines);
|
||||
onLinesUpdate(newLines);
|
||||
setSelectedIndex(targetIndex);
|
||||
} else if (key.downArrow && localLines.length > 1) {
|
||||
const newLines = [...localLines];
|
||||
const targetIndex = selectedIndex + 1 > localLines.length - 1 ? 0 : selectedIndex + 1;
|
||||
const temp = newLines[selectedIndex];
|
||||
const next = newLines[targetIndex];
|
||||
if (temp && next) {
|
||||
[newLines[selectedIndex], newLines[targetIndex]] = [next, temp];
|
||||
}
|
||||
setLocalLines(newLines);
|
||||
onLinesUpdate(newLines);
|
||||
setSelectedIndex(targetIndex);
|
||||
} else if (key.escape || key.return) {
|
||||
setMoveMode(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (input) {
|
||||
case 'a':
|
||||
if (allowEditing) {
|
||||
appendLine();
|
||||
}
|
||||
return;
|
||||
case 'd':
|
||||
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
return;
|
||||
case 'm':
|
||||
if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) {
|
||||
setMoveMode(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex(Math.min(3, selectedIndex + 1)); // 0-2 for lines, 3 for back
|
||||
} else if (key.return) {
|
||||
if (selectedIndex === 3) {
|
||||
onBack();
|
||||
} else {
|
||||
onSelect(selectedIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,17 +156,23 @@ const LineSelector: React.FC<LineSelectorProps> = ({ lines, onSelect, onBack, in
|
||||
<Box marginTop={1}>
|
||||
<Text color='yellow'>
|
||||
⚠ Colors are currently managed by the Powerline theme:
|
||||
{' ' + powerlineTheme.charAt(0).toUpperCase() + powerlineTheme.slice(1)}
|
||||
{' '
|
||||
+ powerlineTheme.charAt(0).toUpperCase()
|
||||
+ powerlineTheme.slice(1)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>To customize colors, either:</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text dimColor>• Change to 'Custom' theme in Powerline Configuration → Themes</Text>
|
||||
<Text dimColor>
|
||||
• Change to 'Custom' theme in Powerline Configuration → Themes
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text dimColor>• Disable Powerline mode in Powerline Configuration</Text>
|
||||
<Text dimColor>
|
||||
• Disable Powerline mode in Powerline Configuration
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={2}>
|
||||
<Text>Press any key to go back...</Text>
|
||||
@@ -76,43 +181,132 @@ const LineSelector: React.FC<LineSelectorProps> = ({ lines, onSelect, onBack, in
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>{title ?? 'Select Line to Edit'}</Text>
|
||||
<Text dimColor>Choose which status line to configure (up to 3 lines supported)</Text>
|
||||
<Text dimColor>Press ESC to go back</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box>
|
||||
<Text color={selectedIndex === 0 ? 'green' : undefined}>
|
||||
{selectedIndex === 0 ? '▶ ' : ' '}
|
||||
☰ Line 1
|
||||
{lines[0] && lines[0].length > 0 ? ` (${lines[0].length} items)` : ' (empty)'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={selectedIndex === 1 ? 'green' : undefined}>
|
||||
{selectedIndex === 1 ? '▶ ' : ' '}
|
||||
☰ Line 2
|
||||
{lines[1] && lines[1].length > 0 ? ` (${lines[1].length} items)` : ' (empty)'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={selectedIndex === 2 ? 'green' : undefined}>
|
||||
{selectedIndex === 2 ? '▶ ' : ' '}
|
||||
☰ Line 3
|
||||
{lines[2] && lines[2].length > 0 ? ` (${lines[2].length} items)` : ' (empty)'}
|
||||
if (showDeleteDialog && selectedLine) {
|
||||
const suffix
|
||||
= selectedLine.length > 0
|
||||
? pluralize('widget', selectedLine.length, true)
|
||||
: 'empty';
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Box flexDirection='column' gap={1}>
|
||||
<Text bold>
|
||||
<Text>
|
||||
<Text>
|
||||
☰ Line
|
||||
{selectedIndex + 1}
|
||||
</Text>
|
||||
{' '}
|
||||
<Text dimColor>
|
||||
(
|
||||
{suffix}
|
||||
)
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
<Text bold>Are you sure you want to delete line?</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={selectedIndex === 3 ? 'green' : undefined}>
|
||||
{selectedIndex === 3 ? '▶ ' : ' '}
|
||||
← Back
|
||||
</Text>
|
||||
<ConfirmDialog
|
||||
inline={true}
|
||||
onConfirm={() => {
|
||||
deleteLine(selectedIndex);
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
setShowDeleteDialog(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowDeleteDialog(false);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const lineItems = localLines.map((line, index) => ({
|
||||
label: `☰ Line ${index + 1}`,
|
||||
sublabel: `(${line.length > 0 ? pluralize('widget', line.length, true) : 'empty'})`,
|
||||
value: index
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box flexDirection='column'>
|
||||
<Box>
|
||||
<Text bold>
|
||||
{title ?? 'Select Line to Edit'}
|
||||
{' '}
|
||||
</Text>
|
||||
{moveMode && <Text color='blue'>[MOVE MODE]</Text>}
|
||||
</Box>
|
||||
<Text dimColor>
|
||||
Choose which status line to configure
|
||||
</Text>
|
||||
{moveMode ? (
|
||||
<Text dimColor>↑↓ to move line, ESC or Enter to exit move mode</Text>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
{allowEditing ? (
|
||||
localLines.length > 1
|
||||
? '(a) to append new line, (d) to delete line, (m) to move line, ESC to go back'
|
||||
: '(a) to append new line, ESC to go back'
|
||||
) : 'ESC to go back'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{moveMode ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{localLines.map((line, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const suffix = line.length
|
||||
? pluralize('widget', line.length, true)
|
||||
: 'empty';
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Text color={isSelected ? 'blue' : undefined}>
|
||||
<Text>{isSelected ? '◆ ' : ' '}</Text>
|
||||
<Text>
|
||||
<Text>
|
||||
☰ Line
|
||||
{' '}
|
||||
{index + 1}
|
||||
</Text>
|
||||
{' '}
|
||||
<Text dimColor={!isSelected}>
|
||||
(
|
||||
{suffix}
|
||||
)
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
marginTop={1}
|
||||
items={lineItems}
|
||||
onSelect={(line) => {
|
||||
if (line === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(line);
|
||||
}}
|
||||
onSelectionChange={(_, index) => {
|
||||
setSelectedIndex(index);
|
||||
}}
|
||||
initialSelection={selectedIndex}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { LineSelector, type LineSelectorProps };
|
||||
export { LineSelector, type LineSelectorProps };
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { ForegroundColorName } from 'chalk';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput,
|
||||
type BoxProps
|
||||
} from 'ink';
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren
|
||||
} from 'react';
|
||||
|
||||
export interface ListEntry<V = string | number> {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
value: V;
|
||||
props?: BoxProps;
|
||||
}
|
||||
|
||||
interface ListProps<V = string | number> extends BoxProps {
|
||||
items: (ListEntry<V> | '-')[];
|
||||
onSelect: (value: V | 'back', index: number) => void;
|
||||
onSelectionChange?: (value: V | 'back', index: number) => void;
|
||||
initialSelection?: number;
|
||||
showBackButton?: boolean;
|
||||
color?: ForegroundColorName;
|
||||
wrapNavigation?: boolean;
|
||||
}
|
||||
|
||||
export function List<V = string | number>({
|
||||
items,
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
initialSelection = 0,
|
||||
showBackButton,
|
||||
color,
|
||||
wrapNavigation = true,
|
||||
...boxProps
|
||||
}: ListProps<V>) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(initialSelection);
|
||||
const latestOnSelectionChangeRef = useRef(onSelectionChange);
|
||||
|
||||
const _items = useMemo(() => {
|
||||
if (showBackButton) {
|
||||
return [...items, '-' as const, { label: '← Back', value: 'back' as V }];
|
||||
}
|
||||
return items;
|
||||
}, [items, showBackButton]);
|
||||
|
||||
const selectableItems = _items.filter(item => item !== '-' && !item.disabled) as ListEntry<V>[];
|
||||
const selectedItem = selectableItems[selectedIndex];
|
||||
const selectedValue = selectedItem?.value;
|
||||
const actualIndex = _items.findIndex(item => item === selectedItem);
|
||||
|
||||
useEffect(() => {
|
||||
latestOnSelectionChangeRef.current = onSelectionChange;
|
||||
}, [onSelectionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxIndex = Math.max(selectableItems.length - 1, 0);
|
||||
setSelectedIndex(Math.min(initialSelection, maxIndex));
|
||||
}, [initialSelection, selectableItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedValue !== undefined) {
|
||||
latestOnSelectionChangeRef.current?.(selectedValue, selectedIndex);
|
||||
}
|
||||
}, [selectedIndex, selectedValue]);
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.upArrow) {
|
||||
const prev = selectedIndex - 1;
|
||||
const prevIndex = prev < 0
|
||||
? (wrapNavigation ? selectableItems.length - 1 : 0)
|
||||
: prev;
|
||||
|
||||
setSelectedIndex(prevIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
const next = selectedIndex + 1;
|
||||
const nextIndex = next > selectableItems.length - 1
|
||||
? (wrapNavigation ? 0 : selectableItems.length - 1)
|
||||
: next;
|
||||
|
||||
setSelectedIndex(nextIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return && selectedItem) {
|
||||
onSelect(selectedItem.value, selectedIndex);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection='column' {...boxProps}>
|
||||
{_items.map((item, index) => {
|
||||
if (item === '-') {
|
||||
return <ListSeparator key={index} />;
|
||||
}
|
||||
|
||||
const isSelected = index === actualIndex;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={index}
|
||||
isSelected={isSelected}
|
||||
color={color}
|
||||
disabled={item.disabled}
|
||||
{...item.props}
|
||||
>
|
||||
<Text>
|
||||
<Text>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.sublabel && (
|
||||
<Text dimColor={!isSelected}>
|
||||
{' '}
|
||||
{item.sublabel}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedItem?.description && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor wrap='wrap'>
|
||||
{selectedItem.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListItemProps extends PropsWithChildren, BoxProps {
|
||||
isSelected: boolean;
|
||||
color?: ForegroundColorName;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ListItem({
|
||||
children,
|
||||
isSelected,
|
||||
color = 'green',
|
||||
disabled,
|
||||
...boxProps
|
||||
}: ListItemProps) {
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
<Text color={isSelected ? color : undefined} dimColor={disabled}>
|
||||
<Text>{isSelected ? '▶ ' : ' '}</Text>
|
||||
<Text>{children}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListSeparator() {
|
||||
return <Text> </Text>;
|
||||
}
|
||||
+132
-84
@@ -1,15 +1,27 @@
|
||||
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'
|
||||
| 'configureStatusLine'
|
||||
| 'starGithub'
|
||||
| 'save'
|
||||
| 'exit';
|
||||
|
||||
export interface MainMenuProps {
|
||||
onSelect: (value: string) => void;
|
||||
onSelect: (value: MainMenuOption, index: number) => void;
|
||||
isClaudeInstalled: boolean;
|
||||
hasChanges: boolean;
|
||||
initialSelection?: number;
|
||||
@@ -18,103 +30,139 @@ 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,
|
||||
...(isClaudeInstalled
|
||||
? [
|
||||
{
|
||||
label: '🔧 Configure Status Line',
|
||||
value: 'configureStatusLine' as MainMenuOption,
|
||||
description: 'Configure Claude Code status line settings like refresh interval'
|
||||
},
|
||||
{
|
||||
label: '🔌 Uninstall from Claude Code',
|
||||
value: 'install' as MainMenuOption,
|
||||
description: 'Remove ccstatusline from your Claude Code settings'
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: '📦 Install to Claude Code',
|
||||
value: 'install' as MainMenuOption,
|
||||
description: 'Add ccstatusline to your Claude Code settings for automatic status line rendering'
|
||||
}
|
||||
]
|
||||
)
|
||||
];
|
||||
|
||||
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 up to 3 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);
|
||||
}
|
||||
@@ -141,10 +146,10 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
// Normal mode
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
} else if (key.upArrow && separators.length > 0) {
|
||||
setSelectedIndex(selectedIndex - 1 < 0 ? separators.length - 1 : selectedIndex - 1);
|
||||
} else if (key.downArrow && separators.length > 0) {
|
||||
setSelectedIndex(Math.min(separators.length - 1, selectedIndex + 1));
|
||||
setSelectedIndex(selectedIndex + 1 > separators.length - 1 ? 0 : selectedIndex + 1);
|
||||
} else if ((key.leftArrow || key.rightArrow) && separators.length > 0) {
|
||||
// Cycle through preset separators
|
||||
const currentChar = separators[selectedIndex] ?? '\uE0B0';
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
@@ -316,4 +322,4 @@ export const PowerlineSeparatorEditor: React.FC<PowerlineSeparatorEditorProps> =
|
||||
)}
|
||||
</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,151 +166,63 @@ 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) {
|
||||
onUpdate({
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
autoAlign: !powerlineConfig.autoAlign
|
||||
}
|
||||
});
|
||||
} else if ((input === 'c' || input === 'C') && powerlineConfig.enabled) {
|
||||
onUpdate({
|
||||
...settings,
|
||||
powerline: {
|
||||
...powerlineConfig,
|
||||
continueThemeAcrossLines: !powerlineConfig.continueThemeAcrossLines
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Render sub-screens
|
||||
if (screen === 'separator') {
|
||||
return (
|
||||
<PowerlineSeparatorEditor
|
||||
@@ -231,7 +266,6 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Main menu screen
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
{!confirmingFontInstall && !installingFonts && !fontInstallMessage && (
|
||||
@@ -320,28 +354,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={() => {
|
||||
@@ -367,7 +380,7 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
<>
|
||||
<Box flexDirection='column'>
|
||||
<Text>
|
||||
{' Font Status: '}
|
||||
{' Font Status: '}
|
||||
{powerlineFontStatus.installed ? (
|
||||
<>
|
||||
<Text color='green'>✓ Installed</Text>
|
||||
@@ -383,7 +396,7 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text>Powerline Mode: </Text>
|
||||
<Text> Powerline Mode: </Text>
|
||||
<Text color={powerlineConfig.enabled ? 'green' : 'red'}>
|
||||
{powerlineConfig.enabled ? '✓ Enabled ' : '✗ Disabled '}
|
||||
</Text>
|
||||
@@ -391,71 +404,59 @@ export const PowerlineSetup: React.FC<PowerlineSetupProps> = ({
|
||||
</Box>
|
||||
|
||||
{powerlineConfig.enabled && (
|
||||
<Box flexDirection='column' marginTop={1}>
|
||||
<Text dimColor>
|
||||
When enabled, global overrides are disabled and powerline separators are used
|
||||
</Text>
|
||||
<>
|
||||
<Box>
|
||||
<Text> Align Widgets: </Text>
|
||||
<Text color={powerlineConfig.autoAlign ? 'green' : 'red'}>
|
||||
{powerlineConfig.autoAlign ? '✓ Enabled ' : '✗ Disabled '}
|
||||
</Text>
|
||||
<Text dimColor> - Press (a) to toggle</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text> Continue Theme: </Text>
|
||||
<Text color={powerlineConfig.continueThemeAcrossLines ? 'green' : 'red'}>
|
||||
{powerlineConfig.continueThemeAcrossLines ? '✓ Enabled ' : '✗ Disabled '}
|
||||
</Text>
|
||||
<Text dimColor> - Press (c) to toggle</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection='column' marginTop={1}>
|
||||
<Text dimColor>
|
||||
When enabled, global overrides are disabled and powerline separators are used
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Continue Theme keeps the Powerline color sequence running across lines
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!powerlineConfig.enabled && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Enable Powerline mode to configure separators, caps, and themes.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{powerlineConfig.enabled ? (
|
||||
<>
|
||||
{menuItems.map((item, index) => {
|
||||
const isSelected = index === selectedMenuItem;
|
||||
let displayValue = '';
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildPowerlineSetupMenuItems(powerlineConfig)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,42 +204,32 @@ 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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { shouldInsertInput } from '../../utils/input-guards';
|
||||
|
||||
import {
|
||||
List,
|
||||
type ListEntry
|
||||
} from './List';
|
||||
|
||||
type ConfigureStatusLineValue = 'refreshInterval';
|
||||
|
||||
function getRefreshInputValue(interval: number | null): string {
|
||||
return interval === null ? '' : String(interval);
|
||||
}
|
||||
|
||||
function getRefreshIntervalSublabel(interval: number | null, supported: boolean): string {
|
||||
if (!supported) {
|
||||
return '(requires Claude Code >=2.1.97)';
|
||||
}
|
||||
|
||||
if (interval === null) {
|
||||
return '(not set)';
|
||||
}
|
||||
|
||||
return `(${interval}s)`;
|
||||
}
|
||||
|
||||
export function buildConfigureStatusLineItems(
|
||||
refreshInterval: number | null,
|
||||
supportsRefreshInterval: boolean
|
||||
): ListEntry<ConfigureStatusLineValue>[] {
|
||||
return [
|
||||
{
|
||||
label: '🔄 Refresh Interval',
|
||||
sublabel: getRefreshIntervalSublabel(refreshInterval, supportsRefreshInterval),
|
||||
value: 'refreshInterval',
|
||||
disabled: !supportsRefreshInterval,
|
||||
description: supportsRefreshInterval
|
||||
? 'How often Claude Code refreshes the status line by re-running the command. Enter value in seconds (1-60), or leave empty to remove.'
|
||||
: 'This setting requires Claude Code version 2.1.97 or later. Please update Claude Code to use this feature.'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export function validateRefreshIntervalInput(value: string): string | null {
|
||||
if (value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseInt(value, 10);
|
||||
|
||||
if (isNaN(parsed)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
|
||||
if (parsed < 1) {
|
||||
return `Minimum interval is 1s (you entered ${parsed}s)`;
|
||||
}
|
||||
|
||||
if (parsed > 60) {
|
||||
return `Maximum interval is 60s (you entered ${parsed}s)`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface RefreshIntervalMenuProps {
|
||||
currentInterval: number | null;
|
||||
supportsRefreshInterval: boolean;
|
||||
onUpdate: (interval: number | null) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const RefreshIntervalMenu: React.FC<RefreshIntervalMenuProps> = ({
|
||||
currentInterval,
|
||||
supportsRefreshInterval,
|
||||
onUpdate,
|
||||
onBack
|
||||
}) => {
|
||||
const [editingRefreshInterval, setEditingRefreshInterval] = useState(false);
|
||||
const [refreshInput, setRefreshInput] = useState(() => getRefreshInputValue(currentInterval));
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (editingRefreshInterval) {
|
||||
if (key.return) {
|
||||
if (refreshInput === '') {
|
||||
onUpdate(null);
|
||||
setEditingRefreshInterval(false);
|
||||
setValidationError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const error = validateRefreshIntervalInput(refreshInput);
|
||||
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
} else {
|
||||
const value = parseInt(refreshInput, 10);
|
||||
onUpdate(value);
|
||||
setEditingRefreshInterval(false);
|
||||
setValidationError(null);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
setRefreshInput(getRefreshInputValue(currentInterval));
|
||||
setEditingRefreshInterval(false);
|
||||
setValidationError(null);
|
||||
} else if (key.backspace) {
|
||||
setRefreshInput(refreshInput.slice(0, -1));
|
||||
setValidationError(null);
|
||||
} else if (key.delete) {
|
||||
// No cursor position in simple input
|
||||
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
|
||||
const newValue = refreshInput + input;
|
||||
if (newValue.length <= 2) {
|
||||
setRefreshInput(newValue);
|
||||
setValidationError(null);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text bold>Configure Status Line</Text>
|
||||
<Text color='white'>Configure Claude Code status line settings</Text>
|
||||
|
||||
{editingRefreshInterval ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text>
|
||||
Enter refresh interval in seconds (1-60):
|
||||
{' '}
|
||||
{refreshInput}
|
||||
{refreshInput.length > 0 ? 's' : ''}
|
||||
</Text>
|
||||
{validationError ? (
|
||||
<Text color='red'>{validationError}</Text>
|
||||
) : (
|
||||
<Text dimColor>Press Enter to confirm, ESC to cancel. Leave empty to remove.</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
marginTop={1}
|
||||
items={buildConfigureStatusLineItems(currentInterval, supportsRefreshInterval)}
|
||||
onSelect={(value) => {
|
||||
if (value === 'back') {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
setRefreshInput(getRefreshInputValue(currentInterval));
|
||||
setEditingRefreshInterval(true);
|
||||
}}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -5,14 +5,23 @@ import {
|
||||
} from 'ink';
|
||||
import React from 'react';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
getVisibleWidth,
|
||||
stripOscCodes,
|
||||
truncateStyledText
|
||||
} from '../../utils/ansi';
|
||||
import { advanceGlobalPowerlineThemeIndex } from '../../utils/powerline-theme-index';
|
||||
import {
|
||||
calculateMaxWidthsFromPreRendered,
|
||||
preRenderAllWidgets,
|
||||
renderStatusLineWithInfo,
|
||||
type RenderContext,
|
||||
type PreRenderedWidget,
|
||||
type RenderResult
|
||||
} from '../../utils/renderer';
|
||||
import { canDetectTerminalWidth } from '../../utils/terminal';
|
||||
import { advanceGlobalSeparatorIndex } from '../../utils/separator-index';
|
||||
|
||||
export interface StatusLinePreviewProps {
|
||||
lines: WidgetItem[][];
|
||||
@@ -24,54 +33,78 @@ export interface StatusLinePreviewProps {
|
||||
const renderSingleLine = (
|
||||
widgets: WidgetItem[],
|
||||
terminalWidth: number,
|
||||
widthDetectionAvailable: boolean,
|
||||
settings: Settings,
|
||||
lineIndex: number,
|
||||
globalSeparatorIndex: number
|
||||
globalSeparatorIndex: number,
|
||||
globalPowerlineThemeIndex: number,
|
||||
preRenderedWidgets: PreRenderedWidget[],
|
||||
preCalculatedMaxWidths: number[]
|
||||
): RenderResult => {
|
||||
// Create render context for preview
|
||||
const context: RenderContext = {
|
||||
terminalWidth,
|
||||
isPreview: true,
|
||||
minimalist: settings.minimalistMode,
|
||||
lineIndex,
|
||||
globalSeparatorIndex
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex
|
||||
};
|
||||
|
||||
return renderStatusLineWithInfo(widgets, settings, context);
|
||||
return renderStatusLineWithInfo(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
|
||||
};
|
||||
|
||||
export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings, onTruncationChange }) => {
|
||||
const widthDetectionAvailable = React.useMemo(() => canDetectTerminalWidth(), []);
|
||||
const PREVIEW_LINE_INDENT = ' ';
|
||||
|
||||
export function preparePreviewLineForTerminal(line: string, terminalWidth: number): string {
|
||||
const printableLine = stripOscCodes(line);
|
||||
const availableWidth = Math.max(0, terminalWidth - getVisibleWidth(PREVIEW_LINE_INDENT));
|
||||
return truncateStyledText(printableLine, availableWidth, { ellipsis: true });
|
||||
}
|
||||
|
||||
export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings, onTruncationChange }) => {
|
||||
// Render each configured line
|
||||
// Pass the full terminal width - the renderer will handle preview adjustments
|
||||
const { renderedLines, anyTruncated } = React.useMemo(() => {
|
||||
if (!settings)
|
||||
return { renderedLines: [], anyTruncated: false };
|
||||
|
||||
// Always pre-render all widgets once (for efficiency)
|
||||
const preRenderedLines = preRenderAllWidgets(lines, settings, { terminalWidth, isPreview: true, minimalist: settings.minimalistMode });
|
||||
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
|
||||
|
||||
let globalSeparatorIndex = 0;
|
||||
let globalPowerlineThemeIndex = 0;
|
||||
const result: string[] = [];
|
||||
let truncated = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineItems = lines[i];
|
||||
if (lineItems && lineItems.length > 0) {
|
||||
const renderResult = renderSingleLine(lineItems, terminalWidth, widthDetectionAvailable, settings, i, globalSeparatorIndex);
|
||||
const preRenderedWidgets = preRenderedLines[i] ?? [];
|
||||
const renderResult = renderSingleLine(
|
||||
lineItems,
|
||||
terminalWidth,
|
||||
settings,
|
||||
i,
|
||||
globalSeparatorIndex,
|
||||
globalPowerlineThemeIndex,
|
||||
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);
|
||||
if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) {
|
||||
globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { renderedLines: result, anyTruncated: truncated };
|
||||
}, [lines, terminalWidth, widthDetectionAvailable, settings]);
|
||||
}, [lines, terminalWidth, settings]);
|
||||
|
||||
// Notify parent when truncation status changes
|
||||
React.useEffect(() => {
|
||||
@@ -87,12 +120,12 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
|
||||
</Text>
|
||||
</Box>
|
||||
{renderedLines.map((line, index) => (
|
||||
<Text key={index}>
|
||||
{' '}
|
||||
{line}
|
||||
<Text key={index} wrap='truncate'>
|
||||
{PREVIEW_LINE_INDENT}
|
||||
{preparePreviewLineForTerminal(line, terminalWidth)}
|
||||
{chalk.reset('')}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,116 +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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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,
|
||||
@@ -144,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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -179,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>
|
||||
@@ -220,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,39 +146,32 @@ 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,182 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../../types/Settings';
|
||||
import { GlobalOverridesMenu } from '../GlobalOverridesMenu';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedWriteStream extends NodeJS.WriteStream {
|
||||
clearOutput: () => void;
|
||||
getOutput: () => string;
|
||||
}
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): CapturedWriteStream {
|
||||
const stream = new MockTtyStream();
|
||||
const chunks: string[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
return Object.assign(stream as unknown as NodeJS.WriteStream, {
|
||||
clearOutput() {
|
||||
chunks.length = 0;
|
||||
},
|
||||
getOutput() {
|
||||
return chunks.join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('GlobalOverridesMenu', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('displays minimalist mode as disabled by default', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
|
||||
const instance = render(
|
||||
React.createElement(GlobalOverridesMenu, {
|
||||
settings: DEFAULT_SETTINGS,
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
expect(stdout.getOutput()).toContain('Minimalist Mode:');
|
||||
expect(stdout.getOutput()).toContain('✗ Disabled');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('toggles minimalist mode on when (m) is pressed', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
|
||||
const instance = render(
|
||||
React.createElement(GlobalOverridesMenu, {
|
||||
settings: { ...DEFAULT_SETTINGS, minimalistMode: false },
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('m');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ minimalistMode: true }));
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('toggles minimalist mode off when (m) is pressed while enabled', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
|
||||
const instance = render(
|
||||
React.createElement(GlobalOverridesMenu, {
|
||||
settings: { ...DEFAULT_SETTINGS, minimalistMode: true },
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('m');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ minimalistMode: false }));
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,178 @@
|
||||
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 {
|
||||
PowerlineSetup,
|
||||
buildPowerlineSetupMenuItems,
|
||||
getCapDisplay,
|
||||
getSeparatorDisplay,
|
||||
getThemeDisplay,
|
||||
type PowerlineSetupProps
|
||||
} from '../PowerlineSetup';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): CapturedWriteStream {
|
||||
const stream = new MockTtyStream();
|
||||
const chunks: string[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
return Object.assign(stream as unknown as NodeJS.WriteStream, {
|
||||
getOutput() {
|
||||
return chunks.join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('PowerlineSetup helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('formats separator, cap, and theme display values', () => {
|
||||
const config = {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B4'],
|
||||
startCaps: ['\uE0B2'],
|
||||
endCaps: ['\uE0B0'],
|
||||
theme: 'gruvbox'
|
||||
};
|
||||
|
||||
expect(getSeparatorDisplay(config)).toBe('\uE0B4 - Round Right');
|
||||
expect(getCapDisplay(config, 'start')).toBe('\uE0B2 - Triangle');
|
||||
expect(getCapDisplay(config, 'end')).toBe('\uE0B0 - Triangle');
|
||||
expect(getThemeDisplay(config)).toBe('Gruvbox');
|
||||
});
|
||||
|
||||
it('builds powerline setup items with disabled states and sublabels', () => {
|
||||
const disabledItems = buildPowerlineSetupMenuItems({
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: false
|
||||
});
|
||||
|
||||
expect(disabledItems.every(item => item.disabled)).toBe(true);
|
||||
|
||||
const enabledItems = buildPowerlineSetupMenuItems({
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
separators: ['\uE0B0', '\uE0B4'],
|
||||
startCaps: [],
|
||||
endCaps: ['\uE0BC'],
|
||||
theme: undefined
|
||||
});
|
||||
|
||||
expect(enabledItems[0]).toMatchObject({
|
||||
label: 'Separator ',
|
||||
sublabel: '(multiple)',
|
||||
disabled: false
|
||||
});
|
||||
expect(enabledItems[1]).toMatchObject({
|
||||
label: 'Start Cap ',
|
||||
sublabel: '(none)'
|
||||
});
|
||||
expect(enabledItems[2]).toMatchObject({
|
||||
label: 'End Cap ',
|
||||
sublabel: '(\uE0BC - Diagonal)'
|
||||
});
|
||||
expect(enabledItems[3]).toMatchObject({
|
||||
label: 'Themes ',
|
||||
sublabel: '(Custom)'
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles continue theme across lines when (c) is pressed', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn<PowerlineSetupProps['onUpdate']>();
|
||||
const onBack = vi.fn();
|
||||
const onInstallFonts = vi.fn();
|
||||
const onClearMessage = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(PowerlineSetup, {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
enabled: true,
|
||||
continueThemeAcrossLines: false
|
||||
}
|
||||
},
|
||||
powerlineFontStatus: { installed: true },
|
||||
onUpdate,
|
||||
onBack,
|
||||
onInstallFonts,
|
||||
installingFonts: false,
|
||||
fontInstallMessage: null,
|
||||
onClearMessage
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
expect(stdout.getOutput()).toContain('Continue Theme:');
|
||||
|
||||
stdin.write('c');
|
||||
await flushInk();
|
||||
|
||||
const updatedSettings = onUpdate.mock.calls[0]?.[0];
|
||||
expect(updatedSettings).toBeDefined();
|
||||
expect(updatedSettings?.powerline.continueThemeAcrossLines).toBe(true);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,159 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
RefreshIntervalMenu,
|
||||
buildConfigureStatusLineItems,
|
||||
validateRefreshIntervalInput
|
||||
} from '../RefreshIntervalMenu';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): CapturedWriteStream {
|
||||
const stream = new MockTtyStream();
|
||||
const chunks: string[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
return Object.assign(stream as unknown as NodeJS.WriteStream, {
|
||||
getOutput() {
|
||||
return chunks.join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('validateRefreshIntervalInput', () => {
|
||||
it('should accept empty string (remove interval)', () => {
|
||||
expect(validateRefreshIntervalInput('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept valid values within range', () => {
|
||||
expect(validateRefreshIntervalInput('1')).toBeNull();
|
||||
expect(validateRefreshIntervalInput('10')).toBeNull();
|
||||
expect(validateRefreshIntervalInput('30')).toBeNull();
|
||||
expect(validateRefreshIntervalInput('60')).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject values below minimum', () => {
|
||||
expect(validateRefreshIntervalInput('0')).toContain('Minimum');
|
||||
});
|
||||
|
||||
it('should reject values above maximum', () => {
|
||||
expect(validateRefreshIntervalInput('61')).toContain('Maximum');
|
||||
});
|
||||
|
||||
it('should reject non-numeric input', () => {
|
||||
expect(validateRefreshIntervalInput('abc')).toContain('valid number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildConfigureStatusLineItems', () => {
|
||||
it('should show (not set) when interval is null and supported', () => {
|
||||
const items = buildConfigureStatusLineItems(null, true);
|
||||
expect(items[0]?.sublabel).toBe('(not set)');
|
||||
});
|
||||
|
||||
it('should show seconds for set intervals', () => {
|
||||
const items = buildConfigureStatusLineItems(10, true);
|
||||
expect(items[0]?.sublabel).toBe('(10s)');
|
||||
});
|
||||
|
||||
it('should show seconds for small values', () => {
|
||||
const items = buildConfigureStatusLineItems(1, true);
|
||||
expect(items[0]?.sublabel).toBe('(1s)');
|
||||
});
|
||||
|
||||
it('should show version requirement when not supported', () => {
|
||||
const items = buildConfigureStatusLineItems(null, false);
|
||||
expect(items[0]?.sublabel).toContain('requires Claude Code');
|
||||
expect(items[0]?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be disabled when supported', () => {
|
||||
const items = buildConfigureStatusLineItems(10, true);
|
||||
expect(items[0]?.disabled).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RefreshIntervalMenu', () => {
|
||||
it('keeps an unset interval empty when reopening the editor', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onUpdate = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(RefreshIntervalMenu, {
|
||||
currentInterval: null,
|
||||
supportsRefreshInterval: true,
|
||||
onUpdate,
|
||||
onBack
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('Enter refresh interval in seconds (1-60):');
|
||||
expect(stdout.getOutput()).not.toContain('10s');
|
||||
|
||||
stdin.write('\r');
|
||||
await flushInk();
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(null);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getVisibleWidth } from '../../../utils/ansi';
|
||||
import { renderOsc8Link } from '../../../utils/hyperlink';
|
||||
import { preparePreviewLineForTerminal } from '../StatusLinePreview';
|
||||
|
||||
describe('StatusLinePreview helpers', () => {
|
||||
it('strips OSC links and clamps preview lines to the terminal width', () => {
|
||||
const line = `${renderOsc8Link(
|
||||
'https://github.com/owner/repo/pull/42',
|
||||
'PR #42'
|
||||
)} OPEN ${'Example PR title '.repeat(8)}`;
|
||||
|
||||
const prepared = preparePreviewLineForTerminal(line, 40);
|
||||
|
||||
expect(prepared).not.toContain('github.com');
|
||||
expect(prepared.endsWith('...')).toBe(true);
|
||||
expect(getVisibleWidth(` ${prepared}`)).toBeLessThanOrEqual(40);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export * from './ItemsEditor';
|
||||
export * from './LineSelector';
|
||||
export * from './MainMenu';
|
||||
export * from './PowerlineSetup';
|
||||
export * from './RefreshIntervalMenu';
|
||||
export * from './StatusLinePreview';
|
||||
export * from './TerminalOptionsMenu';
|
||||
export * from './TerminalWidthMenu';
|
||||
export * from './TerminalWidthMenu';
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
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('resets selection to best match when typing in category search', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'vim-mode', displayName: 'Vim Mode', category: 'Core' },
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Core' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Core'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: 'v',
|
||||
key: {},
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('vim-mode');
|
||||
});
|
||||
|
||||
it('resets selection to best match when typing in widget search', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'vim-mode', displayName: 'Vim Mode', category: 'Core' },
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Core' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Core'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'widget',
|
||||
selectedCategory: 'Core',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: 'v',
|
||||
key: {},
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('vim-mode');
|
||||
});
|
||||
|
||||
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('wraps to last category when pressing up at first category', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'tokens-input', displayName: 'Tokens Input', category: 'Tokens' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git', 'Tokens'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: null
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { upArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedCategory).toBe('Tokens');
|
||||
});
|
||||
|
||||
it('wraps to first category when pressing down at last category', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'tokens-input', displayName: 'Tokens Input', category: 'Tokens' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git', 'Tokens'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'Tokens',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: null
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { downArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedCategory).toBe('All');
|
||||
});
|
||||
|
||||
it('wraps to last widget when pressing up at first widget in picker', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' },
|
||||
{ type: 'git-insertions', displayName: 'Git Insertions', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'widget',
|
||||
selectedCategory: 'Git',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { upArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('git-insertions');
|
||||
});
|
||||
|
||||
it('wraps to first widget when pressing down at last widget in picker', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' },
|
||||
{ type: 'git-insertions', displayName: 'Git Insertions', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'widget',
|
||||
selectedCategory: 'Git',
|
||||
categoryQuery: '',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-insertions'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { downArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('git-branch');
|
||||
});
|
||||
|
||||
it('wraps to last search result when pressing up at first in top-level search', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: 'git',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-branch'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { upArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('git-changes');
|
||||
});
|
||||
|
||||
it('wraps to first search result when pressing down at last in top-level search', () => {
|
||||
const widgetCatalog = createCatalog([
|
||||
{ type: 'git-branch', displayName: 'Git Branch', category: 'Git' },
|
||||
{ type: 'git-changes', displayName: 'Git Changes', category: 'Git' }
|
||||
]);
|
||||
const widgetCategories = ['All', 'Git'];
|
||||
const pickerState = createStateSetter<WidgetPickerState | null>({
|
||||
action: 'change',
|
||||
level: 'category',
|
||||
selectedCategory: 'All',
|
||||
categoryQuery: 'git',
|
||||
widgetQuery: '',
|
||||
selectedType: 'git-changes'
|
||||
});
|
||||
|
||||
handlePickerInputMode({
|
||||
input: '',
|
||||
key: { downArrow: true },
|
||||
widgetPicker: requireState(pickerState.get()),
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker: pickerState.set,
|
||||
applyWidgetPickerSelection: vi.fn()
|
||||
});
|
||||
|
||||
expect(pickerState.get()?.selectedType).toBe('git-branch');
|
||||
});
|
||||
|
||||
it('moves selected widget up in move mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ 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('wraps to last widget when pressing up at first position in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '3', type: 'git-branch' }
|
||||
];
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: '',
|
||||
key: { upArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('wraps to first widget when pressing down at last position in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '3', type: 'git-branch' }
|
||||
];
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: '',
|
||||
key: { downArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 2,
|
||||
separatorChars: ['|'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('wraps to last position when moving widget up from first position', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '3', type: 'git-branch' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleMoveInputMode({
|
||||
key: { upArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn()
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: '3', type: 'git-branch' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
]);
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('wraps to first position when moving widget down from last position', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '3', type: 'git-branch' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleMoveInputMode({
|
||||
key: { downArrow: true },
|
||||
widgets,
|
||||
selectedIndex: 2,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn()
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
{ id: '3', type: 'git-branch' },
|
||||
{ id: '2', type: 'tokens-output' },
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
]);
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('toggles raw value in normal mode for supported widgets', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'tokens-input' }
|
||||
];
|
||||
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(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
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(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
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(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.metadata?.display).toBe('progress');
|
||||
});
|
||||
|
||||
it('uses t to toggle reset timer date mode in normal mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'reset-timer' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 't',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.metadata?.absolute).toBe('true');
|
||||
});
|
||||
|
||||
it('uses h to toggle reset timer hour format in timestamp mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'h',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.metadata?.hour12).toBe('true');
|
||||
});
|
||||
|
||||
it('opens custom editor for reset timer timezone action', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setCustomEditorWidget = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'z',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
|
||||
| { action?: string; widget?: WidgetItem }
|
||||
| undefined;
|
||||
expect(customEditorState?.action).toBe('edit-timezone');
|
||||
expect(customEditorState?.widget?.type).toBe('reset-timer');
|
||||
});
|
||||
|
||||
it('opens custom editor for reset timer locale action', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setCustomEditorWidget = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'l',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
|
||||
| { action?: string; widget?: WidgetItem }
|
||||
| undefined;
|
||||
expect(customEditorState?.action).toBe('edit-locale');
|
||||
expect(customEditorState?.widget?.type).toBe('reset-timer');
|
||||
});
|
||||
|
||||
it('uses v to cycle skills widget mode', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: '1', type: 'skills' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'v',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
|
||||
expect(updated?.[0]?.metadata?.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(),
|
||||
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
|
||||
setCustomEditorWidget
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as
|
||||
| { action?: string; widget?: WidgetItem }
|
||||
| undefined;
|
||||
expect(customEditorState?.action).toBe('edit-list-limit');
|
||||
expect(customEditorState?.widget?.type).toBe('skills');
|
||||
});
|
||||
|
||||
describe('k shortcut - clone widget', () => {
|
||||
it('inserts clone after source and moves selection to clone', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: 'a', type: 'tokens-input' },
|
||||
{ id: 'b', type: 'tokens-output' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
expect(updated).toHaveLength(3);
|
||||
expect(updated[0]?.id).toBe('a');
|
||||
expect(updated[1]?.type).toBe('tokens-input');
|
||||
expect(updated[2]?.id).toBe('b');
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('copies all primitive properties of source to clone', () => {
|
||||
const widgets: WidgetItem[] = [
|
||||
{ id: 'src', type: 'tokens-input', color: 'green', bold: true, rawValue: true, backgroundColor: 'blue' }
|
||||
];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
const clone = updated[1];
|
||||
expect(clone?.color).toBe('green');
|
||||
expect(clone?.bold).toBe(true);
|
||||
expect(clone?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('generates a different id for the clone', () => {
|
||||
const widgets: WidgetItem[] = [{ id: 'src', type: 'tokens-input' }];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
expect(updated[1]?.id).not.toBe('src');
|
||||
expect(typeof updated[1]?.id).toBe('string');
|
||||
expect(updated[1]?.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shallow-clones metadata so mutating clone does not affect source', () => {
|
||||
const sourceMeta = { display: 'progress' };
|
||||
const widgets: WidgetItem[] = [{ id: 'src', type: 'session-usage', metadata: sourceMeta }];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
const cloneMeta = updated[1]?.metadata as Record<string, unknown> | undefined;
|
||||
expect(cloneMeta).toBeDefined();
|
||||
expect(cloneMeta).not.toBe(sourceMeta);
|
||||
expect(cloneMeta?.display).toBe('progress');
|
||||
|
||||
if (cloneMeta) {
|
||||
cloneMeta.display = 'changed';
|
||||
}
|
||||
expect(sourceMeta.display).toBe('progress');
|
||||
});
|
||||
|
||||
it('uses getUniqueBackgroundColor result as backgroundColor in powerline mode', () => {
|
||||
const widgets: WidgetItem[] = [{ id: 'src', type: 'tokens-input', backgroundColor: 'red' }];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn(),
|
||||
getUniqueBackgroundColor: () => 'cyan'
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
expect(updated[1]?.backgroundColor).toBe('cyan');
|
||||
});
|
||||
|
||||
it('preserves source backgroundColor when getUniqueBackgroundColor returns undefined', () => {
|
||||
const widgets: WidgetItem[] = [{ id: 'src', type: 'tokens-input', backgroundColor: 'red' }];
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets,
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex: vi.fn(),
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn(),
|
||||
getUniqueBackgroundColor: () => undefined
|
||||
});
|
||||
|
||||
const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[];
|
||||
expect(updated[1]?.backgroundColor).toBe('red');
|
||||
});
|
||||
|
||||
it('does nothing when widget list is empty', () => {
|
||||
const onUpdate = vi.fn();
|
||||
const setSelectedIndex = vi.fn();
|
||||
|
||||
handleNormalInputMode({
|
||||
input: 'k',
|
||||
key: {},
|
||||
widgets: [],
|
||||
selectedIndex: 0,
|
||||
separatorChars: ['|', '-'],
|
||||
onBack: vi.fn(),
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode: vi.fn(),
|
||||
setShowClearConfirm: vi.fn(),
|
||||
openWidgetPicker: vi.fn(),
|
||||
getCustomKeybindsForWidget: vi.fn().mockReturnValue([]),
|
||||
setCustomEditorWidget: vi.fn()
|
||||
});
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
expect(setSelectedIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetItem,
|
||||
WidgetItemType
|
||||
} from '../../../types/Widget';
|
||||
import { generateGuid } from '../../../utils/guid';
|
||||
import {
|
||||
filterWidgetCatalog,
|
||||
getWidget,
|
||||
type WidgetCatalogEntry
|
||||
} from '../../../utils/widgets';
|
||||
|
||||
export type WidgetPickerAction = 'change' | 'add' | 'insert';
|
||||
export type WidgetPickerLevel = 'category' | 'widget';
|
||||
|
||||
export interface WidgetPickerState {
|
||||
action: WidgetPickerAction;
|
||||
level: WidgetPickerLevel;
|
||||
selectedCategory: string | null;
|
||||
categoryQuery: string;
|
||||
widgetQuery: string;
|
||||
selectedType: WidgetItemType | null;
|
||||
}
|
||||
|
||||
export interface CustomEditorWidgetState {
|
||||
widget: WidgetItem;
|
||||
impl: Widget;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface InputKey {
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
tab?: boolean;
|
||||
shift?: boolean;
|
||||
upArrow?: boolean;
|
||||
downArrow?: boolean;
|
||||
leftArrow?: boolean;
|
||||
rightArrow?: boolean;
|
||||
return?: boolean;
|
||||
escape?: boolean;
|
||||
backspace?: boolean;
|
||||
delete?: boolean;
|
||||
}
|
||||
|
||||
type Setter<T> = (value: T | ((prev: T) => T)) => void;
|
||||
|
||||
function setPickerState(
|
||||
setWidgetPicker: Setter<WidgetPickerState | null>,
|
||||
normalizeState: (state: WidgetPickerState) => WidgetPickerState,
|
||||
updater: (prev: WidgetPickerState) => WidgetPickerState
|
||||
): void {
|
||||
setWidgetPicker((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return normalizeState(updater(prev));
|
||||
});
|
||||
}
|
||||
|
||||
function getPickerCategories(widgetCategories: string[]): string[] {
|
||||
return [...widgetCategories];
|
||||
}
|
||||
|
||||
export function normalizePickerState(
|
||||
state: WidgetPickerState,
|
||||
widgetCatalog: WidgetCatalogEntry[],
|
||||
widgetCategories: string[]
|
||||
): WidgetPickerState {
|
||||
const filteredCategories = getPickerCategories(widgetCategories);
|
||||
const selectedCategory = state.selectedCategory && filteredCategories.includes(state.selectedCategory)
|
||||
? state.selectedCategory
|
||||
: (filteredCategories[0] ?? null);
|
||||
|
||||
const hasTopLevelSearch = state.level === 'category' && state.categoryQuery.trim().length > 0;
|
||||
const effectiveCategory = hasTopLevelSearch ? 'All' : (selectedCategory ?? 'All');
|
||||
const effectiveQuery = hasTopLevelSearch ? state.categoryQuery : state.widgetQuery;
|
||||
const filteredWidgets = filterWidgetCatalog(widgetCatalog, effectiveCategory, effectiveQuery);
|
||||
const hasSelectedType = state.selectedType
|
||||
? filteredWidgets.some(entry => entry.type === state.selectedType)
|
||||
: false;
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedCategory,
|
||||
selectedType: hasSelectedType ? state.selectedType : (filteredWidgets[0]?.type ?? null)
|
||||
};
|
||||
}
|
||||
|
||||
interface PickerViewState {
|
||||
filteredCategories: string[];
|
||||
selectedCategory: string | null;
|
||||
hasTopLevelSearch: boolean;
|
||||
topLevelSearchEntries: WidgetCatalogEntry[];
|
||||
topLevelSelectedEntry: WidgetCatalogEntry | undefined;
|
||||
filteredWidgets: WidgetCatalogEntry[];
|
||||
selectedEntry: WidgetCatalogEntry | undefined;
|
||||
}
|
||||
|
||||
function getPickerViewState(
|
||||
widgetPicker: WidgetPickerState,
|
||||
widgetCatalog: WidgetCatalogEntry[],
|
||||
widgetCategories: string[]
|
||||
): PickerViewState {
|
||||
const filteredCategories = getPickerCategories(widgetCategories);
|
||||
const selectedCategory = widgetPicker.selectedCategory && filteredCategories.includes(widgetPicker.selectedCategory)
|
||||
? widgetPicker.selectedCategory
|
||||
: (filteredCategories[0] ?? null);
|
||||
const hasTopLevelSearch = widgetPicker.level === 'category' && widgetPicker.categoryQuery.trim().length > 0;
|
||||
const topLevelSearchEntries = hasTopLevelSearch
|
||||
? filterWidgetCatalog(widgetCatalog, 'All', widgetPicker.categoryQuery)
|
||||
: [];
|
||||
const topLevelSelectedEntry = topLevelSearchEntries.find(entry => entry.type === widgetPicker.selectedType) ?? topLevelSearchEntries[0];
|
||||
const filteredWidgets = filterWidgetCatalog(widgetCatalog, selectedCategory ?? 'All', widgetPicker.widgetQuery);
|
||||
const selectedEntry = filteredWidgets.find(entry => entry.type === widgetPicker.selectedType) ?? filteredWidgets[0];
|
||||
|
||||
return {
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
hasTopLevelSearch,
|
||||
topLevelSearchEntries,
|
||||
topLevelSelectedEntry,
|
||||
filteredWidgets,
|
||||
selectedEntry
|
||||
};
|
||||
}
|
||||
|
||||
export interface HandlePickerInputModeArgs {
|
||||
input: string;
|
||||
key: InputKey;
|
||||
widgetPicker: WidgetPickerState;
|
||||
widgetCatalog: WidgetCatalogEntry[];
|
||||
widgetCategories: string[];
|
||||
setWidgetPicker: Setter<WidgetPickerState | null>;
|
||||
applyWidgetPickerSelection: (selectedType: WidgetItemType) => void;
|
||||
}
|
||||
|
||||
export function handlePickerInputMode({
|
||||
input,
|
||||
key,
|
||||
widgetPicker,
|
||||
widgetCatalog,
|
||||
widgetCategories,
|
||||
setWidgetPicker,
|
||||
applyWidgetPickerSelection
|
||||
}: HandlePickerInputModeArgs): void {
|
||||
const normalizeState = (state: WidgetPickerState) => normalizePickerState(state, widgetCatalog, widgetCategories);
|
||||
const {
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
hasTopLevelSearch,
|
||||
topLevelSearchEntries,
|
||||
topLevelSelectedEntry,
|
||||
filteredWidgets,
|
||||
selectedEntry
|
||||
} = getPickerViewState(widgetPicker, widgetCatalog, widgetCategories);
|
||||
|
||||
if (widgetPicker.level === 'category') {
|
||||
if (key.escape) {
|
||||
if (widgetPicker.categoryQuery.length > 0) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
categoryQuery: ''
|
||||
}));
|
||||
} else {
|
||||
setWidgetPicker(null);
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (hasTopLevelSearch) {
|
||||
if (topLevelSelectedEntry) {
|
||||
applyWidgetPickerSelection(topLevelSelectedEntry.type);
|
||||
}
|
||||
} else if (selectedCategory) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
level: 'widget',
|
||||
selectedCategory
|
||||
}));
|
||||
}
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
if (hasTopLevelSearch) {
|
||||
if (topLevelSearchEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = topLevelSearchEntries.findIndex(entry => entry.type === widgetPicker.selectedType);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? (currentIndex + 1 > topLevelSearchEntries.length - 1 ? 0 : currentIndex + 1)
|
||||
: (currentIndex - 1 < 0 ? topLevelSearchEntries.length - 1 : currentIndex - 1);
|
||||
const nextType = topLevelSearchEntries[nextIndex]?.type ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
selectedType: nextType
|
||||
}));
|
||||
} else {
|
||||
if (filteredCategories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = filteredCategories.findIndex(category => category === selectedCategory);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? (currentIndex + 1 > filteredCategories.length - 1 ? 0 : currentIndex + 1)
|
||||
: (currentIndex - 1 < 0 ? filteredCategories.length - 1 : currentIndex - 1);
|
||||
const nextCategory = filteredCategories[nextIndex] ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
selectedCategory: nextCategory
|
||||
}));
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
categoryQuery: prev.categoryQuery.slice(0, -1),
|
||||
selectedType: null
|
||||
}));
|
||||
} else if (
|
||||
input
|
||||
&& !key.ctrl
|
||||
&& !key.meta
|
||||
&& !key.tab
|
||||
) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
categoryQuery: prev.categoryQuery + input,
|
||||
selectedType: null
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if (key.escape) {
|
||||
if (widgetPicker.widgetQuery.length > 0) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
widgetQuery: ''
|
||||
}));
|
||||
} else {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
level: 'category'
|
||||
}));
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (selectedEntry) {
|
||||
applyWidgetPickerSelection(selectedEntry.type);
|
||||
}
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
if (filteredWidgets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = filteredWidgets.findIndex(entry => entry.type === widgetPicker.selectedType);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
const nextIndex = key.downArrow
|
||||
? (currentIndex + 1 > filteredWidgets.length - 1 ? 0 : currentIndex + 1)
|
||||
: (currentIndex - 1 < 0 ? filteredWidgets.length - 1 : currentIndex - 1);
|
||||
const nextType = filteredWidgets[nextIndex]?.type ?? null;
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
selectedType: nextType
|
||||
}));
|
||||
} else if (key.backspace || key.delete) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
widgetQuery: prev.widgetQuery.slice(0, -1),
|
||||
selectedType: null
|
||||
}));
|
||||
} else if (
|
||||
input
|
||||
&& !key.ctrl
|
||||
&& !key.meta
|
||||
&& !key.tab
|
||||
) {
|
||||
setPickerState(setWidgetPicker, normalizeState, prev => ({
|
||||
...prev,
|
||||
widgetQuery: prev.widgetQuery + input,
|
||||
selectedType: null
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface HandleMoveInputModeArgs {
|
||||
key: InputKey;
|
||||
widgets: WidgetItem[];
|
||||
selectedIndex: number;
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
setMoveMode: (moveMode: boolean) => void;
|
||||
}
|
||||
|
||||
export function handleMoveInputMode({
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode
|
||||
}: HandleMoveInputModeArgs): void {
|
||||
if (key.upArrow && widgets.length > 1) {
|
||||
const newWidgets = [...widgets];
|
||||
const targetIndex = selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1;
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const prev = newWidgets[targetIndex];
|
||||
if (temp && prev) {
|
||||
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [prev, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(targetIndex);
|
||||
} else if (key.downArrow && widgets.length > 1) {
|
||||
const newWidgets = [...widgets];
|
||||
const targetIndex = selectedIndex + 1 > widgets.length - 1 ? 0 : selectedIndex + 1;
|
||||
const temp = newWidgets[selectedIndex];
|
||||
const next = newWidgets[targetIndex];
|
||||
if (temp && next) {
|
||||
[newWidgets[selectedIndex], newWidgets[targetIndex]] = [next, temp];
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(targetIndex);
|
||||
} else if (key.escape || key.return) {
|
||||
setMoveMode(false);
|
||||
}
|
||||
}
|
||||
|
||||
export interface HandleNormalInputModeArgs {
|
||||
input: string;
|
||||
key: InputKey;
|
||||
widgets: WidgetItem[];
|
||||
selectedIndex: number;
|
||||
separatorChars: string[];
|
||||
onBack: () => void;
|
||||
onUpdate: (widgets: WidgetItem[]) => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
setMoveMode: (moveMode: boolean) => void;
|
||||
setShowClearConfirm: (show: boolean) => void;
|
||||
openWidgetPicker: (action: WidgetPickerAction) => void;
|
||||
getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[];
|
||||
setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void;
|
||||
getUniqueBackgroundColor?: (insertIndex: number) => string | undefined;
|
||||
}
|
||||
|
||||
export function handleNormalInputMode({
|
||||
input,
|
||||
key,
|
||||
widgets,
|
||||
selectedIndex,
|
||||
separatorChars,
|
||||
onBack,
|
||||
onUpdate,
|
||||
setSelectedIndex,
|
||||
setMoveMode,
|
||||
setShowClearConfirm,
|
||||
openWidgetPicker,
|
||||
getCustomKeybindsForWidget,
|
||||
setCustomEditorWidget,
|
||||
getUniqueBackgroundColor
|
||||
}: HandleNormalInputModeArgs): void {
|
||||
if (key.upArrow && widgets.length > 0) {
|
||||
setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1);
|
||||
} else if (key.downArrow && widgets.length > 0) {
|
||||
setSelectedIndex(selectedIndex + 1 > widgets.length - 1 ? 0 : selectedIndex + 1);
|
||||
} else if (key.leftArrow && widgets.length > 0) {
|
||||
openWidgetPicker('change');
|
||||
} else if (key.rightArrow && widgets.length > 0) {
|
||||
openWidgetPicker('change');
|
||||
} else if (key.return && widgets.length > 0) {
|
||||
setMoveMode(true);
|
||||
} else if (input === 'a') {
|
||||
openWidgetPicker('add');
|
||||
} else if (input === 'i') {
|
||||
openWidgetPicker('insert');
|
||||
} else if (input === 'd' && widgets.length > 0) {
|
||||
const newWidgets = widgets.filter((_, i) => i !== selectedIndex);
|
||||
onUpdate(newWidgets);
|
||||
if (selectedIndex >= newWidgets.length && selectedIndex > 0) {
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
}
|
||||
} else if (input === 'k' && widgets.length > 0) {
|
||||
const source = widgets[selectedIndex];
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
const insertIndex = selectedIndex + 1;
|
||||
const newBg = getUniqueBackgroundColor?.(insertIndex);
|
||||
const clone: WidgetItem = {
|
||||
...source,
|
||||
id: generateGuid(),
|
||||
...(source.metadata && { metadata: { ...source.metadata } }),
|
||||
...(newBg && { backgroundColor: newBg })
|
||||
};
|
||||
const newWidgets = [
|
||||
...widgets.slice(0, insertIndex),
|
||||
clone,
|
||||
...widgets.slice(insertIndex)
|
||||
];
|
||||
onUpdate(newWidgets);
|
||||
setSelectedIndex(insertIndex);
|
||||
} else if (input === 'c') {
|
||||
if (widgets.length > 0) {
|
||||
setShowClearConfirm(true);
|
||||
}
|
||||
} else if (input === ' ' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget?.type === 'separator') {
|
||||
const currentChar = currentWidget.character ?? '|';
|
||||
const currentCharIndex = separatorChars.indexOf(currentChar);
|
||||
const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, character: nextChar };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'r' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (!widgetImpl?.supportsRawValue()) {
|
||||
return;
|
||||
}
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'm' && widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && selectedIndex < widgets.length - 1
|
||||
&& currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const newWidgets = [...widgets];
|
||||
let nextMergeState: boolean | 'no-padding' | undefined;
|
||||
|
||||
if (currentWidget.merge === undefined) {
|
||||
nextMergeState = true;
|
||||
} else if (currentWidget.merge === true) {
|
||||
nextMergeState = 'no-padding';
|
||||
} else {
|
||||
nextMergeState = undefined;
|
||||
}
|
||||
|
||||
if (nextMergeState === undefined) {
|
||||
const { merge, ...rest } = currentWidget;
|
||||
void merge; // Intentionally unused
|
||||
newWidgets[selectedIndex] = rest;
|
||||
} else {
|
||||
newWidgets[selectedIndex] = { ...currentWidget, merge: nextMergeState };
|
||||
}
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onBack();
|
||||
} else if (widgets.length > 0) {
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (!widgetImpl?.getCustomKeybinds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget);
|
||||
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
|
||||
|
||||
if (matchedKeybind && !key.ctrl) {
|
||||
if (widgetImpl.handleEditorAction) {
|
||||
const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget);
|
||||
if (updatedWidget) {
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = updatedWidget;
|
||||
onUpdate(newWidgets);
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
} else if (widgetImpl.renderEditor) {
|
||||
setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export { runTUI } from './App';
|
||||
export { runTUI } from './App';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ActivityToolStatus = 'running' | 'completed' | 'error';
|
||||
|
||||
export type ActivityAgentStatus = 'running' | 'completed';
|
||||
|
||||
export type ActivityTodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
export interface ActivityToolEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
target?: string;
|
||||
status: ActivityToolStatus;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
export interface ActivityAgentEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
model?: string;
|
||||
description?: string;
|
||||
status: ActivityAgentStatus;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
export interface ActivityTodoItem {
|
||||
content: string;
|
||||
status: ActivityTodoStatus;
|
||||
}
|
||||
|
||||
export interface ActivitySnapshot {
|
||||
tools: ActivityToolEntry[];
|
||||
agents: ActivityAgentEntry[];
|
||||
todos: ActivityTodoItem[];
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface ActivityParseOptions {
|
||||
maxTools?: number;
|
||||
maxAgents?: number;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export interface BlockMetrics {
|
||||
startTime: Date;
|
||||
lastActivity: Date;
|
||||
isActive: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { TranscriptThinkingEffort } from '../utils/jsonl-metadata';
|
||||
|
||||
export interface ClaudeSettings {
|
||||
effortLevel?: TranscriptThinkingEffort;
|
||||
permissions?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
@@ -7,6 +10,7 @@ export interface ClaudeSettings {
|
||||
type: string;
|
||||
command: string;
|
||||
padding?: number;
|
||||
refreshInterval?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ export interface ColorEntry {
|
||||
ansi16: ChalkInstance;
|
||||
ansi256: ChalkInstance;
|
||||
truecolor: ChalkInstance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ import { z } from 'zod';
|
||||
export const FlexModeSchema = z.enum(['full', 'full-minus-40', 'full-until-compact']);
|
||||
|
||||
// Inferred type from schema
|
||||
export type FlexMode = z.infer<typeof FlexModeSchema>;
|
||||
export type FlexMode = z.infer<typeof FlexModeSchema>;
|
||||
|
||||
@@ -7,8 +7,10 @@ export const PowerlineConfigSchema = z.object({
|
||||
separatorInvertBackground: z.array(z.boolean()).default([false]),
|
||||
startCaps: z.array(z.string()).default([]),
|
||||
endCaps: z.array(z.string()).default([]),
|
||||
theme: z.string().optional()
|
||||
theme: z.string().optional(),
|
||||
autoAlign: z.boolean().default(false),
|
||||
continueThemeAcrossLines: z.boolean().default(false)
|
||||
});
|
||||
|
||||
// Inferred type from schema
|
||||
export type PowerlineConfig = z.infer<typeof PowerlineConfigSchema>;
|
||||
export type PowerlineConfig = z.infer<typeof PowerlineConfigSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface PowerlineFontStatus {
|
||||
installed: boolean;
|
||||
checkedSymbol?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,49 @@
|
||||
import type { BlockMetrics } from '../types';
|
||||
import type {
|
||||
BlockMetrics,
|
||||
SkillsMetrics
|
||||
} from '../types';
|
||||
|
||||
import type { ActivitySnapshot } from './Activity';
|
||||
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 CompactionData { count: number }
|
||||
|
||||
export interface RenderContext {
|
||||
data?: StatusJSON;
|
||||
tokenMetrics?: TokenMetrics | null;
|
||||
speedMetrics?: SpeedMetrics | null;
|
||||
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
|
||||
usageData?: RenderUsageData | null;
|
||||
sessionDuration?: string | null;
|
||||
blockMetrics?: BlockMetrics | null;
|
||||
skillsMetrics?: SkillsMetrics | null;
|
||||
compactionData?: CompactionData | null;
|
||||
activity?: ActivitySnapshot | null;
|
||||
terminalWidth?: number | null;
|
||||
isPreview?: boolean;
|
||||
minimalist?: boolean;
|
||||
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
|
||||
globalSeparatorIndex?: number; // Global separator index that continues across lines
|
||||
}
|
||||
|
||||
// For git widget thresholds
|
||||
gitData?: {
|
||||
changedFiles?: number;
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
};
|
||||
globalPowerlineThemeIndex?: number; // Global powerline theme index that continues across lines
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export const SettingsSchema = z.object({
|
||||
version: z.number().default(CURRENT_VERSION),
|
||||
lines: z.array(z.array(WidgetItemSchema))
|
||||
.min(1)
|
||||
.max(3)
|
||||
.default([
|
||||
[
|
||||
{ id: '1', type: 'model', color: 'cyan' },
|
||||
@@ -37,9 +36,10 @@ export const SettingsSchema = z.object({
|
||||
{ id: '5', type: 'git-branch', color: 'magenta' },
|
||||
{ id: '6', type: 'separator' },
|
||||
{ id: '7', type: 'git-changes', color: 'yellow' }
|
||||
]
|
||||
])
|
||||
.transform(lines => lines.slice(0, 3)), // Ensure max 3 lines
|
||||
],
|
||||
[],
|
||||
[]
|
||||
]), // Ensure max 3 lines
|
||||
flexMode: FlexModeSchema.default('full-minus-40'),
|
||||
compactThreshold: z.number().min(1).max(99).default(60),
|
||||
colorLevel: ColorLevelSchema.default(2),
|
||||
@@ -49,13 +49,16 @@ export const SettingsSchema = z.object({
|
||||
overrideBackgroundColor: z.string().optional(),
|
||||
overrideForegroundColor: z.string().optional(),
|
||||
globalBold: z.boolean().default(false),
|
||||
minimalistMode: z.boolean().default(false),
|
||||
powerline: PowerlineConfigSchema.default({
|
||||
enabled: false,
|
||||
separators: ['\uE0B0'],
|
||||
separatorInvertBackground: [false],
|
||||
startCaps: [],
|
||||
endCaps: [],
|
||||
theme: undefined
|
||||
theme: undefined,
|
||||
autoAlign: false,
|
||||
continueThemeAcrossLines: false
|
||||
}),
|
||||
updatemessage: z.object({
|
||||
message: z.string().nullable().optional(),
|
||||
@@ -67,4 +70,4 @@ export const SettingsSchema = z.object({
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
|
||||
// Export a default settings constant for reference
|
||||
export const DEFAULT_SETTINGS: Settings = SettingsSchema.parse({});
|
||||
export const DEFAULT_SETTINGS: Settings = SettingsSchema.parse({});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+64
-6
@@ -1,20 +1,78 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const CoercedNumberSchema = z.preprocess((value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}, z.number());
|
||||
|
||||
const RateLimitPeriodSchema = z.object({
|
||||
used_percentage: CoercedNumberSchema.nullable().optional(),
|
||||
resets_at: CoercedNumberSchema.nullable().optional() // Unix epoch seconds
|
||||
});
|
||||
|
||||
export const StatusJSONSchema = z.looseObject({
|
||||
hook_event_name: z.string().optional(),
|
||||
session_id: z.string().optional(),
|
||||
transcript_path: z.string().optional(),
|
||||
cwd: z.string().optional(),
|
||||
model: z.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()
|
||||
}).optional(),
|
||||
version: z.string().optional(),
|
||||
output_style: z.object({ name: z.string().optional() }).optional()
|
||||
output_style: z.object({ name: z.string().optional() }).optional(),
|
||||
effort: z.object({ level: z.string().nullable().optional() }).nullable().optional(),
|
||||
cost: z.object({
|
||||
total_cost_usd: CoercedNumberSchema.optional(),
|
||||
total_duration_ms: CoercedNumberSchema.optional(),
|
||||
total_api_duration_ms: CoercedNumberSchema.optional(),
|
||||
total_lines_added: CoercedNumberSchema.optional(),
|
||||
total_lines_removed: CoercedNumberSchema.optional()
|
||||
}).optional(),
|
||||
context_window: z.object({
|
||||
context_window_size: CoercedNumberSchema.nullable().optional(),
|
||||
total_input_tokens: CoercedNumberSchema.nullable().optional(),
|
||||
total_output_tokens: CoercedNumberSchema.nullable().optional(),
|
||||
current_usage: z.union([
|
||||
CoercedNumberSchema,
|
||||
z.object({
|
||||
input_tokens: CoercedNumberSchema.optional(),
|
||||
output_tokens: CoercedNumberSchema.optional(),
|
||||
cache_creation_input_tokens: CoercedNumberSchema.optional(),
|
||||
cache_read_input_tokens: CoercedNumberSchema.optional()
|
||||
})
|
||||
]).nullable().optional(),
|
||||
used_percentage: CoercedNumberSchema.nullable().optional(),
|
||||
remaining_percentage: CoercedNumberSchema.nullable().optional()
|
||||
}).nullable().optional(),
|
||||
vim: z.object({ mode: z.string().optional() }).nullable().optional(),
|
||||
worktree: z.object({
|
||||
name: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
branch: z.string().optional(),
|
||||
original_cwd: z.string().optional(),
|
||||
original_branch: z.string().optional()
|
||||
}).nullable().optional(),
|
||||
rate_limits: z.object({
|
||||
five_hour: RateLimitPeriodSchema.optional(),
|
||||
seven_day: RateLimitPeriodSchema.optional()
|
||||
}).nullable().optional()
|
||||
});
|
||||
|
||||
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
|
||||
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
|
||||
|
||||
@@ -6,9 +6,11 @@ export interface TokenUsage {
|
||||
}
|
||||
|
||||
export interface TranscriptLine {
|
||||
message?: { usage?: TokenUsage };
|
||||
message?: { usage?: TokenUsage; stop_reason?: string | null };
|
||||
isSidechain?: boolean;
|
||||
timestamp?: string;
|
||||
isApiErrorMessage?: boolean;
|
||||
type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot';
|
||||
}
|
||||
|
||||
export interface TokenMetrics {
|
||||
@@ -17,4 +19,4 @@ export interface TokenMetrics {
|
||||
cachedTokens: number;
|
||||
totalTokens: number;
|
||||
contextLength: number;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -13,11 +13,13 @@ export const WidgetItemSchema = z.object({
|
||||
character: z.string().optional(),
|
||||
rawValue: z.boolean().optional(),
|
||||
customText: z.string().optional(),
|
||||
customSymbol: z.string().optional(),
|
||||
commandPath: z.string().optional(),
|
||||
maxWidth: z.number().optional(),
|
||||
preserveColors: z.boolean().optional(),
|
||||
timeout: z.number().optional(),
|
||||
merge: z.union([z.boolean(), z.literal('no-padding')]).optional(),
|
||||
hide: z.boolean().optional(),
|
||||
metadata: z.record(z.string(), z.string()).optional()
|
||||
});
|
||||
|
||||
@@ -34,13 +36,15 @@ 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[];
|
||||
getCustomKeybinds?(item?: WidgetItem): CustomKeybind[];
|
||||
renderEditor?(props: WidgetEditorProps): React.ReactElement | null;
|
||||
supportsRawValue(): boolean;
|
||||
supportsColors(item: WidgetItem): boolean;
|
||||
handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null;
|
||||
getNumericValue?(context: RenderContext, item: WidgetItem): number | null;
|
||||
}
|
||||
|
||||
export interface WidgetEditorProps {
|
||||
@@ -54,4 +58,4 @@ export interface CustomKeybind {
|
||||
key: string;
|
||||
label: string;
|
||||
action: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { StatusJSONSchema } from '../StatusJSON';
|
||||
|
||||
describe('StatusJSONSchema numeric coercion', () => {
|
||||
it('coerces numeric strings to numbers', () => {
|
||||
const result = StatusJSONSchema.safeParse({
|
||||
cost: {
|
||||
total_cost_usd: '1.25',
|
||||
total_duration_ms: '12345',
|
||||
total_api_duration_ms: '2345',
|
||||
total_lines_added: '12',
|
||||
total_lines_removed: '3'
|
||||
},
|
||||
context_window: {
|
||||
context_window_size: '200000',
|
||||
total_input_tokens: '1200',
|
||||
total_output_tokens: '340',
|
||||
current_usage: {
|
||||
input_tokens: '100',
|
||||
output_tokens: '50',
|
||||
cache_creation_input_tokens: '20',
|
||||
cache_read_input_tokens: '10'
|
||||
},
|
||||
used_percentage: '9.3',
|
||||
remaining_percentage: '90.7'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.data.cost?.total_duration_ms).toBe(12345);
|
||||
expect(result.data.context_window?.context_window_size).toBe(200000);
|
||||
expect(result.data.context_window?.current_usage).toEqual({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 20,
|
||||
cache_read_input_tokens: 10
|
||||
});
|
||||
expect(result.data.context_window?.used_percentage).toBe(9.3);
|
||||
});
|
||||
|
||||
it('keeps invalid numeric strings rejected', () => {
|
||||
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: 'not-a-number' } });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps empty numeric strings rejected', () => {
|
||||
const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: '' } });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts null vim payloads', () => {
|
||||
const result = StatusJSONSchema.safeParse({ vim: null });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.data.vim).toBeNull();
|
||||
});
|
||||
|
||||
it('parses rate_limits with valid data', () => {
|
||||
const result = StatusJSONSchema.safeParse({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 42, resets_at: 1774020000 },
|
||||
seven_day: { used_percentage: 15, resets_at: 1774540000 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.data.rate_limits?.five_hour?.used_percentage).toBe(42);
|
||||
expect(result.data.rate_limits?.five_hour?.resets_at).toBe(1774020000);
|
||||
expect(result.data.rate_limits?.seven_day?.used_percentage).toBe(15);
|
||||
expect(result.data.rate_limits?.seven_day?.resets_at).toBe(1774540000);
|
||||
});
|
||||
|
||||
it('accepts null rate_limits', () => {
|
||||
const result = StatusJSONSchema.safeParse({ rate_limits: null });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.data.rate_limits).toBeNull();
|
||||
});
|
||||
|
||||
it('coerces rate_limits string numbers', () => {
|
||||
const result = StatusJSONSchema.safeParse({ rate_limits: { five_hour: { used_percentage: '42', resets_at: '1774020000' } } });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.data.rate_limits?.five_hour?.used_percentage).toBe(42);
|
||||
expect(result.data.rate_limits?.five_hour?.resets_at).toBe(1774020000);
|
||||
});
|
||||
});
|
||||
+13
-1
@@ -17,4 +17,16 @@ 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';
|
||||
export type {
|
||||
ActivityAgentEntry,
|
||||
ActivityAgentStatus,
|
||||
ActivityParseOptions,
|
||||
ActivitySnapshot,
|
||||
ActivityTodoItem,
|
||||
ActivityTodoStatus,
|
||||
ActivityToolEntry,
|
||||
ActivityToolStatus
|
||||
} from './Activity';
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as fs from 'fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
clearActivitySnapshotCache,
|
||||
getActivitySnapshot,
|
||||
parseActivityContent
|
||||
} from '../activity';
|
||||
|
||||
function makeToolUseLine(id: string, name: string, input: Record<string, unknown>, timestamp: string): string {
|
||||
return JSON.stringify({
|
||||
timestamp,
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name,
|
||||
input
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function makeToolResultLine(toolUseId: string, timestamp: string, isError = false): string {
|
||||
return JSON.stringify({
|
||||
timestamp,
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
is_error: isError
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('activity parsing', () => {
|
||||
beforeEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
});
|
||||
|
||||
it('parses tool, agent, and todo activity from transcript content', () => {
|
||||
const content = [
|
||||
makeToolUseLine('tool-1', 'Read', { file_path: '/repo/src/auth.ts' }, '2026-03-03T00:00:00.000Z'),
|
||||
makeToolResultLine('tool-1', '2026-03-03T00:00:01.000Z'),
|
||||
makeToolUseLine('agent-1', 'Task', { subagent_type: 'explore', model: 'haiku', description: 'Find auth flow' }, '2026-03-03T00:00:02.000Z'),
|
||||
makeToolResultLine('agent-1', '2026-03-03T00:00:05.000Z'),
|
||||
makeToolUseLine('todo-write-1', 'TodoWrite', {
|
||||
todos: [
|
||||
{ id: 'task-1', content: 'Investigate auth bug', status: 'in_progress' },
|
||||
{ id: 'task-2', content: 'Add tests', status: 'pending' }
|
||||
]
|
||||
}, '2026-03-03T00:00:06.000Z'),
|
||||
makeToolUseLine('todo-update-1', 'TaskUpdate', {
|
||||
taskId: 'task-2',
|
||||
status: 'completed'
|
||||
}, '2026-03-03T00:00:07.000Z')
|
||||
].join('\n');
|
||||
|
||||
const snapshot = parseActivityContent(content);
|
||||
|
||||
expect(snapshot.tools).toHaveLength(1);
|
||||
expect(snapshot.tools[0]).toMatchObject({
|
||||
id: 'tool-1',
|
||||
name: 'Read',
|
||||
target: '/repo/src/auth.ts',
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
expect(snapshot.agents).toHaveLength(1);
|
||||
expect(snapshot.agents[0]).toMatchObject({
|
||||
id: 'agent-1',
|
||||
type: 'explore',
|
||||
model: 'haiku',
|
||||
description: 'Find auth flow',
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
expect(snapshot.todos).toEqual([
|
||||
{ content: 'Investigate auth bug', status: 'in_progress' },
|
||||
{ content: 'Add tests', status: 'completed' }
|
||||
]);
|
||||
expect(snapshot.updatedAt?.toISOString()).toBe('2026-03-03T00:00:07.000Z');
|
||||
});
|
||||
|
||||
it('handles malformed lines and unknown todo statuses safely', () => {
|
||||
const content = [
|
||||
'not-json',
|
||||
makeToolUseLine('todo-create-1', 'TaskCreate', { subject: 'Draft proposal', status: 'not_started' }, '2026-03-03T00:00:00.000Z'),
|
||||
makeToolUseLine('todo-update-1', 'TaskUpdate', { taskId: '1', status: 'unknown-status' }, '2026-03-03T00:00:01.000Z')
|
||||
].join('\n');
|
||||
|
||||
const snapshot = parseActivityContent(content);
|
||||
expect(snapshot.todos).toEqual([
|
||||
{ content: 'Draft proposal', status: 'pending' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects configured caps for tools and agents', () => {
|
||||
const content = [
|
||||
makeToolUseLine('tool-1', 'Read', { file_path: '/repo/1.ts' }, '2026-03-03T00:00:00.000Z'),
|
||||
makeToolUseLine('tool-2', 'Read', { file_path: '/repo/2.ts' }, '2026-03-03T00:00:01.000Z'),
|
||||
makeToolUseLine('tool-3', 'Read', { file_path: '/repo/3.ts' }, '2026-03-03T00:00:02.000Z'),
|
||||
makeToolUseLine('agent-1', 'Task', { subagent_type: 'a' }, '2026-03-03T00:00:03.000Z'),
|
||||
makeToolUseLine('agent-2', 'Task', { subagent_type: 'b' }, '2026-03-03T00:00:04.000Z')
|
||||
].join('\n');
|
||||
|
||||
const snapshot = parseActivityContent(content, { maxTools: 2, maxAgents: 1 });
|
||||
expect(snapshot.tools.map(tool => tool.id)).toEqual(['tool-2', 'tool-3']);
|
||||
expect(snapshot.agents.map(agent => agent.id)).toEqual(['agent-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activity cache', () => {
|
||||
let tempDir = '';
|
||||
|
||||
beforeEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-activity-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearActivitySnapshotCache();
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('uses cached snapshot when transcript file metadata is unchanged', () => {
|
||||
const line = makeToolUseLine('tool-1', 'Read', { file_path: '/repo/a.ts' }, '2026-03-03T00:00:00.000Z');
|
||||
const transcriptPath = path.join(tempDir, 'activity.jsonl');
|
||||
fs.writeFileSync(transcriptPath, `${line}\n`, 'utf-8');
|
||||
|
||||
const first = getActivitySnapshot(transcriptPath);
|
||||
const second = getActivitySnapshot(transcriptPath);
|
||||
|
||||
expect(first.tools).toHaveLength(1);
|
||||
expect(second.tools).toHaveLength(1);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('re-parses when transcript metadata changes', () => {
|
||||
const firstLine = makeToolUseLine('tool-1', 'Read', { file_path: '/repo/a.ts' }, '2026-03-03T00:00:00.000Z');
|
||||
const secondLine = makeToolUseLine('tool-2', 'Edit', { file_path: '/repo/longer-path-name-b.ts' }, '2026-03-03T00:01:00.000Z');
|
||||
const transcriptPath = path.join(tempDir, 'activity.jsonl');
|
||||
fs.writeFileSync(transcriptPath, `${firstLine}\n`, 'utf-8');
|
||||
|
||||
const first = getActivitySnapshot(transcriptPath);
|
||||
fs.writeFileSync(transcriptPath, `${secondLine}\n`, 'utf-8');
|
||||
const second = getActivitySnapshot(transcriptPath);
|
||||
|
||||
expect(first.tools[0]?.id).toBe('tool-1');
|
||||
expect(second.tools[0]?.id).toBe('tool-2');
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -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,565 @@
|
||||
import * as childProcess from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import {
|
||||
CCSTATUSLINE_COMMANDS,
|
||||
getClaudeCodeVersion,
|
||||
getClaudeJsonPath,
|
||||
getClaudeSettingsPath,
|
||||
getExistingStatusLine,
|
||||
getRefreshInterval,
|
||||
installStatusLine,
|
||||
isClaudeCodeVersionAtLeast,
|
||||
isInstalled,
|
||||
isKnownCommand,
|
||||
loadClaudeSettings,
|
||||
saveClaudeSettings,
|
||||
setRefreshInterval,
|
||||
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 readInstalledRefreshInterval(): number | undefined {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const data = JSON.parse(content) as { statusLine?: { refreshInterval?: number } };
|
||||
return data.statusLine?.refreshInterval;
|
||||
}
|
||||
|
||||
function writeRawClaudeSettings(content: string): void {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-settings-'));
|
||||
process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir;
|
||||
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);
|
||||
});
|
||||
|
||||
it('should match command containing ccstatusline.ts', () => {
|
||||
expect(isKnownCommand('bun run /home/user/ccstatusline/src/ccstatusline.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match command containing a quoted ccstatusline.ts path', () => {
|
||||
expect(isKnownCommand('bun run "/Users/Jane Doe/ccstatusline/src/ccstatusline.ts"')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude config paths', () => {
|
||||
it('should resolve .claude.json inside CLAUDE_CONFIG_DIR when configured', () => {
|
||||
expect(getClaudeJsonPath()).toBe(path.join(testClaudeConfigDir, '.claude.json'));
|
||||
});
|
||||
|
||||
it('should resolve .claude.json beside the default Claude config dir when CLAUDE_CONFIG_DIR is unset', () => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
expect(getClaudeJsonPath()).toBe(path.join(os.homedir(), '.claude.json'));
|
||||
});
|
||||
|
||||
it('should use default .claude.json path when CLAUDE_CONFIG_DIR points to a file', () => {
|
||||
const invalidConfigDir = path.join(testClaudeConfigDir, 'not-a-dir');
|
||||
fs.writeFileSync(invalidConfigDir, 'not a directory', 'utf-8');
|
||||
process.env.CLAUDE_CONFIG_DIR = invalidConfigDir;
|
||||
|
||||
expect(getClaudeJsonPath()).toBe(path.join(os.homedir(), '.claude.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommand via installStatusLine', () => {
|
||||
it('should use base command when no custom config path', async () => {
|
||||
initConfigPath();
|
||||
await installStatusLine(false);
|
||||
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('installStatusLine refreshInterval', () => {
|
||||
it('should set refreshInterval to 10 when version is supported', async () => {
|
||||
initConfigPath();
|
||||
await installStatusLine(false, true);
|
||||
expect(readInstalledRefreshInterval()).toBe(10);
|
||||
});
|
||||
|
||||
it('should not set refreshInterval when version is unsupported', async () => {
|
||||
initConfigPath();
|
||||
await installStatusLine(false, false);
|
||||
expect(readInstalledRefreshInterval()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve existing refreshInterval on re-install', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0,
|
||||
refreshInterval: 5
|
||||
}
|
||||
}));
|
||||
await installStatusLine(false, true);
|
||||
expect(readInstalledRefreshInterval()).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshInterval', () => {
|
||||
it('getRefreshInterval should return null when no settings exist', async () => {
|
||||
await expect(getRefreshInterval()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('getRefreshInterval should return null when statusLine has no refreshInterval', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
await expect(getRefreshInterval()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('getRefreshInterval should return the configured value', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0,
|
||||
refreshInterval: 5
|
||||
}
|
||||
});
|
||||
await expect(getRefreshInterval()).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it('setRefreshInterval should set the value on existing statusLine', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
|
||||
await setRefreshInterval(15);
|
||||
|
||||
const settings = await loadClaudeSettings();
|
||||
expect(settings.statusLine?.refreshInterval).toBe(15);
|
||||
});
|
||||
|
||||
it('setRefreshInterval with null should remove refreshInterval', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0,
|
||||
refreshInterval: 10
|
||||
}
|
||||
});
|
||||
|
||||
await setRefreshInterval(null);
|
||||
|
||||
const settings = await loadClaudeSettings();
|
||||
expect(settings.statusLine?.refreshInterval).toBeUndefined();
|
||||
});
|
||||
|
||||
it('setRefreshInterval should do nothing when no statusLine exists', async () => {
|
||||
await saveClaudeSettings({});
|
||||
|
||||
await setRefreshInterval(10);
|
||||
|
||||
const settings = await loadClaudeSettings();
|
||||
expect(settings.statusLine).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup and error handling behavior', () => {
|
||||
it('saveClaudeSettings should create .bak backup before overwrite', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'preexisting-command',
|
||||
padding: 1
|
||||
}
|
||||
}));
|
||||
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: CCSTATUSLINE_COMMANDS.NPM,
|
||||
padding: 0
|
||||
}
|
||||
});
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string } };
|
||||
expect(saved.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM);
|
||||
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true);
|
||||
|
||||
const backup = JSON.parse(fs.readFileSync(`${settingsPath}.bak`, 'utf-8')) as { statusLine?: { command?: string } };
|
||||
expect(backup.statusLine?.command).toBe('preexisting-command');
|
||||
});
|
||||
|
||||
it('installStatusLine should create .orig backup before updating settings', async () => {
|
||||
writeRawClaudeSettings(JSON.stringify({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'old-command',
|
||||
padding: 1
|
||||
}
|
||||
}));
|
||||
|
||||
await installStatusLine(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);
|
||||
});
|
||||
|
||||
it('isInstalled should accept quoted local development commands when padding is undefined', async () => {
|
||||
await saveClaudeSettings({
|
||||
statusLine: {
|
||||
type: 'command',
|
||||
command: 'bun run "/Users/Jane Doe/ccstatusline/src/ccstatusline.ts"'
|
||||
}
|
||||
});
|
||||
|
||||
await expect(isInstalled()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClaudeCodeVersion', () => {
|
||||
it('should parse version from claude --version output', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.97 (Claude Code)\n');
|
||||
expect(getClaudeCodeVersion()).toBe('2.1.97');
|
||||
});
|
||||
|
||||
it('should parse version without suffix text', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('3.0.0\n');
|
||||
expect(getClaudeCodeVersion()).toBe('3.0.0');
|
||||
});
|
||||
|
||||
it('should return null when claude is not installed', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockImplementation(() => { throw new Error('not found'); });
|
||||
expect(getClaudeCodeVersion()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unexpected output', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('unknown output');
|
||||
expect(getClaudeCodeVersion()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isClaudeCodeVersionAtLeast', () => {
|
||||
it('should return true when version equals minimum', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.97 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when patch is higher', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.100 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when minor is higher', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.2.0 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when major is higher', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('3.0.0 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when version is lower', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.1.96 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when minor is lower', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockReturnValue('2.0.100 (Claude Code)\n');
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when claude is not installed', () => {
|
||||
vi.spyOn(childProcess, 'execSync').mockImplementation(() => { throw new Error('not found'); });
|
||||
expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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,294 @@
|
||||
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 {
|
||||
detectCompaction,
|
||||
loadCompactionState,
|
||||
saveCompactionState,
|
||||
type CompactionState
|
||||
} from '../compaction';
|
||||
|
||||
const fresh: CompactionState = { count: 0, prevCtxPct: -1 };
|
||||
|
||||
describe('detectCompaction', () => {
|
||||
it('does not detect on first render (sentinel prevCtxPct)', () => {
|
||||
const result = detectCompaction(40, fresh);
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.prevCtxPct).toBe(40);
|
||||
});
|
||||
|
||||
it('detects compaction when ctx drops by more than 2 points', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(30, prev);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('does not detect when ctx drops by exactly 2 points', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(38, prev);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
it('does not detect when ctx drops by 1 point (rounding noise)', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 8 };
|
||||
const result = detectCompaction(7, prev);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
it('does not detect when ctx increases', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(45, prev);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
it('does not detect when ctx stays the same', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(40, prev);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
it('detects 3-point drop on 1M window', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 8 };
|
||||
const result = detectCompaction(5, prev);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('detects large compaction on 200K window', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 85 };
|
||||
const result = detectCompaction(30, prev);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('increments existing count', () => {
|
||||
const prev: CompactionState = { count: 3, prevCtxPct: 70 };
|
||||
const result = detectCompaction(40, prev);
|
||||
expect(result.count).toBe(4);
|
||||
});
|
||||
|
||||
it('updates prevCtxPct regardless of detection', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(45, prev);
|
||||
expect(result.prevCtxPct).toBe(45);
|
||||
});
|
||||
|
||||
it('accepts custom threshold', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 10 };
|
||||
const result = detectCompaction(8, prev, 1);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('stores the current context window size when provided', () => {
|
||||
const result = detectCompaction(40, fresh, { windowSize: 200000 });
|
||||
expect(result).toEqual({ count: 0, prevCtxPct: 40, prevWindowSize: 200000 });
|
||||
});
|
||||
|
||||
it('detects compaction when the context window size is unchanged', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40, prevWindowSize: 200000 };
|
||||
const result = detectCompaction(30, prev, { windowSize: 200000 });
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.prevWindowSize).toBe(200000);
|
||||
});
|
||||
|
||||
it('resets the baseline without incrementing when the context window size changes', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40, prevWindowSize: 200000 };
|
||||
const result = detectCompaction(8, prev, { windowSize: 1000000 });
|
||||
expect(result).toEqual({ count: 0, prevCtxPct: 8, prevWindowSize: 1000000 });
|
||||
});
|
||||
|
||||
it('learns the context window size for legacy state without incrementing', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
const result = detectCompaction(8, prev, { windowSize: 1000000 });
|
||||
expect(result).toEqual({ count: 0, prevCtxPct: 8, prevWindowSize: 1000000 });
|
||||
});
|
||||
|
||||
it('accepts custom threshold in options', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 10, prevWindowSize: 200000 };
|
||||
const result = detectCompaction(8, prev, { dropThreshold: 1, windowSize: 200000 });
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('returns state unchanged for NaN input (no poison)', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
expect(detectCompaction(NaN, prev)).toEqual(prev);
|
||||
});
|
||||
|
||||
it('returns state unchanged for Infinity input', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
expect(detectCompaction(Infinity, prev)).toEqual(prev);
|
||||
});
|
||||
|
||||
it('returns state unchanged for negative input', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
|
||||
expect(detectCompaction(-1, prev)).toEqual(prev);
|
||||
});
|
||||
|
||||
it('detects drops using non-integer percentages', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 40.4 };
|
||||
// 2.8-point drop, exceeds default threshold of 2
|
||||
const result = detectCompaction(37.6, prev);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('handles a session that starts at 0% (sentinel guards first render)', () => {
|
||||
// sequence: -1 (fresh) -> 0 -> 5 -> 30 -> 10
|
||||
// First three transitions: no detection. Fourth (30 -> 10) is a real drop.
|
||||
let state = fresh;
|
||||
state = detectCompaction(0, state);
|
||||
expect(state).toEqual({ count: 0, prevCtxPct: 0 });
|
||||
state = detectCompaction(5, state);
|
||||
expect(state.count).toBe(0);
|
||||
state = detectCompaction(30, state);
|
||||
expect(state.count).toBe(0);
|
||||
state = detectCompaction(10, state);
|
||||
expect(state.count).toBe(1);
|
||||
});
|
||||
|
||||
it('detects multiple sequential compactions', () => {
|
||||
// sequence: -1 (fresh) -> 40 -> 10 -> 50 -> 20
|
||||
let state = fresh;
|
||||
state = detectCompaction(40, state);
|
||||
state = detectCompaction(10, state);
|
||||
expect(state.count).toBe(1);
|
||||
state = detectCompaction(50, state);
|
||||
state = detectCompaction(20, state);
|
||||
expect(state.count).toBe(2);
|
||||
});
|
||||
|
||||
it('with threshold 0, every strict drop counts', () => {
|
||||
const prev: CompactionState = { count: 0, prevCtxPct: 10 };
|
||||
expect(detectCompaction(9.5, prev, 0).count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
let testHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'compaction-test-'));
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(testHome);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips state through save and load', () => {
|
||||
const state: CompactionState = { count: 5, prevCtxPct: 42, prevWindowSize: 200000 };
|
||||
saveCompactionState('test-session', state);
|
||||
const loaded = loadCompactionState('test-session');
|
||||
expect(loaded).toEqual(state);
|
||||
});
|
||||
|
||||
it('returns fresh state for unknown session', () => {
|
||||
const loaded = loadCompactionState('nonexistent');
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
});
|
||||
|
||||
it('sanitizes path traversal in session ID', () => {
|
||||
const malicious = '../../../../../../tmp/pwn';
|
||||
saveCompactionState(malicious, { count: 1, prevCtxPct: 50 });
|
||||
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
const files = fs.existsSync(cacheDir) ? fs.readdirSync(cacheDir) : [];
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0]).toMatch(/^compaction-[a-zA-Z0-9_-]+\.json$/);
|
||||
|
||||
expect(fs.existsSync('/tmp/pwn.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('hashes session IDs that contain only illegal characters to avoid collision', () => {
|
||||
// Without hashing, both '....' and '!!!!' would sanitize to '____' and collide.
|
||||
saveCompactionState('....', { count: 1, prevCtxPct: 10 });
|
||||
saveCompactionState('!!!!', { count: 2, prevCtxPct: 20 });
|
||||
expect(loadCompactionState('....').count).toBe(1);
|
||||
expect(loadCompactionState('!!!!').count).toBe(2);
|
||||
});
|
||||
|
||||
it('hashes empty session ID to avoid blank filename leaf', () => {
|
||||
saveCompactionState('', { count: 1, prevCtxPct: 10 });
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
const files = fs.readdirSync(cacheDir);
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0]).not.toBe('compaction-.json');
|
||||
expect(files[0]).toMatch(/^compaction-[a-f0-9]{32}\.json$/);
|
||||
});
|
||||
|
||||
it('does not throw on write failure', () => {
|
||||
vi.spyOn(os, 'homedir').mockReturnValue('/nonexistent/readonly/path');
|
||||
expect(() => {
|
||||
saveCompactionState('test', { count: 1, prevCtxPct: 50 });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns fresh state when cache file has corrupted JSON', () => {
|
||||
saveCompactionState('corrupt-test', { count: 5, prevCtxPct: 50 });
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
const cacheFile = path.join(cacheDir, fs.readdirSync(cacheDir)[0] ?? '');
|
||||
fs.writeFileSync(cacheFile, '{ this is not valid json');
|
||||
|
||||
const loaded = loadCompactionState('corrupt-test');
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
});
|
||||
|
||||
it('returns fresh state when cache file exceeds size cap', () => {
|
||||
saveCompactionState('big-test', { count: 5, prevCtxPct: 50 });
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
const cacheFile = path.join(cacheDir, fs.readdirSync(cacheDir)[0] ?? '');
|
||||
fs.writeFileSync(cacheFile, 'a'.repeat(8192));
|
||||
|
||||
const loaded = loadCompactionState('big-test');
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('returns fresh state when cache path is a symlink', () => {
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const realPath = path.join(testHome, 'real.json');
|
||||
fs.writeFileSync(realPath, JSON.stringify({ count: 99, prevCtxPct: 50 }));
|
||||
|
||||
// sessionId 'symlink-test' has only legal chars, so the cache filename
|
||||
// is deterministically compaction-symlink-test.json
|
||||
const sessionId = 'symlink-test';
|
||||
const symlinkPath = path.join(cacheDir, `compaction-${sessionId}.json`);
|
||||
fs.symlinkSync(realPath, symlinkPath);
|
||||
|
||||
const loaded = loadCompactionState(sessionId);
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
});
|
||||
|
||||
it('uses zod defaults for missing fields in cache file', () => {
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const cacheFile = path.join(cacheDir, 'compaction-partial.json');
|
||||
fs.writeFileSync(cacheFile, JSON.stringify({}));
|
||||
|
||||
const loaded = loadCompactionState('partial');
|
||||
expect(loaded).toEqual({ count: 0, prevCtxPct: -1 });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('atomic save replaces a planted symlink rather than writing through it', () => {
|
||||
const cacheDir = path.join(testHome, '.cache', 'ccstatusline', 'compaction');
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const sessionId = 'rename-test';
|
||||
const targetPath = path.join(cacheDir, `compaction-${sessionId}.json`);
|
||||
const decoyTarget = path.join(testHome, 'decoy.txt');
|
||||
fs.writeFileSync(decoyTarget, 'do not overwrite me');
|
||||
fs.symlinkSync(decoyTarget, targetPath);
|
||||
|
||||
saveCompactionState(sessionId, { count: 1, prevCtxPct: 30 });
|
||||
|
||||
// The decoy must be untouched; the cache path is now a regular file.
|
||||
expect(fs.readFileSync(decoyTarget, 'utf-8')).toBe('do not overwrite me');
|
||||
expect(fs.lstatSync(targetPath).isFile()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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,209 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it('silently rewrites legacy git-pr widget type to git-review on load', async () => {
|
||||
const { settingsPath, configDir } = getSettingsPaths();
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
version: CURRENT_VERSION,
|
||||
lines: [
|
||||
[
|
||||
{ id: 'widget-1', type: 'model' },
|
||||
{ id: 'widget-2', type: 'git-pr' }
|
||||
],
|
||||
[],
|
||||
[]
|
||||
]
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const settings = await loadSettings();
|
||||
|
||||
// In-memory rewrite: legacy string is gone.
|
||||
const types = settings.lines[0]?.map(item => item.type);
|
||||
expect(types).toEqual(['model', 'git-review']);
|
||||
|
||||
// Load does not eagerly persist; the rewrite lands on next save.
|
||||
const onDiskBeforeSave = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { lines: { type: string }[][] };
|
||||
expect(onDiskBeforeSave.lines[0]?.[1]?.type).toBe('git-pr');
|
||||
|
||||
await saveSettings(settings);
|
||||
|
||||
const onDiskAfterSave = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { lines: { type: string }[][] };
|
||||
expect(onDiskAfterSave.lines[0]?.[1]?.type).toBe('git-review');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types';
|
||||
import {
|
||||
calculateContextPercentage,
|
||||
calculateContextPercentageMetrics
|
||||
} from '../context-percentage';
|
||||
|
||||
describe('calculateContextPercentage', () => {
|
||||
describe('Status JSON context_window', () => {
|
||||
it('should prefer context_window used_percentage over token metrics', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-3-5-sonnet-20241022' },
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
used_percentage: 12.5
|
||||
}
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 100000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(12.5);
|
||||
});
|
||||
|
||||
it('should infer the window size for raw percentage metrics when size is missing', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-sonnet-4-5-20250929[1m]' },
|
||||
context_window: { used_percentage: 4.2 }
|
||||
}
|
||||
};
|
||||
|
||||
expect(calculateContextPercentageMetrics(context)).toEqual({
|
||||
usedPercentage: 4.2,
|
||||
windowSize: 1000000
|
||||
});
|
||||
});
|
||||
|
||||
it('should derive percentage from current usage and window size when used_percentage is missing', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 20000,
|
||||
output_tokens: 10000,
|
||||
cache_creation_input_tokens: 5000,
|
||||
cache_read_input_tokens: 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(20);
|
||||
});
|
||||
|
||||
it('should return derived percentage metrics from current usage when used_percentage is missing', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 20000,
|
||||
output_tokens: 10000,
|
||||
cache_creation_input_tokens: 5000,
|
||||
cache_read_input_tokens: 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(calculateContextPercentageMetrics(context)).toEqual({
|
||||
usedPercentage: 20,
|
||||
windowSize: 200000
|
||||
});
|
||||
});
|
||||
|
||||
it('should use context_window_size as denominator when falling back to token metrics', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-3-5-sonnet-20241022' },
|
||||
context_window: { context_window_size: 1000000 }
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
|
||||
it('should return token-metric fallback metrics with the denominator used', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-3-5-sonnet-20241022' },
|
||||
context_window: { context_window_size: 1000000 }
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
expect(calculateContextPercentageMetrics(context)).toEqual({
|
||||
usedPercentage: 4.2,
|
||||
windowSize: 1000000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sonnet 4.5 with 1M context window', () => {
|
||||
it('should calculate percentage using 1M denominator with [1m] suffix', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
|
||||
it('should cap at 100% with [1m] suffix', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 2000000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(100);
|
||||
});
|
||||
|
||||
it('should calculate percentage using 1M denominator with 1M context label', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'Opus 4.6 (1M context)' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
|
||||
it('should calculate percentage using 1M denominator with 1M in parentheses', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'Opus 4.6 (1M)' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
|
||||
it('should calculate percentage from display_name when model id lacks context size suffix', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: {
|
||||
id: 'claude-opus-4-6',
|
||||
display_name: 'Opus 4.6 (1M context)'
|
||||
}
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Older models with 200k context window', () => {
|
||||
it('should calculate percentage using 200k denominator', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'claude-3-5-sonnet-20241022' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(21.0);
|
||||
});
|
||||
|
||||
it('should return 0 when no token metrics', () => {
|
||||
const context: RenderContext = { data: { model: { id: 'claude-3-5-sonnet-20241022' } } };
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(0);
|
||||
expect(calculateContextPercentageMetrics(context)).toBeNull();
|
||||
});
|
||||
|
||||
it('should use default 200k context when model ID is undefined', () => {
|
||||
const context: RenderContext = {
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(21.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getContextWindowMetrics } from '../context-window';
|
||||
|
||||
describe('getContextWindowMetrics', () => {
|
||||
it('returns null metrics when context_window is missing', () => {
|
||||
expect(getContextWindowMetrics(undefined)).toEqual({
|
||||
windowSize: null,
|
||||
usedTokens: null,
|
||||
contextLengthTokens: null,
|
||||
usedPercentage: null,
|
||||
remainingPercentage: null,
|
||||
totalInputTokens: null,
|
||||
totalOutputTokens: null,
|
||||
cachedTokens: null,
|
||||
totalTokens: null
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts totals and usage from current_usage object', () => {
|
||||
const metrics = getContextWindowMetrics({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
total_input_tokens: 4567,
|
||||
total_output_tokens: 890,
|
||||
current_usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 300,
|
||||
cache_read_input_tokens: 400
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
windowSize: 200000,
|
||||
usedTokens: 1900,
|
||||
contextLengthTokens: 1700,
|
||||
usedPercentage: 0.95,
|
||||
remainingPercentage: 99.05,
|
||||
totalInputTokens: 4567,
|
||||
totalOutputTokens: 890,
|
||||
cachedTokens: 700,
|
||||
totalTokens: 1900
|
||||
});
|
||||
});
|
||||
|
||||
it('derives token usage from used_percentage when current_usage is missing', () => {
|
||||
const metrics = getContextWindowMetrics({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
used_percentage: 12.5,
|
||||
remaining_percentage: 87.5,
|
||||
total_input_tokens: 5000,
|
||||
total_output_tokens: 1000
|
||||
}
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
windowSize: 200000,
|
||||
usedTokens: 25000,
|
||||
contextLengthTokens: 25000,
|
||||
usedPercentage: 12.5,
|
||||
remainingPercentage: 87.5,
|
||||
totalInputTokens: 5000,
|
||||
totalOutputTokens: 1000,
|
||||
cachedTokens: null,
|
||||
totalTokens: 6000
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import { clearGitCache } from '../git';
|
||||
import {
|
||||
buildRepoWebUrl,
|
||||
getForkStatus,
|
||||
getRemoteInfo,
|
||||
getUpstreamRemoteInfo,
|
||||
listRemotes,
|
||||
parseRemoteUrl
|
||||
} from '../git-remote';
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
spawnSync: vi.fn()
|
||||
}));
|
||||
|
||||
const mockExecFileSync = execFileSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockImplementation: (impl: () => never) => void;
|
||||
mockImplementationOnce: (impl: () => never) => void;
|
||||
mockReturnValue: (value: string) => void;
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
describe('git-remote utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearGitCache();
|
||||
});
|
||||
|
||||
describe('parseRemoteUrl', () => {
|
||||
describe('SSH format (git@host:owner/repo)', () => {
|
||||
it('parses github.com SSH URL', () => {
|
||||
expect(parseRemoteUrl('git@github.com:owner/repo.git')).toEqual({
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses GHES SSH URL', () => {
|
||||
expect(parseRemoteUrl('git@github.service.anz:org/project.git')).toEqual({
|
||||
host: 'github.service.anz',
|
||||
owner: 'org',
|
||||
repo: 'project'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses SSH URL without .git suffix', () => {
|
||||
expect(parseRemoteUrl('git@github.com:owner/repo')).toEqual({
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses SSH URL with trailing slash', () => {
|
||||
expect(parseRemoteUrl('git@github.com:owner/repo/')).toEqual({
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses GitLab SSH URL', () => {
|
||||
expect(parseRemoteUrl('git@gitlab.com:group/project.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
owner: 'group',
|
||||
repo: 'project'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses nested GitLab SSH namespace', () => {
|
||||
expect(parseRemoteUrl('git@gitlab.com:group/subgroup/project.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
owner: 'group/subgroup',
|
||||
repo: 'project'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTPS format', () => {
|
||||
it('parses github.com HTTPS URL', () => {
|
||||
expect(parseRemoteUrl('https://github.com/owner/repo.git')).toEqual({
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses GHES HTTPS URL', () => {
|
||||
expect(parseRemoteUrl('https://github.service.anz/org/project.git')).toEqual({
|
||||
host: 'github.service.anz',
|
||||
owner: 'org',
|
||||
repo: 'project'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses HTTPS URL without .git suffix', () => {
|
||||
expect(parseRemoteUrl('https://github.com/owner/repo')).toEqual({
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses HTTP URL', () => {
|
||||
expect(parseRemoteUrl('http://github.com/owner/repo.git')).toEqual({
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses nested HTTPS namespace', () => {
|
||||
expect(parseRemoteUrl('https://gitlab.com/group/subgroup/project.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
owner: 'group/subgroup',
|
||||
repo: 'project'
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves non-default HTTPS ports', () => {
|
||||
expect(parseRemoteUrl('https://git.example.com:8443/team/repo.git')).toEqual({
|
||||
host: 'git.example.com:8443',
|
||||
owner: 'team',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh:// protocol format', () => {
|
||||
it('parses ssh:// URL', () => {
|
||||
expect(parseRemoteUrl('ssh://git@github.com/owner/repo.git')).toEqual({
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses ssh:// URL for GHES', () => {
|
||||
expect(parseRemoteUrl('ssh://git@github.service.anz/org/project.git')).toEqual({
|
||||
host: 'github.service.anz',
|
||||
owner: 'org',
|
||||
repo: 'project'
|
||||
});
|
||||
});
|
||||
|
||||
it('omits ssh:// transport ports from the parsed host', () => {
|
||||
expect(parseRemoteUrl('ssh://git@git.example.com:2222/team/repo.git')).toEqual({
|
||||
host: 'git.example.com',
|
||||
owner: 'team',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('git:// protocol format', () => {
|
||||
it('parses git:// URL', () => {
|
||||
expect(parseRemoteUrl('git://github.com/owner/repo.git')).toEqual({
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseRemoteUrl('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for whitespace-only string', () => {
|
||||
expect(parseRemoteUrl(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid URL', () => {
|
||||
expect(parseRemoteUrl('not-a-url')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for URL with only owner (no repo)', () => {
|
||||
expect(parseRemoteUrl('https://github.com/owner')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unsupported protocol', () => {
|
||||
expect(parseRemoteUrl('ftp://github.com/owner/repo.git')).toBeNull();
|
||||
});
|
||||
|
||||
it('trims whitespace from URL', () => {
|
||||
expect(parseRemoteUrl(' https://github.com/owner/repo.git ')).toEqual({
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteInfo', () => {
|
||||
it('returns remote info for valid remote', () => {
|
||||
mockExecFileSync.mockReturnValue('https://github.com/hangie/ccstatusline.git\n');
|
||||
const context: RenderContext = { data: { cwd: '/tmp/repo' } };
|
||||
|
||||
const result = getRemoteInfo('origin', context);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'origin',
|
||||
url: 'https://github.com/hangie/ccstatusline.git',
|
||||
host: 'github.com',
|
||||
owner: 'hangie',
|
||||
repo: 'ccstatusline'
|
||||
});
|
||||
});
|
||||
|
||||
it('passes remote name as a literal git argument', () => {
|
||||
mockExecFileSync.mockReturnValue('https://github.com/hangie/ccstatusline.git\n');
|
||||
const remoteName = 'foo$(touch /tmp/pwn)';
|
||||
|
||||
getRemoteInfo(remoteName, {});
|
||||
|
||||
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('git');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['remote', 'get-url', '--', remoteName]);
|
||||
});
|
||||
|
||||
it('returns null when remote does not exist', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('No such remote'); });
|
||||
|
||||
expect(getRemoteInfo('nonexistent', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when URL cannot be parsed', () => {
|
||||
mockExecFileSync.mockReturnValue('invalid-url\n');
|
||||
|
||||
expect(getRemoteInfo('origin', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpstreamRemoteInfo', () => {
|
||||
it('prefers a literal upstream remote when present', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/upstream-owner/repo.git\n');
|
||||
|
||||
expect(getUpstreamRemoteInfo({})).toEqual({
|
||||
name: 'upstream',
|
||||
url: 'https://github.com/upstream-owner/repo.git',
|
||||
host: 'github.com',
|
||||
owner: 'upstream-owner',
|
||||
repo: 'repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the tracking remote when upstream is not a remote name', () => {
|
||||
mockExecFileSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
|
||||
mockExecFileSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
|
||||
mockExecFileSync.mockReturnValueOnce('origin\nhangie\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n');
|
||||
|
||||
expect(getUpstreamRemoteInfo({})).toEqual({
|
||||
name: 'hangie',
|
||||
url: 'https://github.com/hangie/ccstatusline.git',
|
||||
host: 'github.com',
|
||||
owner: 'hangie',
|
||||
repo: 'ccstatusline'
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the longest remote prefix when remote names contain slashes', () => {
|
||||
mockExecFileSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
|
||||
mockExecFileSync.mockReturnValueOnce('team/upstream/feature/worktree\n');
|
||||
mockExecFileSync.mockReturnValueOnce('origin\nteam\nteam/upstream\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/team/upstream-repo.git\n');
|
||||
|
||||
expect(getUpstreamRemoteInfo({})).toEqual({
|
||||
name: 'team/upstream',
|
||||
url: 'https://github.com/team/upstream-repo.git',
|
||||
host: 'github.com',
|
||||
owner: 'team',
|
||||
repo: 'upstream-repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the tracking remote cannot be resolved', () => {
|
||||
mockExecFileSync.mockImplementationOnce(() => { throw new Error('No such remote'); });
|
||||
mockExecFileSync.mockReturnValueOnce('hangie/feature/new-git-and-worktree-widgets\n');
|
||||
mockExecFileSync.mockReturnValueOnce('origin\n');
|
||||
|
||||
expect(getUpstreamRemoteInfo({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForkStatus', () => {
|
||||
it('detects fork when origin and upstream differ', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/ccstatusline.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/sirmalloc/ccstatusline.git\n');
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
expect(result.isFork).toBe(true);
|
||||
expect(result.origin?.owner).toBe('hangie');
|
||||
expect(result.upstream?.owner).toBe('sirmalloc');
|
||||
});
|
||||
|
||||
it('detects fork when repos have different names', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/my-fork.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/hangie/original.git\n');
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
expect(result.isFork).toBe(true);
|
||||
});
|
||||
|
||||
it('returns not a fork when only origin exists', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('No such remote'); });
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
expect(result.isFork).toBe(false);
|
||||
expect(result.origin).not.toBeNull();
|
||||
expect(result.upstream).toBeNull();
|
||||
});
|
||||
|
||||
it('returns not a fork when origin equals upstream', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
|
||||
mockExecFileSync.mockReturnValueOnce('https://github.com/owner/repo.git\n');
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
expect(result.isFork).toBe(false);
|
||||
});
|
||||
|
||||
it('returns not a fork when no remotes exist', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('No such remote'); });
|
||||
|
||||
const result = getForkStatus({});
|
||||
|
||||
expect(result.isFork).toBe(false);
|
||||
expect(result.origin).toBeNull();
|
||||
expect(result.upstream).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRemotes', () => {
|
||||
it('returns list of remote names', () => {
|
||||
mockExecFileSync.mockReturnValue('origin\nupstream\n');
|
||||
|
||||
expect(listRemotes({})).toEqual(['origin', 'upstream']);
|
||||
});
|
||||
|
||||
it('returns empty array when no remotes', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('Not a git repo'); });
|
||||
|
||||
expect(listRemotes({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters empty lines', () => {
|
||||
mockExecFileSync.mockReturnValue('origin\n\nupstream\n\n');
|
||||
|
||||
expect(listRemotes({})).toEqual(['origin', 'upstream']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRepoWebUrl', () => {
|
||||
it('builds URL for github.com', () => {
|
||||
const remote = {
|
||||
name: 'origin',
|
||||
url: 'git@github.com:owner/repo.git',
|
||||
host: 'github.com',
|
||||
owner: 'owner',
|
||||
repo: 'repo'
|
||||
};
|
||||
|
||||
expect(buildRepoWebUrl(remote)).toBe('https://github.com/owner/repo');
|
||||
});
|
||||
|
||||
it('builds URL for GHES', () => {
|
||||
const remote = {
|
||||
name: 'origin',
|
||||
url: 'git@github.service.anz:org/project.git',
|
||||
host: 'github.service.anz',
|
||||
owner: 'org',
|
||||
repo: 'project'
|
||||
};
|
||||
|
||||
expect(buildRepoWebUrl(remote)).toBe('https://github.service.anz/org/project');
|
||||
});
|
||||
|
||||
it('builds URL for nested namespaces', () => {
|
||||
const remote = {
|
||||
name: 'origin',
|
||||
url: 'git@gitlab.com:group/subgroup/project.git',
|
||||
host: 'gitlab.com',
|
||||
owner: 'group/subgroup',
|
||||
repo: 'project'
|
||||
};
|
||||
|
||||
expect(buildRepoWebUrl(remote)).toBe('https://gitlab.com/group/subgroup/project');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
fetchGitReviewData,
|
||||
type GitReviewCacheDeps
|
||||
} from '../git-review-cache';
|
||||
|
||||
interface FakeCacheFile {
|
||||
content: string;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
interface PrCacheHarness {
|
||||
cacheFiles: Map<string, FakeCacheFile>;
|
||||
deps: GitReviewCacheDeps;
|
||||
execCalls: { args: string[]; cmd: string; cwd?: string }[];
|
||||
ghResponses: (Error | string)[];
|
||||
glabResponses: (Error | string)[];
|
||||
setCurrentRef: (ref: string) => void;
|
||||
setOriginRemoteUrl: (url: string) => void;
|
||||
setGlabAvailable: (available: boolean) => void;
|
||||
setCliAuthedForHost: (cli: 'gh' | 'glab', host: string, authed: boolean) => void;
|
||||
}
|
||||
|
||||
function createHarness(): PrCacheHarness {
|
||||
const cacheFiles = new Map<string, FakeCacheFile>();
|
||||
const execCalls: { args: string[]; cmd: string; cwd?: string }[] = [];
|
||||
const ghResponses: (Error | string)[] = [];
|
||||
const glabResponses: (Error | string)[] = [];
|
||||
const now = 1_700_000_000_000;
|
||||
let currentRef = 'feature/cache-a';
|
||||
let originRemoteUrl: string | null = null;
|
||||
let glabAvailable = false;
|
||||
const authedHosts: Record<'gh' | 'glab', Set<string>> = {
|
||||
gh: new Set(),
|
||||
glab: new Set()
|
||||
};
|
||||
|
||||
const deps: GitReviewCacheDeps = {
|
||||
execFileSync: ((cmd, args, options) => {
|
||||
const commandArgs = Array.isArray(args)
|
||||
? args.map(arg => String(arg))
|
||||
: [];
|
||||
execCalls.push({
|
||||
args: commandArgs,
|
||||
cmd,
|
||||
cwd: typeof options === 'object' && 'cwd' in options
|
||||
? String(options.cwd)
|
||||
: undefined
|
||||
});
|
||||
|
||||
if (cmd === 'git' && commandArgs[0] === 'remote') {
|
||||
if (originRemoteUrl === null) {
|
||||
throw new Error('no origin configured');
|
||||
}
|
||||
return `${originRemoteUrl}\n`;
|
||||
}
|
||||
if (cmd === 'git' && commandArgs[0] === 'branch')
|
||||
return `${currentRef}\n`;
|
||||
if (cmd === 'git' && commandArgs[0] === 'rev-parse')
|
||||
return 'abc123\n';
|
||||
if (cmd === 'gh' && commandArgs[0] === '--version')
|
||||
return 'gh version 2.0.0\n';
|
||||
if (cmd === 'gh' && commandArgs[0] === 'auth' && commandArgs[1] === 'status') {
|
||||
const hostIdx = commandArgs.indexOf('--hostname');
|
||||
const host = hostIdx >= 0 ? commandArgs[hostIdx + 1] : undefined;
|
||||
if (!host || !authedHosts.gh.has(host))
|
||||
throw new Error(`gh not authed for ${host ?? '<unspecified>'}`);
|
||||
return '';
|
||||
}
|
||||
if (cmd === 'gh' && commandArgs[0] === 'pr') {
|
||||
const response = ghResponses.shift();
|
||||
if (response instanceof Error)
|
||||
throw response;
|
||||
return response ?? '';
|
||||
}
|
||||
if (cmd === 'glab' && commandArgs[0] === '--version') {
|
||||
if (!glabAvailable)
|
||||
throw new Error('glab not installed');
|
||||
return 'glab 1.44.0\n';
|
||||
}
|
||||
if (cmd === 'glab' && commandArgs[0] === 'auth' && commandArgs[1] === 'status') {
|
||||
if (!glabAvailable)
|
||||
throw new Error('glab not installed');
|
||||
const hostIdx = commandArgs.indexOf('--hostname');
|
||||
const host = hostIdx >= 0 ? commandArgs[hostIdx + 1] : undefined;
|
||||
if (!host || !authedHosts.glab.has(host))
|
||||
throw new Error(`glab not authed for ${host ?? '<unspecified>'}`);
|
||||
return '';
|
||||
}
|
||||
if (cmd === 'glab' && commandArgs[0] === 'mr') {
|
||||
const response = glabResponses.shift();
|
||||
if (response instanceof Error)
|
||||
throw response;
|
||||
return response ?? '';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command: ${cmd} ${commandArgs.join(' ')}`);
|
||||
}) as GitReviewCacheDeps['execFileSync'],
|
||||
existsSync: (filePath => cacheFiles.has(String(filePath))) as GitReviewCacheDeps['existsSync'],
|
||||
getHomedir: () => '/tmp/home',
|
||||
mkdirSync: (() => undefined) as GitReviewCacheDeps['mkdirSync'],
|
||||
now: () => now,
|
||||
readFileSync: (filePath => cacheFiles.get(String(filePath))?.content ?? '') as GitReviewCacheDeps['readFileSync'],
|
||||
statSync: (filePath => ({ mtimeMs: cacheFiles.get(String(filePath))?.mtimeMs ?? now })) as GitReviewCacheDeps['statSync'],
|
||||
writeFileSync: ((filePath, content) => {
|
||||
const normalizedContent = typeof content === 'string'
|
||||
? content
|
||||
: Buffer.isBuffer(content)
|
||||
? content.toString('utf8')
|
||||
: '';
|
||||
cacheFiles.set(String(filePath), {
|
||||
content: normalizedContent,
|
||||
mtimeMs: now
|
||||
});
|
||||
}) as GitReviewCacheDeps['writeFileSync']
|
||||
};
|
||||
|
||||
return {
|
||||
cacheFiles,
|
||||
deps,
|
||||
execCalls,
|
||||
ghResponses,
|
||||
glabResponses,
|
||||
setCurrentRef: (ref: string) => {
|
||||
currentRef = ref;
|
||||
},
|
||||
setOriginRemoteUrl: (url: string) => {
|
||||
originRemoteUrl = url;
|
||||
},
|
||||
setGlabAvailable: (available: boolean) => {
|
||||
glabAvailable = available;
|
||||
},
|
||||
setCliAuthedForHost: (cli: 'gh' | 'glab', host: string, authed: boolean) => {
|
||||
if (authed) {
|
||||
authedHosts[cli].add(host);
|
||||
} else {
|
||||
authedHosts[cli].delete(host);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('git-review-cache', () => {
|
||||
it('negative-caches failed gh PR lookups', () => {
|
||||
const harness = createHarness();
|
||||
harness.ghResponses.push(new Error('no pull request found'));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
|
||||
|
||||
const ghCallsAfterFirstRender = harness.execCalls.filter(call => call.cmd === 'gh');
|
||||
expect(ghCallsAfterFirstRender).toHaveLength(2);
|
||||
|
||||
const cachedMissEntry = [...harness.cacheFiles.values()].at(0);
|
||||
expect(cachedMissEntry?.content).toBe('');
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
|
||||
|
||||
const ghCallsAfterSecondRender = harness.execCalls.filter(call => call.cmd === 'gh');
|
||||
expect(ghCallsAfterSecondRender).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses a different cache entry for each checked-out branch', () => {
|
||||
const harness = createHarness();
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 123,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'First PR',
|
||||
url: 'https://github.com/owner/repo/pull/123'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 123,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'First PR',
|
||||
url: 'https://github.com/owner/repo/pull/123'
|
||||
});
|
||||
|
||||
harness.setCurrentRef('feature/cache-b');
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 456,
|
||||
reviewDecision: 'APPROVED',
|
||||
state: 'OPEN',
|
||||
title: 'Second PR',
|
||||
url: 'https://github.com/owner/repo/pull/456'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 456,
|
||||
provider: 'gh',
|
||||
reviewDecision: 'APPROVED',
|
||||
state: 'OPEN',
|
||||
title: 'Second PR',
|
||||
url: 'https://github.com/owner/repo/pull/456'
|
||||
});
|
||||
|
||||
const writtenCachePaths = [...harness.cacheFiles.keys()];
|
||||
expect(writtenCachePaths.length).toBe(2);
|
||||
expect(writtenCachePaths[0]).not.toBe(writtenCachePaths[1]);
|
||||
const normalize = (filePath: string): string => filePath.replace(/\\/g, '/');
|
||||
expect(normalize(writtenCachePaths[0] ?? '')).toContain('/.cache/ccstatusline/git-review/git-review-');
|
||||
expect(normalize(writtenCachePaths[1] ?? '')).toContain('/.cache/ccstatusline/git-review/git-review-');
|
||||
|
||||
harness.setCurrentRef('feature/cache-a');
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 123,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'First PR',
|
||||
url: 'https://github.com/owner/repo/pull/123'
|
||||
});
|
||||
expect(harness.cacheFiles.size).toBe(2);
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fetches merge request data from glab for GitLab remotes', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@gitlab.com:owner/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 77,
|
||||
state: 'opened',
|
||||
title: 'GitLab MR',
|
||||
web_url: 'https://gitlab.com/owner/repo/-/merge_requests/77'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 77,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'GitLab MR',
|
||||
url: 'https://gitlab.com/owner/repo/-/merge_requests/77'
|
||||
});
|
||||
|
||||
const ghCalls = harness.execCalls.filter(call => call.cmd === 'gh');
|
||||
expect(ghCalls).toHaveLength(0);
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('maps glab merged state to MERGED', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://gitlab.example.com/owner/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 12,
|
||||
state: 'merged',
|
||||
title: 'Done',
|
||||
web_url: 'https://gitlab.example.com/owner/repo/-/merge_requests/12'
|
||||
}));
|
||||
|
||||
const data = fetchGitReviewData('/tmp/repo', harness.deps);
|
||||
expect(data?.state).toBe('MERGED');
|
||||
});
|
||||
|
||||
it('uses gh\'s default repo resolution when it succeeds (no --repo pin needed)', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/example-owner/example-repo.git');
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 42,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Standard PR',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 42,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Standard PR',
|
||||
url: 'https://github.com/example-owner/example-repo/pull/42'
|
||||
});
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(1);
|
||||
expect(ghPrCalls[0]?.args).not.toContain('--repo');
|
||||
});
|
||||
|
||||
it('falls back to --repo <origin> for forked GitHub repos when gh\'s default resolves elsewhere', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://github.com/fork-owner/example-repo.git');
|
||||
harness.ghResponses.push('');
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 1,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Forked PR',
|
||||
url: 'https://github.com/fork-owner/example-repo/pull/1'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 1,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Forked PR',
|
||||
url: 'https://github.com/fork-owner/example-repo/pull/1'
|
||||
});
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(2);
|
||||
expect(ghPrCalls[0]?.args).not.toContain('--repo');
|
||||
expect(ghPrCalls[1]?.args).toContain('--repo');
|
||||
expect(ghPrCalls[1]?.args).toContain('https://github.com/fork-owner/example-repo');
|
||||
expect(ghPrCalls[1]?.args).toContain('feature/cache-a');
|
||||
});
|
||||
|
||||
it('falls back to --repo <origin> for forked GitLab repos when glab\'s default resolves elsewhere', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@gitlab.com:fork-owner/example-fork.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.glabResponses.push('');
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 9,
|
||||
state: 'opened',
|
||||
title: 'Forked MR',
|
||||
web_url: 'https://gitlab.com/fork-owner/example-fork/-/merge_requests/9'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 9,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Forked MR',
|
||||
url: 'https://gitlab.com/fork-owner/example-fork/-/merge_requests/9'
|
||||
});
|
||||
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(2);
|
||||
expect(glabMrCalls[0]?.args).not.toContain('--repo');
|
||||
expect(glabMrCalls[1]?.args).toContain('--repo');
|
||||
expect(glabMrCalls[1]?.args).toContain('https://gitlab.com/fork-owner/example-fork');
|
||||
expect(glabMrCalls[1]?.args).toContain('feature/cache-a');
|
||||
});
|
||||
|
||||
it('uses glab for unknown host when only glab is authed', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.setCliAuthedForHost('glab', 'git.self-hosted.example', true);
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 5,
|
||||
state: 'opened',
|
||||
title: 'Self-hosted MR',
|
||||
web_url: 'https://git.self-hosted.example/team/repo/-/merge_requests/5'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 5,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Self-hosted MR',
|
||||
url: 'https://git.self-hosted.example/team/repo/-/merge_requests/5'
|
||||
});
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(0);
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves non-default ports when probing and pinning self-hosted GitLab repos', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('https://git.self-hosted.example:8443/team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.setCliAuthedForHost('glab', 'git.self-hosted.example:8443', true);
|
||||
harness.glabResponses.push('');
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 8,
|
||||
state: 'opened',
|
||||
title: 'Port-hosted MR',
|
||||
web_url: 'https://git.self-hosted.example:8443/team/repo/-/merge_requests/8'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 8,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Port-hosted MR',
|
||||
url: 'https://git.self-hosted.example:8443/team/repo/-/merge_requests/8'
|
||||
});
|
||||
|
||||
const glabAuthCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'auth'
|
||||
);
|
||||
expect(glabAuthCalls[0]?.args).toEqual([
|
||||
'auth',
|
||||
'status',
|
||||
'--hostname',
|
||||
'git.self-hosted.example:8443'
|
||||
]);
|
||||
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(2);
|
||||
expect(glabMrCalls[1]?.args).toContain('--repo');
|
||||
expect(glabMrCalls[1]?.args).toContain('https://git.self-hosted.example:8443/team/repo');
|
||||
});
|
||||
|
||||
it('uses gh for unknown host when only gh is authed (no wasted glab mr calls)', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.setCliAuthedForHost('gh', 'git.self-hosted.example', true);
|
||||
harness.ghResponses.push(JSON.stringify({
|
||||
number: 7,
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Self-hosted GHE PR',
|
||||
url: 'https://git.self-hosted.example/team/repo/pull/7'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 7,
|
||||
provider: 'gh',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Self-hosted GHE PR',
|
||||
url: 'https://git.self-hosted.example/team/repo/pull/7'
|
||||
});
|
||||
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(0);
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null for unknown host when neither CLI is authed', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toBeNull();
|
||||
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(0);
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(0);
|
||||
const cachedMissEntry = [...harness.cacheFiles.values()].at(0);
|
||||
expect(cachedMissEntry?.content).toBe('');
|
||||
});
|
||||
|
||||
it('prefers glab over gh for unknown host when both CLIs are authed', () => {
|
||||
const harness = createHarness();
|
||||
harness.setOriginRemoteUrl('git@git.self-hosted.example:team/repo.git');
|
||||
harness.setGlabAvailable(true);
|
||||
harness.setCliAuthedForHost('glab', 'git.self-hosted.example', true);
|
||||
harness.setCliAuthedForHost('gh', 'git.self-hosted.example', true);
|
||||
harness.glabResponses.push(JSON.stringify({
|
||||
iid: 3,
|
||||
state: 'opened',
|
||||
title: 'Ambiguous MR',
|
||||
web_url: 'https://git.self-hosted.example/team/repo/-/merge_requests/3'
|
||||
}));
|
||||
|
||||
expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({
|
||||
number: 3,
|
||||
provider: 'glab',
|
||||
reviewDecision: '',
|
||||
state: 'OPEN',
|
||||
title: 'Ambiguous MR',
|
||||
url: 'https://git.self-hosted.example/team/repo/-/merge_requests/3'
|
||||
});
|
||||
|
||||
const ghPrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'gh' && call.args[0] === 'pr'
|
||||
);
|
||||
expect(ghPrCalls).toHaveLength(0);
|
||||
const glabMrCalls = harness.execCalls.filter(
|
||||
call => call.cmd === 'glab' && call.args[0] === 'mr'
|
||||
);
|
||||
expect(glabMrCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import {
|
||||
clearGitCache,
|
||||
getGitChangeCounts,
|
||||
getGitFileStatusCounts,
|
||||
getGitStatus,
|
||||
isInsideGitWorkTree,
|
||||
resolveGitCwd,
|
||||
runGit
|
||||
} from '../git';
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
spawnSync: vi.fn()
|
||||
}));
|
||||
|
||||
const mockExecFileSync = execFileSync as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockImplementation: (impl: () => never) => void;
|
||||
mockReturnValue: (value: string) => void;
|
||||
mockReturnValueOnce: (value: string) => void;
|
||||
};
|
||||
|
||||
describe('git utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearGitCache();
|
||||
});
|
||||
|
||||
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 trailing whitespace', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('feature/worktree\n');
|
||||
const context: RenderContext = { data: { cwd: '/tmp/repo' } };
|
||||
|
||||
const result = runGit('branch --show-current', context);
|
||||
|
||||
expect(result).toBe('feature/worktree');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('git');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['branch', '--show-current']);
|
||||
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
cwd: '/tmp/repo'
|
||||
});
|
||||
});
|
||||
|
||||
it('runs git command without cwd when no context directory exists', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('true\n');
|
||||
|
||||
const result = runGit('rev-parse --is-inside-work-tree', {});
|
||||
|
||||
expect(result).toBe('true');
|
||||
expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the command fails', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(runGit('status --short', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsideGitWorkTree', () => {
|
||||
it('returns true when git reports true', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('true\n');
|
||||
|
||||
expect(isInsideGitWorkTree({})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when git reports false', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('false\n');
|
||||
|
||||
expect(isInsideGitWorkTree({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when git command fails', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(isInsideGitWorkTree({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitChangeCounts', () => {
|
||||
it('sums staged and unstaged insertions/deletions', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)');
|
||||
mockExecFileSync.mockReturnValueOnce('1 file changed, 3 insertions(+), 4 deletions(-)');
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 5,
|
||||
deletions: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('handles singular insertion/deletion forms', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('1 file changed, 1 insertion(+), 1 deletion(-)');
|
||||
mockExecFileSync.mockReturnValueOnce('');
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 1,
|
||||
deletions: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counts when git diff commands fail', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(getGitChangeCounts({})).toEqual({
|
||||
insertions: 0,
|
||||
deletions: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitStatus', () => {
|
||||
it('returns all false when no git output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('');
|
||||
|
||||
expect(getGitStatus({})).toEqual({
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
untracked: false,
|
||||
conflicts: false
|
||||
});
|
||||
});
|
||||
|
||||
it('detects staged modification', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('M file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(false);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects unstaged modification', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' M file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(false);
|
||||
expect(result.unstaged).toBe(true);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects both staged and unstaged modification', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('MM file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects unstaged deletion', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' D file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(false);
|
||||
expect(result.unstaged).toBe(true);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects staged deletion', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('D file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(false);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects untracked files', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('?? newfile.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.untracked).toBe(true);
|
||||
expect(result.staged).toBe(false);
|
||||
expect(result.unstaged).toBe(false);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects merge conflict: both modified (UU)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('UU file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
});
|
||||
|
||||
it('detects merge conflict: added by us (AU)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('AU file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
});
|
||||
|
||||
it('detects merge conflict: deleted by us (DU)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('DU file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
});
|
||||
|
||||
it('detects merge conflict: both added (AA)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('AA file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
});
|
||||
|
||||
it('detects merge conflict: added by them (UA)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('UA file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
});
|
||||
|
||||
it('detects merge conflict: deleted by them (UD)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('UD file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
});
|
||||
|
||||
it('detects merge conflict: both deleted (DD)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('DD file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
});
|
||||
|
||||
it('detects renamed file in index (staged)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('R oldname.txt -> newname.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(false);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects copied file in index (staged)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('C original.txt -> copy.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(false);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores rename source path in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('R new-name.txt\0DUCK.txt\0');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(false);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores copy source path in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('C copy.txt\0MOUSE.txt\0');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(false);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores unstaged rename source path in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' R new-name.txt\0ANT.txt\0');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(false);
|
||||
expect(result.unstaged).toBe(true);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores unstaged copy source path in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' C copy.txt\0MOUSE.txt\0');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(false);
|
||||
expect(result.unstaged).toBe(true);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects type changed file in index (staged)', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('T file.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(false);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects mixed status with multiple files', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('M staged.txt\0 M unstaged.txt\0?? untracked.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
expect(result.untracked).toBe(true);
|
||||
expect(result.conflicts).toBe(false);
|
||||
});
|
||||
|
||||
it('detects mixed status with conflicts', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('UU conflict.txt\0M staged.txt\0 M unstaged.txt\0?? untracked.txt');
|
||||
|
||||
const result = getGitStatus({});
|
||||
expect(result.conflicts).toBe(true);
|
||||
expect(result.staged).toBe(true);
|
||||
expect(result.unstaged).toBe(true);
|
||||
expect(result.untracked).toBe(true);
|
||||
});
|
||||
|
||||
it('handles git command failure', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(getGitStatus({})).toEqual({
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
untracked: false,
|
||||
conflicts: false
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitFileStatusCounts', () => {
|
||||
it('counts staged, unstaged, and untracked files from porcelain status', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('M staged-a.ts\0A staged-b.ts\0 M unstaged-a.ts\0?? new-a.ts\0?? new-b.ts\0');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 2,
|
||||
unstaged: 1,
|
||||
untracked: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('counts files with both staged and unstaged changes in both totals', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('MM file.ts');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 1,
|
||||
unstaged: 1,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counts when there are no matching files', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores rename source paths in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('R new-name.ts\0old-name.ts\0?? new-file.ts\0');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 1,
|
||||
unstaged: 0,
|
||||
untracked: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores copy source paths in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce('C copy.ts\0original.ts\0 M changed.ts');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 1,
|
||||
unstaged: 1,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unstaged rename source paths in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' R new-name.ts\0A-old-name.ts\0?? new-file.ts\0');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 0,
|
||||
unstaged: 1,
|
||||
untracked: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unstaged copy source paths in porcelain -z output', () => {
|
||||
mockExecFileSync.mockReturnValueOnce(' C copy.ts\0M-original.ts\0 M changed.ts');
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 0,
|
||||
unstaged: 2,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counts when git commands fail', () => {
|
||||
mockExecFileSync.mockImplementation(() => { throw new Error('git failed'); });
|
||||
|
||||
expect(getGitFileStatusCounts({})).toEqual({
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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,30 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
buildIdeFileUrl,
|
||||
encodeGitRefForUrlPath
|
||||
} from '../hyperlink';
|
||||
|
||||
describe('encodeGitRefForUrlPath', () => {
|
||||
it('encodes reserved characters while preserving branch separators', () => {
|
||||
expect(encodeGitRefForUrlPath('feature/issue#1')).toBe('feature/issue%231');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildIdeFileUrl', () => {
|
||||
it('builds encoded IDE links for POSIX paths', () => {
|
||||
expect(buildIdeFileUrl('/Users/example/my repo#1', 'cursor')).toBe('cursor://file/Users/example/my%20repo%231');
|
||||
});
|
||||
|
||||
it('builds IDE links for Windows drive-letter paths', () => {
|
||||
expect(buildIdeFileUrl('C:/Work/my repo#1', 'vscode')).toBe('vscode://file/C:/Work/my%20repo%231');
|
||||
});
|
||||
|
||||
it('builds IDE links for UNC paths', () => {
|
||||
expect(buildIdeFileUrl('\\\\server\\share\\my repo', 'cursor')).toBe('cursor://file//server/share/my%20repo');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
function createMockTimestamps(timestamps: string[]): string {
|
||||
return timestamps.map(ts => JSON.stringify({
|
||||
timestamp: ts,
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50
|
||||
}
|
||||
}
|
||||
})).join('\n');
|
||||
}
|
||||
|
||||
function floorToHour(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
|
||||
function getAllTimestampsFromContent(content: string): Date[] {
|
||||
const timestamps: Date[] = [];
|
||||
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 } };
|
||||
};
|
||||
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
}
|
||||
|
||||
function findBlockStartTime(
|
||||
content: string,
|
||||
currentTime: Date,
|
||||
sessionDurationHours = 5
|
||||
): Date | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = currentTime;
|
||||
|
||||
const timestamps = getAllTimestampsFromContent(content);
|
||||
|
||||
if (timestamps.length === 0)
|
||||
return null;
|
||||
|
||||
timestamps.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
const mostRecentTimestamp = timestamps[0];
|
||||
if (!mostRecentTimestamp)
|
||||
return null;
|
||||
|
||||
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
|
||||
if (timeSinceLastActivity > sessionDurationMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let 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) {
|
||||
break;
|
||||
}
|
||||
|
||||
continuousWorkStart = currentTimestamp;
|
||||
}
|
||||
|
||||
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())) {
|
||||
currentBlockStart = floorToHour(timestamp);
|
||||
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
|
||||
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
|
||||
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
|
||||
&& t.getTime() <= block.end.getTime()
|
||||
);
|
||||
|
||||
if (hasActivity) {
|
||||
return block.start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('Block Detection Algorithm', () => {
|
||||
describe('Real scenario bug fix', () => {
|
||||
it('should correctly handle morning and evening blocks with gap', () => {
|
||||
const content = createMockTimestamps([
|
||||
'2025-09-23T09:42:18.000Z',
|
||||
'2025-09-23T12:52:31.000Z',
|
||||
'2025-09-23T15:44:16.000Z',
|
||||
'2025-09-23T16:57:24.000Z'
|
||||
]);
|
||||
|
||||
const currentTime = new Date('2025-09-23T18:10:00.000Z');
|
||||
const result = findBlockStartTime(content, currentTime);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.toISOString()).toBe('2025-09-23T15:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple messages in single block', () => {
|
||||
it('should create single block for messages within 5 hours', () => {
|
||||
const content = createMockTimestamps([
|
||||
'2025-09-23T08:15:00.000Z',
|
||||
'2025-09-23T08:45:00.000Z',
|
||||
'2025-09-23T09:30:00.000Z',
|
||||
'2025-09-23T10:00:00.000Z'
|
||||
]);
|
||||
|
||||
const currentTime = new Date('2025-09-23T11:30:00.000Z');
|
||||
const result = findBlockStartTime(content, currentTime);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.toISOString()).toBe('2025-09-23T08:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple blocks with gaps', () => {
|
||||
it('should correctly identify current block in multi-block scenario', () => {
|
||||
const content = createMockTimestamps([
|
||||
'2025-09-22T22:13:00.000Z',
|
||||
'2025-09-23T03:56:00.000Z',
|
||||
'2025-09-23T04:01:00.000Z',
|
||||
'2025-09-23T12:33:00.000Z',
|
||||
'2025-09-23T18:01:00.000Z'
|
||||
]);
|
||||
|
||||
const currentTime = new Date('2025-09-23T20:43:00.000Z');
|
||||
const result = findBlockStartTime(content, currentTime);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.toISOString()).toBe('2025-09-23T18:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should return null when current time is in gap between blocks', () => {
|
||||
const content = createMockTimestamps([
|
||||
'2025-09-23T08:00:00.000Z',
|
||||
'2025-09-23T10:00:00.000Z'
|
||||
]);
|
||||
|
||||
const currentTime = new Date('2025-09-23T14:00:00.000Z');
|
||||
const result = findBlockStartTime(content, currentTime);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no messages within 5 hours', () => {
|
||||
const content = createMockTimestamps([
|
||||
'2025-09-23T08:00:00.000Z'
|
||||
]);
|
||||
|
||||
const currentTime = new Date('2025-09-23T14:00:00.000Z');
|
||||
const result = findBlockStartTime(content, currentTime);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle block boundary correctly', () => {
|
||||
const content = createMockTimestamps([
|
||||
'2025-09-23T10:30:00.000Z'
|
||||
]);
|
||||
|
||||
const currentTime = new Date('2025-09-23T15:00:00.000Z');
|
||||
const result = findBlockStartTime(content, currentTime);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.toISOString()).toBe('2025-09-23T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should detect 5+ hour gap as boundary', () => {
|
||||
const content = createMockTimestamps([
|
||||
'2025-09-23T08:00:00.000Z',
|
||||
'2025-09-23T13:01:00.000Z'
|
||||
]);
|
||||
|
||||
const currentTime = new Date('2025-09-23T15:00:00.000Z');
|
||||
const result = findBlockStartTime(content, currentTime);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.toISOString()).toBe('2025-09-23T13:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should handle messages at exact hour boundaries', () => {
|
||||
const content = createMockTimestamps([
|
||||
'2025-09-23T10:00:00.000Z',
|
||||
'2025-09-23T12:00:00.000Z'
|
||||
]);
|
||||
|
||||
const currentTime = new Date('2025-09-23T13:30:00.000Z');
|
||||
const result = findBlockStartTime(content, currentTime);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.toISOString()).toBe('2025-09-23T10:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid inputs', () => {
|
||||
it('should return null for empty content', () => {
|
||||
const result = findBlockStartTime('', new Date());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for invalid JSON', () => {
|
||||
const result = findBlockStartTime('not json', new Date());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_RESET_LOCALE,
|
||||
canonicalizeLocale,
|
||||
filterLocaleOptions,
|
||||
getLocaleMatchSegments,
|
||||
getLocaleOptions,
|
||||
getSystemLocale,
|
||||
isValidLocale
|
||||
} from '../locales';
|
||||
|
||||
describe('locale helpers', () => {
|
||||
it('includes the default locale first and curated common locales', () => {
|
||||
const options = getLocaleOptions();
|
||||
|
||||
expect(options[0]?.value).toBe(DEFAULT_RESET_LOCALE);
|
||||
expect(options.some(option => option.value === 'ja-JP')).toBe(true);
|
||||
expect(options.some(option => option.value === 'en-US')).toBe(true);
|
||||
expect(options.some(option => option.value === 'en-CA')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes the system locale when it differs from the default', () => {
|
||||
const systemLocale = getSystemLocale();
|
||||
const options = getLocaleOptions();
|
||||
|
||||
expect(systemLocale === null || options.some(option => option.value === systemLocale)).toBe(true);
|
||||
});
|
||||
|
||||
it('includes the configured locale when it is valid and uncommon', () => {
|
||||
const configuredLocale = canonicalizeLocale('en-AU');
|
||||
if (!configuredLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = getLocaleOptions(configuredLocale);
|
||||
|
||||
expect(options.some(option => option.value === configuredLocale && option.description === 'Configured locale')).toBe(true);
|
||||
});
|
||||
|
||||
it('filters locale options with fuzzy matching', () => {
|
||||
const matches = filterLocaleOptions(getLocaleOptions(), 'japan');
|
||||
|
||||
expect(matches[0]?.value).toBe('ja-JP');
|
||||
});
|
||||
|
||||
it('adds a custom locale option for valid typed locales outside the curated list', () => {
|
||||
const customLocale = canonicalizeLocale('en-AU');
|
||||
if (!customLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = filterLocaleOptions(getLocaleOptions(), 'en-au');
|
||||
|
||||
expect(matches[0]).toMatchObject({
|
||||
value: customLocale,
|
||||
displayName: `Use ${customLocale}`,
|
||||
description: 'Custom locale'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add a custom locale option for invalid typed locales', () => {
|
||||
const matches = filterLocaleOptions(getLocaleOptions(), 'not-a-locale');
|
||||
|
||||
expect(matches.some(option => option.description === 'Custom locale')).toBe(false);
|
||||
});
|
||||
|
||||
it('highlights locale match segments', () => {
|
||||
const segments = getLocaleMatchSegments('ja-JP', 'ja');
|
||||
|
||||
expect(segments.some(segment => segment.text === 'ja' && segment.matched)).toBe(true);
|
||||
});
|
||||
|
||||
it('canonicalizes and validates BCP 47 locale tags', () => {
|
||||
expect(canonicalizeLocale('ja-jp')).toBe('ja-JP');
|
||||
expect(isValidLocale('fr-ca')).toBe(true);
|
||||
expect(isValidLocale('not-a-locale')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getContextConfig,
|
||||
getModelContextIdentifier
|
||||
} from '../model-context';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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(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'
|
||||
);
|
||||
|
||||
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,148 @@
|
||||
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', () => ({
|
||||
execSync: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user