Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/localization/dutch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/localization/english.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/localization/french.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/localization/german.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/localization/japanese.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "位置をリセット",
Expand Down
1 change: 1 addition & 0 deletions src/localization/korean.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "위치 초기화",
Expand Down
1 change: 1 addition & 0 deletions src/localization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/localization/portuguese_brazil.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/localization/russian.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "Сбросить позицию",
Expand Down
1 change: 1 addition & 0 deletions src/localization/simplified_chinese.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "重置位置",
Expand Down
1 change: 1 addition & 0 deletions src/localization/spanish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/localization/traditional_chinese.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "重置位置",
Expand Down
18 changes: 17 additions & 1 deletion src/native_interop.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
Expand Down
74 changes: 59 additions & 15 deletions src/poller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1524,14 +1524,15 @@ 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 {
format!("{pct} \u{00b7} {cd}")
}
}

#[allow(dead_code)]
fn format_countdown(resets_at: Option<SystemTime>, strings: Strings) -> String {
let reset = match resets_at {
Some(t) => t,
Expand All @@ -1546,13 +1547,51 @@ fn format_countdown(resets_at: Option<SystemTime>, 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<SystemTime>, 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<SystemTime>) -> Option<Duration> {
let reset = resets_at?;
let remaining = reset.duration_since(SystemTime::now()).ok()?;
Some(time_until_display_change_from_secs(remaining.as_secs()))
}

#[allow(dead_code)]
fn format_countdown_from_secs(total_secs: u64, strings: Strings) -> String {
let total_mins = total_secs / 60;
let total_hours = total_secs / 3600;
Expand All @@ -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).
Expand Down Expand Up @@ -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(
Expand Down
70 changes: 69 additions & 1 deletion src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ 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;
// No Antigravity dashboard URL: its quota is only viewable inside the Antigravity
// IDE (Settings > Advanced > Models), so it gets no click-through.

const URL_CLAUDE_USAGE: &str = "https://claude.ai/settings/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;
Expand Down Expand Up @@ -1056,7 +1063,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;
Expand Down Expand Up @@ -2345,6 +2352,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);
}

Expand Down Expand Up @@ -2670,6 +2682,8 @@ 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),
id if id == tray_icon::IDM_TOGGLE_WIDGET => {
toggle_widget_visibility(hwnd);
}
Expand Down Expand Up @@ -2758,6 +2772,25 @@ fn show_context_menu(hwnd: HWND) {
PCWSTR::from_raw(refresh_str.as_ptr()),
);

// 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),
];
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] = [
Expand Down Expand Up @@ -3195,6 +3228,41 @@ 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);

// Antigravity has no web usage page, so its column maps to None (no open).
for (enabled, url) in [
(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 url;
}
model_x += column_width;
}
}
None
}

fn draw_usage_bar(
hdc: HDC,
bar_x: i32,
Expand Down