diff --git a/README.md b/README.md index 8cdd594..3807775 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,105 @@ -# claude-usage-widget +# 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. +A small always-on-top Windows desktop widget that shows your live Claude +subscription usage — the same percentages Claude Code's `/usage` command +displays, refreshed every 5 minutes. ``` -┌────────────── 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 │ -└───────────────────────────────────────┘ +┌─────── Claude Usage ────────────╳ ─┐ +│ │ +│ ╭───────╮ │ +│ ╱ 72% ╲ │ +│ │ session │ │ +│ ╲ resets ╱ │ +│ ╰ 2:50am ╯ │ +│ │ +│ Models (current block) │ +│ ▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱ │ +│ ● Opus 42M ● Haiku 3M │ +│ │ +│ Weekly limits resets May 9 │ +│ All models ▆░░░░░ 8% │ +│ Sonnet ▃░░░░░ 5% │ +└─────────────────────────────────────┘ ``` -## What this is *not* +## Install -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. +1. **Download** the latest `Claude.Usage.Widget__x64-setup.exe` + from the Forgejo releases page. +2. **Run the installer.** Windows SmartScreen will warn "unrecognized publisher" + (it's not code-signed). Click **More info → Run anyway**. +3. The widget pops up in the upper-left corner. Drag it where you want. -## Architecture (one paragraph) +## Requirements -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. +- **Windows 10/11** with [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) + (preinstalled on Windows 11; downloadable on Windows 10). +- **[Claude Code](https://docs.claude.com/en/docs/claude-code)** installed and signed in. + - Native Windows install (`claude.exe` on PATH) → works automatically. + - Or installed inside WSL → also works automatically; the widget probes each + distro to find one with `claude`. -## Build & run +## Using it -You need a **Windows** host with the Tauri 2 toolchain — see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). Quick version: +- The big ring shows your **current 5-hour session** percentage with a reset countdown. +- The bars below show the **rolling weekly** limits (all models + Sonnet only). +- Per-model breakdown (Opus / Sonnet / Haiku) shows how much of *your local + current 5-hour block* came from each — derived from your local Claude Code + transcripts. (Anthropic's `/usage` doesn't break this out, so we compute it + ourselves.) +- **↻** button (title bar) — force-refresh `/usage` right now. +- **⚙** — Settings (custom claude command, refresh interval, autostart, distro override). +- **×** — quit. +- The window is **resizable** — drag any edge. + +## Troubleshooting + +**"Claude Code not found"** — the widget couldn't find a `claude` to run. Make sure +`claude --version` works in either PowerShell or a WSL shell. If it works in +a non-default WSL distro, open Settings and set **claude command** to e.g. +`wsl.exe -d Debian bash -lc claude`. + +**Ring shows "no /usage data"** — the spawn worked but Anthropic's output didn't +match the parser. Open Settings → Test /usage now → expand the *raw output* +disclosure and file an issue with what's there. + +**SmartScreen blocks the installer** — expected; the binary isn't code-signed. +"More info → Run anyway". + +**Autostart toggle doesn't survive reboot** — that's the dev build limitation. +The released installer registers a stable path so autostart works correctly there. + +## Privacy + +Everything stays on your machine. The widget: + +- Reads your local Claude Code config (`~/.claude/projects/`) for the per-model breakdown. +- Spawns `claude /usage` to read live percentages — that command Anthropic + serves from their backend over your existing Claude Code session, exactly + the same as when you type `/usage` interactively. The widget never sees + your OAuth token. +- Stores its own settings at `%APPDATA%\claude-widget\config.json`. +- Makes no other network calls. + +## Building from source + +You need a **Windows host** with: ```powershell -winget install Rustlang.Rustup OpenJS.NodeJS.LTS +winget install Rustlang.Rustup OpenJS.NodeJS.LTS Microsoft.VisualStudio.2022.BuildTools 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). +corepack enable +corepack prepare pnpm@latest --activate ``` -Then, from this directory: +Then in this repo: ```powershell pnpm install -pnpm tauri dev # iterate -pnpm tauri build # NSIS installer in src-tauri\target\release\bundle\nsis\ +pnpm tauri dev # iterate +pnpm tauri build # produces 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. +Project layout, architecture decisions, and known follow-ups live in +[`memory.md`](./memory.md). diff --git a/scripts/make-icon.py b/scripts/make-icon.py new file mode 100644 index 0000000..33b0cd0 --- /dev/null +++ b/scripts/make-icon.py @@ -0,0 +1,72 @@ +""" +Regenerate src-tauri/icons/source.png — dark rounded square + purple +progress ring + white 'C'. Matches the running widget's visual language. + +Run from the project root: + python3 scripts/make-icon.py + pnpm tauri icon src-tauri/icons/source.png # generates ico/icns/etc +""" + +import math +import os +import sys +from pathlib import Path + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + sys.stderr.write("Pillow not installed: pip install --user Pillow\n") + sys.exit(2) + +SIZE = 1024 +PAD = 40 +RADIUS = 180 +RING_PAD = 200 +STROKE = 60 +PROGRESS = 0.72 # decorative; matches the widget at ~real usage + +BG = (22, 24, 32, 255) +TRACK = (255, 255, 255, 26) +ACCENT = (176, 139, 255, 255) +FG = (232, 234, 240, 255) + + +def main(out: Path) -> None: + img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + draw.rounded_rectangle([PAD, PAD, SIZE - PAD, SIZE - PAD], radius=RADIUS, fill=BG) + + box = [RING_PAD, RING_PAD, SIZE - RING_PAD, SIZE - RING_PAD] + draw.arc(box, start=0, end=360, fill=TRACK, width=STROKE) + + sweep = 360 * PROGRESS + draw.arc(box, start=-90, end=-90 + sweep, fill=ACCENT, width=STROKE) + + end_rad = math.radians(-90 + sweep) + cx, cy = SIZE // 2, SIZE // 2 + r = (SIZE - 2 * RING_PAD) / 2 + ex, ey = cx + r * math.cos(end_rad), cy + r * math.sin(end_rad) + dot = STROKE // 2 + draw.ellipse([ex - dot, ey - dot, ex + dot, ey + dot], fill=ACCENT) + + try: + font = ImageFont.load_default(size=320) + except Exception: + font = None + if font: + text = "C" + bbox = draw.textbbox((0, 0), text, font=font) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + draw.text(((SIZE - tw) / 2 - bbox[0], (SIZE - th) / 2 - bbox[1] - 12), + text, font=font, fill=FG) + + out.parent.mkdir(parents=True, exist_ok=True) + img.save(out, "PNG") + print(f"wrote {out} {SIZE}x{SIZE}") + + +if __name__ == "__main__": + here = Path(__file__).resolve().parent.parent + main(here / "src-tauri" / "icons" / "source.png") diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..1e87f29 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..24449ce Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..5eea040 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/README.md b/src-tauri/icons/README.md index 96be9e1..b0af0c7 100644 --- a/src-tauri/icons/README.md +++ b/src-tauri/icons/README.md @@ -1,13 +1,19 @@ # Icons -Placeholder. Before `pnpm tauri build` will succeed you need real icons here. +`source.png` — 1024×1024 master icon. Dark rounded square + purple progress +ring + white "C". Generated by `../../scripts/make-icon.py`. -Quickest path: `pnpm tauri icon path/to/source-1024x1024.png` — Tauri generates every required size + format (`.ico`, `.icns`, `.png`). +To regenerate every required size + format Tauri's bundler needs: -Files Tauri's bundler expects (referenced from `tauri.conf.json`): +```sh +pnpm tauri icon src-tauri/icons/source.png +``` -- `32x32.png` -- `128x128.png` -- `128x128@2x.png` -- `icon.icns` -- `icon.ico` +That populates `32x32.png`, `128x128.png`, `128x128@2x.png`, `icon.icns`, +`icon.ico`, plus Android/iOS sizes (we ignore those — desktop only). + +The generated icons are tracked in git so a clean clone can `pnpm tauri build` +without first running `tauri icon`. + +To customize: edit `scripts/make-icon.py` (colors, progress sweep, monogram) +and rerun. diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..c8e09bb Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..84adeaf Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/source.png b/src-tauri/icons/source.png new file mode 100644 index 0000000..bd3935e Binary files /dev/null and b/src-tauri/icons/source.png differ diff --git a/src-tauri/src/cli_usage.rs b/src-tauri/src/cli_usage.rs index 465a12a..f0d6a7a 100644 --- a/src-tauri/src/cli_usage.rs +++ b/src-tauri/src/cli_usage.rs @@ -67,27 +67,52 @@ pub fn fetch_blocking(command_override: Option<&str>) -> Result { /// Pick a sensible default command line for invoking `claude`. /// -/// On Windows, `claude` may resolve to a Windows-native install that isn't -/// authenticated, while the user's real session lives in WSL. Prefer the -/// WSL Ubuntu invocation when a `wsl.exe` is detectable on PATH. +/// Order: +/// 1. Native `claude` (Windows: `claude.exe` on PATH; Unix: `claude`). +/// 2. On Windows: enumerate WSL distros via `wsl.exe -l -q` and probe +/// each by running `bash -lc 'command -v claude'`. First hit wins. +/// 3. Fallback: bare `claude` (will fail, but at least with a clear error). /// -/// On Linux/macOS, just `claude`. +/// This is called fresh on every `/usage` fetch, but each probe is cheap +/// (<200ms typical) and only runs when no override is set. fn default_command() -> CommandBuilder { - if cfg!(windows) { - // Probe for wsl.exe; if present, run claude through a login bash in - // the Ubuntu distro (the most common dev setup, and the user's PATH - // is wired through .profile / .bashrc so `claude` resolves). - if which_exists("wsl.exe") { - let mut c = CommandBuilder::new("wsl.exe"); - for a in ["-d", "Ubuntu", "bash", "-lc", "claude"] { - c.arg(a); - } - return c; + if let Some(parts) = autodetect_command() { + let mut c = CommandBuilder::new(&parts[0]); + for a in &parts[1..] { + c.arg(a); } + return c; } CommandBuilder::new("claude") } +/// Returns the auto-detected argv (program + args) for invoking claude, or +/// None if nothing reachable was found. +pub fn autodetect_command() -> Option> { + // 1. Native claude. + if which_exists("claude") { + return Some(vec!["claude".to_string()]); + } + + // 2. WSL distros (Windows only). + if cfg!(windows) && which_exists("wsl.exe") { + for distro in list_wsl_distros() { + if probe_claude_in_wsl(&distro) { + return Some(vec![ + "wsl.exe".to_string(), + "-d".to_string(), + distro, + "bash".to_string(), + "-lc".to_string(), + "claude".to_string(), + ]); + } + } + } + + None +} + fn which_exists(name: &str) -> bool { use std::process::Command; let probe = if cfg!(windows) { "where" } else { "which" }; @@ -98,6 +123,36 @@ fn which_exists(name: &str) -> bool { .unwrap_or(false) } +fn list_wsl_distros() -> Vec { + use std::process::Command; + let Ok(out) = Command::new("wsl.exe").args(["-l", "-q"]).output() else { + return Vec::new(); + }; + if !out.status.success() { + return Vec::new(); + } + // wsl.exe outputs UTF-16LE. + let raw_u16: Vec = out + .stdout + .chunks_exact(2) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .collect(); + String::from_utf16_lossy(&raw_u16) + .lines() + .map(|l| l.trim_matches(|c: char| c == '\u{FEFF}' || c.is_whitespace()).to_string()) + .filter(|l| !l.is_empty()) + .collect() +} + +fn probe_claude_in_wsl(distro: &str) -> bool { + use std::process::Command; + Command::new("wsl.exe") + .args(["-d", distro, "bash", "-lc", "command -v claude"]) + .output() + .map(|o| o.status.success() && !o.stdout.is_empty()) + .unwrap_or(false) +} + /// Spawn the CLI in a PTY, send `/usage`, capture stdout for `total_timeout`, /// then send `/exit` and return raw bytes (still containing ANSI escapes). fn drive_claude_usage(command_override: Option<&str>, total_timeout: Duration) -> Result> { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3e423a3..34faa1d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -83,6 +83,13 @@ pub async fn get_cli_usage( Ok(state.cli_usage.read().clone()) } +/// What the auto-detect found. Used by the empty-state UI to tell the +/// user whether claude is even reachable. +#[tauri::command] +pub async fn autodetect_claude_command() -> Result>, String> { + Ok(crate::cli_usage::autodetect_command()) +} + /// Force-refresh /usage by spawning the CLI now. Slow (~3-5s); use sparingly. #[tauri::command] pub async fn refresh_cli_usage( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed8300e..c494375 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -106,6 +106,7 @@ pub fn run() { commands::detect_plan_tier, commands::get_cli_usage, commands::refresh_cli_usage, + commands::autodetect_claude_command, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/App.svelte b/src/components/App.svelte index 3420b8b..c12f3cd 100644 --- a/src/components/App.svelte +++ b/src/components/App.svelte @@ -7,6 +7,8 @@ getCliUsage, onCliUsageUpdated, refreshCliUsage, + autodetectClaudeCommand, + getSettings, } from "../ipc"; import TitleBar from "./TitleBar.svelte"; import BlockRing from "./BlockRing.svelte"; @@ -18,6 +20,8 @@ let cliUsage = $state(null); let cliRefreshing = $state(false); let showSettings = $state(false); + /** True when Claude Code can't be found anywhere on this machine. */ + let claudeMissing = $state(false); let unlisten1: (() => void) | null = null; let unlisten2: (() => void) | null = null; @@ -40,9 +44,18 @@ console.error("listen failed", e); } - // If we have nothing yet, fire a one-shot refresh so the widget is - // useful right away rather than waiting for the 5-min loop. - if (!cliUsage) { + // Probe whether claude is reachable at all. + try { + const settings = await getSettings(); + const hasOverride = !!(settings.claude_command && settings.claude_command.trim()); + const auto = await autodetectClaudeCommand(); + claudeMissing = !hasOverride && !auto; + } catch (e) { + console.warn("autodetect probe failed", e); + } + + // Trigger an initial refresh if we have nothing AND claude is reachable. + if (!cliUsage && !claudeMissing) { void triggerRefresh(); } }); @@ -71,19 +84,38 @@ refreshing={cliRefreshing} /> - +{#if claudeMissing} +
+
Claude Code not found
+

+ This widget reads your subscription usage by running + claude /usage. Install Claude Code first, then sign in. +

+

+ + Install Claude Code → + +

+

+ Already installed? Open Settings and set claude command + (e.g. wsl.exe -d Ubuntu bash -lc claude). +

+
+{:else} + - + - + -{#if cliRefreshing && !cliUsage} -
Reading /usage…
+ {#if cliRefreshing && !cliUsage} +
Reading /usage…
+ {/if} {/if} {#if showSettings} @@ -98,4 +130,30 @@ font-size: 12px; text-align: center; } + .empty-state { + flex: 1 1 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 12px var(--pad); + gap: 6px; + color: var(--fg); + font-size: 12px; + } + .empty-state .title { + color: var(--fg); + font-weight: 600; + text-align: center; + margin-bottom: 4px; + } + .empty-state p { margin: 0; line-height: 1.4; } + .empty-state .hint { color: var(--fg-dim); font-size: 11px; } + .empty-state a { color: var(--accent); text-decoration: none; } + .empty-state a:hover { text-decoration: underline; } + .empty-state code { + background: var(--bg-card); + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; + } diff --git a/src/ipc.ts b/src/ipc.ts index e0254d0..b74d90f 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -19,6 +19,8 @@ export const quitApp = (): Promise => invoke("quit_app"); export const detectPlanTier = (): Promise => invoke("detect_plan_tier"); export const getCliUsage = (): Promise => invoke("get_cli_usage"); export const refreshCliUsage = (): Promise => invoke("refresh_cli_usage"); +export const autodetectClaudeCommand = (): Promise => + invoke("autodetect_claude_command"); export const onUsageUpdated = ( cb: (snap: UsageSnapshot) => void,