From 1df8c3181bffb09d7da66841d78feaacdf653967 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 22:15:51 +0100 Subject: [PATCH] Add per-session claude token/cost usage panel (WSL, v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 14 +- src-tauri/src/lib.rs | 3 + src-tauri/src/pty.rs | 2 +- src-tauri/src/usage.rs | 278 ++++++++++++++++++++++++++++++++++ src/App.tsx | 80 ++++++++++ src/components/UsagePanel.css | 167 ++++++++++++++++++++ src/components/UsagePanel.tsx | 136 +++++++++++++++++ src/ipc.ts | 28 ++++ src/lib/shortcuts.ts | 10 ++ src/lib/usage.ts | 97 ++++++++++++ 10 files changed, 813 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/usage.rs create mode 100644 src/components/UsagePanel.css create mode 100644 src/components/UsagePanel.tsx create mode 100644 src/lib/usage.ts diff --git a/README.md b/README.md index 58eaa5a..aecdca1 100644 --- a/README.md +++ b/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** diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3a88bac..1e0aac9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index c404fdf..d4d78e5 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. -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)] { diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs new file mode 100644 index 0000000..e79ab02 --- /dev/null +++ b/src-tauri/src/usage.rs @@ -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//.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 ffceee2..c0a9858 100644 --- a/src/App.tsx +++ b/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([]); + const [usageLoading, setUsageLoading] = useState(false); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); 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(); + 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() { > πŸ€– + + + + +
+ {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 6660ed8..4600a5e 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -39,6 +39,34 @@ 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 a72cddb..8e71108 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -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" }], diff --git a/src/lib/usage.ts b/src/lib/usage.ts new file mode 100644 index 0000000..a42ea27 --- /dev/null +++ b/src/lib/usage.ts @@ -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`; +}