From d45f8ca1ee0ed5564a818b78ed85788452ff9f99 Mon Sep 17 00:00:00 2001 From: Rtas Date: Sat, 25 Jul 2026 21:35:53 +0100 Subject: [PATCH 1/2] feat: cursor usage meter Adds a Cursor usage meter alongside Claude Code, Codex, and Antigravity. Cursor stores the signed-in JWT in its local state.vscdb and exposes a JSON usage summary at cursor.com/api/usage-summary - no HTML scraping needed. - src/models.rs: new optional 'cursor' field on AppUsageData - src/poller.rs: read cursorAuth/accessToken from state.vscdb via rusqlite (bundled), with CURSOR_SESSION_TOKEN env fallback, build the WorkosCursorSessionToken cookie (user id decoded from the JWT sub claim joined with the token via ::), and map autoPercentUsed / apiPercentUsed onto the two bars. billingCycleEnd is the shared reset for both. Includes a unit test for the mapping (cursor_usage_maps_auto_and_api_percentages). - src/window.rs: new bar in the widget, Models -> Cursor toggle, tray tooltip uses "Auto: ... | API: ..." wording. When Cursor is the only model shown, the row labels switch from 5h/7d to Auto/API since Cursor has no rolling 5-hour window. Accent is near-black, inverted for dark taskbars. - src/localization/*: new cursor_model, cursor_window_title, cursor_auto_window, cursor_api_window strings across all locales - Cargo.toml: rusqlite (bundled) to read the Cursor IDE state DB Verified locally: cargo test --bin claude-code-usage-monitor passes 6/6 (including the new Cursor mapping test), release build unaffected. Co-authored-by: Cursor --- Cargo.lock | 157 +++++++++++- Cargo.toml | 1 + src/localization/dutch.rs | 4 + src/localization/english.rs | 4 + src/localization/french.rs | 4 + src/localization/german.rs | 4 + src/localization/japanese.rs | 4 + src/localization/korean.rs | 4 + src/localization/mod.rs | 4 + src/localization/portuguese_brazil.rs | 4 + src/localization/russian.rs | 4 + src/localization/simplified_chinese.rs | 4 + src/localization/spanish.rs | 4 + src/localization/traditional_chinese.rs | 4 + src/models.rs | 2 + src/poller.rs | 303 +++++++++++++++++++++++- src/window.rs | 259 ++++++++++++++++++-- 17 files changed, 751 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7457e2f..8926210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "cc" version = "1.2.56" @@ -42,6 +48,7 @@ version = "1.4.9" dependencies = [ "dirs", "native-tls", + "rusqlite", "serde", "serde_json", "ureq", @@ -113,6 +120,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -131,6 +150,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -185,7 +210,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -193,6 +218,27 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -326,6 +372,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -348,6 +404,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -511,6 +578,31 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustix" version = "1.1.4" @@ -524,6 +616,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "schannel" version = "0.1.28" @@ -617,6 +715,18 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -772,6 +882,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index 7882510..dcbd9b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ native-tls = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" +rusqlite = { version = "0.40.1", features = ["bundled"] } [dependencies.windows] version = "0.58" diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..6def93f 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "Instellingen", start_with_windows: "Opstarten met Windows", reset_position: "Positie herstellen", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "Widget tonen", session_window: "5u", weekly_window: "7d", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "nu", day_suffix: "d", hour_suffix: "u", @@ -46,5 +49,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Open Antigravity en meld je opnieuw aan. Ververs of herstart de app daarna.", codex_window_title: "Codex-gebruiksmonitor", antigravity_window_title: "Antigravity-gebruiksmonitor", + cursor_window_title: "Cursor-gebruiksmonitor", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..1895161 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "Settings", start_with_windows: "Start with Windows", reset_position: "Reset Position", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "Show Widget", session_window: "5h", weekly_window: "7d", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "now", day_suffix: "d", hour_suffix: "h", @@ -46,5 +49,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Open Antigravity and sign in again. After that, refresh or restart this app.", codex_window_title: "Codex Usage Monitor", antigravity_window_title: "Antigravity Usage Monitor", + cursor_window_title: "Cursor Usage Monitor", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..d9685dc 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "Paramètres", start_with_windows: "Démarrer avec Windows", reset_position: "Réinitialiser la position", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "Afficher le widget", session_window: "5h", weekly_window: "7d", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "maintenant", day_suffix: "j", hour_suffix: "h", @@ -46,5 +49,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Ouvrez Antigravity et reconnectez-vous. Ensuite, actualisez ou redemarrez cette application.", codex_window_title: "Moniteur d'utilisation Codex", antigravity_window_title: "Moniteur d'utilisation Antigravity", + cursor_window_title: "Moniteur d'utilisation Cursor", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..015f032 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "Einstellungen", start_with_windows: "Mit Windows starten", reset_position: "Position zurücksetzen", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "Widget anzeigen", session_window: "5h", weekly_window: "7d", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "jetzt", day_suffix: "T", hour_suffix: "h", @@ -46,5 +49,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Offnen Sie Antigravity und melden Sie sich erneut an. Aktualisieren oder starten Sie diese App anschliessend neu.", codex_window_title: "Codex-Nutzungsmonitor", antigravity_window_title: "Antigravity-Nutzungsmonitor", + cursor_window_title: "Cursor-Nutzungsmonitor", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..595cc53 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "設定", start_with_windows: "Windows と同時に開始", reset_position: "位置をリセット", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "ウィジェットを表示", session_window: "5h", weekly_window: "7d", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "今", day_suffix: "日", hour_suffix: "時間", @@ -46,5 +49,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Antigravity を開いて再度サインインしてください。その後、このアプリを更新するか再起動してください。", codex_window_title: "Codex 使用量モニター", antigravity_window_title: "Antigravity 使用量モニター", + cursor_window_title: "Cursor 使用量モニター", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..6de1f1e 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "설정", start_with_windows: "Windows 시작 시 자동 실행", reset_position: "위치 초기화", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "위젯 표시", session_window: "5시간", weekly_window: "7일", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "지금", day_suffix: "일", hour_suffix: "시간", @@ -46,5 +49,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Antigravity를 열고 다시 로그인하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", antigravity_window_title: "Antigravity 사용량 모니터", + cursor_window_title: "Cursor 사용량 모니터", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index a11ba02..8e9b55e 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -156,6 +156,7 @@ pub struct Strings { pub claude_code_model: &'static str, pub codex_model: &'static str, pub antigravity_model: &'static str, + pub cursor_model: &'static str, pub settings: &'static str, pub start_with_windows: &'static str, pub reset_position: &'static str, @@ -176,6 +177,8 @@ pub struct Strings { pub show_widget: &'static str, pub session_window: &'static str, pub weekly_window: &'static str, + pub cursor_auto_window: &'static str, + pub cursor_api_window: &'static str, pub now: &'static str, pub day_suffix: &'static str, pub hour_suffix: &'static str, @@ -189,6 +192,7 @@ pub struct Strings { pub antigravity_token_expired_body: &'static str, pub codex_window_title: &'static str, pub antigravity_window_title: &'static str, + pub cursor_window_title: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..4a953fa 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "Configurações", start_with_windows: "Iniciar com o Windows", reset_position: "Redefinir Posição", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "Exibir Widget", session_window: "5h", weekly_window: "7d", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "agora", day_suffix: "d", hour_suffix: "h", @@ -47,4 +50,5 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Abra o Antigravity e entre novamente. Depois disso, atualize ou reinicie este aplicativo.", codex_window_title: "Monitor de uso do Codex", antigravity_window_title: "Monitor de uso do Antigravity", + cursor_window_title: "Monitor de uso do Cursor", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..d1f9fed 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "Настройки", start_with_windows: "Запускать вместе с Windows", reset_position: "Сбросить позицию", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "Показать виджет", session_window: "5ч", weekly_window: "7д", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "сейчас", day_suffix: "д", hour_suffix: "ч", @@ -47,4 +50,5 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Откройте Antigravity и войдите снова. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", antigravity_window_title: "Монитор использования Antigravity", + cursor_window_title: "Монитор использования Cursor", }; diff --git a/src/localization/simplified_chinese.rs b/src/localization/simplified_chinese.rs index 8a62588..d250757 100644 --- a/src/localization/simplified_chinese.rs +++ b/src/localization/simplified_chinese.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "设置", start_with_windows: "开机时启动", reset_position: "重置位置", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "显示小组件", session_window: "5h", weekly_window: "7d", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "现在", day_suffix: "天", hour_suffix: "时", @@ -46,5 +49,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "请打开 Antigravity 并重新登录。完成后,请刷新或重新启动此应用程序。", codex_window_title: "Codex 使用量监控", antigravity_window_title: "Antigravity 使用量监控", + cursor_window_title: "Cursor 使用量监控", second_suffix: "秒", }; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..057d8b1 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "Configuración", start_with_windows: "Iniciar con Windows", reset_position: "Restablecer posición", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "Mostrar widget", session_window: "5h", weekly_window: "7d", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "ahora", day_suffix: "d", hour_suffix: "h", @@ -46,5 +49,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Abre Antigravity e inicia sesion otra vez. Despues, actualiza o reinicia esta aplicacion.", codex_window_title: "Monitor de uso de Codex", antigravity_window_title: "Monitor de uso de Antigravity", + cursor_window_title: "Monitor de uso de Cursor", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..8e44033 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -14,6 +14,7 @@ pub(super) const STRINGS: Strings = Strings { claude_code_model: "Claude Code", codex_model: "Codex", antigravity_model: "Antigravity", + cursor_model: "Cursor", settings: "設定", start_with_windows: "開機時啟動", reset_position: "重置位置", @@ -34,6 +35,8 @@ pub(super) const STRINGS: Strings = Strings { show_widget: "顯示小工具", session_window: "5h", weekly_window: "7d", + cursor_auto_window: "Auto", + cursor_api_window: "API", now: "現在", day_suffix: "天", hour_suffix: "時", @@ -46,5 +49,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "請開啟 Antigravity 並重新登入。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", antigravity_window_title: "Antigravity 使用量監控", + cursor_window_title: "Cursor 使用量監控", second_suffix: "秒", }; diff --git a/src/models.rs b/src/models.rs index da49ef1..77ee473 100644 --- a/src/models.rs +++ b/src/models.rs @@ -17,4 +17,6 @@ pub struct AppUsageData { pub claude_code: Option, pub codex: Option, pub antigravity: Option, + /// Cursor plan usage. Auto maps to `session`, API maps to `weekly`. + pub cursor: Option, } diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..4ab2855 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -8,6 +8,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Deserialize; use std::os::windows::process::CommandExt; +use rusqlite::OptionalExtension; use crate::diagnose; use crate::localization::Strings; @@ -175,14 +176,17 @@ pub fn poll( show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_cursor: bool, ) -> Result { poll_with( show_claude_code, show_codex, show_antigravity, + show_cursor, poll_claude_code, poll_codex, poll_antigravity, + poll_cursor, ) } @@ -190,13 +194,18 @@ fn poll_with( show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_cursor: bool, mut poll_claude_code: impl FnMut() -> Result, mut poll_codex: impl FnMut() -> Result, mut poll_antigravity: impl FnMut() -> Result, + mut poll_cursor: impl FnMut() -> Result, ) -> Result { let mut data = AppUsageData::default(); let mut first_error = None; - let active_provider_count = show_claude_code as u8 + show_codex as u8 + show_antigravity as u8; + let active_provider_count = show_claude_code as u8 + + show_codex as u8 + + show_antigravity as u8 + + show_cursor as u8; if show_claude_code { match poll_claude_code() { @@ -234,7 +243,23 @@ fn poll_with( } } - if data.claude_code.is_none() && data.codex.is_none() && data.antigravity.is_none() { + if show_cursor { + match poll_cursor() { + Ok(cursor) => data.cursor = Some(cursor), + Err(error) => { + if active_provider_count > 1 { + diagnose::log(format!("Cursor usage poll failed: {error:?}")); + } + first_error.get_or_insert(error); + } + } + } + + if data.claude_code.is_none() + && data.codex.is_none() + && data.antigravity.is_none() + && data.cursor.is_none() + { Err(first_error.unwrap_or(PollError::RequestFailed)) } else { Ok(data) @@ -287,6 +312,19 @@ fn poll_antigravity() -> Result { fetch_antigravity_usage(&creds.access_token) } +fn poll_cursor() -> Result { + let cookie = match read_cursor_session_cookie() { + Some(cookie) if !cookie.is_empty() => cookie, + _ => { + diagnose::log( + "Cursor usage poll failed: no Cursor session found (sign in to Cursor or set CURSOR_SESSION_TOKEN)", + ); + return Err(PollError::NoCredentials); + } + }; + fetch_cursor_usage(&cookie) +} + fn refresh_or_fallback(mut creds: Credentials) -> Result { loop { if !is_token_expired(creds.expires_at) { @@ -1147,6 +1185,235 @@ fn is_antigravity_display_model(model: &str) -> bool { || model.starts_with("imagen") } +const CURSOR_USAGE_SUMMARY_URL: &str = "https://cursor.com/api/usage-summary"; + +#[derive(Deserialize)] +struct CursorUsageSummaryResponse { + #[serde(rename = "billingCycleEnd")] + billing_cycle_end: Option, + #[serde(rename = "individualUsage")] + individual_usage: Option, +} + +#[derive(Deserialize)] +struct CursorIndividualUsage { + plan: Option, +} + +#[derive(Deserialize)] +struct CursorPlanUsage { + #[serde(rename = "autoPercentUsed")] + auto_percent_used: Option, + #[serde(rename = "apiPercentUsed")] + api_percent_used: Option, + #[serde(rename = "totalPercentUsed")] + total_percent_used: Option, +} + +/// Resolve a Cursor session cookie for `cursor.com` dashboard APIs. +/// +/// Priority: +/// 1. `CURSOR_SESSION_TOKEN` env (raw cookie value or JWT) +/// 2. Access token from Cursor IDE `state.vscdb` (`cursorAuth/accessToken`) +fn read_cursor_session_cookie() -> Option { + if let Ok(token) = std::env::var("CURSOR_SESSION_TOKEN") { + let token = token.trim(); + if !token.is_empty() { + return Some(normalize_cursor_session_cookie(token)); + } + } + + let access_token = read_cursor_access_token_from_state_db()?; + cursor_cookie_from_access_token(&access_token) +} + +fn normalize_cursor_session_cookie(token: &str) -> String { + let token = token + .trim() + .trim_start_matches("WorkosCursorSessionToken=") + .trim(); + if token.contains("%3A%3A") || token.contains("::") { + token.replace("::", "%3A%3A") + } else if let Some(cookie) = cursor_cookie_from_access_token(token) { + cookie + } else { + token.to_string() + } +} + +fn cursor_cookie_from_access_token(access_token: &str) -> Option { + let user_id = extract_cursor_user_id(access_token)?; + Some(format!("{user_id}%3A%3A{access_token}")) +} + +fn extract_cursor_user_id(jwt: &str) -> Option { + let payload = jwt.split('.').nth(1)?; + let padded = match payload.len() % 4 { + 2 => format!("{payload}=="), + 3 => format!("{payload}="), + _ => payload.to_string(), + }; + let decoded = base64_url_decode(&padded)?; + let json: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + let sub = json.get("sub")?.as_str()?; + Some( + sub.rsplit_once('|') + .map(|(_, id)| id.to_string()) + .unwrap_or_else(|| sub.to_string()), + ) +} + +fn base64_url_decode(input: &str) -> Option> { + let mut standard = input.replace('-', "+").replace('_', "/"); + while standard.len() % 4 != 0 { + standard.push('='); + } + base64_decode(&standard) +} + +fn base64_decode(input: &str) -> Option> { + const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut output = Vec::with_capacity(input.len() * 3 / 4); + let mut buf = 0u32; + let mut bits = 0u32; + for byte in input.bytes() { + if byte == b'=' { + break; + } + let value = TABLE.iter().position(|&c| c == byte)? as u32; + buf = (buf << 6) | value; + bits += 6; + if bits >= 8 { + bits -= 8; + output.push(((buf >> bits) & 0xff) as u8); + } + } + Some(output) +} + +fn cursor_state_db_path() -> Option { + // Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb + if let Some(roaming) = dirs::config_dir() { + let path = roaming + .join("Cursor") + .join("User") + .join("globalStorage") + .join("state.vscdb"); + if path.exists() { + return Some(path); + } + } + None +} + +fn read_cursor_access_token_from_state_db() -> Option { + let path = cursor_state_db_path()?; + match query_cursor_access_token(&path) { + Ok(token) => token, + Err(error) => { + // Cursor may hold a write lock; copy then retry read-only. + diagnose::log(format!( + "Cursor state DB direct read failed ({error}); retrying via temp copy" + )); + let temp = std::env::temp_dir().join(format!( + "claude-monitor-cursor-state-{}.vscdb", + std::process::id() + )); + std::fs::copy(&path, &temp).ok()?; + let result = query_cursor_access_token(&temp); + let _ = std::fs::remove_file(&temp); + match result { + Ok(token) => token, + Err(copy_error) => { + diagnose::log(format!( + "Cursor state DB temp-copy read failed: {copy_error}" + )); + None + } + } + } + } +} + +fn query_cursor_access_token(path: &std::path::Path) -> Result, String> { + let conn = rusqlite::Connection::open_with_flags( + path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare("SELECT value FROM ItemTable WHERE key = ?1") + .map_err(|e| e.to_string())?; + let value: Option = stmt + .query_row(["cursorAuth/accessToken"], |row| row.get(0)) + .optional() + .map_err(|e| e.to_string())?; + Ok(value.filter(|token| !token.is_empty())) +} + +fn fetch_cursor_usage(cookie: &str) -> Result { + let agent = build_agent()?; + let cookie_header = if cookie.starts_with("WorkosCursorSessionToken=") { + cookie.to_string() + } else { + format!("WorkosCursorSessionToken={cookie}") + }; + + let resp = match agent + .get(CURSOR_USAGE_SUMMARY_URL) + .set("Cookie", &cookie_header) + .set("User-Agent", "Mozilla/5.0") + .call() + { + Ok(resp) => resp, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + diagnose::log(format!( + "Cursor usage-summary returned auth error status {code}" + )); + return Err(PollError::AuthRequired); + } + Err(error) => { + diagnose::log_error("Cursor usage-summary request failed", error); + return Err(PollError::RequestFailed); + } + }; + + let response: CursorUsageSummaryResponse = match resp.into_json() { + Ok(response) => response, + Err(error) => { + diagnose::log_error("unable to parse Cursor usage-summary response", error); + return Err(PollError::RequestFailed); + } + }; + + cursor_usage_from_summary(response).ok_or_else(|| { + diagnose::log("Cursor usage-summary response missing plan usage"); + PollError::RequestFailed + }) +} + +fn cursor_usage_from_summary(response: CursorUsageSummaryResponse) -> Option { + let plan = response.individual_usage?.plan?; + let reset = parse_iso8601(response.billing_cycle_end.as_deref()); + let auto = plan + .auto_percent_used + .or(plan.total_percent_used) + .unwrap_or(0.0) + .clamp(0.0, 100.0); + let api = plan.api_percent_used.unwrap_or(0.0).clamp(0.0, 100.0); + + Some(UsageData { + session: UsageSection { + percentage: auto, + resets_at: reset, + }, + weekly: UsageSection { + percentage: api, + resets_at: reset, + }, + }) +} + fn get_header_f64(response: &ureq::Response, name: &str) -> f64 { response .header(name) @@ -1598,6 +1865,7 @@ pub fn app_is_past_reset(data: &AppUsageData) -> bool { data.claude_code.as_ref().is_some_and(is_past_reset) || data.codex.as_ref().is_some_and(is_past_reset) || data.antigravity.as_ref().is_some_and(is_past_reset) + || data.cursor.as_ref().is_some_and(is_past_reset) } #[cfg(test)] @@ -1620,9 +1888,11 @@ mod tests { true, true, false, + false, || Err(PollError::AuthRequired), || Ok(usage_with_session_percent(42.0)), || unreachable!("antigravity is disabled"), + || unreachable!("cursor is disabled"), ) .expect("codex data should keep the poll successful"); @@ -1636,9 +1906,11 @@ mod tests { true, true, false, + false, || Ok(usage_with_session_percent(64.0)), || Err(PollError::RequestFailed), || unreachable!("antigravity is disabled"), + || unreachable!("cursor is disabled"), ) .expect("claude data should keep the poll successful"); @@ -1652,9 +1924,11 @@ mod tests { true, true, true, + false, || Err(PollError::AuthRequired), || Err(PollError::RequestFailed), || Err(PollError::NoCredentials), + || unreachable!("cursor is disabled"), ) .expect_err("all-provider failure should return an error"); @@ -1667,9 +1941,11 @@ mod tests { false, true, true, + false, || unreachable!("claude code is disabled"), || Ok(usage_with_session_percent(42.0)), || Err(PollError::NoCredentials), + || unreachable!("cursor is disabled"), ) .expect("codex data should keep the poll successful"); @@ -1677,6 +1953,29 @@ mod tests { assert_eq!(data.codex.unwrap().session.percentage, 42.0); } + #[test] + fn cursor_usage_maps_auto_and_api_percentages() { + let response: CursorUsageSummaryResponse = serde_json::from_str( + r#"{ + "billingCycleEnd": "2026-08-25T19:27:24.000Z", + "individualUsage": { + "plan": { + "autoPercentUsed": 12.5, + "apiPercentUsed": 3.0, + "totalPercentUsed": 10.0 + } + } + }"#, + ) + .expect("fixture should parse"); + + let data = cursor_usage_from_summary(response).expect("plan usage should map"); + assert_eq!(data.session.percentage, 12.5); + assert_eq!(data.weekly.percentage, 3.0); + assert!(data.session.resets_at.is_some()); + assert_eq!(data.session.resets_at, data.weekly.resets_at); + } + #[test] fn antigravity_summary_prefers_gemini_group() { let response: AntigravityQuotaSummaryResponse = serde_json::from_str( diff --git a/src/window.rs b/src/window.rs index bf30f88..6416e2e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -67,9 +67,14 @@ struct AppState { antigravity_session_text: String, antigravity_weekly_percent: f64, antigravity_weekly_text: String, + cursor_session_percent: f64, + cursor_session_text: String, + cursor_weekly_percent: f64, + cursor_weekly_text: String, show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_cursor: bool, data: Option, @@ -132,6 +137,7 @@ const IDM_LANG_SIMPLIFIED_CHINESE: u16 = 51; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; +const IDM_MODEL_CURSOR: u16 = 63; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -317,6 +323,8 @@ struct SettingsFile { show_codex: bool, #[serde(default = "default_show_antigravity")] show_antigravity: bool, + #[serde(default = "default_show_cursor")] + show_cursor: bool, } impl Default for SettingsFile { @@ -331,6 +339,7 @@ impl Default for SettingsFile { show_claude_code: true, show_codex: false, show_antigravity: false, + show_cursor: false, } } } @@ -355,13 +364,21 @@ fn default_show_antigravity() -> bool { false } +fn default_show_cursor() -> bool { + false +} + fn load_settings() -> SettingsFile { let content = match std::fs::read_to_string(settings_path()) { Ok(c) => c, Err(_) => return SettingsFile::default(), }; let mut settings: SettingsFile = serde_json::from_str(&content).unwrap_or_default(); - if !settings.show_claude_code && !settings.show_codex && !settings.show_antigravity { + if !settings.show_claude_code + && !settings.show_codex + && !settings.show_antigravity + && !settings.show_cursor + { settings.show_claude_code = true; } settings @@ -392,6 +409,7 @@ fn save_state_settings() { show_claude_code: s.show_claude_code, show_codex: s.show_codex, show_antigravity: s.show_antigravity, + show_cursor: s.show_cursor, }); } } @@ -437,6 +455,18 @@ fn tray_icon_data_from_state() -> Vec { ), }); } + if s.show_cursor { + icons.push(tray_icon::TrayIconData { + kind: tray_icon::TrayIconKind::Codex, + percent: Some(s.cursor_session_percent), + tooltip: format!( + "{} Auto: {} | API: {}", + s.language.strings().cursor_model, + s.cursor_session_text, + s.cursor_weekly_text + ), + }); + } icons } Some(s) => { @@ -462,6 +492,13 @@ fn tray_icon_data_from_state() -> Vec { tooltip: s.language.strings().antigravity_window_title.to_string(), }); } + if s.show_cursor { + icons.push(tray_icon::TrayIconData { + kind: tray_icon::TrayIconKind::Codex, + percent: None, + tooltip: s.language.strings().cursor_window_title.to_string(), + }); + } icons } None => Vec::new(), @@ -673,6 +710,14 @@ fn refresh_usage_texts(state: &mut AppState) { state.antigravity_session_text = "!".to_string(); state.antigravity_weekly_text = "!".to_string(); } + + if let Some(cursor) = data.cursor.as_ref() { + state.cursor_session_text = poller::format_line(&cursor.session, strings); + state.cursor_weekly_text = poller::format_line(&cursor.weekly, strings); + } else if state.show_cursor { + state.cursor_session_text = "!".to_string(); + state.cursor_weekly_text = "!".to_string(); + } } fn set_window_title(hwnd: HWND, strings: Strings) { @@ -1053,7 +1098,7 @@ const CORNER_RADIUS: i32 = 2; const LEFT_DIVIDER_W: i32 = 3; const DIVIDER_RIGHT_MARGIN: i32 = 10; -const LABEL_WIDTH: i32 = 18; +const LABEL_WIDTH: i32 = 28; const LABEL_RIGHT_MARGIN: i32 = 10; const BAR_RIGHT_MARGIN: i32 = 4; const TEXT_WIDTH: i32 = 62; @@ -1080,8 +1125,14 @@ fn cursor_is_on_drag_handle(hwnd: HWND) -> bool { } } -fn active_model_count(show_claude_code: bool, show_codex: bool, show_antigravity: bool) -> i32 { - (show_claude_code as i32 + show_codex as i32 + show_antigravity as i32).max(1) +fn active_model_count( + show_claude_code: bool, + show_codex: bool, + show_antigravity: bool, + show_cursor: bool, +) -> i32 { + (show_claude_code as i32 + show_codex as i32 + show_antigravity as i32 + show_cursor as i32) + .max(1) } fn row_bar_segment_count(active_models: i32) -> i32 { @@ -1112,6 +1163,7 @@ fn total_widget_width_for_state(state: &AppState) -> i32 { state.show_claude_code, state.show_codex, state.show_antigravity, + state.show_cursor, )) } @@ -1120,7 +1172,14 @@ fn total_widget_width() -> i32 { let state = lock_state(); state .as_ref() - .map(|s| active_model_count(s.show_claude_code, s.show_codex, s.show_antigravity)) + .map(|s| { + active_model_count( + s.show_claude_code, + s.show_codex, + s.show_antigravity, + s.show_cursor, + ) + }) .unwrap_or(1) }; total_widget_width_for(active_models) @@ -1142,6 +1201,15 @@ fn antigravity_accent_color() -> Color { Color::from_hex("#4285F4") } +fn cursor_accent_color(is_dark: bool) -> Color { + // Cursor branding is near-black; invert for dark taskbars so the bar stays visible. + if is_dark { + Color::from_hex("#F5F5F5") + } else { + Color::from_hex("#1A1A1A") + } +} + fn claude_usage_text_color(is_dark: bool) -> Color { if is_dark { Color::from_hex("#F09A7A") @@ -1166,6 +1234,14 @@ fn antigravity_usage_text_color(is_dark: bool) -> Color { } } +fn cursor_usage_text_color(is_dark: bool) -> Color { + if is_dark { + Color::from_hex("#E0E0E0") + } else { + Color::from_hex("#1A1A1A") + } +} + pub fn run() { // Enable Per-Monitor DPI Awareness V2 for crisp rendering at any scale factor unsafe { @@ -1245,6 +1321,7 @@ pub fn run() { settings.show_claude_code, settings.show_codex, settings.show_antigravity, + settings.show_cursor, ); let hwnd = CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_NOACTIVATE, @@ -1308,9 +1385,14 @@ pub fn run() { antigravity_session_text: "--".to_string(), antigravity_weekly_percent: 0.0, antigravity_weekly_text: "--".to_string(), + cursor_session_percent: 0.0, + cursor_session_text: "--".to_string(), + cursor_weekly_percent: 0.0, + cursor_weekly_text: "--".to_string(), show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, show_antigravity: settings.show_antigravity, + show_cursor: settings.show_cursor, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1433,9 +1515,14 @@ fn render_layered() { antigravity_session_text, antigravity_weekly_pct, antigravity_weekly_text, + cursor_session_pct, + cursor_session_text, + cursor_weekly_pct, + cursor_weekly_text, show_claude_code, show_codex, show_antigravity, + show_cursor, ) = { let state = lock_state(); match state.as_ref() { @@ -1456,9 +1543,14 @@ fn render_layered() { s.antigravity_session_text.clone(), s.antigravity_weekly_percent, s.antigravity_weekly_text.clone(), + s.cursor_session_percent, + s.cursor_session_text.clone(), + s.cursor_weekly_percent, + s.cursor_weekly_text.clone(), s.show_claude_code, s.show_codex, s.show_antigravity, + s.show_cursor, ), None => return, } @@ -1480,6 +1572,7 @@ fn render_layered() { let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); let antigravity_accent = antigravity_accent_color(); + let cursor_accent = cursor_accent_color(is_dark); let track = if is_dark { Color::from_hex("#444444") } else { @@ -1551,11 +1644,17 @@ fn render_layered() { &antigravity_session_text, antigravity_weekly_pct, &antigravity_weekly_text, + cursor_session_pct, + &cursor_session_text, + cursor_weekly_pct, + &cursor_weekly_text, show_claude_code, show_codex, show_antigravity, + show_cursor, &codex_accent, &antigravity_accent, + &cursor_accent, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1627,11 +1726,17 @@ fn paint_content( antigravity_session_text: &str, antigravity_weekly_pct: f64, antigravity_weekly_text: &str, + cursor_session_pct: f64, + cursor_session_text: &str, + cursor_weekly_pct: f64, + cursor_weekly_text: &str, show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_cursor: bool, codex_accent: &Color, antigravity_accent: &Color, + cursor_accent: &Color, ) { unsafe { let client_rect = RECT { @@ -1708,25 +1813,36 @@ fn paint_content( ); let old_font = SelectObject(hdc, font); + let classic_windows = show_claude_code || show_codex || show_antigravity; + let (session_label, weekly_label) = if show_cursor && !classic_windows { + (strings.cursor_auto_window, strings.cursor_api_window) + } else { + (strings.session_window, strings.weekly_window) + }; + draw_row( hdc, content_x, row1_y, is_dark, text_color, - strings.session_window, + session_label, session_pct, session_text, codex_session_pct, codex_session_text, antigravity_session_pct, antigravity_session_text, + cursor_session_pct, + cursor_session_text, show_claude_code, show_codex, show_antigravity, + show_cursor, accent, codex_accent, antigravity_accent, + cursor_accent, track, ); draw_row( @@ -1735,19 +1851,23 @@ fn paint_content( row2_y, is_dark, text_color, - strings.weekly_window, + weekly_label, weekly_pct, weekly_text, codex_weekly_pct, codex_weekly_text, antigravity_weekly_pct, antigravity_weekly_text, + cursor_weekly_pct, + cursor_weekly_text, show_claude_code, show_codex, show_antigravity, + show_cursor, accent, codex_accent, antigravity_accent, + cursor_accent, track, ); @@ -1758,15 +1878,22 @@ fn paint_content( fn do_poll(send_hwnd: SendHwnd) { let hwnd = send_hwnd.to_hwnd(); - let (show_claude_code, show_codex, show_antigravity) = { + let (show_claude_code, show_codex, show_antigravity, show_cursor) = { let state = lock_state(); state .as_ref() - .map(|s| (s.show_claude_code, s.show_codex, s.show_antigravity)) - .unwrap_or((true, false, false)) + .map(|s| { + ( + s.show_claude_code, + s.show_codex, + s.show_antigravity, + s.show_cursor, + ) + }) + .unwrap_or((true, false, false, false)) }; - match poller::poll(show_claude_code, show_codex, show_antigravity) { + match poller::poll(show_claude_code, show_codex, show_antigravity, show_cursor) { Ok(data) => { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -1791,6 +1918,13 @@ fn do_poll(send_hwnd: SendHwnd) { s.antigravity_session_percent = 0.0; s.antigravity_weekly_percent = 0.0; } + if let Some(cursor) = data.cursor.as_ref() { + s.cursor_session_percent = cursor.session.percentage; + s.cursor_weekly_percent = cursor.weekly.percentage; + } else if s.show_cursor { + s.cursor_session_percent = 0.0; + s.cursor_weekly_percent = 0.0; + } // Stop fast-poll if reset data is now fresh if !poller::app_is_past_reset(&data) { unsafe { @@ -1862,6 +1996,8 @@ fn do_poll(send_hwnd: SendHwnd) { s.codex_weekly_text = "!".to_string(); s.antigravity_session_text = "!".to_string(); s.antigravity_weekly_text = "!".to_string(); + s.cursor_session_text = "!".to_string(); + s.cursor_weekly_text = "!".to_string(); s.retry_count = s.retry_count.saturating_add(1); unsafe { let _ = KillTimer(hwnd, TIMER_POLL); @@ -1882,6 +2018,8 @@ fn do_poll(send_hwnd: SendHwnd) { s.codex_weekly_text = "...".to_string(); s.antigravity_session_text = "...".to_string(); s.antigravity_weekly_text = "...".to_string(); + s.cursor_session_text = "...".to_string(); + s.cursor_weekly_text = "...".to_string(); s.retry_count = s.retry_count.saturating_add(1); let backoff = RETRY_BASE_MS.saturating_mul( 1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX), @@ -1984,6 +2122,12 @@ fn schedule_countdown_timer() { data.antigravity .as_ref() .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), + data.cursor + .as_ref() + .and_then(|usage| poller::time_until_display_change(usage.session.resets_at)), + data.cursor + .as_ref() + .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), ]; let min_delay = delays.into_iter().flatten().min(); @@ -2595,26 +2739,50 @@ unsafe extern "system" fn wnd_proc( // Reset the poll timer with the new interval SetTimer(hwnd, TIMER_POLL, new_interval, None); } - IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX | IDM_MODEL_ANTIGRAVITY => { + IDM_MODEL_CLAUDE_CODE + | IDM_MODEL_CODEX + | IDM_MODEL_ANTIGRAVITY + | IDM_MODEL_CURSOR => { { let mut state = lock_state(); if let Some(s) = state.as_mut() { match id { IDM_MODEL_CLAUDE_CODE => { - if s.show_codex || s.show_antigravity || !s.show_claude_code { + if s.show_codex + || s.show_antigravity + || s.show_cursor + || !s.show_claude_code + { s.show_claude_code = !s.show_claude_code; } } IDM_MODEL_CODEX => { - if s.show_claude_code || s.show_antigravity || !s.show_codex { + if s.show_claude_code + || s.show_antigravity + || s.show_cursor + || !s.show_codex + { s.show_codex = !s.show_codex; } } IDM_MODEL_ANTIGRAVITY => { - if s.show_claude_code || s.show_codex || !s.show_antigravity { + if s.show_claude_code + || s.show_codex + || s.show_cursor + || !s.show_antigravity + { s.show_antigravity = !s.show_antigravity; } } + IDM_MODEL_CURSOR => { + if s.show_claude_code + || s.show_codex + || s.show_antigravity + || !s.show_cursor + { + s.show_cursor = !s.show_cursor; + } + } _ => {} } s.session_text = "...".to_string(); @@ -2623,6 +2791,8 @@ unsafe extern "system" fn wnd_proc( s.codex_weekly_text = "...".to_string(); s.antigravity_session_text = "...".to_string(); s.antigravity_weekly_text = "...".to_string(); + s.cursor_session_text = "...".to_string(); + s.cursor_weekly_text = "...".to_string(); } } save_state_settings(); @@ -2718,6 +2888,7 @@ fn show_context_menu(hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + show_cursor, ) = { let state = lock_state(); match state.as_ref() { @@ -2732,6 +2903,7 @@ fn show_context_menu(hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.show_cursor, ), None => ( POLL_15_MIN, @@ -2744,6 +2916,7 @@ fn show_context_menu(hwnd: HWND) { true, false, false, + false, ), } }; @@ -2830,6 +3003,19 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(antigravity_model.as_ptr()), ); + let cursor_model = native_interop::wide_str(strings.cursor_model); + let cursor_flags = if show_cursor { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + models_menu, + cursor_flags, + IDM_MODEL_CURSOR as usize, + PCWSTR::from_raw(cursor_model.as_ptr()), + ); + let models_label = native_interop::wide_str(strings.models); let _ = AppendMenuW( menu, @@ -2988,9 +3174,14 @@ fn paint(hdc: HDC, hwnd: HWND) { antigravity_session_text, antigravity_weekly_pct, antigravity_weekly_text, + cursor_session_pct, + cursor_session_text, + cursor_weekly_pct, + cursor_weekly_text, show_claude_code, show_codex, show_antigravity, + show_cursor, ) = { let state = lock_state(); match state.as_ref() { @@ -3009,9 +3200,14 @@ fn paint(hdc: HDC, hwnd: HWND) { s.antigravity_session_text.clone(), s.antigravity_weekly_percent, s.antigravity_weekly_text.clone(), + s.cursor_session_percent, + s.cursor_session_text.clone(), + s.cursor_weekly_percent, + s.cursor_weekly_text.clone(), s.show_claude_code, s.show_codex, s.show_antigravity, + s.show_cursor, ), None => return, } @@ -3020,6 +3216,7 @@ fn paint(hdc: HDC, hwnd: HWND) { let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); let antigravity_accent = antigravity_accent_color(); + let cursor_accent = cursor_accent_color(is_dark); let track = if is_dark { Color::from_hex("#444444") } else { @@ -3072,11 +3269,17 @@ fn paint(hdc: HDC, hwnd: HWND) { &antigravity_session_text, antigravity_weekly_pct, &antigravity_weekly_text, + cursor_session_pct, + &cursor_session_text, + cursor_weekly_pct, + &cursor_weekly_text, show_claude_code, show_codex, show_antigravity, + show_cursor, &codex_accent, &antigravity_accent, + &cursor_accent, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -3100,16 +3303,21 @@ fn draw_row( codex_text: &str, antigravity_percent: f64, antigravity_text: &str, + cursor_percent: f64, + cursor_text: &str, show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_cursor: bool, claude_accent: &Color, codex_accent: &Color, antigravity_accent: &Color, + cursor_accent: &Color, track: &Color, ) { let seg_h = sc(SEGMENT_H); - let active_models = active_model_count(show_claude_code, show_codex, show_antigravity); + let active_models = + active_model_count(show_claude_code, show_codex, show_antigravity, show_cursor); let segment_count = row_bar_segment_count(active_models); let use_model_text_colors = active_models > 1; let claude_value_color = if use_model_text_colors { @@ -3127,6 +3335,11 @@ fn draw_row( } else { *text_color }; + let cursor_value_color = if use_model_text_colors { + cursor_usage_text_color(is_dark) + } else { + *text_color + }; unsafe { let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -3185,6 +3398,20 @@ fn draw_row( track, &antigravity_value_color, ); + model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); + } + if show_cursor { + draw_usage_bar( + hdc, + model_x, + y, + segment_count, + cursor_percent, + cursor_text, + cursor_accent, + track, + &cursor_value_color, + ); } } } From 9184f94604c3fe43232e3a20211ae82a71cccc68 Mon Sep 17 00:00:00 2001 From: Rtas Date: Sat, 25 Jul 2026 21:51:39 +0100 Subject: [PATCH 2/2] fix: address Copilot review on cursor meter - Give Cursor its own TrayIconKind so it no longer shares Codex's tray slot/styling when both are enabled. - Use localized cursor_auto_window / cursor_api_window strings in the Cursor tray tooltip instead of hard-coded English. - Also wire Cursor-only auth balloon copy to the Cursor tray icon (same class of bug as the Ollama review). Co-authored-by: Cursor --- src/localization/dutch.rs | 2 ++ src/localization/english.rs | 2 ++ src/localization/french.rs | 2 ++ src/localization/german.rs | 2 ++ src/localization/japanese.rs | 2 ++ src/localization/korean.rs | 2 ++ src/localization/mod.rs | 2 ++ src/localization/portuguese_brazil.rs | 2 ++ src/localization/russian.rs | 2 ++ src/localization/simplified_chinese.rs | 2 ++ src/localization/spanish.rs | 2 ++ src/localization/traditional_chinese.rs | 2 ++ src/tray_icon.rs | 33 ++++++++++++++++++++++++- src/window.rs | 15 ++++++++--- 14 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 6def93f..c640099 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -50,5 +50,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex-gebruiksmonitor", antigravity_window_title: "Antigravity-gebruiksmonitor", cursor_window_title: "Cursor-gebruiksmonitor", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 1895161..916bb0e 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -50,5 +50,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex Usage Monitor", antigravity_window_title: "Antigravity Usage Monitor", cursor_window_title: "Cursor Usage Monitor", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index d9685dc..cd39f97 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -50,5 +50,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Moniteur d'utilisation Codex", antigravity_window_title: "Moniteur d'utilisation Antigravity", cursor_window_title: "Moniteur d'utilisation Cursor", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 015f032..4679439 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -50,5 +50,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex-Nutzungsmonitor", antigravity_window_title: "Antigravity-Nutzungsmonitor", cursor_window_title: "Cursor-Nutzungsmonitor", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 595cc53..7737558 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -50,5 +50,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 使用量モニター", antigravity_window_title: "Antigravity 使用量モニター", cursor_window_title: "Cursor 使用量モニター", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 6de1f1e..38827b1 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -50,5 +50,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 사용량 모니터", antigravity_window_title: "Antigravity 사용량 모니터", cursor_window_title: "Cursor 사용량 모니터", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 8e9b55e..2f130e1 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -193,6 +193,8 @@ pub struct Strings { pub codex_window_title: &'static str, pub antigravity_window_title: &'static str, pub cursor_window_title: &'static str, + pub cursor_token_expired_title: &'static str, + pub cursor_token_expired_body: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 4a953fa..48d08be 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -51,4 +51,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Monitor de uso do Codex", antigravity_window_title: "Monitor de uso do Antigravity", cursor_window_title: "Monitor de uso do Cursor", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index d1f9fed..57928a2 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -51,4 +51,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Монитор использования Codex", antigravity_window_title: "Монитор использования Antigravity", cursor_window_title: "Монитор использования Cursor", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", }; diff --git a/src/localization/simplified_chinese.rs b/src/localization/simplified_chinese.rs index d250757..0bf1ee3 100644 --- a/src/localization/simplified_chinese.rs +++ b/src/localization/simplified_chinese.rs @@ -50,5 +50,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 使用量监控", antigravity_window_title: "Antigravity 使用量监控", cursor_window_title: "Cursor 使用量监控", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", second_suffix: "秒", }; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 057d8b1..03aa4b7 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -50,5 +50,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Monitor de uso de Codex", antigravity_window_title: "Monitor de uso de Antigravity", cursor_window_title: "Monitor de uso de Cursor", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 8e44033..edf06fa 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -50,5 +50,7 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 使用量監控", antigravity_window_title: "Antigravity 使用量監控", cursor_window_title: "Cursor 使用量監控", + cursor_token_expired_title: "Cursor Auth Error", + cursor_token_expired_body: "Sign in to Cursor (or set CURSOR_SESSION_TOKEN), then refresh or restart this app.", second_suffix: "秒", }; diff --git a/src/tray_icon.rs b/src/tray_icon.rs index e2502e2..77ef972 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -13,6 +13,7 @@ use crate::native_interop::{self, Color, WM_APP_TRAY}; const CLAUDE_TRAY_ICON_ID: u32 = 1; const CODEX_TRAY_ICON_ID: u32 = 2; const ANTIGRAVITY_TRAY_ICON_ID: u32 = 3; +const CURSOR_TRAY_ICON_ID: u32 = 4; /// Menu item ID for toggling widget visibility (used by window.rs context menu). pub const IDM_TOGGLE_WIDGET: u16 = 70; @@ -29,6 +30,7 @@ pub enum TrayIconKind { Claude, Codex, Antigravity, + Cursor, } pub struct TrayIconData { @@ -43,6 +45,7 @@ impl TrayIconKind { Self::Claude => CLAUDE_TRAY_ICON_ID, Self::Codex => CODEX_TRAY_ICON_ID, Self::Antigravity => ANTIGRAVITY_TRAY_ICON_ID, + Self::Cursor => CURSOR_TRAY_ICON_ID, } } } @@ -101,6 +104,14 @@ fn antigravity_fill(percent: f64) -> Color { } } +fn cursor_fill(percent: f64) -> Color { + if percent >= 90.0 { + Color::from_hex("#FFFFFF") + } else { + Color::from_hex("#1A1A1A") + } +} + /// Create a rounded-rectangle tray icon badge showing the usage percentage. /// For Claude, `percent` = None uses the embedded app icon as the loading state. /// For Codex and Antigravity, `percent` = None uses a provider placeholder badge. @@ -115,7 +126,10 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { let size = 64_i32; let margin = 0_i32; let radius = 2_i32; - let outline = if matches!(kind, TrayIconKind::Codex | TrayIconKind::Antigravity) { + let outline = if matches!( + kind, + TrayIconKind::Codex | TrayIconKind::Antigravity | TrayIconKind::Cursor + ) { 3_i32 } else { 0_i32 @@ -125,6 +139,7 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { TrayIconKind::Claude => interpolated_fill(percent.unwrap_or(0.0)), TrayIconKind::Codex => codex_fill(percent.unwrap_or(0.0)), TrayIconKind::Antigravity => antigravity_fill(percent.unwrap_or(0.0)), + TrayIconKind::Cursor => cursor_fill(percent.unwrap_or(0.0)), }; let text_col = match kind { TrayIconKind::Claude => Color::from_hex("#FFFFFF"), @@ -132,6 +147,8 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { TrayIconKind::Codex => Color::from_hex("#FFFFFF"), TrayIconKind::Antigravity if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1967D2"), TrayIconKind::Antigravity => Color::from_hex("#FFFFFF"), + TrayIconKind::Cursor if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1A1A1A"), + TrayIconKind::Cursor => Color::from_hex("#FFFFFF"), }; let outline_col = match kind { TrayIconKind::Claude => fill, @@ -139,6 +156,8 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { TrayIconKind::Codex => Color::from_hex("#FFFFFF"), TrayIconKind::Antigravity if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1967D2"), TrayIconKind::Antigravity => Color::from_hex("#FFFFFF"), + TrayIconKind::Cursor if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1A1A1A"), + TrayIconKind::Cursor => Color::from_hex("#FFFFFF"), }; let display_text = match percent { @@ -147,6 +166,7 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { TrayIconKind::Claude => String::new(), TrayIconKind::Codex => "C".to_string(), TrayIconKind::Antigravity => "A".to_string(), + TrayIconKind::Cursor => "Cu".to_string(), }, }; @@ -417,6 +437,9 @@ pub fn sync(hwnd: HWND, icons: &[TrayIconData]) { let show_antigravity = icons .iter() .find(|icon| matches!(icon.kind, TrayIconKind::Antigravity)); + let show_cursor = icons + .iter() + .find(|icon| matches!(icon.kind, TrayIconKind::Cursor)); if let Some(icon) = show_claude { add(hwnd, icon.kind, icon.percent, &icon.tooltip); @@ -438,12 +461,20 @@ pub fn sync(hwnd: HWND, icons: &[TrayIconData]) { } else { remove(hwnd, TrayIconKind::Antigravity); } + + if let Some(icon) = show_cursor { + add(hwnd, icon.kind, icon.percent, &icon.tooltip); + update(hwnd, icon.kind, icon.percent, &icon.tooltip); + } else { + remove(hwnd, TrayIconKind::Cursor); + } } pub fn remove_all(hwnd: HWND) { remove(hwnd, TrayIconKind::Claude); remove(hwnd, TrayIconKind::Codex); remove(hwnd, TrayIconKind::Antigravity); + remove(hwnd, TrayIconKind::Cursor); } /// Interpret a tray callback message and return the action to take. diff --git a/src/window.rs b/src/window.rs index 6416e2e..c534eb8 100644 --- a/src/window.rs +++ b/src/window.rs @@ -457,12 +457,14 @@ fn tray_icon_data_from_state() -> Vec { } if s.show_cursor { icons.push(tray_icon::TrayIconData { - kind: tray_icon::TrayIconKind::Codex, + kind: tray_icon::TrayIconKind::Cursor, percent: Some(s.cursor_session_percent), tooltip: format!( - "{} Auto: {} | API: {}", + "{} {}: {} | {}: {}", s.language.strings().cursor_model, + s.language.strings().cursor_auto_window, s.cursor_session_text, + s.language.strings().cursor_api_window, s.cursor_weekly_text ), }); @@ -494,7 +496,7 @@ fn tray_icon_data_from_state() -> Vec { } if s.show_cursor { icons.push(tray_icon::TrayIconData { - kind: tray_icon::TrayIconKind::Codex, + kind: tray_icon::TrayIconKind::Cursor, percent: None, tooltip: s.language.strings().cursor_window_title.to_string(), }); @@ -2053,6 +2055,13 @@ fn do_poll(send_hwnd: SendHwnd) { s.language.strings().codex_token_expired_title, s.language.strings().codex_token_expired_body, ) + } else if s.show_cursor { + ( + s.language.strings(), + tray_icon::TrayIconKind::Cursor, + s.language.strings().cursor_token_expired_title, + s.language.strings().cursor_token_expired_body, + ) } else { ( s.language.strings(),