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
80
src/App.tsx
80
src/App.tsx
|
|
@ -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]}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue