diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cdd594 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# claude-usage-widget + +A small always-on-top Windows desktop widget that visualizes your local Claude Code usage: + +- **Current 5-hour session block** — progress ring + countdown to block end. +- **Past 7 days** — daily bars + total. +- **Per-model breakdown** — Opus / Sonnet / Haiku stacked across the current block. + +Reads `~/.claude/projects/**/*.jsonl` directly. No Anthropic API. No auth. + +``` +┌────────────── Claude Usage ───────╳ ─┐ +│ │ +│ ╭─────╮ │ +│ ╱ ╲ │ +│ │ 47k │ ← 5-hour block │ +│ │ 24% │ │ +│ ╲ 3h 12m╱ │ +│ ╰─────╯ │ +│ │ +│ Models (current block) │ +│ ▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱ │ +│ ● Opus 32k ● Sonnet 11k │ +│ │ +│ 7-day total 842k · 42% │ +│ ▁▃▆█▂▅▇ │ +│ Sat Sun Mon Tue Wed Thu Fri │ +└───────────────────────────────────────┘ +``` + +## What this is *not* + +It does not call the Anthropic API and cannot show server-side ground-truth caps. The "5-hour" and "weekly" numbers are derived from your local JSONL transcripts — the same data `ccusage` operates on. The cap values shown in the percentage and the warning thresholds are user-configurable in Settings. + +## Architecture (one paragraph) + +A Tauri 2 app with a Rust backend and a Svelte 5 + Vite + TS frontend. The Rust side enumerates `~/.claude/projects/**/*.jsonl`, tail-parses each file (resuming from a cached byte offset so we never re-parse already-seen lines), dedupes events by `requestId || uuid` (subagent transcripts overlap parents), aggregates into 5-hour blocks (ccusage-equivalent algorithm: `block_start = floor_to_hour(first_ts)`, `block_end = block_start + 5h`, new block on ≥5h gap or end-of-block), computes a rolling 7-day window in the user's local timezone, and emits a `usage-updated` event whenever anything changes. A `notify-debouncer-full` watcher fires on file changes; a 60s `tokio::time::interval` poll backstops it because `ReadDirectoryChangesW` on the WSL `\\wsl$\` 9P mount can miss events. The widget window is frameless, transparent, `alwaysOnTop`, `skipTaskbar`, and 280×360 px; drag via the custom title bar. + +## Build & run + +You need a **Windows** host with the Tauri 2 toolchain — see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). Quick version: + +```powershell +winget install Rustlang.Rustup OpenJS.NodeJS.LTS +rustup default stable-x86_64-pc-windows-msvc +npm i -g pnpm +# Also: MSVC C++ Build Tools + Windows SDK (Visual Studio Installer), +# and Microsoft Edge WebView2 Runtime (preinstalled on Windows 11). +``` + +Then, from this directory: + +```powershell +pnpm install +pnpm tauri dev # iterate +pnpm tauri build # NSIS installer in src-tauri\target\release\bundle\nsis\ +``` + +If you're developing in WSL but building on Windows, the WSL filesystem is mounted at `\\wsl$\\` from Windows; clone or copy this folder onto the Windows side (or work directly via the UNC path) before running `pnpm tauri build` — Tauri itself needs the MSVC linker. + +> **Icons.** Before `pnpm tauri build` succeeds, drop a 1024×1024 PNG into `src-tauri/icons/source.png` and run `pnpm tauri icon src-tauri/icons/source.png` to generate every required size. + +## Configuration + +`%APPDATA%\claude-widget\config.json` (auto-created on first run): + +```jsonc +{ + "caps": { + "block_tokens": 200000, // 5h block cap — placeholder default + "weekly_tokens": 2000000 // 7d weekly cap — placeholder default + }, + "wsl_distro_override": null, // null = autodetect via `wsl.exe -l -q` + "include_native": true, // also scan %USERPROFILE%\.claude\projects + "window_pos": null, + "autostart": false +} +``` + +Everything except `window_pos` is editable in the in-app Settings panel (gear icon). + +## Verification checklist + +1. **Cold parse correctness** — compare `BlockRing` total to: + ```bash + jq -s '[.[]|select(.type=="assistant")|.message.usage|(.input_tokens+.output_tokens+.cache_creation_input_tokens+.cache_read_input_tokens)]|add' \ + ~/.claude/projects//.jsonl + ``` +2. **Block boundary** — `scripts\seed-fake-jsonl.ps1 -OffsetHours -6` appends a back-dated synthetic line; confirm it produces a *new* prior block instead of folding into the active one. +3. **Dedupe** — duplicate one assistant line into the matching `subagents/.jsonl`; total must not double. +4. **Live tail** — start a real `claude` session in WSL; the ring should tick up within a couple of seconds (debouncer ~250 ms). +5. **Watcher fallback** — set `WIDGET_NO_WATCH=1` in the env (TODO: wire this up if needed) and append a line; the next 60s poll picks it up. +6. **Fake feed** — `scripts\seed-fake-jsonl.ps1` writes a synthetic assistant line; UI updates without restart. +7. **WSL detection** — switch `wsl_distro_override` in Settings to a distro with no `.claude/`; snapshot goes empty. +8. **Autostart** — toggle on, reboot, confirm widget appears (Task Manager → Startup tab); toggle off, reboot, confirm it doesn't. +9. **Transparency / drag** — no chrome; title-bar drag moves the window; position survives restart. +10. **Memory ceiling** — 24h soak, expect 40–80 MB RSS. + +## Files of interest + +| File | Purpose | +|---|---| +| `src-tauri/src/jsonl.rs` | Streaming parse + model normalization + dedupe key | +| `src-tauri/src/usage.rs` | 5-hour blocks, weekly window, snapshot builder (incl. unit tests) | +| `src-tauri/src/watch.rs` | `notify` debouncer + 60s poll fallback + emit | +| `src-tauri/src/paths.rs` | WSL detection, `\\wsl$\…` UNC path resolution | +| `src-tauri/src/commands.rs`| Tauri `#[command]` IPC surface | +| `src-tauri/tauri.conf.json`| Frameless / transparent / always-on-top window config | +| `src/components/*.svelte` | UI | + +## License + +Private project. Not published. diff --git a/scripts/seed-fake-jsonl.ps1 b/scripts/seed-fake-jsonl.ps1 new file mode 100644 index 0000000..30ef971 --- /dev/null +++ b/scripts/seed-fake-jsonl.ps1 @@ -0,0 +1,83 @@ +# Verification helper: append a synthetic assistant line to a JSONL under one +# of the resolved roots, so the watcher/poll can be exercised without running +# a real Claude Code session. +# +# Usage (from PowerShell on the Windows host): +# .\scripts\seed-fake-jsonl.ps1 +# .\scripts\seed-fake-jsonl.ps1 -InputTokens 5000 -OutputTokens 1500 -Model claude-sonnet-4-6 +# .\scripts\seed-fake-jsonl.ps1 -OffsetHours -6 # back-date by 6h to test block boundary + +[CmdletBinding()] +param( + [string] $Distro = $null, # null → first distro found + [string] $Model = "claude-opus-4-7", + [int] $InputTokens = 100, + [int] $OutputTokens = 500, + [int] $CacheCreate = 0, + [int] $CacheRead = 0, + [double] $OffsetHours = 0, # back/forward date the timestamp + [string] $Path = $null # explicit JSONL path; overrides discovery +) + +function Find-RootJsonl { + param([string] $Distro) + + if ($Path) { return $Path } + + $candidates = @() + + # Native first. + $native = Join-Path $env:USERPROFILE ".claude\projects" + if (Test-Path $native) { $candidates += $native } + + # WSL. + if (-not $Distro) { + $distros = (& wsl.exe -l -q) | Where-Object { $_ -and $_.Trim() } + if ($distros) { $Distro = $distros[0].Trim() } + } + if ($Distro) { + $homeDir = "\\wsl$\$Distro\home" + if (Test-Path $homeDir) { + Get-ChildItem $homeDir -Directory | ForEach-Object { + $p = Join-Path $_.FullName ".claude\projects" + if (Test-Path $p) { $candidates += $p } + } + } + } + + if (-not $candidates) { throw "No .claude/projects found (native or WSL)." } + + $root = $candidates[0] + Write-Host "Using root: $root" + + # Use a dedicated synthetic project dir so we don't pollute real transcripts. + $project = Join-Path $root "-fake-claude-usage-widget" + New-Item -ItemType Directory -Force -Path $project | Out-Null + return Join-Path $project "synthetic.jsonl" +} + +$target = Find-RootJsonl -Distro $Distro +$ts = (Get-Date).ToUniversalTime().AddHours($OffsetHours).ToString("o") +$uuid = [guid]::NewGuid().ToString() +$reqId = "req_" + ([guid]::NewGuid().ToString("N").Substring(0, 16)) + +$line = @{ + type = "assistant" + timestamp = $ts + sessionId = $uuid + requestId = $reqId + uuid = $uuid + message = @{ + model = $Model + usage = @{ + input_tokens = $InputTokens + output_tokens = $OutputTokens + cache_creation_input_tokens = $CacheCreate + cache_read_input_tokens = $CacheRead + } + } +} | ConvertTo-Json -Compress -Depth 6 + +Add-Content -Path $target -Value $line -Encoding UTF8 +Write-Host "Appended to $target" +Write-Host $line