Repository: jarrodwatts/claude-hud
Branch: main
Commit: 26e38decd762
Files: 70
Total size: 382.0 KB
Directory structure:
gitextract_vjicx7y6/
├── .claude-plugin/
│ ├── marketplace.json
│ └── plugin.json
├── .editorconfig
├── .github/
│ ├── CODEOWNERS
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── config.yml
│ │ └── feature_request.md
│ ├── dependabot.yml
│ ├── pull_request_template.md
│ └── workflows/
│ ├── build-dist.yml
│ ├── ci.yml
│ ├── claude.yml
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── CLAUDE.README.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── MAINTAINERS.md
├── README.md
├── RELEASING.md
├── SECURITY.md
├── SUPPORT.md
├── TESTING.md
├── commands/
│ ├── configure.md
│ └── setup.md
├── package.json
├── src/
│ ├── claude-config-dir.ts
│ ├── config-reader.ts
│ ├── config.ts
│ ├── constants.ts
│ ├── debug.ts
│ ├── extra-cmd.ts
│ ├── git.ts
│ ├── index.ts
│ ├── render/
│ │ ├── agents-line.ts
│ │ ├── colors.ts
│ │ ├── index.ts
│ │ ├── lines/
│ │ │ ├── environment.ts
│ │ │ ├── identity.ts
│ │ │ ├── index.ts
│ │ │ ├── project.ts
│ │ │ └── usage.ts
│ │ ├── session-line.ts
│ │ ├── todos-line.ts
│ │ └── tools-line.ts
│ ├── speed-tracker.ts
│ ├── stdin.ts
│ ├── transcript.ts
│ ├── types.ts
│ ├── usage-api.ts
│ └── utils/
│ └── terminal.ts
├── tests/
│ ├── config.test.js
│ ├── core.test.js
│ ├── extra-cmd.test.js
│ ├── fixtures/
│ │ ├── expected/
│ │ │ └── render-basic.txt
│ │ ├── transcript-basic.jsonl
│ │ └── transcript-render.jsonl
│ ├── git.test.js
│ ├── index.test.js
│ ├── integration.test.js
│ ├── render-width.test.js
│ ├── render.test.js
│ ├── speed-tracker.test.js
│ ├── stdin.test.js
│ ├── terminal.test.js
│ └── usage-api.test.js
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .claude-plugin/marketplace.json
================================================
{
"name": "claude-hud",
"owner": {
"name": "Jarrod Watts",
"email": "jarrodwattsyt@gmail.com"
},
"metadata": {
"description": "Real-time statusline HUD for Claude Code - context health, tool activity, agent tracking, and todo progress",
"version": "0.0.10"
},
"plugins": [
{
"name": "claude-hud",
"source": "./",
"description": "Real-time statusline showing context usage, active tools, running agents, and todo progress. Always visible below your input, zero config required.",
"category": "monitoring",
"tags": ["hud", "statusline", "monitoring", "context", "tools", "agents", "todos"]
}
]
}
================================================
FILE: .claude-plugin/plugin.json
================================================
{
"name": "claude-hud",
"description": "Real-time statusline HUD for Claude Code - context health, tool activity, agent tracking, and todo progress",
"version": "0.0.10",
"author": {
"name": "Jarrod Watts",
"url": "https://github.com/jarrodwatts"
},
"commands": [
"./commands/setup.md",
"./commands/configure.md"
],
"homepage": "https://github.com/jarrodwatts/claude-hud",
"repository": "https://github.com/jarrodwatts/claude-hud",
"license": "MIT",
"keywords": ["hud", "monitoring", "statusline", "context", "tools", "agents", "todos", "claude-code"]
}
================================================
FILE: .editorconfig
================================================
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .github/CODEOWNERS
================================================
* @jarrodwatts
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Report a reproducible problem
labels: bug
---
## Summary
## Steps to Reproduce
## Expected Behavior
## Actual Behavior
## Environment
- OS:
- Node/Bun version:
- Claude Code version:
## Logs or Screenshots
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
- name: Security report
url: mailto:jarrodwttsyt@gmail.com
about: Please report security vulnerabilities via email.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea or enhancement
labels: enhancement
---
## Summary
## Problem to Solve
## Proposed Solution
## Alternatives Considered
## Additional Context
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
================================================
FILE: .github/pull_request_template.md
================================================
## Summary
## Testing
- [ ] `npm test`
- [ ] `npm run test:coverage`
## Checklist
- [ ] Tests updated or not needed
- [ ] Docs updated if behavior changed
================================================
FILE: .github/workflows/build-dist.yml
================================================
name: Build dist
on:
push:
branches: [main]
concurrency:
group: build-dist
cancel-in-progress: false
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[auto]')"
steps:
- uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
- run: npm run build
- name: Verify build output
run: test -f dist/index.js || exit 1
- name: Commit dist/
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add dist/ --force
git diff --staged --quiet || git commit -m "build: compile dist/ [auto]"
git push
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
pull_request:
push:
branches: [main]
paths-ignore:
- 'dist/**'
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm run test:coverage
================================================
FILE: .github/workflows/claude.yml
================================================
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
claude_args: '--model claude-opus-4-5-20251101'
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20.x
cache: npm
- run: npm ci
- run: npm run build
- run: npm test
- run: npm run test:coverage
- name: Extract release notes from CHANGELOG
run: |
version="${GITHUB_REF_NAME#v}"
awk -v version="$version" '
$0 ~ "^## \\[" version "\\]" { in_section = 1; next }
in_section && $0 ~ "^## \\[" { exit }
in_section { print }
' CHANGELOG.md > RELEASE_NOTES.md
if [ ! -s RELEASE_NOTES.md ]; then
echo "No changelog section found for version $version"
exit 1
fi
- name: Create release
uses: softprops/action-gh-release@v2
with:
body_path: RELEASE_NOTES.md
================================================
FILE: .gitignore
================================================
# Dependencies
node_modules/
# Build artifacts
# dist/ is gitignored but exists on main - CI builds and commits it after each merge.
# See .github/workflows/build-dist.yml
dist/
*.tsbuildinfo
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids/
*.pid
*.seed
*.fifo
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment/secrets (safety)
.env
.env.*
.claude/settings.json
.claude/*.local.json
*.pem
*.key
secrets/
credentials/
# Test coverage
coverage/
.nyc_output/
# Temp files
tmp/
temp/
*.tmp
# Lock files (keep package-lock.json for npm)
yarn.lock
bun.lock
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to Claude HUD will be documented in this file.
## [Unreleased]
## [0.0.10] - 2026-03-14
### Added
- Semantic HUD color overrides for context and usage states.
### Changed
- Update the fallback autocompact buffer estimate from `22.5%` (`45k/200k`) to `16.5%` (`33k/200k`) to match current Claude Code `/context` output.
- Clarify in code comments that the fallback buffer is empirical and may change independently of documented Claude Code releases.
- Clarify that context percentages and token displays scale with Claude Code's reported context window size, including newer 1M-context sessions.
- Text-only usage display now shows the 7-day reset countdown when applicable.
- Rate-limited usage refreshes now keep the last successful values visible while marking the HUD as syncing.
### Fixed
- Context percentage no longer starts with an inflated fallback percentage before native data exists.
- Usage API rate-limit handling is more resilient, including better stale-cache behavior and `Retry-After` parsing.
- Zero-byte usage lock files now recover instead of leaving the HUD permanently busy.
- Plugin selection now prefers the highest installed version instead of filesystem mtime.
- macOS Keychain lookup now prefers account-scoped credentials and avoids cross-account fallback when multiple accounts exist.
---
## [0.0.9] - 2026-03-05
### Changed
- Add Usage API timeout override via `CLAUDE_HUD_USAGE_TIMEOUT_MS` (default now 15s).
### Fixed
- Setup instructions now generate shell-safe Windows commands for `win32 + bash` environments (#121, #148).
- Bedrock startup model labels now normalize known model IDs when `model.display_name` is missing (#137).
- Usage API reliability improvements for proxy and OAuth token-refresh edge cases:
- Respect `HTTPS_PROXY`/`ALL_PROXY`/`HTTP_PROXY` with `NO_PROXY` bypass.
- Preserve usage and plan display when keychain tokens refresh without `subscriptionType` metadata.
- Reduce false `timeout`/`403` usage warnings in proxied and high-latency environments (#146, #161, #162).
- Render output now preserves regular spaces instead of non-breaking spaces to avoid vertical statusline rendering issues on startup (#142).
---
## [0.0.8] - 2026-03-03
### Added
- Session name display in the statusline (#155).
- `display.contextValue: "remaining"` mode to show remaining context percent (#157).
- Regression tests for `CLAUDE_CONFIG_DIR` path handling, keychain service resolution fallback ordering, and config counter overlap edge cases.
### Changed
- Prefer subscription plan labels over API env-var detection for account type display (#158).
- Usage reset time formatting now switches to days when the reset window is 24h or more (#132).
### Fixed
- Respect `CLAUDE_CONFIG_DIR` for HUD config lookup, usage cache, speed cache, and legacy credentials file paths (#126).
- Improve macOS Keychain credential lookup for multi-profile setups by using profile-specific service names with compatibility fallbacks.
- Fix config counting overlap detection so project `.claude` files are still counted when `cwd` is home and user scope is redirected.
- Prevent HUD rows from disappearing in narrow terminals (#159).
- Handle object-based legacy layout values safely during config migration (#144).
- Prevent double-counting user vs project `CLAUDE.md` when `cwd` is home (#141).
### Dependencies
- Bump `@types/node` from `25.2.3` to `25.3.3` (#153).
- Bump `c8` from `10.1.3` to `11.0.0` (#154).
---
## [0.0.7] - 2026-02-06
### Changed
- **Redesigned default layout** — clean 2-line display replaces the previous multi-line default
- Line 1: `[Opus | Max] │ my-project git:(main*)`
- Line 2: `Context █████░░░░░ 45% │ Usage ██░░░░░░░░ 25% (1h 30m / 5h)`
- Model bracket moved to project line (line 1)
- Context and usage bars combined onto a single line with `│` separator
- Shortened labels: "Context Window" → "Context", "Usage Limits" → "Usage"
- Consistent `dim()` styling on both labels
- All optional features hidden by default: tools, agents, todos, duration, config counts
- Bedrock provider detection (#111)
- Output speed display (#110)
- Token context display option (#108)
- Seven-day usage threshold config (#107)
### Added
- Setup onboarding now offers optional features (tools, agents & todos, session info) before finishing
- `display.showSpeed` config option for output token speed
### Fixed
- Show API failure reason in usage display (#109)
- Support task todo updates in transcript parsing (#106)
- Keep HUD to one line in compact mode (#105)
- Use Platform context instead of uname for setup detection (#95)
---
## [0.0.6] - 2026-01-14
### Added
- **Expanded multi-line layout mode** - splits the overloaded session line into semantic lines (#76)
- Identity line: model, plan, context bar, duration
- Project line: path, git status
- Environment line: config counts (CLAUDE.md, rules, MCPs, hooks)
- Usage line: rate limits with reset times
- New config options:
- `lineLayout`: `'compact'` | `'expanded'` (default: `'expanded'` for new users)
- `showSeparators`: boolean (orthogonal to layout)
- `display.usageThreshold`: show usage line only when >= N%
- `display.environmentThreshold`: show env line only when counts >= N
### Changed
- Default layout is now `expanded` for new installations
- Threshold logic uses `max(5h, 7d)` to ensure high 7-day usage isn't hidden
### Fixed
- Ghost installation detection and cleanup in setup command (#75)
### Migration
- Existing configs with `layout: "default"` automatically migrate to `lineLayout: "compact"`
- Existing configs with `layout: "separators"` migrate to `lineLayout: "compact"` + `showSeparators: true`
---
## [0.0.5] - 2026-01-14
### Added
- Native context percentage support for Claude Code v2.1.6+
- Uses `used_percentage` field from stdin when available (accurate, matches `/context`)
- Automatic fallback to manual calculation for older versions
- Handles edge cases: NaN, negative values, values >100
- `display.autocompactBuffer` config option (`'enabled'` | `'disabled'`, default: `'enabled'`)
- `'enabled'`: Shows buffered % (matches `/context` when autocompact ON) - **default**
- `'disabled'`: Shows raw % (matches `/context` when autocompact OFF)
- EXDEV cross-device error detection for Linux plugin installation (#53)
### Changed
- Context percentage now uses percentage-based buffer (22.5%) instead of hardcoded 45k tokens (#55)
- Scales correctly for enterprise context windows (>200k)
- Remove automatic PR review workflow (#67)
### Fixed
- Git status: move `--no-optional-locks` to correct position as global git option (#65)
- Prevent stale `index.lock` files during git operations (#63)
- Exclude disabled MCP servers from count (#47)
- Reconvert Date objects when reading from usage API cache (#45)
### Credits
- Ideas from [#30](https://github.com/jarrodwatts/claude-hud/pull/30) ([@r-firpo](https://github.com/r-firpo)), [#43](https://github.com/jarrodwatts/claude-hud/pull/43) ([@yansircc](https://github.com/yansircc)), [#49](https://github.com/jarrodwatts/claude-hud/pull/49) ([@StephenJoshii](https://github.com/StephenJoshii)) informed the autocompact solution
### Dependencies
- Bump @types/node from 25.0.3 to 25.0.6 (#61)
---
## [0.0.4] - 2026-01-07
### Added
- Configuration system via `~/.claude/plugins/claude-hud/config.json`
- Interactive `/claude-hud:configure` skill for in-Claude configuration
- Usage API integration showing 5h/7d rate limits (Pro/Max/Team)
- Git status with dirty indicator and ahead/behind counts
- Configurable path levels (1-3 directory segments)
- Layout options: default and separators
- Display toggles for all HUD elements
### Fixed
- Git status spacing: `main*↑2↓1` → `main* ↑2 ↓1`
- Root path rendering: show `/` instead of empty
- Windows path normalization
### Credits
- Config system, layouts, path levels, git toggle by @Tsopic (#32)
- Usage API, configure skill, bug fixes by @melon-hub (#34)
---
## [0.0.3] - 2025-01-06
### Added
- Display git branch name in session line (#23)
- Display project folder name in session line (#18)
- Dynamic platform and runtime detection in setup command (#24)
### Changed
- Remove redundant COMPACT warning at high context usage (#27)
### Fixed
- Skip auto-review for fork PRs to prevent CI failures (#25)
### Dependencies
- Bump @types/node from 20.19.27 to 25.0.3 (#2)
---
## [0.0.2] - 2025-01-04
### Security
- Add CI workflow to build dist/ after merge - closes attack vector where malicious code could be injected via compiled output in PRs
- Remove dist/ from git tracking - PRs now contain source only, CI handles compilation
### Fixed
- Add 45k token autocompact buffer to context percentage calculation - now matches `/context` output accurately by accounting for Claude Code's reserved autocompact space
- Fix CI caching with package-lock.json
- Use Opus 4.5 for GitHub Actions code review
### Changed
- Setup command now auto-detects installed plugin version (no manual path updates needed)
- Setup prompts for optional GitHub star after successful configuration
- Remove husky pre-commit hook (CI now handles dist/ compilation)
### Dependencies
- Bump c8 from 9.1.0 to 10.1.3
---
## [0.0.1] - 2025-01-04
Initial release of Claude HUD as a Claude Code statusline plugin.
### Features
- Real-time context usage monitoring with color-coded progress bar
- Active tool tracking with completion counts
- Running agent status with elapsed time
- Todo progress display
- Native token data from Claude Code stdin
- Transcript parsing for tool/agent/todo activity
================================================
FILE: CLAUDE.README.md
================================================
# Claude HUD
Real-time statusline showing context usage, active tools, running agents, and todo progress.
---
## For Humans
**Step 1: Add the marketplace**
```
/plugin marketplace add jarrodwatts/claude-hud
```
**Step 2: Install the plugin**
```
/plugin install claude-hud
```
**Step 3: Configure the statusline**
```
/claude-hud:setup
```
Done. Restart Claude Code to load the new statusLine config, then the HUD will appear.
---
## For LLM Agents
If you're an LLM agent helping a user install or work with Claude HUD, follow these steps.
### Step 1: Check Prerequisites
Verify Claude Code version is 1.0.80 or later:
```bash
claude --version
```
If not installed or outdated, direct user to https://claude.ai/code
### Step 2: Add the Marketplace
Run this command in Claude Code:
```
/plugin marketplace add jarrodwatts/claude-hud
```
### Step 3: Install the Plugin
Run this command in Claude Code:
```
/plugin install claude-hud
```
### Step 4: Configure the Statusline
Run this command in Claude Code:
```
/claude-hud:setup
```
This adds the statusLine configuration to the user's settings.json.
### Step 5: Inform User
Tell the user:
- Installation complete
- Restart Claude Code to load the statusline config, then the HUD will appear
- It shows: context usage, active tools, running agents, and todo progress
### Step 6: Ask About Starring (Optional)
Ask the user: "Would you like to star the repository to support the project?"
Only if they explicitly agree, run:
```bash
gh repo star jarrodwatts/claude-hud
```
Never run this automatically without user consent.
---
## Reference
Technical documentation for agents who need to understand, modify, or debug Claude HUD.
Claude HUD
Real-time statusline showing context usage, active tools, running agents, and todo progress. Always visible below your input, zero config required.
github.com/jarrodwatts/claude-hud
MIT
Node.js 18+ or Bun
v1.0.80 or later
TypeScript 5, ES2022 target, NodeNext modules
Claude HUD is a statusline plugin invoked by Claude Code every ~300ms.
It reads data from two sources, renders up to 4 lines, and outputs to stdout.
Claude Code invokes the plugin →
Plugin reads JSON from stdin (model, context, tokens) →
Plugin parses transcript JSONL file (tools, agents, todos) →
Plugin reads config files (MCPs, hooks, rules) →
Plugin renders lines to stdout →
Claude Code displays the statusline
Current model name (Opus, Sonnet, Haiku)
Current token count
Maximum context size
Path to session transcript JSONL file
Current working directory
- tool_use blocks → tool name, target file, start time
- tool_result blocks → completion status, duration
- Running tools = tool_use without matching tool_result
- TodoWrite calls → current todo list
- Task calls → agent type, model, description
- ~/.claude/settings.json → mcpServers count, hooks count
- CLAUDE.md files in cwd and ancestors → rules count
- .mcp.json files → additional MCP count
Reads stdin, parses transcript, counts configs, calls render.
Exports main() for testing with dependency injection.
Reads and validates Claude Code's JSON input.
Returns StdinData with model, context, transcript_path.
Parses the session transcript file line by line.
Extracts tools, agents, todos, and session start time.
Matches tool_use to tool_result by ID to calculate status.
Counts CLAUDE.md files, rules, MCP servers, and hooks.
Searches cwd, ~/.claude/, and project .claude/ directories.
Reads config.json from ~/.claude/plugins/claude-hud/.
Validates and merges user settings with defaults.
Exports HudConfig interface and loadConfig function.
Gets branch name, dirty state, and ahead/behind counts.
Uses execFile with array args for safe command execution.
Reads OAuth credentials from ~/.claude/.credentials.json.
Calls api.anthropic.com/api/oauth/usage endpoint (opt-in).
Caches results (60s success, 15s failure).
StdinData, ToolEntry, AgentEntry, TodoItem, TranscriptData, RenderContext.
Calls each line renderer and outputs to stdout.
Conditionally shows lines based on data presence.
Renders: [Model | Plan] █████░░░░░ 45% | project git:(branch) | 2 CLAUDE.md | 5h: 25% | ⏱️ 5m
Context bar colors: green (<70%), yellow (70-85%), red (>85%).
Renders: ◐ Edit: auth.ts | ✓ Read ×3 | ✓ Grep ×2
Shows running tools with spinner, completed tools aggregated.
Renders: ◐ explore [haiku]: Finding auth code (2m 15s)
Shows agent type, model, description, elapsed time.
Renders: ▸ Fix authentication bug (2/5)
Shows current in_progress task and completion count.
Functions: green(), yellow(), red(), dim(), bold(), reset().
Used for colorizing output based on status/thresholds.
[Model | Plan] █████░░░░░ 45% | project git:(branch) | 2 CLAUDE.md | 5h: 25% | ⏱️ 5m
◐ Edit: auth.ts | ✓ Read ×3 | ✓ Grep ×2
◐ explore [haiku]: Finding auth code (2m 15s)
▸ Fix authentication bug (2/5)
.claude-plugin/plugin.json
{
"name": "claude-hud",
"description": "Real-time statusline HUD for Claude Code",
"version": "0.0.1",
"author": { "name": "Jarrod Watts", "url": "https://github.com/jarrodwatts" }
}
The plugin.json contains metadata only. statusLine is NOT a valid plugin.json field.
The /claude-hud:setup command adds statusLine to ~/.claude/settings.json with an auto-updating command that finds the latest installed version.
Updates are automatic - no need to re-run setup after updating the plugin.
git clone https://github.com/jarrodwatts/claude-hud
cd claude-hud
npm ci
npm run build
npm test # Run all tests
npm run build # Compile TypeScript to dist/
# Test with sample stdin data:
echo '{"model":{"display_name":"Opus"},"context_window":{"current_usage":{"input_tokens":45000},"context_window_size":200000}}' | node dist/index.js
# Test with transcript path:
echo '{"model":{"display_name":"Sonnet"},"transcript_path":"/path/to/transcript.jsonl","context_window":{"current_usage":{"input_tokens":90000},"context_window_size":200000}}' | node dist/index.js
Add new data extraction in transcript.ts or stdin.ts
Add new interface fields in types.ts
Create new render file in src/render/ or modify existing
Update src/render/index.ts to include new line
Run npm run build and test
Edit src/render/session-line.ts to change context threshold values.
Look for the percentage checks that determine color coding.
1. Create src/render/new-line.ts with a render function
2. Import and call it from src/render/index.ts
3. Add any needed types to src/types.ts
4. Add data extraction logic to transcript.ts if needed
Plugin not installed or statusLine not configured
Run: /plugin marketplace add jarrodwatts/claude-hud
Run: /plugin install claude-hud
Run: /claude-hud:setup
Ensure Claude Code is v1.0.80 or later
No stdin data received (normal on first invocation)
This is expected briefly on startup, should resolve automatically
Data comes directly from Claude Code - it's accurate
The percentage is (input_tokens / context_window_size) * 100
No tools used yet or transcript not being parsed
Lines only appear when there's data to show
Ensure Node.js 18+ installed
Run npm ci to reinstall dependencies
Delete dist/ and node_modules/, then npm ci && npm run build
transcript_path?: string
cwd?: string
model?: { id?: string, display_name?: string }
context_window?: { context_window_size?: number, current_usage?: { input_tokens?: number } }
id: string
name: string
target?: string
status: 'running' | 'completed' | 'error'
startTime: Date
endTime?: Date
id: string
type: string
model?: string
description?: string
status: 'running' | 'completed'
startTime: Date
endTime?: Date
content: string
status: 'pending' | 'in_progress' | 'completed'
stdin: StdinData
transcript: TranscriptData
claudeMdCount: number
rulesCount: number
mcpCount: number
hooksCount: number
sessionDuration: string
Follow the agent_workflow steps above.
If the user wants to modify Claude HUD:
1. Clone the repo or navigate to the plugin directory
2. Make changes following the file_structure section
3. Run npm run build to compile
4. Restart Claude Code to see changes
If the user reports issues:
1. Check troubleshooting section first
2. Verify Claude Code version (needs v1.0.80+)
3. Check if plugin is listed: claude /plugin list
4. Test manually with echo command from development section
If the user asks how something works:
1. Reference the architecture and data_flow sections
2. Point to specific files in file_structure
3. Explain the data sources and how they're combined
================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md
This file provides guidance to Claude Code when working with this repository.
## Project Overview
Claude HUD is a Claude Code plugin that displays a real-time multi-line statusline. It shows context health, tool activity, agent status, and todo progress.
## Build Commands
```bash
npm ci # Install dependencies
npm run build # Build TypeScript to dist/
# Test with sample stdin data
echo '{"model":{"display_name":"Opus"},"context_window":{"current_usage":{"input_tokens":45000},"context_window_size":200000}}' | node dist/index.js
```
## Architecture
### Data Flow
```
Claude Code → stdin JSON → parse → render lines → stdout → Claude Code displays
↘ transcript_path → parse JSONL → tools/agents/todos
```
**Key insight**: The statusline is invoked every ~300ms by Claude Code. Each invocation:
1. Receives JSON via stdin (model, context, tokens - native accurate data)
2. Parses the transcript JSONL file for tools, agents, and todos
3. Renders multi-line output to stdout
4. Claude Code displays all lines
### Data Sources
**Native from stdin JSON** (accurate, no estimation):
- `model.display_name` - Current model
- `context_window.current_usage` - Token counts
- `context_window.context_window_size` - Max context
- `transcript_path` - Path to session transcript
**From transcript JSONL parsing**:
- `tool_use` blocks → tool name, input, start time
- `tool_result` blocks → completion, duration
- Running tools = `tool_use` without matching `tool_result`
- `TodoWrite` calls → todo list
- `Task` calls → agent info
**From config files**:
- MCP count from `~/.claude/settings.json` (mcpServers)
- Hooks count from `~/.claude/settings.json` (hooks)
- Rules count from CLAUDE.md files
**From OAuth credentials** (`~/.claude/.credentials.json`, when `display.showUsage` enabled):
- `claudeAiOauth.accessToken` - OAuth token for API calls
- `claudeAiOauth.subscriptionType` - User's plan (Pro, Max, Team)
**From Anthropic Usage API** (`api.anthropic.com/api/oauth/usage`):
- 5-hour and 7-day usage percentages
- Reset timestamps (cached 60s success, 15s failure)
### File Structure
```
src/
├── index.ts # Entry point
├── stdin.ts # Parse Claude's JSON input
├── transcript.ts # Parse transcript JSONL
├── config-reader.ts # Read MCP/rules configs
├── config.ts # Load/validate user config
├── git.ts # Git status (branch, dirty, ahead/behind)
├── usage-api.ts # Fetch usage from Anthropic API
├── types.ts # TypeScript interfaces
└── render/
├── index.ts # Main render coordinator
├── session-line.ts # Compact mode: single line with all info
├── tools-line.ts # Tool activity (opt-in)
├── agents-line.ts # Agent status (opt-in)
├── todos-line.ts # Todo progress (opt-in)
├── colors.ts # ANSI color helpers
└── lines/
├── index.ts # Barrel export
├── project.ts # Line 1: model bracket + project + git
├── identity.ts # Line 2a: context bar
├── usage.ts # Line 2b: usage bar (combined with identity)
└── environment.ts # Config counts (opt-in)
```
### Output Format (default expanded layout)
```
[Opus | Max] │ my-project git:(main*)
Context █████░░░░░ 45% │ Usage ██░░░░░░░░ 25% (1h 30m / 5h)
```
Lines 1-2 always shown. Additional lines are opt-in via config:
- Tools line (`showTools`): ◐ Edit: auth.ts | ✓ Read ×3
- Agents line (`showAgents`): ◐ explore [haiku]: Finding auth code
- Todos line (`showTodos`): ▸ Fix authentication bug (2/5)
- Environment line (`showConfigCounts`): 2 CLAUDE.md | 4 rules
### Context Thresholds
| Threshold | Color | Action |
|-----------|-------|--------|
| <70% | Green | Normal |
| 70-85% | Yellow | Warning |
| >85% | Red | Show token breakdown |
## Plugin Configuration
The plugin manifest is in `.claude-plugin/plugin.json` (metadata only - name, description, version, author).
**StatusLine configuration** must be added to the user's `~/.claude/settings.json` via `/claude-hud:setup`.
The setup command adds an auto-updating command that finds the latest installed version at runtime.
Note: `statusLine` is NOT a valid plugin.json field. It must be configured in settings.json after plugin installation. Updates are automatic - no need to re-run setup.
## Dependencies
- **Runtime**: Node.js 18+ or Bun
- **Build**: TypeScript 5, ES2022 target, NodeNext modules
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Code of Conduct
## Our Pledge
We pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to a positive environment include:
- Being respectful and considerate
- Using welcoming and inclusive language
- Accepting constructive feedback
- Focusing on what is best for the community
Examples of unacceptable behavior include:
- Harassment or discrimination
- Trolling, insulting, or derogatory comments
- Publishing others' private information without permission
## Enforcement
Community leaders are responsible for clarifying standards of acceptable behavior and may take appropriate action in response to unacceptable behavior.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the maintainer at: jarrodwttsyt@gmail.com.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.1.
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing
Thanks for contributing to Claude HUD. This repo is small and fast-moving, so we optimize for clarity and quick review.
## How to Contribute
1) Fork and clone the repo
2) Create a branch
3) Make your changes
4) Run tests and update docs if needed
5) Open a pull request
## Development
```bash
npm ci
npm run build
npm test
```
## Tests
See `TESTING.md` for the full testing strategy, fixtures, and snapshot updates.
## Code Style
- Keep changes focused and small.
- Prefer tests for behavior changes.
- Avoid introducing dependencies unless necessary.
## Build Process
**Important**: PRs should only modify files in `src/` — do not include changes to `dist/`.
CI automatically builds and commits `dist/` after your PR is merged. This keeps PRs focused on source code and makes review easier.
```
Your PR: src/ changes only → Merge → CI builds dist/ → Committed automatically
```
## Pull Requests
- Describe the problem and the fix.
- Include tests or explain why they are not needed.
- Link issues when relevant.
- Only modify `src/` files — CI handles `dist/` automatically.
## Releasing New Versions
When shipping a new version:
1. **Update version numbers** in all three files:
- `package.json` → `"version": "X.Y.Z"`
- `.claude-plugin/plugin.json` → `"version": "X.Y.Z"`
- `.claude-plugin/marketplace.json` → `"version": "X.Y.Z"`
2. **Update CHANGELOG.md** with changes since last release
3. **Commit and merge** — CI builds dist/ automatically
### How Users Get Updates
Claude Code plugins support updates through the `/plugin` interface:
- **Update now** — Fetches latest from main branch, installs immediately
- **Mark for update** — Stages update for later
Claude Code compares the `version` field in `plugin.json` against the installed version. Bumping the version number (e.g., 0.0.1 → 0.0.2) allows users to see an update is available.
### Version Strategy
We use semantic versioning (`MAJOR.MINOR.PATCH`):
- **PATCH** (0.0.x): Bug fixes, minor improvements
- **MINOR** (0.x.0): New features, non-breaking changes
- **MAJOR** (x.0.0): Breaking changes
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2026 Jarrod Watts
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: MAINTAINERS.md
================================================
# Maintainers
- Jarrod Watts (https://github.com/jarrodwatts)
If you are interested in becoming a maintainer, open an issue to start the conversation.
================================================
FILE: README.md
================================================
# Claude HUD
A Claude Code plugin that shows what's happening — context usage, active tools, running agents, and todo progress. Always visible below your input.
[](LICENSE)
[](https://github.com/jarrodwatts/claude-hud/stargazers)

## Install
Inside a Claude Code instance, run the following commands:
**Step 1: Add the marketplace**
```
/plugin marketplace add jarrodwatts/claude-hud
```
**Step 2: Install the plugin**
⚠️ Linux users: Click here first
On Linux, `/tmp` is often a separate filesystem (tmpfs), which causes plugin installation to fail with:
```
EXDEV: cross-device link not permitted
```
**Fix**: Set TMPDIR before installing:
```bash
mkdir -p ~/.cache/tmp && TMPDIR=~/.cache/tmp claude
```
Then run the install command below in that session. This is a [Claude Code platform limitation](https://github.com/anthropics/claude-code/issues/14799).
```
/plugin install claude-hud
```
**Step 3: Configure the statusline**
```
/claude-hud:setup
```
Done! Restart Claude Code to load the new statusLine config, then the HUD will appear.
---
## What is Claude HUD?
Claude HUD gives you better insights into what's happening in your Claude Code session.
| What You See | Why It Matters |
|--------------|----------------|
| **Project path** | Know which project you're in (configurable 1-3 directory levels) |
| **Context health** | Know exactly how full your context window is before it's too late |
| **Tool activity** | Watch Claude read, edit, and search files as it happens |
| **Agent tracking** | See which subagents are running and what they're doing |
| **Todo progress** | Track task completion in real-time |
## What You See
### Default (2 lines)
```
[Opus | Max] │ my-project git:(main*)
Context █████░░░░░ 45% │ Usage ██░░░░░░░░ 25% (1h 30m / 5h)
```
- **Line 1** — Model, plan name (or `Bedrock`), project path, git branch
- **Line 2** — Context bar (green → yellow → red) and usage rate limits
### Optional lines (enable via `/claude-hud:configure`)
```
◐ Edit: auth.ts | ✓ Read ×3 | ✓ Grep ×2 ← Tools activity
◐ explore [haiku]: Finding auth code (2m 15s) ← Agent status
▸ Fix authentication bug (2/5) ← Todo progress
```
---
## How It Works
Claude HUD uses Claude Code's native **statusline API** — no separate window, no tmux required, works in any terminal.
```
Claude Code → stdin JSON → claude-hud → stdout → displayed in your terminal
↘ transcript JSONL (tools, agents, todos)
```
**Key features:**
- Native token data from Claude Code (not estimated)
- Scales with Claude Code's reported context window size, including newer 1M-context sessions
- Parses the transcript for tool/agent activity
- Updates every ~300ms
---
## Configuration
Customize your HUD anytime:
```
/claude-hud:configure
```
The guided flow handles layout and display toggles. Advanced overrides such as
custom colors and thresholds are preserved there, but you set them by editing the config file directly:
- **First time setup**: Choose a preset (Full/Essential/Minimal), then fine-tune individual elements
- **Customize anytime**: Toggle items on/off, adjust git display style, switch layouts
- **Preview before saving**: See exactly how your HUD will look before committing changes
### Presets
| Preset | What's Shown |
|--------|--------------|
| **Full** | Everything enabled — tools, agents, todos, git, usage, duration |
| **Essential** | Activity lines + git status, minimal info clutter |
| **Minimal** | Core only — just model name and context bar |
After choosing a preset, you can turn individual elements on or off.
### Manual Configuration
Edit `~/.claude/plugins/claude-hud/config.json` directly for advanced settings such as `colors.*`,
`pathLevels`, and threshold overrides. Running `/claude-hud:configure` preserves those manual settings.
### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `lineLayout` | string | `expanded` | Layout: `expanded` (multi-line) or `compact` (single line) |
| `pathLevels` | 1-3 | 1 | Directory levels to show in project path |
| `elementOrder` | string[] | `["project","context","usage","environment","tools","agents","todos"]` | Expanded-mode element order. Omit entries to hide them in expanded mode. |
| `gitStatus.enabled` | boolean | true | Show git branch in HUD |
| `gitStatus.showDirty` | boolean | true | Show `*` for uncommitted changes |
| `gitStatus.showAheadBehind` | boolean | false | Show `↑N ↓N` for ahead/behind remote |
| `gitStatus.showFileStats` | boolean | false | Show file change counts `!M +A ✘D ?U` |
| `display.showModel` | boolean | true | Show model name `[Opus]` |
| `display.showContextBar` | boolean | true | Show visual context bar `████░░░░░░` |
| `display.contextValue` | `percent` \| `tokens` \| `remaining` | `percent` | Context display format (`45%`, `45k/200k`, or `55%` remaining) |
| `display.showConfigCounts` | boolean | false | Show CLAUDE.md, rules, MCPs, hooks counts |
| `display.showDuration` | boolean | false | Show session duration `⏱️ 5m` |
| `display.showSpeed` | boolean | false | Show output token speed `out: 42.1 tok/s` |
| `display.showUsage` | boolean | true | Show usage limits (Pro/Max/Team only) |
| `display.usageBarEnabled` | boolean | true | Display usage as visual bar instead of text |
| `display.sevenDayThreshold` | 0-100 | 80 | Show 7-day usage when >= threshold (0 = always) |
| `display.showTokenBreakdown` | boolean | true | Show token details at high context (85%+) |
| `display.showTools` | boolean | false | Show tools activity line |
| `display.showAgents` | boolean | false | Show agents activity line |
| `display.showTodos` | boolean | false | Show todos progress line |
| `display.showSessionName` | boolean | false | Show session slug or custom title from `/rename` |
| `usage.cacheTtlSeconds` | number | 60 | How long (seconds) to cache a successful usage API response |
| `usage.failureCacheTtlSeconds` | number | 15 | How long (seconds) to cache a failed usage API response before retrying |
| `colors.context` | color name | `green` | Base color for the context bar and context percentage |
| `colors.usage` | color name | `brightBlue` | Base color for usage bars and percentages below warning thresholds |
| `colors.warning` | color name | `yellow` | Warning color for context thresholds and usage warning text |
| `colors.usageWarning` | color name | `brightMagenta` | Warning color for usage bars and percentages near their threshold |
| `colors.critical` | color name | `red` | Critical color for limit-reached states and critical thresholds |
Supported color names: `red`, `green`, `yellow`, `magenta`, `cyan`, `brightBlue`, `brightMagenta`.
### Usage Limits (Pro/Max/Team)
Usage display is **enabled by default** for Claude Pro, Max, and Team subscribers. It shows your rate limit consumption on line 2 alongside the context bar.
The 7-day percentage appears when above the `display.sevenDayThreshold` (default 80%):
```
Context █████░░░░░ 45% │ Usage ██░░░░░░░░ 25% (1h 30m / 5h) | ██████████ 85% (2d / 7d)
```
To disable, set `display.showUsage` to `false`.
**Requirements:**
- Claude Pro, Max, or Team subscription (not available for API users)
- OAuth credentials from Claude Code (created automatically when you log in)
**Troubleshooting:** If usage doesn't appear:
- Ensure you're logged in with a Pro/Max/Team account (not API key)
- Check `display.showUsage` is not set to `false` in config
- API users see no usage display (they have pay-per-token, not rate limits)
- AWS Bedrock models display `Bedrock` and hide usage limits (usage is managed in AWS)
- Non-default `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_BASE_URL` settings skip usage display, because the Anthropic OAuth usage API may not apply
- If you are behind a proxy, set `HTTPS_PROXY` (or `HTTP_PROXY`/`ALL_PROXY`) and optional `NO_PROXY`
- For high-latency environments, increase usage API timeout with `CLAUDE_HUD_USAGE_TIMEOUT_MS` (milliseconds)
### Example Configuration
```json
{
"lineLayout": "expanded",
"pathLevels": 2,
"elementOrder": ["project", "tools", "context", "usage", "environment", "agents", "todos"],
"gitStatus": {
"enabled": true,
"showDirty": true,
"showAheadBehind": true,
"showFileStats": true
},
"display": {
"showTools": true,
"showAgents": true,
"showTodos": true,
"showConfigCounts": true,
"showDuration": true
},
"colors": {
"context": "cyan",
"usage": "cyan",
"warning": "yellow",
"usageWarning": "magenta",
"critical": "red"
},
"usage": {
"cacheTtlSeconds": 120,
"failureCacheTtlSeconds": 30
}
}
```
### Display Examples
**1 level (default):** `[Opus] │ my-project git:(main)`
**2 levels:** `[Opus] │ apps/my-project git:(main)`
**3 levels:** `[Opus] │ dev/apps/my-project git:(main)`
**With dirty indicator:** `[Opus] │ my-project git:(main*)`
**With ahead/behind:** `[Opus] │ my-project git:(main ↑2 ↓1)`
**With file stats:** `[Opus] │ my-project git:(main* !3 +1 ?2)`
- `!` = modified files, `+` = added/staged, `✘` = deleted, `?` = untracked
- Counts of 0 are omitted for cleaner display
### Troubleshooting
**Config not applying?**
- Check for JSON syntax errors: invalid JSON silently falls back to defaults
- Ensure valid values: `pathLevels` must be 1, 2, or 3; `lineLayout` must be `expanded` or `compact`
- Delete config and run `/claude-hud:configure` to regenerate
**Git status missing?**
- Verify you're in a git repository
- Check `gitStatus.enabled` is not `false` in config
**Tool/agent/todo lines missing?**
- These are hidden by default — enable with `showTools`, `showAgents`, `showTodos` in config
- They also only appear when there's activity to show
**HUD not appearing after setup?**
- Restart Claude Code so it picks up the new statusLine config
- On macOS, fully quit Claude Code and run `claude` again in your terminal
---
## Requirements
- Claude Code v1.0.80+
- Node.js 18+ or Bun
---
## Development
```bash
git clone https://github.com/jarrodwatts/claude-hud
cd claude-hud
npm ci && npm run build
npm test
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
---
## License
MIT — see [LICENSE](LICENSE)
---
## Star History
[](https://star-history.com/#jarrodwatts/claude-hud&Date)
================================================
FILE: RELEASING.md
================================================
# Releasing
This project ships as a Claude Code plugin. Releases should include compiled `dist/` output.
## Release Checklist
1) Update versions:
- `package.json`
- `.claude-plugin/plugin.json`
- `CHANGELOG.md`
2) Build:
```bash
npm ci
npm run build
npm test
npm run test:coverage
```
3) Verify plugin entrypoint:
- `.claude-plugin/plugin.json` points to `dist/index.js`
4) Commit and tag:
- `git tag vX.Y.Z`
5) Publish:
- Push tag
- Create GitHub release with notes from `CHANGELOG.md`
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Supported Versions
Security fixes are applied to the latest release series only.
## Reporting a Vulnerability
Please report security issues to: jarrodwttsyt@gmail.com
Include a clear description, reproduction steps, and any relevant logs or screenshots.
We will acknowledge receipt within 5 business days and provide a timeline for a fix if applicable.
================================================
FILE: SUPPORT.md
================================================
# Support Policy
This project is maintained on a best-effort basis.
## What We Support
- The latest release
- Claude Code versions documented in `README.md`
- Node.js 18+ or Bun
## How to Get Help
- Open a GitHub issue for bugs or feature requests
- For security issues, see `SECURITY.md`
We cannot guarantee response times, but we will triage issues as time allows.
================================================
FILE: TESTING.md
================================================
# Testing Strategy
This project is small, runs in a terminal, and is mostly deterministic. The testing strategy focuses on fast, reliable checks that validate core behavior and provide a safe merge gate for PRs.
## Goals
- Validate core logic (parsing, aggregation, formatting) deterministically.
- Catch regressions in the HUD output without relying on manual review.
- Keep test execution fast (<5s) to support frequent contributor runs.
## Test Layers
1) Unit tests (fast, deterministic)
- Pure helpers: `getContextPercent`, `getModelName`, token/elapsed formatting.
- Render helpers: string assembly and truncation behavior.
- Transcript parsing: tool/agent/todo aggregation and session start detection.
2) Integration tests (CLI behavior)
- Run the CLI with a sample stdin JSON and a fixture transcript.
- Validate that the rendered output contains expected markers (model, percent, tool names).
- Keep assertions resilient to minor formatting changes (avoid strict full-line matching).
3) Golden-output tests (near-term)
- For known fixtures, compare the full output snapshot to catch subtle UI regressions.
- Update snapshots only when intentional output changes are made.
## What to Test First
- Transcript parsing (tool use/result mapping, todo extraction).
- Context percent calculation (including cache tokens).
- Truncation and aggregation (tools/todos/agents display logic).
- Malformed or partial input (bad JSON lines, missing fields).
## Fixtures
- Keep shared test data under `tests/fixtures/`.
- Use small JSONL files that capture one behavior each (e.g., basic tool flow, agent lifecycle, todo updates).
## Running Tests Locally
```bash
npm test
```
This runs `npm run build` and then executes Node's built-in test runner.
To generate coverage:
```bash
npm run test:coverage
```
To update snapshots:
```bash
npm run test:update-snapshots
```
## CI Gate (recommended)
- `npm ci`
- `npm run build`
- `npm test`
The provided GitHub Actions workflow runs `npm run test:coverage` on Node 18 and 20.
These steps should be required in PR checks to ensure new changes do not regress existing behavior.
## Contributing Expectations
- Add or update tests for behavior changes.
- Prefer unit tests for new helpers and integration tests for user-visible output changes.
- Keep tests deterministic and avoid time-dependent assertions unless controlled.
================================================
FILE: commands/configure.md
================================================
---
description: Configure HUD display options (layout, presets, display elements) while preserving advanced manual overrides
allowed-tools: Read, Write, AskUserQuestion
---
# Configure Claude HUD
**FIRST**: Use the Read tool to load `~/.claude/plugins/claude-hud/config.json` if it exists.
Store current values and note whether config exists (determines which flow to use).
## Always On (Core Features)
These are always enabled and NOT configurable:
- Model name `[Opus]`
- Context bar `████░░░░░░ 45%`
Advanced settings such as `colors.*`, `pathLevels`, `display.usageThreshold`, and
`display.environmentThreshold` are preserved when saving but are not edited by this guided flow.
---
## Two Flows Based on Config State
### Flow A: New User (no config)
Questions: **Layout → Preset → Turn Off → Turn On**
### Flow B: Update Config (config exists)
Questions: **Turn Off → Turn On → Git Style → Layout/Reset → Custom Line** (5 questions max)
---
## Flow A: New User (5 Questions)
### Q1: Layout
- header: "Layout"
- question: "Choose your HUD layout:"
- multiSelect: false
- options:
- "Expanded (Recommended)" - Split into semantic lines (identity, project, environment, usage)
- "Compact" - Everything on one line
- "Compact + Separators" - One line with separator before activity
### Q2: Preset
- header: "Preset"
- question: "Choose a starting configuration:"
- multiSelect: false
- options:
- "Full" - Everything enabled (Recommended)
- "Essential" - Activity + git, minimal info
- "Minimal" - Core only (model, context bar)
### Q3: Turn Off (based on chosen preset)
- header: "Turn Off"
- question: "Disable any of these? (enabled by your preset)"
- multiSelect: true
- options: **ONLY items that are ON in the chosen preset** (max 4)
- "Tools activity" - ◐ Edit: file.ts | ✓ Read ×3
- "Agents status" - ◐ explore [haiku]: Finding code
- "Todo progress" - ▸ Fix bug (2/5 tasks)
- "Project name" - my-project path display
- "Git status" - git:(main*) branch indicator
- "Config counts" - 2 CLAUDE.md | 4 rules
- "Token breakdown" - (in: 45k, cache: 12k)
- "Output speed" - out: 42.1 tok/s
- "Usage limits" - 5h: 25% | 7d: 10%
- "Session duration" - ⏱️ 5m
- "Session name" - fix-auth-bug (session slug or custom title)
### Q4: Turn On (based on chosen preset)
- header: "Turn On"
- question: "Enable any of these? (disabled by your preset)"
- multiSelect: true
- options: **ONLY items that are OFF in the chosen preset** (max 4)
- (same list as above, filtered to OFF items)
**Note:** If preset has all items ON (Full), Q4 shows "Nothing to enable - Full preset has everything!"
If preset has all items OFF (Minimal), Q3 shows "Nothing to disable - Minimal preset is already minimal!"
### Q5: Custom Line (optional)
- header: "Custom Line"
- question: "Add a custom phrase to display in the HUD? (e.g. a motto, max 80 chars)"
- multiSelect: false
- options:
- "Skip" - No custom line
- "Enter custom text" - Ask user for their phrase via AskUserQuestion (free text input)
If user chooses "Enter custom text", use AskUserQuestion to get their text. Save as `display.customLine` in config.
---
## Flow B: Update Config (5 Questions)
### Q1: Turn Off
- header: "Turn Off"
- question: "What do you want to DISABLE? (currently enabled)"
- multiSelect: true
- options: **ONLY items currently ON** (max 4, prioritize Activity first)
- "Tools activity" - ◐ Edit: file.ts | ✓ Read ×3
- "Agents status" - ◐ explore [haiku]: Finding code
- "Todo progress" - ▸ Fix bug (2/5 tasks)
- "Project name" - my-project path display
- "Git status" - git:(main*) branch indicator
- "Session name" - fix-auth-bug (session slug or custom title)
- "Usage bar style" - ██░░ 25% visual bar (only if usageBarEnabled is true)
If more than 4 items ON, show Activity items (Tools, Agents, Todos, Project, Git) first.
Info items (Counts, Tokens, Usage, Speed, Duration) can be turned off via "Reset to Minimal" in Q4.
### Q2: Turn On
- header: "Turn On"
- question: "What do you want to ENABLE? (currently disabled)"
- multiSelect: true
- options: **ONLY items currently OFF** (max 4)
- "Config counts" - 2 CLAUDE.md | 4 rules
- "Token breakdown" - (in: 45k, cache: 12k)
- "Output speed" - out: 42.1 tok/s
- "Usage limits" - 5h: 25% | 7d: 10%
- "Usage bar style" - ██░░ 25% visual bar (only if usageBarEnabled is false)
- "Session name" - fix-auth-bug (session slug or custom title)
- "Session duration" - ⏱️ 5m
### Q3: Git Style (only if Git is currently enabled)
- header: "Git Style"
- question: "How much git info to show?"
- multiSelect: false
- options:
- "Branch only" - git:(main)
- "Branch + dirty" - git:(main*) shows uncommitted changes
- "Full details" - git:(main* ↑2 ↓1) includes ahead/behind
- "File stats" - git:(main* !2 +1 ?3) Starship-compatible format
**Skip Q3 if Git is OFF** - proceed to Q4.
### Q4: Layout/Reset
- header: "Layout/Reset"
- question: "Change layout or reset to preset?"
- multiSelect: false
- options:
- "Keep current" - No layout/preset changes (current: Expanded/Compact/Compact + Separators)
- "Switch to Expanded" - Split into semantic lines (if not current)
- "Switch to Compact" - Everything on one line (if not current)
- "Reset to Full" - Enable everything
- "Reset to Essential" - Activity + git only
### Q5: Custom Line (optional)
- header: "Custom Line"
- question: "Update your custom phrase? (currently: '{current customLine or none}')"
- multiSelect: false
- options:
- "Keep current" - No change (skip if no customLine set)
- "Enter custom text" - Set or update custom phrase (max 80 chars)
- "Remove" - Clear the custom line (only show if customLine is currently set)
If user chooses "Enter custom text", use AskUserQuestion to get their text. Save as `display.customLine` in config.
If user chooses "Remove", set `display.customLine` to `""` in config.
---
## Preset Definitions
**Full** (everything ON):
- Activity: Tools ON, Agents ON, Todos ON
- Info: Counts ON, Tokens ON, Usage ON, Duration ON, Session Name ON
- Git: ON (with dirty indicator, no ahead/behind)
**Essential** (activity + git):
- Activity: Tools ON, Agents ON, Todos ON
- Info: Counts OFF, Tokens OFF, Usage OFF, Duration ON, Session Name OFF
- Git: ON (with dirty indicator)
**Minimal** (core only — this is the default):
- Activity: Tools OFF, Agents OFF, Todos OFF
- Info: Counts OFF, Tokens OFF, Usage OFF, Duration OFF, Session Name OFF
- Git: ON (with dirty indicator)
---
## Layout Mapping
| Option | Config |
|--------|--------|
| Expanded | `lineLayout: "expanded", showSeparators: false` |
| Compact | `lineLayout: "compact", showSeparators: false` |
| Compact + Separators | `lineLayout: "compact", showSeparators: true` |
---
## Git Style Mapping
| Option | Config |
|--------|--------|
| Branch only | `gitStatus: { enabled: true, showDirty: false, showAheadBehind: false, showFileStats: false }` |
| Branch + dirty | `gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false }` |
| Full details | `gitStatus: { enabled: true, showDirty: true, showAheadBehind: true, showFileStats: false }` |
| File stats | `gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: true }` |
---
## Element Mapping
| Element | Config Key |
|---------|------------|
| Tools activity | `display.showTools` |
| Agents status | `display.showAgents` |
| Todo progress | `display.showTodos` |
| Project name | `display.showProject` |
| Git status | `gitStatus.enabled` |
| Config counts | `display.showConfigCounts` |
| Token breakdown | `display.showTokenBreakdown` |
| Output speed | `display.showSpeed` |
| Usage limits | `display.showUsage` |
| Usage bar style | `display.usageBarEnabled` |
| Session name | `display.showSessionName` |
| Session duration | `display.showDuration` |
| Custom line | `display.customLine` |
**Always true (not configurable):**
- `display.showModel: true`
- `display.showContextBar: true`
---
## Usage Style Mapping
| Option | Config |
|--------|--------|
| Bar style | `display.usageBarEnabled: true` — Shows `██░░ 25% (1h 30m / 5h)` |
| Text style | `display.usageBarEnabled: false` — Shows `5h: 25% (1h 30m)` |
**Note**: Usage style only applies when `display.showUsage: true`. When 7d usage >= 80%, it also shows with the same style.
---
## Processing Logic
### For New Users (Flow A):
1. Apply chosen preset as base
2. Apply Turn Off selections (set those items to OFF)
3. Apply Turn On selections (set those items to ON)
4. Apply chosen layout
### For Returning Users (Flow B):
1. Start from current config
2. Apply Turn Off selections (set to OFF, including usageBarEnabled if selected)
3. Apply Turn On selections (set to ON, including usageBarEnabled if selected)
4. Apply Git Style selection (if shown)
5. If "Reset to [preset]" selected, override with preset values
6. If layout change selected, apply it
---
## Before Writing - Validate & Preview
**GUARDS - Do NOT write config if:**
- User cancels (Esc) → say "Configuration cancelled."
- No changes from current config → say "No changes needed - config unchanged."
**Show preview before saving:**
1. **Summary of changes:**
```
Layout: Compact → Expanded
Git style: Branch + dirty
Changes:
- Usage limits: OFF → ON
- Config counts: ON → OFF
```
2. **Preview of HUD (Expanded layout):**
```
[Opus | Pro] │ my-project git:(main*)
Context ████░░░░░ 45% │ Usage ██░░░░░░░░ 25% (1h 30m / 5h)
◐ Edit: file.ts | ✓ Read ×3
▸ Fix auth bug (2/5)
```
**Preview of HUD (Compact layout):**
```
[Opus | Pro] ████░░░░░ 45% | my-project git:(main*) | 5h: 25% | ⏱️ 5m
◐ Edit: file.ts | ✓ Read ×3
▸ Fix auth bug (2/5)
```
3. **Confirm**: "Save these changes?"
---
## Write Configuration
Write to `~/.claude/plugins/claude-hud/config.json`.
Merge with existing config, preserving:
- `pathLevels` (not in configure flow)
- `display.usageThreshold` (advanced config)
- `display.environmentThreshold` (advanced config)
- `colors` (advanced manual palette overrides)
**Migration note**: Old configs with `layout: "default"` or `layout: "separators"` are automatically migrated to the new `lineLayout` + `showSeparators` format on load.
---
## After Writing
Say: "Configuration saved! The HUD will reflect your changes immediately."
================================================
FILE: commands/setup.md
================================================
---
description: Configure claude-hud as your statusline
allowed-tools: Bash, Read, Edit, AskUserQuestion
---
**Note**: Placeholders like `{RUNTIME_PATH}`, `{SOURCE}`, and `{GENERATED_COMMAND}` should be substituted with actual detected values.
## Step 0: Detect Ghost Installation (Run First)
Check for inconsistent plugin state that can occur after failed installations:
**macOS/Linux**:
```bash
# Check 1: Cache exists?
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
CACHE_EXISTS=$(ls -d "$CLAUDE_DIR/plugins/cache/claude-hud" 2>/dev/null && echo "YES" || echo "NO")
# Check 2: Registry entry exists?
REGISTRY_EXISTS=$(grep -q "claude-hud" "$CLAUDE_DIR/plugins/installed_plugins.json" 2>/dev/null && echo "YES" || echo "NO")
# Check 3: Temp files left behind?
TEMP_FILES=$(ls -d "$CLAUDE_DIR/plugins/cache/temp_local_"* 2>/dev/null | head -1)
echo "Cache: $CACHE_EXISTS | Registry: $REGISTRY_EXISTS | Temp: ${TEMP_FILES:-none}"
```
**Windows (PowerShell)**:
```powershell
$cache = Test-Path "$env:USERPROFILE\.claude\plugins\cache\claude-hud"
$registry = (Get-Content "$env:USERPROFILE\.claude\plugins\installed_plugins.json" -ErrorAction SilentlyContinue) -match "claude-hud"
$temp = Get-ChildItem "$env:USERPROFILE\.claude\plugins\cache\temp_local_*" -ErrorAction SilentlyContinue
Write-Host "Cache: $cache | Registry: $registry | Temp: $($temp.Count) files"
```
### Interpreting Results
| Cache | Registry | Meaning | Action |
|-------|----------|---------|--------|
| YES | YES | Normal install (may still be broken) | Continue to Step 1 |
| YES | NO | Ghost install - cache orphaned | Clean up cache |
| NO | YES | Ghost install - registry stale | Clean up registry |
| NO | NO | Not installed | Continue to Step 1 |
If **temp files exist**, a previous install was interrupted. Clean them up.
### Cleanup Commands
If ghost installation detected, ask user if they want to reset. If yes:
**macOS/Linux**:
```bash
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
# Remove orphaned cache
rm -rf "$CLAUDE_DIR/plugins/cache/claude-hud"
# Remove temp files from failed installs
rm -rf "$CLAUDE_DIR/plugins/cache/temp_local_"*
# Reset registry (removes ALL plugins - warn user first!)
# Only run if user confirms they have no other plugins they want to keep:
echo '{"version": 2, "plugins": {}}' > "$CLAUDE_DIR/plugins/installed_plugins.json"
```
**Windows (PowerShell)**:
```powershell
# Remove orphaned cache
Remove-Item -Recurse -Force "$env:USERPROFILE\.claude\plugins\cache\claude-hud" -ErrorAction SilentlyContinue
# Remove temp files
Remove-Item -Recurse -Force "$env:USERPROFILE\.claude\plugins\cache\temp_local_*" -ErrorAction SilentlyContinue
# Reset registry (removes ALL plugins - warn user first!)
'{"version": 2, "plugins": {}}' | Set-Content "$env:USERPROFILE\.claude\plugins\installed_plugins.json"
```
After cleanup, tell user to **restart Claude Code** and run `/plugin install claude-hud` again.
### Linux: Cross-Device Filesystem Check
**On Linux only**, if install keeps failing, check for EXDEV issue:
```bash
[ "$(df --output=source ~ /tmp 2>/dev/null | tail -2 | uniq | wc -l)" = "2" ] && echo "CROSS_DEVICE"
```
If this outputs `CROSS_DEVICE`, `/tmp` and home are on different filesystems. This causes `EXDEV: cross-device link not permitted` during installation. Workaround:
```bash
mkdir -p ~/.cache/tmp && TMPDIR=~/.cache/tmp claude /plugin install claude-hud
```
This is a [Claude Code platform limitation](https://github.com/anthropics/claude-code/issues/14799).
---
## Step 1: Detect Platform, Shell, and Runtime
**IMPORTANT**: Use the environment context values (`Platform:` and `Shell:`), not `uname -s` or ad-hoc checks. The Bash tool may report MINGW/MSYS on Windows, so branch only by the context values.
| Platform | Shell | Command Format |
|----------|-------|----------------|
| `darwin` | any | bash (macOS instructions) |
| `linux` | any | bash (Linux instructions) |
| `win32` | `bash` (Git Bash, MSYS2) | bash (use macOS/Linux instructions) |
| `win32` | `powershell`, `pwsh`, or `cmd` | PowerShell (use Windows instructions) |
---
**macOS/Linux** (Platform: `darwin` or `linux`):
1. Get plugin path (sorted by dotted numeric version, not modification time):
```bash
ls -d "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/claude-hud/claude-hud/*/ 2>/dev/null | awk -F/ '{ print $(NF-1) "\t" $0 }' | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1 | cut -f2-
```
If empty, the plugin is not installed. Go back to Step 0 to check for ghost installation or EXDEV issues. If Step 0 was clean, tell user to install via `/plugin install claude-hud` first.
2. Get runtime absolute path (prefer bun for performance, fallback to node):
```bash
command -v bun 2>/dev/null || command -v node 2>/dev/null
```
If empty, stop and tell user to install Node.js or Bun.
3. Verify the runtime exists:
```bash
ls -la {RUNTIME_PATH}
```
If it doesn't exist, re-detect or ask user to verify their installation.
4. Determine source file based on runtime:
```bash
basename {RUNTIME_PATH}
```
If result is "bun", use `src/index.ts` (bun has native TypeScript support). Otherwise use `dist/index.js` (pre-compiled).
5. Generate command (quotes around runtime path handle spaces):
```
bash -c 'plugin_dir=$(ls -d "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/claude-hud/claude-hud/*/ 2>/dev/null | awk -F/ '"'"'{ print $(NF-1) "\t" $0 }'"'"' | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1 | cut -f2-); exec "{RUNTIME_PATH}" "${plugin_dir}{SOURCE}"'
```
**Windows** (Platform: `win32`):
Choose instructions by `Shell:` value before running any commands:
- `Shell: bash` -> use the macOS/Linux section above (same command format).
- `Shell: powershell`, `pwsh`, or `cmd` -> use the Windows PowerShell section below.
- Any other shell value -> stop and ask the user which shell launched Claude Code.
1. Get plugin path:
```powershell
(Get-ChildItem "$env:USERPROFILE\.claude\plugins\cache\claude-hud\claude-hud" -Directory | Where-Object { $_.Name -match '^\d+(\.\d+)+$' } | Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1).FullName
```
If empty or errors, the plugin is not installed. Tell user to install via marketplace first.
2. Get runtime absolute path (prefer bun, fallback to node):
```powershell
if (Get-Command bun -ErrorAction SilentlyContinue) { (Get-Command bun).Source } elseif (Get-Command node -ErrorAction SilentlyContinue) { (Get-Command node).Source } else { Write-Error "Neither bun nor node found" }
```
If neither found, stop and tell user to install Node.js or Bun.
3. Check if runtime is bun (by filename). If bun, use `src\index.ts`. Otherwise use `dist\index.js`.
4. Generate command (note: quotes around runtime path handle spaces in paths):
```
powershell -Command "& {$p=(Get-ChildItem $env:USERPROFILE\.claude\plugins\cache\claude-hud\claude-hud -Directory | Where-Object { $_.Name -match '^\d+(\.\d+)+$' } | Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1).FullName; & '{RUNTIME_PATH}' (Join-Path $p '{SOURCE}')}"
```
**WSL (Windows Subsystem for Linux)**: If running in WSL, use the macOS/Linux instructions. Ensure the plugin is installed in the Linux environment (`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/...`), not the Windows side.
## Step 2: Test Command
Run the generated command. It should produce output (the HUD lines) within a few seconds.
- If it errors, do not proceed to Step 3.
- If it hangs for more than a few seconds, cancel and debug.
- This test catches issues like broken runtime binaries, missing plugins, or path problems.
## Step 3: Apply Configuration
Read the settings file and merge in the statusLine config, preserving all existing settings:
- **Platform `darwin` or `linux`, or Platform `win32` + Shell `bash`**: `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json`
- **Platform `win32` + Shell `powershell`, `pwsh`, or `cmd`**: `$env:USERPROFILE\.claude\settings.json`
If the file doesn't exist, create it. If it contains invalid JSON, report the error and do not overwrite.
If a write fails with `File has been unexpectedly modified`, re-read the file and retry the merge once.
```json
{
"statusLine": {
"type": "command",
"command": "{GENERATED_COMMAND}"
}
}
```
After successfully writing the config, tell the user:
> ✅ Config written. **Please restart Claude Code now** — quit and run `claude` again in your terminal.
> Once restarted, run `/claude-hud:setup` again to complete Step 4 and verify the HUD is working.
**Note**: The generated command dynamically finds and runs the latest installed plugin version. Updates are automatic - no need to re-run setup after plugin updates. If the HUD suddenly stops working, re-run `/claude-hud:setup` to verify the plugin is still installed.
## Step 4: Optional Features
After the statusLine is applied, ask the user if they'd like to enable additional HUD features beyond the default 2-line display.
Use AskUserQuestion:
- header: "Extras"
- question: "Enable any optional HUD features? (all hidden by default)"
- multiSelect: true
- options:
- "Tools activity" — Shows running/completed tools (◐ Edit: file.ts | ✓ Read ×3)
- "Agents & Todos" — Shows subagent status and todo progress
- "Session info" — Shows session duration and config counts (CLAUDE.md, rules, MCPs)
- "Session name" — Shows session slug or custom title from /rename
- "Custom line" — Display a custom phrase in the HUD
**If user selects any options**, write `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/claude-hud/config.json` (create directories if needed):
| Selection | Config keys |
|-----------|------------|
| Tools activity | `display.showTools: true` |
| Agents & Todos | `display.showAgents: true, display.showTodos: true` |
| Session info | `display.showDuration: true, display.showConfigCounts: true` |
| Session name | `display.showSessionName: true` |
| Custom line | `display.customLine: ""` — ask user for the text (max 80 chars) |
Merge with existing config if the file already exists. Only write keys the user selected — don't write `false` for unselected items (defaults handle that).
**If user selects nothing** (or picks "Other" and says skip/none), do not create a config file. The defaults are fine.
---
## Step 5: Verify & Finish
**First, confirm the user has restarted Claude Code** since Step 3 wrote the config. If they haven't, ask them to restart before proceeding — the HUD cannot appear in the same session where setup was run.
Use AskUserQuestion:
- Question: "Setup complete! The HUD should appear below your input field. Is it working?"
- Options: "Yes, it's working" / "No, something's wrong"
**If yes**: Ask the user if they'd like to ⭐ star the claude-hud repository on GitHub to support the project. If they agree and `gh` CLI is available, run: `gh api -X PUT /user/starred/jarrodwatts/claude-hud`. Only run the star command if they explicitly say yes.
**If no**: Debug systematically:
1. **Restart Claude Code** (most common cause on macOS):
- The statusLine config requires a restart to take effect
- Quit Claude Code completely and run `claude` again, then re-run `/claude-hud:setup` to verify
- If you've already restarted, continue below
2. **Verify config was applied**:
- Read settings file (`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json` or `$env:USERPROFILE\.claude\settings.json` on Windows)
- Check statusLine.command exists and looks correct
- If command contains a hardcoded version path (not using the dynamic version-lookup command), it may be a stale config from a previous setup
3. **Test the command manually** and capture error output:
```bash
{GENERATED_COMMAND} 2>&1
```
4. **Common issues to check**:
**"command not found" or empty output**:
- Runtime path might be wrong: `ls -la {RUNTIME_PATH}`
- On macOS with mise/nvm/asdf: the absolute path may have changed after a runtime update
- Symlinks may be stale: `command -v node` often returns a symlink that can break after version updates
- Solution: re-detect with `command -v bun` or `command -v node`, and verify with `realpath {RUNTIME_PATH}` (or `readlink -f {RUNTIME_PATH}`) to get the true absolute path
**"No such file or directory" for plugin**:
- Plugin might not be installed: `ls "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/cache/claude-hud/"`
- Solution: reinstall plugin via marketplace
**Windows shell mismatch (for example, "bash not recognized")**:
- Command format does not match `Platform:` + `Shell:`
- Solution: re-run Step 1 branch logic and use the matching variant
**Windows: PowerShell execution policy error**:
- Run: `Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned`
**Permission denied**:
- Runtime not executable: `chmod +x {RUNTIME_PATH}`
**WSL confusion**:
- If using WSL, ensure plugin is installed in Linux environment, not Windows
- Check: `ls "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/cache/claude-hud/"`
5. **If still stuck**: Show the user the exact command that was generated and the error, so they can report it or debug further
================================================
FILE: package.json
================================================
{
"name": "claude-hud",
"version": "0.0.10",
"description": "Real-time statusline HUD for Claude Code",
"type": "module",
"main": "dist/index.js",
"files": [
"dist/",
"src/",
"commands/",
".claude-plugin/"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "npm run build && node --test",
"test:coverage": "npm run build && c8 --reporter=text --reporter=lcov node --test",
"test:update-snapshots": "UPDATE_SNAPSHOTS=1 npm test",
"test:stdin": "echo '{\"model\":{\"display_name\":\"Opus\"},\"context_window\":{\"current_usage\":{\"input_tokens\":45000},\"context_window_size\":200000},\"transcript_path\":\"/tmp/test.jsonl\"}' | node dist/index.js"
},
"keywords": [
"claude-code",
"statusline",
"hud"
],
"author": "Jarrod Watts",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@types/node": "^25.5.0",
"c8": "^11.0.0",
"typescript": "^5.0.0"
}
}
================================================
FILE: src/claude-config-dir.ts
================================================
import * as path from 'node:path';
function expandHomeDirPrefix(inputPath: string, homeDir: string): string {
if (inputPath === '~') {
return homeDir;
}
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
return path.join(homeDir, inputPath.slice(2));
}
return inputPath;
}
export function getClaudeConfigDir(homeDir: string): string {
const envConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim();
if (!envConfigDir) {
return path.join(homeDir, '.claude');
}
return path.resolve(expandHomeDirPrefix(envConfigDir, homeDir));
}
export function getClaudeConfigJsonPath(homeDir: string): string {
return `${getClaudeConfigDir(homeDir)}.json`;
}
export function getHudPluginDir(homeDir: string): string {
return path.join(getClaudeConfigDir(homeDir), 'plugins', 'claude-hud');
}
================================================
FILE: src/config-reader.ts
================================================
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createDebug } from './debug.js';
import { getClaudeConfigDir, getClaudeConfigJsonPath } from './claude-config-dir.js';
const debug = createDebug('config');
export interface ConfigCounts {
claudeMdCount: number;
rulesCount: number;
mcpCount: number;
hooksCount: number;
}
// Valid keys for disabled MCP arrays in config files
type DisabledMcpKey = 'disabledMcpServers' | 'disabledMcpjsonServers';
function getMcpServerNames(filePath: string): Set {
if (!fs.existsSync(filePath)) return new Set();
try {
const content = fs.readFileSync(filePath, 'utf8');
const config = JSON.parse(content);
if (config.mcpServers && typeof config.mcpServers === 'object') {
return new Set(Object.keys(config.mcpServers));
}
} catch (error) {
debug(`Failed to read MCP servers from ${filePath}:`, error);
}
return new Set();
}
function getDisabledMcpServers(filePath: string, key: DisabledMcpKey): Set {
if (!fs.existsSync(filePath)) return new Set();
try {
const content = fs.readFileSync(filePath, 'utf8');
const config = JSON.parse(content);
if (Array.isArray(config[key])) {
const validNames = config[key].filter((s: unknown) => typeof s === 'string');
if (validNames.length !== config[key].length) {
debug(`${key} in ${filePath} contains non-string values, ignoring them`);
}
return new Set(validNames);
}
} catch (error) {
debug(`Failed to read ${key} from ${filePath}:`, error);
}
return new Set();
}
function countMcpServersInFile(filePath: string, excludeFrom?: string): number {
const servers = getMcpServerNames(filePath);
if (excludeFrom) {
const exclude = getMcpServerNames(excludeFrom);
for (const name of exclude) {
servers.delete(name);
}
}
return servers.size;
}
function countHooksInFile(filePath: string): number {
if (!fs.existsSync(filePath)) return 0;
try {
const content = fs.readFileSync(filePath, 'utf8');
const config = JSON.parse(content);
if (config.hooks && typeof config.hooks === 'object') {
return Object.keys(config.hooks).length;
}
} catch (error) {
debug(`Failed to read hooks from ${filePath}:`, error);
}
return 0;
}
function countRulesInDir(rulesDir: string): number {
if (!fs.existsSync(rulesDir)) return 0;
let count = 0;
try {
const entries = fs.readdirSync(rulesDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(rulesDir, entry.name);
if (entry.isDirectory()) {
count += countRulesInDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
count++;
}
}
} catch (error) {
debug(`Failed to read rules from ${rulesDir}:`, error);
}
return count;
}
function normalizePathForComparison(inputPath: string): string {
let normalized = path.normalize(path.resolve(inputPath));
const root = path.parse(normalized).root;
while (normalized.length > root.length && normalized.endsWith(path.sep)) {
normalized = normalized.slice(0, -1);
}
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function pathsReferToSameLocation(pathA: string, pathB: string): boolean {
if (normalizePathForComparison(pathA) === normalizePathForComparison(pathB)) {
return true;
}
if (!fs.existsSync(pathA) || !fs.existsSync(pathB)) {
return false;
}
try {
const realPathA = fs.realpathSync.native(pathA);
const realPathB = fs.realpathSync.native(pathB);
return normalizePathForComparison(realPathA) === normalizePathForComparison(realPathB);
} catch {
return false;
}
}
export async function countConfigs(cwd?: string): Promise {
let claudeMdCount = 0;
let rulesCount = 0;
let hooksCount = 0;
const homeDir = os.homedir();
const claudeDir = getClaudeConfigDir(homeDir);
// Collect all MCP servers across scopes, then subtract disabled ones
const userMcpServers = new Set();
const projectMcpServers = new Set();
// === USER SCOPE ===
// ~/.claude/CLAUDE.md
if (fs.existsSync(path.join(claudeDir, 'CLAUDE.md'))) {
claudeMdCount++;
}
// ~/.claude/rules/*.md
rulesCount += countRulesInDir(path.join(claudeDir, 'rules'));
// ~/.claude/settings.json (MCPs and hooks)
const userSettings = path.join(claudeDir, 'settings.json');
for (const name of getMcpServerNames(userSettings)) {
userMcpServers.add(name);
}
hooksCount += countHooksInFile(userSettings);
// {CLAUDE_CONFIG_DIR}.json (additional user-scope MCPs)
const userClaudeJson = getClaudeConfigJsonPath(homeDir);
for (const name of getMcpServerNames(userClaudeJson)) {
userMcpServers.add(name);
}
// Get disabled user-scope MCPs from ~/.claude.json
const disabledUserMcps = getDisabledMcpServers(userClaudeJson, 'disabledMcpServers');
for (const name of disabledUserMcps) {
userMcpServers.delete(name);
}
// === PROJECT SCOPE ===
// Avoid double-counting when project .claude directory is the same location as user scope.
const projectClaudeDir = cwd ? path.join(cwd, '.claude') : null;
const projectClaudeOverlapsUserScope = projectClaudeDir
? pathsReferToSameLocation(projectClaudeDir, claudeDir)
: false;
if (cwd) {
// {cwd}/CLAUDE.md
if (fs.existsSync(path.join(cwd, 'CLAUDE.md'))) {
claudeMdCount++;
}
// {cwd}/CLAUDE.local.md
if (fs.existsSync(path.join(cwd, 'CLAUDE.local.md'))) {
claudeMdCount++;
}
// {cwd}/.claude/CLAUDE.md (alternative location, skip when it is user scope)
if (!projectClaudeOverlapsUserScope && fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.md'))) {
claudeMdCount++;
}
// {cwd}/.claude/CLAUDE.local.md
if (fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.local.md'))) {
claudeMdCount++;
}
// {cwd}/.claude/rules/*.md (recursive)
// Skip when it overlaps with user-scope rules.
if (!projectClaudeOverlapsUserScope) {
rulesCount += countRulesInDir(path.join(cwd, '.claude', 'rules'));
}
// {cwd}/.mcp.json (project MCP config) - tracked separately for disabled filtering
const mcpJsonServers = getMcpServerNames(path.join(cwd, '.mcp.json'));
// {cwd}/.claude/settings.json (project settings)
// Skip when it overlaps with user-scope settings.
const projectSettings = path.join(cwd, '.claude', 'settings.json');
if (!projectClaudeOverlapsUserScope) {
for (const name of getMcpServerNames(projectSettings)) {
projectMcpServers.add(name);
}
hooksCount += countHooksInFile(projectSettings);
}
// {cwd}/.claude/settings.local.json (local project settings)
const localSettings = path.join(cwd, '.claude', 'settings.local.json');
for (const name of getMcpServerNames(localSettings)) {
projectMcpServers.add(name);
}
hooksCount += countHooksInFile(localSettings);
// Get disabled .mcp.json servers from settings.local.json
const disabledMcpJsonServers = getDisabledMcpServers(localSettings, 'disabledMcpjsonServers');
for (const name of disabledMcpJsonServers) {
mcpJsonServers.delete(name);
}
// Add remaining .mcp.json servers to project set
for (const name of mcpJsonServers) {
projectMcpServers.add(name);
}
}
// Total MCP count = user servers + project servers
// Note: Deduplication only occurs within each scope, not across scopes.
// A server with the same name in both user and project scope counts as 2 (separate configs).
const mcpCount = userMcpServers.size + projectMcpServers.size;
return { claudeMdCount, rulesCount, mcpCount, hooksCount };
}
================================================
FILE: src/config.ts
================================================
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { getHudPluginDir } from './claude-config-dir.js';
export type LineLayoutType = 'compact' | 'expanded';
export type AutocompactBufferMode = 'enabled' | 'disabled';
export type ContextValueMode = 'percent' | 'tokens' | 'remaining';
export type HudElement = 'project' | 'context' | 'usage' | 'environment' | 'tools' | 'agents' | 'todos';
export type HudColorName =
| 'red'
| 'green'
| 'yellow'
| 'magenta'
| 'cyan'
| 'brightBlue'
| 'brightMagenta';
/** A color value: named preset, 256-color index (0-255), or hex string (#rrggbb). */
export type HudColorValue = HudColorName | number | string;
export interface HudColorOverrides {
context: HudColorValue;
usage: HudColorValue;
warning: HudColorValue;
usageWarning: HudColorValue;
critical: HudColorValue;
}
export const DEFAULT_ELEMENT_ORDER: HudElement[] = [
'project',
'context',
'usage',
'environment',
'tools',
'agents',
'todos',
];
const KNOWN_ELEMENTS = new Set(DEFAULT_ELEMENT_ORDER);
export interface HudConfig {
lineLayout: LineLayoutType;
showSeparators: boolean;
pathLevels: 1 | 2 | 3;
elementOrder: HudElement[];
gitStatus: {
enabled: boolean;
showDirty: boolean;
showAheadBehind: boolean;
showFileStats: boolean;
};
display: {
showModel: boolean;
showProject: boolean;
showContextBar: boolean;
contextValue: ContextValueMode;
showConfigCounts: boolean;
showDuration: boolean;
showSpeed: boolean;
showTokenBreakdown: boolean;
showUsage: boolean;
usageBarEnabled: boolean;
showTools: boolean;
showAgents: boolean;
showTodos: boolean;
showSessionName: boolean;
autocompactBuffer: AutocompactBufferMode;
usageThreshold: number;
sevenDayThreshold: number;
environmentThreshold: number;
customLine: string;
};
usage: {
cacheTtlSeconds: number;
failureCacheTtlSeconds: number;
};
colors: HudColorOverrides;
}
export const DEFAULT_CONFIG: HudConfig = {
lineLayout: 'expanded',
showSeparators: false,
pathLevels: 1,
elementOrder: [...DEFAULT_ELEMENT_ORDER],
gitStatus: {
enabled: true,
showDirty: true,
showAheadBehind: false,
showFileStats: false,
},
display: {
showModel: true,
showProject: true,
showContextBar: true,
contextValue: 'percent',
showConfigCounts: false,
showDuration: false,
showSpeed: false,
showTokenBreakdown: true,
showUsage: true,
usageBarEnabled: true,
showTools: false,
showAgents: false,
showTodos: false,
showSessionName: false,
autocompactBuffer: 'enabled',
usageThreshold: 0,
sevenDayThreshold: 80,
environmentThreshold: 0,
customLine: '',
},
usage: {
cacheTtlSeconds: 60,
failureCacheTtlSeconds: 15,
},
colors: {
context: 'green',
usage: 'brightBlue',
warning: 'yellow',
usageWarning: 'brightMagenta',
critical: 'red',
},
};
export function getConfigPath(): string {
const homeDir = os.homedir();
return path.join(getHudPluginDir(homeDir), 'config.json');
}
function validatePathLevels(value: unknown): value is 1 | 2 | 3 {
return value === 1 || value === 2 || value === 3;
}
function validateLineLayout(value: unknown): value is LineLayoutType {
return value === 'compact' || value === 'expanded';
}
function validateAutocompactBuffer(value: unknown): value is AutocompactBufferMode {
return value === 'enabled' || value === 'disabled';
}
function validateContextValue(value: unknown): value is ContextValueMode {
return value === 'percent' || value === 'tokens' || value === 'remaining';
}
function validateColorName(value: unknown): value is HudColorName {
return value === 'red'
|| value === 'green'
|| value === 'yellow'
|| value === 'magenta'
|| value === 'cyan'
|| value === 'brightBlue'
|| value === 'brightMagenta';
}
const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
function validateColorValue(value: unknown): value is HudColorValue {
if (validateColorName(value)) return true;
if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255) return true;
if (typeof value === 'string' && HEX_COLOR_PATTERN.test(value)) return true;
return false;
}
function validateElementOrder(value: unknown): HudElement[] {
if (!Array.isArray(value) || value.length === 0) {
return [...DEFAULT_ELEMENT_ORDER];
}
const seen = new Set();
const elementOrder: HudElement[] = [];
for (const item of value) {
if (typeof item !== 'string' || !KNOWN_ELEMENTS.has(item as HudElement)) {
continue;
}
const element = item as HudElement;
if (seen.has(element)) {
continue;
}
seen.add(element);
elementOrder.push(element);
}
return elementOrder.length > 0 ? elementOrder : [...DEFAULT_ELEMENT_ORDER];
}
interface LegacyConfig {
layout?: 'default' | 'separators' | Record;
}
function migrateConfig(userConfig: Partial & LegacyConfig): Partial {
const migrated = { ...userConfig } as Partial & LegacyConfig;
if ('layout' in userConfig && !('lineLayout' in userConfig)) {
if (typeof userConfig.layout === 'string') {
// Legacy string migration (v0.0.x → v0.1.x)
if (userConfig.layout === 'separators') {
migrated.lineLayout = 'compact';
migrated.showSeparators = true;
} else {
migrated.lineLayout = 'compact';
migrated.showSeparators = false;
}
} else if (typeof userConfig.layout === 'object' && userConfig.layout !== null) {
// Object layout written by third-party tools — extract nested fields
const obj = userConfig.layout as Record;
if (typeof obj.lineLayout === 'string') migrated.lineLayout = obj.lineLayout as any;
if (typeof obj.showSeparators === 'boolean') migrated.showSeparators = obj.showSeparators;
if (typeof obj.pathLevels === 'number') migrated.pathLevels = obj.pathLevels as any;
}
delete migrated.layout;
}
return migrated;
}
function validateThreshold(value: unknown, max = 100): number {
if (typeof value !== 'number') return 0;
return Math.max(0, Math.min(max, value));
}
function validatePositiveInt(value: unknown, defaultValue: number): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return defaultValue;
return value;
}
export function mergeConfig(userConfig: Partial): HudConfig {
const migrated = migrateConfig(userConfig);
const lineLayout = validateLineLayout(migrated.lineLayout)
? migrated.lineLayout
: DEFAULT_CONFIG.lineLayout;
const showSeparators = typeof migrated.showSeparators === 'boolean'
? migrated.showSeparators
: DEFAULT_CONFIG.showSeparators;
const pathLevels = validatePathLevels(migrated.pathLevels)
? migrated.pathLevels
: DEFAULT_CONFIG.pathLevels;
const elementOrder = validateElementOrder(migrated.elementOrder);
const gitStatus = {
enabled: typeof migrated.gitStatus?.enabled === 'boolean'
? migrated.gitStatus.enabled
: DEFAULT_CONFIG.gitStatus.enabled,
showDirty: typeof migrated.gitStatus?.showDirty === 'boolean'
? migrated.gitStatus.showDirty
: DEFAULT_CONFIG.gitStatus.showDirty,
showAheadBehind: typeof migrated.gitStatus?.showAheadBehind === 'boolean'
? migrated.gitStatus.showAheadBehind
: DEFAULT_CONFIG.gitStatus.showAheadBehind,
showFileStats: typeof migrated.gitStatus?.showFileStats === 'boolean'
? migrated.gitStatus.showFileStats
: DEFAULT_CONFIG.gitStatus.showFileStats,
};
const display = {
showModel: typeof migrated.display?.showModel === 'boolean'
? migrated.display.showModel
: DEFAULT_CONFIG.display.showModel,
showProject: typeof migrated.display?.showProject === 'boolean'
? migrated.display.showProject
: DEFAULT_CONFIG.display.showProject,
showContextBar: typeof migrated.display?.showContextBar === 'boolean'
? migrated.display.showContextBar
: DEFAULT_CONFIG.display.showContextBar,
contextValue: validateContextValue(migrated.display?.contextValue)
? migrated.display.contextValue
: DEFAULT_CONFIG.display.contextValue,
showConfigCounts: typeof migrated.display?.showConfigCounts === 'boolean'
? migrated.display.showConfigCounts
: DEFAULT_CONFIG.display.showConfigCounts,
showDuration: typeof migrated.display?.showDuration === 'boolean'
? migrated.display.showDuration
: DEFAULT_CONFIG.display.showDuration,
showSpeed: typeof migrated.display?.showSpeed === 'boolean'
? migrated.display.showSpeed
: DEFAULT_CONFIG.display.showSpeed,
showTokenBreakdown: typeof migrated.display?.showTokenBreakdown === 'boolean'
? migrated.display.showTokenBreakdown
: DEFAULT_CONFIG.display.showTokenBreakdown,
showUsage: typeof migrated.display?.showUsage === 'boolean'
? migrated.display.showUsage
: DEFAULT_CONFIG.display.showUsage,
usageBarEnabled: typeof migrated.display?.usageBarEnabled === 'boolean'
? migrated.display.usageBarEnabled
: DEFAULT_CONFIG.display.usageBarEnabled,
showTools: typeof migrated.display?.showTools === 'boolean'
? migrated.display.showTools
: DEFAULT_CONFIG.display.showTools,
showAgents: typeof migrated.display?.showAgents === 'boolean'
? migrated.display.showAgents
: DEFAULT_CONFIG.display.showAgents,
showTodos: typeof migrated.display?.showTodos === 'boolean'
? migrated.display.showTodos
: DEFAULT_CONFIG.display.showTodos,
showSessionName: typeof migrated.display?.showSessionName === 'boolean'
? migrated.display.showSessionName
: DEFAULT_CONFIG.display.showSessionName,
autocompactBuffer: validateAutocompactBuffer(migrated.display?.autocompactBuffer)
? migrated.display.autocompactBuffer
: DEFAULT_CONFIG.display.autocompactBuffer,
usageThreshold: validateThreshold(migrated.display?.usageThreshold, 100),
sevenDayThreshold: validateThreshold(migrated.display?.sevenDayThreshold, 100),
environmentThreshold: validateThreshold(migrated.display?.environmentThreshold, 100),
customLine: typeof migrated.display?.customLine === 'string'
? migrated.display.customLine.slice(0, 80)
: DEFAULT_CONFIG.display.customLine,
};
const usage = {
cacheTtlSeconds: validatePositiveInt(
migrated.usage?.cacheTtlSeconds,
DEFAULT_CONFIG.usage.cacheTtlSeconds
),
failureCacheTtlSeconds: validatePositiveInt(
migrated.usage?.failureCacheTtlSeconds,
DEFAULT_CONFIG.usage.failureCacheTtlSeconds
),
};
const colors = {
context: validateColorValue(migrated.colors?.context)
? migrated.colors.context
: DEFAULT_CONFIG.colors.context,
usage: validateColorValue(migrated.colors?.usage)
? migrated.colors.usage
: DEFAULT_CONFIG.colors.usage,
warning: validateColorValue(migrated.colors?.warning)
? migrated.colors.warning
: DEFAULT_CONFIG.colors.warning,
usageWarning: validateColorValue(migrated.colors?.usageWarning)
? migrated.colors.usageWarning
: DEFAULT_CONFIG.colors.usageWarning,
critical: validateColorValue(migrated.colors?.critical)
? migrated.colors.critical
: DEFAULT_CONFIG.colors.critical,
};
return { lineLayout, showSeparators, pathLevels, elementOrder, gitStatus, display, usage, colors };
}
export async function loadConfig(): Promise {
const configPath = getConfigPath();
try {
if (!fs.existsSync(configPath)) {
return DEFAULT_CONFIG;
}
const content = fs.readFileSync(configPath, 'utf-8');
const userConfig = JSON.parse(content) as Partial;
return mergeConfig(userConfig);
} catch {
return DEFAULT_CONFIG;
}
}
================================================
FILE: src/constants.ts
================================================
/**
* Autocompact buffer percentage.
*
* NOTE: This value is applied as a percentage of Claude Code's reported
* context window size. The `33k/200k` example is just the 200k-window case.
* It is empirically derived from current Claude Code `/context` output, is
* not officially documented by Anthropic, and may need adjustment if users
* report mismatches in future Claude Code versions.
*/
export const AUTOCOMPACT_BUFFER_PERCENT = 0.165;
================================================
FILE: src/debug.ts
================================================
// Shared debug logging utility
// Enable via: DEBUG=claude-hud or DEBUG=*
const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*';
/**
* Create a namespaced debug logger
* @param namespace - Tag for log messages (e.g., 'config', 'usage')
*/
export function createDebug(namespace: string) {
return function debug(msg: string, ...args: unknown[]): void {
if (DEBUG) {
console.error(`[claude-hud:${namespace}] ${msg}`, ...args);
}
};
}
================================================
FILE: src/extra-cmd.ts
================================================
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
const MAX_BUFFER = 10 * 1024; // 10KB - plenty for a label
const MAX_LABEL_LENGTH = 50;
const TIMEOUT_MS = 3000;
const isDebug = process.env.DEBUG?.includes('claude-hud') ?? false;
function debug(message: string): void {
if (isDebug) {
console.error(`[claude-hud:extra-cmd] ${message}`);
}
}
export interface ExtraLabel {
label: string;
}
/**
* Sanitize output to prevent terminal escape injection.
* Strips ANSI escapes, OSC sequences, control characters, and bidi controls.
*/
export function sanitize(input: string): string {
return input
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') // OSC sequences
.replace(/\x1B[@-Z\\-_]/g, '') // 7-bit C1 / ESC Fe
.replace(/[\u0000-\u001F\u007F-\u009F]/g, '') // C0/C1 controls
.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069\u206A-\u206F]/g, ''); // bidi
}
/**
* Parse --extra-cmd argument from process.argv
* Supports both: --extra-cmd "command" and --extra-cmd="command"
*/
export function parseExtraCmdArg(argv: string[] = process.argv): string | null {
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
// Handle --extra-cmd=value syntax
if (arg.startsWith('--extra-cmd=')) {
const value = arg.slice('--extra-cmd='.length);
if (value === '') {
debug('Warning: --extra-cmd value is empty, ignoring');
return null;
}
return value;
}
// Handle --extra-cmd value syntax
if (arg === '--extra-cmd') {
if (i + 1 >= argv.length) {
debug('Warning: --extra-cmd specified but no value provided');
return null;
}
const value = argv[i + 1];
if (value === '') {
debug('Warning: --extra-cmd value is empty, ignoring');
return null;
}
return value;
}
}
return null;
}
/**
* Execute a command and parse JSON output expecting { label: string }
* Returns null on any error (timeout, parse failure, missing label)
*
* SECURITY NOTE: The cmd parameter is sourced exclusively from CLI arguments
* (--extra-cmd) typed by the user. Since the user controls their own shell,
* shell injection is not a concern here - it's intentional user input.
*/
export async function runExtraCmd(cmd: string, timeout: number = TIMEOUT_MS): Promise {
try {
const { stdout } = await execAsync(cmd, {
timeout,
maxBuffer: MAX_BUFFER,
});
const data: unknown = JSON.parse(stdout.trim());
if (
typeof data === 'object' &&
data !== null &&
'label' in data &&
typeof (data as ExtraLabel).label === 'string'
) {
let label = sanitize((data as ExtraLabel).label);
if (label.length > MAX_LABEL_LENGTH) {
label = label.slice(0, MAX_LABEL_LENGTH - 1) + '…';
}
return label;
}
debug(`Command output missing 'label' field or invalid type: ${JSON.stringify(data)}`);
return null;
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('TIMEOUT') || err.message.includes('killed')) {
debug(`Command timed out after ${timeout}ms: ${cmd}`);
} else if (err instanceof SyntaxError) {
debug(`Failed to parse JSON output: ${err.message}`);
} else {
debug(`Command failed: ${err.message}`);
}
} else {
debug(`Command failed with unknown error`);
}
return null;
}
}
================================================
FILE: src/git.ts
================================================
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export interface FileStats {
modified: number;
added: number;
deleted: number;
untracked: number;
}
export interface GitStatus {
branch: string;
isDirty: boolean;
ahead: number;
behind: number;
fileStats?: FileStats;
}
export async function getGitBranch(cwd?: string): Promise {
if (!cwd) return null;
try {
const { stdout } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd, timeout: 1000, encoding: 'utf8' }
);
return stdout.trim() || null;
} catch {
return null;
}
}
export async function getGitStatus(cwd?: string): Promise {
if (!cwd) return null;
try {
// Get branch name
const { stdout: branchOut } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd, timeout: 1000, encoding: 'utf8' }
);
const branch = branchOut.trim();
if (!branch) return null;
// Check for dirty state and parse file stats
let isDirty = false;
let fileStats: FileStats | undefined;
try {
const { stdout: statusOut } = await execFileAsync(
'git',
['--no-optional-locks', 'status', '--porcelain'],
{ cwd, timeout: 1000, encoding: 'utf8' }
);
const trimmed = statusOut.trim();
isDirty = trimmed.length > 0;
if (isDirty) {
fileStats = parseFileStats(trimmed);
}
} catch {
// Ignore errors, assume clean
}
// Get ahead/behind counts
let ahead = 0;
let behind = 0;
try {
const { stdout: revOut } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', '@{upstream}...HEAD'],
{ cwd, timeout: 1000, encoding: 'utf8' }
);
const parts = revOut.trim().split(/\s+/);
if (parts.length === 2) {
behind = parseInt(parts[0], 10) || 0;
ahead = parseInt(parts[1], 10) || 0;
}
} catch {
// No upstream or error, keep 0/0
}
return { branch, isDirty, ahead, behind, fileStats };
} catch {
return null;
}
}
/**
* Parse git status --porcelain output and count file stats (Starship-compatible format)
* Status codes: M=modified, A=added, D=deleted, ??=untracked
*/
function parseFileStats(porcelainOutput: string): FileStats {
const stats: FileStats = { modified: 0, added: 0, deleted: 0, untracked: 0 };
const lines = porcelainOutput.split('\n').filter(Boolean);
for (const line of lines) {
if (line.length < 2) continue;
const index = line[0]; // staged status
const worktree = line[1]; // unstaged status
if (line.startsWith('??')) {
stats.untracked++;
} else if (index === 'A') {
stats.added++;
} else if (index === 'D' || worktree === 'D') {
stats.deleted++;
} else if (index === 'M' || worktree === 'M' || index === 'R' || index === 'C') {
// M=modified, R=renamed (counts as modified), C=copied (counts as modified)
stats.modified++;
}
}
return stats;
}
================================================
FILE: src/index.ts
================================================
import { readStdin } from './stdin.js';
import { parseTranscript } from './transcript.js';
import { render } from './render/index.js';
import { countConfigs } from './config-reader.js';
import { getGitStatus } from './git.js';
import { getUsage } from './usage-api.js';
import { loadConfig } from './config.js';
import { parseExtraCmdArg, runExtraCmd } from './extra-cmd.js';
import type { RenderContext } from './types.js';
import { fileURLToPath } from 'node:url';
import { realpathSync } from 'node:fs';
export type MainDeps = {
readStdin: typeof readStdin;
parseTranscript: typeof parseTranscript;
countConfigs: typeof countConfigs;
getGitStatus: typeof getGitStatus;
getUsage: typeof getUsage;
loadConfig: typeof loadConfig;
parseExtraCmdArg: typeof parseExtraCmdArg;
runExtraCmd: typeof runExtraCmd;
render: typeof render;
now: () => number;
log: (...args: unknown[]) => void;
};
export async function main(overrides: Partial = {}): Promise {
const deps: MainDeps = {
readStdin,
parseTranscript,
countConfigs,
getGitStatus,
getUsage,
loadConfig,
parseExtraCmdArg,
runExtraCmd,
render,
now: () => Date.now(),
log: console.log,
...overrides,
};
try {
const stdin = await deps.readStdin();
if (!stdin) {
// Running without stdin - this happens during setup verification
const isMacOS = process.platform === 'darwin';
deps.log('[claude-hud] Initializing...');
if (isMacOS) {
deps.log('[claude-hud] Note: On macOS, you may need to restart Claude Code for the HUD to appear.');
}
return;
}
const transcriptPath = stdin.transcript_path ?? '';
const transcript = await deps.parseTranscript(transcriptPath);
const { claudeMdCount, rulesCount, mcpCount, hooksCount } = await deps.countConfigs(stdin.cwd);
const config = await deps.loadConfig();
const gitStatus = config.gitStatus.enabled
? await deps.getGitStatus(stdin.cwd)
: null;
// Only fetch usage if enabled in config (replaces env var requirement)
const usageData = config.display.showUsage !== false
? await deps.getUsage({
ttls: {
cacheTtlMs: config.usage.cacheTtlSeconds * 1000,
failureCacheTtlMs: config.usage.failureCacheTtlSeconds * 1000,
},
})
: null;
const extraCmd = deps.parseExtraCmdArg();
const extraLabel = extraCmd ? await deps.runExtraCmd(extraCmd) : null;
const sessionDuration = formatSessionDuration(transcript.sessionStart, deps.now);
const ctx: RenderContext = {
stdin,
transcript,
claudeMdCount,
rulesCount,
mcpCount,
hooksCount,
sessionDuration,
gitStatus,
usageData,
config,
extraLabel,
};
deps.render(ctx);
} catch (error) {
deps.log('[claude-hud] Error:', error instanceof Error ? error.message : 'Unknown error');
}
}
export function formatSessionDuration(sessionStart?: Date, now: () => number = () => Date.now()): string {
if (!sessionStart) {
return '';
}
const ms = now() - sessionStart.getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '<1m';
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
return `${hours}h ${remainingMins}m`;
}
const scriptPath = fileURLToPath(import.meta.url);
const argvPath = process.argv[1];
const isSamePath = (a: string, b: string): boolean => {
try {
return realpathSync(a) === realpathSync(b);
} catch {
return a === b;
}
};
if (argvPath && isSamePath(argvPath, scriptPath)) {
void main();
}
================================================
FILE: src/render/agents-line.ts
================================================
import type { RenderContext, AgentEntry } from '../types.js';
import { yellow, green, magenta, dim } from './colors.js';
export function renderAgentsLine(ctx: RenderContext): string | null {
const { agents } = ctx.transcript;
const runningAgents = agents.filter((a) => a.status === 'running');
const recentCompleted = agents
.filter((a) => a.status === 'completed')
.slice(-2);
const toShow = [...runningAgents, ...recentCompleted].slice(-3);
if (toShow.length === 0) {
return null;
}
const lines: string[] = [];
for (const agent of toShow) {
lines.push(formatAgent(agent));
}
return lines.join('\n');
}
function formatAgent(agent: AgentEntry): string {
const statusIcon = agent.status === 'running' ? yellow('◐') : green('✓');
const type = magenta(agent.type);
const model = agent.model ? dim(`[${agent.model}]`) : '';
const desc = agent.description ? dim(`: ${truncateDesc(agent.description)}`) : '';
const elapsed = formatElapsed(agent);
return `${statusIcon} ${type}${model ? ` ${model}` : ''}${desc} ${dim(`(${elapsed})`)}`;
}
function truncateDesc(desc: string, maxLen: number = 40): string {
if (desc.length <= maxLen) return desc;
return desc.slice(0, maxLen - 3) + '...';
}
function formatElapsed(agent: AgentEntry): string {
const now = Date.now();
const start = agent.startTime.getTime();
const end = agent.endTime?.getTime() ?? now;
const ms = end - start;
if (ms < 1000) return '<1s';
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
const mins = Math.floor(ms / 60000);
const secs = Math.round((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
}
================================================
FILE: src/render/colors.ts
================================================
import type { HudColorName, HudColorValue, HudColorOverrides } from '../config.js';
export const RESET = '\x1b[0m';
const DIM = '\x1b[2m';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const MAGENTA = '\x1b[35m';
const CYAN = '\x1b[36m';
const BRIGHT_BLUE = '\x1b[94m';
const BRIGHT_MAGENTA = '\x1b[95m';
const CLAUDE_ORANGE = '\x1b[38;5;208m';
const ANSI_BY_NAME: Record = {
red: RED,
green: GREEN,
yellow: YELLOW,
magenta: MAGENTA,
cyan: CYAN,
brightBlue: BRIGHT_BLUE,
brightMagenta: BRIGHT_MAGENTA,
};
/** Convert a hex color string (#rrggbb) to a truecolor ANSI escape sequence. */
function hexToAnsi(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `\x1b[38;2;${r};${g};${b}m`;
}
/**
* Resolve a color value to an ANSI escape sequence.
* Accepts named presets, 256-color indices (0-255), or hex strings (#rrggbb).
*/
function resolveAnsi(value: HudColorValue | undefined, fallback: string): string {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value === 'number') {
return `\x1b[38;5;${value}m`;
}
if (typeof value === 'string' && value.startsWith('#') && value.length === 7) {
return hexToAnsi(value);
}
return ANSI_BY_NAME[value as HudColorName] ?? fallback;
}
function colorize(text: string, color: string): string {
return `${color}${text}${RESET}`;
}
export function green(text: string): string {
return colorize(text, GREEN);
}
export function yellow(text: string): string {
return colorize(text, YELLOW);
}
export function red(text: string): string {
return colorize(text, RED);
}
export function cyan(text: string): string {
return colorize(text, CYAN);
}
export function magenta(text: string): string {
return colorize(text, MAGENTA);
}
export function dim(text: string): string {
return colorize(text, DIM);
}
export function claudeOrange(text: string): string {
return colorize(text, CLAUDE_ORANGE);
}
export function warning(text: string, colors?: Partial): string {
return colorize(text, resolveAnsi(colors?.warning, YELLOW));
}
export function critical(text: string, colors?: Partial): string {
return colorize(text, resolveAnsi(colors?.critical, RED));
}
export function getContextColor(percent: number, colors?: Partial): string {
if (percent >= 85) return resolveAnsi(colors?.critical, RED);
if (percent >= 70) return resolveAnsi(colors?.warning, YELLOW);
return resolveAnsi(colors?.context, GREEN);
}
export function getQuotaColor(percent: number, colors?: Partial): string {
if (percent >= 90) return resolveAnsi(colors?.critical, RED);
if (percent >= 75) return resolveAnsi(colors?.usageWarning, BRIGHT_MAGENTA);
return resolveAnsi(colors?.usage, BRIGHT_BLUE);
}
export function quotaBar(percent: number, width: number = 10, colors?: Partial): string {
const safeWidth = Number.isFinite(width) ? Math.max(0, Math.round(width)) : 0;
const safePercent = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0;
const filled = Math.round((safePercent / 100) * safeWidth);
const empty = safeWidth - filled;
const color = getQuotaColor(safePercent, colors);
return `${color}${'█'.repeat(filled)}${DIM}${'░'.repeat(empty)}${RESET}`;
}
export function coloredBar(percent: number, width: number = 10, colors?: Partial): string {
const safeWidth = Number.isFinite(width) ? Math.max(0, Math.round(width)) : 0;
const safePercent = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0;
const filled = Math.round((safePercent / 100) * safeWidth);
const empty = safeWidth - filled;
const color = getContextColor(safePercent, colors);
return `${color}${'█'.repeat(filled)}${DIM}${'░'.repeat(empty)}${RESET}`;
}
================================================
FILE: src/render/index.ts
================================================
import type { HudElement } from '../config.js';
import { DEFAULT_ELEMENT_ORDER } from '../config.js';
import type { RenderContext } from '../types.js';
import { renderSessionLine } from './session-line.js';
import { renderToolsLine } from './tools-line.js';
import { renderAgentsLine } from './agents-line.js';
import { renderTodosLine } from './todos-line.js';
import {
renderIdentityLine,
renderProjectLine,
renderEnvironmentLine,
renderUsageLine,
} from './lines/index.js';
import { dim, RESET } from './colors.js';
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_PATTERN = /^\x1b\[[0-9;]*m/;
const ANSI_ESCAPE_GLOBAL = /\x1b\[[0-9;]*m/g;
const GRAPHEME_SEGMENTER = typeof Intl.Segmenter === 'function'
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null;
function stripAnsi(str: string): string {
return str.replace(ANSI_ESCAPE_GLOBAL, '');
}
function getTerminalWidth(): number | null {
const stdoutColumns = process.stdout?.columns;
if (typeof stdoutColumns === 'number' && Number.isFinite(stdoutColumns) && stdoutColumns > 0) {
return Math.floor(stdoutColumns);
}
// When running as a statusline subprocess, stdout is piped but stderr is
// still connected to the real terminal — use it to get the actual width.
const stderrColumns = process.stderr?.columns;
if (typeof stderrColumns === 'number' && Number.isFinite(stderrColumns) && stderrColumns > 0) {
return Math.floor(stderrColumns);
}
const envColumns = Number.parseInt(process.env.COLUMNS ?? '', 10);
if (Number.isFinite(envColumns) && envColumns > 0) {
return envColumns;
}
return null;
}
function splitAnsiTokens(str: string): Array<{ type: 'ansi' | 'text'; value: string }> {
const tokens: Array<{ type: 'ansi' | 'text'; value: string }> = [];
let i = 0;
while (i < str.length) {
const ansiMatch = ANSI_ESCAPE_PATTERN.exec(str.slice(i));
if (ansiMatch) {
tokens.push({ type: 'ansi', value: ansiMatch[0] });
i += ansiMatch[0].length;
continue;
}
let j = i;
while (j < str.length) {
const nextAnsi = ANSI_ESCAPE_PATTERN.exec(str.slice(j));
if (nextAnsi) {
break;
}
j += 1;
}
tokens.push({ type: 'text', value: str.slice(i, j) });
i = j;
}
return tokens;
}
function segmentGraphemes(text: string): string[] {
if (!text) {
return [];
}
if (!GRAPHEME_SEGMENTER) {
return Array.from(text);
}
return Array.from(GRAPHEME_SEGMENTER.segment(text), segment => segment.segment);
}
function isWideCodePoint(codePoint: number): boolean {
return codePoint >= 0x1100 && (
codePoint <= 0x115F || // Hangul Jamo
codePoint === 0x2329 ||
codePoint === 0x232A ||
(codePoint >= 0x2E80 && codePoint <= 0xA4CF && codePoint !== 0x303F) ||
(codePoint >= 0xAC00 && codePoint <= 0xD7A3) ||
(codePoint >= 0xF900 && codePoint <= 0xFAFF) ||
(codePoint >= 0xFE10 && codePoint <= 0xFE19) ||
(codePoint >= 0xFE30 && codePoint <= 0xFE6F) ||
(codePoint >= 0xFF00 && codePoint <= 0xFF60) ||
(codePoint >= 0xFFE0 && codePoint <= 0xFFE6) ||
(codePoint >= 0x1F300 && codePoint <= 0x1FAFF) ||
(codePoint >= 0x20000 && codePoint <= 0x3FFFD)
);
}
function graphemeWidth(grapheme: string): number {
if (!grapheme || /^\p{Control}$/u.test(grapheme)) {
return 0;
}
// Emoji glyphs and ZWJ sequences generally render as double-width.
if (/\p{Extended_Pictographic}/u.test(grapheme)) {
return 2;
}
let hasVisibleBase = false;
let width = 0;
for (const char of Array.from(grapheme)) {
if (/^\p{Mark}$/u.test(char) || char === '\u200D' || char === '\uFE0F') {
continue;
}
hasVisibleBase = true;
const codePoint = char.codePointAt(0);
if (codePoint !== undefined && isWideCodePoint(codePoint)) {
width = Math.max(width, 2);
} else {
width = Math.max(width, 1);
}
}
return hasVisibleBase ? width : 0;
}
function visualLength(str: string): number {
let width = 0;
for (const token of splitAnsiTokens(str)) {
if (token.type === 'ansi') {
continue;
}
for (const grapheme of segmentGraphemes(token.value)) {
width += graphemeWidth(grapheme);
}
}
return width;
}
function sliceVisible(str: string, maxVisible: number): string {
if (maxVisible <= 0) {
return '';
}
let result = '';
let visibleWidth = 0;
let done = false;
let i = 0;
while (i < str.length && !done) {
const ansiMatch = ANSI_ESCAPE_PATTERN.exec(str.slice(i));
if (ansiMatch) {
result += ansiMatch[0];
i += ansiMatch[0].length;
continue;
}
let j = i;
while (j < str.length) {
const nextAnsi = ANSI_ESCAPE_PATTERN.exec(str.slice(j));
if (nextAnsi) {
break;
}
j += 1;
}
const plainChunk = str.slice(i, j);
for (const grapheme of segmentGraphemes(plainChunk)) {
const graphemeCellWidth = graphemeWidth(grapheme);
if (visibleWidth + graphemeCellWidth > maxVisible) {
done = true;
break;
}
result += grapheme;
visibleWidth += graphemeCellWidth;
}
i = j;
}
return result;
}
function truncateToWidth(str: string, maxWidth: number): string {
if (maxWidth <= 0 || visualLength(str) <= maxWidth) {
return str;
}
const suffix = maxWidth >= 3 ? '...' : '.'.repeat(maxWidth);
const keep = Math.max(0, maxWidth - suffix.length);
return `${sliceVisible(str, keep)}${suffix}${RESET}`;
}
function splitLineBySeparators(line: string): { segments: string[]; separators: string[] } {
const segments: string[] = [];
const separators: string[] = [];
let currentStart = 0;
let i = 0;
while (i < line.length) {
const ansiMatch = ANSI_ESCAPE_PATTERN.exec(line.slice(i));
if (ansiMatch) {
i += ansiMatch[0].length;
continue;
}
const separator = line.startsWith(' | ', i)
? ' | '
: (line.startsWith(' │ ', i) ? ' │ ' : null);
if (separator) {
segments.push(line.slice(currentStart, i));
separators.push(separator);
i += separator.length;
currentStart = i;
continue;
}
i += 1;
}
segments.push(line.slice(currentStart));
return { segments, separators };
}
function splitWrapParts(line: string): Array<{ separator: string; segment: string }> {
const { segments, separators } = splitLineBySeparators(line);
if (segments.length === 0) {
return [];
}
let parts: Array<{ separator: string; segment: string }> = [{
separator: '',
segment: segments[0],
}];
for (let segmentIndex = 1; segmentIndex < segments.length; segmentIndex += 1) {
parts.push({
separator: separators[segmentIndex - 1] ?? ' | ',
segment: segments[segmentIndex],
});
}
// Keep the leading [model | provider] block together.
// This avoids splitting inside the model badge while still splitting
// separators elsewhere in the line.
const firstVisible = stripAnsi(parts[0].segment).trimStart();
const firstHasOpeningBracket = firstVisible.startsWith('[');
const firstHasClosingBracket = stripAnsi(parts[0].segment).includes(']');
if (firstHasOpeningBracket && !firstHasClosingBracket && parts.length > 1) {
let mergedSegment = parts[0].segment;
let consumeIndex = 1;
while (consumeIndex < parts.length) {
const nextPart = parts[consumeIndex];
mergedSegment += `${nextPart.separator}${nextPart.segment}`;
consumeIndex += 1;
if (stripAnsi(nextPart.segment).includes(']')) {
break;
}
}
parts = [
{ separator: '', segment: mergedSegment },
...parts.slice(consumeIndex),
];
}
return parts;
}
function wrapLineToWidth(line: string, maxWidth: number): string[] {
if (maxWidth <= 0 || visualLength(line) <= maxWidth) {
return [line];
}
const parts = splitWrapParts(line);
if (parts.length <= 1) {
return [truncateToWidth(line, maxWidth)];
}
const wrapped: string[] = [];
let current = parts[0].segment;
for (const part of parts.slice(1)) {
const candidate = `${current}${part.separator}${part.segment}`;
if (visualLength(candidate) <= maxWidth) {
current = candidate;
continue;
}
wrapped.push(truncateToWidth(current, maxWidth));
current = part.segment;
}
if (current) {
wrapped.push(truncateToWidth(current, maxWidth));
}
return wrapped;
}
function makeSeparator(length: number): string {
return dim('─'.repeat(Math.max(length, 1)));
}
const ACTIVITY_ELEMENTS = new Set(['tools', 'agents', 'todos']);
function collectActivityLines(ctx: RenderContext): string[] {
const activityLines: string[] = [];
const display = ctx.config?.display;
if (display?.showTools !== false) {
const toolsLine = renderToolsLine(ctx);
if (toolsLine) {
activityLines.push(toolsLine);
}
}
if (display?.showAgents !== false) {
const agentsLine = renderAgentsLine(ctx);
if (agentsLine) {
activityLines.push(agentsLine);
}
}
if (display?.showTodos !== false) {
const todosLine = renderTodosLine(ctx);
if (todosLine) {
activityLines.push(todosLine);
}
}
return activityLines;
}
function renderElementLine(ctx: RenderContext, element: HudElement): string | null {
const display = ctx.config?.display;
switch (element) {
case 'project':
return renderProjectLine(ctx);
case 'context':
return renderIdentityLine(ctx);
case 'usage':
return renderUsageLine(ctx);
case 'environment':
return renderEnvironmentLine(ctx);
case 'tools':
return display?.showTools === false ? null : renderToolsLine(ctx);
case 'agents':
return display?.showAgents === false ? null : renderAgentsLine(ctx);
case 'todos':
return display?.showTodos === false ? null : renderTodosLine(ctx);
}
}
function renderCompact(ctx: RenderContext): string[] {
const lines: string[] = [];
const sessionLine = renderSessionLine(ctx);
if (sessionLine) {
lines.push(sessionLine);
}
return lines;
}
function renderExpanded(ctx: RenderContext): Array<{ line: string; isActivity: boolean }> {
const elementOrder = ctx.config?.elementOrder ?? DEFAULT_ELEMENT_ORDER;
const seen = new Set();
const lines: Array<{ line: string; isActivity: boolean }> = [];
for (let index = 0; index < elementOrder.length; index += 1) {
const element = elementOrder[index];
if (seen.has(element)) {
continue;
}
const nextElement = elementOrder[index + 1];
if (
(element === 'context' && nextElement === 'usage' && !seen.has('usage'))
|| (element === 'usage' && nextElement === 'context' && !seen.has('context'))
) {
seen.add(element);
seen.add(nextElement);
const firstLine = renderElementLine(ctx, element);
const secondLine = renderElementLine(ctx, nextElement);
if (firstLine && secondLine) {
lines.push({ line: `${firstLine} │ ${secondLine}`, isActivity: false });
} else if (firstLine) {
lines.push({ line: firstLine, isActivity: false });
} else if (secondLine) {
lines.push({ line: secondLine, isActivity: false });
}
continue;
}
seen.add(element);
const line = renderElementLine(ctx, element);
if (!line) {
continue;
}
lines.push({
line,
isActivity: ACTIVITY_ELEMENTS.has(element),
});
}
return lines;
}
export function render(ctx: RenderContext): void {
const lineLayout = ctx.config?.lineLayout ?? 'expanded';
const showSeparators = ctx.config?.showSeparators ?? false;
const terminalWidth = getTerminalWidth();
let lines: string[];
if (lineLayout === 'expanded') {
const renderedLines = renderExpanded(ctx);
lines = renderedLines.map(({ line }) => line);
if (showSeparators) {
const firstActivityIndex = renderedLines.findIndex(({ isActivity }) => isActivity);
if (firstActivityIndex > 0) {
const separatorBaseWidth = Math.max(
...renderedLines
.slice(0, firstActivityIndex)
.map(({ line }) => visualLength(line)),
20
);
const separatorWidth = terminalWidth
? Math.min(separatorBaseWidth, terminalWidth)
: separatorBaseWidth;
lines.splice(firstActivityIndex, 0, makeSeparator(separatorWidth));
}
}
} else {
const headerLines = renderCompact(ctx);
const activityLines = collectActivityLines(ctx);
lines = [...headerLines];
if (showSeparators && activityLines.length > 0) {
const maxWidth = Math.max(...headerLines.map(visualLength), 20);
const separatorWidth = terminalWidth ? Math.min(maxWidth, terminalWidth) : maxWidth;
lines.push(makeSeparator(separatorWidth));
}
lines.push(...activityLines);
}
const physicalLines = lines.flatMap(line => line.split('\n'));
const visibleLines = terminalWidth
? physicalLines.flatMap(line => wrapLineToWidth(line, terminalWidth))
: physicalLines;
for (const line of visibleLines) {
const outputLine = `${RESET}${line}`;
console.log(outputLine);
}
}
================================================
FILE: src/render/lines/environment.ts
================================================
import type { RenderContext } from '../../types.js';
import { dim } from '../colors.js';
export function renderEnvironmentLine(ctx: RenderContext): string | null {
const display = ctx.config?.display;
if (display?.showConfigCounts === false) {
return null;
}
const totalCounts = ctx.claudeMdCount + ctx.rulesCount + ctx.mcpCount + ctx.hooksCount;
const threshold = display?.environmentThreshold ?? 0;
if (totalCounts === 0 || totalCounts < threshold) {
return null;
}
const parts: string[] = [];
if (ctx.claudeMdCount > 0) {
parts.push(`${ctx.claudeMdCount} CLAUDE.md`);
}
if (ctx.rulesCount > 0) {
parts.push(`${ctx.rulesCount} rules`);
}
if (ctx.mcpCount > 0) {
parts.push(`${ctx.mcpCount} MCPs`);
}
if (ctx.hooksCount > 0) {
parts.push(`${ctx.hooksCount} hooks`);
}
if (parts.length === 0) {
return null;
}
return dim(parts.join(' | '));
}
================================================
FILE: src/render/lines/identity.ts
================================================
import type { RenderContext } from '../../types.js';
import { getContextPercent, getBufferedPercent, getTotalTokens } from '../../stdin.js';
import { coloredBar, dim, getContextColor, RESET } from '../colors.js';
import { getAdaptiveBarWidth } from '../../utils/terminal.js';
const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*';
export function renderIdentityLine(ctx: RenderContext): string {
const rawPercent = getContextPercent(ctx.stdin);
const bufferedPercent = getBufferedPercent(ctx.stdin);
const autocompactMode = ctx.config?.display?.autocompactBuffer ?? 'enabled';
const percent = autocompactMode === 'disabled' ? rawPercent : bufferedPercent;
const colors = ctx.config?.colors;
if (DEBUG && autocompactMode === 'disabled') {
console.error(`[claude-hud:context] autocompactBuffer=disabled, showing raw ${rawPercent}% (buffered would be ${bufferedPercent}%)`);
}
const display = ctx.config?.display;
const contextValueMode = display?.contextValue ?? 'percent';
const contextValue = formatContextValue(ctx, percent, contextValueMode);
const contextValueDisplay = `${getContextColor(percent, colors)}${contextValue}${RESET}`;
let line = display?.showContextBar !== false
? `${dim('Context')} ${coloredBar(percent, getAdaptiveBarWidth(), colors)} ${contextValueDisplay}`
: `${dim('Context')} ${contextValueDisplay}`;
if (display?.showTokenBreakdown !== false && percent >= 85) {
const usage = ctx.stdin.context_window?.current_usage;
if (usage) {
const input = formatTokens(usage.input_tokens ?? 0);
const cache = formatTokens((usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0));
line += dim(` (in: ${input}, cache: ${cache})`);
}
}
return line;
}
function formatTokens(n: number): string {
if (n >= 1000000) {
return `${(n / 1000000).toFixed(1)}M`;
}
if (n >= 1000) {
return `${(n / 1000).toFixed(0)}k`;
}
return n.toString();
}
function formatContextValue(ctx: RenderContext, percent: number, mode: 'percent' | 'tokens' | 'remaining'): string {
if (mode === 'tokens') {
const totalTokens = getTotalTokens(ctx.stdin);
const size = ctx.stdin.context_window?.context_window_size ?? 0;
if (size > 0) {
return `${formatTokens(totalTokens)}/${formatTokens(size)}`;
}
return formatTokens(totalTokens);
}
if (mode === 'remaining') {
return `${Math.max(0, 100 - percent)}%`;
}
return `${percent}%`;
}
================================================
FILE: src/render/lines/index.ts
================================================
export { renderIdentityLine } from './identity.js';
export { renderProjectLine } from './project.js';
export { renderEnvironmentLine } from './environment.js';
export { renderUsageLine } from './usage.js';
================================================
FILE: src/render/lines/project.ts
================================================
import type { RenderContext } from '../../types.js';
import { getModelName, getProviderLabel } from '../../stdin.js';
import { getOutputSpeed } from '../../speed-tracker.js';
import { cyan, dim, magenta, yellow, red, claudeOrange } from '../colors.js';
export function renderProjectLine(ctx: RenderContext): string | null {
const display = ctx.config?.display;
const parts: string[] = [];
if (display?.showModel !== false) {
const model = getModelName(ctx.stdin);
const providerLabel = getProviderLabel(ctx.stdin);
const showUsage = display?.showUsage !== false;
const planName = showUsage ? ctx.usageData?.planName : undefined;
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
const billingLabel = showUsage ? (planName ?? (hasApiKey ? red('API') : undefined)) : undefined;
const planDisplay = providerLabel ?? billingLabel;
const modelDisplay = planDisplay ? `${model} | ${planDisplay}` : model;
parts.push(cyan(`[${modelDisplay}]`));
}
let projectPart: string | null = null;
if (display?.showProject !== false && ctx.stdin.cwd) {
const segments = ctx.stdin.cwd.split(/[/\\]/).filter(Boolean);
const pathLevels = ctx.config?.pathLevels ?? 1;
const projectPath = segments.length > 0 ? segments.slice(-pathLevels).join('/') : '/';
projectPart = yellow(projectPath);
}
let gitPart = '';
const gitConfig = ctx.config?.gitStatus;
const showGit = gitConfig?.enabled ?? true;
if (showGit && ctx.gitStatus) {
const gitParts: string[] = [ctx.gitStatus.branch];
if ((gitConfig?.showDirty ?? true) && ctx.gitStatus.isDirty) {
gitParts.push('*');
}
if (gitConfig?.showAheadBehind) {
if (ctx.gitStatus.ahead > 0) {
gitParts.push(` ↑${ctx.gitStatus.ahead}`);
}
if (ctx.gitStatus.behind > 0) {
gitParts.push(` ↓${ctx.gitStatus.behind}`);
}
}
if (gitConfig?.showFileStats && ctx.gitStatus.fileStats) {
const { modified, added, deleted, untracked } = ctx.gitStatus.fileStats;
const statParts: string[] = [];
if (modified > 0) statParts.push(`!${modified}`);
if (added > 0) statParts.push(`+${added}`);
if (deleted > 0) statParts.push(`✘${deleted}`);
if (untracked > 0) statParts.push(`?${untracked}`);
if (statParts.length > 0) {
gitParts.push(` ${statParts.join(' ')}`);
}
}
gitPart = `${magenta('git:(')}${cyan(gitParts.join(''))}${magenta(')')}`;
}
if (projectPart && gitPart) {
parts.push(`${projectPart} ${gitPart}`);
} else if (projectPart) {
parts.push(projectPart);
} else if (gitPart) {
parts.push(gitPart);
}
if (display?.showSessionName && ctx.transcript.sessionName) {
parts.push(dim(ctx.transcript.sessionName));
}
if (ctx.extraLabel) {
parts.push(dim(ctx.extraLabel));
}
if (display?.showSpeed) {
const speed = getOutputSpeed(ctx.stdin);
if (speed !== null) {
parts.push(dim(`out: ${speed.toFixed(1)} tok/s`));
}
}
if (display?.showDuration !== false && ctx.sessionDuration) {
parts.push(dim(`⏱️ ${ctx.sessionDuration}`));
}
const customLine = display?.customLine;
if (customLine) {
parts.push(claudeOrange(customLine));
}
if (parts.length === 0) {
return null;
}
return parts.join(' \u2502 ');
}
================================================
FILE: src/render/lines/usage.ts
================================================
import type { RenderContext } from '../../types.js';
import { isLimitReached } from '../../types.js';
import { getProviderLabel } from '../../stdin.js';
import { critical, warning, dim, getQuotaColor, quotaBar, RESET } from '../colors.js';
import { getAdaptiveBarWidth } from '../../utils/terminal.js';
export function renderUsageLine(ctx: RenderContext): string | null {
const display = ctx.config?.display;
const colors = ctx.config?.colors;
if (display?.showUsage === false) {
return null;
}
if (!ctx.usageData?.planName) {
return null;
}
if (getProviderLabel(ctx.stdin)) {
return null;
}
const label = dim('Usage');
if (ctx.usageData.apiUnavailable) {
const errorHint = formatUsageError(ctx.usageData.apiError);
return `${label} ${warning(`⚠${errorHint}`, colors)}`;
}
if (isLimitReached(ctx.usageData)) {
const resetTime = ctx.usageData.fiveHour === 100
? formatResetTime(ctx.usageData.fiveHourResetAt)
: formatResetTime(ctx.usageData.sevenDayResetAt);
return `${label} ${critical(`⚠ Limit reached${resetTime ? ` (resets ${resetTime})` : ''}`, colors)}`;
}
const threshold = display?.usageThreshold ?? 0;
const fiveHour = ctx.usageData.fiveHour;
const sevenDay = ctx.usageData.sevenDay;
const effectiveUsage = Math.max(fiveHour ?? 0, sevenDay ?? 0);
if (effectiveUsage < threshold) {
return null;
}
const fiveHourDisplay = formatUsagePercent(ctx.usageData.fiveHour, colors);
const fiveHourReset = formatResetTime(ctx.usageData.fiveHourResetAt);
const usageBarEnabled = display?.usageBarEnabled ?? true;
const fiveHourPart = usageBarEnabled
? (fiveHourReset
? `${quotaBar(fiveHour ?? 0, getAdaptiveBarWidth(), colors)} ${fiveHourDisplay} (resets in ${fiveHourReset})`
: `${quotaBar(fiveHour ?? 0, getAdaptiveBarWidth(), colors)} ${fiveHourDisplay}`)
: (fiveHourReset
? `5h: ${fiveHourDisplay} (resets in ${fiveHourReset})`
: `5h: ${fiveHourDisplay}`);
const sevenDayThreshold = display?.sevenDayThreshold ?? 80;
const syncingSuffix = ctx.usageData.apiError === 'rate-limited'
? ` ${dim('(syncing...)')}`
: '';
if (sevenDay !== null && sevenDay >= sevenDayThreshold) {
const sevenDayDisplay = formatUsagePercent(sevenDay, colors);
const sevenDayReset = formatResetTime(ctx.usageData.sevenDayResetAt);
const sevenDayPart = usageBarEnabled
? (sevenDayReset
? `${quotaBar(sevenDay, getAdaptiveBarWidth(), colors)} ${sevenDayDisplay} (resets in ${sevenDayReset})`
: `${quotaBar(sevenDay, getAdaptiveBarWidth(), colors)} ${sevenDayDisplay}`)
: (sevenDayReset
? `7d: ${sevenDayDisplay} (resets in ${sevenDayReset})`
: `7d: ${sevenDayDisplay}`);
return `${label} ${fiveHourPart} | ${sevenDayPart}${syncingSuffix}`;
}
return `${label} ${fiveHourPart}${syncingSuffix}`;
}
function formatUsagePercent(percent: number | null, colors?: RenderContext['config']['colors']): string {
if (percent === null) {
return dim('--');
}
const color = getQuotaColor(percent, colors);
return `${color}${percent}%${RESET}`;
}
function formatUsageError(error?: string): string {
if (!error) return '';
if (error === 'rate-limited') return ' (syncing...)';
if (error.startsWith('http-')) return ` (${error.slice(5)})`;
return ` (${error})`;
}
function formatResetTime(resetAt: Date | null): string {
if (!resetAt) return '';
const now = new Date();
const diffMs = resetAt.getTime() - now.getTime();
if (diffMs <= 0) return '';
const diffMins = Math.ceil(diffMs / 60000);
if (diffMins < 60) return `${diffMins}m`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours >= 24) {
const days = Math.floor(hours / 24);
const remHours = hours % 24;
if (remHours > 0) return `${days}d ${remHours}h`;
return `${days}d`;
}
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
================================================
FILE: src/render/session-line.ts
================================================
import type { RenderContext } from '../types.js';
import { isLimitReached } from '../types.js';
import { getContextPercent, getBufferedPercent, getModelName, getProviderLabel, getTotalTokens } from '../stdin.js';
import { getOutputSpeed } from '../speed-tracker.js';
import { coloredBar, critical, cyan, dim, magenta, red, warning, yellow, getContextColor, getQuotaColor, quotaBar, claudeOrange, RESET } from './colors.js';
import { getAdaptiveBarWidth } from '../utils/terminal.js';
const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*';
/**
* Renders the full session line (model + context bar + project + git + counts + usage + duration).
* Used for compact layout mode.
*/
export function renderSessionLine(ctx: RenderContext): string {
const model = getModelName(ctx.stdin);
const rawPercent = getContextPercent(ctx.stdin);
const bufferedPercent = getBufferedPercent(ctx.stdin);
const autocompactMode = ctx.config?.display?.autocompactBuffer ?? 'enabled';
const percent = autocompactMode === 'disabled' ? rawPercent : bufferedPercent;
if (DEBUG && autocompactMode === 'disabled') {
console.error(`[claude-hud:context] autocompactBuffer=disabled, showing raw ${rawPercent}% (buffered would be ${bufferedPercent}%)`);
}
const colors = ctx.config?.colors;
const barWidth = getAdaptiveBarWidth();
const bar = coloredBar(percent, barWidth, colors);
const parts: string[] = [];
const display = ctx.config?.display;
const contextValueMode = display?.contextValue ?? 'percent';
const contextValue = formatContextValue(ctx, percent, contextValueMode);
const contextValueDisplay = `${getContextColor(percent, colors)}${contextValue}${RESET}`;
// Model and context bar (FIRST)
// Plan name only shows if showUsage is enabled (respects hybrid toggle)
const providerLabel = getProviderLabel(ctx.stdin);
const showUsage = display?.showUsage !== false;
const planName = showUsage ? ctx.usageData?.planName : undefined;
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
const billingLabel = showUsage ? (planName ?? (hasApiKey ? red('API') : undefined)) : undefined;
const planDisplay = providerLabel ?? billingLabel;
const modelDisplay = planDisplay ? `${model} | ${planDisplay}` : model;
if (display?.showModel !== false && display?.showContextBar !== false) {
parts.push(`${cyan(`[${modelDisplay}]`)} ${bar} ${contextValueDisplay}`);
} else if (display?.showModel !== false) {
parts.push(`${cyan(`[${modelDisplay}]`)} ${contextValueDisplay}`);
} else if (display?.showContextBar !== false) {
parts.push(`${bar} ${contextValueDisplay}`);
} else {
parts.push(contextValueDisplay);
}
// Project path + git status (SECOND)
let projectPart: string | null = null;
if (display?.showProject !== false && ctx.stdin.cwd) {
// Split by both Unix (/) and Windows (\) separators for cross-platform support
const segments = ctx.stdin.cwd.split(/[/\\]/).filter(Boolean);
const pathLevels = ctx.config?.pathLevels ?? 1;
// Always join with forward slash for consistent display
// Handle root path (/) which results in empty segments
const projectPath = segments.length > 0 ? segments.slice(-pathLevels).join('/') : '/';
projectPart = yellow(projectPath);
}
let gitPart = '';
const gitConfig = ctx.config?.gitStatus;
const showGit = gitConfig?.enabled ?? true;
if (showGit && ctx.gitStatus) {
const gitParts: string[] = [ctx.gitStatus.branch];
// Show dirty indicator
if ((gitConfig?.showDirty ?? true) && ctx.gitStatus.isDirty) {
gitParts.push('*');
}
// Show ahead/behind (with space separator for readability)
if (gitConfig?.showAheadBehind) {
if (ctx.gitStatus.ahead > 0) {
gitParts.push(` ↑${ctx.gitStatus.ahead}`);
}
if (ctx.gitStatus.behind > 0) {
gitParts.push(` ↓${ctx.gitStatus.behind}`);
}
}
// Show file stats in Starship-compatible format (!modified +added ✘deleted ?untracked)
if (gitConfig?.showFileStats && ctx.gitStatus.fileStats) {
const { modified, added, deleted, untracked } = ctx.gitStatus.fileStats;
const statParts: string[] = [];
if (modified > 0) statParts.push(`!${modified}`);
if (added > 0) statParts.push(`+${added}`);
if (deleted > 0) statParts.push(`✘${deleted}`);
if (untracked > 0) statParts.push(`?${untracked}`);
if (statParts.length > 0) {
gitParts.push(` ${statParts.join(' ')}`);
}
}
gitPart = `${magenta('git:(')}${cyan(gitParts.join(''))}${magenta(')')}`;
}
if (projectPart && gitPart) {
parts.push(`${projectPart} ${gitPart}`);
} else if (projectPart) {
parts.push(projectPart);
} else if (gitPart) {
parts.push(gitPart);
}
// Session name (custom title from /rename, or auto-generated slug)
if (display?.showSessionName && ctx.transcript.sessionName) {
parts.push(dim(ctx.transcript.sessionName));
}
// Config counts (respects environmentThreshold)
if (display?.showConfigCounts !== false) {
const totalCounts = ctx.claudeMdCount + ctx.rulesCount + ctx.mcpCount + ctx.hooksCount;
const envThreshold = display?.environmentThreshold ?? 0;
if (totalCounts > 0 && totalCounts >= envThreshold) {
if (ctx.claudeMdCount > 0) {
parts.push(dim(`${ctx.claudeMdCount} CLAUDE.md`));
}
if (ctx.rulesCount > 0) {
parts.push(dim(`${ctx.rulesCount} rules`));
}
if (ctx.mcpCount > 0) {
parts.push(dim(`${ctx.mcpCount} MCPs`));
}
if (ctx.hooksCount > 0) {
parts.push(dim(`${ctx.hooksCount} hooks`));
}
}
}
// Usage limits display (shown when enabled in config, respects usageThreshold)
if (display?.showUsage !== false && ctx.usageData?.planName && !providerLabel) {
if (ctx.usageData.apiUnavailable) {
const errorHint = formatUsageError(ctx.usageData.apiError);
parts.push(warning(`usage: ⚠${errorHint}`, colors));
} else if (isLimitReached(ctx.usageData)) {
const resetTime = ctx.usageData.fiveHour === 100
? formatResetTime(ctx.usageData.fiveHourResetAt)
: formatResetTime(ctx.usageData.sevenDayResetAt);
parts.push(critical(`⚠ Limit reached${resetTime ? ` (resets ${resetTime})` : ''}`, colors));
} else {
const usageThreshold = display?.usageThreshold ?? 0;
const fiveHour = ctx.usageData.fiveHour;
const sevenDay = ctx.usageData.sevenDay;
const effectiveUsage = Math.max(fiveHour ?? 0, sevenDay ?? 0);
if (effectiveUsage >= usageThreshold) {
const syncingSuffix = ctx.usageData.apiError === 'rate-limited'
? ` ${dim('(syncing...)')}`
: '';
const fiveHourDisplay = formatUsagePercent(fiveHour, colors);
const fiveHourReset = formatResetTime(ctx.usageData.fiveHourResetAt);
const usageBarEnabled = display?.usageBarEnabled ?? true;
const fiveHourPart = usageBarEnabled
? (fiveHourReset
? `${quotaBar(fiveHour ?? 0, barWidth, colors)} ${fiveHourDisplay} (${fiveHourReset} / 5h)`
: `${quotaBar(fiveHour ?? 0, barWidth, colors)} ${fiveHourDisplay}`)
: (fiveHourReset
? `5h: ${fiveHourDisplay} (${fiveHourReset})`
: `5h: ${fiveHourDisplay}`);
const sevenDayThreshold = display?.sevenDayThreshold ?? 80;
if (sevenDay !== null && sevenDay >= sevenDayThreshold) {
const sevenDayDisplay = formatUsagePercent(sevenDay, colors);
const sevenDayReset = formatResetTime(ctx.usageData.sevenDayResetAt);
const sevenDayPart = usageBarEnabled
? (sevenDayReset
? `${quotaBar(sevenDay, barWidth, colors)} ${sevenDayDisplay} (${sevenDayReset} / 7d)`
: `${quotaBar(sevenDay, barWidth, colors)} ${sevenDayDisplay}`)
: (sevenDayReset
? `7d: ${sevenDayDisplay} (${sevenDayReset})`
: `7d: ${sevenDayDisplay}`);
parts.push(`${fiveHourPart} | ${sevenDayPart}${syncingSuffix}`);
} else {
parts.push(`${fiveHourPart}${syncingSuffix}`);
}
}
}
}
// Session duration
if (display?.showSpeed) {
const speed = getOutputSpeed(ctx.stdin);
if (speed !== null) {
parts.push(dim(`out: ${speed.toFixed(1)} tok/s`));
}
}
if (display?.showDuration !== false && ctx.sessionDuration) {
parts.push(dim(`⏱️ ${ctx.sessionDuration}`));
}
if (ctx.extraLabel) {
parts.push(dim(ctx.extraLabel));
}
// Custom line (static user-defined text)
const customLine = display?.customLine;
if (customLine) {
parts.push(claudeOrange(customLine));
}
let line = parts.join(' | ');
// Token breakdown at high context
if (display?.showTokenBreakdown !== false && percent >= 85) {
const usage = ctx.stdin.context_window?.current_usage;
if (usage) {
const input = formatTokens(usage.input_tokens ?? 0);
const cache = formatTokens((usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0));
line += dim(` (in: ${input}, cache: ${cache})`);
}
}
return line;
}
function formatTokens(n: number): string {
if (n >= 1000000) {
return `${(n / 1000000).toFixed(1)}M`;
}
if (n >= 1000) {
return `${(n / 1000).toFixed(0)}k`;
}
return n.toString();
}
function formatContextValue(ctx: RenderContext, percent: number, mode: 'percent' | 'tokens' | 'remaining'): string {
if (mode === 'tokens') {
const totalTokens = getTotalTokens(ctx.stdin);
const size = ctx.stdin.context_window?.context_window_size ?? 0;
if (size > 0) {
return `${formatTokens(totalTokens)}/${formatTokens(size)}`;
}
return formatTokens(totalTokens);
}
if (mode === 'remaining') {
return `${Math.max(0, 100 - percent)}%`;
}
return `${percent}%`;
}
function formatUsagePercent(percent: number | null, colors?: RenderContext['config']['colors']): string {
if (percent === null) {
return dim('--');
}
const color = getQuotaColor(percent, colors);
return `${color}${percent}%${RESET}`;
}
function formatUsageError(error?: string): string {
if (!error) return '';
if (error === 'rate-limited') return ' (syncing...)';
if (error.startsWith('http-')) return ` (${error.slice(5)})`;
return ` (${error})`;
}
function formatResetTime(resetAt: Date | null): string {
if (!resetAt) return '';
const now = new Date();
const diffMs = resetAt.getTime() - now.getTime();
if (diffMs <= 0) return '';
const diffMins = Math.ceil(diffMs / 60000);
if (diffMins < 60) return `${diffMins}m`;
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours >= 24) {
const days = Math.floor(hours / 24);
const remHours = hours % 24;
if (remHours > 0) return `${days}d ${remHours}h`;
return `${days}d`;
}
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
================================================
FILE: src/render/todos-line.ts
================================================
import type { RenderContext } from '../types.js';
import { yellow, green, dim } from './colors.js';
export function renderTodosLine(ctx: RenderContext): string | null {
const { todos } = ctx.transcript;
if (!todos || todos.length === 0) {
return null;
}
const inProgress = todos.find((t) => t.status === 'in_progress');
const completed = todos.filter((t) => t.status === 'completed').length;
const total = todos.length;
if (!inProgress) {
if (completed === total && total > 0) {
return `${green('✓')} All todos complete ${dim(`(${completed}/${total})`)}`;
}
return null;
}
const content = truncateContent(inProgress.content);
const progress = dim(`(${completed}/${total})`);
return `${yellow('▸')} ${content} ${progress}`;
}
function truncateContent(content: string, maxLen: number = 50): string {
if (content.length <= maxLen) return content;
return content.slice(0, maxLen - 3) + '...';
}
================================================
FILE: src/render/tools-line.ts
================================================
import type { RenderContext } from '../types.js';
import { yellow, green, cyan, dim } from './colors.js';
export function renderToolsLine(ctx: RenderContext): string | null {
const { tools } = ctx.transcript;
if (tools.length === 0) {
return null;
}
const parts: string[] = [];
const runningTools = tools.filter((t) => t.status === 'running');
const completedTools = tools.filter((t) => t.status === 'completed' || t.status === 'error');
for (const tool of runningTools.slice(-2)) {
const target = tool.target ? truncatePath(tool.target) : '';
parts.push(`${yellow('◐')} ${cyan(tool.name)}${target ? dim(`: ${target}`) : ''}`);
}
const toolCounts = new Map();
for (const tool of completedTools) {
const count = toolCounts.get(tool.name) ?? 0;
toolCounts.set(tool.name, count + 1);
}
const sortedTools = Array.from(toolCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
for (const [name, count] of sortedTools) {
parts.push(`${green('✓')} ${name} ${dim(`×${count}`)}`);
}
if (parts.length === 0) {
return null;
}
return parts.join(' | ');
}
function truncatePath(path: string, maxLen: number = 20): string {
// Normalize Windows backslashes to forward slashes for consistent display
const normalizedPath = path.replace(/\\/g, '/');
if (normalizedPath.length <= maxLen) return normalizedPath;
// Split by forward slash (already normalized)
const parts = normalizedPath.split('/');
const filename = parts.pop() || normalizedPath;
if (filename.length >= maxLen) {
return filename.slice(0, maxLen - 3) + '...';
}
return '.../' + filename;
}
================================================
FILE: src/speed-tracker.ts
================================================
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import type { StdinData } from './types.js';
import { getHudPluginDir } from './claude-config-dir.js';
const SPEED_WINDOW_MS = 2000;
interface SpeedCache {
outputTokens: number;
timestamp: number;
}
export type SpeedTrackerDeps = {
homeDir: () => string;
now: () => number;
};
const defaultDeps: SpeedTrackerDeps = {
homeDir: () => os.homedir(),
now: () => Date.now(),
};
function getCachePath(homeDir: string): string {
return path.join(getHudPluginDir(homeDir), '.speed-cache.json');
}
function readCache(homeDir: string): SpeedCache | null {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath)) return null;
const content = fs.readFileSync(cachePath, 'utf8');
const parsed = JSON.parse(content) as SpeedCache;
if (typeof parsed.outputTokens !== 'number' || typeof parsed.timestamp !== 'number') {
return null;
}
return parsed;
} catch {
return null;
}
}
function writeCache(homeDir: string, cache: SpeedCache): void {
try {
const cachePath = getCachePath(homeDir);
const cacheDir = path.dirname(cachePath);
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
} catch {
// Ignore cache write failures
}
}
export function getOutputSpeed(stdin: StdinData, overrides: Partial = {}): number | null {
const outputTokens = stdin.context_window?.current_usage?.output_tokens;
if (typeof outputTokens !== 'number' || !Number.isFinite(outputTokens)) {
return null;
}
const deps = { ...defaultDeps, ...overrides };
const now = deps.now();
const homeDir = deps.homeDir();
const previous = readCache(homeDir);
let speed: number | null = null;
if (previous && outputTokens >= previous.outputTokens) {
const deltaTokens = outputTokens - previous.outputTokens;
const deltaMs = now - previous.timestamp;
if (deltaTokens > 0 && deltaMs > 0 && deltaMs <= SPEED_WINDOW_MS) {
speed = deltaTokens / (deltaMs / 1000);
}
}
writeCache(homeDir, { outputTokens, timestamp: now });
return speed;
}
================================================
FILE: src/stdin.ts
================================================
import type { StdinData } from './types.js';
import { AUTOCOMPACT_BUFFER_PERCENT } from './constants.js';
export async function readStdin(): Promise {
if (process.stdin.isTTY) {
return null;
}
const chunks: string[] = [];
try {
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) {
chunks.push(chunk as string);
}
const raw = chunks.join('');
if (!raw.trim()) {
return null;
}
return JSON.parse(raw) as StdinData;
} catch {
return null;
}
}
export function getTotalTokens(stdin: StdinData): number {
const usage = stdin.context_window?.current_usage;
return (
(usage?.input_tokens ?? 0) +
(usage?.cache_creation_input_tokens ?? 0) +
(usage?.cache_read_input_tokens ?? 0)
);
}
/**
* Get native percentage from Claude Code v2.1.6+ if available.
* Returns null if not available or invalid, triggering fallback to manual calculation.
*/
function getNativePercent(stdin: StdinData): number | null {
const nativePercent = stdin.context_window?.used_percentage;
if (typeof nativePercent === 'number' && !Number.isNaN(nativePercent)) {
return Math.min(100, Math.max(0, Math.round(nativePercent)));
}
return null;
}
export function getContextPercent(stdin: StdinData): number {
// Prefer native percentage (v2.1.6+) - accurate and matches /context
const native = getNativePercent(stdin);
if (native !== null) {
return native;
}
// Fallback: manual calculation without buffer
const size = stdin.context_window?.context_window_size;
if (!size || size <= 0) {
return 0;
}
const totalTokens = getTotalTokens(stdin);
return Math.min(100, Math.round((totalTokens / size) * 100));
}
export function getBufferedPercent(stdin: StdinData): number {
// Prefer native percentage (v2.1.6+) so the HUD matches Claude Code's
// own context output. The buffered fallback only approximates older versions.
const native = getNativePercent(stdin);
if (native !== null) {
return native;
}
// Fallback: manual calculation with buffer for older Claude Code versions
const size = stdin.context_window?.context_window_size;
if (!size || size <= 0) {
return 0;
}
const totalTokens = getTotalTokens(stdin);
// Scale buffer by raw usage: no buffer at ≤5% (e.g. after /clear),
// full buffer at ≥50%. Autocompact doesn't kick in at very low usage.
const rawRatio = totalTokens / size;
const LOW = 0.05;
const HIGH = 0.50;
const scale = Math.min(1, Math.max(0, (rawRatio - LOW) / (HIGH - LOW)));
const buffer = size * AUTOCOMPACT_BUFFER_PERCENT * scale;
return Math.min(100, Math.round(((totalTokens + buffer) / size) * 100));
}
export function getModelName(stdin: StdinData): string {
const displayName = stdin.model?.display_name?.trim();
if (displayName) {
return displayName;
}
const modelId = stdin.model?.id?.trim();
if (!modelId) {
return 'Unknown';
}
const normalizedBedrockLabel = normalizeBedrockModelLabel(modelId);
return normalizedBedrockLabel ?? modelId;
}
export function isBedrockModelId(modelId?: string): boolean {
if (!modelId) {
return false;
}
const normalized = modelId.toLowerCase();
return normalized.includes('anthropic.claude-');
}
export function getProviderLabel(stdin: StdinData): string | null {
if (isBedrockModelId(stdin.model?.id)) {
return 'Bedrock';
}
return null;
}
function normalizeBedrockModelLabel(modelId: string): string | null {
if (!isBedrockModelId(modelId)) {
return null;
}
const lowercaseId = modelId.toLowerCase();
const claudePrefix = 'anthropic.claude-';
const claudeIndex = lowercaseId.indexOf(claudePrefix);
if (claudeIndex === -1) {
return null;
}
let suffix = lowercaseId.slice(claudeIndex + claudePrefix.length);
suffix = suffix.replace(/-v\d+:\d+$/, '');
suffix = suffix.replace(/-\d{8}$/, '');
const tokens = suffix.split('-').filter(Boolean);
if (tokens.length === 0) {
return null;
}
const familyIndex = tokens.findIndex((token) => token === 'haiku' || token === 'sonnet' || token === 'opus');
if (familyIndex === -1) {
return null;
}
const family = tokens[familyIndex];
const beforeVersion = readNumericVersion(tokens, familyIndex - 1, -1).reverse();
const afterVersion = readNumericVersion(tokens, familyIndex + 1, 1);
const versionParts = beforeVersion.length >= afterVersion.length ? beforeVersion : afterVersion;
const version = versionParts.length ? versionParts.join('.') : null;
const familyLabel = family[0].toUpperCase() + family.slice(1);
return version ? `Claude ${familyLabel} ${version}` : `Claude ${familyLabel}`;
}
function readNumericVersion(tokens: string[], startIndex: number, step: -1 | 1): string[] {
const parts: string[] = [];
for (let i = startIndex; i >= 0 && i < tokens.length; i += step) {
if (!/^\d+$/.test(tokens[i])) {
break;
}
parts.push(tokens[i]);
if (parts.length === 2) {
break;
}
}
return parts;
}
================================================
FILE: src/transcript.ts
================================================
import * as fs from 'fs';
import * as readline from 'readline';
import type { TranscriptData, ToolEntry, AgentEntry, TodoItem } from './types.js';
interface TranscriptLine {
timestamp?: string;
type?: string;
slug?: string;
customTitle?: string;
message?: {
content?: ContentBlock[];
};
}
interface ContentBlock {
type: string;
id?: string;
name?: string;
input?: Record;
tool_use_id?: string;
is_error?: boolean;
}
export async function parseTranscript(transcriptPath: string): Promise {
const result: TranscriptData = {
tools: [],
agents: [],
todos: [],
};
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
return result;
}
const toolMap = new Map();
const agentMap = new Map();
let latestTodos: TodoItem[] = [];
const taskIdToIndex = new Map();
let latestSlug: string | undefined;
let customTitle: string | undefined;
try {
const fileStream = fs.createReadStream(transcriptPath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as TranscriptLine;
if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') {
customTitle = entry.customTitle;
} else if (typeof entry.slug === 'string') {
latestSlug = entry.slug;
}
processEntry(entry, toolMap, agentMap, taskIdToIndex, latestTodos, result);
} catch {
// Skip malformed lines
}
}
} catch {
// Return partial results on error
}
result.tools = Array.from(toolMap.values()).slice(-20);
result.agents = Array.from(agentMap.values()).slice(-10);
result.todos = latestTodos;
result.sessionName = customTitle ?? latestSlug;
return result;
}
function processEntry(
entry: TranscriptLine,
toolMap: Map,
agentMap: Map,
taskIdToIndex: Map,
latestTodos: TodoItem[],
result: TranscriptData
): void {
const timestamp = entry.timestamp ? new Date(entry.timestamp) : new Date();
if (!result.sessionStart && entry.timestamp) {
result.sessionStart = timestamp;
}
const content = entry.message?.content;
if (!content || !Array.isArray(content)) return;
for (const block of content) {
if (block.type === 'tool_use' && block.id && block.name) {
const toolEntry: ToolEntry = {
id: block.id,
name: block.name,
target: extractTarget(block.name, block.input),
status: 'running',
startTime: timestamp,
};
if (block.name === 'Task') {
const input = block.input as Record;
const agentEntry: AgentEntry = {
id: block.id,
type: (input?.subagent_type as string) ?? 'unknown',
model: (input?.model as string) ?? undefined,
description: (input?.description as string) ?? undefined,
status: 'running',
startTime: timestamp,
};
agentMap.set(block.id, agentEntry);
} else if (block.name === 'TodoWrite') {
const input = block.input as { todos?: TodoItem[] };
if (input?.todos && Array.isArray(input.todos)) {
latestTodos.length = 0;
taskIdToIndex.clear();
latestTodos.push(...input.todos);
}
} else if (block.name === 'TaskCreate') {
const input = block.input as Record;
const subject = typeof input?.subject === 'string' ? input.subject : '';
const description = typeof input?.description === 'string' ? input.description : '';
const content = subject || description || 'Untitled task';
const status = normalizeTaskStatus(input?.status) ?? 'pending';
latestTodos.push({ content, status });
const rawTaskId = input?.taskId;
const taskId = typeof rawTaskId === 'string' || typeof rawTaskId === 'number'
? String(rawTaskId)
: block.id;
if (taskId) {
taskIdToIndex.set(taskId, latestTodos.length - 1);
}
} else if (block.name === 'TaskUpdate') {
const input = block.input as Record;
const index = resolveTaskIndex(input?.taskId, taskIdToIndex, latestTodos);
if (index !== null) {
const status = normalizeTaskStatus(input?.status);
if (status) {
latestTodos[index].status = status;
}
const subject = typeof input?.subject === 'string' ? input.subject : '';
const description = typeof input?.description === 'string' ? input.description : '';
const content = subject || description;
if (content) {
latestTodos[index].content = content;
}
}
} else {
toolMap.set(block.id, toolEntry);
}
}
if (block.type === 'tool_result' && block.tool_use_id) {
const tool = toolMap.get(block.tool_use_id);
if (tool) {
tool.status = block.is_error ? 'error' : 'completed';
tool.endTime = timestamp;
}
const agent = agentMap.get(block.tool_use_id);
if (agent) {
agent.status = 'completed';
agent.endTime = timestamp;
}
}
}
}
function extractTarget(toolName: string, input?: Record): string | undefined {
if (!input) return undefined;
switch (toolName) {
case 'Read':
case 'Write':
case 'Edit':
return (input.file_path as string) ?? (input.path as string);
case 'Glob':
return input.pattern as string;
case 'Grep':
return input.pattern as string;
case 'Bash':
const cmd = input.command as string;
return cmd?.slice(0, 30) + (cmd?.length > 30 ? '...' : '');
}
return undefined;
}
function resolveTaskIndex(
taskId: unknown,
taskIdToIndex: Map,
latestTodos: TodoItem[]
): number | null {
if (typeof taskId === 'string' || typeof taskId === 'number') {
const key = String(taskId);
const mapped = taskIdToIndex.get(key);
if (typeof mapped === 'number') {
return mapped;
}
if (/^\d+$/.test(key)) {
const numericIndex = Number.parseInt(key, 10) - 1;
if (numericIndex >= 0 && numericIndex < latestTodos.length) {
return numericIndex;
}
}
}
return null;
}
function normalizeTaskStatus(status: unknown): TodoItem['status'] | null {
if (typeof status !== 'string') return null;
switch (status) {
case 'pending':
case 'not_started':
return 'pending';
case 'in_progress':
case 'running':
return 'in_progress';
case 'completed':
case 'complete':
case 'done':
return 'completed';
default:
return null;
}
}
================================================
FILE: src/types.ts
================================================
import type { HudConfig } from './config.js';
import type { GitStatus } from './git.js';
export interface StdinData {
transcript_path?: string;
cwd?: string;
model?: {
id?: string;
display_name?: string;
};
context_window?: {
context_window_size?: number;
current_usage?: {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
} | null;
// Native percentage fields (Claude Code v2.1.6+)
used_percentage?: number | null;
remaining_percentage?: number | null;
};
}
export interface ToolEntry {
id: string;
name: string;
target?: string;
status: 'running' | 'completed' | 'error';
startTime: Date;
endTime?: Date;
}
export interface AgentEntry {
id: string;
type: string;
model?: string;
description?: string;
status: 'running' | 'completed';
startTime: Date;
endTime?: Date;
}
export interface TodoItem {
content: string;
status: 'pending' | 'in_progress' | 'completed';
}
/** Usage window data from the OAuth API */
export interface UsageWindow {
utilization: number | null; // 0-100 percentage, null if unavailable
resetAt: Date | null;
}
export interface UsageData {
planName: string | null; // 'Max', 'Pro', or null for API users
fiveHour: number | null; // 0-100 percentage, null if unavailable
sevenDay: number | null; // 0-100 percentage, null if unavailable
fiveHourResetAt: Date | null;
sevenDayResetAt: Date | null;
apiUnavailable?: boolean; // true if API call failed (user should check DEBUG logs)
apiError?: string; // short error reason (e.g., 401, timeout)
}
/** Check if usage limit is reached (either window at 100%) */
export function isLimitReached(data: UsageData): boolean {
return data.fiveHour === 100 || data.sevenDay === 100;
}
export interface TranscriptData {
tools: ToolEntry[];
agents: AgentEntry[];
todos: TodoItem[];
sessionStart?: Date;
sessionName?: string;
}
export interface RenderContext {
stdin: StdinData;
transcript: TranscriptData;
claudeMdCount: number;
rulesCount: number;
mcpCount: number;
hooksCount: number;
sessionDuration: string;
gitStatus: GitStatus | null;
usageData: UsageData | null;
config: HudConfig;
extraLabel: string | null;
}
================================================
FILE: src/usage-api.ts
================================================
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as net from 'net';
import * as tls from 'tls';
import * as https from 'https';
import { execFileSync } from 'child_process';
import { createHash } from 'crypto';
import type { UsageData } from './types.js';
import { createDebug } from './debug.js';
import { getClaudeConfigDir, getHudPluginDir } from './claude-config-dir.js';
export type { UsageData } from './types.js';
const debug = createDebug('usage');
const LEGACY_KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials';
interface CredentialsFile {
claudeAiOauth?: {
accessToken?: string;
refreshToken?: string;
subscriptionType?: string;
rateLimitTier?: string;
expiresAt?: number; // Unix millisecond timestamp
scopes?: string[];
};
}
interface UsageApiResponse {
five_hour?: {
utilization?: number;
resets_at?: string;
};
seven_day?: {
utilization?: number;
resets_at?: string;
};
}
interface UsageApiResult {
data: UsageApiResponse | null;
error?: string;
/** Retry-After header value in seconds (from 429 responses) */
retryAfterSec?: number;
}
// File-based cache (HUD runs as new process each render, so in-memory cache won't persist)
const CACHE_TTL_MS = 5 * 60_000; // 5 minutes — matches Anthropic usage API rate limit window
const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests
const CACHE_RATE_LIMITED_BASE_MS = 60_000; // 60s base for 429 backoff
const CACHE_RATE_LIMITED_MAX_MS = 5 * 60_000; // 5 min max backoff
const CACHE_LOCK_STALE_MS = 30_000;
const CACHE_LOCK_WAIT_MS = 2_000;
const CACHE_LOCK_POLL_MS = 50;
const KEYCHAIN_TIMEOUT_MS = 3000;
const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting
const USAGE_API_TIMEOUT_MS_DEFAULT = 15_000;
export const USAGE_API_USER_AGENT = 'claude-code/2.1';
/**
* Check if user is using a custom API endpoint instead of the default Anthropic API.
* When using custom providers (e.g., via cc-switch), the OAuth usage API is not applicable.
*/
function isUsingCustomApiEndpoint(env: NodeJS.ProcessEnv = process.env): boolean {
const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || env.ANTHROPIC_API_BASE_URL?.trim();
// No custom endpoint configured - using default Anthropic API
if (!baseUrl) {
return false;
}
try {
return new URL(baseUrl).origin !== 'https://api.anthropic.com';
} catch {
return true;
}
}
interface CacheFile {
data: UsageData;
timestamp: number;
/** Consecutive 429 count for exponential backoff */
rateLimitedCount?: number;
/** Absolute timestamp (ms) when retry is allowed (from Retry-After header) */
retryAfterUntil?: number;
/** Last successful API data — preserved across rate-limited periods */
lastGoodData?: UsageData;
}
interface CacheState {
data: UsageData;
timestamp: number;
isFresh: boolean;
}
type CacheLockStatus = 'acquired' | 'busy' | 'unsupported';
function getCachePath(homeDir: string): string {
return path.join(getHudPluginDir(homeDir), '.usage-cache.json');
}
function getCacheLockPath(homeDir: string): string {
return path.join(getHudPluginDir(homeDir), '.usage-cache.lock');
}
function hydrateCacheData(data: UsageData): UsageData {
// JSON.stringify converts Date to ISO string, so we need to reconvert on read.
// new Date() handles both Date objects and ISO strings safely.
if (data.fiveHourResetAt) {
data.fiveHourResetAt = new Date(data.fiveHourResetAt);
}
if (data.sevenDayResetAt) {
data.sevenDayResetAt = new Date(data.sevenDayResetAt);
}
return data;
}
type CacheTtls = { cacheTtlMs: number; failureCacheTtlMs: number };
function getRateLimitedTtlMs(count: number): number {
// Exponential backoff: 60s, 120s, 240s, capped at 5 min
return Math.min(CACHE_RATE_LIMITED_BASE_MS * Math.pow(2, Math.max(0, count - 1)), CACHE_RATE_LIMITED_MAX_MS);
}
function getRateLimitedRetryUntil(cache: CacheFile): number | null {
if (cache.data.apiError !== 'rate-limited') {
return null;
}
if (cache.retryAfterUntil && cache.retryAfterUntil > cache.timestamp) {
return cache.retryAfterUntil;
}
if (cache.rateLimitedCount && cache.rateLimitedCount > 0) {
return cache.timestamp + getRateLimitedTtlMs(cache.rateLimitedCount);
}
return null;
}
function withRateLimitedSyncing(data: UsageData): UsageData {
return {
...data,
apiError: 'rate-limited',
};
}
function readCacheState(homeDir: string, now: number, ttls: CacheTtls): CacheState | null {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath)) return null;
const content = fs.readFileSync(cachePath, 'utf8');
const cache: CacheFile = JSON.parse(content);
// Only serve lastGoodData during rate-limit backoff. Other failures should remain visible.
const displayData = (cache.data.apiError === 'rate-limited' && cache.lastGoodData)
? withRateLimitedSyncing(cache.lastGoodData)
: cache.data;
const rateLimitedRetryUntil = getRateLimitedRetryUntil(cache);
if (rateLimitedRetryUntil && now < rateLimitedRetryUntil) {
return { data: hydrateCacheData(displayData), timestamp: cache.timestamp, isFresh: true };
}
const ttl = cache.data.apiUnavailable ? ttls.failureCacheTtlMs : ttls.cacheTtlMs;
return {
data: hydrateCacheData(displayData),
timestamp: cache.timestamp,
isFresh: now - cache.timestamp < ttl,
};
} catch {
return null;
}
}
function readRateLimitedCount(homeDir: string): number {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath)) return 0;
const content = fs.readFileSync(cachePath, 'utf8');
const cache: CacheFile = JSON.parse(content);
return cache.rateLimitedCount ?? 0;
} catch {
return 0;
}
}
function readLastGoodData(homeDir: string): UsageData | null {
try {
const cachePath = getCachePath(homeDir);
if (!fs.existsSync(cachePath)) return null;
const content = fs.readFileSync(cachePath, 'utf8');
const cache: CacheFile = JSON.parse(content);
return cache.lastGoodData ? hydrateCacheData(cache.lastGoodData) : null;
} catch {
return null;
}
}
function readCache(homeDir: string, now: number, ttls: CacheTtls): UsageData | null {
const cache = readCacheState(homeDir, now, ttls);
return cache?.isFresh ? cache.data : null;
}
interface WriteCacheOpts {
rateLimitedCount?: number;
retryAfterUntil?: number;
lastGoodData?: UsageData;
}
function writeCache(homeDir: string, data: UsageData, timestamp: number, opts?: WriteCacheOpts): void {
try {
const cachePath = getCachePath(homeDir);
const cacheDir = path.dirname(cachePath);
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const cache: CacheFile = { data, timestamp };
if (opts?.rateLimitedCount && opts.rateLimitedCount > 0) {
cache.rateLimitedCount = opts.rateLimitedCount;
}
if (opts?.retryAfterUntil) {
cache.retryAfterUntil = opts.retryAfterUntil;
}
if (opts?.lastGoodData) {
cache.lastGoodData = opts.lastGoodData;
}
fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
} catch {
// Ignore cache write failures
}
}
function readLockTimestamp(lockPath: string): number | null {
try {
if (!fs.existsSync(lockPath)) return null;
const raw = fs.readFileSync(lockPath, 'utf8').trim();
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
}
function tryAcquireCacheLock(homeDir: string): CacheLockStatus {
const lockPath = getCacheLockPath(homeDir);
const cacheDir = path.dirname(lockPath);
try {
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const fd = fs.openSync(lockPath, 'wx');
try {
fs.writeFileSync(fd, String(Date.now()), 'utf8');
} finally {
fs.closeSync(fd);
}
return 'acquired';
} catch (error) {
const maybeError = error as NodeJS.ErrnoException;
if (maybeError.code !== 'EEXIST') {
debug('Usage cache lock unavailable, continuing without coordination:', maybeError.message);
return 'unsupported';
}
}
const lockTimestamp = readLockTimestamp(lockPath);
// Unparseable timestamp — use mtime to distinguish a crash leftover from an active writer.
if (lockTimestamp === null) {
try {
const lockStat = fs.statSync(lockPath);
if (Date.now() - lockStat.mtimeMs < CACHE_LOCK_STALE_MS) {
return 'busy';
}
} catch {
return tryAcquireCacheLock(homeDir);
}
try {
fs.unlinkSync(lockPath);
} catch {
return 'busy';
}
return tryAcquireCacheLock(homeDir);
}
if (lockTimestamp != null && Date.now() - lockTimestamp > CACHE_LOCK_STALE_MS) {
try {
fs.unlinkSync(lockPath);
} catch {
return 'busy';
}
return tryAcquireCacheLock(homeDir);
}
return 'busy';
}
function releaseCacheLock(homeDir: string): void {
try {
const lockPath = getCacheLockPath(homeDir);
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
} catch {
// Ignore lock cleanup failures
}
}
async function waitForFreshCache(
homeDir: string,
now: () => number,
ttls: CacheTtls,
timeoutMs: number = CACHE_LOCK_WAIT_MS
): Promise {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS));
const cached = readCache(homeDir, now(), ttls);
if (cached) {
return cached;
}
if (!fs.existsSync(getCacheLockPath(homeDir))) {
break;
}
}
return readCache(homeDir, now(), ttls);
}
// Dependency injection for testing
export type UsageApiDeps = {
homeDir: () => string;
fetchApi: (accessToken: string) => Promise;
now: () => number;
readKeychain: (now: number, homeDir: string) => { accessToken: string; subscriptionType: string } | null;
ttls: CacheTtls;
};
const defaultDeps: UsageApiDeps = {
homeDir: () => os.homedir(),
fetchApi: fetchUsageApi,
now: () => Date.now(),
readKeychain: readKeychainCredentials,
ttls: { cacheTtlMs: CACHE_TTL_MS, failureCacheTtlMs: CACHE_FAILURE_TTL_MS },
};
/**
* Get OAuth usage data from Anthropic API.
* Returns null if user is an API user (no OAuth credentials) or credentials are expired.
* Returns { apiUnavailable: true, ... } if API call fails (to show warning in HUD).
*
* Uses file-based cache since HUD runs as a new process each render (~300ms).
* Cache TTL is configurable via usage.cacheTtlSeconds / usage.failureCacheTtlSeconds in config.json
* (defaults: 60s for success, 15s for failures).
*/
export async function getUsage(overrides: Partial = {}): Promise {
const deps = { ...defaultDeps, ...overrides };
const now = deps.now();
const homeDir = deps.homeDir();
// Skip usage API if user is using a custom provider
if (isUsingCustomApiEndpoint()) {
debug('Skipping usage API: custom API endpoint configured');
return null;
}
// Check file-based cache first
const cacheState = readCacheState(homeDir, now, deps.ttls);
if (cacheState?.isFresh) {
return cacheState.data;
}
let holdsCacheLock = false;
const lockStatus = tryAcquireCacheLock(homeDir);
if (lockStatus === 'busy') {
if (cacheState) {
return cacheState.data;
}
return await waitForFreshCache(homeDir, deps.now, deps.ttls);
}
holdsCacheLock = lockStatus === 'acquired';
try {
const refreshedCache = readCache(homeDir, deps.now(), deps.ttls);
if (refreshedCache) {
return refreshedCache;
}
const credentials = readCredentials(homeDir, now, deps.readKeychain);
if (!credentials) {
return null;
}
const { accessToken, subscriptionType } = credentials;
// Determine plan name from subscriptionType
const planName = getPlanName(subscriptionType);
if (!planName) {
// API user, no usage limits to show
return null;
}
// Fetch usage from API
const apiResult = await deps.fetchApi(accessToken);
if (!apiResult.data) {
const isRateLimited = apiResult.error === 'rate-limited';
const prevCount = readRateLimitedCount(homeDir);
const rateLimitedCount = isRateLimited ? prevCount + 1 : 0;
const retryAfterUntil = isRateLimited && apiResult.retryAfterSec
? now + apiResult.retryAfterSec * 1000
: undefined;
const backoffOpts: WriteCacheOpts = {
rateLimitedCount: isRateLimited ? rateLimitedCount : undefined,
retryAfterUntil,
};
const failureResult: UsageData = {
planName,
fiveHour: null,
sevenDay: null,
fiveHourResetAt: null,
sevenDayResetAt: null,
apiUnavailable: true,
apiError: apiResult.error,
};
if (isRateLimited) {
const staleCache = readCacheState(homeDir, now, deps.ttls);
const lastGood = readLastGoodData(homeDir);
const goodData = (staleCache && !staleCache.data.apiUnavailable)
? staleCache.data
: lastGood;
if (goodData) {
// Preserve the backoff state in cache, but keep rendering the last successful values
// with a syncing hint so stale data is visible to the user.
writeCache(homeDir, failureResult, now, { ...backoffOpts, lastGoodData: goodData });
return withRateLimitedSyncing(goodData);
}
}
writeCache(homeDir, failureResult, now, backoffOpts);
return failureResult;
}
// Parse response - API returns 0-100 percentage directly
// Clamp to 0-100 and handle NaN/Infinity
const fiveHour = parseUtilization(apiResult.data.five_hour?.utilization);
const sevenDay = parseUtilization(apiResult.data.seven_day?.utilization);
const fiveHourResetAt = parseDate(apiResult.data.five_hour?.resets_at);
const sevenDayResetAt = parseDate(apiResult.data.seven_day?.resets_at);
const result: UsageData = {
planName,
fiveHour,
sevenDay,
fiveHourResetAt,
sevenDayResetAt,
};
// Write to file cache — also store as lastGoodData for rate-limit resilience
writeCache(homeDir, result, now, { lastGoodData: result });
return result;
} catch (error) {
debug('getUsage failed:', error);
return null;
} finally {
if (holdsCacheLock) {
releaseCacheLock(homeDir);
}
}
}
/**
* Get path for keychain failure backoff cache.
* Separate from usage cache to track keychain-specific failures.
*/
function getKeychainBackoffPath(homeDir: string): string {
return path.join(getHudPluginDir(homeDir), '.keychain-backoff');
}
/**
* Check if we're in keychain backoff period (recent failure/timeout).
* Prevents re-prompting user on every render cycle.
*/
function isKeychainBackoff(homeDir: string, now: number): boolean {
try {
const backoffPath = getKeychainBackoffPath(homeDir);
if (!fs.existsSync(backoffPath)) return false;
const timestamp = parseInt(fs.readFileSync(backoffPath, 'utf8'), 10);
return now - timestamp < KEYCHAIN_BACKOFF_MS;
} catch {
return false;
}
}
/**
* Record keychain failure for backoff.
*/
function recordKeychainFailure(homeDir: string, now: number): void {
try {
const backoffPath = getKeychainBackoffPath(homeDir);
const dir = path.dirname(backoffPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(backoffPath, String(now), 'utf8');
} catch {
// Ignore write failures
}
}
/**
* Determine the macOS Keychain service name for Claude Code credentials.
* Claude Code uses the default service for ~/.claude and a hashed suffix for custom config directories.
*/
export function getKeychainServiceName(configDir: string, homeDir: string): string {
const normalizedConfigDir = path.normalize(path.resolve(configDir));
const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude')));
if (normalizedConfigDir === normalizedDefaultDir) {
return LEGACY_KEYCHAIN_SERVICE_NAME;
}
const hash = createHash('sha256').update(normalizedConfigDir).digest('hex').slice(0, 8);
return `${LEGACY_KEYCHAIN_SERVICE_NAME}-${hash}`;
}
export function getKeychainServiceNames(
configDir: string,
homeDir: string,
env: NodeJS.ProcessEnv = process.env
): string[] {
const serviceNames: string[] = [getKeychainServiceName(configDir, homeDir)];
const envConfigDir = env.CLAUDE_CONFIG_DIR?.trim();
if (envConfigDir) {
const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude')));
const normalizedEnvDir = path.normalize(path.resolve(envConfigDir));
if (normalizedEnvDir === normalizedDefaultDir) {
serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME);
} else {
const envHash = createHash('sha256').update(envConfigDir).digest('hex').slice(0, 8);
serviceNames.push(`${LEGACY_KEYCHAIN_SERVICE_NAME}-${envHash}`);
}
}
serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME);
return [...new Set(serviceNames)];
}
function isMissingKeychainItemError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybeError = error as { status?: unknown; message?: unknown; stderr?: unknown };
if (maybeError.status === 44) return true;
const message = typeof maybeError.message === 'string' ? maybeError.message.toLowerCase() : '';
if (message.includes('could not be found in the keychain')) return true;
const stderr = typeof maybeError.stderr === 'string'
? maybeError.stderr.toLowerCase()
: Buffer.isBuffer(maybeError.stderr)
? maybeError.stderr.toString('utf8').toLowerCase()
: '';
return stderr.includes('could not be found in the keychain');
}
export function resolveKeychainCredentials(
serviceNames: string[],
now: number,
loadService: (serviceName: string, accountName?: string) => string,
accountName?: string | null,
): { credentials: { accessToken: string; subscriptionType: string } | null; shouldBackoff: boolean } {
let shouldBackoff = false;
let allowGenericFallback = Boolean(accountName);
for (const serviceName of serviceNames) {
try {
const keychainData = accountName
? loadService(serviceName, accountName)
: loadService(serviceName);
if (accountName) allowGenericFallback = false;
const trimmedKeychainData = keychainData.trim();
if (!trimmedKeychainData) continue;
const data: CredentialsFile = JSON.parse(trimmedKeychainData);
const credentials = parseCredentialsData(data, now);
if (credentials) {
return { credentials, shouldBackoff: false };
}
} catch (error) {
if (!isMissingKeychainItemError(error)) {
if (accountName) allowGenericFallback = false;
shouldBackoff = true;
}
}
}
if (!accountName || !allowGenericFallback) {
return { credentials: null, shouldBackoff };
}
for (const serviceName of serviceNames) {
try {
const keychainData = loadService(serviceName).trim();
if (!keychainData) continue;
const data: CredentialsFile = JSON.parse(keychainData);
const credentials = parseCredentialsData(data, now);
if (credentials) {
return { credentials, shouldBackoff: false };
}
} catch (error) {
if (!isMissingKeychainItemError(error)) {
shouldBackoff = true;
}
}
}
return { credentials: null, shouldBackoff };
}
function getKeychainAccountName(): string | null {
try {
const username = os.userInfo().username.trim();
return username || null;
} catch {
return null;
}
}
/**
* Read credentials from macOS Keychain.
* Claude Code stores OAuth credentials in the macOS Keychain with profile-specific service names.
* Returns null if not on macOS or credentials not found.
*
* Security: Uses execFileSync with absolute path to avoid shell injection and PATH hijacking.
*/
function readKeychainCredentials(now: number, homeDir: string): { accessToken: string; subscriptionType: string } | null {
// Only available on macOS
if (process.platform !== 'darwin') {
return null;
}
// Check backoff to avoid re-prompting on every render after a failure
if (isKeychainBackoff(homeDir, now)) {
debug('Keychain in backoff period, skipping');
return null;
}
try {
const configDir = getClaudeConfigDir(homeDir);
const serviceNames = getKeychainServiceNames(configDir, homeDir);
const accountName = getKeychainAccountName();
debug('Trying keychain service names:', serviceNames);
if (accountName) {
debug('Trying keychain account name:', accountName);
}
const resolved = resolveKeychainCredentials(
serviceNames,
now,
(serviceName, lookupAccountName) => execFileSync(
'/usr/bin/security',
lookupAccountName
? ['find-generic-password', '-s', serviceName, '-a', lookupAccountName, '-w']
: ['find-generic-password', '-s', serviceName, '-w'],
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }
),
accountName,
);
if (resolved.credentials) {
return resolved.credentials;
}
if (resolved.shouldBackoff) {
recordKeychainFailure(homeDir, now);
}
return null;
} catch (error) {
// Security: Only log error message, not full error object (may contain stdout/stderr with tokens)
const message = error instanceof Error ? error.message : 'unknown error';
debug('Failed to read from macOS Keychain:', message);
// Record failure for backoff to avoid re-prompting
recordKeychainFailure(homeDir, now);
return null;
}
}
/**
* Read credentials from file (legacy method).
* Older versions of Claude Code stored credentials in {CLAUDE_CONFIG_DIR}/.credentials.json.
*/
function readFileCredentials(homeDir: string, now: number): { accessToken: string; subscriptionType: string } | null {
const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json');
if (!fs.existsSync(credentialsPath)) {
return null;
}
try {
const content = fs.readFileSync(credentialsPath, 'utf8');
const data: CredentialsFile = JSON.parse(content);
return parseCredentialsData(data, now);
} catch (error) {
debug('Failed to read credentials file:', error);
return null;
}
}
function readFileSubscriptionType(homeDir: string): string | null {
const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json');
if (!fs.existsSync(credentialsPath)) {
return null;
}
try {
const content = fs.readFileSync(credentialsPath, 'utf8');
const data: CredentialsFile = JSON.parse(content);
const subscriptionType = data.claudeAiOauth?.subscriptionType;
const normalizedSubscriptionType = typeof subscriptionType === 'string'
? subscriptionType.trim()
: '';
if (!normalizedSubscriptionType) {
return null;
}
return normalizedSubscriptionType;
} catch (error) {
debug('Failed to read file subscriptionType:', error);
return null;
}
}
/**
* Parse and validate credentials data from either Keychain or file.
*/
function parseCredentialsData(data: CredentialsFile, now: number): { accessToken: string; subscriptionType: string } | null {
const accessToken = data.claudeAiOauth?.accessToken;
const subscriptionType = data.claudeAiOauth?.subscriptionType ?? '';
if (!accessToken) {
return null;
}
// Check if token is expired (expiresAt is Unix ms timestamp)
// Use != null to handle expiresAt=0 correctly (would be expired)
const expiresAt = data.claudeAiOauth?.expiresAt;
if (expiresAt != null && expiresAt <= now) {
debug('OAuth token expired');
return null;
}
return { accessToken, subscriptionType };
}
/**
* Read OAuth credentials, trying macOS Keychain first (Claude Code 2.x),
* then falling back to file-based credentials (older versions).
*
* Token priority: Keychain token is authoritative (Claude Code 2.x stores current token there).
* SubscriptionType: Can be supplemented from file if keychain lacks it (display-only field).
*/
function readCredentials(
homeDir: string,
now: number,
readKeychain: (now: number, homeDir: string) => { accessToken: string; subscriptionType: string } | null
): { accessToken: string; subscriptionType: string } | null {
// Try macOS Keychain first (Claude Code 2.x)
const keychainCreds = readKeychain(now, homeDir);
if (keychainCreds) {
if (keychainCreds.subscriptionType) {
debug('Using credentials from macOS Keychain');
return keychainCreds;
}
// Keychain has token but no subscriptionType - try to supplement from file
const fileSubscriptionType = readFileSubscriptionType(homeDir);
if (fileSubscriptionType) {
debug('Using keychain token with file subscriptionType');
return {
accessToken: keychainCreds.accessToken,
subscriptionType: fileSubscriptionType,
};
}
// No subscriptionType available - use keychain token anyway
debug('Using keychain token without subscriptionType');
return keychainCreds;
}
// Fall back to file-based credentials (older versions or non-macOS)
const fileCreds = readFileCredentials(homeDir, now);
if (fileCreds) {
debug('Using credentials from file');
return fileCreds;
}
return null;
}
function getPlanName(subscriptionType: string): string | null {
const lower = subscriptionType.toLowerCase();
if (lower.includes('max')) return 'Max';
if (lower.includes('pro')) return 'Pro';
if (lower.includes('team')) return 'Team';
// API users don't have subscriptionType or have 'api'
if (!subscriptionType || lower.includes('api')) return null;
// Unknown subscription type - show it capitalized
return subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1);
}
/** Parse utilization value, clamping to 0-100 and handling NaN/Infinity */
function parseUtilization(value: number | undefined): number | null {
if (value == null) return null;
if (!Number.isFinite(value)) return null; // Handles NaN and Infinity
return Math.round(Math.max(0, Math.min(100, value)));
}
/** Parse ISO date string safely, returning null for invalid dates */
function parseDate(dateStr: string | undefined): Date | null {
if (!dateStr) return null;
const date = new Date(dateStr);
// Check for Invalid Date
if (isNaN(date.getTime())) {
debug('Invalid date string:', dateStr);
return null;
}
return date;
}
export function getUsageApiTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.CLAUDE_HUD_USAGE_TIMEOUT_MS?.trim();
if (!raw) return USAGE_API_TIMEOUT_MS_DEFAULT;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
debug('Invalid CLAUDE_HUD_USAGE_TIMEOUT_MS value:', raw);
return USAGE_API_TIMEOUT_MS_DEFAULT;
}
return parsed;
}
export function isNoProxy(hostname: string, env: NodeJS.ProcessEnv = process.env): boolean {
const noProxy = env.NO_PROXY ?? env.no_proxy;
if (!noProxy) return false;
const host = hostname.toLowerCase();
return noProxy.split(',').some((entry) => {
const pattern = entry.trim().toLowerCase();
if (!pattern) return false;
if (pattern === '*') return true;
if (host === pattern) return true;
const suffix = pattern.startsWith('.') ? pattern : `.${pattern}`;
return host.endsWith(suffix);
});
}
export function getProxyUrl(hostname: string, env: NodeJS.ProcessEnv = process.env): URL | null {
if (isNoProxy(hostname, env)) {
debug('Proxy bypassed by NO_PROXY for host:', hostname);
return null;
}
const proxyEnv = env.HTTPS_PROXY
?? env.https_proxy
?? env.ALL_PROXY
?? env.all_proxy
?? env.HTTP_PROXY
?? env.http_proxy;
if (!proxyEnv) return null;
try {
const proxyUrl = new URL(proxyEnv);
if (proxyUrl.protocol !== 'http:' && proxyUrl.protocol !== 'https:') {
debug('Unsupported proxy protocol:', proxyUrl.protocol);
return null;
}
return proxyUrl;
} catch {
debug('Invalid proxy URL:', proxyEnv);
return null;
}
}
function createProxyTunnelAgent(proxyUrl: URL): https.Agent {
const proxyHost = proxyUrl.hostname;
const proxyPort = Number.parseInt(proxyUrl.port || (proxyUrl.protocol === 'https:' ? '443' : '80'), 10);
const proxyAuth = proxyUrl.username
? `Basic ${Buffer.from(
`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password || '')}`
).toString('base64')}`
: null;
return new class extends https.Agent {
override createConnection(
options: https.RequestOptions,
callback?: (err: Error | null, socket: net.Socket) => void
): undefined {
const targetHost = String(options.host ?? options.hostname ?? 'localhost');
const targetPort = Number(options.port) || 443;
let settled = false;
const settle = (err: Error | null, socket: net.Socket): void => {
if (settled) return;
settled = true;
callback?.(err, socket);
};
const proxySocket = proxyUrl.protocol === 'https:'
? tls.connect({ host: proxyHost, port: proxyPort, servername: proxyHost })
: net.connect(proxyPort, proxyHost);
proxySocket.once('error', (error) => {
settle(error, proxySocket);
});
proxySocket.once('connect', () => {
const connectHeaders = [
`CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
`Host: ${targetHost}:${targetPort}`,
];
if (proxyAuth) {
connectHeaders.push(`Proxy-Authorization: ${proxyAuth}`);
}
connectHeaders.push('', '');
proxySocket.write(connectHeaders.join('\r\n'));
let responseBuffer = Buffer.alloc(0);
const onData = (chunk: Buffer): void => {
responseBuffer = Buffer.concat([responseBuffer, chunk]);
const headerEndIndex = responseBuffer.indexOf('\r\n\r\n');
if (headerEndIndex === -1) return;
proxySocket.removeListener('data', onData);
const headerText = responseBuffer.subarray(0, headerEndIndex).toString('utf8');
const statusLine = headerText.split('\r\n')[0] ?? '';
if (!/^HTTP\/1\.[01] 200 /.test(statusLine)) {
const error = new Error(`Proxy CONNECT rejected: ${statusLine || 'unknown status'}`);
proxySocket.destroy(error);
settle(error, proxySocket);
return;
}
const tlsSocket = tls.connect({
socket: proxySocket,
servername: String(options.servername ?? targetHost),
rejectUnauthorized: options.rejectUnauthorized !== false,
}, () => {
settle(null, tlsSocket);
});
tlsSocket.once('error', (error) => {
settle(error, tlsSocket);
});
};
proxySocket.on('data', onData);
});
// Must not return the socket here. In Node.js _http_agent.js, createSocket()
// calls: `if (newSocket) oncreate(null, newSocket)` — returning a truthy value
// causes the HTTP request to be written to the raw proxy socket immediately,
// before the CONNECT tunnel is established. Only deliver the final TLS socket
// asynchronously via the callback after the CONNECT handshake succeeds.
return undefined;
}
}();
}
function fetchUsageApi(accessToken: string): Promise {
return new Promise((resolve) => {
const host = 'api.anthropic.com';
const timeoutMs = getUsageApiTimeoutMs();
const proxyUrl = getProxyUrl(host);
const options = {
hostname: host,
path: '/api/oauth/usage',
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'anthropic-beta': 'oauth-2025-04-20',
'User-Agent': USAGE_API_USER_AGENT,
},
timeout: timeoutMs,
agent: proxyUrl ? createProxyTunnelAgent(proxyUrl) : undefined,
};
if (proxyUrl) {
debug('Using proxy for usage API:', proxyUrl.origin);
}
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
res.on('end', () => {
if (res.statusCode !== 200) {
debug('API returned non-200 status:', res.statusCode);
// Use a distinct error key for 429 so cache/render can handle it specially
const error = res.statusCode === 429
? 'rate-limited'
: res.statusCode ? `http-${res.statusCode}` : 'http-error';
const retryAfterSec = res.statusCode === 429
? parseRetryAfterSeconds(res.headers['retry-after'])
: undefined;
if (retryAfterSec) {
debug('Retry-After:', retryAfterSec, 'seconds');
}
resolve({ data: null, error, retryAfterSec });
return;
}
try {
const parsed: UsageApiResponse = JSON.parse(data);
resolve({ data: parsed });
} catch (error) {
debug('Failed to parse API response:', error);
resolve({ data: null, error: 'parse' });
}
});
});
req.on('error', (error) => {
debug('API request error:', error);
resolve({ data: null, error: 'network' });
});
req.on('timeout', () => {
debug('API request timeout');
req.destroy();
resolve({ data: null, error: 'timeout' });
});
req.end();
});
}
export function parseRetryAfterSeconds(
raw: string | string[] | undefined,
nowMs: number = Date.now(),
): number | undefined {
const value = Array.isArray(raw) ? raw[0] : raw;
if (!value) return undefined;
const parsedSeconds = Number.parseInt(value, 10);
if (Number.isFinite(parsedSeconds) && parsedSeconds > 0) {
return parsedSeconds;
}
const retryAtMs = Date.parse(value);
if (!Number.isFinite(retryAtMs)) {
return undefined;
}
const retryAfterSeconds = Math.ceil((retryAtMs - nowMs) / 1000);
return retryAfterSeconds > 0 ? retryAfterSeconds : undefined;
}
// Export for testing
export function clearCache(homeDir?: string): void {
if (homeDir) {
try {
const cachePath = getCachePath(homeDir);
if (fs.existsSync(cachePath)) {
fs.unlinkSync(cachePath);
}
const lockPath = getCacheLockPath(homeDir);
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
} catch {
// Ignore
}
}
}
================================================
FILE: src/utils/terminal.ts
================================================
// Returns a progress bar width scaled to the current terminal width.
// Wide (>=100): 10, Medium (60-99): 6, Narrow (<60): 4. Defaults to 10.
export function getAdaptiveBarWidth(): number {
const stdoutCols = process.stdout?.columns;
const cols = (typeof stdoutCols === 'number' && Number.isFinite(stdoutCols) && stdoutCols > 0)
? Math.floor(stdoutCols)
: Number.parseInt(process.env.COLUMNS ?? '', 10);
if (Number.isFinite(cols) && cols > 0) {
if (cols >= 100) return 10;
if (cols >= 60) return 6;
return 4;
}
return 10;
}
================================================
FILE: tests/config.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadConfig,
getConfigPath,
mergeConfig,
DEFAULT_CONFIG,
DEFAULT_ELEMENT_ORDER,
} from '../dist/config.js';
import * as path from 'node:path';
import * as os from 'node:os';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
function restoreEnvVar(name, value) {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
test('loadConfig returns valid config structure', async () => {
const config = await loadConfig();
// pathLevels must be 1, 2, or 3
assert.ok([1, 2, 3].includes(config.pathLevels), 'pathLevels should be 1, 2, or 3');
// lineLayout must be valid
const validLineLayouts = ['compact', 'expanded'];
assert.ok(validLineLayouts.includes(config.lineLayout), 'lineLayout should be valid');
// showSeparators must be boolean
assert.equal(typeof config.showSeparators, 'boolean', 'showSeparators should be boolean');
assert.ok(Array.isArray(config.elementOrder), 'elementOrder should be an array');
assert.ok(config.elementOrder.length > 0, 'elementOrder should not be empty');
assert.deepEqual(config.elementOrder, DEFAULT_ELEMENT_ORDER, 'elementOrder should default to the full expanded layout');
// gitStatus object with expected properties
assert.equal(typeof config.gitStatus, 'object');
assert.equal(typeof config.gitStatus.enabled, 'boolean');
assert.equal(typeof config.gitStatus.showDirty, 'boolean');
assert.equal(typeof config.gitStatus.showAheadBehind, 'boolean');
// display object with expected properties
assert.equal(typeof config.display, 'object');
assert.equal(typeof config.display.showModel, 'boolean');
assert.equal(typeof config.display.showContextBar, 'boolean');
assert.ok(['percent', 'tokens', 'remaining'].includes(config.display.contextValue), 'contextValue should be valid');
assert.equal(typeof config.display.showConfigCounts, 'boolean');
assert.equal(typeof config.display.showDuration, 'boolean');
assert.equal(typeof config.display.showSpeed, 'boolean');
assert.equal(typeof config.display.showTokenBreakdown, 'boolean');
assert.equal(typeof config.display.showUsage, 'boolean');
assert.equal(typeof config.display.showTools, 'boolean');
assert.equal(typeof config.display.showAgents, 'boolean');
assert.equal(typeof config.display.showTodos, 'boolean');
assert.equal(typeof config.display.showSessionName, 'boolean');
assert.equal(typeof config.colors, 'object');
for (const key of ['context', 'usage', 'warning', 'usageWarning', 'critical']) {
const t = typeof config.colors[key];
assert.ok(t === 'string' || t === 'number', `colors.${key} should be string or number, got ${t}`);
}
});
test('getConfigPath returns correct path', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
delete process.env.CLAUDE_CONFIG_DIR;
try {
const configPath = getConfigPath();
const homeDir = os.homedir();
assert.equal(configPath, path.join(homeDir, '.claude', 'plugins', 'claude-hud', 'config.json'));
} finally {
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
}
});
test('mergeConfig defaults showSessionName to false', () => {
const config = mergeConfig({});
assert.equal(config.display.showSessionName, false);
assert.equal(DEFAULT_CONFIG.display.showSessionName, false);
});
test('mergeConfig preserves explicit showSessionName=true', () => {
const config = mergeConfig({ display: { showSessionName: true } });
assert.equal(config.display.showSessionName, true);
});
test('mergeConfig preserves customLine and truncates long values', () => {
const customLine = 'x'.repeat(120);
const config = mergeConfig({ display: { customLine } });
assert.equal(config.display.customLine.length, 80);
assert.equal(config.display.customLine, customLine.slice(0, 80));
});
test('getConfigPath respects CLAUDE_CONFIG_DIR', async () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
const customConfigDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-config-dir-'));
try {
process.env.CLAUDE_CONFIG_DIR = customConfigDir;
const configPath = getConfigPath();
assert.equal(configPath, path.join(customConfigDir, 'plugins', 'claude-hud', 'config.json'));
} finally {
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
await rm(customConfigDir, { recursive: true, force: true });
}
});
test('loadConfig reads user config from CLAUDE_CONFIG_DIR', async () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
const customConfigDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-config-load-'));
try {
process.env.CLAUDE_CONFIG_DIR = customConfigDir;
const pluginDir = path.join(customConfigDir, 'plugins', 'claude-hud');
await mkdir(pluginDir, { recursive: true });
await writeFile(
path.join(pluginDir, 'config.json'),
JSON.stringify({
lineLayout: 'compact',
pathLevels: 2,
display: { showSpeed: true },
}),
'utf8'
);
const config = await loadConfig();
assert.equal(config.lineLayout, 'compact');
assert.equal(config.pathLevels, 2);
assert.equal(config.display.showSpeed, true);
} finally {
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
await rm(customConfigDir, { recursive: true, force: true });
}
});
// --- migrateConfig tests (via mergeConfig) ---
test('migrate legacy layout: "default" -> compact, no separators', () => {
const config = mergeConfig({ layout: 'default' });
assert.equal(config.lineLayout, 'compact');
assert.equal(config.showSeparators, false);
});
test('migrate legacy layout: "separators" -> compact, with separators', () => {
const config = mergeConfig({ layout: 'separators' });
assert.equal(config.lineLayout, 'compact');
assert.equal(config.showSeparators, true);
});
test('migrate object layout: extracts nested fields to top level', () => {
const config = mergeConfig({
layout: { lineLayout: 'expanded', showSeparators: true, pathLevels: 2 },
});
assert.equal(config.lineLayout, 'expanded');
assert.equal(config.showSeparators, true);
assert.equal(config.pathLevels, 2);
});
test('migrate object layout: empty object does not crash', () => {
const config = mergeConfig({ layout: {} });
// Should fall back to defaults since no fields were extracted
assert.equal(config.lineLayout, DEFAULT_CONFIG.lineLayout);
assert.equal(config.showSeparators, DEFAULT_CONFIG.showSeparators);
assert.equal(config.pathLevels, DEFAULT_CONFIG.pathLevels);
});
test('no layout key -> no migration, uses defaults', () => {
const config = mergeConfig({});
assert.equal(config.lineLayout, DEFAULT_CONFIG.lineLayout);
assert.equal(config.showSeparators, DEFAULT_CONFIG.showSeparators);
});
test('both layout and lineLayout present -> layout ignored', () => {
const config = mergeConfig({ layout: 'separators', lineLayout: 'expanded' });
// When lineLayout is already present, migration should not run
assert.equal(config.lineLayout, 'expanded');
assert.equal(config.showSeparators, DEFAULT_CONFIG.showSeparators);
});
test('mergeConfig accepts contextValue=remaining', () => {
const config = mergeConfig({
display: {
contextValue: 'remaining',
},
});
assert.equal(config.display.contextValue, 'remaining');
});
test('mergeConfig falls back to default for invalid contextValue', () => {
const config = mergeConfig({
display: {
contextValue: 'invalid-mode',
},
});
assert.equal(config.display.contextValue, DEFAULT_CONFIG.display.contextValue);
});
test('mergeConfig defaults elementOrder to the full expanded layout', () => {
const config = mergeConfig({});
assert.deepEqual(config.elementOrder, DEFAULT_ELEMENT_ORDER);
});
test('mergeConfig preserves valid custom elementOrder including activity elements', () => {
const config = mergeConfig({
elementOrder: ['tools', 'project', 'usage', 'context', 'agents', 'todos', 'environment'],
});
assert.deepEqual(
config.elementOrder,
['tools', 'project', 'usage', 'context', 'agents', 'todos', 'environment']
);
});
test('mergeConfig filters unknown entries and de-duplicates elementOrder', () => {
const config = mergeConfig({
elementOrder: ['project', 'agents', 'project', 'banana', 'usage', 'agents', 'context'],
});
assert.deepEqual(config.elementOrder, ['project', 'agents', 'usage', 'context']);
});
test('mergeConfig treats elementOrder as an explicit expanded-mode filter', () => {
const config = mergeConfig({
elementOrder: ['usage', 'project'],
});
assert.deepEqual(config.elementOrder, ['usage', 'project']);
});
test('mergeConfig falls back to default when elementOrder is empty or invalid', () => {
assert.deepEqual(mergeConfig({ elementOrder: [] }).elementOrder, DEFAULT_ELEMENT_ORDER);
assert.deepEqual(mergeConfig({ elementOrder: ['unknown'] }).elementOrder, DEFAULT_ELEMENT_ORDER);
assert.deepEqual(mergeConfig({ elementOrder: 'project' }).elementOrder, DEFAULT_ELEMENT_ORDER);
});
test('mergeConfig defaults usage to expected values', () => {
const config = mergeConfig({});
assert.equal(config.usage.cacheTtlSeconds, 60);
assert.equal(config.usage.failureCacheTtlSeconds, 15);
});
test('mergeConfig defaults colors to expected semantic palette', () => {
const config = mergeConfig({});
assert.equal(config.colors.context, 'green');
assert.equal(config.colors.usage, 'brightBlue');
assert.equal(config.colors.warning, 'yellow');
assert.equal(config.colors.usageWarning, 'brightMagenta');
assert.equal(config.colors.critical, 'red');
});
test('mergeConfig accepts valid color overrides and filters invalid values', () => {
const config = mergeConfig({
colors: {
context: 'cyan',
usage: 'magenta',
warning: 'brightBlue',
usageWarning: 'yellow',
critical: 'not-a-color',
},
});
assert.equal(config.colors.context, 'cyan');
assert.equal(config.colors.usage, 'magenta');
assert.equal(config.colors.warning, 'brightBlue');
assert.equal(config.colors.usageWarning, 'yellow');
assert.equal(config.colors.critical, DEFAULT_CONFIG.colors.critical);
});
test('mergeConfig accepts custom usage TTL values', () => {
const config = mergeConfig({
usage: { cacheTtlSeconds: 120, failureCacheTtlSeconds: 30 },
});
assert.equal(config.usage.cacheTtlSeconds, 120);
assert.equal(config.usage.failureCacheTtlSeconds, 30);
});
test('mergeConfig falls back to defaults for invalid usage values', () => {
const config = mergeConfig({
usage: { cacheTtlSeconds: -1, failureCacheTtlSeconds: 0 },
});
assert.equal(config.usage.cacheTtlSeconds, DEFAULT_CONFIG.usage.cacheTtlSeconds);
assert.equal(config.usage.failureCacheTtlSeconds, DEFAULT_CONFIG.usage.failureCacheTtlSeconds);
});
// --- Custom color value tests (256-color and hex) ---
test('mergeConfig accepts 256-color index values', () => {
const config = mergeConfig({
colors: {
context: 82,
usage: 214,
warning: 220,
usageWarning: 97,
critical: 196,
},
});
assert.equal(config.colors.context, 82);
assert.equal(config.colors.usage, 214);
assert.equal(config.colors.warning, 220);
assert.equal(config.colors.usageWarning, 97);
assert.equal(config.colors.critical, 196);
});
test('mergeConfig accepts hex color strings', () => {
const config = mergeConfig({
colors: {
context: '#33ff00',
usage: '#FFB000',
warning: '#ff87d7',
},
});
assert.equal(config.colors.context, '#33ff00');
assert.equal(config.colors.usage, '#FFB000');
assert.equal(config.colors.warning, '#ff87d7');
});
test('mergeConfig accepts mixed named, 256-color, and hex values', () => {
const config = mergeConfig({
colors: {
context: '#33ff00',
usage: 214,
warning: 'yellow',
usageWarning: '#af87ff',
critical: 'red',
},
});
assert.equal(config.colors.context, '#33ff00');
assert.equal(config.colors.usage, 214);
assert.equal(config.colors.warning, 'yellow');
assert.equal(config.colors.usageWarning, '#af87ff');
assert.equal(config.colors.critical, 'red');
});
test('mergeConfig rejects invalid 256-color indices', () => {
const config = mergeConfig({
colors: {
context: 256,
usage: -1,
warning: 1.5,
},
});
assert.equal(config.colors.context, DEFAULT_CONFIG.colors.context);
assert.equal(config.colors.usage, DEFAULT_CONFIG.colors.usage);
assert.equal(config.colors.warning, DEFAULT_CONFIG.colors.warning);
});
test('mergeConfig rejects invalid hex strings', () => {
const config = mergeConfig({
colors: {
context: '#fff',
usage: '#gggggg',
warning: 'ff0000',
},
});
assert.equal(config.colors.context, DEFAULT_CONFIG.colors.context);
assert.equal(config.colors.usage, DEFAULT_CONFIG.colors.usage);
assert.equal(config.colors.warning, DEFAULT_CONFIG.colors.warning);
});
================================================
FILE: tests/core.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseTranscript } from '../dist/transcript.js';
import { countConfigs } from '../dist/config-reader.js';
import { getContextPercent, getBufferedPercent, getModelName, getProviderLabel, isBedrockModelId } from '../dist/stdin.js';
import * as fs from 'node:fs';
function restoreEnvVar(name, value) {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
test('getContextPercent returns 0 when data is missing', () => {
assert.equal(getContextPercent({}), 0);
assert.equal(getContextPercent({ context_window: { context_window_size: 0 } }), 0);
assert.equal(getBufferedPercent({}), 0);
assert.equal(getBufferedPercent({ context_window: { context_window_size: 0 } }), 0);
});
test('getContextPercent returns raw percentage without buffer', () => {
// 55000 / 200000 = 27.5% → rounds to 28%
const percent = getContextPercent({
context_window: {
context_window_size: 200000,
current_usage: {
input_tokens: 30000,
cache_creation_input_tokens: 12500,
cache_read_input_tokens: 12500,
},
},
});
assert.equal(percent, 28);
});
test('getBufferedPercent scales buffer by raw usage', () => {
// 55000 / 200000 = 27.5% raw, scale = (0.275 - 0.05) / (0.50 - 0.05) = 0.5
// buffer = 200000 * 0.165 * 0.5 = 16500, (55000 + 16500) / 200000 = 35.75% → 36%
const percent = getBufferedPercent({
context_window: {
context_window_size: 200000,
current_usage: {
input_tokens: 30000,
cache_creation_input_tokens: 12500,
cache_read_input_tokens: 12500,
},
},
});
assert.equal(percent, 36);
});
test('getContextPercent handles missing input tokens', () => {
// 5000 / 200000 = 2.5% → rounds to 3%
const percent = getContextPercent({
context_window: {
context_window_size: 200000,
current_usage: {
cache_creation_input_tokens: 3000,
cache_read_input_tokens: 2000,
},
},
});
assert.equal(percent, 3);
});
test('getBufferedPercent applies no buffer at very low usage', () => {
// 1M window, 45000 tokens = 4.5% raw → below 5% threshold → scale = 0 → no buffer
const rawPercent = getContextPercent({
context_window: {
context_window_size: 1000000,
current_usage: { input_tokens: 45000 },
},
});
const bufferedPercent = getBufferedPercent({
context_window: {
context_window_size: 1000000,
current_usage: { input_tokens: 45000 },
},
});
assert.equal(rawPercent, 5);
assert.equal(bufferedPercent, 5); // no buffer at low usage (e.g. after /clear)
});
test('getBufferedPercent returns 0 for startup state before usage exists', () => {
const percent = getBufferedPercent({
context_window: {
context_window_size: 200000,
current_usage: {},
used_percentage: null,
},
});
assert.equal(percent, 0);
});
test('getBufferedPercent applies full buffer at high usage', () => {
// 200k window, 110000 tokens = 55% raw → above 50% threshold → scale = 1 → full buffer
// buffer = 200000 * 0.165 = 33000, (110000 + 33000) / 200000 = 71.5% → 72%
const percent = getBufferedPercent({
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 110000 },
},
});
assert.equal(percent, 72);
});
// Native percentage tests (Claude Code v2.1.6+)
test('getContextPercent prefers native used_percentage when available', () => {
const percent = getContextPercent({
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 55000 }, // would be 28% raw
used_percentage: 47, // native value takes precedence
},
});
assert.equal(percent, 47);
});
test('getBufferedPercent prefers native used_percentage when available', () => {
const percent = getBufferedPercent({
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 55000 }, // would be 44% buffered
used_percentage: 47, // native value takes precedence
},
});
assert.equal(percent, 47);
});
test('getBufferedPercent switches from startup fallback to native percentage when available', () => {
const startupPercent = getBufferedPercent({
context_window: {
context_window_size: 200000,
current_usage: {},
used_percentage: null,
},
});
const nativePercent = getBufferedPercent({
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 1000 },
used_percentage: 1,
},
});
assert.equal(startupPercent, 0);
assert.equal(nativePercent, 1);
});
test('getContextPercent falls back when native is null', () => {
const percent = getContextPercent({
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 55000 },
used_percentage: null,
},
});
assert.equal(percent, 28); // raw calculation
});
test('getBufferedPercent falls back when native is null', () => {
// 55000 / 200000 = 27.5% raw, scale = 0.5, buffer = 200000 * 0.165 * 0.5 = 16500 → 36%
const percent = getBufferedPercent({
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 55000 },
used_percentage: null,
},
});
assert.equal(percent, 36); // scaled buffered calculation
});
test('native percentage handles zero correctly', () => {
assert.equal(getContextPercent({ context_window: { used_percentage: 0 } }), 0);
assert.equal(getBufferedPercent({ context_window: { used_percentage: 0 } }), 0);
});
test('native percentage clamps negative values to 0', () => {
assert.equal(getContextPercent({ context_window: { used_percentage: -5 } }), 0);
assert.equal(getBufferedPercent({ context_window: { used_percentage: -10 } }), 0);
});
test('native percentage clamps values over 100 to 100', () => {
assert.equal(getContextPercent({ context_window: { used_percentage: 150 } }), 100);
assert.equal(getBufferedPercent({ context_window: { used_percentage: 200 } }), 100);
});
test('native percentage falls back when NaN', () => {
const percent = getContextPercent({
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 55000 },
used_percentage: NaN,
},
});
assert.equal(percent, 28); // falls back to raw calculation
});
test('getModelName precedence: trimmed display name, then normalized bedrock label, then raw id, then fallback', () => {
assert.equal(getModelName({ model: { display_name: ' Opus ', id: 'anthropic.claude-3-5-sonnet-20240620-v1:0' } }), 'Opus');
assert.equal(getModelName({ model: { id: 'anthropic.claude-3-5-sonnet-20240620-v1:0' } }), 'Claude Sonnet 3.5');
assert.equal(getModelName({ model: { id: 'eu.anthropic.claude-opus-4-5-20251101-v1:0' } }), 'Claude Opus 4.5');
assert.equal(getModelName({ model: { id: 'us.anthropic.claude-sonnet-4-20250514-v1:0' } }), 'Claude Sonnet 4');
assert.equal(getModelName({ model: { id: ' apac.anthropic.claude-unknown-nextgen-20250101-v1:0 ' } }), 'apac.anthropic.claude-unknown-nextgen-20250101-v1:0');
assert.equal(getModelName({ model: { id: ' sonnet-456 ' } }), 'sonnet-456');
assert.equal(getModelName({ model: { display_name: ' ', id: ' ' } }), 'Unknown');
assert.equal(getModelName({}), 'Unknown');
});
test('bedrock model detection recognizes bedrock ids', () => {
assert.ok(isBedrockModelId('anthropic.claude-3-5-sonnet-20240620-v1:0'));
assert.ok(isBedrockModelId('eu.anthropic.claude-opus-4-5-20251101-v1:0'));
assert.equal(isBedrockModelId('claude-3-5-sonnet-20241022'), false);
assert.equal(getProviderLabel({ model: { id: 'anthropic.claude-3-5-sonnet-20240620-v1:0' } }), 'Bedrock');
assert.equal(getProviderLabel({ model: { id: 'claude-3-5-sonnet-20241022' } }), null);
});
test('parseTranscript aggregates tools, agents, and todos', async () => {
const fixturePath = fileURLToPath(new URL('./fixtures/transcript-basic.jsonl', import.meta.url));
const result = await parseTranscript(fixturePath);
assert.equal(result.tools.length, 1);
assert.equal(result.tools[0].status, 'completed');
assert.equal(result.tools[0].target, '/tmp/example.txt');
assert.equal(result.agents.length, 1);
assert.equal(result.agents[0].status, 'completed');
assert.equal(result.todos.length, 4);
assert.equal(result.todos[0].status, 'completed');
assert.equal(result.todos[1].status, 'in_progress');
assert.equal(result.todos[2].content, 'Third task');
assert.equal(result.todos[2].status, 'completed');
assert.equal(result.todos[3].status, 'in_progress');
assert.equal(result.sessionStart?.toISOString(), '2024-01-01T00:00:00.000Z');
});
test('parseTranscript prefers custom title over slug for session name', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
const filePath = path.join(dir, 'session-name-custom-title.jsonl');
const lines = [
JSON.stringify({ type: 'user', slug: 'auto-slug-1' }),
JSON.stringify({ type: 'custom-title', customTitle: 'My Renamed Session' }),
JSON.stringify({ type: 'assistant', slug: 'auto-slug-2' }),
];
await writeFile(filePath, lines.join('\n'), 'utf8');
try {
const result = await parseTranscript(filePath);
assert.equal(result.sessionName, 'My Renamed Session');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('parseTranscript falls back to latest slug when custom title is missing', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
const filePath = path.join(dir, 'session-name-slug.jsonl');
const lines = [
JSON.stringify({ type: 'user', slug: 'auto-slug-1' }),
JSON.stringify({ type: 'assistant', slug: 'auto-slug-2' }),
];
await writeFile(filePath, lines.join('\n'), 'utf8');
try {
const result = await parseTranscript(filePath);
assert.equal(result.sessionName, 'auto-slug-2');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('parseTranscript returns empty result when file is missing', async () => {
const result = await parseTranscript('/tmp/does-not-exist.jsonl');
assert.equal(result.tools.length, 0);
assert.equal(result.agents.length, 0);
assert.equal(result.todos.length, 0);
});
test('parseTranscript tolerates malformed lines', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
const filePath = path.join(dir, 'malformed.jsonl');
const lines = [
'{"timestamp":"2024-01-01T00:00:00.000Z","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read"}]}}',
'{not-json}',
'{"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1"}]}}',
'',
];
await writeFile(filePath, lines.join('\n'), 'utf8');
try {
const result = await parseTranscript(filePath);
assert.equal(result.tools.length, 1);
assert.equal(result.tools[0].status, 'completed');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('parseTranscript extracts tool targets for common tools', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
const filePath = path.join(dir, 'targets.jsonl');
const lines = [
JSON.stringify({
message: {
content: [
{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'echo hello world' } },
{ type: 'tool_use', id: 'tool-2', name: 'Glob', input: { pattern: '**/*.ts' } },
{ type: 'tool_use', id: 'tool-3', name: 'Grep', input: { pattern: 'render' } },
],
},
}),
];
await writeFile(filePath, lines.join('\n'), 'utf8');
try {
const result = await parseTranscript(filePath);
const targets = new Map(result.tools.map((tool) => [tool.name, tool.target]));
assert.equal(targets.get('Bash'), 'echo hello world');
assert.equal(targets.get('Glob'), '**/*.ts');
assert.equal(targets.get('Grep'), 'render');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('parseTranscript truncates long bash commands in targets', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
const filePath = path.join(dir, 'bash.jsonl');
const longCommand = 'echo ' + 'x'.repeat(50);
const lines = [
JSON.stringify({
message: {
content: [{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: longCommand } }],
},
}),
];
await writeFile(filePath, lines.join('\n'), 'utf8');
try {
const result = await parseTranscript(filePath);
assert.equal(result.tools.length, 1);
assert.ok(result.tools[0].target?.endsWith('...'));
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('parseTranscript handles edge-case lines and error statuses', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
const filePath = path.join(dir, 'edge-cases.jsonl');
const lines = [
' ',
JSON.stringify({ message: { content: 'not-an-array' } }),
JSON.stringify({
message: {
content: [
{ type: 'tool_use', id: 'agent-1', name: 'Task', input: {} },
{ type: 'tool_use', id: 'tool-error', name: 'Read', input: { path: '/tmp/fallback.txt' } },
{ type: 'tool_result', tool_use_id: 'tool-error', is_error: true },
{ type: 'tool_result', tool_use_id: 'missing-tool' },
],
},
}),
];
await writeFile(filePath, lines.join('\n'), 'utf8');
try {
const result = await parseTranscript(filePath);
const errorTool = result.tools.find((tool) => tool.id === 'tool-error');
assert.equal(errorTool?.status, 'error');
assert.equal(errorTool?.target, '/tmp/fallback.txt');
assert.equal(result.agents[0]?.type, 'unknown');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('parseTranscript returns undefined targets for unknown tools', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
const filePath = path.join(dir, 'unknown-tools.jsonl');
const lines = [
JSON.stringify({
message: {
content: [{ type: 'tool_use', id: 'tool-1', name: 'UnknownTool', input: { foo: 'bar' } }],
},
}),
];
await writeFile(filePath, lines.join('\n'), 'utf8');
try {
const result = await parseTranscript(filePath);
assert.equal(result.tools.length, 1);
assert.equal(result.tools[0].target, undefined);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('parseTranscript returns partial results when stream creation fails', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-'));
const transcriptDir = path.join(dir, 'transcript-dir');
await mkdir(transcriptDir);
try {
const result = await parseTranscript(transcriptDir);
assert.equal(result.tools.length, 0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('countConfigs honors project and global config locations', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const projectDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-project-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
await mkdir(path.join(homeDir, '.claude', 'rules', 'nested'), { recursive: true });
await writeFile(path.join(homeDir, '.claude', 'CLAUDE.md'), 'global', 'utf8');
await writeFile(path.join(homeDir, '.claude', 'rules', 'rule.md'), '# rule', 'utf8');
await writeFile(path.join(homeDir, '.claude', 'rules', 'nested', 'rule-nested.md'), '# rule nested', 'utf8');
await writeFile(
path.join(homeDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { one: {} }, hooks: { onStart: {} } }),
'utf8'
);
await writeFile(path.join(homeDir, '.claude.json'), '{bad json', 'utf8');
await mkdir(path.join(projectDir, '.claude', 'rules'), { recursive: true });
await writeFile(path.join(projectDir, 'CLAUDE.md'), 'project', 'utf8');
await writeFile(path.join(projectDir, 'CLAUDE.local.md'), 'project-local', 'utf8');
await writeFile(path.join(projectDir, '.claude', 'CLAUDE.md'), 'project-alt', 'utf8');
await writeFile(path.join(projectDir, '.claude', 'CLAUDE.local.md'), 'project-alt-local', 'utf8');
await writeFile(path.join(projectDir, '.claude', 'rules', 'rule2.md'), '# rule2', 'utf8');
await writeFile(
path.join(projectDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { two: {}, three: {} }, hooks: { onStop: {} } }),
'utf8'
);
await writeFile(path.join(projectDir, '.claude', 'settings.local.json'), '{bad json', 'utf8');
await writeFile(path.join(projectDir, '.mcp.json'), JSON.stringify({ mcpServers: { four: {} } }), 'utf8');
const counts = await countConfigs(projectDir);
assert.equal(counts.claudeMdCount, 5);
assert.equal(counts.rulesCount, 3);
assert.equal(counts.mcpCount, 4);
assert.equal(counts.hooksCount, 2);
} finally {
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
await rm(projectDir, { recursive: true, force: true });
}
});
test('countConfigs uses CLAUDE_CONFIG_DIR and matching .json sidecar for user scope', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const customConfigDir = path.join(homeDir, '.claude-2');
const originalHome = process.env.HOME;
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.HOME = homeDir;
process.env.CLAUDE_CONFIG_DIR = customConfigDir;
try {
// Default directory should be ignored when CLAUDE_CONFIG_DIR is set.
await mkdir(path.join(homeDir, '.claude', 'rules'), { recursive: true });
await writeFile(path.join(homeDir, '.claude', 'CLAUDE.md'), 'default-global', 'utf8');
await writeFile(path.join(homeDir, '.claude', 'rules', 'rule.md'), '# default rule', 'utf8');
await writeFile(
path.join(homeDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { defaultA: {} }, hooks: { onDefault: {} } }),
'utf8'
);
await writeFile(path.join(homeDir, '.claude.json'), JSON.stringify({ disabledMcpServers: ['defaultA'] }), 'utf8');
// Custom config directory and sidecar should drive user-scope counts.
await mkdir(customConfigDir, { recursive: true });
await writeFile(path.join(customConfigDir, 'CLAUDE.md'), 'custom-global', 'utf8');
await writeFile(
path.join(customConfigDir, 'settings.json'),
JSON.stringify({
mcpServers: { customA: {}, customB: {} },
hooks: { onStart: {}, onStop: {} },
}),
'utf8'
);
await writeFile(
`${customConfigDir}.json`,
JSON.stringify({ disabledMcpServers: ['customA'] }),
'utf8'
);
const counts = await countConfigs();
assert.equal(counts.claudeMdCount, 1);
assert.equal(counts.rulesCount, 0);
assert.equal(counts.mcpCount, 1);
assert.equal(counts.hooksCount, 2);
} finally {
restoreEnvVar('HOME', originalHome);
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
await rm(homeDir, { recursive: true, force: true });
}
});
test('countConfigs still counts project .claude when cwd is home and CLAUDE_CONFIG_DIR points elsewhere', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const customConfigDir = path.join(homeDir, '.claude-2');
const originalHome = process.env.HOME;
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.HOME = homeDir;
process.env.CLAUDE_CONFIG_DIR = customConfigDir;
try {
// User scope: custom config directory
await mkdir(path.join(customConfigDir, 'rules'), { recursive: true });
await writeFile(path.join(customConfigDir, 'CLAUDE.md'), 'custom-global', 'utf8');
await writeFile(path.join(customConfigDir, 'rules', 'user-rule.md'), '# user rule', 'utf8');
await writeFile(
path.join(customConfigDir, 'settings.json'),
JSON.stringify({ mcpServers: { userServer: {} }, hooks: { onUser: {} } }),
'utf8'
);
// Project scope: cwd is home directory with its own .claude contents
await mkdir(path.join(homeDir, '.claude', 'rules'), { recursive: true });
await writeFile(path.join(homeDir, '.claude', 'CLAUDE.md'), 'project-alt', 'utf8');
await writeFile(path.join(homeDir, '.claude', 'rules', 'project-rule.md'), '# project rule', 'utf8');
await writeFile(
path.join(homeDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { projectServer: {} }, hooks: { onProject: {} } }),
'utf8'
);
const counts = await countConfigs(homeDir);
assert.equal(counts.claudeMdCount, 2);
assert.equal(counts.rulesCount, 2);
assert.equal(counts.mcpCount, 2);
assert.equal(counts.hooksCount, 2);
} finally {
restoreEnvVar('HOME', originalHome);
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
await rm(homeDir, { recursive: true, force: true });
}
});
test('countConfigs avoids home cwd double-counting across counters and keeps CLAUDE.local.md', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
await mkdir(path.join(homeDir, '.claude', 'rules'), { recursive: true });
await writeFile(path.join(homeDir, '.claude', 'CLAUDE.md'), 'global', 'utf8');
await writeFile(path.join(homeDir, '.claude', 'CLAUDE.local.md'), 'global-local', 'utf8');
await writeFile(path.join(homeDir, '.claude', 'rules', 'rule.md'), '# rule', 'utf8');
await writeFile(
path.join(homeDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { one: {} }, hooks: { onStart: {} } }),
'utf8'
);
const exactCounts = await countConfigs(homeDir);
assert.equal(exactCounts.claudeMdCount, 2);
assert.equal(exactCounts.rulesCount, 1);
assert.equal(exactCounts.mcpCount, 1);
assert.equal(exactCounts.hooksCount, 1);
const trailingSlashCounts = await countConfigs(`${homeDir}${path.sep}`);
assert.equal(trailingSlashCounts.claudeMdCount, 2);
assert.equal(trailingSlashCounts.rulesCount, 1);
assert.equal(trailingSlashCounts.mcpCount, 1);
assert.equal(trailingSlashCounts.hooksCount, 1);
} finally {
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
}
});
test('countConfigs excludes disabled user-scope MCPs', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
// 3 MCPs defined in settings.json
await writeFile(
path.join(homeDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { server1: {}, server2: {}, server3: {} } }),
'utf8'
);
// 1 MCP disabled in ~/.claude.json
await writeFile(
path.join(homeDir, '.claude.json'),
JSON.stringify({ disabledMcpServers: ['server2'] }),
'utf8'
);
const counts = await countConfigs();
assert.equal(counts.mcpCount, 2); // 3 - 1 disabled = 2
} finally {
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
}
});
test('countConfigs excludes disabled project .mcp.json servers', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const projectDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-project-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
await mkdir(path.join(projectDir, '.claude'), { recursive: true });
// 4 MCPs in .mcp.json
await writeFile(
path.join(projectDir, '.mcp.json'),
JSON.stringify({ mcpServers: { mcp1: {}, mcp2: {}, mcp3: {}, mcp4: {} } }),
'utf8'
);
// 2 disabled via disabledMcpjsonServers
await writeFile(
path.join(projectDir, '.claude', 'settings.local.json'),
JSON.stringify({ disabledMcpjsonServers: ['mcp2', 'mcp4'] }),
'utf8'
);
const counts = await countConfigs(projectDir);
assert.equal(counts.mcpCount, 2); // 4 - 2 disabled = 2
} finally {
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
await rm(projectDir, { recursive: true, force: true });
}
});
test('countConfigs handles all MCPs disabled', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
// 2 MCPs defined
await writeFile(
path.join(homeDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { serverA: {}, serverB: {} } }),
'utf8'
);
// Both disabled
await writeFile(
path.join(homeDir, '.claude.json'),
JSON.stringify({ disabledMcpServers: ['serverA', 'serverB'] }),
'utf8'
);
const counts = await countConfigs();
assert.equal(counts.mcpCount, 0); // All disabled
} finally {
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
}
});
test('countConfigs tolerates rule directory read errors', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
const rulesDir = path.join(homeDir, '.claude', 'rules');
await mkdir(rulesDir, { recursive: true });
fs.chmodSync(rulesDir, 0);
try {
const counts = await countConfigs();
assert.equal(counts.rulesCount, 0);
} finally {
fs.chmodSync(rulesDir, 0o755);
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
}
});
test('countConfigs ignores non-string values in disabledMcpServers', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
// 3 MCPs defined
await writeFile(
path.join(homeDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { server1: {}, server2: {}, server3: {} } }),
'utf8'
);
// disabledMcpServers contains mixed types - only 'server2' is a valid string
await writeFile(
path.join(homeDir, '.claude.json'),
JSON.stringify({ disabledMcpServers: [123, null, 'server2', { name: 'server3' }, [], true] }),
'utf8'
);
const counts = await countConfigs();
assert.equal(counts.mcpCount, 2); // Only 'server2' disabled, server1 and server3 remain
} finally {
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
}
});
test('countConfigs counts same-named servers in different scopes separately', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const projectDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-project-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
await mkdir(path.join(projectDir, '.claude'), { recursive: true });
// User scope: server named 'shared-server'
await writeFile(
path.join(homeDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { 'shared-server': {}, 'user-only': {} } }),
'utf8'
);
// Project scope: also has 'shared-server' (different config, same name)
await writeFile(
path.join(projectDir, '.mcp.json'),
JSON.stringify({ mcpServers: { 'shared-server': {}, 'project-only': {} } }),
'utf8'
);
const counts = await countConfigs(projectDir);
// 'shared-server' counted in BOTH scopes (user + project) = 4 total
assert.equal(counts.mcpCount, 4);
} finally {
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
await rm(projectDir, { recursive: true, force: true });
}
});
test('countConfigs uses case-sensitive matching for disabled servers', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
// MCP named 'MyServer' (mixed case)
await writeFile(
path.join(homeDir, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { MyServer: {}, otherServer: {} } }),
'utf8'
);
// Try to disable with wrong case - should NOT work
await writeFile(
path.join(homeDir, '.claude.json'),
JSON.stringify({ disabledMcpServers: ['myserver', 'MYSERVER', 'OTHERSERVER'] }),
'utf8'
);
const counts = await countConfigs();
// Both servers should still be enabled (case mismatch means not disabled)
assert.equal(counts.mcpCount, 2);
} finally {
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
}
});
// Regression test for GitHub Issue #3:
// "MCP count showing 5 when user has 6, still showing 5 when all disabled"
// https://github.com/jarrodwatts/claude-hud/issues/3
test('Issue #3: MCP count updates correctly when servers are disabled', async () => {
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
const originalHome = process.env.HOME;
process.env.HOME = homeDir;
try {
await mkdir(path.join(homeDir, '.claude'), { recursive: true });
// User has 6 MCPs configured (simulating the issue reporter's setup)
await writeFile(
path.join(homeDir, '.claude.json'),
JSON.stringify({
mcpServers: {
mcp1: { command: 'cmd1' },
mcp2: { command: 'cmd2' },
mcp3: { command: 'cmd3' },
mcp4: { command: 'cmd4' },
mcp5: { command: 'cmd5' },
mcp6: { command: 'cmd6' },
},
}),
'utf8'
);
// Scenario 1: No servers disabled - should show 6
let counts = await countConfigs();
assert.equal(counts.mcpCount, 6, 'Should show all 6 MCPs when none disabled');
// Scenario 2: 1 server disabled - should show 5 (this was the initial bug report state)
await writeFile(
path.join(homeDir, '.claude.json'),
JSON.stringify({
mcpServers: {
mcp1: { command: 'cmd1' },
mcp2: { command: 'cmd2' },
mcp3: { command: 'cmd3' },
mcp4: { command: 'cmd4' },
mcp5: { command: 'cmd5' },
mcp6: { command: 'cmd6' },
},
disabledMcpServers: ['mcp1'],
}),
'utf8'
);
counts = await countConfigs();
assert.equal(counts.mcpCount, 5, 'Should show 5 MCPs when 1 is disabled');
// Scenario 3: ALL servers disabled - should show 0 (this was the main bug)
await writeFile(
path.join(homeDir, '.claude.json'),
JSON.stringify({
mcpServers: {
mcp1: { command: 'cmd1' },
mcp2: { command: 'cmd2' },
mcp3: { command: 'cmd3' },
mcp4: { command: 'cmd4' },
mcp5: { command: 'cmd5' },
mcp6: { command: 'cmd6' },
},
disabledMcpServers: ['mcp1', 'mcp2', 'mcp3', 'mcp4', 'mcp5', 'mcp6'],
}),
'utf8'
);
counts = await countConfigs();
assert.equal(counts.mcpCount, 0, 'Should show 0 MCPs when all are disabled');
} finally {
process.env.HOME = originalHome;
await rm(homeDir, { recursive: true, force: true });
}
});
================================================
FILE: tests/extra-cmd.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sanitize, parseExtraCmdArg, runExtraCmd } from '../dist/extra-cmd.js';
// ============================================================================
// sanitize() tests
// ============================================================================
test('sanitize strips ANSI CSI sequences', () => {
const input = '\x1B[31mRed\x1B[0m Text';
assert.equal(sanitize(input), 'Red Text');
});
test('sanitize strips OSC sequences', () => {
const input = '\x1B]0;Window Title\x07Normal Text';
assert.equal(sanitize(input), 'Normal Text');
});
test('sanitize strips C0 control characters', () => {
const input = 'Hello\x00World\x1FTest';
assert.equal(sanitize(input), 'HelloWorldTest');
});
test('sanitize strips C1 control characters', () => {
const input = 'Hello\x80World\x9FTest';
assert.equal(sanitize(input), 'HelloWorldTest');
});
test('sanitize strips bidi control characters', () => {
const input = 'Hello\u200EWorld\u202ATest\u2069End';
assert.equal(sanitize(input), 'HelloWorldTestEnd');
});
test('sanitize preserves normal text', () => {
const input = 'Just normal text 123!';
assert.equal(sanitize(input), 'Just normal text 123!');
});
test('sanitize handles empty string', () => {
assert.equal(sanitize(''), '');
});
test('sanitize handles complex mixed escape sequences', () => {
const input = '\x1B[1;32mBold Green\x1B[0m \x1B]0;Title\x07 \x00Hidden\x1F';
assert.equal(sanitize(input), 'Bold Green Hidden');
});
// ============================================================================
// parseExtraCmdArg() tests
// ============================================================================
test('parseExtraCmdArg returns null when no --extra-cmd present', () => {
const argv = ['node', 'index.js', '--other', 'arg'];
assert.equal(parseExtraCmdArg(argv), null);
});
test('parseExtraCmdArg parses --extra-cmd value syntax', () => {
const argv = ['node', 'index.js', '--extra-cmd', 'echo hello'];
assert.equal(parseExtraCmdArg(argv), 'echo hello');
});
test('parseExtraCmdArg parses --extra-cmd=value syntax', () => {
const argv = ['node', 'index.js', '--extra-cmd=echo hello'];
assert.equal(parseExtraCmdArg(argv), 'echo hello');
});
test('parseExtraCmdArg returns null when --extra-cmd is last arg with space syntax', () => {
const argv = ['node', 'index.js', '--extra-cmd'];
assert.equal(parseExtraCmdArg(argv), null);
});
test('parseExtraCmdArg returns null for empty value with equals syntax', () => {
const argv = ['node', 'index.js', '--extra-cmd='];
assert.equal(parseExtraCmdArg(argv), null);
});
test('parseExtraCmdArg returns null for empty value with space syntax', () => {
const argv = ['node', 'index.js', '--extra-cmd', ''];
assert.equal(parseExtraCmdArg(argv), null);
});
test('parseExtraCmdArg handles command with equals sign in value', () => {
const argv = ['node', 'index.js', '--extra-cmd=echo "key=value"'];
assert.equal(parseExtraCmdArg(argv), 'echo "key=value"');
});
test('parseExtraCmdArg takes first occurrence when multiple present', () => {
const argv = ['node', 'index.js', '--extra-cmd', 'first', '--extra-cmd', 'second'];
assert.equal(parseExtraCmdArg(argv), 'first');
});
test('parseExtraCmdArg handles command with spaces and quotes', () => {
const argv = ['node', 'index.js', '--extra-cmd', 'echo "hello world"'];
assert.equal(parseExtraCmdArg(argv), 'echo "hello world"');
});
// ============================================================================
// runExtraCmd() tests
// ============================================================================
test('runExtraCmd returns label from valid JSON output', async () => {
const result = await runExtraCmd('echo \'{"label": "test"}\'');
assert.equal(result, 'test');
});
test('runExtraCmd returns null for non-JSON output', async () => {
const result = await runExtraCmd('echo "not json"');
assert.equal(result, null);
});
test('runExtraCmd returns null for JSON without label field', async () => {
const result = await runExtraCmd('echo \'{"other": "field"}\'');
assert.equal(result, null);
});
test('runExtraCmd returns null for JSON with non-string label', async () => {
const result = await runExtraCmd('echo \'{"label": 123}\'');
assert.equal(result, null);
});
test('runExtraCmd truncates long labels with ellipsis', async () => {
const longLabel = 'a'.repeat(60);
const result = await runExtraCmd(`echo '{"label": "${longLabel}"}'`);
assert.equal(result?.length, 50);
assert.ok(result?.endsWith('…'));
});
test('runExtraCmd sanitizes output containing escape sequences', async () => {
const result = await runExtraCmd('echo \'{"label": "\\u001b[31mRed\\u001b[0m"}\'');
assert.equal(result, 'Red');
});
test('runExtraCmd returns null when command fails', async () => {
const result = await runExtraCmd('exit 1');
assert.equal(result, null);
});
test('runExtraCmd returns null when command does not exist', async () => {
const result = await runExtraCmd('nonexistent-command-xyz123');
assert.equal(result, null);
});
test('runExtraCmd handles timeout', async () => {
const start = Date.now();
const result = await runExtraCmd('sleep 10', 100);
const elapsed = Date.now() - start;
assert.equal(result, null);
assert.ok(elapsed < 1000, `Expected timeout around 100ms, but took ${elapsed}ms`);
});
test('runExtraCmd handles empty stdout', async () => {
const result = await runExtraCmd('echo ""');
assert.equal(result, null);
});
test('runExtraCmd handles JSON array instead of object', async () => {
const result = await runExtraCmd('echo \'[1,2,3]\'');
assert.equal(result, null);
});
test('runExtraCmd handles null JSON', async () => {
const result = await runExtraCmd('echo "null"');
assert.equal(result, null);
});
test('runExtraCmd handles valid JSON with extra whitespace', async () => {
const result = await runExtraCmd('echo \' { "label": "trimmed" } \'');
assert.equal(result, 'trimmed');
});
================================================
FILE: tests/fixtures/expected/render-basic.txt
================================================
[Opus] │ my-project
Context ███░░░░░░░ 29%
================================================
FILE: tests/fixtures/transcript-basic.jsonl
================================================
{"timestamp":"2024-01-01T00:00:00.000Z","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/example.txt"}}]}}
{"timestamp":"2024-01-01T00:00:01.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","is_error":false}]}}
{"timestamp":"2024-01-01T00:00:02.000Z","message":{"content":[{"type":"tool_use","id":"agent-1","name":"Task","input":{"subagent_type":"explore","model":"haiku"}}]}}
{"timestamp":"2024-01-01T00:00:03.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"agent-1","is_error":false}]}}
{"timestamp":"2024-01-01T00:00:04.000Z","message":{"content":[{"type":"tool_use","id":"todo-1","name":"TodoWrite","input":{"todos":[{"content":"First task","status":"completed"},{"content":"Second task","status":"in_progress"}]}}]}}
{"timestamp":"2024-01-01T00:00:05.000Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"TaskCreate","input":{"taskId":"alpha","subject":"Third task"}}]}}
{"timestamp":"2024-01-01T00:00:06.000Z","message":{"content":[{"type":"tool_use","id":"task-2","name":"TaskCreate","input":{"subject":"Fourth task","status":"in_progress"}}]}}
{"timestamp":"2024-01-01T00:00:07.000Z","message":{"content":[{"type":"tool_use","id":"task-3","name":"TaskUpdate","input":{"taskId":"alpha","status":"completed"}}]}}
================================================
FILE: tests/fixtures/transcript-render.jsonl
================================================
{"message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/example.txt"}}]}}
{"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","is_error":false}]}}
{"message":{"content":[{"type":"tool_use","id":"tool-2","name":"Edit","input":{"file_path":"/tmp/very/long/path/to/authentication.ts"}}]}}
{"message":{"content":[{"type":"tool_use","id":"agent-1","name":"Task","input":{"subagent_type":"explore","model":"haiku","description":"Finding auth code"}}]}}
{"message":{"content":[{"type":"tool_result","tool_use_id":"agent-1","is_error":false}]}}
{"message":{"content":[{"type":"tool_use","id":"todo-1","name":"TodoWrite","input":{"todos":[{"content":"Fix auth bug","status":"completed"},{"content":"Add tests","status":"in_progress"}]}}]}}
================================================
FILE: tests/git.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { getGitBranch, getGitStatus } from '../dist/git.js';
test('getGitBranch returns null when cwd is undefined', async () => {
const result = await getGitBranch(undefined);
assert.equal(result, null);
});
test('getGitBranch returns null for non-git directory', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-nogit-'));
try {
const result = await getGitBranch(dir);
assert.equal(result, null);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getGitBranch returns branch name for git directory', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
try {
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
const result = await getGitBranch(dir);
assert.ok(result === 'main' || result === 'master', `Expected main or master, got ${result}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getGitBranch returns custom branch name', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
try {
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['checkout', '-b', 'feature/test-branch'], { cwd: dir, stdio: 'ignore' });
const result = await getGitBranch(dir);
assert.equal(result, 'feature/test-branch');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// getGitStatus tests
test('getGitStatus returns null when cwd is undefined', async () => {
const result = await getGitStatus(undefined);
assert.equal(result, null);
});
test('getGitStatus returns null for non-git directory', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-nogit-'));
try {
const result = await getGitStatus(dir);
assert.equal(result, null);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getGitStatus returns clean state for clean repo', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
try {
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
const result = await getGitStatus(dir);
assert.ok(result?.branch === 'main' || result?.branch === 'master');
assert.equal(result?.isDirty, false);
assert.equal(result?.ahead, 0);
assert.equal(result?.behind, 0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getGitStatus detects dirty state', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
try {
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
// Create uncommitted file
await writeFile(path.join(dir, 'dirty.txt'), 'uncommitted change');
const result = await getGitStatus(dir);
assert.equal(result?.isDirty, true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// fileStats tests
test('getGitStatus returns undefined fileStats for clean repo', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
try {
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
const result = await getGitStatus(dir);
assert.equal(result?.fileStats, undefined);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getGitStatus counts untracked files', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
try {
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
// Create untracked files
await writeFile(path.join(dir, 'untracked1.txt'), 'content');
await writeFile(path.join(dir, 'untracked2.txt'), 'content');
const result = await getGitStatus(dir);
assert.equal(result?.fileStats?.untracked, 2);
assert.equal(result?.fileStats?.modified, 0);
assert.equal(result?.fileStats?.added, 0);
assert.equal(result?.fileStats?.deleted, 0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getGitStatus counts modified files', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
try {
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
// Create and commit a file
await writeFile(path.join(dir, 'file.txt'), 'original');
execFileSync('git', ['add', 'file.txt'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['commit', '-m', 'add file'], { cwd: dir, stdio: 'ignore' });
// Modify the file
await writeFile(path.join(dir, 'file.txt'), 'modified');
const result = await getGitStatus(dir);
assert.equal(result?.fileStats?.modified, 1);
assert.equal(result?.fileStats?.untracked, 0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getGitStatus counts staged added files', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
try {
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: dir, stdio: 'ignore' });
// Create and stage a new file
await writeFile(path.join(dir, 'newfile.txt'), 'content');
execFileSync('git', ['add', 'newfile.txt'], { cwd: dir, stdio: 'ignore' });
const result = await getGitStatus(dir);
assert.equal(result?.fileStats?.added, 1);
assert.equal(result?.fileStats?.untracked, 0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('getGitStatus counts deleted files', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-git-'));
try {
execFileSync('git', ['init'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'ignore' });
// Create, commit, then delete a file
await writeFile(path.join(dir, 'todelete.txt'), 'content');
execFileSync('git', ['add', 'todelete.txt'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['commit', '-m', 'add file'], { cwd: dir, stdio: 'ignore' });
execFileSync('git', ['rm', 'todelete.txt'], { cwd: dir, stdio: 'ignore' });
const result = await getGitStatus(dir);
assert.equal(result?.fileStats?.deleted, 1);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
================================================
FILE: tests/index.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatSessionDuration, main } from '../dist/index.js';
test('formatSessionDuration returns empty string without session start', () => {
assert.equal(formatSessionDuration(undefined, () => 0), '');
});
test('formatSessionDuration formats sub-minute and minute durations', () => {
const start = new Date(0);
assert.equal(formatSessionDuration(start, () => 30 * 1000), '<1m');
assert.equal(formatSessionDuration(start, () => 5 * 60 * 1000), '5m');
});
test('formatSessionDuration formats hour durations', () => {
const start = new Date(0);
assert.equal(formatSessionDuration(start, () => 2 * 60 * 60 * 1000 + 5 * 60 * 1000), '2h 5m');
});
test('formatSessionDuration uses Date.now by default', () => {
const originalNow = Date.now;
Date.now = () => 60000;
try {
const result = formatSessionDuration(new Date(0));
assert.equal(result, '1m');
} finally {
Date.now = originalNow;
}
});
test('main logs an error when dependencies throw', async () => {
const logs = [];
await main({
readStdin: async () => {
throw new Error('boom');
},
parseTranscript: async () => ({ tools: [], agents: [], todos: [] }),
countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }),
getGitBranch: async () => null,
getUsage: async () => null,
render: () => {},
now: () => Date.now(),
log: (...args) => logs.push(args.join(' ')),
});
assert.ok(logs.some((line) => line.includes('[claude-hud] Error:')));
});
test('main logs unknown error for non-Error throws', async () => {
const logs = [];
await main({
readStdin: async () => {
throw 'boom';
},
parseTranscript: async () => ({ tools: [], agents: [], todos: [] }),
countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }),
getGitBranch: async () => null,
getUsage: async () => null,
render: () => {},
now: () => Date.now(),
log: (...args) => logs.push(args.join(' ')),
});
assert.ok(logs.some((line) => line.includes('Unknown error')));
});
test('index entrypoint runs when executed directly', async () => {
const originalArgv = [...process.argv];
const originalIsTTY = process.stdin.isTTY;
const originalLog = console.log;
const logs = [];
try {
const moduleUrl = new URL('../dist/index.js', import.meta.url);
process.argv[1] = new URL(moduleUrl).pathname;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
console.log = (...args) => logs.push(args.join(' '));
await import(`${moduleUrl}?entry=${Date.now()}`);
} finally {
console.log = originalLog;
process.argv = originalArgv;
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
}
assert.ok(logs.some((line) => line.includes('[claude-hud] Initializing...')));
});
test('main executes the happy path with default dependencies', async () => {
const originalNow = Date.now;
Date.now = () => 60000;
let renderedContext;
try {
await main({
readStdin: async () => ({
model: { display_name: 'Opus' },
context_window: { context_window_size: 100, current_usage: { input_tokens: 90 } },
}),
parseTranscript: async () => ({ tools: [], agents: [], todos: [], sessionStart: new Date(0) }),
countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }),
getGitBranch: async () => null,
getUsage: async () => null,
render: (ctx) => {
renderedContext = ctx;
},
});
} finally {
Date.now = originalNow;
}
assert.equal(renderedContext?.sessionDuration, '1m');
});
test('main includes git status in render context', async () => {
let renderedContext;
await main({
readStdin: async () => ({
model: { display_name: 'Opus' },
context_window: { context_window_size: 100, current_usage: { input_tokens: 10 } },
cwd: '/some/path',
}),
parseTranscript: async () => ({ tools: [], agents: [], todos: [] }),
countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }),
getGitStatus: async () => ({ branch: 'feature/test', isDirty: false, ahead: 0, behind: 0 }),
getUsage: async () => null,
loadConfig: async () => ({
lineLayout: 'compact',
showSeparators: false,
pathLevels: 1,
gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false },
display: { showModel: true, showContextBar: true, contextValue: 'percent', showConfigCounts: true, showDuration: true, showSpeed: false, showTokenBreakdown: true, showUsage: true, showTools: true, showAgents: true, showTodos: true, autocompactBuffer: 'enabled', usageThreshold: 0, sevenDayThreshold: 80, environmentThreshold: 0 },
usage: { cacheTtlSeconds: 60, failureCacheTtlSeconds: 15 },
}),
render: (ctx) => {
renderedContext = ctx;
},
});
assert.equal(renderedContext?.gitStatus?.branch, 'feature/test');
});
test('main includes usageData in render context', async () => {
let renderedContext;
const mockUsageData = {
planName: 'Max',
fiveHour: 50,
sevenDay: 25,
fiveHourResetAt: null,
sevenDayResetAt: null,
limitReached: false,
};
await main({
readStdin: async () => ({
model: { display_name: 'Opus' },
context_window: { context_window_size: 100, current_usage: { input_tokens: 10 } },
}),
parseTranscript: async () => ({ tools: [], agents: [], todos: [] }),
countConfigs: async () => ({ claudeMdCount: 0, rulesCount: 0, mcpCount: 0, hooksCount: 0 }),
getGitBranch: async () => null,
getUsage: async () => mockUsageData,
render: (ctx) => {
renderedContext = ctx;
},
});
assert.deepEqual(renderedContext?.usageData, mockUsageData);
});
================================================
FILE: tests/integration.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { readFileSync } from 'node:fs';
function stripAnsi(text) {
return text.replace(
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]/g,
''
);
}
function skipIfSpawnBlocked(result, t) {
if (result.error?.code === 'EPERM') {
t.skip('spawnSync is blocked by sandbox policy in this environment');
return true;
}
return false;
}
test('CLI renders expected output for a basic transcript', async (t) => {
const fixturePath = fileURLToPath(new URL('./fixtures/transcript-render.jsonl', import.meta.url));
const expectedPath = fileURLToPath(new URL('./fixtures/expected/render-basic.txt', import.meta.url));
const expected = readFileSync(expectedPath, 'utf8').trimEnd();
const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-'));
// Use a fixed 3-level path for deterministic test output
const projectDir = path.join(homeDir, 'dev', 'apps', 'my-project');
await import('node:fs/promises').then(fs => fs.mkdir(projectDir, { recursive: true }));
try {
const stdin = JSON.stringify({
model: { display_name: 'Opus' },
context_window: {
context_window_size: 200000,
current_usage: { input_tokens: 45000 },
},
transcript_path: fixturePath,
cwd: projectDir,
});
const result = spawnSync('node', ['dist/index.js'], {
cwd: path.resolve(process.cwd()),
input: stdin,
encoding: 'utf8',
env: { ...process.env, HOME: homeDir },
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, result.stderr || 'non-zero exit');
const normalized = stripAnsi(result.stdout).replace(/\u00A0/g, ' ').trimEnd();
if (process.env.UPDATE_SNAPSHOTS === '1') {
await writeFile(expectedPath, normalized + '\n', 'utf8');
return;
}
assert.equal(normalized, expected);
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
test('CLI prints initializing message on empty stdin', (t) => {
const result = spawnSync('node', ['dist/index.js'], {
cwd: path.resolve(process.cwd()),
input: '',
encoding: 'utf8',
});
if (skipIfSpawnBlocked(result, t)) return;
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, result.stderr || 'non-zero exit');
const normalized = stripAnsi(result.stdout).replace(/\u00A0/g, ' ').trimEnd();
assert.ok(normalized.startsWith('[claude-hud] Initializing...'), `unexpected output: ${normalized}`);
});
================================================
FILE: tests/render-width.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { render } from '../dist/render/index.js';
function baseContext() {
return {
stdin: {
model: { display_name: 'Opus' },
context_window: {
context_window_size: 200000,
current_usage: {
input_tokens: 10000,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
},
transcript: { tools: [], agents: [], todos: [] },
claudeMdCount: 0,
rulesCount: 0,
mcpCount: 0,
hooksCount: 0,
sessionDuration: '',
gitStatus: null,
usageData: null,
config: {
lineLayout: 'compact',
showSeparators: false,
pathLevels: 1,
gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false },
display: {
showModel: true,
showContextBar: true,
contextValue: 'percent',
showConfigCounts: true,
showDuration: true,
showSpeed: false,
showTokenBreakdown: true,
showUsage: true,
usageBarEnabled: false,
showTools: true,
showAgents: true,
showTodos: true,
autocompactBuffer: 'enabled',
usageThreshold: 0,
sevenDayThreshold: 80,
environmentThreshold: 0,
},
},
extraLabel: null,
};
}
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '');
}
function isWideCodePoint(codePoint) {
return codePoint >= 0x1100 && (
codePoint <= 0x115F ||
codePoint === 0x2329 ||
codePoint === 0x232A ||
(codePoint >= 0x2E80 && codePoint <= 0xA4CF && codePoint !== 0x303F) ||
(codePoint >= 0xAC00 && codePoint <= 0xD7A3) ||
(codePoint >= 0xF900 && codePoint <= 0xFAFF) ||
(codePoint >= 0xFE10 && codePoint <= 0xFE19) ||
(codePoint >= 0xFE30 && codePoint <= 0xFE6F) ||
(codePoint >= 0xFF00 && codePoint <= 0xFF60) ||
(codePoint >= 0xFFE0 && codePoint <= 0xFFE6) ||
(codePoint >= 0x1F300 && codePoint <= 0x1FAFF) ||
(codePoint >= 0x20000 && codePoint <= 0x3FFFD)
);
}
function displayWidth(text) {
let width = 0;
for (const char of Array.from(text)) {
const codePoint = char.codePointAt(0);
width += codePoint !== undefined && isWideCodePoint(codePoint) ? 2 : 1;
}
return width;
}
function withColumns(stream, columns, fn) {
const originalColumns = stream.columns;
Object.defineProperty(stream, 'columns', { value: columns, configurable: true });
try {
fn();
} finally {
if (originalColumns === undefined) {
delete stream.columns;
} else {
Object.defineProperty(stream, 'columns', { value: originalColumns, configurable: true });
}
}
}
function withTerminal(columns, fn) {
withColumns(process.stdout, columns, fn);
}
function captureRender(ctx) {
const logs = [];
const originalLog = console.log;
console.log = line => logs.push(line);
try {
render(ctx);
} finally {
console.log = originalLog;
}
return logs.map(line => stripAnsi(line).replace(/\u00A0/g, ' '));
}
function countContaining(lines, needle) {
return lines.filter(line => line.includes(needle)).length;
}
test('render wraps long lines to terminal width and keeps all activity lines visible', () => {
const ctx = baseContext();
ctx.stdin.model = { display_name: 'Sonnet 4.6' };
ctx.stdin.cwd = '/tmp/very-long-project-name-for-terminal-wrap-checking';
ctx.gitStatus = {
branch: 'feature/this-is-a-very-long-branch-name',
isDirty: true,
ahead: 7,
behind: 0,
fileStats: { modified: 12, added: 4, deleted: 2, untracked: 9 },
};
ctx.config.gitStatus.showFileStats = true;
ctx.claudeMdCount = 1;
ctx.rulesCount = 2;
ctx.hooksCount = 3;
ctx.usageData = {
planName: 'Team',
fiveHour: 30,
sevenDay: 3,
fiveHourResetAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
sevenDayResetAt: new Date(Date.now() + 6 * 24 * 60 * 60 * 1000),
};
ctx.transcript.tools = [
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
];
ctx.transcript.agents = [
{ id: 'agent-1', type: 'plan-a', status: 'running', startTime: new Date(0) },
{ id: 'agent-2', type: 'plan-b', status: 'completed', startTime: new Date(0), endTime: new Date(3000) },
{ id: 'agent-3', type: 'plan-c', status: 'completed', startTime: new Date(0), endTime: new Date(3500) },
];
ctx.transcript.todos = [
{ content: 'todo-marker', status: 'in_progress' },
];
let lines = [];
withTerminal(20, () => {
lines = captureRender(ctx);
});
assert.equal(countContaining(lines, 'Read'), 1, 'tool line should remain visible');
assert.equal(countContaining(lines, 'plan-a'), 1, 'first agent line should remain visible');
assert.equal(countContaining(lines, 'plan-b'), 1, 'second agent line should remain visible');
assert.equal(countContaining(lines, 'plan-c'), 1, 'third agent line should remain visible');
assert.equal(countContaining(lines, 'todo-marker'), 1, 'todo line should remain visible');
assert.ok(lines.every(line => displayWidth(line) <= 20), 'all lines should fit terminal width');
});
test('render falls back to COLUMNS env when stdout.columns is unavailable', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/project';
ctx.extraLabel = '你好你好你好你好你好';
const originalEnvColumns = process.env.COLUMNS;
let lines = [];
withTerminal(undefined, () => {
process.env.COLUMNS = '10';
try {
lines = captureRender(ctx);
} finally {
if (originalEnvColumns === undefined) {
delete process.env.COLUMNS;
} else {
process.env.COLUMNS = originalEnvColumns;
}
}
});
assert.ok(lines.length > 1, 'should still render output lines');
assert.ok(lines.every(line => displayWidth(line) <= 10), 'all lines should fit COLUMNS width');
});
test('render falls back to stderr.columns when stdout.columns is unavailable', () => {
const ctx = baseContext();
const originalEnvColumns = process.env.COLUMNS;
let lines = [];
withColumns(process.stdout, undefined, () => {
withColumns(process.stderr, 12, () => {
process.env.COLUMNS = '10';
try {
lines = captureRender(ctx);
} finally {
if (originalEnvColumns === undefined) {
delete process.env.COLUMNS;
} else {
process.env.COLUMNS = originalEnvColumns;
}
}
});
});
assert.ok(lines.length > 0, 'should still render output lines');
assert.ok(lines.every(line => displayWidth(line) <= 12), 'stderr width should be honored');
assert.ok(lines.some(line => displayWidth(line) > 10), 'stderr width should override COLUMNS fallback');
});
test('render prefers stdout columns over COLUMNS env fallback', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/very-long-project-name-for-width-checking';
const originalEnvColumns = process.env.COLUMNS;
process.env.COLUMNS = '10';
let lines = [];
withTerminal(30, () => {
lines = captureRender(ctx);
});
if (originalEnvColumns === undefined) {
delete process.env.COLUMNS;
} else {
process.env.COLUMNS = originalEnvColumns;
}
assert.ok(lines.every(line => displayWidth(line) <= 30), 'stdout width should be honored');
assert.ok(lines.some(line => displayWidth(line) > 10), 'stdout width should override COLUMNS fallback');
});
test('render does not split model/provider separator inside brackets', () => {
const ctx = baseContext();
ctx.stdin.model = { display_name: 'Sonnet', id: 'anthropic.claude-3-5-sonnet-20240620-v1:0' };
ctx.config.display.showUsage = false;
ctx.config.display.showContextBar = false;
ctx.config.display.showConfigCounts = false;
ctx.config.display.showDuration = false;
let wideLines = [];
withTerminal(80, () => {
wideLines = captureRender(ctx);
});
assert.ok(wideLines.some(line => line.includes('[Sonnet | Bedrock]')), 'model/provider badge should be preserved when width allows');
let lines = [];
withTerminal(12, () => {
lines = captureRender(ctx);
});
assert.equal(lines.length, 1, 'single compact line should be truncated, not split');
assert.ok(!lines[0].startsWith('Bedrock]'), 'provider label should not become a wrapped prefix');
});
test('render clamps separator width in narrow terminals', () => {
const ctx = baseContext();
ctx.config.showSeparators = true;
ctx.transcript.tools = [
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
];
let lines = [];
withTerminal(8, () => {
lines = captureRender(ctx);
});
const separatorLine = lines.find(line => line.includes('─'));
assert.ok(separatorLine, 'separator should render when enabled with activity');
assert.ok(displayWidth(separatorLine) <= 8, 'separator should fit terminal width');
});
test('render truncation respects Unicode display width', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/project';
ctx.extraLabel = '你好你好你好你好你好';
let lines = [];
withTerminal(10, () => {
lines = captureRender(ctx);
});
assert.ok(lines.some(line => line.includes('...')), 'should truncate an overlong Unicode segment');
assert.ok(lines.every(line => displayWidth(line) <= 10), 'all lines should respect terminal cell width');
});
================================================
FILE: tests/render.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { render } from '../dist/render/index.js';
import { renderSessionLine } from '../dist/render/session-line.js';
import { renderProjectLine } from '../dist/render/lines/project.js';
import { renderToolsLine } from '../dist/render/tools-line.js';
import { renderAgentsLine } from '../dist/render/agents-line.js';
import { renderTodosLine } from '../dist/render/todos-line.js';
import { renderUsageLine } from '../dist/render/lines/usage.js';
import { getContextColor, getQuotaColor } from '../dist/render/colors.js';
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '');
}
function baseContext() {
return {
stdin: {
model: { display_name: 'Opus' },
context_window: {
context_window_size: 200000,
current_usage: {
input_tokens: 10000,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
},
transcript: { tools: [], agents: [], todos: [] },
claudeMdCount: 0,
rulesCount: 0,
mcpCount: 0,
hooksCount: 0,
sessionDuration: '',
gitStatus: null,
usageData: null,
config: {
lineLayout: 'compact',
showSeparators: false,
pathLevels: 1,
elementOrder: ['project', 'context', 'usage', 'environment', 'tools', 'agents', 'todos'],
gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false },
display: { showModel: true, showProject: true, showContextBar: true, contextValue: 'percent', showConfigCounts: true, showDuration: true, showSpeed: false, showTokenBreakdown: true, showUsage: true, usageBarEnabled: false, showTools: true, showAgents: true, showTodos: true, showSessionName: false, autocompactBuffer: 'enabled', usageThreshold: 0, sevenDayThreshold: 80, environmentThreshold: 0 },
colors: {
context: 'green',
usage: 'brightBlue',
warning: 'yellow',
usageWarning: 'brightMagenta',
critical: 'red',
},
},
};
}
function captureRenderLines(ctx) {
const logs = [];
const originalLog = console.log;
console.log = line => logs.push(stripAnsi(line));
try {
render(ctx);
} finally {
console.log = originalLog;
}
return logs;
}
async function withDeterministicSpeedCache(fn) {
const tempConfigDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-render-'));
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
const originalNow = Date.now;
const cachePath = path.join(tempConfigDir, 'plugins', 'claude-hud', '.speed-cache.json');
process.env.CLAUDE_CONFIG_DIR = tempConfigDir;
await mkdir(path.dirname(cachePath), { recursive: true });
await writeFile(cachePath, JSON.stringify({ outputTokens: 1000, timestamp: 1000 }), 'utf8');
Date.now = () => 2000;
try {
await fn();
} finally {
Date.now = originalNow;
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
}
await rm(tempConfigDir, { recursive: true, force: true });
}
}
test('renderSessionLine adds token breakdown when context is high', () => {
const ctx = baseContext();
// For 90%: (tokens + 33000) / 200000 = 0.9 → tokens = 147000
ctx.stdin.context_window.current_usage.input_tokens = 147000;
const line = renderSessionLine(ctx);
assert.ok(line.includes('in:'), 'expected token breakdown');
assert.ok(line.includes('cache:'), 'expected cache breakdown');
});
test('renderSessionLine includes duration and formats large tokens', () => {
const ctx = baseContext();
ctx.sessionDuration = '1m';
// Use 1M context, need 85%+ to show breakdown
// For 85%: (tokens + 165000) / 1000000 = 0.85 → tokens = 685000
ctx.stdin.context_window.context_window_size = 1000000;
ctx.stdin.context_window.current_usage.input_tokens = 685000;
ctx.stdin.context_window.current_usage.cache_read_input_tokens = 1500;
const line = renderSessionLine(ctx);
assert.ok(line.includes('⏱️'));
assert.ok(line.includes('685k') || line.includes('685.0k'), 'expected large input token display');
assert.ok(line.includes('2k'), 'expected cache token display');
});
test('renderSessionLine handles missing input tokens and cache creation usage', () => {
const ctx = baseContext();
// For 90%: (tokens + 33000) / 200000 = 0.9 → tokens = 147000 (all from cache)
ctx.stdin.context_window.context_window_size = 200000;
ctx.stdin.context_window.current_usage = {
cache_creation_input_tokens: 147000,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('90%'));
assert.ok(line.includes('in: 0'));
});
test('renderSessionLine handles missing cache token fields', () => {
const ctx = baseContext();
// For 90%: (tokens + 33000) / 200000 = 0.9 → tokens = 147000
ctx.stdin.context_window.context_window_size = 200000;
ctx.stdin.context_window.current_usage = {
input_tokens: 147000,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('cache: 0'));
});
test('getContextColor returns yellow for warning threshold', () => {
assert.equal(getContextColor(70), '\x1b[33m');
});
test('getContextColor and getQuotaColor respect custom semantic overrides', () => {
const colors = {
context: 'cyan',
usage: 'magenta',
warning: 'brightBlue',
usageWarning: 'yellow',
critical: 'red',
};
assert.equal(getContextColor(10, colors), '\x1b[36m');
assert.equal(getContextColor(70, colors), '\x1b[94m');
assert.equal(getQuotaColor(25, colors), '\x1b[35m');
assert.equal(getQuotaColor(80, colors), '\x1b[33m');
});
test('getContextColor and getQuotaColor resolve 256-color indices', () => {
const colors = {
context: 82,
usage: 214,
warning: 220,
usageWarning: 97,
critical: 196,
};
assert.equal(getContextColor(10, colors), '\x1b[38;5;82m');
assert.equal(getContextColor(70, colors), '\x1b[38;5;220m');
assert.equal(getContextColor(90, colors), '\x1b[38;5;196m');
assert.equal(getQuotaColor(25, colors), '\x1b[38;5;214m');
assert.equal(getQuotaColor(80, colors), '\x1b[38;5;97m');
assert.equal(getQuotaColor(95, colors), '\x1b[38;5;196m');
});
test('getContextColor and getQuotaColor resolve hex color strings', () => {
const colors = {
context: '#33ff00',
usage: '#FFB000',
warning: '#ff87d7',
usageWarning: '#af87ff',
critical: '#ff0000',
};
assert.equal(getContextColor(10, colors), '\x1b[38;2;51;255;0m');
assert.equal(getContextColor(70, colors), '\x1b[38;2;255;135;215m');
assert.equal(getQuotaColor(25, colors), '\x1b[38;2;255;176;0m');
assert.equal(getQuotaColor(80, colors), '\x1b[38;2;175;135;255m');
});
test('renderSessionLine includes config counts when present', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.claudeMdCount = 1;
ctx.rulesCount = 2;
ctx.mcpCount = 3;
ctx.hooksCount = 4;
const line = renderSessionLine(ctx);
assert.ok(line.includes('CLAUDE.md'));
assert.ok(line.includes('rules'));
assert.ok(line.includes('MCPs'));
assert.ok(line.includes('hooks'));
});
test('renderSessionLine displays project name from POSIX cwd', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/Users/jarrod/my-project';
const line = renderSessionLine(ctx);
assert.ok(line.includes('my-project'));
assert.ok(!line.includes('/Users/jarrod'));
});
test('renderSessionLine displays project name from Windows cwd', { skip: process.platform !== 'win32' }, () => {
const ctx = baseContext();
ctx.stdin.cwd = 'C:\\Users\\jarrod\\my-project';
const line = renderSessionLine(ctx);
assert.ok(line.includes('my-project'));
assert.ok(!line.includes('C:\\'));
});
test('renderSessionLine handles root path gracefully', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/';
const line = renderSessionLine(ctx);
assert.ok(line.includes('[Opus]'));
});
test('renderSessionLine supports token-based context display', () => {
const ctx = baseContext();
ctx.config.display.contextValue = 'tokens';
ctx.stdin.context_window.context_window_size = 200000;
ctx.stdin.context_window.current_usage.input_tokens = 12345;
const line = renderSessionLine(ctx);
assert.ok(line.includes('12k/200k'), 'should include token counts');
});
test('renderSessionLine supports remaining-based context display', () => {
const ctx = baseContext();
ctx.config.display.contextValue = 'remaining';
ctx.stdin.context_window.context_window_size = 200000;
ctx.stdin.context_window.current_usage.input_tokens = 12345;
const line = renderSessionLine(ctx);
// 12345/200k = 6.17% raw, scale ≈ 0.026, buffer ≈ 858 → 7% buffered → 93% remaining
assert.ok(line.includes('93%'), 'should include remaining percentage');
});
test('render expanded layout supports remaining-based context display', () => {
const ctx = baseContext();
ctx.config.lineLayout = 'expanded';
ctx.config.display.contextValue = 'remaining';
ctx.stdin.context_window.context_window_size = 200000;
ctx.stdin.context_window.current_usage.input_tokens = 12345;
const logs = [];
const originalLog = console.log;
console.log = (line) => logs.push(line);
try {
render(ctx);
} finally {
console.log = originalLog;
}
// 12345/200k = 6.17% raw, scale ≈ 0.026, buffer ≈ 858 → 7% buffered → 93% remaining
assert.ok(logs.some(line => line.includes('Context') && line.includes('93%')), 'expected remaining percentage on context line');
});
test('renderSessionLine omits project name when cwd is undefined', () => {
const ctx = baseContext();
ctx.stdin.cwd = undefined;
const line = renderSessionLine(ctx);
assert.ok(line.includes('[Opus]'));
});
test('renderSessionLine includes session name when showSessionName is true', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.transcript.sessionName = 'Renamed Session';
ctx.config.display.showSessionName = true;
const line = renderSessionLine(ctx);
assert.ok(line.includes('Renamed Session'));
});
test('renderSessionLine hides session name by default', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.transcript.sessionName = 'Renamed Session';
const line = renderSessionLine(ctx);
assert.ok(!line.includes('Renamed Session'));
});
test('renderSessionLine includes customLine when configured', () => {
const ctx = baseContext();
ctx.config.display.customLine = 'Ship it';
const line = stripAnsi(renderSessionLine(ctx));
assert.ok(line.includes('Ship it'));
});
test('renderProjectLine includes session name when showSessionName is true', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.transcript.sessionName = 'Renamed Session';
ctx.config.display.showSessionName = true;
const line = renderProjectLine(ctx);
assert.ok(line?.includes('Renamed Session'));
});
test('renderProjectLine includes extraLabel when present', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.extraLabel = 'user [MAX]';
const line = renderProjectLine(ctx);
assert.ok(line?.includes('user [MAX]'));
});
test('renderProjectLine omits extraLabel when null', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.extraLabel = null;
const line = renderProjectLine(ctx);
assert.ok(!line?.includes('user [MAX]'));
});
test('renderProjectLine hides session name by default', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.transcript.sessionName = 'Renamed Session';
const line = renderProjectLine(ctx);
assert.ok(!line?.includes('Renamed Session'));
});
test('renderProjectLine includes customLine when configured', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.config.display.customLine = 'Stay sharp';
const line = stripAnsi(renderProjectLine(ctx) ?? '');
assert.ok(line.includes('Stay sharp'));
});
test('renderProjectLine includes duration when showDuration is true', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.config.display.showDuration = true;
ctx.sessionDuration = '12m 34s';
const line = renderProjectLine(ctx);
assert.ok(line?.includes('12m 34s'), 'should include session duration');
});
test('renderProjectLine omits duration when showDuration is false', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.config.display.showDuration = false;
ctx.sessionDuration = '12m 34s';
const line = renderProjectLine(ctx);
assert.ok(!line?.includes('12m 34s'), 'should not include session duration when disabled');
});
test('renderProjectLine includes speed when showSpeed is true and speed is available', async () => {
await withDeterministicSpeedCache(async () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.stdin.context_window.current_usage.output_tokens = 2000;
ctx.config.display.showSpeed = true;
const line = renderProjectLine(ctx);
assert.ok(line?.includes('out: 1000.0 tok/s'), 'should include deterministic speed');
});
});
test('renderProjectLine omits speed when showSpeed is false', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.config.display.showSpeed = false;
ctx.stdin.context_window.current_usage.output_tokens = 5000;
const line = renderProjectLine(ctx);
assert.ok(!line?.includes('tok/s'), 'should not include speed when disabled');
});
test('render expanded layout includes speed and duration on the project line', async () => {
await withDeterministicSpeedCache(async () => {
const ctx = baseContext();
ctx.config.lineLayout = 'expanded';
ctx.stdin.cwd = '/tmp/my-project';
ctx.stdin.context_window.current_usage.output_tokens = 2000;
ctx.config.display.showSpeed = true;
ctx.sessionDuration = '12m 34s';
const lines = captureRenderLines(ctx);
const projectLine = lines.find(line => line.includes('my-project'));
assert.ok(projectLine, 'expected an expanded project line');
assert.ok(projectLine.includes('out: 1000.0 tok/s'), 'should include deterministic speed');
assert.ok(projectLine.includes('⏱️ 12m 34s'), 'should include session duration');
});
});
test('renderSessionLine omits project name when showProject is false', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/Users/jarrod/my-project';
ctx.gitStatus = { branch: 'main', isDirty: true, ahead: 0, behind: 0 };
ctx.config.display.showProject = false;
const line = renderSessionLine(ctx);
assert.ok(!line.includes('my-project'), 'should not include project name when showProject is false');
assert.ok(line.includes('git:('), 'should still include git status when showProject is false');
});
test('renderProjectLine keeps git status when showProject is false', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/Users/jarrod/my-project';
ctx.gitStatus = { branch: 'main', isDirty: true, ahead: 0, behind: 0 };
ctx.config.display.showProject = false;
const line = renderProjectLine(ctx);
assert.ok(line?.includes('git:('), 'should still include git status');
assert.ok(!line?.includes('my-project'), 'should hide project path');
});
test('renderSessionLine displays git branch when present', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.gitStatus = { branch: 'main', isDirty: false, ahead: 0, behind: 0 };
const line = renderSessionLine(ctx);
assert.ok(line.includes('git:('));
assert.ok(line.includes('main'));
});
test('renderSessionLine omits git branch when null', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.gitStatus = null;
const line = renderSessionLine(ctx);
assert.ok(!line.includes('git:('));
});
test('renderSessionLine displays branch with slashes', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.gitStatus = { branch: 'feature/add-auth', isDirty: false, ahead: 0, behind: 0 };
const line = renderSessionLine(ctx);
assert.ok(line.includes('git:('));
assert.ok(line.includes('feature/add-auth'));
});
test('renderToolsLine renders running and completed tools', () => {
const ctx = baseContext();
ctx.transcript.tools = [
{
id: 'tool-1',
name: 'Read',
status: 'completed',
startTime: new Date(0),
endTime: new Date(0),
duration: 0,
},
{
id: 'tool-2',
name: 'Edit',
target: '/tmp/very/long/path/to/authentication.ts',
status: 'running',
startTime: new Date(0),
},
];
const line = renderToolsLine(ctx);
assert.ok(line?.includes('Read'));
assert.ok(line?.includes('Edit'));
assert.ok(line?.includes('.../authentication.ts'));
});
test('renderToolsLine truncates long filenames', () => {
const ctx = baseContext();
ctx.transcript.tools = [
{
id: 'tool-1',
name: 'Edit',
target: '/tmp/this-is-a-very-very-long-filename.ts',
status: 'running',
startTime: new Date(0),
},
];
const line = renderToolsLine(ctx);
assert.ok(line?.includes('...'));
assert.ok(!line?.includes('/tmp/'));
});
test('renderToolsLine handles trailing slash paths', () => {
const ctx = baseContext();
ctx.transcript.tools = [
{
id: 'tool-1',
name: 'Read',
target: '/tmp/very/long/path/with/trailing/',
status: 'running',
startTime: new Date(0),
},
];
const line = renderToolsLine(ctx);
assert.ok(line?.includes('...'));
});
test('renderToolsLine preserves short targets and handles missing targets', () => {
const ctx = baseContext();
ctx.transcript.tools = [
{
id: 'tool-1',
name: 'Read',
target: 'short.txt',
status: 'running',
startTime: new Date(0),
},
{
id: 'tool-2',
name: 'Write',
status: 'running',
startTime: new Date(0),
},
];
const line = renderToolsLine(ctx);
assert.ok(line?.includes('short.txt'));
assert.ok(line?.includes('Write'));
});
test('renderToolsLine returns null when tools are unrecognized', () => {
const ctx = baseContext();
ctx.transcript.tools = [
{
id: 'tool-1',
name: 'WeirdTool',
status: 'unknown',
startTime: new Date(0),
},
];
assert.equal(renderToolsLine(ctx), null);
});
test('renderAgentsLine returns null when no agents exist', () => {
const ctx = baseContext();
assert.equal(renderAgentsLine(ctx), null);
});
test('renderAgentsLine renders completed agents', () => {
const ctx = baseContext();
ctx.transcript.agents = [
{
id: 'agent-1',
type: 'explore',
model: 'haiku',
description: 'Finding auth code',
status: 'completed',
startTime: new Date(0),
endTime: new Date(0),
elapsed: 0,
},
];
const line = renderAgentsLine(ctx);
assert.ok(line?.includes('explore'));
assert.ok(line?.includes('haiku'));
});
test('renderAgentsLine truncates long descriptions and formats elapsed time', () => {
const ctx = baseContext();
ctx.transcript.agents = [
{
id: 'agent-1',
type: 'explore',
model: 'haiku',
description: 'A very long description that should be truncated in the HUD output',
status: 'completed',
startTime: new Date(0),
endTime: new Date(1500),
},
{
id: 'agent-2',
type: 'analyze',
status: 'completed',
startTime: new Date(0),
endTime: new Date(65000),
},
];
const line = renderAgentsLine(ctx);
assert.ok(line?.includes('...'));
assert.ok(line?.includes('2s'));
assert.ok(line?.includes('1m'));
});
test('renderAgentsLine renders running agents with live elapsed time', () => {
const ctx = baseContext();
const originalNow = Date.now;
Date.now = () => 2000;
try {
ctx.transcript.agents = [
{
id: 'agent-1',
type: 'plan',
status: 'running',
startTime: new Date(0),
},
];
const line = renderAgentsLine(ctx);
assert.ok(line?.includes('◐'));
assert.ok(line?.includes('2s'));
} finally {
Date.now = originalNow;
}
});
test('renderTodosLine handles in-progress and completed-only cases', () => {
const ctx = baseContext();
ctx.transcript.todos = [
{ content: 'First task', status: 'completed' },
{ content: 'Second task', status: 'in_progress' },
];
assert.ok(renderTodosLine(ctx)?.includes('Second task'));
ctx.transcript.todos = [{ content: 'First task', status: 'completed' }];
assert.ok(renderTodosLine(ctx)?.includes('All todos complete'));
});
test('renderTodosLine returns null when no todos are in progress', () => {
const ctx = baseContext();
ctx.transcript.todos = [
{ content: 'First task', status: 'completed' },
{ content: 'Second task', status: 'pending' },
];
assert.equal(renderTodosLine(ctx), null);
});
test('renderTodosLine truncates long todo content', () => {
const ctx = baseContext();
ctx.transcript.todos = [
{
content: 'This is a very long todo content that should be truncated for display',
status: 'in_progress',
},
];
const line = renderTodosLine(ctx);
assert.ok(line?.includes('...'));
});
test('renderTodosLine returns null when no todos exist', () => {
const ctx = baseContext();
assert.equal(renderTodosLine(ctx), null);
});
test('renderToolsLine returns null when no tools exist', () => {
const ctx = baseContext();
assert.equal(renderToolsLine(ctx), null);
});
// Usage display tests
test('renderSessionLine displays plan name in model bracket', () => {
const ctx = baseContext();
ctx.usageData = {
planName: 'Max',
fiveHour: 23,
sevenDay: 45,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('Opus'), 'should include model name');
assert.ok(line.includes('Max'), 'should include plan name');
});
test('renderSessionLine prefers subscription plan over API env var', () => {
const ctx = baseContext();
ctx.usageData = {
planName: 'Max',
fiveHour: 23,
sevenDay: 45,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
const savedApiKey = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = 'test-key';
try {
const line = renderSessionLine(ctx);
assert.ok(line.includes('Max'), 'should include plan label');
assert.ok(!line.includes('API'), 'should not include API label when plan is known');
} finally {
if (savedApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = savedApiKey;
}
}
});
test('renderProjectLine prefers subscription plan over API env var', () => {
const ctx = baseContext();
ctx.usageData = {
planName: 'Pro',
fiveHour: 10,
sevenDay: 20,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
const savedApiKey = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = 'test-key';
try {
const line = renderProjectLine(ctx);
assert.ok(line?.includes('Pro'), 'should include plan label');
assert.ok(!line?.includes('API'), 'should not include API label when plan is known');
} finally {
if (savedApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = savedApiKey;
}
}
});
test('renderSessionLine shows Bedrock label and hides usage for bedrock model ids', () => {
const ctx = baseContext();
ctx.stdin.model = { display_name: 'Sonnet', id: 'anthropic.claude-3-5-sonnet-20240620-v1:0' };
ctx.usageData = {
planName: 'Max',
fiveHour: 23,
sevenDay: 45,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('Sonnet'), 'should include model name');
assert.ok(line.includes('Bedrock'), 'should include Bedrock label');
assert.ok(!line.includes('5h:'), 'should hide usage display');
});
test('renderSessionLine displays usage percentages (7d hidden when low)', () => {
const ctx = baseContext();
ctx.config.display.sevenDayThreshold = 80;
ctx.usageData = {
planName: 'Pro',
fiveHour: 6,
sevenDay: 13,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('5h:'), 'should include 5h label');
assert.ok(!line.includes('7d:'), 'should NOT include 7d when below 80%');
assert.ok(line.includes('6%'), 'should include 5h percentage');
});
test('renderSessionLine shows 7d when approaching limit (>=80%)', () => {
const ctx = baseContext();
ctx.config.display.sevenDayThreshold = 80;
ctx.usageData = {
planName: 'Pro',
fiveHour: 45,
sevenDay: 85,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('5h:'), 'should include 5h label');
assert.ok(line.includes('7d:'), 'should include 7d when >= 80%');
assert.ok(line.includes('85%'), 'should include 7d percentage');
});
test('renderSessionLine shows 7d reset countdown in text-only mode', () => {
const ctx = baseContext();
const resetTime = new Date(Date.now() + (28 * 60 * 60 * 1000)); // 1d 4h from now
ctx.config.display.sevenDayThreshold = 80;
ctx.config.display.usageBarEnabled = false;
ctx.usageData = {
planName: 'Pro',
fiveHour: 45,
sevenDay: 85,
fiveHourResetAt: null,
sevenDayResetAt: resetTime,
};
const line = stripAnsi(renderSessionLine(ctx));
assert.ok(line.includes('7d: 85%'), `should include 7d label and percentage: ${line}`);
assert.ok(line.includes('(1d 4h)'), `should include 7d reset countdown in text-only mode: ${line}`);
});
test('renderSessionLine respects sevenDayThreshold override', () => {
const ctx = baseContext();
ctx.config.display.sevenDayThreshold = 0;
ctx.usageData = {
planName: 'Pro',
fiveHour: 10,
sevenDay: 5,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('7d:'), 'should include 7d when threshold is 0');
});
test('renderSessionLine shows 5hr reset countdown', () => {
const ctx = baseContext();
const resetTime = new Date(Date.now() + 7200000); // 2 hours from now
ctx.usageData = {
planName: 'Pro',
fiveHour: 45,
sevenDay: 20,
fiveHourResetAt: resetTime,
sevenDayResetAt: null,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('5h:'), 'should include 5h label');
assert.ok(line.includes('2h'), 'should include reset countdown');
});
test('renderUsageLine shows reset countdown in days when >= 24 hours', () => {
const ctx = baseContext();
const resetTime = new Date(Date.now() + (151 * 3600000) + (59 * 60000)); // 6d 7h 59m from now
ctx.config.display.usageBarEnabled = true;
ctx.usageData = {
planName: 'Pro',
fiveHour: 45,
sevenDay: 20,
fiveHourResetAt: resetTime,
sevenDayResetAt: null,
};
const line = renderUsageLine(ctx);
assert.ok(line, 'should render usage line');
const plain = stripAnsi(line);
assert.ok(plain.includes('(resets in 6d 7h)'), `expected bar-mode reset wording, got: ${plain}`);
assert.ok(!plain.includes('151h'), `should avoid raw hour format for long durations: ${plain}`);
});
test('renderUsageLine shows 7d reset countdown in text-only mode', () => {
const ctx = baseContext();
const resetTime = new Date(Date.now() + (28 * 60 * 60 * 1000)); // 1d 4h from now
ctx.config.display.usageBarEnabled = false;
ctx.config.display.sevenDayThreshold = 80;
ctx.usageData = {
planName: 'Pro',
fiveHour: 45,
sevenDay: 85,
fiveHourResetAt: null,
sevenDayResetAt: resetTime,
};
const line = stripAnsi(renderUsageLine(ctx));
assert.ok(line.includes('5h: 45%'), `should include 5h text-only usage: ${line}`);
assert.ok(line.includes('7d: 85%'), `should include 7d text-only usage: ${line}`);
assert.ok(line.includes('(resets in 1d 4h)'), `should include 7d reset countdown in text-only mode: ${line}`);
});
test('renderUsageLine shows 7d reset countdown in bar mode when above threshold', () => {
const ctx = baseContext();
const resetTime = new Date(Date.now() + (28 * 60 * 60 * 1000)); // 1d 4h from now
ctx.config.display.usageBarEnabled = true;
ctx.config.display.sevenDayThreshold = 80;
ctx.usageData = {
planName: 'Pro',
fiveHour: 45,
sevenDay: 85,
fiveHourResetAt: null,
sevenDayResetAt: resetTime,
};
const line = stripAnsi(renderUsageLine(ctx));
assert.ok(line.includes('45%'), `should include 5h percentage in bar mode: ${line}`);
assert.ok(line.includes('85%'), `should include 7d percentage: ${line}`);
assert.ok(line.includes('(resets in 1d 4h)'), `should include 7d reset countdown in bar mode: ${line}`);
assert.ok(line.includes('|'), `should render both usage windows above the threshold: ${line}`);
});
test('renderSessionLine displays limit reached warning', () => {
const ctx = baseContext();
const resetTime = new Date(Date.now() + 3600000); // 1 hour from now
ctx.usageData = {
planName: 'Pro',
fiveHour: 100,
sevenDay: 45,
fiveHourResetAt: resetTime,
sevenDayResetAt: null,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('Limit reached'), 'should show limit reached');
assert.ok(line.includes('resets'), 'should show reset time');
});
test('renderUsageLine shows limit reset in days when >= 24 hours', () => {
const ctx = baseContext();
const resetTime = new Date(Date.now() + (151 * 3600000) + (59 * 60000)); // 6d 7h 59m from now
ctx.usageData = {
planName: 'Pro',
fiveHour: 100,
sevenDay: 45,
fiveHourResetAt: resetTime,
sevenDayResetAt: null,
};
const line = renderUsageLine(ctx);
assert.ok(line, 'should render usage line');
const plain = stripAnsi(line);
assert.ok(plain.includes('Limit reached'), 'should show limit reached');
assert.ok(/resets \d+d( \d+h)?/.test(plain), `expected day/hour reset format, got: ${plain}`);
assert.ok(!plain.includes('151h'), `should avoid raw hour format for long durations: ${plain}`);
});
test('renderSessionLine displays -- for null usage values', () => {
const ctx = baseContext();
ctx.usageData = {
planName: 'Max',
fiveHour: null,
sevenDay: null,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('5h:'), 'should include 5h label');
assert.ok(line.includes('--'), 'should show -- for null values');
});
test('renderSessionLine omits usage when usageData is null', () => {
const ctx = baseContext();
ctx.usageData = null;
const line = renderSessionLine(ctx);
assert.ok(!line.includes('5h:'), 'should not include 5h label');
assert.ok(!line.includes('7d:'), 'should not include 7d label');
});
test('renderSessionLine displays warning when API is unavailable', () => {
const ctx = baseContext();
ctx.usageData = {
planName: 'Max',
fiveHour: null,
sevenDay: null,
fiveHourResetAt: null,
sevenDayResetAt: null,
apiUnavailable: true,
apiError: 'http-401',
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('usage:'), 'should show usage label');
assert.ok(line.includes('⚠'), 'should show warning indicator');
assert.ok(line.includes('401'), 'should include error code');
assert.ok(!line.includes('5h:'), 'should not show 5h when API unavailable');
});
test('renderSessionLine shows syncing hint when usage API is rate-limited', () => {
const ctx = baseContext();
ctx.usageData = {
planName: 'Max',
fiveHour: null,
sevenDay: null,
fiveHourResetAt: null,
sevenDayResetAt: null,
apiUnavailable: true,
apiError: 'rate-limited',
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('usage:'), 'should show usage label');
assert.ok(line.includes('syncing...'), 'should show syncing hint for rate limiting');
assert.ok(!line.includes('rate-limited'), 'should not expose raw rate-limit error key');
});
test('renderSessionLine keeps stale usage visible while rate-limited', () => {
const ctx = baseContext();
ctx.usageData = {
planName: 'Max',
fiveHour: 25,
sevenDay: 85,
fiveHourResetAt: null,
sevenDayResetAt: null,
apiError: 'rate-limited',
};
const compactLine = renderSessionLine(ctx);
assert.ok(compactLine.includes('25%'), 'should keep the last successful 5h usage visible');
assert.ok(compactLine.includes('85%'), 'should keep the last successful 7d usage visible');
assert.ok(compactLine.includes('syncing...'), 'should show syncing hint alongside stale usage');
const usageLine = renderUsageLine(ctx);
assert.ok(usageLine?.includes('25%'), 'expanded usage line should keep stale 5h usage visible');
assert.ok(usageLine?.includes('85%'), 'expanded usage line should keep stale 7d usage visible');
assert.ok(usageLine?.includes('syncing...'), 'expanded usage line should show syncing hint');
});
test('renderSessionLine uses custom warning and critical colors for usage states', () => {
const ctx = baseContext();
ctx.config.colors = {
context: 'green',
usage: 'brightBlue',
warning: 'cyan',
usageWarning: 'brightMagenta',
critical: 'magenta',
};
ctx.usageData = {
planName: 'Max',
fiveHour: null,
sevenDay: null,
fiveHourResetAt: null,
sevenDayResetAt: null,
apiUnavailable: true,
apiError: 'http-401',
};
const warningLine = renderSessionLine(ctx);
assert.ok(warningLine.includes('\x1b[36musage: ⚠ (401)\x1b[0m'), `expected custom warning color, got: ${JSON.stringify(warningLine)}`);
ctx.usageData = {
planName: 'Pro',
fiveHour: 100,
sevenDay: 45,
fiveHourResetAt: new Date(Date.now() + 3600000),
sevenDayResetAt: null,
};
const criticalLine = renderSessionLine(ctx);
assert.ok(criticalLine.includes('\x1b[35m⚠ Limit reached'), `expected custom critical color, got: ${JSON.stringify(criticalLine)}`);
});
test('renderUsageLine uses custom usage palette overrides', () => {
const ctx = baseContext();
ctx.config.display.usageBarEnabled = true;
ctx.config.colors = {
context: 'green',
usage: 'cyan',
warning: 'yellow',
usageWarning: 'magenta',
critical: 'red',
};
ctx.usageData = {
planName: 'Pro',
fiveHour: 25,
sevenDay: 80,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
const line = renderUsageLine(ctx);
assert.ok(line, 'should render usage line');
assert.ok(line.includes('\x1b[36m███'), `expected custom usage bar color, got: ${JSON.stringify(line)}`);
assert.ok(line.includes('\x1b[36m25%\x1b[0m'), `expected custom usage percentage color, got: ${JSON.stringify(line)}`);
assert.ok(line.includes('\x1b[35m████████'), `expected custom usage warning color, got: ${JSON.stringify(line)}`);
assert.ok(line.includes('\x1b[35m80%\x1b[0m'), `expected custom usage warning percentage color, got: ${JSON.stringify(line)}`);
});
test('renderSessionLine hides usage when showUsage config is false (hybrid toggle)', () => {
const ctx = baseContext();
ctx.usageData = {
planName: 'Pro',
fiveHour: 25,
sevenDay: 10,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
// Even with usageData present, setting showUsage to false should hide it
ctx.config.display.showUsage = false;
const line = renderSessionLine(ctx);
assert.ok(!line.includes('5h:'), 'should not show usage when showUsage is false');
assert.ok(!line.includes('Pro'), 'should not show plan name when showUsage is false');
});
test('renderSessionLine uses buffered percent when autocompactBuffer is enabled', () => {
const ctx = baseContext();
// 60000 tokens / 200000 = 30% raw, scale = (0.30 - 0.05) / (0.50 - 0.05) ≈ 0.556
// buffer = 200000 * 0.165 * 0.556 ≈ 18333, (60000 + 18333) / 200000 = 39.2% → 39%
ctx.stdin.context_window.current_usage.input_tokens = 60000;
ctx.config.display.autocompactBuffer = 'enabled';
const line = renderSessionLine(ctx);
// Should show 39% (buffered), not 30% (raw)
assert.ok(line.includes('39%'), `expected buffered percent 39%, got: ${line}`);
});
test('renderSessionLine uses raw percent when autocompactBuffer is disabled', () => {
const ctx = baseContext();
// 60000 tokens / 200000 = 30% raw
ctx.stdin.context_window.current_usage.input_tokens = 60000;
ctx.config.display.autocompactBuffer = 'disabled';
const line = renderSessionLine(ctx);
// Should show 30% (raw), not 39% (buffered)
assert.ok(line.includes('30%'), `expected raw percent 30%, got: ${line}`);
});
test('renderSessionLine avoids inflated startup percentage before native context data exists', () => {
const ctx = baseContext();
ctx.stdin.context_window.current_usage = {};
ctx.stdin.context_window.used_percentage = null;
ctx.config.display.autocompactBuffer = 'enabled';
const line = renderSessionLine(ctx);
assert.ok(line.includes('0%'), `expected startup percent 0%, got: ${line}`);
});
test('render adds separator line when showSeparators is true and activity exists', () => {
const ctx = baseContext();
ctx.config.showSeparators = true;
ctx.transcript.tools = [
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
];
const logs = [];
const originalLog = console.log;
console.log = (line) => logs.push(line);
try {
render(ctx);
} finally {
console.log = originalLog;
}
assert.ok(logs.length > 1, 'should render multiple lines');
assert.ok(logs.some(l => l.includes('─')), 'should include separator line');
});
test('render omits separator when showSeparators is true but no activity', () => {
const ctx = baseContext();
ctx.config.showSeparators = true;
const logs = [];
const originalLog = console.log;
console.log = (line) => logs.push(line);
try {
render(ctx);
} finally {
console.log = originalLog;
}
assert.ok(!logs.some(l => l.includes('─')), 'should not include separator');
});
test('render preserves regular spaces instead of non-breaking spaces', () => {
const ctx = baseContext();
ctx.config.lineLayout = 'expanded';
const logs = [];
const originalLog = console.log;
console.log = (line) => logs.push(line);
try {
render(ctx);
} finally {
console.log = originalLog;
}
assert.ok(logs.length > 0, 'should render at least one line');
assert.ok(logs.every(line => !line.includes('\u00A0')), 'output should not include non-breaking spaces');
});
// fileStats tests
test('renderSessionLine displays file stats when showFileStats is true', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.config.gitStatus.showFileStats = true;
ctx.gitStatus = {
branch: 'main',
isDirty: true,
ahead: 0,
behind: 0,
fileStats: { modified: 2, added: 1, deleted: 0, untracked: 3 },
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('!2'), 'expected modified count');
assert.ok(line.includes('+1'), 'expected added count');
assert.ok(line.includes('?3'), 'expected untracked count');
assert.ok(!line.includes('✘'), 'should not show deleted when 0');
});
test('renderSessionLine omits file stats when showFileStats is false', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.config.gitStatus.showFileStats = false;
ctx.gitStatus = {
branch: 'main',
isDirty: true,
ahead: 0,
behind: 0,
fileStats: { modified: 2, added: 1, deleted: 0, untracked: 3 },
};
const line = renderSessionLine(ctx);
assert.ok(!line.includes('!2'), 'should not show modified count');
assert.ok(!line.includes('+1'), 'should not show added count');
});
test('renderSessionLine handles missing showFileStats config (backward compatibility)', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
// Simulate old config without showFileStats
delete ctx.config.gitStatus.showFileStats;
ctx.gitStatus = {
branch: 'main',
isDirty: true,
ahead: 0,
behind: 0,
fileStats: { modified: 2, added: 1, deleted: 0, untracked: 3 },
};
// Should not crash and should not show file stats (default is false)
const line = renderSessionLine(ctx);
assert.ok(line.includes('git:('), 'should still show git info');
assert.ok(!line.includes('!2'), 'should not show file stats when config missing');
});
test('renderSessionLine combines showFileStats with showDirty and showAheadBehind', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.config.gitStatus = {
enabled: true,
showDirty: true,
showAheadBehind: true,
showFileStats: true,
};
ctx.gitStatus = {
branch: 'feature',
isDirty: true,
ahead: 2,
behind: 1,
fileStats: { modified: 3, added: 0, deleted: 1, untracked: 0 },
};
const line = renderSessionLine(ctx);
assert.ok(line.includes('feature'), 'expected branch name');
assert.ok(line.includes('*'), 'expected dirty indicator');
assert.ok(line.includes('↑2'), 'expected ahead count');
assert.ok(line.includes('↓1'), 'expected behind count');
assert.ok(line.includes('!3'), 'expected modified count');
assert.ok(line.includes('✘1'), 'expected deleted count');
});
test('render expanded layout honors custom elementOrder including activity placement', () => {
const ctx = baseContext();
ctx.config.lineLayout = 'expanded';
ctx.stdin.cwd = '/tmp/my-project';
ctx.usageData = {
planName: 'Team',
fiveHour: 30,
sevenDay: 10,
fiveHourResetAt: new Date(Date.now() + 60 * 60 * 1000),
sevenDayResetAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
};
ctx.claudeMdCount = 1;
ctx.rulesCount = 2;
ctx.transcript.tools = [
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
];
ctx.transcript.agents = [
{ id: 'agent-1', type: 'planner', status: 'running', startTime: new Date(0) },
];
ctx.transcript.todos = [
{ content: 'todo-marker', status: 'in_progress' },
];
ctx.config.elementOrder = ['tools', 'project', 'usage', 'context', 'environment', 'agents', 'todos'];
const lines = captureRenderLines(ctx);
const toolIndex = lines.findIndex(line => line.includes('Read'));
const projectIndex = lines.findIndex(line => line.includes('my-project'));
const combinedIndex = lines.findIndex(line => line.includes('Usage') && line.includes('Context'));
const environmentIndex = lines.findIndex(line => line.includes('CLAUDE.md'));
const agentIndex = lines.findIndex(line => line.includes('planner'));
const todoIndex = lines.findIndex(line => line.includes('todo-marker'));
assert.deepEqual(
[toolIndex, projectIndex, combinedIndex, environmentIndex, agentIndex, todoIndex].every(index => index >= 0),
true,
'expected all configured elements to render'
);
assert.ok(toolIndex < projectIndex, 'tool line should move ahead of project');
assert.ok(projectIndex < combinedIndex, 'combined usage/context line should follow project');
assert.ok(combinedIndex < environmentIndex, 'environment line should follow context/usage');
assert.ok(environmentIndex < agentIndex, 'agent line should follow environment');
assert.ok(agentIndex < todoIndex, 'todo line should follow agent line');
});
test('render expanded layout omits elements not present in elementOrder', () => {
const ctx = baseContext();
ctx.config.lineLayout = 'expanded';
ctx.stdin.cwd = '/tmp/my-project';
ctx.usageData = {
planName: 'Team',
fiveHour: 30,
sevenDay: 10,
fiveHourResetAt: new Date(Date.now() + 60 * 60 * 1000),
sevenDayResetAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
};
ctx.claudeMdCount = 1;
ctx.transcript.tools = [
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
];
ctx.transcript.agents = [
{ id: 'agent-1', type: 'planner', status: 'running', startTime: new Date(0) },
];
ctx.transcript.todos = [
{ content: 'todo-marker', status: 'in_progress' },
];
ctx.config.elementOrder = ['project', 'tools'];
const output = captureRenderLines(ctx).join('\n');
assert.ok(output.includes('my-project'), 'project should render when included');
assert.ok(output.includes('Read'), 'tools should render when included');
assert.ok(!output.includes('Context'), 'context should be omitted when excluded');
assert.ok(!output.includes('Usage'), 'usage should be omitted when excluded');
assert.ok(!output.includes('CLAUDE.md'), 'environment should be omitted when excluded');
assert.ok(!output.includes('planner'), 'agents should be omitted when excluded');
assert.ok(!output.includes('todo-marker'), 'todos should be omitted when excluded');
});
test('render expanded layout combines usage and context when adjacent in elementOrder', () => {
const ctx = baseContext();
ctx.config.lineLayout = 'expanded';
ctx.usageData = {
planName: 'Team',
fiveHour: 30,
sevenDay: 10,
fiveHourResetAt: new Date(Date.now() + 60 * 60 * 1000),
sevenDayResetAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
};
ctx.config.elementOrder = ['usage', 'context'];
const lines = captureRenderLines(ctx);
assert.equal(lines.length, 1, 'adjacent usage and context should share one expanded line');
assert.ok(lines[0].includes('Usage'), 'combined line should include usage');
assert.ok(lines[0].includes('Context'), 'combined line should include context');
assert.ok(lines[0].includes('│'), 'combined line should preserve the shared separator');
});
test('render expanded layout keeps usage and context separate when not adjacent', () => {
const ctx = baseContext();
ctx.config.lineLayout = 'expanded';
ctx.stdin.cwd = '/tmp/my-project';
ctx.usageData = {
planName: 'Team',
fiveHour: 30,
sevenDay: 10,
fiveHourResetAt: new Date(Date.now() + 60 * 60 * 1000),
sevenDayResetAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
};
ctx.config.elementOrder = ['usage', 'project', 'context'];
const lines = captureRenderLines(ctx);
const usageLine = lines.find(line => line.includes('Usage'));
const contextLine = lines.find(line => line.includes('Context'));
const combinedLine = lines.find(line => line.includes('Usage') && line.includes('Context'));
assert.ok(usageLine, 'usage should render on its own line');
assert.ok(contextLine, 'context should render on its own line');
assert.equal(combinedLine, undefined, 'usage and context should not combine when separated by another element');
});
test('render compact layout keeps activity lines even when elementOrder omits them', () => {
const ctx = baseContext();
ctx.stdin.cwd = '/tmp/my-project';
ctx.transcript.tools = [
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
];
ctx.transcript.todos = [
{ content: 'todo-marker', status: 'in_progress' },
];
ctx.config.elementOrder = ['project'];
const output = captureRenderLines(ctx).join('\n');
assert.ok(output.includes('Read'), 'compact mode should keep tools visible');
assert.ok(output.includes('todo-marker'), 'compact mode should keep todos visible');
});
================================================
FILE: tests/speed-tracker.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { getOutputSpeed } from '../dist/speed-tracker.js';
import { existsSync } from 'node:fs';
function restoreEnvVar(name, value) {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
async function createTempHome() {
return await mkdtemp(path.join(tmpdir(), 'claude-hud-speed-'));
}
test('getOutputSpeed returns null when output tokens are missing', () => {
const speed = getOutputSpeed({ context_window: { current_usage: { input_tokens: 10 } } });
assert.equal(speed, null);
});
test('getOutputSpeed computes tokens per second within window', async () => {
const tempHome = await createTempHome();
try {
const base = { homeDir: () => tempHome };
const first = getOutputSpeed(
{ context_window: { current_usage: { output_tokens: 10 } } },
{ ...base, now: () => 1000 }
);
assert.equal(first, null);
const second = getOutputSpeed(
{ context_window: { current_usage: { output_tokens: 20 } } },
{ ...base, now: () => 1500 }
);
assert.ok(second !== null);
assert.ok(Math.abs(second - 20) < 0.01);
} finally {
await rm(tempHome, { recursive: true, force: true });
}
});
test('getOutputSpeed ignores stale windows', async () => {
const tempHome = await createTempHome();
try {
const base = { homeDir: () => tempHome };
getOutputSpeed(
{ context_window: { current_usage: { output_tokens: 10 } } },
{ ...base, now: () => 1000 }
);
const speed = getOutputSpeed(
{ context_window: { current_usage: { output_tokens: 30 } } },
{ ...base, now: () => 8000 }
);
assert.equal(speed, null);
} finally {
await rm(tempHome, { recursive: true, force: true });
}
});
test('getOutputSpeed writes cache under CLAUDE_CONFIG_DIR by default', async () => {
const tempHome = await createTempHome();
const customConfigDir = path.join(tempHome, '.claude-alt');
const originalHome = process.env.HOME;
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.HOME = tempHome;
process.env.CLAUDE_CONFIG_DIR = customConfigDir;
try {
const first = getOutputSpeed(
{ context_window: { current_usage: { output_tokens: 10 } } },
{ now: () => 1000 }
);
assert.equal(first, null);
const second = getOutputSpeed(
{ context_window: { current_usage: { output_tokens: 20 } } },
{ now: () => 1500 }
);
assert.ok(second !== null);
const customCachePath = path.join(customConfigDir, 'plugins', 'claude-hud', '.speed-cache.json');
const defaultCachePath = path.join(tempHome, '.claude', 'plugins', 'claude-hud', '.speed-cache.json');
assert.equal(existsSync(customCachePath), true);
assert.equal(existsSync(defaultCachePath), false);
} finally {
restoreEnvVar('HOME', originalHome);
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
await rm(tempHome, { recursive: true, force: true });
}
});
================================================
FILE: tests/stdin.test.js
================================================
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readStdin } from '../dist/stdin.js';
test('readStdin returns null for TTY input', async () => {
const originalIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
try {
const result = await readStdin();
assert.equal(result, null);
} finally {
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
}
});
test('readStdin returns null on stream errors', async () => {
const originalIsTTY = process.stdin.isTTY;
const originalSetEncoding = process.stdin.setEncoding;
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
process.stdin.setEncoding = () => {
throw new Error('boom');
};
try {
const result = await readStdin();
assert.equal(result, null);
} finally {
process.stdin.setEncoding = originalSetEncoding;
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
}
});
================================================
FILE: tests/terminal.test.js
================================================
import { test, describe, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { getAdaptiveBarWidth } from '../dist/utils/terminal.js';
describe('getAdaptiveBarWidth', () => {
let originalColumns;
let originalEnvColumns;
beforeEach(() => {
originalColumns = Object.getOwnPropertyDescriptor(process.stdout, 'columns');
originalEnvColumns = process.env.COLUMNS;
delete process.env.COLUMNS;
});
afterEach(() => {
if (originalColumns) {
Object.defineProperty(process.stdout, 'columns', originalColumns);
} else {
delete process.stdout.columns;
}
if (originalEnvColumns !== undefined) {
process.env.COLUMNS = originalEnvColumns;
} else {
delete process.env.COLUMNS;
}
});
test('returns 4 for narrow terminal (<60 cols)', () => {
Object.defineProperty(process.stdout, 'columns', { value: 40, configurable: true });
assert.equal(getAdaptiveBarWidth(), 4);
});
test('returns 4 for exactly 59 cols', () => {
Object.defineProperty(process.stdout, 'columns', { value: 59, configurable: true });
assert.equal(getAdaptiveBarWidth(), 4);
});
test('returns 6 for medium terminal (60-99 cols)', () => {
Object.defineProperty(process.stdout, 'columns', { value: 70, configurable: true });
assert.equal(getAdaptiveBarWidth(), 6);
});
test('returns 6 for exactly 60 cols', () => {
Object.defineProperty(process.stdout, 'columns', { value: 60, configurable: true });
assert.equal(getAdaptiveBarWidth(), 6);
});
test('returns 6 for exactly 99 cols', () => {
Object.defineProperty(process.stdout, 'columns', { value: 99, configurable: true });
assert.equal(getAdaptiveBarWidth(), 6);
});
test('returns 10 for wide terminal (>=100 cols)', () => {
Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true });
assert.equal(getAdaptiveBarWidth(), 10);
});
test('returns 10 for exactly 100 cols', () => {
Object.defineProperty(process.stdout, 'columns', { value: 100, configurable: true });
assert.equal(getAdaptiveBarWidth(), 10);
});
test('returns 10 when stdout.columns is undefined (non-TTY/piped)', () => {
Object.defineProperty(process.stdout, 'columns', { value: undefined, configurable: true });
assert.equal(getAdaptiveBarWidth(), 10);
});
test('falls back to COLUMNS env var when stdout.columns unavailable', () => {
Object.defineProperty(process.stdout, 'columns', { value: undefined, configurable: true });
process.env.COLUMNS = '70';
assert.equal(getAdaptiveBarWidth(), 6);
});
test('returns 10 when both stdout.columns and COLUMNS are unavailable', () => {
Object.defineProperty(process.stdout, 'columns', { value: undefined, configurable: true });
delete process.env.COLUMNS;
assert.equal(getAdaptiveBarWidth(), 10);
});
});
================================================
FILE: tests/usage-api.test.js
================================================
import { test, describe, before, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, rm, mkdir, writeFile, utimes } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { createServer } from 'node:net';
let tempHome = null;
let cacheTempHome = null;
let getUsage;
let clearCache;
let getKeychainServiceName;
let getKeychainServiceNames;
let resolveKeychainCredentials;
let getUsageApiTimeoutMs;
let isNoProxy;
let getProxyUrl;
let parseRetryAfterSeconds;
let USAGE_API_USER_AGENT;
function ensureUsageApiDistIsCurrent() {
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '..');
const distPath = path.join(repoRoot, 'dist', 'usage-api.js');
if (!existsSync(distPath)) {
execFileSync('npm', ['run', 'build'], {
cwd: repoRoot,
stdio: 'inherit',
});
return;
}
// These tests import dist/ modules directly. In a source-only PR worktree,
// git checkout mtimes are not a reliable signal that dist matches src, so
// rebuild once up front to make direct `node --test` runs deterministic.
execFileSync('npm', ['run', 'build'], {
cwd: repoRoot,
stdio: 'inherit',
});
}
before(async () => {
ensureUsageApiDistIsCurrent();
({
getUsage,
clearCache,
getKeychainServiceName,
getKeychainServiceNames,
resolveKeychainCredentials,
getUsageApiTimeoutMs,
isNoProxy,
getProxyUrl,
parseRetryAfterSeconds,
USAGE_API_USER_AGENT,
} = await import(`../dist/usage-api.js?cacheBust=${Date.now()}`));
});
async function createTempHome() {
return await mkdtemp(path.join(tmpdir(), 'claude-hud-usage-'));
}
function restoreEnvVar(name, value) {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
async function writeCredentialsInConfigDir(configDir, credentials) {
const credDir = configDir;
await mkdir(credDir, { recursive: true });
await writeFile(path.join(credDir, '.credentials.json'), JSON.stringify(credentials), 'utf8');
}
async function writeCredentials(homeDir, credentials) {
await writeCredentialsInConfigDir(path.join(homeDir, '.claude'), credentials);
}
function buildCredentials(overrides = {}) {
return {
claudeAiOauth: {
accessToken: 'test-token',
subscriptionType: 'claude_pro_2024',
expiresAt: Date.now() + 3600000, // 1 hour from now
...overrides,
},
};
}
function buildApiResponse(overrides = {}) {
return {
five_hour: {
utilization: 25,
resets_at: '2026-01-06T15:00:00Z',
},
seven_day: {
utilization: 10,
resets_at: '2026-01-13T00:00:00Z',
},
...overrides,
};
}
function buildApiResult(overrides = {}) {
return {
data: buildApiResponse(),
...overrides,
};
}
function buildMissingKeychainError() {
const err = new Error('security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.');
err.status = 44;
return err;
}
describe('resolveKeychainCredentials', () => {
test('falls back to legacy service when profile-specific service is missing', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials-deadbeef', 'Claude Code-credentials'];
const calls = [];
const result = resolveKeychainCredentials(serviceNames, now, (serviceName) => {
calls.push(serviceName);
if (serviceName === 'Claude Code-credentials-deadbeef') {
throw buildMissingKeychainError();
}
return JSON.stringify(buildCredentials({
accessToken: 'legacy-token',
subscriptionType: 'claude_pro_2024',
expiresAt: now + 60_000,
}));
});
assert.equal(result.credentials?.accessToken, 'legacy-token');
assert.equal(result.shouldBackoff, false);
assert.deepEqual(calls, serviceNames);
});
test('does not request backoff when all services are missing', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials-deadbeef', 'Claude Code-credentials'];
const result = resolveKeychainCredentials(serviceNames, now, () => {
throw buildMissingKeychainError();
});
assert.equal(result.credentials, null);
assert.equal(result.shouldBackoff, false);
});
test('requests backoff on non-missing keychain errors', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials-deadbeef', 'Claude Code-credentials'];
const result = resolveKeychainCredentials(serviceNames, now, (serviceName) => {
if (serviceName === 'Claude Code-credentials-deadbeef') {
throw new Error('security command timed out');
}
throw buildMissingKeychainError();
});
assert.equal(result.credentials, null);
assert.equal(result.shouldBackoff, true);
});
test('treats missing-item message as non-backoff condition', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials-hashed'];
const result = resolveKeychainCredentials(serviceNames, now, () => {
throw new Error('The specified item could not be found in the keychain.');
});
assert.equal(result.credentials, null);
assert.equal(result.shouldBackoff, false);
});
test('uses first valid credential in candidate order', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials-canonical', 'Claude Code-credentials-fallback'];
const result = resolveKeychainCredentials(serviceNames, now, (serviceName) => {
if (serviceName === 'Claude Code-credentials-canonical') {
return JSON.stringify(buildCredentials({
accessToken: 'canonical-token',
subscriptionType: 'claude_max_2024',
expiresAt: now + 60_000,
}));
}
return JSON.stringify(buildCredentials({
accessToken: 'fallback-token',
subscriptionType: 'claude_pro_2024',
expiresAt: now + 60_000,
}));
});
assert.equal(result.credentials?.accessToken, 'canonical-token');
assert.equal(result.shouldBackoff, false);
});
test('prefers account-scoped keychain entries before generic fallback', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials'];
const calls = [];
const result = resolveKeychainCredentials(
serviceNames,
now,
(serviceName, accountName) => {
calls.push({ serviceName, accountName: accountName ?? null });
if (accountName === 'jarrod') {
return JSON.stringify(buildCredentials({
accessToken: 'account-token',
subscriptionType: 'claude_max_2024',
expiresAt: now + 60_000,
}));
}
return JSON.stringify({
claudeAiOauth: {
accessToken: 'generic-token',
subscriptionType: 'claude_pro_2024',
expiresAt: now + 60_000,
},
});
},
'jarrod',
);
assert.equal(result.credentials?.accessToken, 'account-token');
assert.equal(result.shouldBackoff, false);
assert.deepEqual(calls, [{ serviceName: 'Claude Code-credentials', accountName: 'jarrod' }]);
});
test('falls back to generic lookup when account-scoped entry is missing', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials'];
const calls = [];
const result = resolveKeychainCredentials(
serviceNames,
now,
(serviceName, accountName) => {
calls.push({ serviceName, accountName: accountName ?? null });
if (accountName === 'jarrod') {
throw buildMissingKeychainError();
}
return JSON.stringify(buildCredentials({
accessToken: 'generic-token',
subscriptionType: 'claude_pro_2024',
expiresAt: now + 60_000,
}));
},
'jarrod',
);
assert.equal(result.credentials?.accessToken, 'generic-token');
assert.equal(result.shouldBackoff, false);
assert.deepEqual(calls, [
{ serviceName: 'Claude Code-credentials', accountName: 'jarrod' },
{ serviceName: 'Claude Code-credentials', accountName: null },
]);
});
test('does not fall back to a generic entry when account-scoped data exists but is unusable', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials'];
const calls = [];
const result = resolveKeychainCredentials(
serviceNames,
now,
(serviceName, accountName) => {
calls.push({ serviceName, accountName: accountName ?? null });
if (accountName === 'jarrod') {
return JSON.stringify({ mcpOAuth: { accessToken: 'wrong-scope' } });
}
return JSON.stringify(buildCredentials({
accessToken: 'generic-token',
subscriptionType: 'claude_pro_2024',
expiresAt: now + 60_000,
}));
},
'jarrod',
);
assert.equal(result.credentials, null);
assert.equal(result.shouldBackoff, false);
assert.deepEqual(calls, [{ serviceName: 'Claude Code-credentials', accountName: 'jarrod' }]);
});
test('does not fall back to a generic entry when account-scoped lookup returns an empty secret', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials'];
const calls = [];
const result = resolveKeychainCredentials(
serviceNames,
now,
(serviceName, accountName) => {
calls.push({ serviceName, accountName: accountName ?? null });
if (accountName === 'jarrod') {
return '';
}
return JSON.stringify(buildCredentials({
accessToken: 'generic-token',
subscriptionType: 'claude_pro_2024',
expiresAt: now + 60_000,
}));
},
'jarrod',
);
assert.equal(result.credentials, null);
assert.equal(result.shouldBackoff, false);
assert.deepEqual(calls, [{ serviceName: 'Claude Code-credentials', accountName: 'jarrod' }]);
});
test('does not fall back to a generic entry when account-scoped lookup fails with a non-missing error', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials'];
const calls = [];
const result = resolveKeychainCredentials(
serviceNames,
now,
(serviceName, accountName) => {
calls.push({ serviceName, accountName: accountName ?? null });
if (accountName === 'jarrod') {
throw new Error('security command timed out');
}
return JSON.stringify(buildCredentials({
accessToken: 'generic-token',
subscriptionType: 'claude_pro_2024',
expiresAt: now + 60_000,
}));
},
'jarrod',
);
assert.equal(result.credentials, null);
assert.equal(result.shouldBackoff, true);
assert.deepEqual(calls, [{ serviceName: 'Claude Code-credentials', accountName: 'jarrod' }]);
});
test('does not fall back to generic credentials from another service when a later account-scoped entry is unusable', () => {
const now = 1000;
const serviceNames = ['Claude Code-credentials-hashed', 'Claude Code-credentials'];
const calls = [];
const result = resolveKeychainCredentials(
serviceNames,
now,
(serviceName, accountName) => {
calls.push({ serviceName, accountName: accountName ?? null });
if (accountName === 'jarrod' && serviceName === 'Claude Code-credentials-hashed') {
throw buildMissingKeychainError();
}
if (accountName === 'jarrod' && serviceName === 'Claude Code-credentials') {
return JSON.stringify({ mcpOAuth: { accessToken: 'wrong-scope' } });
}
return JSON.stringify(buildCredentials({
accessToken: `generic-${serviceName}`,
subscriptionType: 'claude_pro_2024',
expiresAt: now + 60_000,
}));
},
'jarrod',
);
assert.equal(result.credentials, null);
assert.equal(result.shouldBackoff, false);
assert.deepEqual(calls, [
{ serviceName: 'Claude Code-credentials-hashed', accountName: 'jarrod' },
{ serviceName: 'Claude Code-credentials', accountName: 'jarrod' },
]);
});
});
describe('getUsage', () => {
beforeEach(async () => {
tempHome = await createTempHome();
clearCache(tempHome);
});
afterEach(async () => {
if (tempHome) {
await rm(tempHome, { recursive: true, force: true });
tempHome = null;
}
});
test('returns null when credentials file does not exist', async () => {
let fetchCalls = 0;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => {
fetchCalls += 1;
return { data: null };
},
now: () => 1000,
readKeychain: () => null, // Disable Keychain for tests
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
});
test('returns null when claudeAiOauth is missing', async () => {
await writeCredentials(tempHome, {});
let fetchCalls = 0;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => {
fetchCalls += 1;
return buildApiResult();
},
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
});
test('returns null when token is expired', async () => {
await writeCredentials(tempHome, buildCredentials({ expiresAt: 500 }));
let fetchCalls = 0;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => {
fetchCalls += 1;
return buildApiResult();
},
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
});
test('returns null for API users (no subscriptionType)', async () => {
await writeCredentials(tempHome, buildCredentials({ subscriptionType: 'api' }));
let fetchCalls = 0;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => {
fetchCalls += 1;
return buildApiResult();
},
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
});
test('uses complete keychain credentials without falling back to file', async () => {
// No file credentials - keychain should be sufficient
let usedToken = null;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async (token) => {
usedToken = token;
return buildApiResult();
},
now: () => 1000,
readKeychain: () => ({ accessToken: 'keychain-token', subscriptionType: 'claude_max_2024' }),
});
assert.equal(usedToken, 'keychain-token');
assert.equal(result?.planName, 'Max');
});
test('uses keychain token with file subscriptionType when keychain lacks subscriptionType', async () => {
await writeCredentials(tempHome, buildCredentials({
accessToken: 'old-file-token',
subscriptionType: 'claude_pro_2024',
}));
let usedToken = null;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async (token) => {
usedToken = token;
return buildApiResult();
},
now: () => 1000,
readKeychain: () => ({ accessToken: 'keychain-token', subscriptionType: '' }),
});
// Must use keychain token (authoritative), but can use file's subscriptionType
assert.equal(usedToken, 'keychain-token', 'should use keychain token, not file token');
assert.equal(result?.planName, 'Pro');
});
test('uses file subscriptionType fallback even when file token is expired', async () => {
await writeCredentials(tempHome, buildCredentials({
accessToken: 'stale-file-token',
subscriptionType: 'claude_team_2024',
expiresAt: 1,
}));
let usedToken = null;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async (token) => {
usedToken = token;
return buildApiResult();
},
now: () => 1000,
readKeychain: () => ({ accessToken: 'fresh-keychain-token', subscriptionType: '' }),
});
assert.equal(usedToken, 'fresh-keychain-token');
assert.equal(result?.planName, 'Team');
});
test('returns null when keychain has token but no subscriptionType anywhere', async () => {
// No file credentials, keychain has no subscriptionType
// This user is treated as an API user (no usage limits)
let fetchCalls = 0;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => {
fetchCalls += 1;
return buildApiResult();
},
now: () => 1000,
readKeychain: () => ({ accessToken: 'keychain-token', subscriptionType: '' }),
});
// No subscriptionType means API user, returns null without calling API
assert.equal(result, null);
assert.equal(fetchCalls, 0);
});
test('parses plan name and usage data', async () => {
await writeCredentials(tempHome, buildCredentials({ subscriptionType: 'claude_pro_2024' }));
let fetchCalls = 0;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => {
fetchCalls += 1;
return buildApiResult();
},
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.equal(result?.planName, 'Pro');
assert.equal(result?.fiveHour, 25);
assert.equal(result?.sevenDay, 10);
});
test('parses Team plan name', async () => {
await writeCredentials(tempHome, buildCredentials({ subscriptionType: 'claude_team_2024' }));
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => buildApiResult(),
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result?.planName, 'Team');
});
test('returns apiUnavailable and caches failures', async () => {
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
let nowValue = 1000;
const fetchApi = async () => {
fetchCalls += 1;
return { data: null, error: 'http-401' };
};
const first = await getUsage({
homeDir: () => tempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(first?.apiUnavailable, true);
assert.equal(first?.apiError, 'http-401');
assert.equal(fetchCalls, 1);
nowValue += 10_000;
const cached = await getUsage({
homeDir: () => tempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(cached?.apiUnavailable, true);
assert.equal(cached?.apiError, 'http-401');
assert.equal(fetchCalls, 1);
nowValue += 6_000;
const second = await getUsage({
homeDir: () => tempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(second?.apiUnavailable, true);
assert.equal(second?.apiError, 'http-401');
assert.equal(fetchCalls, 2);
});
test('reads credentials from CLAUDE_CONFIG_DIR and prefers them over default path', async () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
const customConfigDir = path.join(tempHome, '.claude-2');
process.env.CLAUDE_CONFIG_DIR = customConfigDir;
try {
await writeCredentials(tempHome, buildCredentials({ accessToken: 'default-token' }));
await writeCredentialsInConfigDir(
customConfigDir,
buildCredentials({ accessToken: 'custom-token', subscriptionType: 'claude_pro_2024' })
);
let usedToken = null;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async (token) => {
usedToken = token;
return buildApiResult();
},
now: () => 1000,
readKeychain: () => null,
});
assert.equal(usedToken, 'custom-token');
assert.equal(result?.planName, 'Pro');
} finally {
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
}
});
test('writes usage cache under CLAUDE_CONFIG_DIR plugin directory', async () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
const customConfigDir = path.join(tempHome, '.claude-2');
process.env.CLAUDE_CONFIG_DIR = customConfigDir;
try {
await writeCredentialsInConfigDir(customConfigDir, buildCredentials({ accessToken: 'custom-token' }));
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => ({ data: null, error: 'http-401' }),
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result?.apiUnavailable, true);
const customCachePath = path.join(customConfigDir, 'plugins', 'claude-hud', '.usage-cache.json');
const defaultCachePath = path.join(tempHome, '.claude', 'plugins', 'claude-hud', '.usage-cache.json');
assert.equal(existsSync(customCachePath), true);
assert.equal(existsSync(defaultCachePath), false);
} finally {
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
}
});
test('returns null when ANTHROPIC_BASE_URL points to a custom endpoint', async () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
process.env.ANTHROPIC_BASE_URL = 'https://my-proxy.example.com';
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
try {
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
} finally {
restoreEnvVar('ANTHROPIC_BASE_URL', originalBaseUrl);
}
});
test('returns null when ANTHROPIC_API_BASE_URL points to a custom endpoint', async () => {
const originalApiBaseUrl = process.env.ANTHROPIC_API_BASE_URL;
process.env.ANTHROPIC_API_BASE_URL = 'https://my-proxy.example.com';
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
try {
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
} finally {
restoreEnvVar('ANTHROPIC_API_BASE_URL', originalApiBaseUrl);
}
});
test('proceeds normally when ANTHROPIC_BASE_URL is set to empty string', async () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
process.env.ANTHROPIC_BASE_URL = '';
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
try {
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.ok(result !== null);
} finally {
restoreEnvVar('ANTHROPIC_BASE_URL', originalBaseUrl);
}
});
test('falls back to ANTHROPIC_API_BASE_URL when ANTHROPIC_BASE_URL is empty', async () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
const originalApiBaseUrl = process.env.ANTHROPIC_API_BASE_URL;
process.env.ANTHROPIC_BASE_URL = '';
process.env.ANTHROPIC_API_BASE_URL = 'https://my-proxy.example.com';
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
try {
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
} finally {
restoreEnvVar('ANTHROPIC_BASE_URL', originalBaseUrl);
restoreEnvVar('ANTHROPIC_API_BASE_URL', originalApiBaseUrl);
}
});
test('falls back to ANTHROPIC_API_BASE_URL when ANTHROPIC_BASE_URL is whitespace', async () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
const originalApiBaseUrl = process.env.ANTHROPIC_API_BASE_URL;
process.env.ANTHROPIC_BASE_URL = ' ';
process.env.ANTHROPIC_API_BASE_URL = 'https://my-proxy.example.com';
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
try {
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1000,
readKeychain: () => null,
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
} finally {
restoreEnvVar('ANTHROPIC_BASE_URL', originalBaseUrl);
restoreEnvVar('ANTHROPIC_API_BASE_URL', originalApiBaseUrl);
}
});
test('proceeds normally when ANTHROPIC_BASE_URL is the default Anthropic endpoint', async () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
try {
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.ok(result !== null);
} finally {
restoreEnvVar('ANTHROPIC_BASE_URL', originalBaseUrl);
}
});
test('proceeds normally when ANTHROPIC_BASE_URL is the default endpoint with trailing slash', async () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com/';
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
try {
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.ok(result !== null);
} finally {
restoreEnvVar('ANTHROPIC_BASE_URL', originalBaseUrl);
}
});
test('proceeds normally when ANTHROPIC_BASE_URL is the default endpoint with /v1 path', async () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1/';
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
try {
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.ok(result !== null);
} finally {
restoreEnvVar('ANTHROPIC_BASE_URL', originalBaseUrl);
}
});
test('prefers non-empty ANTHROPIC_BASE_URL over ANTHROPIC_API_BASE_URL', async () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
const originalApiBaseUrl = process.env.ANTHROPIC_API_BASE_URL;
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
process.env.ANTHROPIC_API_BASE_URL = 'https://my-proxy.example.com';
await writeCredentials(tempHome, buildCredentials());
let fetchCalls = 0;
try {
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.ok(result !== null);
} finally {
restoreEnvVar('ANTHROPIC_BASE_URL', originalBaseUrl);
restoreEnvVar('ANTHROPIC_API_BASE_URL', originalApiBaseUrl);
}
});
test('ignores cached Anthropic usage when a custom API endpoint is active', async () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
await writeCredentials(tempHome, buildCredentials());
try {
const cachedResult = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => buildApiResult(),
now: () => 1000,
readKeychain: () => null,
});
assert.ok(cachedResult !== null);
process.env.ANTHROPIC_BASE_URL = 'https://my-proxy.example.com';
let fetchCalls = 0;
const result = await getUsage({
homeDir: () => tempHome,
fetchApi: async () => { fetchCalls += 1; return buildApiResult(); },
now: () => 1500,
readKeychain: () => null,
});
assert.equal(result, null);
assert.equal(fetchCalls, 0);
} finally {
restoreEnvVar('ANTHROPIC_BASE_URL', originalBaseUrl);
}
});
test('sends CONNECT to proxy before any usage API request bytes', async () => {
const originalHttpsProxy = process.env.HTTPS_PROXY;
const originalUsageTimeout = process.env.CLAUDE_HUD_USAGE_TIMEOUT_MS;
await writeCredentials(tempHome, buildCredentials());
let firstRequestLine = null;
let resolveFirstLine = () => {};
const firstLinePromise = new Promise((resolve) => {
resolveFirstLine = resolve;
});
const proxyServer = createServer((socket) => {
let buffered = '';
socket.on('data', (chunk) => {
buffered += chunk.toString('utf8');
const lineEnd = buffered.indexOf('\r\n');
if (lineEnd === -1 || firstRequestLine) return;
firstRequestLine = buffered.slice(0, lineEnd);
resolveFirstLine(firstRequestLine);
socket.write('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
socket.end();
});
});
try {
await new Promise((resolve) => proxyServer.listen(0, '127.0.0.1', resolve));
const address = proxyServer.address();
assert.ok(address && typeof address === 'object', 'proxy server should have a bound address');
process.env.HTTPS_PROXY = `http://127.0.0.1:${address.port}`;
process.env.CLAUDE_HUD_USAGE_TIMEOUT_MS = '2000';
const result = await getUsage({
homeDir: () => tempHome,
now: () => 1000,
readKeychain: () => null,
});
const requestLine = await Promise.race([
firstLinePromise,
new Promise((resolve) => setTimeout(() => resolve('timeout'), 5000)),
]);
assert.match(requestLine, /^CONNECT api\.anthropic\.com:443 HTTP\/1\.1$/);
assert.equal(result?.apiUnavailable, true);
} finally {
await new Promise((resolve) => proxyServer.close(() => resolve()));
restoreEnvVar('HTTPS_PROXY', originalHttpsProxy);
restoreEnvVar('CLAUDE_HUD_USAGE_TIMEOUT_MS', originalUsageTimeout);
}
});
});
test('usage API user agent uses a non-empty claude-hud identifier', () => {
assert.equal(USAGE_API_USER_AGENT, 'claude-code/2.1');
});
describe('getKeychainServiceName', () => {
test('uses legacy default service name for default config directory', () => {
const homeDir = '/tmp/claude-hud-home-default';
const defaultConfigDir = path.join(homeDir, '.claude');
const serviceName = getKeychainServiceName(defaultConfigDir, homeDir);
assert.equal(serviceName, 'Claude Code-credentials');
});
test('uses profile-specific hashed service name for custom config directory', () => {
const homeDir = '/tmp/claude-hud-home-custom';
const customConfigDir = path.join(homeDir, '.claude-2');
const expectedHash = createHash('sha256').update(path.resolve(customConfigDir)).digest('hex').slice(0, 8);
const serviceName = getKeychainServiceName(customConfigDir, homeDir);
assert.equal(serviceName, `Claude Code-credentials-${expectedHash}`);
});
test('treats normalized default path as legacy service name', () => {
const homeDir = '/tmp/claude-hud-home-normalized';
const serviceName = getKeychainServiceName(path.join(homeDir, '.claude', '..', '.claude'), homeDir);
assert.equal(serviceName, 'Claude Code-credentials');
});
});
describe('getKeychainServiceNames', () => {
test('includes both env-hash and normalized-dir hash candidates before legacy fallback', () => {
const homeDir = '/tmp/claude-hud-home-candidates';
const configDir = path.join(homeDir, '.claude-2');
const envConfigDir = '~/.claude-2';
const envHash = createHash('sha256').update(envConfigDir).digest('hex').slice(0, 8);
const normalizedHash = createHash('sha256').update(path.resolve(configDir)).digest('hex').slice(0, 8);
const serviceNames = getKeychainServiceNames(configDir, homeDir, { CLAUDE_CONFIG_DIR: envConfigDir });
assert.deepEqual(serviceNames, [
`Claude Code-credentials-${normalizedHash}`,
`Claude Code-credentials-${envHash}`,
'Claude Code-credentials',
]);
});
test('returns legacy-only when config resolves to default location', () => {
const homeDir = '/tmp/claude-hud-home-default-candidates';
const defaultConfigDir = path.join(homeDir, '.claude');
const serviceNames = getKeychainServiceNames(defaultConfigDir, homeDir, {});
assert.deepEqual(serviceNames, ['Claude Code-credentials']);
});
test('returns legacy-only when env also points to default location', () => {
const homeDir = '/tmp/claude-hud-home-default-env';
const defaultConfigDir = path.join(homeDir, '.claude');
const serviceNames = getKeychainServiceNames(
defaultConfigDir,
homeDir,
{ CLAUDE_CONFIG_DIR: defaultConfigDir }
);
assert.deepEqual(serviceNames, ['Claude Code-credentials']);
});
});
describe('getUsage caching behavior', { concurrency: false }, () => {
beforeEach(async () => {
cacheTempHome = await createTempHome();
clearCache(cacheTempHome);
});
afterEach(async () => {
if (cacheTempHome) {
await rm(cacheTempHome, { recursive: true, force: true });
cacheTempHome = null;
}
});
test('cache expires after 5 minutes for success', async () => {
await writeCredentials(cacheTempHome, buildCredentials());
let fetchCalls = 0;
let nowValue = 1000;
const fetchApi = async () => {
fetchCalls += 1;
return buildApiResult();
};
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 1);
// Still fresh at 2 minutes
nowValue += 120_000;
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 1);
// Expired after 5 minutes
nowValue += 181_000;
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 2);
});
test('cache expires after 15 seconds for failures', async () => {
await writeCredentials(cacheTempHome, buildCredentials());
let fetchCalls = 0;
let nowValue = 1000;
const fetchApi = async () => {
fetchCalls += 1;
return { data: null, error: 'timeout' };
};
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 1);
nowValue += 10_000;
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 1);
nowValue += 6_000;
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null });
assert.equal(fetchCalls, 2);
});
test('respects custom cacheTtlMs and failureCacheTtlMs', async () => {
await writeCredentials(cacheTempHome, buildCredentials());
let fetchCalls = 0;
let nowValue = 1000;
const fetchApi = async () => {
fetchCalls += 1;
return buildApiResult();
};
const ttls = { cacheTtlMs: 10_000, failureCacheTtlMs: 5_000 };
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null, ttls });
assert.equal(fetchCalls, 1);
nowValue += 8_000;
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null, ttls });
assert.equal(fetchCalls, 1); // still fresh
nowValue += 3_000;
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => nowValue, readKeychain: () => null, ttls });
assert.equal(fetchCalls, 2); // expired after 11s total
});
test('clearCache removes file-based cache', async () => {
await writeCredentials(cacheTempHome, buildCredentials());
let fetchCalls = 0;
const fetchApi = async () => {
fetchCalls += 1;
return buildApiResult();
};
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => 1000, readKeychain: () => null });
assert.equal(fetchCalls, 1);
clearCache(cacheTempHome);
await getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => 2000, readKeychain: () => null });
assert.equal(fetchCalls, 2);
});
test('serves last good data during rate-limit backoff only', async () => {
await writeCredentials(cacheTempHome, buildCredentials());
let nowValue = 1000;
let fetchCalls = 0;
const fetchApi = async () => {
fetchCalls += 1;
if (fetchCalls === 1) {
return buildApiResult({
data: buildApiResponse({
five_hour: {
utilization: 25,
resets_at: '2026-01-06T15:00:00Z',
},
}),
});
}
return { data: null, error: 'rate-limited', retryAfterSec: 120 };
};
const initial = await getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(initial?.fiveHour, 25);
nowValue += 301_000;
const rateLimited = await getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(rateLimited?.fiveHour, 25);
assert.equal(rateLimited?.apiUnavailable, undefined);
assert.equal(rateLimited?.apiError, 'rate-limited');
assert.equal(fetchCalls, 2);
nowValue += 60_000;
const cachedDuringBackoff = await getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(cachedDuringBackoff?.fiveHour, 25);
assert.equal(cachedDuringBackoff?.apiUnavailable, undefined);
assert.equal(cachedDuringBackoff?.apiError, 'rate-limited');
assert.equal(fetchCalls, 2);
});
test('does not mask non-rate-limited failures with stale good data', async () => {
await writeCredentials(cacheTempHome, buildCredentials());
let nowValue = 1000;
let fetchCalls = 0;
const fetchApi = async () => {
fetchCalls += 1;
if (fetchCalls === 1) {
return buildApiResult({
data: buildApiResponse({
five_hour: {
utilization: 25,
resets_at: '2026-01-06T15:00:00Z',
},
}),
});
}
return { data: null, error: 'network' };
};
const initial = await getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(initial?.fiveHour, 25);
nowValue += 301_000;
const failure = await getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(fetchCalls, 2);
assert.equal(failure?.apiUnavailable, true);
assert.equal(failure?.apiError, 'network');
assert.equal(failure?.fiveHour, null);
});
test('deduplicates concurrent refreshes when cache is missing', async () => {
await writeCredentials(cacheTempHome, buildCredentials());
let fetchCalls = 0;
let releaseFetch = () => {};
let signalFetchStarted = () => {};
const fetchStarted = new Promise((resolve) => {
signalFetchStarted = resolve;
});
const fetchGate = new Promise((resolve) => {
releaseFetch = resolve;
});
const fetchApi = async () => {
fetchCalls += 1;
signalFetchStarted();
await fetchGate;
return buildApiResult({
data: buildApiResponse({
five_hour: {
utilization: 42,
resets_at: '2026-01-06T15:00:00Z',
},
}),
});
};
const first = getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => 1000, readKeychain: () => null });
await fetchStarted;
const second = getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => 1000, readKeychain: () => null });
const third = getUsage({ homeDir: () => cacheTempHome, fetchApi, now: () => 1000, readKeychain: () => null });
releaseFetch();
const results = await Promise.all([first, second, third]);
assert.equal(fetchCalls, 1);
assert.deepEqual(results.map((result) => result?.fiveHour), [42, 42, 42]);
});
test('returns stale cache while another process refreshes expired data', async () => {
await writeCredentials(cacheTempHome, buildCredentials());
let nowValue = 1000;
await getUsage({
homeDir: () => cacheTempHome,
fetchApi: async () => buildApiResult(),
now: () => nowValue,
readKeychain: () => null,
});
nowValue += 301_000;
let fetchCalls = 0;
let releaseFetch = () => {};
let signalFetchStarted = () => {};
const fetchStarted = new Promise((resolve) => {
signalFetchStarted = resolve;
});
const fetchGate = new Promise((resolve) => {
releaseFetch = resolve;
});
const fetchApi = async () => {
fetchCalls += 1;
signalFetchStarted();
await fetchGate;
return buildApiResult({
data: buildApiResponse({
five_hour: {
utilization: 88,
resets_at: '2026-01-06T16:00:00Z',
},
}),
});
};
const leader = getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
await fetchStarted;
const follower = await getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => nowValue,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.equal(follower?.fiveHour, 25);
releaseFetch();
const refreshed = await leader;
assert.equal(refreshed?.fiveHour, 88);
});
test('treats zero-byte lock file as stale and fetches fresh data', async () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
delete process.env.CLAUDE_CONFIG_DIR;
try {
await writeCredentials(cacheTempHome, buildCredentials());
const pluginDir = path.join(cacheTempHome, '.claude', 'plugins', 'claude-hud');
await mkdir(pluginDir, { recursive: true });
const lockFile = path.join(pluginDir, '.usage-cache.lock');
await writeFile(lockFile, '');
const past = new Date(Date.now() - 60_000);
await utimes(lockFile, past, past);
let fetchCalls = 0;
const fetchApi = async () => {
fetchCalls += 1;
return buildApiResult();
};
const result = await getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.ok(result);
assert.equal(result.fiveHour, 25);
assert.equal(existsSync(path.join(pluginDir, '.usage-cache.lock')), false);
} finally {
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
}
});
test('treats corrupt lock contents as stale and fetches fresh data', async () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
delete process.env.CLAUDE_CONFIG_DIR;
try {
await writeCredentials(cacheTempHome, buildCredentials());
const pluginDir = path.join(cacheTempHome, '.claude', 'plugins', 'claude-hud');
await mkdir(pluginDir, { recursive: true });
const lockFile = path.join(pluginDir, '.usage-cache.lock');
await writeFile(lockFile, 'not-a-timestamp');
const past = new Date(Date.now() - 60_000);
await utimes(lockFile, past, past);
let fetchCalls = 0;
const fetchApi = async () => {
fetchCalls += 1;
return buildApiResult();
};
const result = await getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 1);
assert.ok(result);
assert.equal(result.fiveHour, 25);
assert.equal(existsSync(lockFile), false);
} finally {
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
}
});
test('returns busy for zero-byte lock with recent mtime (active writer)', async () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
delete process.env.CLAUDE_CONFIG_DIR;
try {
await writeCredentials(cacheTempHome, buildCredentials());
const pluginDir = path.join(cacheTempHome, '.claude', 'plugins', 'claude-hud');
await mkdir(pluginDir, { recursive: true });
const lockFile = path.join(pluginDir, '.usage-cache.lock');
await writeFile(lockFile, '');
let fetchCalls = 0;
const fetchApi = async () => {
fetchCalls += 1;
return buildApiResult();
};
const result = await getUsage({
homeDir: () => cacheTempHome,
fetchApi,
now: () => 1000,
readKeychain: () => null,
});
assert.equal(fetchCalls, 0);
assert.equal(result, null);
assert.equal(existsSync(lockFile), true);
} finally {
restoreEnvVar('CLAUDE_CONFIG_DIR', originalConfigDir);
}
});
});
describe('parseRetryAfterSeconds', () => {
test('parses numeric Retry-After values', () => {
assert.equal(parseRetryAfterSeconds('120', 0), 120);
});
test('parses HTTP-date Retry-After values', () => {
const nowMs = Date.parse('2026-03-14T00:00:00Z');
assert.equal(
parseRetryAfterSeconds('Sat, 14 Mar 2026 00:02:30 GMT', nowMs),
150,
);
});
test('ignores expired or invalid Retry-After values', () => {
const nowMs = Date.parse('2026-03-14T00:00:00Z');
assert.equal(parseRetryAfterSeconds('Sat, 14 Mar 2026 00:00:00 GMT', nowMs), undefined);
assert.equal(parseRetryAfterSeconds('not-a-date', nowMs), undefined);
});
});
describe('getUsageApiTimeoutMs', () => {
test('returns default timeout when env is unset', () => {
assert.equal(getUsageApiTimeoutMs({}), 15000);
});
test('returns env timeout when value is a positive integer', () => {
assert.equal(getUsageApiTimeoutMs({ CLAUDE_HUD_USAGE_TIMEOUT_MS: '20000' }), 20000);
});
test('returns default timeout for invalid env values', () => {
assert.equal(getUsageApiTimeoutMs({ CLAUDE_HUD_USAGE_TIMEOUT_MS: '0' }), 15000);
assert.equal(getUsageApiTimeoutMs({ CLAUDE_HUD_USAGE_TIMEOUT_MS: '-1' }), 15000);
assert.equal(getUsageApiTimeoutMs({ CLAUDE_HUD_USAGE_TIMEOUT_MS: 'abc' }), 15000);
});
});
describe('isNoProxy', () => {
test('returns false when NO_PROXY is unset', () => {
assert.equal(isNoProxy('api.anthropic.com', {}), false);
});
test('matches exact host and domain suffix patterns', () => {
assert.equal(isNoProxy('api.anthropic.com', { NO_PROXY: 'api.anthropic.com' }), true);
assert.equal(isNoProxy('api.anthropic.com', { NO_PROXY: '.anthropic.com' }), true);
assert.equal(isNoProxy('anthropic.com', { NO_PROXY: '.anthropic.com' }), false);
assert.equal(isNoProxy('api.anthropic.com', { NO_PROXY: 'anthropic.com' }), true);
});
test('supports wildcard and lowercase no_proxy', () => {
assert.equal(isNoProxy('api.anthropic.com', { NO_PROXY: '*' }), true);
assert.equal(isNoProxy('api.anthropic.com', { no_proxy: 'api.anthropic.com' }), true);
});
});
describe('getProxyUrl', () => {
test('prefers HTTPS_PROXY and falls back through ALL_PROXY then HTTP_PROXY', () => {
const fromHttps = getProxyUrl('api.anthropic.com', {
HTTPS_PROXY: 'http://proxy-https.local:8443',
HTTP_PROXY: 'http://proxy-http.local:8080',
});
assert.equal(fromHttps?.hostname, 'proxy-https.local');
const fromAll = getProxyUrl('api.anthropic.com', {
ALL_PROXY: 'http://proxy-all.local:8888',
HTTP_PROXY: 'http://proxy-http.local:8080',
});
assert.equal(fromAll?.hostname, 'proxy-all.local');
const fromHttp = getProxyUrl('api.anthropic.com', {
HTTP_PROXY: 'http://proxy-http.local:8080',
});
assert.equal(fromHttp?.hostname, 'proxy-http.local');
});
test('returns null when NO_PROXY matches or proxy URL is invalid', () => {
assert.equal(getProxyUrl('api.anthropic.com', {
HTTPS_PROXY: 'http://proxy.local:8080',
NO_PROXY: 'api.anthropic.com',
}), null);
assert.equal(getProxyUrl('api.anthropic.com', {
HTTPS_PROXY: 'not a url',
}), null);
});
});
describe('isLimitReached', () => {
test('returns true when fiveHour is 100', async () => {
// Import from types since isLimitReached is exported there
const { isLimitReached } = await import('../dist/types.js');
const data = {
planName: 'Pro',
fiveHour: 100,
sevenDay: 50,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
assert.equal(isLimitReached(data), true);
});
test('returns true when sevenDay is 100', async () => {
const { isLimitReached } = await import('../dist/types.js');
const data = {
planName: 'Pro',
fiveHour: 50,
sevenDay: 100,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
assert.equal(isLimitReached(data), true);
});
test('returns false when both are below 100', async () => {
const { isLimitReached } = await import('../dist/types.js');
const data = {
planName: 'Pro',
fiveHour: 50,
sevenDay: 50,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
assert.equal(isLimitReached(data), false);
});
test('handles null values correctly', async () => {
const { isLimitReached } = await import('../dist/types.js');
const data = {
planName: 'Pro',
fiveHour: null,
sevenDay: null,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
// null !== 100, so should return false
assert.equal(isLimitReached(data), false);
});
test('returns true when sevenDay is 100 but fiveHour is null', async () => {
const { isLimitReached } = await import('../dist/types.js');
const data = {
planName: 'Pro',
fiveHour: null,
sevenDay: 100,
fiveHourResetAt: null,
sevenDayResetAt: null,
};
assert.equal(isLimitReached(data), true);
});
});
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}