From c23648ecd5fc4a057b88dd6898b8f0ccd353c169 Mon Sep 17 00:00:00 2001 From: handlecusion Date: Thu, 28 May 2026 22:28:20 +0900 Subject: [PATCH 1/2] Add agent OAuth usage dashboard --- src-tauri/Cargo.lock | 194 ++++- src-tauri/Cargo.toml | 2 + src-tauri/src/agent_usage.rs | 1247 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 7 + src/App.tsx | 174 ++-- src/components/AgentLimitsCard.tsx | 134 +++ src/components/DashboardTabs.tsx | 66 ++ src/components/HeaderBar.tsx | 8 +- src/components/UsageBarGraph2D.tsx | 231 ++++++ src/components/UsageTraceCard.tsx | 5 +- src/hooks/useAgentUsage.ts | 33 + src/lib/agentUsage.ts | 32 + src/styles.css | 370 +++++++++ 13 files changed, 2396 insertions(+), 107 deletions(-) create mode 100644 src-tauri/src/agent_usage.rs create mode 100644 src/components/AgentLimitsCard.tsx create mode 100644 src/components/DashboardTabs.tsx create mode 100644 src/components/UsageBarGraph2D.tsx create mode 100644 src/hooks/useAgentUsage.ts create mode 100644 src/lib/agentUsage.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4026ca2..fb7ac82 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -164,6 +164,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.21.7" @@ -352,6 +374,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -388,6 +412,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -402,6 +432,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -988,6 +1027,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -1175,8 +1220,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1186,9 +1233,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1859,6 +1908,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.97" @@ -2028,6 +2087,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2526,7 +2591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand", + "rand 0.8.6", ] [[package]] @@ -2670,6 +2735,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2763,6 +2837,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2790,7 +2920,27 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -2799,6 +2949,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2914,6 +3073,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3019,6 +3179,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -3045,6 +3206,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -3081,6 +3243,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4109,10 +4272,26 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokcat" version = "0.1.23" dependencies = [ + "base64 0.22.1", "chrono", "env_logger", "image", @@ -4125,6 +4304,7 @@ dependencies = [ "objc2-quartz-core", "once_cell", "parking_lot", + "reqwest", "rusqlite", "serde", "serde_json", @@ -4724,6 +4904,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e5168f3..74d7960 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,8 @@ tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-pn tauri-plugin-autostart = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } +base64 = "0.22" tokio = { version = "1", features = ["full"] } parking_lot = "0.12" once_cell = "1" diff --git a/src-tauri/src/agent_usage.rs b/src-tauri/src/agent_usage.rs new file mode 100644 index 0000000..e777ace --- /dev/null +++ b/src-tauri/src/agent_usage.rs @@ -0,0 +1,1247 @@ +use chrono::{DateTime, SecondsFormat, TimeZone, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; + +const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage"; +const CODEX_REFRESH_URL: &str = "https://auth.openai.com/oauth/token"; +const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const CLAUDE_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +const CLAUDE_REFRESH_URL: &str = "https://platform.claude.com/v1/oauth/token"; +const CLAUDE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const CLAUDE_KEYCHAIN_SERVICE: &str = "Claude Code-credentials"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsagePayload { + generated_at: String, + agents: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsageSnapshot { + client_id: String, + source: String, + updated_at: String, + identity: Option, + windows: Vec, + credits: Option, + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentIdentity { + email: Option, + plan: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageWindow { + label: String, + used_percent: f64, + remaining_percent: f64, + resets_at: Option, + reset_text: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreditsSnapshot { + remaining: Option, + unlimited: bool, +} + +#[derive(Debug, Clone)] +struct CodexCredentials { + access_token: String, + refresh_token: Option, + id_token: Option, + account_id: Option, + last_refresh: Option>, + auth_path: PathBuf, + raw_json: Value, +} + +#[derive(Debug, Clone)] +struct ClaudeCredentials { + access_token: String, + refresh_token: Option, + expires_at: Option>, + scopes: Vec, + rate_limit_tier: Option, + subscription_type: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeCredentialsRoot { + #[serde(default, rename = "claudeAiOauth")] + claude_ai_oauth: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClaudeCredentialsOauth { + access_token: Option, + refresh_token: Option, + expires_at: Option, + scopes: Option>, + rate_limit_tier: Option, + subscription_type: Option, +} + +#[derive(Debug, Deserialize)] +struct CodexUsageResponse { + #[serde(default)] + plan_type: Option, + #[serde(default)] + rate_limit: Option, + #[serde(default)] + additional_rate_limits: Option>, + #[serde(default)] + credits: Option, +} + +#[derive(Debug, Deserialize)] +struct CodexRateLimit { + #[serde(default)] + primary_window: Option, + #[serde(default)] + secondary_window: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct CodexWindow { + used_percent: f64, + reset_at: i64, + limit_window_seconds: i64, +} + +#[derive(Debug, Deserialize)] +struct CodexAdditionalRateLimit { + #[serde(default)] + limit_name: Option, + #[serde(default)] + metered_feature: Option, + #[serde(default)] + rate_limit: Option, +} + +#[derive(Debug, Deserialize)] +struct CodexCredits { + #[serde(default)] + unlimited: bool, + #[serde(default, deserialize_with = "deserialize_optional_f64")] + balance: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ClaudeUsageResponse { + #[serde(default)] + five_hour: Option, + #[serde(default)] + seven_day: Option, + #[serde(default)] + seven_day_oauth_apps: Option, + #[serde(default)] + seven_day_opus: Option, + #[serde(default)] + seven_day_sonnet: Option, + #[serde(default)] + seven_day_design: Option, + #[serde(default)] + seven_day_claude_design: Option, + #[serde(default)] + claude_design: Option, + #[serde(default)] + design: Option, + #[serde(default)] + seven_day_omelette: Option, + #[serde(default)] + omelette: Option, + #[serde(default)] + omelette_promotional: Option, + #[serde(default)] + seven_day_routines: Option, + #[serde(default)] + seven_day_claude_routines: Option, + #[serde(default)] + claude_routines: Option, + #[serde(default)] + routines: Option, + #[serde(default)] + routine: Option, + #[serde(default)] + seven_day_cowork: Option, + #[serde(default)] + cowork: Option, + #[serde(default)] + extra_usage: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ClaudeWindow { + #[serde(default, deserialize_with = "deserialize_optional_f64")] + utilization: Option, + #[serde(default)] + resets_at: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeExtraUsage { + #[serde(default)] + is_enabled: bool, + #[serde(default, deserialize_with = "deserialize_optional_f64")] + monthly_limit: Option, + #[serde(default, deserialize_with = "deserialize_optional_f64")] + used_credits: Option, + #[serde(default, deserialize_with = "deserialize_optional_f64")] + utilization: Option, + #[serde(default)] + currency: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeRefreshResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + expires_in: i64, +} + +pub async fn run() -> AgentUsagePayload { + let generated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + AgentUsagePayload { + generated_at, + agents: vec![fetch_codex().await, fetch_claude().await], + } +} + +async fn fetch_codex() -> AgentUsageSnapshot { + match fetch_codex_inner().await { + Ok(snapshot) => snapshot, + Err(error) => AgentUsageSnapshot { + client_id: "codex".to_string(), + source: "oauth".to_string(), + updated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + identity: None, + windows: Vec::new(), + credits: None, + error: Some(error), + }, + } +} + +async fn fetch_claude() -> AgentUsageSnapshot { + match fetch_claude_inner().await { + Ok(snapshot) => snapshot, + Err(error) => AgentUsageSnapshot { + client_id: "claude".to_string(), + source: "oauth".to_string(), + updated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + identity: None, + windows: Vec::new(), + credits: None, + error: Some(error), + }, + } +} + +async fn fetch_codex_inner() -> Result { + let mut credentials = load_codex_credentials()?; + if credentials_needs_refresh(credentials.last_refresh) { + if credentials + .refresh_token + .as_deref() + .unwrap_or("") + .is_empty() + { + return Err( + "Codex OAuth token needs refresh but auth.json has no refresh token.".to_string(), + ); + } + credentials = refresh_codex_credentials(credentials).await?; + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("build Codex OAuth client: {}", e))?; + + let mut request = client + .get(CODEX_USAGE_URL) + .bearer_auth(&credentials.access_token) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, "Tokcat"); + if let Some(account_id) = credentials.account_id.as_deref().filter(|s| !s.is_empty()) { + request = request.header("ChatGPT-Account-Id", account_id); + } + + let response = request + .send() + .await + .map_err(|e| format!("Codex OAuth request failed: {}", e))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Codex OAuth response: {}", e))?; + + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err( + "Codex OAuth token expired or invalid. Run `codex` to log in again.".to_string(), + ); + } + if !status.is_success() { + return Err(format!("Codex usage API returned {}.", status.as_u16())); + } + + let usage: CodexUsageResponse = + serde_json::from_str(&body).map_err(|e| format!("decode Codex usage response: {}", e))?; + let now = Utc::now(); + let identity = Some(AgentIdentity { + email: credentials.id_token.as_deref().and_then(jwt_email), + plan: usage.plan_type.as_deref().map(clean_plan).or_else(|| { + credentials + .id_token + .as_deref() + .and_then(jwt_plan) + .map(clean_plan) + }), + }); + let windows = codex_windows( + usage.rate_limit.as_ref(), + usage.additional_rate_limits.as_deref(), + now, + ); + if windows.is_empty() && usage.credits.as_ref().and_then(|c| c.balance).is_none() { + return Err("Codex usage API returned no rate-limit windows.".to_string()); + } + + Ok(AgentUsageSnapshot { + client_id: "codex".to_string(), + source: "oauth".to_string(), + updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), + identity, + windows, + credits: usage.credits.map(|credits| CreditsSnapshot { + remaining: credits.balance, + unlimited: credits.unlimited, + }), + error: None, + }) +} + +async fn fetch_claude_inner() -> Result { + let mut credentials = load_claude_credentials()?; + if claude_credentials_expired(&credentials) { + credentials = refresh_claude_credentials(&credentials).await?; + } + + if !credentials.scopes.is_empty() + && !credentials + .scopes + .iter() + .any(|scope| scope == "user:profile") + { + return Err( + "Claude OAuth token lacks the user:profile scope. Run `claude logout && claude login`." + .to_string(), + ); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("build Claude OAuth client: {}", e))?; + + let response = client + .get(CLAUDE_USAGE_URL) + .bearer_auth(&credentials.access_token) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::USER_AGENT, claude_user_agent()) + .header("anthropic-beta", "oauth-2025-04-20") + .send() + .await + .map_err(|e| format!("Claude OAuth request failed: {}", e))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Claude OAuth response: {}", e))?; + + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err( + "Claude OAuth token expired or invalid. Run `claude` to re-authenticate.".to_string(), + ); + } + if status == reqwest::StatusCode::FORBIDDEN { + return Err( + "Claude OAuth usage was denied. Run `claude logout && claude login` to grant user:profile." + .to_string(), + ); + } + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + return Err( + "Claude OAuth usage endpoint is rate limited. Try Refresh again later.".to_string(), + ); + } + if !status.is_success() { + return Err(format!("Claude usage API returned {}.", status.as_u16())); + } + + let usage: ClaudeUsageResponse = + serde_json::from_str(&body).map_err(|e| format!("decode Claude usage response: {}", e))?; + let now = Utc::now(); + let windows = claude_windows(&usage, now); + if windows.is_empty() { + return Err("Claude usage API returned no rate-limit windows.".to_string()); + } + + Ok(AgentUsageSnapshot { + client_id: "claude".to_string(), + source: "oauth".to_string(), + updated_at: now.to_rfc3339_opts(SecondsFormat::Millis, true), + identity: Some(AgentIdentity { + email: None, + plan: first_non_empty([ + credentials.subscription_type.as_deref(), + credentials.rate_limit_tier.as_deref(), + ]) + .map(clean_plan), + }), + windows, + credits: claude_credits(usage.extra_usage.as_ref()), + error: None, + }) +} + +fn load_codex_credentials() -> Result { + let auth_path = codex_home().join("auth.json"); + let raw = fs::read_to_string(&auth_path) + .map_err(|_| "Codex auth.json not found. Run `codex` to log in.".to_string())?; + let raw_json: Value = + serde_json::from_str(&raw).map_err(|e| format!("decode Codex auth.json: {}", e))?; + + if raw_json + .get("OPENAI_API_KEY") + .and_then(Value::as_str) + .is_some_and(|key| !key.trim().is_empty()) + { + return Err( + "Codex is using API-key auth; OAuth usage limits require `codex login`.".to_string(), + ); + } + + let tokens = raw_json + .get("tokens") + .and_then(Value::as_object) + .ok_or_else(|| "Codex auth.json exists but contains no OAuth tokens.".to_string())?; + let access_token = string_key(tokens, "access_token", "accessToken") + .ok_or_else(|| "Codex auth.json has no access token.".to_string())?; + let refresh_token = string_key(tokens, "refresh_token", "refreshToken"); + let id_token = string_key(tokens, "id_token", "idToken"); + let account_id = string_key(tokens, "account_id", "accountId"); + let last_refresh = raw_json + .get("last_refresh") + .and_then(Value::as_str) + .and_then(parse_datetime); + + Ok(CodexCredentials { + access_token, + refresh_token, + id_token, + account_id, + last_refresh, + auth_path, + raw_json, + }) +} + +fn load_claude_credentials() -> Result { + if let Some(credentials) = load_claude_credentials_from_environment()? { + return Ok(credentials); + } + + let credentials_path = claude_credentials_path(); + match fs::read_to_string(&credentials_path) { + Ok(raw) => return parse_claude_credentials_data(&raw), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "read Claude credentials file {}: {}", + credentials_path.display(), + error + )); + } + } + + if let Some(raw) = load_claude_credentials_from_keychain()? { + return parse_claude_credentials_data(&raw); + } + + Err("Claude OAuth credentials not found. Run `claude` to authenticate.".to_string()) +} + +fn load_claude_credentials_from_environment() -> Result, String> { + let token = std::env::var("TOKCAT_CLAUDE_OAUTH_TOKEN") + .or_else(|_| std::env::var("CODEXBAR_CLAUDE_OAUTH_TOKEN")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let Some(access_token) = token else { + return Ok(None); + }; + let scopes = std::env::var("TOKCAT_CLAUDE_OAUTH_SCOPES") + .or_else(|_| std::env::var("CODEXBAR_CLAUDE_OAUTH_SCOPES")) + .unwrap_or_default() + .split([',', ' ']) + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .map(str::to_string) + .collect(); + Ok(Some(ClaudeCredentials { + access_token, + refresh_token: None, + expires_at: None, + scopes, + rate_limit_tier: None, + subscription_type: None, + })) +} + +fn parse_claude_credentials_data(raw: &str) -> Result { + let root: ClaudeCredentialsRoot = + serde_json::from_str(raw).map_err(|e| format!("decode Claude OAuth credentials: {}", e))?; + let oauth = root + .claude_ai_oauth + .ok_or_else(|| "Claude OAuth credentials are missing claudeAiOauth.".to_string())?; + let access_token = oauth + .access_token + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) + .ok_or_else(|| "Claude OAuth credentials have no access token.".to_string())?; + let expires_at = oauth + .expires_at + .and_then(|millis| Utc.timestamp_millis_opt(millis as i64).single()); + Ok(ClaudeCredentials { + access_token, + refresh_token: oauth + .refresh_token + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()), + expires_at, + scopes: oauth.scopes.unwrap_or_default(), + rate_limit_tier: oauth.rate_limit_tier, + subscription_type: oauth.subscription_type, + }) +} + +#[cfg(target_os = "macos")] +fn load_claude_credentials_from_keychain() -> Result, String> { + let output = std::process::Command::new("/usr/bin/security") + .args(["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"]) + .output() + .map_err(|e| format!("read Claude Keychain credentials: {}", e))?; + if !output.status.success() { + return Ok(None); + } + let raw = String::from_utf8(output.stdout) + .map_err(|_| "Claude Keychain credentials are not UTF-8 JSON.".to_string())?; + let raw = raw.trim_matches(['\r', '\n']).to_string(); + if raw.trim().is_empty() { + return Ok(None); + } + Ok(Some(raw)) +} + +#[cfg(not(target_os = "macos"))] +fn load_claude_credentials_from_keychain() -> Result, String> { + Ok(None) +} + +async fn refresh_codex_credentials( + credentials: CodexCredentials, +) -> Result { + let refresh_token = credentials + .refresh_token + .as_deref() + .ok_or_else(|| "Codex auth.json has no refresh token.".to_string())?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("build Codex refresh client: {}", e))?; + let body = serde_json::json!({ + "client_id": CODEX_CLIENT_ID, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": "openid profile email" + }); + let response = client + .post(CODEX_REFRESH_URL) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("Codex token refresh failed: {}", e))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Codex refresh response: {}", e))?; + if !status.is_success() { + return Err("Codex OAuth refresh failed. Run `codex` to log in again.".to_string()); + } + let json: Value = + serde_json::from_str(&body).map_err(|e| format!("decode Codex refresh response: {}", e))?; + + let refreshed = CodexCredentials { + access_token: json + .get("access_token") + .and_then(Value::as_str) + .unwrap_or(&credentials.access_token) + .to_string(), + refresh_token: json + .get("refresh_token") + .and_then(Value::as_str) + .map(str::to_string) + .or(credentials.refresh_token), + id_token: json + .get("id_token") + .and_then(Value::as_str) + .map(str::to_string) + .or(credentials.id_token), + account_id: credentials.account_id, + last_refresh: Some(Utc::now()), + auth_path: credentials.auth_path, + raw_json: credentials.raw_json, + }; + save_codex_credentials(&refreshed)?; + Ok(refreshed) +} + +async fn refresh_claude_credentials( + credentials: &ClaudeCredentials, +) -> Result { + let refresh_token = credentials.refresh_token.as_deref().ok_or_else(|| { + "Claude OAuth token is expired and has no refresh token. Run `claude`.".to_string() + })?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("build Claude refresh client: {}", e))?; + let response = client + .post(CLAUDE_REFRESH_URL) + .header(reqwest::header::ACCEPT, "application/json") + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(form_urlencoded(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", CLAUDE_CLIENT_ID), + ])) + .send() + .await + .map_err(|e| format!("Claude OAuth refresh failed: {}", e))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Claude refresh response: {}", e))?; + if !status.is_success() { + return Err("Claude OAuth refresh failed. Run `claude` to re-authenticate.".to_string()); + } + let token_response: ClaudeRefreshResponse = serde_json::from_str(&body) + .map_err(|e| format!("decode Claude refresh response: {}", e))?; + Ok(ClaudeCredentials { + access_token: token_response.access_token, + refresh_token: token_response + .refresh_token + .or_else(|| credentials.refresh_token.clone()), + expires_at: Some(Utc::now() + chrono::Duration::seconds(token_response.expires_in)), + scopes: credentials.scopes.clone(), + rate_limit_tier: credentials.rate_limit_tier.clone(), + subscription_type: credentials.subscription_type.clone(), + }) +} + +fn save_codex_credentials(credentials: &CodexCredentials) -> Result<(), String> { + let mut raw = credentials.raw_json.clone(); + raw["tokens"]["access_token"] = Value::String(credentials.access_token.clone()); + if let Some(refresh_token) = &credentials.refresh_token { + raw["tokens"]["refresh_token"] = Value::String(refresh_token.clone()); + } + if let Some(id_token) = &credentials.id_token { + raw["tokens"]["id_token"] = Value::String(id_token.clone()); + } + if let Some(account_id) = &credentials.account_id { + raw["tokens"]["account_id"] = Value::String(account_id.clone()); + } + raw["last_refresh"] = Value::String(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)); + let data = + serde_json::to_vec_pretty(&raw).map_err(|e| format!("encode Codex auth.json: {}", e))?; + fs::write(&credentials.auth_path, data).map_err(|e| format!("save Codex auth.json: {}", e)) +} + +fn codex_windows( + rate_limit: Option<&CodexRateLimit>, + additional_rate_limits: Option<&[CodexAdditionalRateLimit]>, + now: DateTime, +) -> Vec { + let mut windows = Vec::new(); + if let Some(rate_limit) = rate_limit { + let mut primary = rate_limit.primary_window.clone(); + let mut secondary = rate_limit.secondary_window.clone(); + if role(primary.as_ref()) == Some("weekly") && role(secondary.as_ref()) != Some("weekly") { + std::mem::swap(&mut primary, &mut secondary); + } + + if let Some(window) = primary { + windows.push(map_window("Session", window, now)); + } + if let Some(window) = secondary { + windows.push(map_window("Weekly", window, now)); + } + } + + let mut seen = windows + .iter() + .map(|w| w.label.clone()) + .collect::>(); + for extra in additional_rate_limits.unwrap_or(&[]) { + let Some(rate_limit) = extra.rate_limit.as_ref() else { + continue; + }; + let Some(window) = rate_limit + .primary_window + .clone() + .or_else(|| rate_limit.secondary_window.clone()) + else { + continue; + }; + let label = additional_limit_label(extra); + if seen.insert(label.clone()) { + windows.push(map_window(&label, window, now)); + } + } + windows +} + +fn claude_windows(usage: &ClaudeUsageResponse, now: DateTime) -> Vec { + let mut windows = Vec::new(); + push_claude_window(&mut windows, "Session", usage.five_hour.as_ref(), now); + push_claude_window(&mut windows, "Weekly", usage.seven_day.as_ref(), now); + push_claude_window( + &mut windows, + "OAuth Apps", + usage.seven_day_oauth_apps.as_ref(), + now, + ); + push_claude_window(&mut windows, "Sonnet", usage.seven_day_sonnet.as_ref(), now); + push_claude_window(&mut windows, "Opus", usage.seven_day_opus.as_ref(), now); + push_claude_window(&mut windows, "Designs", usage.design_window(), now); + push_claude_window(&mut windows, "Daily Routines", usage.routines_window(), now); + if let Some(extra) = claude_extra_usage_window(usage.extra_usage.as_ref()) { + windows.push(extra); + } + windows +} + +impl ClaudeUsageResponse { + fn design_window(&self) -> Option<&ClaudeWindow> { + [ + self.seven_day_design.as_ref(), + self.seven_day_claude_design.as_ref(), + self.claude_design.as_ref(), + self.design.as_ref(), + self.seven_day_omelette.as_ref(), + self.omelette.as_ref(), + self.omelette_promotional.as_ref(), + ] + .into_iter() + .flatten() + .next() + } + + fn routines_window(&self) -> Option<&ClaudeWindow> { + [ + self.seven_day_routines.as_ref(), + self.seven_day_claude_routines.as_ref(), + self.claude_routines.as_ref(), + self.routines.as_ref(), + self.routine.as_ref(), + self.seven_day_cowork.as_ref(), + self.cowork.as_ref(), + ] + .into_iter() + .flatten() + .next() + } +} + +fn push_claude_window( + windows: &mut Vec, + label: &str, + window: Option<&ClaudeWindow>, + now: DateTime, +) { + if let Some(mapped) = window.and_then(|window| map_claude_window(label, window, now)) { + windows.push(mapped); + } +} + +fn map_claude_window( + label: &str, + window: &ClaudeWindow, + now: DateTime, +) -> Option { + let used = window.utilization?.clamp(0.0, 100.0); + let resets_at = window.resets_at.as_deref().and_then(parse_datetime); + Some(UsageWindow { + label: label.to_string(), + used_percent: used, + remaining_percent: (100.0 - used).max(0.0), + resets_at: resets_at.map(|date| date.to_rfc3339_opts(SecondsFormat::Millis, true)), + reset_text: resets_at.map(|date| reset_text(date, now)), + }) +} + +fn claude_extra_usage_window(extra: Option<&ClaudeExtraUsage>) -> Option { + let extra = extra?; + if !extra.is_enabled { + return None; + } + let used = extra.utilization.or_else(|| { + let used = extra.used_credits?; + let limit = extra.monthly_limit?; + if limit > 0.0 { + Some((used / limit) * 100.0) + } else { + None + } + })?; + let reset_text = match (extra.used_credits, extra.monthly_limit) { + (Some(used), Some(limit)) => Some(format!( + "Monthly cap: {} / {}", + format_currency_minor_units(used, extra.currency.as_deref()), + format_currency_minor_units(limit, extra.currency.as_deref()) + )), + _ => None, + }; + Some(UsageWindow { + label: "Extra usage".to_string(), + used_percent: used.clamp(0.0, 100.0), + remaining_percent: (100.0 - used).max(0.0), + resets_at: None, + reset_text, + }) +} + +fn claude_credits(extra: Option<&ClaudeExtraUsage>) -> Option { + let extra = extra?; + if !extra.is_enabled { + return None; + } + let remaining = match (extra.monthly_limit, extra.used_credits) { + (Some(limit), Some(used)) => Some(((limit - used) / 100.0).max(0.0)), + _ => None, + }; + Some(CreditsSnapshot { + remaining, + unlimited: false, + }) +} + +fn format_currency_minor_units(value: f64, currency: Option<&str>) -> String { + let major = value / 100.0; + match currency.unwrap_or("USD").trim().to_uppercase().as_str() { + "USD" => format!("${:.2}", major), + code if !code.is_empty() => format!("{:.2} {}", major, code), + _ => format!("${:.2}", major), + } +} + +fn additional_limit_label(limit: &CodexAdditionalRateLimit) -> String { + let source = first_non_empty([ + limit.limit_name.as_deref(), + limit.metered_feature.as_deref(), + ]) + .unwrap_or("Codex extra limit"); + let lower = source.to_lowercase(); + if lower.contains("spark") { + return "Codex Spark".to_string(); + } + clean_limit_label(source) +} + +fn first_non_empty(values: [Option<&str>; 2]) -> Option<&str> { + values + .into_iter() + .flatten() + .map(str::trim) + .find(|value| !value.is_empty()) +} + +fn clean_limit_label(value: &str) -> String { + value + .replace(['_', '-'], " ") + .split_whitespace() + .map(|part| { + if part.eq_ignore_ascii_case("gpt") { + "GPT".to_string() + } else if part.eq_ignore_ascii_case("codex") { + "Codex".to_string() + } else { + let mut chars = part.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } + } + }) + .collect::>() + .join(" ") +} + +fn map_window(label: &str, window: CodexWindow, now: DateTime) -> UsageWindow { + let resets_at = if window.reset_at > 0 { + Utc.timestamp_opt(window.reset_at, 0).single() + } else { + None + }; + let used = window.used_percent.clamp(0.0, 100.0); + UsageWindow { + label: label.to_string(), + used_percent: used, + remaining_percent: (100.0 - used).max(0.0), + resets_at: resets_at.map(|date| date.to_rfc3339_opts(SecondsFormat::Millis, true)), + reset_text: resets_at.map(|date| reset_text(date, now)), + } +} + +fn role(window: Option<&CodexWindow>) -> Option<&'static str> { + match window?.limit_window_seconds { + 18_000 => Some("session"), + 604_800 => Some("weekly"), + _ => None, + } +} + +fn reset_text(reset: DateTime, now: DateTime) -> String { + let seconds = (reset - now).num_seconds(); + if seconds <= 0 { + return "Resets now".to_string(); + } + let minutes = (seconds + 59) / 60; + if minutes < 60 { + return format!("Resets in {}m", minutes); + } + let hours = minutes / 60; + let mins = minutes % 60; + if hours < 48 { + if mins > 0 { + return format!("Resets in {}h {}m", hours, mins); + } + return format!("Resets in {}h", hours); + } + let days = hours / 24; + let rem_hours = hours % 24; + if rem_hours > 0 { + format!("Resets in {}d {}h", days, rem_hours) + } else { + format!("Resets in {}d", days) + } +} + +fn codex_home() -> PathBuf { + std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex"))) + .unwrap_or_else(|| PathBuf::from(".codex")) +} + +fn claude_credentials_path() -> PathBuf { + std::env::var_os("HOME") + .map(|home| PathBuf::from(home).join(".claude/.credentials.json")) + .unwrap_or_else(|| PathBuf::from(".claude/.credentials.json")) +} + +fn credentials_needs_refresh(last_refresh: Option>) -> bool { + let Some(last_refresh) = last_refresh else { + return true; + }; + (Utc::now() - last_refresh).num_days() > 8 +} + +fn claude_credentials_expired(credentials: &ClaudeCredentials) -> bool { + credentials + .expires_at + .is_some_and(|expires_at| Utc::now() >= expires_at) +} + +fn parse_datetime(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .map(|dt| dt.with_timezone(&Utc)) + .ok() +} + +fn claude_user_agent() -> String { + std::process::Command::new("claude") + .arg("--version") + .output() + .ok() + .and_then(|output| { + if output.status.success() { + String::from_utf8(output.stdout).ok() + } else { + None + } + }) + .and_then(|stdout| stdout.split_whitespace().next().map(str::to_string)) + .filter(|version| !version.is_empty()) + .map(|version| format!("claude-code/{}", version)) + .unwrap_or_else(|| "claude-code/2.1.0".to_string()) +} + +fn form_urlencoded(params: &[(&str, &str)]) -> String { + params + .iter() + .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value))) + .collect::>() + .join("&") +} + +fn percent_encode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + b' ' => encoded.push('+'), + _ => encoded.push_str(&format!("%{:02X}", byte)), + } + } + encoded +} + +fn string_key( + map: &serde_json::Map, + snake_case: &str, + camel_case: &str, +) -> Option { + map.get(snake_case) + .or_else(|| map.get(camel_case)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn jwt_payload(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let mut encoded = payload.replace('-', "+").replace('_', "/"); + while encoded.len() % 4 != 0 { + encoded.push('='); + } + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + serde_json::from_slice(&data).ok() +} + +fn jwt_email(token: &str) -> Option { + let payload = jwt_payload(token)?; + payload + .get("email") + .and_then(Value::as_str) + .or_else(|| { + payload + .get("https://api.openai.com/profile") + .and_then(Value::as_object) + .and_then(|profile| profile.get("email")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn jwt_plan(token: &str) -> Option { + let payload = jwt_payload(token)?; + payload + .get("chatgpt_plan_type") + .and_then(Value::as_str) + .or_else(|| { + payload + .get("https://api.openai.com/auth") + .and_then(Value::as_object) + .and_then(|auth| auth.get("chatgpt_plan_type")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn clean_plan(value: impl AsRef) -> String { + value + .as_ref() + .split(['_', '-']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn deserialize_optional_f64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(Value::Number(n)) => n.as_f64(), + Some(Value::String(s)) => s.parse::().ok(), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_codex_primary_and_secondary_windows() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let rate_limit = CodexRateLimit { + primary_window: Some(CodexWindow { + used_percent: 8.0, + reset_at: 1_700_005_400, + limit_window_seconds: 18_000, + }), + secondary_window: Some(CodexWindow { + used_percent: 35.0, + reset_at: 1_700_172_800, + limit_window_seconds: 604_800, + }), + }; + let windows = codex_windows(Some(&rate_limit), None, now); + assert_eq!(windows.len(), 2); + assert_eq!(windows[0].label, "Session"); + assert_eq!(windows[0].remaining_percent, 92.0); + assert_eq!(windows[1].label, "Weekly"); + assert_eq!(windows[1].remaining_percent, 65.0); + } + + #[test] + fn maps_codex_additional_model_limits() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let extra = CodexAdditionalRateLimit { + limit_name: Some("gpt-5.2-codex-spark".to_string()), + metered_feature: None, + rate_limit: Some(CodexRateLimit { + primary_window: Some(CodexWindow { + used_percent: 41.0, + reset_at: 1_700_003_600, + limit_window_seconds: 18_000, + }), + secondary_window: None, + }), + }; + let windows = codex_windows(None, Some(&[extra]), now); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].label, "Codex Spark"); + assert_eq!(windows[0].remaining_percent, 59.0); + } + + #[test] + fn parses_claude_credentials_file() { + let raw = r#"{ + "claudeAiOauth": { + "accessToken": "access", + "refreshToken": "refresh", + "expiresAt": 1700000000000, + "scopes": ["user:profile"], + "rateLimitTier": "max", + "subscriptionType": "pro" + } + }"#; + let credentials = parse_claude_credentials_data(raw).unwrap(); + assert_eq!(credentials.access_token, "access"); + assert_eq!(credentials.refresh_token.as_deref(), Some("refresh")); + assert_eq!(credentials.scopes, vec!["user:profile"]); + assert_eq!(credentials.subscription_type.as_deref(), Some("pro")); + } + + #[test] + fn maps_claude_oauth_windows() { + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let usage = ClaudeUsageResponse { + five_hour: Some(ClaudeWindow { + utilization: Some(8.0), + resets_at: Some("2023-11-14T23:13:20Z".to_string()), + }), + seven_day: Some(ClaudeWindow { + utilization: Some(23.0), + resets_at: Some("2023-11-17T22:13:20Z".to_string()), + }), + seven_day_oauth_apps: None, + seven_day_opus: None, + seven_day_sonnet: Some(ClaudeWindow { + utilization: Some(3.0), + resets_at: None, + }), + seven_day_design: Some(ClaudeWindow { + utilization: Some(0.0), + resets_at: None, + }), + seven_day_routines: None, + extra_usage: None, + ..Default::default() + }; + let windows = claude_windows(&usage, now); + assert_eq!(windows.len(), 4); + assert_eq!(windows[0].label, "Session"); + assert_eq!(windows[0].remaining_percent, 92.0); + assert_eq!(windows[1].label, "Weekly"); + assert_eq!(windows[1].remaining_percent, 77.0); + assert_eq!(windows[2].label, "Sonnet"); + assert_eq!(windows[2].remaining_percent, 97.0); + assert_eq!(windows[3].label, "Designs"); + assert_eq!(windows[3].remaining_percent, 100.0); + } + + #[test] + fn decodes_claude_alias_windows_without_duplicate_error() { + let raw = r#"{ + "five_hour": { "utilization": 5, "resets_at": "2026-05-28T14:00:00Z" }, + "seven_day": { "utilization": 23, "resets_at": "2026-05-31T14:00:00Z" }, + "seven_day_sonnet": { "utilization": 3, "resets_at": null }, + "seven_day_omelette": { "utilization": 0, "resets_at": null }, + "omelette_promotional": { "utilization": 0, "resets_at": null }, + "seven_day_cowork": { "utilization": 0, "resets_at": null } + }"#; + let usage: ClaudeUsageResponse = serde_json::from_str(raw).unwrap(); + let now = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); + let windows = claude_windows(&usage, now); + assert_eq!( + windows.iter().map(|w| w.label.as_str()).collect::>(), + vec!["Session", "Weekly", "Sonnet", "Designs", "Daily Routines"] + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa59d30..3cb0e5f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod animation; +mod agent_usage; #[cfg(target_os = "macos")] mod native_tray; mod state; @@ -33,6 +34,11 @@ pub struct RateUpdate { pub trace: Vec, } +#[tauri::command] +async fn get_agent_usage() -> Result { + Ok(agent_usage::run().await) +} + #[tauri::command] async fn get_graph( year: String, @@ -312,6 +318,7 @@ pub fn run() { set_animation_style, get_usage_trace, get_tokens_per_min, + get_agent_usage, set_popover_height, tray::update_tray_title ]); diff --git a/src/App.tsx b/src/App.tsx index 84175a4..d943062 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,23 +1,22 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Panel } from './components/Panel' import { HeaderBar } from './components/HeaderBar' -import { FilterChips } from './components/FilterChips' -import { InnerCard } from './components/InnerCard' import { TokenUsageCard } from './components/TokenUsageCard' import { StreaksCard } from './components/StreaksCard' -import { ContributionGraph2D } from './components/ContributionGraph2D' -import { ContributionGraph3D } from './components/ContributionGraph3D' import { SettingsPanel } from './components/SettingsPanel' +import { AgentLimitsCard } from './components/AgentLimitsCard' +import { DashboardTabs } from './components/DashboardTabs' +import { UsageBarGraph2D } from './components/UsageBarGraph2D' import { useGraphStream } from './hooks/useGraphStream' +import { useAgentUsage } from './hooks/useAgentUsage' import { computeStats } from './lib/stats' -import { buildGrid } from './lib/grid' -import { formatCost } from './lib/format' import { isTauri } from './lib/runtime' import { computeTrayTitle, loadSettings, saveSettings, Settings } from './lib/settings' import { TraceBucket, RateUpdate } from './lib/usage' import { UsageTraceCard } from './components/UsageTraceCard' import { checkForUpdatesSilent, checkForUpdatesInteractive } from './lib/updater' import { getTheme, THEMES, ThemeName } from './lib/themes' +import { getClientStyle } from './lib/clients' const THEME_KEY = 'tokcat:theme:v1' @@ -35,22 +34,21 @@ function defaultYear(): string { export default function App() { const [year, setYear] = useState(defaultYear()) + const [refreshTick, setRefreshTick] = useState(0) const { payload, error } = useGraphStream(year) + const agentUsage = useAgentUsage(refreshTick) const [theme, setTheme] = useState(() => loadTheme()) const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)').matches : false ) - const [view, setView] = useState<'2D' | '3D'>('3D') - const [selected, setSelected] = useState | null>(null) + const [activeTab, setActiveTab] = useState('overview') const [settings, setSettings] = useState(() => loadSettings()) const [settingsOpen, setSettingsOpen] = useState(false) - const [knownClients, setKnownClients] = useState>(new Set()) const [aboutOpen, setAboutOpen] = useState(false) const [appVersion, setAppVersion] = useState('') - const [refreshTick, setRefreshTick] = useState(0) useEffect(() => { saveSettings(settings) @@ -150,48 +148,6 @@ export default function App() { })() }, [refreshTick, year]) - // Initialize / reconcile selected clients when payload arrives. - useEffect(() => { - if (!payload) return - const present = new Set() - for (const c of payload.contributions) for (const cc of c.clients) present.add(cc.client) - setSelected(prev => { - if (!prev) return new Set(present) - const next = new Set() - for (const id of present) { - if (knownClients.has(id)) { - if (prev.has(id)) next.add(id) - } else { - next.add(id) - } - } - if (next.size === prev.size) { - let same = true - for (const id of next) if (!prev.has(id)) { same = false; break } - if (same) return prev - } - return next - }) - setKnownClients(prev => { - let added = false - for (const id of present) if (!prev.has(id)) { added = true; break } - if (!added) return prev - const merged = new Set(prev) - for (const id of present) merged.add(id) - return merged - }) - }, [payload]) - - const stats = useMemo(() => { - if (!payload || !selected) return null - return computeStats(payload, selected) - }, [payload, selected]) - - const grid = useMemo(() => { - if (!stats) return null - return buildGrid(year, stats.perDayMap) - }, [stats, year]) - const allYears = useMemo(() => { if (!payload) return [year] return payload.years.map(y => y.year) @@ -204,14 +160,33 @@ export default function App() { return Array.from(set).sort() }, [payload]) - function toggleClient(id: string) { - setSelected(prev => { - const next = new Set(prev ?? []) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } + const dashboardClients = useMemo(() => { + const set = new Set(presentClients) + for (const agent of agentUsage.payload?.agents ?? []) set.add(agent.clientId) + return Array.from(set).sort() + }, [agentUsage.payload, presentClients]) + + useEffect(() => { + if (activeTab === 'overview') return + if (!dashboardClients.includes(activeTab)) setActiveTab('overview') + }, [activeTab, dashboardClients]) + + const overviewClientSet = useMemo(() => new Set(presentClients), [presentClients]) + const activeClientIds = useMemo( + () => (activeTab === 'overview' ? presentClients : [activeTab]), + [activeTab, presentClients], + ) + const activeClientSet = useMemo(() => new Set(activeClientIds), [activeClientIds]) + + const overviewStats = useMemo(() => { + if (!payload) return null + return computeStats(payload, overviewClientSet) + }, [overviewClientSet, payload]) + + const activeStats = useMemo(() => { + if (!payload) return null + return computeStats(payload, activeClientSet) + }, [activeClientSet, payload]) // Live tokens-per-minute + per-(client, agent, model) breakdown, pushed // by the backend's JSONL tailer every ~5s. No client-side diffing — the @@ -243,10 +218,10 @@ export default function App() { } }, []) - // Push tray title whenever stats, trayMode, or the rate changes (Tauri only). + // Push tray title from the all-agent overview, regardless of the visible tab. useEffect(() => { if (!isTauri()) return - const title = computeTrayTitle(settings.trayMode, stats, tokensPerMin) + const title = computeTrayTitle(settings.trayMode, overviewStats, tokensPerMin) ;(async () => { try { const { invoke } = await import('@tauri-apps/api/core') @@ -255,7 +230,7 @@ export default function App() { // ignore } })() - }, [stats, settings.trayMode, tokensPerMin]) + }, [overviewStats, settings.trayMode, tokensPerMin]) // Push animateTray flag to backend whenever it changes (Tauri only). useEffect(() => { @@ -321,7 +296,7 @@ export default function App() { ro.disconnect() if (unlistenShown) unlistenShown() } - }, [trace.length, stats?.totalTokens, view, settings.trayMode, settings.detailedTrace]) + }, [activeStats?.totalTokens, activeTab, trace.length, settings.trayMode, settings.detailedTrace]) return (
@@ -329,49 +304,56 @@ export default function App() { {!payload && !error &&
Loading…
} {error &&
Error: {error}
} - {payload && stats && grid && selected && ( + {payload && overviewStats && activeStats && ( <> setTheme(t as ThemeName)} - view={view} - onViewChange={setView} onRefresh={() => setRefreshTick(t => t + 1)} onOpenSettings={() => setSettingsOpen(true)} /> - - -
-
- {view === '3D' ? ( - - ) : ( - - )} -
- {view === '3D' && ( -
- -
- Average: {formatCost(stats.averagePerDay)} / day -
-
- )} -
- -
+ + {activeTab === 'overview' ? ( +
+ + + + + +
+ ) : ( +
+ + + +
- - + )} )} diff --git a/src/components/AgentLimitsCard.tsx b/src/components/AgentLimitsCard.tsx new file mode 100644 index 0000000..7716c83 --- /dev/null +++ b/src/components/AgentLimitsCard.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import { clientInitial, getClientStyle } from '../lib/clients' +import type { AgentUsagePayload, AgentUsageSnapshot } from '../lib/agentUsage' +import type { TraceBucket } from '../lib/usage' + +interface Props { + clients: string[] + trace: TraceBucket[] + agentUsage: AgentUsagePayload | null + title?: string + note?: string +} + +interface LimitRow { + label: string + usedPercent?: number + remainingPercent?: number + resetText?: string +} + +const LIMIT_ROWS: Record = { + codex: [{ label: 'Session' }, { label: 'Weekly' }], + claude: [{ label: 'Session' }, { label: 'Weekly' }], + gemini: [{ label: 'Pro' }, { label: 'Flash' }], +} + +function normalizeTraceClient(id: string): string { + if (id === 'claude-code') return 'claude' + if (id === 'codex-cli') return 'codex' + if (id === 'gemini-cli') return 'gemini' + return id.replace(/-cli$/, '') +} + +function mark(id: string) { + const style = getClientStyle(id) + if (style.iconRaw) { + return ( +