diff --git a/src-tauri/src/cursor_usage.rs b/src-tauri/src/cursor_usage.rs new file mode 100644 index 00000000..af90caa0 --- /dev/null +++ b/src-tauri/src/cursor_usage.rs @@ -0,0 +1,397 @@ +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use rusqlite::{Connection, OpenFlags}; +use serde::Serialize; +use serde_json::Value; + +use crate::dirs_home; + +const USAGE_SUMMARY_URL: &str = "https://cursor.com/api/usage-summary"; +const USER_AGENT: &str = "MonoCode"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(10); +const TOKEN_EXPIRY_BUFFER_SECS: i64 = 60; +const AUTH_TOKEN_KEY: &str = "cursorAuth/accessToken"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorUsageFetch { + pub status: String, + pub http_status: Option, + pub body: Option, + pub error: Option, +} + +/// Fetch Cursor plan usage via the signed-in Cursor.app session. +/// The access token never leaves the host process. +#[tauri::command] +pub async fn fetch_cursor_usage() -> Result { + tauri::async_runtime::spawn_blocking(fetch_cursor_usage_sync) + .await + .map_err(|e| e.to_string())? +} + +fn fetch_cursor_usage_sync() -> Result { + let Some(token) = read_cursor_access_token() else { + return Ok(usage_result( + "unavailable", + None, + None, + Some("Cursor not signed in".into()), + )); + }; + if !jwt_is_usable(&token, now_secs()) { + return Ok(usage_result( + "unavailable", + None, + None, + Some("Cursor sign-in expired".into()), + )); + } + let Some(cookie) = cursor_cookie_header(&token) else { + return Ok(usage_result( + "unavailable", + None, + None, + Some("Cursor sign-in is invalid".into()), + )); + }; + Ok(fetch_usage_with_cookie(&cookie)) +} + +fn usage_result( + status: &str, + http_status: Option, + body: Option, + error: Option, +) -> CursorUsageFetch { + CursorUsageFetch { + status: status.into(), + http_status, + body, + error, + } +} + +fn fetch_usage_with_cookie(cookie: &str) -> CursorUsageFetch { + let agent = ureq::AgentBuilder::new().timeout(HTTP_TIMEOUT).build(); + let result = agent + .get(USAGE_SUMMARY_URL) + .set("Accept", "application/json") + .set("Cookie", cookie) + .set("User-Agent", USER_AGENT) + .call(); + + match result { + Ok(response) => { + let http_status = response.status(); + let body = response.into_string().unwrap_or_default(); + if (200..300).contains(&http_status) { + usage_result("ok", Some(http_status), Some(body), None) + } else { + usage_error(http_status) + } + } + Err(ureq::Error::Status(status, response)) => { + let _ = response.into_string(); + usage_error(status) + } + Err(error) => usage_result( + "error", + None, + None, + Some(format!("Cursor usage request failed: {error}")), + ), + } +} + +fn usage_error(status: u16) -> CursorUsageFetch { + let (kind, message) = if status == 401 || status == 403 { + ("unavailable", "Cursor not signed in".into()) + } else { + ("error", format!("Cursor usage request failed ({status})")) + }; + usage_result(kind, Some(status), None, Some(message)) +} + +fn read_cursor_access_token() -> Option { + let path = cursor_state_db_path()?; + if !path.is_file() { + return None; + } + let token = read_item_table_value(&path, AUTH_TOKEN_KEY).ok()??; + let trimmed = token.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn cursor_state_db_path() -> Option { + let home = dirs_home()?; + Some(cursor_state_db_path_for(&home)) +} + +pub(crate) fn cursor_state_db_path_for(home: &str) -> PathBuf { + let home = Path::new(home); + #[cfg(target_os = "macos")] + { + home.join("Library/Application Support/Cursor/User/globalStorage/state.vscdb") + } + #[cfg(target_os = "windows")] + { + home.join("AppData/Roaming/Cursor/User/globalStorage/state.vscdb") + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let config = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join(".config")); + config.join("Cursor/User/globalStorage/state.vscdb") + } +} + +fn read_item_table_value(path: &Path, key: &str) -> Result, rusqlite::Error> { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + connection.busy_timeout(Duration::from_millis(250))?; + let mut statement = connection.prepare("SELECT value FROM ItemTable WHERE key = ?1 LIMIT 1")?; + let mut rows = statement.query(rusqlite::params![key])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + Ok(decode_sqlite_text(row.get_ref(0)?)) +} + +fn decode_sqlite_text(value: rusqlite::types::ValueRef<'_>) -> Option { + match value { + rusqlite::types::ValueRef::Text(bytes) => String::from_utf8(bytes.to_vec()) + .ok() + .or_else(|| decode_utf16le(bytes)), + rusqlite::types::ValueRef::Blob(bytes) => { + decode_utf16le(bytes).or_else(|| String::from_utf8(bytes.to_vec()).ok()) + } + rusqlite::types::ValueRef::Null => None, + _ => None, + } +} + +fn decode_utf16le(bytes: &[u8]) -> Option { + if bytes.len() < 2 || !bytes.len().is_multiple_of(2) { + return None; + } + let (pairs, _) = bytes.as_chunks::<2>(); + let ascii_utf16le = pairs + .iter() + .all(|pair| (1..128).contains(&pair[0]) && pair[1] == 0); + if !ascii_utf16le { + return None; + } + String::from_utf16( + &pairs + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect::>(), + ) + .ok() +} + +pub(crate) fn jwt_is_usable(token: &str, now_secs: i64) -> bool { + let Some(payload) = jwt_payload(token) else { + return false; + }; + let Some(exp) = payload.get("exp").and_then(Value::as_i64) else { + return false; + }; + exp - now_secs > TOKEN_EXPIRY_BUFFER_SECS +} + +pub(crate) fn cursor_cookie_header(token: &str) -> Option { + let user_id = jwt_user_id(token)?; + Some(format!("WorkosCursorSessionToken={user_id}%3A%3A{token}")) +} + +pub(crate) fn jwt_user_id(token: &str) -> Option { + let payload = jwt_payload(token)?; + let subject = payload.get("sub")?.as_str()?.trim(); + if subject.is_empty() { + return None; + } + let user_id = subject.rsplit('|').next().unwrap_or(subject).trim(); + if user_id.is_empty() { + return None; + } + if !user_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + { + return None; + } + Some(user_id.to_string()) +} + +fn jwt_payload(token: &str) -> Option { + let mut parts = token.split('.'); + let _header = parts.next()?; + let payload = parts.next()?; + if payload.is_empty() || parts.next().is_none() { + return None; + } + let mut encoded = payload.replace('-', "+").replace('_', "/"); + match encoded.len() % 4 { + 2 => encoded.push_str("=="), + 3 => encoded.push('='), + 0 => {} + _ => return None, + } + let bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + encoded.as_bytes(), + ) + .ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + + fn test_jwt(sub: &str, exp: i64) -> String { + let header = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + br#"{"alg":"none"}"#, + ); + let payload = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + format!(r#"{{"sub":"{sub}","exp":{exp}}}"#).as_bytes(), + ); + format!("{header}.{payload}.sig") + } + + #[test] + fn jwt_user_id_takes_the_last_subject_segment() { + let token = test_jwt("auth0|user_abc-1", 2_000_000_000); + assert_eq!(jwt_user_id(&token).as_deref(), Some("user_abc-1")); + } + + #[test] + fn jwt_user_id_rejects_invalid_characters() { + let token = test_jwt("auth0|user/abc", 2_000_000_000); + assert_eq!(jwt_user_id(&token), None); + } + + #[test] + fn jwt_is_usable_requires_a_future_expiry() { + let token = test_jwt("user_1", 1_000_061); + assert!(jwt_is_usable(&token, 1_000_000)); + assert!(!jwt_is_usable(&token, 1_000_002)); + } + + #[test] + fn cookie_header_uses_the_cursor_session_shape() { + let token = test_jwt("auth0|user_1", 2_000_000_000); + let expected = format!("WorkosCursorSessionToken=user_1%3A%3A{token}"); + assert_eq!( + cursor_cookie_header(&token).as_deref(), + Some(expected.as_str()) + ); + } + + #[test] + fn macos_state_db_lives_under_application_support() { + let path = cursor_state_db_path_for("/Users/ada"); + #[cfg(target_os = "macos")] + assert_eq!( + path, + PathBuf::from( + "/Users/ada/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + ) + ); + #[cfg(not(target_os = "macos"))] + let _ = path; + } + + #[test] + fn reads_item_table_text_and_utf16le_blobs() { + let connection = Connection::open_in_memory().unwrap(); + connection + .execute( + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + params!["cursorAuth/accessToken", "plain-token"], + ) + .unwrap(); + let utf16: Vec = "utf16-token" + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect(); + connection + .execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + params!["other", utf16], + ) + .unwrap(); + + let mut statement = connection + .prepare("SELECT value FROM ItemTable WHERE key = ?1") + .unwrap(); + let text = statement + .query_row(params!["cursorAuth/accessToken"], |row| { + Ok(decode_sqlite_text(row.get_ref(0)?)) + }) + .unwrap(); + assert_eq!(text.as_deref(), Some("plain-token")); + let blob = statement + .query_row(params!["other"], |row| { + Ok(decode_sqlite_text(row.get_ref(0)?)) + }) + .unwrap(); + assert_eq!(blob.as_deref(), Some("utf16-token")); + } + + #[test] + fn even_length_utf8_jwt_blob_falls_back_to_utf8() { + let connection = Connection::open_in_memory().unwrap(); + connection + .execute( + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", + [], + ) + .unwrap(); + // Even-length ASCII JWT: naive UTF-16LE decoding would mojibake it. + let token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1In0.sig"; + assert_eq!(token.len() % 2, 0); + assert_eq!(decode_utf16le(token.as_bytes()), None); + connection + .execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + params!["cursorAuth/accessToken", token.as_bytes()], + ) + .unwrap(); + let decoded = connection + .query_row( + "SELECT value FROM ItemTable WHERE key = ?1", + params!["cursorAuth/accessToken"], + |row| Ok(decode_sqlite_text(row.get_ref(0)?)), + ) + .unwrap(); + assert_eq!(decoded.as_deref(), Some(token)); + } +} diff --git a/src-tauri/src/grok_usage.rs b/src-tauri/src/grok_usage.rs new file mode 100644 index 00000000..77f595f2 --- /dev/null +++ b/src-tauri/src/grok_usage.rs @@ -0,0 +1,167 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Serialize; +use serde_json::Value; + +use crate::dirs_home; + +const BILLING_CREDITS_URL: &str = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +const USER_AGENT: &str = "MonoCode"; +const TOKEN_AUTH: &str = "xai-grok-cli"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GrokUsageFetch { + pub status: String, + pub http_status: Option, + pub body: Option, + pub error: Option, +} + +/// Fetch Grok credit usage via the signed-in Grok CLI session. +/// The access token never leaves the host process. +#[tauri::command] +pub async fn fetch_grok_usage() -> Result { + tauri::async_runtime::spawn_blocking(fetch_grok_usage_sync) + .await + .map_err(|e| e.to_string())? +} + +fn fetch_grok_usage_sync() -> Result { + let Some(token) = read_grok_access_token() else { + return Ok(usage_result( + "unavailable", + None, + None, + Some("Grok not signed in".into()), + )); + }; + Ok(fetch_usage_with_token(&token)) +} + +fn usage_result( + status: &str, + http_status: Option, + body: Option, + error: Option, +) -> GrokUsageFetch { + GrokUsageFetch { + status: status.into(), + http_status, + body, + error, + } +} + +fn fetch_usage_with_token(token: &str) -> GrokUsageFetch { + let agent = ureq::AgentBuilder::new().timeout(HTTP_TIMEOUT).build(); + let result = agent + .get(BILLING_CREDITS_URL) + .set("Accept", "application/json") + .set("Authorization", &format!("Bearer {token}")) + .set("x-xai-token-auth", TOKEN_AUTH) + .set("User-Agent", USER_AGENT) + .call(); + + match result { + Ok(response) => { + let http_status = response.status(); + let body = response.into_string().unwrap_or_default(); + if (200..300).contains(&http_status) { + usage_result("ok", Some(http_status), Some(body), None) + } else { + usage_error(http_status) + } + } + Err(ureq::Error::Status(status, response)) => { + let _ = response.into_string(); + usage_error(status) + } + Err(error) => usage_result( + "error", + None, + None, + Some(format!("Grok usage request failed: {error}")), + ), + } +} + +fn usage_error(status: u16) -> GrokUsageFetch { + let (kind, message) = if status == 401 || status == 403 { + ("unavailable", "Grok not signed in".into()) + } else { + ("error", format!("Grok usage request failed ({status})")) + }; + usage_result(kind, Some(status), None, Some(message)) +} + +fn read_grok_access_token() -> Option { + let path = grok_auth_path()?; + let raw = std::fs::read_to_string(path).ok()?; + extract_grok_access_token(&raw) +} + +fn grok_auth_path() -> Option { + Some(Path::new(&dirs_home()?).join(".grok/auth.json")) +} + +pub(crate) fn extract_grok_access_token(raw: &str) -> Option { + let value: Value = serde_json::from_str(raw.trim()).ok()?; + let object = value.as_object()?; + let mut best: Option<(String, String)> = None; + for entry in object.values() { + let Some(key) = entry.get("key").and_then(Value::as_str) else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + let expires = entry + .get("expires_at") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let replace = match &best { + Some((current, _)) => expires > *current, + None => true, + }; + if replace { + best = Some((expires, key.to_string())); + } + } + best.map(|(_, key)| key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_grok_access_token_picks_the_newest_entry() { + let raw = r#"{ + "https://auth.x.ai::old": { + "key": "old-token", + "expires_at": "2026-01-01T00:00:00Z" + }, + "https://auth.x.ai::new": { + "key": "new-token", + "expires_at": "2026-09-09T01:42:07Z" + } + }"#; + assert_eq!(extract_grok_access_token(raw).as_deref(), Some("new-token")); + } + + #[test] + fn extract_grok_access_token_skips_empty_keys() { + let raw = r#"{"https://auth.x.ai::a":{"key":" ","expires_at":"2099-01-01T00:00:00Z"}}"#; + assert_eq!(extract_grok_access_token(raw), None); + } + + #[test] + fn extract_grok_access_token_rejects_garbage() { + assert_eq!(extract_grok_access_token("not json"), None); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bfeca204..3003f187 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,8 +3,10 @@ use tauri::Manager; mod chat_background; mod checkpoint; mod cursor_store; +mod cursor_usage; mod fs; mod gitlab; +mod grok_usage; mod harness; mod inbox_media; mod linear; @@ -289,6 +291,8 @@ pub fn run() { harness::harness_sse_close, harness::harness_exec, rate_limits::fetch_claude_usage, + cursor_usage::fetch_cursor_usage, + grok_usage::fetch_grok_usage, pty::pty_spawn, pty::pty_write, pty::pty_resize, diff --git a/src/App.tsx b/src/App.tsx index 359697f8..66417af4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -332,13 +332,17 @@ import { } from "./lib/sessionWorkItem"; import { linearIssueDetails, peekLinearIssueDetails } from "./lib/linear"; import { gitlabWorkItemDetails, peekGitlabWorkItemDetails } from "./lib/gitlab"; +import { usageFooterProviders } from "./lib/rateLimits"; import { + ALWAYS_SHOW_USAGE_DEFAULT, + loadAlwaysShowUsage, loadLiveAgentsEnabled, loadNotesEnabled, loadDiffViewer, loadFollowUpBehavior, loadSettingsSection, saveSettingsSection, + subscribeAlwaysShowUsage, subscribeLiveAgentsEnabled, subscribeNotesEnabled, type SettingsSectionId, @@ -634,6 +638,11 @@ export default function App({ loadLiveAgentsEnabled, () => true, ); + const alwaysShowUsage = useSyncExternalStore( + subscribeAlwaysShowUsage, + loadAlwaysShowUsage, + () => ALWAYS_SHOW_USAGE_DEFAULT, + ); const [settingsOpen, setSettingsOpen] = useState(false); const [updateNotice, setUpdateNotice] = useState(installedUpdate); const [whatsNewVersion, setWhatsNewVersion] = useState(null); @@ -934,12 +943,14 @@ export default function App({ } const busySessionIds = busySessionIdsRef.current; - const usageProviders = useMemo(() => { - if (active?.harness === "claude" || active?.harness === "codex") { - return [active.harness]; - } - return []; - }, [active?.harness]); + const usageProviders = useMemo( + () => + usageFooterProviders({ + activeHarness: active?.harness, + alwaysShow: alwaysShowUsage, + }), + [active?.harness, alwaysShowUsage], + ); const usageSession = useMemo(() => { if (!active) return undefined; return { harness: active.harness }; diff --git a/src/chrome/UsageFooter.tsx b/src/chrome/UsageFooter.tsx index a25d142a..e6cf30bb 100644 --- a/src/chrome/UsageFooter.tsx +++ b/src/chrome/UsageFooter.tsx @@ -1,20 +1,20 @@ import { RefreshCw } from "./icons"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { HarnessIcon } from "./HarnessIcon"; import { Popover } from "./Popover"; import { - fetchClaudeRateLimits, - fetchCodexRateLimits, -} from "../lib/rateLimitsFetch"; + getRateLimitsSnapshot, + refreshRateLimits, + setRateLimitProviders, + subscribeRateLimits, +} from "../lib/rateLimitsStore"; import { clampUsedPercent, - fetchingRateLimits, formatRateLimitWindowChipLabel, formatUsagePercent, - idleRateLimits, - RATE_LIMIT_POLL_MS, + sharedWindowResetLabel, + isRateLimitProvider, rateLimitWindowTooltip, - shouldFetchProvider, type ProviderRateLimits, type RateLimitProvider, type RateLimitWindow, @@ -44,79 +44,52 @@ export function UsageFooter({ terminalOpen?: boolean; onToggleTerminal?: (fileId: string) => void; }) { - const wantClaude = providers.includes("claude"); - const wantCodex = providers.includes("codex"); - const [claude, setClaude] = useState(() => - idleRateLimits("claude"), - ); - const [codex, setCodex] = useState(() => - idleRateLimits("codex"), + const snapshot = useSyncExternalStore( + subscribeRateLimits, + getRateLimitsSnapshot, + getRateLimitsSnapshot, ); + const claude = snapshot.claude; + const codex = snapshot.codex; + const cursor = snapshot.cursor; + const grok = snapshot.grok; + const refreshing = snapshot.refreshing; const [now, setNow] = useState(() => Date.now()); - const [refreshing, setRefreshing] = useState(false); - const inflight = useRef | null>(null); - const claudeRef = useRef(claude); - const codexRef = useRef(codex); - claudeRef.current = claude; - codexRef.current = codex; - - const refresh = useCallback((force = false) => { - if (inflight.current) return inflight.current; - const visible = document.visibilityState === "visible"; - const fetchClaude = - wantClaude && - shouldFetchProvider(claudeRef.current, { force, visible }); - const fetchCodex = - wantCodex && - shouldFetchProvider(codexRef.current, { force, visible }); - if (!fetchClaude && !fetchCodex) return; - if (force) setRefreshing(true); - const jobs: Promise[] = []; - if (fetchClaude) { - setClaude((current) => fetchingRateLimits("claude", current)); - jobs.push( - fetchClaudeRateLimits().then((value) => { - setClaude(value); - }), - ); - } - if (fetchCodex) { - setCodex((current) => fetchingRateLimits("codex", current)); - jobs.push( - fetchCodexRateLimits().then((value) => { - setCodex(value); - }), - ); - } - const run = Promise.allSettled(jobs) - .then(() => undefined) - .finally(() => { - inflight.current = null; - setRefreshing(false); - }); - inflight.current = run; - return run; - }, [wantClaude, wantCodex]); useEffect(() => { - void refresh(); - const poll = window.setInterval(() => void refresh(), RATE_LIMIT_POLL_MS); - const onVisible = () => { - if (document.visibilityState === "visible") void refresh(); - }; - document.addEventListener("visibilitychange", onVisible); - return () => { - window.clearInterval(poll); - document.removeEventListener("visibilitychange", onVisible); - }; - }, [refresh]); + setRateLimitProviders(providers); + }, [providers]); + + const refresh = (force = false) => refreshRateLimits(force); useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), CLOCK_MS); return () => window.clearInterval(timer); }, []); - const showUsage = wantClaude || wantCodex; + const showUsage = providers.length > 0; + const usageChips = providers + .map((provider) => + provider === "claude" + ? claude + : provider === "codex" + ? codex + : provider === "cursor" + ? cursor + : provider === "grok" + ? grok + : null, + ) + .filter((limits): limits is ProviderRateLimits => limits != null); + // The session chip is the fallback when no usage chip covers the active + // provider. With the roster pinned it sits alongside the usage chips, so an + // OpenCode session still says "opencode" instead of vanishing behind them. + const showSession = + session != null && + !( + isRateLimitProvider(session.harness) && + providers.includes(session.harness) + ); const showTerminals = terminals.length > 0; const showRight = showUsage || showTerminals; const ariaLabel = showUsage @@ -132,13 +105,16 @@ export function UsageFooter({ aria-label={ariaLabel} className="flex h-7 shrink-0 items-center gap-3 overflow-x-auto border-t border-content/10 px-3 text-[11px] text-content/55" > - {showUsage ? ( - <> - {wantClaude ? : null} - {wantCodex ? : null} - - ) : session ? ( - + {showSession && session ? : null} + {usageChips.length > 0 ? ( + + {usageChips.map((limits, index) => ( + + {index > 0 ? : null} + + + ))} + ) : null} {showRight ? (
@@ -181,6 +157,10 @@ function TerminalLiveMark() { ); } +function ProviderDivider() { + return ; +} + function SessionChip({ session }: { session: UsageFooterSession }) { return ( rateLimitWindowTooltip(entry.window, now)) .join(" · "); + const sharedReset = sharedWindowResetLabel( + windows.map((entry) => entry.window), + now, + ); return ( 0 ? · : null} {formatUsagePercent(entry.window.usedPercent)}{" "} - {formatRateLimitWindowChipLabel(entry.window, now)} + + {formatRateLimitWindowChipLabel(entry.window, now)} + ))} + {sharedReset ? ( + + · + {sharedReset} + + ) : null} )} diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts index 86fe2e4c..5a77a00a 100644 --- a/src/lib/rateLimits.test.ts +++ b/src/lib/rateLimits.test.ts @@ -7,15 +7,20 @@ import { formatUsagePercent, formatWindowLabel, idleRateLimits, + isRateLimitProvider, isRateLimitSnapshotStale, mapUsageWindow, parseClaudeOAuthUsage, parseCodexRateLimits, + parseCursorUsageSummary, + parseGrokBilling, parseResetTimestamp, RATE_LIMIT_MIN_REFETCH_MS, rateLimitWindowTooltip, + sharedWindowResetLabel, shouldFetchProvider, shouldFetchRateLimits, + usageFooterProviders, } from "./rateLimits"; describe("formatWindowLabel", () => { @@ -82,6 +87,20 @@ describe("formatRateLimitWindowChipLabel", () => { ), ).toBe("wk"); }); + + it("prefers an explicit chip label over remaining time", () => { + expect( + formatRateLimitWindowChipLabel( + { + usedPercent: 32, + windowMinutes: 44_640, + resetsAt: now + 6 * 86_400_000, + chipLabel: "Auto", + }, + now, + ), + ).toBe("Auto"); + }); }); describe("formatUsagePercent", () => { @@ -194,6 +213,63 @@ describe("rateLimitWindowTooltip", () => { ), ).toBe("42% used · Resets in 2h 33m"); }); + + it("prefixes Cursor lane labels", () => { + const now = Date.parse("2026-08-27T08:00:00Z"); + expect( + rateLimitWindowTooltip( + { + usedPercent: 31.9, + windowMinutes: 44_640, + resetsAt: now + 6 * 86_400_000, + chipLabel: "Auto", + }, + now, + ), + ).toBe("Auto · 32% used · Resets in 6d"); + }); +}); + +describe("sharedWindowResetLabel", () => { + const now = Date.parse("2026-08-27T08:00:00Z"); + const resetsAt = now + 6 * 86_400_000; + + it("appends one countdown when labeled lanes share a reset", () => { + expect( + sharedWindowResetLabel( + [ + { + usedPercent: 32, + windowMinutes: 44_640, + resetsAt, + chipLabel: "Auto", + }, + { + usedPercent: 45, + windowMinutes: 44_640, + resetsAt, + chipLabel: "API", + }, + ], + now, + ), + ).toBe("6d"); + }); + + it("stays off when the chip already shows remaining time", () => { + expect( + sharedWindowResetLabel( + [ + { + usedPercent: 32, + windowMinutes: 300, + resetsAt, + }, + ], + now, + ), + ).toBeNull(); + }); }); describe("shouldFetchRateLimits", () => { @@ -311,3 +387,191 @@ describe("shouldFetchRateLimits", () => { ).toBe(true); }); }); + +describe("parseCursorUsageSummary", () => { + const cycle = { + billingCycleStart: "2026-08-14T12:56:47.000Z", + billingCycleEnd: "2026-09-14T12:56:47.000Z", + }; + const windowMinutes = Math.round( + (Date.parse(cycle.billingCycleEnd) - Date.parse(cycle.billingCycleStart)) / + 60_000, + ); + + it("maps Auto and API pools when both percents are present", () => { + const limits = parseCursorUsageSummary( + JSON.stringify({ + ...cycle, + individualUsage: { + plan: { + used: 40000, + limit: 40000, + autoPercentUsed: 31.96, + apiPercentUsed: 44.67, + totalPercentUsed: 33.77, + }, + }, + }), + ); + expect(limits.status).toBe("ok"); + expect(limits.provider).toBe("cursor"); + expect(limits.session).toEqual({ + usedPercent: 31.96, + windowMinutes, + resetsAt: Date.parse(cycle.billingCycleEnd), + chipLabel: "Auto", + }); + expect(limits.weekly).toEqual({ + usedPercent: 44.67, + windowMinutes, + resetsAt: Date.parse(cycle.billingCycleEnd), + chipLabel: "API", + }); + }); + + it("falls back to total plan percent when Auto/API are missing", () => { + const limits = parseCursorUsageSummary( + JSON.stringify({ + ...cycle, + individualUsage: { + plan: { totalPercentUsed: 18.2 }, + }, + }), + ); + expect(limits.session).toEqual({ + usedPercent: 18.2, + windowMinutes, + resetsAt: Date.parse(cycle.billingCycleEnd), + }); + expect(limits.weekly).toBeNull(); + }); + + it("uses used/limit when Cursor omits percent fields", () => { + const limits = parseCursorUsageSummary( + JSON.stringify({ + ...cycle, + individualUsage: { + overall: { used: 25, limit: 100 }, + }, + }), + ); + expect(limits.session?.usedPercent).toBe(25); + expect(limits.weekly).toBeNull(); + }); + + it("returns an error for garbage", () => { + const limits = parseCursorUsageSummary("not json"); + expect(limits.status).toBe("error"); + expect(limits.session).toBeNull(); + }); +}); + +describe("parseGrokBilling", () => { + const cycle = { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-09-03T16:48:05.298690+00:00", + end: "2026-09-10T16:48:05.298690+00:00", + }; + + it("maps credit usage and the weekly billing period", () => { + const limits = parseGrokBilling( + JSON.stringify({ + config: { + currentPeriod: cycle, + creditUsagePercent: 19, + productUsage: [ + { product: "GrokBuild", usagePercent: 17 }, + { product: "GrokChat", usagePercent: 2 }, + ], + }, + }), + ); + expect(limits.status).toBe("ok"); + expect(limits.provider).toBe("grok"); + expect(limits.session).toEqual({ + usedPercent: 19, + windowMinutes: 10_080, + resetsAt: Date.parse(cycle.end), + }); + expect(limits.weekly).toBeNull(); + }); + + it("falls back to GrokBuild when the headline percent is missing", () => { + const limits = parseGrokBilling( + JSON.stringify({ + config: { + currentPeriod: cycle, + productUsage: [{ product: "GrokBuild", usagePercent: 17 }], + }, + }), + ); + expect(limits.session?.usedPercent).toBe(17); + }); + + it("returns an error for garbage", () => { + const limits = parseGrokBilling("not json"); + expect(limits.status).toBe("error"); + expect(limits.session).toBeNull(); + }); +}); + +describe("usageFooterProviders", () => { + it("mirrors the active session by default", () => { + expect( + usageFooterProviders({ activeHarness: "claude", alwaysShow: false }), + ).toEqual(["claude"]); + expect( + usageFooterProviders({ activeHarness: "codex", alwaysShow: false }), + ).toEqual(["codex"]); + expect( + usageFooterProviders({ activeHarness: "cursor", alwaysShow: false }), + ).toEqual(["cursor"]); + expect( + usageFooterProviders({ activeHarness: "grok", alwaysShow: false }), + ).toEqual(["grok"]); + }); + + it("shows nothing by default for a provider without usage data", () => { + expect( + usageFooterProviders({ activeHarness: "opencode", alwaysShow: false }), + ).toEqual([]); + expect( + usageFooterProviders({ activeHarness: undefined, alwaysShow: false }), + ).toEqual([]); + expect( + usageFooterProviders({ activeHarness: null, alwaysShow: false }), + ).toEqual([]); + }); + + it("pins the full roster once the setting is on", () => { + expect( + usageFooterProviders({ activeHarness: "opencode", alwaysShow: true }), + ).toEqual(["claude", "codex", "cursor", "grok"]); + expect( + usageFooterProviders({ activeHarness: "claude", alwaysShow: true }), + ).toEqual(["claude", "codex", "cursor", "grok"]); + expect( + usageFooterProviders({ activeHarness: undefined, alwaysShow: true }), + ).toEqual(["claude", "codex", "cursor", "grok"]); + }); + + it("hands back a fresh array so callers cannot mutate the roster", () => { + const first = usageFooterProviders({ alwaysShow: true }); + first.pop(); + expect(usageFooterProviders({ alwaysShow: true })).toEqual([ + "claude", + "codex", + "cursor", + "grok", + ]); + }); + + it("recognises only the providers we can actually poll", () => { + expect(isRateLimitProvider("claude")).toBe(true); + expect(isRateLimitProvider("codex")).toBe(true); + expect(isRateLimitProvider("cursor")).toBe(true); + expect(isRateLimitProvider("grok")).toBe(true); + expect(isRateLimitProvider("opencode")).toBe(false); + expect(isRateLimitProvider(undefined)).toBe(false); + }); +}); diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts index f242dea4..9099e773 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -1,6 +1,45 @@ import { asRecord } from "./harness/codexProtocol"; -export type RateLimitProvider = "claude" | "codex"; +export type RateLimitProvider = "claude" | "codex" | "cursor" | "grok"; + +/** + * Every provider that reports usage from a supported source: Claude Code over + * its OAuth usage endpoint, Codex over `account/rateLimits/read`, Cursor over + * cursor.com `usage-summary`, Grok over the CLI billing credits API. Ordered + * the way the footer renders them. + */ +export const RATE_LIMIT_PROVIDERS: RateLimitProvider[] = [ + "claude", + "codex", + "cursor", + "grok", +]; + +export function isRateLimitProvider( + value: unknown, +): value is RateLimitProvider { + return ( + value === "claude" || + value === "codex" || + value === "cursor" || + value === "grok" + ); +} + +/** + * Which chips the usage footer renders. By default the footer mirrors the + * active session, so it goes quiet the moment you switch to a provider we + * cannot report usage for. `alwaysShow` pins the whole roster instead, so an + * OpenCode (or any other) session no longer hides Claude, Codex, Cursor, and + * Grok. + */ +export function usageFooterProviders(input: { + activeHarness?: string | null; + alwaysShow: boolean; +}): RateLimitProvider[] { + if (input.alwaysShow) return [...RATE_LIMIT_PROVIDERS]; + return isRateLimitProvider(input.activeHarness) ? [input.activeHarness] : []; +} export type RateLimitStatus = "idle" | "fetching" | "ok" | "error" | "unavailable"; @@ -12,6 +51,8 @@ export type RateLimitWindow = { windowMinutes: number; /** Unix ms timestamp when the window resets, if known. */ resetsAt: number | null; + /** Compact chip suffix when remaining time would be ambiguous (Cursor Auto/API). */ + chipLabel?: string; }; export type ProviderRateLimits = { @@ -57,11 +98,15 @@ export function shouldFetchRateLimits(input: { visible: boolean; claude: ProviderRateLimits; codex: ProviderRateLimits; + cursor?: ProviderRateLimits; + grok?: ProviderRateLimits; now?: number; }): boolean { return ( shouldFetchProvider(input.claude, input) || - shouldFetchProvider(input.codex, input) + shouldFetchProvider(input.codex, input) || + (input.cursor != null && shouldFetchProvider(input.cursor, input)) || + (input.grok != null && shouldFetchProvider(input.grok, input)) ); } @@ -193,21 +238,41 @@ export function formatRateLimitWindowChipLabel( window: RateLimitWindow, now = Date.now(), ): string { + if (window.chipLabel) return window.chipLabel; if (window.resetsAt != null) { return formatResetDuration(window.resetsAt - now); } return formatWindowLabel(window.windowMinutes); } +/** + * Cursor Auto/API share one billing-cycle reset. The per-window chip label + * already names the lane, so show the countdown once at the end instead of + * repeating it on every percent. + */ +export function sharedWindowResetLabel( + windows: RateLimitWindow[], + now = Date.now(), +): string | null { + if (windows.length === 0 || !windows.every((window) => window.chipLabel)) { + return null; + } + const resetsAt = windows[0]?.resetsAt; + if (resetsAt == null) return null; + if (windows.some((window) => window.resetsAt !== resetsAt)) return null; + return formatResetDuration(resetsAt - now); +} + export function rateLimitWindowTooltip( window: RateLimitWindow, now = Date.now(), ): string { const used = `${formatUsagePercent(window.usedPercent)} used`; + const labeled = window.chipLabel ? `${window.chipLabel} · ${used}` : used; if (window.resetsAt == null) { - return `${used} · ${formatWindowLabel(window.windowMinutes)} window`; + return `${labeled} · ${formatWindowLabel(window.windowMinutes)} window`; } - return `${used} · ${formatResetCountdown(window.resetsAt - now)}`; + return `${labeled} · ${formatResetCountdown(window.resetsAt - now)}`; } export function parseResetTimestamp(value: unknown): number | null { @@ -283,6 +348,183 @@ type CodexWindowSnapshot = { resetsAt: unknown; }; +export function parseCursorUsageSummary(body: string): ProviderRateLimits { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return errorRateLimits("cursor", "Cursor usage response was not JSON"); + } + const rec = asRecord(parsed); + if (!rec) { + return errorRateLimits("cursor", "Cursor usage response was empty"); + } + + const individual = asRecord(rec.individualUsage); + const team = asRecord(rec.teamUsage); + const plan = asRecord(individual?.plan); + const overall = asRecord(individual?.overall); + const pooled = asRecord(team?.pooled); + const autoPercent = optionalPercent(plan, "autoPercentUsed"); + const apiPercent = optionalPercent(plan, "apiPercentUsed"); + const totalPercent = optionalPercent(plan, "totalPercentUsed"); + const planPercent = + totalPercent ?? + averagePercent(autoPercent, apiPercent) ?? + apiPercent ?? + autoPercent ?? + ratioPercent(plan) ?? + ratioPercent(overall) ?? + ratioPercent(pooled); + const resetsAt = + parseResetTimestamp(rec.billingCycleEnd) ?? + parseResetTimestamp(rec.billing_cycle_end); + const startedAt = + parseResetTimestamp(rec.billingCycleStart) ?? + parseResetTimestamp(rec.billing_cycle_start); + const windowMinutes = + startedAt != null && resetsAt != null && resetsAt > startedAt + ? Math.max(1, Math.round((resetsAt - startedAt) / 60_000)) + : 30 * 24 * 60; + + if (autoPercent == null && apiPercent == null && planPercent == null) { + return errorRateLimits("cursor", "No Cursor usage data"); + } + + const labeled = autoPercent != null && apiPercent != null; + return { + provider: "cursor", + session: labeled + ? cursorWindow(autoPercent, windowMinutes, resetsAt, "Auto") + : cursorWindow(planPercent ?? autoPercent ?? apiPercent, windowMinutes, resetsAt), + weekly: labeled + ? cursorWindow(apiPercent, windowMinutes, resetsAt, "API") + : null, + updatedAt: Date.now(), + error: null, + status: "ok", + }; +} + +function cursorWindow( + usedPercent: number | null | undefined, + windowMinutes: number, + resetsAt: number | null, + chipLabel?: string, +): RateLimitWindow | null { + if (usedPercent == null) return null; + return { + usedPercent: clampUsedPercent(usedPercent), + windowMinutes, + resetsAt, + ...(chipLabel ? { chipLabel } : {}), + }; +} + +function optionalPercent( + rec: Record | null, + key: string, +): number | null { + if (!rec) return null; + const value = numberField(rec, key); + return value == null ? null : clampUsedPercent(value); +} + +function averagePercent(left: number | null, right: number | null): number | null { + if (left == null || right == null) return null; + return clampUsedPercent((left + right) / 2); +} + +function ratioPercent(rec: Record | null): number | null { + if (!rec) return null; + const used = numberField(rec, "used"); + const limit = numberField(rec, "limit"); + if (used == null || limit == null || limit <= 0) return null; + return clampUsedPercent((used / limit) * 100); +} + +export function parseGrokBilling(body: string): ProviderRateLimits { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return errorRateLimits("grok", "Grok usage response was not JSON"); + } + const rec = asRecord(parsed); + const config = asRecord(rec?.config) ?? rec; + if (!config) { + return errorRateLimits("grok", "Grok usage response was empty"); + } + + const period = asRecord(config.currentPeriod); + const usedPercent = + optionalPercent(config, "creditUsagePercent") ?? + grokProductPercent(config, "GrokBuild") ?? + grokOnDemandPercent(config); + const resetsAt = + parseResetTimestamp(period?.end) ?? + parseResetTimestamp(config.billingPeriodEnd); + const startedAt = + parseResetTimestamp(period?.start) ?? + parseResetTimestamp(config.billingPeriodStart); + const periodType = typeof period?.type === "string" ? period.type : ""; + const windowMinutes = + startedAt != null && resetsAt != null && resetsAt > startedAt + ? Math.max(1, Math.round((resetsAt - startedAt) / 60_000)) + : /weekly/i.test(periodType) + ? WEEKLY_WINDOW_MINUTES + : 30 * 24 * 60; + + if (usedPercent == null) { + return errorRateLimits("grok", "No Grok usage data"); + } + return { + provider: "grok", + session: { + usedPercent, + windowMinutes, + resetsAt, + }, + weekly: null, + updatedAt: Date.now(), + error: null, + status: "ok", + }; +} + +function grokProductPercent( + config: Record, + product: string, +): number | null { + const products = config.productUsage; + if (!Array.isArray(products)) return null; + for (const item of products) { + const rec = asRecord(item); + if (rec?.product !== product) continue; + return optionalPercent(rec, "usagePercent"); + } + return null; +} + +function grokOnDemandPercent( + config: Record, +): number | null { + const used = nestedNumber(config, "onDemandUsed"); + const cap = nestedNumber(config, "onDemandCap"); + if (used == null || cap == null || cap <= 0) return null; + return clampUsedPercent((used / cap) * 100); +} + +function nestedNumber( + rec: Record, + key: string, +): number | null { + const direct = numberField(rec, key); + if (direct != null) return direct; + const nested = asRecord(rec[key]); + return nested ? numberField(nested, "val") : null; +} + export function parseCodexRateLimits(result: unknown): ProviderRateLimits { const rec = asRecord(result); const wrapper = asRecord(rec?.rateLimits) ?? rec; diff --git a/src/lib/rateLimitsFetch.ts b/src/lib/rateLimitsFetch.ts index 275f7526..16eb007b 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -4,8 +4,11 @@ import { errorRateLimits, parseClaudeOAuthUsage, parseCodexRateLimits, + parseCursorUsageSummary, + parseGrokBilling, unavailableRateLimits, type ProviderRateLimits, + type RateLimitProvider, } from "./rateLimits"; import { killChild, @@ -21,18 +24,26 @@ const USAGE_CHILD_ID = "monocode-codex-usage"; const DISCOVERY_TIMEOUT_MS = 15_000; const REQUEST_TIMEOUT_MS = 12_000; -type ClaudeUsageFetch = { +type UsageFetch = { status: "ok" | "error" | "unavailable" | string; httpStatus?: number | null; body?: string | null; error?: string | null; }; -export async function fetchClaudeRateLimits(): Promise { +type InvokeUsageProvider = Exclude; + +async function fetchInvokeRateLimits( + command: string, + provider: InvokeUsageProvider, + parse: (body: string) => ProviderRateLimits, + unavailableMessage: string, + errorMessage: string, +): Promise { try { - const result = await invoke("fetch_claude_usage"); + const result = await invoke(command); if (result.status === "ok" && result.body) { - const parsed = parseClaudeOAuthUsage(result.body); + const parsed = parse(result.body); if (parsed.session || parsed.weekly) return parsed; return { ...parsed, @@ -41,22 +52,49 @@ export async function fetchClaudeRateLimits(): Promise { } if (result.status === "unavailable") { return unavailableRateLimits( - "claude", - result.error?.trim() || "Claude not signed in", + provider, + result.error?.trim() || unavailableMessage, ); } - return errorRateLimits( - "claude", - result.error?.trim() || "Claude usage unavailable", - ); + return errorRateLimits(provider, result.error?.trim() || errorMessage); } catch (error) { return errorRateLimits( - "claude", - error instanceof Error ? error.message : "Claude usage unavailable", + provider, + error instanceof Error ? error.message : errorMessage, ); } } +export async function fetchClaudeRateLimits(): Promise { + return fetchInvokeRateLimits( + "fetch_claude_usage", + "claude", + parseClaudeOAuthUsage, + "Claude not signed in", + "Claude usage unavailable", + ); +} + +export async function fetchCursorRateLimits(): Promise { + return fetchInvokeRateLimits( + "fetch_cursor_usage", + "cursor", + parseCursorUsageSummary, + "Cursor not signed in", + "Cursor usage unavailable", + ); +} + +export async function fetchGrokRateLimits(): Promise { + return fetchInvokeRateLimits( + "fetch_grok_usage", + "grok", + parseGrokBilling, + "Grok not signed in", + "Grok usage unavailable", + ); +} + export async function fetchCodexRateLimits(): Promise { let path: string; try { diff --git a/src/lib/rateLimitsStore.test.ts b/src/lib/rateLimitsStore.test.ts new file mode 100644 index 00000000..f237b29f --- /dev/null +++ b/src/lib/rateLimitsStore.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + RATE_LIMITS_CACHE_KEY, + RATE_LIMITS_LOCK_KEY, + getRateLimitsSnapshot, + refreshRateLimits, + resetRateLimitsStoreForTests, + setRateLimitFetchersForTests, + setRateLimitProviders, + subscribeRateLimits, +} from "./rateLimitsStore"; +import { idleRateLimits, type ProviderRateLimits } from "./rateLimits"; + +function mockLocalStorage() { + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + clear: () => { + data.clear(); + }, + key: (index: number) => [...data.keys()][index] ?? null, + get length() { + return data.size; + }, + }; + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + }); +} + +function okLimits( + provider: ProviderRateLimits["provider"], + usedPercent: number, +): ProviderRateLimits { + return { + provider, + session: { usedPercent, windowMinutes: 300, resetsAt: null }, + weekly: null, + updatedAt: Date.now(), + error: null, + status: "ok", + }; +} + +describe("rateLimitsStore", () => { + beforeEach(() => { + mockLocalStorage(); + resetRateLimitsStoreForTests(); + }); + + afterEach(() => { + resetRateLimitsStoreForTests(); + }); + + it("keeps the cache across unsubscribe so remounts do not refetch", async () => { + const claude = vi.fn(async () => okLimits("claude", 12)); + setRateLimitFetchersForTests({ + claude, + codex: vi.fn(async () => idleRateLimits("codex")), + cursor: vi.fn(async () => idleRateLimits("cursor")), + grok: vi.fn(async () => idleRateLimits("grok")), + }); + const stop = subscribeRateLimits(() => undefined); + setRateLimitProviders(["claude"]); + await refreshRateLimits(); + expect(claude).toHaveBeenCalledTimes(1); + expect(getRateLimitsSnapshot().claude.session?.usedPercent).toBe(12); + stop(); + + const stopAgain = subscribeRateLimits(() => undefined); + setRateLimitProviders(["claude"]); + await refreshRateLimits(); + expect(claude).toHaveBeenCalledTimes(1); + expect(getRateLimitsSnapshot().claude.session?.usedPercent).toBe(12); + stopAgain(); + }); + + it("queues a forced refresh after an in-flight poll", async () => { + let release: ((value: ProviderRateLimits) => void) | undefined; + const first = new Promise((resolve) => { + release = resolve; + }); + const claude = vi + .fn() + .mockImplementationOnce(() => first) + .mockImplementationOnce(async () => okLimits("claude", 99)); + setRateLimitFetchersForTests({ + claude, + codex: vi.fn(async () => idleRateLimits("codex")), + cursor: vi.fn(async () => idleRateLimits("cursor")), + grok: vi.fn(async () => idleRateLimits("grok")), + }); + subscribeRateLimits(() => undefined); + setRateLimitProviders(["claude"]); + const forced = refreshRateLimits(true); + expect(getRateLimitsSnapshot().refreshing).toBe(true); + release?.(okLimits("claude", 12)); + await forced; + expect(claude).toHaveBeenCalledTimes(2); + expect(getRateLimitsSnapshot().claude.session?.usedPercent).toBe(99); + expect(getRateLimitsSnapshot().refreshing).toBe(false); + }); + + it("skips a fetch when another window holds the lock", async () => { + const claude = vi.fn(async () => okLimits("claude", 12)); + setRateLimitFetchersForTests({ + claude, + codex: vi.fn(async () => idleRateLimits("codex")), + cursor: vi.fn(async () => idleRateLimits("cursor")), + grok: vi.fn(async () => idleRateLimits("grok")), + }); + localStorage.setItem( + RATE_LIMITS_LOCK_KEY, + JSON.stringify({ at: Date.now() }), + ); + subscribeRateLimits(() => undefined); + setRateLimitProviders(["claude"]); + await refreshRateLimits(); + expect(claude).not.toHaveBeenCalled(); + }); + + it("hydrates from a cache written by another window", () => { + localStorage.setItem( + RATE_LIMITS_CACHE_KEY, + JSON.stringify({ claude: okLimits("claude", 41) }), + ); + subscribeRateLimits(() => undefined); + expect(getRateLimitsSnapshot().claude.session?.usedPercent).toBe(41); + }); +}); diff --git a/src/lib/rateLimitsStore.ts b/src/lib/rateLimitsStore.ts new file mode 100644 index 00000000..1009e97a --- /dev/null +++ b/src/lib/rateLimitsStore.ts @@ -0,0 +1,285 @@ +import { + fetchClaudeRateLimits, + fetchCodexRateLimits, + fetchCursorRateLimits, + fetchGrokRateLimits, +} from "./rateLimitsFetch"; +import { + fetchingRateLimits, + idleRateLimits, + RATE_LIMIT_POLL_MS, + RATE_LIMIT_PROVIDERS, + shouldFetchProvider, + type ProviderRateLimits, + type RateLimitProvider, +} from "./rateLimits"; + +export const RATE_LIMITS_CACHE_KEY = "monocode.rateLimits.cache"; +export const RATE_LIMITS_LOCK_KEY = "monocode.rateLimits.lock"; +const LOCK_TTL_MS = 30_000; + +export type RateLimitSnapshot = { + claude: ProviderRateLimits; + codex: ProviderRateLimits; + cursor: ProviderRateLimits; + grok: ProviderRateLimits; + refreshing: boolean; +}; + +export type RateLimitFetcherMap = { + [K in RateLimitProvider]: () => Promise; +}; + +const defaultFetchers: RateLimitFetcherMap = { + claude: fetchClaudeRateLimits, + codex: fetchCodexRateLimits, + cursor: fetchCursorRateLimits, + grok: fetchGrokRateLimits, +}; + +let fetchers = defaultFetchers; +let snapshot = idleSnapshot(); +const listeners = new Set<() => void>(); +let wanted: RateLimitProvider[] = []; +let inflight: Promise | null = null; +let started = false; +let pollTimer: ReturnType | undefined; + +function idleSnapshot(): RateLimitSnapshot { + return { + claude: idleRateLimits("claude"), + codex: idleRateLimits("codex"), + cursor: idleRateLimits("cursor"), + grok: idleRateLimits("grok"), + refreshing: false, + }; +} + +function emit() { + for (const listener of listeners) listener(); +} + +function replace(next: RateLimitSnapshot) { + snapshot = next; + emit(); +} + +function isVisible(): boolean { + return typeof document === "undefined" || document.visibilityState !== "hidden"; +} + +function readCache(): Partial> | null { + try { + const raw = localStorage.getItem(RATE_LIMITS_CACHE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + const rec = parsed as Record; + const out: Partial> = {}; + for (const provider of RATE_LIMIT_PROVIDERS) { + const limits = asLimits(provider, rec[provider]); + if (limits) out[provider] = limits; + } + return out; + } catch { + return null; + } +} + +function asLimits( + provider: RateLimitProvider, + value: unknown, +): ProviderRateLimits | null { + if (!value || typeof value !== "object") return null; + const rec = value as Record; + if (rec.provider !== provider || typeof rec.updatedAt !== "number") { + return null; + } + const status = rec.status; + if ( + status !== "idle" && + status !== "fetching" && + status !== "ok" && + status !== "error" && + status !== "unavailable" + ) { + return null; + } + return { + provider, + session: windowFromUnknown(rec.session), + weekly: windowFromUnknown(rec.weekly), + updatedAt: rec.updatedAt, + error: typeof rec.error === "string" ? rec.error : null, + status: status === "fetching" ? "ok" : status, + }; +} + +function windowFromUnknown( + value: unknown, +): ProviderRateLimits["session"] { + return value && typeof value === "object" + ? (value as ProviderRateLimits["session"]) + : null; +} + +function writeCache(current: RateLimitSnapshot) { + try { + const payload: Record = {}; + for (const provider of RATE_LIMIT_PROVIDERS) { + const limits = current[provider]; + if (limits.status === "idle" || limits.status === "fetching") continue; + payload[provider] = limits; + } + localStorage.setItem(RATE_LIMITS_CACHE_KEY, JSON.stringify(payload)); + } catch { + // private mode / quota + } +} + +function lockHeld(): boolean { + try { + const raw = localStorage.getItem(RATE_LIMITS_LOCK_KEY); + if (!raw) return false; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return false; + const at = (parsed as { at?: unknown }).at; + return typeof at === "number" && Date.now() - at < LOCK_TTL_MS; + } catch { + return false; + } +} + +function acquireLock() { + try { + localStorage.setItem(RATE_LIMITS_LOCK_KEY, JSON.stringify({ at: Date.now() })); + } catch { + // private mode / quota + } +} + +function releaseLock() { + try { + localStorage.removeItem(RATE_LIMITS_LOCK_KEY); + } catch { + // private mode / quota + } +} + +function hydrateFromCache() { + const cached = readCache(); + if (!cached) return; + replace({ + claude: cached.claude ?? snapshot.claude, + codex: cached.codex ?? snapshot.codex, + cursor: cached.cursor ?? snapshot.cursor, + grok: cached.grok ?? snapshot.grok, + refreshing: snapshot.refreshing, + }); +} + +function onStorage(event: StorageEvent) { + if (event.key !== RATE_LIMITS_CACHE_KEY) return; + hydrateFromCache(); +} + +function onVisible() { + if (isVisible()) void refreshRateLimits(); +} + +function ensureStarted() { + if (started) return; + started = true; + hydrateFromCache(); + if (typeof window !== "undefined") { + window.addEventListener("storage", onStorage); + document.addEventListener("visibilitychange", onVisible); + } + pollTimer = setInterval(() => void refreshRateLimits(), RATE_LIMIT_POLL_MS); +} + +export function getRateLimitsSnapshot(): RateLimitSnapshot { + return snapshot; +} + +export function subscribeRateLimits(onStoreChange: () => void) { + listeners.add(onStoreChange); + ensureStarted(); + return () => { + listeners.delete(onStoreChange); + }; +} + +export function setRateLimitProviders(providers: RateLimitProvider[]) { + wanted = [...providers]; + ensureStarted(); + void refreshRateLimits(); +} + +export function refreshRateLimits(force = false): Promise | undefined { + ensureStarted(); + if (inflight) { + if (!force) return inflight; + if (!snapshot.refreshing) replace({ ...snapshot, refreshing: true }); + return inflight.then(async () => { + await refreshRateLimits(true); + }); + } + const visible = isVisible(); + const pending = RATE_LIMIT_PROVIDERS.filter( + (provider) => + wanted.includes(provider) && + shouldFetchProvider(snapshot[provider], { force, visible }), + ); + if (pending.length === 0) return; + if (!force && lockHeld()) return; + acquireLock(); + + let next = { ...snapshot, refreshing: force || snapshot.refreshing }; + for (const provider of pending) { + next = { + ...next, + [provider]: fetchingRateLimits(provider, snapshot[provider]), + }; + } + replace(next); + + const run = Promise.allSettled( + pending.map((provider) => + fetchers[provider]().then((value) => { + snapshot = { ...snapshot, [provider]: value }; + emit(); + }), + ), + ) + .then(() => undefined) + .finally(() => { + inflight = null; + replace({ ...snapshot, refreshing: false }); + writeCache(snapshot); + releaseLock(); + }); + inflight = run; + return run; +} + +export function setRateLimitFetchersForTests(next: RateLimitFetcherMap) { + fetchers = next; +} + +export function resetRateLimitsStoreForTests() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = undefined; + } + if (typeof window !== "undefined") { + window.removeEventListener("storage", onStorage); + document.removeEventListener("visibilitychange", onVisible); + } + fetchers = defaultFetchers; + snapshot = idleSnapshot(); + listeners.clear(); + wanted = []; + inflight = null; + started = false; +} diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 3005d87f..62bc71c0 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + ALWAYS_SHOW_USAGE_DEFAULT, COMPOSER_RUNNER_DEFAULT, DIFF_VIEWER_DEFAULT, FOLLOW_UP_BEHAVIOR_DEFAULT, GRID_ARCADE_ENABLED_DEFAULT, KEYBINDINGS, LIVE_AGENTS_ENABLED_DEFAULT, + loadAlwaysShowUsage, loadComposerRunner, loadDiffViewer, loadFollowUpBehavior, @@ -13,6 +15,7 @@ import { loadLiveAgentsEnabled, loadNotesEnabled, NOTES_ENABLED_DEFAULT, + saveAlwaysShowUsage, saveComposerRunner, saveDiffViewer, saveFollowUpBehavior, @@ -27,6 +30,7 @@ const LIVE_AGENTS_KEY = "monocode.liveAgentsEnabled"; const GRID_ARCADE_KEY = "monocode.gridArcadeEnabled"; const DIFF_VIEWER_KEY = "monocode.diffViewer"; const FOLLOW_UP_BEHAVIOR_KEY = "monocode.followUpBehavior"; +const ALWAYS_SHOW_USAGE_KEY = "monocode.alwaysShowUsage"; describe("follow-up behavior setting", () => { beforeEach(mockLocalStorage); @@ -197,3 +201,29 @@ describe("diff viewer setting", () => { expect(loadDiffViewer()).toBe("editor"); }); }); + +describe("always show provider usage setting", () => { + beforeEach(mockLocalStorage); + afterEach(() => { + localStorage.removeItem(ALWAYS_SHOW_USAGE_KEY); + }); + + it("defaults to off so the footer keeps following the active session", () => { + expect(ALWAYS_SHOW_USAGE_DEFAULT).toBe(false); + expect(loadAlwaysShowUsage()).toBe(false); + }); + + it("persists an on switch", () => { + saveAlwaysShowUsage(true); + expect(localStorage.getItem(ALWAYS_SHOW_USAGE_KEY)).toBe("1"); + expect(loadAlwaysShowUsage()).toBe(true); + saveAlwaysShowUsage(false); + expect(localStorage.getItem(ALWAYS_SHOW_USAGE_KEY)).toBe("0"); + expect(loadAlwaysShowUsage()).toBe(false); + }); + + it("reads the legacy truthy spelling", () => { + localStorage.setItem(ALWAYS_SHOW_USAGE_KEY, "true"); + expect(loadAlwaysShowUsage()).toBe(true); + }); +}); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 14a74708..97518c33 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -244,6 +244,43 @@ export function subscribeGridArcadeEnabled(onStoreChange: () => void) { window.removeEventListener(GRID_ARCADE_ENABLED_CHANGE_EVENT, onStoreChange); } +const ALWAYS_SHOW_USAGE_KEY = "monocode.alwaysShowUsage"; + +export const ALWAYS_SHOW_USAGE_DEFAULT = false; + +/** Fired on `window` when the always-show provider usage setting flips. */ +export const ALWAYS_SHOW_USAGE_CHANGE_EVENT = + "monocode:always-show-usage-change"; + +export function loadAlwaysShowUsage(): boolean { + try { + const raw = localStorage.getItem(ALWAYS_SHOW_USAGE_KEY); + if (raw == null) return ALWAYS_SHOW_USAGE_DEFAULT; + return raw === "1" || raw === "true"; + } catch { + return ALWAYS_SHOW_USAGE_DEFAULT; + } +} + +export function saveAlwaysShowUsage(value: boolean) { + try { + localStorage.setItem(ALWAYS_SHOW_USAGE_KEY, value ? "1" : "0"); + } catch { + // private mode / quota + } + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(ALWAYS_SHOW_USAGE_CHANGE_EVENT, { detail: value }), + ); +} + +export function subscribeAlwaysShowUsage(onStoreChange: () => void) { + if (typeof window === "undefined") return () => {}; + window.addEventListener(ALWAYS_SHOW_USAGE_CHANGE_EVENT, onStoreChange); + return () => + window.removeEventListener(ALWAYS_SHOW_USAGE_CHANGE_EVENT, onStoreChange); +} + const DIFF_VIEWER_KEY = "monocode.diffViewer"; export type DiffViewer = "editor" | "unified"; diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index 2c6d5cd7..35050fe4 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -151,6 +151,7 @@ import { loadTabGroupLabels, resolveTabGroupLabel } from "../lib/tabGroups"; import { filterKeybindings, KEYBINDINGS, + loadAlwaysShowUsage, loadClaudeHooks, loadComposerRunner, loadDiffViewer, @@ -158,6 +159,7 @@ import { loadGridArcadeEnabled, loadLiveAgentsEnabled, loadNotesEnabled, + saveAlwaysShowUsage, saveClaudeHooks, saveComposerRunner, saveDiffViewer, @@ -327,6 +329,7 @@ function GeneralPage({ const [notificationPermission, setNotificationPermission] = useState(cachedNotificationPermission); const [claudeHooks, setClaudeHooks] = useState(loadClaudeHooks); + const [alwaysShowUsage, setAlwaysShowUsage] = useState(loadAlwaysShowUsage); // The user may flip the switch in System Settings and come back: re-read // the OS state whenever the window regains focus while the toggle is on. @@ -390,6 +393,11 @@ function GeneralPage({ setLiveAgentsEnabled(next); }; + const onAlwaysShowUsage = (next: boolean) => { + saveAlwaysShowUsage(next); + setAlwaysShowUsage(next); + }; + const onSoundsEnabled = (next: boolean) => { saveSoundsEnabled(next); setSoundsEnabled(next); @@ -497,6 +505,16 @@ function GeneralPage({ onChange={onLiveAgentsEnabled} /> + + +