diff --git a/README.md b/README.md index aecdca1..23ec0cb 100644 --- a/README.md +++ b/README.md @@ -89,12 +89,6 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `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** | Key | Action | diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1e0aac9..5279e1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,7 +110,7 @@ pub fn run() { commands::mcp_policy_load, commands::mcp_policy_save, commands::mcp_hard_deny_labels, - usage::get_claude_usage, + usage::get_pane_context, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index e79ab02..e718dc8 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1,17 +1,15 @@ -//! Reads claude-code session transcripts and tallies token usage per session -//! for the usage panel. +//! Reads claude-code session transcripts to report each session's **current +//! context-window occupancy** for the per-pane context indicator. //! //! 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. +//! carries `cwd`, `message.model`, and `message.usage`. The size of the prompt +//! sent on the most recent turn — `input_tokens + cache_read_input_tokens + +//! cache_creation_input_tokens` of the LAST assistant line — is a good proxy +//! for "how full is this session's context window right now". //! //! 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}; @@ -29,23 +27,15 @@ 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 struct SessionContext { pub session_id: String, pub cwd: String, - pub project_dir: String, pub distro: String, pub last_active_ms: i64, - pub models: Vec, + /// Prompt size of the last assistant turn (input + both cache buckets) — + /// the current context-window occupancy. + pub context_tokens: u64, + pub model: String, } /// Parsed-file cache entry, validated by (size, mtime) so we only re-parse the @@ -54,7 +44,8 @@ struct CachedFile { size: u64, mtime_ms: i64, cwd: String, - models: Vec, + context_tokens: u64, + model: String, } #[derive(Default)] @@ -64,23 +55,24 @@ pub struct UsageCache { 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. +/// Read each recent session's current context occupancy across the given WSL +/// distros (the distinct distros of currently-open WSL panes). Newest first, +/// capped to MAX_SESSIONS. #[tauri::command] -pub async fn get_claude_usage( +pub async fn get_pane_context( distros: Vec, cache: tauri::State<'_, UsageCache>, -) -> Result, String> { +) -> Result, String> { if !cfg!(windows) { return Ok(Vec::new()); } let cache = cache.inner(); - let mut out: Vec = Vec::new(); + 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}"), + Err(e) => tracing::warn!("context scan for distro {distro} failed: {e}"), } } out.sort_by(|a, b| b.last_active_ms.cmp(&a.last_active_ms)); @@ -88,20 +80,19 @@ pub async fn get_claude_usage( Ok(out) } -fn collect_distro(distro: &str, cache: &UsageCache) -> Result, String> { +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. + // Gather candidate transcripts (path, mtime), newest first. let now = now_ms(); - let mut candidates: Vec<(PathBuf, String, i64)> = Vec::new(); + let mut candidates: Vec<(PathBuf, 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, @@ -119,15 +110,15 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result, if now - mtime > MAX_AGE_MS { continue; } - candidates.push((p, proj_name.clone(), mtime)); + candidates.push((p, mtime)); } } - candidates.sort_by(|a, b| b.2.cmp(&a.2)); + candidates.sort_by(|a, b| b.1.cmp(&a.1)); 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) { + for (path, mtime) in candidates { + let (cwd, context_tokens, model) = match parse_or_cache(&path, cache) { Ok(v) => v, Err(e) => { tracing::debug!("skip transcript {}: {e}", path.display()); @@ -138,13 +129,13 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result, .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); - out.push(SessionUsage { + out.push(SessionContext { session_id, cwd, - project_dir: proj_name, distro: distro.to_string(), last_active_ms: mtime, - models, + context_tokens, + model, }); } Ok(out) @@ -190,32 +181,39 @@ fn projects_dir(distro: &str, home: &str) -> Option { None } -fn parse_or_cache(path: &Path, cache: &UsageCache) -> Result<(String, Vec), String> { +fn parse_or_cache( + path: &Path, + cache: &UsageCache, +) -> Result<(String, u64, String), 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())); + return Ok((c.cwd.clone(), c.context_tokens, c.model.clone())); } } - let (cwd, models) = parse_file(path)?; + let (cwd, context_tokens, model) = parse_file(path)?; cache.files.lock().insert( path.to_path_buf(), CachedFile { size, mtime_ms: mtime, cwd: cwd.clone(), - models: models.clone(), + context_tokens, + model: model.clone(), }, ); - Ok((cwd, models)) + Ok((cwd, context_tokens, model)) } -fn parse_file(path: &Path) -> Result<(String, Vec), String> { +/// Returns (cwd, context_tokens, model) where context_tokens is the prompt size +/// of the LAST assistant turn — the current context-window occupancy. +fn parse_file(path: &Path) -> Result<(String, u64, String), 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(); + let mut context_tokens = 0u64; + let mut model = String::new(); for line in content.lines() { let line = line.trim(); @@ -242,28 +240,19 @@ fn parse_file(path: &Path) -> Result<(String, Vec), String> { Some(u) => u, None => continue, }; - let model = msg + let tok = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0); + // Overwrite each turn so we end up with the LAST assistant line's values. + context_tokens = tok("input_tokens") + + tok("cache_read_input_tokens") + + tok("cache_creation_input_tokens"); + 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)) + Ok((cwd, context_tokens, model)) } fn now_ms() -> i64 { diff --git a/src/App.tsx b/src/App.tsx index 2299eea..8f214a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,10 +23,10 @@ import { createPaneWindow, takePendingWindowInit, pushWindowWorkspaces, - getClaudeUsage, + getPaneContext, type PaneId, type SpawnSpec, - type SessionUsage, + type SessionContext, type SshHost, type McpStatus, type McpMirror, @@ -108,8 +108,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 +241,7 @@ 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 [contextSessions, setContextSessions] = useState([]); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -757,59 +753,52 @@ 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; + // ---- claude context tracking -------------------------------------------- + // Reads each recent session's current context occupancy from ~/.claude + // transcripts (backend), for the per-pane context-fill indicator. The fetch + // guard collapses overlapping ticks. + const contextFetchingRef = useRef(false); + const refreshContext = useCallback(async () => { + if (contextFetchingRef.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([]); + setContextSessions([]); return; } - usageFetchingRef.current = true; - setUsageLoading(true); + contextFetchingRef.current = true; try { - setUsageSessions(await getClaudeUsage(Array.from(distros))); + setContextSessions(await getPaneContext(Array.from(distros))); } catch (e) { - console.warn("getClaudeUsage failed:", e); + console.warn("getPaneContext failed:", e); } finally { - usageFetchingRef.current = false; - setUsageLoading(false); + contextFetchingRef.current = false; } }, []); - // Background heartbeat so the titlebar total stays roughly current without - // the panel open. Gated on visibility so a hidden/minimized window stays quiet. + // Poll on a light interval, gated on visibility so a hidden/minimized window + // stays quiet. useEffect(() => { const tick = () => { - if (document.visibilityState === "visible") void refreshUsage(); + if (document.visibilityState === "visible") void refreshContext(); }; tick(); - const id = window.setInterval(tick, 20000); + const id = window.setInterval(tick, 15000); return () => clearInterval(id); - }, [refreshUsage]); + }, [refreshContext]); - // 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], - ); - - // Titlebar chip total — scoped to the open panes ("this workspace"), matching - // the usage panel's default view, so it isn't inflated by unrelated projects. - const workspaceUsageTotal = useMemo(() => { - const cwds = new Set(openPanes.map((p) => p.cwd).filter(Boolean)); - return totalCost(usageSessions.filter((s) => cwds.has(s.cwd))); - }, [openPanes, usageSessions]); + // cwd -> newest session's context, consumed by each LeafPane via orchestration. + const paneContext = useMemo(() => { + const m = new Map(); + for (const s of contextSessions) { + if (!s.cwd) continue; + const prev = m.get(s.cwd); + if (!prev || s.lastActiveMs > prev.lastActiveMs) m.set(s.cwd, s); + } + return m; + }, [contextSessions]); // Outside-click dismissal for the titlebar dropdowns. Mirrors the // per-pane shell-picker pattern in LeafPane.tsx. @@ -913,13 +902,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") { @@ -1353,6 +1335,7 @@ export default function App() { reportLeafIdle, moveToNewWindow, getInitialPaneIdFor, + paneContext, }), [ activeLeafId, @@ -1378,6 +1361,7 @@ export default function App() { reportLeafIdle, moveToNewWindow, getInitialPaneIdFor, + paneContext, ], ); @@ -2154,14 +2138,6 @@ export default function App() { > 🤖 - - - {formatTokens(shown.reduce((a, s) => a + sessionTokens(s), 0))} tok - - {" · ~"} - {formatUsd(total)} - - - - - - -
- {shown.length === 0 ? ( -

- {loading - ? "Reading transcripts…" - : sessions.length > 0 && !showAll - ? "No open pane has a matching claude session yet." - : "No recent claude sessions found in the open panes' WSL distros."} - {sessions.length > 0 && !showAll && ( - <> - {" "} - - - )} -

- ) : ( -
    - {shown.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  ·  ~$ is an API-pricing estimate (n/a on Pro/Max; - can't reflect /usage quota)  ·  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..5eb0829 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -39,33 +39,25 @@ export interface SshHost { export const listDistros = (): Promise => invoke("list_distros"); -// ---- claude usage tracking ------------------------------------------------ +// ---- claude context 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 { +/** One claude session's current context-window occupancy, read from its + * transcript. Mirrors Rust SessionContext. `contextTokens` is the prompt + * size of the last assistant turn (input + both cache buckets). */ +export interface SessionContext { sessionId: string; cwd: string; - projectDir: string; distro: string; lastActiveMs: number; - models: ModelUsage[]; + contextTokens: number; + model: string; } -/** 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 }); +/** Scan ~/.claude/projects in the given WSL distros (distinct distros of open + * WSL panes) and return each recent session's current context occupancy. + * WSL/Windows only — returns [] otherwise. */ +export const getPaneContext = (distros: string[]): Promise => + invoke("get_pane_context", { distros }); export const spawnPane = (args: { spec: SpawnSpec; diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index 785e5f2..bccac05 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -84,6 +84,10 @@ overflow: hidden; text-overflow: ellipsis; max-width: 200px; + /* Give up width first when the pane is narrow, so the chips, context + indicator, and close button stay visible (overrides .pane-toolbar > *). */ + flex-shrink: 1; + min-width: 0; } .pane-label:hover { background: #222; @@ -242,6 +246,9 @@ .pane-status.idle { color: #d96060; } .pane-actions { + /* Final fallback right-anchor (non-claude pane has no .pane-ctx, and at + narrow tiers .pane-status is hidden) so the close button stays pinned right. */ + margin-left: auto; display: flex; gap: 2px; } @@ -264,6 +271,55 @@ background: #5a1a1a; color: #fcc; } + +/* ---- per-pane context-fill indicator ----------------------------------- */ +.pane-ctx { + /* Fallback right-anchor: when .pane-status is hidden (narrow tiers) its + margin-left:auto is gone, so carry it here too. First auto in DOM order + (status → ctx → actions) consumes the free space; the rest no-op. */ + margin-left: auto; + display: flex; + align-items: center; + gap: 5px; + font-size: 10px; + color: #9aa0a6; +} +.pane-ctx-bar { + width: 42px; + height: 6px; + background: #2a2a2a; + border-radius: 3px; + overflow: hidden; +} +.pane-ctx-fill { + display: block; + height: 100%; + border-radius: 3px; + transition: width 0.3s, background 0.3s; +} +.pane-ctx-pct { + font-variant-numeric: tabular-nums; + min-width: 26px; + text-align: right; +} + +/* ---- narrow-pane reflow ------------------------------------------------- + The close button + context indicator stay visible at every width; lower- + priority toolbar items drop out by tier so a 180px pane keeps its close ×. */ +.leaf--narrow .pane-status, +.leaf--narrow .pane-actions .pane-btn:not(.close) { + display: none; +} +.leaf--xnarrow .pane-status, +.leaf--xnarrow .pane-actions .pane-btn:not(.close), +.leaf--xnarrow .distro-wrap, +.leaf--xnarrow .bcast-chip { + display: none; +} +/* Keep just the % (drop the bar) at the tightest width. */ +.leaf--xnarrow .pane-ctx-bar { + display: none; +} .xterm-wrap { flex: 1 1 auto; min-height: 0; diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index cbd53af..a158396 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -10,6 +10,12 @@ import { import { createPortal } from "react-dom"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; import { useOrchestration } from "./orchestration"; +import { + contextLabel, + contextPercent, + contextColor, + contextFraction, +} from "../../lib/usage"; import XtermPane from "../../components/XtermPane"; import type { SpawnSpec } from "../../ipc"; import "./LeafPane.css"; @@ -42,6 +48,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { const [editingLabel, setEditingLabel] = useState(false); const [labelDraft, setLabelDraft] = useState(""); const labelInputRef = useRef(null); + const rootRef = useRef(null); const startEditLabel = useCallback( (e: MouseEvent) => { @@ -156,6 +163,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { return () => orch.reportLeafIdle(leaf.id, false); }, [leaf.id, orch.reportLeafIdle]); + // ---- width tier --------------------------------------------------------- + // Drives which toolbar items collapse on a narrow pane (CSS does the hiding). + // The close button + context indicator stay visible at every tier; min pane + // width is 180px (MIN_PANE_PX), so "xnarrow" must keep those reachable. + const [widthTier, setWidthTier] = useState<"" | "narrow" | "xnarrow">(""); + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + const w = el.clientWidth; + setWidthTier(w < 230 ? "xnarrow" : w < 320 ? "narrow" : ""); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + // ---- broadcast --------------------------------------------------------- const onTerminalInput = useCallback( (b64: string) => { @@ -386,9 +409,13 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { }; })(); + const ctx = + leaf.shellKind === "wsl" && leaf.cwd ? orch.paneContext.get(leaf.cwd) : undefined; + return (
{status} )} + {ctx && ( + + + + + {contextPercent(ctx)}% + + )} +