diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 90771fc..cc4f729 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,8 @@ once_cell = "1" parking_lot = "0.12" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +portable-pty = "0.8" +regex = "1" [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = ["Win32_System_Console"] } diff --git a/src-tauri/src/cli_usage.rs b/src-tauri/src/cli_usage.rs new file mode 100644 index 0000000..e384dc4 --- /dev/null +++ b/src-tauri/src/cli_usage.rs @@ -0,0 +1,339 @@ +//! Drive `claude /usage` via a pseudo-TTY and parse the rendered output +//! into structured percentages. +//! +//! This is the only way to get the *real* subscription-side numbers (the +//! same ones the user sees when typing `/usage` interactively). Anthropic +//! doesn't expose `/usage` via `claude --print`, and the percentages aren't +//! cached anywhere on disk between invocations. +//! +//! Output we parse (whitespace-collapsed, ANSI stripped): +//! +//! Current session +//! ████████████▌ 67% used +//! Resets 2:50am (Europe/London) +//! +//! Current week (all models) +//! ████ 8% used +//! Resets May 9, 12pm (Europe/London) +//! +//! Current week (Sonnet only) +//! ██▌ 5% used +//! Resets May 9, 12pm (Europe/London) +//! +//! The "Sonnet only" section is not always present (depends on plan tier +//! and recent usage). We treat it as optional. + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Utc}; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use serde::Serialize; +use std::io::{Read, Write}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Serialize)] +pub struct UsageBar { + /// e.g. "Current session", "Current week (all models)" + pub label: String, + /// 0..=100 + pub percent: u8, + /// Free text describing reset, e.g. "2:50am (Europe/London)" or + /// "May 9, 12pm (Europe/London)". Anthropic's renderer is the source + /// of truth here; we don't try to re-format. + pub resets_at_text: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CliUsage { + pub session: Option, + pub week_all: Option, + pub week_sonnet: Option, + pub fetched_at: DateTime, + /// True if at least one bar parsed. False on parse failure / timeout + /// (UI can fall back to JSONL-derived numbers). + pub ok: bool, + /// Raw stripped text, kept for diagnostics in Settings. + pub raw_text: String, +} + +/// Spawn `claude`, send `/usage`, capture output, kill, parse. +/// +/// `command_override` lets the caller supply an alternate program (e.g. +/// `wsl.exe` with args) for the Windows-host case. None means just `claude`. +pub fn fetch_blocking(command_override: Option<&str>) -> Result { + let raw = drive_claude_usage(command_override, Duration::from_secs(20))?; + let stripped = strip_ansi_collapse(&raw); + parse_usage_text(&stripped, raw) +} + +/// Spawn the CLI in a PTY, send `/usage`, capture stdout for `total_timeout`, +/// then send `/exit` and return raw bytes (still containing ANSI escapes). +fn drive_claude_usage(command_override: Option<&str>, total_timeout: Duration) -> Result> { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 50, + cols: 200, // wide so /usage doesn't wrap awkwardly + pixel_width: 0, + pixel_height: 0, + }) + .context("openpty")?; + + let mut cmd = match command_override { + Some(s) => { + // Allow simple "wsl.exe -- claude" style strings. + let parts: Vec<&str> = s.split_whitespace().collect(); + if parts.is_empty() { + return Err(anyhow!("empty command_override")); + } + let mut c = CommandBuilder::new(parts[0]); + for arg in &parts[1..] { + c.arg(arg); + } + c + } + None => CommandBuilder::new("claude"), + }; + // claude inspects $TERM; give it something reasonable. + cmd.env("TERM", "xterm-256color"); + + let mut child = pair.slave.spawn_command(cmd).context("spawn claude")?; + drop(pair.slave); // close our copy of the slave + + let mut writer = pair.master.take_writer().context("take_writer")?; + let mut reader = pair.master.try_clone_reader().context("clone_reader")?; + + // Read in a background thread; communicate via a channel. + let (tx, rx) = std::sync::mpsc::channel::>(); + let reader_handle = std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + } + } + }); + + let mut output: Vec = Vec::with_capacity(64 * 1024); + + // 1. Wait ~3.5s for the TUI to initialize (welcome banner, prompt, etc). + drain(&rx, &mut output, Duration::from_millis(3500)); + + // 2. Send /usage. + writer.write_all(b"/usage\r").context("write /usage")?; + writer.flush().ok(); + + // 3. Drain output until quiet period or total deadline. + let deadline = Instant::now() + total_timeout; + let mut last_growth = Instant::now(); + let prev_len = output.len(); + let _ = prev_len; + loop { + let pre = output.len(); + drain(&rx, &mut output, Duration::from_millis(700)); + if output.len() > pre { + last_growth = Instant::now(); + } + // Quiet for >1.2s → assume render finished. + if last_growth.elapsed() > Duration::from_millis(1200) { + break; + } + if Instant::now() > deadline { + break; + } + } + + // 4. Best-effort exit. If it doesn't shut down, we'll kill the child. + let _ = writer.write_all(b"/exit\r"); + let _ = writer.flush(); + drain(&rx, &mut output, Duration::from_millis(500)); + + drop(writer); + let _ = child.kill(); + let _ = child.wait(); + drop(pair.master); + let _ = reader_handle.join(); + + Ok(output) +} + +fn drain( + rx: &std::sync::mpsc::Receiver>, + out: &mut Vec, + timeout: Duration, +) { + let deadline = Instant::now() + timeout; + while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { + match rx.recv_timeout(remaining) { + Ok(chunk) => out.extend_from_slice(&chunk), + Err(_) => break, + } + } +} + +/// Strip CSI / OSC / DCS escape sequences and collapse runs of identical +/// re-rendered lines (TUIs paint the same content many times during scan). +pub fn strip_ansi_collapse(raw: &[u8]) -> String { + // CSI: ESC [ ... + // OSC: ESC ] ... BEL + // DCS / SOS / PM / APC: ESC P / X / ^ / _ ... ESC \ + static CSI_RE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| { + regex::bytes::Regex::new( + r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\x1b[PX^_].*?\x1b\\", + ) + .unwrap() + }); + let cleaned = CSI_RE.replace_all(raw, &b""[..]); + // Drop stray BEL / lone ESC. + let cleaned: Vec = cleaned + .iter() + .copied() + .filter(|&b| b != 0x07 && b != 0x1b) + .collect(); + let text = String::from_utf8_lossy(&cleaned).to_string(); + + // Collapse repeated lines (TUI redraws same line hundreds of times). + let mut out: Vec<&str> = Vec::new(); + for line in text.lines() { + let trimmed = line.trim_end(); + if out.last().map(|p| *p == trimmed).unwrap_or(false) { + continue; + } + out.push(trimmed); + } + // Keep blank lines for parser hints, but collapse runs of >1 blank. + let mut compressed: Vec<&str> = Vec::with_capacity(out.len()); + let mut prev_blank = false; + for line in out { + let blank = line.is_empty(); + if blank && prev_blank { + continue; + } + compressed.push(line); + prev_blank = blank; + } + compressed.join("\n") +} + +/// Parse the cleaned text into a [`CliUsage`]. +/// +/// Strategy: scan for the headings ("Current session", "Current week (all models)", +/// "Current week (Sonnet only)") then look for `NN% used` and `Resets …` on the +/// next few lines after each heading. +pub fn parse_usage_text(stripped: &str, raw_for_diag: Vec) -> Result { + let lines: Vec<&str> = stripped.lines().collect(); + + let find_section = |label: &str| -> Option { + let idx = lines.iter().position(|l| { + let t = l.trim(); + // Some renderings squash spaces ("Currentsession"). Match both. + t == label || t.replace(' ', "") == label.replace(' ', "") + })?; + // Look at the next 6 lines for "X% used" and "Resets ...". + let mut percent: Option = None; + let mut resets: Option = None; + for line in &lines[idx + 1..(idx + 7).min(lines.len())] { + if percent.is_none() { + if let Some(cap) = PCT_RE.captures(line) { + if let Ok(n) = cap[1].parse::() { + percent = Some(n); + } + } + } + if resets.is_none() { + if let Some(cap) = RESET_RE.captures(line) { + resets = Some(cap[1].trim().to_string()); + } + } + if percent.is_some() && resets.is_some() { + break; + } + } + Some(UsageBar { + label: label.to_string(), + percent: percent?, + resets_at_text: resets.unwrap_or_default(), + }) + }; + + let session = find_section("Current session"); + let week_all = find_section("Current week (all models)"); + let week_sonnet = find_section("Current week (Sonnet only)"); + + let ok = session.is_some() || week_all.is_some(); + let _ = raw_for_diag; // accepted for API compat; we surface the cleaned form + + Ok(CliUsage { + session, + week_all, + week_sonnet, + fetched_at: Utc::now(), + ok, + raw_text: stripped.to_string(), + }) +} + +static PCT_RE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| regex::Regex::new(r"(\d{1,3})\s*%\s*used").unwrap()); + +static RESET_RE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| regex::Regex::new(r"Resets?\s+(.+)$").unwrap()); + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r"Settings Status Config Usage Stats + +Session + +Total cost: $0.0000 +Total duration (API): 0s +Total duration (wall): 4s + +Current session +█████████████████████████████████▌ 67% used +Resets 2:50am (Europe/London) + +Current week (all models) +████ 8% used +Resets May 9, 12pm (Europe/London) + +Current week (Sonnet only) +██▌ 5% used +Resets May 9, 12pm (Europe/London) + +What's contributing to your limits usage? +"; + + #[test] + fn parses_three_bars() { + let u = parse_usage_text(SAMPLE, Vec::new()).unwrap(); + assert!(u.ok); + let s = u.session.expect("session"); + assert_eq!(s.percent, 67); + assert!(s.resets_at_text.contains("2:50am")); + let w = u.week_all.expect("week_all"); + assert_eq!(w.percent, 8); + assert!(w.resets_at_text.contains("May 9")); + let so = u.week_sonnet.expect("week_sonnet"); + assert_eq!(so.percent, 5); + } + + #[test] + fn parser_tolerates_missing_sonnet_section() { + let s = SAMPLE + .split("Current week (Sonnet only)") + .next() + .unwrap(); + let u = parse_usage_text(s, Vec::new()).unwrap(); + assert!(u.session.is_some()); + assert!(u.week_all.is_some()); + assert!(u.week_sonnet.is_none()); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3b7da4c..3e423a3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; +use tauri::Emitter; + use crate::paths::{list_wsl_distros, resolve_roots, ResolvedRoots}; use crate::settings::{save as save_settings, Caps, Settings}; use crate::state::SharedState; @@ -74,6 +76,35 @@ pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { Ok(()) } +#[tauri::command] +pub async fn get_cli_usage( + state: tauri::State<'_, SharedState>, +) -> Result, String> { + Ok(state.cli_usage.read().clone()) +} + +/// Force-refresh /usage by spawning the CLI now. Slow (~3-5s); use sparingly. +#[tauri::command] +pub async fn refresh_cli_usage( + state: tauri::State<'_, SharedState>, + app: tauri::AppHandle, +) -> Result { + let cmd = state.settings.read().claude_command.clone(); + // Clone the Arc out of `state` (which borrows from the request); the + // blocking task needs an owned handle. + let shared: SharedState = state.inner().clone(); + let app_clone = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + crate::cli_usage::fetch_blocking(cmd.as_deref()) + }) + .await + .map_err(|e| format!("join: {e}"))? + .map_err(|e| e.to_string())?; + *shared.cli_usage.write() = Some(result.clone()); + let _ = app_clone.emit("cli-usage-updated", &result); + Ok(result) +} + #[derive(serde::Serialize)] pub struct TierInfo { /// The classified tier — `Pro`, `Max5x`, `Max20x`, etc. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8877e84..ed8300e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ //! //! Wires Tauri builder, plugins, app state, and the watcher. +mod cli_usage; mod commands; mod jsonl; mod paths; @@ -11,7 +12,35 @@ mod tier; mod usage; mod watch; -use tauri::Manager; +use tauri::{Emitter, Manager}; +use std::time::Duration; + +async fn refresh_cli_loop(app: tauri::AppHandle, state: state::SharedState) { + // Small initial delay so we don't compete with cold-start file scanning. + tokio::time::sleep(Duration::from_secs(2)).await; + + loop { + let cmd = state.settings.read().claude_command.clone(); + let interval_secs = state.settings.read().cli_refresh_secs.max(60); + + let cmd_for_blocking = cmd.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + cli_usage::fetch_blocking(cmd_for_blocking.as_deref()) + }) + .await; + + match result { + Ok(Ok(u)) => { + *state.cli_usage.write() = Some(u.clone()); + let _ = app.emit("cli-usage-updated", &u); + } + Ok(Err(e)) => tracing::warn!("/usage fetch failed: {e:#}"), + Err(e) => tracing::warn!("/usage join failed: {e:#}"), + } + + tokio::time::sleep(Duration::from_secs(interval_secs)).await; + } +} pub fn run() { // Logs go to stderr; in release on Windows there's no console attached, @@ -57,6 +86,13 @@ pub fn run() { } }); + // Periodic /usage refresh. + let handle_for_cli = app.handle().clone(); + let state_for_cli = shared.clone(); + tauri::async_runtime::spawn(async move { + refresh_cli_loop(handle_for_cli, state_for_cli).await; + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -68,6 +104,8 @@ pub fn run() { commands::force_rescan, commands::quit_app, commands::detect_plan_tier, + commands::get_cli_usage, + commands::refresh_cli_usage, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 7a7e0c2..664b744 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -36,6 +36,12 @@ pub struct Settings { pub window_pos: Option<(i32, i32)>, /// Mirror of the Tauri autostart plugin state for UI display. pub autostart: bool, + /// Override for the `claude` invocation used by the PTY-driven /usage + /// fetcher. None = auto: try `claude` first, then `wsl.exe -- claude` + /// on Windows. Whitespace-separated tokens, e.g. `wsl.exe -- claude`. + pub claude_command: Option, + /// How often (seconds) to refetch `/usage`. Defaults to 300 (5 min). + pub cli_refresh_secs: u64, } impl Default for Settings { @@ -46,6 +52,8 @@ impl Default for Settings { include_native: true, window_pos: None, autostart: false, + claude_command: None, + cli_refresh_secs: 300, } } } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 508cbea..c2459b7 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -5,6 +5,7 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; +use crate::cli_usage::CliUsage; use crate::jsonl::UsageEvent; use crate::settings::Settings; use crate::watch::WatcherHandle; @@ -23,6 +24,9 @@ pub struct AppState { pub files: RwLock>, pub seen_ids: RwLock>, pub settings: RwLock, + /// Latest /usage result. Refreshed periodically by a background task, + /// or on-demand via the `refresh_cli_usage` command. + pub cli_usage: RwLock>, /// Boxed so we can keep the watcher alive across the whole app lifetime /// without polluting Tauri's setup hook. pub watcher: Mutex>, @@ -37,6 +41,7 @@ impl AppState { files: RwLock::new(HashMap::new()), seen_ids: RwLock::new(HashSet::new()), settings: RwLock::new(settings), + cli_usage: RwLock::new(None), watcher: Mutex::new(None), }) } diff --git a/src/components/App.svelte b/src/components/App.svelte index f9f8107..eb29d45 100644 --- a/src/components/App.svelte +++ b/src/components/App.svelte @@ -1,7 +1,13 @@ - (showSettings = true)} /> + (showSettings = true)} + onRefreshUsage={triggerRefresh} + refreshing={cliRefreshing} +/> -{#if snap} - - - -{:else} -
Loading transcripts…
+ + + + + + +{#if cliRefreshing && !cliUsage} +
Reading /usage…
{/if} {#if showSettings} diff --git a/src/components/BlockRing.svelte b/src/components/BlockRing.svelte index cd47549..8351ce6 100644 --- a/src/components/BlockRing.svelte +++ b/src/components/BlockRing.svelte @@ -1,16 +1,14 @@
@@ -78,49 +82,37 @@ {#if err}
{err}
{/if} {#if settings} - {#if tier} -
-
-
-
Plan tier
-
{tier.label}
-
- -
-
- Approximate — Anthropic doesn't publish exact caps. Tune below - once you actually hit a limit. -
- {#if tier.label.startsWith("Unknown") && tier.searched.length > 0} -
- Searched paths -
    - {#each tier.searched as p (p)}
  • {p}
  • {/each} -
-
+
+
claude command
+ +
+ How the widget invokes Claude Code to read /usage. Leave blank to + auto-detect (`claude` first, then `wsl.exe -- claude`). +
+
+ + {#if cli} + + {cli.ok ? "OK" : "no bars parsed"} · + fetched {new Date(cli.fetched_at).toLocaleTimeString()} + {/if}
- {/if} - -
-
5-hour block cap (tokens)
-
-
Weekly cap (tokens)
+
/usage refresh interval (seconds)
@@ -136,7 +128,7 @@