From b5804ff22cee716b3a065dbf2f56ffefcdaa2df3 Mon Sep 17 00:00:00 2001 From: Ash Date: Mon, 20 Jul 2026 15:34:33 +0100 Subject: [PATCH 1/2] feat(ui): open provider usage page (click + menu) and two-unit countdown Co-Authored-By: Claude Opus 4.8 --- src/localization/dutch.rs | 1 + src/localization/english.rs | 1 + src/localization/french.rs | 1 + src/localization/german.rs | 1 + src/localization/japanese.rs | 1 + src/localization/korean.rs | 1 + src/localization/mod.rs | 1 + src/localization/portuguese_brazil.rs | 1 + src/localization/russian.rs | 1 + src/localization/simplified_chinese.rs | 1 + src/localization/spanish.rs | 1 + src/localization/traditional_chinese.rs | 1 + src/native_interop.rs | 18 +++++- src/poller.rs | 74 +++++++++++++++++++----- src/window.rs | 75 ++++++++++++++++++++++++- 15 files changed, 162 insertions(+), 17 deletions(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..a9d60e9 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", + open_prefix: "Open usage page:", settings: "Instellingen", start_with_windows: "Opstarten met Windows", reset_position: "Positie herstellen", diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..bc7373d 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", + open_prefix: "Open usage page:", settings: "Settings", start_with_windows: "Start with Windows", reset_position: "Reset Position", diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..2a0e07e 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", + open_prefix: "Open usage page:", settings: "Paramètres", start_with_windows: "Démarrer avec Windows", reset_position: "Réinitialiser la position", diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..17613c8 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", + open_prefix: "Open usage page:", settings: "Einstellungen", start_with_windows: "Mit Windows starten", reset_position: "Position zurücksetzen", diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..53df12b 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", + open_prefix: "Open usage page:", settings: "設定", start_with_windows: "Windows と同時に開始", reset_position: "位置をリセット", diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..1d02512 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", + open_prefix: "Open usage page:", settings: "설정", start_with_windows: "Windows 시작 시 자동 실행", reset_position: "위치 초기화", diff --git a/src/localization/mod.rs b/src/localization/mod.rs index a11ba02..b38d789 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 open_prefix: &'static str, pub settings: &'static str, pub start_with_windows: &'static str, pub reset_position: &'static str, diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 56cf3bf..d338066 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", + open_prefix: "Open usage page:", settings: "Configurações", start_with_windows: "Iniciar com o Windows", reset_position: "Redefinir Posição", diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..eb1269f 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", + open_prefix: "Open usage page:", settings: "Настройки", start_with_windows: "Запускать вместе с Windows", reset_position: "Сбросить позицию", diff --git a/src/localization/simplified_chinese.rs b/src/localization/simplified_chinese.rs index 8a62588..4514997 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", + open_prefix: "Open usage page:", settings: "设置", start_with_windows: "开机时启动", reset_position: "重置位置", diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..97af316 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", + open_prefix: "Open usage page:", settings: "Configuración", start_with_windows: "Iniciar con Windows", reset_position: "Restablecer posición", diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..701730b 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", + open_prefix: "Open usage page:", settings: "設定", start_with_windows: "開機時啟動", reset_position: "重置位置", diff --git a/src/native_interop.rs b/src/native_interop.rs index c745d08..0528690 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,7 +1,7 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; -use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; +use windows::Win32::UI::Shell::{ShellExecuteW, SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; // Window style constants @@ -176,6 +176,22 @@ pub fn unhook_win_event(hook: HWINEVENTHOOK) { } } +/// Open a URL in the user's default browser. Best-effort; ignores failure. +pub fn open_url(url: &str) { + let verb = wide_str("open"); + let target = wide_str(url); + unsafe { + let _ = ShellExecuteW( + None, + PCWSTR(verb.as_ptr()), + PCWSTR(target.as_ptr()), + PCWSTR::null(), + PCWSTR::null(), + SW_SHOWNORMAL, + ); + } +} + /// Convert a Rust string to a null-terminated wide string pub fn wide_str(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..7acdf3a 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1524,7 +1524,7 @@ fn is_leap(y: u64) -> bool { /// Format a usage section as "X% · Yh" style text pub fn format_line(section: &UsageSection, strings: Strings) -> String { let pct = format!("{:.0}%", section.percentage); - let cd = format_countdown(section.resets_at, strings); + let cd = format_countdown_two(section.resets_at, strings); if cd.is_empty() { pct } else { @@ -1532,6 +1532,7 @@ pub fn format_line(section: &UsageSection, strings: Strings) -> String { } } +#[allow(dead_code)] fn format_countdown(resets_at: Option, strings: Strings) -> String { let reset = match resets_at { Some(t) => t, @@ -1546,6 +1547,43 @@ fn format_countdown(resets_at: Option, strings: Strings) -> String { format_countdown_from_secs(remaining.as_secs(), strings) } +/// Same reset handling as `format_countdown` but renders up to two components. +fn format_countdown_two(resets_at: Option, strings: Strings) -> String { + let reset = match resets_at { + Some(t) => t, + None => return String::new(), + }; + + let remaining = match reset.duration_since(SystemTime::now()) { + Ok(d) => d, + Err(_) => return strings.now.to_string(), + }; + + format_countdown_two_units(remaining.as_secs(), strings) +} + +/// Countdown with up to TWO components, e.g. "2h 15m", "3d 4h", "45m 10s". +pub fn format_countdown_two_units(total_secs: u64, strings: Strings) -> String { + let days = total_secs / 86_400; + let hours = (total_secs % 86_400) / 3_600; + let minutes = (total_secs % 3_600) / 60; + let seconds = total_secs % 60; + let (a_val, a_suf, b_val, b_suf) = if days > 0 { + (days, strings.day_suffix, hours, strings.hour_suffix) + } else if hours > 0 { + (hours, strings.hour_suffix, minutes, strings.minute_suffix) + } else if minutes > 0 { + (minutes, strings.minute_suffix, seconds, strings.second_suffix) + } else { + (seconds, strings.second_suffix, 0, strings.second_suffix) + }; + if b_val > 0 { + format!("{}{} {}{}", a_val, a_suf, b_val, b_suf) + } else { + format!("{}{}", a_val, a_suf) + } +} + /// Calculate how long until the display text would change pub fn time_until_display_change(resets_at: Option) -> Option { let reset = resets_at?; @@ -1553,6 +1591,7 @@ pub fn time_until_display_change(resets_at: Option) -> Option String { let total_mins = total_secs / 60; let total_hours = total_secs / 3600; @@ -1569,22 +1608,19 @@ fn format_countdown_from_secs(total_secs: u64, strings: Strings) -> String { } } +/// How long until the two-unit countdown text next changes. +/// +/// When at least an hour remains the smallest displayed unit is minutes +/// (e.g. "2h 15m"), so the display changes on the next whole-minute boundary. +/// Below an hour seconds are shown (e.g. "45m 10s" / "30s"), so it changes +/// every second. fn time_until_display_change_from_secs(total_secs: u64) -> Duration { - let total_mins = total_secs / 60; - let total_hours = total_secs / 3600; - let total_days = total_secs / 86400; - - let current_bucket_start = if total_days >= 1 { - total_days * 86400 - } else if total_hours >= 1 { - total_hours * 3600 - } else if total_mins >= 1 { - total_mins * 60 + if total_secs >= 3600 { + let rem = total_secs % 60; + Duration::from_secs(if rem == 0 { 60 } else { 60 - rem }) } else { - total_secs - }; - - Duration::from_secs(total_secs.saturating_sub(current_bucket_start) + 1) + Duration::from_secs(1) + } } /// Returns true if either section has reached "now" (reset time has passed). @@ -1614,6 +1650,14 @@ mod tests { } } + #[test] + fn two_unit_countdown_formats() { + let s = crate::localization::LanguageId::English.strings(); + assert_eq!(format_countdown_two_units(2 * 3600 + 15 * 60, s), "2h 15m"); + assert_eq!(format_countdown_two_units(30, s), "30s"); + assert_eq!(format_countdown_two_units(2 * 3600, s), "2h"); + } + #[test] fn claude_failure_does_not_block_codex_when_both_are_enabled() { let data = poll_with( diff --git a/src/window.rs b/src/window.rs index bf30f88..5cae97d 100644 --- a/src/window.rs +++ b/src/window.rs @@ -132,6 +132,14 @@ 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_OPEN_DASHBOARD_CLAUDE: u16 = 90; +const IDM_OPEN_DASHBOARD_CODEX: u16 = 91; +const IDM_OPEN_DASHBOARD_ANTIGRAVITY: u16 = 92; + +// TODO: verify current usage-page URL before merge +const URL_CLAUDE_USAGE: &str = "https://claude.ai/settings/usage"; +const URL_CODEX_USAGE: &str = "https://platform.openai.com/usage"; +const URL_ANTIGRAVITY_USAGE: &str = "https://aistudio.google.com/usage"; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -1056,7 +1064,7 @@ const DIVIDER_RIGHT_MARGIN: i32 = 10; const LABEL_WIDTH: i32 = 18; const LABEL_RIGHT_MARGIN: i32 = 10; const BAR_RIGHT_MARGIN: i32 = 4; -const TEXT_WIDTH: i32 = 62; +const TEXT_WIDTH: i32 = 84; const MODEL_RIGHT_MARGIN: i32 = 3; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; @@ -2345,6 +2353,11 @@ unsafe extern "system" fn wnd_proc( let client_x = (lparam.0 & 0xFFFF) as i16 as i32; let client_y = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32; if !is_drag_handle_point(client_x, client_y) { + // Body click (outside the drag handle): open the usage page of + // the provider column under the cursor. + if let Some(url) = provider_url_at_x(client_x) { + native_interop::open_url(url); + } return LRESULT(0); } @@ -2670,6 +2683,9 @@ unsafe extern "system" fn wnd_proc( save_state_settings(); render_layered(); } + IDM_OPEN_DASHBOARD_CLAUDE => native_interop::open_url(URL_CLAUDE_USAGE), + IDM_OPEN_DASHBOARD_CODEX => native_interop::open_url(URL_CODEX_USAGE), + IDM_OPEN_DASHBOARD_ANTIGRAVITY => native_interop::open_url(URL_ANTIGRAVITY_USAGE), id if id == tray_icon::IDM_TOGGLE_WIDGET => { toggle_widget_visibility(hwnd); } @@ -2758,6 +2774,29 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(refresh_str.as_ptr()), ); + // Open usage-page items for each enabled provider + let open_items: [(bool, u16, &str); 3] = [ + (show_claude_code, IDM_OPEN_DASHBOARD_CLAUDE, strings.claude_code_model), + (show_codex, IDM_OPEN_DASHBOARD_CODEX, strings.codex_model), + ( + show_antigravity, + IDM_OPEN_DASHBOARD_ANTIGRAVITY, + strings.antigravity_model, + ), + ]; + for (enabled, id, name) in open_items { + if enabled { + let label = format!("{} {}", strings.open_prefix, name); + let label_str = native_interop::wide_str(&label); + let _ = AppendMenuW( + menu, + MENU_ITEM_FLAGS(0), + id as usize, + PCWSTR::from_raw(label_str.as_ptr()), + ); + } + } + // Update Frequency submenu let freq_menu = CreatePopupMenu().unwrap(); let freq_items: [(u16, u32, &str); 4] = [ @@ -3195,6 +3234,40 @@ fn model_usage_width(segment_count: i32) -> i32 { + sc(TEXT_WIDTH) } +/// Map a client-area X coordinate (in the widget body, not the drag handle) to +/// the usage-page URL of the provider column under it. This replicates the +/// exact left→right column advance used by `draw_row`: content origin, skip the +/// label, then each enabled provider occupies +/// `model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN)`. +fn provider_url_at_x(client_x: i32) -> Option<&'static str> { + let (show_claude_code, show_codex, show_antigravity) = { + let state = lock_state(); + let s = state.as_ref()?; + (s.show_claude_code, s.show_codex, s.show_antigravity) + }; + + let active_models = active_model_count(show_claude_code, show_codex, show_antigravity); + let segment_count = row_bar_segment_count(active_models); + let column_width = model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); + + let content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN); + let mut model_x = content_x + sc(LABEL_WIDTH) + sc(LABEL_RIGHT_MARGIN); + + for (enabled, url) in [ + (show_claude_code, URL_CLAUDE_USAGE), + (show_codex, URL_CODEX_USAGE), + (show_antigravity, URL_ANTIGRAVITY_USAGE), + ] { + if enabled { + if client_x >= model_x && client_x < model_x + column_width { + return Some(url); + } + model_x += column_width; + } + } + None +} + fn draw_usage_bar( hdc: HDC, bar_x: i32, From 73dbedc2fffbe74b0a5f8b93ea2db2cbbedbcd3a Mon Sep 17 00:00:00 2001 From: Ash Date: Mon, 20 Jul 2026 16:03:25 +0100 Subject: [PATCH 2/2] fix(clickthrough): correct Codex usage URL, drop Antigravity (no web page) Codex plan usage lives at chatgpt.com/codex/settings/usage (not the API billing page). Antigravity quota is only viewable in-IDE, so it gets no click-through (menu item + column click omitted). Co-Authored-By: Claude Opus 4.8 --- src/window.rs | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/window.rs b/src/window.rs index 5cae97d..e75574b 100644 --- a/src/window.rs +++ b/src/window.rs @@ -134,12 +134,11 @@ const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; const IDM_OPEN_DASHBOARD_CLAUDE: u16 = 90; const IDM_OPEN_DASHBOARD_CODEX: u16 = 91; -const IDM_OPEN_DASHBOARD_ANTIGRAVITY: u16 = 92; +// No Antigravity dashboard URL: its quota is only viewable inside the Antigravity +// IDE (Settings > Advanced > Models), so it gets no click-through. -// TODO: verify current usage-page URL before merge const URL_CLAUDE_USAGE: &str = "https://claude.ai/settings/usage"; -const URL_CODEX_USAGE: &str = "https://platform.openai.com/usage"; -const URL_ANTIGRAVITY_USAGE: &str = "https://aistudio.google.com/usage"; +const URL_CODEX_USAGE: &str = "https://chatgpt.com/codex/settings/usage"; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -2685,7 +2684,6 @@ unsafe extern "system" fn wnd_proc( } IDM_OPEN_DASHBOARD_CLAUDE => native_interop::open_url(URL_CLAUDE_USAGE), IDM_OPEN_DASHBOARD_CODEX => native_interop::open_url(URL_CODEX_USAGE), - IDM_OPEN_DASHBOARD_ANTIGRAVITY => native_interop::open_url(URL_ANTIGRAVITY_USAGE), id if id == tray_icon::IDM_TOGGLE_WIDGET => { toggle_widget_visibility(hwnd); } @@ -2774,15 +2772,11 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(refresh_str.as_ptr()), ); - // Open usage-page items for each enabled provider - let open_items: [(bool, u16, &str); 3] = [ + // Open usage-page items for each enabled provider that has a web usage + // page (Antigravity has none — its quota is only in-IDE). + let open_items: [(bool, u16, &str); 2] = [ (show_claude_code, IDM_OPEN_DASHBOARD_CLAUDE, strings.claude_code_model), (show_codex, IDM_OPEN_DASHBOARD_CODEX, strings.codex_model), - ( - show_antigravity, - IDM_OPEN_DASHBOARD_ANTIGRAVITY, - strings.antigravity_model, - ), ]; for (enabled, id, name) in open_items { if enabled { @@ -3253,14 +3247,15 @@ fn provider_url_at_x(client_x: i32) -> Option<&'static str> { let content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN); let mut model_x = content_x + sc(LABEL_WIDTH) + sc(LABEL_RIGHT_MARGIN); + // Antigravity has no web usage page, so its column maps to None (no open). for (enabled, url) in [ - (show_claude_code, URL_CLAUDE_USAGE), - (show_codex, URL_CODEX_USAGE), - (show_antigravity, URL_ANTIGRAVITY_USAGE), + (show_claude_code, Some(URL_CLAUDE_USAGE)), + (show_codex, Some(URL_CODEX_USAGE)), + (show_antigravity, None), ] { if enabled { if client_x >= model_x && client_x < model_x + column_width { - return Some(url); + return url; } model_x += column_width; }