diff --git a/README.md b/README.md index 08c8a28..c2a554f 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,16 @@ ![Screenshot](.github/animation.gif) -A lightweight Windows taskbar widget for people already using Claude Code, with optional Codex usage display. +A lightweight Windows taskbar widget for people already using Claude Code, with optional Codex and Google Antigravity usage display. -It sits in your taskbar and shows how much of your Claude Code and/or Codex usage window you have left, without needing to open the terminal or the provider site. +It sits in your taskbar and shows how much of your Claude Code, Codex, and/or Antigravity usage window you have left, without needing to open the terminal or the provider site. ## What You Get - A **5h** bar for your current 5-hour Claude usage window - A **7d** bar for your current 7-day window - Optional Codex usage bars alongside Claude Code +- Optional Antigravity usage bars for Google's 5-hour and broader user quota windows - A live countdown until each limit resets - A small native widget that lives directly in the Windows taskbar - System tray icon badges showing your enabled model usage percentage @@ -26,6 +27,8 @@ This app is for Windows users who already have **Claude Code (CLI or App) instal Codex support is optional. To show Codex usage, install and sign in to the Codex CLI, then enable Codex from the right-click **Models** menu. +Antigravity support is optional too. To show Antigravity usage, install and sign in to Google Antigravity, then enable Antigravity from the right-click **Models** menu. + It works best if you want a simple "how close am I to the limit?" display that is always visible. ## Requirements @@ -33,6 +36,7 @@ It works best if you want a simple "how close am I to the limit?" display that i - Windows 10 or Windows 11 - Claude Code (CLI or App) installed and authenticated - Optional: Codex CLI installed and authenticated, if you want Codex usage +- Optional: Google Antigravity installed and authenticated, if you want Antigravity usage If you use Claude Code through WSL, that is supported too. The monitor can read your Claude Code credentials from Windows or from your WSL environment. @@ -67,16 +71,17 @@ Use the right-click **Models** menu to choose what the widget displays: - **Claude Code** is enabled by default - **Codex** can be enabled alongside Claude Code or shown by itself +- **Antigravity** can be enabled alongside the other providers or shown by itself -When both models are shown, each model has its own usage bar and matching usage text color. +When multiple models are shown, each model has its own usage bar and matching usage text color. ### System Tray Icon The tray icon shows your current 5-hour usage as a percentage badge. -If both Claude Code and Codex are enabled, the app shows two tray icons: one for Claude Code and one for Codex. If only one model is enabled, it shows one tray icon. +If multiple providers are enabled, the app shows one tray icon per provider. If only one model is enabled, it shows one tray icon. -The Claude Code tray icon uses the same warm usage colors as the Claude bar. The Codex tray icon uses a black and white badge style. +The Claude Code tray icon uses the same warm usage colors as the Claude bar. The Codex tray icon uses a black and white badge style. The Antigravity tray icon uses a green badge style. Hovering over a tray icon shows the usage values for that model. @@ -120,11 +125,13 @@ What the app reads: - Your local Claude Code OAuth credentials from `~/.claude/.credentials.json` - If needed, the same credentials file inside an installed WSL distro - If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json` or `~/.codex/auth.json` +- If Antigravity is enabled, your local Antigravity OAuth token from Windows Credential Manager target `gemini:antigravity` What the app sends over the network: - Requests to Anthropic's Claude endpoints to read your usage and rate-limit information - Requests to ChatGPT's Codex usage endpoint to read your Codex usage and rate-limit information, if Codex is enabled +- Requests to Google's Cloud Code / Antigravity endpoints to read your Antigravity quota information, if Antigravity is enabled - Requests to GitHub only if you use the app's update check / self-update feature - If proxy environment variables such as `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY` are set, those outbound requests may use that proxy @@ -148,6 +155,7 @@ Notes: - If your Claude Code token is expired, the app may ask the local Claude CLI to refresh it in the background - If your Codex token is expired, the app may ask the local Codex CLI to refresh it in the background. The monitor does not write `auth.json` itself; any credential update is handled by the Codex CLI. +- If your Antigravity token is expired, open Antigravity and sign in again. The monitor does not write Windows Credential Manager entries itself. - Portable installs can update themselves by downloading the latest release from this repository - Proxies should be trusted because proxied usage requests include your OAuth bearer token inside the TLS connection @@ -156,7 +164,7 @@ Notes: The monitor: 1. Finds your enabled model login credentials -2. Reads your current usage from Anthropic and/or ChatGPT +2. Reads your current usage from Anthropic, ChatGPT, and/or Google's Antigravity endpoints 3. Shows the result directly in the Windows taskbar 4. Refreshes periodically in the background diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 0eb486d..ed815bf 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "Modellen", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "Instellingen", start_with_windows: "Opstarten met Windows", reset_position: "Positie herstellen", @@ -41,6 +42,9 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "Voer 'claude' uit in een terminal, gebruik daarna '/login' en volg de stappen. Ververs of herstart de app daarna.", codex_token_expired_title: "Codex-authenticatiefout", codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.", + antigravity_token_expired_title: "Antigravity-authenticatiefout", + 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", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 2b92f36..0249730 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "Models", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "Settings", start_with_windows: "Start with Windows", reset_position: "Reset Position", @@ -41,6 +42,9 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.", codex_token_expired_title: "Codex Auth Error", codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.", + antigravity_token_expired_title: "Antigravity Auth Error", + 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", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index fa448fb..1850f41 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "Modeles", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "Paramètres", start_with_windows: "Démarrer avec Windows", reset_position: "Réinitialiser la position", @@ -41,6 +42,9 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application.", codex_token_expired_title: "Erreur d'authentification Codex", codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.", + antigravity_token_expired_title: "Erreur d'authentification Antigravity", + 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", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 5c7bb23..2b91a81 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "Modelle", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "Einstellungen", start_with_windows: "Mit Windows starten", reset_position: "Position zurücksetzen", @@ -41,6 +42,9 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "Führen Sie 'claude' in einem Terminal aus, verwenden Sie dann '/login' und folgen Sie den Anweisungen. Aktualisieren oder starten Sie diese App anschließend neu.", codex_token_expired_title: "Codex-Authentifizierungsfehler", codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.", + antigravity_token_expired_title: "Antigravity-Authentifizierungsfehler", + 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", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 0020018..2eec041 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "モデル", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "設定", start_with_windows: "Windows と同時に開始", reset_position: "位置をリセット", @@ -41,6 +42,9 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。", codex_token_expired_title: "Codex 認証エラー", codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。", + antigravity_token_expired_title: "Antigravity 認証エラー", + antigravity_token_expired_body: "Antigravity を開いて再度サインインしてください。その後、このアプリを更新するか再起動してください。", codex_window_title: "Codex 使用量モニター", + antigravity_window_title: "Antigravity 使用量モニター", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 59e3829..965687d 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "모델", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "설정", start_with_windows: "Windows 시작 시 자동 실행", reset_position: "위치 초기화", @@ -41,6 +42,9 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_token_expired_title: "Codex 인증 오류", codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", + antigravity_token_expired_title: "Antigravity 인증 오류", + antigravity_token_expired_body: "Antigravity를 열고 다시 로그인하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", + antigravity_window_title: "Antigravity 사용량 모니터", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 39ee702..2a06b04 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -4,10 +4,10 @@ mod french; mod german; mod japanese; mod korean; +mod portuguese_brazil; +mod russian; mod spanish; mod traditional_chinese; -mod russian; -mod portuguese_brazil; use windows::core::PWSTR; use windows::Win32::Globalization::{ @@ -147,6 +147,7 @@ pub struct Strings { pub models: &'static str, pub claude_code_model: &'static str, pub codex_model: &'static str, + pub antigravity_model: &'static str, pub settings: &'static str, pub start_with_windows: &'static str, pub reset_position: &'static str, @@ -176,7 +177,10 @@ pub struct Strings { pub token_expired_body: &'static str, pub codex_token_expired_title: &'static str, pub codex_token_expired_body: &'static str, + pub antigravity_token_expired_title: &'static str, + pub antigravity_token_expired_body: &'static str, pub codex_window_title: &'static str, + pub antigravity_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 691ee77..56cf3bf 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "Modelos", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "Configurações", start_with_windows: "Iniciar com o Windows", reset_position: "Redefinir Posição", @@ -42,5 +43,8 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "Execute 'claude' em um terminal, use '/login' e siga as instruções. Depois disso, atualize ou reinicie este aplicativo.", codex_token_expired_title: "Erro de Autenticação do Codex", codex_token_expired_body: "Execute 'codex' em um terminal e siga as instruções de login. Depois disso, atualize ou reinicie este aplicativo.", + antigravity_token_expired_title: "Erro de Autenticação do Antigravity", + 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", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index 786d0fa..fc7e372 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "Модели", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "Настройки", start_with_windows: "Запускать вместе с Windows", reset_position: "Сбросить позицию", @@ -42,5 +43,8 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "Запустите 'claude' в терминале, затем используйте '/login' и следуйте инструкциям. После этого обновите или перезапустите приложение.", codex_token_expired_title: "Ошибка авторизации Codex", codex_token_expired_body: "Запустите 'codex' в терминале и следуйте инструкциям для входа. После этого обновите или перезапустите приложение.", + antigravity_token_expired_title: "Ошибка авторизации Antigravity", + antigravity_token_expired_body: "Откройте Antigravity и войдите снова. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", -}; \ No newline at end of file + antigravity_window_title: "Монитор использования Antigravity", +}; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 8e6513e..e635771 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "Modelos", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "Configuración", start_with_windows: "Iniciar con Windows", reset_position: "Restablecer posición", @@ -41,6 +42,9 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación.", codex_token_expired_title: "Error de autenticacion de Codex", codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.", + antigravity_token_expired_title: "Error de autenticacion de Antigravity", + 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", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 809ebba..3eb3514 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -13,6 +13,7 @@ pub(super) const STRINGS: Strings = Strings { models: "模型", claude_code_model: "Claude Code", codex_model: "Codex", + antigravity_model: "Antigravity", settings: "設定", start_with_windows: "開機時啟動", reset_position: "重置位置", @@ -41,6 +42,9 @@ pub(super) const STRINGS: Strings = Strings { token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。", codex_token_expired_title: "Codex 驗證錯誤", codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。", + antigravity_token_expired_title: "Antigravity 驗證錯誤", + antigravity_token_expired_body: "請開啟 Antigravity 並重新登入。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", + antigravity_window_title: "Antigravity 使用量監控", second_suffix: "秒", }; diff --git a/src/main.rs b/src/main.rs index 88c363e..a17bc0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,10 +15,7 @@ fn main() { let diagnose_enabled = args.iter().any(|arg| arg == "--diagnose"); if diagnose_enabled { match diagnose::init() { - Ok(path) => diagnose::log(format!( - "startup args={args:?} log_path={}", - path.display() - )), + Ok(path) => diagnose::log(format!("startup args={args:?} log_path={}", path.display())), Err(error) => { // Logging may not be available yet, but keep startup behavior unchanged. let _ = error; diff --git a/src/models.rs b/src/models.rs index bd3a456..da49ef1 100644 --- a/src/models.rs +++ b/src/models.rs @@ -16,4 +16,5 @@ pub struct UsageData { pub struct AppUsageData { pub claude_code: Option, pub codex: Option, + pub antigravity: Option, } diff --git a/src/poller.rs b/src/poller.rs index 5bd02bc..93f89e8 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1,3 +1,7 @@ +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::ffi::c_void; +use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -12,6 +16,12 @@ use crate::models::{AppUsageData, UsageData, UsageSection}; const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage"; +const ANTIGRAVITY_CREDENTIAL_TARGET: &str = "gemini:antigravity"; +const ANTIGRAVITY_ENDPOINTS: &[&str] = &[ + "https://daily-cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", +]; const CREATE_NO_WINDOW: u32 = 0x08000000; const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"]; @@ -28,6 +38,7 @@ pub enum PollError { pub enum CredentialWatchMode { ActiveSource, AllSources, + Antigravity, } pub type CredentialWatchSnapshot = Vec; @@ -72,7 +83,99 @@ struct CodexRateLimitWindow { reset_at: i64, } -pub fn poll(show_claude_code: bool, show_codex: bool) -> Result { +#[derive(Deserialize)] +struct AntigravityAuthFile { + token: AntigravityTokenData, +} + +#[derive(Deserialize)] +struct AntigravityTokenData { + access_token: String, +} + +#[derive(Deserialize)] +struct AntigravityLoadResponse { + #[serde(rename = "cloudaicompanionProject")] + project: Option, +} + +#[derive(Deserialize)] +struct AntigravityModelsResponse { + models: HashMap, +} + +#[derive(Deserialize)] +struct AntigravityModelInfo { + #[serde(rename = "quotaInfo")] + quota_info: Option, +} + +#[derive(Deserialize)] +struct AntigravityQuotaInfo { + #[serde(rename = "remainingFraction")] + remaining_fraction: Option, + #[serde(rename = "resetTime")] + reset_time: Option, +} + +#[derive(Deserialize)] +struct AntigravityQuotaSummaryResponse { + groups: Option>, +} + +#[derive(Deserialize)] +struct AntigravityQuotaSummaryGroup { + #[serde(rename = "displayName")] + display_name: Option, + description: Option, + buckets: Option>, +} + +#[derive(Clone, Deserialize)] +struct AntigravityQuotaSummaryBucket { + #[serde(rename = "bucketId")] + bucket_id: Option, + #[serde(rename = "displayName")] + display_name: Option, + window: Option, + #[serde(rename = "remainingFraction")] + remaining_fraction: Option, + #[serde(rename = "resetTime")] + reset_time: Option, +} + +#[repr(C)] +struct CredentialW { + flags: u32, + type_: u32, + target_name: *mut u16, + comment: *mut u16, + last_written: u64, + credential_blob_size: u32, + credential_blob: *mut u8, + persist: u32, + attribute_count: u32, + attributes: *mut c_void, + target_alias: *mut u16, + user_name: *mut u16, +} + +#[link(name = "Advapi32")] +extern "system" { + fn CredReadW( + target_name: *const u16, + type_: u32, + reserved_flags: u32, + credential: *mut *mut CredentialW, + ) -> i32; + fn CredFree(buffer: *mut c_void); +} + +pub fn poll( + show_claude_code: bool, + show_codex: bool, + show_antigravity: bool, +) -> Result { let mut data = AppUsageData::default(); if show_claude_code { @@ -82,12 +185,20 @@ pub fn poll(show_claude_code: bool, show_codex: bool) -> Result data.codex = Some(codex), - Err(error) if !show_claude_code => return Err(error), + Err(error) if !show_claude_code && !show_antigravity => return Err(error), Err(error) => diagnose::log(format!("Codex usage poll failed: {error:?}")), } } - if data.claude_code.is_none() && data.codex.is_none() { + if show_antigravity { + match poll_antigravity() { + Ok(antigravity) => data.antigravity = Some(antigravity), + Err(error) if !show_claude_code && !show_codex => return Err(error), + Err(error) => diagnose::log(format!("Antigravity usage poll failed: {error:?}")), + } + } + + if data.claude_code.is_none() && data.codex.is_none() && data.antigravity.is_none() { Err(PollError::RequestFailed) } else { Ok(data) @@ -128,6 +239,18 @@ fn poll_codex() -> Result { } } +fn poll_antigravity() -> Result { + let creds = match read_antigravity_credentials() { + Some(creds) => creds, + None => { + diagnose::log("Antigravity usage poll failed: no Antigravity credentials found"); + return Err(PollError::NoCredentials); + } + }; + + fetch_antigravity_usage(&creds.access_token) +} + fn refresh_or_fallback(mut creds: Credentials) -> Result { loop { if !is_token_expired(creds.expires_at) { @@ -405,11 +528,16 @@ fn build_agent() -> Result { } pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSnapshot { + if mode == CredentialWatchMode::Antigravity { + return vec![antigravity_credential_watch_signature()]; + } + let sources = match mode { CredentialWatchMode::ActiveSource => read_first_credentials() .map(|creds| vec![creds.source]) .unwrap_or_else(all_known_credential_sources), CredentialWatchMode::AllSources => all_known_credential_sources(), + CredentialWatchMode::Antigravity => unreachable!(), }; let mut snapshot: CredentialWatchSnapshot = sources @@ -694,6 +822,295 @@ fn codex_section_from_window(window: &CodexRateLimitWindow) -> UsageSection { } } +fn antigravity_credential_watch_signature() -> String { + let Some(content) = read_windows_generic_credential(ANTIGRAVITY_CREDENTIAL_TARGET) else { + return format!("{ANTIGRAVITY_CREDENTIAL_TARGET}|missing"); + }; + + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + format!( + "{ANTIGRAVITY_CREDENTIAL_TARGET}|present|{}|{}", + content.len(), + hasher.finish() + ) +} + +fn fetch_antigravity_usage(token: &str) -> Result { + let mut auth_error = false; + let mut last_error = PollError::RequestFailed; + + for base_url in ANTIGRAVITY_ENDPOINTS { + match fetch_antigravity_usage_from_endpoint(base_url, token) { + Ok(data) => return Ok(data), + Err(PollError::AuthRequired) => auth_error = true, + Err(error) => last_error = error, + } + } + + if auth_error { + Err(PollError::AuthRequired) + } else { + Err(last_error) + } +} + +fn fetch_antigravity_usage_from_endpoint( + base_url: &str, + token: &str, +) -> Result { + let project = fetch_antigravity_project(base_url, token)?; + if let Some(project) = project.as_deref() { + match fetch_antigravity_quota_summary(base_url, token, project) { + Ok(data) => return Ok(data), + Err(PollError::AuthRequired) => return Err(PollError::AuthRequired), + Err(error) => diagnose::log(format!( + "Antigravity retrieveUserQuotaSummary failed, falling back to model quota: {error:?}" + )), + } + } + + let session = fetch_antigravity_model_quota(base_url, token, project.as_deref())?; + let weekly = UsageSection::default(); + + Ok(UsageData { session, weekly }) +} + +fn fetch_antigravity_project(base_url: &str, token: &str) -> Result, PollError> { + let agent = build_agent()?; + let body = serde_json::json!({ + "metadata": { + "ideType": "ANTIGRAVITY" + } + }); + + let resp = match agent + .post(&format!("{base_url}/v1internal:loadCodeAssist")) + .set("Authorization", &format!("Bearer {token}")) + .set("Content-Type", "application/json") + .set("User-Agent", "antigravity") + .send_json(&body) + { + Ok(resp) => resp, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + diagnose::log(format!( + "Antigravity loadCodeAssist returned auth error status {code}" + )); + return Err(PollError::AuthRequired); + } + Err(error) => { + diagnose::log_error("Antigravity loadCodeAssist request failed", error); + return Err(PollError::RequestFailed); + } + }; + + let response: AntigravityLoadResponse = match resp.into_json() { + Ok(response) => response, + Err(error) => { + diagnose::log_error("unable to parse Antigravity loadCodeAssist response", error); + return Err(PollError::RequestFailed); + } + }; + + Ok(response.project.filter(|project| !project.is_empty())) +} + +fn fetch_antigravity_model_quota( + base_url: &str, + token: &str, + project: Option<&str>, +) -> Result { + let agent = build_agent()?; + let body = match project { + Some(project) => serde_json::json!({ "project": project }), + None => serde_json::json!({}), + }; + + let resp = match agent + .post(&format!("{base_url}/v1internal:fetchAvailableModels")) + .set("Authorization", &format!("Bearer {token}")) + .set("Content-Type", "application/json") + .set("User-Agent", "antigravity") + .send_json(&body) + { + Ok(resp) => resp, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + diagnose::log(format!( + "Antigravity fetchAvailableModels returned auth error status {code}" + )); + return Err(PollError::AuthRequired); + } + Err(error) => { + diagnose::log_error("Antigravity fetchAvailableModels request failed", error); + return Err(PollError::RequestFailed); + } + }; + + let response: AntigravityModelsResponse = match resp.into_json() { + Ok(response) => response, + Err(error) => { + diagnose::log_error( + "unable to parse Antigravity fetchAvailableModels response", + error, + ); + return Err(PollError::RequestFailed); + } + }; + + best_antigravity_section(response.models.into_iter().filter_map(|(model, info)| { + let quota = info.quota_info?; + if !is_antigravity_display_model(&model) { + return None; + } + antigravity_section_from_quota(quota) + })) + .ok_or(PollError::RequestFailed) +} + +fn fetch_antigravity_quota_summary( + base_url: &str, + token: &str, + project: &str, +) -> Result { + let agent = build_agent()?; + let body = serde_json::json!({ "project": project }); + + let resp = match agent + .post(&format!("{base_url}/v1internal:retrieveUserQuotaSummary")) + .set("Authorization", &format!("Bearer {token}")) + .set("Content-Type", "application/json") + .set("User-Agent", "antigravity") + .send_json(&body) + { + Ok(resp) => resp, + Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => { + return Err(PollError::AuthRequired); + } + Err(error) => { + diagnose::log_error("Antigravity retrieveUserQuotaSummary request failed", error); + return Err(PollError::RequestFailed); + } + }; + + let response: AntigravityQuotaSummaryResponse = match resp.into_json() { + Ok(response) => response, + Err(error) => { + diagnose::log_error( + "unable to parse Antigravity retrieveUserQuotaSummary response", + error, + ); + return Err(PollError::RequestFailed); + } + }; + + antigravity_usage_from_summary(response).ok_or(PollError::RequestFailed) +} + +fn antigravity_section_from_quota(quota: AntigravityQuotaInfo) -> Option { + let remaining = quota.remaining_fraction?.clamp(0.0, 1.0); + Some(UsageSection { + percentage: (1.0 - remaining) * 100.0, + resets_at: parse_iso8601(quota.reset_time.as_deref()), + }) +} + +fn antigravity_section_from_summary_bucket( + bucket: &AntigravityQuotaSummaryBucket, +) -> Option { + let remaining = bucket.remaining_fraction?.clamp(0.0, 1.0); + Some(UsageSection { + percentage: (1.0 - remaining) * 100.0, + resets_at: parse_iso8601(bucket.reset_time.as_deref()), + }) +} + +fn antigravity_usage_from_summary(response: AntigravityQuotaSummaryResponse) -> Option { + let mut fallback = None; + + for group in response.groups.unwrap_or_default() { + let is_gemini = is_antigravity_gemini_summary_group(&group); + let usage = antigravity_usage_from_summary_group(group); + + if is_gemini && usage.is_some() { + return usage; + } + + if fallback.is_none() { + fallback = usage; + } + } + + fallback +} + +fn antigravity_usage_from_summary_group(group: AntigravityQuotaSummaryGroup) -> Option { + let mut data = UsageData::default(); + let mut has_quota = false; + + for bucket in group.buckets.unwrap_or_default() { + let Some(section) = antigravity_section_from_summary_bucket(&bucket) else { + continue; + }; + + match bucket.window.as_deref() { + Some(window) if window.eq_ignore_ascii_case("5h") => { + data.session = section; + has_quota = true; + } + Some(window) if window.eq_ignore_ascii_case("weekly") => { + data.weekly = section; + has_quota = true; + } + _ => {} + } + } + + has_quota.then_some(data) +} + +fn is_antigravity_gemini_summary_group(group: &AntigravityQuotaSummaryGroup) -> bool { + group + .display_name + .as_deref() + .is_some_and(|name| name.to_ascii_lowercase().contains("gemini")) + || group + .description + .as_deref() + .is_some_and(|description| description.to_ascii_lowercase().contains("gemini")) + || group.buckets.as_ref().is_some_and(|buckets| { + buckets.iter().any(|bucket| { + bucket + .bucket_id + .as_deref() + .is_some_and(|id| id.to_ascii_lowercase().starts_with("gemini-")) + || bucket + .display_name + .as_deref() + .is_some_and(|name| name.to_ascii_lowercase().contains("gemini")) + }) + }) +} + +fn best_antigravity_section(sections: I) -> Option +where + I: IntoIterator, +{ + sections.into_iter().max_by(|a, b| { + a.percentage + .partial_cmp(&b.percentage) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.resets_at.cmp(&b.resets_at)) + }) +} + +fn is_antigravity_display_model(model: &str) -> bool { + model.starts_with("gemini") + || model.starts_with("claude") + || model.starts_with("gpt") + || model.starts_with("image") + || model.starts_with("imagen") +} + fn get_header_f64(response: &ureq::Response, name: &str) -> f64 { response .header(name) @@ -799,6 +1216,54 @@ fn read_codex_credentials() -> Option { auth.tokens.filter(|tokens| !tokens.access_token.is_empty()) } +fn read_antigravity_credentials() -> Option { + let content = read_windows_generic_credential(ANTIGRAVITY_CREDENTIAL_TARGET)?; + let auth: AntigravityAuthFile = serde_json::from_str(&content).ok()?; + if auth.token.access_token.is_empty() { + None + } else { + Some(auth.token) + } +} + +fn read_windows_generic_credential(target: &str) -> Option { + const CRED_TYPE_GENERIC: u32 = 1; + + let mut target_wide: Vec = target.encode_utf16().chain(std::iter::once(0)).collect(); + let mut credential: *mut CredentialW = std::ptr::null_mut(); + + let ok = unsafe { + CredReadW( + target_wide.as_mut_ptr(), + CRED_TYPE_GENERIC, + 0, + &mut credential, + ) + }; + + if ok == 0 || credential.is_null() { + diagnose::log(format!( + "unable to read Windows generic credential target {target}" + )); + return None; + } + + let result = unsafe { + let cred = &*credential; + if cred.credential_blob_size == 0 || cred.credential_blob.is_null() { + CredFree(credential as *mut c_void); + return None; + } + let bytes = + std::slice::from_raw_parts(cred.credential_blob, cred.credential_blob_size as usize); + let text = String::from_utf8(bytes.to_vec()).ok(); + CredFree(credential as *mut c_void); + text + }; + + result +} + fn read_wsl_credentials(distro: &str) -> Option { let output = run_with_timeout( Command::new("wsl.exe") @@ -1096,4 +1561,66 @@ pub fn is_past_reset(data: &UsageData) -> bool { 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) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn antigravity_summary_prefers_gemini_group() { + let response: AntigravityQuotaSummaryResponse = serde_json::from_str( + r#"{ + "groups": [ + { + "displayName": "Claude and GPT models", + "buckets": [ + { + "bucketId": "3p-weekly", + "window": "weekly", + "resetTime": "2026-06-20T18:32:02Z", + "remainingFraction": 1 + }, + { + "bucketId": "3p-5h", + "window": "5h", + "resetTime": "2026-06-13T23:32:02Z", + "remainingFraction": 1 + } + ] + }, + { + "displayName": "Gemini Models", + "description": "Models within this group: Gemini Flash, Gemini Pro", + "buckets": [ + { + "bucketId": "gemini-weekly", + "displayName": "Weekly Limit", + "window": "weekly", + "resetTime": "2026-06-20T17:08:54Z", + "remainingFraction": 0.99304295 + }, + { + "bucketId": "gemini-5h", + "displayName": "Five Hour Limit", + "window": "5h", + "resetTime": "2026-06-13T22:08:54Z", + "remainingFraction": 0.9582575 + } + ] + } + ] + }"#, + ) + .expect("summary response should deserialize"); + + let usage = + antigravity_usage_from_summary(response).expect("Gemini quota should be selected"); + + assert!((usage.weekly.percentage - 0.695705).abs() < 0.000001); + assert!((usage.session.percentage - 4.17425).abs() < 0.000001); + assert!(usage.weekly.resets_at.is_some()); + assert!(usage.session.resets_at.is_some()); + } } diff --git a/src/tray_icon.rs b/src/tray_icon.rs index 27c3352..1242cf2 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -12,6 +12,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; /// Menu item ID for toggling widget visibility (used by window.rs context menu). pub const IDM_TOGGLE_WIDGET: u16 = 50; @@ -27,6 +28,7 @@ pub enum TrayAction { pub enum TrayIconKind { Claude, Codex, + Antigravity, } pub struct TrayIconData { @@ -40,6 +42,7 @@ impl TrayIconKind { match self { Self::Claude => CLAUDE_TRAY_ICON_ID, Self::Codex => CODEX_TRAY_ICON_ID, + Self::Antigravity => ANTIGRAVITY_TRAY_ICON_ID, } } } @@ -90,9 +93,17 @@ fn codex_fill(percent: f64) -> Color { } } +fn antigravity_fill(percent: f64) -> Color { + if percent >= 90.0 { + Color::from_hex("#FFFFFF") + } else { + Color::from_hex("#34A853") + } +} + /// 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, `percent` = None uses a black/white Codex placeholder badge. +/// For Codex and Antigravity, `percent` = None uses a provider placeholder badge. pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { if matches!(kind, TrayIconKind::Claude) && percent.is_none() { let app_icon = load_embedded_app_icon(); @@ -104,7 +115,7 @@ 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) { + let outline = if matches!(kind, TrayIconKind::Codex | TrayIconKind::Antigravity) { 3_i32 } else { 0_i32 @@ -113,16 +124,21 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { let fill = match kind { 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)), }; let text_col = match kind { TrayIconKind::Claude => Color::from_hex("#FFFFFF"), TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"), TrayIconKind::Codex => Color::from_hex("#FFFFFF"), + TrayIconKind::Antigravity if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1E7D41"), + TrayIconKind::Antigravity => Color::from_hex("#FFFFFF"), }; let outline_col = match kind { TrayIconKind::Claude => fill, TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"), TrayIconKind::Codex => Color::from_hex("#FFFFFF"), + TrayIconKind::Antigravity if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#1E7D41"), + TrayIconKind::Antigravity => Color::from_hex("#FFFFFF"), }; let display_text = match percent { @@ -130,6 +146,7 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { None => match kind { TrayIconKind::Claude => String::new(), TrayIconKind::Codex => "C".to_string(), + TrayIconKind::Antigravity => "A".to_string(), }, }; @@ -397,6 +414,9 @@ pub fn sync(hwnd: HWND, icons: &[TrayIconData]) { let show_codex = icons .iter() .find(|icon| matches!(icon.kind, TrayIconKind::Codex)); + let show_antigravity = icons + .iter() + .find(|icon| matches!(icon.kind, TrayIconKind::Antigravity)); if let Some(icon) = show_claude { add(hwnd, icon.kind, icon.percent, &icon.tooltip); @@ -411,11 +431,19 @@ pub fn sync(hwnd: HWND, icons: &[TrayIconData]) { } else { remove(hwnd, TrayIconKind::Codex); } + + if let Some(icon) = show_antigravity { + add(hwnd, icon.kind, icon.percent, &icon.tooltip); + update(hwnd, icon.kind, icon.percent, &icon.tooltip); + } else { + remove(hwnd, TrayIconKind::Antigravity); + } } pub fn remove_all(hwnd: HWND) { remove(hwnd, TrayIconKind::Claude); remove(hwnd, TrayIconKind::Codex); + remove(hwnd, TrayIconKind::Antigravity); } /// Interpret a tray callback message and return the action to take. diff --git a/src/window.rs b/src/window.rs index 7ce1dea..ef3d1b8 100644 --- a/src/window.rs +++ b/src/window.rs @@ -63,8 +63,13 @@ struct AppState { codex_session_text: String, codex_weekly_percent: f64, codex_weekly_text: String, + antigravity_session_percent: f64, + antigravity_session_text: String, + antigravity_weekly_percent: f64, + antigravity_weekly_text: String, show_claude_code: bool, show_codex: bool, + show_antigravity: bool, data: Option, @@ -123,6 +128,7 @@ const IDM_LANG_RUSSIAN: u16 = 49; const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; +const IDM_MODEL_ANTIGRAVITY: u16 = 62; const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN @@ -215,6 +221,8 @@ struct SettingsFile { show_claude_code: bool, #[serde(default = "default_show_codex")] show_codex: bool, + #[serde(default = "default_show_antigravity")] + show_antigravity: bool, } impl Default for SettingsFile { @@ -227,6 +235,7 @@ impl Default for SettingsFile { widget_visible: true, show_claude_code: true, show_codex: false, + show_antigravity: false, } } } @@ -247,13 +256,17 @@ fn default_show_codex() -> bool { false } +fn default_show_antigravity() -> 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 { + if !settings.show_claude_code && !settings.show_codex && !settings.show_antigravity { settings.show_claude_code = true; } settings @@ -282,6 +295,7 @@ fn save_state_settings() { widget_visible: s.widget_visible, show_claude_code: s.show_claude_code, show_codex: s.show_codex, + show_antigravity: s.show_antigravity, }); } } @@ -315,6 +329,18 @@ fn tray_icon_data_from_state() -> Vec { ), }); } + if s.show_antigravity { + icons.push(tray_icon::TrayIconData { + kind: tray_icon::TrayIconKind::Antigravity, + percent: Some(s.antigravity_session_percent), + tooltip: format!( + "{} 5h: {} | 7d: {}", + s.language.strings().antigravity_model, + s.antigravity_session_text, + s.antigravity_weekly_text + ), + }); + } icons } Some(s) => { @@ -333,6 +359,13 @@ fn tray_icon_data_from_state() -> Vec { tooltip: s.language.strings().codex_window_title.to_string(), }); } + if s.show_antigravity { + icons.push(tray_icon::TrayIconData { + kind: tray_icon::TrayIconKind::Antigravity, + percent: None, + tooltip: s.language.strings().antigravity_window_title.to_string(), + }); + } icons } None => Vec::new(), @@ -434,6 +467,19 @@ fn refresh_usage_texts(state: &mut AppState) { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); } + + if let Some(antigravity) = data.antigravity.as_ref() { + state.antigravity_session_text = poller::format_line(&antigravity.session, strings); + state.antigravity_weekly_text = + if antigravity.weekly.resets_at.is_none() && antigravity.weekly.percentage == 0.0 { + "--".to_string() + } else { + poller::format_line(&antigravity.weekly, strings) + }; + } else if state.show_antigravity { + state.antigravity_session_text = "!".to_string(); + state.antigravity_weekly_text = "!".to_string(); + } } fn set_window_title(hwnd: HWND, strings: Strings) { @@ -822,8 +868,8 @@ const MODEL_RIGHT_MARGIN: i32 = 5; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; -fn active_model_count(show_claude_code: bool, show_codex: bool) -> i32 { - (show_claude_code as i32 + show_codex as i32).max(1) +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 row_bar_segment_count(active_models: i32) -> i32 { @@ -850,7 +896,11 @@ fn total_widget_width_for(active_models: i32) -> i32 { } fn total_widget_width_for_state(state: &AppState) -> i32 { - total_widget_width_for(active_model_count(state.show_claude_code, state.show_codex)) + total_widget_width_for(active_model_count( + state.show_claude_code, + state.show_codex, + state.show_antigravity, + )) } fn total_widget_width() -> i32 { @@ -858,7 +908,7 @@ fn total_widget_width() -> i32 { let state = lock_state(); state .as_ref() - .map(|s| active_model_count(s.show_claude_code, s.show_codex)) + .map(|s| active_model_count(s.show_claude_code, s.show_codex, s.show_antigravity)) .unwrap_or(1) }; total_widget_width_for(active_models) @@ -876,6 +926,10 @@ fn codex_accent_color(is_dark: bool) -> Color { } } +fn antigravity_accent_color() -> Color { + Color::from_hex("#34A853") +} + fn claude_usage_text_color(is_dark: bool) -> Color { if is_dark { Color::from_hex("#F09A7A") @@ -892,6 +946,14 @@ fn codex_usage_text_color(is_dark: bool) -> Color { } } +fn antigravity_usage_text_color(is_dark: bool) -> Color { + if is_dark { + Color::from_hex("#6FD58D") + } else { + Color::from_hex("#1E7D41") + } +} + pub fn run() { // Enable Per-Monitor DPI Awareness V2 for crisp rendering at any scale factor unsafe { @@ -953,8 +1015,11 @@ pub fn run() { // Create as layered popup (will be reparented into taskbar) let title = native_interop::wide_str(language.strings().window_title); - let initial_model_count = - active_model_count(settings.show_claude_code, settings.show_codex); + let initial_model_count = active_model_count( + settings.show_claude_code, + settings.show_codex, + settings.show_antigravity, + ); let hwnd = CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_NOACTIVATE, PCWSTR::from_raw(class_name.as_ptr()), @@ -1013,8 +1078,13 @@ pub fn run() { codex_session_text: "--".to_string(), codex_weekly_percent: 0.0, codex_weekly_text: "--".to_string(), + antigravity_session_percent: 0.0, + antigravity_session_text: "--".to_string(), + antigravity_weekly_percent: 0.0, + antigravity_weekly_text: "--".to_string(), show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, + show_antigravity: settings.show_antigravity, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1152,8 +1222,13 @@ fn render_layered() { codex_session_text, codex_weekly_pct, codex_weekly_text, + antigravity_session_pct, + antigravity_session_text, + antigravity_weekly_pct, + antigravity_weekly_text, show_claude_code, show_codex, + show_antigravity, ) = { let state = lock_state(); match state.as_ref() { @@ -1170,8 +1245,13 @@ fn render_layered() { s.codex_session_text.clone(), s.codex_weekly_percent, s.codex_weekly_text.clone(), + s.antigravity_session_percent, + s.antigravity_session_text.clone(), + s.antigravity_weekly_percent, + s.antigravity_weekly_text.clone(), s.show_claude_code, s.show_codex, + s.show_antigravity, ), None => return, } @@ -1192,6 +1272,7 @@ fn render_layered() { let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); + let antigravity_accent = antigravity_accent_color(); let track = if is_dark { Color::from_hex("#444444") } else { @@ -1259,9 +1340,15 @@ fn render_layered() { &codex_session_text, codex_weekly_pct, &codex_weekly_text, + antigravity_session_pct, + &antigravity_session_text, + antigravity_weekly_pct, + &antigravity_weekly_text, show_claude_code, show_codex, + show_antigravity, &codex_accent, + &antigravity_accent, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1329,9 +1416,15 @@ fn paint_content( codex_session_text: &str, codex_weekly_pct: f64, codex_weekly_text: &str, + antigravity_session_pct: f64, + antigravity_session_text: &str, + antigravity_weekly_pct: f64, + antigravity_weekly_text: &str, show_claude_code: bool, show_codex: bool, + show_antigravity: bool, codex_accent: &Color, + antigravity_accent: &Color, ) { unsafe { let client_rect = RECT { @@ -1419,10 +1512,14 @@ fn paint_content( session_text, codex_session_pct, codex_session_text, + antigravity_session_pct, + antigravity_session_text, show_claude_code, show_codex, + show_antigravity, accent, codex_accent, + antigravity_accent, track, ); draw_row( @@ -1436,10 +1533,14 @@ fn paint_content( weekly_text, codex_weekly_pct, codex_weekly_text, + antigravity_weekly_pct, + antigravity_weekly_text, show_claude_code, show_codex, + show_antigravity, accent, codex_accent, + antigravity_accent, track, ); @@ -1450,15 +1551,15 @@ fn paint_content( fn do_poll(send_hwnd: SendHwnd) { let hwnd = send_hwnd.to_hwnd(); - let (show_claude_code, show_codex) = { + let (show_claude_code, show_codex, show_antigravity) = { let state = lock_state(); state .as_ref() - .map(|s| (s.show_claude_code, s.show_codex)) - .unwrap_or((true, false)) + .map(|s| (s.show_claude_code, s.show_codex, s.show_antigravity)) + .unwrap_or((true, false, false)) }; - match poller::poll(show_claude_code, show_codex) { + match poller::poll(show_claude_code, show_codex, show_antigravity) { Ok(data) => { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -1476,6 +1577,13 @@ fn do_poll(send_hwnd: SendHwnd) { s.codex_session_percent = 0.0; s.codex_weekly_percent = 0.0; } + if let Some(antigravity) = data.antigravity.as_ref() { + s.antigravity_session_percent = antigravity.session.percentage; + s.antigravity_weekly_percent = antigravity.weekly.percentage; + } else if s.show_antigravity { + s.antigravity_session_percent = 0.0; + s.antigravity_weekly_percent = 0.0; + } // Stop fast-poll if reset data is now fresh if !poller::app_is_past_reset(&data) { unsafe { @@ -1507,6 +1615,14 @@ fn do_poll(send_hwnd: SendHwnd) { } Err(e) => { let auth_watch = match e { + poller::PollError::AuthRequired | poller::PollError::TokenExpired + if show_antigravity && !show_claude_code && !show_codex => + { + Some(( + poller::CredentialWatchMode::Antigravity, + poller::credential_watch_snapshot(poller::CredentialWatchMode::Antigravity), + )) + } poller::PollError::AuthRequired | poller::PollError::TokenExpired => Some(( poller::CredentialWatchMode::ActiveSource, poller::credential_watch_snapshot(poller::CredentialWatchMode::ActiveSource), @@ -1537,6 +1653,8 @@ fn do_poll(send_hwnd: SendHwnd) { s.weekly_text = "!".to_string(); s.codex_session_text = "!".to_string(); s.codex_weekly_text = "!".to_string(); + s.antigravity_session_text = "!".to_string(); + s.antigravity_weekly_text = "!".to_string(); s.retry_count = s.retry_count.saturating_add(1); unsafe { let _ = KillTimer(hwnd, TIMER_POLL); @@ -1555,6 +1673,8 @@ fn do_poll(send_hwnd: SendHwnd) { s.weekly_text = "...".to_string(); s.codex_session_text = "...".to_string(); s.codex_weekly_text = "...".to_string(); + s.antigravity_session_text = "...".to_string(); + s.antigravity_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), @@ -1581,13 +1701,20 @@ fn do_poll(send_hwnd: SendHwnd) { s.language.strings().token_expired_title, s.language.strings().token_expired_body, ) - } else { + } else if s.show_codex { ( s.language.strings(), tray_icon::TrayIconKind::Codex, s.language.strings().codex_token_expired_title, s.language.strings().codex_token_expired_body, ) + } else { + ( + s.language.strings(), + tray_icon::TrayIconKind::Antigravity, + s.language.strings().antigravity_token_expired_title, + s.language.strings().antigravity_token_expired_body, + ) } }) }; @@ -1644,6 +1771,12 @@ fn schedule_countdown_timer() { data.codex .as_ref() .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), + data.antigravity + .as_ref() + .and_then(|usage| poller::time_until_display_change(usage.session.resets_at)), + data.antigravity + .as_ref() + .and_then(|usage| poller::time_until_display_change(usage.weekly.resets_at)), ]; let min_delay = delays.into_iter().flatten().min(); @@ -2220,27 +2353,34 @@ 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_CLAUDE_CODE | IDM_MODEL_CODEX | IDM_MODEL_ANTIGRAVITY => { { let mut state = lock_state(); if let Some(s) = state.as_mut() { match id { IDM_MODEL_CLAUDE_CODE => { - if s.show_codex || !s.show_claude_code { + if s.show_codex || s.show_antigravity || !s.show_claude_code { s.show_claude_code = !s.show_claude_code; } } IDM_MODEL_CODEX => { - if s.show_claude_code || !s.show_codex { + if s.show_claude_code || s.show_antigravity || !s.show_codex { s.show_codex = !s.show_codex; } } + IDM_MODEL_ANTIGRAVITY => { + if s.show_claude_code || s.show_codex || !s.show_antigravity { + s.show_antigravity = !s.show_antigravity; + } + } _ => {} } s.session_text = "...".to_string(); s.weekly_text = "...".to_string(); s.codex_session_text = "...".to_string(); s.codex_weekly_text = "...".to_string(); + s.antigravity_session_text = "...".to_string(); + s.antigravity_weekly_text = "...".to_string(); } } save_state_settings(); @@ -2333,6 +2473,7 @@ fn show_context_menu(hwnd: HWND) { widget_visible, show_claude_code, show_codex, + show_antigravity, ) = { let state = lock_state(); match state.as_ref() { @@ -2346,6 +2487,7 @@ fn show_context_menu(hwnd: HWND) { s.widget_visible, s.show_claude_code, s.show_codex, + s.show_antigravity, ), None => ( POLL_15_MIN, @@ -2357,6 +2499,7 @@ fn show_context_menu(hwnd: HWND) { true, true, false, + false, ), } }; @@ -2430,6 +2573,19 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(codex_model.as_ptr()), ); + let antigravity_model = native_interop::wide_str(strings.antigravity_model); + let antigravity_flags = if show_antigravity { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + models_menu, + antigravity_flags, + IDM_MODEL_ANTIGRAVITY as usize, + PCWSTR::from_raw(antigravity_model.as_ptr()), + ); + let models_label = native_interop::wide_str(strings.models); let _ = AppendMenuW( menu, @@ -2583,8 +2739,13 @@ fn paint(hdc: HDC, hwnd: HWND) { codex_session_text, codex_weekly_pct, codex_weekly_text, + antigravity_session_pct, + antigravity_session_text, + antigravity_weekly_pct, + antigravity_weekly_text, show_claude_code, show_codex, + show_antigravity, ) = { let state = lock_state(); match state.as_ref() { @@ -2599,8 +2760,13 @@ fn paint(hdc: HDC, hwnd: HWND) { s.codex_session_text.clone(), s.codex_weekly_percent, s.codex_weekly_text.clone(), + s.antigravity_session_percent, + s.antigravity_session_text.clone(), + s.antigravity_weekly_percent, + s.antigravity_weekly_text.clone(), s.show_claude_code, s.show_codex, + s.show_antigravity, ), None => return, } @@ -2608,6 +2774,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 track = if is_dark { Color::from_hex("#444444") } else { @@ -2656,9 +2823,15 @@ fn paint(hdc: HDC, hwnd: HWND) { &codex_session_text, codex_weekly_pct, &codex_weekly_text, + antigravity_session_pct, + &antigravity_session_text, + antigravity_weekly_pct, + &antigravity_weekly_text, show_claude_code, show_codex, + show_antigravity, &codex_accent, + &antigravity_accent, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -2680,16 +2853,20 @@ fn draw_row( claude_text: &str, codex_percent: f64, codex_text: &str, + antigravity_percent: f64, + antigravity_text: &str, show_claude_code: bool, show_codex: bool, + show_antigravity: bool, claude_accent: &Color, codex_accent: &Color, + antigravity_accent: &Color, track: &Color, ) { let seg_h = sc(SEGMENT_H); - let active_models = active_model_count(show_claude_code, show_codex); + let active_models = active_model_count(show_claude_code, show_codex, show_antigravity); let segment_count = row_bar_segment_count(active_models); - let use_model_text_colors = show_claude_code && show_codex; + let use_model_text_colors = active_models > 1; let claude_value_color = if use_model_text_colors { claude_usage_text_color(is_dark) } else { @@ -2700,6 +2877,11 @@ fn draw_row( } else { *text_color }; + let antigravity_value_color = if use_model_text_colors { + antigravity_usage_text_color(is_dark) + } else { + *text_color + }; unsafe { let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -2744,6 +2926,20 @@ fn draw_row( track, &codex_value_color, ); + model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); + } + if show_antigravity { + draw_usage_bar( + hdc, + model_x, + y, + segment_count, + antigravity_percent, + antigravity_text, + antigravity_accent, + track, + &antigravity_value_color, + ); } } }