diff --git a/README.md b/README.md index 23b95d2f0..519ca2b69 100644 --- a/README.md +++ b/README.md @@ -243,9 +243,9 @@ tokscale models --json > report.json # Save to file The interactive TUI mode provides: -- **6 Views**: Overview (chart + top models), Usage (subscription quotas), Models, Daily, Hourly, Stats (contribution graph), Agents +- **7 Views**: Overview (chart + top models), Usage (subscription quotas), Models, Daily, Hourly, Stats (contribution graph), Agents - **Keyboard Navigation**: - - `1-6` or `←/→/Tab`: Switch views + - `←/→/Tab`: Switch views - `↑/↓`: Navigate lists - `c/d/t`: Sort by cost/date/tokens - `s`: Open source picker dialog diff --git a/crates/tokscale-cli/src/commands/usage/amp.rs b/crates/tokscale-cli/src/commands/usage/amp.rs index d882800f9..c9fc6a6b7 100644 --- a/crates/tokscale-cli/src/commands/usage/amp.rs +++ b/crates/tokscale-cli/src/commands/usage/amp.rs @@ -111,6 +111,11 @@ fn detect_plan(metrics: &[UsageMetric]) -> Option { } } +pub fn has_credentials() -> bool { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".local").join("share").join("amp").join("secrets.json").exists() +} + pub fn fetch() -> Result { let api_key = read_credentials()?; diff --git a/crates/tokscale-cli/src/commands/usage/claude.rs b/crates/tokscale-cli/src/commands/usage/claude.rs index 360b01f33..4d2557d0a 100644 --- a/crates/tokscale-cli/src/commands/usage/claude.rs +++ b/crates/tokscale-cli/src/commands/usage/claude.rs @@ -47,6 +47,12 @@ fn read_keychain() -> Result { super::helpers::read_keychain("Claude Code-credentials") } +pub fn has_credentials() -> bool { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".claude").join(".credentials.json").exists() + || super::helpers::read_keychain("Claude Code-credentials").is_ok() +} + fn read_credentials() -> Result { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let path = home.join(".claude").join(".credentials.json"); diff --git a/crates/tokscale-cli/src/commands/usage/codex.rs b/crates/tokscale-cli/src/commands/usage/codex.rs index cb28ed117..5a904493e 100644 --- a/crates/tokscale-cli/src/commands/usage/codex.rs +++ b/crates/tokscale-cli/src/commands/usage/codex.rs @@ -48,30 +48,63 @@ struct Refresh { fn read_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"), - ]; + let mut paths: Vec = Vec::new(); + + // CODEX_HOME takes precedence + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + paths.push(std::path::PathBuf::from(codex_home).join("auth.json")); + } + paths.push(home.join(".config").join("codex").join("auth.json")); + paths.push(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() { + // Only accept if tokens contains a usable access_token + if auth.tokens.as_ref().and_then(|t| t.access_token.as_ref()).is_some() { return Ok(auth); } } } } + + // macOS keychain fallback + if let Ok(raw) = super::helpers::read_keychain("Codex Auth") { + if let Ok(auth) = serde_json::from_str::(&raw) { + if auth.tokens.as_ref().and_then(|t| t.access_token.as_ref()).is_some() { + return Ok(auth); + } + } + } + anyhow::bail!("No Codex credentials found. Run 'codex' to log in.") } +pub fn has_credentials() -> bool { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + if std::path::PathBuf::from(codex_home).join("auth.json").exists() { + return true; + } + } + if home.join(".config").join("codex").join("auth.json").exists() { + return true; + } + if home.join(".codex").join("auth.json").exists() { + return true; + } + super::helpers::read_keychain("Codex Auth").is_ok() +} + async fn refresh_token(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={CLIENT_ID}&refresh_token={rt}" - )) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", CLIENT_ID), + ("refresh_token", rt), + ]) .send() .await?; if !resp.status().is_success() { diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index 84caea9c7..9cd3a93a3 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -43,9 +43,17 @@ fn read_token_from_keychain() -> Result { } } +fn gh_config_dir() -> std::path::PathBuf { + std::env::var("GH_CONFIG_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".config").join("gh") + }) +} + fn read_token_from_hosts() -> Result { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let path = home.join(".config").join("gh").join("hosts.yml"); + let path = gh_config_dir().join("hosts.yml"); if !path.exists() { anyhow::bail!("No gh hosts file"); } @@ -58,15 +66,16 @@ fn read_token_from_hosts() -> Result { in_github = true; continue; } + // A non-indented, non-empty, non-comment line starts a new section + if in_github && !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() && !trimmed.starts_with('#') { + in_github = false; + } if in_github && trimmed.starts_with("oauth_token:") { let token = trimmed.trim_start_matches("oauth_token:").trim(); if !token.is_empty() { return Ok(token.to_string()); } } - if in_github && !trimmed.is_empty() && !trimmed.starts_with("oauth_token") && !trimmed.starts_with('#') { - in_github = false; - } } anyhow::bail!("No oauth_token found in hosts.yml") } @@ -151,6 +160,13 @@ async fn fetch_api(client: &reqwest::Client, token: &str) -> Result bool { + if super::helpers::read_keychain("gh:github.com").is_ok() { + return true; + } + gh_config_dir().join("hosts.yml").exists() +} + pub fn fetch() -> Result { let token = read_credentials()?; diff --git a/crates/tokscale-cli/src/commands/usage/helpers.rs b/crates/tokscale-cli/src/commands/usage/helpers.rs index 25f0fcaa5..7a5d6d10b 100644 --- a/crates/tokscale-cli/src/commands/usage/helpers.rs +++ b/crates/tokscale-cli/src/commands/usage/helpers.rs @@ -10,6 +10,9 @@ pub fn capitalize(s: &str) -> String { } pub fn read_keychain(service: &str) -> Result { + if cfg!(not(target_os = "macos")) { + anyhow::bail!("Keychain lookup is only available on macOS"); + } let out = std::process::Command::new("security") .args(["find-generic-password", "-s", service, "-w"]) .output()?; diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index 6604d0628..1850b8081 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -13,6 +13,13 @@ struct Credentials { expires_at: Option, } +#[derive(Debug, Deserialize)] +struct RefreshResponse { + access_token: Option, + refresh_token: Option, + expires_in: Option, +} + #[derive(Debug, Deserialize)] struct UsageResponse { usage: Option, @@ -52,15 +59,6 @@ struct Membership { level: Option, } -#[derive(Debug, Deserialize)] -struct RefreshResponse { - access_token: Option, - #[allow(dead_code)] - refresh_token: Option, - #[allow(dead_code)] - expires_in: Option, -} - fn read_credentials() -> Result { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let path = home.join(".kimi").join("credentials").join("kimi-code.json"); @@ -71,6 +69,20 @@ fn read_credentials() -> Result { Ok(serde_json::from_str(&content)?) } +fn save_credentials(access_token: &str, refresh_token: &str, expires_in: i64) { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let path = home.join(".kimi").join("credentials").join("kimi-code.json"); + let expires_at = chrono::Utc::now().timestamp() as f64 + expires_in as f64; + let json = serde_json::json!({ + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + "scope": "kimi-code", + "token_type": "Bearer" + }); + let _ = std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap_or_default()); +} + fn needs_refresh(expires_at: Option) -> bool { if let Some(expires_at) = expires_at { let now = chrono::Utc::now().timestamp() as f64; @@ -83,10 +95,11 @@ fn needs_refresh(expires_at: Option) -> bool { async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { let resp = client .post("https://auth.kimi.com/api/oauth/token") - .header("Content-Type", "application/x-www-form-urlencoded") - .body(format!( - "client_id={CLIENT_ID}&grant_type=refresh_token&refresh_token={rt}" - )) + .form(&[ + ("client_id", CLIENT_ID), + ("grant_type", "refresh_token"), + ("refresh_token", rt), + ]) .send() .await?; if !resp.status().is_success() { @@ -130,6 +143,11 @@ fn parse_quota_detail(label: &str, detail: &QuotaDetail) -> Option }) } +pub fn has_credentials() -> bool { + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".kimi").join("credentials").join("kimi-code.json").exists() +} + pub fn fetch() -> Result { let creds = read_credentials()?; let mut access_token = creds @@ -147,8 +165,11 @@ pub fn fetch() -> Result { if needs_refresh(expires_at) { if let Some(ref rt_str) = stored_refresh_token { if let Ok(refreshed) = refresh_token(&client, rt_str).await { - if let Some(new_token) = refreshed.access_token { + if let Some(new_token) = refreshed.access_token.clone() { access_token = new_token; + if let (Some(new_rt), Some(expires_in)) = (&refreshed.refresh_token, refreshed.expires_in) { + save_credentials(&access_token, new_rt, expires_in); + } } } } @@ -163,7 +184,11 @@ pub fn fetch() -> Result { let refreshed = refresh_token(&client, rt_str).await?; let new = refreshed .access_token + .clone() .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + if let (Some(new_rt), Some(expires_in)) = (&refreshed.refresh_token, refreshed.expires_in) { + save_credentials(&new, new_rt, expires_in); + } fetch_usage(&client, &new).await? } Err(e) => return Err(e), diff --git a/crates/tokscale-cli/src/commands/usage/minimax.rs b/crates/tokscale-cli/src/commands/usage/minimax.rs index 3fc312e92..be70a5600 100644 --- a/crates/tokscale-cli/src/commands/usage/minimax.rs +++ b/crates/tokscale-cli/src/commands/usage/minimax.rs @@ -5,11 +5,15 @@ use serde::Deserialize; use super::{UsageMetric, UsageOutput}; use super::helpers::capitalize; +const MODEL_CALLS_PER_PROMPT: i64 = 15; + #[derive(Debug, Deserialize)] struct ApiResponse { base_resp: Option, model_remains: Option>, data: Option, + current_subscribe_title: Option, + plan_name: Option, } #[derive(Debug, Deserialize)] @@ -21,6 +25,8 @@ struct BaseResp { #[derive(Debug, Deserialize)] struct ApiData { model_remains: Option>, + current_subscribe_title: Option, + plan_name: Option, } #[derive(Debug, Deserialize)] @@ -28,11 +34,11 @@ struct ModelRemains { current_interval_total_count: Option, current_interval_usage_count: Option, current_interval_remaining_count: Option, + current_interval_used_count: Option, current_subscribe_title: Option, #[allow(dead_code)] start_time: Option, end_time: Option, - #[allow(dead_code)] remains_time: Option, } @@ -42,9 +48,9 @@ fn read_api_key() -> Result { .map_err(|_| anyhow::anyhow!("No MINIMAX_API_KEY or MINIMAX_API_TOKEN set.")) } -fn is_error(resp: &ApiResponse) -> bool { +fn is_auth_error(resp: &ApiResponse) -> bool { if let Some(ref base) = resp.base_resp { - if base.status_code.unwrap_or(0) != 0 { + if base.status_code == Some(1004) { return true; } if let Some(ref msg) = base.status_msg { @@ -57,19 +63,47 @@ fn is_error(resp: &ApiResponse) -> bool { false } -fn infer_plan(total: i64) -> String { - match total { - 0..=15 => "Starter".into(), - 16..=300 => "Plus".into(), - 301..=1000 => "Max".into(), - _ => "Ultra".into(), +fn is_api_error(resp: &ApiResponse) -> bool { + if let Some(ref base) = resp.base_resp { + if base.status_code.unwrap_or(0) != 0 { + return true; + } + } + false +} + +fn normalize_plan_name(raw: &str) -> String { + let without_prefix = raw.trim_start_matches("MiniMax Coding Plan").trim() + .trim_start_matches(':').trim_start_matches('-').trim(); + if without_prefix.is_empty() { + capitalize(raw.trim()) + } else { + capitalize(without_prefix) } } +fn infer_plan(total: i64) -> Option { + let prompt_limit = if total % MODEL_CALLS_PER_PROMPT == 0 { + total / MODEL_CALLS_PER_PROMPT + } else { + total + }; + Some(match prompt_limit { + 100 => "Starter".into(), + 300 => "Plus".into(), + 1000 => "Max".into(), + 2000 => "Ultra".into(), + _ => return None, + }) +} + +fn epoch_to_ms(ts: i64) -> i64 { + if ts.abs() < 1_000_000_000 { ts * 1000 } else { ts } +} + fn parse_end_time(ts: i64) -> String { - // Auto-detect seconds vs milliseconds - let secs = if ts > 1_000_000_000_0 { ts / 1000 } else { ts }; - Utc.timestamp_opt(secs, 0) + let ms = epoch_to_ms(ts); + Utc.timestamp_millis_opt(ms) .single() .map(|dt| dt.to_rfc3339()) .unwrap_or_else(|| ts.to_string()) @@ -84,12 +118,20 @@ async fn fetch_api(client: &reqwest::Client, key: &str) -> Result { .send() .await?; - if !resp.status().is_success() { - anyhow::bail!("MiniMax usage request failed (HTTP {})", resp.status()); + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("Session expired. Check your MiniMax API key."); + } + if !status.is_success() { + anyhow::bail!("MiniMax usage request failed (HTTP {status})"); } Ok(resp.json().await?) } +pub fn has_credentials() -> bool { + std::env::var("MINIMAX_API_KEY").or_else(|_| std::env::var("MINIMAX_API_TOKEN")).is_ok() +} + pub fn fetch() -> Result { let api_key = read_api_key()?; @@ -98,7 +140,10 @@ pub fn fetch() -> Result { let client = reqwest::Client::new(); let resp = fetch_api(&client, &api_key).await?; - if is_error(&resp) { + if is_auth_error(&resp) { + anyhow::bail!("Session expired. Check your MiniMax API key."); + } + if is_api_error(&resp) { let msg = resp.base_resp.as_ref() .and_then(|b| b.status_msg.clone()) .unwrap_or_else(|| "Unknown error".into()); @@ -111,47 +156,57 @@ pub fn fetch() -> Result { .map(|v| v.as_slice()) .unwrap_or(&[]); + // Pick the first entry with a valid total + let chosen = remains.iter().find(|m| { + m.current_interval_total_count.unwrap_or(0) > 0 + }); + let mut metrics = Vec::new(); - let mut plan: Option = None; + let mut plan: Option = resp.data.as_ref() + .and_then(|d| d.current_subscribe_title.as_ref().or(d.plan_name.as_ref())) + .or_else(|| resp.current_subscribe_title.as_ref().or(resp.plan_name.as_ref())) + .map(|s| normalize_plan_name(s)); - for model in remains.iter() { - // Try explicit plan title first + if let Some(model) = chosen { if plan.is_none() { - plan = model.current_subscribe_title.as_ref() - .map(|t| { - let cleaned = t.trim_start_matches("MiniMax Coding Plan").trim(); - if cleaned.is_empty() { t.clone() } else { capitalize(cleaned) } - }); + plan = model.current_subscribe_title.as_ref().map(|s| normalize_plan_name(s)); } let total = model.current_interval_total_count.unwrap_or(0); - if total <= 0 { - continue; - } - - // MiniMax's usage_count is often actually remaining count - let remaining = model.current_interval_remaining_count - .or_else(|| model.current_interval_usage_count.map(|u| if u <= total { total - u } else { u })) - .unwrap_or(0); - let used = (total - remaining).max(0); - let used_pct = (used as f64 / total as f64 * 100.0).clamp(0.0, 100.0); - - let resets_at = model.end_time.map(|ts| parse_end_time(ts)); + // Prefer explicit used_count, then compute from remaining + let used = model.current_interval_used_count + .map(|u| u.clamp(0, total)) + .unwrap_or_else(|| { + // Both remaining_count and usage_count represent remaining prompts + let remaining = model.current_interval_remaining_count + .or(model.current_interval_usage_count) + .unwrap_or(0); + (total - remaining).max(0) + }); + + let used_pct = if total > 0 { (used as f64 / total as f64 * 100.0).clamp(0.0, 100.0) } else { 0.0 }; + + // Reset time: prefer end_time, fallback to remains_time + let resets_at = model.end_time.map(|ts| parse_end_time(ts)) + .or_else(|| { + model.remains_time.map(|rt| { + let ms = if rt > 1_000_000_000 { rt } else { rt * 1000 }; + let dt = Utc::now() + chrono::Duration::milliseconds(ms); + dt.to_rfc3339() + }) + }); metrics.push(UsageMetric { - label: "Prompts".into(), + label: "Session".into(), used_percent: used_pct, remaining_percent: 100.0 - used_pct, - remaining_label: Some(format!("{remaining}/{total} left")), + remaining_label: Some(format!("{}/{} prompts left", total - used, total)), resets_at, }); - } - // Infer plan from total count if not explicitly provided - if plan.is_none() { - if let Some(first) = remains.first() { - plan = first.current_interval_total_count.map(infer_plan); + if plan.is_none() { + plan = infer_plan(total); } } diff --git a/crates/tokscale-cli/src/commands/usage/mod.rs b/crates/tokscale-cli/src/commands/usage/mod.rs index 31b7b3f1d..002e03fef 100644 --- a/crates/tokscale-cli/src/commands/usage/mod.rs +++ b/crates/tokscale-cli/src/commands/usage/mod.rs @@ -11,7 +11,7 @@ use anyhow::Result; // ── Shared types ── -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UsageMetric { pub label: String, pub used_percent: f64, @@ -20,7 +20,7 @@ pub struct UsageMetric { pub resets_at: Option, } -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UsageOutput { pub provider: String, pub plan: Option, @@ -28,35 +28,91 @@ pub struct UsageOutput { pub metrics: Vec, } +// ── Cache ── + +fn cache_path() -> Option { + let dir = crate::paths::get_cache_dir(); + if std::fs::create_dir_all(&dir).is_err() { + return None; + } + Some(dir.join("subscription-usage-cache.json")) +} + +pub fn save_cache(data: &[UsageOutput]) { + let Some(path) = cache_path() else { return }; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let json = serde_json::json!({ + "timestamp": timestamp, + "data": data, + }); + let _ = std::fs::write(&path, serde_json::to_string(&json).unwrap_or_default()); +} + +pub fn load_cache() -> Option> { + let path = cache_path()?; + let content = std::fs::read_to_string(&path).ok()?; + let doc: serde_json::Value = serde_json::from_str(&content).ok()?; + let timestamp = doc.get("timestamp")?.as_u64()?; + let age = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_sub(timestamp); + // Cache expires after 5 minutes + if age > 300 { + return None; + } + serde_json::from_value(doc.get("data")?.clone()).ok() +} + // ── Public API ── pub fn fetch_all() -> Vec { - let mut results = Vec::new(); - - macro_rules! try_fetch { - ($name:expr, $func:expr) => { - match $func() { - Ok(o) => results.push(o), - Err(e) => eprintln!("{}: {e}", $name), - } - }; - } + let providers: Vec<(&str, fn() -> bool, fn() -> Result)> = vec![ + ("Claude", claude::has_credentials, claude::fetch), + ("Codex", codex::has_credentials, codex::fetch), + ("Z.ai", zai::has_credentials, zai::fetch), + ("Amp", amp::has_credentials, amp::fetch), + ("Copilot", copilot::has_credentials, copilot::fetch), + ("Kimi", kimi::has_credentials, kimi::fetch), + ("MiniMax", minimax::has_credentials, minimax::fetch), + ]; + + let active: Vec<_> = providers + .into_iter() + .filter(|(_, has, _)| has()) + .collect(); - try_fetch!("Claude", claude::fetch); - try_fetch!("Codex", codex::fetch); - try_fetch!("Z.ai", zai::fetch); - try_fetch!("Amp", amp::fetch); - try_fetch!("Copilot", copilot::fetch); - try_fetch!("Kimi", kimi::fetch); - try_fetch!("MiniMax", minimax::fetch); + if active.is_empty() { + return vec![]; + } - results + std::thread::scope(|s| { + active + .into_iter() + .map(|(name, _, fetch)| { + s.spawn(move || match fetch() { + Ok(o) => Some(o), + Err(e) => { + eprintln!("{name}: {e}"); + None + } + }) + }) + .collect::>() + .into_iter() + .filter_map(|h| h.join().ok().flatten()) + .collect() + }) } // ── Light-mode rendering ── const BAR_WIDTH: usize = 12; -const CARD_WIDTH: usize = 58; +const CARD_WIDTH: usize = 62; fn render_light(output: &UsageOutput) { println!("╭{}╮", "─".repeat(CARD_WIDTH)); @@ -64,7 +120,7 @@ fn render_light(output: &UsageOutput) { let rem = m.remaining_label.clone().unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); let bar = helpers::render_ascii_bar(m.remaining_percent, BAR_WIDTH); let reset = m.resets_at.as_ref().map(|r| helpers::format_reset_time(r)).unwrap_or_default(); - println!("│ {:<10}{:<11}{:<14}{:<20}│", m.label, rem, bar, reset); + println!("│ {:<14}{:<11}{:<14}{:<20}│", m.label, rem, bar, reset); } if let Some(ref email) = output.email { println!("│ {: Result { Ok(resp.json().await?) } +pub fn has_credentials() -> bool { + std::env::var("ZAI_API_KEY").or_else(|_| std::env::var("GLM_API_KEY")).is_ok() +} + pub fn fetch() -> Result { let api_key = std::env::var("ZAI_API_KEY") .or_else(|_| std::env::var("GLM_API_KEY")) @@ -84,30 +88,37 @@ pub fn fetch() -> Result { .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(); + let mut session_metric = None; + let mut weekly_metric = None; + let mut search_metric = None; + 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(), + let metric = UsageMetric { + label: String::new(), used_percent: pct, remaining_percent: 100.0 - pct, remaining_label: None, resets_at: None, - }); + }; + match (limit.unit, limit.number) { + (Some(3), Some(5)) => { + session_metric = Some(UsageMetric { label: "Session".into(), ..metric }); + } + (Some(6), Some(1)) => { + weekly_metric = Some(UsageMetric { label: "Weekly".into(), ..metric }); + } + _ => {} + } } Some("TIME_LIMIT") => { let remaining_label = limit.remaining.map(|r| format!("{:.0} left", r)); - metrics.push(UsageMetric { - label: "Web Searches".into(), + search_metric = Some(UsageMetric { + label: "Web Search".into(), used_percent: pct, remaining_percent: 100.0 - pct, remaining_label, @@ -123,6 +134,11 @@ pub fn fetch() -> Result { } } + let mut metrics = Vec::new(); + if let Some(m) = session_metric { metrics.push(m); } + if let Some(m) = weekly_metric { metrics.push(m); } + if let Some(m) = search_metric { metrics.push(m); } + Ok(UsageOutput { provider: "Z.ai".into(), plan, diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 142d6045f..a123cc5b1 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -588,6 +588,7 @@ fn main() -> Result<()> { run_antigravity_command(subcommand) } Some(Commands::Usage { json, light }) => { + reject_unsupported_home_override(&cli.home, "usage")?; commands::usage::run(json, light) } Some(Commands::DeleteSubmittedData) => { diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 4eea9b71d..6d061a9e0 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -285,7 +285,7 @@ impl App { dialog_needs_reload, hourly_view_mode: HourlyViewMode::default(), model_shade_map: HashMap::new(), - subscription_usage: Vec::new(), + subscription_usage: crate::commands::usage::load_cache().unwrap_or_default(), }; app.build_model_shade_map(); Ok(app) @@ -498,6 +498,7 @@ impl App { pub fn fetch_subscription_usage(&mut self) { self.subscription_usage = crate::commands::usage::fetch_all(); if !self.subscription_usage.is_empty() { + crate::commands::usage::save_cache(&self.subscription_usage); self.status_message = Some("Usage data loaded".into()); } else { self.status_message = Some("No usage data available".into()); @@ -1124,18 +1125,20 @@ mod tests { #[test] fn test_tab_all() { let tabs = Tab::all(); - assert_eq!(tabs.len(), 6); + assert_eq!(tabs.len(), 7); assert_eq!(tabs[0], Tab::Overview); - assert_eq!(tabs[1], Tab::Models); - assert_eq!(tabs[2], Tab::Daily); - assert_eq!(tabs[3], Tab::Hourly); - assert_eq!(tabs[4], Tab::Stats); - assert_eq!(tabs[5], Tab::Agents); + assert_eq!(tabs[1], Tab::Usage); + assert_eq!(tabs[2], Tab::Models); + assert_eq!(tabs[3], Tab::Daily); + assert_eq!(tabs[4], Tab::Hourly); + assert_eq!(tabs[5], Tab::Stats); + assert_eq!(tabs[6], Tab::Agents); } #[test] fn test_tab_next() { - assert_eq!(Tab::Overview.next(), Tab::Models); + assert_eq!(Tab::Overview.next(), Tab::Usage); + assert_eq!(Tab::Usage.next(), Tab::Models); assert_eq!(Tab::Models.next(), Tab::Daily); assert_eq!(Tab::Daily.next(), Tab::Hourly); assert_eq!(Tab::Hourly.next(), Tab::Stats); @@ -1146,7 +1149,8 @@ mod tests { #[test] fn test_tab_prev() { assert_eq!(Tab::Overview.prev(), Tab::Agents); - assert_eq!(Tab::Models.prev(), Tab::Overview); + assert_eq!(Tab::Usage.prev(), Tab::Overview); + assert_eq!(Tab::Models.prev(), Tab::Usage); assert_eq!(Tab::Daily.prev(), Tab::Models); assert_eq!(Tab::Hourly.prev(), Tab::Daily); assert_eq!(Tab::Stats.prev(), Tab::Hourly); @@ -1467,6 +1471,9 @@ mod tests { let mut app = make_app(); assert_eq!(app.current_tab, Tab::Overview); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.current_tab, Tab::Usage); + app.handle_key_event(key(KeyCode::Tab)); assert_eq!(app.current_tab, Tab::Models); @@ -1505,6 +1512,12 @@ mod tests { app.handle_key_event(key(KeyCode::BackTab)); assert_eq!(app.current_tab, Tab::Models); + + app.handle_key_event(key(KeyCode::BackTab)); + assert_eq!(app.current_tab, Tab::Usage); + + app.handle_key_event(key(KeyCode::BackTab)); + assert_eq!(app.current_tab, Tab::Overview); } #[test] @@ -1586,11 +1599,14 @@ mod tests { #[test] fn test_handle_key_left_right_switch() { let mut app = make_app(); + app.handle_key_event(key(KeyCode::Right)); + assert_eq!(app.current_tab, Tab::Usage); + app.handle_key_event(key(KeyCode::Right)); assert_eq!(app.current_tab, Tab::Models); app.handle_key_event(key(KeyCode::Left)); - assert_eq!(app.current_tab, Tab::Overview); + assert_eq!(app.current_tab, Tab::Usage); } #[test] diff --git a/crates/tokscale-cli/src/tui/ui/usage.rs b/crates/tokscale-cli/src/tui/ui/usage.rs index 5d64935d2..6015763b2 100644 --- a/crates/tokscale-cli/src/tui/ui/usage.rs +++ b/crates/tokscale-cli/src/tui/ui/usage.rs @@ -53,6 +53,8 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { if app.subscription_usage.is_empty() { render_loading(frame, app, inner); + } else if app.subscription_usage.iter().all(|o| o.metrics.is_empty()) { + render_empty(frame, app, inner); } else { render_loaded(frame, app, inner, &app.subscription_usage); } @@ -79,6 +81,22 @@ fn render_loading(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(paragraph, center); } +fn render_empty(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 paragraph = Paragraph::new("No subscription data available") + .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(); @@ -102,7 +120,7 @@ fn render_loaded(frame: &mut Frame, app: &App, area: Rect, outputs: &[crate::com .unwrap_or_default(); let label = Span::styled( - format!(" {:<12}", m.label), + format!(" {:<14}", m.label), Style::default().fg(app.theme.foreground), ); let value = Span::styled(