From 05b89ec6be27b17f7860fb345b4c6e304991e294 Mon Sep 17 00:00:00 2001 From: zhnd Date: Sun, 12 Apr 2026 23:09:39 +0800 Subject: [PATCH 1/3] feat: real CLI fallback for subscription usage via PTY + vt100 Lumo's usage page previously failed completely whenever Anthropic's OAuth usage API returned 403 (edge-layer rejection, likely TLS fingerprinting of rustls). The existing CLI fallback was broken: it used a non-existent --output json flag and had no PTY, but Claude Code CLI is a React/Ink TUI that requires a terminal to function. This ports ClaudeBar's dual-probe architecture to Rust: - interactive_runner.rs: generic PTY runner via portable-pty. Spawns a child process in a 50x160 PTY, writes input, auto-responds to known prompts (trust dialog, "Press Enter to continue", etc.), and exits on child exit, hard timeout, or idle timeout after meaningful data. - terminal_renderer.rs: thin wrapper around vt100::Parser that replays the captured bytes through a headless terminal emulator and returns the rendered screen contents as plain text. - claude_cli_probe.rs: Claude-specific probe. Runs `claude /usage --allowed-tools ""` with CLAUDE_CODE_OAUTH_TOKEN stripped, inside a stable working directory (~/Library/Application Support/Lumo/Probe on macOS, ~/.lumo/probe on Linux). Pre-writes the trust entry to ~/.claude.json to avoid the workspace trust dialog stalling the run. Parses the rendered text with ClaudeBar's regex patterns (([0-9]{1,3})\s*%\s*(used|left) plus section-label windows) into SubscriptionUsageResponse. subscription_usage_service.rs now delegates fetch_via_cli() to ClaudeCliProbe, removing the broken naive parser helpers. The existing API-primary/CLI-fallback flow is preserved. Adds three new dependencies: portable-pty 0.9, vt100 0.16, regex 1. All new modules have unit tests for prompt detection and usage parsing (19 total tests passing). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 124 ++++++- src-tauri/Cargo.toml | 3 + src-tauri/src/services/claude_cli_probe.rs | 296 ++++++++++++++++ src-tauri/src/services/interactive_runner.rs | 322 ++++++++++++++++++ src-tauri/src/services/mod.rs | 3 + .../services/subscription_usage_service.rs | 174 +--------- src-tauri/src/services/terminal_renderer.rs | 46 +++ 7 files changed, 798 insertions(+), 170 deletions(-) create mode 100644 src-tauri/src/services/claude_cli_probe.rs create mode 100644 src-tauri/src/services/interactive_runner.rs create mode 100644 src-tauri/src/services/terminal_renderer.rs diff --git a/Cargo.lock b/Cargo.lock index 3e086d1..385480e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,6 +141,8 @@ dependencies = [ "dirs", "log", "notify", + "portable-pty", + "regex", "reqwest", "security-framework", "serde", @@ -163,6 +165,7 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", "typeshare", "uuid", + "vt100", "which", ] @@ -550,7 +553,7 @@ checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -766,6 +769,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1328,7 +1337,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1493,6 +1502,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -3071,6 +3091,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3868,6 +3900,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -4080,7 +4133,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -4121,7 +4174,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -4941,6 +4994,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial2" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e66ab7ee258c6456796c6098e1b53a5baa1a5e0637347de59ddb44ee8e20be6e" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5029,6 +5093,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -6647,6 +6727,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -6776,6 +6862,27 @@ dependencies = [ "libc", ] +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -7676,6 +7783,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d77d729..576ed98 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -49,6 +49,9 @@ notify = { version = "8", default-features = false, features = ["macos_fsevent"] serde_yaml = "0.9" which = "8" tauri-plugin-opener = "2" +portable-pty = "0.9" +vt100 = "0.16" +regex = "1" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3" diff --git a/src-tauri/src/services/claude_cli_probe.rs b/src-tauri/src/services/claude_cli_probe.rs new file mode 100644 index 0000000..b5b32d5 --- /dev/null +++ b/src-tauri/src/services/claude_cli_probe.rs @@ -0,0 +1,296 @@ +//! Fallback probe that shells out to `claude /usage` when the OAuth API is +//! unreachable or blocked. Ported from ClaudeBar's `ClaudeUsageProbe.swift`. + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use regex::Regex; + +use super::interactive_runner::{run_interactive, RunOptions}; +use super::terminal_renderer::TerminalRenderer; +use crate::types::{SubscriptionUsageResponse, UsageBucket}; + +/// Environment variable that must be stripped before invoking `claude` — +/// setup-tokens only have `user:inference` scope, which can't hit `/usage`. +const ENV_EXCLUSION: &str = "CLAUDE_CODE_OAUTH_TOKEN"; + +pub struct ClaudeCliProbe; + +impl ClaudeCliProbe { + /// Run `claude /usage` inside a PTY, render the ANSI output, and parse + /// the usage buckets. Blocking I/O is offloaded via `spawn_blocking` so + /// the async caller isn't held up by the PTY read loop. + pub async fn fetch_usage() -> Result { + let claude_path = which::which("claude") + .context("Claude CLI binary not found in PATH")?; + log::debug!("ClaudeCliProbe: using binary at {}", claude_path.display()); + + let working_dir = probe_working_directory()?; + + // Best-effort: make sure our probe directory is pre-trusted so the + // CLI doesn't stall waiting for the interactive trust dialog. + let _ = write_claude_trust(&working_dir); + + let raw = tokio::task::spawn_blocking(move || { + run_interactive( + &claude_path, + RunOptions { + args: vec![ + "/usage".to_string(), + "--allowed-tools".to_string(), + String::new(), + ], + input: None, + timeout: Duration::from_secs(20), + idle_timeout: Duration::from_secs(3), + working_directory: Some(working_dir), + env_exclusions: vec![ENV_EXCLUSION.to_string()], + auto_responses: vec![ + ("Esc to cancel".to_string(), "\r".to_string()), + ("Ready to code here?".to_string(), "\r".to_string()), + ("Press Enter to continue".to_string(), "\r".to_string()), + ("ctrl+t to disable".to_string(), "\r".to_string()), + ("Yes, I trust this folder".to_string(), "\r".to_string()), + ], + }, + ) + }) + .await + .context("Interactive runner panicked")??; + + let rendered = TerminalRenderer::new().render(&raw); + log::debug!( + "ClaudeCliProbe: rendered {} bytes of /usage output ({} chars)", + raw.len(), + rendered.len() + ); + + parse_usage_output(&rendered) + } +} + +/// Parse the cleaned text of `claude /usage` into a `SubscriptionUsageResponse`. +/// Looks for the four known section labels and the shared "used/left" percentage +/// pattern. We deliberately ignore reset-time parsing here — the raw text goes +/// into the UI via `UsageBucket::resets_at` and is formatted client-side when +/// available. +fn parse_usage_output(text: &str) -> Result { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + bail!("Empty CLI output"); + } + + let five_hour = build_bucket(&lines, &["current session"]); + let seven_day = build_bucket(&lines, &["current week (all models)"]); + let seven_day_opus = build_bucket(&lines, &["current week (opus)"]); + let seven_day_sonnet = build_bucket( + &lines, + &["current week (sonnet only)", "current week (sonnet)"], + ); + + // We need at least one bucket — otherwise the output wasn't the usage + // screen we expected (e.g. API billing account or an unknown format). + if five_hour.is_none() + && seven_day.is_none() + && seven_day_opus.is_none() + && seven_day_sonnet.is_none() + { + bail!( + "Could not find any usage sections in CLI output (got {} chars)", + text.len() + ); + } + + Ok(SubscriptionUsageResponse { + five_hour, + seven_day, + seven_day_opus, + seven_day_sonnet, + // Extra usage parsing is out of scope for the first cut — the + // credentials file already gives us `subscriptionType`, which is + // what the UI uses to show the tier badge. + extra_usage: None, + }) +} + +fn build_bucket(lines: &[&str], label_candidates: &[&str]) -> Option { + for label in label_candidates { + if let Some(pct) = extract_percent(lines, label) { + let reset = extract_reset(lines, label); + return Some(UsageBucket { + utilization: Some(pct), + resets_at: reset, + }); + } + } + None +} + +/// Find the line containing `label_substring` (case-insensitive) and scan +/// the next 12 lines for a "X% used" or "X% left" value. Matches ClaudeBar's +/// behaviour — the CLI renders the bar on the line right after the label. +fn extract_percent(lines: &[&str], label_substring: &str) -> Option { + let label = label_substring.to_lowercase(); + let mut found = None; + + for (idx, line) in lines.iter().enumerate() { + if line.to_lowercase().contains(&label) { + let end = (idx + 12).min(lines.len()); + for candidate in &lines[idx..end] { + if let Some(value) = percent_from_line(candidate) { + found = Some(value); + break; + } + } + if found.is_some() { + break; + } + } + } + + found +} + +fn percent_from_line(line: &str) -> Option { + // Regex matches "65% left", "25% used", etc. with flexible whitespace. + // Built once per call — these lines are tiny so the cost is negligible. + let re = Regex::new(r"(?i)([0-9]{1,3})\s*%\s*(used|left)").ok()?; + let caps = re.captures(line)?; + let raw = caps.get(1)?.as_str().parse::().ok()?; + let kind = caps.get(2)?.as_str().to_lowercase(); + // API returns `utilization` as percent USED, so we normalize back to + // "used" here for consistency with the HTTP path. + let used = if kind.contains("left") { + 100.0 - raw + } else { + raw + }; + Some(used.clamp(0.0, 100.0)) +} + +/// Extract the first "resets in ..." line near a section label. Returns the +/// raw trimmed text — the frontend doesn't parse CLI reset strings yet. +fn extract_reset(lines: &[&str], label_substring: &str) -> Option { + let label = label_substring.to_lowercase(); + for (idx, line) in lines.iter().enumerate() { + if line.to_lowercase().contains(&label) { + let end = (idx + 14).min(lines.len()); + for candidate in &lines[idx..end] { + let lower = candidate.to_lowercase(); + if lower.contains("reset") { + return Some(candidate.trim().to_string()); + } + } + } + } + None +} + +/// Create (or reuse) a stable working directory for the probe so every run +/// happens inside the same folder — avoids the CLI re-prompting for trust. +fn probe_working_directory() -> Result { + let home = dirs::home_dir().context("Could not locate home directory")?; + #[cfg(target_os = "macos")] + let base = home.join("Library/Application Support/Lumo/Probe"); + #[cfg(not(target_os = "macos"))] + let base = home.join(".lumo/probe"); + + std::fs::create_dir_all(&base) + .with_context(|| format!("Failed to create probe directory {}", base.display()))?; + Ok(base) +} + +/// Pre-mark the probe directory as trusted in `~/.claude.json` so the CLI +/// doesn't stall on the workspace trust dialog. Safe no-op if the file +/// doesn't exist or the entry is already present. +fn write_claude_trust(working_dir: &std::path::Path) -> bool { + let home = match dirs::home_dir() { + Some(h) => h, + None => return false, + }; + let path = home.join(".claude.json"); + if !path.exists() { + return false; + } + + let Ok(data) = std::fs::read_to_string(&path) else { + return false; + }; + let Ok(mut json) = serde_json::from_str::(&data) else { + return false; + }; + + // Walk into `.projects[]` and set `hasTrustDialogAccepted`. + let projects = json + .as_object_mut() + .and_then(|obj| obj.entry("projects").or_insert_with(|| serde_json::json!({})).as_object_mut()); + let Some(projects) = projects else { + return false; + }; + + let key = working_dir.to_string_lossy().into_owned(); + let entry = projects + .entry(key) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut(); + let Some(entry) = entry else { + return false; + }; + + if entry.get("hasTrustDialogAccepted") == Some(&serde_json::Value::Bool(true)) { + return false; + } + + entry.insert( + "hasTrustDialogAccepted".to_string(), + serde_json::Value::Bool(true), + ); + + let Ok(serialized) = serde_json::to_string_pretty(&json) else { + return false; + }; + std::fs::write(&path, serialized).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percent_from_line_handles_used_and_left() { + assert_eq!(percent_from_line("18% used"), Some(18.0)); + assert_eq!(percent_from_line("27% left"), Some(73.0)); + assert_eq!(percent_from_line("no percent here"), None); + } + + #[test] + fn extract_percent_walks_label_window() { + let lines = vec![ + "Opus 4.5 · Claude Max", + "", + "Current session", + "█████████████░░░░░░░ 65% left", + "Resets 4:59pm", + ]; + assert_eq!(extract_percent(&lines, "current session"), Some(35.0)); + } + + #[test] + fn parse_usage_output_builds_buckets() { + let sample = "\ +Opus 4.5 · Claude Max + +Current session +█████████████░░░░░░░ 18% used +Resets 4:59pm (America/New_York) + +Current week (all models) +█████████░░░░░░░░░░░ 36% used +Resets Dec 25 at 2:59pm +"; + let parsed = parse_usage_output(sample).expect("should parse"); + assert_eq!(parsed.five_hour.as_ref().unwrap().utilization, Some(18.0)); + assert_eq!(parsed.seven_day.as_ref().unwrap().utilization, Some(36.0)); + } +} diff --git a/src-tauri/src/services/interactive_runner.rs b/src-tauri/src/services/interactive_runner.rs new file mode 100644 index 0000000..a62760d --- /dev/null +++ b/src-tauri/src/services/interactive_runner.rs @@ -0,0 +1,322 @@ +//! Generic PTY runner for interactive CLI commands. +//! +//! Ported from ClaudeBar's `InteractiveRunner.swift`. Spawns a child process +//! inside a pseudo-terminal so React/Ink-based TUI applications (like the +//! Claude Code CLI) run correctly, collects output, and auto-responds to +//! known prompts. Exits when the child finishes, the total timeout elapses, +//! or the idle timeout has been reached after meaningful output was seen. + +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + +/// Configuration for a single interactive run. +pub struct RunOptions { + pub args: Vec, + /// Additional text to write to the child's stdin after launch (e.g. slash + /// commands typed into a REPL). A trailing `\r` is appended automatically. + pub input: Option, + /// Hard cap on total runtime. + pub timeout: Duration, + /// If no new meaningful data arrives for this long AND we already have + /// meaningful output buffered, terminate the child and return. + pub idle_timeout: Duration, + pub working_directory: Option, + /// Environment variable names to strip from the child's environment. + pub env_exclusions: Vec, + /// Substring → response pairs. When the buffer contains the substring, + /// the response is written to stdin and the prompt is marked as answered. + pub auto_responses: Vec<(String, String)>, +} + +impl Default for RunOptions { + fn default() -> Self { + Self { + args: Vec::new(), + input: None, + timeout: Duration::from_secs(20), + idle_timeout: Duration::from_secs(3), + working_directory: None, + env_exclusions: Vec::new(), + auto_responses: Vec::new(), + } + } +} + +/// Run an interactive CLI command inside a PTY and capture its output as raw +/// bytes (including ANSI escape sequences — feed the result into a terminal +/// renderer if you want clean text). +pub fn run_interactive(binary: &std::path::Path, options: RunOptions) -> Result> { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 50, + cols: 160, + pixel_width: 0, + pixel_height: 0, + }) + .context("Failed to open PTY")?; + + let mut cmd = CommandBuilder::new(binary); + for arg in &options.args { + cmd.arg(arg); + } + if let Some(cwd) = &options.working_directory { + cmd.cwd(cwd); + } + + // Build an environment for the child. portable-pty inherits the current + // process environment by default only if we don't set any variables, so + // we explicitly copy everything except the excluded vars. + for (key, value) in std::env::vars() { + if options.env_exclusions.iter().any(|k| k == &key) { + continue; + } + cmd.env(key, value); + } + + let mut child = pair + .slave + .spawn_command(cmd) + .context("Failed to spawn command in PTY")?; + + // We don't need the slave handle after spawning the child. + drop(pair.slave); + + let mut writer = pair + .master + .take_writer() + .context("Failed to take PTY writer")?; + let mut reader = pair + .master + .try_clone_reader() + .context("Failed to clone PTY reader")?; + + // Let the child initialize (matches ClaudeBar's 400ms pause). + thread::sleep(Duration::from_millis(400)); + + // Send the input command, if any. React/Ink apps expect a terminal-style + // carriage return rather than a newline. + if let Some(ref input) = options.input { + let trimmed = input.trim(); + if !trimmed.is_empty() { + writer + .write_all(trimmed.as_bytes()) + .context("Failed to send input")?; + writer + .write_all(b"\r") + .context("Failed to send input terminator")?; + writer.flush().ok(); + } + } + + // Buffer is shared between the reader thread and the main loop so we can + // inspect it for auto-response prompts without racing the reader. + let buffer: Arc>> = Arc::new(Mutex::new(Vec::new())); + let buffer_reader = Arc::clone(&buffer); + let reader_done = Arc::new(Mutex::new(false)); + let reader_done_clone = Arc::clone(&reader_done); + + thread::spawn(move || { + let mut chunk = [0u8; 4096]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + if let Ok(mut buf) = buffer_reader.lock() { + buf.extend_from_slice(&chunk[..n]); + } + } + Err(_) => break, + } + } + if let Ok(mut done) = reader_done_clone.lock() { + *done = true; + } + }); + + let deadline = Instant::now() + options.timeout; + let mut last_meaningful_at = Instant::now(); + let mut last_len = 0usize; + let mut responded: Vec = vec![false; options.auto_responses.len()]; + + loop { + // Snapshot the buffer for inspection + let snapshot = match buffer.lock() { + Ok(b) => b.clone(), + Err(_) => break, + }; + + // Track meaningful data + if snapshot.len() > last_len { + let new_data = &snapshot[last_len..]; + if has_meaningful_content(new_data) { + last_meaningful_at = Instant::now(); + } + last_len = snapshot.len(); + } + + // Auto-respond to prompts we haven't already answered + for (idx, (prompt, response)) in options.auto_responses.iter().enumerate() { + if responded[idx] { + continue; + } + if contains_subslice(&snapshot, prompt.as_bytes()) { + if writer.write_all(response.as_bytes()).is_ok() { + writer.flush().ok(); + } + responded[idx] = true; + last_meaningful_at = Instant::now(); + } + } + + // Exit conditions + let child_done = child.try_wait().ok().flatten().is_some(); + if child_done { + break; + } + + let reader_done_flag = reader_done.lock().map(|d| *d).unwrap_or(false); + if reader_done_flag { + break; + } + + if Instant::now() >= deadline { + log::debug!("InteractiveRunner: hard timeout hit"); + break; + } + + // Idle exit: we have meaningful content and nothing new has arrived + // in `idle_timeout`. Don't exit prematurely when the buffer is still + // empty (the child might still be starting up). + if has_meaningful_content(&snapshot) + && Instant::now().duration_since(last_meaningful_at) >= options.idle_timeout + { + break; + } + + thread::sleep(Duration::from_millis(60)); + } + + // Force-stop the child if it's still running + let _ = child.kill(); + let _ = child.wait(); + + // One last read of whatever is in the buffer + let final_buffer = buffer.lock().map(|b| b.clone()).unwrap_or_default(); + Ok(final_buffer) +} + +/// Returns true if the buffer contains the given sub-slice. Linear scan is +/// fine here since buffers stay small (a few KB of terminal output). +fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || needle.len() > haystack.len() { + return false; + } + haystack.windows(needle.len()).any(|w| w == needle) +} + +/// Returns true if the data contains anything beyond ANSI escape sequences. +/// Ported from ClaudeBar's `hasMeaningfulContent` — the goal is to avoid +/// resetting the idle timer on cursor movement, title updates, and other +/// non-visible noise that the terminal keeps emitting while rendering. +fn has_meaningful_content(data: &[u8]) -> bool { + // Decode as UTF-8; if it fails, treat as meaningful (binary data). + let Ok(text) = std::str::from_utf8(data) else { + return !data.is_empty(); + }; + + let mut stripped = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch != '\x1b' { + stripped.push(ch); + continue; + } + // We saw ESC; classify the sequence + let Some(&next) = chars.peek() else { + continue; + }; + + match next { + '[' => { + // CSI sequence: ESC [ + chars.next(); + for c in chars.by_ref() { + if c.is_ascii_alphabetic() { + break; + } + } + } + '(' | ')' => { + // Charset designator: ESC ( + chars.next(); + chars.next(); + } + ']' => { + // OSC sequence: ESC ] ... BEL or ESC ] ... ESC \ + chars.next(); + while let Some(&c) = chars.peek() { + chars.next(); + if c == '\x07' { + break; + } + if c == '\x1b' { + // Skip the terminating backslash + if let Some(&b) = chars.peek() { + if b == '\\' { + chars.next(); + } + } + break; + } + } + } + _ => { + // Unknown escape — just drop the ESC + } + } + } + + stripped + .chars() + .any(|c| !c.is_whitespace() && c != '\u{07}') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn meaningful_content_detects_plain_text() { + assert!(has_meaningful_content(b"hello")); + } + + #[test] + fn meaningful_content_ignores_csi_only() { + assert!(!has_meaningful_content(b"\x1b[2J\x1b[H")); + } + + #[test] + fn meaningful_content_ignores_osc_title() { + assert!(!has_meaningful_content(b"\x1b]0;title\x07")); + } + + #[test] + fn meaningful_content_detects_text_with_ansi() { + assert!(has_meaningful_content(b"\x1b[32mhello\x1b[0m")); + } + + #[test] + fn contains_subslice_works() { + assert!(contains_subslice(b"hello world", b"world")); + assert!(!contains_subslice(b"hello", b"world")); + assert!(!contains_subslice(b"ab", b"abc")); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index bfc547f..1d6d804 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -10,9 +10,12 @@ mod notification_settings_service; pub mod session_cache; pub mod session_watcher; mod stats_service; +mod claude_cli_probe; mod claude_credentials; mod insights_service; +mod interactive_runner; mod subscription_usage_service; +mod terminal_renderer; pub mod time_range; mod tools_service; mod trends_service; diff --git a/src-tauri/src/services/subscription_usage_service.rs b/src-tauri/src/services/subscription_usage_service.rs index 791a790..3cf8722 100644 --- a/src-tauri/src/services/subscription_usage_service.rs +++ b/src-tauri/src/services/subscription_usage_service.rs @@ -400,126 +400,17 @@ impl SubscriptionUsageService { } // --------------------------------------------------------------- - // CLI fallback + // CLI fallback — delegates to ClaudeCliProbe (PTY + vt100 renderer) // --------------------------------------------------------------- async fn fetch_via_cli() -> Result { - let claude_path = which::which("claude") - .context("Claude CLI binary not found in PATH")?; - - log::debug!("CLI fallback: using {}", claude_path.display()); - - // Strip CLAUDE_CODE_OAUTH_TOKEN from env to force stored credentials - // (setup-tokens only have inference scope, not usage scope) - let env_vars: Vec<(String, String)> = std::env::vars() - .filter(|(k, _)| k != "CLAUDE_CODE_OAUTH_TOKEN") - .collect(); - - let output = tokio::process::Command::new(&claude_path) - .args(["/usage", "--output", "json", "--allowed-tools", ""]) - .env_clear() - .envs(env_vars) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .output() - .await - .context("Failed to execute claude /usage")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("claude /usage failed (exit {}): {}", output.status, stderr); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - - // Try parsing as JSON first (--output json may work) - if let Ok(api_resp) = serde_json::from_str::(&stdout) { - return Ok(SubscriptionUsageResult { - needs_login: false, - usage: Some(Self::convert_response(api_resp)), - error: None, - subscription_type: None, - }); - } - - // Try parsing as text output (fallback) - match Self::parse_cli_text_output(&stdout) { - Some(usage) => Ok(SubscriptionUsageResult { - needs_login: false, - usage: Some(usage), - error: None, - subscription_type: None, - }), - None => { - log::warn!("CLI fallback: could not parse output: {}", &stdout[..stdout.len().min(500)]); - anyhow::bail!("Failed to parse claude /usage output") - } - } - } - - /// Best-effort parser for `claude /usage` text output. - /// Looks for percentage patterns like "45.2% used" or "45%" and reset times. - fn parse_cli_text_output(text: &str) -> Option { - use crate::types::{ExtraUsage, UsageBucket}; - - // Strip ANSI escape codes - let clean = strip_ansi(text); - let lines: Vec<&str> = clean.lines().collect(); - - let mut five_hour: Option = None; - let mut seven_day: Option = None; - let mut seven_day_opus: Option = None; - let mut seven_day_sonnet: Option = None; - let mut extra_usage: Option = None; - - let mut i = 0; - while i < lines.len() { - let line = lines[i].trim().to_lowercase(); - - if line.contains("session") && line.contains("limit") || line.contains("5-hour") || line.contains("five") { - if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) { - five_hour = Some(UsageBucket { utilization: Some(util), resets_at: resets }); - } - } else if line.contains("opus") { - if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) { - seven_day_opus = Some(UsageBucket { utilization: Some(util), resets_at: resets }); - } - } else if line.contains("sonnet") { - if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) { - seven_day_sonnet = Some(UsageBucket { utilization: Some(util), resets_at: resets }); - } - } else if (line.contains("weekly") || line.contains("7-day") || line.contains("seven")) - && !line.contains("opus") && !line.contains("sonnet") - { - if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) { - seven_day = Some(UsageBucket { utilization: Some(util), resets_at: resets }); - } - } else if line.contains("extra") || line.contains("overage") || line.contains("pay") { - if let Some((util, _)) = find_usage_in_nearby_lines(&lines, i) { - extra_usage = Some(ExtraUsage { - is_enabled: true, - utilization: Some(util), - used_credits: None, - monthly_limit: None, - }); - } - } - - i += 1; - } - - // Only return if we found at least one bucket - if five_hour.is_some() || seven_day.is_some() || seven_day_opus.is_some() || seven_day_sonnet.is_some() { - Some(SubscriptionUsageResponse { - five_hour, - seven_day, - seven_day_opus, - seven_day_sonnet, - extra_usage, - }) - } else { - None - } + let usage = super::claude_cli_probe::ClaudeCliProbe::fetch_usage().await?; + Ok(SubscriptionUsageResult { + needs_login: false, + usage: Some(usage), + error: None, + subscription_type: None, + }) } // --------------------------------------------------------------- @@ -549,52 +440,3 @@ impl SubscriptionUsageService { } } -// --- Helpers --- - -/// Strip ANSI escape codes from text. -fn strip_ansi(text: &str) -> String { - let mut result = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\x1b' { - // Skip until we find the terminating letter - if chars.peek() == Some(&'[') { - chars.next(); - while let Some(&c) = chars.peek() { - chars.next(); - if c.is_ascii_alphabetic() { - break; - } - } - } - } else { - result.push(ch); - } - } - result -} - -/// Extract a percentage value from text (e.g. "45.2% used" → 45.2). -fn extract_percentage(text: &str) -> Option { - let text = text.trim(); - for word in text.split_whitespace() { - let word = word.trim_end_matches('%'); - if let Ok(val) = word.parse::() { - if (0.0..=100.0).contains(&val) { - return Some(val); - } - } - } - None -} - -/// Search nearby lines (current + next 5) for a percentage value. -fn find_usage_in_nearby_lines(lines: &[&str], start: usize) -> Option<(f64, Option)> { - let end = (start + 6).min(lines.len()); - for line in &lines[start..end] { - if let Some(pct) = extract_percentage(line) { - return Some((pct, None)); - } - } - None -} diff --git a/src-tauri/src/services/terminal_renderer.rs b/src-tauri/src/services/terminal_renderer.rs new file mode 100644 index 0000000..f336f35 --- /dev/null +++ b/src-tauri/src/services/terminal_renderer.rs @@ -0,0 +1,46 @@ +//! Renders raw terminal output (containing ANSI escape sequences) into +//! clean, line-oriented text by feeding it through a VT100 emulator. +//! +//! This is the Rust equivalent of ClaudeBar's `TerminalRenderer.swift`, which +//! wraps SwiftTerm to handle cursor movement, screen clearing, and other +//! control sequences that would otherwise corrupt captured PTY output. + +/// Default dimensions used for the headless terminal. Matches ClaudeBar so +/// we parse the same rendered layout. +const DEFAULT_ROWS: u16 = 50; +const DEFAULT_COLS: u16 = 160; + +pub struct TerminalRenderer { + rows: u16, + cols: u16, +} + +impl TerminalRenderer { + pub fn new() -> Self { + Self { + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + } + } + + /// Feed raw bytes through a vt100 parser and return the rendered screen + /// text, trimmed of trailing empty rows. + pub fn render(&self, raw: &[u8]) -> String { + let mut parser = vt100::Parser::new(self.rows, self.cols, 0); + parser.process(raw); + + let contents = parser.screen().contents(); + + // Trim trailing empty lines so the parser doesn't have to walk empty + // rows. `contents()` pads every row to `cols`, so internal empty rows + // are just newlines. + let trimmed_end = contents.trim_end_matches(['\n', ' ']); + trimmed_end.to_string() + } +} + +impl Default for TerminalRenderer { + fn default() -> Self { + Self::new() + } +} From 0137b3e569f75a3a11159ef7b8bc21e2735265d1 Mon Sep 17 00:00:00 2001 From: zhnd Date: Sun, 12 Apr 2026 23:12:49 +0800 Subject: [PATCH 2/3] refactor: prefer CLI probe so happy path never touches Keychain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous API-primary/CLI-fallback design always hit load_credentials() first, which triggered a macOS Keychain authorization prompt on every release build launch — unreasonable UX when the CLI path can fetch the same data without any Keychain access. Matches ClaudeBar's default behaviour: CLI is the primary probe, API is the fallback that only runs if the CLI can't spawn (binary missing, PTY failure, parse error, etc.). To keep the subscription tier badge working without reading credentials from Keychain, the CLI probe now also parses the account tier from the rendered /usage header line ("Opus 4.5 · Claude Max" → "MAX", etc.) and returns it alongside the usage buckets. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/services/claude_cli_probe.rs | 58 +++++++++++++++- .../services/subscription_usage_service.rs | 69 ++++++------------- 2 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/services/claude_cli_probe.rs b/src-tauri/src/services/claude_cli_probe.rs index b5b32d5..860006d 100644 --- a/src-tauri/src/services/claude_cli_probe.rs +++ b/src-tauri/src/services/claude_cli_probe.rs @@ -15,13 +15,25 @@ use crate::types::{SubscriptionUsageResponse, UsageBucket}; /// setup-tokens only have `user:inference` scope, which can't hit `/usage`. const ENV_EXCLUSION: &str = "CLAUDE_CODE_OAUTH_TOKEN"; +/// Result of a CLI probe run, combining parsed usage data with any +/// metadata we could read from the CLI output itself (e.g. the account +/// tier shown in the header row). Keeping these in one struct lets the +/// caller avoid touching the Keychain just for a badge string. +pub struct CliProbeResult { + pub usage: SubscriptionUsageResponse, + /// Normalized subscription badge: "MAX", "PRO", "API", or a + /// pass-through of whatever the header said. `None` means we didn't + /// recognize the header line. + pub subscription_badge: Option, +} + pub struct ClaudeCliProbe; impl ClaudeCliProbe { /// Run `claude /usage` inside a PTY, render the ANSI output, and parse /// the usage buckets. Blocking I/O is offloaded via `spawn_blocking` so /// the async caller isn't held up by the PTY read loop. - pub async fn fetch_usage() -> Result { + pub async fn fetch_usage() -> Result { let claude_path = which::which("claude") .context("Claude CLI binary not found in PATH")?; log::debug!("ClaudeCliProbe: using binary at {}", claude_path.display()); @@ -66,7 +78,13 @@ impl ClaudeCliProbe { rendered.len() ); - parse_usage_output(&rendered) + let usage = parse_usage_output(&rendered)?; + let subscription_badge = detect_subscription_badge(&rendered); + + Ok(CliProbeResult { + usage, + subscription_badge, + }) } } @@ -114,6 +132,25 @@ fn parse_usage_output(text: &str) -> Result { }) } +/// Detect the subscription tier from the `claude /usage` header row. +/// Example headers: `"Opus 4.5 · Claude Max"`, `"Sonnet 4.5 · Claude Pro"`, +/// `"Sonnet 4.5 · API Usage Billing"`. We only scan the first ~6 rendered +/// lines since the tier always appears at the top. +fn detect_subscription_badge(text: &str) -> Option { + let head: String = text.lines().take(6).collect::>().join("\n"); + let lower = head.to_lowercase(); + + if lower.contains("· claude max") || lower.contains("·claude max") { + Some("MAX".to_string()) + } else if lower.contains("· claude pro") || lower.contains("·claude pro") { + Some("PRO".to_string()) + } else if lower.contains("api usage billing") { + Some("API".to_string()) + } else { + None + } +} + fn build_bucket(lines: &[&str], label_candidates: &[&str]) -> Option { for label in label_candidates { if let Some(pct) = extract_percent(lines, label) { @@ -276,6 +313,23 @@ mod tests { assert_eq!(extract_percent(&lines, "current session"), Some(35.0)); } + #[test] + fn detect_subscription_badge_matches_header() { + assert_eq!( + detect_subscription_badge("Opus 4.5 · Claude Max · Organization\n"), + Some("MAX".to_string()) + ); + assert_eq!( + detect_subscription_badge("Sonnet 4.5 · Claude Pro\n"), + Some("PRO".to_string()) + ); + assert_eq!( + detect_subscription_badge("Sonnet 4.5 · API Usage Billing\n"), + Some("API".to_string()) + ); + assert_eq!(detect_subscription_badge("Something else\n"), None); + } + #[test] fn parse_usage_output_builds_buckets() { let sample = "\ diff --git a/src-tauri/src/services/subscription_usage_service.rs b/src-tauri/src/services/subscription_usage_service.rs index 3cf8722..46e8b99 100644 --- a/src-tauri/src/services/subscription_usage_service.rs +++ b/src-tauri/src/services/subscription_usage_service.rs @@ -77,44 +77,33 @@ enum AuthOrError { pub struct SubscriptionUsageService; impl SubscriptionUsageService { + /// Fetch subscription usage. Prefers the CLI path (`claude /usage` inside + /// a PTY) since it requires no direct Keychain access and matches what + /// ClaudeBar does by default. Falls back to the OAuth API only if the CLI + /// can't run (binary missing, PTY spawn failure, parse failure, etc). pub async fn fetch_usage() -> Result { - // Derive subscription badge once — applies to all result paths (API + CLI). - let subscription_badge = claude_credentials::load_credentials() - .as_ref() - .and_then(Self::parse_subscription_badge); - - let mut result = match Self::fetch_via_api().await { - Ok(r) if !r.needs_login => r, - Ok(api_result) => { - // needs_login from API — try CLI fallback before giving up - log::debug!("API probe requires login, trying CLI fallback..."); - match Self::fetch_via_cli().await { - Ok(cli_result) => cli_result, - Err(e) => { - log::debug!("CLI fallback also failed: {}", e); - api_result - } - } + // Primary: CLI probe. No Keychain access — `claude` itself reads the + // stored credentials. + match super::claude_cli_probe::ClaudeCliProbe::fetch_usage().await { + Ok(cli) => { + return Ok(SubscriptionUsageResult { + needs_login: false, + usage: Some(cli.usage), + error: None, + subscription_type: cli.subscription_badge, + }); } - Err(api_err) => { - // API error — try CLI fallback - log::warn!("API probe failed: {}, trying CLI fallback...", api_err); - match Self::fetch_via_cli().await { - Ok(cli_result) => cli_result, - Err(cli_err) => { - log::warn!("CLI fallback also failed: {}", cli_err); - return Err(api_err); - } - } + Err(cli_err) => { + log::warn!( + "CLI usage probe failed, falling back to OAuth API: {}", + cli_err + ); } - }; - - // Ensure subscription badge is present on all paths - if result.subscription_type.is_none() { - result.subscription_type = subscription_badge; } - Ok(result) + // Fallback: API path. This DOES touch the Keychain (via + // load_credentials) because we need the OAuth access token. + Self::fetch_via_api().await } // --------------------------------------------------------------- @@ -399,20 +388,6 @@ impl SubscriptionUsageService { }) } - // --------------------------------------------------------------- - // CLI fallback — delegates to ClaudeCliProbe (PTY + vt100 renderer) - // --------------------------------------------------------------- - - async fn fetch_via_cli() -> Result { - let usage = super::claude_cli_probe::ClaudeCliProbe::fetch_usage().await?; - Ok(SubscriptionUsageResult { - needs_login: false, - usage: Some(usage), - error: None, - subscription_type: None, - }) - } - // --------------------------------------------------------------- // Response conversion // --------------------------------------------------------------- From bc46d882ca49e23365da50bc2e000fb192647b07 Mon Sep 17 00:00:00 2001 From: zhnd Date: Mon, 13 Apr 2026 08:01:01 +0800 Subject: [PATCH 3/3] feat: redesign Usage page as ECharts gauges with API billing support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the Usage page around circular ECharts gauges and add a real CLI parser that correctly handles untouched sub-buckets and pay-as-you-go API accounts. Frontend - Replace the horizontal progress-bar cards with ECharts `type: gauge` circular gauges (progress fill + inverted color bands so the "safe" zone is where a full ring points). Gauges show percent REMAINING instead of used, matching ClaudeBar's fuel-gauge metaphor. - Card header now sits top-left for scan-ability; reset text is left- aligned below the gauge. Gauges are capped at 200px square and flow in a 1-col → 2-col responsive grid with no section headers — the card labels already disambiguate each bucket. - Untouched buckets (e.g. Sonnet on a Max account where it hasn't been touched yet) still render the full gauge at 100% remaining, but the reset slot is swapped for "You haven't used Sonnet yet" when the backend reports `utilization == null`. - New `ApiBillingNotice` empty state for pay-as-you-go / API billing accounts so they no longer see the misleading "Claude Code login required" message. Copy is provider-neutral ("Check your provider's dashboard") to cover third-party gateways. - Extract `UsageStatusTheme`, `UsageStatus`, and `UseUsageBucketCardResult` into `usage-category-card/types.ts`; move `buildUsageStatusTheme` and `prefersReducedMotion` into `libs.ts`. `use-service.ts` is now just the hook body. Backend - `ExtraUsage` gains an optional `resets_at` field so CLI-parsed extra usage reset lines can flow through to the UI. - `ClaudeCliProbe` now returns `CliProbeResult.usage: Option<...>`. If the CLI header reports "API Usage Billing", we short-circuit with `usage: None` so the frontend gets a clean signal instead of the probe bailing and falling through to the OAuth API path. - Per-bucket reset extraction (no more shared weekly reset): each bucket extracts its own reset inside the section window bounded by the "Extra usage" section so inline "... Resets May 1" fragments can't bleed backwards into Sonnet / Opus. - New tri-state `SectionPercent::{NotFound, Untouched, Used}` so the parser can distinguish "section absent" from "section present but the user hasn't touched this model yet" (CLI renders `0% used` with no block characters). Untouched buckets carry `utilization: None` all the way to the frontend. Tests - 12 `claude_cli_probe` unit tests including `real_cli_output_produces _expected_buckets` which pins the exact rendered CLI output from a Max account where Opus is missing and Sonnet is untouched. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/api-billing-notice/index.tsx | 26 + .../components/extra-usage-card/index.tsx | 68 +-- .../extra-usage-card/use-service.ts | 163 ++++++ .../ui/src/modules/usage/components/index.ts | 1 + .../components/usage-category-card/index.tsx | 112 +--- .../components/usage-category-card/libs.ts | 53 ++ .../components/usage-category-card/types.ts | 24 + .../usage-category-card/use-service.ts | 126 +++++ .../usage/components/usage-skeleton/index.tsx | 45 +- packages/ui/src/modules/usage/index.tsx | 102 ++-- packages/ui/src/modules/usage/types.ts | 10 +- packages/ui/src/modules/usage/use-service.ts | 3 + src-tauri/src/services/claude_cli_probe.rs | 511 ++++++++++++++++-- .../services/subscription_usage_service.rs | 131 ++++- src-tauri/src/types/subscription_usage.rs | 2 + 15 files changed, 1101 insertions(+), 276 deletions(-) create mode 100644 packages/ui/src/modules/usage/components/api-billing-notice/index.tsx create mode 100644 packages/ui/src/modules/usage/components/extra-usage-card/use-service.ts create mode 100644 packages/ui/src/modules/usage/components/usage-category-card/libs.ts create mode 100644 packages/ui/src/modules/usage/components/usage-category-card/use-service.ts diff --git a/packages/ui/src/modules/usage/components/api-billing-notice/index.tsx b/packages/ui/src/modules/usage/components/api-billing-notice/index.tsx new file mode 100644 index 0000000..1cefd7d --- /dev/null +++ b/packages/ui/src/modules/usage/components/api-billing-notice/index.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Receipt } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; + +export function ApiBillingNotice() { + return ( +
+ + +
+ +
+
+

Pay-as-you-go account

+

+ This account uses pay-per-use API billing instead of a Claude + subscription, so there are no fixed quotas to show. Check your + provider's dashboard for spend and usage details. +

+
+
+
+
+ ); +} diff --git a/packages/ui/src/modules/usage/components/extra-usage-card/index.tsx b/packages/ui/src/modules/usage/components/extra-usage-card/index.tsx index 4ec0a16..5f1efec 100644 --- a/packages/ui/src/modules/usage/components/extra-usage-card/index.tsx +++ b/packages/ui/src/modules/usage/components/extra-usage-card/index.tsx @@ -1,67 +1,23 @@ "use client"; -import { cn } from "@/lib/utils"; +import { EChart } from "@/components/echarts"; import type { ExtraUsageCardProps } from "./types"; - -function formatCents(cents: number): string { - return `$${(cents / 100).toFixed(2)}`; -} +import { useExtraUsageCard } from "./use-service"; export function ExtraUsageCard({ extra }: ExtraUsageCardProps) { - const percent = Math.round(extra.utilization ?? 0); - const barColor = - percent >= 90 - ? "bg-red-500" - : percent >= 70 - ? "bg-amber-500" - : "bg-chart-1"; - const textColor = - percent >= 90 - ? "text-red-600 dark:text-red-400" - : percent >= 70 - ? "text-amber-600 dark:text-amber-400" - : "text-muted-foreground"; + const { option, resetText } = useExtraUsageCard(extra); return ( -
- {/* Progress bar */} -
-
-
-

- {extra.usedCredits != null - ? `${formatCents(extra.usedCredits)} spent` - : "Extra usage"} -

-
- - {percent}% used - -
- -
-
-
+
+

Extra usage

+
+
- - {/* Monthly limit */} - {extra.monthlyLimit != null && ( -
-

- {formatCents(extra.monthlyLimit)} -

-

- Monthly spend limit -

-
+ {resetText && ( +

Resets {resetText}

)}
); diff --git a/packages/ui/src/modules/usage/components/extra-usage-card/use-service.ts b/packages/ui/src/modules/usage/components/extra-usage-card/use-service.ts new file mode 100644 index 0000000..0ed83f8 --- /dev/null +++ b/packages/ui/src/modules/usage/components/extra-usage-card/use-service.ts @@ -0,0 +1,163 @@ +"use client"; + +import type { EChartsOption } from "echarts"; +import { useMemo } from "react"; +import { + resolveChartColor, + resolveChartColorAlpha, +} from "@/components/echarts"; +import type { ExtraUsage } from "@/generated/typeshare-types"; +import { getUsageStatus } from "../usage-category-card/libs"; +import type { UsageStatus } from "../usage-category-card/types"; + +function buildTheme(status: UsageStatus) { + switch (status) { + case "healthy": + return { + primary: resolveChartColor("--chart-3"), + gradientStart: resolveChartColor("--chart-3"), + gradientEnd: resolveChartColor("--chart-1"), + }; + case "warning": + return { + primary: resolveChartColor("--chart-4"), + gradientStart: resolveChartColor("--chart-4"), + gradientEnd: resolveChartColor("--chart-5"), + }; + case "critical": + return { + primary: resolveChartColor("--destructive"), + gradientStart: resolveChartColor("--destructive"), + gradientEnd: resolveChartColor("--chart-5"), + }; + } +} + +function prefersReducedMotion(): boolean { + if (typeof window === "undefined") return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function formatCents(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +export interface UseExtraUsageCardResult { + percentRemaining: number; + status: UsageStatus; + option: EChartsOption; + usedDisplay: string | null; + limitDisplay: string | null; + resetText: string | null; +} + +/** + * Extra usage mirrors the bucket card visuals but the center label shows + * the dollar spent, and the sublabel shows the monthly limit. Status is + * still computed from percent remaining. + */ +export function useExtraUsageCard(extra: ExtraUsage): UseExtraUsageCardResult { + return useMemo(() => { + const utilization = extra.utilization ?? 0; + const percentRemaining = Math.round( + Math.max(0, Math.min(100, 100 - utilization)), + ); + const status = getUsageStatus(percentRemaining); + const theme = buildTheme(status); + const mutedColor = resolveChartColor("--muted-foreground"); + const reducedMotion = prefersReducedMotion(); + + const usedDisplay = + extra.usedCredits != null ? formatCents(extra.usedCredits) : null; + const limitDisplay = + extra.monthlyLimit != null ? formatCents(extra.monthlyLimit) : null; + + const centerText = usedDisplay ?? `${percentRemaining}%`; + const sublabelText = limitDisplay ? `of ${limitDisplay}` : "REMAINING"; + + const bands: [number, string][] = [ + [0.2, resolveChartColorAlpha("--destructive", 0.28)], + [0.5, resolveChartColorAlpha("--chart-4", 0.28)], + [1.0, resolveChartColorAlpha("--chart-3", 0.28)], + ]; + + const option: EChartsOption = { + animation: !reducedMotion, + series: [ + { + type: "gauge", + startAngle: 90, + endAngle: -270, + radius: "92%", + min: 0, + max: 100, + progress: { + show: true, + width: 14, + roundCap: true, + itemStyle: { + color: { + type: "linear", + x: 0, + y: 0, + x2: 1, + y2: 1, + colorStops: [ + { offset: 0, color: theme.gradientStart }, + { offset: 1, color: theme.gradientEnd }, + ], + }, + }, + }, + axisLine: { + lineStyle: { width: 14, color: bands }, + }, + pointer: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + anchor: { show: false }, + title: { show: false }, + detail: { + valueAnimation: false, + offsetCenter: [0, "-8%"], + fontSize: 26, + fontWeight: 700, + color: theme.primary, + formatter: () => centerText, + }, + data: [{ value: percentRemaining }], + }, + { + type: "gauge", + radius: "92%", + startAngle: 90, + endAngle: -270, + axisLine: { show: false }, + pointer: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + title: { show: false }, + detail: { + offsetCenter: [0, "28%"], + fontSize: 10, + fontWeight: 600, + color: mutedColor, + formatter: () => sublabelText, + }, + data: [{ value: 0 }], + }, + ], + }; + + return { + percentRemaining, + status, + option, + usedDisplay, + limitDisplay, + resetText: extra.resetsAt ?? null, + }; + }, [extra]); +} diff --git a/packages/ui/src/modules/usage/components/index.ts b/packages/ui/src/modules/usage/components/index.ts index 8d6bda3..3bb8ac2 100644 --- a/packages/ui/src/modules/usage/components/index.ts +++ b/packages/ui/src/modules/usage/components/index.ts @@ -1,3 +1,4 @@ +export { ApiBillingNotice } from "./api-billing-notice"; export { ExtraUsageCard } from "./extra-usage-card"; export { LoginPrompt } from "./login-prompt"; export { UsageBucketCard } from "./usage-category-card"; diff --git a/packages/ui/src/modules/usage/components/usage-category-card/index.tsx b/packages/ui/src/modules/usage/components/usage-category-card/index.tsx index c3a46e8..9dbe240 100644 --- a/packages/ui/src/modules/usage/components/usage-category-card/index.tsx +++ b/packages/ui/src/modules/usage/components/usage-category-card/index.tsx @@ -1,99 +1,41 @@ "use client"; -import { cn } from "@/lib/utils"; +import { EChart } from "@/components/echarts"; import type { UsageBucketCardProps } from "./types"; +import { useUsageBucketCard } from "./use-service"; -function getBarColor(percent: number): string { - if (percent >= 90) return "bg-red-500"; - if (percent >= 70) return "bg-amber-500"; - return "bg-chart-1"; -} - -function getTextColor(percent: number): string { - if (percent >= 90) return "text-red-600 dark:text-red-400"; - if (percent >= 70) return "text-amber-600 dark:text-amber-400"; - return "text-muted-foreground"; -} - -function formatRelativeReset(diffMs: number): string { - if (diffMs <= 0) return "Resetting soon"; - - const totalMinutes = Math.ceil(diffMs / 60_000); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - - if (hours >= 24) { - const days = Math.floor(hours / 24); - const remainingHours = hours % 24; - return `in ${days}d ${remainingHours}h`; - } - - if (hours > 0) return `in ${hours}h ${minutes}m`; - return `in ${minutes}m`; -} - -function formatAbsoluteReset(date: Date): string { - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - const isTomorrow = date.toDateString() === tomorrow.toDateString(); - - const time = date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - }); - - if (isToday) return `Today ${time}`; - if (isTomorrow) return `Tomorrow ${time}`; - - return date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); +/** Extract the model name from a label like "Sonnet · weekly" → "Sonnet". + * Falls back to the full label if no separator is present. */ +function modelNameFromLabel(label: string): string { + const [first] = label.split("·"); + return first.trim() || label; } export function UsageBucketCard({ label, bucket }: UsageBucketCardProps) { - const percent = Math.round(bucket.utilization ?? 0); - const barColor = getBarColor(percent); - const textColor = getTextColor(percent); + const { isUntouched, option, resetText } = useUsageBucketCard(bucket); + + // Reset slot priority: + // 1. Concrete reset text from the backend → "Resets {text}" + // 2. Untouched bucket (utilization null, no reset) + // → "You haven't used {model} yet" + // 3. Otherwise → hide the line + let footer: string | null = null; + if (resetText) { + footer = `Resets ${resetText}`; + } else if (isUntouched) { + footer = `You haven't used ${modelNameFromLabel(label)} yet`; + } return ( -
-
-
-

{label}

- {bucket.resetsAt && - (() => { - const resetDate = new Date(bucket.resetsAt); - const diffMs = resetDate.getTime() - Date.now(); - return ( -

- Resets {formatRelativeReset(diffMs)} - · - {formatAbsoluteReset(resetDate)} -

- ); - })()} -
- - {percent}% used - -
- -
-
+

{label}

+
+
+ {footer &&

{footer}

}
); } diff --git a/packages/ui/src/modules/usage/components/usage-category-card/libs.ts b/packages/ui/src/modules/usage/components/usage-category-card/libs.ts new file mode 100644 index 0000000..ec9686b --- /dev/null +++ b/packages/ui/src/modules/usage/components/usage-category-card/libs.ts @@ -0,0 +1,53 @@ +import { resolveChartColor } from "@/components/echarts"; +import type { UsageStatus, UsageStatusTheme } from "./types"; + +/** + * Map a "percent remaining" value (0-100) to a simple three-tier status. + * Matches ClaudeBar's thresholds: + * - > 50% remaining → healthy + * - 20% < remaining ≤ 50% → warning + * - ≤ 20% remaining → critical + */ +export function getUsageStatus(percentRemaining: number): UsageStatus { + if (percentRemaining <= 20) return "critical"; + if (percentRemaining <= 50) return "warning"; + return "healthy"; +} + +/** + * Resolve theme colors for the gauge at render time by looking up the + * Tailwind/shadcn CSS variables. Returns a bundle of colors keyed by + * status so callers can interpolate them into an ECharts option. + */ +export function buildUsageStatusTheme(status: UsageStatus): UsageStatusTheme { + switch (status) { + case "healthy": + return { + primary: resolveChartColor("--chart-3"), + gradientStart: resolveChartColor("--chart-3"), + gradientEnd: resolveChartColor("--chart-1"), + }; + case "warning": + return { + primary: resolveChartColor("--chart-4"), + gradientStart: resolveChartColor("--chart-4"), + gradientEnd: resolveChartColor("--chart-5"), + }; + case "critical": + return { + primary: resolveChartColor("--destructive"), + gradientStart: resolveChartColor("--destructive"), + gradientEnd: resolveChartColor("--chart-5"), + }; + } +} + +/** + * Returns `true` when the user has requested reduced motion at the OS + * level. Used to suppress ECharts' spring animation on gauges so the + * usage page respects system accessibility settings. + */ +export function prefersReducedMotion(): boolean { + if (typeof window === "undefined") return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} diff --git a/packages/ui/src/modules/usage/components/usage-category-card/types.ts b/packages/ui/src/modules/usage/components/usage-category-card/types.ts index 4c32b6f..85d5e96 100644 --- a/packages/ui/src/modules/usage/components/usage-category-card/types.ts +++ b/packages/ui/src/modules/usage/components/usage-category-card/types.ts @@ -1,6 +1,30 @@ +import type { EChartsOption } from "echarts"; import type { UsageBucket } from "@/generated/typeshare-types"; export interface UsageBucketCardProps { label: string; bucket: UsageBucket; } + +export type UsageStatus = "healthy" | "warning" | "critical"; + +export interface UsageStatusTheme { + /** Primary color for the center number and the progress fill start. */ + primary: string; + /** Gauge progress gradient start color. */ + gradientStart: string; + /** Gauge progress gradient end color. */ + gradientEnd: string; +} + +export interface UseUsageBucketCardResult { + /** `true` when the backend reports the bucket as present but with no + * usage yet (e.g. a Max account that hasn't touched Sonnet). The + * gauge is still rendered at 100% remaining; the parent uses this + * flag to swap the reset text for a "You haven't used X yet" hint. */ + isUntouched: boolean; + percentRemaining: number; + status: UsageStatus; + option: EChartsOption; + resetText: string | null; +} diff --git a/packages/ui/src/modules/usage/components/usage-category-card/use-service.ts b/packages/ui/src/modules/usage/components/usage-category-card/use-service.ts new file mode 100644 index 0000000..439edf4 --- /dev/null +++ b/packages/ui/src/modules/usage/components/usage-category-card/use-service.ts @@ -0,0 +1,126 @@ +"use client"; + +import type { EChartsOption } from "echarts"; +import { useMemo } from "react"; +import { + resolveChartColor, + resolveChartColorAlpha, +} from "@/components/echarts"; +import type { UsageBucket } from "@/generated/typeshare-types"; +import { + buildUsageStatusTheme, + getUsageStatus, + prefersReducedMotion, +} from "./libs"; +import type { UseUsageBucketCardResult } from "./types"; + +export function useUsageBucketCard( + bucket: UsageBucket, +): UseUsageBucketCardResult { + return useMemo(() => { + // `utilization: null` from the backend means "section is present but + // has no usage yet" — we still render the gauge (full ring at 100% + // remaining) so the card matches the other buckets visually. + const isUntouched = bucket.utilization == null; + const utilization = bucket.utilization ?? 0; + const percentRemaining = Math.round( + Math.max(0, Math.min(100, 100 - utilization)), + ); + const status = getUsageStatus(percentRemaining); + const theme = buildUsageStatusTheme(status); + const mutedColor = resolveChartColor("--muted-foreground"); + const reducedMotion = prefersReducedMotion(); + + // Background color bands — inverted semantics because we display + // REMAINING: the "safe" zone is where a full ring points (high + // remaining values). Bands widths add up to 1. + const bands: [number, string][] = [ + [0.2, resolveChartColorAlpha("--destructive", 0.28)], + [0.5, resolveChartColorAlpha("--chart-4", 0.28)], + [1.0, resolveChartColorAlpha("--chart-3", 0.28)], + ]; + + const option: EChartsOption = { + animation: !reducedMotion, + series: [ + { + type: "gauge", + startAngle: 90, + endAngle: -270, + radius: "92%", + min: 0, + max: 100, + progress: { + show: true, + width: 14, + roundCap: true, + itemStyle: { + color: { + type: "linear", + x: 0, + y: 0, + x2: 1, + y2: 1, + colorStops: [ + { offset: 0, color: theme.gradientStart }, + { offset: 1, color: theme.gradientEnd }, + ], + }, + }, + }, + axisLine: { + lineStyle: { + width: 14, + color: bands, + }, + }, + pointer: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + anchor: { show: false }, + title: { show: false }, + detail: { + valueAnimation: !reducedMotion, + offsetCenter: [0, "-8%"], + fontSize: 32, + fontWeight: 700, + color: theme.primary, + formatter: "{value}%", + }, + data: [{ value: percentRemaining }], + }, + // Second invisible gauge that only contributes a "REMAINING" sublabel + // beneath the big number. Cheaper than adding a `graphic` layer. + { + type: "gauge", + radius: "92%", + startAngle: 90, + endAngle: -270, + axisLine: { show: false }, + pointer: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + title: { show: false }, + detail: { + offsetCenter: [0, "28%"], + fontSize: 10, + fontWeight: 600, + color: mutedColor, + formatter: "REMAINING", + }, + data: [{ value: 0 }], + }, + ], + }; + + return { + isUntouched, + percentRemaining, + status, + option, + resetText: bucket.resetsAt ?? null, + }; + }, [bucket]); +} diff --git a/packages/ui/src/modules/usage/components/usage-skeleton/index.tsx b/packages/ui/src/modules/usage/components/usage-skeleton/index.tsx index 08de6ec..dd925bd 100644 --- a/packages/ui/src/modules/usage/components/usage-skeleton/index.tsx +++ b/packages/ui/src/modules/usage/components/usage-skeleton/index.tsx @@ -1,49 +1,28 @@ "use client"; -import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; -function CategorySkeleton({ hasSubtext = true }: { hasSubtext?: boolean }) { +function GaugeCardSkeleton() { return ( -
-
-
- - {hasSubtext && } +
+ +
+
+
-
- +
); } export function UsageSkeleton() { return ( -
- {/* Plan usage limits */} -
- - -
- - - - {/* Weekly limits */} -
- - - - -
- - - - {/* Extra usage */} -
- - -
+
+ + + +
); } diff --git a/packages/ui/src/modules/usage/index.tsx b/packages/ui/src/modules/usage/index.tsx index b13a761..e7e9766 100644 --- a/packages/ui/src/modules/usage/index.tsx +++ b/packages/ui/src/modules/usage/index.tsx @@ -4,8 +4,8 @@ import { Clock, Crown, RefreshCw } from "lucide-react"; import { CardError } from "@/components/card-error"; import { PageHeader } from "@/components/page-header"; import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; import { + ApiBillingNotice, ExtraUsageCard, LoginPrompt, UsageBucketCard, @@ -65,11 +65,22 @@ export function Usage() {
-
+
{status === "loading" && } {status === "login" && } + {status === "api_billing" && ( + <> + {subscriptionType && ( +
+ +
+ )} + + + )} + {status === "error" && ( +
{/* Subscription tier banner */} {subscriptionType && ( )} - {/* Session limit (5-hour) */} - {usage.fiveHour && ( -
-

Session limit

+ {/* Fixed 2-column grid on anything from small screens up so the + gauges always land in a 2x2 / 2x3 shape regardless of + window width. On very narrow windows (< 640px) fall back + to a single column so cards don't get squeezed. */} +
+ {usage.fiveHour && ( -
- )} - - {/* Weekly limits */} - {(usage.sevenDay || - usage.sevenDayOpus || - usage.sevenDaySonnet) && ( - <> - {usage.fiveHour && } -
-

Weekly limits

- {usage.sevenDay && ( - - )} - {usage.sevenDay && - (usage.sevenDayOpus || usage.sevenDaySonnet) && ( - - )} - {usage.sevenDayOpus && ( - - )} - {usage.sevenDayOpus && usage.sevenDaySonnet && ( - - )} - {usage.sevenDaySonnet && ( - - )} -
- - )} - - {/* Extra usage */} - {usage.extraUsage?.isEnabled && ( - <> - -
-

Extra usage

- -
- - )} + )} + {usage.sevenDay && ( + + )} + {usage.sevenDayOpus && ( + + )} + {usage.sevenDaySonnet && ( + + )} + {usage.extraUsage?.isEnabled && ( + + )} +
{/* Last updated + refresh policy */}
@@ -173,7 +159,7 @@ function SubscriptionBanner({ type }: { type: string }) { return (
diff --git a/packages/ui/src/modules/usage/types.ts b/packages/ui/src/modules/usage/types.ts index 0230f68..cf931fb 100644 --- a/packages/ui/src/modules/usage/types.ts +++ b/packages/ui/src/modules/usage/types.ts @@ -1,6 +1,14 @@ import type { SubscriptionUsageResult } from "@/generated/typeshare-types"; -export type FetchStatus = "idle" | "loading" | "success" | "error" | "login"; +export type FetchStatus = + | "idle" + | "loading" + | "success" + | "error" + | "login" + /** Pay-per-use API billing account. No subscription quotas to show; + * the page renders an informational empty state instead of gauges. */ + | "api_billing"; export interface UseServiceReturn { status: FetchStatus; diff --git a/packages/ui/src/modules/usage/use-service.ts b/packages/ui/src/modules/usage/use-service.ts index 6a978d8..ad7c892 100644 --- a/packages/ui/src/modules/usage/use-service.ts +++ b/packages/ui/src/modules/usage/use-service.ts @@ -36,6 +36,9 @@ export function useService(): UseServiceReturn { status = "error"; } else if (data?.usage) { status = "success"; + } else if (data && data.subscriptionType === "API" && !data.usage) { + // Pay-per-use API billing account — no quotas to show. + status = "api_billing"; } return { diff --git a/src-tauri/src/services/claude_cli_probe.rs b/src-tauri/src/services/claude_cli_probe.rs index 860006d..e3ba6db 100644 --- a/src-tauri/src/services/claude_cli_probe.rs +++ b/src-tauri/src/services/claude_cli_probe.rs @@ -9,7 +9,7 @@ use regex::Regex; use super::interactive_runner::{run_interactive, RunOptions}; use super::terminal_renderer::TerminalRenderer; -use crate::types::{SubscriptionUsageResponse, UsageBucket}; +use crate::types::{ExtraUsage, SubscriptionUsageResponse, UsageBucket}; /// Environment variable that must be stripped before invoking `claude` — /// setup-tokens only have `user:inference` scope, which can't hit `/usage`. @@ -20,7 +20,9 @@ const ENV_EXCLUSION: &str = "CLAUDE_CODE_OAUTH_TOKEN"; /// tier shown in the header row). Keeping these in one struct lets the /// caller avoid touching the Keychain just for a badge string. pub struct CliProbeResult { - pub usage: SubscriptionUsageResponse, + /// Parsed usage buckets. `None` for account types that don't have + /// subscription quotas (e.g. pay-per-use API billing accounts). + pub usage: Option, /// Normalized subscription badge: "MAX", "PRO", "API", or a /// pass-through of whatever the header said. `None` means we didn't /// recognize the header line. @@ -78,11 +80,27 @@ impl ClaudeCliProbe { rendered.len() ); - let usage = parse_usage_output(&rendered)?; let subscription_badge = detect_subscription_badge(&rendered); + // API billing accounts don't have subscription quotas — the CLI + // prints a "Sonnet 4.5 · API Usage Billing" header and either no + // bucket sections at all or a "/usage is only available for + // subscription plans" message. Rather than bailing (which would + // fall through to the OAuth API path and ultimately surface a + // misleading "Claude Code login required"), return a successful + // result with `usage = None` so the frontend can render a + // dedicated API-billing empty state. + if subscription_badge.as_deref() == Some("API") { + return Ok(CliProbeResult { + usage: None, + subscription_badge, + }); + } + + let usage = parse_usage_output(&rendered)?; + Ok(CliProbeResult { - usage, + usage: Some(usage), subscription_badge, }) } @@ -99,6 +117,10 @@ fn parse_usage_output(text: &str) -> Result { bail!("Empty CLI output"); } + // Each bucket extracts its own reset — we don't fabricate a shared + // weekly reset for sub-buckets (Opus / Sonnet). If the CLI doesn't + // print a reset line inside the sub-bucket's window, that bucket's + // `resets_at` is `None` and the UI hides it. let five_hour = build_bucket(&lines, &["current session"]); let seven_day = build_bucket(&lines, &["current week (all models)"]); let seven_day_opus = build_bucket(&lines, &["current week (opus)"]); @@ -125,10 +147,89 @@ fn parse_usage_output(text: &str) -> Result { seven_day, seven_day_opus, seven_day_sonnet, - // Extra usage parsing is out of scope for the first cut — the - // credentials file already gives us `subscriptionType`, which is - // what the UI uses to show the tier badge. - extra_usage: None, + extra_usage: extract_extra_usage(text, &lines), + }) +} + +/// Parse the "Extra usage" section from a Pro/Max account's `/usage` output. +/// Returns `None` when the section is absent or explicitly disabled. +/// +/// Expected layout: +/// ```text +/// Extra usage +/// █████░░░░░░░░░░░░░░░ 27% used +/// $5.41 / $20.00 spent · Resets Jan 1, 2026 +/// ``` +/// +/// The frontend displays `used_credits` / `monthly_limit` as dollars after +/// dividing by 100, so we store the values in **cents** here to match the +/// OAuth API response shape. +fn extract_extra_usage(full_text: &str, lines: &[&str]) -> Option { + let lower_full = full_text.to_lowercase(); + if !lower_full.contains("extra usage") { + return None; + } + // Honor explicit opt-out so we don't render an empty card. + if lower_full.contains("extra usage not enabled") { + return None; + } + + let start_idx = lines + .iter() + .position(|line| line.to_lowercase().contains("extra usage"))?; + + // The percentage bar isn't guaranteed to be present on every account, + // but when it is, it sits within the section window like any other + // bucket — reuse the shared extractor so the 12-line window is consistent. + let utilization: Option = match extract_percent(lines, "extra usage") { + SectionPercent::Used(v) => Some(v), + SectionPercent::Untouched | SectionPercent::NotFound => None, + }; + + // Scan the next ~10 lines for "$X / $Y spent" (the section always shows + // the cost line on its own row right after the bar). + let cost_re = + Regex::new(r"(?i)\$?([\d,]+\.?\d*)\s*/\s*\$?([\d,]+\.?\d*)\s*spent").ok()?; + + // Scan the same 10-line window for a reset text line so we can surface + // the Extra usage reset date if the CLI prints one (e.g. "Resets Jan 1, 2026"). + let resets_at = extract_reset(lines, "extra usage"); + + let end = (start_idx + 10).min(lines.len()); + for line in &lines[start_idx..end] { + if let Some(caps) = cost_re.captures(line) { + let spent_dollars = caps + .get(1)? + .as_str() + .replace(',', "") + .parse::() + .ok()?; + let budget_dollars = caps + .get(2)? + .as_str() + .replace(',', "") + .parse::() + .ok()?; + // Convert to cents so the frontend's /100 formatter produces the + // right display string. + return Some(ExtraUsage { + is_enabled: true, + utilization, + used_credits: Some(spent_dollars * 100.0), + monthly_limit: Some(budget_dollars * 100.0), + resets_at: resets_at.clone(), + }); + } + } + + // Section header found but no parseable cost line — still report the + // section as enabled so the UI can show the bar without a dollar total. + utilization.map(|u| ExtraUsage { + is_enabled: true, + utilization: Some(u), + used_credits: None, + monthly_limit: None, + resets_at, }) } @@ -153,45 +254,91 @@ fn detect_subscription_badge(text: &str) -> Option { fn build_bucket(lines: &[&str], label_candidates: &[&str]) -> Option { for label in label_candidates { - if let Some(pct) = extract_percent(lines, label) { - let reset = extract_reset(lines, label); - return Some(UsageBucket { - utilization: Some(pct), - resets_at: reset, - }); + match extract_percent(lines, label) { + SectionPercent::NotFound => continue, + SectionPercent::Untouched => { + // Bucket exists in the CLI output but has no usage yet. + // `utilization: None` tells the frontend to render an + // empty state ("You haven't used X yet") instead of a + // gauge at 100% remaining. Reset is also left None + // unless the CLI prints one inside this section's window. + return Some(UsageBucket { + utilization: None, + resets_at: extract_reset(lines, label), + }); + } + SectionPercent::Used(pct) => { + return Some(UsageBucket { + utilization: Some(pct), + resets_at: extract_reset(lines, label), + }); + } } } None } +/// Tri-state result for reading a section's percent. +#[derive(Debug, PartialEq)] +enum SectionPercent { + /// The label wasn't in the output at all — bucket does not exist. + NotFound, + /// The label IS in the output but the bucket has no usage yet + /// (CLI prints `0% used` with no progress-bar character, or the + /// section window has no percent line at all). + Untouched, + /// The bucket reports a concrete usage percentage. + Used(f64), +} + /// Find the line containing `label_substring` (case-insensitive) and scan -/// the next 12 lines for a "X% used" or "X% left" value. Matches ClaudeBar's -/// behaviour — the CLI renders the bar on the line right after the label. -fn extract_percent(lines: &[&str], label_substring: &str) -> Option { +/// the next 12 lines for a percentage token. Mirrors ClaudeBar's +/// "first match wins" behaviour within a small window: once we find a +/// line that looks like a `X% used/left` statement we commit to that +/// verdict, preventing subsequent sections from bleeding in. +fn extract_percent(lines: &[&str], label_substring: &str) -> SectionPercent { let label = label_substring.to_lowercase(); - let mut found = None; for (idx, line) in lines.iter().enumerate() { - if line.to_lowercase().contains(&label) { - let end = (idx + 12).min(lines.len()); - for candidate in &lines[idx..end] { - if let Some(value) = percent_from_line(candidate) { - found = Some(value); - break; - } + if !line.to_lowercase().contains(&label) { + continue; + } + let end = (idx + 12).min(lines.len()); + for candidate in &lines[idx + 1..end] { + if !line_has_percent_token(candidate) { + continue; } - if found.is_some() { - break; + // Commit to the first line with a percent token. A "0% used" + // line with no filled block character is the CLI's signal + // that the bucket exists but hasn't been touched yet — we + // report it as Untouched so the UI can render an empty + // state. Any other value (including genuine 0% with a bar) + // is a real usage value. + match percent_from_line(candidate) { + Some(v) if v == 0.0 && !has_filled_block_char(candidate) => { + return SectionPercent::Untouched; + } + Some(v) => return SectionPercent::Used(v), + // Malformed percent line — fall through and treat the + // section as present but empty. + None => return SectionPercent::Untouched, } } + // Label present, window scanned, no percent token anywhere. + return SectionPercent::Untouched; } + SectionPercent::NotFound +} - found +/// Cheap pre-check: does `line` look like it has `X% used` or `X% left`? +fn line_has_percent_token(line: &str) -> bool { + let lower = line.to_lowercase(); + (lower.contains("% used") || lower.contains("% left")) + && line.chars().any(|c| c.is_ascii_digit()) } fn percent_from_line(line: &str) -> Option { // Regex matches "65% left", "25% used", etc. with flexible whitespace. - // Built once per call — these lines are tiny so the cost is negligible. let re = Regex::new(r"(?i)([0-9]{1,3})\s*%\s*(used|left)").ok()?; let caps = re.captures(line)?; let raw = caps.get(1)?.as_str().parse::().ok()?; @@ -206,24 +353,67 @@ fn percent_from_line(line: &str) -> Option { Some(used.clamp(0.0, 100.0)) } -/// Extract the first "resets in ..." line near a section label. Returns the -/// raw trimmed text — the frontend doesn't parse CLI reset strings yet. +/// Returns true if `line` contains any Unicode "filled block" character +/// in the range `U+2588..=U+258F` (FULL BLOCK through LEFT ONE EIGHTH +/// BLOCK). These are the glyphs the Claude CLI uses to draw progress +/// bars. An untouched bucket renders as whitespace + `0% used` with NO +/// block characters at all — that absence is the "not used yet" signal. +fn has_filled_block_char(line: &str) -> bool { + line.chars().any(|c| ('\u{2588}'..='\u{258F}').contains(&c)) +} + +/// Extract the reset text near a section label. The returned string is +/// display-ready (e.g. `"in 18m"`, `"4:59pm (America/New_York)"`) with the +/// leading `"Resets "` word removed. The frontend renders it verbatim +/// under a `"Resets "` label, matching the API path's formatted output. +/// +/// The forward scan stops at section boundaries (lines that begin a new +/// known section like `"Extra usage"`) so we never bleed an unrelated +/// section's reset text into the current bucket. fn extract_reset(lines: &[&str], label_substring: &str) -> Option { let label = label_substring.to_lowercase(); - for (idx, line) in lines.iter().enumerate() { - if line.to_lowercase().contains(&label) { - let end = (idx + 14).min(lines.len()); - for candidate in &lines[idx..end] { - let lower = candidate.to_lowercase(); - if lower.contains("reset") { - return Some(candidate.trim().to_string()); - } + for (idx, start_line) in lines.iter().enumerate() { + if !start_line.to_lowercase().contains(&label) { + continue; + } + let end = (idx + 14).min(lines.len()); + // Skip the label line itself (index 0 in the slice) — it may contain + // the word "reset" as part of its own text on future CLI versions. + for candidate in &lines[idx + 1..end] { + let lower = candidate.to_lowercase(); + // Stop if we cross into the Extra usage section. Its cost line + // ends in "Resets " which would otherwise be mis-attributed + // to preceding weekly buckets when the CLI omits a weekly reset. + if lower.contains("extra usage") && !label.contains("extra") { + return None; + } + if lower.contains("reset") { + return Some(strip_resets_prefix(candidate)); } } + return None; } None } +/// Strip a leading case-insensitive "Resets" word (and any following +/// whitespace) from a line. If the line doesn't start with "Resets", +/// returns the trimmed line as-is — some CLI layouts put the reset text +/// on a line that begins with cost info (e.g. `"$5.41 / $20.00 spent · Resets Jan 1, 2026"`), +/// in which case we take everything after the LAST "Resets" token. +fn strip_resets_prefix(line: &str) -> String { + let trimmed = line.trim(); + // Locate the last case-insensitive "resets" occurrence; this handles + // both leading and mid-line cases robustly. + let lower = trimmed.to_lowercase(); + if let Some(pos) = lower.rfind("resets") { + let after = &trimmed[pos + "resets".len()..]; + after.trim_start().to_string() + } else { + trimmed.to_string() + } +} + /// Create (or reuse) a stable working directory for the probe so every run /// happens inside the same folder — avoids the CLI re-prompting for trust. fn probe_working_directory() -> Result { @@ -310,7 +500,10 @@ mod tests { "█████████████░░░░░░░ 65% left", "Resets 4:59pm", ]; - assert_eq!(extract_percent(&lines, "current session"), Some(35.0)); + assert_eq!( + extract_percent(&lines, "current session"), + SectionPercent::Used(35.0) + ); } #[test] @@ -330,6 +523,244 @@ mod tests { assert_eq!(detect_subscription_badge("Something else\n"), None); } + #[test] + fn extra_usage_with_cost_line() { + let sample = "\ +Opus 4.5 · Claude Pro + +Current session +█████████████░░░░░░░ 18% used +Resets 4:59pm + +Extra usage +█████░░░░░░░░░░░░░░░ 27% used +$5.41 / $20.00 spent · Resets Jan 1, 2026 +"; + let parsed = parse_usage_output(sample).expect("should parse"); + let extra = parsed.extra_usage.expect("extra usage should be present"); + assert!(extra.is_enabled); + assert_eq!(extra.utilization, Some(27.0)); + assert_eq!(extra.used_credits, Some(541.0)); + assert_eq!(extra.monthly_limit, Some(2000.0)); + // The reset line appears on the same row as the cost, so `strip_resets_prefix` + // should take the substring after "Resets" and return just the date. + assert_eq!(extra.resets_at.as_deref(), Some("Jan 1, 2026")); + } + + #[test] + fn strip_resets_prefix_handles_leading_and_inline() { + assert_eq!(strip_resets_prefix("Resets in 18m"), "in 18m"); + assert_eq!( + strip_resets_prefix("Resets 4:59pm (America/New_York)"), + "4:59pm (America/New_York)" + ); + // Inline usage: "cost line · Resets DATE" + assert_eq!( + strip_resets_prefix("$5.41 / $20.00 spent · Resets Jan 1, 2026"), + "Jan 1, 2026" + ); + // No "Resets" prefix at all: return trimmed as-is + assert_eq!(strip_resets_prefix(" some other text "), "some other text"); + } + + #[test] + fn extract_reset_returns_display_ready_text() { + let lines = vec![ + "Current session", + "█████████████░░░░░░░ 18% used", + "Resets in 2h 15m", + ]; + assert_eq!( + extract_reset(&lines, "current session"), + Some("in 2h 15m".to_string()) + ); + } + + #[test] + fn sonnet_weekly_does_not_steal_extra_usage_reset() { + // Regression: the CLI only prints one weekly reset (next to "Current + // week (all models)"). Sonnet/Opus have no reset line of their own — + // per-bucket extraction must stop at the Extra usage boundary so the + // `· Resets May 1` fragment doesn't leak into Sonnet. + let sample = "\ +Opus 4.5 · Claude Max + +Current week (all models) +█████░░░░░░░░░░░░░░░ 30% used +Resets in 6d + +Current week (Opus) +█████░░░░░░░░░░░░░░░ 30% used + +Current week (Sonnet only) +████░░░░░░░░░░░░░░░░ 20% used + +Extra usage +█████░░░░░░░░░░░░░░░ 27% used +$5.41 / $20.00 spent · Resets May 1 +"; + let parsed = parse_usage_output(sample).expect("should parse"); + + let weekly = parsed.seven_day.expect("weekly bucket"); + assert_eq!(weekly.resets_at.as_deref(), Some("in 6d")); + + // Opus / Sonnet have their own percentages but no dedicated reset + // line — they MUST NOT borrow the Extra usage "May 1" text. Their + // resets_at is `None` (the UI hides the reset line). + let opus = parsed.seven_day_opus.expect("opus bucket"); + assert_eq!(opus.utilization, Some(30.0)); + assert!( + opus.resets_at.is_none(), + "Opus must not leak Extra usage reset, got: {:?}", + opus.resets_at + ); + + let sonnet = parsed.seven_day_sonnet.expect("sonnet bucket"); + assert_eq!(sonnet.utilization, Some(20.0)); + assert!( + sonnet.resets_at.is_none(), + "Sonnet must not leak Extra usage reset, got: {:?}", + sonnet.resets_at + ); + + // Extra usage still gets its own reset. + let extra = parsed.extra_usage.expect("extra usage"); + assert_eq!(extra.resets_at.as_deref(), Some("May 1")); + } + + #[test] + fn real_cli_output_produces_expected_buckets() { + // Exact rendered output captured from a real Max account where the + // user hasn't touched Sonnet or Opus. The CLI still prints a + // "Current week (Sonnet only)" section with whitespace + "0% used" + // (no bar, no reset line). Opus is omitted entirely. Lumo must: + // - show session, weekly (all models), extra usage with their + // own utilization AND reset strings + // - drop opus (section missing from CLI output) + // - show sonnet as an "untouched" bucket: `utilization = None` + // (no gauge, frontend renders "You haven't used Sonnet yet") + // and `resets_at = None` (no reset line in CLI, don't fabricate) + let sample = "\ + Current session + ███████████████ 30% used + Resets 4am (Asia/Shanghai) + + Current week (all models) + ███████████████▌ 31% used + Resets 11am (Asia/Shanghai) + + Current week (Sonnet only) + 0% used + + Extra usage + ▋ 1% used + $0.88 / $70.00 spent · Resets May 1 (Asia/Shanghai) +"; + let parsed = parse_usage_output(sample).expect("should parse real output"); + + let session = parsed.five_hour.as_ref().expect("session bucket"); + assert_eq!(session.utilization, Some(30.0)); + assert_eq!(session.resets_at.as_deref(), Some("4am (Asia/Shanghai)")); + + let weekly = parsed.seven_day.as_ref().expect("weekly bucket"); + assert_eq!(weekly.utilization, Some(31.0)); + assert_eq!(weekly.resets_at.as_deref(), Some("11am (Asia/Shanghai)")); + + assert!( + parsed.seven_day_opus.is_none(), + "Opus must be None (section absent), got: {:?}", + parsed.seven_day_opus + ); + + // Sonnet section IS printed but the user hasn't used it. The bucket + // exists (so the UI can show an empty state card) with no + // utilization and no reset. + let sonnet = parsed + .seven_day_sonnet + .as_ref() + .expect("sonnet bucket should be present even at 0%"); + assert!( + sonnet.utilization.is_none(), + "Sonnet utilization must be None (untouched), got: {:?}", + sonnet.utilization + ); + assert!( + sonnet.resets_at.is_none(), + "Sonnet resets_at must be None — no reset line in CLI, got: {:?}", + sonnet.resets_at + ); + + let extra = parsed.extra_usage.as_ref().expect("extra usage"); + assert_eq!(extra.utilization, Some(1.0)); + assert_eq!(extra.used_credits, Some(88.0)); + assert_eq!(extra.monthly_limit, Some(7000.0)); + assert_eq!(extra.resets_at.as_deref(), Some("May 1 (Asia/Shanghai)")); + } + + #[test] + fn weekly_reset_none_when_cli_omits_it() { + // If the CLI renders weekly sections without any "Resets" line in the + // weekly block, we must return None (not leak into Extra usage). + let sample = "\ +Opus 4.5 · Claude Max + +Current week (all models) +█████░░░░░░░░░░░░░░░ 30% used + +Current week (Sonnet only) +████░░░░░░░░░░░░░░░░ 20% used + +Extra usage +█████░░░░░░░░░░░░░░░ 27% used +$5.41 / $20.00 spent · Resets May 1 +"; + let parsed = parse_usage_output(sample).expect("should parse"); + + let weekly = parsed.seven_day.expect("weekly bucket"); + assert!( + weekly.resets_at.is_none(), + "weekly should not claim Extra usage reset, got: {:?}", + weekly.resets_at + ); + + let sonnet = parsed.seven_day_sonnet.expect("sonnet bucket"); + assert!(sonnet.resets_at.is_none()); + } + + #[test] + fn extra_usage_not_enabled_returns_none() { + let sample = "\ +Opus 4.5 · Claude Pro + +Current session +█████████████░░░░░░░ 18% used +Resets 4:59pm + +Extra usage not enabled +"; + let parsed = parse_usage_output(sample).expect("should parse"); + assert!(parsed.extra_usage.is_none()); + } + + #[test] + fn extra_usage_with_commas_and_decimals() { + // Large budgets may format with a thousands separator + let sample = "\ +Opus 4.5 · Claude Max + +Current session +█████████████░░░░░░░ 18% used + +Extra usage +███████████░░░░░░░░░ 55% used +$1,234.56 / $2,000.00 spent +"; + let parsed = parse_usage_output(sample).expect("should parse"); + let extra = parsed.extra_usage.expect("extra usage should be present"); + assert_eq!(extra.used_credits, Some(123_456.0)); + assert_eq!(extra.monthly_limit, Some(200_000.0)); + } + #[test] fn parse_usage_output_builds_buckets() { let sample = "\ diff --git a/src-tauri/src/services/subscription_usage_service.rs b/src-tauri/src/services/subscription_usage_service.rs index 46e8b99..e7c4834 100644 --- a/src-tauri/src/services/subscription_usage_service.rs +++ b/src-tauri/src/services/subscription_usage_service.rs @@ -83,12 +83,15 @@ impl SubscriptionUsageService { /// can't run (binary missing, PTY spawn failure, parse failure, etc). pub async fn fetch_usage() -> Result { // Primary: CLI probe. No Keychain access — `claude` itself reads the - // stored credentials. + // stored credentials. `cli.usage` is `None` for account types that + // don't expose subscription quotas (e.g. pay-per-use API billing), + // in which case we propagate the badge to the frontend which then + // renders a dedicated empty state instead of a gauge grid. match super::claude_cli_probe::ClaudeCliProbe::fetch_usage().await { Ok(cli) => { return Ok(SubscriptionUsageResult { needs_login: false, - usage: Some(cli.usage), + usage: cli.usage, error: None, subscription_type: cli.subscription_badge, }); @@ -397,7 +400,10 @@ impl SubscriptionUsageService { let convert_bucket = |b: ApiUsageBucket| UsageBucket { utilization: b.utilization, - resets_at: b.resets_at, + // Format the ISO timestamp into a display string so the frontend + // doesn't need to parse dates — the CLI path already delivers + // display-ready text, and both paths must look the same. + resets_at: b.resets_at.as_deref().and_then(format_api_reset_time), }; SubscriptionUsageResponse { @@ -410,8 +416,127 @@ impl SubscriptionUsageService { utilization: e.utilization, used_credits: e.used_credits, monthly_limit: e.monthly_limit, + // The OAuth API doesn't currently return a reset field for + // extra_usage; leave it None and the UI will hide the row. + resets_at: None, }), } } } +/// Format an ISO 8601 timestamp into a compact human-readable "Resets in ..." +/// style string, matching the shape of the CLI probe output. Returns `None` +/// if the timestamp is already in the past or can't be parsed. +/// +/// Examples: +/// - 45 minutes from now → `"in 45m"` +/// - 2 hours 15 minutes from now → `"in 2h 15m"` +/// - 3 days from now → `"in 3d"` +/// - 30 days from now → `"Jan 15"` (or `"Jan 15, 2027"` if year differs) +fn format_api_reset_time(iso: &str) -> Option { + use chrono::{DateTime, Datelike, Local, Utc}; + + let parsed = DateTime::parse_from_rfc3339(iso).ok()?; + let now = Utc::now(); + let target_utc = parsed.with_timezone(&Utc); + let delta = target_utc.signed_duration_since(now); + + if delta.num_seconds() <= 0 { + return None; + } + + let total_minutes = delta.num_minutes(); + let total_hours = delta.num_hours(); + let total_days = delta.num_days(); + + // < 1 hour → "in Xm" + if total_hours < 1 { + return Some(format!("in {}m", total_minutes.max(1))); + } + + // < 24 hours → "in Xh" or "in Xh Ym" + if total_days < 1 { + let minutes_remainder = total_minutes - total_hours * 60; + if minutes_remainder == 0 { + return Some(format!("in {}h", total_hours)); + } + return Some(format!("in {}h {}m", total_hours, minutes_remainder)); + } + + // < 7 days → "in Xd" or "in Xd Yh" + if total_days < 7 { + let hours_remainder = total_hours - total_days * 24; + if hours_remainder == 0 { + return Some(format!("in {}d", total_days)); + } + return Some(format!("in {}d {}h", total_days, hours_remainder)); + } + + // >= 7 days → absolute date in local timezone, e.g. "Jan 15" or "Jan 15, 2027" + let local = target_utc.with_timezone(&Local); + let now_local = now.with_timezone(&Local); + if local.year() == now_local.year() { + Some(local.format("%b %-d").to_string()) + } else { + Some(local.format("%b %-d, %Y").to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + + fn iso_offset(duration: Duration) -> String { + (Utc::now() + duration) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + } + + #[test] + fn format_api_reset_time_minutes() { + let s = format_api_reset_time(&iso_offset(Duration::minutes(15))).unwrap(); + // Allow slight clock drift between iso_offset() and format_api_reset_time() + assert!(s == "in 15m" || s == "in 14m", "got: {}", s); + } + + #[test] + fn format_api_reset_time_hours_exact() { + let s = format_api_reset_time(&iso_offset(Duration::hours(3))).unwrap(); + // Depending on rounding, might be "in 3h" or "in 2h 59m" + assert!(s == "in 3h" || s == "in 2h 59m", "got: {}", s); + } + + #[test] + fn format_api_reset_time_hours_and_minutes() { + let s = format_api_reset_time(&iso_offset( + Duration::hours(2) + Duration::minutes(15), + )) + .unwrap(); + assert!(s.starts_with("in 2h 1"), "got: {}", s); + } + + #[test] + fn format_api_reset_time_days() { + let s = format_api_reset_time(&iso_offset(Duration::days(3))).unwrap(); + assert!(s == "in 3d" || s == "in 2d 23h", "got: {}", s); + } + + #[test] + fn format_api_reset_time_far_future_uses_abs_date() { + let s = format_api_reset_time(&iso_offset(Duration::days(30))).unwrap(); + // Should be an abbreviated month + day, NOT "in 30d" + assert!(!s.starts_with("in "), "got: {}", s); + } + + #[test] + fn format_api_reset_time_past_returns_none() { + let s = format_api_reset_time(&iso_offset(Duration::seconds(-10))); + assert!(s.is_none()); + } + + #[test] + fn format_api_reset_time_invalid_returns_none() { + assert!(format_api_reset_time("not-a-date").is_none()); + } +} + diff --git a/src-tauri/src/types/subscription_usage.rs b/src-tauri/src/types/subscription_usage.rs index 41b20f5..1b82b94 100644 --- a/src-tauri/src/types/subscription_usage.rs +++ b/src-tauri/src/types/subscription_usage.rs @@ -28,6 +28,8 @@ pub struct ExtraUsage { pub used_credits: Option, /// Monthly limit in minor units (cents) pub monthly_limit: Option, + /// Pre-formatted reset string (e.g. "Jan 1, 2026"). None if not available. + pub resets_at: Option, } /// Full subscription usage response from the OAuth API