diff --git a/README.md b/README.md index aecdca1..58eaa5a 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,7 @@ 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 (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. | +| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction | **Broadcast** @@ -85,15 +82,6 @@ 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** diff --git a/memory.md b/memory.md index f5efa26..e3dde1f 100644 --- a/memory.md +++ b/memory.md @@ -56,7 +56,7 @@ Durable memory for this project. Read at session start, update before session en Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecosystem, codebase gap-analysis) into things to add. **Headline finding:** tiletopia already owns the hard primitives (tiling, multi-window, broadcast, MCP control surface); the real gap vs Conductor/Crystal/claude-squad/Vibe-Kanban is *git-worktree isolation + per-session status/cost/diff visibility*. Full agent deliverables are in this session's conversation; condensed here. **→ Exploring first (user-selected 2026-05-28):** -- [x] ~~**Per-session cost / token tracking.**~~ Done (code) 2026-05-28 — **WSL-only v1, pending Windows runtime verify.** Backend `src-tauri/src/usage.rs` (`get_claude_usage(distros)` command): probes `$HOME` per distro via `wsl.exe`, reads `~/.claude/projects/*/*.jsonl` over the `\\wsl.localhost\` UNC share, tallies `message.usage` **per model per assistant line** (sessions can switch models). Cached by `(path,size,mtime)`; recency-capped 30d/50 sessions. Frontend: `src/lib/usage.ts` holds the editable pricing table (per-MTok, matched by opus/sonnet/haiku substring) + cost/format helpers; `UsagePanel.tsx` (MCP-panel modal pattern) lists sessions, highlights those whose transcript `cwd` matches an open pane (`[pane: label]`); titlebar 💰 total chip; App polls 20s (visible) / 5s (panel open); **Ctrl+Shift+U** opens it. **Design choice:** session-list attribution (not 1:1 pane binding) — avoids the unsolvable "2 claudes in one cwd" ambiguity. **Caveats:** cost is an estimate (cache-creation priced at 5m rate; rates hardcoded, may drift); panes with no explicit cwd (`~`) won't highlight; PowerShell/SSH show nothing. Plan: `~/.claude/plans/greedy-cooking-flask.md`. +- [ ] **Per-session cost / token tracking.** Parse `~/.claude/projects//.jsonl` (`message.usage`: input/output/cache_read/cache_write + model per assistant line) → tokens + estimated $ per pane and per workspace. Easy parsing; the fiddly bit is mapping a tiletopia pane → its session file (capture session id / cwd at spawn). Difficulty: easy–medium. - [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium. - [x] ~~**Find in scrollback.**~~ Done + **verified on Windows 2026-05-28** — `@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight. - [x] ~~**Unicode 11 + grapheme width.**~~ Done + **verified on Windows 2026-05-28** — `@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. (Skipped the separate `addon-unicode-graphemes` for now.) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21bfa82..3eb8b88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,18 +17,9 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2.0.0 version: 2.5.4 - '@xterm/addon-canvas': - specifier: ^0.7.0 - version: 0.7.0(@xterm/xterm@5.5.0) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) - '@xterm/addon-search': - specifier: ^0.15.0 - version: 0.15.0(@xterm/xterm@5.5.0) - '@xterm/addon-unicode11': - specifier: ^0.8.0 - version: 0.8.0(@xterm/xterm@5.5.0) '@xterm/addon-web-links': specifier: ^0.12.0 version: 0.12.0 @@ -593,26 +584,11 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - '@xterm/addon-canvas@0.7.0': - resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - '@xterm/addon-fit@0.10.0': resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} peerDependencies: '@xterm/xterm': ^5.0.0 - '@xterm/addon-search@0.15.0': - resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - - '@xterm/addon-unicode11@0.8.0': - resolution: {integrity: sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - '@xterm/addon-web-links@0.12.0': resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} @@ -1310,22 +1286,10 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - '@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': dependencies: '@xterm/xterm': 5.5.0 - '@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - - '@xterm/addon-unicode11@0.8.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - '@xterm/addon-web-links@0.12.0': {} '@xterm/xterm@5.5.0': {} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1e0aac9..3a88bac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,6 @@ mod hosts; mod mcp; mod mcp_policy; mod pty; -mod usage; mod window_state; use std::sync::Arc; @@ -67,7 +66,6 @@ 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 @@ -110,7 +108,6 @@ 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"); diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index d4d78e5..c404fdf 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -495,7 +495,7 @@ fn looks_like_password_prompt(buf: &[u8]) -> bool { // ---- distro enumeration ----------------------------------------------------- /// Run a process without flashing a console window on Windows. -pub(crate) fn quiet_command(program: &str) -> std::process::Command { +fn quiet_command(program: &str) -> std::process::Command { let mut c = std::process::Command::new(program); #[cfg(windows)] { diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs deleted file mode 100644 index e79ab02..0000000 --- a/src-tauri/src/usage.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! 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//.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\\…` 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, -} - -/// 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, -} - -#[derive(Default)] -pub struct UsageCache { - files: Mutex>, - /// distro -> resolved `$HOME` (one wsl.exe probe per distro per process). - homes: Mutex>, -} - -/// 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, - cache: tauri::State<'_, UsageCache>, -) -> Result, String> { - if !cfg!(windows) { - return Ok(Vec::new()); - } - let cache = cache.inner(); - let mut out: Vec = 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, 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 { - 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 { - 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), 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), 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 = 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 = 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 { - t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_millis() as i64) -} diff --git a/src/App.tsx b/src/App.tsx index c0a9858..ffceee2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,10 +23,8 @@ import { createPaneWindow, takePendingWindowInit, pushWindowWorkspaces, - getClaudeUsage, type PaneId, type SpawnSpec, - type SessionUsage, type SshHost, type McpStatus, type McpMirror, @@ -108,8 +106,6 @@ 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"; @@ -243,9 +239,6 @@ export default function App() { token: null, }); const [mcpPanelOpen, setMcpPanelOpen] = useState(false); - const [usagePanelOpen, setUsagePanelOpen] = useState(false); - const [usageSessions, setUsageSessions] = useState([]); - const [usageLoading, setUsageLoading] = useState(false); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -757,53 +750,6 @@ 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(); - 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(() => { @@ -906,14 +852,6 @@ 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(); @@ -2147,14 +2085,6 @@ export default function App() { > 🤖 - - - - -
- {sessions.length === 0 ? ( -

- {loading - ? "Reading transcripts…" - : "No recent claude sessions found in the open panes' WSL distros."} -

- ) : ( -
    - {sessions.map((s) => { - const paneLabel = paneByCwd.get(s.cwd); - const open = paneLabel !== undefined; - return ( -
  • - - {open ? "●" : "○"} - -
    -
    - - {projectName(s.cwd) || s.projectDir} - - {dominantModel(s)} - - {formatTokens(sessionTokens(s))} tok - - {formatUsd(sessionCost(s))} -
    -
    - - {s.cwd} - - {open && ( - [pane: {paneLabel}] - )} - {relativeTime(s.lastActiveMs, nowMs)} -
    -
    -
  • - ); - })} -
- )} -
- -
- ● = open pane  ·  estimate (rates may drift)  ·  recent - sessions only -
- - - ); -} - -/** 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; -} diff --git a/src/ipc.ts b/src/ipc.ts index 4600a5e..6660ed8 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -39,34 +39,6 @@ export interface SshHost { export const listDistros = (): Promise => 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 => - invoke("get_claude_usage", { distros }); - export const spawnPane = (args: { spec: SpawnSpec; cols: number; diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 8e71108..a72cddb 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -130,16 +130,6 @@ 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" }], diff --git a/src/lib/usage.ts b/src/lib/usage.ts deleted file mode 100644 index a42ea27..0000000 --- a/src/lib/usage.ts +++ /dev/null @@ -1,97 +0,0 @@ -// 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`; -}