diff --git a/CLAUDE.md b/CLAUDE.md index c14022e..2e2137b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ bun run build # Build TypeScript bun run dev # Watch mode for development bun test # Run all tests bun test # Run specific test (e.g., bun test sparkline) + bun run replay:events -- --input ../tui/test-fixtures/hud-events.jsonl # Replay events # Manual testing with a FIFO mkfifo /tmp/test.fifo @@ -77,6 +78,20 @@ Runtime files stored in `~/.claude/hud/`: - `pids/.pid` - Process ID for cleanup - `logs/.log` - Fallback output when split pane unavailable +## HUD Configuration + +Optional HUD config lives at `~/.claude/hud/config.json`: + +```json +{ + "panelOrder": ["status", "context", "tools", "agents", "todos"], + "hiddenPanels": ["cost"], + "width": 56 +} +``` + +Panel IDs: `status`, `context`, `cost`, `contextInfo`, `tools`, `agents`, `todos`. + ## Dependencies - **Runtime**: Node.js 18+ or Bun, jq (JSON parsing in hooks) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac5c831..c392d54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,10 @@ Thanks for your interest in contributing! This guide will help you get started. +## Community Standards + +Please read `CODE_OF_CONDUCT.md`. For security issues, see `SECURITY.md`. + ## Development Setup ```bash @@ -28,6 +32,17 @@ bun run typecheck # Format code bun run format + +# Replay a fixture event stream +bun run replay:events -- --input ../tui/test-fixtures/hud-events.jsonl +``` + +### One-shot checks + +From the repo root: + +```bash +./scripts/check.sh ``` ### Local Testing diff --git a/docs/adr/005-hud-store.md b/docs/adr/005-hud-store.md new file mode 100644 index 0000000..2405c53 --- /dev/null +++ b/docs/adr/005-hud-store.md @@ -0,0 +1,23 @@ +# 005 - HUD Store and Reducer Architecture + +## Status +Accepted + +## Context +The HUD app was previously managing IO and state derivation inside a React hook. +That mixed FIFO reading, settings/config scanning, and UI state updates in one +place, which made testing and reuse difficult. + +## Decision +Introduce a `HudStore` that owns IO and side effects, and a pure reducer for +state transitions. + +- `tui/src/state/hud-store.ts` manages EventReader, trackers, and environment + refresh intervals. +- `tui/src/state/hud-reducer.ts` handles event-driven state transitions. +- `tui/src/state/hud-state.ts` defines public state and internal tracking. + +## Consequences +- UI code subscribes to a stable store and stays focused on rendering. +- Event handling is testable in isolation via reducer tests. +- Future renderers can reuse the store without React. diff --git a/tui/package.json b/tui/package.json index 5fdc1c5..bb26888 100644 --- a/tui/package.json +++ b/tui/package.json @@ -13,7 +13,8 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "replay:events": "bun run scripts/replay-events.ts --" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/tui/scripts/replay-events.ts b/tui/scripts/replay-events.ts new file mode 100644 index 0000000..95425fa --- /dev/null +++ b/tui/scripts/replay-events.ts @@ -0,0 +1,100 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { EventEmitter } from 'node:events'; +import { HudStore } from '../src/state/hud-store.js'; +import type { EventSource } from '../src/state/hud-store.js'; +import { parseHudEvent } from '../src/lib/hud-event.js'; + +type Args = { + input?: string; + json?: boolean; +}; + +function parseArgs(argv: string[]): Args { + const args: Args = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--input') { + args.input = argv[i + 1]; + i += 1; + } else if (arg === '--json') { + args.json = true; + } + } + return args; +} + +function createEventSource() { + const emitter = new EventEmitter(); + const source: EventSource = { + on(event, listener) { + emitter.on(event, listener); + }, + getStatus() { + return 'connected'; + }, + close() { + emitter.removeAllListeners(); + }, + switchFifo() { + return; + }, + }; + return { emitter, source }; +} + +function usage(): void { + console.log('Usage: bun run replay:events -- --input path/to/events.jsonl [--json]'); +} + +const args = parseArgs(process.argv.slice(2)); +if (!args.input) { + usage(); + process.exit(1); +} + +const inputPath = resolve(args.input); +const contents = readFileSync(inputPath, 'utf-8'); +const lines = contents.split('\n').filter((line) => line.trim().length > 0); + +const { emitter, source } = createEventSource(); +const store = new HudStore({ + fifoPath: 'replay', + clockIntervalMs: 0, + eventSourceFactory: () => source, +}); + +lines.forEach((line, index) => { + const event = parseHudEvent(line); + if (!event) { + console.warn(`Skipping invalid event line ${index + 1}`); + return; + } + emitter.emit('event', event); + const state = store.getState(); + if (args.json) { + console.log( + JSON.stringify( + { + index: index + 1, + event: event.event, + tools: state.tools.length, + todos: state.todos.length, + agents: state.agents.length, + cost: state.cost.totalCost, + contextPercent: state.context.percent, + }, + null, + 2, + ), + ); + } else { + console.log( + `#${index + 1} ${event.event} tools=${state.tools.length} todos=${state.todos.length} agents=${state.agents.length} cost=$${state.cost.totalCost.toFixed( + 2, + )}`, + ); + } +}); + +store.dispose(); diff --git a/tui/test-fixtures/hud-events.jsonl b/tui/test-fixtures/hud-events.jsonl new file mode 100644 index 0000000..7713b60 --- /dev/null +++ b/tui/test-fixtures/hud-events.jsonl @@ -0,0 +1,5 @@ +{"event":"UserPromptSubmit","session":"fixture","ts":1,"prompt":"Hello"} +{"event":"PreToolUse","tool":"Read","toolUseId":"tool-1","input":{"file_path":"README.md"},"response":null,"session":"fixture","ts":2} +{"event":"PostToolUse","tool":"Read","toolUseId":"tool-1","input":null,"response":{"duration_ms":200},"session":"fixture","ts":2} +{"event":"PreToolUse","tool":"TodoWrite","toolUseId":"tool-2","input":{"todos":[{"content":"Ship HUD tests","status":"in_progress"}]},"response":null,"session":"fixture","ts":3} +{"event":"Stop","tool":null,"input":null,"response":null,"session":"fixture","ts":4}