Add per-session claude token/cost usage panel (WSL, v1)

Reads ~/.claude/projects/*.jsonl transcripts from the open WSL panes'
distros and shows per-session token counts + estimated USD cost, with a
running total in the titlebar.

Backend (src-tauri/src/usage.rs): new get_claude_usage command. For each
distro it probes $HOME once via wsl.exe, reaches the transcripts over the
\\wsl.localhost UNC share, and tallies message.usage per model per
session (summed by each line's model, since a session can switch models).
Results cached by (path,size,mtime) so polling only re-parses the file
that grew; recency-capped (30d / 50 sessions) to bound scan cost.
Windows-only; returns [] elsewhere. quiet_command made pub(crate).

Frontend: src/lib/usage.ts holds the pricing table (per-MTok rates,
matched by model-family substring) + cost/format helpers, so rates are
editable without recompiling Rust. UsagePanel.tsx mirrors the MCP panel
modal; rows whose transcript cwd matches an open pane are highlighted
with a [pane: label] tag. App polls every 20s (visible windows) for the
titlebar 💰 total and every 5s while the panel is open. Ctrl+Shift+U
opens it; added to shortcuts.ts + regenerated README.

tsc clean. Rust builds on the Windows host; needs runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-28 22:15:51 +01:00
parent a6d3f8a9f9
commit 1df8c3181b
10 changed files with 813 additions and 2 deletions

97
src/lib/usage.ts Normal file
View file

@ -0,0 +1,97 @@
// Pricing + formatting helpers for the claude usage panel. Token tallies come
// from the backend (src-tauri/src/usage.rs); cost is applied here so the rate
// table is easy to edit without recompiling Rust.
import type { SessionUsage } from "../ipc";
interface Rate {
/** USD per million tokens. */
input: number;
output: number;
cacheWrite: number;
cacheRead: number;
}
// Published Anthropic API rates, USD per million tokens, as of 2026-05.
// UPDATE if pricing changes. Matched against the model id by substring.
// cacheWrite uses the 5-minute-TTL rate (1.25× input); 1-hour cache writes
// (2× input) are billed slightly higher than this estimate shows.
const RATES: { match: string; rate: Rate }[] = [
{ match: "opus", rate: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
{ match: "sonnet", rate: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } },
{ match: "haiku", rate: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } },
];
// Unknown model → assume sonnet-tier rates (a middle-ground estimate).
const FALLBACK_RATE = RATES[1].rate;
function rateFor(model: string): Rate {
const m = model.toLowerCase();
return RATES.find((r) => m.includes(r.match))?.rate ?? FALLBACK_RATE;
}
/** Estimated USD cost for one session, summed per-model. */
export function sessionCost(s: SessionUsage): number {
let usd = 0;
for (const mu of s.models) {
const r = rateFor(mu.model);
usd +=
(mu.inputTokens * r.input +
mu.outputTokens * r.output +
mu.cacheCreationTokens * r.cacheWrite +
mu.cacheReadTokens * r.cacheRead) /
1_000_000;
}
return usd;
}
/** Total tokens (all kinds) for one session. */
export function sessionTokens(s: SessionUsage): number {
let t = 0;
for (const mu of s.models) {
t += mu.inputTokens + mu.outputTokens + mu.cacheCreationTokens + mu.cacheReadTokens;
}
return t;
}
/** Short family name of the model that produced the most output in a session. */
export function dominantModel(s: SessionUsage): string {
let best: SessionUsage["models"][number] | undefined;
for (const mu of s.models) {
if (!best || mu.outputTokens > best.outputTokens) best = mu;
}
return best ? shortModel(best.model) : "—";
}
export function shortModel(model: string): string {
const m = model.toLowerCase();
if (m.includes("opus")) return "opus";
if (m.includes("sonnet")) return "sonnet";
if (m.includes("haiku")) return "haiku";
return model;
}
export function totalCost(sessions: SessionUsage[]): number {
return sessions.reduce((acc, s) => acc + sessionCost(s), 0);
}
export function formatUsd(n: number): string {
return "$" + n.toFixed(2);
}
export function formatTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return Math.round(n / 1_000) + "k";
return String(n);
}
/** `nowMs` is passed in so callers can avoid Date.now() churn in render. */
export function relativeTime(ms: number, nowMs: number): string {
const dt = Math.max(0, nowMs - ms);
const s = Math.floor(dt / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}