Replace cap-based estimation with PTY-driven 'claude /usage' parser

The widget now spawns 'claude' via portable-pty, sends /usage, parses the
three rendered bars (Current session / Current week all / Current week
Sonnet), and shows the real percentages in the ring + weekly bars. A
background task refreshes every 5 minutes; the title-bar refresh button
forces an immediate fetch.

Drops the cap-tuning UI and tier card from Settings; adds a 'claude command'
override (e.g. 'wsl.exe -- claude' for Windows-host widgets reading WSL
credentials) and a refresh-interval setting. Fixes title-bar buttons getting
swallowed as drag attempts via data-tauri-drag-region="false".
This commit is contained in:
megaproxy 2026-05-09 01:40:44 +01:00
parent 18e55cd139
commit db9a10a4c2
13 changed files with 656 additions and 166 deletions

View file

@ -1,7 +1,13 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import type { UsageSnapshot } from "../types";
import { getSnapshot, onUsageUpdated } from "../ipc";
import type { CliUsage, UsageSnapshot } from "../types";
import {
getSnapshot,
onUsageUpdated,
getCliUsage,
onCliUsageUpdated,
refreshCliUsage,
} from "../ipc";
import TitleBar from "./TitleBar.svelte";
import BlockRing from "./BlockRing.svelte";
import ModelStack from "./ModelStack.svelte";
@ -9,50 +15,75 @@
import Settings from "./Settings.svelte";
let snap = $state<UsageSnapshot | null>(null);
let cliUsage = $state<CliUsage | null>(null);
let cliRefreshing = $state(false);
let showSettings = $state(false);
let unlisten: (() => void) | null = null;
let tickHandle: ReturnType<typeof setInterval> | null = null;
// Locally-decremented countdown so we don't IPC just to tick the clock.
let nowSeconds = $state(0);
function updateFromSnapshot(s: UsageSnapshot) {
snap = s;
nowSeconds = s.block?.seconds_remaining ?? 0;
}
let unlisten1: (() => void) | null = null;
let unlisten2: (() => void) | null = null;
onMount(async () => {
try {
updateFromSnapshot(await getSnapshot());
snap = await getSnapshot();
} catch (e) {
console.error("initial snapshot failed", e);
}
try {
unlisten = await onUsageUpdated(updateFromSnapshot);
cliUsage = await getCliUsage();
} catch (e) {
console.error("initial cli usage failed", e);
}
try {
unlisten1 = await onUsageUpdated((s) => (snap = s));
unlisten2 = await onCliUsageUpdated((u) => (cliUsage = u));
} catch (e) {
console.error("listen failed", e);
}
tickHandle = setInterval(() => {
if (nowSeconds > 0) nowSeconds -= 1;
}, 1000);
// 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) {
void triggerRefresh();
}
});
onDestroy(() => {
if (unlisten) unlisten();
if (tickHandle) clearInterval(tickHandle);
unlisten1?.();
unlisten2?.();
});
async function triggerRefresh() {
if (cliRefreshing) return;
cliRefreshing = true;
try {
cliUsage = await refreshCliUsage();
} catch (e) {
console.error("refresh /usage failed", e);
} finally {
cliRefreshing = false;
}
}
</script>
<TitleBar onSettings={() => (showSettings = true)} />
<TitleBar
onSettings={() => (showSettings = true)}
onRefreshUsage={triggerRefresh}
refreshing={cliRefreshing}
/>
{#if snap}
<BlockRing block={snap.block} cap={snap.caps.block_tokens} {nowSeconds} />
<ModelStack
breakdown={snap.block?.by_family ?? snap.weekly.by_family}
/>
<WeeklyBar weekly={snap.weekly} cap={snap.caps.weekly_tokens} />
{:else}
<div class="loading">Loading transcripts…</div>
<BlockRing bar={cliUsage?.session ?? null} />
<ModelStack
breakdown={snap?.block?.by_family ?? snap?.weekly.by_family ?? { opus: 0, sonnet: 0, haiku: 0, other: 0 }}
/>
<WeeklyBar
weekAll={cliUsage?.week_all ?? null}
weekSonnet={cliUsage?.week_sonnet ?? null}
/>
{#if cliRefreshing && !cliUsage}
<div class="loading">Reading /usage…</div>
{/if}
{#if showSettings}

View file

@ -1,16 +1,14 @@
<script lang="ts">
import type { BlockSummary } from "../types";
import { formatTokens, formatCountdown, formatPct } from "../format";
import type { UsageBar } from "../types";
// Shows the *real* percentage from `claude /usage` — the
// "Current session" bar (5-hour rolling window).
let {
block,
cap,
nowSeconds,
bar,
fallbackText = "—",
}: {
block: BlockSummary | null;
cap: number;
/** Locally-decremented countdown so we don't IPC just to tick the clock. */
nowSeconds: number;
bar: UsageBar | null;
fallbackText?: string;
} = $props();
// Geometry.
@ -19,7 +17,7 @@
const R = (SIZE - STROKE) / 2;
const C = 2 * Math.PI * R;
let pct = $derived(block && cap > 0 ? Math.min(1, block.total_tokens / cap) : 0);
let pct = $derived(bar ? Math.min(1, bar.percent / 100) : 0);
let dash = $derived(C * pct);
let color = $derived(
pct > 0.95 ? "var(--danger)" : pct > 0.8 ? "var(--warn)" : "var(--accent)",
@ -29,7 +27,6 @@
<div class="wrap">
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
<!-- track -->
<circle
cx={SIZE / 2}
cy={SIZE / 2}
@ -38,7 +35,6 @@
stroke="var(--border)"
stroke-width={STROKE}
/>
<!-- progress -->
<circle
cx={SIZE / 2}
cy={SIZE / 2}
@ -52,13 +48,13 @@
class:pulse
/>
<text x={SIZE / 2} y={SIZE / 2 - 6} text-anchor="middle" class="big">
{block ? formatTokens(block.total_tokens) : "—"}
{bar ? `${bar.percent}%` : fallbackText}
</text>
<text x={SIZE / 2} y={SIZE / 2 + 12} text-anchor="middle" class="small">
{block ? formatPct(block.total_tokens, cap) : ""}
{bar ? "session" : ""}
</text>
<text x={SIZE / 2} y={SIZE / 2 + 30} text-anchor="middle" class="countdown">
{block ? formatCountdown(nowSeconds) : "no activity"}
<text x={SIZE / 2} y={SIZE / 2 + 30} text-anchor="middle" class="resets">
{bar ? `resets ${bar.resets_at_text.split('(')[0].trim()}` : "no /usage data"}
</text>
</svg>
</div>
@ -66,9 +62,9 @@
<style>
.wrap { display: flex; justify-content: center; padding: 14px 0 6px; }
text { fill: var(--fg); font-family: inherit; }
text.big { font-size: 22px; font-weight: 600; }
text.big { font-size: 26px; font-weight: 600; }
text.small { font-size: 11px; fill: var(--fg-dim); }
text.countdown { font-size: 11px; fill: var(--fg-dim); font-variant-numeric: tabular-nums; }
text.resets { font-size: 10px; fill: var(--fg-dim); }
.pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }

View file

@ -10,17 +10,19 @@
setSettings,
listDistros,
getRoots,
detectPlanTier,
refreshCliUsage,
getCliUsage,
} from "../ipc";
import type { Settings, ResolvedRoots, TierInfo } from "../types";
import type { Settings, ResolvedRoots, CliUsage } from "../types";
let { onClose }: { onClose: () => void } = $props();
let settings = $state<Settings | null>(null);
let distros = $state<string[]>([]);
let roots = $state<ResolvedRoots | null>(null);
let tier = $state<TierInfo | null>(null);
let cli = $state<CliUsage | null>(null);
let busy = $state(false);
let testingCli = $state(false);
let err = $state<string | null>(null);
onMount(async () => {
@ -28,8 +30,7 @@
settings = await getSettings();
distros = await listDistros();
roots = await getRoots();
tier = await detectPlanTier();
// Sync displayed autostart from the plugin's actual state.
cli = await getCliUsage();
const enabled = await isAutostartEnabled();
if (settings && settings.autostart !== enabled) {
settings = { ...settings, autostart: enabled };
@ -39,23 +40,11 @@
}
});
function applyTierCaps() {
if (!settings || !tier) return;
settings = {
...settings,
caps: {
block_tokens: tier.recommended_caps.block_tokens,
weekly_tokens: tier.recommended_caps.weekly_tokens,
},
};
}
async function save() {
if (!settings) return;
busy = true;
err = null;
try {
// Autostart state must be applied via the plugin, not just stored.
if (settings.autostart) await enableAutostart();
else await disableAutostart();
await setSettings(settings);
@ -66,6 +55,21 @@
busy = false;
}
}
async function testCli() {
if (!settings) return;
testingCli = true;
err = null;
try {
// Save first so the new claude_command is in effect.
await setSettings(settings);
cli = await refreshCliUsage();
} catch (e) {
err = `${e}`;
} finally {
testingCli = false;
}
}
</script>
<div class="overlay">
@ -78,49 +82,37 @@
{#if err}<div class="error">{err}</div>{/if}
{#if settings}
{#if tier}
<div class="tier-card">
<div class="row spread">
<div>
<div class="label" style="margin:0">Plan tier</div>
<div>{tier.label}</div>
</div>
<button onclick={applyTierCaps} title="Set caps to recommended values for this tier">
Use recommended
</button>
</div>
<div class="muted hint">
Approximate — Anthropic doesn't publish exact caps. Tune below
once you actually hit a limit.
</div>
{#if tier.label.startsWith("Unknown") && tier.searched.length > 0}
<details class="muted hint">
<summary>Searched paths</summary>
<ul class="paths">
{#each tier.searched as p (p)}<li><code>{p}</code></li>{/each}
</ul>
</details>
<div class="field">
<div class="label">claude command</div>
<input
type="text"
placeholder="e.g. wsl.exe -- claude (auto if blank)"
bind:value={settings.claude_command}
/>
<div class="muted hint">
How the widget invokes Claude Code to read /usage. Leave blank to
auto-detect (`claude` first, then `wsl.exe -- claude`).
</div>
<div class="row" style="margin-top:6px">
<button onclick={testCli} disabled={testingCli || busy}>
{testingCli ? "Reading…" : "Test /usage now"}
</button>
{#if cli}
<span class="muted hint">
{cli.ok ? "OK" : "no bars parsed"} ·
fetched {new Date(cli.fetched_at).toLocaleTimeString()}
</span>
{/if}
</div>
{/if}
<div class="field">
<div class="label">5-hour block cap (tokens)</div>
<input
type="number"
min="0"
step="100000"
bind:value={settings.caps.block_tokens}
/>
</div>
<div class="field">
<div class="label">Weekly cap (tokens)</div>
<div class="label">/usage refresh interval (seconds)</div>
<input
type="number"
min="0"
step="1000000"
bind:value={settings.caps.weekly_tokens}
min="60"
step="60"
bind:value={settings.cli_refresh_secs}
/>
</div>
@ -136,7 +128,7 @@
<label class="check">
<input type="checkbox" bind:checked={settings.include_native} />
Also scan native <code>%USERPROFILE%\.claude\projects</code>
Also scan <code>%USERPROFILE%\.claude\projects</code>
</label>
<label class="check">
@ -192,19 +184,7 @@
.actions { margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border); }
.roots { margin: 0; padding-left: 14px; max-height: 60px; overflow: auto; }
.roots code { font-size: 10px; word-break: break-all; }
.tier-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.hint { font-size: 10px; line-height: 1.3; }
details summary { cursor: pointer; font-size: 10px; }
ul.paths { margin: 4px 0 0; padding-left: 14px; max-height: 70px; overflow: auto; }
ul.paths code { font-size: 10px; word-break: break-all; }
.error {
background: rgba(255, 107, 107, 0.15);
border: 1px solid var(--danger);

View file

@ -1,18 +1,46 @@
<script lang="ts">
import { quitApp, forceRescan } from "../ipc";
let { onSettings }: { onSettings: () => void } = $props();
import { quitApp } from "../ipc";
let {
onSettings,
onRefreshUsage,
refreshing = false,
}: {
onSettings: () => void;
onRefreshUsage?: () => void;
refreshing?: boolean;
} = $props();
</script>
<!--
data-tauri-drag-region: anywhere with this attribute is grabbable as a
window-drag handle. https://v2.tauri.app/learn/window-customization/
data-tauri-drag-region on the header makes the title bar draggable. Buttons
must explicitly opt out via data-tauri-drag-region="false" or clicks get
swallowed as drag attempts.
-->
<header data-tauri-drag-region>
<span class="title" data-tauri-drag-region>Claude Usage</span>
<div class="actions">
<button class="icon" title="Refresh" onclick={forceRescan} aria-label="Refresh"></button>
<button class="icon" title="Settings" onclick={onSettings} aria-label="Settings"></button>
<button class="icon" title="Quit" onclick={quitApp} aria-label="Quit">×</button>
<button
class="icon"
class:spin={refreshing}
data-tauri-drag-region="false"
title="Re-fetch /usage"
onclick={() => onRefreshUsage?.()}
aria-label="Refresh /usage"
>↻</button>
<button
class="icon"
data-tauri-drag-region="false"
title="Settings"
onclick={onSettings}
aria-label="Settings"
>⚙</button>
<button
class="icon"
data-tauri-drag-region="false"
title="Quit"
onclick={quitApp}
aria-label="Quit"
>×</button>
</div>
</header>
@ -34,4 +62,9 @@
cursor: grab;
}
.actions { display: inline-flex; gap: 2px; }
.icon.spin { animation: spin 1.2s linear infinite; }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View file

@ -1,69 +1,67 @@
<script lang="ts">
import type { WeeklySummary } from "../types";
import { formatTokens, formatPct, weekdayShort } from "../format";
import type { UsageBar } from "../types";
let {
weekly,
cap,
weekAll,
weekSonnet,
}: {
weekly: WeeklySummary;
cap: number;
weekAll: UsageBar | null;
weekSonnet: UsageBar | null;
} = $props();
let max = $derived(
Math.max(1, ...weekly.by_day.map((d) => d.total_tokens))
);
function pctOrZero(b: UsageBar | null) {
return b ? Math.min(100, Math.max(0, b.percent)) : 0;
}
</script>
<div class="section">
<div class="row spread">
<div class="label">7-day total</div>
<div class="muted">{formatTokens(weekly.total_tokens)} · {formatPct(weekly.total_tokens, cap)}</div>
</div>
<div class="bars">
{#each weekly.by_day as d (d.date_local)}
<div class="col" title="{d.date_local}: {formatTokens(d.total_tokens)}">
<div class="bar-track">
<div
class="bar-fill"
style="height: {(d.total_tokens / max) * 100}%"
></div>
</div>
<div class="day-label">{weekdayShort(d.date_local)}</div>
</div>
{/each}
<div class="label">Weekly limits</div>
{#if weekAll}
<div class="muted">resets {weekAll.resets_at_text.split('(')[0].trim()}</div>
{/if}
</div>
{#if weekAll}
<div class="bar-row">
<span class="lbl">All models</span>
<div class="track"><div class="fill" style="width:{pctOrZero(weekAll)}%"></div></div>
<span class="num">{weekAll.percent}%</span>
</div>
{/if}
{#if weekSonnet}
<div class="bar-row">
<span class="lbl">Sonnet</span>
<div class="track"><div class="fill sonnet" style="width:{pctOrZero(weekSonnet)}%"></div></div>
<span class="num">{weekSonnet.percent}%</span>
</div>
{/if}
{#if !weekAll && !weekSonnet}
<div class="muted">no /usage data yet</div>
{/if}
</div>
<style>
.bars {
.bar-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-top: 6px;
height: 50px;
align-items: end;
}
.col {
display: flex;
flex-direction: column;
grid-template-columns: 60px 1fr 36px;
align-items: center;
height: 100%;
gap: 2px;
gap: 6px;
font-size: 11px;
margin-top: 4px;
}
.bar-track {
flex: 1;
width: 100%;
background: var(--bg-card);
.lbl { color: var(--fg-dim); }
.num { color: var(--fg); text-align: right; font-variant-numeric: tabular-nums; }
.track {
height: 6px;
border-radius: 3px;
display: flex;
align-items: end;
background: var(--bg-card);
overflow: hidden;
}
.bar-fill {
width: 100%;
.fill {
height: 100%;
background: var(--accent);
min-height: 1px;
transition: width 250ms ease-out;
}
.day-label { font-size: 9px; color: var(--fg-dim); }
.fill.sonnet { background: var(--sonnet); }
</style>

View file

@ -1,6 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { ResolvedRoots, Settings, TierInfo, UsageSnapshot } from "./types";
import type {
CliUsage,
ResolvedRoots,
Settings,
TierInfo,
UsageSnapshot,
} from "./types";
export const getSnapshot = (): Promise<UsageSnapshot> => invoke("get_snapshot");
export const getSettings = (): Promise<Settings> => invoke("get_settings");
@ -11,8 +17,15 @@ export const getRoots = (): Promise<ResolvedRoots> => invoke("get_roots");
export const forceRescan = (): Promise<void> => invoke("force_rescan");
export const quitApp = (): Promise<void> => invoke("quit_app");
export const detectPlanTier = (): Promise<TierInfo> => invoke("detect_plan_tier");
export const getCliUsage = (): Promise<CliUsage | null> => invoke("get_cli_usage");
export const refreshCliUsage = (): Promise<CliUsage> => invoke("refresh_cli_usage");
export const onUsageUpdated = (
cb: (snap: UsageSnapshot) => void,
): Promise<UnlistenFn> =>
listen<UsageSnapshot>("usage-updated", (e) => cb(e.payload));
export const onCliUsageUpdated = (
cb: (u: CliUsage) => void,
): Promise<UnlistenFn> =>
listen<CliUsage>("cli-usage-updated", (e) => cb(e.payload));

View file

@ -69,3 +69,19 @@ export interface TierInfo {
recommended_caps: Caps;
searched: string[];
}
// Mirrors src-tauri/src/cli_usage.rs.
export interface UsageBar {
label: string;
percent: number; // 0..=100
resets_at_text: string;
}
export interface CliUsage {
session: UsageBar | null;
week_all: UsageBar | null;
week_sonnet: UsageBar | null;
fetched_at: string; // ISO 8601
ok: boolean;
raw_text: string;
}