From e9b0e9f680434fc274b188249a03d0fa500c0dec Mon Sep 17 00:00:00 2001 From: Jarrod Watts Date: Sat, 3 Jan 2026 18:10:26 +1100 Subject: [PATCH] feat: comprehensive config detection across all scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ~/.claude.json user-scope MCP support - Add .mcp.json project MCP support - Add .claude/settings.local.json support - Add CLAUDE.local.md and .claude/CLAUDE.md detection - Deduplicate MCPs that appear in multiple files Locations now covered: - User: ~/.claude/CLAUDE.md, ~/.claude/rules/, ~/.claude/settings.json, ~/.claude.json - Project: CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md, .claude/rules/, .mcp.json - Project settings: .claude/settings.json, .claude/settings.local.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/settings.json | 214 ----------------------- .gitignore | 2 + CHANGELOG.md | 52 +----- CLAUDE.md | 3 +- README.md | 11 +- TESTING.md | 59 +++++++ package.json | 1 + src/config-reader.ts | 136 ++++++++------ src/render/agents-line.ts | 2 +- src/render/colors.ts | 29 +-- src/render/todos-line.ts | 2 +- src/render/tools-line.ts | 4 +- src/types.ts | 4 - tests/core.test.js | 62 +++++++ tests/fixtures/expected/render-basic.txt | 4 + tests/fixtures/transcript-basic.jsonl | 5 + tests/fixtures/transcript-render.jsonl | 6 + tests/integration.test.js | 47 +++++ tests/render.test.js | 100 +++++++++++ 19 files changed, 403 insertions(+), 340 deletions(-) delete mode 100644 .claude/settings.json create mode 100644 TESTING.md create mode 100644 tests/core.test.js create mode 100644 tests/fixtures/expected/render-basic.txt create mode 100644 tests/fixtures/transcript-basic.jsonl create mode 100644 tests/fixtures/transcript-render.jsonl create mode 100644 tests/integration.test.js create mode 100644 tests/render.test.js diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index e989acf..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,214 +0,0 @@ -{ - "permissions": { - "defaultMode": "acceptEdits", - "allow": [ - "Bash(bun:*)", - "Bash(npm:*)", - "Bash(npx:*)", - "Bash(node:*)", - "Bash(tsc:*)", - "Bash(vitest:*)", - "Bash(jest:*)", - "Bash(eslint:*)", - "Bash(prettier:*)", - "Bash(husky:*)", - "Bash(lint-staged:*)", - "Bash(git:*)", - "Bash(gh:*)", - "Bash(mkdir:*)", - "Bash(touch:*)", - "Bash(rm:*)", - "Bash(mv:*)", - "Bash(cp:*)", - "Bash(chmod:*)", - "Bash(ln:*)", - "Bash(stat:*)", - "Bash(file:*)", - "Bash(cat:*)", - "Bash(echo:*)", - "Bash(printf:*)", - "Bash(head:*)", - "Bash(tail:*)", - "Bash(less:*)", - "Bash(more:*)", - "Bash(wc:*)", - "Bash(ls:*)", - "Bash(tree:*)", - "Bash(find:*)", - "Bash(grep:*)", - "Bash(egrep:*)", - "Bash(fgrep:*)", - "Bash(rg:*)", - "Bash(ag:*)", - "Bash(sed:*)", - "Bash(awk:*)", - "Bash(gawk:*)", - "Bash(sort:*)", - "Bash(uniq:*)", - "Bash(tr:*)", - "Bash(cut:*)", - "Bash(paste:*)", - "Bash(join:*)", - "Bash(xargs:*)", - "Bash(tee:*)", - "Bash(jq:*)", - "Bash(yq:*)", - "Bash(curl:*)", - "Bash(wget:*)", - "Bash(which:*)", - "Bash(whereis:*)", - "Bash(type:*)", - "Bash(command:*)", - "Bash(hash:*)", - "Bash(test:*)", - "Bash([:*)", - "Bash([[:*)", - "Bash(true:*)", - "Bash(false:*)", - "Bash(sleep:*)", - "Bash(timeout:*)", - "Bash(wait:*)", - "Bash(date:*)", - "Bash(time:*)", - "Bash(pwd:*)", - "Bash(cd:*)", - "Bash(pushd:*)", - "Bash(popd:*)", - "Bash(dirs:*)", - "Bash(export:*)", - "Bash(unset:*)", - "Bash(env:*)", - "Bash(printenv:*)", - "Bash(set:*)", - "Bash(source:*)", - "Bash(.:*)", - "Bash(dirname:*)", - "Bash(basename:*)", - "Bash(realpath:*)", - "Bash(readlink:*)", - "Bash(mktemp:*)", - "Bash(diff:*)", - "Bash(cmp:*)", - "Bash(comm:*)", - "Bash(patch:*)", - "Bash(tar:*)", - "Bash(unzip:*)", - "Bash(zip:*)", - "Bash(gzip:*)", - "Bash(gunzip:*)", - "Bash(bzip2:*)", - "Bash(bunzip2:*)", - "Bash(xz:*)", - "Bash(unxz:*)", - "Bash(read:*)", - "Bash(seq:*)", - "Bash(yes:*)", - "Bash(expr:*)", - "Bash(bc:*)", - "Bash(dc:*)", - "Bash(ps:*)", - "Bash(pgrep:*)", - "Bash(pkill:*)", - "Bash(kill:*)", - "Bash(killall:*)", - "Bash(du:*)", - "Bash(df:*)", - "Bash(id:*)", - "Bash(whoami:*)", - "Bash(groups:*)", - "Bash(uname:*)", - "Bash(hostname:*)", - "Bash(nproc:*)", - "Bash(mkfifo:*)", - "Bash(tput:*)", - "Bash(stty:*)", - "Bash(clear:*)", - "Bash(reset:*)", - "Bash(exec:*)", - "Bash(eval:*)", - "Bash(sh:*)", - "Bash(bash:*)", - "Bash(zsh:*)", - "Bash(for:*)", - "Bash(while:*)", - "Bash(if:*)", - "Bash(case:*)", - "Bash(lsof:*)", - "Bash(nc:*)", - "Bash(netcat:*)", - "Bash(open:*)", - "Bash(pbcopy:*)", - "Bash(pbpaste:*)", - "Bash(security:*)", - "Read", - "Edit", - "Write", - "Glob", - "Grep", - "WebFetch", - "WebSearch", - "Task", - "TodoWrite", - "mcp__*", - "LSP", - "NotebookEdit" - ], - "deny": [ - "Bash(sudo:*)", - "Bash(su:*)", - "Bash(doas:*)", - "Bash(brew:*)", - "Bash(port:*)", - "Bash(apt:*)", - "Bash(apt-get:*)", - "Bash(dpkg:*)", - "Bash(yum:*)", - "Bash(dnf:*)", - "Bash(rpm:*)", - "Bash(pacman:*)", - "Bash(snap:*)", - "Bash(flatpak:*)", - "Bash(npm install -g:*)", - "Bash(npm i -g:*)", - "Bash(pnpm add -g:*)", - "Bash(yarn global:*)", - "Bash(pip install:*)", - "Bash(pip3 install:*)", - "Bash(gem install:*)", - "Bash(cargo install:*)", - "Bash(go install:*)", - "Bash(ssh:*)", - "Bash(scp:*)", - "Bash(rsync:*)", - "Bash(ftp:*)", - "Bash(sftp:*)", - "Bash(telnet:*)", - "Bash(systemctl:*)", - "Bash(service:*)", - "Bash(launchctl:*)", - "Bash(chown:*)", - "Bash(chgrp:*)", - "Bash(mount:*)", - "Bash(umount:*)", - "Bash(fdisk:*)", - "Bash(mkfs:*)", - "Bash(dd:*)", - "Bash(shutdown:*)", - "Bash(reboot:*)", - "Bash(halt:*)", - "Bash(poweroff:*)", - "Bash(init:*)", - "Bash(iptables:*)", - "Bash(firewall-cmd:*)", - "Bash(ufw:*)", - "Bash(useradd:*)", - "Bash(userdel:*)", - "Bash(usermod:*)", - "Bash(groupadd:*)", - "Bash(passwd:*)", - "Bash(visudo:*)", - "Bash(crontab:*)", - "AskUserQuestion" - ] - } -} diff --git a/.gitignore b/.gitignore index 3e84ab5..6dffefa 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ Thumbs.db # Environment/secrets (safety) .env .env.* +.claude/settings.json +.claude/*.local.json *.pem *.key secrets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index beed01e..efba9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,51 +2,15 @@ All notable changes to Claude HUD will be documented in this file. -Note: The canonical changelog now lives at `docs/CHANGELOG.md`. This file is kept for legacy references. - -## [0.1.0] - 2025-01-02 - -### Added -- **Developer Intelligence Dashboard**: Surface real data from Claude Code internals -- **Real Token Stats**: Read actual token usage from `~/.claude/stats-cache.json` instead of estimates -- **Model Display**: Show current model (opus/sonnet/haiku) from settings -- **Plugin & MCP Awareness**: Display enabled plugins and MCP server counts from `~/.claude/settings.json` -- **CLAUDE.md Detection**: Show when global and project CLAUDE.md files are loaded -- **StatusBar Component**: Rich header showing model, idle state, plugin/MCP counts, and working directory -- **ContextInfo Component**: Visual indicator of loaded context files +## [2.0.0] - 2025-01-02 ### Changed -- ContextMeter now displays real token data when available (today's usage, cache stats) -- McpStatus renamed to Connections, now shows both MCP servers and plugins +- Complete rewrite from split-pane TUI to inline statusline +- New statusline renderer with multi-line output +- Transcript-driven tool/agent/todo parsing +- Native context usage from stdin JSON -### Technical -- Added settings-reader.ts for parsing ~/.claude/settings.json -- Added stats-reader.ts for parsing ~/.claude/stats-cache.json -- Added context-detector.ts for CLAUDE.md file detection -- 30-second polling interval for settings/stats refresh +### Removed +- Hook-based capture flow +- Split-pane UI and related components ---- - -## [0.0.2] - 2025-01-02 - -### Added -- **Enhanced Hook Support**: Added PreToolUse, UserPromptSubmit, Stop, and PreCompact hooks -- **Cost Estimation**: Real-time cost tracking with input/output breakdown (supports Sonnet/Opus/Haiku pricing) -- **Token Sparkline**: Visual history of token usage over time -- **Session Status**: Idle indicator (💤/⚡), permission mode display, compaction count -- **Git Integration**: Branch name, ahead/behind counts, staged/modified/untracked file counts -- **Last Prompt Preview**: Shows last user prompt submitted -- **Verification Script**: `verify-install.sh` to check installation status -- **Comprehensive Tests**: 90+ tests covering components, event parsing, cost tracking - -### Improved -- Tool stream now shows true "running" state via PreToolUse hook -- Better event handling with toolUseId correlation -- Enhanced documentation with architecture overview -- Comprehensive troubleshooting guide - -### Technical -- Added ink-testing-library for component testing -- CostTracker class for API cost estimation -- GitStatus component with 30-second auto-refresh -- Event parser with edge case handling diff --git a/CLAUDE.md b/CLAUDE.md index bfb44fb..3af61d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,8 @@ Claude Code → stdin JSON → parse → render lines → stdout → Claude Code - `Task` calls → agent info **From config files**: -- MCP count from `~/.claude/.mcp.json` +- MCP count from `~/.claude/settings.json` (mcpServers) +- Hooks count from `~/.claude/settings.json` (hooks) - Rules count from CLAUDE.md files ### File Structure diff --git a/README.md b/README.md index 8c37e21..1137725 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ That's it. Start Claude Code as usual — the statusline appears automatically. - ⚠️ COMPACT: >95% (critical) - **Rules count** — How many CLAUDE.md files loaded - **MCP count** — Connected MCP servers +- **Hooks count** — Number of configured hooks (from settings) - **Duration** — Session time ### Line 2: Tool Activity @@ -72,7 +73,7 @@ Claude HUD uses Claude Code's **statusline API** — a multi-line display that u Unlike other approaches, Claude HUD: - **No separate window** — Displays inline in your terminal -- **No hooks needed** — Parses the transcript directly +- **No hooks required** — Parses the transcript directly (hooks are optional and only counted) - **Native data** — Gets accurate token/context info from Claude Code - **Works everywhere** — Any terminal, not just tmux/iTerm @@ -118,6 +119,14 @@ bun run build echo '{"model":{"display_name":"Opus"},"context_window":{"current_usage":{"input_tokens":45000},"context_window_size":200000}}' | node dist/index.js ``` +## Testing + +```bash +npm test +``` + +See `TESTING.md` for the full testing strategy and contribution expectations. + ## License MIT diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..3de94df --- /dev/null +++ b/TESTING.md @@ -0,0 +1,59 @@ +# 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. + +## CI Gate (recommended) + +- `npm ci` +- `npm run build` +- `npm test` + +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. diff --git a/package.json b/package.json index 0cabe4d..0669e36 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "test": "npm run build && node --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"], diff --git a/src/config-reader.ts b/src/config-reader.ts index 1933e19..2df8f2a 100644 --- a/src/config-reader.ts +++ b/src/config-reader.ts @@ -9,78 +9,116 @@ export interface ConfigCounts { hooksCount: number; } +function getMcpServerNames(filePath: string): Set { + if (!fs.existsSync(filePath)) return new Set(); + try { + const content = fs.readFileSync(filePath, 'utf8'); + const config = JSON.parse(content); + if (config.mcpServers && typeof config.mcpServers === 'object') { + return new Set(Object.keys(config.mcpServers)); + } + } catch { + // Ignore errors + } + 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 { + // Ignore errors + } + return 0; +} + +function countRulesInDir(rulesDir: string): number { + if (!fs.existsSync(rulesDir)) return 0; + try { + const files = fs.readdirSync(rulesDir); + return files.filter((f) => f.endsWith('.md')).length; + } catch { + return 0; + } +} + export async function countConfigs(cwd?: string): Promise { let claudeMdCount = 0; let rulesCount = 0; let mcpCount = 0; let hooksCount = 0; - const claudeDir = path.join(os.homedir(), '.claude'); - const globalRulesDir = path.join(claudeDir, 'rules'); - const settingsPath = path.join(claudeDir, 'settings.json'); + const homeDir = os.homedir(); + const claudeDir = path.join(homeDir, '.claude'); + // === USER SCOPE === + + // ~/.claude/CLAUDE.md if (fs.existsSync(path.join(claudeDir, 'CLAUDE.md'))) { claudeMdCount++; } - if (fs.existsSync(globalRulesDir)) { - try { - const files = fs.readdirSync(globalRulesDir); - rulesCount += files.filter((f) => f.endsWith('.md')).length; - } catch { - // Ignore errors - } - } + // ~/.claude/rules/*.md + rulesCount += countRulesInDir(path.join(claudeDir, 'rules')); - if (fs.existsSync(settingsPath)) { - try { - const content = fs.readFileSync(settingsPath, 'utf8'); - const settings = JSON.parse(content); + // ~/.claude/settings.json (MCPs and hooks) + const userSettings = path.join(claudeDir, 'settings.json'); + mcpCount += countMcpServersInFile(userSettings); + hooksCount += countHooksInFile(userSettings); - if (settings.mcpServers && typeof settings.mcpServers === 'object') { - mcpCount += Object.keys(settings.mcpServers).length; - } + // ~/.claude.json (additional user-scope MCPs, dedupe by counting unique) + const userClaudeJson = path.join(homeDir, '.claude.json'); + mcpCount += countMcpServersInFile(userClaudeJson, userSettings); - if (settings.hooks && typeof settings.hooks === 'object') { - hooksCount += Object.keys(settings.hooks).length; - } - } catch { - // Ignore errors - } - } + // === PROJECT SCOPE === if (cwd) { + // {cwd}/CLAUDE.md if (fs.existsSync(path.join(cwd, 'CLAUDE.md'))) { claudeMdCount++; } - const projectRulesDir = path.join(cwd, '.claude', 'rules'); - if (fs.existsSync(projectRulesDir)) { - try { - const files = fs.readdirSync(projectRulesDir); - rulesCount += files.filter((f) => f.endsWith('.md')).length; - } catch { - // Ignore errors - } + // {cwd}/CLAUDE.local.md + if (fs.existsSync(path.join(cwd, 'CLAUDE.local.md'))) { + claudeMdCount++; } - const projectSettingsPath = path.join(cwd, '.claude', 'settings.json'); - if (fs.existsSync(projectSettingsPath)) { - try { - const content = fs.readFileSync(projectSettingsPath, 'utf8'); - const settings = JSON.parse(content); - - if (settings.mcpServers && typeof settings.mcpServers === 'object') { - mcpCount += Object.keys(settings.mcpServers).length; - } - - if (settings.hooks && typeof settings.hooks === 'object') { - hooksCount += Object.keys(settings.hooks).length; - } - } catch { - // Ignore errors - } + // {cwd}/.claude/CLAUDE.md (alternative location) + if (fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.md'))) { + claudeMdCount++; } + + // {cwd}/.claude/rules/*.md + rulesCount += countRulesInDir(path.join(cwd, '.claude', 'rules')); + + // {cwd}/.mcp.json (project MCP config) + mcpCount += countMcpServersInFile(path.join(cwd, '.mcp.json')); + + // {cwd}/.claude/settings.json (project settings) + const projectSettings = path.join(cwd, '.claude', 'settings.json'); + mcpCount += countMcpServersInFile(projectSettings); + hooksCount += countHooksInFile(projectSettings); + + // {cwd}/.claude/settings.local.json (local project settings) + const localSettings = path.join(cwd, '.claude', 'settings.local.json'); + mcpCount += countMcpServersInFile(localSettings); + hooksCount += countHooksInFile(localSettings); } return { claudeMdCount, rulesCount, mcpCount, hooksCount }; diff --git a/src/render/agents-line.ts b/src/render/agents-line.ts index e01c007..7af172a 100644 --- a/src/render/agents-line.ts +++ b/src/render/agents-line.ts @@ -1,5 +1,5 @@ import type { RenderContext, AgentEntry } from '../types.js'; -import { yellow, green, magenta, dim, cyan } from './colors.js'; +import { yellow, green, magenta, dim } from './colors.js'; export function renderAgentsLine(ctx: RenderContext): string | null { const { agents } = ctx.transcript; diff --git a/src/render/colors.ts b/src/render/colors.ts index a12eacb..9bfc1be 100644 --- a/src/render/colors.ts +++ b/src/render/colors.ts @@ -1,24 +1,11 @@ export const RESET = '\x1b[0m'; -export const BOLD = '\x1b[1m'; -export const DIM = '\x1b[2m'; -export const BLACK = '\x1b[30m'; -export const RED = '\x1b[31m'; -export const GREEN = '\x1b[32m'; -export const YELLOW = '\x1b[33m'; -export const BLUE = '\x1b[34m'; -export const MAGENTA = '\x1b[35m'; -export const CYAN = '\x1b[36m'; -export const WHITE = '\x1b[37m'; - -export const BG_BLACK = '\x1b[40m'; -export const BG_RED = '\x1b[41m'; -export const BG_GREEN = '\x1b[42m'; -export const BG_YELLOW = '\x1b[43m'; -export const BG_BLUE = '\x1b[44m'; -export const BG_MAGENTA = '\x1b[45m'; -export const BG_CYAN = '\x1b[46m'; -export const BG_WHITE = '\x1b[47m'; +const DIM = '\x1b[2m'; +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const MAGENTA = '\x1b[35m'; +const CYAN = '\x1b[36m'; export function green(text: string): string { return `${GREEN}${text}${RESET}`; @@ -44,10 +31,6 @@ export function dim(text: string): string { return `${DIM}${text}${RESET}`; } -export function bold(text: string): string { - return `${BOLD}${text}${RESET}`; -} - export function getContextColor(percent: number): string { if (percent >= 85) return RED; if (percent >= 70) return YELLOW; diff --git a/src/render/todos-line.ts b/src/render/todos-line.ts index 1f3255a..5da6714 100644 --- a/src/render/todos-line.ts +++ b/src/render/todos-line.ts @@ -1,5 +1,5 @@ import type { RenderContext } from '../types.js'; -import { yellow, green, dim, cyan } from './colors.js'; +import { yellow, green, dim } from './colors.js'; export function renderTodosLine(ctx: RenderContext): string | null { const { todos } = ctx.transcript; diff --git a/src/render/tools-line.ts b/src/render/tools-line.ts index 8c8fc4d..7b2e9f7 100644 --- a/src/render/tools-line.ts +++ b/src/render/tools-line.ts @@ -1,5 +1,5 @@ -import type { RenderContext, ToolEntry } from '../types.js'; -import { yellow, green, red, cyan, dim } from './colors.js'; +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; diff --git a/src/types.ts b/src/types.ts index edcc5bf..36b1c7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,4 @@ export interface StdinData { - hook_event_name?: string; session_id?: string; transcript_path?: string; cwd?: string; @@ -18,9 +17,6 @@ export interface StdinData { cache_read_input_tokens?: number; }; }; - cost?: { - total_cost_usd?: number; - }; } export interface ToolEntry { diff --git a/tests/core.test.js b/tests/core.test.js new file mode 100644 index 0000000..aa68829 --- /dev/null +++ b/tests/core.test.js @@ -0,0 +1,62 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseTranscript } from '../dist/transcript.js'; +import { getContextPercent } from '../dist/stdin.js'; + +test('getContextPercent returns 0 when data is missing', () => { + assert.equal(getContextPercent({}), 0); + assert.equal(getContextPercent({ context_window: { context_window_size: 0 } }), 0); +}); + +test('getContextPercent includes cache tokens', () => { + const percent = getContextPercent({ + context_window: { + context_window_size: 200, + current_usage: { + input_tokens: 50, + cache_creation_input_tokens: 25, + cache_read_input_tokens: 25, + }, + }, + }); + + assert.equal(percent, 50); +}); + +test('parseTranscript aggregates tools, agents, and todos', async () => { + const fixturePath = fileURLToPath(new URL('./fixtures/transcript-basic.jsonl', import.meta.url)); + const result = await parseTranscript(fixturePath); + assert.equal(result.tools.length, 1); + assert.equal(result.tools[0].status, 'completed'); + assert.equal(result.tools[0].target, '/tmp/example.txt'); + assert.equal(result.agents.length, 1); + assert.equal(result.agents[0].status, 'completed'); + assert.equal(result.todos.length, 2); + assert.equal(result.todos[1].status, 'in_progress'); + assert.equal(result.sessionStart?.toISOString(), '2024-01-01T00:00:00.000Z'); +}); + +test('parseTranscript tolerates malformed lines', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'claude-hud-')); + const filePath = path.join(dir, 'malformed.jsonl'); + const lines = [ + '{"timestamp":"2024-01-01T00:00:00.000Z","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read"}]}}', + '{not-json}', + '{"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1"}]}}', + '', + ]; + + await writeFile(filePath, lines.join('\n'), 'utf8'); + + try { + const result = await parseTranscript(filePath); + assert.equal(result.tools.length, 1); + assert.equal(result.tools[0].status, 'completed'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/fixtures/expected/render-basic.txt b/tests/fixtures/expected/render-basic.txt new file mode 100644 index 0000000..98e48de --- /dev/null +++ b/tests/fixtures/expected/render-basic.txt @@ -0,0 +1,4 @@ +[Opus] ██░░░░░░░░ 23% +◐ Edit: .../authentication.ts | ✓ Read ×1 +✓ explore [haiku]: Finding auth code (<1s) +▸ Add tests (1/2) diff --git a/tests/fixtures/transcript-basic.jsonl b/tests/fixtures/transcript-basic.jsonl new file mode 100644 index 0000000..9aefd10 --- /dev/null +++ b/tests/fixtures/transcript-basic.jsonl @@ -0,0 +1,5 @@ +{"timestamp":"2024-01-01T00:00:00.000Z","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/example.txt"}}]}} +{"timestamp":"2024-01-01T00:00:01.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","is_error":false}]}} +{"timestamp":"2024-01-01T00:00:02.000Z","message":{"content":[{"type":"tool_use","id":"agent-1","name":"Task","input":{"subagent_type":"explore","model":"haiku"}}]}} +{"timestamp":"2024-01-01T00:00:03.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"agent-1","is_error":false}]}} +{"timestamp":"2024-01-01T00:00:04.000Z","message":{"content":[{"type":"tool_use","id":"todo-1","name":"TodoWrite","input":{"todos":[{"content":"First task","status":"completed"},{"content":"Second task","status":"in_progress"}]}}}]}} diff --git a/tests/fixtures/transcript-render.jsonl b/tests/fixtures/transcript-render.jsonl new file mode 100644 index 0000000..c4d766e --- /dev/null +++ b/tests/fixtures/transcript-render.jsonl @@ -0,0 +1,6 @@ +{"message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/example.txt"}}]}} +{"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","is_error":false}]}} +{"message":{"content":[{"type":"tool_use","id":"tool-2","name":"Edit","input":{"file_path":"/tmp/very/long/path/to/authentication.ts"}}]}} +{"message":{"content":[{"type":"tool_use","id":"agent-1","name":"Task","input":{"subagent_type":"explore","model":"haiku","description":"Finding auth code"}}]}} +{"message":{"content":[{"type":"tool_result","tool_use_id":"agent-1","is_error":false}]}} +{"message":{"content":[{"type":"tool_use","id":"todo-1","name":"TodoWrite","input":{"todos":[{"content":"Fix auth bug","status":"completed"},{"content":"Add tests","status":"in_progress"}]}}}]} diff --git a/tests/integration.test.js b/tests/integration.test.js new file mode 100644 index 0000000..cade260 --- /dev/null +++ b/tests/integration.test.js @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { readFileSync } from 'node:fs'; + +function stripAnsi(text) { + return text.replace( + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]/g, + '' + ); +} + +test('CLI renders expected output for a basic transcript', async () => { + const fixturePath = fileURLToPath(new URL('./fixtures/transcript-render.jsonl', import.meta.url)); + const expectedPath = fileURLToPath(new URL('./fixtures/expected/render-basic.txt', import.meta.url)); + const expected = readFileSync(expectedPath, 'utf8').trimEnd(); + + const homeDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-home-')); + try { + const stdin = JSON.stringify({ + model: { display_name: 'Opus' }, + context_window: { + context_window_size: 200000, + current_usage: { input_tokens: 45000 }, + }, + transcript_path: fixturePath, + cwd: homeDir, + }); + + const result = spawnSync('node', ['dist/index.js'], { + cwd: path.resolve(process.cwd()), + input: stdin, + encoding: 'utf8', + env: { ...process.env, HOME: homeDir }, + }); + + assert.equal(result.status, 0, result.stderr || 'non-zero exit'); + const normalized = stripAnsi(result.stdout).replace(/\u00A0/g, ' ').trimEnd(); + assert.equal(normalized, expected); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); diff --git a/tests/render.test.js b/tests/render.test.js new file mode 100644 index 0000000..73e137c --- /dev/null +++ b/tests/render.test.js @@ -0,0 +1,100 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderSessionLine } from '../dist/render/session-line.js'; +import { renderToolsLine } from '../dist/render/tools-line.js'; +import { renderAgentsLine } from '../dist/render/agents-line.js'; +import { renderTodosLine } from '../dist/render/todos-line.js'; + +function baseContext() { + return { + stdin: { + model: { display_name: 'Opus' }, + context_window: { + context_window_size: 100, + current_usage: { + input_tokens: 10, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }, + transcript: { tools: [], agents: [], todos: [] }, + claudeMdCount: 0, + rulesCount: 0, + mcpCount: 0, + hooksCount: 0, + sessionDuration: '', + }; +} + +test('renderSessionLine adds token breakdown when context is high', () => { + const ctx = baseContext(); + ctx.stdin.context_window.current_usage.input_tokens = 90; + const line = renderSessionLine(ctx); + assert.ok(line.includes('in:'), 'expected token breakdown'); + assert.ok(line.includes('cache:'), 'expected cache breakdown'); +}); + +test('renderSessionLine shows compact warning at critical threshold', () => { + const ctx = baseContext(); + ctx.stdin.context_window.current_usage.input_tokens = 96; + const line = renderSessionLine(ctx); + assert.ok(line.includes('COMPACT')); +}); + +test('renderToolsLine renders running and completed tools', () => { + const ctx = baseContext(); + ctx.transcript.tools = [ + { + id: 'tool-1', + name: 'Read', + status: 'completed', + startTime: new Date(0), + endTime: new Date(0), + duration: 0, + }, + { + id: 'tool-2', + name: 'Edit', + target: '/tmp/example.txt', + status: 'running', + startTime: new Date(0), + }, + ]; + + const line = renderToolsLine(ctx); + assert.ok(line?.includes('Read')); + assert.ok(line?.includes('Edit')); +}); + +test('renderAgentsLine renders completed agents', () => { + const ctx = baseContext(); + ctx.transcript.agents = [ + { + id: 'agent-1', + type: 'explore', + model: 'haiku', + description: 'Finding auth code', + status: 'completed', + startTime: new Date(0), + endTime: new Date(0), + elapsed: 0, + }, + ]; + + const line = renderAgentsLine(ctx); + assert.ok(line?.includes('explore')); + assert.ok(line?.includes('haiku')); +}); + +test('renderTodosLine handles in-progress and completed-only cases', () => { + const ctx = baseContext(); + ctx.transcript.todos = [ + { content: 'First task', status: 'completed' }, + { content: 'Second task', status: 'in_progress' }, + ]; + assert.ok(renderTodosLine(ctx)?.includes('Second task')); + + ctx.transcript.todos = [{ content: 'First task', status: 'completed' }]; + assert.ok(renderTodosLine(ctx)?.includes('All todos complete')); +});