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]}