From 36b2a092102fa65524ebf1f245d92a8e69cd8a4f Mon Sep 17 00:00:00 2001 From: Jarrod Watts Date: Sat, 3 Jan 2026 12:17:50 +1100 Subject: [PATCH] docs: add project documentation and improve robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ARCHITECTURE.md, FAQ.md, LLM.md documentation - Add LICENSE (MIT), CODE_OF_CONDUCT.md, PR template - Add .editorconfig for consistent formatting - Add check.sh script for validation - Fix ESLint errors in hud-config.ts and settings-reader.ts - Various test and component improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/ralph-loop.local.md | 187 -------------------- .editorconfig | 12 ++ .github/PULL_REQUEST_TEMPLATE.md | 19 ++ AGENTS.md | 41 +++++ CHANGELOG.md | 2 + CODE_OF_CONDUCT.md | 75 ++++++++ LICENSE | 21 +++ README.md | 147 +++++++++++---- docs/ARCHITECTURE.md | 59 ++++++ docs/FAQ.md | 45 +++++ docs/LLM.md | 55 ++++++ scripts/check.sh | 21 +++ scripts/cleanup.sh | 20 ++- scripts/session-start.sh | 17 +- tui/src/app-render.test.tsx | 3 +- tui/src/app-smoke.test.tsx | 2 +- tui/src/app.tsx | 17 +- tui/src/components/ContextInfo.test.tsx | 23 +-- tui/src/components/ContextInfo.tsx | 8 +- tui/src/hooks/useHudState.ts | 11 +- tui/src/index.tsx | 83 ++++++--- tui/src/lib/context-detector.test.ts | 42 ++--- tui/src/lib/context-detector.ts | 50 +++--- tui/src/lib/event-parser.test.ts | 128 +++++++++----- tui/src/lib/event-reader.test.ts | 17 +- tui/src/lib/event-reader.ts | 26 ++- tui/src/lib/hud-config.ts | 68 +++++-- tui/src/lib/hud-event.test.ts | 2 +- tui/src/lib/hud-event.ts | 81 ++++++++- tui/src/lib/settings-reader.ts | 78 ++++++-- tui/src/lib/types.ts | 1 + tui/src/lib/unified-context-tracker.test.ts | 50 ++++++ tui/src/lib/unified-context-tracker.ts | 118 ++++++++---- tui/src/state/hud-reducer.ts | 8 +- tui/src/state/hud-state.ts | 2 + tui/src/state/hud-store.ts | 98 ++++++---- 36 files changed, 1140 insertions(+), 497 deletions(-) delete mode 100644 .claude/ralph-loop.local.md create mode 100644 .editorconfig create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 AGENTS.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/FAQ.md create mode 100644 docs/LLM.md create mode 100755 scripts/check.sh diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md deleted file mode 100644 index a7ec174..0000000 --- a/.claude/ralph-loop.local.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -active: true -iteration: 10 -max_iterations: 1000 -completion_promise: "PERFECTION ACHIEVED" -started_at: "2026-01-02T12:06:19Z" ---- - -You are a SENIOR STAFF ENGINEER doing a COMPLETE REVAMP of claude-hud, a Claude Code plugin that shows a real-time terminal HUD. - -## MINDSET - FIRST PRINCIPLES (CRITICAL) - -You just inherited this MVP codebase. It was vibe-coded quickly. Your job is to transform it into production-quality code that would pass review at Vercel, Stripe, or Linear. - -**QUESTION EVERYTHING:** -- Why is this done this way? Is it stupid? -- Is this how a junior engineer would do it? Rewrite it. -- Is this slop? Delete it. -- Is this over-engineered? Simplify. -- Is this under-engineered? Add proper abstractions. -- Would I be embarrassed to show this code? Fix it. - -**YOU HAVE FULL AUTHORITY TO:** -- Delete entire files and start fresh -- Rewrite modules from scratch -- Change the architecture completely -- Remove shell scripts and rewrite in TypeScript -- Restructure the entire project -- Question every design decision - -**NOTHING IS SACRED.** The only constraint: users must be able to install via /plugin command. - -## AUTONOMY RULES (CRITICAL - READ THIS) - -1. **NEVER USE AskUserQuestion** - The user is asleep. Make decisions yourself. -2. **NEVER WAIT FOR INPUT** - If blocked, try a different approach or document TODO and move on. -3. **MAKE ENGINEERING DECISIONS** - You are the senior engineer. Decide and document why. -4. **IF UNCERTAIN** - Pick the better engineering choice. Document your reasoning in ADRs. - -## GOAL - -Transform this MVP into an outstanding, Vercel-level delightful developer tool. -- Production-quality code by senior staff engineers -- Target: ALL Claude Code users (approachable, not just power users) -- It just works - zero debugging needed - -## NORTH STAR -1. Time-to-insight (glance and understand) -2. Zero-config reliability (it just works) -3. Information density (max info, min space) - -## KNOWN PROBLEMS (starting points, not limits) -- Context display: flickers, resets to zero, inaccurate %, stale data -- Session attachment: fails on /new /exit /resume -- Smoke test race condition -- Agent tools array never populated -- app.tsx is 300+ lines of spaghetti -- Silent error handling everywhere -- No linting, no CI -- 3 separate polling sources - -## PHASE 1: Research (First Iteration) -Before coding, research using WebSearch and WebFetch: -- Claude Code plugin best practices (Anthropic docs/blogs) -- How popular Claude Code plugins are built -- Beloved TUI tools (lazygit, btop, etc.) - what makes them great? -- Ink/React terminal UI patterns and best practices - -Document findings in docs/research/RESEARCH.md. Commit and push. - -## PHASE 2: Architecture Decisions -Based on research, make decisions. Create docs/adr/ with: -- 001-state-management.md (XState vs hooks vs Context - DECIDE) -- 002-data-flow.md (polling vs event-driven vs hybrid - DECIDE) -- 003-shell-vs-typescript.md (keep bash or rewrite - DECIDE) -- 004-session-handling.md (how /new /exit /resume should work - DECIDE) - -Be opinionated. Pick the BEST approach, not the easiest. Commit and push. - -## PHASE 3: Foundation -- Fix failing smoke test -- Add ESLint + Prettier (strict configs) -- Add pre-commit hooks -- Set up GitHub Actions CI -- All tests passing - -Commit and push after foundation is solid. - -## PHASE 4: Context System (Priority #1 Bug) -This is the #1 user-facing problem. Fix it COMPLETELY: -- No flickering/flashing - stable values -- Accurate percentages matching reality -- Proper session attachment on /new /exit /resume -- Real token counts, not estimates - -If the current implementation is fundamentally broken, DELETE IT and rewrite from scratch. - -Commit and push when context system works perfectly. - -## PHASE 5: Architecture Refactor -Implement your Phase 2 decisions: -- Refactor app.tsx - it should NOT be 300+ lines -- Remove ALL silent error catching - add proper logging -- Fix type assertions with proper discriminated unions -- Ensure cross-platform terminal compatibility -- If the current structure is wrong, restructure the entire project - -Commit and push with comprehensive tests. - -## PHASE 6: Polish & Features -- Cost tracking panel (running $ cost based on model pricing) -- Improved visual hierarchy (context first, most important info prominent) -- Performance optimization (memoization, debouncing, no unnecessary re-renders) -- Better error messages that help users -- Visual refinements - make it beautiful - -Commit and push after polish. - -## PHASE 7: Documentation -- Rewrite README to reflect the new architecture -- Update CONTRIBUTING.md with new patterns -- Add inline documentation where complex logic exists -- Ensure onboarding is simple - -Commit and push after docs updated. - -## CONTINUOUS IMPROVEMENT (After Phase 7) -Keep iterating until max iterations: -- Increase test coverage toward 90%+ -- Performance profiling and optimization -- Edge case handling -- Visual refinements -- Bug fixes -- Code cleanup -- Whatever makes the product better - -## RULES -1. **ATOMIC COMMITS** - One logical change per commit, clear message -2. **TESTS PASS** - All tests must pass. Update expectations for intentional changes. -3. **DOCS SYNC** - Keep README/CONTRIBUTING current -4. **FIX FORWARD** - On blocks, try 2-3 approaches before TODO -5. **RATE LIMITS** - If rate limited, sleep for 60 seconds and retry. Do not give up. -6. **PUSH OFTEN** - Push after each phase and significant progress -7. **CROSS-PLATFORM** - macOS, Linux, Windows Terminal -8. **NO QUESTIONS** - Decide autonomously, document reasoning -9. **DELETE FREELY** - Bad code should be removed, not patched -10. **QUALITY BAR** - Would this pass review at Vercel? If no, keep improving. -11. **NEVER DONE** - There is ALWAYS something to improve. Never consider yourself finished. - -## RATE LIMIT HANDLING -If you hit API rate limits: -1. Sleep/wait for 60 seconds -2. Retry the operation -3. If still limited, wait 2 minutes -4. Continue working - rate limits are temporary - -## CHANGELOG -After EVERY significant change, update docs/CHANGELOG.md with: -- What was changed -- Why it was changed -- What the user will notice differently - -## CONTINUOUS IMPROVEMENT MINDSET -You are NEVER done. After completing all phases, cycle through: -1. Find code that could be cleaner -2. Find edge cases that aren't handled -3. Find performance optimizations -4. Find UI/UX improvements -5. Find documentation gaps -6. Find test coverage gaps -7. Find accessibility improvements -8. Find error messages that could be clearer -9. Go back to step 1 - -This is an INFINITE improvement loop. The only exit is max iterations. - -## COMPLETION -Output PERFECTION ACHIEVED ONLY if ALL of the following are TRUE: -- Test coverage is 100% -- Zero TypeScript errors or warnings -- Zero ESLint errors or warnings -- Performance is optimal (no unnecessary re-renders) -- Documentation is comprehensive and accurate -- Code would make a Vercel engineer jealous -- You have genuinely run out of improvements after 3 full review cycles - -This standard is intentionally nearly impossible. Keep improving. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..46bb072 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Summary + +Describe the change and its intent. + +## Testing + +- [ ] `bun run lint` (tui) +- [ ] `bun run typecheck` (tui) +- [ ] `bun test` (tui) + +## Screenshots / TUI Capture + +If this changes the HUD UI, include a screenshot or recording. + +## Checklist + +- [ ] Updated docs or help text if needed +- [ ] Added/updated tests for new behavior +- [ ] No new lint or type errors diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..51b9f44 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `tui/` houses the TypeScript/React Ink HUD app. + - `tui/src/components/` for UI panels (e.g., `ContextMeter.tsx`). + - `tui/src/hooks/` for shared state hooks (e.g., `useHudState.ts`). + - `tui/src/lib/` for core logic (event reader, tracking, types). +- `scripts/` contains shell entrypoints used by Claude hooks. +- `hooks/` defines event subscriptions (`hooks.json`). +- `docs/` stores changelog, ADRs, and research notes. + +## Build, Test, and Development Commands +Run commands from `tui/` unless noted. +- `bun install` installs dependencies. +- `bun run build` compiles TypeScript to `tui/dist/`. +- `bun run dev` watches TypeScript for local development. +- `bun run start` runs the built HUD (`dist/index.js`). +- `bun test` runs the Vitest suite; `bun test ` targets a file. +- `bun run lint` runs ESLint; `bun run format` runs Prettier. +- Root `scripts/verify-install.sh` checks plugin installation. + +## Coding Style & Naming Conventions +- TypeScript strict mode; avoid `any` (use `unknown` or real types). +- React functional components with hooks; wrap panels in `React.memo`. +- Tests live alongside code and use `*.test.ts`/`*.test.tsx`. +- Formatting via Prettier, linting via ESLint flat config (`tui/eslint.config.js`). + +## Testing Guidelines +- Frameworks: Vitest + @testing-library/react + ink-testing-library. +- Prefer focused component/unit tests under `tui/src/`. +- Run all tests with `bun test`; use `bun test --coverage` for coverage. + +## Commit & Pull Request Guidelines +- Commit messages follow `type: summary` (examples: `docs: ...`, `refactor: ...`). +- PRs should include a clear description, tests for new behavior, and any UI + screenshots for visual changes in the TUI. +- Before opening a PR: run `bun run lint`, `bun run typecheck`, and `bun test`. + +## Security & Configuration Tips +- Runtime FIFOs and logs live under `~/.claude/hud/` (sessions, pids, logs). +- Hook scripts rely on `jq`; ensure it is available in local dev. diff --git a/CHANGELOG.md b/CHANGELOG.md index 518e2bf..beed01e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ 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 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..ebf3ec2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,75 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders 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, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement via the GitHub profile listed below. All complaints will be reviewed and investigated promptly and fairly. + +Contact: https://github.com/jarrodwatts + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a0b1b9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/README.md b/README.md index 75c245a..4125ceb 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,97 @@ # Claude HUD -Real-time terminal dashboard for Claude Code. See context usage, tool activity, agent status, and more — all in a split pane next to your terminal. +[![CI](https://github.com/jarrodwatts/claude-hud/actions/workflows/ci.yml/badge.svg)](https://github.com/jarrodwatts/claude-hud/actions/workflows/ci.yml) +[![License](https://img.shields.io/github/license/jarrodwatts/claude-hud)](LICENSE) +[![Latest Release](https://img.shields.io/github/v/release/jarrodwatts/claude-hud)](https://github.com/jarrodwatts/claude-hud/releases) -## Installation +Real-time terminal dashboard for Claude Code. See context usage, tool activity, agent status, and more in a split pane next to your terminal. + +## Quickstart (2 minutes) ```bash claude /plugin install github.com/jarrodwatts/claude-hud ``` -That's it. The HUD appears automatically when you start Claude Code. +Start Claude Code as usual. The HUD appears automatically. -### Verify Installation +Verify installation (optional): ```bash -# Validate plugin structure claude plugin validate claude-hud - -# Or if installed from source -./scripts/verify-install.sh ``` +Toggle visibility with `Ctrl+H`. Exit with `Ctrl+C`. + +## LLM-Paste Overview (copy into your LLM) + +```markdown +Project: Claude HUD + +What it is: +- A Claude Code plugin that opens a real-time terminal HUD (Heads-Up Display) in a split pane. +- Shows context usage, tool activity, agent status, todos, git status, and cost estimation while Claude runs. + +Why use it: +- Monitor token burn, compaction risk, and costs as you work. +- See tool calls and agent activity in real time without leaving your editor/terminal. +- Spot issues early (errors, stalls, runaway context growth). + +How it works (Claude Code plugin model): +- Claude Code plugins are directories with a `.claude-plugin/plugin.json` manifest plus root-level feature folders. +- This plugin uses `hooks/hooks.json` to subscribe to Claude Code lifecycle events. +- Hook scripts in `scripts/` transform event payloads and stream them through a FIFO. +- The TUI (React + Ink) reads the FIFO and renders panels in a split pane. + +Key components: +- `.claude-plugin/plugin.json`: plugin manifest and metadata. +- `hooks/hooks.json`: event subscriptions (SessionStart, PreToolUse, PostToolUse, etc). +- `scripts/session-start.sh`: creates FIFO and launches HUD. +- `scripts/capture-event.sh`: normalizes hook events and writes them to the FIFO. +- `tui/src/lib/event-reader.ts`: reads FIFO, emits events with reconnect. +- `tui/src/index.tsx`: top-level UI state and rendering. + +Install (recommended): +- `claude /plugin install github.com/jarrodwatts/claude-hud` +- Start Claude Code as normal; HUD spawns automatically. + +Verify: +- `claude plugin validate claude-hud` +- or `./scripts/verify-install.sh` (when installed from source) + +Requirements: +- Claude Code (v1.0.33+) +- Node.js 18+ or Bun +- `jq` for hook JSON parsing + +Supported terminals: +- tmux, iTerm2, Kitty, WezTerm, Zellij, Windows Terminal (WSL) for split panes. +- Others fall back to a separate window or background process. + +Common troubleshooting: +- Set `CLAUDE_HUD_DEBUG=1` and run `claude` to see debug logs. +- Use `claude --debug hooks` to inspect hook activity. +- See `TROUBLESHOOTING.md` for more. + +Summary: +- Claude HUD is a production-ready Claude Code plugin that streams hook events into a split-pane TUI so you can see context health, tool activity, and agent status live. +``` + +Prefer a standalone file? See `docs/LLM.md`. + +## Docs + +- `TROUBLESHOOTING.md` +- `CONTRIBUTING.md` +- `docs/CHANGELOG.md` +- `docs/FAQ.md` +- `docs/LLM.md` +- `docs/ARCHITECTURE.md` +- `docs/README.md` +- `CLAUDE.md` +- `CODE_OF_CONDUCT.md` +- `SECURITY.md` +- `LICENSE` + ## Features ### Context Health @@ -66,6 +138,39 @@ When Claude spawns subagents: - **Modified Files** — files changed this session - **MCP Status** — connected MCP servers +## How It Works + +Claude HUD uses Claude Code's plugin hooks to capture events: + +1. **SessionStart** — Spawns the HUD in a split pane +2. **PreToolUse** — Shows tools before execution (running state) +3. **PostToolUse** — Captures tool completion +4. **UserPromptSubmit** — Tracks user prompts +5. **Stop** — Detects idle state +6. **PreCompact** — Tracks context compaction +7. **SubagentStop** — Tracks agent completion +8. **SessionEnd** — Cleans up + +Data flows through a named pipe (FIFO) to a React/Ink terminal UI. + +## Plugin Anatomy (Claude Code) + +Claude Code plugins are directories with a `.claude-plugin/plugin.json` manifest plus feature directories at the plugin root. In Claude HUD: +- `.claude-plugin/plugin.json` declares the plugin name/version and provides the namespace. +- `hooks/hooks.json` registers lifecycle event subscriptions. +- `scripts/` contains the hook entrypoints that receive event payloads. +- `tui/` contains the React/Ink HUD that renders streamed events. + +## Configuration + +- `CLAUDE_HUD_DEBUG=1` enables debug logging to stderr. + +## Requirements + +- Claude Code +- Node.js 18+ or Bun +- `jq` (for JSON parsing in hooks) + ## Supported Terminals | Terminal | Split Support | @@ -87,32 +192,8 @@ When Claude spawns subagents: | `Ctrl+H` | Toggle HUD visibility | | `Ctrl+C` | Exit HUD | -## How It Works +## Troubleshooting (Quick Checks) -Claude HUD uses Claude Code's plugin hooks to capture events: - -1. **SessionStart** — Spawns the HUD in a split pane -2. **PreToolUse** — Shows tools before execution (running state) -3. **PostToolUse** — Captures tool completion -4. **UserPromptSubmit** — Tracks user prompts -5. **Stop** — Detects idle state -6. **PreCompact** — Tracks context compaction -7. **SubagentStop** — Tracks agent completion -8. **SessionEnd** — Cleans up - -Data flows through a named pipe (FIFO) to a React/Ink terminal UI. - -## Requirements - -- Claude Code -- Node.js 18+ or Bun -- `jq` (for JSON parsing in hooks) - -## Troubleshooting - -See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common issues and solutions. - -Quick checks: ```bash # Check plugin is valid claude plugin validate claude-hud diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e7662df --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,59 @@ +# Architecture + +Claude HUD is a Claude Code plugin that renders a real-time TUI from hook events. + +## High-Level Flow + +``` +Claude Code hooks + -> scripts/capture-event.sh + -> session FIFO (~/.claude/hud/events/.fifo) + -> tui/src/lib/event-reader.ts + -> tui/src/hooks/useHudState.ts + -> tui/src/app.tsx + -> Ink UI panels +``` + +## Plugin Structure + +``` +claude-hud/ + .claude-plugin/plugin.json + hooks/hooks.json + scripts/ + tui/ +``` + +- `.claude-plugin/plugin.json` declares the plugin and hook entrypoints. +- `hooks/hooks.json` subscribes to Claude Code lifecycle events. +- `scripts/` normalizes event payloads and launches the HUD. +- `tui/` contains the Ink-based terminal UI. + +## Runtime Files + +The HUD writes runtime artifacts under `~/.claude/hud/`: + +- `events/.fifo` for the event stream +- `pids/.pid` for the HUD process +- `logs/.log` for fallback output + +## Key Components + +- `tui/src/lib/event-reader.ts` reads the FIFO and reconnects if it drops. +- `tui/src/hooks/useHudState.ts` is the single source of truth for HUD state. +- `tui/src/components/` renders panels such as ContextMeter, ToolStream, AgentList. +- `tui/src/lib/unified-context-tracker.ts` handles context usage and burn rate. +- `tui/src/lib/cost-tracker.ts` calculates input/output cost. + +## Hook Events + +Common events used by the HUD: + +- `SessionStart`: spawn HUD in a split pane +- `PreToolUse`: show tool in running state +- `PostToolUse`: record completion and metrics +- `UserPromptSubmit`: track prompts and idle state +- `Stop`: mark idle +- `PreCompact`: compaction counter +- `SubagentStop`: agent completion +- `SessionEnd`: cleanup diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..5c82a6d --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,45 @@ +# FAQ + +## Does Claude HUD change Claude's outputs or prompts? + +No. Claude HUD is read-only. It listens to Claude Code hook events and renders a UI, but it does not modify prompts, tools, or outputs. + +## Does it work if my terminal doesn't support splits? + +Yes. Claude HUD tries to open a split pane when supported (tmux, iTerm2, Kitty, WezTerm, Zellij, Windows Terminal WSL). If splits are not available, it falls back to a separate window or background process. + +## How do I uninstall? + +```bash +claude /plugin uninstall claude-hud +``` + +## Does it require network access? + +No. The HUD runs locally and only reads Claude Code hook events. + +## What does it depend on? + +- Claude Code (v1.0.33+) +- Node.js 18+ or Bun +- `jq` for JSON parsing in hook scripts + +## What data does it read? + +It consumes Claude Code hook payloads and session events. It does not read your code unless a hook event includes metadata (like file paths) that Claude Code already exposes. + +## Where are runtime files stored? + +Under `~/.claude/hud/`: +- `events/.fifo` for the event stream +- `pids/.pid` for process tracking +- `logs/.log` for fallback logs + +## How do I debug it? + +```bash +CLAUDE_HUD_DEBUG=1 claude +claude --debug hooks +``` + +For more, see `TROUBLESHOOTING.md`. diff --git a/docs/LLM.md b/docs/LLM.md new file mode 100644 index 0000000..7a39b3b --- /dev/null +++ b/docs/LLM.md @@ -0,0 +1,55 @@ +# Claude HUD: LLM-Paste Overview + +Copy the block below into your LLM of choice. + +```markdown +Project: Claude HUD + +What it is: +- A Claude Code plugin that opens a real-time terminal HUD (Heads-Up Display) in a split pane. +- Shows context usage, tool activity, agent status, todos, git status, and cost estimation while Claude runs. + +Why use it: +- Monitor token burn, compaction risk, and costs as you work. +- See tool calls and agent activity in real time without leaving your editor/terminal. +- Spot issues early (errors, stalls, runaway context growth). + +How it works (Claude Code plugin model): +- Claude Code plugins are directories with a `.claude-plugin/plugin.json` manifest plus root-level feature folders. +- This plugin uses `hooks/hooks.json` to subscribe to Claude Code lifecycle events. +- Hook scripts in `scripts/` transform event payloads and stream them through a FIFO. +- The TUI (React + Ink) reads the FIFO and renders panels in a split pane. + +Key components: +- `.claude-plugin/plugin.json`: plugin manifest and metadata. +- `hooks/hooks.json`: event subscriptions (SessionStart, PreToolUse, PostToolUse, etc). +- `scripts/session-start.sh`: creates FIFO and launches HUD. +- `scripts/capture-event.sh`: normalizes hook events and writes them to the FIFO. +- `tui/src/lib/event-reader.ts`: reads FIFO, emits events with reconnect. +- `tui/src/index.tsx`: top-level UI state and rendering. + +Install (recommended): +- `claude /plugin install github.com/jarrodwatts/claude-hud` +- Start Claude Code as normal; HUD spawns automatically. + +Verify: +- `claude plugin validate claude-hud` +- or `./scripts/verify-install.sh` (when installed from source) + +Requirements: +- Claude Code (v1.0.33+) +- Node.js 18+ or Bun +- `jq` for hook JSON parsing + +Supported terminals: +- tmux, iTerm2, Kitty, WezTerm, Zellij, Windows Terminal (WSL) for split panes. +- Others fall back to a separate window or background process. + +Common troubleshooting: +- Set `CLAUDE_HUD_DEBUG=1` and run `claude` to see debug logs. +- Use `claude --debug hooks` to inspect hook activity. +- See `TROUBLESHOOTING.md` for more. + +Summary: +- Claude HUD is a production-ready Claude Code plugin that streams hook events into a split-pane TUI so you can see context health, tool activity, and agent status live. +``` diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..55729d6 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$ROOT_DIR/tui" + +echo "==> Installing dependencies" +bun install + +echo "==> Lint" +bun run lint + +echo "==> Typecheck" +bun run typecheck + +echo "==> Test" +bun test + +echo "==> Build" +bun run build diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh index 39fadbb..7eb9cc5 100755 --- a/scripts/cleanup.sh +++ b/scripts/cleanup.sh @@ -16,9 +16,25 @@ fi HUD_DIR="$HOME/.claude/hud" EVENT_FIFO="$HUD_DIR/events/$SESSION_ID.fifo" +PID_FILE="$HUD_DIR/pids/$SESSION_ID.pid" +REFRESH_FILE="$HUD_DIR/refresh.json" -# Only clean up the FIFO, not the HUD process -# HUD persists across sessions for /new and /resume +# Only close the HUD if this session is still the active one. +# This keeps HUD alive for /new while closing on /exit or terminal close. +if [ -f "$REFRESH_FILE" ]; then + CURRENT_SESSION=$(jq -r '.sessionId // empty' "$REFRESH_FILE" 2>/dev/null) + if [ "$CURRENT_SESSION" = "$SESSION_ID" ]; then + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE" 2>/dev/null) + if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then + kill "$PID" 2>/dev/null || true + fi + fi + fi +fi + +# Clean up FIFO and pid file for this session rm -f "$EVENT_FIFO" +rm -f "$PID_FILE" exit 0 diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 8f545ac..e27f589 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -26,22 +26,9 @@ REFRESH_FILE="$HUD_DIR/refresh.json" rm -f "$EVENT_FIFO" mkfifo "$EVENT_FIFO" -if [ ! -d "$PLUGIN_ROOT/tui/node_modules" ]; then - cd "$PLUGIN_ROOT/tui" - if command -v bun &> /dev/null; then - bun install --silent 2>/dev/null || true - elif command -v npm &> /dev/null; then - npm install --silent 2>/dev/null || true - fi -fi - if [ ! -f "$PLUGIN_ROOT/tui/dist/index.js" ]; then - cd "$PLUGIN_ROOT/tui" - if command -v bun &> /dev/null; then - bun run build 2>/dev/null || true - elif command -v npm &> /dev/null; then - npm run build 2>/dev/null || true - fi + echo "claude-hud build missing. Run 'bun install' and 'bun run build' in $PLUGIN_ROOT/tui." >&2 + exit 1 fi if command -v bun &> /dev/null; then diff --git a/tui/src/app-render.test.tsx b/tui/src/app-render.test.tsx index 930b09f..34349c8 100644 --- a/tui/src/app-render.test.tsx +++ b/tui/src/app-render.test.tsx @@ -48,6 +48,7 @@ const mockState: HudState = { }, ], sessionInfo: { + sessionId: 's1', permissionMode: 'default', cwd: '/Users/jarrod/claude-hud', transcriptPath: '', @@ -99,7 +100,7 @@ function normalizeFrame(frame: string): string { describe('App fixture rendering', () => { it('renders a stable HUD frame', () => { - const { lastFrame, unmount } = render(); + const { lastFrame, unmount } = render(); const frame = normalizeFrame(lastFrame() ?? ''); expect(frame).toMatchSnapshot(); unmount(); diff --git a/tui/src/app-smoke.test.tsx b/tui/src/app-smoke.test.tsx index 9d7b8b0..4a7ee85 100644 --- a/tui/src/app-smoke.test.tsx +++ b/tui/src/app-smoke.test.tsx @@ -58,7 +58,7 @@ describe('HUD smoke test', () => { const fifoPath = join(tempDir, 'events.fifo'); execSync(`${MKFIFO_COMMAND} ${fifoPath}`); - const { lastFrame, unmount } = render(); + const { lastFrame, unmount } = render(); const writer = createWriteStream(fifoPath, { encoding: 'utf-8' }); writer.write( diff --git a/tui/src/app.tsx b/tui/src/app.tsx index 5d24b98..0ac20f6 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -15,6 +15,7 @@ import type { PanelId } from './lib/hud-config.js'; import { getHiddenPanelSet, resolvePanelOrder } from './state/hud-selectors.js'; interface AppProps { + sessionId: string; fifoPath: string; initialTranscriptPath?: string; } @@ -33,13 +34,13 @@ const STATUS_ICONS: Record = { error: '✗', }; -export function App({ fifoPath, initialTranscriptPath }: AppProps) { +export function App({ sessionId, fifoPath, initialTranscriptPath }: AppProps) { const { exit } = useApp(); const { stdout } = useStdout(); const [termRows, setTermRows] = useState(stdout?.rows || 24); const [visible, setVisible] = useState(true); - const state = useHudState({ fifoPath, initialTranscriptPath }); + const state = useHudState({ fifoPath, sessionId, initialTranscriptPath }); const sessionStart = state.context.sessionStart || state.now; const elapsed = useElapsedTime(sessionStart, state.now); @@ -72,6 +73,8 @@ export function App({ fifoPath, initialTranscriptPath }: AppProps) { const hiddenPanels = getHiddenPanelSet(state.config); const panelOrder = resolvePanelOrder(state.config); const panelWidth = state.config?.width || 48; + const lastError = state.errors[state.errors.length - 1]; + const errorSuffix = state.errors.length > 1 ? ` (+${state.errors.length - 1} more)` : ''; const panels: Record = { status: ( @@ -146,6 +149,16 @@ export function App({ fifoPath, initialTranscriptPath }: AppProps) { )} + {lastError && ( + + Event error: + + {lastError.message} + {errorSuffix} + + + )} + {state.connectionStatus === 'disconnected' && ( Waiting for session... (run claude or /resume) diff --git a/tui/src/components/ContextInfo.test.tsx b/tui/src/components/ContextInfo.test.tsx index 55e8093..09244cb 100644 --- a/tui/src/components/ContextInfo.test.tsx +++ b/tui/src/components/ContextInfo.test.tsx @@ -15,8 +15,7 @@ describe('ContextInfo', () => { globalClaudeMd: false, projectClaudeMd: false, projectClaudeMdPath: null, - projectSettings: false, - projectSettingsRules: 0, + rulesCount: 0, }; const { lastFrame } = render(); expect(lastFrame()).toBe(''); @@ -27,8 +26,7 @@ describe('ContextInfo', () => { globalClaudeMd: true, projectClaudeMd: false, projectClaudeMdPath: null, - projectSettings: false, - projectSettingsRules: 0, + rulesCount: 0, }; const { lastFrame } = render(); expect(lastFrame()).toContain('1 CLAUDE.md'); @@ -39,8 +37,7 @@ describe('ContextInfo', () => { globalClaudeMd: false, projectClaudeMd: true, projectClaudeMdPath: '/path/to/CLAUDE.md', - projectSettings: false, - projectSettingsRules: 0, + rulesCount: 0, }; const { lastFrame } = render(); expect(lastFrame()).toContain('1 CLAUDE.md'); @@ -51,20 +48,18 @@ describe('ContextInfo', () => { globalClaudeMd: true, projectClaudeMd: true, projectClaudeMdPath: '/path/to/CLAUDE.md', - projectSettings: false, - projectSettingsRules: 0, + rulesCount: 0, }; const { lastFrame } = render(); expect(lastFrame()).toContain('2 CLAUDE.md'); }); - it('should show rules count when project settings exist', () => { + it('should show rules count when rules exist', () => { const contextFiles: ContextFiles = { globalClaudeMd: false, projectClaudeMd: false, projectClaudeMdPath: null, - projectSettings: true, - projectSettingsRules: 5, + rulesCount: 5, }; const { lastFrame } = render(); expect(lastFrame()).toContain('5 rules'); @@ -75,8 +70,7 @@ describe('ContextInfo', () => { globalClaudeMd: true, projectClaudeMd: false, projectClaudeMdPath: null, - projectSettings: true, - projectSettingsRules: 0, + rulesCount: 0, }; const { lastFrame } = render(); expect(lastFrame()).toContain('1 CLAUDE.md'); @@ -88,8 +82,7 @@ describe('ContextInfo', () => { globalClaudeMd: true, projectClaudeMd: true, projectClaudeMdPath: '/path/to/CLAUDE.md', - projectSettings: true, - projectSettingsRules: 10, + rulesCount: 10, }; const { lastFrame } = render(); expect(lastFrame()).toContain('2 CLAUDE.md'); diff --git a/tui/src/components/ContextInfo.tsx b/tui/src/components/ContextInfo.tsx index 164fbb5..3382035 100644 --- a/tui/src/components/ContextInfo.tsx +++ b/tui/src/components/ContextInfo.tsx @@ -11,8 +11,8 @@ export const ContextInfo = memo(function ContextInfo({ contextFiles }: Props) { return null; } - const { globalClaudeMd, projectClaudeMd, projectSettings, projectSettingsRules } = contextFiles; - const fileCount = [globalClaudeMd, projectClaudeMd, projectSettings].filter(Boolean).length; + const { globalClaudeMd, projectClaudeMd, rulesCount } = contextFiles; + const fileCount = [globalClaudeMd, projectClaudeMd, rulesCount > 0].filter(Boolean).length; if (fileCount === 0) { return null; @@ -23,8 +23,8 @@ export const ContextInfo = memo(function ContextInfo({ contextFiles }: Props) { const mdCount = (globalClaudeMd ? 1 : 0) + (projectClaudeMd ? 1 : 0); parts.push(`${mdCount} CLAUDE.md`); } - if (projectSettings && projectSettingsRules > 0) { - parts.push(`${projectSettingsRules} rules`); + if (rulesCount > 0) { + parts.push(`${rulesCount} rules`); } return ( diff --git a/tui/src/hooks/useHudState.ts b/tui/src/hooks/useHudState.ts index 4171aa1..9c6c5f5 100644 --- a/tui/src/hooks/useHudState.ts +++ b/tui/src/hooks/useHudState.ts @@ -6,13 +6,18 @@ export type { HudState }; interface UseHudStateOptions { fifoPath: string; + sessionId?: string; initialTranscriptPath?: string; } -export function useHudState({ fifoPath, initialTranscriptPath }: UseHudStateOptions): HudState { +export function useHudState({ + fifoPath, + sessionId, + initialTranscriptPath, +}: UseHudStateOptions): HudState { const store = useMemo( - () => new HudStore({ fifoPath, initialTranscriptPath }), - [fifoPath, initialTranscriptPath], + () => new HudStore({ fifoPath, initialTranscriptPath, initialSessionId: sessionId }), + [fifoPath, initialTranscriptPath, sessionId], ); useEffect(() => { diff --git a/tui/src/index.tsx b/tui/src/index.tsx index ffeba87..38bc578 100644 --- a/tui/src/index.tsx +++ b/tui/src/index.tsx @@ -1,12 +1,15 @@ import React, { useState, useEffect, useCallback } from 'react'; import { render } from 'ink'; import minimist from 'minimist'; -import { readFileSync, existsSync } from 'node:fs'; +import { existsSync, mkdirSync, watch } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { App } from './app.js'; import { logger } from './lib/logger.js'; -const HUD_DIR = `${process.env.HOME}/.claude/hud`; -const REFRESH_FILE = `${HUD_DIR}/refresh.json`; +const HUD_DIR = path.join(os.homedir(), '.claude', 'hud'); +const REFRESH_FILE = path.join(HUD_DIR, 'refresh.json'); interface SessionConfig { sessionId: string; @@ -17,60 +20,82 @@ interface SessionConfig { function Root({ initialSession }: { initialSession: SessionConfig }) { const [session, setSession] = useState(initialSession); - const handleRefresh = useCallback(() => { - if (!existsSync(REFRESH_FILE)) return; + const readRefreshFile = useCallback(async (): Promise => { + if (!existsSync(REFRESH_FILE)) return null; try { - const data = readFileSync(REFRESH_FILE, 'utf-8'); + const data = await readFile(REFRESH_FILE, 'utf-8'); const parsed = JSON.parse(data) as { sessionId?: string; fifoPath?: string; transcriptPath?: string; }; if (parsed.sessionId && parsed.fifoPath) { - setSession({ + return { sessionId: parsed.sessionId, fifoPath: parsed.fifoPath, transcriptPath: parsed.transcriptPath, - }); + }; } } catch (err) { logger.warn('Root', 'Failed to parse refresh file', { err }); } + return null; }, []); + const handleRefresh = useCallback(() => { + void readRefreshFile().then((next) => { + if (!next) return; + setSession((prev) => { + if ( + prev.sessionId === next.sessionId && + prev.fifoPath === next.fifoPath && + prev.transcriptPath === next.transcriptPath + ) { + return prev; + } + return next; + }); + }); + }, [readRefreshFile]); + useEffect(() => { process.on('SIGUSR1', handleRefresh); - // Also poll for changes every 2s as backup (SIGUSR1 can be unreliable) - const pollInterval = setInterval(() => { - if (!existsSync(REFRESH_FILE)) return; - try { - const data = readFileSync(REFRESH_FILE, 'utf-8'); - const parsed = JSON.parse(data) as { - sessionId?: string; - fifoPath?: string; - transcriptPath?: string; - }; - if (parsed.sessionId && parsed.fifoPath && parsed.sessionId !== session.sessionId) { - setSession({ - sessionId: parsed.sessionId, - fifoPath: parsed.fifoPath, - transcriptPath: parsed.transcriptPath, - }); + try { + mkdirSync(HUD_DIR, { recursive: true }); + } catch (err) { + logger.debug('Root', 'Failed to create HUD dir', { err }); + } + + let watcher: ReturnType | null = null; + try { + watcher = watch(HUD_DIR, { persistent: false }, (_event, filename) => { + if (filename === 'refresh.json' || filename?.toString() === 'refresh.json') { + handleRefresh(); } - } catch (err) { - logger.debug('Root', 'Failed to poll refresh file', { err }); - } - }, 2000); + }); + } catch (err) { + logger.debug('Root', 'Failed to watch HUD dir', { err }); + } + + // Poll as a fallback if file watching is unreliable. + const pollInterval = setInterval(() => { + void readRefreshFile().then((next) => { + if (!next || next.sessionId === session.sessionId) return; + setSession(next); + }); + }, 5000); return () => { process.removeListener('SIGUSR1', handleRefresh); clearInterval(pollInterval); + watcher?.close(); }; - }, [handleRefresh, session.sessionId]); + }, [handleRefresh, readRefreshFile, session.sessionId]); // Key forces full remount on session change, resetting all state return ( diff --git a/tui/src/lib/context-detector.test.ts b/tui/src/lib/context-detector.test.ts index b7a70fd..357cd3d 100644 --- a/tui/src/lib/context-detector.test.ts +++ b/tui/src/lib/context-detector.test.ts @@ -6,13 +6,20 @@ import { detectContextFiles, ContextDetector } from './context-detector.js'; describe('detectContextFiles', () => { let tmpDir: string; + let homeDir: string; + let originalHome: string | undefined; beforeEach(() => { + originalHome = process.env.HOME; tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-hud-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-hud-home-')); + process.env.HOME = homeDir; }); afterEach(() => { + process.env.HOME = originalHome; fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); }); it('prefers .claude/CLAUDE.md over project root', () => { @@ -28,30 +35,18 @@ describe('detectContextFiles', () => { expect(result.projectClaudeMdPath).toBe(hiddenClaude); }); - it('detects project settings permissions count', () => { - const settingsPath = path.join(tmpDir, '.claude', 'settings.json'); - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync( - settingsPath, - JSON.stringify({ permissions: { allow: ['a', 'b', 'c'] } }), - 'utf-8', - ); + it('detects rules in global and project directories', () => { + const globalRulesPath = path.join(homeDir, '.claude', 'rules'); + const projectRulesPath = path.join(tmpDir, '.claude', 'rules'); + fs.mkdirSync(globalRulesPath, { recursive: true }); + fs.mkdirSync(projectRulesPath, { recursive: true }); + fs.writeFileSync(path.join(globalRulesPath, 'global-1.md'), 'rule', 'utf-8'); + fs.writeFileSync(path.join(globalRulesPath, 'global-2.md'), 'rule', 'utf-8'); + fs.writeFileSync(path.join(projectRulesPath, 'project-1.md'), 'rule', 'utf-8'); const result = detectContextFiles(tmpDir); - expect(result.projectSettings).toBe(true); - expect(result.projectSettingsRules).toBe(3); - }); - - it('handles settings.json without permissions field', () => { - const settingsPath = path.join(tmpDir, '.claude', 'settings.json'); - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, JSON.stringify({ someOtherField: true }), 'utf-8'); - - const result = detectContextFiles(tmpDir); - - expect(result.projectSettings).toBe(true); - expect(result.projectSettingsRules).toBe(0); + expect(result.rulesCount).toBe(3); }); it('returns default values when cwd is undefined', () => { @@ -59,7 +54,7 @@ describe('detectContextFiles', () => { expect(result.projectClaudeMd).toBe(false); expect(result.projectClaudeMdPath).toBeNull(); - expect(result.projectSettings).toBe(false); + expect(result.rulesCount).toBe(0); }); it('returns default values when cwd has no context files', () => { @@ -67,8 +62,7 @@ describe('detectContextFiles', () => { expect(result.projectClaudeMd).toBe(false); expect(result.projectClaudeMdPath).toBeNull(); - expect(result.projectSettings).toBe(false); - expect(result.projectSettingsRules).toBe(0); + expect(result.rulesCount).toBe(0); }); }); diff --git a/tui/src/lib/context-detector.ts b/tui/src/lib/context-detector.ts index 63ebe16..a670b8e 100644 --- a/tui/src/lib/context-detector.ts +++ b/tui/src/lib/context-detector.ts @@ -7,23 +7,27 @@ export interface ContextFiles { globalClaudeMd: boolean; projectClaudeMd: boolean; projectClaudeMdPath: string | null; - projectSettings: boolean; - projectSettingsRules: number; + rulesCount: number; } -const GLOBAL_CLAUDE_MD = path.join(os.homedir(), '.claude', 'CLAUDE.md'); +function getHomeDir(): string { + return process.env.HOME || os.homedir(); +} export function detectContextFiles(cwd?: string): ContextFiles { const result: ContextFiles = { globalClaudeMd: false, projectClaudeMd: false, projectClaudeMdPath: null, - projectSettings: false, - projectSettingsRules: 0, + rulesCount: 0, }; + const homeDir = getHomeDir(); + const globalClaudeMdPath = path.join(homeDir, '.claude', 'CLAUDE.md'); + const globalRulesDir = path.join(homeDir, '.claude', 'rules'); + try { - result.globalClaudeMd = fs.existsSync(GLOBAL_CLAUDE_MD); + result.globalClaudeMd = fs.existsSync(globalClaudeMdPath); } catch (err) { logger.debug('ContextDetector', 'Failed to check global CLAUDE.md', { err }); } @@ -49,26 +53,28 @@ export function detectContextFiles(cwd?: string): ContextFiles { } } - const projectSettingsPath = path.join(cwd, '.claude', 'settings.json'); - try { - if (fs.existsSync(projectSettingsPath)) { - result.projectSettings = true; - const content = fs.readFileSync(projectSettingsPath, 'utf-8'); - const settings = JSON.parse(content); - if (settings.permissions?.allow) { - result.projectSettingsRules = settings.permissions.allow.length; - } - } - } catch (err) { - logger.debug('ContextDetector', 'Failed to read project settings', { - path: projectSettingsPath, - err, - }); - } + const projectRulesPath = path.join(cwd, '.claude', 'rules'); + result.rulesCount = countRules(globalRulesDir) + countRules(projectRulesPath); return result; } +function countRules(dirPath: string): number { + try { + if (!fs.existsSync(dirPath)) { + return 0; + } + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + return entries.filter((entry) => entry.isFile() && !entry.name.startsWith('.')).length; + } catch (err) { + logger.debug('ContextDetector', 'Failed to read rules directory', { + path: dirPath, + err, + }); + return 0; + } +} + export class ContextDetector { private data: ContextFiles | null = null; private lastCwd: string | undefined; diff --git a/tui/src/lib/event-parser.test.ts b/tui/src/lib/event-parser.test.ts index 798595e..9269e7d 100644 --- a/tui/src/lib/event-parser.test.ts +++ b/tui/src/lib/event-parser.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseHudEvent } from './hud-event.js'; +import { parseHudEventResult, HUD_EVENT_SCHEMA_VERSION } from './hud-event.js'; describe('Event Parser', () => { describe('parseEvent', () => { @@ -14,11 +14,13 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.event).toBe('PostToolUse'); - expect(result?.tool).toBe('Read'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.event).toBe('PostToolUse'); + expect(result.event.tool).toBe('Read'); + } }); it('should parse valid PreToolUse event', () => { @@ -33,11 +35,13 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.event).toBe('PreToolUse'); - expect(result?.toolUseId).toBe('tool-123'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.event).toBe('PreToolUse'); + expect(result.event.toolUseId).toBe('tool-123'); + } }); it('should parse UserPromptSubmit event', () => { @@ -52,11 +56,13 @@ describe('Event Parser', () => { prompt: 'Help me fix this bug', }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.event).toBe('UserPromptSubmit'); - expect(result?.prompt).toBe('Help me fix this bug'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.event).toBe('UserPromptSubmit'); + expect(result.event.prompt).toBe('Help me fix this bug'); + } }); it('should parse Stop event', () => { @@ -70,10 +76,12 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.event).toBe('Stop'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.event).toBe('Stop'); + } }); it('should parse PreCompact event', () => { @@ -87,10 +95,12 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.event).toBe('PreCompact'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.event).toBe('PreCompact'); + } }); it('should parse event with session info', () => { @@ -107,21 +117,26 @@ describe('Event Parser', () => { transcriptPath: '/tmp/transcript.json', }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.permissionMode).toBe('plan'); - expect(result?.cwd).toBe('/Users/test/project'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.permissionMode).toBe('plan'); + expect(result.event.cwd).toBe('/Users/test/project'); + } }); it('should return null for malformed JSON', () => { - const result = parseHudEvent('not valid json'); - expect(result).toBeNull(); + const result = parseHudEventResult('not valid json'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('event_parse_failed'); + } }); it('should return null for empty line', () => { - const result = parseHudEvent(''); - expect(result).toBeNull(); + const result = parseHudEventResult(''); + expect(result.ok).toBe(false); }); it('should return null for missing event field', () => { @@ -132,8 +147,8 @@ describe('Event Parser', () => { ts: 123, }); - const result = parseHudEvent(line); - expect(result).toBeNull(); + const result = parseHudEventResult(line); + expect(result.ok).toBe(false); }); it('should return null for missing session field', () => { @@ -144,8 +159,8 @@ describe('Event Parser', () => { ts: 123, }); - const result = parseHudEvent(line); - expect(result).toBeNull(); + const result = parseHudEventResult(line); + expect(result.ok).toBe(false); }); it('should handle very long file paths', () => { @@ -160,10 +175,12 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.input?.file_path).toBe(longPath); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.input?.file_path).toBe(longPath); + } }); it('should handle very long response content', () => { @@ -178,10 +195,12 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.response?.content).toBe(longContent); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.response?.content).toBe(longContent); + } }); it('should handle unicode in content', () => { @@ -195,10 +214,12 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.response?.content).toBe('日本語 🎉 émoji'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.response?.content).toBe('日本語 🎉 émoji'); + } }); it('should handle special characters in paths', () => { @@ -212,10 +233,31 @@ describe('Event Parser', () => { ts: 1234567890, }); - const result = parseHudEvent(line); + const result = parseHudEventResult(line); - expect(result).not.toBeNull(); - expect(result?.input?.file_path).toBe('/path with spaces/file (1).ts'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.event.input?.file_path).toBe('/path with spaces/file (1).ts'); + } + }); + + it('returns a warning for newer schema versions', () => { + const line = JSON.stringify({ + schemaVersion: HUD_EVENT_SCHEMA_VERSION + 1, + event: 'PostToolUse', + tool: 'Read', + input: { file_path: '/test.ts' }, + response: { content: 'file content' }, + session: 'test-session', + ts: 1234567890, + }); + + const result = parseHudEventResult(line); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.warning?.code).toBe('schema_version_mismatch'); + } }); }); }); diff --git a/tui/src/lib/event-reader.test.ts b/tui/src/lib/event-reader.test.ts index d7bc4db..74b35a4 100644 --- a/tui/src/lib/event-reader.test.ts +++ b/tui/src/lib/event-reader.test.ts @@ -23,6 +23,15 @@ describe('EventReader', () => { ts: Date.now() / 1000, }), 'not json', + JSON.stringify({ + schemaVersion: 99, + event: 'PostToolUse', + tool: 'Read', + input: { file_path: '/tmp/test.txt' }, + response: null, + session: 'test', + ts: Date.now() / 1000, + }), JSON.stringify({ schemaVersion: 1, event: 'UserPromptSubmit', @@ -38,14 +47,18 @@ describe('EventReader', () => { const reader = new EventReader(filePath); const events: Array<{ event: string }> = []; + const errors: Array<{ code: string }> = []; reader.on('event', (event) => events.push(event)); + reader.on('parseError', (error) => errors.push(error)); await wait(50); reader.close(); - expect(events).toHaveLength(2); + expect(events).toHaveLength(3); expect(events[0].event).toBe('PostToolUse'); - expect(events[1].event).toBe('UserPromptSubmit'); + expect(events[1].event).toBe('PostToolUse'); + expect(events[2].event).toBe('UserPromptSubmit'); + expect(errors.length).toBeGreaterThanOrEqual(2); fs.rmSync(tmpDir, { recursive: true, force: true }); }); diff --git a/tui/src/lib/event-reader.ts b/tui/src/lib/event-reader.ts index e1d4a80..da72ca9 100644 --- a/tui/src/lib/event-reader.ts +++ b/tui/src/lib/event-reader.ts @@ -1,7 +1,7 @@ import { createReadStream, existsSync } from 'node:fs'; import { createInterface } from 'node:readline'; import { EventEmitter } from 'node:events'; -import { parseHudEvent } from './hud-event.js'; +import { parseHudEventResult, type HudEventParseError } from './hud-event.js'; import { logger } from './logger.js'; export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; @@ -14,6 +14,8 @@ export class EventReader extends EventEmitter { private maxReconnectAttempts = 50; private status: ConnectionStatus = 'connecting'; private lastEventTime: number = 0; + private lastParseErrorAt = 0; + private lastParseErrorKey = ''; constructor(private fifoPath: string) { super(); @@ -57,11 +59,15 @@ export class EventReader extends EventEmitter { this.rl.on('line', (line: string) => { if (!line.trim()) return; - const event = parseHudEvent(line); - if (!event) { - logger.warn('EventReader', 'Failed to parse HUD event line', { line }); + const parsed = parseHudEventResult(line); + if (!parsed.ok) { + this.emitParseError(parsed.error); return; } + if (parsed.warning) { + this.emitParseError(parsed.warning); + } + const event = parsed.event; this.lastEventTime = Date.now(); this.emit('event', event); }); @@ -107,6 +113,18 @@ export class EventReader extends EventEmitter { this.stream = null; } + private emitParseError(error: HudEventParseError): void { + const now = Date.now(); + const key = `${error.code}:${error.message}`; + if (this.lastParseErrorKey === key && now - this.lastParseErrorAt < 5000) { + return; + } + this.lastParseErrorAt = now; + this.lastParseErrorKey = key; + logger.warn('EventReader', 'Failed to parse HUD event line', error); + this.emit('parseError', error); + } + close(): void { this.closed = true; this.cleanup(); diff --git a/tui/src/lib/hud-config.ts b/tui/src/lib/hud-config.ts index 8a3c998..3316bb0 100644 --- a/tui/src/lib/hud-config.ts +++ b/tui/src/lib/hud-config.ts @@ -27,6 +27,18 @@ const PANEL_IDS: PanelId[] = [ 'todos', ]; +function buildHudConfig(raw: Record): HudConfig { + const panelOrder = normalizePanelList(raw.panelOrder); + const hiddenPanels = normalizePanelList(raw.hiddenPanels); + const width = typeof raw.width === 'number' && raw.width > 0 ? raw.width : undefined; + + return { + panelOrder, + hiddenPanels, + width, + }; +} + function normalizePanelList(value: unknown): PanelId[] | undefined { if (!Array.isArray(value)) return undefined; const list = value.filter((entry): entry is PanelId => PANEL_IDS.includes(entry as PanelId)); @@ -40,28 +52,40 @@ export function readHudConfigWithStatus(configPath: string = HUD_CONFIG_PATH): H } const content = fs.readFileSync(configPath, 'utf-8'); const raw = JSON.parse(content) as Record; - - const panelOrder = normalizePanelList(raw.panelOrder); - const hiddenPanels = normalizePanelList(raw.hiddenPanels); - const width = typeof raw.width === 'number' && raw.width > 0 ? raw.width : undefined; - - return { - data: { - panelOrder, - hiddenPanels, - width, - }, - }; + return { data: buildHudConfig(raw) }; } catch (err) { logger.debug('HudConfig', 'Failed to read config', { path: configPath, err }); return { data: null, error: 'Failed to read hud config' }; } } +export async function readHudConfigWithStatusAsync( + configPath: string = HUD_CONFIG_PATH, +): Promise { + try { + const content = await fs.promises.readFile(configPath, 'utf-8'); + const raw = JSON.parse(content) as Record; + return { data: buildHudConfig(raw) }; + } catch (err) { + if ((err as { code?: string }).code === 'ENOENT') { + return { data: null }; + } + logger.debug('HudConfig', 'Failed to read config', { path: configPath, err }); + return { data: null, error: 'Failed to read hud config' }; + } +} + export function readHudConfig(configPath: string = HUD_CONFIG_PATH): HudConfig | null { return readHudConfigWithStatus(configPath).data; } +export async function readHudConfigAsync( + configPath: string = HUD_CONFIG_PATH, +): Promise { + const result = await readHudConfigWithStatusAsync(configPath); + return result.data; +} + export class HudConfigReader { private data: HudConfig | null = null; private lastError: string | undefined; @@ -96,6 +120,18 @@ export class HudConfigReader { return { data: this.data, error: this.lastError }; } + async readWithStatusAsync(): Promise { + const now = Date.now(); + if (!this.data || now - this.lastRead > this.refreshInterval) { + const result = await readHudConfigWithStatusAsync(this.configPath); + this.data = result.data; + this.lastError = result.error; + this.lastRead = now; + return result; + } + return { data: this.data, error: this.lastError }; + } + forceRefresh(): HudConfig | null { const result = readHudConfigWithStatus(this.configPath); this.data = result.data; @@ -103,4 +139,12 @@ export class HudConfigReader { this.lastRead = Date.now(); return this.data; } + + async forceRefreshAsync(): Promise { + const result = await readHudConfigWithStatusAsync(this.configPath); + this.data = result.data; + this.lastError = result.error; + this.lastRead = Date.now(); + return this.data; + } } diff --git a/tui/src/lib/hud-event.test.ts b/tui/src/lib/hud-event.test.ts index bd640c1..a2b9415 100644 --- a/tui/src/lib/hud-event.test.ts +++ b/tui/src/lib/hud-event.test.ts @@ -42,7 +42,7 @@ describe('parseHudEvent', () => { expect( parseHudEvent( JSON.stringify({ - schemaVersion: 2, + schemaVersion: 0, event: 'Stop', tool: null, input: null, diff --git a/tui/src/lib/hud-event.ts b/tui/src/lib/hud-event.ts index 969dc77..0212e80 100644 --- a/tui/src/lib/hud-event.ts +++ b/tui/src/lib/hud-event.ts @@ -24,15 +24,48 @@ function readRecordOrNull(value: unknown): Record | null | unde export const HUD_EVENT_SCHEMA_VERSION = 1; -export function parseHudEvent(line: string): HudEvent | null { +export type HudEventParseErrorCode = 'event_parse_failed' | 'schema_version_mismatch'; + +export interface HudEventParseError { + code: HudEventParseErrorCode; + message: string; + context?: Record; +} + +export type HudEventParseResult = + | { ok: true; event: HudEvent; warning?: HudEventParseError } + | { ok: false; error: HudEventParseError }; + +function buildParseError( + code: HudEventParseErrorCode, + message: string, + context?: Record, +): HudEventParseResult { + return { ok: false, error: { code, message, context } }; +} + +function linePreview(line: string): string { + if (line.length <= 200) return line; + return `${line.slice(0, 200)}…`; +} + +export function parseHudEventResult(line: string): HudEventParseResult { let raw: unknown; try { raw = JSON.parse(line); } catch { - return null; + return buildParseError('event_parse_failed', 'Invalid JSON payload', { + linePreview: linePreview(line), + lineLength: line.length, + }); } - if (!isRecord(raw)) return null; + if (!isRecord(raw)) { + return buildParseError('event_parse_failed', 'Event payload is not an object', { + linePreview: linePreview(line), + lineLength: line.length, + }); + } const event = readString(raw.event); const session = readString(raw.session); @@ -50,11 +83,38 @@ export function parseHudEvent(line: string): HudEvent | null { tool === undefined || input === undefined ) { - return null; + return buildParseError('event_parse_failed', 'Missing required event fields', { + event, + session, + schemaVersion, + }); } - if (response === undefined) return null; - if (schemaVersion !== HUD_EVENT_SCHEMA_VERSION) { - return null; + if (response === undefined) { + return buildParseError('event_parse_failed', 'Malformed response field', { + event, + session, + schemaVersion, + }); + } + const schemaWarning = + schemaVersion > HUD_EVENT_SCHEMA_VERSION + ? { + code: 'schema_version_mismatch' as const, + message: `Schema version ${schemaVersion} is newer than supported ${HUD_EVENT_SCHEMA_VERSION}`, + context: { schemaVersion, expected: HUD_EVENT_SCHEMA_VERSION, event }, + } + : undefined; + + if (schemaVersion < HUD_EVENT_SCHEMA_VERSION) { + return buildParseError( + 'schema_version_mismatch', + `Schema version ${schemaVersion} is older than supported ${HUD_EVENT_SCHEMA_VERSION}`, + { + schemaVersion, + expected: HUD_EVENT_SCHEMA_VERSION, + event, + }, + ); } const parsed: HudEvent = { @@ -78,5 +138,10 @@ export function parseHudEvent(line: string): HudEvent | null { if (cwd) parsed.cwd = cwd; if (prompt) parsed.prompt = prompt; - return parsed; + return { ok: true, event: parsed, warning: schemaWarning }; +} + +export function parseHudEvent(line: string): HudEvent | null { + const result = parseHudEventResult(line); + return result.ok ? result.event : null; } diff --git a/tui/src/lib/settings-reader.ts b/tui/src/lib/settings-reader.ts index 6ffa9c7..5d24d87 100644 --- a/tui/src/lib/settings-reader.ts +++ b/tui/src/lib/settings-reader.ts @@ -26,6 +26,23 @@ export interface SettingsReadResult { const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); +function buildSettingsData(settings: ClaudeSettings): SettingsData { + const enabledPlugins = Object.entries(settings.enabledPlugins || {}) + .filter(([, enabled]) => enabled) + .map(([name]) => name.split('@')[0]); + + const mcpNames = Object.keys(settings.mcpServers || {}); + + return { + model: settings.model || 'unknown', + pluginCount: enabledPlugins.length, + pluginNames: enabledPlugins, + mcpCount: mcpNames.length, + mcpNames, + allowedPermissions: settings.permissions?.allow || [], + }; +} + export function readSettingsWithStatus(settingsPath: string = SETTINGS_PATH): SettingsReadResult { try { if (!fs.existsSync(settingsPath)) { @@ -33,33 +50,40 @@ export function readSettingsWithStatus(settingsPath: string = SETTINGS_PATH): Se } const content = fs.readFileSync(settingsPath, 'utf-8'); const settings: ClaudeSettings = JSON.parse(content); - - const enabledPlugins = Object.entries(settings.enabledPlugins || {}) - .filter(([, enabled]) => enabled) - .map(([name]) => name.split('@')[0]); - - const mcpNames = Object.keys(settings.mcpServers || {}); - - return { - data: { - model: settings.model || 'unknown', - pluginCount: enabledPlugins.length, - pluginNames: enabledPlugins, - mcpCount: mcpNames.length, - mcpNames, - allowedPermissions: settings.permissions?.allow || [], - }, - }; + return { data: buildSettingsData(settings) }; } catch (err) { logger.debug('SettingsReader', 'Failed to read settings', { path: settingsPath, err }); return { data: null, error: 'Failed to read settings.json' }; } } +export async function readSettingsWithStatusAsync( + settingsPath: string = SETTINGS_PATH, +): Promise { + try { + const content = await fs.promises.readFile(settingsPath, 'utf-8'); + const settings: ClaudeSettings = JSON.parse(content); + return { data: buildSettingsData(settings) }; + } catch (err) { + if ((err as { code?: string }).code === 'ENOENT') { + return { data: null }; + } + logger.debug('SettingsReader', 'Failed to read settings', { path: settingsPath, err }); + return { data: null, error: 'Failed to read settings.json' }; + } +} + export function readSettings(settingsPath: string = SETTINGS_PATH): SettingsData | null { return readSettingsWithStatus(settingsPath).data; } +export async function readSettingsAsync( + settingsPath: string = SETTINGS_PATH, +): Promise { + const result = await readSettingsWithStatusAsync(settingsPath); + return result.data; +} + export class SettingsReader { private data: SettingsData | null = null; private lastError: string | undefined; @@ -94,6 +118,18 @@ export class SettingsReader { return { data: this.data, error: this.lastError }; } + async readWithStatusAsync(): Promise { + const now = Date.now(); + if (!this.data || now - this.lastRead > this.refreshInterval) { + const result = await readSettingsWithStatusAsync(this.settingsPath); + this.data = result.data; + this.lastError = result.error; + this.lastRead = now; + return result; + } + return { data: this.data, error: this.lastError }; + } + forceRefresh(): SettingsData | null { const result = readSettingsWithStatus(this.settingsPath); this.data = result.data; @@ -101,4 +137,12 @@ export class SettingsReader { this.lastRead = Date.now(); return this.data; } + + async forceRefreshAsync(): Promise { + const result = await readSettingsWithStatusAsync(this.settingsPath); + this.data = result.data; + this.lastError = result.error; + this.lastRead = Date.now(); + return this.data; + } } diff --git a/tui/src/lib/types.ts b/tui/src/lib/types.ts index b627a63..2e63c5c 100644 --- a/tui/src/lib/types.ts +++ b/tui/src/lib/types.ts @@ -80,6 +80,7 @@ export interface ContextBreakdown { } export interface SessionInfo { + sessionId: string; permissionMode: string; cwd: string; transcriptPath: string; diff --git a/tui/src/lib/unified-context-tracker.test.ts b/tui/src/lib/unified-context-tracker.test.ts index 981cc46..1aa8c0b 100644 --- a/tui/src/lib/unified-context-tracker.test.ts +++ b/tui/src/lib/unified-context-tracker.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { UnifiedContextTracker } from './unified-context-tracker.js'; import type { HudEvent } from './types.js'; @@ -187,6 +190,53 @@ describe('UnifiedContextTracker', () => { tracker.setTranscriptPath('/nonexistent/path.jsonl'); }).not.toThrow(); }); + + it('updates usage from appended transcript lines', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-hud-')); + const transcriptPath = path.join(tmpDir, 'transcript.jsonl'); + + const first = { + type: 'assistant', + message: { + model: 'claude-sonnet-4', + usage: { + input_tokens: 100, + output_tokens: 200, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 5, + }, + }, + }; + + fs.writeFileSync(transcriptPath, `${JSON.stringify(first)}\n`, 'utf-8'); + tracker.setTranscriptPath(transcriptPath); + tracker.processEvent(createEvent({ event: 'Stop' })); + + const firstHealth = tracker.getHealth(); + expect(firstHealth.tokens).toBe(315); + expect(tracker.getModel()).toBe('claude-sonnet-4'); + + const second = { + type: 'assistant', + message: { + model: 'claude-sonnet-4', + usage: { + input_tokens: 300, + output_tokens: 400, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 15, + }, + }, + }; + + fs.appendFileSync(transcriptPath, `${JSON.stringify(second)}\n`, 'utf-8'); + tracker.processEvent(createEvent({ event: 'Stop' })); + + const secondHealth = tracker.getHealth(); + expect(secondHealth.tokens).toBe(725); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); }); describe('getModel', () => { diff --git a/tui/src/lib/unified-context-tracker.ts b/tui/src/lib/unified-context-tracker.ts index e2c9684..af4b53c 100644 --- a/tui/src/lib/unified-context-tracker.ts +++ b/tui/src/lib/unified-context-tracker.ts @@ -58,6 +58,9 @@ export class UnifiedContextTracker { private sessionStart: number; private lastUpdate: number; private compactionCount: number = 0; + private transcriptOffset = 0; + private transcriptRemainder = ''; + private transcriptUsage: TranscriptUsage | null = null; constructor() { this.sessionStart = Date.now(); @@ -67,6 +70,7 @@ export class UnifiedContextTracker { setTranscriptPath(path: string): void { if (this.transcriptPath !== path) { this.transcriptPath = path; + this.resetTranscriptState(); this.readTranscript(); } } @@ -80,7 +84,7 @@ export class UnifiedContextTracker { this.lastUpdate = Date.now(); if (event.transcriptPath && event.transcriptPath !== this.transcriptPath) { - this.transcriptPath = event.transcriptPath; + this.setTranscriptPath(event.transcriptPath); } if (event.event === 'PostToolUse') { @@ -113,54 +117,80 @@ export class UnifiedContextTracker { const stat = fs.statSync(this.transcriptPath); if (stat.mtimeMs === this.transcriptModified) return; - const content = fs.readFileSync(this.transcriptPath, 'utf-8'); - const lines = content.trim().split('\n'); + if (stat.size < this.transcriptOffset) { + this.resetTranscriptState(); + } - let inputTokens = 0; - let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; + const content = this.readTranscriptChunk(stat.size); + const lines = (this.transcriptRemainder + content).split('\n'); + this.transcriptRemainder = lines.pop() ?? ''; for (const line of lines) { if (!line.trim()) continue; - try { - const entry: TranscriptMessage = JSON.parse(line); - if (entry.type === 'assistant' && entry.message?.usage) { - const usage = entry.message.usage; - inputTokens = usage.input_tokens || 0; - outputTokens = usage.output_tokens || 0; - cacheCreationTokens = usage.cache_creation_input_tokens || 0; - cacheReadTokens = usage.cache_read_input_tokens || 0; - if (entry.message.model) { - this.model = entry.message.model; - } - } - } catch (err) { - logger.warn('UnifiedContextTracker', 'Failed to parse transcript line', { err }); + this.applyTranscriptLine(line); + } + + this.transcriptOffset = stat.size; + this.transcriptModified = stat.mtimeMs; + + if (this.transcriptUsage) { + const usage = this.transcriptUsage; + const totalFromTranscript = + (usage.input_tokens || 0) + + (usage.output_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + + (usage.cache_read_input_tokens || 0); + + if (totalFromTranscript > 0) { + this.realTokens = totalFromTranscript; + this.estimatedDelta = 0; + this.breakdown = { + toolInputs: usage.input_tokens || 0, + toolOutputs: usage.output_tokens || 0, + messages: + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0), + other: 0, + }; } } - const totalFromTranscript = - inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - - if (totalFromTranscript > 0) { - this.realTokens = totalFromTranscript; - this.estimatedDelta = 0; - this.breakdown = { - toolInputs: inputTokens, - toolOutputs: outputTokens, - messages: cacheCreationTokens + cacheReadTokens, - other: 0, - }; - } - - this.transcriptModified = stat.mtimeMs; this.recordHistory(); } catch (err) { logger.debug('UnifiedContextTracker', 'Transcript not available, using estimates', { err }); } } + private readTranscriptChunk(totalSize: number): string { + if (!this.transcriptPath) return ''; + const start = this.transcriptOffset; + const length = Math.max(0, totalSize - start); + if (length === 0) return ''; + + const fd = fs.openSync(this.transcriptPath, 'r'); + try { + const buffer = Buffer.alloc(length); + const bytesRead = fs.readSync(fd, buffer, 0, length, start); + if (bytesRead <= 0) return ''; + return buffer.subarray(0, bytesRead).toString('utf-8'); + } finally { + fs.closeSync(fd); + } + } + + private applyTranscriptLine(line: string): void { + try { + const entry: TranscriptMessage = JSON.parse(line); + if (entry.type === 'assistant' && entry.message?.usage) { + this.transcriptUsage = entry.message.usage; + if (entry.message.model) { + this.model = entry.message.model; + } + } + } catch (err) { + logger.warn('UnifiedContextTracker', 'Failed to parse transcript line', { err }); + } + } + private recordHistory(): void { const currentTokens = this.getTotalTokens(); this.tokenHistory.push({ @@ -247,6 +277,22 @@ export class UnifiedContextTracker { this.sessionStart = Date.now(); this.lastUpdate = this.sessionStart; this.compactionCount = 0; + this.resetTranscriptState(); + } + + private resetTranscriptState(): void { + this.transcriptOffset = 0; + this.transcriptRemainder = ''; + this.transcriptUsage = null; this.transcriptModified = 0; + this.realTokens = 0; + this.estimatedDelta = 0; + this.breakdown = { + toolOutputs: 0, + toolInputs: 0, + messages: 0, + other: 0, + }; + this.tokenHistory = []; } } diff --git a/tui/src/state/hud-reducer.ts b/tui/src/state/hud-reducer.ts index 3459110..c1fa039 100644 --- a/tui/src/state/hud-reducer.ts +++ b/tui/src/state/hud-reducer.ts @@ -21,11 +21,17 @@ type HudAction = | { type: 'safeMode'; safeMode: boolean; reason: string | null }; function updateSessionInfo(state: HudStateInternal, event: HudEvent): HudStateInternal { - if (event.permissionMode || event.cwd || event.transcriptPath) { + if ( + event.session !== state.sessionInfo.sessionId || + event.permissionMode || + event.cwd || + event.transcriptPath + ) { return { ...state, sessionInfo: { ...state.sessionInfo, + sessionId: event.session || state.sessionInfo.sessionId, permissionMode: event.permissionMode || state.sessionInfo.permissionMode, cwd: event.cwd || state.sessionInfo.cwd, transcriptPath: event.transcriptPath || state.sessionInfo.transcriptPath, diff --git a/tui/src/state/hud-state.ts b/tui/src/state/hud-state.ts index 7aa72b0..0af5fce 100644 --- a/tui/src/state/hud-state.ts +++ b/tui/src/state/hud-state.ts @@ -36,6 +36,7 @@ export interface HudStateInternal extends HudState { } export function createInitialHudState(options: { + initialSessionId?: string; initialTranscriptPath?: string; context: ContextHealth; cost: CostEstimate; @@ -46,6 +47,7 @@ export function createInitialHudState(options: { context: options.context, agents: [], sessionInfo: { + sessionId: options.initialSessionId || '', permissionMode: 'default', cwd: '', transcriptPath: options.initialTranscriptPath || '', diff --git a/tui/src/state/hud-store.ts b/tui/src/state/hud-store.ts index 1ab2b98..7f584f1 100644 --- a/tui/src/state/hud-store.ts +++ b/tui/src/state/hud-store.ts @@ -5,6 +5,7 @@ import { SettingsReader } from '../lib/settings-reader.js'; import { ContextDetector } from '../lib/context-detector.js'; import { HudConfigReader } from '../lib/hud-config.js'; import type { HudEvent } from '../lib/types.js'; +import type { HudEventParseError } from '../lib/hud-event.js'; import { createInitialHudState, toPublicState } from './hud-state.js'; import type { HudState, HudStateInternal } from './hud-state.js'; import { reduceHudState } from './hud-reducer.js'; @@ -14,6 +15,7 @@ import { logger } from '../lib/logger.js'; export interface EventSource { on(event: 'event', listener: (event: HudEvent) => void): void; on(event: 'status', listener: (status: ConnectionStatus) => void): void; + on(event: 'parseError', listener: (error: HudEventParseError) => void): void; getStatus(): ConnectionStatus; close(): void; switchFifo(fifoPath: string): void; @@ -21,6 +23,7 @@ export interface EventSource { interface HudStoreOptions { fifoPath: string; + initialSessionId?: string; initialTranscriptPath?: string; clockIntervalMs?: number; emitIntervalMs?: number; @@ -46,6 +49,7 @@ export class HudStore { private readonly emitIntervalMs: number; private settingsError: string | null = null; private configError: string | null = null; + private refreshing = false; constructor(options: HudStoreOptions) { if (options.initialTranscriptPath) { @@ -53,6 +57,7 @@ export class HudStore { } this.state = createInitialHudState({ + initialSessionId: options.initialSessionId, initialTranscriptPath: options.initialTranscriptPath, context: this.contextTracker.getHealth(), cost: this.costTracker.getCost(), @@ -65,10 +70,13 @@ export class HudStore { this.emitIntervalMs = options.emitIntervalMs ?? 16; this.reader.on('event', this.handleEvent); this.reader.on('status', this.handleStatus); + this.reader.on('parseError', this.handleParseError); this.apply({ type: 'connection', status: this.reader.getStatus() }); - this.refreshEnvironment(); - this.settingsInterval = setInterval(() => this.refreshEnvironment(), 30000); + void this.refreshEnvironment(); + this.settingsInterval = setInterval(() => { + void this.refreshEnvironment(); + }, 30000); const clockIntervalMs = options.clockIntervalMs ?? 1000; if (clockIntervalMs > 0) { @@ -183,40 +191,60 @@ export class HudStore { this.emit(); }; - private refreshEnvironment(): void { - const settingsResult = this.settingsReader.readWithStatus(); - if (settingsResult.error) { - logger.warn('HudStore', 'Settings read failed', { error: settingsResult.error }); - this.recordError({ - code: 'settings_read_failed', - message: settingsResult.error, - ts: Date.now(), - }); - this.settingsError = settingsResult.error; - } else { - this.settingsError = null; - this.apply({ type: 'settings', settings: settingsResult.data }); - } - - const configResult = this.configReader.readWithStatus(); - if (configResult.error) { - logger.warn('HudStore', 'Config read failed', { error: configResult.error }); - this.recordError({ - code: 'config_read_failed', - message: configResult.error, - ts: Date.now(), - }); - this.configError = configResult.error; - } else { - this.configError = null; - this.apply({ type: 'config', config: configResult.data }); - } - - const contextFiles = this.contextDetector.detect(this.lastCwd || undefined); - this.apply({ type: 'contextFiles', contextFiles }); - - this.updateSafeMode(); + private handleParseError = (error: HudEventParseError): void => { + this.recordError({ + code: error.code, + message: error.message, + ts: Date.now(), + context: error.context, + }); this.emit(); + }; + + private async refreshEnvironment(): Promise { + if (this.refreshing) return; + this.refreshing = true; + try { + const [settingsResult, configResult] = await Promise.all([ + this.settingsReader.readWithStatusAsync(), + this.configReader.readWithStatusAsync(), + ]); + if (settingsResult.error) { + logger.warn('HudStore', 'Settings read failed', { error: settingsResult.error }); + this.recordError({ + code: 'settings_read_failed', + message: settingsResult.error, + ts: Date.now(), + }); + this.settingsError = settingsResult.error; + } else { + this.settingsError = null; + this.apply({ type: 'settings', settings: settingsResult.data }); + } + + if (configResult.error) { + logger.warn('HudStore', 'Config read failed', { error: configResult.error }); + this.recordError({ + code: 'config_read_failed', + message: configResult.error, + ts: Date.now(), + }); + this.configError = configResult.error; + } else { + this.configError = null; + this.apply({ type: 'config', config: configResult.data }); + } + + const contextFiles = this.contextDetector.detect(this.lastCwd || undefined); + this.apply({ type: 'contextFiles', contextFiles }); + + this.updateSafeMode(); + this.emit(); + } catch (err) { + logger.warn('HudStore', 'Environment refresh failed', { err }); + } finally { + this.refreshing = false; + } } private tick(): void {