Replace token-usage panel with per-pane context-fill indicator

For a subscription user, lifetime token totals + a $ estimate aren't
actionable; how full each session's context window is right now is. So:

- Removed the UsagePanel, the titlebar 💰 chip, and Ctrl+Shift+U.
- Repurposed the transcript reader (src-tauri/src/usage.rs): get_pane_context
  returns each recent session's CURRENT context occupancy = the last
  assistant turn's input + cache_read + cache_creation tokens (the prompt
  size), instead of lifetime sums. Same UNC/$HOME/cache/recency machinery.
- src/lib/usage.ts now holds context helpers (window inference 200k vs 1M by
  whether occupancy already exceeds 200k, % , green→amber→red ramp, label).
- App polls get_pane_context (15s, visibility-gated) into a cwd→context map
  exposed via orchestration; each LeafPane looks itself up by leaf.cwd and
  renders a slim fill bar + % in its header (hidden for non-claude/unmatched
  panes).

Also fixes the narrow-pane toolbar: a ResizeObserver sets leaf--narrow /
leaf--xnarrow width tiers; the label shrinks first, split buttons / status /
secondary chips drop out by tier, and the close × + context indicator stay
pinned right and visible down to the 180px min width.

tsc clean (apart from the not-yet-installed xterm addons). 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:
megaproxy 2026-05-28 22:43:06 +01:00
parent b23f3d1ecb
commit d951c360ae
12 changed files with 235 additions and 612 deletions

View file

@ -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) | | `Enter / Shift+Enter` | Next / previous match (while search bar is focused) |
| `Escape` | Close find bar and return focus to terminal | | `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** **Help**
| Key | Action | | Key | Action |

View file

@ -110,7 +110,7 @@ pub fn run() {
commands::mcp_policy_load, commands::mcp_policy_load,
commands::mcp_policy_save, commands::mcp_policy_save,
commands::mcp_hard_deny_labels, commands::mcp_hard_deny_labels,
usage::get_claude_usage, usage::get_pane_context,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

@ -1,17 +1,15 @@
//! Reads claude-code session transcripts and tallies token usage per session //! Reads claude-code session transcripts to report each session's **current
//! for the usage panel. //! context-window occupancy** for the per-pane context indicator.
//! //!
//! claude writes one JSONL transcript per session at //! claude writes one JSONL transcript per session at
//! `~/.claude/projects/<mangled-cwd>/<sessionId>.jsonl`. Every assistant line //! `~/.claude/projects/<mangled-cwd>/<sessionId>.jsonl`. Every assistant line
//! carries `cwd`, `sessionId`, `message.model`, and `message.usage` //! carries `cwd`, `message.model`, and `message.usage`. The size of the prompt
//! (input/output/cache tokens). We read those straight out of the file, so the //! sent on the most recent turn — `input_tokens + cache_read_input_tokens +
//! reported cwd/model are accurate regardless of where the pane was spawned. //! 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 //! Windows-only: the transcripts live inside each WSL distro, reached via the
//! `\\wsl.localhost\<distro>\…` 9p share. Returns empty on non-Windows. //! `\\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::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -29,23 +27,15 @@ const MAX_SESSIONS: usize = 50;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ModelUsage { pub struct SessionContext {
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 session_id: String,
pub cwd: String, pub cwd: String,
pub project_dir: String,
pub distro: String, pub distro: String,
pub last_active_ms: i64, pub last_active_ms: i64,
pub models: Vec<ModelUsage>, /// 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 /// Parsed-file cache entry, validated by (size, mtime) so we only re-parse the
@ -54,7 +44,8 @@ struct CachedFile {
size: u64, size: u64,
mtime_ms: i64, mtime_ms: i64,
cwd: String, cwd: String,
models: Vec<ModelUsage>, context_tokens: u64,
model: String,
} }
#[derive(Default)] #[derive(Default)]
@ -64,23 +55,24 @@ pub struct UsageCache {
homes: Mutex<HashMap<String, String>>, homes: Mutex<HashMap<String, String>>,
} }
/// Read + tally claude usage across the given WSL distros (the distinct distros /// Read each recent session's current context occupancy across the given WSL
/// of currently-open WSL panes). Newest sessions first, capped to MAX_SESSIONS. /// distros (the distinct distros of currently-open WSL panes). Newest first,
/// capped to MAX_SESSIONS.
#[tauri::command] #[tauri::command]
pub async fn get_claude_usage( pub async fn get_pane_context(
distros: Vec<String>, distros: Vec<String>,
cache: tauri::State<'_, UsageCache>, cache: tauri::State<'_, UsageCache>,
) -> Result<Vec<SessionUsage>, String> { ) -> Result<Vec<SessionContext>, String> {
if !cfg!(windows) { if !cfg!(windows) {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let cache = cache.inner(); let cache = cache.inner();
let mut out: Vec<SessionUsage> = Vec::new(); let mut out: Vec<SessionContext> = Vec::new();
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for distro in distros.into_iter().filter(|d| !d.is_empty() && seen.insert(d.clone())) { for distro in distros.into_iter().filter(|d| !d.is_empty() && seen.insert(d.clone())) {
match collect_distro(&distro, cache) { match collect_distro(&distro, cache) {
Ok(mut v) => out.append(&mut v), 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)); 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) Ok(out)
} }
fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionUsage>, String> { fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionContext>, String> {
let home = resolve_home(distro, cache)?; let home = resolve_home(distro, cache)?;
let projects = projects_dir(distro, &home) let projects = projects_dir(distro, &home)
.ok_or_else(|| format!("no ~/.claude/projects reachable for {distro}"))?; .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 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() { for proj in std::fs::read_dir(&projects).map_err(|e| e.to_string())?.flatten() {
let proj_path = proj.path(); let proj_path = proj.path();
if !proj_path.is_dir() { if !proj_path.is_dir() {
continue; continue;
} }
let proj_name = proj.file_name().to_string_lossy().into_owned();
let inner = match std::fs::read_dir(&proj_path) { let inner = match std::fs::read_dir(&proj_path) {
Ok(it) => it, Ok(it) => it,
Err(_) => continue, Err(_) => continue,
@ -119,15 +110,15 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionUsage>,
if now - mtime > MAX_AGE_MS { if now - mtime > MAX_AGE_MS {
continue; 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); candidates.truncate(MAX_SESSIONS);
let mut out = Vec::new(); let mut out = Vec::new();
for (path, proj_name, mtime) in candidates { for (path, mtime) in candidates {
let (cwd, models) = match parse_or_cache(&path, cache) { let (cwd, context_tokens, model) = match parse_or_cache(&path, cache) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
tracing::debug!("skip transcript {}: {e}", path.display()); tracing::debug!("skip transcript {}: {e}", path.display());
@ -138,13 +129,13 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionUsage>,
.file_stem() .file_stem()
.map(|s| s.to_string_lossy().into_owned()) .map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(); .unwrap_or_default();
out.push(SessionUsage { out.push(SessionContext {
session_id, session_id,
cwd, cwd,
project_dir: proj_name,
distro: distro.to_string(), distro: distro.to_string(),
last_active_ms: mtime, last_active_ms: mtime,
models, context_tokens,
model,
}); });
} }
Ok(out) Ok(out)
@ -190,32 +181,39 @@ fn projects_dir(distro: &str, home: &str) -> Option<PathBuf> {
None None
} }
fn parse_or_cache(path: &Path, cache: &UsageCache) -> Result<(String, Vec<ModelUsage>), 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 meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
let size = meta.len(); let size = meta.len();
let mtime = meta.modified().ok().and_then(sys_to_ms).unwrap_or(0); let mtime = meta.modified().ok().and_then(sys_to_ms).unwrap_or(0);
if let Some(c) = cache.files.lock().get(path) { if let Some(c) = cache.files.lock().get(path) {
if c.size == size && c.mtime_ms == mtime { 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( cache.files.lock().insert(
path.to_path_buf(), path.to_path_buf(),
CachedFile { CachedFile {
size, size,
mtime_ms: mtime, mtime_ms: mtime,
cwd: cwd.clone(), 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<ModelUsage>), 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 content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let mut cwd = String::new(); let mut cwd = String::new();
let mut by_model: HashMap<String, ModelUsage> = HashMap::new(); let mut context_tokens = 0u64;
let mut model = String::new();
for line in content.lines() { for line in content.lines() {
let line = line.trim(); let line = line.trim();
@ -242,28 +240,19 @@ fn parse_file(path: &Path) -> Result<(String, Vec<ModelUsage>), String> {
Some(u) => u, Some(u) => u,
None => continue, 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") .get("model")
.and_then(|x| x.as_str()) .and_then(|x| x.as_str())
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .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(); Ok((cwd, context_tokens, model))
models.sort_by(|a, b| b.output_tokens.cmp(&a.output_tokens));
Ok((cwd, models))
} }
fn now_ms() -> i64 { fn now_ms() -> i64 {

View file

@ -23,10 +23,10 @@ import {
createPaneWindow, createPaneWindow,
takePendingWindowInit, takePendingWindowInit,
pushWindowWorkspaces, pushWindowWorkspaces,
getClaudeUsage, getPaneContext,
type PaneId, type PaneId,
type SpawnSpec, type SpawnSpec,
type SessionUsage, type SessionContext,
type SshHost, type SshHost,
type McpStatus, type McpStatus,
type McpMirror, type McpMirror,
@ -108,8 +108,6 @@ import Palette from "./components/Palette";
import HostManager from "./components/HostManager"; import HostManager from "./components/HostManager";
import Help from "./components/Help"; import Help from "./components/Help";
import McpPanel from "./components/McpPanel"; import McpPanel from "./components/McpPanel";
import UsagePanel from "./components/UsagePanel";
import { totalCost, formatUsd } from "./lib/usage";
import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm"; import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm";
import TabStrip from "./components/TabStrip"; import TabStrip from "./components/TabStrip";
import "./App.css"; import "./App.css";
@ -243,9 +241,7 @@ export default function App() {
token: null, token: null,
}); });
const [mcpPanelOpen, setMcpPanelOpen] = useState(false); const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
const [usagePanelOpen, setUsagePanelOpen] = useState(false); const [contextSessions, setContextSessions] = useState<SessionContext[]>([]);
const [usageSessions, setUsageSessions] = useState<SessionUsage[]>([]);
const [usageLoading, setUsageLoading] = useState(false);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [notifications, setNotifications] = useState<Toast[]>([]); const [notifications, setNotifications] = useState<Toast[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
@ -757,59 +753,52 @@ export default function App() {
const openHostManager = useCallback(() => setHostManagerOpen(true), []); const openHostManager = useCallback(() => setHostManagerOpen(true), []);
const closeHostManager = useCallback(() => setHostManagerOpen(false), []); const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
// ---- claude usage tracking ---------------------------------------------- // ---- claude context tracking --------------------------------------------
// Reads ~/.claude transcripts in the open WSL panes' distros (backend). The // Reads each recent session's current context occupancy from ~/.claude
// fetch guard collapses overlapping calls (the open panel polls every 5s and // transcripts (backend), for the per-pane context-fill indicator. The fetch
// the background heartbeat every 20s both call this). // guard collapses overlapping ticks.
const usageFetchingRef = useRef(false); const contextFetchingRef = useRef(false);
const refreshUsage = useCallback(async () => { const refreshContext = useCallback(async () => {
if (usageFetchingRef.current) return; if (contextFetchingRef.current) return;
const distros = new Set<string>(); const distros = new Set<string>();
for (const leaf of walkLeaves(treeRef.current)) { for (const leaf of walkLeaves(treeRef.current)) {
if (leaf.shellKind === "wsl" && leaf.distro) distros.add(leaf.distro); if (leaf.shellKind === "wsl" && leaf.distro) distros.add(leaf.distro);
} }
if (distros.size === 0) { if (distros.size === 0) {
setUsageSessions([]); setContextSessions([]);
return; return;
} }
usageFetchingRef.current = true; contextFetchingRef.current = true;
setUsageLoading(true);
try { try {
setUsageSessions(await getClaudeUsage(Array.from(distros))); setContextSessions(await getPaneContext(Array.from(distros)));
} catch (e) { } catch (e) {
console.warn("getClaudeUsage failed:", e); console.warn("getPaneContext failed:", e);
} finally { } finally {
usageFetchingRef.current = false; contextFetchingRef.current = false;
setUsageLoading(false);
} }
}, []); }, []);
// Background heartbeat so the titlebar total stays roughly current without // Poll on a light interval, gated on visibility so a hidden/minimized window
// the panel open. Gated on visibility so a hidden/minimized window stays quiet. // stays quiet.
useEffect(() => { useEffect(() => {
const tick = () => { const tick = () => {
if (document.visibilityState === "visible") void refreshUsage(); if (document.visibilityState === "visible") void refreshContext();
}; };
tick(); tick();
const id = window.setInterval(tick, 20000); const id = window.setInterval(tick, 15000);
return () => clearInterval(id); return () => clearInterval(id);
}, [refreshUsage]); }, [refreshContext]);
// cwd + label of open WSL panes, for highlighting matching sessions. // cwd -> newest session's context, consumed by each LeafPane via orchestration.
const openPanes = useMemo( const paneContext = useMemo(() => {
() => const m = new Map<string, SessionContext>();
Array.from(walkLeaves(tree)) for (const s of contextSessions) {
.filter((l) => l.shellKind === "wsl") if (!s.cwd) continue;
.map((l) => ({ cwd: l.cwd ?? "", label: l.label ?? l.distro ?? "pane" })), const prev = m.get(s.cwd);
[tree], if (!prev || s.lastActiveMs > prev.lastActiveMs) m.set(s.cwd, s);
); }
return m;
// Titlebar chip total — scoped to the open panes ("this workspace"), matching }, [contextSessions]);
// 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]);
// Outside-click dismissal for the titlebar dropdowns. Mirrors the // Outside-click dismissal for the titlebar dropdowns. Mirrors the
// per-pane shell-picker pattern in LeafPane.tsx. // per-pane shell-picker pattern in LeafPane.tsx.
@ -913,13 +902,6 @@ export default function App() {
return; 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 // Ctrl+Shift+Alt+B — global broadcast all/none
if (ctrl && shift && alt && key === "b") { if (ctrl && shift && alt && key === "b") {
@ -1353,6 +1335,7 @@ export default function App() {
reportLeafIdle, reportLeafIdle,
moveToNewWindow, moveToNewWindow,
getInitialPaneIdFor, getInitialPaneIdFor,
paneContext,
}), }),
[ [
activeLeafId, activeLeafId,
@ -1378,6 +1361,7 @@ export default function App() {
reportLeafIdle, reportLeafIdle,
moveToNewWindow, moveToNewWindow,
getInitialPaneIdFor, getInitialPaneIdFor,
paneContext,
], ],
); );
@ -2154,14 +2138,6 @@ export default function App() {
> >
🤖 🤖
</button> </button>
<button
className="palette-btn usage-btn"
onClick={() => setUsagePanelOpen(true)}
title="claude token usage & estimated cost (Ctrl+Shift+U)"
aria-label="Usage"
>
💰{workspaceUsageTotal > 0 ? ` ~${formatUsd(workspaceUsageTotal)}` : ""}
</button>
<button <button
className="palette-btn" className="palette-btn"
onClick={() => setHelpOpen(true)} onClick={() => setHelpOpen(true)}
@ -2282,16 +2258,6 @@ export default function App() {
/> />
)} )}
{usagePanelOpen && (
<UsagePanel
sessions={usageSessions}
loading={usageLoading}
onRefresh={refreshUsage}
onClose={() => setUsagePanelOpen(false)}
openPanes={openPanes}
/>
)}
{confirmQueue.length > 0 && ( {confirmQueue.length > 0 && (
<McpConfirm <McpConfirm
spec={confirmQueue[0]} spec={confirmQueue[0]}

View file

@ -1,194 +0,0 @@
/* 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-scope {
font: inherit;
font-size: 11px;
background: transparent;
border: 1px solid #2a2a2a;
border-radius: 3px;
color: #9aa0a6;
padding: 2px 8px;
cursor: pointer;
margin-right: auto;
}
.usage-scope:hover {
background: #2a2a2a;
color: #ddd;
}
.usage-total {
font-size: 13px;
font-weight: 600;
color: #e6e6e6;
}
.usage-total-usd {
color: #6cc04a;
font-weight: 600;
}
.usage-link {
font: inherit;
background: transparent;
border: none;
color: #6ca0d8;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
.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;
}

View file

@ -1,171 +0,0 @@
import { useEffect, useState } 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) {
// Default to the open panes' sessions ("this workspace"); toggle to see
// every recent session on the distros.
const [showAll, setShowAll] = useState(false);
// 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 matched = sessions.filter((s) => paneByCwd.has(s.cwd));
const shown = showAll ? sessions : matched;
const nowMs = Date.now();
const total = totalCost(shown);
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>
<button
className="usage-scope"
onClick={() => setShowAll((v) => !v)}
title={
showAll
? "Showing every recent session on the open panes' distros"
: "Showing only sessions for currently-open panes"
}
>
{showAll ? `all recent (${sessions.length})` : `open panes (${matched.length})`}
</button>
<span
className="usage-total"
title="Total tokens across the listed sessions"
>
{formatTokens(shown.reduce((a, s) => a + sessionTokens(s), 0))} tok
<span className="usage-total-usd" title="API-pricing estimate — n/a on a Pro/Max subscription">
{" · ~"}
{formatUsd(total)}
</span>
</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">
{shown.length === 0 ? (
<p className="usage-empty">
{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 && (
<>
{" "}
<button className="usage-link" onClick={() => setShowAll(true)}>
Show all recent ({sessions.length})
</button>
</>
)}
</p>
) : (
<ul className="usage-list">
{shown.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 &nbsp;·&nbsp; ~$ is an API-pricing estimate (n/a on Pro/Max;
can't reflect <code>/usage</code> quota) &nbsp;·&nbsp; 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;
}

View file

@ -39,33 +39,25 @@ export interface SshHost {
export const listDistros = (): Promise<string[]> => invoke("list_distros"); export const listDistros = (): Promise<string[]> => invoke("list_distros");
// ---- claude usage tracking ------------------------------------------------ // ---- claude context tracking ----------------------------------------------
/** Per-model token tally within one claude session. Mirrors Rust ModelUsage. */ /** One claude session's current context-window occupancy, read from its
export interface ModelUsage { * transcript. Mirrors Rust SessionContext. `contextTokens` is the prompt
model: string; * size of the last assistant turn (input + both cache buckets). */
inputTokens: number; export interface SessionContext {
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; sessionId: string;
cwd: string; cwd: string;
projectDir: string;
distro: string; distro: string;
lastActiveMs: number; lastActiveMs: number;
models: ModelUsage[]; contextTokens: number;
model: string;
} }
/** Scan ~/.claude/projects in the given WSL distros (distinct distros of /** Scan ~/.claude/projects in the given WSL distros (distinct distros of open
* open WSL panes) and return recent sessions' token tallies. WSL/Windows * WSL panes) and return each recent session's current context occupancy.
* only returns [] otherwise. */ * WSL/Windows only returns [] otherwise. */
export const getClaudeUsage = (distros: string[]): Promise<SessionUsage[]> => export const getPaneContext = (distros: string[]): Promise<SessionContext[]> =>
invoke("get_claude_usage", { distros }); invoke("get_pane_context", { distros });
export const spawnPane = (args: { export const spawnPane = (args: {
spec: SpawnSpec; spec: SpawnSpec;

View file

@ -84,6 +84,10 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 200px; 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 { .pane-label:hover {
background: #222; background: #222;
@ -242,6 +246,9 @@
.pane-status.idle { color: #d96060; } .pane-status.idle { color: #d96060; }
.pane-actions { .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; display: flex;
gap: 2px; gap: 2px;
} }
@ -264,6 +271,55 @@
background: #5a1a1a; background: #5a1a1a;
color: #fcc; 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 { .xterm-wrap {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;

View file

@ -10,6 +10,12 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
import { useOrchestration } from "./orchestration"; import { useOrchestration } from "./orchestration";
import {
contextLabel,
contextPercent,
contextColor,
contextFraction,
} from "../../lib/usage";
import XtermPane from "../../components/XtermPane"; import XtermPane from "../../components/XtermPane";
import type { SpawnSpec } from "../../ipc"; import type { SpawnSpec } from "../../ipc";
import "./LeafPane.css"; import "./LeafPane.css";
@ -42,6 +48,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const [editingLabel, setEditingLabel] = useState(false); const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState(""); const [labelDraft, setLabelDraft] = useState("");
const labelInputRef = useRef<HTMLInputElement | null>(null); const labelInputRef = useRef<HTMLInputElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const startEditLabel = useCallback( const startEditLabel = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
@ -156,6 +163,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
return () => orch.reportLeafIdle(leaf.id, false); return () => orch.reportLeafIdle(leaf.id, false);
}, [leaf.id, orch.reportLeafIdle]); }, [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 --------------------------------------------------------- // ---- broadcast ---------------------------------------------------------
const onTerminalInput = useCallback( const onTerminalInput = useCallback(
(b64: string) => { (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 ( return (
<div <div
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`} ref={rootRef}
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}${widthTier ? ` leaf--${widthTier}` : ""}`}
role="group" role="group"
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`} aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
data-leaf-id={leaf.id} data-leaf-id={leaf.id}
@ -537,6 +564,24 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span> <span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
)} )}
{ctx && (
<span
className="pane-ctx"
title={`Context: ${contextLabel(ctx)} (${ctx.model})`}
>
<span className="pane-ctx-bar">
<span
className="pane-ctx-fill"
style={{
width: `${contextPercent(ctx)}%`,
background: contextColor(contextFraction(ctx)),
}}
/>
</span>
<span className="pane-ctx-pct">{contextPercent(ctx)}%</span>
</span>
)}
<span className="pane-actions"> <span className="pane-actions">
<button <button
className="pane-btn" className="pane-btn"

View file

@ -1,6 +1,6 @@
import { createContext, useContext, type ReactNode } from "react"; import { createContext, useContext, type ReactNode } from "react";
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree"; import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
import type { PaneId, SshHost } from "../../ipc"; import type { PaneId, SshHost, SessionContext } from "../../ipc";
/** /**
* Orchestration context every piece of shared state and every operation * Orchestration context every piece of shared state and every operation
@ -77,6 +77,10 @@ export interface Orchestration {
* the spawn). One-shot App clears the entry once the pane has * the spawn). One-shot App clears the entry once the pane has
* registered. */ * registered. */
getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined; getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined;
/** cwd -> the newest claude session's current context occupancy, for the
* per-pane context-fill indicator. A leaf looks itself up by `leaf.cwd`;
* absent for non-claude / unmatched panes. Polled by App. */
paneContext: Map<string, SessionContext>;
} }
/** Discriminated intent emitted by XtermPane's key handler. App resolves /** Discriminated intent emitted by XtermPane's key handler. App resolves

View file

@ -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", title: "Help",
items: [{ keys: "F1", description: "Show this help overlay" }], items: [{ keys: "F1", description: "Show this help overlay" }],

View file

@ -1,81 +1,38 @@
// Pricing + formatting helpers for the claude usage panel. Token tallies come // Helpers for the per-pane context-fill indicator. Context occupancy (token
// from the backend (src-tauri/src/usage.rs); cost is applied here so the rate // count) comes from the backend (src-tauri/src/usage.rs, get_pane_context); this
// table is easy to edit without recompiling Rust. // turns it into a window %, a colour, and a human label.
import type { SessionUsage } from "../ipc"; import type { SessionContext } from "../ipc";
interface Rate { const WINDOW_STANDARD = 200_000;
/** USD per million tokens. */ const WINDOW_LARGE = 1_000_000;
input: number;
output: number; /**
cacheWrite: number; * Context-window size for a session. The transcript's model id doesn't encode
cacheRead: number; * the 200k-vs-1M variant, so we infer: a session whose prompt has already
* exceeded 200k must be running the 1M-context window. Approximate near the
* boundary, but correct for the cases that matter (a small session reads
* against 200k; a large one against 1M).
*/
export function contextWindow(contextTokens: number): number {
return contextTokens > WINDOW_STANDARD ? WINDOW_LARGE : WINDOW_STANDARD;
} }
// Published Anthropic API rates, USD per million tokens, as of 2026-05. /** Fraction (0..1) of the inferred window currently occupied. */
// UPDATE if pricing changes. Matched against the model id by substring. export function contextFraction(s: SessionContext): number {
// cacheWrite uses the 5-minute-TTL rate (1.25× input); 1-hour cache writes const w = contextWindow(s.contextTokens);
// (2× input) are billed slightly higher than this estimate shows. return w > 0 ? Math.min(1, s.contextTokens / w) : 0;
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 contextPercent(s: SessionContext): number {
export function sessionCost(s: SessionUsage): number { return Math.round(contextFraction(s) * 100);
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. */ /** Green → amber → red ramp as the window fills. */
export function sessionTokens(s: SessionUsage): number { export function contextColor(fraction: number): string {
let t = 0; if (fraction >= 0.85) return "#d65a5a";
for (const mu of s.models) { if (fraction >= 0.6) return "#d6a23a";
t += mu.inputTokens + mu.outputTokens + mu.cacheCreationTokens + mu.cacheReadTokens; return "#5aa84a";
}
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 { export function formatTokens(n: number): string {
@ -84,14 +41,9 @@ export function formatTokens(n: number): string {
return String(n); return String(n);
} }
/** `nowMs` is passed in so callers can avoid Date.now() churn in render. */ /** e.g. "~274k / 1M" for a tooltip. */
export function relativeTime(ms: number, nowMs: number): string { export function contextLabel(s: SessionContext): string {
const dt = Math.max(0, nowMs - ms); const w = contextWindow(s.contextTokens);
const s = Math.floor(dt / 1000); const wLabel = w >= 1_000_000 ? "1M" : "200k";
if (s < 60) return `${s}s ago`; return `~${formatTokens(s.contextTokens)} / ${wLabel}`;
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`;
} }