Full Code of jarrodwatts/claude-hud for AI

main 26e38decd762 cached
70 files
382.0 KB
102.7k tokens
256 symbols
2 requests
Download .txt
Showing preview only (402K chars total). Download the full file or copy to clipboard to get everything.
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.

<agent_workflow>

### 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.

</agent_workflow>

---

## Reference

Technical documentation for agents who need to understand, modify, or debug Claude HUD.

<plugin>
  <name>Claude HUD</name>
  <description>Real-time statusline showing context usage, active tools, running agents, and todo progress. Always visible below your input, zero config required.</description>
  <repository>github.com/jarrodwatts/claude-hud</repository>
  <license>MIT</license>
</plugin>

<requirements>
  <runtime>Node.js 18+ or Bun</runtime>
  <claude_code>v1.0.80 or later</claude_code>
  <build>TypeScript 5, ES2022 target, NodeNext modules</build>
</requirements>

<architecture>
  <overview>
    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.
  </overview>

  <data_flow>
    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
  </data_flow>

  <data_sources>
    <stdin_json description="Native accurate data from Claude Code">
      <field path="model.display_name">Current model name (Opus, Sonnet, Haiku)</field>
      <field path="context_window.current_usage.input_tokens">Current token count</field>
      <field path="context_window.context_window_size">Maximum context size</field>
      <field path="transcript_path">Path to session transcript JSONL file</field>
      <field path="cwd">Current working directory</field>
    </stdin_json>

    <transcript_jsonl description="Parsed from transcript file">
      <item>tool_use blocks → tool name, target file, start time</item>
      <item>tool_result blocks → completion status, duration</item>
      <item>Running tools = tool_use without matching tool_result</item>
      <item>TodoWrite calls → current todo list</item>
      <item>Task calls → agent type, model, description</item>
    </transcript_jsonl>

    <config_files description="Read from Claude configuration">
      <item>~/.claude/settings.json → mcpServers count, hooks count</item>
      <item>CLAUDE.md files in cwd and ancestors → rules count</item>
      <item>.mcp.json files → additional MCP count</item>
    </config_files>
  </data_sources>
</architecture>

<file_structure>
  <directory name="src">
    <file name="index.ts" purpose="Entry point, orchestrates data flow">
      Reads stdin, parses transcript, counts configs, calls render.
      Exports main() for testing with dependency injection.
    </file>
    <file name="stdin.ts" purpose="Parse JSON from stdin">
      Reads and validates Claude Code's JSON input.
      Returns StdinData with model, context, transcript_path.
    </file>
    <file name="transcript.ts" purpose="Parse transcript JSONL">
      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.
    </file>
    <file name="config-reader.ts" purpose="Count configuration items">
      Counts CLAUDE.md files, rules, MCP servers, and hooks.
      Searches cwd, ~/.claude/, and project .claude/ directories.
    </file>
    <file name="config.ts" purpose="Load and validate user configuration">
      Reads config.json from ~/.claude/plugins/claude-hud/.
      Validates and merges user settings with defaults.
      Exports HudConfig interface and loadConfig function.
    </file>
    <file name="git.ts" purpose="Git repository status">
      Gets branch name, dirty state, and ahead/behind counts.
      Uses execFile with array args for safe command execution.
    </file>
    <file name="usage-api.ts" purpose="Fetch usage from Anthropic API">
      Reads OAuth credentials from ~/.claude/.credentials.json.
      Calls api.anthropic.com/api/oauth/usage endpoint (opt-in).
      Caches results (60s success, 15s failure).
    </file>
    <file name="types.ts" purpose="TypeScript interfaces">
      StdinData, ToolEntry, AgentEntry, TodoItem, TranscriptData, RenderContext.
    </file>
  </directory>

  <directory name="src/render">
    <file name="index.ts" purpose="Main render coordinator">
      Calls each line renderer and outputs to stdout.
      Conditionally shows lines based on data presence.
    </file>
    <file name="session-line.ts" purpose="Line 1: Session info">
      Renders: [Model | Plan] █████░░░░░ 45% | project git:(branch) | 2 CLAUDE.md | 5h: 25% | ⏱️ 5m
      Context bar colors: green (&lt;70%), yellow (70-85%), red (&gt;85%).
    </file>
    <file name="tools-line.ts" purpose="Line 2: Tool activity">
      Renders: ◐ Edit: auth.ts | ✓ Read ×3 | ✓ Grep ×2
      Shows running tools with spinner, completed tools aggregated.
    </file>
    <file name="agents-line.ts" purpose="Line 3: Agent status">
      Renders: ◐ explore [haiku]: Finding auth code (2m 15s)
      Shows agent type, model, description, elapsed time.
    </file>
    <file name="todos-line.ts" purpose="Line 4: Todo progress">
      Renders: ▸ Fix authentication bug (2/5)
      Shows current in_progress task and completion count.
    </file>
    <file name="colors.ts" purpose="ANSI color helpers">
      Functions: green(), yellow(), red(), dim(), bold(), reset().
      Used for colorizing output based on status/thresholds.
    </file>
  </directory>
</file_structure>

<output_format>
  <line number="1" name="session" always_shown="true">
    [Model | Plan] █████░░░░░ 45% | project git:(branch) | 2 CLAUDE.md | 5h: 25% | ⏱️ 5m
  </line>
  <line number="2" name="tools" shown_if="any tools used">
    ◐ Edit: auth.ts | ✓ Read ×3 | ✓ Grep ×2
  </line>
  <line number="3" name="agents" shown_if="agents active">
    ◐ explore [haiku]: Finding auth code (2m 15s)
  </line>
  <line number="4" name="todos" shown_if="todos exist">
    ▸ Fix authentication bug (2/5)
  </line>
</output_format>

<context_thresholds>
  <threshold range="0-70%" color="green" meaning="Healthy" />
  <threshold range="70-85%" color="yellow" meaning="Warning" />
  <threshold range="85%+" color="red" meaning="Critical, shows token breakdown" />
</context_thresholds>

<plugin_configuration>
  <manifest>.claude-plugin/plugin.json</manifest>
  <manifest_content>
    {
      "name": "claude-hud",
      "description": "Real-time statusline HUD for Claude Code",
      "version": "0.0.1",
      "author": { "name": "Jarrod Watts", "url": "https://github.com/jarrodwatts" }
    }
  </manifest_content>
  <note>The plugin.json contains metadata only. statusLine is NOT a valid plugin.json field.</note>

  <statusline_config>
    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.
  </statusline_config>
</plugin_configuration>

<development>
  <setup>
    git clone https://github.com/jarrodwatts/claude-hud
    cd claude-hud
    npm ci
    npm run build
  </setup>

  <test_commands>
    npm test                    # Run all tests
    npm run build               # Compile TypeScript to dist/
  </test_commands>

  <manual_testing>
    # 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
  </manual_testing>
</development>

<customization>
  <extending description="How to add new features">
    <step>Add new data extraction in transcript.ts or stdin.ts</step>
    <step>Add new interface fields in types.ts</step>
    <step>Create new render file in src/render/ or modify existing</step>
    <step>Update src/render/index.ts to include new line</step>
    <step>Run npm run build and test</step>
  </extending>

  <modifying_thresholds>
    Edit src/render/session-line.ts to change context threshold values.
    Look for the percentage checks that determine color coding.
  </modifying_thresholds>

  <adding_new_line>
    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
  </adding_new_line>
</customization>

<troubleshooting>
  <issue name="Statusline not appearing">
    <cause>Plugin not installed or statusLine not configured</cause>
    <solution>Run: /plugin marketplace add jarrodwatts/claude-hud</solution>
    <solution>Run: /plugin install claude-hud</solution>
    <solution>Run: /claude-hud:setup</solution>
    <solution>Ensure Claude Code is v1.0.80 or later</solution>
  </issue>

  <issue name="Shows [claude-hud] Initializing...">
    <cause>No stdin data received (normal on first invocation)</cause>
    <solution>This is expected briefly on startup, should resolve automatically</solution>
  </issue>

  <issue name="Context percentage seems wrong">
    <cause>Data comes directly from Claude Code - it's accurate</cause>
    <solution>The percentage is (input_tokens / context_window_size) * 100</solution>
  </issue>

  <issue name="Tools/agents not showing">
    <cause>No tools used yet or transcript not being parsed</cause>
    <solution>Lines only appear when there's data to show</solution>
  </issue>

  <issue name="Build errors">
    <solution>Ensure Node.js 18+ installed</solution>
    <solution>Run npm ci to reinstall dependencies</solution>
    <solution>Delete dist/ and node_modules/, then npm ci && npm run build</solution>
  </issue>
</troubleshooting>

<key_interfaces>
  <interface name="StdinData">
    transcript_path?: string
    cwd?: string
    model?: { id?: string, display_name?: string }
    context_window?: { context_window_size?: number, current_usage?: { input_tokens?: number } }
  </interface>

  <interface name="ToolEntry">
    id: string
    name: string
    target?: string
    status: 'running' | 'completed' | 'error'
    startTime: Date
    endTime?: Date
  </interface>

  <interface name="AgentEntry">
    id: string
    type: string
    model?: string
    description?: string
    status: 'running' | 'completed'
    startTime: Date
    endTime?: Date
  </interface>

  <interface name="TodoItem">
    content: string
    status: 'pending' | 'in_progress' | 'completed'
  </interface>

  <interface name="RenderContext">
    stdin: StdinData
    transcript: TranscriptData
    claudeMdCount: number
    rulesCount: number
    mcpCount: number
    hooksCount: number
    sessionDuration: string
  </interface>
</key_interfaces>

<task_instructions>
  <install>
    Follow the agent_workflow steps above.
  </install>

  <modify>
    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
  </modify>

  <debug>
    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
  </debug>

  <understand>
    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
  </understand>
</task_instructions>


================================================
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://img.shields.io/github/license/jarrodwatts/claude-hud?v=2)](LICENSE)
[![Stars](https://img.shields.io/github/stars/jarrodwatts/claude-hud)](https://github.com/jarrodwatts/claude-hud/stargazers)

![Claude HUD in action](claude-hud-preview-5-2.png)

## 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**

<details>
<summary><strong>⚠️ Linux users: Click here first</strong></summary>

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).

</details>

```
/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

[![Star History Chart](https://api.star-history.com/svg?repos=jarrodwatts/claude-hud&type=Date)](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: "<user's text>"` — 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<string> {
  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<string> {
  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<ConfigCounts> {
  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<string>();
  const projectMcpServers = new Set<string>();

  // === 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<HudElement>(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<HudElement>();
  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<string, unknown>;
}

function migrateConfig(userConfig: Partial<HudConfig> & LegacyConfig): Partial<HudConfig> {
  const migrated = { ...userConfig } as Partial<HudConfig> & 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<string, unknown>;
      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>): 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<HudConfig> {
  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<HudConfig>;
    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<string | null> {
  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<string | null> {
  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<GitStatus | null> {
  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<MainDeps> = {}): Promise<void> {
  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<HudColorName, string> = {
  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<HudColorOverrides>): string {
  return colorize(text, resolveAnsi(colors?.warning, YELLOW));
}

export function critical(text: string, colors?: Partial<HudColorOverrides>): string {
  return colorize(text, resolveAnsi(colors?.critical, RED));
}

export function getContextColor(percent: number, colors?: Partial<HudColorOverrides>): 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<HudColorOverrides>): 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<HudColorOverrides>): 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<HudColorOverrides>): 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<HudElement>(['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<HudElement>();
  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<string, number>();
  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<SpeedTrackerDeps> = {}): 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<StdinData | null> {
  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<string, unknown>;
  tool_use_id?: string;
  is_error?: boolean;
}

export async function parseTranscript(transcriptPath: string): Promise<TranscriptData> {
  const result: TranscriptData = {
    tools: [],
    agents: [],
    todos: [],
  };

  if (!transcriptPath || !fs.existsSync(transcriptPath)) {
    return result;
  }

  const toolMap = new Map<string, ToolEntry>();
  const agentMap = new Map<string, AgentEntry>();
  let latestTodos: TodoItem[] = [];
  const taskIdToIndex = new Map<string, number>();
  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<string, ToolEntry>,
  agentMap: Map<string, AgentEntry>,
  taskIdToIndex: Map<string, number>,
  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<string, unknown>;
        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<string, unknown>;
        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<string, unknown>;
        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, unknown>): 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<string, number>,
  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<UsageData | null> {
  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<UsageApiResult>;
  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<UsageApiDeps> = {}): Promise<UsageData | null> {
  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?.s
Download .txt
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
Download .txt
SYMBOL INDEX (256 symbols across 31 files)

FILE: src/claude-config-dir.ts
  function expandHomeDirPrefix (line 3) | function expandHomeDirPrefix(inputPath: string, homeDir: string): string {
  function getClaudeConfigDir (line 13) | function getClaudeConfigDir(homeDir: string): string {
  function getClaudeConfigJsonPath (line 21) | function getClaudeConfigJsonPath(homeDir: string): string {
  function getHudPluginDir (line 25) | function getHudPluginDir(homeDir: string): string {

FILE: src/config-reader.ts
  type ConfigCounts (line 9) | interface ConfigCounts {
  type DisabledMcpKey (line 17) | type DisabledMcpKey = 'disabledMcpServers' | 'disabledMcpjsonServers';
  function getMcpServerNames (line 19) | function getMcpServerNames(filePath: string): Set<string> {
  function getDisabledMcpServers (line 33) | function getDisabledMcpServers(filePath: string, key: DisabledMcpKey): S...
  function countMcpServersInFile (line 51) | function countMcpServersInFile(filePath: string, excludeFrom?: string): ...
  function countHooksInFile (line 62) | function countHooksInFile(filePath: string): number {
  function countRulesInDir (line 76) | function countRulesInDir(rulesDir: string): number {
  function normalizePathForComparison (line 95) | function normalizePathForComparison(inputPath: string): string {
  function pathsReferToSameLocation (line 104) | function pathsReferToSameLocation(pathA: string, pathB: string): boolean {
  function countConfigs (line 122) | async function countConfigs(cwd?: string): Promise<ConfigCounts> {

FILE: src/config.ts
  type LineLayoutType (line 6) | type LineLayoutType = 'compact' | 'expanded';
  type AutocompactBufferMode (line 8) | type AutocompactBufferMode = 'enabled' | 'disabled';
  type ContextValueMode (line 9) | type ContextValueMode = 'percent' | 'tokens' | 'remaining';
  type HudElement (line 10) | type HudElement = 'project' | 'context' | 'usage' | 'environment' | 'too...
  type HudColorName (line 11) | type HudColorName =
  type HudColorValue (line 21) | type HudColorValue = HudColorName | number | string;
  type HudColorOverrides (line 23) | interface HudColorOverrides {
  constant DEFAULT_ELEMENT_ORDER (line 31) | const DEFAULT_ELEMENT_ORDER: HudElement[] = [
  constant KNOWN_ELEMENTS (line 41) | const KNOWN_ELEMENTS = new Set<HudElement>(DEFAULT_ELEMENT_ORDER);
  type HudConfig (line 43) | interface HudConfig {
  constant DEFAULT_CONFIG (line 82) | const DEFAULT_CONFIG: HudConfig = {
  function getConfigPath (line 127) | function getConfigPath(): string {
  function validatePathLevels (line 132) | function validatePathLevels(value: unknown): value is 1 | 2 | 3 {
  function validateLineLayout (line 136) | function validateLineLayout(value: unknown): value is LineLayoutType {
  function validateAutocompactBuffer (line 140) | function validateAutocompactBuffer(value: unknown): value is Autocompact...
  function validateContextValue (line 144) | function validateContextValue(value: unknown): value is ContextValueMode {
  function validateColorName (line 148) | function validateColorName(value: unknown): value is HudColorName {
  constant HEX_COLOR_PATTERN (line 158) | const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
  function validateColorValue (line 160) | function validateColorValue(value: unknown): value is HudColorValue {
  function validateElementOrder (line 167) | function validateElementOrder(value: unknown): HudElement[] {
  type LegacyConfig (line 192) | interface LegacyConfig {
  function migrateConfig (line 196) | function migrateConfig(userConfig: Partial<HudConfig> & LegacyConfig): P...
  function validateThreshold (line 222) | function validateThreshold(value: unknown, max = 100): number {
  function validatePositiveInt (line 227) | function validatePositiveInt(value: unknown, defaultValue: number): numb...
  function mergeConfig (line 232) | function mergeConfig(userConfig: Partial<HudConfig>): HudConfig {
  function loadConfig (line 350) | async function loadConfig(): Promise<HudConfig> {

FILE: src/constants.ts
  constant AUTOCOMPACT_BUFFER_PERCENT (line 10) | const AUTOCOMPACT_BUFFER_PERCENT = 0.165;

FILE: src/debug.ts
  constant DEBUG (line 4) | const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.D...
  function createDebug (line 10) | function createDebug(namespace: string) {

FILE: src/extra-cmd.ts
  constant MAX_BUFFER (line 6) | const MAX_BUFFER = 10 * 1024;
  constant MAX_LABEL_LENGTH (line 7) | const MAX_LABEL_LENGTH = 50;
  constant TIMEOUT_MS (line 8) | const TIMEOUT_MS = 3000;
  function debug (line 12) | function debug(message: string): void {
  type ExtraLabel (line 18) | interface ExtraLabel {
  function sanitize (line 26) | function sanitize(input: string): string {
  function parseExtraCmdArg (line 39) | function parseExtraCmdArg(argv: string[] = process.argv): string | null {
  function runExtraCmd (line 79) | async function runExtraCmd(cmd: string, timeout: number = TIMEOUT_MS): P...

FILE: src/git.ts
  type FileStats (line 6) | interface FileStats {
  type GitStatus (line 13) | interface GitStatus {
  function getGitBranch (line 21) | async function getGitBranch(cwd?: string): Promise<string | null> {
  function getGitStatus (line 36) | async function getGitStatus(cwd?: string): Promise<GitStatus | null> {
  function parseFileStats (line 95) | function parseFileStats(porcelainOutput: string): FileStats {

FILE: src/index.ts
  type MainDeps (line 13) | type MainDeps = {
  function main (line 27) | async function main(overrides: Partial<MainDeps> = {}): Promise<void> {
  function formatSessionDuration (line 101) | function formatSessionDuration(sessionStart?: Date, now: () => number = ...

FILE: src/render/agents-line.ts
  function renderAgentsLine (line 4) | function renderAgentsLine(ctx: RenderContext): string | null {
  function formatAgent (line 27) | function formatAgent(agent: AgentEntry): string {
  function truncateDesc (line 37) | function truncateDesc(desc: string, maxLen: number = 40): string {
  function formatElapsed (line 42) | function formatElapsed(agent: AgentEntry): string {

FILE: src/render/colors.ts
  constant RESET (line 3) | const RESET = '\x1b[0m';
  constant DIM (line 5) | const DIM = '\x1b[2m';
  constant RED (line 6) | const RED = '\x1b[31m';
  constant GREEN (line 7) | const GREEN = '\x1b[32m';
  constant YELLOW (line 8) | const YELLOW = '\x1b[33m';
  constant MAGENTA (line 9) | const MAGENTA = '\x1b[35m';
  constant CYAN (line 10) | const CYAN = '\x1b[36m';
  constant BRIGHT_BLUE (line 11) | const BRIGHT_BLUE = '\x1b[94m';
  constant BRIGHT_MAGENTA (line 12) | const BRIGHT_MAGENTA = '\x1b[95m';
  constant CLAUDE_ORANGE (line 13) | const CLAUDE_ORANGE = '\x1b[38;5;208m';
  constant ANSI_BY_NAME (line 15) | const ANSI_BY_NAME: Record<HudColorName, string> = {
  function hexToAnsi (line 26) | function hexToAnsi(hex: string): string {
  function resolveAnsi (line 37) | function resolveAnsi(value: HudColorValue | undefined, fallback: string)...
  function colorize (line 50) | function colorize(text: string, color: string): string {
  function green (line 54) | function green(text: string): string {
  function yellow (line 58) | function yellow(text: string): string {
  function red (line 62) | function red(text: string): string {
  function cyan (line 66) | function cyan(text: string): string {
  function magenta (line 70) | function magenta(text: string): string {
  function dim (line 74) | function dim(text: string): string {
  function claudeOrange (line 78) | function claudeOrange(text: string): string {
  function warning (line 82) | function warning(text: string, colors?: Partial<HudColorOverrides>): str...
  function critical (line 86) | function critical(text: string, colors?: Partial<HudColorOverrides>): st...
  function getContextColor (line 90) | function getContextColor(percent: number, colors?: Partial<HudColorOverr...
  function getQuotaColor (line 96) | function getQuotaColor(percent: number, colors?: Partial<HudColorOverrid...
  function quotaBar (line 102) | function quotaBar(percent: number, width: number = 10, colors?: Partial<...
  function coloredBar (line 111) | function coloredBar(percent: number, width: number = 10, colors?: Partia...

FILE: src/render/index.ts
  constant ANSI_ESCAPE_PATTERN (line 17) | const ANSI_ESCAPE_PATTERN = /^\x1b\[[0-9;]*m/;
  constant ANSI_ESCAPE_GLOBAL (line 18) | const ANSI_ESCAPE_GLOBAL = /\x1b\[[0-9;]*m/g;
  constant GRAPHEME_SEGMENTER (line 19) | const GRAPHEME_SEGMENTER = typeof Intl.Segmenter === 'function'
  function stripAnsi (line 23) | function stripAnsi(str: string): string {
  function getTerminalWidth (line 27) | function getTerminalWidth(): number | null {
  function splitAnsiTokens (line 48) | function splitAnsiTokens(str: string): Array<{ type: 'ansi' | 'text'; va...
  function segmentGraphemes (line 75) | function segmentGraphemes(text: string): string[] {
  function isWideCodePoint (line 85) | function isWideCodePoint(codePoint: number): boolean {
  function graphemeWidth (line 102) | function graphemeWidth(grapheme: string): number {
  function visualLength (line 130) | function visualLength(str: string): number {
  function sliceVisible (line 143) | function sliceVisible(str: string, maxVisible: number): string {
  function truncateToWidth (line 187) | function truncateToWidth(str: string, maxWidth: number): string {
  function splitLineBySeparators (line 197) | function splitLineBySeparators(line: string): { segments: string[]; sepa...
  function splitWrapParts (line 229) | function splitWrapParts(line: string): Array<{ separator: string; segmen...
  function wrapLineToWidth (line 272) | function wrapLineToWidth(line: string, maxWidth: number): string[] {
  function makeSeparator (line 303) | function makeSeparator(length: number): string {
  constant ACTIVITY_ELEMENTS (line 307) | const ACTIVITY_ELEMENTS = new Set<HudElement>(['tools', 'agents', 'todos...
  function collectActivityLines (line 309) | function collectActivityLines(ctx: RenderContext): string[] {
  function renderElementLine (line 337) | function renderElementLine(ctx: RenderContext, element: HudElement): str...
  function renderCompact (line 358) | function renderCompact(ctx: RenderContext): string[] {
  function renderExpanded (line 369) | function renderExpanded(ctx: RenderContext): Array<{ line: string; isAct...
  function render (line 418) | function render(ctx: RenderContext): void {

FILE: src/render/lines/environment.ts
  function renderEnvironmentLine (line 4) | function renderEnvironmentLine(ctx: RenderContext): string | null {

FILE: src/render/lines/identity.ts
  constant DEBUG (line 6) | const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.D...
  function renderIdentityLine (line 8) | function renderIdentityLine(ctx: RenderContext): string {
  function formatTokens (line 40) | function formatTokens(n: number): string {
  function formatContextValue (line 50) | function formatContextValue(ctx: RenderContext, percent: number, mode: '...

FILE: src/render/lines/project.ts
  function renderProjectLine (line 6) | function renderProjectLine(ctx: RenderContext): string | null {

FILE: src/render/lines/usage.ts
  function renderUsageLine (line 7) | function renderUsageLine(ctx: RenderContext): string | null {
  function formatUsagePercent (line 78) | function formatUsagePercent(percent: number | null, colors?: RenderConte...
  function formatUsageError (line 86) | function formatUsageError(error?: string): string {
  function formatResetTime (line 93) | function formatResetTime(resetAt: Date | null): string {

FILE: src/render/session-line.ts
  constant DEBUG (line 8) | const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.D...
  function renderSessionLine (line 14) | function renderSessionLine(ctx: RenderContext): string {
  function formatTokens (line 231) | function formatTokens(n: number): string {
  function formatContextValue (line 241) | function formatContextValue(ctx: RenderContext, percent: number, mode: '...
  function formatUsagePercent (line 258) | function formatUsagePercent(percent: number | null, colors?: RenderConte...
  function formatUsageError (line 266) | function formatUsageError(error?: string): string {
  function formatResetTime (line 273) | function formatResetTime(resetAt: Date | null): string {

FILE: src/render/todos-line.ts
  function renderTodosLine (line 4) | function renderTodosLine(ctx: RenderContext): string | null {
  function truncateContent (line 28) | function truncateContent(content: string, maxLen: number = 50): string {

FILE: src/render/tools-line.ts
  function renderToolsLine (line 4) | function renderToolsLine(ctx: RenderContext): string | null {
  function truncatePath (line 42) | function truncatePath(path: string, maxLen: number = 20): string {

FILE: src/speed-tracker.ts
  constant SPEED_WINDOW_MS (line 7) | const SPEED_WINDOW_MS = 2000;
  type SpeedCache (line 9) | interface SpeedCache {
  type SpeedTrackerDeps (line 14) | type SpeedTrackerDeps = {
  function getCachePath (line 24) | function getCachePath(homeDir: string): string {
  function readCache (line 28) | function readCache(homeDir: string): SpeedCache | null {
  function writeCache (line 43) | function writeCache(homeDir: string, cache: SpeedCache): void {
  function getOutputSpeed (line 56) | function getOutputSpeed(stdin: StdinData, overrides: Partial<SpeedTracke...

FILE: src/stdin.ts
  function readStdin (line 4) | async function readStdin(): Promise<StdinData | null> {
  function getTotalTokens (line 26) | function getTotalTokens(stdin: StdinData): number {
  function getNativePercent (line 39) | function getNativePercent(stdin: StdinData): number | null {
  function getContextPercent (line 47) | function getContextPercent(stdin: StdinData): number {
  function getBufferedPercent (line 64) | function getBufferedPercent(stdin: StdinData): number {
  function getModelName (line 91) | function getModelName(stdin: StdinData): string {
  function isBedrockModelId (line 106) | function isBedrockModelId(modelId?: string): boolean {
  function getProviderLabel (line 114) | function getProviderLabel(stdin: StdinData): string | null {
  function normalizeBedrockModelLabel (line 121) | function normalizeBedrockModelLabel(modelId: string): string | null {
  function readNumericVersion (line 157) | function readNumericVersion(tokens: string[], startIndex: number, step: ...

FILE: src/transcript.ts
  type TranscriptLine (line 5) | interface TranscriptLine {
  type ContentBlock (line 15) | interface ContentBlock {
  function parseTranscript (line 24) | async function parseTranscript(transcriptPath: string): Promise<Transcri...
  function processEntry (line 76) | function processEntry(
  function extractTarget (line 173) | function extractTarget(toolName: string, input?: Record<string, unknown>...
  function resolveTaskIndex (line 192) | function resolveTaskIndex(
  function normalizeTaskStatus (line 215) | function normalizeTaskStatus(status: unknown): TodoItem['status'] | null {

FILE: src/types.ts
  type StdinData (line 4) | interface StdinData {
  type ToolEntry (line 25) | interface ToolEntry {
  type AgentEntry (line 34) | interface AgentEntry {
  type TodoItem (line 44) | interface TodoItem {
  type UsageWindow (line 50) | interface UsageWindow {
  type UsageData (line 55) | interface UsageData {
  function isLimitReached (line 66) | function isLimitReached(data: UsageData): boolean {
  type TranscriptData (line 70) | interface TranscriptData {
  type RenderContext (line 78) | interface RenderContext {

FILE: src/usage-api.ts
  constant LEGACY_KEYCHAIN_SERVICE_NAME (line 16) | const LEGACY_KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials';
  type CredentialsFile (line 18) | interface CredentialsFile {
  type UsageApiResponse (line 29) | interface UsageApiResponse {
  type UsageApiResult (line 40) | interface UsageApiResult {
  constant CACHE_TTL_MS (line 48) | const CACHE_TTL_MS = 5 * 60_000;
  constant CACHE_FAILURE_TTL_MS (line 49) | const CACHE_FAILURE_TTL_MS = 15_000;
  constant CACHE_RATE_LIMITED_BASE_MS (line 50) | const CACHE_RATE_LIMITED_BASE_MS = 60_000;
  constant CACHE_RATE_LIMITED_MAX_MS (line 51) | const CACHE_RATE_LIMITED_MAX_MS = 5 * 60_000;
  constant CACHE_LOCK_STALE_MS (line 52) | const CACHE_LOCK_STALE_MS = 30_000;
  constant CACHE_LOCK_WAIT_MS (line 53) | const CACHE_LOCK_WAIT_MS = 2_000;
  constant CACHE_LOCK_POLL_MS (line 54) | const CACHE_LOCK_POLL_MS = 50;
  constant KEYCHAIN_TIMEOUT_MS (line 55) | const KEYCHAIN_TIMEOUT_MS = 3000;
  constant KEYCHAIN_BACKOFF_MS (line 56) | const KEYCHAIN_BACKOFF_MS = 60_000;
  constant USAGE_API_TIMEOUT_MS_DEFAULT (line 57) | const USAGE_API_TIMEOUT_MS_DEFAULT = 15_000;
  constant USAGE_API_USER_AGENT (line 58) | const USAGE_API_USER_AGENT = 'claude-code/2.1';
  function isUsingCustomApiEndpoint (line 64) | function isUsingCustomApiEndpoint(env: NodeJS.ProcessEnv = process.env):...
  type CacheFile (line 79) | interface CacheFile {
  type CacheState (line 90) | interface CacheState {
  type CacheLockStatus (line 96) | type CacheLockStatus = 'acquired' | 'busy' | 'unsupported';
  function getCachePath (line 98) | function getCachePath(homeDir: string): string {
  function getCacheLockPath (line 102) | function getCacheLockPath(homeDir: string): string {
  function hydrateCacheData (line 106) | function hydrateCacheData(data: UsageData): UsageData {
  type CacheTtls (line 118) | type CacheTtls = { cacheTtlMs: number; failureCacheTtlMs: number };
  function getRateLimitedTtlMs (line 120) | function getRateLimitedTtlMs(count: number): number {
  function getRateLimitedRetryUntil (line 125) | function getRateLimitedRetryUntil(cache: CacheFile): number | null {
  function withRateLimitedSyncing (line 141) | function withRateLimitedSyncing(data: UsageData): UsageData {
  function readCacheState (line 148) | function readCacheState(homeDir: string, now: number, ttls: CacheTtls): ...
  function readRateLimitedCount (line 178) | function readRateLimitedCount(homeDir: string): number {
  function readLastGoodData (line 190) | function readLastGoodData(homeDir: string): UsageData | null {
  function readCache (line 202) | function readCache(homeDir: string, now: number, ttls: CacheTtls): Usage...
  type WriteCacheOpts (line 207) | interface WriteCacheOpts {
  function writeCache (line 213) | function writeCache(homeDir: string, data: UsageData, timestamp: number,...
  function readLockTimestamp (line 238) | function readLockTimestamp(lockPath: string): number | null {
  function tryAcquireCacheLock (line 249) | function tryAcquireCacheLock(homeDir: string): CacheLockStatus {
  function releaseCacheLock (line 304) | function releaseCacheLock(homeDir: string): void {
  function waitForFreshCache (line 315) | async function waitForFreshCache(
  type UsageApiDeps (line 339) | type UsageApiDeps = {
  function getUsage (line 364) | async function getUsage(overrides: Partial<UsageApiDeps> = {}): Promise<...
  function getKeychainBackoffPath (line 487) | function getKeychainBackoffPath(homeDir: string): string {
  function isKeychainBackoff (line 495) | function isKeychainBackoff(homeDir: string, now: number): boolean {
  function recordKeychainFailure (line 509) | function recordKeychainFailure(homeDir: string, now: number): void {
  function getKeychainServiceName (line 526) | function getKeychainServiceName(configDir: string, homeDir: string): str...
  function getKeychainServiceNames (line 538) | function getKeychainServiceNames(
  function isMissingKeychainItemError (line 562) | function isMissingKeychainItemError(error: unknown): boolean {
  function resolveKeychainCredentials (line 579) | function resolveKeychainCredentials(
  function getKeychainAccountName (line 634) | function getKeychainAccountName(): string | null {
  function readKeychainCredentials (line 650) | function readKeychainCredentials(now: number, homeDir: string): { access...
  function readFileCredentials (line 706) | function readFileCredentials(homeDir: string, now: number): { accessToke...
  function readFileSubscriptionType (line 723) | function readFileSubscriptionType(homeDir: string): string | null {
  function parseCredentialsData (line 750) | function parseCredentialsData(data: CredentialsFile, now: number): { acc...
  function readCredentials (line 776) | function readCredentials(
  function getPlanName (line 812) | function getPlanName(subscriptionType: string): string | null {
  function parseUtilization (line 824) | function parseUtilization(value: number | undefined): number | null {
  function parseDate (line 831) | function parseDate(dateStr: string | undefined): Date | null {
  function getUsageApiTimeoutMs (line 842) | function getUsageApiTimeoutMs(env: NodeJS.ProcessEnv = process.env): num...
  function isNoProxy (line 854) | function isNoProxy(hostname: string, env: NodeJS.ProcessEnv = process.en...
  function getProxyUrl (line 869) | function getProxyUrl(hostname: string, env: NodeJS.ProcessEnv = process....
  function createProxyTunnelAgent (line 896) | function createProxyTunnelAgent(proxyUrl: URL): https.Agent {
  function fetchUsageApi (line 983) | function fetchUsageApi(accessToken: string): Promise<UsageApiResult> {
  function parseRetryAfterSeconds (line 1053) | function parseRetryAfterSeconds(
  function clearCache (line 1075) | function clearCache(homeDir?: string): void {

FILE: src/utils/terminal.ts
  function getAdaptiveBarWidth (line 3) | function getAdaptiveBarWidth(): number {

FILE: tests/config.test.js
  function restoreEnvVar (line 15) | function restoreEnvVar(name, value) {

FILE: tests/core.test.js
  function restoreEnvVar (line 12) | function restoreEnvVar(name, value) {

FILE: tests/integration.test.js
  function stripAnsi (line 10) | function stripAnsi(text) {
  function skipIfSpawnBlocked (line 17) | function skipIfSpawnBlocked(result, t) {

FILE: tests/render-width.test.js
  function baseContext (line 5) | function baseContext() {
  function stripAnsi (line 54) | function stripAnsi(str) {
  function isWideCodePoint (line 59) | function isWideCodePoint(codePoint) {
  function displayWidth (line 76) | function displayWidth(text) {
  function withColumns (line 85) | function withColumns(stream, columns, fn) {
  function withTerminal (line 99) | function withTerminal(columns, fn) {
  function captureRender (line 103) | function captureRender(ctx) {
  function countContaining (line 115) | function countContaining(lines, needle) {

FILE: tests/render.test.js
  function stripAnsi (line 15) | function stripAnsi(str) {
  function baseContext (line 20) | function baseContext() {
  function captureRenderLines (line 59) | function captureRenderLines(ctx) {
  function withDeterministicSpeedCache (line 71) | async function withDeterministicSpeedCache(fn) {

FILE: tests/speed-tracker.test.js
  function restoreEnvVar (line 9) | function restoreEnvVar(name, value) {
  function createTempHome (line 17) | async function createTempHome() {

FILE: tests/usage-api.test.js
  constant USAGE_API_USER_AGENT (line 23) | let USAGE_API_USER_AGENT;
  function ensureUsageApiDistIsCurrent (line 25) | function ensureUsageApiDistIsCurrent() {
  function createTempHome (line 63) | async function createTempHome() {
  function restoreEnvVar (line 67) | function restoreEnvVar(name, value) {
  function writeCredentialsInConfigDir (line 75) | async function writeCredentialsInConfigDir(configDir, credentials) {
  function writeCredentials (line 81) | async function writeCredentials(homeDir, credentials) {
  function buildCredentials (line 85) | function buildCredentials(overrides = {}) {
  function buildApiResponse (line 96) | function buildApiResponse(overrides = {}) {
  function buildApiResult (line 110) | function buildApiResult(overrides = {}) {
  function buildMissingKeychainError (line 117) | function buildMissingKeychainError() {
Condensed preview — 70 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (408K chars).
[
  {
    "path": ".claude-plugin/marketplace.json",
    "chars": 662,
    "preview": "{\n  \"name\": \"claude-hud\",\n  \"owner\": {\n    \"name\": \"Jarrod Watts\",\n    \"email\": \"jarrodwattsyt@gmail.com\"\n  },\n  \"metada"
  },
  {
    "path": ".claude-plugin/plugin.json",
    "chars": 592,
    "preview": "{\n  \"name\": \"claude-hud\",\n  \"description\": \"Real-time statusline HUD for Claude Code - context health, tool activity, ag"
  },
  {
    "path": ".editorconfig",
    "chars": 188,
    "preview": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\ntrim_"
  },
  {
    "path": ".github/CODEOWNERS",
    "chars": 15,
    "preview": "* @jarrodwatts\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 241,
    "preview": "---\nname: Bug report\nabout: Report a reproducible problem\nlabels: bug\n---\n\n## Summary\n\n## Steps to Reproduce\n\n## Expecte"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 169,
    "preview": "blank_issues_enabled: false\ncontact_links:\n  - name: Security report\n    url: mailto:jarrodwttsyt@gmail.com\n    about: P"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 194,
    "preview": "---\nname: Feature request\nabout: Suggest an idea or enhancement\nlabels: enhancement\n---\n\n## Summary\n\n## Problem to Solve"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 139,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    open-pull"
  },
  {
    "path": ".github/pull_request_template.md",
    "chars": 159,
    "preview": "## Summary\n\n## Testing\n\n- [ ] `npm test`\n- [ ] `npm run test:coverage`\n\n## Checklist\n\n- [ ] Tests updated or not needed\n"
  },
  {
    "path": ".github/workflows/build-dist.yml",
    "chars": 919,
    "preview": "name: Build dist\n\non:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: build-dist\n  cancel-in-progress: false\n\npermis"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 422,
    "preview": "name: CI\n\non:\n  pull_request:\n  push:\n    branches: [main]\n    paths-ignore:\n      - 'dist/**'\n\njobs:\n  test:\n    runs-o"
  },
  {
    "path": ".github/workflows/claude.yml",
    "chars": 1726,
    "preview": "name: Claude Code\n\non:\n  issue_comment:\n    types: [created]\n  pull_request_review_comment:\n    types: [created]\n  issue"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 989,
    "preview": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*.*.*\"\n\npermissions:\n  contents: write\n\njobs:\n  release:\n    runs-on: ubu"
  },
  {
    "path": ".gitignore",
    "chars": 676,
    "preview": "# Dependencies\nnode_modules/\n\n# Build artifacts\n# dist/ is gitignored but exists on main - CI builds and commits it afte"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 9615,
    "preview": "# Changelog\n\nAll notable changes to Claude HUD will be documented in this file.\n\n## [Unreleased]\n\n## [0.0.10] - 2026-03-"
  },
  {
    "path": "CLAUDE.README.md",
    "chars": 13351,
    "preview": "# Claude HUD\n\nReal-time statusline showing context usage, active tools, running agents, and todo progress.\n\n---\n\n## For "
  },
  {
    "path": "CLAUDE.md",
    "chars": 4454,
    "preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code when working with this repository.\n\n## Project Overview\n\nClaude "
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "chars": 1297,
    "preview": "# Code of Conduct\n\n## Our Pledge\n\nWe pledge to make participation in our community a harassment-free experience for ever"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 2113,
    "preview": "# Contributing\n\nThanks for contributing to Claude HUD. This repo is small and fast-moving, so we optimize for clarity an"
  },
  {
    "path": "LICENSE",
    "chars": 1069,
    "preview": "MIT License\n\nCopyright (c) 2026 Jarrod Watts\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "MAINTAINERS.md",
    "chars": 153,
    "preview": "# Maintainers\n\n- Jarrod Watts (https://github.com/jarrodwatts)\n\nIf you are interested in becoming a maintainer, open an "
  },
  {
    "path": "README.md",
    "chars": 10646,
    "preview": "# Claude HUD\n\nA Claude Code plugin that shows what's happening — context usage, active tools, running agents, and todo p"
  },
  {
    "path": "RELEASING.md",
    "chars": 529,
    "preview": "# Releasing\n\nThis project ships as a Claude Code plugin. Releases should include compiled `dist/` output.\n\n## Release Ch"
  },
  {
    "path": "SECURITY.md",
    "chars": 379,
    "preview": "# Security Policy\n\n## Supported Versions\n\nSecurity fixes are applied to the latest release series only.\n\n## Reporting a "
  },
  {
    "path": "SUPPORT.md",
    "chars": 373,
    "preview": "# Support Policy\n\nThis project is maintained on a best-effort basis.\n\n## What We Support\n\n- The latest release\n- Claude "
  },
  {
    "path": "TESTING.md",
    "chars": 2383,
    "preview": "# Testing Strategy\n\nThis project is small, runs in a terminal, and is mostly deterministic. The testing strategy focuses"
  },
  {
    "path": "commands/configure.md",
    "chars": 10319,
    "preview": "---\ndescription: Configure HUD display options (layout, presets, display elements) while preserving advanced manual over"
  },
  {
    "path": "commands/setup.md",
    "chars": 13253,
    "preview": "---\ndescription: Configure claude-hud as your statusline\nallowed-tools: Bash, Read, Edit, AskUserQuestion\n---\n\n**Note**:"
  },
  {
    "path": "package.json",
    "chars": 986,
    "preview": "{\n  \"name\": \"claude-hud\",\n  \"version\": \"0.0.10\",\n  \"description\": \"Real-time statusline HUD for Claude Code\",\n  \"type\": "
  },
  {
    "path": "src/claude-config-dir.ts",
    "chars": 823,
    "preview": "import * as path from 'node:path';\n\nfunction expandHomeDirPrefix(inputPath: string, homeDir: string): string {\n  if (inp"
  },
  {
    "path": "src/config-reader.ts",
    "chars": 7773,
    "preview": "import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport { createDebug } from './debug.j"
  },
  {
    "path": "src/config.ts",
    "chars": 11975,
    "preview": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport { getHudPluginDi"
  },
  {
    "path": "src/constants.ts",
    "chars": 449,
    "preview": "/**\n * Autocompact buffer percentage.\n *\n * NOTE: This value is applied as a percentage of Claude Code's reported\n * con"
  },
  {
    "path": "src/debug.ts",
    "chars": 486,
    "preview": "// Shared debug logging utility\n// Enable via: DEBUG=claude-hud or DEBUG=*\n\nconst DEBUG = process.env.DEBUG?.includes('c"
  },
  {
    "path": "src/extra-cmd.ts",
    "chars": 3551,
    "preview": "import { exec } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst execAsync = promisify(exec);\n\nc"
  },
  {
    "path": "src/git.ts",
    "chars": 3144,
    "preview": "import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst execFileAsync = promisify(e"
  },
  {
    "path": "src/index.ts",
    "chars": 3684,
    "preview": "import { readStdin } from './stdin.js';\nimport { parseTranscript } from './transcript.js';\nimport { render } from './ren"
  },
  {
    "path": "src/render/agents-line.ts",
    "chars": 1646,
    "preview": "import type { RenderContext, AgentEntry } from '../types.js';\nimport { yellow, green, magenta, dim } from './colors.js';"
  },
  {
    "path": "src/render/colors.ts",
    "chars": 3974,
    "preview": "import type { HudColorName, HudColorValue, HudColorOverrides } from '../config.js';\n\nexport const RESET = '\\x1b[0m';\n\nco"
  },
  {
    "path": "src/render/index.ts",
    "chars": 13173,
    "preview": "import type { HudElement } from '../config.js';\nimport { DEFAULT_ELEMENT_ORDER } from '../config.js';\nimport type { Rend"
  },
  {
    "path": "src/render/lines/environment.ts",
    "chars": 920,
    "preview": "import type { RenderContext } from '../../types.js';\nimport { dim } from '../colors.js';\n\nexport function renderEnvironm"
  },
  {
    "path": "src/render/lines/identity.ts",
    "chars": 2503,
    "preview": "import type { RenderContext } from '../../types.js';\nimport { getContextPercent, getBufferedPercent, getTotalTokens } fr"
  },
  {
    "path": "src/render/lines/index.ts",
    "chars": 206,
    "preview": "export { renderIdentityLine } from './identity.js';\nexport { renderProjectLine } from './project.js';\nexport { renderEnv"
  },
  {
    "path": "src/render/lines/project.ts",
    "chars": 3312,
    "preview": "import type { RenderContext } from '../../types.js';\nimport { getModelName, getProviderLabel } from '../../stdin.js';\nim"
  },
  {
    "path": "src/render/lines/usage.ts",
    "chars": 3962,
    "preview": "import type { RenderContext } from '../../types.js';\nimport { isLimitReached } from '../../types.js';\nimport { getProvid"
  },
  {
    "path": "src/render/session-line.ts",
    "chars": 10957,
    "preview": "import type { RenderContext } from '../types.js';\nimport { isLimitReached } from '../types.js';\nimport { getContextPerce"
  },
  {
    "path": "src/render/todos-line.ts",
    "chars": 946,
    "preview": "import type { RenderContext } from '../types.js';\nimport { yellow, green, dim } from './colors.js';\n\nexport function ren"
  },
  {
    "path": "src/render/tools-line.ts",
    "chars": 1669,
    "preview": "import type { RenderContext } from '../types.js';\nimport { yellow, green, cyan, dim } from './colors.js';\n\nexport functi"
  },
  {
    "path": "src/speed-tracker.ts",
    "chars": 2255,
    "preview": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport type { StdinData"
  },
  {
    "path": "src/stdin.ts",
    "chars": 5038,
    "preview": "import type { StdinData } from './types.js';\nimport { AUTOCOMPACT_BUFFER_PERCENT } from './constants.js';\n\nexport async "
  },
  {
    "path": "src/transcript.ts",
    "chars": 6890,
    "preview": "import * as fs from 'fs';\nimport * as readline from 'readline';\nimport type { TranscriptData, ToolEntry, AgentEntry, Tod"
  },
  {
    "path": "src/types.ts",
    "chars": 2311,
    "preview": "import type { HudConfig } from './config.js';\nimport type { GitStatus } from './git.js';\n\nexport interface StdinData {\n "
  },
  {
    "path": "src/usage-api.ts",
    "chars": 34764,
    "preview": "import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as net from 'net';\nimport * a"
  },
  {
    "path": "src/utils/terminal.ts",
    "chars": 556,
    "preview": "// Returns a progress bar width scaled to the current terminal width.\n// Wide (>=100): 10, Medium (60-99): 6, Narrow (<6"
  },
  {
    "path": "tests/config.test.js",
    "chars": 13039,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport {\n  loadConfig,\n  getConfigPath,\n  mer"
  },
  {
    "path": "tests/core.test.js",
    "chars": 31998,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { mkdtemp, rm, writeFile, mkdir } from"
  },
  {
    "path": "tests/extra-cmd.test.js",
    "chars": 6064,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { sanitize, parseExtraCmdArg, runExtra"
  },
  {
    "path": "tests/fixtures/expected/render-basic.txt",
    "chars": 43,
    "preview": "[Opus] │ my-project\nContext ███░░░░░░░ 29%\n"
  },
  {
    "path": "tests/fixtures/transcript-basic.jsonl",
    "chars": 1322,
    "preview": "{\"timestamp\":\"2024-01-01T00:00:00.000Z\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"tool-1\",\"name\":\"Read\",\"input\":{\"f"
  },
  {
    "path": "tests/fixtures/transcript-render.jsonl",
    "chars": 788,
    "preview": "{\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"tool-1\",\"name\":\"Read\",\"input\":{\"file_path\":\"/tmp/example.txt\"}}]}}\n{\"mes"
  },
  {
    "path": "tests/git.test.js",
    "chars": 8701,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { mkdtemp, rm, writeFile } from 'node:"
  },
  {
    "path": "tests/index.test.js",
    "chars": 5943,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { formatSessionDuration, main } from '"
  },
  {
    "path": "tests/integration.test.js",
    "chars": 2865,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { fileURLToPath } from 'node:url';\nimp"
  },
  {
    "path": "tests/render-width.test.js",
    "chars": 9396,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { render } from '../dist/render/index."
  },
  {
    "path": "tests/render.test.js",
    "chars": 47431,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { mkdir, mkdtemp, rm, writeFile } from"
  },
  {
    "path": "tests/speed-tracker.test.js",
    "chars": 3148,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { mkdtemp, rm } from 'node:fs/promises"
  },
  {
    "path": "tests/stdin.test.js",
    "chars": 1085,
    "preview": "import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { readStdin } from '../dist/stdin.js';"
  },
  {
    "path": "tests/terminal.test.js",
    "chars": 2888,
    "preview": "import { test, describe, beforeEach, afterEach } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { get"
  },
  {
    "path": "tests/usage-api.test.js",
    "chars": 51091,
    "preview": "import { test, describe, before, beforeEach, afterEach } from 'node:test';\nimport assert from 'node:assert/strict';\nimpo"
  },
  {
    "path": "tsconfig.json",
    "chars": 427,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"outD"
  }
]

About this extraction

This page contains the full source code of the jarrodwatts/claude-hud GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 70 files (382.0 KB), approximately 102.7k tokens, and a symbol index with 256 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!