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
14
README.md
14
README.md
|
|
@ -61,7 +61,10 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
|
|||
| Key | Action |
|
||||
|---|---|
|
||||
| `Ctrl+K` | Open jump-to-pane palette |
|
||||
| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction |
|
||||
| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction (window-level — works even when no terminal is focused) |
|
||||
| `Ctrl+Alt+← / → / ↑ / ↓` | Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it) |
|
||||
| `Ctrl+Alt+H / J / K / L` | Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right) |
|
||||
| `Alt+1 … Alt+9` | Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict. |
|
||||
|
||||
**Broadcast**
|
||||
|
||||
|
|
@ -82,6 +85,15 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
|
|||
| Key | Action |
|
||||
|---|---|
|
||||
| `Ctrl+Shift+C / Ctrl+Shift+V` | Copy selection / paste in terminal |
|
||||
| `Ctrl+Shift+F` | Open find-in-scrollback bar for the focused pane |
|
||||
| `Enter / Shift+Enter` | Next / previous match (while search bar is focused) |
|
||||
| `Escape` | Close find bar and return focus to terminal |
|
||||
|
||||
**Panels**
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Ctrl+Shift+U` | Open the usage panel — per-session claude token counts + estimated cost for the open WSL panes |
|
||||
|
||||
**Help**
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ mod hosts;
|
|||
mod mcp;
|
||||
mod mcp_policy;
|
||||
mod pty;
|
||||
mod usage;
|
||||
mod window_state;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
@ -66,6 +67,7 @@ pub fn run() {
|
|||
.manage(pending_actions)
|
||||
.manage(windows_state)
|
||||
.manage(pending_inits)
|
||||
.manage(usage::UsageCache::default())
|
||||
.on_window_event(move |window, event| {
|
||||
// When a non-main window closes, drop its workspaces from the
|
||||
// aggregator AND any unconsumed pending-init payload so neither
|
||||
|
|
@ -108,6 +110,7 @@ pub fn run() {
|
|||
commands::mcp_policy_load,
|
||||
commands::mcp_policy_save,
|
||||
commands::mcp_hard_deny_labels,
|
||||
usage::get_claude_usage,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -495,7 +495,7 @@ fn looks_like_password_prompt(buf: &[u8]) -> bool {
|
|||
// ---- distro enumeration -----------------------------------------------------
|
||||
|
||||
/// Run a process without flashing a console window on Windows.
|
||||
fn quiet_command(program: &str) -> std::process::Command {
|
||||
pub(crate) fn quiet_command(program: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
|
|
|
|||
278
src-tauri/src/usage.rs
Normal file
278
src-tauri/src/usage.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
//! Reads claude-code session transcripts and tallies token usage per session
|
||||
//! for the usage panel.
|
||||
//!
|
||||
//! claude writes one JSONL transcript per session at
|
||||
//! `~/.claude/projects/<mangled-cwd>/<sessionId>.jsonl`. Every assistant line
|
||||
//! carries `cwd`, `sessionId`, `message.model`, and `message.usage`
|
||||
//! (input/output/cache tokens). We read those straight out of the file, so the
|
||||
//! reported cwd/model are accurate regardless of where the pane was spawned.
|
||||
//!
|
||||
//! Windows-only: the transcripts live inside each WSL distro, reached via the
|
||||
//! `\\wsl.localhost\<distro>\…` 9p share. Returns empty on non-Windows.
|
||||
//!
|
||||
//! Cost is computed on the frontend (see src/lib/usage.ts) so the rate table is
|
||||
//! easy to tweak; this module only returns raw per-model token tallies.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::pty::quiet_command;
|
||||
|
||||
/// Ignore sessions older than this, and cap the number returned — bounds the
|
||||
/// scan cost on machines with a large transcript history.
|
||||
const MAX_AGE_MS: i64 = 30 * 24 * 60 * 60 * 1000;
|
||||
const MAX_SESSIONS: usize = 50;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelUsage {
|
||||
pub model: String,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionUsage {
|
||||
pub session_id: String,
|
||||
pub cwd: String,
|
||||
pub project_dir: String,
|
||||
pub distro: String,
|
||||
pub last_active_ms: i64,
|
||||
pub models: Vec<ModelUsage>,
|
||||
}
|
||||
|
||||
/// Parsed-file cache entry, validated by (size, mtime) so we only re-parse the
|
||||
/// one transcript that actually grew between polls.
|
||||
struct CachedFile {
|
||||
size: u64,
|
||||
mtime_ms: i64,
|
||||
cwd: String,
|
||||
models: Vec<ModelUsage>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UsageCache {
|
||||
files: Mutex<HashMap<PathBuf, CachedFile>>,
|
||||
/// distro -> resolved `$HOME` (one wsl.exe probe per distro per process).
|
||||
homes: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Read + tally claude usage across the given WSL distros (the distinct distros
|
||||
/// of currently-open WSL panes). Newest sessions first, capped to MAX_SESSIONS.
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_usage(
|
||||
distros: Vec<String>,
|
||||
cache: tauri::State<'_, UsageCache>,
|
||||
) -> Result<Vec<SessionUsage>, String> {
|
||||
if !cfg!(windows) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let cache = cache.inner();
|
||||
let mut out: Vec<SessionUsage> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for distro in distros.into_iter().filter(|d| !d.is_empty() && seen.insert(d.clone())) {
|
||||
match collect_distro(&distro, cache) {
|
||||
Ok(mut v) => out.append(&mut v),
|
||||
Err(e) => tracing::warn!("usage scan for distro {distro} failed: {e}"),
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.last_active_ms.cmp(&a.last_active_ms));
|
||||
out.truncate(MAX_SESSIONS);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionUsage>, String> {
|
||||
let home = resolve_home(distro, cache)?;
|
||||
let projects = projects_dir(distro, &home)
|
||||
.ok_or_else(|| format!("no ~/.claude/projects reachable for {distro}"))?;
|
||||
|
||||
// Gather candidate transcripts (path, project-dir name, mtime), newest first.
|
||||
let now = now_ms();
|
||||
let mut candidates: Vec<(PathBuf, String, i64)> = Vec::new();
|
||||
for proj in std::fs::read_dir(&projects).map_err(|e| e.to_string())?.flatten() {
|
||||
let proj_path = proj.path();
|
||||
if !proj_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let proj_name = proj.file_name().to_string_lossy().into_owned();
|
||||
let inner = match std::fs::read_dir(&proj_path) {
|
||||
Ok(it) => it,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for f in inner.flatten() {
|
||||
let p = f.path();
|
||||
if p.extension().and_then(|e| e.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
let mtime = std::fs::metadata(&p)
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(sys_to_ms)
|
||||
.unwrap_or(0);
|
||||
if now - mtime > MAX_AGE_MS {
|
||||
continue;
|
||||
}
|
||||
candidates.push((p, proj_name.clone(), mtime));
|
||||
}
|
||||
}
|
||||
candidates.sort_by(|a, b| b.2.cmp(&a.2));
|
||||
candidates.truncate(MAX_SESSIONS);
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (path, proj_name, mtime) in candidates {
|
||||
let (cwd, models) = match parse_or_cache(&path, cache) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::debug!("skip transcript {}: {e}", path.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let session_id = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
out.push(SessionUsage {
|
||||
session_id,
|
||||
cwd,
|
||||
project_dir: proj_name,
|
||||
distro: distro.to_string(),
|
||||
last_active_ms: mtime,
|
||||
models,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Probe `$HOME` inside the distro once and cache it. `sh -c` (not a login
|
||||
/// shell) so rc-file output can't contaminate stdout.
|
||||
fn resolve_home(distro: &str, cache: &UsageCache) -> Result<String, String> {
|
||||
if let Some(h) = cache.homes.lock().get(distro) {
|
||||
return Ok(h.clone());
|
||||
}
|
||||
let out = quiet_command("wsl.exe")
|
||||
.args(["-d", distro, "--", "sh", "-c", "printf %s \"$HOME\""])
|
||||
.output()
|
||||
.map_err(|e| format!("wsl.exe $HOME probe: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("wsl.exe $HOME probe exited {:?}", out.status.code()));
|
||||
}
|
||||
let home = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if home.is_empty() {
|
||||
return Err("empty $HOME".into());
|
||||
}
|
||||
cache.homes.lock().insert(distro.to_string(), home.clone());
|
||||
Ok(home)
|
||||
}
|
||||
|
||||
/// `~/.claude/projects` as a Windows UNC path into the distro. Tries the newer
|
||||
/// `\\wsl.localhost\` share first, then the legacy `\\wsl$\` alias.
|
||||
fn projects_dir(distro: &str, home: &str) -> Option<PathBuf> {
|
||||
let home_rel = home.trim_start_matches('/');
|
||||
for prefix in [
|
||||
format!(r"\\wsl.localhost\{distro}"),
|
||||
format!(r"\\wsl$\{distro}"),
|
||||
] {
|
||||
let p = PathBuf::from(prefix)
|
||||
.join(home_rel)
|
||||
.join(".claude")
|
||||
.join("projects");
|
||||
if p.is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_or_cache(path: &Path, cache: &UsageCache) -> Result<(String, Vec<ModelUsage>), String> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
|
||||
let size = meta.len();
|
||||
let mtime = meta.modified().ok().and_then(sys_to_ms).unwrap_or(0);
|
||||
if let Some(c) = cache.files.lock().get(path) {
|
||||
if c.size == size && c.mtime_ms == mtime {
|
||||
return Ok((c.cwd.clone(), c.models.clone()));
|
||||
}
|
||||
}
|
||||
let (cwd, models) = parse_file(path)?;
|
||||
cache.files.lock().insert(
|
||||
path.to_path_buf(),
|
||||
CachedFile {
|
||||
size,
|
||||
mtime_ms: mtime,
|
||||
cwd: cwd.clone(),
|
||||
models: models.clone(),
|
||||
},
|
||||
);
|
||||
Ok((cwd, models))
|
||||
}
|
||||
|
||||
fn parse_file(path: &Path) -> Result<(String, Vec<ModelUsage>), String> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let mut cwd = String::new();
|
||||
let mut by_model: HashMap<String, ModelUsage> = HashMap::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let v: serde_json::Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // tolerate a truncated final line / stray text
|
||||
};
|
||||
if cwd.is_empty() {
|
||||
if let Some(c) = v.get("cwd").and_then(|x| x.as_str()) {
|
||||
cwd = c.to_string();
|
||||
}
|
||||
}
|
||||
if v.get("type").and_then(|x| x.as_str()) != Some("assistant") {
|
||||
continue;
|
||||
}
|
||||
let msg = match v.get("message") {
|
||||
Some(m) => m,
|
||||
None => continue,
|
||||
};
|
||||
let usage = match msg.get("usage") {
|
||||
Some(u) => u,
|
||||
None => continue,
|
||||
};
|
||||
let model = msg
|
||||
.get("model")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let tok = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
let entry = by_model.entry(model.clone()).or_insert_with(|| ModelUsage {
|
||||
model,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
});
|
||||
entry.input_tokens += tok("input_tokens");
|
||||
entry.output_tokens += tok("output_tokens");
|
||||
entry.cache_creation_tokens += tok("cache_creation_input_tokens");
|
||||
entry.cache_read_tokens += tok("cache_read_input_tokens");
|
||||
}
|
||||
|
||||
let mut models: Vec<ModelUsage> = by_model.into_values().collect();
|
||||
models.sort_by(|a, b| b.output_tokens.cmp(&a.output_tokens));
|
||||
Ok((cwd, models))
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn sys_to_ms(t: SystemTime) -> Option<i64> {
|
||||
t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_millis() as i64)
|
||||
}
|
||||
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]}
|
||||
|
|
|
|||
167
src/components/UsagePanel.css
Normal file
167
src/components/UsagePanel.css
Normal 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;
|
||||
}
|
||||
136
src/components/UsagePanel.tsx
Normal file
136
src/components/UsagePanel.tsx
Normal 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 · estimate (rates may drift) · 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;
|
||||
}
|
||||
28
src/ipc.ts
28
src/ipc.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
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