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:
parent
a6d3f8a9f9
commit
1df8c3181b
10 changed files with 813 additions and 2 deletions
97
src/lib/usage.ts
Normal file
97
src/lib/usage.ts
Normal 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`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue