diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e93d1dc..080d41d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,55 +11,6 @@ concurrency: cancel-in-progress: true jobs: - # ============================================ - # BASH SCRIPTS - LINT AND TEST - # ============================================ - bash-lint: - name: Bash Lint (ShellCheck) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@master - with: - scandir: './scripts' - additional_files: 'install.sh' - severity: warning - format: tty - - bash-test: - name: Bash Tests (${{ matrix.os }}) - needs: bash-lint - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install jq - run: | - if [[ "$RUNNER_OS" == "Linux" ]]; then - sudo apt-get update && sudo apt-get install -y jq - elif [[ "$RUNNER_OS" == "macOS" ]]; then - brew install jq - fi - - - name: Install Bats - run: | - if [[ "$RUNNER_OS" == "Linux" ]]; then - sudo apt-get install -y bats - elif [[ "$RUNNER_OS" == "macOS" ]]; then - brew install bats-core - fi - - - name: Run Bats tests - run: bats tests/bash/*.bats - # ============================================ # PYTHON SCRIPT - LINT AND TEST # ============================================ @@ -144,106 +95,12 @@ jobs: flags: python fail_ci_if_error: false - # ============================================ - # NODE.JS SCRIPT - LINT AND TEST - # ============================================ - node-lint: - name: Node.js Lint - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run ESLint - run: npx eslint scripts/statusline.js - - - name: Run Prettier check - run: npx prettier --check scripts/statusline.js - - node-test: - name: Node.js Tests (${{ matrix.os }}, Node ${{ matrix.node-version }}) - needs: node-lint - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - node-version: ['18', '20', '22'] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run Jest tests - run: npm test -- --coverage - - - name: Upload coverage to Codecov - if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' - uses: codecov/codecov-action@v4 - with: - files: ./coverage/node/lcov.info - flags: node - fail_ci_if_error: false - - # ============================================ - # CROSS-IMPLEMENTATION PARITY TESTS - # ============================================ - parity-test: - name: Parity Tests (${{ matrix.os }}) - needs: [python-test, node-test] - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install Bats - run: | - if [[ "$RUNNER_OS" == "Linux" ]]; then - sudo apt-get update && sudo apt-get install -y bats - elif [[ "$RUNNER_OS" == "macOS" ]]; then - brew install bats-core - fi - - - name: Run parity tests - run: bats tests/bash/test_parity.bats - # ============================================ # INTEGRATION TESTS # ============================================ integration-test: name: Integration Tests (${{ matrix.os }}) - needs: [bash-test, python-test, node-test] + needs: python-test runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -253,136 +110,23 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install jq - run: | - if [[ "$RUNNER_OS" == "Linux" ]]; then - sudo apt-get update && sudo apt-get install -y jq - elif [[ "$RUNNER_OS" == "macOS" ]]; then - brew install jq - fi - - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Run integration tests + - name: Test Python script produces non-empty output run: | SAMPLE_INPUT='{"model":{"display_name":"Claude 3.5 Sonnet"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":1000,"cache_creation_input_tokens":500,"cache_read_input_tokens":200}}}' - echo "Testing bash scripts..." - echo "$SAMPLE_INPUT" | ./scripts/statusline-full.sh - echo "$SAMPLE_INPUT" | ./scripts/statusline-git.sh - echo "$SAMPLE_INPUT" | ./scripts/statusline-minimal.sh - echo "Testing Python script..." echo "$SAMPLE_INPUT" | python3 ./scripts/statusline.py - echo "Testing Node.js script..." - echo "$SAMPLE_INPUT" | node ./scripts/statusline.js - echo "All integration tests passed!" # ============================================ # END-TO-END TESTS # ============================================ - e2e-install-bash: - name: E2E Install Bash (${{ matrix.os }}) - needs: [bash-lint] - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install jq - run: | - if [[ "$RUNNER_OS" == "Linux" ]]; then - sudo apt-get update && sudo apt-get install -y jq - elif [[ "$RUNNER_OS" == "macOS" ]]; then - brew install jq - fi - - - name: Run install.sh - run: ./install.sh - - - name: Verify statusline script installed - run: | - test -f "$HOME/.claude/statusline.sh" - echo "✓ statusline.sh exists" - test -x "$HOME/.claude/statusline.sh" - echo "✓ statusline.sh is executable" - - - name: Verify context-stats CLI installed - run: | - test -f "$HOME/.local/bin/context-stats" - echo "✓ context-stats exists" - test -x "$HOME/.local/bin/context-stats" - echo "✓ context-stats is executable" - - - name: Verify config file created - run: | - test -f "$HOME/.claude/statusline.conf" - echo "✓ statusline.conf exists" - - - name: Verify settings.json updated - run: | - test -f "$HOME/.claude/settings.json" - grep -q "statusLine" "$HOME/.claude/settings.json" - echo "✓ settings.json contains statusLine config" - - - name: Verify installed script produces output - run: | - SAMPLE_INPUT='{"model":{"display_name":"Claude 3.5 Sonnet"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":1000,"cache_creation_input_tokens":500,"cache_read_input_tokens":200}}}' - OUTPUT=$(echo "$SAMPLE_INPUT" | "$HOME/.claude/statusline.sh") - test -n "$OUTPUT" - echo "✓ Installed statusline.sh produces non-empty output" - echo " Output: $OUTPUT" - - e2e-install-npm: - name: E2E Install npm (${{ matrix.os }}, Node ${{ matrix.node-version }}) - needs: [node-lint] - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - node-version: ['18', '20', '22'] - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Install globally via npm - run: npm install -g . - - - name: Verify context-stats command is available - run: | - which context-stats - echo "✓ context-stats is on PATH" - - - name: Verify statusline.js produces output via npm global - run: | - SAMPLE_INPUT='{"model":{"display_name":"Claude 3.5 Sonnet"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":1000,"cache_creation_input_tokens":500,"cache_read_input_tokens":200}}}' - # The npm global install makes statusline.js available as the package main - OUTPUT=$(echo "$SAMPLE_INPUT" | node "$(npm root -g)/cc-context-stats/scripts/statusline.js") - test -n "$OUTPUT" - echo "✓ statusline.js produces non-empty output via npm global install" - echo " Output: $OUTPUT" - e2e-install-pip: name: E2E Install pip (${{ matrix.os }}, Python ${{ matrix.python-version }}) needs: [python-lint] @@ -424,7 +168,7 @@ jobs: e2e-exec: name: E2E Exec (${{ matrix.os }}) - needs: [bash-lint, python-lint, node-lint] + needs: [python-lint] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -434,45 +178,20 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install jq - run: | - if [[ "$RUNNER_OS" == "Linux" ]]; then - sudo apt-get update && sudo apt-get install -y jq - elif [[ "$RUNNER_OS" == "macOS" ]]; then - brew install jq - fi - - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Test all scripts produce non-empty output + - name: Test Python script produces non-empty output run: | SAMPLE_INPUT='{"model":{"display_name":"Claude 3.5 Sonnet"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":1000,"cache_creation_input_tokens":500,"cache_read_input_tokens":200}}}' - echo "=== Bash scripts ===" - for script in statusline-full.sh statusline-git.sh statusline-minimal.sh; do - OUTPUT=$(echo "$SAMPLE_INPUT" | ./scripts/"$script") - test -n "$OUTPUT" || { echo "✗ $script produced empty output"; exit 1; } - echo "✓ $script: $OUTPUT" - done - echo "=== Python script ===" OUTPUT=$(echo "$SAMPLE_INPUT" | python3 ./scripts/statusline.py) test -n "$OUTPUT" || { echo "✗ statusline.py produced empty output"; exit 1; } echo "✓ statusline.py: $OUTPUT" - echo "=== Node.js script ===" - OUTPUT=$(echo "$SAMPLE_INPUT" | node ./scripts/statusline.js) - test -n "$OUTPUT" || { echo "✗ statusline.js produced empty output"; exit 1; } - echo "✓ statusline.js: $OUTPUT" - echo "" echo "All E2E execution tests passed!" @@ -481,73 +200,66 @@ jobs: SAMPLE_1M='{"model":{"display_name":"Claude Opus 4.6"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"},"context_window":{"context_window_size":1000000,"current_usage":{"input_tokens":50000,"cache_creation_input_tokens":10000,"cache_read_input_tokens":5000}}}' echo "=== 1M model context ===" - for script in statusline-full.sh statusline-git.sh statusline-minimal.sh; do - OUTPUT=$(echo "$SAMPLE_1M" | ./scripts/"$script") - test -n "$OUTPUT" || { echo "✗ $script (1M) produced empty output"; exit 1; } - echo "✓ $script (1M): $OUTPUT" - done - OUTPUT=$(echo "$SAMPLE_1M" | python3 ./scripts/statusline.py) test -n "$OUTPUT" || { echo "✗ statusline.py (1M) produced empty output"; exit 1; } echo "✓ statusline.py (1M): $OUTPUT" - OUTPUT=$(echo "$SAMPLE_1M" | node ./scripts/statusline.js) - test -n "$OUTPUT" || { echo "✗ statusline.js (1M) produced empty output"; exit 1; } - echo "✓ statusline.js (1M): $OUTPUT" - - name: Test with edge case inputs run: | echo "=== Empty/zero context ===" ZERO_CTX='{"model":{"display_name":"Test"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":0,"current_usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}' - for script in statusline-full.sh statusline-git.sh statusline-minimal.sh; do - OUTPUT=$(echo "$ZERO_CTX" | ./scripts/"$script") - test -n "$OUTPUT" || { echo "✗ $script (zero ctx) produced empty output"; exit 1; } - echo "✓ $script handles zero context" - done OUTPUT=$(echo "$ZERO_CTX" | python3 ./scripts/statusline.py) test -n "$OUTPUT" || { echo "✗ statusline.py (zero ctx) produced empty output"; exit 1; } echo "✓ statusline.py handles zero context" - OUTPUT=$(echo "$ZERO_CTX" | node ./scripts/statusline.js) - test -n "$OUTPUT" || { echo "✗ statusline.js (zero ctx) produced empty output"; exit 1; } - echo "✓ statusline.js handles zero context" echo "=== High utilization ===" HIGH_USE='{"model":{"display_name":"Claude 3.5 Sonnet"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":180000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}' - for script in statusline-full.sh statusline-git.sh statusline-minimal.sh; do - OUTPUT=$(echo "$HIGH_USE" | ./scripts/"$script") - test -n "$OUTPUT" || { echo "✗ $script (high use) produced empty output"; exit 1; } - echo "✓ $script handles high utilization" - done OUTPUT=$(echo "$HIGH_USE" | python3 ./scripts/statusline.py) test -n "$OUTPUT" || { echo "✗ statusline.py (high use) produced empty output"; exit 1; } echo "✓ statusline.py handles high utilization" - OUTPUT=$(echo "$HIGH_USE" | node ./scripts/statusline.js) - test -n "$OUTPUT" || { echo "✗ statusline.js (high use) produced empty output"; exit 1; } - echo "✓ statusline.js handles high utilization" + + # ============================================ + # BASH TESTS (install/check scripts only) + # ============================================ + bash-test: + name: Bash Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Bats + run: | + if [[ "$RUNNER_OS" == "Linux" ]]; then + sudo apt-get install -y bats + elif [[ "$RUNNER_OS" == "macOS" ]]; then + brew install bats-core + fi + + - name: Run Bats tests + run: bats tests/bash/test_check_install.bats tests/bash/test_context_stats_subcommands.bats tests/bash/test_e2e_install.bats tests/bash/test_install.bats # ============================================ # FINAL STATUS CHECK # ============================================ ci-success: name: CI Success - needs: [bash-lint, bash-test, python-lint, python-test, node-lint, node-test, integration-test, parity-test, e2e-install-bash, e2e-install-npm, e2e-install-pip, e2e-exec] + needs: [python-lint, python-test, integration-test, e2e-install-pip, e2e-exec, bash-test] runs-on: ubuntu-latest if: always() steps: - name: Check all jobs passed run: | - if [[ "${{ needs.bash-lint.result }}" != "success" ]] || \ - [[ "${{ needs.bash-test.result }}" != "success" ]] || \ - [[ "${{ needs.python-lint.result }}" != "success" ]] || \ + if [[ "${{ needs.python-lint.result }}" != "success" ]] || \ [[ "${{ needs.python-test.result }}" != "success" ]] || \ - [[ "${{ needs.node-lint.result }}" != "success" ]] || \ - [[ "${{ needs.node-test.result }}" != "success" ]] || \ [[ "${{ needs.integration-test.result }}" != "success" ]] || \ - [[ "${{ needs.parity-test.result }}" != "success" ]] || \ - [[ "${{ needs.e2e-install-bash.result }}" != "success" ]] || \ - [[ "${{ needs.e2e-install-npm.result }}" != "success" ]] || \ [[ "${{ needs.e2e-install-pip.result }}" != "success" ]] || \ - [[ "${{ needs.e2e-exec.result }}" != "success" ]]; then + [[ "${{ needs.e2e-exec.result }}" != "success" ]] || \ + [[ "${{ needs.bash-test.result }}" != "success" ]]; then echo "One or more jobs failed" exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5367e6b..f52bdb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,12 +59,6 @@ jobs: with: python-version: '3.11' - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq bats @@ -73,15 +67,9 @@ jobs: pip install -r requirements-dev.txt pip install -e . - - name: Install Node.js dependencies - run: npm ci - - name: Run Python tests run: pytest tests/python/ -v - - name: Run Node.js tests - run: npm test - - name: Run Bash tests run: bats tests/bash/*.bats diff --git a/CLAUDE.md b/CLAUDE.md index febeb40..0259747 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,23 +4,19 @@ cc-context-stats provides real-time context window monitoring for Claude Code sessions. It tracks token consumption over time and displays live ASCII graphs so users can see how much context remains. -## Dual-Implementation Rationale +## Implementation -The statusline is implemented in three languages (Bash, Python, Node.js) so users can choose whichever runtime they have available. Claude Code invokes the statusline script via stdin JSON pipe — any implementation that reads JSON from stdin and writes formatted text to stdout works. The Python and Node.js implementations also persist state to CSV files read by the `context-stats` CLI. +The statusline is implemented in Python. Claude Code invokes the statusline script via stdin JSON pipe — the script reads JSON from stdin and writes formatted text to stdout. The Python implementation persists state to CSV files read by the `context-stats` CLI. ## CSV Format Contract State files are append-only CSV at `~/.claude/statusline/statusline..state` with 14 comma-separated fields. See [docs/CSV_FORMAT.md](docs/CSV_FORMAT.md) for the full field specification. Key constraint: `workspace_project_dir` has commas replaced with underscores before writing. -## Statusline Script Landscape +## Statusline Script | Script | Language | State writes | Notes | |---|---|---|---| -| `scripts/statusline-full.sh` | Bash | No | Full display, requires `jq` | -| `scripts/statusline-git.sh` | Bash | No | Git-focused variant | -| `scripts/statusline-minimal.sh` | Bash | No | Minimal variant | | `scripts/statusline.py` | Python 3 | Yes | Pip-installable via package | -| `scripts/statusline.js` | Node.js | Yes | Standalone script | ## Test Commands @@ -29,14 +25,11 @@ State files are append-only CSV at `~/.claude/statusline/statusline. source venv/bin/activate pytest tests/python/ -v -# Node.js tests -npm test - -# Bash integration tests -bats tests/bash/*.bats +# Bash integration tests (install/check scripts) +bats tests/bash/test_check_install.bats tests/bash/test_context_stats_subcommands.bats tests/bash/test_e2e_install.bats tests/bash/test_install.bats # All tests -pytest && npm test && bats tests/bash/*.bats +pytest && bats tests/bash/test_check_install.bats tests/bash/test_context_stats_subcommands.bats tests/bash/test_e2e_install.bats tests/bash/test_install.bats ``` ## Key Architectural Decisions @@ -44,26 +37,26 @@ pytest && npm test && bats tests/bash/*.bats - **Append-only CSV state files** with rotation at 10,000 lines (keeps most recent 5,000) - **No network requests** — all data stays local in `~/.claude/statusline/` - **Session ID validation** — rejects `/`, `\`, `..`, and null bytes for path-traversal defense -- **5-second git command timeout** in both Python and Node.js implementations +- **5-second git command timeout** in the Python implementation - **Config via `~/.claude/statusline.conf`** — simple key=value pairs -## Cross-Implementation Sync Points - -The following logic is duplicated across three implementations and **must be kept in sync** when modified: - -| Logic | Package (`src/`) | Standalone Python (`scripts/statusline.py`) | Node.js (`scripts/statusline.js`) | -|---|---|---|---| -| Config parsing | `core/config.py` | `read_config()` | `readConfig()` | -| Color name map | `core/colors.py:COLOR_NAMES` | `_COLOR_NAMES` | `COLOR_NAMES` | -| Color parser | `core/colors.py:parse_color()` | `_parse_color()` | `parseColor()` | -| Git info | `core/git.py:get_git_info()` | `get_git_info()` | `getGitInfo()` | -| State rotation | `core/state.py` | `maybe_rotate_state_file()` | `maybeRotateStateFile()` | -| MI profiles | `graphs/intelligence.py:MODEL_PROFILES` | `MODEL_PROFILES` | `MODEL_PROFILES` | -| MI formula | `graphs/intelligence.py:calculate_context_pressure()` | `compute_mi()` | `computeMI()` | -| MI colors | `graphs/intelligence.py:get_mi_color()` | `get_mi_color()` | `getMIColor()` | -| Zone indicator | `graphs/intelligence.py:get_context_zone()` | `get_context_zone()` | `getContextZone()` | -| Zone constants | `ZONE_1M_*`, `ZONE_STD_*`, `LARGE_MODEL_THRESHOLD` | same | same | -| Per-property colors | `colors.py:ColorManager` props, `config.py:_COLOR_KEYS` | `_COLOR_KEYS`, per-property vars | `COLOR_CONFIG_KEYS`, per-property consts | +## Sync Points: Package vs Standalone Script + +The following logic is duplicated between the installable package (`src/`) and the standalone script (`scripts/statusline.py`) and **must be kept in sync** when modified: + +| Logic | Package (`src/`) | Standalone Python (`scripts/statusline.py`) | +|---|---|---| +| Config parsing | `core/config.py` | `read_config()` | +| Color name map | `core/colors.py:COLOR_NAMES` | `_COLOR_NAMES` | +| Color parser | `core/colors.py:parse_color()` | `_parse_color()` | +| Git info | `core/git.py:get_git_info()` | `get_git_info()` | +| State rotation | `core/state.py` | `maybe_rotate_state_file()` | +| MI profiles | `graphs/intelligence.py:MODEL_PROFILES` | `MODEL_PROFILES` | +| MI formula | `graphs/intelligence.py:calculate_context_pressure()` | `compute_mi()` | +| MI colors | `graphs/intelligence.py:get_mi_color()` | `get_mi_color()` | +| Zone indicator | `graphs/intelligence.py:get_context_zone()` | `get_context_zone()` | +| Zone constants | `ZONE_1M_*`, `ZONE_STD_*`, `LARGE_MODEL_THRESHOLD` | same | +| Per-property colors | `colors.py:ColorManager` props, `config.py:_COLOR_KEYS` | `_COLOR_KEYS`, per-property vars | ## Cross-References diff --git a/README.md b/README.md index d89aad6..0da6dc7 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,7 @@

Know your context zone. Act before Claude degrades.

[![PyPI version](https://img.shields.io/pypi/v/cc-context-stats)](https://pypi.org/project/cc-context-stats/) -[![npm version](https://img.shields.io/npm/v/cc-context-stats)](https://www.npmjs.com/package/cc-context-stats) [![PyPI Downloads](https://img.shields.io/pypi/dm/cc-context-stats)](https://pypi.org/project/cc-context-stats/) -[![npm Downloads](https://img.shields.io/npm/dm/cc-context-stats)](https://www.npmjs.com/package/cc-context-stats) [![GitHub stars](https://img.shields.io/github/stars/luongnv89/cc-context-stats)](https://github.com/luongnv89/cc-context-stats) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -209,19 +207,7 @@ See the full example in [`context-stats-export-output.md`](context-stats-export- ## Installation and Configuration -### Shell script (recommended) - -```bash -curl -fsSL https://raw.githubusercontent.com/luongnv89/cc-context-stats/main/install.sh | bash -``` - -### npm - -```bash -npm install -g cc-context-stats -``` - -### Python (pip) +### Python (pip) — recommended ```bash pip install cc-context-stats @@ -259,14 +245,14 @@ Yes. MIT licensed, zero external dependencies. No. Session data stays local in `~/.claude/statusline/`. **What runtimes does it support?** -Shell (Bash + jq), Python 3, and Node.js. All three read the same config; Python and Node.js also write state files for the CLI. +Python 3. Install via `pip install cc-context-stats`. --- ## Get Started ```bash -curl -fsSL https://raw.githubusercontent.com/luongnv89/cc-context-stats/main/install.sh | bash +pip install cc-context-stats ``` [Read the docs](docs/installation.md) · [View export example](context-stats-export-output.md) · MIT Licensed @@ -276,7 +262,7 @@ curl -fsSL https://raw.githubusercontent.com/luongnv89/cc-context-stats/main/ins
Documentation -- [Installation Guide](docs/installation.md) - Platform-specific setup (shell, pip, npm) +- [Installation Guide](docs/installation.md) - Platform-specific setup (shell, pip) - [Context Stats Guide](docs/context-stats.md) - Detailed CLI usage guide - [Configuration Options](docs/configuration.md) - All settings explained - [Available Scripts](docs/scripts.md) - Script variants and features @@ -302,9 +288,9 @@ This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.
How It Works (Architecture) -Context Stats hooks into Claude Code's status line feature to track token usage across sessions. The Python and Node.js statusline scripts write state data to local CSV files, which the `context-stats` CLI reads to render live graphs. Data is stored locally in `~/.claude/statusline/` and never sent anywhere. +Context Stats hooks into Claude Code's status line feature to track token usage across sessions. The Python statusline script writes state data to local CSV files, which the `context-stats` CLI reads to render live graphs. Data is stored locally in `~/.claude/statusline/` and never sent anywhere. -The statusline is implemented in three languages (Bash, Python, Node.js) so you can choose whichever runtime you have available. Claude Code invokes the statusline script via stdin JSON pipe — any implementation that reads JSON from stdin and writes formatted text to stdout works. +The statusline is implemented in Python. Claude Code invokes the statusline script via stdin JSON pipe — the script reads JSON from stdin and writes formatted text to stdout.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d0c5776..210d579 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,7 +22,7 @@ graph TD ``` ┌─────────────┐ JSON stdin ┌──────────────────┐ │ Claude Code │ ──────────────────> │ Statusline Script │ -│ (host) │ <────────────────── │ (sh/py/js) │ +│ (host) │ <────────────────── │ (Python) │ └─────────────┘ stdout text └──────┬───────────┘ │ writes ▼ @@ -35,44 +35,33 @@ graph TD ▼ ┌──────────────────┐ │ Context Stats CLI │ - │ (Python/Bash) │ + │ (Python) │ └──────────────────┘ ``` ## Component Details -### Status Line Scripts +### Status Line Script -Three implementation languages with identical output: - -| Script | Language | Dependencies | State Writes | -| ---------------------- | ---------- | ------------ | ------------ | -| `statusline-full.sh` | Bash | `jq` | No | -| `statusline-git.sh` | Bash | `jq` | No | -| `statusline-minimal.sh`| Bash | `jq` | No | -| `statusline.py` | Python 3 | None | Yes | -| `statusline.js` | Node.js 18+| None | Yes | - -> **Note:** Only the Python and Node.js scripts write state files. The bash scripts provide status line display only, without persisting data for the context-stats CLI. +| Script | Language | Dependencies | State Writes | +| --------------- | -------- | ------------ | ------------ | +| `statusline.py` | Python 3 | None | Yes | **Data flow:** 1. Claude Code pipes JSON state via stdin on each refresh 2. Script parses model info, context tokens, session data 3. Script reads `~/.claude/statusline.conf` for user preferences 4. Script checks git status for branch/changes info (5-second timeout) -5. Python/Node.js scripts write state to `~/.claude/statusline/.state` +5. Script writes state to `~/.claude/statusline/.state` 6. Script outputs formatted ANSI text to stdout ### Context Stats CLI -Two implementations of the live dashboard: - | Script | Language | Install Method | | ------------------ | -------- | ------------------------- | -| `context-stats.sh` | Bash | Shell installer | | `context_stats.py` | Python | `pip install cc-context-stats` | -The Python CLI (installed via pip or npm) is the primary implementation, providing live ASCII graphs with zone awareness. The bash script is a standalone alternative installed by the shell installer. +The Python CLI provides live ASCII graphs with zone awareness. A thin shell wrapper (`context-stats.sh`) is also included for environments where the pip-installed `context-stats` command is not yet in PATH. ### Python Package (`src/claude_statusline/`) diff --git a/docs/CSV_FORMAT.md b/docs/CSV_FORMAT.md index 8d4cc5d..02cd4fa 100644 --- a/docs/CSV_FORMAT.md +++ b/docs/CSV_FORMAT.md @@ -28,7 +28,7 @@ State files are stored at `~/.claude/statusline/statusline..state`. - Numeric fields default to `0` when absent. String fields default to empty string. - Lines are newline-terminated (`\n`). - Files are append-only. -- Files are automatically rotated at 10,000 lines (keeps most recent 5,000) by the Python and Node.js statusline scripts. +- Files are automatically rotated at 10,000 lines (keeps most recent 5,000) by the Python statusline script. - Duplicate entries (same token count as previous line) are skipped to prevent file bloat. ## Legacy Format diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 08222c3..19e446c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,15 +2,14 @@ ## Distribution Channels -cc-context-stats is distributed through three channels: +cc-context-stats is distributed through two channels: | Channel | Package Name | Command | | ------------ | ----------------- | ------------------------------------ | | Shell script | N/A | `curl -fsSL .../install.sh \| bash` | | PyPI | `cc-context-stats`| `pip install cc-context-stats` | -| npm | `cc-context-stats`| `npm install -g cc-context-stats` | -Both pip and npm installs provide the `claude-statusline` and `context-stats` CLI commands. +The pip install provides the `claude-statusline` and `context-stats` CLI commands. ## Publishing to PyPI @@ -28,16 +27,6 @@ twine check dist/* twine upload dist/* ``` -## Publishing to npm - -```bash -# Verify package.json -npm pack --dry-run - -# Publish -npm publish -``` - ## Release Workflow The project uses GitHub Actions for automated releases (`.github/workflows/release.yml`): @@ -46,8 +35,8 @@ The project uses GitHub Actions for automated releases (`.github/workflows/relea 2. Update `CHANGELOG.md` with the new version entry 3. Create and push a version tag: `git tag v1.x.x && git push --tags` 4. The release workflow automatically: - - Runs the full test suite (Python, Node.js, Bash) - - Builds Python and npm packages + - Runs the full test suite (Python and Bash) + - Builds the Python package - Creates a GitHub Release with release notes CI is also run on every push and PR via `.github/workflows/ci.yml`. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6a6059c..7a869b8 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3,9 +3,7 @@ ## Prerequisites - **Git** - Version control -- **jq** - JSON processor (for bash scripts) - **Python 3.9+** - For Python package and testing -- **Node.js 18+** - For Node.js script and testing - **Bats** - Bash Automated Testing System (optional, for bash tests) - **pre-commit** - Git hook framework (optional, for automated code quality) @@ -22,9 +20,6 @@ source venv/bin/activate pip install -r requirements-dev.txt pip install -e ".[dev]" -# Node.js setup -npm install - # Install pre-commit hooks (optional but recommended) pre-commit install ``` @@ -39,22 +34,16 @@ cc-context-stats/ │ ├── formatters/ # Token, time, layout formatting │ ├── graphs/ # ASCII graph rendering │ └── ui/ # Icons, waiting animation -├── scripts/ # Standalone scripts (sh/py/js) -│ ├── statusline-full.sh # Full-featured bash statusline -│ ├── statusline-git.sh # Git-focused bash variant -│ ├── statusline-minimal.sh # Minimal bash variant +├── scripts/ # Standalone scripts │ ├── statusline.py # Python standalone statusline -│ ├── statusline.js # Node.js standalone statusline -│ └── context-stats.sh # Bash context-stats CLI +│ └── context-stats.sh # Shell wrapper for context-stats CLI ├── tests/ -│ ├── bash/ # Bats tests -│ ├── python/ # Pytest tests -│ └── node/ # Jest tests +│ ├── bash/ # Bats tests (install/check scripts) +│ └── python/ # Pytest tests ├── config/ # Configuration examples ├── docs/ # Documentation ├── .github/workflows/ # CI/CD (ci.yml, release.yml) -├── pyproject.toml # Python build config (hatchling) -└── package.json # Node.js config +└── pyproject.toml # Python build config (hatchling) ``` ## Running Tests @@ -64,14 +53,11 @@ cc-context-stats/ source venv/bin/activate pytest tests/python/ -v -# Node.js tests (Jest) -npm test - -# Bash integration tests -bats tests/bash/*.bats +# Bash integration tests (install/check scripts) +bats tests/bash/test_check_install.bats tests/bash/test_context_stats_subcommands.bats tests/bash/test_e2e_install.bats tests/bash/test_install.bats # All tests -pytest && npm test && bats tests/bash/*.bats +pytest && bats tests/bash/test_check_install.bats tests/bash/test_context_stats_subcommands.bats tests/bash/test_e2e_install.bats tests/bash/test_install.bats ``` ### Coverage Reports @@ -79,9 +65,6 @@ pytest && npm test && bats tests/bash/*.bats ```bash # Python coverage pytest tests/python/ -v --cov=scripts --cov-report=html - -# Node.js coverage -npm run test:coverage ``` ## Linting & Formatting @@ -93,20 +76,14 @@ pre-commit run --all-files # Individual tools ruff check src/ scripts/statusline.py # Python lint ruff format src/ scripts/statusline.py # Python format -npx eslint scripts/statusline.js # JavaScript lint -npx prettier --write scripts/statusline.js # JavaScript format shellcheck scripts/*.sh install.sh # Bash lint ``` ## Manual Testing ```bash -# Test statusline scripts with mock input +# Test statusline script with mock input echo '{"model":{"display_name":"Test"},"cwd":"/test","session_id":"abc123","context":{"tokens_remaining":64000,"context_window":200000}}' | python3 scripts/statusline.py - -echo '{"model":{"display_name":"Test"}}' | node scripts/statusline.js - -echo '{"model":{"display_name":"Test"}}' | bash scripts/statusline-full.sh ``` ## Building @@ -117,20 +94,14 @@ python -m build # Verify package twine check dist/* - -# npm dry run -npm pack --dry-run ``` -## Cross-Script Consistency +## Consistency: Package vs Standalone Script -All three implementations (bash, Python, Node.js) must produce identical output for the same input. When modifying status line behavior: +The standalone `scripts/statusline.py` duplicates core logic from the `src/` package so it can run without installation. When modifying status line behavior: -1. Update all three script variants -2. Run integration tests to verify parity -3. Test on multiple platforms if possible - -The delta parity tests (`tests/bash/`) verify that Python and Node.js produce identical deltas for the same CSV state data. +1. Update both `scripts/statusline.py` and the corresponding `src/` module +2. Run Python tests to verify correctness ## Debugging @@ -153,9 +124,6 @@ watch -n 1 'tail -5 ~/.claude/statusline/statusline.*.state' # Python with verbose output pytest tests/python/ -v -s -# Node.js with verbose output -npx jest --verbose - # Bats with verbose output -bats --verbose-run tests/bash/*.bats +bats --verbose-run tests/bash/test_check_install.bats ``` diff --git a/docs/MODEL_INTELLIGENCE.md b/docs/MODEL_INTELLIGENCE.md index 69b50dd..95367d0 100644 --- a/docs/MODEL_INTELLIGENCE.md +++ b/docs/MODEL_INTELLIGENCE.md @@ -147,15 +147,15 @@ The `mi_curve_beta` config overrides the model profile's beta (but not alpha). S If `context_window_size == 0` (malformed data), MI returns 1.0 with utilization 0.0. -## Cross-Implementation Sync Points - -The MI formula and zone logic are implemented in 4 languages and must be kept in sync: - -| Logic | Package (`src/`) | Standalone Python | Node.js | Bash | -|-------|-----------------|-------------------|---------|------| -| MODEL_PROFILES | `intelligence.py` | `statusline.py` | `statusline.js` | `statusline-full.sh` | -| get_model_profile | `intelligence.py` | `statusline.py` | `statusline.js` | inline in awk | -| MI formula | `calculate_context_pressure()` | `compute_mi()` | `computeMI()` | `compute_mi()` | -| Color thresholds | `get_mi_color()` | `get_mi_color()` | `getMIColor()` | `get_mi_color()` | -| Zone indicator | `get_context_zone()` | `get_context_zone()` | `getContextZone()` | (not yet) | -| Zone constants | `ZONE_1M_*`, `ZONE_STD_*` | `ZONE_1M_*`, `ZONE_STD_*` | `ZONE_1M_*`, `ZONE_STD_*` | (not yet) | +## Sync Points: Package vs Standalone Script + +The MI formula and zone logic are duplicated between the package and the standalone script and must be kept in sync: + +| Logic | Package (`src/`) | Standalone Python (`scripts/statusline.py`) | +|-------|-----------------|----------------------------------------------| +| MODEL_PROFILES | `intelligence.py` | `statusline.py` | +| get_model_profile | `intelligence.py` | `statusline.py` | +| MI formula | `calculate_context_pressure()` | `compute_mi()` | +| Color thresholds | `get_mi_color()` | `get_mi_color()` | +| Zone indicator | `get_context_zone()` | `get_context_zone()` | +| Zone constants | `ZONE_1M_*`, `ZONE_STD_*` | `ZONE_1M_*`, `ZONE_STD_*` | diff --git a/docs/installation.md b/docs/installation.md index f26aed6..9f1133f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,38 +2,7 @@ ## Quick Install -### One-Line Install (Recommended) - -```bash -curl -fsSL https://raw.githubusercontent.com/luongnv89/cc-context-stats/main/install.sh | bash -``` - -This downloads and runs the installer directly from GitHub. It installs the **full** statusline script and the `context-stats` CLI tool. - -### NPM (Recommended for Node.js users) - -```bash -npm install -g cc-context-stats -``` - -Or with yarn: - -```bash -yarn global add cc-context-stats -``` - -After installation, add to `~/.claude/settings.json`: - -```json -{ - "statusLine": { - "type": "command", - "command": "claude-statusline" - } -} -``` - -### Python (Recommended for Python users) +### Python (pip) — Recommended ```bash pip install cc-context-stats @@ -61,37 +30,20 @@ After installation, add to `~/.claude/settings.json`: ```bash git clone https://github.com/luongnv89/cc-context-stats.git cd cc-context-stats -./install.sh +pip install . ``` -The installer will: - -1. Install the statusline script to `~/.claude/` -2. Install `context-stats` CLI tool to `~/.local/bin/` -3. Create default configuration at `~/.claude/statusline.conf` -4. Update `~/.claude/settings.json` - ### Windows -Use the Python or Node.js version (no `jq` required): - ```powershell -# Python (via pip) pip install cc-context-stats - -# Or manually copy the script -git clone https://github.com/luongnv89/cc-context-stats.git -copy cc-context-stats\scripts\statusline.py %USERPROFILE%\.claude\statusline.py ``` -Or with Node.js: +Or manually copy the script: ```powershell -# Node.js (via npm) -npm install -g cc-context-stats - -# Or manually copy the script -copy cc-context-stats\scripts\statusline.js %USERPROFILE%\.claude\statusline.js +git clone https://github.com/luongnv89/cc-context-stats.git +copy cc-context-stats\scripts\statusline.py %USERPROFILE%\.claude\statusline.py ``` ## Manual Installation @@ -99,8 +51,8 @@ copy cc-context-stats\scripts\statusline.js %USERPROFILE%\.claude\statusline.js ### macOS / Linux ```bash -cp scripts/statusline-full.sh ~/.claude/statusline.sh -chmod +x ~/.claude/statusline.sh +cp scripts/statusline.py ~/.claude/statusline.py +chmod +x ~/.claude/statusline.py ``` ### Context Stats CLI (Optional) @@ -131,9 +83,7 @@ Add to your Claude Code settings: - macOS/Linux: `~/.claude/settings.json` - Windows: `%USERPROFILE%\.claude\settings.json` -### pip / npm Install - -If you installed via `pip install cc-context-stats` or `npm install -g cc-context-stats`: +### pip Install ```json { @@ -144,17 +94,6 @@ If you installed via `pip install cc-context-stats` or `npm install -g cc-contex } ``` -### Bash (macOS/Linux) - -```json -{ - "statusLine": { - "type": "command", - "command": "~/.claude/statusline.sh" - } -} -``` - ### Python (Manual Copy) ```json @@ -177,75 +116,21 @@ Windows: } ``` -### Node.js (Manual Copy) - -```json -{ - "statusLine": { - "type": "command", - "command": "node ~/.claude/statusline.js" - } -} -``` - -Windows: - -```json -{ - "statusLine": { - "type": "command", - "command": "node %USERPROFILE%\\.claude\\statusline.js" - } -} -``` - ## Requirements -### macOS - -```bash -brew install jq # Only needed for bash scripts -``` - -### Linux (Debian/Ubuntu) - -```bash -sudo apt install jq # Only needed for bash scripts -``` - -### Linux (Fedora/RHEL) - -```bash -sudo dnf install jq # Only needed for bash scripts -``` - -### Windows - -No additional requirements for Python/Node.js scripts (via pip or npm). - -For bash scripts via WSL: - -```bash -sudo apt install jq -``` +Python 3.9+ is the only requirement. No additional system packages needed. ## Verify Installation Test your statusline: ```bash -# If installed via pip or npm +# If installed via pip echo '{"model":{"display_name":"Test"}}' | claude-statusline -# Bash script (macOS/Linux) -echo '{"model":{"display_name":"Test"}}' | ~/.claude/statusline.sh - # Python script (manual copy) echo '{"model":{"display_name":"Test"}}' | python3 ~/.claude/statusline.py -# Node.js script (manual copy) -echo '{"model":{"display_name":"Test"}}' | node ~/.claude/statusline.js - # Windows (Python) echo {"model":{"display_name":"Test"}} | python %USERPROFILE%\.claude\statusline.py ``` diff --git a/docs/scripts.md b/docs/scripts.md index b51d8db..e74617e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -2,73 +2,22 @@ ## Overview -| Script | Platform | Requirements | State Writes | Features | -| ----------------------- | ------------ | ------------ | ------------ | --------------------------------- | -| `statusline-full.sh` | macOS, Linux | `jq` | No | Full-featured with all indicators | -| `statusline-git.sh` | macOS, Linux | `jq` | No | Git branch and changes | -| `statusline-minimal.sh` | macOS, Linux | `jq` | No | Model + directory only | -| `statusline.py` | All | Python 3 | Yes | Cross-platform, full-featured | -| `statusline.js` | All | Node.js 18+ | Yes | Cross-platform, full-featured | -| `context-stats.sh` | macOS, Linux | Bash | No | Token usage visualization (CLI) | +| Script | Platform | Requirements | State Writes | Features | +| ------------------ | -------- | ------------ | ------------ | ----------------------------- | +| `statusline.py` | All | Python 3 | Yes | Cross-platform, full-featured | +| `context-stats.sh` | macOS, Linux | Bash | No | Token usage visualization (CLI) | ## Installation Methods | Method | Statusline Command | Context Stats Command | | ------ | ------------------ | --------------------- | | `pip install cc-context-stats` | `claude-statusline` | `context-stats` | -| `npm install -g cc-context-stats` | `claude-statusline` | `context-stats` | -| Shell installer (`install.sh`) | `~/.claude/statusline.sh` | `~/.local/bin/context-stats` | -## Bash Scripts +## statusline.py -### statusline-full.sh (Recommended for bash users) +Python implementation. Works on Windows, macOS, and Linux without additional dependencies beyond Python 3. -Complete status line with all features: - -- Model name -- Current directory -- Git branch and changes -- Token usage with color coding -- Token delta tracking -- Model Intelligence (MI) score with per-model profiles -- Autocompact indicator -- Session ID - -> **Note:** Does not write state files. For context-stats CLI support, use the Python or Node.js script instead. - -### statusline-git.sh - -Lighter version with git info: - -- Model name -- Current directory -- Git branch and changes - -### statusline-minimal.sh - -Minimal footprint: - -- Model name -- Current directory - -## Cross-Platform Scripts - -### statusline.py - -Python implementation matching `statusline-full.sh` functionality. Works on Windows, macOS, and Linux without additional dependencies beyond Python 3. - -Features beyond bash scripts: -- Writes state files for context-stats CLI -- Duplicate-entry deduplication -- State file rotation (10k/5k threshold) -- Model Intelligence (MI) with per-model profiles -- 5-second git command timeout - -### statusline.js - -Node.js implementation matching `statusline-full.sh` functionality. Works on all platforms with Node.js 18+ installed. - -Features beyond bash scripts: +Features: - Writes state files for context-stats CLI - Duplicate-entry deduplication - State file rotation (10k/5k threshold) @@ -79,12 +28,10 @@ Features beyond bash scripts: ### context-stats.sh -Standalone bash CLI tool for visualizing token consumption. Reads state files written by the Python or Node.js statusline scripts. See [Context Stats](context-stats.md) for details. +Standalone bash CLI tool for visualizing token consumption. Reads state files written by the Python statusline script. See [Context Stats](context-stats.md) for details. ## Output Format -All statusline scripts produce consistent output: - ``` [Model] directory | branch [changes] | XXk free (XX%) [+delta] MI:0.XXX [AC:XXk] session_id ``` @@ -127,7 +74,7 @@ Scripts receive JSON via stdin from Claude Code: ## Color Codes -All scripts use consistent ANSI colors (defaults, overridable via `~/.claude/statusline.conf`): +The script uses consistent ANSI colors (defaults, overridable via `~/.claude/statusline.conf`): ### Per-Property Colors diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d22abdf..0b04ae9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4,18 +4,18 @@ ### Status line not appearing -**macOS/Linux:** +**macOS/Linux (shell installer):** 1. Check script is executable: ```bash - chmod +x ~/.claude/statusline.sh + chmod +x ~/.claude/statusline.py ``` 2. Test the script: ```bash - echo '{"model":{"display_name":"Test"}}' | ~/.claude/statusline.sh + echo '{"model":{"display_name":"Test"}}' | python3 ~/.claude/statusline.py ``` 3. Verify settings.json configuration: @@ -24,7 +24,7 @@ cat ~/.claude/settings.json ``` -**pip/npm install:** +**pip install:** 1. Verify the command is available: @@ -46,34 +46,8 @@ echo {"model":{"display_name":"Test"}} | python %USERPROFILE%\.claude\statusline.py ``` -### jq not found - -The bash scripts require `jq` for JSON parsing. Python and Node.js scripts do **not** need `jq`. - -**macOS:** - -```bash -brew install jq -``` - -**Linux (Debian/Ubuntu):** - -```bash -sudo apt install jq -``` - -**Linux (Fedora/RHEL):** - -```bash -sudo dnf install jq -``` - -Alternatively, use the Python or Node.js version which don't require `jq`. - ### context-stats command not found -**If installed via pip or npm:** - 1. Verify installation: ```bash @@ -83,24 +57,10 @@ Alternatively, use the Python or Node.js version which don't require `jq`. 2. Reinstall if missing: ```bash - pip install cc-context-stats # or: npm install -g cc-context-stats - ``` - -**If installed via shell installer:** - -1. Verify installation: - - ```bash - ls -la ~/.local/bin/context-stats + pip install cc-context-stats ``` -2. Check PATH: - - ```bash - echo $PATH | grep -q "$HOME/.local/bin" && echo "In PATH" || echo "Not in PATH" - ``` - -3. Add to PATH if needed: +3. Check PATH if pip installed to user directory: ```bash # zsh @@ -132,35 +92,11 @@ Alternatively, use the Python or Node.js version which don't require `jq`. uv pip install cc-context-stats ``` -### npm install fails - -1. Ensure Node.js 18+: - - ```bash - node --version - ``` - -2. Try with sudo (Linux/macOS): - - ```bash - sudo npm install -g cc-context-stats - ``` - -3. Or fix npm permissions: - - ```bash - mkdir -p ~/.npm-global - npm config set prefix '~/.npm-global' - echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc - source ~/.zshrc - npm install -g cc-context-stats - ``` - ### No token graph data Token history requires: -1. Python or Node.js statusline script (bash scripts do **not** write state files) +1. Python statusline script (the Python script writes state files) 2. `show_delta=true` in `~/.claude/statusline.conf` (default) 3. Active Claude Code session generating state files 4. State files at `~/.claude/statusline/statusline..state` @@ -248,13 +184,11 @@ cat << 'EOF' > /tmp/test-input.json } EOF -# Test each script -cat /tmp/test-input.json | ~/.claude/statusline.sh -cat /tmp/test-input.json | python3 ~/.claude/statusline.py -cat /tmp/test-input.json | node ~/.claude/statusline.js - -# Test pip/npm installed version +# Test installed version cat /tmp/test-input.json | claude-statusline + +# Or test standalone script directly +cat /tmp/test-input.json | python3 ~/.claude/statusline.py ``` ### Check state files @@ -273,6 +207,6 @@ watch -n 1 'tail -5 ~/.claude/statusline/statusline.*.state' - Open a new issue with: - Operating system - Shell type (bash/zsh) - - Installation method (pip, npm, shell installer, manual) + - Installation method (pip, uv, manual) - Script version being used - Error messages or unexpected behavior diff --git a/install.sh b/install.sh index 1113c07..049edc4 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ # - jq (for JSON configuration, optional but recommended) # # What gets installed: -# - ~/.claude/statusline.sh - Status line command +# - ~/.claude/statusline.py - Status line command # - ~/.local/bin/context-stats - CLI tool for live context monitoring # - ~/.claude/statusline.conf - Configuration file # - ~/.claude/settings.json - Claude Code settings updated @@ -83,11 +83,11 @@ check_curl() { fi } -# Check for jq (required for bash scripts) +# Check for jq (optional, used only for automatic settings.json updates) check_jq() { if ! command -v jq &>/dev/null; then echo -e "${YELLOW}Warning: 'jq' is not installed.${RESET}" - echo "jq is required for bash status line scripts." + echo "jq is only used to update settings.json automatically." echo if [[ "$OSTYPE" == "darwin"* ]]; then echo "Install with: brew install jq" @@ -130,10 +130,10 @@ get_remote_commit_hash() { echo "${hash:-unknown}" } -# Set script to install (full featured bash script) +# Set script to install (Python statusline implementation) select_script() { - SCRIPT_REMOTE="scripts/statusline-full.sh" - SCRIPT_NAME="statusline.sh" + SCRIPT_REMOTE="scripts/statusline.py" + SCRIPT_NAME="statusline.py" if [ "$INSTALL_MODE" = "local" ]; then SCRIPT_SRC="$SCRIPT_DIR/$SCRIPT_REMOTE" diff --git a/package.json b/package.json index 6624102..a4c1eb1 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,12 @@ "name": "cc-context-stats", "version": "1.16.1", "description": "Monitor your Claude Code session context in real-time - track token usage and never run out of context", - "main": "scripts/statusline.js", "bin": { "context-stats": "scripts/context-stats.sh" }, "scripts": { - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "lint": "eslint scripts/statusline.js", - "lint:fix": "eslint scripts/statusline.js --fix", - "format": "prettier --write 'scripts/*.js' '*.json' '*.md'", - "format:check": "prettier --check 'scripts/*.js' '*.json' '*.md'" + "format": "prettier --write '*.json' '*.md'", + "format:check": "prettier --check '*.json' '*.md'" }, "keywords": [ "claude", @@ -33,18 +27,9 @@ "url": "https://github.com/luongnv89/cc-context-stats.git" }, "devDependencies": { - "eslint": "^8.56.0", - "jest": "^29.7.0", "prettier": "^3.8.1" }, "files": [ - "scripts/statusline.js", "scripts/context-stats.sh" - ], - "engines": { - "node": ">=18" - }, - "dependencies": { - "cc-context-stats": "^1.16.0" - } + ] } diff --git a/scripts/check-install.sh b/scripts/check-install.sh index d8dcee7..8436e4d 100755 --- a/scripts/check-install.sh +++ b/scripts/check-install.sh @@ -2,7 +2,7 @@ # # cc-context-stats Installation Checker # Verifies that both the statusline and context-stats CLI are properly installed -# regardless of installation method (bash, npm, or pip). +# regardless of installation method (shell installer or pip). # # Usage: # ./scripts/check-install.sh @@ -44,14 +44,9 @@ info() { detect_install_method() { local methods=() - # Check bash installer artifacts - if [ -f "$HOME/.claude/statusline.sh" ]; then - methods+=("bash") - fi - - # Check npm global install - if npm list -g cc-context-stats &>/dev/null 2>&1; then - methods+=("npm") + # Check shell installer artifacts + if [ -f "$HOME/.claude/statusline.py" ]; then + methods+=("shell") fi # Check pip install @@ -89,7 +84,6 @@ if [ -z "$METHODS" ]; then fail "No installation detected" info "Install via one of:" info " curl -fsSL https://raw.githubusercontent.com/luongnv89/cc-context-stats/main/install.sh | bash" - info " npm install -g cc-context-stats" info " pip install cc-context-stats" echo echo -e "${RED}Check failed: nothing is installed.${RESET}" @@ -107,23 +101,13 @@ echo -e "${BLUE}2. Statusline Command${RESET}" STATUSLINE_CMD="" STATUSLINE_SOURCE="" -# Check claude-statusline in PATH (npm/pip install) +# Check claude-statusline in PATH (pip install) if command -v claude-statusline &>/dev/null; then STATUSLINE_CMD="claude-statusline" STATUSLINE_SOURCE="PATH ($(command -v claude-statusline))" fi -# Check bash installer location -if [ -x "$HOME/.claude/statusline.sh" ]; then - if [ -z "$STATUSLINE_CMD" ]; then - STATUSLINE_CMD="$HOME/.claude/statusline.sh" - STATUSLINE_SOURCE="bash installer ($HOME/.claude/statusline.sh)" - else - pass "Also found bash script: ~/.claude/statusline.sh" - fi -fi - -# Check standalone Python script +# Check standalone Python script (shell installer) if [ -f "$HOME/.claude/statusline.py" ]; then if [ -z "$STATUSLINE_CMD" ]; then STATUSLINE_CMD="python3 $HOME/.claude/statusline.py" @@ -133,25 +117,13 @@ if [ -f "$HOME/.claude/statusline.py" ]; then fi fi -# Check standalone Node.js script -if [ -f "$HOME/.claude/statusline.js" ]; then - if [ -z "$STATUSLINE_CMD" ]; then - STATUSLINE_CMD="node $HOME/.claude/statusline.js" - STATUSLINE_SOURCE="standalone Node.js ($HOME/.claude/statusline.js)" - else - pass "Also found standalone Node.js: ~/.claude/statusline.js" - fi -fi - if [ -n "$STATUSLINE_CMD" ]; then pass "Statusline command found: $STATUSLINE_SOURCE" else fail "No statusline command found" info "Expected one of:" - info " - 'claude-statusline' in PATH (npm/pip install)" - info " - ~/.claude/statusline.sh (bash install)" - info " - ~/.claude/statusline.py (manual Python)" - info " - ~/.claude/statusline.js (manual Node.js)" + info " - 'claude-statusline' in PATH (pip install)" + info " - ~/.claude/statusline.py (shell installer or manual Python)" fi # Test that the statusline command actually works @@ -197,8 +169,8 @@ else else fail "context-stats CLI not found" info "Expected one of:" - info " - 'context-stats' in PATH (npm/pip install)" - info " - ~/.local/bin/context-stats (bash install)" + info " - 'context-stats' in PATH (pip install)" + info " - ~/.local/bin/context-stats (shell installer)" fi fi @@ -306,14 +278,12 @@ else echo -e " ${BLUE}curl -fsSL https://raw.githubusercontent.com/luongnv89/cc-context-stats/main/install.sh | bash${RESET}" elif [ -z "$STATUSLINE_CMD" ]; then echo "The statusline is missing. Fix depends on your install method:" - echo " npm: Verify with 'npm list -g cc-context-stats' and check 'claude-statusline' is in PATH" - echo " pip: Verify with 'pip show cc-context-stats' and check 'claude-statusline' is in PATH" - echo " bash: Re-run the installer: ./install.sh" + echo " pip: Verify with 'pip show cc-context-stats' and check 'claude-statusline' is in PATH" + echo " shell: Re-run the installer: ./install.sh" elif [ -z "$CONTEXT_STATS_CMD" ]; then echo "The context-stats CLI is missing. Fix depends on your install method:" - echo " npm: Verify with 'npm list -g cc-context-stats' and check 'context-stats' is in PATH" - echo " pip: Verify with 'pip show cc-context-stats' and check 'context-stats' is in PATH" - echo " bash: Re-run the installer: ./install.sh" + echo " pip: Verify with 'pip show cc-context-stats' and check 'context-stats' is in PATH" + echo " shell: Re-run the installer: ./install.sh" else echo "Components found but settings may need updating." echo "Check the failed items above for specific fix instructions." diff --git a/scripts/e2e-install-test.sh b/scripts/e2e-install-test.sh index 4effc80..98d7e62 100755 --- a/scripts/e2e-install-test.sh +++ b/scripts/e2e-install-test.sh @@ -2,14 +2,12 @@ # # E2E Clean Install Smoke Tests for cc-context-stats # -# Validates each runtime implementation (Node.js, Python, Bash) from a fresh -# install and confirms all expected CLI commands are available and functional. +# Validates the Python runtime installation from a fresh install and confirms +# all expected CLI commands are available and functional. # # Usage: -# ./scripts/e2e-install-test.sh # Run all three runtimes -# ./scripts/e2e-install-test.sh --nodejs # Node.js only -# ./scripts/e2e-install-test.sh --python # Python only -# ./scripts/e2e-install-test.sh --bash # Bash only +# ./scripts/e2e-install-test.sh # Run Python tests +# ./scripts/e2e-install-test.sh --python # Python only (same as above) # # Exit codes: # 0 all tests passed @@ -86,80 +84,6 @@ assert_statusline_ok() { fi } -# ── Node.js E2E ──────────────────────────────────────────────────────────────── - -run_nodejs_e2e() { - section "Node.js — Clean Install Smoke Test" - - # Prerequisites - if ! command -v node &>/dev/null; then - fail "node not found in PATH — skipping Node.js tests" - return - fi - if ! command -v npm &>/dev/null; then - fail "npm not found in PATH — skipping Node.js tests" - return - fi - info "node $(node --version), npm $(npm --version)" - - info "Installing from $PROJECT_ROOT via npm pack..." - local pack_file - pack_file=$(cd "$PROJECT_ROOT" && npm pack --quiet 2>/dev/null | tail -1) || true - if [ -z "$pack_file" ]; then - fail "npm pack failed — cannot perform clean Node.js install test" - return - fi - - local pack_path="$PROJECT_ROOT/$pack_file" - - # Install into a fresh directory - local install_dir - install_dir=$(mktemp -d) - # shellcheck disable=SC2064 - trap "rm -rf '$install_dir' '$pack_path'" EXIT - - (cd "$install_dir" && npm install --quiet "$pack_path" 2>/dev/null) \ - && npm_exit=0 || npm_exit=$? - rm -f "$pack_path" - - if [ "$npm_exit" -ne 0 ]; then - fail "npm install failed (exit=$npm_exit)" - rm -rf "$install_dir" - return - fi - pass "npm install succeeded" - - local node_bin="$install_dir/node_modules/.bin" - - # Assert: context-stats entry point exists and is executable - if [ -x "$node_bin/context-stats" ] || [ -L "$node_bin/context-stats" ]; then - pass "context-stats entry point exists in node_modules/.bin" - else - fail "context-stats entry point missing from node_modules/.bin" - fi - - # Assert: statusline.js script is present - local statusline_js="$install_dir/node_modules/cc-context-stats/scripts/statusline.js" - if [ -f "$statusline_js" ]; then - pass "statusline.js present in installed package" - else - fail "statusline.js missing from installed package" - fi - - # Assert: statusline.js accepts JSON and produces output - assert_statusline_ok "statusline.js processes JSON input" node "$statusline_js" - - # Assert: context-stats --help exits 0 - local context_stats_sh="$install_dir/node_modules/cc-context-stats/scripts/context-stats.sh" - if [ -f "$context_stats_sh" ]; then - assert_exits_ok "context-stats.sh --help exits 0" bash "$context_stats_sh" --help - else - fail "context-stats.sh missing from installed package" - fi - - rm -rf "$install_dir" -} - # ── Python E2E ───────────────────────────────────────────────────────────────── run_python_e2e() { @@ -238,106 +162,25 @@ run_python_e2e() { rm -rf "$venv_dir" } -# ── Bash E2E ─────────────────────────────────────────────────────────────────── - -run_bash_e2e() { - section "Bash — Clean Environment Smoke Test" - - local bash_version - bash_version=$(bash --version | head -1) - info "$bash_version" - - # Expected Bash scripts (standalone, no external state needed) - declare -a BASH_SCRIPTS=( - "scripts/statusline-full.sh" - "scripts/statusline-minimal.sh" - "scripts/statusline-git.sh" - "scripts/context-stats.sh" - "scripts/check-install.sh" - ) - - # Assert: every script is executable - for script in "${BASH_SCRIPTS[@]}"; do - local script_path="$PROJECT_ROOT/$script" - if [ -f "$script_path" ] && [ -x "$script_path" ]; then - pass "$script is executable" - elif [ -f "$script_path" ]; then - fail "$script exists but is not executable" - else - fail "$script not found" - fi - done - - # Assert: statusline scripts accept JSON from stdin under clean environment - # Use env -i to strip the environment (keep only PATH and HOME) - local clean_env_prefix="env -i HOME=$HOME PATH=/usr/local/bin:/usr/bin:/bin" - - for script in "scripts/statusline-full.sh" "scripts/statusline-minimal.sh" "scripts/statusline-git.sh"; do - local script_path="$PROJECT_ROOT/$script" - if [ -f "$script_path" ] && [ -x "$script_path" ]; then - # Check if jq is required and available - if grep -q "jq" "$script_path" && ! command -v jq &>/dev/null; then - info "$script requires jq (not available) — skipping JSON input test" - continue - fi - local output exit_code - output=$(echo "$STATUSLINE_TEST_JSON" | $clean_env_prefix bash "$script_path" 2>/dev/null) && exit_code=$? || exit_code=$? - if [ "$exit_code" -eq 0 ] && [ -n "$output" ]; then - pass "$script processes JSON in clean environment" - else - fail "$script failed in clean environment (exit=$exit_code)" - fi - fi - done - - # Assert: context-stats.sh --help exits 0 in clean environment - local cs_script="$PROJECT_ROOT/scripts/context-stats.sh" - if [ -f "$cs_script" ] && [ -x "$cs_script" ]; then - local exit_code - $clean_env_prefix bash "$cs_script" --help >/dev/null 2>&1 && exit_code=$? || exit_code=$? - if [ "$exit_code" -eq 0 ]; then - pass "context-stats.sh --help exits 0 in clean environment" - else - fail "context-stats.sh --help failed in clean environment (exit=$exit_code)" - fi - fi - - # Assert: check-install.sh has correct shebang and is syntactically valid - local ci_script="$PROJECT_ROOT/scripts/check-install.sh" - if [ -f "$ci_script" ]; then - assert_exits_ok "check-install.sh passes bash syntax check" bash -n "$ci_script" - fi -} - # ── Main ─────────────────────────────────────────────────────────────────────── main() { echo -e "${BLUE}${BOLD}cc-context-stats E2E Install Tests${RESET}" echo "======================================" - local run_nodejs=true - local run_python=true - local run_bash=true - # Parse flags for arg in "$@"; do case "$arg" in - --nodejs) run_nodejs=true; run_python=false; run_bash=false ;; - --python) run_nodejs=false; run_python=true; run_bash=false ;; - --bash) run_nodejs=false; run_python=false; run_bash=true ;; + --python) ;; # default, accepted for compatibility --help|-h) - echo "Usage: $0 [--nodejs|--python|--bash]" - echo " --nodejs Test Node.js runtime only" - echo " --python Test Python runtime only" - echo " --bash Test Bash scripts only" + echo "Usage: $0 [--python]" + echo " --python Test Python runtime (default)" exit 0 ;; esac done - [ "$run_nodejs" = true ] && run_nodejs_e2e - [ "$run_python" = true ] && run_python_e2e - [ "$run_bash" = true ] && run_bash_e2e + run_python_e2e # ── Summary ──────────────────────────────────────────────────────────────── echo diff --git a/scripts/statusline-full.sh b/scripts/statusline-full.sh deleted file mode 100755 index 7296001..0000000 --- a/scripts/statusline-full.sh +++ /dev/null @@ -1,577 +0,0 @@ -#!/bin/bash -# Full-featured status line with context window usage -# Usage: Copy to ~/.claude/statusline.sh and make executable -# -# Configuration: -# Create/edit ~/.claude/statusline.conf and set: -# -# autocompact=false (when autocompact is disabled in Claude Code - default) -# autocompact=true (when you enable autocompact via /config in Claude Code) -# -# token_detail=true (show exact token count like 64,000 - default) -# token_detail=false (show abbreviated tokens like 64.0k) -# -# show_delta=true (show token delta since last refresh like [+2,500] - default) -# show_delta=false (disable delta display - saves file I/O on every refresh) -# -# show_session=true (show session_id in status line - default) -# show_session=false (hide session_id from status line) -# -# When AC is enabled, 22.5% of context window is reserved for autocompact buffer. -# -# State file format (CSV): -# timestamp,total_input_tokens,total_output_tokens,current_usage_input_tokens,current_usage_output_tokens,current_usage_cache_creation,current_usage_cache_read,total_cost_usd,total_lines_added,total_lines_removed,session_id,model_id,workspace_project_dir - -# Colors (defaults, overridable via config) -# shellcheck disable=SC2034 # Used dynamically via COLOR_KEYS eval -BLUE='\033[0;34m' -# shellcheck disable=SC2034 # Used dynamically via COLOR_KEYS eval -MAGENTA='\033[0;35m' -CYAN='\033[0;36m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -RED='\033[0;31m' -DIM='\033[2m' -RESET='\033[0m' - -# Named colors for config parsing -declare -A COLOR_NAMES=( - [black]='\033[0;30m' [red]='\033[0;31m' [green]='\033[0;32m' - [yellow]='\033[0;33m' [blue]='\033[0;34m' [magenta]='\033[0;35m' - [cyan]='\033[0;36m' [white]='\033[0;37m' - [bright_black]='\033[0;90m' [bright_red]='\033[0;91m' [bright_green]='\033[0;92m' - [bright_yellow]='\033[0;93m' [bright_blue]='\033[0;94m' [bright_magenta]='\033[0;95m' - [bright_cyan]='\033[0;96m' [bright_white]='\033[0;97m' - [bold_white]='\033[1;97m' [dim]='\033[2m' -) - -# Color config key to slot mapping -declare -A COLOR_KEYS=( - [color_green]=GREEN [color_yellow]=YELLOW [color_red]=RED - [color_blue]=BLUE [color_magenta]=MAGENTA [color_cyan]=CYAN - [color_context_length]=C_CONTEXT_LENGTH [color_project_name]=C_PROJECT_NAME - [color_branch_name]=C_BRANCH_NAME [color_mi_score]=C_MI_SCORE - [color_zone]=C_ZONE [color_separator]=C_SEPARATOR -) - -# Parse a color name or #rrggbb hex into an ANSI escape code -parse_color() { - local value - value=$(echo "$1" | tr '[:upper:]' '[:lower:]' | xargs) - if [[ -n "${COLOR_NAMES[$value]+x}" ]]; then - echo "${COLOR_NAMES[$value]}" - return - fi - if [[ "$value" =~ ^#[0-9a-f]{6}$ ]]; then - local r=$((16#${value:1:2})) - local g=$((16#${value:3:2})) - local b=$((16#${value:5:2})) - echo "\033[38;2;${r};${g};${b}m" - return - fi -} - -# State file rotation constants -ROTATION_THRESHOLD=10000 -ROTATION_KEEP=5000 - -# Rotate state file if it exceeds threshold -maybe_rotate_state_file() { - local state_file="$1" - [[ -f "$state_file" ]] || return - local line_count - line_count=$(wc -l < "$state_file" | tr -d ' ') - if [[ "$line_count" -gt "$ROTATION_THRESHOLD" ]]; then - local tmp_file="${state_file}.tmp.$$" - tail -n "$ROTATION_KEEP" "$state_file" > "$tmp_file" && mv "$tmp_file" "$state_file" || rm -f "$tmp_file" - fi -} - -# Model Intelligence computation (uses awk for float math) -# MI(u) = max(0, 1 - u^beta) where beta is model-specific -compute_mi() { - local used_tokens=$1 context_window=$2 model_id=$3 beta_override=$4 - awk -v used="$used_tokens" -v cw="$context_window" -v mid="$model_id" -v bo="$beta_override" ' - BEGIN { - if (cw == 0) { printf "1.000"; exit } - # Model profile lookup (beta only) - mid_lower = tolower(mid) - if (index(mid_lower, "opus") > 0) beta = 1.8 - else if (index(mid_lower, "sonnet") > 0) beta = 1.5 - else if (index(mid_lower, "haiku") > 0) beta = 1.2 - else beta = 1.5 - # Beta override - if (bo + 0 > 0) beta = bo + 0 - # MI calculation - u = used / cw - if (u <= 0) mi = 1.0 - else { mi = 1.0 - (u ^ beta); if (mi < 0) mi = 0.0 } - printf "%.3f", mi - }' -} - -get_mi_color() { - local mi_val="$1" utilization="$2" - awk -v mi="$mi_val" -v u="$utilization" 'BEGIN { - if (mi + 0 <= 0.80 || u + 0 >= 0.80) print "red" - else if (mi + 0 < 0.90 || u + 0 >= 0.40) print "yellow" - else print "green" - }' -} - -# Context zone indicator — always shown -# Returns "zone_word color_name" (e.g., "Plan green") -get_context_zone() { - local used_tokens=$1 context_window=$2 - awk -v used="$used_tokens" -v cw="$context_window" ' - BEGIN { - if (cw == 0) { print "Plan green"; exit } - if (cw >= 500000) { - # 1M model thresholds - if (used < 70000) print "Plan green" - else if (used < 100000) print "Code yellow" - else if (used < 250000) print "Dump orange" - else if (used < 275000) print "ExDump dark_red" - else print "Dead gray" - } else { - # Standard model thresholds - dump_zone = int(cw * 0.40) - warn_start = dump_zone - 30000 - if (warn_start < 0) warn_start = 0 - hard_limit = int(cw * 0.70) - dead_zone = int(cw * 0.75) - if (used < warn_start) print "Plan green" - else if (used < dump_zone) print "Code yellow" - else if (used < hard_limit) print "Dump orange" - else if (used < dead_zone) print "ExDump dark_red" - else print "Dead gray" - } - }' -} - -zone_ansi_color() { - local color_name="$1" - case "$color_name" in - green) echo "$GREEN" ;; - yellow) echo "$YELLOW" ;; - orange) echo "\033[38;2;255;165;0m" ;; - dark_red) echo "\033[38;2;139;0;0m" ;; - gray) echo "\033[0;90m" ;; - *) echo "$RESET" ;; - esac -} - -# Read JSON input from stdin -input=$(cat) - -# Extract information from JSON -cwd=$(echo "$input" | jq -r '.workspace.current_dir') -project_dir=$(echo "$input" | jq -r '.workspace.project_dir') -model=$(echo "$input" | jq -r '.model.display_name // "Claude"') -session_id=$(echo "$input" | jq -r '.session_id // empty') -dir_name=$(basename "$cwd") - -# Git information (skip optional locks for performance) -git_info="" -if [[ -d "$project_dir/.git" ]]; then - git_branch=$(cd "$project_dir" 2>/dev/null && git --no-optional-locks rev-parse --abbrev-ref HEAD 2>/dev/null) - git_status_count=$(cd "$project_dir" 2>/dev/null && git --no-optional-locks status --porcelain 2>/dev/null | wc -l | tr -d ' ') - - if [[ -n "$git_branch" ]]; then - if [[ "$git_status_count" != "0" ]]; then - git_info=" | ${C_BRANCH_NAME}${git_branch}${RESET} ${CYAN}[${git_status_count}]${RESET}" - else - git_info=" | ${C_BRANCH_NAME}${git_branch}${RESET}" - fi - fi -fi - -# Read settings from ~/.claude/statusline.conf -# Sync this manually when you change settings in Claude Code via /config -autocompact_enabled=true -token_detail_enabled=true -show_delta_enabled=true -show_session_enabled=true -show_mi_enabled=false -mi_curve_beta=0 -delta_info="" -mi_info="" -zone_info="" -session_info="" - -# Create config file with defaults if it doesn't exist -if [[ ! -f ~/.claude/statusline.conf ]]; then - mkdir -p ~/.claude - cat >~/.claude/statusline.conf <<'EOF' -# cc-context-stats — statusline configuration -# Full reference: https://github.com/luongnv89/cc-context-stats/blob/main/docs/configuration.md - -# ─── Display Settings ─────────────────────────────────────────────── - -# Autocompact setting — sync with Claude Code's /config -# When true, 22.5% of the context window is reserved for the autocompact buffer. -autocompact=true - -# Token display format -# true = exact count (e.g., 64,000) -# false = abbreviated (e.g., 64.0k) -token_detail=true - -# Show token delta since last refresh (e.g., +2,500) -# Adds file I/O on every refresh; disable if you don't need it -show_delta=true - -# Show session_id in the status line -show_session=true - -# Show input/output token breakdown (reserved for future use) -show_io_tokens=true - -# Disable rotating text animations (accessibility) -reduced_motion=false - -# ─── Model Intelligence (MI) ──────────────────────────────────────── - -# Show the MI score in the status line -show_mi=false - -# MI curve beta override (0 = use model-specific profile) -# Per-model defaults: opus=1.8, sonnet=1.5, haiku=1.2 -# Set a positive value to override for all models (e.g., 1.5) -mi_curve_beta=0 - -# ─── Zone Threshold Overrides ─────────────────────────────────────── -# Uncomment and set a positive value to override the built-in defaults. -# Omitted or commented-out keys use the defaults shown below. - -# Context windows >= this value use 1M-class thresholds (token count) -# large_model_threshold=500000 - -# 1M-class models (context >= large_model_threshold) -# Values are token counts for zone boundaries -# zone_1m_plan_max=70000 -# zone_1m_code_max=100000 -# zone_1m_dump_max=250000 -# zone_1m_xdump_max=275000 - -# Standard models (context < large_model_threshold) -# Ratios are 0–1 fractions of the context window; warn_buffer is a token count -# zone_std_dump_ratio=0.40 -# zone_std_warn_buffer=30000 -# zone_std_hard_limit=0.70 -# zone_std_dead_ratio=0.75 - -# ─── Base Color Slots ─────────────────────────────────────────────── -# Override the MI/context traffic-light colors and legacy element colors. -# Accepts named colors or hex codes (#rrggbb). -# -# Named colors: black, red, green, yellow, blue, magenta, cyan, white, -# bright_black, bright_red, bright_green, bright_yellow, -# bright_blue, bright_magenta, bright_cyan, bright_white, -# bold_white, dim -# -# color_green=green -# color_yellow=yellow -# color_red=red -# color_blue=blue -# color_magenta=magenta -# color_cyan=cyan - -# ─── Per-Property Colors ──────────────────────────────────────────── -# Override individual statusline elements. These take precedence over -# base color slots. Unset keys fall back to the base slot or built-in. -# -# color_context_length=bold_white -# color_project_name=cyan -# color_branch_name=green -# color_mi_score=yellow -# color_zone=default -# color_separator=dim -EOF -fi - -if [[ -f ~/.claude/statusline.conf ]]; then - while IFS= read -r line; do - line=$(echo "$line" | xargs) - [[ -z "$line" || "$line" == \#* ]] && continue - [[ "$line" != *=* ]] && continue - key="${line%%=*}" - key=$(echo "$key" | xargs) - raw_value="${line#*=}" - raw_value=$(echo "$raw_value" | xargs) - value_lower=$(echo "$raw_value" | tr '[:upper:]' '[:lower:]') - case "$key" in - autocompact) [[ "$value_lower" == "false" ]] && autocompact_enabled=false ;; - token_detail) [[ "$value_lower" == "false" ]] && token_detail_enabled=false ;; - show_delta) [[ "$value_lower" == "false" ]] && show_delta_enabled=false ;; - show_session) [[ "$value_lower" == "false" ]] && show_session_enabled=false ;; - show_mi) [[ "$value_lower" == "false" ]] && show_mi_enabled=false ;; - mi_curve_beta) mi_curve_beta="$raw_value" ;; - color_*) - if [[ -n "${COLOR_KEYS[$key]+x}" ]]; then - slot="${COLOR_KEYS[$key]}" - ansi=$(parse_color "$raw_value") - if [[ -n "$ansi" ]]; then - eval "$slot='$ansi'" - eval "${slot}_CONFIG_SET=yes" - fi - fi - ;; - esac - done < ~/.claude/statusline.conf -fi - -# Per-property color defaults (highlighted key info) -# Track which per-property colors were explicitly set via config -C_CONTEXT_LENGTH_SET="${C_CONTEXT_LENGTH_CONFIG_SET:-}" -C_MI_SCORE_SET="${C_MI_SCORE_CONFIG_SET:-}" -C_ZONE_SET="${C_ZONE_CONFIG_SET:-}" -: "${C_CONTEXT_LENGTH:=\033[1;97m}" # bold_white -# Cascade: per-property key -> old color key -> new highlighted default -if [[ -z "$C_PROJECT_NAME" ]]; then - if [[ "${BLUE_CONFIG_SET:-}" == "yes" ]]; then - C_PROJECT_NAME="$BLUE" - else - C_PROJECT_NAME="$CYAN" - fi -fi -if [[ -z "$C_BRANCH_NAME" ]]; then - if [[ "${MAGENTA_CONFIG_SET:-}" == "yes" ]]; then - C_BRANCH_NAME="$MAGENTA" - else - C_BRANCH_NAME="$GREEN" - fi -fi -: "${C_MI_SCORE:=$YELLOW}" -: "${C_SEPARATOR:=$DIM}" - -# Width-fitting helpers -visible_width() { - # Strip ANSI escape sequences (both literal \033 and actual ESC byte) and return string length - local stripped - stripped=$(printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g') - printf '%s' "$stripped" | wc -m | tr -d ' ' -} - -get_terminal_width() { - # Return terminal width for fit_to_width truncation. - # When running inside Claude Code's statusline subprocess, neither $COLUMNS - # nor tput can detect the real terminal width (they always return 80). - # If COLUMNS is explicitly set, trust it. Otherwise use 200 as default - # so no parts are unnecessarily dropped; Claude Code handles overflow. - if [[ -n "$COLUMNS" ]]; then - echo "$COLUMNS" - else - local cols - cols=$(tput cols 2>/dev/null || echo 80) - if [[ "$cols" -eq 80 ]]; then - echo 200 - else - echo "$cols" - fi - fi -} - -fit_to_width() { - # Assemble parts into a single line that fits within max_width. - # Usage: fit_to_width max_width part1 part2 part3 ... - # First part (base) is always included. Subsequent parts are - # included only if adding them does not exceed max_width. - local max_width=$1 - shift - local parts=("$@") - - if [[ ${#parts[@]} -eq 0 ]]; then - echo "" - return - fi - - local result="${parts[0]}" - local current_width - current_width=$(visible_width "$result") - - for ((i = 1; i < ${#parts[@]}; i++)); do - local part="${parts[$i]}" - if [[ -z "$part" ]]; then - continue - fi - local part_width - part_width=$(visible_width "$part") - if (( current_width + part_width <= max_width )); then - result+="$part" - (( current_width += part_width )) - fi - done - - echo -e "$result" -} - -# Calculate context window - show remaining free space -context_info="" -total_size=$(echo "$input" | jq -r '.context_window.context_window_size // 0') -current_usage=$(echo "$input" | jq '.context_window.current_usage') -total_input_tokens=$(echo "$input" | jq -r '.context_window.total_input_tokens // 0') -total_output_tokens=$(echo "$input" | jq -r '.context_window.total_output_tokens // 0') -cost_usd=$(echo "$input" | jq -r '.cost.total_cost_usd // 0') -lines_added=$(echo "$input" | jq -r '.cost.total_lines_added // 0') -lines_removed=$(echo "$input" | jq -r '.cost.total_lines_removed // 0') -model_id=$(echo "$input" | jq -r '.model.id // ""') -workspace_project_dir=$(echo "$input" | jq -r '.workspace.project_dir // ""' | tr ',' '_') - -if [[ "$total_size" -gt 0 && "$current_usage" != "null" ]]; then - # Get tokens from current_usage (includes cache) - input_tokens=$(echo "$current_usage" | jq -r '.input_tokens // 0') - cache_creation=$(echo "$current_usage" | jq -r '.cache_creation_input_tokens // 0') - cache_read=$(echo "$current_usage" | jq -r '.cache_read_input_tokens // 0') - - # Total used from current request - used_tokens=$((input_tokens + cache_creation + cache_read)) - - # Calculate autocompact buffer (22.5% of context window = 45k for 200k) - autocompact_buffer=$((total_size * 225 / 1000)) - - # Free tokens calculation depends on autocompact setting - if [[ "$autocompact_enabled" == "true" ]]; then - # When AC enabled: subtract buffer to show actual usable space - free_tokens=$((total_size - used_tokens - autocompact_buffer)) - else - # When AC disabled: show full free space - free_tokens=$((total_size - used_tokens)) - fi - - if [[ "$free_tokens" -lt 0 ]]; then - free_tokens=0 - fi - - # Calculate percentage with one decimal (relative to total size) - free_pct=$(awk "BEGIN {printf \"%.1f\", ($free_tokens * 100.0 / $total_size)}") - - # Format tokens based on token_detail setting - if [[ "$token_detail_enabled" == "true" ]]; then - # Use awk for portable comma formatting (works regardless of locale) - free_display=$(awk -v n="$free_tokens" 'BEGIN { printf "%\047d", n }') - else - free_display=$(awk "BEGIN {printf \"%.1fk\", $free_tokens / 1000}") - fi - - # Color based on MI thresholds (consistent with MI display) - ctx_mi_val=$(compute_mi "$used_tokens" "$total_size" "$model_id" "$mi_curve_beta") - ctx_util=$(awk -v u="$used_tokens" -v t="$total_size" 'BEGIN { if (t > 0) printf "%.4f", u/t; else print "0" }') - ctx_color_name=$(get_mi_color "$ctx_mi_val" "$ctx_util") - case "$ctx_color_name" in - green) ctx_color="$GREEN" ;; - yellow) ctx_color="$YELLOW" ;; - red) ctx_color="$RED" ;; - esac - - # Use per-property context_length color if explicitly configured, else MI-based color - if [[ "$C_CONTEXT_LENGTH_SET" == "yes" ]]; then - effective_ctx_color="$C_CONTEXT_LENGTH" - else - effective_ctx_color="$ctx_color" - fi - context_info=" | ${effective_ctx_color}${free_display} (${free_pct}%)${RESET}" - - # Always show zone indicator - zone_result=$(get_context_zone "$used_tokens" "$total_size") - zone_word=$(echo "$zone_result" | awk '{print $1}') - zone_color_name=$(echo "$zone_result" | awk '{print $2}') - # Use per-property zone color if explicitly configured, else dynamic zone color - if [[ "$C_ZONE_SET" == "yes" ]]; then - zone_ansi="$C_ZONE" - else - zone_ansi=$(zone_ansi_color "$zone_color_name") - fi - zone_info=" | ${zone_ansi}${zone_word}${RESET}" - - # Read previous entry if needed for delta OR MI - if [[ "$show_delta_enabled" == "true" || "$show_mi_enabled" == "true" ]]; then - # Use session_id for per-session state (avoids conflicts with parallel sessions) - state_dir=~/.claude/statusline - mkdir -p "$state_dir" - - # Migrate old state files from ~/.claude/ to ~/.claude/statusline/ (one-time migration) - old_state_dir=~/.claude - for old_file in "$old_state_dir"/statusline*.state; do - if [[ -f "$old_file" ]]; then - new_file="${state_dir}/$(basename "$old_file")" - if [[ ! -f "$new_file" ]]; then - mv "$old_file" "$new_file" 2>/dev/null || true - else - rm -f "$old_file" 2>/dev/null || true - fi - fi - done - - if [[ -n "$session_id" ]]; then - state_file=${state_dir}/statusline.${session_id}.state - else - state_file=${state_dir}/statusline.state - fi - has_prev=false - prev_tokens=0 - if [[ -f "$state_file" ]]; then - has_prev=true - # Read last line and calculate previous state - # CSV: ts[0],in[1],out[2],cur_in[3],cur_out[4],cache_create[5],cache_read[6], - # cost[7],+lines[8],-lines[9],session[10],model[11],dir[12],size[13] - last_line=$(tail -1 "$state_file" 2>/dev/null) - if [[ -n "$last_line" ]]; then - prev_cur_in=$(echo "$last_line" | cut -d',' -f4) - prev_cache_create=$(echo "$last_line" | cut -d',' -f6) - prev_cache_read=$(echo "$last_line" | cut -d',' -f7) - prev_tokens=$(( ${prev_cur_in:-0} + ${prev_cache_create:-0} + ${prev_cache_read:-0} )) - fi - fi - - # Calculate and display token delta if enabled - if [[ "$show_delta_enabled" == "true" ]]; then - delta=$((used_tokens - prev_tokens)) - if [[ "$has_prev" == "true" && "$delta" -gt 0 ]]; then - if [[ "$token_detail_enabled" == "true" ]]; then - delta_display=$(awk -v n="$delta" 'BEGIN { printf "%\047d", n }') - else - delta_display=$(awk "BEGIN {printf \"%.1fk\", $delta / 1000}") - fi - delta_info=" | ${C_SEPARATOR}+${delta_display}${RESET}" - fi - fi - - # Calculate and display MI score if enabled - if [[ "$show_mi_enabled" == "true" ]]; then - mi_val=$(compute_mi "$used_tokens" "$total_size" "$model_id" "$mi_curve_beta") - mi_util=$(awk -v u="$used_tokens" -v t="$total_size" 'BEGIN { if (t > 0) printf "%.4f", u/t; else print "0" }') - mi_color_name=$(get_mi_color "$mi_val" "$mi_util") - case "$mi_color_name" in - green) mi_color="$GREEN" ;; - yellow) mi_color="$YELLOW" ;; - red) mi_color="$RED" ;; - esac - # Use per-property mi_score color if explicitly configured, else MI-based color - if [[ "$C_MI_SCORE_SET" == "yes" ]]; then - mi_color="$C_MI_SCORE" - fi - mi_info=" | ${mi_color}MI:${mi_val}${RESET}" - fi - - # Only append if context usage changed (avoid duplicates from multiple refreshes) - cur_input_tokens=$(echo "$current_usage" | jq -r '.input_tokens // 0') - cur_output_tokens=$(echo "$current_usage" | jq -r '.output_tokens // 0') - if [[ "$has_prev" != "true" || "$used_tokens" != "$prev_tokens" ]]; then - echo "$(date +%s),$total_input_tokens,$total_output_tokens,$cur_input_tokens,$cur_output_tokens,$cache_creation,$cache_read,$cost_usd,$lines_added,$lines_removed,$session_id,$model_id,$workspace_project_dir,$total_size" >>"$state_file" - maybe_rotate_state_file "$state_file" - fi - fi -fi - -# Display session_id if enabled -if [[ "$show_session_enabled" == "true" && -n "$session_id" ]]; then - session_info=" | ${C_SEPARATOR}${session_id}${RESET}" -fi - -# Output: directory | branch [changes] | XXk free (XX%) | zone | MI | +delta | [Model] [S:session_id] -# Model name is lowest priority — truncated first when terminal is narrow -base="${C_PROJECT_NAME}${dir_name}${RESET}" -model_info=" | ${C_SEPARATOR}${model}${RESET}" -max_width=$(get_terminal_width) -fit_to_width "$max_width" "$base" "$git_info" "$context_info" "$zone_info" "$mi_info" "$delta_info" "$model_info" "$session_info" diff --git a/scripts/statusline-git.sh b/scripts/statusline-git.sh deleted file mode 100755 index 730dcdf..0000000 --- a/scripts/statusline-git.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -# Git-aware status line - shows model, directory, and git branch -# Usage: Copy to ~/.claude/statusline.sh and make executable - -# Colors -BLUE='\033[0;34m' -MAGENTA='\033[0;35m' -CYAN='\033[0;36m' -RESET='\033[0m' - -input=$(cat) - -MODEL_DISPLAY=$(echo "$input" | jq -r '.model.display_name // "Claude"') -CURRENT_DIR=$(echo "$input" | jq -r '.workspace.current_dir // "~"') -DIR_NAME="${CURRENT_DIR##*/}" - -# Width-fitting helpers -visible_width() { - local stripped - stripped=$(printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g') - printf '%s' "$stripped" | wc -m | tr -d ' ' -} - -get_terminal_width() { - # When running inside Claude Code's statusline subprocess, $COLUMNS is not set - # and tput falls back to 80. If COLUMNS is set, trust it. Otherwise use 200 - # so no parts are dropped; Claude Code handles overflow. - if [[ -n "$COLUMNS" ]]; then - echo "$COLUMNS" - else - local cols - cols=$(tput cols 2>/dev/null || echo 80) - if [[ "$cols" -eq 80 ]]; then - echo 200 - else - echo "$cols" - fi - fi -} - -fit_to_width() { - local max_width=$1 - shift - local parts=("$@") - - if [[ ${#parts[@]} -eq 0 ]]; then - echo "" - return - fi - - local result="${parts[0]}" - local current_width - current_width=$(visible_width "$result") - - for ((i = 1; i < ${#parts[@]}; i++)); do - local part="${parts[$i]}" - if [[ -z "$part" ]]; then - continue - fi - local part_width - part_width=$(visible_width "$part") - if (( current_width + part_width <= max_width )); then - result+="$part" - (( current_width += part_width )) - fi - done - - echo -e "$result" -} - -# Git branch detection -GIT_INFO="" -if git -C "$CURRENT_DIR" rev-parse --git-dir > /dev/null 2>&1; then - BRANCH=$(git -C "$CURRENT_DIR" branch --show-current 2>/dev/null) - if [ -n "$BRANCH" ]; then - # Count uncommitted changes - CHANGES=$(git -C "$CURRENT_DIR" status --porcelain 2>/dev/null | wc -l | tr -d ' ') - if [ "$CHANGES" -gt 0 ]; then - GIT_INFO=" | ${MAGENTA}${BRANCH}${RESET} ${CYAN}[${CHANGES}]${RESET}" - else - GIT_INFO=" | ${MAGENTA}${BRANCH}${RESET}" - fi - fi -fi - -base="[${MODEL_DISPLAY}] ${BLUE}${DIR_NAME}${RESET}" -max_width=$(get_terminal_width) -fit_to_width "$max_width" "$base" "$GIT_INFO" diff --git a/scripts/statusline-minimal.sh b/scripts/statusline-minimal.sh deleted file mode 100755 index 41e676e..0000000 --- a/scripts/statusline-minimal.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/bash -# Minimal status line - shows model and current directory -# Usage: Copy to ~/.claude/statusline.sh and make executable - -input=$(cat) - -MODEL_DISPLAY=$(echo "$input" | jq -r '.model.display_name // "Claude"') -CURRENT_DIR=$(echo "$input" | jq -r '.workspace.current_dir // "~"') -DIR_NAME="${CURRENT_DIR##*/}" - -# Width-fitting helpers -visible_width() { - local stripped - stripped=$(printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g') - printf '%s' "$stripped" | wc -m | tr -d ' ' -} - -get_terminal_width() { - # When running inside Claude Code's statusline subprocess, $COLUMNS is not set - # and tput falls back to 80. If COLUMNS is set, trust it. Otherwise use 200 - # so no parts are dropped; Claude Code handles overflow. - if [[ -n "$COLUMNS" ]]; then - echo "$COLUMNS" - else - local cols - cols=$(tput cols 2>/dev/null || echo 80) - if [[ "$cols" -eq 80 ]]; then - echo 200 - else - echo "$cols" - fi - fi -} - -fit_to_width() { - local max_width=$1 - shift - local parts=("$@") - - if [[ ${#parts[@]} -eq 0 ]]; then - echo "" - return - fi - - local result="${parts[0]}" - local current_width - current_width=$(visible_width "$result") - - for ((i = 1; i < ${#parts[@]}; i++)); do - local part="${parts[$i]}" - if [[ -z "$part" ]]; then - continue - fi - local part_width - part_width=$(visible_width "$part") - if (( current_width + part_width <= max_width )); then - result+="$part" - (( current_width += part_width )) - fi - done - - echo -e "$result" -} - -base="[$MODEL_DISPLAY] $DIR_NAME" -max_width=$(get_terminal_width) -fit_to_width "$max_width" "$base" diff --git a/scripts/statusline.js b/scripts/statusline.js deleted file mode 100755 index 1ff41d1..0000000 --- a/scripts/statusline.js +++ /dev/null @@ -1,964 +0,0 @@ -#!/usr/bin/env node -/** - * Node.js status line script for Claude Code - * Usage: Copy to ~/.claude/statusline.js and make executable - * - * Configuration: - * Create/edit ~/.claude/statusline.conf and set: - * - * autocompact=false (when autocompact is disabled in Claude Code - default) - * autocompact=true (when you enable autocompact via /config in Claude Code) - * - * token_detail=true (show exact token count like 64,000 - default) - * token_detail=false (show abbreviated tokens like 64.0k) - * - * show_delta=true (show token delta since last refresh like [+2,500] - default) - * show_delta=false (disable delta display - saves file I/O on every refresh) - * - * show_session=true (show session_id in status line - default) - * show_session=false (hide session_id from status line) - * - * When AC is enabled, 22.5% of context window is reserved for autocompact buffer. - * - * State file format (CSV): - * timestamp,total_input_tokens,total_output_tokens,current_usage_input_tokens, - * current_usage_output_tokens,current_usage_cache_creation,current_usage_cache_read, - * total_cost_usd,total_lines_added,total_lines_removed,session_id,model_id, - * workspace_project_dir,context_window_size - */ - -const { execSync } = require('child_process'); -const crypto = require('crypto'); -const path = require('path'); -const fs = require('fs'); -const os = require('os'); - -const ROTATION_THRESHOLD = 10000; -const ROTATION_KEEP = 5000; - -// Model Intelligence color thresholds -const MI_GREEN_THRESHOLD = 0.9; -const MI_YELLOW_THRESHOLD = 0.8; -const MI_CONTEXT_YELLOW = 0.4; // 40% context used -const MI_CONTEXT_RED = 0.8; // 80% context used - -// Per-model degradation profiles: beta controls curve shape -// Higher beta = quality retained longer (degradation happens later) -const MODEL_PROFILES = { - opus: 1.8, - sonnet: 1.5, - haiku: 1.2, - default: 1.5, -}; - -// Zone indicator thresholds -const LARGE_MODEL_THRESHOLD = 500000; // >= 500k context = 1M-class model -const ZONE_1M_P_MAX = 70000; // P zone: < 70k used -const ZONE_1M_C_MAX = 100000; // C zone: 70k–100k used -const ZONE_1M_D_MAX = 250000; // D zone: 100k–250k used -const ZONE_1M_X_MAX = 275000; // X zone: 250k–275k used; Z zone: >= 275k -const ZONE_STD_DUMP_ZONE = 0.4; -const ZONE_STD_WARN_BUFFER = 30000; -const ZONE_STD_HARD_LIMIT = 0.7; -const ZONE_STD_DEAD_ZONE = 0.75; - -/** - * Match model_id to degradation beta. - */ -function getModelProfile(modelId) { - const lower = (modelId || '').toLowerCase(); - for (const family of ['opus', 'sonnet', 'haiku']) { - if (lower.includes(family)) { - return MODEL_PROFILES[family]; - } - } - return MODEL_PROFILES.default; -} - -/** - * Compute Model Intelligence score. - * MI(u) = max(0, 1 - u^beta) where beta is model-specific. - * Returns { mi }. - */ -function computeMI(usedTokens, contextWindowSize, modelId, betaOverride) { - // Guard clause - if (contextWindowSize === 0) { - return { mi: 1.0 }; - } - - const betaFromProfile = getModelProfile(modelId || ''); - const beta = betaOverride && betaOverride > 0 ? betaOverride : betaFromProfile; - - const u = usedTokens / contextWindowSize; - if (u <= 0) { - return { mi: 1.0 }; - } - - const mi = Math.max(0, 1 - Math.pow(u, beta)); - return { mi }; -} - -/** - * Return ANSI color code for MI score considering both MI and context utilization. - */ -function getMIColor(mi, utilization, greenColor, yellowColor, redColor) { - if (mi <= MI_YELLOW_THRESHOLD || utilization >= MI_CONTEXT_RED) { - return redColor || RED; - } - if (mi < MI_GREEN_THRESHOLD || utilization >= MI_CONTEXT_YELLOW) { - return yellowColor || YELLOW; - } - return greenColor || GREEN; -} - -/** - * Determine context zone indicator (P/C/D/X/Z) based on token usage. - * Returns { zone, colorName }. - * zoneConfig is an optional object of threshold overrides (0 = use default). - */ -function getContextZone(usedTokens, contextWindowSize, zoneConfig) { - if (contextWindowSize === 0) { - return { zone: 'Plan', colorName: 'green' }; - } - - const zc = zoneConfig || {}; - - const lmt = zc.large_model_threshold || LARGE_MODEL_THRESHOLD; - const isLarge = contextWindowSize >= lmt; - - if (isLarge) { - const pMax = zc.zone_1m_plan_max || ZONE_1M_P_MAX; - const cMax = zc.zone_1m_code_max || ZONE_1M_C_MAX; - const dMax = zc.zone_1m_dump_max || ZONE_1M_D_MAX; - const xMax = zc.zone_1m_xdump_max || ZONE_1M_X_MAX; - - if (usedTokens < pMax) { - return { zone: 'Plan', colorName: 'green' }; - } - if (usedTokens < cMax) { - return { zone: 'Code', colorName: 'yellow' }; - } - if (usedTokens < dMax) { - return { zone: 'Dump', colorName: 'orange' }; - } - if (usedTokens < xMax) { - return { zone: 'ExDump', colorName: 'dark_red' }; - } - return { zone: 'Dead', colorName: 'gray' }; - } - - // Standard models - const dumpRatio = zc.zone_std_dump_ratio || ZONE_STD_DUMP_ZONE; - const warnBuf = zc.zone_std_warn_buffer || ZONE_STD_WARN_BUFFER; - const hardLim = zc.zone_std_hard_limit || ZONE_STD_HARD_LIMIT; - const deadRat = zc.zone_std_dead_ratio || ZONE_STD_DEAD_ZONE; - - const dumpZoneTokens = Math.floor(contextWindowSize * dumpRatio); - const warnStart = Math.max(0, dumpZoneTokens - warnBuf); - const hardLimitTokens = Math.floor(contextWindowSize * hardLim); - const deadZoneTokens = Math.floor(contextWindowSize * deadRat); - - if (usedTokens < warnStart) { - return { zone: 'Plan', colorName: 'green' }; - } - if (usedTokens < dumpZoneTokens) { - return { zone: 'Code', colorName: 'yellow' }; - } - if (usedTokens < hardLimitTokens) { - return { zone: 'Dump', colorName: 'orange' }; - } - if (usedTokens < deadZoneTokens) { - return { zone: 'ExDump', colorName: 'dark_red' }; - } - return { zone: 'Dead', colorName: 'gray' }; -} - -/** - * Map zone color name to ANSI escape code. - */ -function zoneAnsiColor(colorName) { - if (colorName === 'green') { - return GREEN; - } - if (colorName === 'yellow') { - return YELLOW; - } - if (colorName === 'orange') { - return '\x1b[38;2;255;165;0m'; - } - if (colorName === 'dark_red') { - return '\x1b[38;2;139;0;0m'; - } - if (colorName === 'gray') { - return '\x1b[0;90m'; - } - return RESET; -} - -/** - * Rotate a state file if it exceeds ROTATION_THRESHOLD lines. - * Keeps the most recent ROTATION_KEEP lines via atomic temp-file + rename. - */ -function maybeRotateStateFile(stateFile) { - try { - if (!fs.existsSync(stateFile)) { - return; - } - const content = fs.readFileSync(stateFile, 'utf8'); - const lines = content.split('\n'); - // Remove trailing empty element from split if file ends with newline - if (lines.length > 0 && lines[lines.length - 1] === '') { - lines.pop(); - } - if (lines.length <= ROTATION_THRESHOLD) { - return; - } - const keep = lines.slice(-ROTATION_KEEP); - const tmpFile = stateFile + '.' + crypto.randomBytes(6).toString('hex') + '.tmp'; - try { - fs.writeFileSync(tmpFile, keep.join('\n') + '\n'); - fs.renameSync(tmpFile, stateFile); - } catch (e) { - try { - fs.unlinkSync(tmpFile); - } catch { - /* cleanup best-effort */ - } - throw e; - } - } catch (e) { - process.stderr.write(`[statusline] warning: failed to rotate state file: ${e.message}\n`); - } -} - -// ANSI Colors (defaults, overridable via config) -const BLUE = '\x1b[0;34m'; -const MAGENTA = '\x1b[0;35m'; -const CYAN = '\x1b[0;36m'; -const GREEN = '\x1b[0;32m'; -const YELLOW = '\x1b[0;33m'; -const RED = '\x1b[0;31m'; -const DIM = '\x1b[2m'; -const RESET = '\x1b[0m'; - -// Named colors for config parsing -const COLOR_NAMES = { - black: '\x1b[0;30m', - red: '\x1b[0;31m', - green: '\x1b[0;32m', - yellow: '\x1b[0;33m', - blue: '\x1b[0;34m', - magenta: '\x1b[0;35m', - cyan: '\x1b[0;36m', - white: '\x1b[0;37m', - bright_black: '\x1b[0;90m', - bright_red: '\x1b[0;91m', - bright_green: '\x1b[0;92m', - bright_yellow: '\x1b[0;93m', - bright_blue: '\x1b[0;94m', - bright_magenta: '\x1b[0;95m', - bright_cyan: '\x1b[0;96m', - bright_white: '\x1b[0;97m', - bold_white: '\x1b[1;97m', - dim: '\x1b[2m', -}; - -/** - * Parse a color name or #rrggbb hex into an ANSI escape code. - * Returns null if unrecognized. - */ -function parseColor(value) { - value = value.trim().toLowerCase(); - if (COLOR_NAMES[value]) { - return COLOR_NAMES[value]; - } - const m = value.match(/^#([0-9a-f]{6})$/); - if (m) { - const r = parseInt(m[1].slice(0, 2), 16); - const g = parseInt(m[1].slice(2, 4), 16); - const b = parseInt(m[1].slice(4, 6), 16); - return `\x1b[38;2;${r};${g};${b}m`; - } - return null; -} - -const COLOR_CONFIG_KEYS = { - color_green: 'green', - color_yellow: 'yellow', - color_red: 'red', - color_blue: 'blue', - color_magenta: 'magenta', - color_cyan: 'cyan', - // Per-property color keys - color_context_length: 'context_length', - color_project_name: 'project_name', - color_branch_name: 'branch_name', - color_mi_score: 'mi_score', - color_zone: 'zone', - color_separator: 'separator', -}; - -// Zone threshold config keys (integer token counts) -const ZONE_INT_KEYS = new Set([ - 'zone_1m_plan_max', - 'zone_1m_code_max', - 'zone_1m_dump_max', - 'zone_1m_xdump_max', - 'zone_std_warn_buffer', - 'large_model_threshold', -]); - -// Zone threshold config keys (float ratios 0-1) -const ZONE_FLOAT_KEYS = new Set([ - 'zone_std_dump_ratio', - 'zone_std_hard_limit', - 'zone_std_dead_ratio', -]); - -/** - * Return the visible width of a string after stripping ANSI escape sequences. - */ -function visibleWidth(s) { - // eslint-disable-next-line no-control-regex - return s.replace(/\x1b\[[0-9;]*m/g, '').length; -} - -/** - * Return the terminal width in columns, defaulting to 80. - */ -/** - * Return the terminal width in columns. - * - * When running inside Claude Code's statusline subprocess, neither $COLUMNS - * nor process.stdout.columns can detect the real terminal width (they return - * undefined or 80). If COLUMNS is not explicitly set and we'd fall back to 80, - * use a generous default of 200 so that no parts are unnecessarily dropped; - * Claude Code's own UI handles any overflow/truncation. - */ -function getTerminalWidth() { - if (process.env.COLUMNS) { - return parseInt(process.env.COLUMNS, 10) || 200; - } - const cols = process.stdout.columns; - if (cols && cols !== 80) { - return cols; - } - return 200; -} - -/** - * Assemble parts into a single line that fits within maxWidth. - * Parts are added in priority order (first = highest priority). - * The first part (base) is always included. - */ -function fitToWidth(parts, maxWidth) { - if (!parts.length) { - return ''; - } - - let result = parts[0]; - let currentWidth = visibleWidth(result); - - for (let i = 1; i < parts.length; i++) { - const part = parts[i]; - if (!part) { - continue; - } - const partWidth = visibleWidth(part); - if (currentWidth + partWidth <= maxWidth) { - result += part; - currentWidth += partWidth; - } - } - - return result; -} - -function getGitInfo(projectDir, magentaColor, cyanColor) { - const mg = magentaColor || MAGENTA; - const cy = cyanColor || CYAN; - const gitDir = path.join(projectDir, '.git'); - if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) { - return ''; - } - - try { - // Get branch name (skip optional locks for performance) - const branch = execSync('git --no-optional-locks rev-parse --abbrev-ref HEAD', { - cwd: projectDir, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000, - }).trim(); - - if (!branch) { - return ''; - } - - // Count changes - const status = execSync('git --no-optional-locks status --porcelain', { - cwd: projectDir, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000, - }); - const changes = status.split('\n').filter(l => l.trim()).length; - - if (changes > 0) { - return ` | ${mg}${branch}${RESET} ${cy}[${changes}]${RESET}`; - } - return ` | ${mg}${branch}${RESET}`; - } catch { - return ''; - } -} - -function readConfig() { - const config = { - autocompact: false, - tokenDetail: true, - showDelta: true, - showSession: true, - showIoTokens: true, - reducedMotion: false, - showMI: false, - miCurveBeta: 0, - colors: {}, - zoneConfig: {}, - }; - const configPath = path.join(os.homedir(), '.claude', 'statusline.conf'); - - // Create config file with defaults if it doesn't exist - if (!fs.existsSync(configPath)) { - try { - const configDir = path.dirname(configPath); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); - } - const defaultConfig = `\ -# ============================================================================ -# cc-context-stats — statusline configuration -# ============================================================================ -# -# Copy this file to: ~/.claude/statusline.conf -# Windows: %USERPROFILE%\\.claude\\statusline.conf -# -# Full reference: -# https://github.com/luongnv89/cc-context-stats/blob/main/docs/configuration.md -# -# Format: -# - key=value (no spaces around '=') -# - Lines starting with '#' are comments -# - Unrecognized keys are silently ignored -# - Missing or invalid values fall back to built-in defaults -# -# ============================================================================ - - -# ─── Display Settings ─────────────────────────────────────────────────────── -# -# These boolean flags control which elements appear in the statusline. -# Any value other than "false" (case-insensitive) is treated as true. - -# Autocompact buffer display. -# When true, 22.5% of the context window is reserved for Claude Code's -# autocompact feature. This affects the "free tokens" calculation. -# Must match your Claude Code setting — check with: /config -# true -> shows [AC:45k] buffer in statusline -# false -> shows [AC:off] -autocompact=false - -# Token display format. -# true = exact count with commas (e.g., 64,000 free) -# false = abbreviated with suffix (e.g., 64.0k free) -# Also affects the delta display (+2,500 vs +2.5k). -token_detail=true - -# Show token delta since last refresh (e.g., +2,500). -# Displays how many tokens were consumed since the previous statusline update. -# Requires file I/O on every refresh to read the previous state. -# Disable if you want to reduce disk overhead. -show_delta=true - -# Show the session ID at the end of the statusline. -# Useful when running multiple Claude Code instances to identify sessions. -# Double-click in terminal to select and copy. -show_session=true - -# Show input/output token breakdown. -# Reserved for future use — currently read but not displayed. -show_io_tokens=true - -# Disable rotating text and icon animations for accessibility. -# false = animations enabled (default) -# true = static display, no motion -reduced_motion=false - - -# ─── Model Intelligence (MI) ──────────────────────────────────────────────── -# -# MI measures how effectively the model uses its context window. The score -# ranges from 0.000 (fully degraded) to 1.000 (optimal). As context fills, -# MI degrades following a model-specific curve. - -# Show the MI score in the statusline (e.g., MI:0.918). -# When enabled, also requires state file I/O for tracking. -# false = MI score hidden (default) -# true = MI score visible -show_mi=false - -# Override the MI degradation curve beta for all models. -# Each model has a built-in profile that controls how quickly MI degrades: -# opus = 1.8 (retains quality longest, steep drop near end) -# sonnet = 1.5 (moderate degradation) -# haiku = 1.2 (degrades earliest) -# Set to 0 to use the model-specific profile (recommended). -# Set a positive value (e.g., 1.5) to override for all models. -mi_curve_beta=0 - - -# ─── Zone Threshold Overrides ─────────────────────────────────────────────── -# -# Zones indicate how much context pressure your session is under: -# Plan (P) = plenty of room, ideal for planning and exploration -# Code (C) = normal coding zone, context is filling but healthy -# Dump (D) = getting full, consider wrapping up or starting fresh -# ExDump (X) = critical, autocompact may trigger, quality degrading -# Dead (Z) = context exhausted, start a new session -# -# There are two threshold sets: one for large models (1M+ context) using -# absolute token counts, and one for standard models using ratios (0-1). -# -# Uncomment and set a positive value to override the built-in defaults. -# Invalid values (negative, non-numeric, ratios outside 0-1) are ignored -# with a warning to stderr. - -# Context windows >= this value use 1M-class thresholds (token count). -# Models below this threshold use the standard ratio-based zones. -# large_model_threshold=500000 - -# --- 1M-Class Models (context >= large_model_threshold) --- -# Values are absolute token counts for zone boundaries (tokens used). -# zone_1m_plan_max=70000 # Plan -> Code boundary -# zone_1m_code_max=100000 # Code -> Dump boundary -# zone_1m_dump_max=250000 # Dump -> ExDump boundary -# zone_1m_xdump_max=275000 # ExDump -> Dead boundary - -# --- Standard Models (context < large_model_threshold) --- -# Ratios are 0-1 fractions of the total context window. -# zone_std_dump_ratio=0.40 # Dump zone starts at 40% utilization -# zone_std_warn_buffer=30000 # Show warning this many tokens before dump zone -# zone_std_hard_limit=0.70 # Hard limit at 70% utilization -# zone_std_dead_ratio=0.75 # Dead zone starts at 75% utilization - - -# ─── Base Color Slots ─────────────────────────────────────────────────────── -# -# Override the 6 base palette colors used for MI-based traffic-light coloring -# and as fallbacks for per-property colors (see next section). -# -# Accepts named colors or hex codes (#rrggbb). -# -# Named colors (18 available): -# Standard: black, red, green, yellow, blue, magenta, cyan, white -# Bright: bright_black, bright_red, bright_green, bright_yellow, -# bright_blue, bright_magenta, bright_cyan, bright_white -# Special: bold_white, dim -# -# Hex colors: any #rrggbb value (requires 24-bit color terminal support) -# -# Unrecognized values are ignored with a warning to stderr. - -# Traffic-light colors — used for MI score and context zone indicators. -# Colors are determined by BOTH MI score and context utilization: -# color_green -> MI >= 0.90 AND context < 40% (model operating well) -# color_yellow -> MI in (0.80, 0.90) OR context in [40%, 80%) (pressure building) -# color_red -> MI <= 0.80 OR context >= 80% (significant degradation) -color_green=#7dcfff -color_yellow=#e0af68 -color_red=#f7768e - -# Legacy element fallback colors: -# color_blue -> fallback for project name (if color_project_name not set) -# color_magenta -> fallback for branch name (if color_branch_name not set) -# color_cyan -> git change-count brackets (e.g., [3]) -color_blue=#7aa2f7 -color_magenta=#bb9af7 -color_cyan=#2ac3de - - -# ─── Per-Property Colors ──────────────────────────────────────────────────── -# -# Override individual statusline elements. These take precedence over -# base color slots above. -# -# Fallback chain: per-property key -> base color slot -> built-in default -# -# For example, if color_project_name is not set, it falls back to color_blue -# (if set), then to the built-in cyan. - -# Context tokens remaining — the most critical info. -# When not set, uses zone traffic-light color (green/yellow/red) automatically. -# Set explicitly to use a fixed color regardless of zone. -# color_context_length=bold_white - -# Project directory name (e.g., "my-project"). -color_project_name=bright_cyan - -# Git branch name (e.g., "main"). -color_branch_name=bright_magenta - -# MI score display (e.g., "MI:0.918"). -# When not set, uses MI-based traffic-light color automatically. -color_mi_score=#ff9e64 - -# Zone indicator label (e.g., "Plan", "Code", "Dump"). -# When not set, uses zone traffic-light color automatically. -# color_zone=bright_green - -# Structural elements: model name, token delta, session ID. -# "dim" makes these visually recede so primary info stands out. -color_separator=dim - - -# ─── Statusline Layout Reference ──────────────────────────────────────────── -# -# The statusline elements are displayed in this order (highest priority first): -# -# project_name | branch [changes] | tokens_free (%) | Zone | MI:score | +delta | Model | session_id -# -# Example output: -# my-project | main [3] | 64,000 free (32.0%) | Code | MI:0.918 | +2,500 | Opus 4.6 | abc-123 -# -# If the terminal is too narrow, lower-priority elements are dropped: -# 1. session_id (dropped first) -# 2. model name -# 3. token delta -# 4. MI score -# 5. zone indicator -# 6. context info -# 7. git info -# 8. project name (always shown, never dropped) -`; - fs.writeFileSync(configPath, defaultConfig); - } catch (e) { - process.stderr.write(`[statusline] warning: failed to create config: ${e.message}\n`); - return config; - } - } - - if (!fs.existsSync(configPath)) { - return config; - } - - try { - const content = fs.readFileSync(configPath, 'utf8'); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (trimmed.startsWith('#') || !trimmed.includes('=')) { - continue; - } - const eqIdx = trimmed.indexOf('='); - const keyTrimmed = trimmed.slice(0, eqIdx).trim(); - const rawValue = trimmed.slice(eqIdx + 1).trim(); - const valueTrimmed = rawValue.toLowerCase(); - if (keyTrimmed === 'autocompact') { - config.autocompact = valueTrimmed !== 'false'; - } else if (keyTrimmed === 'token_detail') { - config.tokenDetail = valueTrimmed !== 'false'; - } else if (keyTrimmed === 'show_delta') { - config.showDelta = valueTrimmed !== 'false'; - } else if (keyTrimmed === 'show_session') { - config.showSession = valueTrimmed !== 'false'; - } else if (keyTrimmed === 'show_io_tokens') { - config.showIoTokens = valueTrimmed !== 'false'; - } else if (keyTrimmed === 'reduced_motion') { - config.reducedMotion = valueTrimmed !== 'false'; - } else if (keyTrimmed === 'show_mi') { - config.showMI = valueTrimmed !== 'false'; - } else if (keyTrimmed === 'mi_curve_beta') { - const parsed = parseFloat(rawValue); - if (!isNaN(parsed)) { - config.miCurveBeta = parsed; - } - } else if (COLOR_CONFIG_KEYS[keyTrimmed]) { - const ansi = parseColor(rawValue); - if (ansi) { - config.colors[COLOR_CONFIG_KEYS[keyTrimmed]] = ansi; - } - } else if (ZONE_INT_KEYS.has(keyTrimmed)) { - const v = parseInt(rawValue, 10); - if (!isNaN(v) && v > 0) { - config.zoneConfig[keyTrimmed] = v; - } else { - process.stderr.write( - `[statusline] warning: ${keyTrimmed} must be a positive integer, ignoring '${rawValue}'\n` - ); - } - } else if (ZONE_FLOAT_KEYS.has(keyTrimmed)) { - const v = parseFloat(rawValue); - if (!isNaN(v) && v > 0 && v < 1) { - config.zoneConfig[keyTrimmed] = v; - } else { - process.stderr.write( - `[statusline] warning: ${keyTrimmed} must be between 0 and 1, ignoring '${rawValue}'\n` - ); - } - } - } - } catch (e) { - process.stderr.write(`[statusline] warning: failed to read config: ${e.message}\n`); - } - return config; -} - -let input = ''; - -process.stdin.setEncoding('utf8'); -process.stdin.on('data', chunk => (input += chunk)); - -process.stdin.on('end', () => { - let data; - try { - data = JSON.parse(input); - } catch { - process.stdout.write('[Claude] ~\n'); - return; - } - - // Extract data - const cwd = data.workspace?.current_dir || '~'; - const projectDir = data.workspace?.project_dir || cwd; - const model = data.model?.display_name || 'Claude'; - const dirName = path.basename(cwd) || '~'; - - // Read settings from config file - const config = readConfig(); - const autocompactEnabled = config.autocompact; - const tokenDetail = config.tokenDetail; - const showDelta = config.showDelta; - const showSession = config.showSession; - // Note: showIoTokens setting is read but not yet implemented - - // Apply color overrides from config - const c = config.colors || {}; - const cGreen = c.green || GREEN; - const cYellow = c.yellow || YELLOW; - const cRed = c.red || RED; - const cBlue = c.blue || BLUE; - const cMagenta = c.magenta || MAGENTA; - const cCyan = c.cyan || CYAN; - - // Per-property color defaults (highlighted key info) - // Falls back to old color keys for backward compatibility, then to new defaults - const cProjectName = c.project_name || (c.blue ? cBlue : CYAN); - const cBranchName = c.branch_name || (c.magenta ? cMagenta : GREEN); - const cSeparator = c.separator || DIM; - - // Git info (use per-property branch color, fallback to green) - const gitInfo = getGitInfo(projectDir, cBranchName, cCyan); - - // Extract session_id once for reuse - const sessionId = data.session_id; - - // Context window calculation - let contextInfo = ''; - let deltaInfo = ''; - let miInfo = ''; - let zoneInfo = ''; - let sessionInfo = ''; - const showMI = config.showMI; - const miCurveBeta = config.miCurveBeta; - const totalSize = data.context_window?.context_window_size || 0; - const currentUsage = data.context_window?.current_usage; - const totalInputTokens = data.context_window?.total_input_tokens || 0; - const totalOutputTokens = data.context_window?.total_output_tokens || 0; - const costUsd = data.cost?.total_cost_usd || 0; - const linesAdded = data.cost?.total_lines_added || 0; - const linesRemoved = data.cost?.total_lines_removed || 0; - const modelId = data.model?.id || ''; - const workspaceProjectDir = data.workspace?.project_dir || ''; - - if (totalSize > 0 && currentUsage) { - // Get tokens from current_usage (includes cache) - const inputTokens = currentUsage.input_tokens || 0; - const cacheCreation = currentUsage.cache_creation_input_tokens || 0; - const cacheRead = currentUsage.cache_read_input_tokens || 0; - - // Total used from current request - const usedTokens = inputTokens + cacheCreation + cacheRead; - - // Calculate autocompact buffer (22.5% of context window = 45k for 200k) - const autocompactBuffer = Math.floor(totalSize * 0.225); - - // Free tokens calculation depends on autocompact setting - let freeTokens; - if (autocompactEnabled) { - freeTokens = totalSize - usedTokens - autocompactBuffer; - } else { - freeTokens = totalSize - usedTokens; - } - - if (freeTokens < 0) { - freeTokens = 0; - } - - // Calculate percentage with one decimal (relative to total size) - const freePct = (freeTokens * 100.0) / totalSize; - - // Format tokens based on token_detail setting - const freeDisplay = tokenDetail - ? freeTokens.toLocaleString('en-US') - : `${(freeTokens / 1000).toFixed(1)}k`; - - // Zone indicator — determines color for both context info and zone label - const zoneResult = getContextZone(usedTokens, totalSize, config.zoneConfig); - const zoneAnsi = zoneAnsiColor(zoneResult.colorName); - - // Context info uses zone color (traffic-light), with per-property override - const effectiveCtxColor = c.context_length || zoneAnsi; - - contextInfo = ` | ${effectiveCtxColor}${freeDisplay} (${freePct.toFixed(1)}%)${RESET}`; - - // Zone label uses same color, with per-property override - const effectiveZoneColor = c.zone || zoneAnsi; - zoneInfo = ` | ${effectiveZoneColor}${zoneResult.zone}${RESET}`; - - // Read previous entry if needed for delta OR MI - if (showDelta || showMI) { - const stateDir = path.join(os.homedir(), '.claude', 'statusline'); - if (!fs.existsSync(stateDir)) { - fs.mkdirSync(stateDir, { recursive: true }); - } - - const oldStateDir = path.join(os.homedir(), '.claude'); - try { - const oldFiles = fs - .readdirSync(oldStateDir) - .filter(f => f.match(/^statusline.*\.state$/)); - for (const fileName of oldFiles) { - const oldFile = path.join(oldStateDir, fileName); - const newFile = path.join(stateDir, fileName); - if (fs.statSync(oldFile).isFile()) { - if (!fs.existsSync(newFile)) { - fs.renameSync(oldFile, newFile); - } else { - fs.unlinkSync(oldFile); - } - } - } - } catch { - /* migration errors are non-fatal */ - } - - const stateFileName = sessionId ? `statusline.${sessionId}.state` : 'statusline.state'; - const stateFile = path.join(stateDir, stateFileName); - let hasPrev = false; - let prevTokens = 0; - try { - if (fs.existsSync(stateFile)) { - hasPrev = true; - // Read last line to get previous state - const content = fs.readFileSync(stateFile, 'utf8').trim(); - const lines = content.split('\n'); - const lastLine = lines[lines.length - 1]; - if (lastLine.includes(',')) { - const parts = lastLine.split(','); - // Calculate previous context usage: - // cur_input + cache_creation + cache_read - // CSV indices: cur_in[3], cache_create[5], cache_read[6] - const prevCurInput = parseInt(parts[3], 10) || 0; - const prevCacheCreation = parseInt(parts[5], 10) || 0; - const prevCacheRead = parseInt(parts[6], 10) || 0; - prevTokens = prevCurInput + prevCacheCreation + prevCacheRead; - // For MI productivity score - } else { - // Old format - single value - prevTokens = parseInt(lastLine, 10) || 0; - } - } - } catch (e) { - process.stderr.write( - `[statusline] warning: failed to read state file: ${e.message}\n` - ); - prevTokens = 0; - } - - // Calculate and display token delta if enabled - if (showDelta) { - const delta = usedTokens - prevTokens; - if (hasPrev && delta > 0) { - const deltaDisplay = tokenDetail - ? delta.toLocaleString('en-US') - : `${(delta / 1000).toFixed(1)}k`; - deltaInfo = ` | ${cSeparator}+${deltaDisplay}${RESET}`; - } - } - - // Calculate and display MI score if enabled - if (showMI) { - const miResult = computeMI(usedTokens, totalSize, modelId, miCurveBeta); - const miUtil = totalSize > 0 ? usedTokens / totalSize : 0; - const miColor = getMIColor(miResult.mi, miUtil, cGreen, cYellow, cRed); - // Use per-property mi_score color if configured, else MI-based color - const effectiveMIColor = c.mi_score || miColor; - miInfo = ` | ${effectiveMIColor}MI:${miResult.mi.toFixed(3)}${RESET}`; - } - - // Only append if context usage changed (avoid duplicates from multiple refreshes) - if (!hasPrev || usedTokens !== prevTokens) { - try { - const timestamp = Math.floor(Date.now() / 1000); - const curInputTokens = currentUsage.input_tokens || 0; - const curOutputTokens = currentUsage.output_tokens || 0; - const stateData = [ - timestamp, - totalInputTokens, - totalOutputTokens, - curInputTokens, - curOutputTokens, - cacheCreation, - cacheRead, - costUsd, - linesAdded, - linesRemoved, - sessionId || '', - modelId, - workspaceProjectDir.replace(/,/g, '_'), - totalSize, - ].join(','); - fs.appendFileSync(stateFile, `${stateData}\n`); - maybeRotateStateFile(stateFile); - } catch (e) { - process.stderr.write( - `[statusline] warning: failed to write state file: ${e.message}\n` - ); - } - } - } - } - - // Display session_id if enabled - if (showSession && sessionId) { - sessionInfo = ` | ${cSeparator}${sessionId}${RESET}`; - } - - // Output: dir | branch [n] | free (%) | zone | MI | +delta | [Model] session - // Model name is lowest priority — truncated first when terminal is narrow - const base = `${cProjectName}${dirName}${RESET}`; - const modelInfo = ` | ${cSeparator}${model}${RESET}`; - const maxWidth = getTerminalWidth(); - const parts = [base, gitInfo, contextInfo, zoneInfo, miInfo, deltaInfo, modelInfo, sessionInfo]; - console.log(fitToWidth(parts, maxWidth)); -}); - -// Export for testing -if (typeof module !== 'undefined' && module.exports) { - module.exports = { - maybeRotateStateFile, - ROTATION_THRESHOLD, - ROTATION_KEEP, - computeMI, - getContextZone, - }; -} diff --git a/src/claude_statusline/cli/cache_warm.py b/src/claude_statusline/cli/cache_warm.py index b892669..9a26e5b 100644 --- a/src/claude_statusline/cli/cache_warm.py +++ b/src/claude_statusline/cli/cache_warm.py @@ -20,7 +20,7 @@ # Default heartbeat settings DEFAULT_DURATION = 30 * 60 # 30 minutes -DEFAULT_INTERVAL = 4 * 60 # 4 minutes (under 5-min cache TTL) +DEFAULT_INTERVAL = 4 * 60 # 4 minutes (under 5-min cache TTL) # State file path template: ~/.claude/statusline/cache-warm..json _STATE_DIR = Path.home() / ".claude" / "statusline" @@ -184,9 +184,7 @@ def cmd_cache_warm_on(session_id: str, duration_str: str | None, colors: object) # Fork a background process for the heartbeat loop if not hasattr(os, "fork"): - sys.stderr.write( - "Error: cache-warm requires a Unix-like OS (fork not available).\n" - ) + sys.stderr.write("Error: cache-warm requires a Unix-like OS (fork not available).\n") sys.exit(1) # Set SIGCHLD to SIG_IGN before fork so the kernel auto-reaps the child (no zombie). @@ -311,7 +309,6 @@ def run_cache_warm(session_id: str, argv: list[str], colors: object) -> None: else: sys.stderr.write( - f"Error: Unknown cache-warm subcommand '{subcmd}'. " - "Use 'on [duration]' or 'off'.\n" + f"Error: Unknown cache-warm subcommand '{subcmd}'. Use 'on [duration]' or 'off'.\n" ) sys.exit(1) diff --git a/src/claude_statusline/cli/context_stats.py b/src/claude_statusline/cli/context_stats.py index e18eceb..d09fcc2 100755 --- a/src/claude_statusline/cli/context_stats.py +++ b/src/claude_statusline/cli/context_stats.py @@ -181,7 +181,9 @@ def _normalize_argv(argv: list[str]) -> tuple[str, str, list[str]]: action = positionals[1] if action not in _KNOWN_ACTIONS: - sys.stderr.write(f"Error: Unknown action '{action}'. Valid actions: {', '.join(sorted(_KNOWN_ACTIONS))}\n") + sys.stderr.write( + f"Error: Unknown action '{action}'. Valid actions: {', '.join(sorted(_KNOWN_ACTIONS))}\n" + ) sys.exit(1) # Build remaining args: remove session_id and action from argv @@ -207,8 +209,12 @@ def _build_graph_parser() -> argparse.ArgumentParser: help="Graph type (default: delta)", ) parser.add_argument( - "--watch", "-w", - nargs="?", const=2, type=int, default=2, + "--watch", + "-w", + nargs="?", + const=2, + type=int, + default=2, help="Refresh interval in seconds (default: 2)", ) parser.add_argument( @@ -222,7 +228,8 @@ def _build_graph_parser() -> argparse.ArgumentParser: help="Disable color output", ) parser.add_argument( - "--help", "-h", + "--help", + "-h", action="store_true", help="Show help for graph action", ) @@ -409,9 +416,7 @@ def emit(line: str = "") -> None: mi_scores = [] for entry in entries: ctx_window = entry.context_window_size - score = calculate_intelligence( - entry, ctx_window, entry.model_id, beta - ) + score = calculate_intelligence(entry, ctx_window, entry.model_id, beta) mi_scores.append(score) mi_score = mi_scores[-1] @@ -428,18 +433,23 @@ def emit(line: str = "") -> None: else: # Only compute MI for last entry (for summary display) last = entries[-1] - mi_score = calculate_intelligence( - last, last.context_window_size, last.model_id, beta - ) + mi_score = calculate_intelligence(last, last.context_window_size, last.model_id, beta) # Summary and footer from claude_statusline.cli.cache_warm import _warm_state_path, is_cache_warm_active session_id = state_file.session_id or "" # Only show cache-warm status when a state file exists for this session - cache_warm_status = is_cache_warm_active(session_id) if session_id and _warm_state_path(session_id).exists() else None + cache_warm_status = ( + is_cache_warm_active(session_id) + if session_id and _warm_state_path(session_id).exists() + else None + ) renderer.render_summary( - entries, deltas, mi_score=mi_score, graph_type=graph_type, + entries, + deltas, + mi_score=mi_score, + graph_type=graph_type, cache_warm_status=cache_warm_status, ) renderer.render_footer(__version__) diff --git a/src/claude_statusline/cli/export.py b/src/claude_statusline/cli/export.py index 86d2dc3..cb07743 100644 --- a/src/claude_statusline/cli/export.py +++ b/src/claude_statusline/cli/export.py @@ -10,8 +10,8 @@ from __future__ import annotations import argparse -from collections import Counter import sys +from collections import Counter from datetime import datetime from pathlib import Path @@ -45,7 +45,8 @@ def _parse_export_args(argv: list[str]) -> argparse.Namespace: help="Session ID (default: latest session)", ) parser.add_argument( - "--output", "-o", + "--output", + "-o", default=None, help="Output file path (default: context-stats-.md)", ) @@ -104,7 +105,9 @@ def _format_chart_timestamp(ts: int) -> str: return str(ts) -def _sample_entries_by_window(entries: list, window_minutes: int = 5, max_points: int = 12) -> list[tuple[str, object]]: +def _sample_entries_by_window( + entries: list, window_minutes: int = 5, max_points: int = 12 +) -> list[tuple[str, object]]: """Downsample entries so Mermaid charts stay readable on long sessions. Keep at most one point per time window, then trim again if the chart is @@ -114,7 +117,9 @@ def _sample_entries_by_window(entries: list, window_minutes: int = 5, max_points return [] window_seconds = max(1, window_minutes) * 60 - sampled: list[tuple[str, object]] = [(_format_chart_timestamp(entries[0].timestamp), entries[0])] + sampled: list[tuple[str, object]] = [ + (_format_chart_timestamp(entries[0].timestamp), entries[0]) + ] last_kept_ts = entries[0].timestamp for entry in entries[1:-1]: @@ -184,7 +189,9 @@ def _generate_mermaid_trend_chart(entries: list, context_window: int) -> list[st def _generate_mermaid_zone_chart(entries: list, context_window: int) -> list[str]: """Generate a Mermaid pie chart showing how often each zone appears.""" - zone_counts = Counter(get_context_zone(entry.current_used_tokens, context_window).label for entry in entries) + zone_counts = Counter( + get_context_zone(entry.current_used_tokens, context_window).label for entry in entries + ) lines = [ "### Zone Distribution", @@ -263,7 +270,15 @@ def _generate_mermaid_cache_chart(entries: list) -> list[str]: ] -def _generate_key_takeaways(entries: list, last_entry, ctx_window: int, final_used: int, final_pct: float, zone_label: str, duration: int) -> list[str]: +def _generate_key_takeaways( + entries: list, + last_entry, + ctx_window: int, + final_used: int, + final_pct: float, + zone_label: str, + duration: int, +) -> list[str]: """Generate a compact bullet list of the main insights from the session.""" prev_used = 0 max_delta = 0 @@ -276,7 +291,9 @@ def _generate_key_takeaways(entries: list, last_entry, ctx_window: int, final_us max_delta_idx = i prev_used = ctx_used - zones = Counter(get_context_zone(entry.current_used_tokens, ctx_window).label for entry in entries) + zones = Counter( + get_context_zone(entry.current_used_tokens, ctx_window).label for entry in entries + ) dominant_zone, dominant_zone_count = zones.most_common(1)[0] cache_total = last_entry.cache_creation + last_entry.cache_read cache_ratio = (cache_total / final_used * 100) if final_used > 0 else 0 @@ -295,14 +312,30 @@ def _generate_key_takeaways(entries: list, last_entry, ctx_window: int, final_us f"- **Cache load:** {format_tokens(cache_total)} tokens in cache activity ({cache_ratio:.1f}% of the final used context)." ) if last_entry.cache_creation >= last_entry.cache_read: - takeaways.append("- **Cache pattern:** more cache creation than cache reads, so the session leaned toward new cache material.") + takeaways.append( + "- **Cache pattern:** more cache creation than cache reads, so the session leaned toward new cache material." + ) else: - takeaways.append("- **Cache pattern:** cache reads outweighed creation, so the session reused prior work heavily.") + takeaways.append( + "- **Cache pattern:** cache reads outweighed creation, so the session reused prior work heavily." + ) return takeaways -def _generate_exec_snapshot(session_id: str, project_name: str, last_entry, ctx_window: int, final_used: int, final_pct: float, zone_label: str, duration: int, start_time: str, end_time: str, interactions: int) -> list[str]: +def _generate_exec_snapshot( + session_id: str, + project_name: str, + last_entry, + ctx_window: int, + final_used: int, + final_pct: float, + zone_label: str, + duration: int, + start_time: str, + end_time: str, + interactions: int, +) -> list[str]: """Generate a compact executive snapshot for the top of the report.""" cache_total = last_entry.cache_creation + last_entry.cache_read cache_pct = (cache_total / final_used * 100) if final_used > 0 else 0 @@ -324,7 +357,9 @@ def _generate_exec_snapshot(session_id: str, project_name: str, last_entry, ctx_ ] if cache_total > 0: - lines.append(f"| **Cache activity** | **{format_tokens(cache_total)}** ({cache_pct:.1f}%) | Explains how much of the final context is cache-related. |") + lines.append( + f"| **Cache activity** | **{format_tokens(cache_total)}** ({cache_pct:.1f}%) | Explains how much of the final context is cache-related. |" + ) lines.append("") return lines @@ -356,7 +391,7 @@ def _generate_markdown(entries: list, session_id: str, config: Config) -> str: else: project_name = "Unknown" - lines.append(f"# Context Stats Report") + lines.append("# Context Stats Report") lines.append("") ctx_window = last.context_window_size @@ -373,15 +408,27 @@ def _generate_markdown(entries: list, session_id: str, config: Config) -> str: lines.append("```") lines.append("") - exec_snapshot = _generate_exec_snapshot(session_id, project_name, last, ctx_window, final_used, final_pct, zone.label, duration, start_time, end_time, len(entries)) + exec_snapshot = _generate_exec_snapshot( + session_id, + project_name, + last, + ctx_window, + final_used, + final_pct, + zone.label, + duration, + start_time, + end_time, + len(entries), + ) lines.extend(exec_snapshot) # --- Summary --- lines.append("## Summary") lines.append("") - lines.append(f"| Metric | Value |") - lines.append(f"|--------|-------|") + lines.append("| Metric | Value |") + lines.append("|--------|-------|") lines.append(f"| Context window | {format_tokens(ctx_window)} tokens |") lines.append(f"| Final usage | {format_tokens(final_used)} ({final_pct:.1f}%) |") lines.append(f"| Total input tokens | {format_tokens(last.total_input_tokens)} |") @@ -405,7 +452,11 @@ def _generate_markdown(entries: list, session_id: str, config: Config) -> str: # --- Key Takeaways --- lines.append("## Key Takeaways") lines.append("") - lines.extend(_generate_key_takeaways(entries, last, ctx_window, final_used, final_pct, zone.label, duration)) + lines.extend( + _generate_key_takeaways( + entries, last, ctx_window, final_used, final_pct, zone.label, duration + ) + ) lines.append("") # --- Mermaid Visual Summary --- @@ -427,7 +478,9 @@ def _generate_markdown(entries: list, session_id: str, config: Config) -> str: for i, entry in enumerate(entries, 1): time_str = _format_time(entry.timestamp) ctx_used = entry.current_used_tokens - ctx_pct = (ctx_used / entry.context_window_size * 100) if entry.context_window_size > 0 else 0 + ctx_pct = ( + (ctx_used / entry.context_window_size * 100) if entry.context_window_size > 0 else 0 + ) mi_score = calculate_intelligence(entry, entry.context_window_size, entry.model_id, beta) zone_info = get_context_zone(ctx_used, entry.context_window_size) @@ -461,9 +514,13 @@ def _generate_markdown(entries: list, session_id: str, config: Config) -> str: lines.append(f"- **Starting context:** {format_tokens(entries[0].current_used_tokens)} tokens") lines.append(f"- **Final context:** {format_tokens(last.current_used_tokens)} tokens") - lines.append(f"- **Total growth:** {format_tokens(last.current_used_tokens - entries[0].current_used_tokens)} tokens") + lines.append( + f"- **Total growth:** {format_tokens(last.current_used_tokens - entries[0].current_used_tokens)} tokens" + ) if max_delta > 0 and max_delta_idx < len(entries): - lines.append(f"- **Largest single jump:** {format_tokens(max_delta)} tokens (interaction #{max_delta_idx + 1})") + lines.append( + f"- **Largest single jump:** {format_tokens(max_delta)} tokens (interaction #{max_delta_idx + 1})" + ) lines.append("") # --- Token Breakdown --- @@ -485,7 +542,9 @@ def _generate_markdown(entries: list, session_id: str, config: Config) -> str: # --- Footer --- lines.append("---") - lines.append(f"*Generated by [cc-context-stats](https://github.com/luongnv89/cc-context-stats) v{__version__}*") + lines.append( + f"*Generated by [cc-context-stats](https://github.com/luongnv89/cc-context-stats) v{__version__}*" + ) lines.append("") return "\n".join(lines) diff --git a/src/claude_statusline/cli/report.py b/src/claude_statusline/cli/report.py index 431a0c6..0fce8f8 100644 --- a/src/claude_statusline/cli/report.py +++ b/src/claude_statusline/cli/report.py @@ -34,7 +34,8 @@ def _parse_report_args(argv: list[str]) -> argparse.Namespace: description="Generate comprehensive token usage analytics", ) parser.add_argument( - "--output", "-o", + "--output", + "-o", default=None, help="Output file path (default: context-stats-report-.md)", ) @@ -96,7 +97,9 @@ def generate_report(projects_stats: list) -> str: lines.append("## Grand Totals") lines.append("") - lines.append(f"- **Total Tokens**: {format_tokens(total_input + total_output + total_cache_creation + total_cache_read)}") + lines.append( + f"- **Total Tokens**: {format_tokens(total_input + total_output + total_cache_creation + total_cache_read)}" + ) lines.append(f" - Input: {format_tokens(total_input)}") lines.append(f" - Output: {format_tokens(total_output)}") lines.append(f" - Cache Creation: {format_tokens(total_cache_creation)}") @@ -141,7 +144,9 @@ def generate_report(projects_stats: list) -> str: lines.append(f"- **{session.session_id[:8]}...** ({session.model_id})") lines.append(f" - Tokens: {tokens} | Cost: ${session.cost_usd:.2f}") lines.append(f" - Duration: {duration} | Started: {start}") - lines.append(f" - Details: {format_tokens(session.total_input_tokens)} input, {format_tokens(session.total_output_tokens)} output, {format_tokens(session.total_cache_creation)} cache_create, {format_tokens(session.total_cache_read)} cache_read") + lines.append( + f" - Details: {format_tokens(session.total_input_tokens)} input, {format_tokens(session.total_output_tokens)} output, {format_tokens(session.total_cache_creation)} cache_create, {format_tokens(session.total_cache_read)} cache_read" + ) lines.append("") diff --git a/src/claude_statusline/cli/statusline.py b/src/claude_statusline/cli/statusline.py index 9f35561..d4fbb15 100755 --- a/src/claude_statusline/cli/statusline.py +++ b/src/claude_statusline/cli/statusline.py @@ -58,7 +58,9 @@ def main() -> None: # Git info (use per-property branch color if set, else fallback to magenta) branch_color = colors.branch_name # Build a color manager with branch_name mapped to magenta slot for git_info - git_colors = ColorManager(enabled=True, overrides={**config.color_overrides, "magenta": branch_color}) + git_colors = ColorManager( + enabled=True, overrides={**config.color_overrides, "magenta": branch_color} + ) git_info = get_git_info(project_dir, color_manager=git_colors) # Extract session_id once for reuse @@ -181,9 +183,7 @@ def main() -> None: get_mi_color, ) - mi_score = calculate_intelligence( - entry, total_size, model_id, config.mi_curve_beta - ) + mi_score = calculate_intelligence(entry, total_size, model_id, config.mi_curve_beta) mi_color_name = get_mi_color(mi_score.mi, mi_score.utilization) mi_color = getattr(colors, mi_color_name) # Use per-property mi_score color if configured, else MI-based color diff --git a/src/claude_statusline/core/config.py b/src/claude_statusline/core/config.py index 19007da..6240ea3 100644 --- a/src/claude_statusline/core/config.py +++ b/src/claude_statusline/core/config.py @@ -196,8 +196,7 @@ def _read_config(self) -> None: ) except ValueError: sys.stderr.write( - f"[statusline] warning: invalid integer for {key}: " - f"'{raw_value}'\n" + f"[statusline] warning: invalid integer for {key}: '{raw_value}'\n" ) elif key in _ZONE_FLOAT_KEYS: try: @@ -211,8 +210,7 @@ def _read_config(self) -> None: ) except ValueError: sys.stderr.write( - f"[statusline] warning: invalid number for {key}: " - f"'{raw_value}'\n" + f"[statusline] warning: invalid number for {key}: '{raw_value}'\n" ) elif key in _COLOR_KEYS: slot = _COLOR_KEYS[key] diff --git a/src/claude_statusline/core/state.py b/src/claude_statusline/core/state.py index 74ac745..0b268d6 100644 --- a/src/claude_statusline/core/state.py +++ b/src/claude_statusline/core/state.py @@ -236,7 +236,9 @@ def read_history(self) -> list[StateEntry]: if entry: entries.append(entry) except OSError as e: - sys.stderr.write(f"[statusline] warning: failed to read state history {file_path}: {e}\n") + sys.stderr.write( + f"[statusline] warning: failed to read state history {file_path}: {e}\n" + ) return entries @@ -305,7 +307,9 @@ def _maybe_rotate(self) -> None: pass raise except OSError as e: - sys.stderr.write(f"[statusline] warning: failed to rotate state file {file_path}: {e}\n") + sys.stderr.write( + f"[statusline] warning: failed to rotate state file {file_path}: {e}\n" + ) def list_sessions(self) -> list[str]: """List all available session IDs. diff --git a/src/claude_statusline/graphs/intelligence.py b/src/claude_statusline/graphs/intelligence.py index 54cdb23..2e31aef 100644 --- a/src/claude_statusline/graphs/intelligence.py +++ b/src/claude_statusline/graphs/intelligence.py @@ -28,31 +28,31 @@ MI_YELLOW_THRESHOLD = 0.80 # Context utilization zones (used as fallback for color decisions) MI_CONTEXT_YELLOW_THRESHOLD = 0.40 # 40% context used -MI_CONTEXT_RED_THRESHOLD = 0.80 # 80% context used +MI_CONTEXT_RED_THRESHOLD = 0.80 # 80% context used # 1M model detection threshold (context windows >= 500k are treated as 1M-class) LARGE_MODEL_THRESHOLD = 500_000 # Zone thresholds for 1M models (token counts) -ZONE_1M_P_MAX = 70_000 # P zone: < 70k used -ZONE_1M_C_MAX = 100_000 # C zone: 70k–100k used -ZONE_1M_D_MAX = 250_000 # D zone: 100k–250k used -ZONE_1M_X_MAX = 275_000 # X zone: 250k–275k used; Z zone: >= 275k +ZONE_1M_P_MAX = 70_000 # P zone: < 70k used +ZONE_1M_C_MAX = 100_000 # C zone: 70k–100k used +ZONE_1M_D_MAX = 250_000 # D zone: 100k–250k used +ZONE_1M_X_MAX = 275_000 # X zone: 250k–275k used; Z zone: >= 275k # Zone thresholds for standard models (< 1M) — expressed as utilization ratios -ZONE_STD_DUMP_ZONE = 0.40 # dump zone starts at 40% +ZONE_STD_DUMP_ZONE = 0.40 # dump zone starts at 40% ZONE_STD_WARN_BUFFER = 30_000 # warn 30k tokens before dump zone -ZONE_STD_HARD_LIMIT = 0.70 # hard limit at 70% -ZONE_STD_DEAD_ZONE = 0.75 # dead zone starts at 75% +ZONE_STD_HARD_LIMIT = 0.70 # hard limit at 70% +ZONE_STD_DEAD_ZONE = 0.75 # dead zone starts at 75% # Per-model degradation profiles calibrated from MRCR v2 8-needle benchmark # beta controls curve shape: higher = quality retained longer # All models drop from 1.0 to 0.0, but at different rates MODEL_PROFILES: dict[str, float] = { - "opus": 1.8, # retains quality longest, steep drop near end + "opus": 1.8, # retains quality longest, steep drop near end "sonnet": 1.5, # moderate degradation - "haiku": 1.2, # degrades earliest - "default": 1.5, # same as sonnet + "haiku": 1.2, # degrades earliest + "default": 1.5, # same as sonnet } @@ -60,9 +60,9 @@ class ZoneInfo: """Context zone indicator with color.""" - zone: str # "Plan", "Code", "Dump", "ExDump", or "Dead" - color: str # "green", "yellow", "orange", "dark_red", or "gray" - label: str # Human-readable label + zone: str # "Plan", "Code", "Dump", "ExDump", or "Dead" + color: str # "green", "yellow", "orange", "dark_red", or "gray" + label: str # Human-readable label @dataclass diff --git a/src/claude_statusline/graphs/renderer.py b/src/claude_statusline/graphs/renderer.py index 5da1796..e5bb6f6 100644 --- a/src/claude_statusline/graphs/renderer.py +++ b/src/claude_statusline/graphs/renderer.py @@ -395,8 +395,7 @@ def render_summary( if ttl_remaining > 0: ttl_text = format_duration(ttl_remaining, precise=True) self._emit( - f" {self.colors.yellow}{'Cache TTL:':<20}" - f"{self.colors.reset} {ttl_text}" + f" {self.colors.yellow}{'Cache TTL:':<20}{self.colors.reset} {ttl_text}" ) else: self._emit( @@ -430,9 +429,7 @@ def render_summary( f"active ({warm_time} remaining)" ) else: - self._emit( - f" {self.colors.dim}{'Cache Warm:':<20}{self.colors.reset} inactive" - ) + self._emit(f" {self.colors.dim}{'Cache Warm:':<20}{self.colors.reset} inactive") self._emit() def render_footer(self, version: str = "1.6.1", commit_hash: str = "dev") -> None: diff --git a/src/claude_statusline/graphs/statistics.py b/src/claude_statusline/graphs/statistics.py index e19e749..1df794a 100644 --- a/src/claude_statusline/graphs/statistics.py +++ b/src/claude_statusline/graphs/statistics.py @@ -62,7 +62,7 @@ def detect_spike(deltas: list[int], context_window_size: int, window: int = 5) - return True # Check relative threshold: > 3x rolling average of previous deltas - previous = deltas[-(window + 1):-1] if len(deltas) > window else deltas[:-1] + previous = deltas[-(window + 1) : -1] if len(deltas) > window else deltas[:-1] if previous: avg = sum(previous) / len(previous) if avg > 0 and latest > avg * 3: diff --git a/tests/bash/test_check_install.bats b/tests/bash/test_check_install.bats index 3c5faff..2e83397 100644 --- a/tests/bash/test_check_install.bats +++ b/tests/bash/test_check_install.bats @@ -19,7 +19,7 @@ setup() { @test "check-install.sh contains statusline check section" { grep -q "Statusline Command" "$SCRIPT" grep -q "claude-statusline" "$SCRIPT" - grep -q "statusline.sh" "$SCRIPT" + grep -q "statusline.py" "$SCRIPT" } @test "check-install.sh contains context-stats check section" { @@ -33,9 +33,8 @@ setup() { grep -q "statusLine" "$SCRIPT" } -@test "check-install.sh detects all three install methods" { - grep -q 'methods+=("bash")' "$SCRIPT" - grep -q 'methods+=("npm")' "$SCRIPT" +@test "check-install.sh detects install methods" { + grep -q 'methods+=("shell")' "$SCRIPT" grep -q 'methods+=("pip")' "$SCRIPT" } @@ -46,7 +45,6 @@ setup() { @test "check-install.sh provides fix guidance on failure" { grep -q "curl -fsSL" "$SCRIPT" - grep -q "npm list -g" "$SCRIPT" grep -q "pip show" "$SCRIPT" } diff --git a/tests/bash/test_delta_parity.bats b/tests/bash/test_delta_parity.bats deleted file mode 100644 index 8553c2c..0000000 --- a/tests/bash/test_delta_parity.bats +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env bats - -# Delta calculation parity tests: Python vs Node.js statusline scripts -# Verifies both implementations compute identical deltas from identical state. - -strip_ansi() { - printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g' -} - -setup() { - PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - PYTHON_SCRIPT="$PROJECT_ROOT/scripts/statusline.py" - NODE_SCRIPT="$PROJECT_ROOT/scripts/statusline.js" - - # Create isolated temp HOME so state files don't pollute real ~/.claude/ - TEST_HOME=$(mktemp -d) - export HOME="$TEST_HOME" - - # Normalize terminal width for deterministic output - export COLUMNS=200 - - # Enable delta display - mkdir -p "$TEST_HOME/.claude" - echo "show_delta=true" > "$TEST_HOME/.claude/statusline.conf" - - # Create a non-git temp working directory so both scripts skip git info - TEST_WORKDIR=$(mktemp -d) - cd "$TEST_WORKDIR" -} - -teardown() { - rm -rf "$TEST_HOME" - rm -rf "$TEST_WORKDIR" -} - -# Helper: create a JSON payload with specific token values and session_id -make_payload() { - local session="$1" input_tokens="$2" cache_creation="$3" cache_read="$4" - cat < /dev/null 2>&1 - echo "$payload1_node" | node "$NODE_SCRIPT" > /dev/null 2>&1 - - # Second payload: 80k context usage (40k input + 20k cache_create + 20k cache_read) - # Expected delta = 80k - 30k = 50k - local payload2_py=$(make_payload "$py_session" 40000 20000 20000) - local payload2_node=$(make_payload "$node_session" 40000 20000 20000) - - local py_output=$(echo "$payload2_py" | python3 "$PYTHON_SCRIPT" 2>/dev/null) - local node_output=$(echo "$payload2_node" | node "$NODE_SCRIPT" 2>/dev/null) - - local py_clean=$(strip_ansi "$py_output") - local node_clean=$(strip_ansi "$node_output") - - # Both should contain +50,000 delta (pipe-separated, no brackets) - if [[ "$py_clean" != *"+50,000"* ]]; then - echo "Python output missing expected delta +50,000" - echo "Python output: $py_clean" - return 1 - fi - if [[ "$node_clean" != *"+50,000"* ]]; then - echo "Node.js output missing expected delta +50,000" - echo "Node.js output: $node_clean" - return 1 - fi - - # Compare outputs ignoring session_id suffix (which intentionally differs) - # Strip the trailing session ID from both outputs for comparison - local py_no_session=$(echo "$py_clean" | sed 's/ delta-parity-py$//') - local node_no_session=$(echo "$node_clean" | sed 's/ delta-parity-node$//') - - if [ "$py_no_session" != "$node_no_session" ]; then - echo "DELTA PARITY MISMATCH (ignoring session_id)" - echo "Python: $py_no_session" - echo "Node.js: $node_no_session" - return 1 - fi -} - -@test "delta parity: no delta shown on first run (no previous state)" { - local py_session="delta-first-py" - local node_session="delta-first-node" - - local payload_py=$(make_payload "$py_session" 50000 10000 5000) - local payload_node=$(make_payload "$node_session" 50000 10000 5000) - - local py_output=$(echo "$payload_py" | python3 "$PYTHON_SCRIPT" 2>/dev/null) - local node_output=$(echo "$payload_node" | node "$NODE_SCRIPT" 2>/dev/null) - - local py_clean=$(strip_ansi "$py_output") - local node_clean=$(strip_ansi "$node_output") - - # Neither should show a delta on first run - if [[ "$py_clean" == *"[+"* ]]; then - echo "Python should not show delta on first run" - echo "Python output: $py_clean" - return 1 - fi - if [[ "$node_clean" == *"[+"* ]]; then - echo "Node.js should not show delta on first run" - echo "Node.js output: $node_clean" - return 1 - fi -} - -@test "delta parity: no delta shown when tokens decrease (context reset)" { - local py_session="delta-decrease-py" - local node_session="delta-decrease-node" - - # First payload: high usage - local payload1_py=$(make_payload "$py_session" 80000 20000 10000) - local payload1_node=$(make_payload "$node_session" 80000 20000 10000) - - echo "$payload1_py" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1 - echo "$payload1_node" | node "$NODE_SCRIPT" > /dev/null 2>&1 - - # Second payload: lower usage (context was reset/compacted) - local payload2_py=$(make_payload "$py_session" 20000 5000 5000) - local payload2_node=$(make_payload "$node_session" 20000 5000 5000) - - local py_output=$(echo "$payload2_py" | python3 "$PYTHON_SCRIPT" 2>/dev/null) - local node_output=$(echo "$payload2_node" | node "$NODE_SCRIPT" 2>/dev/null) - - local py_clean=$(strip_ansi "$py_output") - local node_clean=$(strip_ansi "$node_output") - - # Neither should show delta when tokens decrease - if [[ "$py_clean" == *"[+"* ]]; then - echo "Python should not show delta when tokens decrease" - echo "Python output: $py_clean" - return 1 - fi - if [[ "$node_clean" == *"[+"* ]]; then - echo "Node.js should not show delta when tokens decrease" - echo "Node.js output: $node_clean" - return 1 - fi -} - -@test "delta parity: duplicate guard prevents writing when tokens unchanged" { - local py_session="delta-dedup-py" - local node_session="delta-dedup-node" - - local payload_py=$(make_payload "$py_session" 50000 10000 5000) - local payload_node=$(make_payload "$node_session" 50000 10000 5000) - - # Run same payload three times - echo "$payload_py" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1 - echo "$payload_py" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1 - echo "$payload_py" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1 - - echo "$payload_node" | node "$NODE_SCRIPT" > /dev/null 2>&1 - echo "$payload_node" | node "$NODE_SCRIPT" > /dev/null 2>&1 - echo "$payload_node" | node "$NODE_SCRIPT" > /dev/null 2>&1 - - local py_state="$TEST_HOME/.claude/statusline/statusline.${py_session}.state" - local node_state="$TEST_HOME/.claude/statusline/statusline.${node_session}.state" - - # Both should have written only 1 line (duplicate guard) - local py_lines=$(wc -l < "$py_state" | tr -d ' ') - local node_lines=$(wc -l < "$node_state" | tr -d ' ') - - if [ "$py_lines" -ne 1 ]; then - echo "Python wrote $py_lines lines (expected 1 — duplicate guard failed)" - return 1 - fi - if [ "$node_lines" -ne 1 ]; then - echo "Node.js wrote $node_lines lines (expected 1 — duplicate guard failed)" - return 1 - fi -} diff --git a/tests/bash/test_e2e_install.bats b/tests/bash/test_e2e_install.bats index c211d9c..e636a9e 100644 --- a/tests/bash/test_e2e_install.bats +++ b/tests/bash/test_e2e_install.bats @@ -29,12 +29,6 @@ setup() { grep -q "set -euo pipefail" "$SCRIPT" } -@test "e2e-install-test.sh contains Node.js test section" { - grep -q "run_nodejs_e2e" "$SCRIPT" - grep -q "npm install" "$SCRIPT" - grep -q "statusline.js" "$SCRIPT" -} - @test "e2e-install-test.sh contains Python test section" { grep -q "run_python_e2e" "$SCRIPT" grep -q "virtualenv\|venv" "$SCRIPT" @@ -42,13 +36,6 @@ setup() { grep -q "context-stats" "$SCRIPT" } -@test "e2e-install-test.sh contains Bash test section" { - grep -q "run_bash_e2e" "$SCRIPT" - grep -q "statusline-full.sh" "$SCRIPT" - grep -q "statusline-minimal.sh" "$SCRIPT" - grep -q "statusline-git.sh" "$SCRIPT" -} - @test "e2e-install-test.sh reports pass/fail per test" { grep -q "pass()" "$SCRIPT" grep -q "fail()" "$SCRIPT" @@ -63,22 +50,10 @@ setup() { grep -q "FAILED_ITEMS" "$SCRIPT" } -@test "e2e-install-test.sh accepts --nodejs flag" { - grep -q '\-\-nodejs' "$SCRIPT" -} - @test "e2e-install-test.sh accepts --python flag" { grep -q '\-\-python' "$SCRIPT" } -@test "e2e-install-test.sh accepts --bash flag" { - grep -q '\-\-bash' "$SCRIPT" -} - -@test "e2e-install-test.sh tests bash scripts in clean environment" { - grep -q "env -i" "$SCRIPT" -} - @test "e2e-install-test.sh --help exits 0" { run bash "$SCRIPT" --help [ "$status" -eq 0 ] diff --git a/tests/bash/test_mi_monotonicity.bats b/tests/bash/test_mi_monotonicity.bats deleted file mode 100644 index 51a790d..0000000 --- a/tests/bash/test_mi_monotonicity.bats +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env bats - -# Tests that MI always reflects context length: more free context = better MI. -# Verifies the bash (awk) implementation of compute_mi maintains monotonicity. -# MI = max(0, 1 - alpha * u^beta) with per-model profiles. - -setup() { - PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - # Source the compute_mi and get_mi_color functions from statusline-full.sh - eval "$(sed -n '/^compute_mi()/,/^}/p' "$PROJECT_ROOT/scripts/statusline-full.sh")" - eval "$(sed -n '/^get_mi_color()/,/^}/p' "$PROJECT_ROOT/scripts/statusline-full.sh")" -} - -# Helper: extract MI value from compute_mi output (single field now) -get_mi() { - echo "$1" -} - -# Helper: compare two floats, return 0 if a <= b -float_le() { - awk -v a="$1" -v b="$2" 'BEGIN { exit !(a <= b + 0.001) }' -} - -# Helper: compare two floats, return 0 if a < b -float_lt() { - awk -v a="$1" -v b="$2" 'BEGIN { exit !(a < b) }' -} - -# --- MI monotonicity per model --- - -@test "MI decreases with utilization (opus profile)" { - local cw=200000 - local prev_mi="" - - for pct in 0 5 10 20 30 40 50 60 70 80 90 95 100; do - local used=$((pct * cw / 100)) - local mi - mi=$(compute_mi "$used" "$cw" "claude-opus-4-6" "") - - if [ -n "$prev_mi" ]; then - float_le "$mi" "$prev_mi" || { - echo "MI increased at ${pct}% (opus): $mi > $prev_mi" - return 1 - } - fi - prev_mi="$mi" - done -} - -@test "MI decreases with utilization (sonnet profile)" { - local cw=200000 - local prev_mi="" - - for pct in 0 10 20 30 40 50 60 70 80 90 100; do - local used=$((pct * cw / 100)) - local mi - mi=$(compute_mi "$used" "$cw" "claude-sonnet-4-6" "") - - if [ -n "$prev_mi" ]; then - float_le "$mi" "$prev_mi" || { - echo "MI increased at ${pct}% (sonnet): $mi > $prev_mi" - return 1 - } - fi - prev_mi="$mi" - done -} - -@test "MI decreases with utilization (haiku profile)" { - local cw=200000 - local prev_mi="" - - for pct in 0 10 20 30 40 50 60 70 80 90 100; do - local used=$((pct * cw / 100)) - local mi - mi=$(compute_mi "$used" "$cw" "claude-haiku-4-5" "") - - if [ -n "$prev_mi" ]; then - float_le "$mi" "$prev_mi" || { - echo "MI increased at ${pct}% (haiku): $mi > $prev_mi" - return 1 - } - fi - prev_mi="$mi" - done -} - -@test "MI decreases with utilization (unknown/default profile)" { - local cw=200000 - local prev_mi="" - - for pct in 0 10 20 30 40 50 60 70 80 90 100; do - local used=$((pct * cw / 100)) - local mi - mi=$(compute_mi "$used" "$cw" "unknown-model" "") - - if [ -n "$prev_mi" ]; then - float_le "$mi" "$prev_mi" || { - echo "MI increased at ${pct}% (default): $mi > $prev_mi" - return 1 - } - fi - prev_mi="$mi" - done -} - -# --- Beta override monotonicity --- - -@test "MI monotonic with beta_override=1.0" { - local cw=200000 - local prev_mi="" - - for pct in 0 10 20 30 40 50 60 70 80 90 100; do - local used=$((pct * cw / 100)) - local mi - mi=$(compute_mi "$used" "$cw" "claude-opus-4-6" 1.0) - - if [ -n "$prev_mi" ]; then - float_le "$mi" "$prev_mi" || { - echo "MI not monotonic at ${pct}% with beta=1.0: $mi > $prev_mi" - return 1 - } - fi - prev_mi="$mi" - done -} - -@test "MI monotonic with beta_override=2.0" { - local cw=200000 - local prev_mi="" - - for pct in 0 10 20 30 40 50 60 70 80 90 100; do - local used=$((pct * cw / 100)) - local mi - mi=$(compute_mi "$used" "$cw" "claude-opus-4-6" 2.0) - - if [ -n "$prev_mi" ]; then - float_le "$mi" "$prev_mi" || { - echo "MI not monotonic at ${pct}% with beta=2.0: $mi > $prev_mi" - return 1 - } - fi - prev_mi="$mi" - done -} - -@test "MI monotonic with beta_override=3.0" { - local cw=200000 - local prev_mi="" - - for pct in 0 10 20 30 40 50 60 70 80 90 100; do - local used=$((pct * cw / 100)) - local mi - mi=$(compute_mi "$used" "$cw" "claude-opus-4-6" 3.0) - - if [ -n "$prev_mi" ]; then - float_le "$mi" "$prev_mi" || { - echo "MI not monotonic at ${pct}% with beta=3.0: $mi > $prev_mi" - return 1 - } - fi - prev_mi="$mi" - done -} - -# --- MI reflects context zones --- - -@test "smart zone MI > dumb zone MI > wrap up zone MI" { - local cw=200000 - - local smart_mi dumb_mi wrap_mi - smart_mi=$(compute_mi $((cw * 20 / 100)) "$cw" "claude-sonnet-4-6" "") - dumb_mi=$(compute_mi $((cw * 60 / 100)) "$cw" "claude-sonnet-4-6" "") - wrap_mi=$(compute_mi $((cw * 90 / 100)) "$cw" "claude-sonnet-4-6" "") - - float_lt "$dumb_mi" "$smart_mi" || { - echo "Smart zone MI ($smart_mi) should be > dumb zone MI ($dumb_mi)" - return 1 - } - float_lt "$wrap_mi" "$dumb_mi" || { - echo "Dumb zone MI ($dumb_mi) should be > wrap up zone MI ($wrap_mi)" - return 1 - } -} - -@test "empty context has MI=1.000" { - local mi - mi=$(compute_mi 0 200000 "claude-opus-4-6" "") - [ "$mi" = "1.000" ] -} - -@test "all models reach MI=0.000 at full context" { - for model in "claude-opus-4-6" "claude-sonnet-4-6" "claude-haiku-4-5"; do - local mi - mi=$(compute_mi 200000 200000 "$model" "") - [ "$mi" = "0.000" ] || { - echo "$model at full context should be 0.000 but got $mi" - return 1 - } - done -} - -@test "opus degrades less than sonnet at 70% utilization" { - local cw=200000 - local used=$((cw * 70 / 100)) - local opus_mi sonnet_mi - opus_mi=$(compute_mi "$used" "$cw" "claude-opus-4-6" "") - sonnet_mi=$(compute_mi "$used" "$cw" "claude-sonnet-4-6" "") - float_lt "$sonnet_mi" "$opus_mi" || { - echo "Opus MI ($opus_mi) should be > sonnet MI ($sonnet_mi) at 70%" - return 1 - } -} - -# --- Guard clause --- - -@test "context_window=0 returns MI=1.000" { - local mi - mi=$(compute_mi 50000 0 "claude-opus-4-6" "") - [ "$mi" = "1.000" ] -} - -# --- Color thresholds (MI + utilization) --- - -@test "MI color: green for MI >= 0.90 and low utilization" { - local color - color=$(get_mi_color "0.95" "0.10") - [ "$color" = "green" ] -} - -@test "MI color: yellow for MI < 0.90" { - local color - color=$(get_mi_color "0.85" "0.10") - [ "$color" = "yellow" ] -} - -@test "MI color: yellow when context 40-80%" { - local color - color=$(get_mi_color "0.95" "0.50") - [ "$color" = "yellow" ] -} - -@test "MI color: red for MI <= 0.80" { - local color - color=$(get_mi_color "0.75" "0.10") - [ "$color" = "red" ] -} - -@test "MI color: red when context >= 80%" { - local color - color=$(get_mi_color "0.95" "0.85") - [ "$color" = "red" ] -} diff --git a/tests/bash/test_parity.bats b/tests/bash/test_parity.bats deleted file mode 100644 index d65057c..0000000 --- a/tests/bash/test_parity.bats +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/bin/env bats - -# Cross-implementation parity tests: Python vs Node.js statusline scripts -# Ensures both implementations produce equivalent output for identical input. - -strip_ansi() { - printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g' -} - -setup() { - PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - PYTHON_SCRIPT="$PROJECT_ROOT/scripts/statusline.py" - NODE_SCRIPT="$PROJECT_ROOT/scripts/statusline.js" - FIXTURES="$PROJECT_ROOT/tests/fixtures/json" - - # Create isolated temp HOME so state files don't pollute real ~/.claude/ - TEST_HOME=$(mktemp -d) - export HOME="$TEST_HOME" - - # Normalize terminal width for deterministic output - export COLUMNS=120 - - # Disable delta display to avoid cross-fixture state file interference - mkdir -p "$TEST_HOME/.claude" - echo "show_delta=false" > "$TEST_HOME/.claude/statusline.conf" - - # Create a non-git temp working directory so both scripts skip git info - TEST_WORKDIR=$(mktemp -d) - cd "$TEST_WORKDIR" -} - -teardown() { - rm -rf "$TEST_HOME" - rm -rf "$TEST_WORKDIR" -} - -# Helper: inject a session_id into a JSON fixture via Python -inject_session_py() { - local fixture="$1" session="$2" - python3 -c " -import json, sys -data = json.load(open('$fixture')) -data['session_id'] = '$session' -json.dump(data, sys.stdout) -" -} - -# Helper: inject a session_id into a JSON fixture via Node -inject_session_node() { - local fixture="$1" session="$2" - node -e " -const fs = require('fs'); -const data = JSON.parse(fs.readFileSync('$fixture', 'utf8')); -data.session_id = '$session'; -process.stdout.write(JSON.stringify(data)); -" -} - -# ============================================ -# Stdout Parity Tests -# ============================================ - -@test "stdout parity: Python and Node.js produce identical ANSI-stripped output for all fixtures" { - for fixture in "$FIXTURES"/*.json; do - fixture_name=$(basename "$fixture") - - py_output=$(cat "$fixture" | python3 "$PYTHON_SCRIPT" 2>/dev/null) - node_output=$(cat "$fixture" | node "$NODE_SCRIPT" 2>/dev/null) - - py_clean=$(strip_ansi "$py_output") - node_clean=$(strip_ansi "$node_output") - - if [ "$py_clean" != "$node_clean" ]; then - echo "STDOUT MISMATCH for fixture: $fixture_name" - echo "---" - echo "Python output: $py_clean" - echo "Node.js output: $node_clean" - echo "---" - return 1 - fi - done -} - -# ============================================ -# CSV State File Parity Tests -# ============================================ - -@test "CSV parity: both scripts write exactly 14 fields for all fixtures" { - for fixture in "$FIXTURES"/*.json; do - fixture_name=$(basename "$fixture" .json) - - py_session="parity-py-${fixture_name}" - node_session="parity-node-${fixture_name}" - - py_input=$(inject_session_py "$fixture" "$py_session") - node_input=$(inject_session_node "$fixture" "$node_session") - - echo "$py_input" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1 - echo "$node_input" | node "$NODE_SCRIPT" > /dev/null 2>&1 - - py_state_file="$TEST_HOME/.claude/statusline/statusline.${py_session}.state" - node_state_file="$TEST_HOME/.claude/statusline/statusline.${node_session}.state" - - # Skip fixtures that don't produce state files (e.g., no context_window data) - if [ ! -f "$py_state_file" ] && [ ! -f "$node_state_file" ]; then - continue - fi - - # If only one script wrote a state file, that's a parity failure - if [ ! -f "$py_state_file" ]; then - echo "PARITY ERROR for fixture: $fixture_name" - echo "Node.js wrote a state file but Python did not" - return 1 - fi - if [ ! -f "$node_state_file" ]; then - echo "PARITY ERROR for fixture: $fixture_name" - echo "Python wrote a state file but Node.js did not" - return 1 - fi - - # Read last line of each state file - py_line=$(tail -1 "$py_state_file") - node_line=$(tail -1 "$node_state_file") - - # Count fields (comma-separated) - py_field_count=$(echo "$py_line" | awk -F',' '{print NF}') - node_field_count=$(echo "$node_line" | awk -F',' '{print NF}') - - if [ "$py_field_count" -ne 14 ]; then - echo "FIELD COUNT ERROR for fixture: $fixture_name" - echo "Python state has $py_field_count fields (expected 14)" - echo "Python line: $py_line" - return 1 - fi - if [ "$node_field_count" -ne 14 ]; then - echo "FIELD COUNT ERROR for fixture: $fixture_name" - echo "Node.js state has $node_field_count fields (expected 14)" - echo "Node.js line: $node_line" - return 1 - fi - done -} - -@test "CSV parity: fields 1-13 match between Python and Node.js for all fixtures" { - # Field names for diagnostic output (index 0 = timestamp, 1-13 = data fields) - local field_names=( - "timestamp" - "total_input_tokens" - "total_output_tokens" - "current_usage_input_tokens" - "current_usage_output_tokens" - "current_usage_cache_creation" - "current_usage_cache_read" - "total_cost_usd" - "total_lines_added" - "total_lines_removed" - "session_id" - "model_id" - "workspace_project_dir" - "context_window_size" - ) - - for fixture in "$FIXTURES"/*.json; do - fixture_name=$(basename "$fixture" .json) - - py_session="parity-fields-py-${fixture_name}" - node_session="parity-fields-node-${fixture_name}" - - py_input=$(inject_session_py "$fixture" "$py_session") - node_input=$(inject_session_node "$fixture" "$node_session") - - echo "$py_input" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1 - echo "$node_input" | node "$NODE_SCRIPT" > /dev/null 2>&1 - - py_state_file="$TEST_HOME/.claude/statusline/statusline.${py_session}.state" - node_state_file="$TEST_HOME/.claude/statusline/statusline.${node_session}.state" - - # Skip fixtures that don't produce state files - if [ ! -f "$py_state_file" ] && [ ! -f "$node_state_file" ]; then - continue - fi - - [ -f "$py_state_file" ] || { echo "Python wrote no state file but Node did for $fixture_name"; return 1; } - [ -f "$node_state_file" ] || { echo "Node wrote no state file but Python did for $fixture_name"; return 1; } - - py_line=$(tail -1 "$py_state_file") - node_line=$(tail -1 "$node_state_file") - - # Compare fields 1-13 (skip timestamp at index 0, and skip session_id at index 10 since we set different ones) - local has_mismatch=0 - for i in $(seq 1 13); do - # Skip field 10 (session_id) since we intentionally set different session IDs - if [ "$i" -eq 10 ]; then - continue - fi - - py_field=$(echo "$py_line" | cut -d',' -f$((i + 1))) - node_field=$(echo "$node_line" | cut -d',' -f$((i + 1))) - - if [ "$py_field" != "$node_field" ]; then - echo "FIELD MISMATCH for fixture: $fixture_name" - echo " Field $i (${field_names[$i]}): Python='$py_field' Node='$node_field'" - has_mismatch=1 - fi - done - - if [ "$has_mismatch" -eq 1 ]; then - echo "Full Python line: $py_line" - echo "Full Node.js line: $node_line" - return 1 - fi - done -} - -@test "CSV parity: comma in workspace_project_dir is sanitized to underscore" { - fixture="$FIXTURES/comma_in_path.json" - [ -f "$fixture" ] || skip "comma_in_path.json fixture missing" - - # Enable show_delta so state files are written - echo "show_delta=true" > "$TEST_HOME/.claude/statusline.conf" - - py_session="parity-comma-py" - node_session="parity-comma-node" - - py_input=$(inject_session_py "$fixture" "$py_session") - node_input=$(inject_session_node "$fixture" "$node_session") - - echo "$py_input" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1 - echo "$node_input" | node "$NODE_SCRIPT" > /dev/null 2>&1 - - # Restore show_delta=false for subsequent tests - echo "show_delta=false" > "$TEST_HOME/.claude/statusline.conf" - - py_state_file="$TEST_HOME/.claude/statusline/statusline.${py_session}.state" - node_state_file="$TEST_HOME/.claude/statusline/statusline.${node_session}.state" - - [ -f "$py_state_file" ] || { echo "Python wrote no state file"; return 1; } - [ -f "$node_state_file" ] || { echo "Node wrote no state file"; return 1; } - - # Extract workspace_project_dir (field 13, 1-indexed in cut) - py_dir=$(tail -1 "$py_state_file" | cut -d',' -f13) - node_dir=$(tail -1 "$node_state_file" | cut -d',' -f13) - - # Both must have commas replaced with underscores - expected="/home/user/my_project_dir" - - if [ "$py_dir" != "$expected" ]; then - echo "Python did not sanitize commas: got '$py_dir', expected '$expected'" - return 1 - fi - if [ "$node_dir" != "$expected" ]; then - echo "Node.js did not sanitize commas: got '$node_dir', expected '$expected'" - return 1 - fi - - # Both must produce exactly 14 fields (commas didn't corrupt the CSV) - py_fields=$(tail -1 "$py_state_file" | awk -F',' '{print NF}') - node_fields=$(tail -1 "$node_state_file" | awk -F',' '{print NF}') - - if [ "$py_fields" -ne 14 ]; then - echo "Python state has $py_fields fields (expected 14)" - return 1 - fi - if [ "$node_fields" -ne 14 ]; then - echo "Node.js state has $node_fields fields (expected 14)" - return 1 - fi -} - -@test "CSV parity: timestamp differs by at most 2 seconds between Python and Node.js" { - for fixture in "$FIXTURES"/*.json; do - fixture_name=$(basename "$fixture" .json) - - py_session="parity-ts-py-${fixture_name}" - node_session="parity-ts-node-${fixture_name}" - - py_input=$(inject_session_py "$fixture" "$py_session") - node_input=$(inject_session_node "$fixture" "$node_session") - - echo "$py_input" | python3 "$PYTHON_SCRIPT" > /dev/null 2>&1 - echo "$node_input" | node "$NODE_SCRIPT" > /dev/null 2>&1 - - py_state_file="$TEST_HOME/.claude/statusline/statusline.${py_session}.state" - node_state_file="$TEST_HOME/.claude/statusline/statusline.${node_session}.state" - - # Skip fixtures that don't produce state files - if [ ! -f "$py_state_file" ] && [ ! -f "$node_state_file" ]; then - continue - fi - - [ -f "$py_state_file" ] || { echo "Python wrote no state file but Node did for $fixture_name"; return 1; } - [ -f "$node_state_file" ] || { echo "Node wrote no state file but Python did for $fixture_name"; return 1; } - - py_line=$(tail -1 "$py_state_file") - node_line=$(tail -1 "$node_state_file") - - # Extract timestamp (field 0, which is field 1 in cut 1-indexed) - py_ts=$(echo "$py_line" | cut -d',' -f1) - node_ts=$(echo "$node_line" | cut -d',' -f1) - - # Calculate absolute difference - if [ -n "$py_ts" ] && [ -n "$node_ts" ]; then - diff=$((py_ts - node_ts)) - abs_diff=${diff#-} - - if [ "$abs_diff" -gt 2 ]; then - echo "TIMESTAMP DRIFT for fixture: $fixture_name" - echo " Python timestamp: $py_ts" - echo " Node.js timestamp: $node_ts" - echo " Difference: ${abs_diff}s (max allowed: 2s)" - return 1 - fi - fi - done -} diff --git a/tests/bash/test_statusline_full.bats b/tests/bash/test_statusline_full.bats deleted file mode 100755 index f569c30..0000000 --- a/tests/bash/test_statusline_full.bats +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env bats - -# Test suite for statusline-full.sh - -setup() { - PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - SCRIPT="$PROJECT_ROOT/scripts/statusline-full.sh" - FIXTURES="$PROJECT_ROOT/tests/fixtures/json" - - # Create a temp directory for config tests - TEST_HOME=$(mktemp -d) - export HOME="$TEST_HOME" - mkdir -p "$TEST_HOME/.claude" -} - -teardown() { - rm -rf "$TEST_HOME" -} - -@test "statusline-full.sh exists and is executable" { - [ -f "$SCRIPT" ] - [ -x "$SCRIPT" ] -} - -@test "outputs model name from JSON input" { - input='{"model":{"display_name":"Opus 4.5"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"}}' - result=$(echo "$input" | "$SCRIPT") - [[ "$result" == *"Opus 4.5"* ]] -} - -@test "outputs directory name from path" { - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/home/user/myproject","project_dir":"/home/user/myproject"}}' - result=$(echo "$input" | "$SCRIPT") - [[ "$result" == *"myproject"* ]] -} - -@test "handles full valid input with context window" { - result=$(cat "$FIXTURES/valid_full.json" | "$SCRIPT") - [[ "$result" == *"Opus 4.5"* ]] - [[ "$result" == *"my-project"* ]] - [[ "$result" == *"%"* ]] -} - -@test "AC indicator removed from statusline" { - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}' - result=$(echo "$input" | "$SCRIPT") - [[ "$result" != *"[AC:"* ]] -} - -@test "shows exact tokens by default (token_detail=true)" { - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}' - result=$(echo "$input" | "$SCRIPT") - # Should NOT show 'k' suffix by default, should show comma-formatted number - [[ "$result" != *"k ("* ]] - [[ "$result" == *"%"* ]] -} - -@test "shows abbreviated tokens when token_detail=false" { - echo "token_detail=false" > "$TEST_HOME/.claude/statusline.conf" - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}' - result=$(echo "$input" | "$SCRIPT") - # Should show 'k' suffix for abbreviated format - [[ "$result" == *"k ("* ]] -} - -@test "handles missing context window gracefully" { - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"}}' - run bash "$SCRIPT" <<< "$input" - [ "$status" -eq 0 ] -} - -@test "calculates free tokens percentage correctly" { - # Low usage fixture: 30k tokens used out of 200k = 85% free - result=$(cat "$FIXTURES/low_usage.json" | "$SCRIPT") - [[ "$result" == *"%"* ]] -} - -@test "uses fixture files correctly" { - for fixture in valid_full valid_minimal low_usage medium_usage high_usage; do - run bash "$SCRIPT" < "$FIXTURES/${fixture}.json" - [ "$status" -eq 0 ] - done -} - -@test "shows session_id by default (show_session=true)" { - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"session_id":"test-session-123"}' - result=$(echo "$input" | "$SCRIPT") - [[ "$result" == *"test-session-123"* ]] -} - -@test "hides session_id when show_session=false" { - echo "show_session=false" > "$TEST_HOME/.claude/statusline.conf" - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"session_id":"test-session-123"}' - result=$(echo "$input" | "$SCRIPT") - [[ "$result" != *"test-session-123"* ]] -} - -@test "handles missing session_id gracefully" { - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"}}' - run bash "$SCRIPT" <<< "$input" - [ "$status" -eq 0 ] -} - -# Width truncation tests - -strip_ansi() { - printf '%s' "$1" | sed -e $'s/\033\[[0-9;]*m//g' -e 's/\\033\[[0-9;]*m//g' -} - -@test "output fits within 80 columns" { - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp/proj","project_dir":"/tmp/proj"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":500,"cache_read_input_tokens":200}},"session_id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}' - result=$(COLUMNS=80 bash "$SCRIPT" <<< "$input") - visible=$(strip_ansi "$result") - len=$(printf '%s' "$visible" | wc -m | tr -d ' ') - [ "$len" -le 80 ] -} - -@test "narrow terminal prioritizes directory and context over model" { - input='{"model":{"display_name":"Claude 3.5 Sonnet"},"workspace":{"current_dir":"/tmp/myproject","project_dir":"/tmp/myproject"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":500,"cache_read_input_tokens":200}}}' - result=$(COLUMNS=40 bash "$SCRIPT" <<< "$input") - visible=$(strip_ansi "$result") - len=$(printf '%s' "$visible" | wc -m | tr -d ' ') - [ "$len" -le 40 ] - [[ "$visible" == *"myproject"* ]] - # Model name is lowest priority — truncated first in narrow terminals - [[ "$visible" != *"Claude 3.5 Sonnet"* ]] -} - -@test "wide terminal shows session_id" { - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp/proj","project_dir":"/tmp/proj"},"session_id":"test-wide-session-uuid"}' - result=$(COLUMNS=200 bash "$SCRIPT" <<< "$input") - [[ "$result" == *"test-wide-session-uuid"* ]] -} diff --git a/tests/bash/test_statusline_git.bats b/tests/bash/test_statusline_git.bats deleted file mode 100755 index ae0283f..0000000 --- a/tests/bash/test_statusline_git.bats +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bats - -# Test suite for statusline-git.sh - -setup() { - PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - SCRIPT="$PROJECT_ROOT/scripts/statusline-git.sh" - FIXTURES="$PROJECT_ROOT/tests/fixtures/json" -} - -@test "statusline-git.sh exists and is executable" { - [ -f "$SCRIPT" ] - [ -x "$SCRIPT" ] -} - -@test "outputs model name from JSON input" { - input='{"model":{"display_name":"Opus 4.5"},"workspace":{"current_dir":"/tmp/test","project_dir":"/tmp/test"}}' - result=$(echo "$input" | "$SCRIPT") - [[ "$result" == *"Opus 4.5"* ]] -} - -@test "outputs directory name" { - input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/home/user/myproject","project_dir":"/tmp"}}' - result=$(echo "$input" | "$SCRIPT") - [[ "$result" == *"myproject"* ]] -} - -@test "handles valid input without crashing" { - run bash "$SCRIPT" < "$FIXTURES/valid_full.json" - [ "$status" -eq 0 ] -} - -@test "shows git branch when in git repo" { - # Use project dir which is a git repo - input=$(cat < { - test('guard clause: context_window=0 returns MI=1.0', () => { - const result = computeMI(50000, 0, 'claude-opus-4-6'); - expect(result.mi).toBe(1.0); - }); - - test('empty context returns MI=1.0', () => { - const result = computeMI(0, 200000, 'claude-sonnet-4-6'); - expect(result.mi).toBe(1.0); - }); - - test('full context is always MI=0.0 regardless of model', () => { - for (const model of ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5']) { - const result = computeMI(200000, 200000, model); - expect(result.mi).toBe(0); - } - }); - - test('unknown model uses default (sonnet) profile', () => { - const result = computeMI(100000, 200000, 'unknown-model'); - const sonnet = computeMI(100000, 200000, 'claude-sonnet-4-6'); - expect(result.mi).toBeCloseTo(sonnet.mi, 2); - }); - - test('beta override takes precedence', () => { - // Opus with beta_override=1.0: MI = 1 - 0.5^1.0 = 0.5 - const result = computeMI(100000, 200000, 'claude-opus-4-6', 1.0); - expect(result.mi).toBeCloseTo(0.5, 2); - }); - - test('MI is always between 0 and 1', () => { - const utilizations = [0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0]; - for (const u of utilizations) { - const used = Math.floor(u * 200000); - const result = computeMI(used, 200000, 'claude-sonnet-4-6'); - expect(result.mi).toBeGreaterThanOrEqual(0); - expect(result.mi).toBeLessThanOrEqual(1); - } - }); - - test('opus degrades less than sonnet at same utilization', () => { - const opus = computeMI(140000, 200000, 'claude-opus-4-6'); - const sonnet = computeMI(140000, 200000, 'claude-sonnet-4-6'); - expect(opus.mi).toBeGreaterThan(sonnet.mi); - }); -}); - -// --- Context zone tests --- - -describe('getContextZone', () => { - // 1M model tests - test('1M model, 50k used → P (green)', () => { - const z = getContextZone(50000, 1000000); - expect(z.zone).toBe('Plan'); - expect(z.colorName).toBe('green'); - }); - - test('1M model, 85k used → C (yellow)', () => { - const z = getContextZone(85000, 1000000); - expect(z.zone).toBe('Code'); - expect(z.colorName).toBe('yellow'); - }); - - test('1M model, 150k used → D (orange)', () => { - const z = getContextZone(150000, 1000000); - expect(z.zone).toBe('Dump'); - expect(z.colorName).toBe('orange'); - }); - - test('1M model, 250k used → X (dark_red)', () => { - const z = getContextZone(250000, 1000000); - expect(z.zone).toBe('ExDump'); - expect(z.colorName).toBe('dark_red'); - }); - - test('1M model, 300k used → Z (gray)', () => { - const z = getContextZone(300000, 1000000); - expect(z.zone).toBe('Dead'); - expect(z.colorName).toBe('gray'); - }); - - // Boundary tests - test('boundary: 70k → C (not P)', () => { - expect(getContextZone(70000, 1000000).zone).toBe('Code'); - expect(getContextZone(69999, 1000000).zone).toBe('Plan'); - }); - - test('boundary: 100k → D (not C)', () => { - expect(getContextZone(100000, 1000000).zone).toBe('Dump'); - expect(getContextZone(99999, 1000000).zone).toBe('Code'); - }); - - test('boundary: 275k → Z (past X), X is 250k–275k range', () => { - expect(getContextZone(275000, 1000000).zone).toBe('Dead'); - expect(getContextZone(274999, 1000000).zone).toBe('ExDump'); - // 250001 is now within X range (not Z) - expect(getContextZone(250001, 1000000).zone).toBe('ExDump'); - }); - - // Standard model tests - test('200k model, 20k used → P', () => { - expect(getContextZone(20000, 200000).zone).toBe('Plan'); - }); - - test('200k model, 60k used → C', () => { - expect(getContextZone(60000, 200000).zone).toBe('Code'); - }); - - test('200k model, 100k (50%) → D', () => { - expect(getContextZone(100000, 200000).zone).toBe('Dump'); - }); - - test('200k model, 140k (70%) → X', () => { - expect(getContextZone(140000, 200000).zone).toBe('ExDump'); - }); - - test('200k model, 150k (75%) → Z', () => { - expect(getContextZone(150000, 200000).zone).toBe('Dead'); - }); - - // Guard clause - test('context_window=0 → P', () => { - expect(getContextZone(50000, 0).zone).toBe('Plan'); - }); - - // Large model threshold - test('500k context is treated as 1M-class', () => { - expect(getContextZone(50000, 500000).zone).toBe('Plan'); - }); -}); - -// --- Configurable zone threshold tests --- - -describe('getContextZone with config overrides', () => { - // 1M model overrides - test('custom zone_1m_plan_max shifts P→C boundary', () => { - const zone = getContextZone(80000, 1000000, { zone_1m_plan_max: 90000 }); - expect(zone.zone).toBe('Plan'); - // Default would be Code - expect(getContextZone(80000, 1000000).zone).toBe('Code'); - }); - - test('custom zone_1m_code_max shifts C→D boundary', () => { - const zone = getContextZone(95000, 1000000, { zone_1m_code_max: 80000 }); - expect(zone.zone).toBe('Dump'); - expect(getContextZone(95000, 1000000).zone).toBe('Code'); - }); - - test('custom zone_1m_dump_max shifts D→X boundary', () => { - const zone = getContextZone(200000, 1000000, { zone_1m_dump_max: 180000 }); - expect(zone.zone).toBe('ExDump'); - expect(getContextZone(200000, 1000000).zone).toBe('Dump'); - }); - - test('custom zone_1m_xdump_max shifts X→Z boundary', () => { - const zone = getContextZone(260000, 1000000, { zone_1m_xdump_max: 255000 }); - expect(zone.zone).toBe('Dead'); - expect(getContextZone(260000, 1000000).zone).toBe('ExDump'); - }); - - // Standard model overrides - test('custom zone_std_dump_ratio shifts dump zone start', () => { - const zone = getContextZone(70000, 200000, { zone_std_dump_ratio: 0.30 }); - expect(zone.zone).toBe('Dump'); - expect(getContextZone(70000, 200000).zone).toBe('Code'); - }); - - test('custom zone_std_hard_limit shifts hard limit', () => { - const zone = getContextZone(110000, 200000, { zone_std_hard_limit: 0.50 }); - expect(zone.zone).toBe('ExDump'); - expect(getContextZone(110000, 200000).zone).toBe('Dump'); - }); - - test('custom zone_std_dead_ratio shifts dead zone start', () => { - // Default: dead at 75% (150k). With 0.72 → dead at 144k. - const zone = getContextZone(145000, 200000, { zone_std_dead_ratio: 0.72 }); - expect(zone.zone).toBe('Dead'); - // Default: 145k between hard_limit (140k) and dead (150k) → ExDump - expect(getContextZone(145000, 200000).zone).toBe('ExDump'); - }); - - // Large model threshold override - test('custom large_model_threshold changes model classification', () => { - const zone = getContextZone(80000, 400000, { large_model_threshold: 300000 }); - expect(zone.zone).toBe('Code'); // 1M thresholds: 70k-100k - }); - - // Zero override = use default - test('zero override uses default', () => { - const zone = getContextZone(80000, 1000000, { zone_1m_plan_max: 0 }); - expect(zone.zone).toBe('Code'); // Same as default - }); -}); - -describe('shared test vectors', () => { - vectors.forEach((vec) => { - test(vec.description, () => { - const inp = vec.input; - const exp = vec.expected; - - const betaOverride = inp.beta_override || 0; - - const result = computeMI( - inp.current_used, - inp.context_window, - inp.model_id, - betaOverride - ); - - expect(result.mi).toBeCloseTo(exp.mi, 1); - }); - }); -}); diff --git a/tests/node/mi_monotonicity.test.js b/tests/node/mi_monotonicity.test.js deleted file mode 100644 index 1fdf328..0000000 --- a/tests/node/mi_monotonicity.test.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Tests that MI always reflects context length: more free context = better MI. - * - * Verifies monotonicity property across model profiles, beta values, - * and zone alignment. - */ - -const path = require('path'); -const fs = require('fs'); -const { computeMI } = require('../../scripts/statusline'); - -const VECTORS_PATH = path.join(__dirname, '..', 'fixtures', 'mi_monotonicity_vectors.json'); -const vectors = JSON.parse(fs.readFileSync(VECTORS_PATH, 'utf8')); - -// --- MI monotonicity --- - -describe('MI monotonicity', () => { - test('MI decreases with utilization (default/sonnet profile)', () => { - const steps = vectors.utilization_steps; - const cw = vectors.context_window; - - let prevMI = null; - for (const step of steps) { - const result = computeMI(step.used, cw, 'claude-sonnet-4-6'); - - if (prevMI !== null) { - expect(result.mi).toBeLessThanOrEqual(prevMI + 1e-9); - } - prevMI = result.mi; - } - }); - - test.each(['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5', 'unknown-model'])( - 'MI decreases for model %s', - (modelId) => { - const steps = vectors.utilization_steps; - const cw = vectors.context_window; - - let prevMI = null; - for (const step of steps) { - const result = computeMI(step.used, cw, modelId); - - if (prevMI !== null) { - expect(result.mi).toBeLessThanOrEqual(prevMI + 1e-9); - } - prevMI = result.mi; - } - } - ); - - test.each([1.0, 1.5, 2.0, 3.0])('MI decreases for beta_override=%s', (beta) => { - const cw = 200000; - let prevMI = null; - - for (let pct = 0; pct <= 100; pct += 5) { - const used = Math.floor(pct / 100 * cw); - const result = computeMI(used, cw, 'claude-opus-4-6', beta); - - if (prevMI !== null) { - expect(result.mi).toBeLessThanOrEqual(prevMI + 1e-9); - } - prevMI = result.mi; - } - }); -}); - -// --- Fine-grained resolution --- - -describe('MI fine-grained monotonicity', () => { - test.each(['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'])( - 'MI monotonic at 1%% resolution for %s', - (modelId) => { - const cw = 200000; - let prevMI = null; - - for (let pct = 0; pct <= 100; pct++) { - const used = Math.floor(pct / 100 * cw); - const result = computeMI(used, cw, modelId); - - if (prevMI !== null) { - expect(result.mi).toBeLessThanOrEqual(prevMI + 1e-9); - } - prevMI = result.mi; - } - } - ); -}); - -// --- MI reflects context zones --- - -describe('MI reflects context zones', () => { - test('smart zone MI > dumb zone MI > wrap up zone MI', () => { - const cw = 200000; - const smart = computeMI(Math.floor(0.20 * cw), cw, 'claude-sonnet-4-6'); - const dumb = computeMI(Math.floor(0.60 * cw), cw, 'claude-sonnet-4-6'); - const wrap = computeMI(Math.floor(0.90 * cw), cw, 'claude-sonnet-4-6'); - - expect(smart.mi).toBeGreaterThan(dumb.mi); - expect(dumb.mi).toBeGreaterThan(wrap.mi); - }); - - test('empty context has MI=1.0', () => { - const cw = 200000; - const result = computeMI(0, cw, 'claude-opus-4-6'); - expect(result.mi).toBe(1.0); - }); - - test('opus retains higher MI than sonnet at 50% context', () => { - const cw = 200000; - const opus = computeMI(cw / 2, cw, 'claude-opus-4-6'); - const sonnet = computeMI(cw / 2, cw, 'claude-sonnet-4-6'); - expect(opus.mi).toBeGreaterThan(sonnet.mi); - }); - - test('all models reach MI=0 at full context', () => { - const cw = 200000; - for (const model of ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5']) { - const result = computeMI(cw, cw, model); - expect(result.mi).toBe(0); - } - }); - - test('MI spread is 1.0 for all models (0 to full)', () => { - const cw = 200000; - for (const model of ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5']) { - const empty = computeMI(0, cw, model); - const full = computeMI(cw, cw, model); - expect(empty.mi - full.mi).toBe(1.0); - } - }); -}); diff --git a/tests/node/rotation.test.js b/tests/node/rotation.test.js deleted file mode 100644 index fdc5813..0000000 --- a/tests/node/rotation.test.js +++ /dev/null @@ -1,89 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -// Import rotation function from statusline.js -// The script reads stdin on require, so we mock stdin to prevent hanging -const originalStdin = process.stdin; - -// Prevent the script's stdin listener from blocking -jest.spyOn(process.stdin, 'setEncoding').mockImplementation(() => {}); -jest.spyOn(process.stdin, 'on').mockImplementation(() => {}); - -const { maybeRotateStateFile, ROTATION_THRESHOLD, ROTATION_KEEP } = require('../../scripts/statusline.js'); - -function makeCsvLine(index) { - return `${1710288000 + index},100,200,300,400,500,600,0.01,10,5,sess-${index},model,/tmp/proj,200000`; -} - -describe('maybeRotateStateFile', () => { - let tmpDir; - let stateFile; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rotation-test-')); - stateFile = path.join(tmpDir, 'test.state'); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test('file below threshold is not rotated', () => { - const lines = Array.from({ length: 9999 }, (_, i) => makeCsvLine(i)); - fs.writeFileSync(stateFile, lines.join('\n') + '\n'); - - maybeRotateStateFile(stateFile); - - const result = fs.readFileSync(stateFile, 'utf8').trim().split('\n'); - expect(result.length).toBe(9999); - }); - - test('file at exactly threshold is not rotated', () => { - const lines = Array.from({ length: ROTATION_THRESHOLD }, (_, i) => makeCsvLine(i)); - fs.writeFileSync(stateFile, lines.join('\n') + '\n'); - - maybeRotateStateFile(stateFile); - - const result = fs.readFileSync(stateFile, 'utf8').trim().split('\n'); - expect(result.length).toBe(ROTATION_THRESHOLD); - }); - - test('file exceeding threshold is truncated to ROTATION_KEEP lines', () => { - const lines = Array.from({ length: 10001 }, (_, i) => makeCsvLine(i)); - fs.writeFileSync(stateFile, lines.join('\n') + '\n'); - - maybeRotateStateFile(stateFile); - - const result = fs.readFileSync(stateFile, 'utf8').trim().split('\n'); - expect(result.length).toBe(ROTATION_KEEP); - }); - - test('retained lines are the most recent', () => { - const total = 10001; - const lines = Array.from({ length: total }, (_, i) => makeCsvLine(i)); - fs.writeFileSync(stateFile, lines.join('\n') + '\n'); - - maybeRotateStateFile(stateFile); - - const result = fs.readFileSync(stateFile, 'utf8').trim().split('\n'); - // First retained line should be index (total - ROTATION_KEEP) - expect(result[0]).toContain(`sess-${total - ROTATION_KEEP}`); - // Last retained line should be the last original line - expect(result[result.length - 1]).toContain(`sess-${total - 1}`); - }); - - test('non-existent file does not throw', () => { - expect(() => maybeRotateStateFile('/tmp/nonexistent-rotation-test.state')).not.toThrow(); - }); - - test('no temp files remain after rotation', () => { - const lines = Array.from({ length: 10001 }, (_, i) => makeCsvLine(i)); - fs.writeFileSync(stateFile, lines.join('\n') + '\n'); - - maybeRotateStateFile(stateFile); - - const tmpFiles = fs.readdirSync(tmpDir).filter(f => f.endsWith('.tmp')); - expect(tmpFiles.length).toBe(0); - }); -}); diff --git a/tests/node/statusline.test.js b/tests/node/statusline.test.js deleted file mode 100644 index 43c3dba..0000000 --- a/tests/node/statusline.test.js +++ /dev/null @@ -1,241 +0,0 @@ -const { spawn } = require('child_process'); -const path = require('path'); -const fs = require('fs'); - -const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'statusline.js'); -const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures', 'json'); - -/** - * Strip ANSI escape sequences from a string - */ -function stripAnsi(s) { - return s.replace(/\x1b\[[0-9;]*m/g, ''); -} - -/** - * Run the statusline.js script with the given input data - * @param {Object|string} inputData - JSON input or string - * @param {Object} [envOverrides] - Optional environment variable overrides - * @returns {Promise<{stdout: string, stderr: string, code: number}>} - */ -function runScript(inputData, envOverrides) { - return new Promise((resolve, reject) => { - const env = { ...process.env, ...envOverrides }; - const child = spawn('node', [SCRIPT_PATH], { env }); - let stdout = ''; - let stderr = ''; - - child.stdout.on('data', data => { - stdout += data.toString(); - }); - - child.stderr.on('data', data => { - stderr += data.toString(); - }); - - child.on('close', code => { - resolve({ stdout: stdout.trim(), stderr, code }); - }); - - child.on('error', reject); - - const input = typeof inputData === 'string' ? inputData : JSON.stringify(inputData); - child.stdin.write(input); - child.stdin.end(); - }); -} - -/** - * Load a JSON fixture file - * @param {string} name - Fixture name without .json extension - * @returns {Object} - */ -function loadFixture(name) { - const filePath = path.join(FIXTURES_DIR, `${name}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -describe('statusline.js', () => { - const sampleInput = { - model: { display_name: 'Claude 3.5 Sonnet' }, - workspace: { - current_dir: '/home/user/myproject', - project_dir: '/home/user/myproject', - }, - context_window: { - context_window_size: 200000, - current_usage: { - input_tokens: 10000, - cache_creation_input_tokens: 500, - cache_read_input_tokens: 200, - }, - }, - }; - - describe('Script basics', () => { - test('script file exists', () => { - expect(fs.existsSync(SCRIPT_PATH)).toBe(true); - }); - - test('script has node shebang', () => { - const content = fs.readFileSync(SCRIPT_PATH, 'utf8'); - expect(content.startsWith('#!/usr/bin/env node')).toBe(true); - }); - }); - - describe('Output content', () => { - test('outputs model name', async () => { - const result = await runScript(sampleInput); - expect(result.stdout).toContain('Claude 3.5 Sonnet'); - expect(result.code).toBe(0); - }); - - test('outputs directory name', async () => { - const result = await runScript(sampleInput); - expect(result.stdout).toContain('myproject'); - }); - - test('shows free tokens indicator', async () => { - const result = await runScript(sampleInput); - expect(result.stdout).toContain('%'); - }); - - test('AC indicator removed from statusline', async () => { - const result = await runScript(sampleInput); - expect(result.stdout).not.toContain('[AC:'); - }); - - test('shows percentage', async () => { - const result = await runScript(sampleInput); - expect(result.stdout).toMatch(/\d+\.\d+%/); - }); - }); - - describe('Error handling', () => { - test('handles missing model gracefully', async () => { - const input = { - workspace: { current_dir: '/tmp/test', project_dir: '/tmp/test' }, - }; - const result = await runScript(input); - expect(result.stdout).toContain('Claude'); // Default fallback - expect(result.code).toBe(0); - }); - - test('handles missing context window gracefully', async () => { - const input = { - model: { display_name: 'Claude' }, - workspace: { current_dir: '/tmp/test', project_dir: '/tmp/test' }, - }; - const result = await runScript(input); - expect(result.code).toBe(0); - }); - - test('handles invalid JSON gracefully', async () => { - const result = await runScript('invalid json'); - expect(result.code).toBe(0); - expect(result.stdout).toContain('Claude'); - }); - - test('handles empty input gracefully', async () => { - const result = await runScript(''); - expect(result.code).toBe(0); - }); - }); - - describe('Fixtures', () => { - test('handles valid_full fixture', async () => { - const input = loadFixture('valid_full'); - const result = await runScript(input); - expect(result.code).toBe(0); - expect(result.stdout).toContain('Opus 4.5'); - expect(result.stdout).toContain('my-project'); - }); - - test('handles valid_minimal fixture', async () => { - const input = loadFixture('valid_minimal'); - const result = await runScript(input); - expect(result.code).toBe(0); - expect(result.stdout).toContain('Claude'); - }); - - test('handles low_usage fixture', async () => { - const input = loadFixture('low_usage'); - const result = await runScript(input); - expect(result.code).toBe(0); - expect(result.stdout).toContain('%'); - }); - - test('handles medium_usage fixture', async () => { - const input = loadFixture('medium_usage'); - const result = await runScript(input); - expect(result.code).toBe(0); - expect(result.stdout).toContain('%'); - }); - - test('handles high_usage fixture', async () => { - const input = loadFixture('high_usage'); - const result = await runScript(input); - expect(result.code).toBe(0); - expect(result.stdout).toContain('%'); - }); - - test('all JSON fixtures succeed', async () => { - const fixtures = fs.readdirSync(FIXTURES_DIR).filter(f => f.endsWith('.json')); - for (const fixture of fixtures) { - const input = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, fixture), 'utf8')); - const result = await runScript(input); - expect(result.code).toBe(0); - } - }); - }); - - describe('Session ID display', () => { - test('shows session_id by default', async () => { - const inputWithSession = { - ...sampleInput, - session_id: 'test-session-abc123', - }; - const result = await runScript(inputWithSession, { COLUMNS: '200' }); - expect(result.code).toBe(0); - expect(result.stdout).toContain('test-session-abc123'); - }); - - test('handles missing session_id gracefully', async () => { - const result = await runScript(sampleInput); - expect(result.code).toBe(0); - }); - }); - - describe('Width truncation', () => { - test('output fits 80 columns', async () => { - const inputWithSession = { - ...sampleInput, - session_id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', - }; - const result = await runScript(inputWithSession, { COLUMNS: '80' }); - expect(result.code).toBe(0); - const visible = stripAnsi(result.stdout); - expect(visible.length).toBeLessThanOrEqual(80); - }); - - test('narrow terminal drops parts', async () => { - const result = await runScript(sampleInput, { COLUMNS: '40' }); - expect(result.code).toBe(0); - const visible = stripAnsi(result.stdout); - expect(visible.length).toBeLessThanOrEqual(40); - expect(visible).toContain('myproject'); - // Model name is lowest priority — truncated first in narrow terminals - expect(visible).not.toContain('Claude 3.5 Sonnet'); - }); - - test('wide terminal shows all', async () => { - const inputWithSession = { - ...sampleInput, - session_id: 'test-wide-session-uuid', - }; - const result = await runScript(inputWithSession, { COLUMNS: '200' }); - expect(result.code).toBe(0); - expect(result.stdout).toContain('test-wide-session-uuid'); - }); - }); -}); diff --git a/tests/python/conftest.py b/tests/python/conftest.py index 023a711..18db406 100644 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -44,6 +44,7 @@ def _clear_coverage_tracer(): sys.stderr.write("[conftest] Forcing exit code 0 on Windows\n") session.exitstatus = 0 + # Get the project root directory PROJECT_ROOT = Path(__file__).parent.parent.parent FIXTURES_DIR = PROJECT_ROOT / "tests" / "fixtures" / "json" diff --git a/tests/python/test_config_colors.py b/tests/python/test_config_colors.py index a477b35..beff138 100644 --- a/tests/python/test_config_colors.py +++ b/tests/python/test_config_colors.py @@ -115,9 +115,7 @@ def test_default_config_matches_example(self, tmp_path): content = config_file.read_text(encoding="utf-8") # Template must have autocompact=false as default (matching examples/statusline.conf) - assert "autocompact=false" in content, ( - "Default config should set autocompact=false" - ) + assert "autocompact=false" in content, "Default config should set autocompact=false" def test_default_config_contains_expected_keys(self, tmp_path): """Verify the generated config has all documented settings.""" @@ -125,8 +123,14 @@ def test_default_config_contains_expected_keys(self, tmp_path): Config.load(config_path=config_file) content = config_file.read_text(encoding="utf-8") - for key in ("autocompact=", "token_detail=", "show_delta=", - "show_session=", "show_mi=", "mi_curve_beta="): + for key in ( + "autocompact=", + "token_detail=", + "show_delta=", + "show_session=", + "show_mi=", + "mi_curve_beta=", + ): assert key in content, f"Default config should contain '{key}'" def test_existing_config_not_overwritten(self, tmp_path): @@ -148,9 +152,7 @@ def test_package_data_matches_example_file(self): """data/statusline.conf.default must match examples/statusline.conf exactly.""" repo_root = Path(__file__).resolve().parents[2] example_file = repo_root / "examples" / "statusline.conf" - assert example_file.exists(), ( - f"examples/statusline.conf not found at {example_file}" - ) + assert example_file.exists(), f"examples/statusline.conf not found at {example_file}" package_data_file = ( repo_root / "src" / "claude_statusline" / "data" / "statusline.conf.default" ) diff --git a/tests/python/test_data_pipeline.py b/tests/python/test_data_pipeline.py index 09bd0e0..a787710 100644 --- a/tests/python/test_data_pipeline.py +++ b/tests/python/test_data_pipeline.py @@ -47,9 +47,7 @@ def _render_summary_output(entries, deltas=None, graph_type=None): ), ) renderer.begin_buffering() - renderer.render_summary( - entries, deltas if deltas is not None else [], graph_type=graph_type - ) + renderer.render_summary(entries, deltas if deltas is not None else [], graph_type=graph_type) return renderer.get_buffer() @@ -205,15 +203,11 @@ def test_total_tokens(self): assert entry.total_tokens == 83500 def test_current_used_tokens(self): - entry = _make_entry( - current_input_tokens=50000, cache_creation=10000, cache_read=20000 - ) + entry = _make_entry(current_input_tokens=50000, cache_creation=10000, cache_read=20000) assert entry.current_used_tokens == 80000 def test_current_used_tokens_all_zero(self): - entry = _make_entry( - current_input_tokens=0, cache_creation=0, cache_read=0 - ) + entry = _make_entry(current_input_tokens=0, cache_creation=0, cache_read=0) assert entry.current_used_tokens == 0 @@ -359,10 +353,14 @@ class TestZoneThresholds: """ def test_zero_usage_smart_zone(self): - entries = [_make_entry( - current_input_tokens=0, cache_creation=0, cache_read=0, - context_window_size=200000, - )] + entries = [ + _make_entry( + current_input_tokens=0, + cache_creation=0, + cache_read=0, + context_window_size=200000, + ) + ] output = _render_summary_output(entries) assert "SMART ZONE" in output assert "DUMB ZONE" not in output @@ -370,20 +368,28 @@ def test_zero_usage_smart_zone(self): def test_usage_39_pct_smart_zone(self): # current_used=78000, remaining=122000, remaining%=61, usage%=39 - entries = [_make_entry( - current_input_tokens=78000, cache_creation=0, cache_read=0, - context_window_size=200000, - )] + entries = [ + _make_entry( + current_input_tokens=78000, + cache_creation=0, + cache_read=0, + context_window_size=200000, + ) + ] output = _render_summary_output(entries) assert "SMART ZONE" in output assert "DUMB ZONE" not in output def test_usage_40_pct_dumb_zone(self): # current_used=78001, remaining=121999, remaining%=60, usage%=40 - entries = [_make_entry( - current_input_tokens=78001, cache_creation=0, cache_read=0, - context_window_size=200000, - )] + entries = [ + _make_entry( + current_input_tokens=78001, + cache_creation=0, + cache_read=0, + context_window_size=200000, + ) + ] output = _render_summary_output(entries) assert "DUMB ZONE" in output assert "SMART ZONE" not in output @@ -391,48 +397,68 @@ def test_usage_40_pct_dumb_zone(self): def test_usage_79_pct_dumb_zone(self): # current_used=158000, remaining=42000, remaining%=21, usage%=79 - entries = [_make_entry( - current_input_tokens=158000, cache_creation=0, cache_read=0, - context_window_size=200000, - )] + entries = [ + _make_entry( + current_input_tokens=158000, + cache_creation=0, + cache_read=0, + context_window_size=200000, + ) + ] output = _render_summary_output(entries) assert "DUMB ZONE" in output assert "WRAP UP ZONE" not in output def test_usage_80_pct_wrap_up_zone(self): # current_used=158001, remaining=41999, remaining%=20, usage%=80 - entries = [_make_entry( - current_input_tokens=158001, cache_creation=0, cache_read=0, - context_window_size=200000, - )] + entries = [ + _make_entry( + current_input_tokens=158001, + cache_creation=0, + cache_read=0, + context_window_size=200000, + ) + ] output = _render_summary_output(entries) assert "WRAP UP ZONE" in output assert "DUMB ZONE" not in output def test_usage_100_pct_wrap_up_zone(self): - entries = [_make_entry( - current_input_tokens=200000, cache_creation=0, cache_read=0, - context_window_size=200000, - )] + entries = [ + _make_entry( + current_input_tokens=200000, + cache_creation=0, + cache_read=0, + context_window_size=200000, + ) + ] output = _render_summary_output(entries) assert "WRAP UP ZONE" in output def test_usage_exceeds_context_window(self): """Remaining clamped to 0 when usage exceeds window.""" - entries = [_make_entry( - current_input_tokens=250000, cache_creation=0, cache_read=0, - context_window_size=200000, - )] + entries = [ + _make_entry( + current_input_tokens=250000, + cache_creation=0, + cache_read=0, + context_window_size=200000, + ) + ] output = _render_summary_output(entries) assert "WRAP UP ZONE" in output def test_cache_tokens_contribute_to_usage(self): """cache_creation + cache_read push usage past 40% boundary.""" # current_used = 40000 + 20000 + 18001 = 78001 → usage=40% → Dumb Zone - entries = [_make_entry( - current_input_tokens=40000, cache_creation=20000, cache_read=18001, - context_window_size=200000, - )] + entries = [ + _make_entry( + current_input_tokens=40000, + cache_creation=20000, + cache_read=18001, + context_window_size=200000, + ) + ] output = _render_summary_output(entries) assert "DUMB ZONE" in output assert "SMART ZONE" not in output diff --git a/tests/python/test_export.py b/tests/python/test_export.py index 3f5e083..20dde0f 100644 --- a/tests/python/test_export.py +++ b/tests/python/test_export.py @@ -4,8 +4,6 @@ import sys from pathlib import Path -import pytest - from claude_statusline.cli.export import ( _format_datetime, _format_duration, @@ -153,7 +151,11 @@ def test_interaction_timeline(self): assert "## Interaction Timeline" in md assert "| # | Time |" in md # Should have 3 rows in the timeline (8 columns per row) - lines = [l for l in md.split("\n") if l.startswith("| ") and l[2:3].isdigit() and l.count("|") >= 8] + lines = [ + l + for l in md.split("\n") + if l.startswith("| ") and l[2:3].isdigit() and l.count("|") >= 8 + ] assert len(lines) == 3 def test_context_growth_section(self): diff --git a/tests/python/test_intelligence.py b/tests/python/test_intelligence.py index ed56010..02af10d 100644 --- a/tests/python/test_intelligence.py +++ b/tests/python/test_intelligence.py @@ -261,8 +261,7 @@ def test_all_vectors(self, vectors): ) assert score.mi == pytest.approx(exp["mi"], abs=0.01), ( - f"MI mismatch for '{vec['description']}': " - f"got {score.mi:.4f}, expected {exp['mi']}" + f"MI mismatch for '{vec['description']}': got {score.mi:.4f}, expected {exp['mi']}" ) assert score.utilization == pytest.approx(exp["utilization"], abs=0.01), ( f"Utilization mismatch for '{vec['description']}': " diff --git a/tests/python/test_mi_monotonicity.py b/tests/python/test_mi_monotonicity.py index 4580283..6ade9d5 100644 --- a/tests/python/test_mi_monotonicity.py +++ b/tests/python/test_mi_monotonicity.py @@ -13,9 +13,6 @@ from claude_statusline.core.state import StateEntry from claude_statusline.graphs.intelligence import ( - MI_GREEN_THRESHOLD, - MI_YELLOW_THRESHOLD, - MODEL_PROFILES, calculate_context_pressure, calculate_intelligence, ) @@ -98,8 +95,7 @@ def test_mi_monotonic_for_all_beta(self, beta): mi = calculate_context_pressure(u, beta=beta) if prev_mi is not None: assert mi <= prev_mi, ( - f"MI not monotonic at {pct}% with beta={beta}: " - f"{mi:.4f} > {prev_mi:.4f}" + f"MI not monotonic at {pct}% with beta={beta}: {mi:.4f} > {prev_mi:.4f}" ) prev_mi = mi @@ -146,8 +142,7 @@ def test_mi_monotonic_at_1pct_resolution(self, model_family): if prev_mi is not None: assert score.mi <= prev_mi + 1e-9, ( - f"MI increased at {pct}% for {model_family}: " - f"{score.mi:.6f} > {prev_mi:.6f}" + f"MI increased at {pct}% for {model_family}: {score.mi:.6f} > {prev_mi:.6f}" ) prev_mi = score.mi @@ -179,9 +174,7 @@ def test_mi_formula_monotonic_at_1pct_resolution(self): u = pct / 100.0 mi = calculate_context_pressure(u) if prev_mi is not None: - assert mi <= prev_mi + 1e-9, ( - f"MI increased at {pct}%: {mi:.6f} > {prev_mi:.6f}" - ) + assert mi <= prev_mi + 1e-9, f"MI increased at {pct}%: {mi:.6f} > {prev_mi:.6f}" prev_mi = mi diff --git a/tests/python/test_report.py b/tests/python/test_report.py index 8db2e0a..629ef10 100644 --- a/tests/python/test_report.py +++ b/tests/python/test_report.py @@ -1,6 +1,5 @@ """Tests for the report command.""" - from claude_statusline.analytics import ProjectStats, SessionStats from claude_statusline.cli.report import generate_report diff --git a/tests/python/test_state_rotation_validation.py b/tests/python/test_state_rotation_validation.py index bed6553..069edea 100644 --- a/tests/python/test_state_rotation_validation.py +++ b/tests/python/test_state_rotation_validation.py @@ -8,7 +8,6 @@ from claude_statusline.core.state import StateFile, _validate_session_id - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -215,7 +214,13 @@ class TestCliSessionIdRejection: def test_cli_rejects_path_traversal(self): result = subprocess.run( - [sys.executable, "-m", "claude_statusline.cli.context_stats", "../../etc/passwd", "graph"], + [ + sys.executable, + "-m", + "claude_statusline.cli.context_stats", + "../../etc/passwd", + "graph", + ], capture_output=True, text=True, ) diff --git a/tests/python/test_zz_cache_warm.py b/tests/python/test_zz_cache_warm.py index 08c5bea..c11cc3d 100644 --- a/tests/python/test_zz_cache_warm.py +++ b/tests/python/test_zz_cache_warm.py @@ -19,10 +19,8 @@ from claude_statusline.cli.cache_warm import ( _clear_warm_state, - _is_process_alive, _parse_duration, _save_warm_state, - _warm_state_path, cmd_cache_warm_off, cmd_cache_warm_on, is_cache_warm_active, @@ -50,6 +48,7 @@ def tmp_dir(monkeypatch): # Helper # --------------------------------------------------------------------------- + def _mock_colors(): c = SimpleNamespace() for attr in ("green", "yellow", "red", "dim", "bold", "reset", "cyan"): @@ -61,6 +60,7 @@ def _mock_colors(): # _parse_duration # --------------------------------------------------------------------------- + class TestParseDuration: def test_minutes(self): assert _parse_duration("30m") == 1800 @@ -89,6 +89,7 @@ def test_zero(self): # State persistence helpers # --------------------------------------------------------------------------- + class TestWarmStatePersistence: def test_save_and_load(self, tmp_dir): state = {"pid": 12345, "start_time": 1000, "expiry_time": 2000, "interval": 240} @@ -112,6 +113,7 @@ def test_clear_nonexistent_is_noop(self, tmp_dir): # is_cache_warm_active # --------------------------------------------------------------------------- + class TestIsCacheWarmActive: def test_no_state_returns_false(self, tmp_dir): active, remaining = is_cache_warm_active("s1") @@ -148,6 +150,7 @@ def test_dead_pid_returns_false_and_clears(self, tmp_dir): # cmd_cache_warm_on / cmd_cache_warm_off # --------------------------------------------------------------------------- + class TestCacheWarmOn: @unix_only def test_starts_heartbeat_and_saves_state(self, tmp_dir, capsys): @@ -213,8 +216,10 @@ def test_already_active_refreshes(self, tmp_dir, capsys): own_pid = os.getpid() _save_warm_state("sess", {"pid": own_pid, "expiry_time": future, "interval": 240}) - with patch("os.fork", return_value=55, create=True), \ - patch("os.kill"): # suppress signal to own pid during off step + with ( + patch("os.fork", return_value=55, create=True), + patch("os.kill"), + ): # suppress signal to own pid during off step cmd_cache_warm_on("sess", "5m", colors) out = capsys.readouterr().out @@ -232,8 +237,10 @@ def test_stops_active_session(self, tmp_dir, capsys): future = int(time.time()) + 600 _save_warm_state("sess", {"pid": 9999999, "expiry_time": future, "interval": 240}) - with patch("claude_statusline.cli.cache_warm._is_process_alive", return_value=True), \ - patch("os.kill"): + with ( + patch("claude_statusline.cli.cache_warm._is_process_alive", return_value=True), + patch("os.kill"), + ): cmd_cache_warm_off("sess", colors) out = capsys.readouterr().out @@ -259,6 +266,7 @@ def test_silent_suppresses_output(self, tmp_dir, capsys): # run_cache_warm dispatcher # --------------------------------------------------------------------------- + class TestRunCacheWarm: def test_no_args_shows_usage(self, tmp_dir, capsys): colors = _mock_colors()