Replace cap-based estimation with PTY-driven 'claude /usage' parser

The widget now spawns 'claude' via portable-pty, sends /usage, parses the
three rendered bars (Current session / Current week all / Current week
Sonnet), and shows the real percentages in the ring + weekly bars. A
background task refreshes every 5 minutes; the title-bar refresh button
forces an immediate fetch.

Drops the cap-tuning UI and tier card from Settings; adds a 'claude command'
override (e.g. 'wsl.exe -- claude' for Windows-host widgets reading WSL
credentials) and a refresh-interval setting. Fixes title-bar buttons getting
swallowed as drag attempts via data-tauri-drag-region="false".
This commit is contained in:
megaproxy 2026-05-09 01:40:44 +01:00
parent 18e55cd139
commit db9a10a4c2
13 changed files with 656 additions and 166 deletions

View file

@ -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"] }

339
src-tauri/src/cli_usage.rs Normal file
View file

@ -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<UsageBar>,
pub week_all: Option<UsageBar>,
pub week_sonnet: Option<UsageBar>,
pub fetched_at: DateTime<Utc>,
/// 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<CliUsage> {
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<Vec<u8>> {
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::<Vec<u8>>();
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<u8> = 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<Vec<u8>>,
out: &mut Vec<u8>,
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 [ ... <terminator>
// OSC: ESC ] ... BEL
// DCS / SOS / PM / APC: ESC P / X / ^ / _ ... ESC \
static CSI_RE: once_cell::sync::Lazy<regex::bytes::Regex> =
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<u8> = 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<u8>) -> Result<CliUsage> {
let lines: Vec<&str> = stripped.lines().collect();
let find_section = |label: &str| -> Option<UsageBar> {
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<u8> = None;
let mut resets: Option<String> = 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::<u8>() {
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<regex::Regex> =
once_cell::sync::Lazy::new(|| regex::Regex::new(r"(\d{1,3})\s*%\s*used").unwrap());
static RESET_RE: once_cell::sync::Lazy<regex::Regex> =
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());
}
}

View file

@ -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<Option<crate::cli_usage::CliUsage>, 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<crate::cli_usage::CliUsage, String> {
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.

View file

@ -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");

View file

@ -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<String>,
/// 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,
}
}
}

View file

@ -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<HashMap<PathBuf, FileCache>>,
pub seen_ids: RwLock<HashSet<String>>,
pub settings: RwLock<Settings>,
/// Latest /usage result. Refreshed periodically by a background task,
/// or on-demand via the `refresh_cli_usage` command.
pub cli_usage: RwLock<Option<CliUsage>>,
/// Boxed so we can keep the watcher alive across the whole app lifetime
/// without polluting Tauri's setup hook.
pub watcher: Mutex<Option<WatcherHandle>>,
@ -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),
})
}