From 3ef4029ce3d627e9b395359c00f18566451c2798 Mon Sep 17 00:00:00 2001 From: Ash Date: Mon, 20 Jul 2026 14:58:32 +0100 Subject: [PATCH 1/4] feat(pace): add pure pace/ETA/alert math module with tests Tests not run locally (Windows-only crate; build.rs requires Windows toolchain). Co-Authored-By: Claude Opus 4.8 --- src/main.rs | 1 + src/pace.rs | 285 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 src/pace.rs diff --git a/src/main.rs b/src/main.rs index a17bc0b..88c4aa1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod diagnose; mod localization; mod models; mod native_interop; +mod pace; mod poller; mod theme; mod tray_icon; diff --git a/src/pace.rs b/src/pace.rs new file mode 100644 index 0000000..990e93c --- /dev/null +++ b/src/pace.rs @@ -0,0 +1,285 @@ +//! Pure pace / burn-rate math. No Win32, no rendering — fully unit tested. +use std::time::{Duration, SystemTime}; + +/// Which provider a usage cell belongs to. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Provider { + ClaudeCode, + Codex, + Antigravity, +} + +/// Which rolling window a cell represents. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Section { + Session, // "5h" + Weekly, // "7d" +} + +/// Fixed, known window length. `None` => unknown => omit all pace UI/alerts. +pub fn window_length(provider: Provider, section: Section) -> Option { + match provider { + Provider::ClaudeCode => Some(match section { + Section::Session => Duration::from_secs(5 * 3600), + Section::Weekly => Duration::from_secs(7 * 24 * 3600), + }), + // Real windows differ and are not reliably known; omit rather than fake. + Provider::Codex | Provider::Antigravity => None, + } +} + +/// Fraction of the window elapsed, clamped to 0.0..=1.0. +/// `None` if inputs are unusable (no reset, no length, or reset already passed). +pub fn elapsed_fraction( + resets_at: Option, + window_len: Option, + now: SystemTime, +) -> Option { + let resets_at = resets_at?; + let window_len = window_len?; + let remaining = resets_at.duration_since(now).ok()?; // Err => already past reset + let remaining_s = remaining.as_secs_f64(); + let window_s = window_len.as_secs_f64(); + if window_s <= 0.0 { + return None; + } + let frac = 1.0 - (remaining_s / window_s); + Some(frac.clamp(0.0, 1.0)) +} + +/// Seconds until projected exhaustion at the current burn rate. +/// `None` when not projected to run out this window (rate <= 100%) or no usage yet. +pub fn eta_to_empty_secs( + used_percent: f64, + elapsed_fraction: f64, + window_len: Duration, +) -> Option { + if used_percent <= 0.0 || elapsed_fraction <= 0.0 { + return None; + } + // Projected end-of-window usage if the current rate holds. + let projected = used_percent / elapsed_fraction; + if projected <= 100.0 { + return None; + } + // Total time from window start to reach 100% at this rate. + let window_s = window_len.as_secs_f64(); + let elapsed_s = elapsed_fraction * window_s; + let time_to_100 = elapsed_s * (100.0 / used_percent); + let eta = time_to_100 - elapsed_s; + if eta <= 0.0 { + Some(0) + } else { + Some(eta as u64) + } +} + +/// At-a-glance pace verdict, used to color the tick and (single-provider) the % text. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PaceStatus { + /// Comfortably under pace. + Ahead, + /// Near the pace line (within the near-band on either side). + OnPace, + /// Over pace — projected to run out early. + Behind, +} + +/// Compare used% against the elapsed-fraction tick. +/// `near_band` is the +/- percentage-point tolerance treated as "on pace" (e.g. 5.0). +pub fn pace_status(used_percent: f64, elapsed_fraction: f64, near_band: f64) -> PaceStatus { + let expected = 100.0 * elapsed_fraction; + let delta = used_percent - expected; + if delta > near_band { + PaceStatus::Behind + } else if delta < -near_band { + PaceStatus::Ahead + } else { + PaceStatus::OnPace + } +} + +/// User-selectable alert sensitivity. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AlertPreset { + Off, + At90, + At80And90, + Pace, +} + +impl AlertPreset { + pub fn as_str(self) -> &'static str { + match self { + AlertPreset::Off => "off", + AlertPreset::At90 => "at90", + AlertPreset::At80And90 => "at80and90", + AlertPreset::Pace => "pace", + } + } + pub fn from_str(s: &str) -> AlertPreset { + match s { + "at90" => AlertPreset::At90, + "at80and90" => AlertPreset::At80And90, + "pace" => AlertPreset::Pace, + _ => AlertPreset::Off, + } + } +} + +/// Result of evaluating one cell against the active preset. +/// `active` = the cell currently meets the alert condition (drives danger badge). +/// `fire` = we should raise a balloon NOW (edge: became active this evaluation). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct AlertDecision { + pub active: bool, + pub fire: bool, +} + +/// Pure, edge-triggered evaluation. `was_active` is the previous stored state for the cell. +/// `over_pace` should be `eta_to_empty_secs(...).is_some()` for the cell. +pub fn evaluate_alert( + preset: AlertPreset, + used_percent: f64, + over_pace: bool, + was_active: bool, +) -> AlertDecision { + let active = match preset { + AlertPreset::Off => false, + AlertPreset::At90 => used_percent >= 90.0, + AlertPreset::At80And90 => used_percent >= 80.0, + AlertPreset::Pace => over_pace, + }; + AlertDecision { + active, + fire: active && !was_active, // rising edge only + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t(secs_from_now: i64, now: SystemTime) -> Option { + if secs_from_now >= 0 { + Some(now + Duration::from_secs(secs_from_now as u64)) + } else { + Some(now - Duration::from_secs((-secs_from_now) as u64)) + } + } + + #[test] + fn window_length_known_for_claude_only() { + assert_eq!( + window_length(Provider::ClaudeCode, Section::Session), + Some(Duration::from_secs(5 * 3600)) + ); + assert_eq!( + window_length(Provider::ClaudeCode, Section::Weekly), + Some(Duration::from_secs(7 * 24 * 3600)) + ); + assert_eq!(window_length(Provider::Codex, Section::Session), None); + assert_eq!(window_length(Provider::Antigravity, Section::Weekly), None); + } + + #[test] + fn elapsed_fraction_halfway() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + // 5h window, 2.5h remaining => 50% elapsed. + let f = elapsed_fraction( + t(2 * 3600 + 1800, now), + Some(Duration::from_secs(5 * 3600)), + now, + ); + assert!((f.unwrap() - 0.5).abs() < 1e-9); + } + + #[test] + fn elapsed_fraction_none_when_past_or_missing() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + assert_eq!( + elapsed_fraction(t(-10, now), Some(Duration::from_secs(3600)), now), + None + ); + assert_eq!(elapsed_fraction(None, Some(Duration::from_secs(3600)), now), None); + assert_eq!(elapsed_fraction(t(10, now), None, now), None); + } + + #[test] + fn eta_none_when_on_or_under_pace() { + // 50% used at 50% elapsed => projected 100% exactly => not "runs out early". + assert_eq!( + eta_to_empty_secs(50.0, 0.5, Duration::from_secs(5 * 3600)), + None + ); + // Under pace. + assert_eq!( + eta_to_empty_secs(20.0, 0.5, Duration::from_secs(5 * 3600)), + None + ); + // No usage yet. + assert_eq!(eta_to_empty_secs(0.0, 0.5, Duration::from_secs(5 * 3600)), None); + } + + #[test] + fn eta_positive_when_burning_fast() { + // 80% used at 50% elapsed of a 5h window. + // elapsed_s = 9000s; time_to_100 = 9000 * (100/80) = 11250s; eta = 2250s. + let eta = eta_to_empty_secs(80.0, 0.5, Duration::from_secs(5 * 3600)).unwrap(); + assert_eq!(eta, 2250); + } + + #[test] + fn pace_status_bands() { + // 50% elapsed => expected 50%. + assert_eq!(pace_status(30.0, 0.5, 5.0), PaceStatus::Ahead); + assert_eq!(pace_status(52.0, 0.5, 5.0), PaceStatus::OnPace); + assert_eq!(pace_status(70.0, 0.5, 5.0), PaceStatus::Behind); + } + + #[test] + fn preset_str_roundtrip() { + for p in [ + AlertPreset::Off, + AlertPreset::At90, + AlertPreset::At80And90, + AlertPreset::Pace, + ] { + assert_eq!(AlertPreset::from_str(p.as_str()), p); + } + assert_eq!(AlertPreset::from_str("garbage"), AlertPreset::Off); + } + + #[test] + fn alert_edge_triggers_once() { + // Rising edge fires. + let d = evaluate_alert(AlertPreset::At90, 91.0, false, false); + assert_eq!(d, AlertDecision { active: true, fire: true }); + // Still active, already fired => no re-fire. + let d = evaluate_alert(AlertPreset::At90, 95.0, false, true); + assert_eq!(d, AlertDecision { active: true, fire: false }); + // Dropped below => inactive, re-arms. + let d = evaluate_alert(AlertPreset::At90, 50.0, false, true); + assert_eq!(d, AlertDecision { active: false, fire: false }); + } + + #[test] + fn alert_off_never_fires() { + assert_eq!( + evaluate_alert(AlertPreset::Off, 99.0, true, false), + AlertDecision { active: false, fire: false } + ); + } + + #[test] + fn alert_pace_uses_over_pace_flag() { + assert_eq!( + evaluate_alert(AlertPreset::Pace, 60.0, true, false), + AlertDecision { active: true, fire: true } + ); + assert_eq!( + evaluate_alert(AlertPreset::Pace, 60.0, false, false), + AlertDecision { active: false, fire: false } + ); + } +} From f717f7f882f7a597b32bfe21db4125e5cb3f8c5b Mon Sep 17 00:00:00 2001 From: Ash Date: Mon, 20 Jul 2026 15:02:49 +0100 Subject: [PATCH 2/4] feat: add two-unit countdown, hh:mm formatter, and local-time conversion --- Cargo.toml | 2 ++ src/native_interop.rs | 27 ++++++++++++++++++++- src/poller.rs | 55 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 7882510..ad8bf62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ features = [ "Win32_UI_Accessibility", "Win32_System_Registry", "Win32_System_Threading", + "Win32_System_Time", + "Win32_Storage_FileSystem", "Win32_Security", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_HiDpi", diff --git a/src/native_interop.rs b/src/native_interop.rs index c745d08..2124e2a 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,5 +1,7 @@ use windows::core::PCWSTR; -use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; +use windows::Win32::Foundation::{BOOL, FILETIME, HWND, LPARAM, RECT, SYSTEMTIME}; +use windows::Win32::Storage::FileSystem::FileTimeToLocalFileTime; +use windows::Win32::System::Time::FileTimeToSystemTime; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; @@ -181,6 +183,29 @@ pub fn wide_str(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } +/// Returns local (hour_24, minute, day_of_week) for a UTC SystemTime. +/// day_of_week: 0 = Sunday .. 6 = Saturday. None on conversion failure. +pub fn system_time_to_local_hms(t: std::time::SystemTime) -> Option<(u32, u32, u16)> { + let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); + // Win32 FILETIME is 100ns ticks since 1601-01-01; offset to unix epoch = 11644473600 s. + let ticks: u64 = (secs + 11_644_473_600) * 10_000_000; + let ft = FILETIME { + dwLowDateTime: (ticks & 0xFFFF_FFFF) as u32, + dwHighDateTime: (ticks >> 32) as u32, + }; + unsafe { + let mut local_ft = FILETIME::default(); + if FileTimeToLocalFileTime(&ft, &mut local_ft).is_err() { + return None; + } + let mut st = SYSTEMTIME::default(); + if FileTimeToSystemTime(&local_ft, &mut st).is_err() { + return None; + } + Some((st.wHour as u32, st.wMinute as u32, st.wDayOfWeek)) + } +} + /// COLORREF wrapper (RGB packed into u32) pub fn colorref(r: u8, g: u8, b: u8) -> u32 { r as u32 | (g as u32) << 8 | (b as u32) << 16 diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..9428f79 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1569,6 +1569,42 @@ fn format_countdown_from_secs(total_secs: u64, strings: Strings) -> String { } } +/// Countdown with up to TWO components, e.g. "2h 15m", "3d 4h", "45m 10s". +/// Falls back to a single component when the smaller one is zero. +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) + } +} + +/// Format a broken-down local time as "h:mm AM/PM" (12-hour, no leading zero on hour). +pub fn format_hh_mm_ampm(hour_24: u32, minute: u32) -> String { + let (h12, suffix) = match hour_24 { + 0 => (12, "AM"), + 1..=11 => (hour_24, "AM"), + 12 => (12, "PM"), + _ => (hour_24 - 12, "PM"), + }; + format!("{}:{:02} {}", h12, minute, suffix) +} + fn time_until_display_change_from_secs(total_secs: u64) -> Duration { let total_mins = total_secs / 60; let total_hours = total_secs / 3600; @@ -1732,4 +1768,23 @@ mod tests { assert!(usage.weekly.resets_at.is_some()); assert!(usage.session.resets_at.is_some()); } + + #[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(3 * 86_400 + 4 * 3600, s), "3d 4h"); + assert_eq!(format_countdown_two_units(45 * 60 + 10, s), "45m 10s"); + assert_eq!(format_countdown_two_units(30, s), "30s"); + assert_eq!(format_countdown_two_units(2 * 3600, s), "2h"); + } + + #[test] + fn hh_mm_ampm_formats() { + assert_eq!(format_hh_mm_ampm(0, 5), "12:05 AM"); + assert_eq!(format_hh_mm_ampm(9, 0), "9:00 AM"); + assert_eq!(format_hh_mm_ampm(12, 45), "12:45 PM"); + assert_eq!(format_hh_mm_ampm(16, 45), "4:45 PM"); + assert_eq!(format_hh_mm_ampm(23, 59), "11:59 PM"); + } } From 238d907e42fef03110eb20417cb7597d5d02c844 Mon Sep 17 00:00:00 2001 From: Ash Date: Mon, 20 Jul 2026 15:13:25 +0100 Subject: [PATCH 3/4] feat(ui): pace tick, pace text color, and rich per-provider tooltip Co-Authored-By: Claude Opus 4.8 --- 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/window.rs | 177 ++++++++++++++++++++++-- 13 files changed, 189 insertions(+), 12 deletions(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index ed815bf..536187e 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -46,5 +46,7 @@ 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", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..12894f7 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -46,5 +46,7 @@ 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", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index 1850f41..854b17d 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -46,5 +46,7 @@ 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", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b91a81..2b10059 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -46,5 +46,7 @@ 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", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 2eec041..51e03ad 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Antigravity を開いて再度サインインしてください。その後、このアプリを更新するか再起動してください。", codex_window_title: "Codex 使用量モニター", antigravity_window_title: "Antigravity 使用量モニター", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 965687d..d283c17 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Antigravity를 열고 다시 로그인하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", antigravity_window_title: "Antigravity 사용량 모니터", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index a11ba02..bb27472 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -189,6 +189,8 @@ pub struct Strings { pub antigravity_token_expired_body: &'static str, pub codex_window_title: &'static str, pub antigravity_window_title: &'static str, + pub resets_at_label: &'static str, + pub left_at_rate_label: &'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..82da3a5 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -47,4 +47,6 @@ 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", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index fc7e372..5d247ec 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -47,4 +47,6 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "Откройте Antigravity и войдите снова. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", antigravity_window_title: "Монитор использования Antigravity", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", }; diff --git a/src/localization/simplified_chinese.rs b/src/localization/simplified_chinese.rs index 8a62588..5c33567 100644 --- a/src/localization/simplified_chinese.rs +++ b/src/localization/simplified_chinese.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "请打开 Antigravity 并重新登录。完成后,请刷新或重新启动此应用程序。", codex_window_title: "Codex 使用量监控", antigravity_window_title: "Antigravity 使用量监控", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", second_suffix: "秒", }; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index e635771..16b8021 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -46,5 +46,7 @@ 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", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 3eb3514..d628169 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -46,5 +46,7 @@ pub(super) const STRINGS: Strings = Strings { antigravity_token_expired_body: "請開啟 Antigravity 並重新登入。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", antigravity_window_title: "Antigravity 使用量監控", + resets_at_label: "resets", + left_at_rate_label: "left at this rate", second_suffix: "秒", }; diff --git a/src/window.rs b/src/window.rs index bf30f88..84b283a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -396,6 +396,51 @@ fn save_state_settings() { } } +fn build_provider_tooltip( + name: &str, + strings: Strings, + provider: crate::pace::Provider, + usage: Option<&crate::models::UsageData>, +) -> String { + use crate::pace::{elapsed_fraction, eta_to_empty_secs, window_length, Section}; + let now = std::time::SystemTime::now(); + let mut out = String::from(name); + + for (section, label, sec) in [ + (Section::Session, strings.session_window, usage.map(|u| &u.session)), + (Section::Weekly, strings.weekly_window, usage.map(|u| &u.weekly)), + ] { + let Some(sec) = sec else { continue }; + let mut line = format!("\n{}: {:.0}%", label, sec.percentage); + if let Some(rt) = sec.resets_at { + if let Some((h, m, _dow)) = crate::native_interop::system_time_to_local_hms(rt) { + line.push_str(&format!( + " · {} {}", + strings.resets_at_label, + crate::poller::format_hh_mm_ampm(h, m) + )); + } + } + let wl = window_length(provider, section); + if let (Some(wl), Some(rt)) = (wl, sec.resets_at) { + if let Some(f) = elapsed_fraction(Some(rt), Some(wl), now) { + if let Some(eta) = eta_to_empty_secs(sec.percentage, f, wl) { + line.push_str(&format!( + " · ~{} {}", + crate::poller::format_countdown_two_units(eta, strings), + strings.left_at_rate_label + )); + } + } + } + out.push_str(&line); + } + if out.chars().count() > 120 { + out = out.chars().take(120).collect(); + } + out +} + fn tray_icon_data_from_state() -> Vec { let state = lock_state(); match state.as_ref() { @@ -405,11 +450,11 @@ fn tray_icon_data_from_state() -> Vec { icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Claude, percent: Some(s.session_percent), - tooltip: format!( - "{} 5h: {} | 7d: {}", + tooltip: build_provider_tooltip( s.language.strings().claude_code_model, - s.session_text, - s.weekly_text + s.language.strings(), + crate::pace::Provider::ClaudeCode, + s.data.as_ref().and_then(|d| d.claude_code.as_ref()), ), }); } @@ -417,11 +462,11 @@ fn tray_icon_data_from_state() -> Vec { icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Codex, percent: Some(s.codex_session_percent), - tooltip: format!( - "{} 5h: {} | 7d: {}", + tooltip: build_provider_tooltip( s.language.strings().codex_model, - s.codex_session_text, - s.codex_weekly_text + s.language.strings(), + crate::pace::Provider::Codex, + s.data.as_ref().and_then(|d| d.codex.as_ref()), ), }); } @@ -429,11 +474,11 @@ fn tray_icon_data_from_state() -> Vec { icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Antigravity, percent: Some(s.antigravity_session_percent), - tooltip: format!( - "{} 5h: {} | 7d: {}", + tooltip: build_provider_tooltip( s.language.strings().antigravity_model, - s.antigravity_session_text, - s.antigravity_weekly_text + s.language.strings(), + crate::pace::Provider::Antigravity, + s.data.as_ref().and_then(|d| d.antigravity.as_ref()), ), }); } @@ -1166,6 +1211,18 @@ fn antigravity_usage_text_color(is_dark: bool) -> Color { } } +fn pace_color(status: crate::pace::PaceStatus, is_dark: bool) -> Color { + use crate::pace::PaceStatus::*; + match (status, is_dark) { + (Ahead, true) => Color::from_hex("#4CC38A"), + (Ahead, false) => Color::from_hex("#217A4A"), + (OnPace, true) => Color::from_hex("#E0A030"), + (OnPace, false) => Color::from_hex("#9A6B00"), + (Behind, true) => Color::from_hex("#F0736A"), + (Behind, false) => Color::from_hex("#B82020"), + } +} + pub fn run() { // Enable Per-Monitor DPI Awareness V2 for crisp rendering at any scale factor unsafe { @@ -1436,6 +1493,8 @@ fn render_layered() { show_claude_code, show_codex, show_antigravity, + claude_session_resets, + claude_weekly_resets, ) = { let state = lock_state(); match state.as_ref() { @@ -1459,6 +1518,14 @@ fn render_layered() { s.show_claude_code, s.show_codex, s.show_antigravity, + s.data + .as_ref() + .and_then(|d| d.claude_code.as_ref()) + .and_then(|u| u.session.resets_at), + s.data + .as_ref() + .and_then(|d| d.claude_code.as_ref()) + .and_then(|u| u.weekly.resets_at), ), None => return, } @@ -1556,6 +1623,8 @@ fn render_layered() { show_antigravity, &codex_accent, &antigravity_accent, + claude_session_resets, + claude_weekly_resets, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1632,6 +1701,8 @@ fn paint_content( show_antigravity: bool, codex_accent: &Color, antigravity_accent: &Color, + claude_session_resets: Option, + claude_weekly_resets: Option, ) { unsafe { let client_rect = RECT { @@ -1708,6 +1779,24 @@ fn paint_content( ); let old_font = SelectObject(hdc, font); + let now = std::time::SystemTime::now(); + let claude_session_pace = claude_session_resets.and_then(|rt| { + let wl = crate::pace::window_length( + crate::pace::Provider::ClaudeCode, + crate::pace::Section::Session, + ); + let f = crate::pace::elapsed_fraction(Some(rt), wl, now)?; + Some((f, crate::pace::pace_status(session_pct, f, 5.0))) + }); + let claude_weekly_pace = claude_weekly_resets.and_then(|rt| { + let wl = crate::pace::window_length( + crate::pace::Provider::ClaudeCode, + crate::pace::Section::Weekly, + ); + let f = crate::pace::elapsed_fraction(Some(rt), wl, now)?; + Some((f, crate::pace::pace_status(weekly_pct, f, 5.0))) + }); + draw_row( hdc, content_x, @@ -1728,6 +1817,9 @@ fn paint_content( codex_accent, antigravity_accent, track, + claude_session_pace, + None, + None, ); draw_row( hdc, @@ -1749,6 +1841,9 @@ fn paint_content( codex_accent, antigravity_accent, track, + claude_weekly_pace, + None, + None, ); SelectObject(hdc, old_font); @@ -2991,6 +3086,8 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + claude_session_resets, + claude_weekly_resets, ) = { let state = lock_state(); match state.as_ref() { @@ -3012,6 +3109,14 @@ fn paint(hdc: HDC, hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.data + .as_ref() + .and_then(|d| d.claude_code.as_ref()) + .and_then(|u| u.session.resets_at), + s.data + .as_ref() + .and_then(|d| d.claude_code.as_ref()) + .and_then(|u| u.weekly.resets_at), ), None => return, } @@ -3077,6 +3182,8 @@ fn paint(hdc: HDC, hwnd: HWND) { show_antigravity, &codex_accent, &antigravity_accent, + claude_session_resets, + claude_weekly_resets, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -3107,6 +3214,9 @@ fn draw_row( codex_accent: &Color, antigravity_accent: &Color, track: &Color, + claude_pace: Option<(f64, crate::pace::PaceStatus)>, + codex_pace: Option<(f64, crate::pace::PaceStatus)>, + antigravity_pace: Option<(f64, crate::pace::PaceStatus)>, ) { let seg_h = sc(SEGMENT_H); let active_models = active_model_count(show_claude_code, show_codex, show_antigravity); @@ -3114,20 +3224,39 @@ fn draw_row( let use_model_text_colors = active_models > 1; let claude_value_color = if use_model_text_colors { claude_usage_text_color(is_dark) + } else if let Some((_, st)) = claude_pace { + pace_color(st, is_dark) } else { *text_color }; let codex_value_color = if use_model_text_colors { codex_usage_text_color(is_dark) + } else if let Some((_, st)) = codex_pace { + pace_color(st, is_dark) } else { *text_color }; let antigravity_value_color = if use_model_text_colors { antigravity_usage_text_color(is_dark) + } else if let Some((_, st)) = antigravity_pace { + pace_color(st, is_dark) } else { *text_color }; + let claude_pace_fraction = claude_pace.map(|(f, _)| f); + let claude_tick_color = claude_pace + .map(|(_, st)| pace_color(st, is_dark)) + .unwrap_or(claude_value_color); + let codex_pace_fraction = codex_pace.map(|(f, _)| f); + let codex_tick_color = codex_pace + .map(|(_, st)| pace_color(st, is_dark)) + .unwrap_or(codex_value_color); + let antigravity_pace_fraction = antigravity_pace.map(|(f, _)| f); + let antigravity_tick_color = antigravity_pace + .map(|(_, st)| pace_color(st, is_dark)) + .unwrap_or(antigravity_value_color); + unsafe { let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); let mut label_wide: Vec = label.encode_utf16().collect(); @@ -3156,6 +3285,8 @@ fn draw_row( claude_accent, track, &claude_value_color, + claude_pace_fraction, + &claude_tick_color, ); model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); } @@ -3170,6 +3301,8 @@ fn draw_row( codex_accent, track, &codex_value_color, + codex_pace_fraction, + &codex_tick_color, ); model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); } @@ -3184,6 +3317,8 @@ fn draw_row( antigravity_accent, track, &antigravity_value_color, + antigravity_pace_fraction, + &antigravity_tick_color, ); } } @@ -3205,6 +3340,8 @@ fn draw_usage_bar( accent: &Color, track: &Color, text_color: &Color, + pace_fraction: Option, + pace_tick_color: &Color, ) { let seg_w = sc(SEGMENT_W); let seg_h = sc(SEGMENT_H); @@ -3260,6 +3397,22 @@ fn draw_usage_bar( } } + if let Some(p) = pace_fraction { + let p = p.clamp(0.0, 1.0); + let bar_span = segment_count * (seg_w + seg_gap) - seg_gap; + let tick_x = bar_x + (p * bar_span as f64) as i32; + let tick_w = sc(2).max(1); + let tick_rect = RECT { + left: tick_x, + top: y - sc(1), + right: tick_x + tick_w, + bottom: y + seg_h + sc(1), + }; + let brush = CreateSolidBrush(COLORREF(pace_tick_color.to_colorref())); + FillRect(hdc, &tick_rect, brush); + let _ = DeleteObject(brush); + } + let text_x = bar_x + segment_count * (seg_w + seg_gap) - seg_gap + sc(BAR_RIGHT_MARGIN); let mut text_wide: Vec = text.encode_utf16().collect(); let mut text_rect = RECT { From 29e80f7a3a73cdadbd3d103ec86a77b2fdecc346 Mon Sep 17 00:00:00 2001 From: Ash Date: Mon, 20 Jul 2026 15:24:23 +0100 Subject: [PATCH 4/4] feat(alerts): burn-rate alerts with preset menu, balloon, and danger badge Co-Authored-By: Claude Opus 4.8 --- src/localization/dutch.rs | 7 + src/localization/english.rs | 7 + src/localization/french.rs | 7 + src/localization/german.rs | 7 + src/localization/japanese.rs | 7 + src/localization/korean.rs | 7 + src/localization/mod.rs | 7 + src/localization/portuguese_brazil.rs | 7 + src/localization/russian.rs | 7 + src/localization/simplified_chinese.rs | 7 + src/localization/spanish.rs | 7 + src/localization/traditional_chinese.rs | 7 + src/native_interop.rs | 1 + src/tray_icon.rs | 40 +++-- src/window.rs | 185 ++++++++++++++++++++++++ 15 files changed, 295 insertions(+), 15 deletions(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 536187e..2a68cb0 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -48,5 +48,12 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Antigravity-gebruiksmonitor", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 12894f7..c354820 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -48,5 +48,12 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Antigravity Usage Monitor", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index 854b17d..72096c4 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -48,5 +48,12 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Moniteur d'utilisation Antigravity", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 2b10059..abf6264 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -48,5 +48,12 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Antigravity-Nutzungsmonitor", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 51e03ad..992b49d 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -48,5 +48,12 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Antigravity 使用量モニター", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index d283c17..0572846 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -48,5 +48,12 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Antigravity 사용량 모니터", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index bb27472..9abd2e9 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -191,6 +191,13 @@ pub struct Strings { pub antigravity_window_title: &'static str, pub resets_at_label: &'static str, pub left_at_rate_label: &'static str, + pub alerts_menu: &'static str, + pub alert_off: &'static str, + pub alert_at_90: &'static str, + pub alert_at_80_90: &'static str, + pub alert_pace: &'static str, + pub alert_balloon_title: &'static str, + pub alert_balloon_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 82da3a5..54d9fe5 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -49,4 +49,11 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Monitor de uso do Antigravity", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index 5d247ec..b706a51 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -49,4 +49,11 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Монитор использования Antigravity", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", }; diff --git a/src/localization/simplified_chinese.rs b/src/localization/simplified_chinese.rs index 5c33567..9ad9347 100644 --- a/src/localization/simplified_chinese.rs +++ b/src/localization/simplified_chinese.rs @@ -48,5 +48,12 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Antigravity 使用量监控", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", second_suffix: "秒", }; diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 16b8021..59e2767 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -48,5 +48,12 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Monitor de uso de Antigravity", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index d628169..708cb3e 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -48,5 +48,12 @@ pub(super) const STRINGS: Strings = Strings { antigravity_window_title: "Antigravity 使用量監控", resets_at_label: "resets", left_at_rate_label: "left at this rate", + alerts_menu: "Usage alerts", + alert_off: "Off", + alert_at_90: "At 90%", + alert_at_80_90: "At 80% & 90%", + alert_pace: "Pacing to run out", + alert_balloon_title: "Usage running low", + alert_balloon_body: "You're pacing to run out before your quota resets.", second_suffix: "秒", }; diff --git a/src/native_interop.rs b/src/native_interop.rs index 2124e2a..6ae6829 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -20,6 +20,7 @@ pub const TIMER_POLL: usize = 1; pub const TIMER_COUNTDOWN: usize = 2; pub const TIMER_RESET_POLL: usize = 3; pub const TIMER_UPDATE_CHECK: usize = 4; +pub const TIMER_ALERT_FLASH: usize = 5; // Custom messages pub const WM_APP: u32 = 0x8000; diff --git a/src/tray_icon.rs b/src/tray_icon.rs index e2502e2..90c6f2e 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -35,6 +35,7 @@ pub struct TrayIconData { pub kind: TrayIconKind, pub percent: Option, pub tooltip: String, + pub danger: bool, } impl TrayIconKind { @@ -101,10 +102,15 @@ fn antigravity_fill(percent: f64) -> Color { } } +/// Strong red used to fill the badge when a burn-rate alert is active. +fn danger_fill() -> Color { + Color::from_hex("#B82020") +} + /// 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. -pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { +pub fn create_icon(kind: TrayIconKind, percent: Option, danger: bool) -> HICON { if matches!(kind, TrayIconKind::Claude) && percent.is_none() { let app_icon = load_embedded_app_icon(); if !app_icon.is_invalid() { @@ -121,10 +127,14 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { 0_i32 }; - 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 fill = if danger { + danger_fill() + } else { + 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"), @@ -360,8 +370,8 @@ fn copy_wide_256(s: &str, buf: &mut [u16; 256]) { } /// Register the tray icon with the shell. -pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option, tooltip: &str) { - let hicon = create_icon(kind, percent); +pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option, danger: bool, tooltip: &str) { + let hicon = create_icon(kind, percent, danger); unsafe { let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); nid.cbSize = std::mem::size_of::() as u32; @@ -379,8 +389,8 @@ pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option, tooltip: &str) } /// Update the tray icon colour and tooltip to reflect current usage. -pub fn update(hwnd: HWND, kind: TrayIconKind, percent: Option, tooltip: &str) { - let hicon = create_icon(kind, percent); +pub fn update(hwnd: HWND, kind: TrayIconKind, percent: Option, danger: bool, tooltip: &str) { + let hicon = create_icon(kind, percent, danger); unsafe { let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); nid.cbSize = std::mem::size_of::() as u32; @@ -419,22 +429,22 @@ pub fn sync(hwnd: HWND, icons: &[TrayIconData]) { .find(|icon| matches!(icon.kind, TrayIconKind::Antigravity)); if let Some(icon) = show_claude { - add(hwnd, icon.kind, icon.percent, &icon.tooltip); - update(hwnd, icon.kind, icon.percent, &icon.tooltip); + add(hwnd, icon.kind, icon.percent, icon.danger, &icon.tooltip); + update(hwnd, icon.kind, icon.percent, icon.danger, &icon.tooltip); } else { remove(hwnd, TrayIconKind::Claude); } if let Some(icon) = show_codex { - add(hwnd, icon.kind, icon.percent, &icon.tooltip); - update(hwnd, icon.kind, icon.percent, &icon.tooltip); + add(hwnd, icon.kind, icon.percent, icon.danger, &icon.tooltip); + update(hwnd, icon.kind, icon.percent, icon.danger, &icon.tooltip); } 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); + add(hwnd, icon.kind, icon.percent, icon.danger, &icon.tooltip); + update(hwnd, icon.kind, icon.percent, icon.danger, &icon.tooltip); } else { remove(hwnd, TrayIconKind::Antigravity); } diff --git a/src/window.rs b/src/window.rs index 84b283a..d5de48a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -71,6 +71,12 @@ struct AppState { show_codex: bool, show_antigravity: bool, + alert_preset: crate::pace::AlertPreset, + alert_active_session: bool, + alert_active_weekly: bool, + flash_on: bool, + flash_ticks: u8, + data: Option, poll_interval_ms: u32, @@ -132,6 +138,10 @@ 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_ALERT_OFF: u16 = 80; +const IDM_ALERT_90: u16 = 81; +const IDM_ALERT_80_90: u16 = 82; +const IDM_ALERT_PACE: u16 = 83; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -317,6 +327,8 @@ struct SettingsFile { show_codex: bool, #[serde(default = "default_show_antigravity")] show_antigravity: bool, + #[serde(default = "default_alert_preset")] + pub alert_preset: String, } impl Default for SettingsFile { @@ -331,6 +343,7 @@ impl Default for SettingsFile { show_claude_code: true, show_codex: false, show_antigravity: false, + alert_preset: default_alert_preset(), } } } @@ -355,6 +368,10 @@ fn default_show_antigravity() -> bool { false } +fn default_alert_preset() -> String { + "off".to_string() +} + fn load_settings() -> SettingsFile { let content = match std::fs::read_to_string(settings_path()) { Ok(c) => c, @@ -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, + alert_preset: s.alert_preset.as_str().to_string(), }); } } @@ -456,6 +474,14 @@ fn tray_icon_data_from_state() -> Vec { crate::pace::Provider::ClaudeCode, s.data.as_ref().and_then(|d| d.claude_code.as_ref()), ), + danger: { + let steady = s.alert_active_session || s.alert_active_weekly; + if s.flash_ticks > 0 { + s.flash_on + } else { + steady + } + }, }); } if s.show_codex { @@ -468,6 +494,7 @@ fn tray_icon_data_from_state() -> Vec { crate::pace::Provider::Codex, s.data.as_ref().and_then(|d| d.codex.as_ref()), ), + danger: false, }); } if s.show_antigravity { @@ -480,6 +507,7 @@ fn tray_icon_data_from_state() -> Vec { crate::pace::Provider::Antigravity, s.data.as_ref().and_then(|d| d.antigravity.as_ref()), ), + danger: false, }); } icons @@ -491,6 +519,7 @@ fn tray_icon_data_from_state() -> Vec { kind: tray_icon::TrayIconKind::Claude, percent: None, tooltip: s.language.strings().window_title.to_string(), + danger: false, }); } if s.show_codex { @@ -498,6 +527,7 @@ fn tray_icon_data_from_state() -> Vec { kind: tray_icon::TrayIconKind::Codex, percent: None, tooltip: s.language.strings().codex_window_title.to_string(), + danger: false, }); } if s.show_antigravity { @@ -505,6 +535,7 @@ fn tray_icon_data_from_state() -> Vec { kind: tray_icon::TrayIconKind::Antigravity, percent: None, tooltip: s.language.strings().antigravity_window_title.to_string(), + danger: false, }); } icons @@ -1368,6 +1399,11 @@ pub fn run() { show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, show_antigravity: settings.show_antigravity, + alert_preset: crate::pace::AlertPreset::from_str(&settings.alert_preset), + alert_active_session: false, + alert_active_weekly: false, + flash_on: false, + flash_ticks: 0, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1863,6 +1899,9 @@ fn do_poll(send_hwnd: SendHwnd) { match poller::poll(show_claude_code, show_codex, show_antigravity) { Ok(data) => { + // Balloon to raise (title, body) once the guard is released, if a + // burn-rate alert fired this poll. + let mut alert_fire: Option<(&'static str, &'static str)> = None; let mut state = lock_state(); if let Some(s) = state.as_mut() { if let Some(claude_code) = data.claude_code.as_ref() { @@ -1897,6 +1936,61 @@ fn do_poll(send_hwnd: SendHwnd) { s.last_poll_ok = true; refresh_usage_texts(s); + // Evaluate burn-rate alerts for Claude session + weekly cells. + let now = std::time::SystemTime::now(); + let claude = s + .data + .as_ref() + .and_then(|d| d.claude_code.as_ref()) + .map(|u| { + ( + u.session.percentage, + u.session.resets_at, + u.weekly.percentage, + u.weekly.resets_at, + ) + }); + if let Some((sp, sr, wp, wr)) = claude { + let preset = s.alert_preset; + let mut any_fire = false; + for (is_session, pct, rt, section) in [ + (true, sp, sr, crate::pace::Section::Session), + (false, wp, wr, crate::pace::Section::Weekly), + ] { + let over_pace = crate::pace::window_length( + crate::pace::Provider::ClaudeCode, + section, + ) + .and_then(|wl| { + crate::pace::elapsed_fraction(rt, Some(wl), now) + .and_then(|f| crate::pace::eta_to_empty_secs(pct, f, wl)) + }) + .is_some(); + let was = if is_session { + s.alert_active_session + } else { + s.alert_active_weekly + }; + let decision = + crate::pace::evaluate_alert(preset, pct, over_pace, was); + if is_session { + s.alert_active_session = decision.active; + } else { + s.alert_active_weekly = decision.active; + } + if decision.fire { + any_fire = true; + } + } + if any_fire { + let strings = s.language.strings(); + s.flash_on = true; + s.flash_ticks = 0; + alert_fire = + Some((strings.alert_balloon_title, strings.alert_balloon_body)); + } + } + // Recovered from errors — restore normal poll interval if s.retry_count > 0 { s.retry_count = 0; @@ -1911,6 +2005,16 @@ fn do_poll(send_hwnd: SendHwnd) { s.auth_watch_snapshot.clear(); } + // Release the state guard before notify_balloon / SetTimer so the + // subsequent tray sync (which re-locks state) cannot deadlock. + drop(state); + if let Some((title, body)) = alert_fire { + tray_icon::notify_balloon(hwnd, tray_icon::TrayIconKind::Claude, title, body); + unsafe { + SetTimer(hwnd, native_interop::TIMER_ALERT_FLASH, 400, None); + } + } + unsafe { let _ = PostMessageW(hwnd, WM_APP_USAGE_UPDATED, WPARAM(0), LPARAM(0)); } @@ -2400,6 +2504,32 @@ unsafe extern "system" fn wnd_proc( TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } + native_interop::TIMER_ALERT_FLASH => { + // Toggle the danger badge for a few cycles, then settle. + let done = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.flash_ticks = s.flash_ticks.saturating_add(1); + s.flash_on = !s.flash_on; + s.flash_ticks >= 6 + } else { + true + } + }; + sync_tray_icons(hwnd); + if done { + let _ = KillTimer(hwnd, native_interop::TIMER_ALERT_FLASH); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.flash_ticks = 0; + s.flash_on = false; + } + } + // Settle to the steady danger/normal state. + sync_tray_icons(hwnd); + } + } _ => {} } LRESULT(0) @@ -2690,6 +2820,23 @@ unsafe extern "system" fn wnd_proc( // Reset the poll timer with the new interval SetTimer(hwnd, TIMER_POLL, new_interval, None); } + IDM_ALERT_OFF | IDM_ALERT_90 | IDM_ALERT_80_90 | IDM_ALERT_PACE => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.alert_preset = match id { + IDM_ALERT_90 => crate::pace::AlertPreset::At90, + IDM_ALERT_80_90 => crate::pace::AlertPreset::At80And90, + IDM_ALERT_PACE => crate::pace::AlertPreset::Pace, + _ => crate::pace::AlertPreset::Off, + }; + // Re-arm edges on preset change. + s.alert_active_session = false; + s.alert_active_weekly = false; + } + } + save_state_settings(); + } IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX | IDM_MODEL_ANTIGRAVITY => { { let mut state = lock_state(); @@ -2813,6 +2960,7 @@ fn show_context_menu(hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + alert_preset, ) = { let state = lock_state(); match state.as_ref() { @@ -2827,6 +2975,7 @@ fn show_context_menu(hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.alert_preset, ), None => ( POLL_15_MIN, @@ -2839,6 +2988,7 @@ fn show_context_menu(hwnd: HWND) { true, false, false, + crate::pace::AlertPreset::Off, ), } }; @@ -2884,6 +3034,41 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(freq_label.as_ptr()), ); + // Usage alerts submenu + let alerts_menu = CreatePopupMenu().unwrap(); + let alert_items: [(u16, crate::pace::AlertPreset, &str); 4] = [ + (IDM_ALERT_OFF, crate::pace::AlertPreset::Off, strings.alert_off), + (IDM_ALERT_90, crate::pace::AlertPreset::At90, strings.alert_at_90), + ( + IDM_ALERT_80_90, + crate::pace::AlertPreset::At80And90, + strings.alert_at_80_90, + ), + (IDM_ALERT_PACE, crate::pace::AlertPreset::Pace, strings.alert_pace), + ]; + for (id, preset, label) in alert_items { + let label_str = native_interop::wide_str(label); + let flags = if preset == alert_preset { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + alerts_menu, + flags, + id as usize, + PCWSTR::from_raw(label_str.as_ptr()), + ); + } + + let alerts_label = native_interop::wide_str(strings.alerts_menu); + let _ = AppendMenuW( + menu, + MF_POPUP, + alerts_menu.0 as usize, + PCWSTR::from_raw(alerts_label.as_ptr()), + ); + // Models submenu let models_menu = CreatePopupMenu().unwrap(); let claude_model = native_interop::wide_str(strings.claude_code_model);