From 3faa3b315d34631da12fae4a21028fa232caeeb0 Mon Sep 17 00:00:00 2001 From: Felipe Orlando Date: Mon, 7 Sep 2026 23:31:28 -0300 Subject: [PATCH 1/7] feat: always show provider usage in the footer Co-Authored-By: Claude Opus 5 --- src/App.tsx | 23 +++++++++++---- src/chrome/UsageFooter.tsx | 21 ++++++++------ src/lib/rateLimits.test.ts | 53 +++++++++++++++++++++++++++++++++++ src/lib/rateLimits.ts | 27 ++++++++++++++++++ src/lib/settings.test.ts | 30 ++++++++++++++++++++ src/lib/settings.ts | 37 ++++++++++++++++++++++++ src/surfaces/SettingsView.tsx | 18 ++++++++++++ 7 files changed, 195 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 64f31df5..da00e765 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -325,13 +325,17 @@ import { inboxAskKey, inboxAskPrompt } from "./lib/inboxAsk"; import { NotesView } from "./surfaces/NotesView"; import { inboxComposerCard, type InboxItem } from "./lib/githubTasks"; import { linearIssueDetails, peekLinearIssueDetails } from "./lib/linear"; +import { usageFooterProviders } from "./lib/rateLimits"; import { + ALWAYS_SHOW_USAGE_DEFAULT, + loadAlwaysShowUsage, loadLiveAgentsEnabled, loadNotesEnabled, loadDiffViewer, loadFollowUpBehavior, loadSettingsSection, saveSettingsSection, + subscribeAlwaysShowUsage, subscribeLiveAgentsEnabled, subscribeNotesEnabled, type SettingsSectionId, @@ -625,6 +629,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); @@ -921,12 +930,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..5e73729f 100644 --- a/src/chrome/UsageFooter.tsx +++ b/src/chrome/UsageFooter.tsx @@ -12,6 +12,7 @@ import { formatRateLimitWindowChipLabel, formatUsagePercent, idleRateLimits, + isRateLimitProvider, RATE_LIMIT_POLL_MS, rateLimitWindowTooltip, shouldFetchProvider, @@ -117,6 +118,15 @@ export function UsageFooter({ }, []); const showUsage = wantClaude || wantCodex; + // 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 a + // Cursor session still says "cursor" 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,14 +142,9 @@ 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 ? ( - - ) : null} + {showSession && session ? : null} + {wantClaude ? : null} + {wantCodex ? : null} {showRight ? (
{showTerminals ? ( diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts index 86fe2e4c..b449fe22 100644 --- a/src/lib/rateLimits.test.ts +++ b/src/lib/rateLimits.test.ts @@ -7,6 +7,7 @@ import { formatUsagePercent, formatWindowLabel, idleRateLimits, + isRateLimitProvider, isRateLimitSnapshotStale, mapUsageWindow, parseClaudeOAuthUsage, @@ -16,6 +17,7 @@ import { rateLimitWindowTooltip, shouldFetchProvider, shouldFetchRateLimits, + usageFooterProviders, } from "./rateLimits"; describe("formatWindowLabel", () => { @@ -311,3 +313,54 @@ describe("shouldFetchRateLimits", () => { ).toBe(true); }); }); + +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"]); + }); + + it("shows nothing by default for a provider without usage data", () => { + expect( + usageFooterProviders({ activeHarness: "cursor", 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: "cursor", alwaysShow: true }), + ).toEqual(["claude", "codex"]); + expect( + usageFooterProviders({ activeHarness: "claude", alwaysShow: true }), + ).toEqual(["claude", "codex"]); + expect( + usageFooterProviders({ activeHarness: undefined, alwaysShow: true }), + ).toEqual(["claude", "codex"]); + }); + + 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", + ]); + }); + + it("recognises only the providers we can actually poll", () => { + expect(isRateLimitProvider("claude")).toBe(true); + expect(isRateLimitProvider("codex")).toBe(true); + expect(isRateLimitProvider("cursor")).toBe(false); + expect(isRateLimitProvider(undefined)).toBe(false); + }); +}); diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts index f242dea4..43b900c7 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -2,6 +2,33 @@ import { asRecord } from "./harness/codexProtocol"; export type RateLimitProvider = "claude" | "codex"; +/** + * Every provider that reports usage from a supported source: Claude Code over + * its OAuth usage endpoint, Codex over `account/rateLimits/read`. Ordered the + * way the footer renders them. + */ +export const RATE_LIMIT_PROVIDERS: RateLimitProvider[] = ["claude", "codex"]; + +export function isRateLimitProvider( + value: unknown, +): value is RateLimitProvider { + return value === "claude" || value === "codex"; +} + +/** + * 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 a + * Cursor (or any other) session no longer hides Claude and Codex. + */ +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"; 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 dea2196c..6d1c8c55 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -146,6 +146,7 @@ import { loadTabGroupLabels, resolveTabGroupLabel } from "../lib/tabGroups"; import { filterKeybindings, KEYBINDINGS, + loadAlwaysShowUsage, loadClaudeHooks, loadComposerRunner, loadDiffViewer, @@ -153,6 +154,7 @@ import { loadGridArcadeEnabled, loadLiveAgentsEnabled, loadNotesEnabled, + saveAlwaysShowUsage, saveClaudeHooks, saveComposerRunner, saveDiffViewer, @@ -322,6 +324,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. @@ -385,6 +388,11 @@ function GeneralPage({ setLiveAgentsEnabled(next); }; + const onAlwaysShowUsage = (next: boolean) => { + saveAlwaysShowUsage(next); + setAlwaysShowUsage(next); + }; + const onSoundsEnabled = (next: boolean) => { saveSoundsEnabled(next); setSoundsEnabled(next); @@ -492,6 +500,16 @@ function GeneralPage({ onChange={onLiveAgentsEnabled} /> + + + Date: Tue, 8 Sep 2026 18:13:19 -0300 Subject: [PATCH 2/7] feat: show Cursor and Grok usage in the footer Read the local Cursor.app and Grok CLI sessions so the pinned usage bar can show real Auto/API and credit percentages, with a shared muted reset countdown. Co-authored-by: Cursor --- src-tauri/src/cursor_usage.rs | 373 ++++++++++++++++++++++++++++++++++ src-tauri/src/grok_usage.rs | 167 +++++++++++++++ src-tauri/src/lib.rs | 4 + src/chrome/UsageFooter.tsx | 96 ++++++++- src/lib/rateLimits.test.ts | 223 +++++++++++++++++++- src/lib/rateLimits.ts | 235 ++++++++++++++++++++- src/lib/rateLimitsFetch.ts | 64 +++++- src/surfaces/SettingsView.tsx | 2 +- 8 files changed, 1136 insertions(+), 28 deletions(-) create mode 100644 src-tauri/src/cursor_usage.rs create mode 100644 src-tauri/src/grok_usage.rs diff --git a/src-tauri/src/cursor_usage.rs b/src-tauri/src/cursor_usage.rs new file mode 100644 index 00000000..784b18c9 --- /dev/null +++ b/src-tauri/src/cursor_usage.rs @@ -0,0 +1,373 @@ +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")] + { + return home.join("Library/Application Support/Cursor/User/globalStorage/state.vscdb"); + } + #[cfg(target_os = "windows")] + { + return 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() % 2 != 0 { + return None; + } + let ascii_utf16le = bytes + .chunks_exact(2) + .all(|pair| (1..128).contains(&pair[0]) && pair[1] == 0); + if !ascii_utf16le { + return String::from_utf16( + &bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(), + ) + .ok(); + } + String::from_utf16( + &bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .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_str("="), + 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")); + } +} 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 f78b0cea..0b42310b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,9 @@ use tauri::Manager; mod chat_background; mod checkpoint; mod cursor_store; +mod cursor_usage; mod fs; +mod grok_usage; mod harness; mod inbox_media; mod linear; @@ -279,6 +281,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/chrome/UsageFooter.tsx b/src/chrome/UsageFooter.tsx index 5e73729f..6b5c4c3d 100644 --- a/src/chrome/UsageFooter.tsx +++ b/src/chrome/UsageFooter.tsx @@ -1,10 +1,12 @@ import { RefreshCw } from "./icons"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { HarnessIcon } from "./HarnessIcon"; import { Popover } from "./Popover"; import { fetchClaudeRateLimits, fetchCodexRateLimits, + fetchCursorRateLimits, + fetchGrokRateLimits, } from "../lib/rateLimitsFetch"; import { clampUsedPercent, @@ -12,6 +14,7 @@ import { formatRateLimitWindowChipLabel, formatUsagePercent, idleRateLimits, + sharedWindowResetLabel, isRateLimitProvider, RATE_LIMIT_POLL_MS, rateLimitWindowTooltip, @@ -47,19 +50,31 @@ export function UsageFooter({ }) { const wantClaude = providers.includes("claude"); const wantCodex = providers.includes("codex"); + const wantCursor = providers.includes("cursor"); + const wantGrok = providers.includes("grok"); const [claude, setClaude] = useState(() => idleRateLimits("claude"), ); const [codex, setCodex] = useState(() => idleRateLimits("codex"), ); + const [cursor, setCursor] = useState(() => + idleRateLimits("cursor"), + ); + const [grok, setGrok] = useState(() => + idleRateLimits("grok"), + ); const [now, setNow] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); const inflight = useRef | null>(null); const claudeRef = useRef(claude); const codexRef = useRef(codex); + const cursorRef = useRef(cursor); + const grokRef = useRef(grok); claudeRef.current = claude; codexRef.current = codex; + cursorRef.current = cursor; + grokRef.current = grok; const refresh = useCallback((force = false) => { if (inflight.current) return inflight.current; @@ -70,7 +85,13 @@ export function UsageFooter({ const fetchCodex = wantCodex && shouldFetchProvider(codexRef.current, { force, visible }); - if (!fetchClaude && !fetchCodex) return; + const fetchCursor = + wantCursor && + shouldFetchProvider(cursorRef.current, { force, visible }); + const fetchGrok = + wantGrok && + shouldFetchProvider(grokRef.current, { force, visible }); + if (!fetchClaude && !fetchCodex && !fetchCursor && !fetchGrok) return; if (force) setRefreshing(true); const jobs: Promise[] = []; if (fetchClaude) { @@ -89,6 +110,22 @@ export function UsageFooter({ }), ); } + if (fetchCursor) { + setCursor((current) => fetchingRateLimits("cursor", current)); + jobs.push( + fetchCursorRateLimits().then((value) => { + setCursor(value); + }), + ); + } + if (fetchGrok) { + setGrok((current) => fetchingRateLimits("grok", current)); + jobs.push( + fetchGrokRateLimits().then((value) => { + setGrok(value); + }), + ); + } const run = Promise.allSettled(jobs) .then(() => undefined) .finally(() => { @@ -97,7 +134,7 @@ export function UsageFooter({ }); inflight.current = run; return run; - }, [wantClaude, wantCodex]); + }, [wantClaude, wantCodex, wantCursor, wantGrok]); useEffect(() => { void refresh(); @@ -117,10 +154,23 @@ export function UsageFooter({ return () => window.clearInterval(timer); }, []); - const showUsage = wantClaude || wantCodex; + const showUsage = wantClaude || wantCodex || wantCursor || wantGrok; + 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 a - // Cursor session still says "cursor" instead of vanishing behind them. + // 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 && !( @@ -143,8 +193,16 @@ export function UsageFooter({ 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" > {showSession && session ? : null} - {wantClaude ? : null} - {wantCodex ? : null} + {usageChips.length > 0 ? ( + + {usageChips.map((limits, index) => ( + + {index > 0 ? : null} + + + ))} + + ) : null} {showRight ? (
{showTerminals ? ( @@ -186,6 +244,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 b449fe22..5a77a00a 100644 --- a/src/lib/rateLimits.test.ts +++ b/src/lib/rateLimits.test.ts @@ -12,9 +12,12 @@ import { mapUsageWindow, parseClaudeOAuthUsage, parseCodexRateLimits, + parseCursorUsageSummary, + parseGrokBilling, parseResetTimestamp, RATE_LIMIT_MIN_REFETCH_MS, rateLimitWindowTooltip, + sharedWindowResetLabel, shouldFetchProvider, shouldFetchRateLimits, usageFooterProviders, @@ -84,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", () => { @@ -196,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", () => { @@ -314,6 +388,133 @@ describe("shouldFetchRateLimits", () => { }); }); +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( @@ -322,11 +523,17 @@ describe("usageFooterProviders", () => { 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: "cursor", alwaysShow: false }), + usageFooterProviders({ activeHarness: "opencode", alwaysShow: false }), ).toEqual([]); expect( usageFooterProviders({ activeHarness: undefined, alwaysShow: false }), @@ -338,14 +545,14 @@ describe("usageFooterProviders", () => { it("pins the full roster once the setting is on", () => { expect( - usageFooterProviders({ activeHarness: "cursor", alwaysShow: true }), - ).toEqual(["claude", "codex"]); + usageFooterProviders({ activeHarness: "opencode", alwaysShow: true }), + ).toEqual(["claude", "codex", "cursor", "grok"]); expect( usageFooterProviders({ activeHarness: "claude", alwaysShow: true }), - ).toEqual(["claude", "codex"]); + ).toEqual(["claude", "codex", "cursor", "grok"]); expect( usageFooterProviders({ activeHarness: undefined, alwaysShow: true }), - ).toEqual(["claude", "codex"]); + ).toEqual(["claude", "codex", "cursor", "grok"]); }); it("hands back a fresh array so callers cannot mutate the roster", () => { @@ -354,13 +561,17 @@ describe("usageFooterProviders", () => { 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(false); + 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 43b900c7..9099e773 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -1,25 +1,37 @@ 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`. Ordered the - * way the footer renders them. + * 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"]; +export const RATE_LIMIT_PROVIDERS: RateLimitProvider[] = [ + "claude", + "codex", + "cursor", + "grok", +]; export function isRateLimitProvider( value: unknown, ): value is RateLimitProvider { - return value === "claude" || value === "codex"; + 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 a - * Cursor (or any other) session no longer hides Claude and Codex. + * 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; @@ -39,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 = { @@ -84,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)) ); } @@ -220,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 { @@ -310,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..56444849 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -4,6 +4,8 @@ import { errorRateLimits, parseClaudeOAuthUsage, parseCodexRateLimits, + parseCursorUsageSummary, + parseGrokBilling, unavailableRateLimits, type ProviderRateLimits, } from "./rateLimits"; @@ -21,7 +23,7 @@ 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; @@ -30,7 +32,7 @@ type ClaudeUsageFetch = { export async function fetchClaudeRateLimits(): Promise { try { - const result = await invoke("fetch_claude_usage"); + const result = await invoke("fetch_claude_usage"); if (result.status === "ok" && result.body) { const parsed = parseClaudeOAuthUsage(result.body); if (parsed.session || parsed.weekly) return parsed; @@ -57,6 +59,64 @@ export async function fetchClaudeRateLimits(): Promise { } } +export async function fetchCursorRateLimits(): Promise { + try { + const result = await invoke("fetch_cursor_usage"); + if (result.status === "ok" && result.body) { + const parsed = parseCursorUsageSummary(result.body); + if (parsed.session || parsed.weekly) return parsed; + return { + ...parsed, + status: parsed.status === "ok" ? "ok" : parsed.status, + }; + } + if (result.status === "unavailable") { + return unavailableRateLimits( + "cursor", + result.error?.trim() || "Cursor not signed in", + ); + } + return errorRateLimits( + "cursor", + result.error?.trim() || "Cursor usage unavailable", + ); + } catch (error) { + return errorRateLimits( + "cursor", + error instanceof Error ? error.message : "Cursor usage unavailable", + ); + } +} + +export async function fetchGrokRateLimits(): Promise { + try { + const result = await invoke("fetch_grok_usage"); + if (result.status === "ok" && result.body) { + const parsed = parseGrokBilling(result.body); + if (parsed.session || parsed.weekly) return parsed; + return { + ...parsed, + status: parsed.status === "ok" ? "ok" : parsed.status, + }; + } + if (result.status === "unavailable") { + return unavailableRateLimits( + "grok", + result.error?.trim() || "Grok not signed in", + ); + } + return errorRateLimits( + "grok", + result.error?.trim() || "Grok usage unavailable", + ); + } catch (error) { + return errorRateLimits( + "grok", + error instanceof Error ? error.message : "Grok usage unavailable", + ); + } +} + export async function fetchCodexRateLimits(): Promise { let path: string; try { diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index 6d1c8c55..df645476 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -502,7 +502,7 @@ function GeneralPage({ Date: Wed, 9 Sep 2026 03:46:47 +0000 Subject: [PATCH 3/7] Fix Cursor JWT UTF-16LE false positive in usage decoder Return None when SQLite blob bytes are not ASCII UTF-16LE so even-length UTF-8 JWTs fall through to the UTF-8 path instead of mojibake. Share the duplicated invoke status handling across Claude, Cursor, and Grok fetchers. Co-authored-by: Felipe Orlando --- src-tauri/src/cursor_usage.rs | 37 +++++++++--- src/lib/rateLimitsFetch.ts | 104 ++++++++++++++-------------------- 2 files changed, 71 insertions(+), 70 deletions(-) diff --git a/src-tauri/src/cursor_usage.rs b/src-tauri/src/cursor_usage.rs index 784b18c9..5e3f9a63 100644 --- a/src-tauri/src/cursor_usage.rs +++ b/src-tauri/src/cursor_usage.rs @@ -188,13 +188,7 @@ fn decode_utf16le(bytes: &[u8]) -> Option { .chunks_exact(2) .all(|pair| (1..128).contains(&pair[0]) && pair[1] == 0); if !ascii_utf16le { - return String::from_utf16( - &bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) - .collect::>(), - ) - .ok(); + return None; } String::from_utf16( &bytes @@ -370,4 +364,33 @@ mod tests { .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/lib/rateLimitsFetch.ts b/src/lib/rateLimitsFetch.ts index 56444849..16eb007b 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -8,6 +8,7 @@ import { parseGrokBilling, unavailableRateLimits, type ProviderRateLimits, + type RateLimitProvider, } from "./rateLimits"; import { killChild, @@ -30,11 +31,19 @@ type UsageFetch = { 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, @@ -43,78 +52,47 @@ 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 { - try { - const result = await invoke("fetch_cursor_usage"); - if (result.status === "ok" && result.body) { - const parsed = parseCursorUsageSummary(result.body); - if (parsed.session || parsed.weekly) return parsed; - return { - ...parsed, - status: parsed.status === "ok" ? "ok" : parsed.status, - }; - } - if (result.status === "unavailable") { - return unavailableRateLimits( - "cursor", - result.error?.trim() || "Cursor not signed in", - ); - } - return errorRateLimits( - "cursor", - result.error?.trim() || "Cursor usage unavailable", - ); - } catch (error) { - return errorRateLimits( - "cursor", - error instanceof Error ? error.message : "Cursor usage unavailable", - ); - } + return fetchInvokeRateLimits( + "fetch_cursor_usage", + "cursor", + parseCursorUsageSummary, + "Cursor not signed in", + "Cursor usage unavailable", + ); } export async function fetchGrokRateLimits(): Promise { - try { - const result = await invoke("fetch_grok_usage"); - if (result.status === "ok" && result.body) { - const parsed = parseGrokBilling(result.body); - if (parsed.session || parsed.weekly) return parsed; - return { - ...parsed, - status: parsed.status === "ok" ? "ok" : parsed.status, - }; - } - if (result.status === "unavailable") { - return unavailableRateLimits( - "grok", - result.error?.trim() || "Grok not signed in", - ); - } - return errorRateLimits( - "grok", - result.error?.trim() || "Grok usage unavailable", - ); - } catch (error) { - return errorRateLimits( - "grok", - error instanceof Error ? error.message : "Grok usage unavailable", - ); - } + return fetchInvokeRateLimits( + "fetch_grok_usage", + "grok", + parseGrokBilling, + "Grok not signed in", + "Grok usage unavailable", + ); } export async function fetchCodexRateLimits(): Promise { From 1080e0c83eccefa8e0e79ba745a4292795ca234f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 19:20:47 +0000 Subject: [PATCH 4/7] Silence clippy lints in the Cursor usage decoder Replace the even-length check and UTF-16 chunking with is_multiple_of and as_chunks, and push a char when padding JWT payloads. Co-authored-by: Felipe Orlando --- src-tauri/src/cursor_usage.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/cursor_usage.rs b/src-tauri/src/cursor_usage.rs index 5e3f9a63..bec7d0f9 100644 --- a/src-tauri/src/cursor_usage.rs +++ b/src-tauri/src/cursor_usage.rs @@ -181,19 +181,20 @@ fn decode_sqlite_text(value: rusqlite::types::ValueRef<'_>) -> Option { } fn decode_utf16le(bytes: &[u8]) -> Option { - if bytes.len() < 2 || bytes.len() % 2 != 0 { + if bytes.len() < 2 || !bytes.len().is_multiple_of(2) { return None; } - let ascii_utf16le = bytes - .chunks_exact(2) + 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( - &bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + &pairs + .iter() + .map(|pair| u16::from_le_bytes(*pair)) .collect::>(), ) .ok() @@ -243,7 +244,7 @@ fn jwt_payload(token: &str) -> Option { let mut encoded = payload.replace('-', "+").replace('_', "/"); match encoded.len() % 4 { 2 => encoded.push_str("=="), - 3 => encoded.push_str("="), + 3 => encoded.push('='), 0 => {} _ => return None, } From 5304cc3d965770ef28d8e49de661cae3407c74b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 19:20:47 +0000 Subject: [PATCH 5/7] Keep provider usage polling outside the footer Move cache and polling into a module store so opening Settings, Search, Inbox, or Notes no longer drops snapshots or immediately re-fetches. Share the cache through localStorage so extra windows skip duplicate Codex probes while another fetch is in flight. Co-authored-by: Felipe Orlando --- src/chrome/UsageFooter.tsx | 127 +++------------ src/lib/rateLimitsStore.test.ts | 111 +++++++++++++ src/lib/rateLimitsStore.ts | 279 ++++++++++++++++++++++++++++++++ 3 files changed, 410 insertions(+), 107 deletions(-) create mode 100644 src/lib/rateLimitsStore.test.ts create mode 100644 src/lib/rateLimitsStore.ts diff --git a/src/chrome/UsageFooter.tsx b/src/chrome/UsageFooter.tsx index 6b5c4c3d..e6cf30bb 100644 --- a/src/chrome/UsageFooter.tsx +++ b/src/chrome/UsageFooter.tsx @@ -1,24 +1,20 @@ import { RefreshCw } from "./icons"; -import { Fragment, 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, - fetchCursorRateLimits, - fetchGrokRateLimits, -} from "../lib/rateLimitsFetch"; + getRateLimitsSnapshot, + refreshRateLimits, + setRateLimitProviders, + subscribeRateLimits, +} from "../lib/rateLimitsStore"; import { clampUsedPercent, - fetchingRateLimits, formatRateLimitWindowChipLabel, formatUsagePercent, - idleRateLimits, sharedWindowResetLabel, isRateLimitProvider, - RATE_LIMIT_POLL_MS, rateLimitWindowTooltip, - shouldFetchProvider, type ProviderRateLimits, type RateLimitProvider, type RateLimitWindow, @@ -48,113 +44,30 @@ export function UsageFooter({ terminalOpen?: boolean; onToggleTerminal?: (fileId: string) => void; }) { - const wantClaude = providers.includes("claude"); - const wantCodex = providers.includes("codex"); - const wantCursor = providers.includes("cursor"); - const wantGrok = providers.includes("grok"); - const [claude, setClaude] = useState(() => - idleRateLimits("claude"), - ); - const [codex, setCodex] = useState(() => - idleRateLimits("codex"), - ); - const [cursor, setCursor] = useState(() => - idleRateLimits("cursor"), - ); - const [grok, setGrok] = useState(() => - idleRateLimits("grok"), + 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); - const cursorRef = useRef(cursor); - const grokRef = useRef(grok); - claudeRef.current = claude; - codexRef.current = codex; - cursorRef.current = cursor; - grokRef.current = grok; - - 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 }); - const fetchCursor = - wantCursor && - shouldFetchProvider(cursorRef.current, { force, visible }); - const fetchGrok = - wantGrok && - shouldFetchProvider(grokRef.current, { force, visible }); - if (!fetchClaude && !fetchCodex && !fetchCursor && !fetchGrok) 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); - }), - ); - } - if (fetchCursor) { - setCursor((current) => fetchingRateLimits("cursor", current)); - jobs.push( - fetchCursorRateLimits().then((value) => { - setCursor(value); - }), - ); - } - if (fetchGrok) { - setGrok((current) => fetchingRateLimits("grok", current)); - jobs.push( - fetchGrokRateLimits().then((value) => { - setGrok(value); - }), - ); - } - const run = Promise.allSettled(jobs) - .then(() => undefined) - .finally(() => { - inflight.current = null; - setRefreshing(false); - }); - inflight.current = run; - return run; - }, [wantClaude, wantCodex, wantCursor, wantGrok]); 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 || wantCursor || wantGrok; + const showUsage = providers.length > 0; const usageChips = providers .map((provider) => provider === "claude" diff --git a/src/lib/rateLimitsStore.test.ts b/src/lib/rateLimitsStore.test.ts new file mode 100644 index 00000000..927d0640 --- /dev/null +++ b/src/lib/rateLimitsStore.test.ts @@ -0,0 +1,111 @@ +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("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..0d59e607 --- /dev/null +++ b/src/lib/rateLimitsStore.ts @@ -0,0 +1,279 @@ +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) return inflight; + 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; +} From 30bb19dc3166b742946ba707be89db0096c8f1c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 19:32:56 +0000 Subject: [PATCH 6/7] Drop needless returns in Cursor state-db path helpers macOS and Windows clippy treat the cfg blocks as the function tail, so the explicit return trips needless_return under -D warnings. Co-authored-by: Felipe Orlando --- src-tauri/src/cursor_usage.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/cursor_usage.rs b/src-tauri/src/cursor_usage.rs index bec7d0f9..af90caa0 100644 --- a/src-tauri/src/cursor_usage.rs +++ b/src-tauri/src/cursor_usage.rs @@ -137,11 +137,11 @@ pub(crate) fn cursor_state_db_path_for(home: &str) -> PathBuf { let home = Path::new(home); #[cfg(target_os = "macos")] { - return home.join("Library/Application Support/Cursor/User/globalStorage/state.vscdb"); + home.join("Library/Application Support/Cursor/User/globalStorage/state.vscdb") } #[cfg(target_os = "windows")] { - return home.join("AppData/Roaming/Cursor/User/globalStorage/state.vscdb"); + home.join("AppData/Roaming/Cursor/User/globalStorage/state.vscdb") } #[cfg(not(any(target_os = "macos", target_os = "windows")))] { From 6ba2b30ac9fa52fcefc311a3a3bcda45447469b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 19:43:05 +0000 Subject: [PATCH 7/7] Queue forced usage refresh after an in-flight poll Clicking refresh while a background fetch is running now waits for that fetch and then refetches, instead of returning the stale poll. Co-authored-by: Felipe Orlando --- src/lib/rateLimitsStore.test.ts | 26 ++++++++++++++++++++++++++ src/lib/rateLimitsStore.ts | 8 +++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/lib/rateLimitsStore.test.ts b/src/lib/rateLimitsStore.test.ts index 927d0640..f237b29f 100644 --- a/src/lib/rateLimitsStore.test.ts +++ b/src/lib/rateLimitsStore.test.ts @@ -82,6 +82,32 @@ describe("rateLimitsStore", () => { 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({ diff --git a/src/lib/rateLimitsStore.ts b/src/lib/rateLimitsStore.ts index 0d59e607..1009e97a 100644 --- a/src/lib/rateLimitsStore.ts +++ b/src/lib/rateLimitsStore.ts @@ -218,7 +218,13 @@ export function setRateLimitProviders(providers: RateLimitProvider[]) { export function refreshRateLimits(force = false): Promise | undefined { ensureStarted(); - if (inflight) return inflight; + 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) =>