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
| Name | Usage |
red | Critical warnings, errors |
green | Healthy context, completed |
yellow | Running tools, warnings |
cyan | Tool names, info |
magenta | Agent indicators |
claudeOrange | 256-color index 208 |
dim | Secondary 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
| Category | Key Options |
| Layout | lineLayout, showSeparators |
| Git | branch, dirty, ahead/behind, file stats |
| Context | bar, percent format, token breakdown |
| Usage | rate bars, 7-day threshold, cache TTL |
| Colors | context, usage, warning, critical |
| Activity | tools, agents, todos visibility |
| Display | model, 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+