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:
parent
b23f3d1ecb
commit
d951c360ae
12 changed files with 235 additions and 612 deletions
|
|
@ -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/<mangled-cwd>/<sessionId>.jsonl`. Every assistant line
|
||||
//! carries `cwd`, `sessionId`, `message.model`, and `message.usage`
|
||||
//! (input/output/cache tokens). We read those straight out of the file, so the
|
||||
//! reported cwd/model are accurate regardless of where the pane was spawned.
|
||||
//! 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\<distro>\…` 9p share. Returns empty on non-Windows.
|
||||
//!
|
||||
//! Cost is computed on the frontend (see src/lib/usage.ts) so the rate table is
|
||||
//! easy to tweak; this module only returns raw per-model token tallies.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -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<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
|
||||
|
|
@ -54,7 +44,8 @@ struct CachedFile {
|
|||
size: u64,
|
||||
mtime_ms: i64,
|
||||
cwd: String,
|
||||
models: Vec<ModelUsage>,
|
||||
context_tokens: u64,
|
||||
model: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -64,23 +55,24 @@ pub struct UsageCache {
|
|||
homes: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Read + tally claude usage across the given WSL distros (the distinct distros
|
||||
/// of currently-open WSL panes). Newest sessions first, capped to MAX_SESSIONS.
|
||||
/// 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<String>,
|
||||
cache: tauri::State<'_, UsageCache>,
|
||||
) -> Result<Vec<SessionUsage>, String> {
|
||||
) -> Result<Vec<SessionContext>, String> {
|
||||
if !cfg!(windows) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let cache = cache.inner();
|
||||
let mut out: Vec<SessionUsage> = Vec::new();
|
||||
let mut out: Vec<SessionContext> = 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<Vec<SessionUsage>, String> {
|
||||
fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionContext>, 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<Vec<SessionUsage>,
|
|||
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<Vec<SessionUsage>,
|
|||
.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<PathBuf> {
|
|||
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 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<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 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() {
|
||||
let line = line.trim();
|
||||
|
|
@ -242,28 +240,19 @@ fn parse_file(path: &Path) -> Result<(String, Vec<ModelUsage>), 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<ModelUsage> = 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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue