Compare commits
No commits in common. "5f8e9f92c5da939e0a2cf0ec18c9a74e55229042" and "b23f3d1ecbd305873c4ebee6a54fdfa9f5afaf04" have entirely different histories.
5f8e9f92c5
...
b23f3d1ecb
13 changed files with 616 additions and 240 deletions
|
|
@ -89,6 +89,12 @@ 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 |
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,7 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos
|
||||||
|
|
||||||
**→ Exploring first (user-selected 2026-05-28):**
|
**→ Exploring first (user-selected 2026-05-28):**
|
||||||
- [x] ~~**Per-session cost / token tracking.**~~ Done (code) 2026-05-28 — **WSL-only v1, pending Windows runtime verify.** Backend `src-tauri/src/usage.rs` (`get_claude_usage(distros)` command): probes `$HOME` per distro via `wsl.exe`, reads `~/.claude/projects/*/*.jsonl` over the `\\wsl.localhost\<distro>` UNC share, tallies `message.usage` **per model per assistant line** (sessions can switch models). Cached by `(path,size,mtime)`; recency-capped 30d/50 sessions. Frontend: `src/lib/usage.ts` holds the editable pricing table (per-MTok, matched by opus/sonnet/haiku substring) + cost/format helpers; `UsagePanel.tsx` (MCP-panel modal pattern) lists sessions, highlights those whose transcript `cwd` matches an open pane (`[pane: label]`); titlebar 💰 total chip; App polls 20s (visible) / 5s (panel open); **Ctrl+Shift+U** opens it. **Design choice:** session-list attribution (not 1:1 pane binding) — avoids the unsolvable "2 claudes in one cwd" ambiguity. **Caveats:** cost is an estimate (cache-creation priced at 5m rate; rates hardcoded, may drift); panes with no explicit cwd (`~`) won't highlight; PowerShell/SSH show nothing. Plan: `~/.claude/plans/greedy-cooking-flask.md`.
|
- [x] ~~**Per-session cost / token tracking.**~~ Done (code) 2026-05-28 — **WSL-only v1, pending Windows runtime verify.** Backend `src-tauri/src/usage.rs` (`get_claude_usage(distros)` command): probes `$HOME` per distro via `wsl.exe`, reads `~/.claude/projects/*/*.jsonl` over the `\\wsl.localhost\<distro>` UNC share, tallies `message.usage` **per model per assistant line** (sessions can switch models). Cached by `(path,size,mtime)`; recency-capped 30d/50 sessions. Frontend: `src/lib/usage.ts` holds the editable pricing table (per-MTok, matched by opus/sonnet/haiku substring) + cost/format helpers; `UsagePanel.tsx` (MCP-panel modal pattern) lists sessions, highlights those whose transcript `cwd` matches an open pane (`[pane: label]`); titlebar 💰 total chip; App polls 20s (visible) / 5s (panel open); **Ctrl+Shift+U** opens it. **Design choice:** session-list attribution (not 1:1 pane binding) — avoids the unsolvable "2 claudes in one cwd" ambiguity. **Caveats:** cost is an estimate (cache-creation priced at 5m rate; rates hardcoded, may drift); panes with no explicit cwd (`~`) won't highlight; PowerShell/SSH show nothing. Plan: `~/.claude/plans/greedy-cooking-flask.md`.
|
||||||
- **PIVOTED 2026-05-28 → per-pane context-fill indicator (replaces the panel).** User decided lifetime token totals + $ aren't worth it on a subscription; what's actionable is *current context-window occupancy* per pane (spot the one needing `/compact`). Removed `UsagePanel`, the 💰 titlebar chip, and `Ctrl+Shift+U`. Repurposed `usage.rs`: `get_pane_context` returns each recent session's **current** occupancy = the LAST assistant turn's `input + cache_read + cache_creation` tokens (verified ~274k on this 1M session). `src/lib/usage.ts` now does window inference (200k vs 1M by whether occupancy already exceeds 200k — model id doesn't encode the variant), %, color ramp. App polls 15s (visibility-gated) → `cwd→SessionContext` map via orchestration; `LeafPane` renders a slim fill bar + % in the header, matched by `leaf.cwd`. **Also fixed narrow-pane toolbar** (user report: close × clipped when slim): a `ResizeObserver` in LeafPane sets `leaf--narrow`/`leaf--xnarrow` tiers; label shrinks first, split/status/secondary chips drop by tier, close × + context indicator stay pinned-right + visible down to the 180px min. Plan: `~/.claude/plans/greedy-cooking-flask.md` (rewritten for the pivot). **Pending Windows runtime verify.** Window-size 200k/1M is inferred (approx near boundary); `~`-spawned / cd'd panes may not match their session.
|
- **Refined same day after user feedback:** (1) **Scope** — panel + titlebar chip now default to sessions matching open panes ("this workspace"), with an "open panes / all recent" toggle. The first cut summed *every* recent session on the distro (all projects, `/mnt` + home), which read as inflated. **Investigated the "double counting mounted folders + projects" report: NOT a real double count** — every transcript file is read exactly once, and no two project dirs share a cwd because claude resolves symlinks/mounts to the real path before mangling the project-dir name (e.g. the `~/claude/projects/tiletopia → /mnt/d/dev/tiletopia` symlink yields only `-mnt-d-dev-tiletopia`). The inflation was purely the global scope. (2) **Metric framing** — user is on a Pro/Max subscription where $ is meaningless (and `/usage` rate-limit quota can't be derived from transcripts); **tokens are now the headline**, the API-cost estimate is a labeled secondary `~$` kept visible so the user can validate it against real API billing at work. **Open question:** accuracy of the $ estimate vs actual API billing — user will check at work.
|
||||||
- **Superseded — original lifetime-token panel refinements (kept for history):** (1) **Scope** — panel + titlebar chip now default to sessions matching open panes ("this workspace"), with an "open panes / all recent" toggle. The first cut summed *every* recent session on the distro (all projects, `/mnt` + home), which read as inflated. **Investigated the "double counting mounted folders + projects" report: NOT a real double count** — every transcript file is read exactly once, and no two project dirs share a cwd because claude resolves symlinks/mounts to the real path before mangling the project-dir name (e.g. the `~/claude/projects/tiletopia → /mnt/d/dev/tiletopia` symlink yields only `-mnt-d-dev-tiletopia`). The inflation was purely the global scope. (2) **Metric framing** — user is on a Pro/Max subscription where $ is meaningless (and `/usage` rate-limit quota can't be derived from transcripts); **tokens are now the headline**, the API-cost estimate is a labeled secondary `~$` kept visible so the user can validate it against real API billing at work. **Open question:** accuracy of the $ estimate vs actual API billing — user will check at work.
|
|
||||||
- [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium.
|
- [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium.
|
||||||
- [x] ~~**Find in scrollback.**~~ Done + **verified on Windows 2026-05-28** — `@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight.
|
- [x] ~~**Find in scrollback.**~~ Done + **verified on Windows 2026-05-28** — `@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight.
|
||||||
- [x] ~~**Unicode 11 + grapheme width.**~~ Done + **verified on Windows 2026-05-28** — `@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. (Skipped the separate `addon-unicode-graphemes` for now.)
|
- [x] ~~**Unicode 11 + grapheme width.**~~ Done + **verified on Windows 2026-05-28** — `@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. (Skipped the separate `addon-unicode-graphemes` for now.)
|
||||||
|
|
|
||||||
|
|
@ -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_pane_context,
|
usage::get_claude_usage,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
//! Reads claude-code session transcripts to report each session's **current
|
//! Reads claude-code session transcripts and tallies token usage per session
|
||||||
//! context-window occupancy** for the per-pane context indicator.
|
//! for the usage panel.
|
||||||
//!
|
//!
|
||||||
//! 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`, `message.model`, and `message.usage`. The size of the prompt
|
//! carries `cwd`, `sessionId`, `message.model`, and `message.usage`
|
||||||
//! sent on the most recent turn — `input_tokens + cache_read_input_tokens +
|
//! (input/output/cache tokens). We read those straight out of the file, so the
|
||||||
//! cache_creation_input_tokens` of the LAST assistant line — is a good proxy
|
//! reported cwd/model are accurate regardless of where the pane was spawned.
|
||||||
//! 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};
|
||||||
|
|
@ -27,15 +29,23 @@ const MAX_SESSIONS: usize = 50;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SessionContext {
|
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 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,
|
||||||
/// Prompt size of the last assistant turn (input + both cache buckets) —
|
pub models: Vec<ModelUsage>,
|
||||||
/// 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
|
||||||
|
|
@ -44,8 +54,7 @@ struct CachedFile {
|
||||||
size: u64,
|
size: u64,
|
||||||
mtime_ms: i64,
|
mtime_ms: i64,
|
||||||
cwd: String,
|
cwd: String,
|
||||||
context_tokens: u64,
|
models: Vec<ModelUsage>,
|
||||||
model: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -55,24 +64,23 @@ pub struct UsageCache {
|
||||||
homes: Mutex<HashMap<String, String>>,
|
homes: Mutex<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read each recent session's current context occupancy across the given WSL
|
/// Read + tally claude usage across the given WSL distros (the distinct distros
|
||||||
/// distros (the distinct distros of currently-open WSL panes). Newest first,
|
/// of currently-open WSL panes). Newest sessions first, capped to MAX_SESSIONS.
|
||||||
/// capped to MAX_SESSIONS.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_pane_context(
|
pub async fn get_claude_usage(
|
||||||
distros: Vec<String>,
|
distros: Vec<String>,
|
||||||
cache: tauri::State<'_, UsageCache>,
|
cache: tauri::State<'_, UsageCache>,
|
||||||
) -> Result<Vec<SessionContext>, String> {
|
) -> Result<Vec<SessionUsage>, 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<SessionContext> = Vec::new();
|
let mut out: Vec<SessionUsage> = 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!("context scan for distro {distro} failed: {e}"),
|
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.sort_by(|a, b| b.last_active_ms.cmp(&a.last_active_ms));
|
||||||
|
|
@ -80,19 +88,20 @@ pub async fn get_pane_context(
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionContext>, String> {
|
fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionUsage>, 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, mtime), newest first.
|
// Gather candidate transcripts (path, project-dir name, mtime), newest first.
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let mut candidates: Vec<(PathBuf, i64)> = Vec::new();
|
let mut candidates: Vec<(PathBuf, String, 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,
|
||||||
|
|
@ -110,15 +119,15 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionContext
|
||||||
if now - mtime > MAX_AGE_MS {
|
if now - mtime > MAX_AGE_MS {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
candidates.push((p, mtime));
|
candidates.push((p, proj_name.clone(), mtime));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
candidates.sort_by(|a, b| b.1.cmp(&a.1));
|
candidates.sort_by(|a, b| b.2.cmp(&a.2));
|
||||||
candidates.truncate(MAX_SESSIONS);
|
candidates.truncate(MAX_SESSIONS);
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (path, mtime) in candidates {
|
for (path, proj_name, mtime) in candidates {
|
||||||
let (cwd, context_tokens, model) = match parse_or_cache(&path, cache) {
|
let (cwd, models) = 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());
|
||||||
|
|
@ -129,13 +138,13 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result<Vec<SessionContext
|
||||||
.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(SessionContext {
|
out.push(SessionUsage {
|
||||||
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,
|
||||||
context_tokens,
|
models,
|
||||||
model,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|
@ -181,39 +190,32 @@ fn projects_dir(distro: &str, home: &str) -> Option<PathBuf> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_or_cache(
|
fn parse_or_cache(path: &Path, cache: &UsageCache) -> Result<(String, Vec<ModelUsage>), String> {
|
||||||
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.context_tokens, c.model.clone()));
|
return Ok((c.cwd.clone(), c.models.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (cwd, context_tokens, model) = parse_file(path)?;
|
let (cwd, models) = 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(),
|
||||||
context_tokens,
|
models: models.clone(),
|
||||||
model: model.clone(),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok((cwd, context_tokens, model))
|
Ok((cwd, models))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns (cwd, context_tokens, model) where context_tokens is the prompt size
|
fn parse_file(path: &Path) -> Result<(String, Vec<ModelUsage>), String> {
|
||||||
/// 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 context_tokens = 0u64;
|
let mut by_model: HashMap<String, ModelUsage> = HashMap::new();
|
||||||
let mut model = String::new();
|
|
||||||
|
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
|
|
@ -240,19 +242,28 @@ fn parse_file(path: &Path) -> Result<(String, u64, String), String> {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
let tok = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
|
let model = msg
|
||||||
// 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((cwd, context_tokens, model))
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_ms() -> i64 {
|
fn now_ms() -> i64 {
|
||||||
|
|
|
||||||
98
src/App.tsx
98
src/App.tsx
|
|
@ -23,10 +23,10 @@ import {
|
||||||
createPaneWindow,
|
createPaneWindow,
|
||||||
takePendingWindowInit,
|
takePendingWindowInit,
|
||||||
pushWindowWorkspaces,
|
pushWindowWorkspaces,
|
||||||
getPaneContext,
|
getClaudeUsage,
|
||||||
type PaneId,
|
type PaneId,
|
||||||
type SpawnSpec,
|
type SpawnSpec,
|
||||||
type SessionContext,
|
type SessionUsage,
|
||||||
type SshHost,
|
type SshHost,
|
||||||
type McpStatus,
|
type McpStatus,
|
||||||
type McpMirror,
|
type McpMirror,
|
||||||
|
|
@ -108,6 +108,8 @@ 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";
|
||||||
|
|
@ -241,7 +243,9 @@ export default function App() {
|
||||||
token: null,
|
token: null,
|
||||||
});
|
});
|
||||||
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
|
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
|
||||||
const [contextSessions, setContextSessions] = useState<SessionContext[]>([]);
|
const [usagePanelOpen, setUsagePanelOpen] = useState(false);
|
||||||
|
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);
|
||||||
|
|
@ -753,52 +757,59 @@ 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 context tracking --------------------------------------------
|
// ---- claude usage tracking ----------------------------------------------
|
||||||
// Reads each recent session's current context occupancy from ~/.claude
|
// Reads ~/.claude transcripts in the open WSL panes' distros (backend). The
|
||||||
// transcripts (backend), for the per-pane context-fill indicator. The fetch
|
// fetch guard collapses overlapping calls (the open panel polls every 5s and
|
||||||
// guard collapses overlapping ticks.
|
// the background heartbeat every 20s both call this).
|
||||||
const contextFetchingRef = useRef(false);
|
const usageFetchingRef = useRef(false);
|
||||||
const refreshContext = useCallback(async () => {
|
const refreshUsage = useCallback(async () => {
|
||||||
if (contextFetchingRef.current) return;
|
if (usageFetchingRef.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) {
|
||||||
setContextSessions([]);
|
setUsageSessions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
contextFetchingRef.current = true;
|
usageFetchingRef.current = true;
|
||||||
|
setUsageLoading(true);
|
||||||
try {
|
try {
|
||||||
setContextSessions(await getPaneContext(Array.from(distros)));
|
setUsageSessions(await getClaudeUsage(Array.from(distros)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("getPaneContext failed:", e);
|
console.warn("getClaudeUsage failed:", e);
|
||||||
} finally {
|
} finally {
|
||||||
contextFetchingRef.current = false;
|
usageFetchingRef.current = false;
|
||||||
|
setUsageLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll on a light interval, gated on visibility so a hidden/minimized window
|
// Background heartbeat so the titlebar total stays roughly current without
|
||||||
// stays quiet.
|
// the panel open. Gated on visibility so a hidden/minimized window stays quiet.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (document.visibilityState === "visible") void refreshContext();
|
if (document.visibilityState === "visible") void refreshUsage();
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
const id = window.setInterval(tick, 15000);
|
const id = window.setInterval(tick, 20000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [refreshContext]);
|
}, [refreshUsage]);
|
||||||
|
|
||||||
// cwd -> newest session's context, consumed by each LeafPane via orchestration.
|
// cwd + label of open WSL panes, for highlighting matching sessions.
|
||||||
const paneContext = useMemo(() => {
|
const openPanes = useMemo(
|
||||||
const m = new Map<string, SessionContext>();
|
() =>
|
||||||
for (const s of contextSessions) {
|
Array.from(walkLeaves(tree))
|
||||||
if (!s.cwd) continue;
|
.filter((l) => l.shellKind === "wsl")
|
||||||
const prev = m.get(s.cwd);
|
.map((l) => ({ cwd: l.cwd ?? "", label: l.label ?? l.distro ?? "pane" })),
|
||||||
if (!prev || s.lastActiveMs > prev.lastActiveMs) m.set(s.cwd, s);
|
[tree],
|
||||||
}
|
);
|
||||||
return m;
|
|
||||||
}, [contextSessions]);
|
// 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]);
|
||||||
|
|
||||||
// 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.
|
||||||
|
|
@ -902,6 +913,13 @@ 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") {
|
||||||
|
|
@ -1335,7 +1353,6 @@ export default function App() {
|
||||||
reportLeafIdle,
|
reportLeafIdle,
|
||||||
moveToNewWindow,
|
moveToNewWindow,
|
||||||
getInitialPaneIdFor,
|
getInitialPaneIdFor,
|
||||||
paneContext,
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeLeafId,
|
activeLeafId,
|
||||||
|
|
@ -1361,7 +1378,6 @@ export default function App() {
|
||||||
reportLeafIdle,
|
reportLeafIdle,
|
||||||
moveToNewWindow,
|
moveToNewWindow,
|
||||||
getInitialPaneIdFor,
|
getInitialPaneIdFor,
|
||||||
paneContext,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2138,6 +2154,14 @@ 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)}
|
||||||
|
|
@ -2258,6 +2282,16 @@ 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]}
|
||||||
|
|
|
||||||
194
src/components/UsagePanel.css
Normal file
194
src/components/UsagePanel.css
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
171
src/components/UsagePanel.tsx
Normal file
171
src/components/UsagePanel.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
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 · ~$ is an API-pricing estimate (n/a on Pro/Max;
|
||||||
|
can't reflect <code>/usage</code> quota) · 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;
|
||||||
|
}
|
||||||
38
src/ipc.ts
38
src/ipc.ts
|
|
@ -39,25 +39,33 @@ export interface SshHost {
|
||||||
|
|
||||||
export const listDistros = (): Promise<string[]> => invoke("list_distros");
|
export const listDistros = (): Promise<string[]> => invoke("list_distros");
|
||||||
|
|
||||||
// ---- claude context tracking ----------------------------------------------
|
// ---- claude usage tracking ------------------------------------------------
|
||||||
|
|
||||||
/** One claude session's current context-window occupancy, read from its
|
/** Per-model token tally within one claude session. Mirrors Rust ModelUsage. */
|
||||||
* transcript. Mirrors Rust SessionContext. `contextTokens` is the prompt
|
export interface ModelUsage {
|
||||||
* size of the last assistant turn (input + both cache buckets). */
|
|
||||||
export interface SessionContext {
|
|
||||||
sessionId: string;
|
|
||||||
cwd: string;
|
|
||||||
distro: string;
|
|
||||||
lastActiveMs: number;
|
|
||||||
contextTokens: number;
|
|
||||||
model: string;
|
model: string;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheCreationTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scan ~/.claude/projects in the given WSL distros (distinct distros of open
|
/** One claude session's usage, read from its transcript. Mirrors Rust
|
||||||
* WSL panes) and return each recent session's current context occupancy.
|
* SessionUsage. Cost is computed frontend-side (see src/lib/usage.ts). */
|
||||||
* WSL/Windows only — returns [] otherwise. */
|
export interface SessionUsage {
|
||||||
export const getPaneContext = (distros: string[]): Promise<SessionContext[]> =>
|
sessionId: string;
|
||||||
invoke("get_pane_context", { distros });
|
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<SessionUsage[]> =>
|
||||||
|
invoke("get_claude_usage", { distros });
|
||||||
|
|
||||||
export const spawnPane = (args: {
|
export const spawnPane = (args: {
|
||||||
spec: SpawnSpec;
|
spec: SpawnSpec;
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,6 @@
|
||||||
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;
|
||||||
|
|
@ -246,9 +242,6 @@
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
@ -271,55 +264,6 @@
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,6 @@ 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";
|
||||||
|
|
@ -48,7 +42,6 @@ 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) => {
|
||||||
|
|
@ -163,22 +156,6 @@ 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) => {
|
||||||
|
|
@ -409,13 +386,9 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const ctx =
|
|
||||||
leaf.shellKind === "wsl" && leaf.cwd ? orch.paneContext.get(leaf.cwd) : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
|
||||||
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}
|
||||||
|
|
@ -564,24 +537,6 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -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, SessionContext } from "../../ipc";
|
import type { PaneId, SshHost } from "../../ipc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestration context — every piece of shared state and every operation
|
* Orchestration context — every piece of shared state and every operation
|
||||||
|
|
@ -77,10 +77,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
title: "Help",
|
||||||
items: [{ keys: "F1", description: "Show this help overlay" }],
|
items: [{ keys: "F1", description: "Show this help overlay" }],
|
||||||
|
|
|
||||||
112
src/lib/usage.ts
112
src/lib/usage.ts
|
|
@ -1,38 +1,81 @@
|
||||||
// Helpers for the per-pane context-fill indicator. Context occupancy (token
|
// Pricing + formatting helpers for the claude usage panel. Token tallies come
|
||||||
// count) comes from the backend (src-tauri/src/usage.rs, get_pane_context); this
|
// from the backend (src-tauri/src/usage.rs); cost is applied here so the rate
|
||||||
// turns it into a window %, a colour, and a human label.
|
// table is easy to edit without recompiling Rust.
|
||||||
|
|
||||||
import type { SessionContext } from "../ipc";
|
import type { SessionUsage } from "../ipc";
|
||||||
|
|
||||||
const WINDOW_STANDARD = 200_000;
|
interface Rate {
|
||||||
const WINDOW_LARGE = 1_000_000;
|
/** USD per million tokens. */
|
||||||
|
input: number;
|
||||||
/**
|
output: number;
|
||||||
* Context-window size for a session. The transcript's model id doesn't encode
|
cacheWrite: number;
|
||||||
* the 200k-vs-1M variant, so we infer: a session whose prompt has already
|
cacheRead: number;
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fraction (0..1) of the inferred window currently occupied. */
|
// Published Anthropic API rates, USD per million tokens, as of 2026-05.
|
||||||
export function contextFraction(s: SessionContext): number {
|
// UPDATE if pricing changes. Matched against the model id by substring.
|
||||||
const w = contextWindow(s.contextTokens);
|
// cacheWrite uses the 5-minute-TTL rate (1.25× input); 1-hour cache writes
|
||||||
return w > 0 ? Math.min(1, s.contextTokens / w) : 0;
|
// (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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function contextPercent(s: SessionContext): number {
|
/** Estimated USD cost for one session, summed per-model. */
|
||||||
return Math.round(contextFraction(s) * 100);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Green → amber → red ramp as the window fills. */
|
/** Total tokens (all kinds) for one session. */
|
||||||
export function contextColor(fraction: number): string {
|
export function sessionTokens(s: SessionUsage): number {
|
||||||
if (fraction >= 0.85) return "#d65a5a";
|
let t = 0;
|
||||||
if (fraction >= 0.6) return "#d6a23a";
|
for (const mu of s.models) {
|
||||||
return "#5aa84a";
|
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 {
|
export function formatTokens(n: number): string {
|
||||||
|
|
@ -41,9 +84,14 @@ export function formatTokens(n: number): string {
|
||||||
return String(n);
|
return String(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** e.g. "~274k / 1M" for a tooltip. */
|
/** `nowMs` is passed in so callers can avoid Date.now() churn in render. */
|
||||||
export function contextLabel(s: SessionContext): string {
|
export function relativeTime(ms: number, nowMs: number): string {
|
||||||
const w = contextWindow(s.contextTokens);
|
const dt = Math.max(0, nowMs - ms);
|
||||||
const wLabel = w >= 1_000_000 ? "1M" : "200k";
|
const s = Math.floor(dt / 1000);
|
||||||
return `~${formatTokens(s.contextTokens)} / ${wLabel}`;
|
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`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue