Claude HUD

Real-Time Statusline Plugin for Claude Code
Architecture, Implementation & Internals
jarrodwatts/claude-hud · 10.2k stars · TypeScript · MIT License

What Is Claude HUD?

A Claude Code plugin that renders a real-time statusline below your input prompt, showing context health, tool activity, agents, todos, and usage limits.

[Opus | Max] │ my-project git:(main*) ↑2
Context █████░░░░░ 45% │ Usage ██░░░░░░░░ 25% (1h 30m / 5h)
◐ Read src/index.ts | ✓ Grep ×12 | ✓ Edit ×3
⊕ Agent "Fix lint errors" | Todo 3/5

All information is native — read directly from Claude Code's stdin data and transcript JSONL, not estimated or scraped.

Plugin Architecture Overview

┌─────────────────────────────────────────────────────────┐ │ Claude Code Runtime │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ stdin │───▶│ claude-hud │───▶│ stdout │ │ │ │ JSON data │ │ (Node.js) │ │ status text │ │ │ └──────────┘ └──────┬───────┘ └──────────────┘ │ │ │ │ │ ┌────────────┼────────────┐ │ │ ▼ ▼ ▼ │ │ ┌────────────┐ ┌────────┐ ┌──────────────┐ │ │ │ transcript │ │ git │ │ usage-api │ │ │ │ JSONL parse │ │ status │ │ OAuth + cache │ │ │ └────────────┘ └────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────┘

The plugin runs as a separate Node.js process spawned by Claude Code's statusline API. Each render cycle (~300ms) is a fresh process invocation.

Project Structure

Source Layout

src/ ├── index.ts # Entry point, main() ├── stdin.ts # Parse stdin JSON ├── transcript.ts # JSONL transcript parser ├── config.ts # Config load/merge/migrate ├── config-reader.ts # File I/O for config ├── constants.ts # Autocompact buffer % ├── git.ts # Git status integration ├── usage-api.ts # OAuth + rate limit API ├── speed-tracker.ts # Token throughput ├── types.ts # All TypeScript types ├── debug.ts # Debug logging ├── extra-cmd.ts # CLI extra commands └── render/ ├── index.ts # Main render orchestrator ├── session-line.ts # Context + usage bars ├── tools-line.ts # Tool activity display ├── agents-line.ts # Agent tracking ├── todos-line.ts # Todo progress └── colors.ts # ANSI color system

Plugin Config

.claude-plugin/ ├── plugin.json # Plugin metadata └── marketplace.json # Registry info commands/ ├── setup.md # /claude-hud:setup └── configure.md # /claude-hud:configure

Tech Stack

  • TypeScript 5.x, ES modules
  • Node.js 18+ (or Bun)
  • Zero runtime dependencies
  • tsc build, Node test runner

Data Flow: The main() Pipeline

The entry point uses dependency injection for testability. Each render cycle runs the full pipeline:

// Simplified main() flow async function main(overrides?) { // 1. Read stdin JSON from Claude Code const stdin = await readStdin(); // StdinData | null // 2. Parse the transcript JSONL file const transcript = await parseTranscript(stdin.transcript_path); // 3. Count config files (claude.md, rules, MCP, hooks) const configCounts = countConfigFiles(stdin.cwd); // 4. Load user config (~/.claude/plugins/claude-hud/config.json) const config = await loadConfig(); // 5. Conditionally fetch git status const git = config.showGit ? await getGitStatus(stdin.cwd) : null; // 6. Conditionally fetch usage data (OAuth) const usage = config.showUsage ? await getUsageData() : null; // 7. Assemble RenderContext and render render({ stdin, transcript, configCounts, config, git, usage }); }

Step 1: stdin Parsing

Claude Code pipes a JSON object to the plugin's stdin on each render cycle:

StdinData Shape

interface StdinData { transcript_path: string; cwd: string; model: string; context_window: { used: number; total: number; cache_read: number; cache_creation: number; }; }

readStdin() Logic

async function readStdin() { // Skip if running in a TTY if (process.stdin.isTTY) return null; // Collect all chunks const chunks = []; for await (const c of process.stdin) chunks.push(c); // Parse as JSON return JSON.parse( chunks.join('') ); }

If stdin is a TTY (manual run), it triggers setup verification mode with platform-specific guidance.

Step 2: Transcript JSONL Parsing

The transcript file is a line-delimited JSON log of the entire session. The parser uses readline.createInterface() to stream it line by line:

// Each line is a JSON object. The parser extracts three categories: // 1. TOOLS — tool_use blocks { "type": "tool_use", "name": "Read", "id": "abc", "input": { "file_path": "src/index.ts" } } // 2. AGENTS — tool_use where name === "Task" { "type": "tool_use", "name": "Task", "input": { "description": "Fix lint errors", "model": "sonnet" } } // 3. TODOS — TodoWrite, TaskCreate, TaskUpdate operations { "type": "tool_use", "name": "TodoWrite", "input": { "todos": [{ "content": "...", "status": "pending" }] } }

Tool results update status to completed or error based on the is_error flag. Malformed lines are silently skipped.

Tool Activity: What Gets Tracked

ToolEntry Structure

interface ToolEntry { id: string; name: string; // Read, Edit, Grep... target: string; // file path or pattern status: 'running' | 'completed' | 'error'; startedAt: number; endedAt?: number; }

Target Extraction

  • Read/Edit: input.file_path
  • Grep: input.pattern
  • Bash: first 30 chars of command

Rendered Output

◐ Read .../index.ts | ✓ Grep ×12 | ✓ Edit ×3

Display rules:

  • Up to 2 running tools (most recent)
  • Up to 4 completed tools (by frequency)
  • Paths truncated to 20 chars with .../filename
  • Yellow spinner for running, green check for done

Agent & Todo Tracking

AgentEntry

interface AgentEntry { type: string; model: string; description: string; status: 'running' | 'completed'; startedAt: number; endedAt?: number; }

Sub-agents are detected when tool name is "Task". Their lifecycle is tracked via start/end timestamps from tool_result blocks.

TodoItem & Normalization

interface TodoItem { content: string; status: 'pending' | 'in_progress' | 'completed'; } // Three todo operations: // TodoWrite → replaces entire list // TaskCreate → appends new item // TaskUpdate → updates by ID

normalizeTaskStatus() maps any string to the three canonical states.

Git Integration

GitStatus Interface

interface GitStatus { branch: string; dirty: boolean; ahead: number; behind: number; files: { modified: number; added: number; deleted: number; untracked: number; }; }

Implementation Details

  • Uses execFile (not exec) to avoid shell injection
  • 1-second timeout per git command
  • --no-optional-locks flag prevents lock contention
  • Parses git status --porcelain output
  • Renamed/copied files counted as modifications
  • Returns null on any failure (graceful degradation)

Display Format

my-project git:(main*) ↑2 ↓1 !3 +1 ✘1 ?2

Usage API & OAuth Flow

For Pro/Max/Team users, the HUD shows rate limit consumption via Anthropic's OAuth endpoint.

Credential Resolution (priority order): 1. macOS Keychain → /usr/bin/security find-generic-password └── Absolute path to binary (avoids PATH hijacking) 2. File fallback → ~/.claude/.credentials.json └── Legacy storage for non-macOS 3. Validation → Check expiry timestamp, reject if expired API Request: GET api.anthropic.com/api/oauth/usage Authorization: Bearer {token} anthropic-beta: oauth-2025-04-20 Response: { five_hour: { utilization_pct, reset_at }, seven_day: { utilization_pct, reset_at } }

Usage API: Multi-Layer Caching

Since the HUD runs as a new process every ~300ms, aggressive caching prevents API flooding:

Success Cache — 5 min TTL

Successful API responses cached to file. Matches API rate limit expectations. All HUD processes read this file.

Failure Cache — 15 sec TTL

Failed requests cached briefly so we retry soon but don't hammer the API during transient issues.

Rate-Limit Backoff

Exponential: 60s, 120s, 240s, capped at 5 min. Prevents cascading 429 errors during heavy usage.

Lock File Coordination

Only one HUD process fetches at a time. Others read the last-good-data file. Prevents duplicate requests.

Keychain backoff: 60-second cooldown after macOS Keychain access failures to avoid re-prompting the user for permissions.

Render System Architecture

Two Layout Modes

Compact (default)

Session line + optional activity lines. All context, usage, git in 1-2 lines.

Expanded

Each element gets its own line. Order configurable via elementOrder array.

Composable Elements

project, context, usage, environment, tools, agents, todos

Unicode-Aware Text Handling

// Width-aware functions visualLength(str) // Strips ANSI, handles CJK // double-width + emoji wrapLineToWidth(line, width) // Splits at │ separators // to fit terminal width truncateToWidth(str, max) // Adds ellipsis when too long isWideCodePoint(cp) // CJK: 0x1100–0x3FFFD // Uses Intl.Segmenter // when available

ANSI Color System

Named Presets

NameUsage
redCritical warnings, errors
greenHealthy context, completed
yellowRunning tools, warnings
cyanTool names, info
magentaAgent indicators
claudeOrange256-color index 208
dimSecondary info, counts

Custom Color Formats

resolveAnsi(colorValue) { // 1. Named preset if (colorValue === 'red') return '\x1b[31m'; // 2. 256-color index (0-255) if (typeof colorValue === 'number') return `\x1b[38;5;${colorValue}m`; // 3. Hex → truecolor if (colorValue.startsWith('#')) return `\x1b[38;2;${r};${g};${b}m`; }

Severity Thresholds

  • Context: green <70%, yellow 70%+, red 85%+
  • Quota: blue <75%, magenta 75%+, red 90%+

Context Window: Autocompact Buffer

Claude Code automatically compacts context when it gets too full. The HUD accounts for this with a 16.5% buffer:

// constants.ts export const AUTOCOMPACT_BUFFER_PERCENT = 0.165; // In session-line.ts — the "buffered" percentage const rawPercent = (stdin.context_window.used / stdin.context_window.total) * 100; const effectiveTotal = stdin.context_window.total * (1 - AUTOCOMPACT_BUFFER_PERCENT); const bufferedPercent = (stdin.context_window.used / effectiveTotal) * 100; // Config can toggle between raw and buffered display const percent = autocompactMode === 'disabled' ? rawPercent : bufferedPercent;
Why 16.5%? Empirically derived from Claude Code's /context output. The actual compaction threshold may shift in future Claude Code versions, so this constant may need updating.

At 85%+ usage, the HUD shows a token breakdown: (in: 42k, cache: 18k) to help users understand what's consuming context.

Configuration System

Config Categories

CategoryKey Options
LayoutlineLayout, showSeparators
Gitbranch, dirty, ahead/behind, file stats
Contextbar, percent format, token breakdown
Usagerate bars, 7-day threshold, cache TTL
Colorscontext, usage, warning, critical
Activitytools, agents, todos visibility
Displaymodel, session duration, token speed

Three Presets

// via /claude-hud:configure Full // Everything enabled Essential // Git + activity lines Minimal // Model + context bar

Config Robustness

  • mergeConfig() validates each property type
  • Invalid values replaced with defaults (not rejected)
  • migrateConfig() handles legacy layout property
  • File stored at ~/.claude/plugins/claude-hud/config.json

Performance Considerations

The HUD runs as a fresh process every ~300ms. Every millisecond counts.

Zero Runtime Dependencies

No node_modules to load. Only Node.js built-ins (readline, fs, child_process, path). Fast cold start.

Streaming Transcript Parse

readline.createInterface() streams the JSONL file line by line instead of reading the entire file into memory.

Conditional Computation

Git status, usage API, and extra commands only run when enabled in config. Each has its own toggle.

File-Based Caching

Usage data, keychain tokens, and rate-limit state cached to files. New processes read cache instead of re-fetching.

Why fresh processes? Claude Code's statusline API spawns the plugin binary each cycle. This keeps the plugin stateless and crash-resilient — a failure in one cycle doesn't affect the next.

Security Considerations

Shell Injection Prevention

Uses execFile (not exec) for all git commands. Arguments passed as arrays, never interpolated into shell strings.

Keychain Access

Absolute path /usr/bin/security for macOS Keychain binary. Prevents PATH hijacking attacks.

Graceful Degradation

Every external call wrapped in try-catch. Failures return null and the HUD continues with available data.

No Network by Default

Usage API calls are opt-in. Without OAuth tokens, the plugin is fully offline — just file reads and git commands.

Session Line: The Core Display

The renderSessionLine() function assembles the primary 1-2 line display. It's the most complex render component:

// Assembly order (each part optional based on config): Part 1: [Model | Plan] // e.g. [Opus | Max] Part 2: project git:(branch*) // Path depth configurable 1-3 Part 3: Context ████░░ 45% // Bar + percentage Part 4: Usage ██░░ 25% (1h/5h) // 5-hour + optional 7-day Part 5: Speed: 42 tok/s // Token throughput Part 6: Session: 2h 15m // Elapsed time // Parts joined with │ separator, then wrapped to terminal width // Adjacent context+usage elements get smart-merged
Adaptive display: At 85%+ context usage, token breakdown appears automatically: (in: 42k, cache: 18k). This helps users understand whether input tokens or cache is consuming their window.

Progress Bars & Visual Indicators

Context Bar

coloredBar(percent, width, config) { const filled = Math.round( width * percent / 100 ); const empty = width - filled; // █ for filled, ░ for empty const color = getContextColor( percent, config ); // green → yellow → red // as context fills up return colorize( '█'.repeat(filled), color ) + dim( '░'.repeat(empty) ); }

Quota Bar

quotaBar(percent, width, config) { // Same mechanics, different // color thresholds: // <75% → brightBlue // 75% → brightMagenta // 90% → red // Reset time shown when // approaching limit: formatResetTime(resetAt) // → "resets in 2h 15m" }

Percent Format Options

  • percent — 45%
  • tokens — 42k / 100k
  • remaining — 58k left

Claude Code Plugin System

How does Claude Code know about the HUD? Through the .claude-plugin/ directory:

plugin.json

{ "name": "claude-hud", "description": "Real-time statusline HUD for Claude Code", "version": "0.0.10", "author": { "name": "Jarrod Watts" }, "commands": [ "./commands/setup.md", "./commands/configure.md" ] }

How It Hooks In

Claude Code (v1.0.80+) │ ├── Reads plugin.json │ └── Registers plugin + commands │ ├── /claude-hud:setup │ └── Configures statusline hook │ pointing to dist/index.js │ └── Every ~300ms, spawns: echo '{"context_window":...}' | node dist/index.js └── stdout → rendered in terminal

Installation & Setup Deep Dive

# Step 1: Register marketplace (one-time) /plugin marketplace add jarrodwatts/claude-hud # Step 2: Install the plugin /plugin install claude-hud # → Clones repo to ~/.claude/plugins/claude-hud/ # → Runs npm ci && npm run build # Step 3: Configure statusline hook /claude-hud:setup # → Writes statusline config to Claude Code settings # → Points to dist/index.js as the render binary # Then restart Claude Code!
Linux users: Due to tmpfs limitations, set TMPDIR=~/.cache/tmp before installation to avoid cross-device link errors during npm ci.

/claude-hud:configure

Interactive preset selection (Full / Essential / Minimal) or direct JSON editing for advanced users.

Config Location

~/.claude/plugins/claude-hud/config.json — survives plugin updates, user-editable.

Key Architectural Takeaways

Stateless by Design

Fresh process each render. No long-running daemon. Crash-resilient — one bad cycle doesn't affect the next.

Zero Dependencies

Only Node.js built-ins. Fast cold start, minimal attack surface, no supply chain risk.

Native Data Sources

Context window data from stdin (not estimated). Transcript parsed from authoritative JSONL. Git from actual commands.

Aggressive Caching

File-based caching with lock coordination. Multiple cache layers with different TTLs for different data freshness needs.

Defensive Coding

Every external call wrapped in try-catch. execFile over exec. Absolute binary paths. Invalid config falls back to defaults.

User Respect

Highly configurable. Three presets for different needs. Every element independently togglable. No telemetry.

Questions?

github.com/jarrodwatts/claude-hud
10.2k stars · TypeScript · MIT License · Zero Dependencies
Claude Code v1.0.80+ · Node.js 18+