Polish for shipping: robust auto-detect, empty state, real icons, end-user README
- cli_usage::default_command now enumerates WSL distros and probes each for claude before falling back; no more hardcoded -d Ubuntu. - New autodetect_claude_command Tauri command + IPC binding so the UI knows whether claude is reachable. - App.svelte: clear 'Claude Code not found' empty state with install link. - Real icons: scripts/make-icon.py generates a 1024x1024 source.png; runtime produces 32/128/256 PNGs and a multi-resolution .ico. README in icons/ explains how to regen. - README rewritten for friends: install / requirements / troubleshooting on top; build-from-source moved to bottom.
This commit is contained in:
parent
0a960dae2d
commit
9be856d37c
14 changed files with 321 additions and 128 deletions
178
README.md
178
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:
|
A small always-on-top Windows desktop widget that shows your live Claude
|
||||||
|
subscription usage — the same percentages Claude Code's `/usage` command
|
||||||
- **Current 5-hour session block** — progress ring + countdown to block end.
|
displays, refreshed every 5 minutes.
|
||||||
- **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 ───────╳ ─┐
|
┌─────── Claude Usage ────────────╳ ─┐
|
||||||
│ │
|
│ │
|
||||||
│ ╭─────╮ │
|
│ ╭───────╮ │
|
||||||
│ ╱ ╲ │
|
│ ╱ 72% ╲ │
|
||||||
│ │ 47k │ ← 5-hour block │
|
│ │ session │ │
|
||||||
│ │ 24% │ │
|
│ ╲ resets ╱ │
|
||||||
│ ╲ 3h 12m╱ │
|
│ ╰ 2:50am ╯ │
|
||||||
│ ╰─────╯ │
|
│ │
|
||||||
│ │
|
│ Models (current block) │
|
||||||
│ Models (current block) │
|
│ ▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱ │
|
||||||
│ ▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱ │
|
│ ● Opus 42M ● Haiku 3M │
|
||||||
│ ● Opus 32k ● Sonnet 11k │
|
│ │
|
||||||
│ │
|
│ Weekly limits resets May 9 │
|
||||||
│ 7-day total 842k · 42% │
|
│ All models ▆░░░░░ 8% │
|
||||||
│ ▁▃▆█▂▅▇ │
|
│ Sonnet ▃░░░░░ 5% │
|
||||||
│ Sat Sun Mon Tue Wed Thu Fri │
|
└─────────────────────────────────────┘
|
||||||
└───────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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_<version>_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
|
```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
|
rustup default stable-x86_64-pc-windows-msvc
|
||||||
npm i -g pnpm
|
corepack enable
|
||||||
# Also: MSVC C++ Build Tools + Windows SDK (Visual Studio Installer),
|
corepack prepare pnpm@latest --activate
|
||||||
# and Microsoft Edge WebView2 Runtime (preinstalled on Windows 11).
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, from this directory:
|
Then in this repo:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm tauri dev # iterate
|
pnpm tauri dev # iterate
|
||||||
pnpm tauri build # NSIS installer in src-tauri\target\release\bundle\nsis\
|
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$\<distro>\` 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.
|
Project layout, architecture decisions, and known follow-ups live in
|
||||||
|
[`memory.md`](./memory.md).
|
||||||
> **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/<path>/<sessionId>.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/<id>.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.
|
|
||||||
|
|
|
||||||
72
scripts/make-icon.py
Normal file
72
scripts/make-icon.py
Normal file
|
|
@ -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")
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
BIN
src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -1,13 +1,19 @@
|
||||||
# Icons
|
# 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`
|
That populates `32x32.png`, `128x128.png`, `128x128@2x.png`, `icon.icns`,
|
||||||
- `128x128.png`
|
`icon.ico`, plus Android/iOS sizes (we ignore those — desktop only).
|
||||||
- `128x128@2x.png`
|
|
||||||
- `icon.icns`
|
The generated icons are tracked in git so a clean clone can `pnpm tauri build`
|
||||||
- `icon.ico`
|
without first running `tauri icon`.
|
||||||
|
|
||||||
|
To customize: edit `scripts/make-icon.py` (colors, progress sweep, monogram)
|
||||||
|
and rerun.
|
||||||
|
|
|
||||||
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 836 B |
BIN
src-tauri/icons/source.png
Normal file
BIN
src-tauri/icons/source.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -67,27 +67,52 @@ pub fn fetch_blocking(command_override: Option<&str>) -> Result<CliUsage> {
|
||||||
|
|
||||||
/// Pick a sensible default command line for invoking `claude`.
|
/// Pick a sensible default command line for invoking `claude`.
|
||||||
///
|
///
|
||||||
/// On Windows, `claude` may resolve to a Windows-native install that isn't
|
/// Order:
|
||||||
/// authenticated, while the user's real session lives in WSL. Prefer the
|
/// 1. Native `claude` (Windows: `claude.exe` on PATH; Unix: `claude`).
|
||||||
/// WSL Ubuntu invocation when a `wsl.exe` is detectable on PATH.
|
/// 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 {
|
fn default_command() -> CommandBuilder {
|
||||||
if cfg!(windows) {
|
if let Some(parts) = autodetect_command() {
|
||||||
// Probe for wsl.exe; if present, run claude through a login bash in
|
let mut c = CommandBuilder::new(&parts[0]);
|
||||||
// the Ubuntu distro (the most common dev setup, and the user's PATH
|
for a in &parts[1..] {
|
||||||
// is wired through .profile / .bashrc so `claude` resolves).
|
c.arg(a);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return c;
|
||||||
}
|
}
|
||||||
CommandBuilder::new("claude")
|
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<Vec<String>> {
|
||||||
|
// 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 {
|
fn which_exists(name: &str) -> bool {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
let probe = if cfg!(windows) { "where" } else { "which" };
|
let probe = if cfg!(windows) { "where" } else { "which" };
|
||||||
|
|
@ -98,6 +123,36 @@ fn which_exists(name: &str) -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_wsl_distros() -> Vec<String> {
|
||||||
|
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<u16> = 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`,
|
/// Spawn the CLI in a PTY, send `/usage`, capture stdout for `total_timeout`,
|
||||||
/// then send `/exit` and return raw bytes (still containing ANSI escapes).
|
/// then send `/exit` and return raw bytes (still containing ANSI escapes).
|
||||||
fn drive_claude_usage(command_override: Option<&str>, total_timeout: Duration) -> Result<Vec<u8>> {
|
fn drive_claude_usage(command_override: Option<&str>, total_timeout: Duration) -> Result<Vec<u8>> {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,13 @@ pub async fn get_cli_usage(
|
||||||
Ok(state.cli_usage.read().clone())
|
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<Option<Vec<String>>, String> {
|
||||||
|
Ok(crate::cli_usage::autodetect_command())
|
||||||
|
}
|
||||||
|
|
||||||
/// Force-refresh /usage by spawning the CLI now. Slow (~3-5s); use sparingly.
|
/// Force-refresh /usage by spawning the CLI now. Slow (~3-5s); use sparingly.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn refresh_cli_usage(
|
pub async fn refresh_cli_usage(
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ pub fn run() {
|
||||||
commands::detect_plan_tier,
|
commands::detect_plan_tier,
|
||||||
commands::get_cli_usage,
|
commands::get_cli_usage,
|
||||||
commands::refresh_cli_usage,
|
commands::refresh_cli_usage,
|
||||||
|
commands::autodetect_claude_command,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
getCliUsage,
|
getCliUsage,
|
||||||
onCliUsageUpdated,
|
onCliUsageUpdated,
|
||||||
refreshCliUsage,
|
refreshCliUsage,
|
||||||
|
autodetectClaudeCommand,
|
||||||
|
getSettings,
|
||||||
} from "../ipc";
|
} from "../ipc";
|
||||||
import TitleBar from "./TitleBar.svelte";
|
import TitleBar from "./TitleBar.svelte";
|
||||||
import BlockRing from "./BlockRing.svelte";
|
import BlockRing from "./BlockRing.svelte";
|
||||||
|
|
@ -18,6 +20,8 @@
|
||||||
let cliUsage = $state<CliUsage | null>(null);
|
let cliUsage = $state<CliUsage | null>(null);
|
||||||
let cliRefreshing = $state(false);
|
let cliRefreshing = $state(false);
|
||||||
let showSettings = $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 unlisten1: (() => void) | null = null;
|
||||||
let unlisten2: (() => void) | null = null;
|
let unlisten2: (() => void) | null = null;
|
||||||
|
|
@ -40,9 +44,18 @@
|
||||||
console.error("listen failed", e);
|
console.error("listen failed", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have nothing yet, fire a one-shot refresh so the widget is
|
// Probe whether claude is reachable at all.
|
||||||
// useful right away rather than waiting for the 5-min loop.
|
try {
|
||||||
if (!cliUsage) {
|
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();
|
void triggerRefresh();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -71,19 +84,38 @@
|
||||||
refreshing={cliRefreshing}
|
refreshing={cliRefreshing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BlockRing bar={cliUsage?.session ?? null} />
|
{#if claudeMissing}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="title">Claude Code not found</div>
|
||||||
|
<p>
|
||||||
|
This widget reads your subscription usage by running
|
||||||
|
<code>claude /usage</code>. Install Claude Code first, then sign in.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://docs.claude.com/en/docs/claude-code" target="_blank" rel="noopener">
|
||||||
|
Install Claude Code →
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p class="hint">
|
||||||
|
Already installed? Open Settings and set <em>claude command</em>
|
||||||
|
(e.g. <code>wsl.exe -d Ubuntu bash -lc claude</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<BlockRing bar={cliUsage?.session ?? null} />
|
||||||
|
|
||||||
<ModelStack
|
<ModelStack
|
||||||
breakdown={snap?.block?.by_family ?? snap?.weekly.by_family ?? { opus: 0, sonnet: 0, haiku: 0, other: 0 }}
|
breakdown={snap?.block?.by_family ?? snap?.weekly.by_family ?? { opus: 0, sonnet: 0, haiku: 0, other: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WeeklyBar
|
<WeeklyBar
|
||||||
weekAll={cliUsage?.week_all ?? null}
|
weekAll={cliUsage?.week_all ?? null}
|
||||||
weekSonnet={cliUsage?.week_sonnet ?? null}
|
weekSonnet={cliUsage?.week_sonnet ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if cliRefreshing && !cliUsage}
|
{#if cliRefreshing && !cliUsage}
|
||||||
<div class="loading">Reading /usage…</div>
|
<div class="loading">Reading /usage…</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showSettings}
|
{#if showSettings}
|
||||||
|
|
@ -98,4 +130,30 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: center;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export const quitApp = (): Promise<void> => invoke("quit_app");
|
||||||
export const detectPlanTier = (): Promise<TierInfo> => invoke("detect_plan_tier");
|
export const detectPlanTier = (): Promise<TierInfo> => invoke("detect_plan_tier");
|
||||||
export const getCliUsage = (): Promise<CliUsage | null> => invoke("get_cli_usage");
|
export const getCliUsage = (): Promise<CliUsage | null> => invoke("get_cli_usage");
|
||||||
export const refreshCliUsage = (): Promise<CliUsage> => invoke("refresh_cli_usage");
|
export const refreshCliUsage = (): Promise<CliUsage> => invoke("refresh_cli_usage");
|
||||||
|
export const autodetectClaudeCommand = (): Promise<string[] | null> =>
|
||||||
|
invoke("autodetect_claude_command");
|
||||||
|
|
||||||
export const onUsageUpdated = (
|
export const onUsageUpdated = (
|
||||||
cb: (snap: UsageSnapshot) => void,
|
cb: (snap: UsageSnapshot) => void,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue