From 581cae07666a2bec353a0feb934b3884914d05f6 Mon Sep 17 00:00:00 2001 From: shidevil Date: Fri, 1 May 2026 13:22:48 +0000 Subject: [PATCH] feat: add subscription usage command with Claude, Codex, and Z.ai providers Adds tokscale usage CLI command and TUI tab for monitoring subscription quotas across Claude, Codex, and Z.ai. Supports --json and --light output modes, token refresh, and keychain credential fallback. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 8 +- crates/tokscale-cli/src/commands/mod.rs | 1 + crates/tokscale-cli/src/commands/usage.rs | 592 ++++++++++++++++++++++ crates/tokscale-cli/src/main.rs | 10 + crates/tokscale-cli/src/tui/app.rs | 40 +- crates/tokscale-cli/src/tui/ui/mod.rs | 2 + crates/tokscale-cli/src/tui/ui/usage.rs | 144 ++++++ 7 files changed, 790 insertions(+), 7 deletions(-) create mode 100644 crates/tokscale-cli/src/commands/usage.rs create mode 100644 crates/tokscale-cli/src/tui/ui/usage.rs diff --git a/Cargo.lock b/Cargo.lock index 4a7841a8d..779aaa81d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2120,9 +2120,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.5.5+3.5.5" +version = "300.6.0+3.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" dependencies = [ "cc", ] @@ -3372,7 +3372,7 @@ dependencies = [ [[package]] name = "tokscale-cli" -version = "2.0.27" +version = "2.1.0" dependencies = [ "ab_glyph", "anyhow", @@ -3411,7 +3411,7 @@ dependencies = [ [[package]] name = "tokscale-core" -version = "2.0.27" +version = "2.1.0" dependencies = [ "bincode", "chrono", diff --git a/crates/tokscale-cli/src/commands/mod.rs b/crates/tokscale-cli/src/commands/mod.rs index 10ecad686..06b9ec621 100644 --- a/crates/tokscale-cli/src/commands/mod.rs +++ b/crates/tokscale-cli/src/commands/mod.rs @@ -1 +1,2 @@ +pub mod usage; pub mod wrapped; diff --git a/crates/tokscale-cli/src/commands/usage.rs b/crates/tokscale-cli/src/commands/usage.rs new file mode 100644 index 000000000..122af4090 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage.rs @@ -0,0 +1,592 @@ +use anyhow::Result; +use chrono::{DateTime, Duration, TimeZone, Utc}; +use serde::Deserialize; +use serde_json; + +// ── Shared types ── + +#[derive(Debug, Clone, serde::Serialize)] +pub struct UsageMetric { + pub label: String, + pub used_percent: f64, + pub remaining_percent: f64, + pub remaining_label: Option, + pub resets_at: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct UsageOutput { + pub provider: String, + pub plan: Option, + pub email: Option, + pub metrics: Vec, +} + +// ── Claude ── + +const CLAUDE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const CLAUDE_BETA: &str = "oauth-2025-04-20"; + +#[derive(Debug, Deserialize)] +struct ClaudeCredentials { + #[serde(rename = "claudeAiOauth")] + claude_ai_oauth: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeOauth { + #[serde(rename = "accessToken")] + access_token: Option, + #[serde(rename = "refreshToken")] + refresh_token: Option, + #[serde(rename = "subscriptionType")] + subscription_type: Option, + #[serde(rename = "rateLimitTier")] + rate_limit_tier: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeUsageResponse { + five_hour: Option, + seven_day: Option, + seven_day_opus: Option, + #[allow(dead_code)] + extra_usage: Option, +} + +#[derive(Debug, Deserialize)] +struct Window { + utilization: f64, + resets_at: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct ClaudeExtraUsage { + is_enabled: Option, + used_credits: Option, + monthly_limit: Option, + currency: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeTokenRefresh { + access_token: Option, +} + +fn read_claude_keychain() -> Result { + let out = std::process::Command::new("security") + .args(["find-generic-password", "-s", "Claude Code-credentials", "-w"]) + .output()?; + if !out.status.success() { + anyhow::bail!("Keychain lookup failed"); + } + Ok(String::from_utf8(out.stdout)?.trim_end().to_string()) +} + +fn read_claude_credentials() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let path = home.join(".claude").join(".credentials.json"); + let content = if path.exists() { + std::fs::read_to_string(&path)? + } else { + read_claude_keychain()? + }; + Ok(serde_json::from_str(&content)?) +} + +async fn claude_refresh(client: &reqwest::Client, rt: &str) -> Result { + let resp = client + .post("https://platform.claude.com/v1/oauth/token") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": rt, + "client_id": CLAUDE_CLIENT_ID, + "scope": "user:profile user:inference user:sessions:claude_code user:mcp_servers" + })) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Claude token refresh failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn claude_fetch(client: &reqwest::Client, token: &str) -> Result { + let resp = client + .get("https://api.anthropic.com/api/oauth/usage") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("anthropic-beta", CLAUDE_BETA) + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Claude usage request failed (HTTP {status})"); + } + Ok(resp.json().await?) +} + +fn fetch_claude() -> Result { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let creds = read_claude_credentials()?; + let oauth = creds.claude_ai_oauth.ok_or_else(|| { + anyhow::anyhow!("No Claude OAuth credentials. Run 'claude' to log in.") + })?; + let access_token = oauth + .access_token + .ok_or_else(|| anyhow::anyhow!("No Claude access token."))?; + let plan = oauth.subscription_type.map(|s| { + let tier = oauth.rate_limit_tier.as_deref().and_then(|t| { + // "default_claude_max_20x" -> "20x", "default_claude_max_5x" -> "5x" + t.rsplit('_').next() + }); + match tier { + Some(mult) => format!("{} {}", capitalize(&s), mult), + None => capitalize(&s), + } + }); + + let client = reqwest::Client::new(); + let resp = match claude_fetch(&client, &access_token).await { + Ok(r) => r, + Err(e) if e.to_string().contains("NEEDS_AUTH") => { + let rt = oauth + .refresh_token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; + let refreshed = claude_refresh(&client, rt).await?; + let new = refreshed + .access_token + .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + claude_fetch(&client, &new).await? + } + Err(e) => return Err(e), + }; + + let mut metrics = Vec::new(); + if let Some(ref w) = resp.five_hour { + metrics.push(window_metric("Session", w)); + } + if let Some(ref w) = resp.seven_day { + metrics.push(window_metric("Weekly", w)); + } + if let Some(ref w) = resp.seven_day_opus { + metrics.push(window_metric("Opus", w)); + } + + Ok(UsageOutput { + provider: "Claude".into(), + plan, + email: None, + metrics, + }) + }) +} + +// ── Codex (OpenAI) ── + +const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +#[derive(Debug, Deserialize)] +struct CodexAuth { + tokens: Option, +} + +#[derive(Debug, Deserialize)] +struct CodexTokens { + access_token: Option, + refresh_token: Option, + account_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct CodexUsage { + email: Option, + plan_type: Option, + rate_limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct CodexRateLimit { + primary_window: Option, + secondary_window: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct CodexWindow { + used_percent: Option, + reset_at: Option, + #[allow(dead_code)] + limit_window_seconds: Option, +} + +#[derive(Debug, Deserialize)] +struct CodexRefresh { + access_token: Option, +} + +fn read_codex_credentials() -> Result { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let paths = [ + home.join(".config").join("codex").join("auth.json"), + home.join(".codex").join("auth.json"), + ]; + for p in &paths { + if p.exists() { + let content = std::fs::read_to_string(p)?; + if let Ok(auth) = serde_json::from_str::(&content) { + if auth.tokens.is_some() { + return Ok(auth); + } + } + } + } + anyhow::bail!("No Codex credentials found. Run 'codex' to log in.") +} + +async fn codex_refresh(client: &reqwest::Client, rt: &str) -> Result { + let resp = client + .post("https://auth.openai.com/oauth/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(format!( + "grant_type=refresh_token&client_id={CODEX_CLIENT_ID}&refresh_token={rt}" + )) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Codex token refresh failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn codex_fetch(client: &reqwest::Client, token: &str, account_id: Option<&str>) -> Result { + let mut req = client + .get("https://chatgpt.com/backend-api/wham/usage") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"); + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + let resp = req.send().await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Codex usage request failed (HTTP {status})"); + } + let body = resp.text().await?; + if body.trim().starts_with('<') { + anyhow::bail!("NEEDS_AUTH"); + } + Ok(serde_json::from_str(&body)?) +} + +fn fetch_codex() -> Result { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let auth = read_codex_credentials()?; + let tokens = auth + .tokens + .ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?; + let access_token = tokens + .access_token + .ok_or_else(|| anyhow::anyhow!("No Codex access token."))?; + let account_id = tokens.account_id.as_deref(); + + let client = reqwest::Client::new(); + let resp = match codex_fetch(&client, &access_token, account_id).await { + Ok(r) => r, + Err(e) if e.to_string().contains("NEEDS_AUTH") => { + let rt = tokens + .refresh_token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; + let refreshed = codex_refresh(&client, rt).await?; + let new = refreshed + .access_token + .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + codex_fetch(&client, &new, account_id).await? + } + Err(e) => return Err(e), + }; + + let plan = resp.plan_type.as_deref().map(capitalize); + let mut metrics = Vec::new(); + if let Some(ref rl) = resp.rate_limit { + if let Some(ref w) = rl.primary_window { + let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; + metrics.push(UsageMetric { + label: "Session".into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: w.reset_at.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) + .map(|dt| dt.to_rfc3339()), + }); + } + if let Some(ref w) = rl.secondary_window { + let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; + metrics.push(UsageMetric { + label: "Weekly".into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: w.reset_at.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) + .map(|dt| dt.to_rfc3339()), + }); + } + } + + Ok(UsageOutput { + provider: "Codex".into(), + plan, + email: resp.email, + metrics, + }) + }) +} + +// ── Z.ai ── + +#[derive(Debug, Deserialize)] +struct ZaiQuotaResp { + data: Option, +} + +#[derive(Debug, Deserialize)] +struct ZaiQuotaData { + limits: Option>, + level: Option, +} + +#[derive(Debug, Deserialize)] +struct ZaiLimit { + #[serde(rename = "type")] + limit_type: Option, + #[allow(dead_code)] + usage: Option, + #[allow(dead_code)] + remaining: Option, + percentage: Option, + #[allow(dead_code)] + current_value: Option, + number: Option, + unit: Option, +} + +#[derive(Debug, Deserialize)] +struct ZaiSubResp { + data: Option>, +} + +#[derive(Debug, Deserialize)] +struct ZaiSub { + product_name: Option, + next_renew_time: Option, +} + +async fn zai_fetch_quota(client: &reqwest::Client, key: &str) -> Result { + let resp = client + .get("https://api.z.ai/api/monitor/usage/quota/limit") + .header("Authorization", format!("Bearer {key}")) + .header("Accept", "application/json") + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Z.ai quota request failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +async fn zai_fetch_sub(client: &reqwest::Client, key: &str) -> Result { + let resp = client + .get("https://api.z.ai/api/biz/subscription/list") + .header("Authorization", format!("Bearer {key}")) + .header("Accept", "application/json") + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Z.ai subscription request failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +fn fetch_zai() -> Result { + let api_key = std::env::var("ZAI_API_KEY") + .or_else(|_| std::env::var("GLM_API_KEY")) + .map_err(|_| anyhow::anyhow!("No ZAI_API_KEY or GLM_API_KEY set."))?; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let client = reqwest::Client::new(); + let quota = zai_fetch_quota(&client, &api_key).await?; + let sub = zai_fetch_sub(&client, &api_key).await.ok(); + + let plan = sub + .as_ref() + .and_then(|s| s.data.as_ref()) + .and_then(|d| d.first()) + .and_then(|s| s.product_name.clone()) + .or_else(|| quota.data.as_ref().and_then(|d| d.level.clone()).map(|l| capitalize(&l))); + + let mut metrics = Vec::new(); + if let Some(ref limits) = quota.data.as_ref().and_then(|d| d.limits.as_ref()) { + for limit in limits.iter() { + let pct = limit.percentage.unwrap_or(0.0).clamp(0.0, 100.0); + + match limit.limit_type.as_deref() { + Some("TOKENS_LIMIT") => { + let label = match (limit.unit, limit.number) { + (Some(3), Some(5)) => "Session", + (Some(6), Some(1)) => "Monthly", + _ => "Tokens", + }; + metrics.push(UsageMetric { + label: label.into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: None, + }); + } + Some("TIME_LIMIT") => { + let remaining_label = limit.remaining.map(|r| format!("{:.0} left", r)); + metrics.push(UsageMetric { + label: "Web Searches".into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label, + resets_at: sub + .as_ref() + .and_then(|s| s.data.as_ref()) + .and_then(|d| d.first()) + .and_then(|s| s.next_renew_time.clone()), + }); + } + _ => {} + } + } + } + + Ok(UsageOutput { + provider: "Z.ai".into(), + plan, + email: None, + metrics, + }) + }) +} + +// ── Helpers ── + +fn capitalize(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => s.to_string(), + } +} + +fn window_metric(label: &str, w: &Window) -> UsageMetric { + let used = w.utilization.clamp(0.0, 100.0); + UsageMetric { + label: label.into(), + used_percent: used, + remaining_percent: 100.0 - used, + remaining_label: None, + resets_at: w.resets_at.clone(), + } +} + +// ── Public API ── + +pub fn fetch_all() -> Vec { + let mut results = Vec::new(); + + match fetch_claude() { + Ok(o) => results.push(o), + Err(e) => eprintln!("Claude: {e}"), + } + match fetch_codex() { + Ok(o) => results.push(o), + Err(e) => eprintln!("Codex: {e}"), + } + match fetch_zai() { + Ok(o) => results.push(o), + Err(e) => eprintln!("Z.ai: {e}"), + } + + results +} + +const BAR_WIDTH: usize = 12; +const CARD_WIDTH: usize = 58; + +fn format_reset_time(resets_at: &str) -> String { + let dt = match DateTime::parse_from_rfc3339(resets_at) { + Ok(d) => d.with_timezone(&Utc), + Err(_) => return resets_at.into(), + }; + let diff = dt - Utc::now(); + if diff <= Duration::zero() { + return "resets now".into(); + } + let total_mins = diff.num_minutes(); + if total_mins < 60 { + format!("resets in {total_mins}m") + } else if total_mins < 24 * 60 { + let h = diff.num_hours(); + let m = (diff - Duration::hours(h)).num_minutes(); + if m > 0 { format!("resets in {h}h {m}m") } else { format!("resets in {h}h") } + } else if diff.num_days() < 7 { + format!("resets {} {}", dt.format("%a"), dt.format("%-I%P")) + } else { + format!("resets {}", dt.format("%b %-d")) + } +} + +fn render_ascii_bar(pct: f64) -> String { + let filled = (pct.clamp(0.0, 100.0) / 100.0 * BAR_WIDTH as f64).round() as usize; + format!("[{}{}]", "=".repeat(filled), "-".repeat(BAR_WIDTH - filled)) +} + +fn render_light(output: &UsageOutput) { + println!("╭{}╮", "─".repeat(CARD_WIDTH)); + for m in &output.metrics { + let rem = m.remaining_label.clone().unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); + let bar = render_ascii_bar(m.remaining_percent); + let reset = m.resets_at.as_ref().map(|r| format_reset_time(r)).unwrap_or_default(); + println!("│ {:<10}{:<11}{:<14}{:<20}│", m.label, rem, bar, reset); + } + if let Some(ref email) = output.email { + println!("│ {: Result<()> { + let outputs = fetch_all(); + if json { + println!("{}", serde_json::to_string_pretty(&outputs)?); + } else { + for o in &outputs { + render_light(o); + } + } + Ok(()) +} diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 999f5c7bf..142d6045f 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -242,6 +242,13 @@ enum Commands { #[arg(long, help = "Disable loading spinner (for scripting)")] no_spinner: bool, }, + #[command(about = "Show subscription usage and quota for AI providers")] + Usage { + #[arg(long, help = "Output as JSON")] + json: bool, + #[arg(long, help = "Light terminal output (no TUI)")] + light: bool, + }, #[command(about = "Cursor IDE integration commands")] Cursor { #[command(subcommand)] @@ -580,6 +587,9 @@ fn main() -> Result<()> { reject_unsupported_home_override(&cli.home, "antigravity")?; run_antigravity_command(subcommand) } + Some(Commands::Usage { json, light }) => { + commands::usage::run(json, light) + } Some(Commands::DeleteSubmittedData) => { reject_unsupported_home_override(&cli.home, "delete-submitted-data")?; run_delete_data_command() diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index fcfd328ee..4eea9b71d 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -33,6 +33,7 @@ pub struct TuiConfig { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Tab { Overview, + Usage, Models, Daily, Hourly, @@ -44,6 +45,7 @@ impl Tab { pub fn all() -> &'static [Tab] { &[ Tab::Overview, + Tab::Usage, Tab::Models, Tab::Daily, Tab::Hourly, @@ -55,6 +57,7 @@ impl Tab { pub fn as_str(&self) -> &'static str { match self { Tab::Overview => "Overview", + Tab::Usage => "Usage", Tab::Models => "Models", Tab::Daily => "Daily", Tab::Hourly => "Hourly", @@ -66,6 +69,7 @@ impl Tab { pub fn short_name(&self) -> &'static str { match self { Tab::Overview => "Ovw", + Tab::Usage => "Use", Tab::Models => "Mod", Tab::Daily => "Day", Tab::Hourly => "Hr", @@ -76,7 +80,8 @@ impl Tab { pub fn next(self) -> Tab { match self { - Tab::Overview => Tab::Models, + Tab::Overview => Tab::Usage, + Tab::Usage => Tab::Models, Tab::Models => Tab::Daily, Tab::Daily => Tab::Hourly, Tab::Hourly => Tab::Stats, @@ -88,7 +93,8 @@ impl Tab { pub fn prev(self) -> Tab { match self { Tab::Overview => Tab::Agents, - Tab::Models => Tab::Overview, + Tab::Usage => Tab::Overview, + Tab::Models => Tab::Usage, Tab::Daily => Tab::Models, Tab::Hourly => Tab::Daily, Tab::Stats => Tab::Hourly, @@ -189,6 +195,8 @@ pub struct App { pub hourly_view_mode: HourlyViewMode, pub model_shade_map: HashMap, + + pub subscription_usage: Vec, } impl App { @@ -277,6 +285,7 @@ impl App { dialog_needs_reload, hourly_view_mode: HourlyViewMode::default(), model_shade_map: HashMap::new(), + subscription_usage: Vec::new(), }; app.build_model_shade_map(); Ok(app) @@ -430,6 +439,9 @@ impl App { self.set_status("Refresh already in progress"); } else { self.needs_reload = true; + if self.current_tab == Tab::Usage { + self.fetch_subscription_usage(); + } } } KeyCode::Char('R') if key.modifiers.contains(KeyModifiers::SHIFT) => { @@ -466,6 +478,9 @@ impl App { KeyCode::Char('g') => { self.open_group_by_picker(); } + KeyCode::Char('u') if self.current_tab == Tab::Usage => { + self.fetch_subscription_usage(); + } KeyCode::Enter if self.current_tab == Tab::Stats => { self.handle_graph_selection(); } @@ -480,6 +495,16 @@ impl App { false } + pub fn fetch_subscription_usage(&mut self) { + self.subscription_usage = crate::commands::usage::fetch_all(); + if !self.subscription_usage.is_empty() { + self.status_message = Some("Usage data loaded".into()); + } else { + self.status_message = Some("No usage data available".into()); + } + self.status_message_time = Some(std::time::Instant::now()); + } + pub fn handle_mouse_event(&mut self, event: MouseEvent) { if self.dialog_stack.is_active() { self.dialog_stack.handle_mouse(event); @@ -581,6 +606,10 @@ impl App { self.current_tab = target; + if target == Tab::Usage && self.subscription_usage.is_empty() { + self.fetch_subscription_usage(); + } + let (field, dir) = self.tab_sort_state .get(&target) @@ -714,6 +743,11 @@ impl App { 0 } } + Tab::Usage => self + .subscription_usage + .iter() + .map(|u| u.metrics.len()) + .sum(), } } @@ -894,7 +928,7 @@ impl App { h.cost ) }), - Tab::Stats => None, + Tab::Stats | Tab::Usage => None, }; if let Some(text) = text { diff --git a/crates/tokscale-cli/src/tui/ui/mod.rs b/crates/tokscale-cli/src/tui/ui/mod.rs index 917c9832c..7d247a83f 100644 --- a/crates/tokscale-cli/src/tui/ui/mod.rs +++ b/crates/tokscale-cli/src/tui/ui/mod.rs @@ -10,6 +10,7 @@ mod models; mod overview; pub mod spinner; mod stats; +mod usage; pub(crate) mod widgets; use ratatui::prelude::*; @@ -49,6 +50,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { Tab::Daily => daily::render(frame, app, chunks[1]), Tab::Hourly => hourly::render(frame, app, chunks[1]), Tab::Stats => stats::render(frame, app, chunks[1]), + Tab::Usage => usage::render(frame, app, chunks[1]), } } diff --git a/crates/tokscale-cli/src/tui/ui/usage.rs b/crates/tokscale-cli/src/tui/ui/usage.rs new file mode 100644 index 000000000..5d64935d2 --- /dev/null +++ b/crates/tokscale-cli/src/tui/ui/usage.rs @@ -0,0 +1,144 @@ +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::tui::app::App; + +const BAR_WIDTH: usize = 20; + +fn render_ascii_bar(remaining_percent: f64) -> String { + let pct = remaining_percent.clamp(0.0, 100.0) / 100.0; + let filled = (pct * BAR_WIDTH as f64).round() as usize; + let empty = BAR_WIDTH - filled; + format!("[{}{}]", "=".repeat(filled), "-".repeat(empty)) +} + +fn format_reset_time(resets_at: &str) -> String { + use chrono::{DateTime, Duration, Utc}; + let dt = match DateTime::parse_from_rfc3339(resets_at) { + Ok(d) => d.with_timezone(&Utc), + Err(_) => return format!("resets {resets_at}"), + }; + let diff = dt - Utc::now(); + if diff <= Duration::zero() { + return "resets now".into(); + } + let total_mins = diff.num_minutes(); + if total_mins < 60 { + format!("resets in {total_mins}m") + } else if total_mins < 24 * 60 { + let hours = diff.num_hours(); + let mins = (diff - Duration::hours(hours)).num_minutes(); + if mins > 0 { + format!("resets in {hours}h {mins}m") + } else { + format!("resets in {hours}h") + } + } else if diff.num_days() < 7 { + format!("resets {} {}", dt.format("%a"), dt.format("%-I%P")) + } else { + format!("resets {}", dt.format("%b %-d")) + } +} + +pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)) + .title(" Subscription Usage ") + .title_style(Style::default().fg(app.theme.foreground)) + .style(Style::default().bg(app.theme.background)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if app.subscription_usage.is_empty() { + render_loading(frame, app, inner); + } else { + render_loaded(frame, app, inner, &app.subscription_usage); + } +} + +fn render_loading(frame: &mut Frame, app: &App, area: Rect) { + let center = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(40), + Constraint::Length(3), + Constraint::Percentage(40), + ]) + .split(area)[1]; + + let msg = if app.data.loading { + "Loading subscription data..." + } else { + "Press 'u' to fetch subscription usage" + }; + let paragraph = Paragraph::new(msg) + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, center); +} + +fn render_loaded(frame: &mut Frame, app: &App, area: Rect, outputs: &[crate::commands::usage::UsageOutput]) { + let mut lines: Vec = Vec::new(); + + for (i, output) in outputs.iter().enumerate() { + if i > 0 { + lines.push(Line::from("")); + } + + lines.push(Line::from(Span::styled( + format!(" {} ", output.provider), + Style::default().fg(app.theme.foreground).add_modifier(Modifier::BOLD), + ))); + + for m in &output.metrics { + let remaining = m.remaining_label.clone().unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); + let bar = render_ascii_bar(m.remaining_percent); + let reset = m + .resets_at + .as_ref() + .map(|r| format_reset_time(r)) + .unwrap_or_default(); + + let label = Span::styled( + format!(" {:<12}", m.label), + Style::default().fg(app.theme.foreground), + ); + let value = Span::styled( + format!("{:<11}", remaining), + Style::default().fg(app.theme.foreground), + ); + let bar_span = Span::styled( + format!("{:<24}", bar), + Style::default() + .fg(if m.remaining_percent < 10.0 { + Color::Red + } else if m.remaining_percent < 25.0 { + Color::Yellow + } else { + app.theme.accent + }), + ); + let reset_span = Span::styled(reset, Style::default().fg(app.theme.muted)); + + lines.push(Line::from(vec![label, value, bar_span, reset_span])); + } + + if let Some(ref email) = output.email { + lines.push(Line::from(Span::styled( + format!(" {:<12}{email}", "Account"), + Style::default().fg(app.theme.muted), + ))); + } + if let Some(ref plan) = output.plan { + lines.push(Line::from(Span::styled( + format!(" {:<12}{plan}", "Plan"), + Style::default().fg(app.theme.muted), + ))); + } + } + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); +}