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

View file

@ -23,8 +23,10 @@ import {
createPaneWindow,
takePendingWindowInit,
pushWindowWorkspaces,
getClaudeUsage,
type PaneId,
type SpawnSpec,
type SessionUsage,
type SshHost,
type McpStatus,
type McpMirror,
@ -106,6 +108,8 @@ import Palette from "./components/Palette";
import HostManager from "./components/HostManager";
import Help from "./components/Help";
import McpPanel from "./components/McpPanel";
import UsagePanel from "./components/UsagePanel";
import { totalCost, formatUsd } from "./lib/usage";
import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm";
import TabStrip from "./components/TabStrip";
import "./App.css";
@ -239,6 +243,9 @@ export default function App() {
token: null,
});
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
const [usagePanelOpen, setUsagePanelOpen] = useState(false);
const [usageSessions, setUsageSessions] = useState<SessionUsage[]>([]);
const [usageLoading, setUsageLoading] = useState(false);
const [ready, setReady] = useState(false);
const [notifications, setNotifications] = useState<Toast[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
@ -750,6 +757,53 @@ export default function App() {
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
// ---- claude usage tracking ----------------------------------------------
// Reads ~/.claude transcripts in the open WSL panes' distros (backend). The
// fetch guard collapses overlapping calls (the open panel polls every 5s and
// the background heartbeat every 20s both call this).
const usageFetchingRef = useRef(false);
const refreshUsage = useCallback(async () => {
if (usageFetchingRef.current) return;
const distros = new Set<string>();
for (const leaf of walkLeaves(treeRef.current)) {
if (leaf.shellKind === "wsl" && leaf.distro) distros.add(leaf.distro);
}
if (distros.size === 0) {
setUsageSessions([]);
return;
}
usageFetchingRef.current = true;
setUsageLoading(true);
try {
setUsageSessions(await getClaudeUsage(Array.from(distros)));
} catch (e) {
console.warn("getClaudeUsage failed:", e);
} finally {
usageFetchingRef.current = false;
setUsageLoading(false);
}
}, []);
// Background heartbeat so the titlebar total stays roughly current without
// the panel open. Gated on visibility so a hidden/minimized window stays quiet.
useEffect(() => {
const tick = () => {
if (document.visibilityState === "visible") void refreshUsage();
};
tick();
const id = window.setInterval(tick, 20000);
return () => clearInterval(id);
}, [refreshUsage]);
// cwd + label of open WSL panes, for highlighting matching sessions.
const openPanes = useMemo(
() =>
Array.from(walkLeaves(tree))
.filter((l) => l.shellKind === "wsl")
.map((l) => ({ cwd: l.cwd ?? "", label: l.label ?? l.distro ?? "pane" })),
[tree],
);
// Outside-click dismissal for the titlebar dropdowns. Mirrors the
// per-pane shell-picker pattern in LeafPane.tsx.
useEffect(() => {
@ -852,6 +906,14 @@ export default function App() {
return;
}
// Ctrl+Shift+U — usage panel
if (ctrl && shift && !alt && key === "u") {
e.preventDefault();
e.stopPropagation();
setUsagePanelOpen((v) => !v);
return;
}
// Ctrl+Shift+Alt+B — global broadcast all/none
if (ctrl && shift && alt && key === "b") {
e.preventDefault();
@ -2085,6 +2147,14 @@ export default function App() {
>
🤖
</button>
<button
className="palette-btn usage-btn"
onClick={() => setUsagePanelOpen(true)}
title="claude token usage & estimated cost (Ctrl+Shift+U)"
aria-label="Usage"
>
💰{usageSessions.length > 0 ? ` ${formatUsd(totalCost(usageSessions))}` : ""}
</button>
<button
className="palette-btn"
onClick={() => setHelpOpen(true)}
@ -2205,6 +2275,16 @@ export default function App() {
/>
)}
{usagePanelOpen && (
<UsagePanel
sessions={usageSessions}
loading={usageLoading}
onRefresh={refreshUsage}
onClose={() => setUsagePanelOpen(false)}
openPanes={openPanes}
/>
)}
{confirmQueue.length > 0 && (
<McpConfirm
spec={confirmQueue[0]}

View file

@ -0,0 +1,167 @@
/* Usage panel mirrors the McpPanel modal shell (.backdrop + fixed dialog).
Palette matches McpPanel.css / SearchBar.css: #161616 surface, #2a2a2a
borders, #ccc text, accent green for cost. */
.usage-panel {
position: fixed;
top: 8vh;
left: 50%;
transform: translateX(-50%);
width: min(640px, 92vw);
max-height: 84vh;
background: #161616;
color: #ccc;
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.usage-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
}
.usage-title {
font-weight: 600;
font-size: 13px;
}
.usage-total {
font-size: 13px;
font-weight: 600;
color: #6cc04a;
margin-right: auto;
}
.usage-refresh,
.usage-close {
background: transparent;
border: none;
color: #888;
line-height: 1;
cursor: pointer;
border-radius: 3px;
}
.usage-refresh {
font-size: 15px;
padding: 2px 7px;
}
.usage-close {
font-size: 18px;
padding: 2px 8px;
}
.usage-refresh:hover,
.usage-close:hover {
background: #2a2a2a;
color: #ddd;
}
.usage-refresh:disabled {
opacity: 0.4;
cursor: default;
}
.usage-body {
overflow-y: auto;
flex: 1;
padding: 6px 0;
}
.usage-empty {
color: #777;
font-size: 12px;
padding: 22px 16px;
text-align: center;
}
.usage-list {
list-style: none;
}
.usage-row {
display: flex;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid #202020;
}
.usage-row--open {
background: #18221a;
}
.usage-dot {
color: #444;
font-size: 11px;
line-height: 18px;
}
.usage-row--open .usage-dot {
color: #6cc04a;
}
.usage-row-main {
flex: 1;
min-width: 0;
}
.usage-row-top {
display: flex;
align-items: baseline;
gap: 10px;
font-size: 12px;
}
.usage-proj {
font-weight: 600;
color: #e6e6e6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.usage-model {
color: #888;
font-size: 11px;
}
.usage-tokens {
color: #9aa0a6;
margin-left: auto;
white-space: nowrap;
}
.usage-cost {
color: #6cc04a;
font-weight: 600;
white-space: nowrap;
min-width: 56px;
text-align: right;
}
.usage-row-sub {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 2px;
font-size: 11px;
color: #666;
}
.usage-cwd {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.usage-pane-tag {
color: #6cc04a;
white-space: nowrap;
}
.usage-when {
margin-left: auto;
white-space: nowrap;
}
.usage-foot {
padding: 7px 14px;
border-top: 1px solid #2a2a2a;
font-size: 10px;
color: #666;
text-align: center;
}

View file

@ -0,0 +1,136 @@
import { useEffect } from "react";
import type { SessionUsage } from "../ipc";
import {
sessionCost,
sessionTokens,
dominantModel,
totalCost,
formatUsd,
formatTokens,
relativeTime,
} from "../lib/usage";
import "./UsagePanel.css";
// Re-fetch cadence while the panel is open. App owns the data + the slower
// background poll that feeds the titlebar chip; this keeps the open panel live.
const PANEL_REFRESH_MS = 5000;
interface UsagePanelProps {
sessions: SessionUsage[];
loading: boolean;
onRefresh: () => void;
onClose: () => void;
/** cwd + label of currently-open WSL panes, used to highlight matching
* sessions (a session whose transcript cwd equals an open pane's cwd). */
openPanes: { cwd: string; label: string }[];
}
export default function UsagePanel({
sessions,
loading,
onRefresh,
onClose,
openPanes,
}: UsagePanelProps) {
// Refresh on open and on a light interval while open.
useEffect(() => {
onRefresh();
const id = window.setInterval(onRefresh, PANEL_REFRESH_MS);
return () => clearInterval(id);
// onRefresh is a stable useCallback in the parent.
}, [onRefresh]);
// cwd -> first matching open pane label.
const paneByCwd = new Map<string, string>();
for (const p of openPanes) {
if (p.cwd && !paneByCwd.has(p.cwd)) paneByCwd.set(p.cwd, p.label);
}
const nowMs = Date.now();
const total = totalCost(sessions);
return (
<>
<button className="backdrop" onClick={onClose} aria-label="Close" />
<div className="usage-panel" role="dialog" aria-label="Token usage">
<header className="usage-header">
<span className="usage-title">Usage</span>
<span className="usage-total" title="Estimated total across listed sessions">
{formatUsd(total)}
</span>
<button
className="usage-refresh"
onClick={onRefresh}
disabled={loading}
title="Refresh"
aria-label="Refresh"
>
</button>
<button className="usage-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
<div className="usage-body">
{sessions.length === 0 ? (
<p className="usage-empty">
{loading
? "Reading transcripts…"
: "No recent claude sessions found in the open panes' WSL distros."}
</p>
) : (
<ul className="usage-list">
{sessions.map((s) => {
const paneLabel = paneByCwd.get(s.cwd);
const open = paneLabel !== undefined;
return (
<li
key={`${s.distro}/${s.sessionId}`}
className={`usage-row${open ? " usage-row--open" : ""}`}
>
<span className="usage-dot" aria-hidden>
{open ? "●" : "○"}
</span>
<div className="usage-row-main">
<div className="usage-row-top">
<span className="usage-proj" title={s.cwd}>
{projectName(s.cwd) || s.projectDir}
</span>
<span className="usage-model">{dominantModel(s)}</span>
<span className="usage-tokens">
{formatTokens(sessionTokens(s))} tok
</span>
<span className="usage-cost">{formatUsd(sessionCost(s))}</span>
</div>
<div className="usage-row-sub">
<span className="usage-cwd" title={s.cwd}>
{s.cwd}
</span>
{open && (
<span className="usage-pane-tag">[pane: {paneLabel}]</span>
)}
<span className="usage-when">{relativeTime(s.lastActiveMs, nowMs)}</span>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
<footer className="usage-foot">
= open pane &nbsp;·&nbsp; estimate (rates may drift) &nbsp;·&nbsp; recent
sessions only
</footer>
</div>
</>
);
}
/** Last path segment of a cwd for a compact project label. */
function projectName(cwd: string): string {
const parts = cwd.split("/").filter(Boolean);
return parts.length ? parts[parts.length - 1] : cwd;
}

View file

@ -39,6 +39,34 @@ export interface SshHost {
export const listDistros = (): Promise<string[]> => invoke("list_distros");
// ---- claude usage tracking ------------------------------------------------
/** Per-model token tally within one claude session. Mirrors Rust ModelUsage. */
export interface ModelUsage {
model: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
}
/** One claude session's usage, read from its transcript. Mirrors Rust
* SessionUsage. Cost is computed frontend-side (see src/lib/usage.ts). */
export interface SessionUsage {
sessionId: string;
cwd: string;
projectDir: string;
distro: string;
lastActiveMs: number;
models: ModelUsage[];
}
/** Scan ~/.claude/projects in the given WSL distros (distinct distros of
* open WSL panes) and return recent sessions' token tallies. WSL/Windows
* only returns [] otherwise. */
export const getClaudeUsage = (distros: string[]): Promise<SessionUsage[]> =>
invoke("get_claude_usage", { distros });
export const spawnPane = (args: {
spec: SpawnSpec;
cols: number;

View file

@ -130,6 +130,16 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
},
],
},
{
title: "Panels",
items: [
{
keys: "Ctrl+Shift+U",
description:
"Open the usage panel — per-session claude token counts + estimated cost for the open WSL panes",
},
],
},
{
title: "Help",
items: [{ keys: "F1", description: "Show this help overlay" }],

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`;
}