diff --git a/Cargo.lock b/Cargo.lock index d30ee07..0df6ef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "claude-code-usage-bubble" -version = "0.1.4" +version = "0.1.5" dependencies = [ "dirs", "embed-resource", diff --git a/Cargo.toml b/Cargo.toml index da84a58..48776e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-code-usage-bubble" -version = "0.1.4" +version = "0.1.5" edition = "2021" license = "Apache-2.0" description = "Floating bubble showing Claude Code and Codex usage on Windows" diff --git a/src/app.rs b/src/app.rs index c3e5f43..286b1eb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -468,39 +468,57 @@ fn apply_results( results: Vec<(ProviderId, Result)>, ) -> Vec { let mut auth_failures = Vec::new(); - let mut s = lock_state(); - let Some(s) = s.as_mut() else { - return auth_failures; - }; - if results.is_empty() { - return auth_failures; - } - let strings = s.i18n.strings().clone(); - let mut any_ok = false; - for (id, outcome) in results { - match outcome { - Ok(windows) => { - let entry = s.snapshots.entry(id).or_default(); - entry.windows = windows; - entry.primary_text = i18n::format_window(&windows.primary, &strings); - entry.secondary_text = i18n::format_window(&windows.secondary, &strings); - any_ok = true; - } - Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => { - auth_failures.push(id); - let entry = s.snapshots.entry(id).or_default(); - entry.primary_text = "!".into(); - entry.secondary_text = "!".into(); - } - Err(e) => { - log::warn!("provider {id:?} poll failed: {e}"); - let entry = s.snapshots.entry(id).or_default(); - entry.primary_text = "…".into(); - entry.secondary_text = "…".into(); + let mut crossings: Vec<(ProviderId, u8)> = Vec::new(); + { + let mut guard = lock_state(); + let Some(state) = guard.as_mut() else { + return auth_failures; + }; + if results.is_empty() { + return auth_failures; + } + let strings = state.i18n.strings().clone(); + let mut any_ok = false; + for (id, outcome) in results { + match outcome { + Ok(windows) => { + let entry = state.snapshots.entry(id).or_default(); + let old_pct = entry.windows.primary.utilization; + let new_pct = windows.primary.utilization; + // Fire a balloon only the cycle a provider CROSSES a + // threshold so the user is nudged once, not on every + // subsequent poll while parked above it. + for threshold in [80u8, 95u8] { + if old_pct < threshold as f64 && new_pct >= threshold as f64 { + crossings.push((id, threshold)); + } + } + entry.windows = windows; + entry.primary_text = i18n::format_window(&windows.primary, &strings); + entry.secondary_text = i18n::format_window(&windows.secondary, &strings); + any_ok = true; + } + Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => { + auth_failures.push(id); + let entry = state.snapshots.entry(id).or_default(); + entry.primary_text = "!".into(); + entry.secondary_text = "!".into(); + } + Err(e) => { + log::warn!("provider {id:?} poll failed: {e}"); + let entry = state.snapshots.entry(id).or_default(); + entry.primary_text = "…".into(); + entry.secondary_text = "…".into(); + } } } + state.last_poll_ok = any_ok; + } + // Lock released. Fire any threshold balloons outside the critical + // section so tray::notify and i18n cloning don't block the UI thread. + for (id, threshold) in crossings { + show_threshold_balloon(id, threshold); } - s.last_poll_ok = any_ok; auth_failures } @@ -752,6 +770,60 @@ fn handle_tray_action(action: TrayAction) { } } +/// Re-read the system theme and, if it changed, push the new value into +/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows +/// posts that to every top-level window when the user toggles light/dark +/// in Settings, so this naturally fires once per change. +pub fn recheck_theme() { + let now_dark = os::theme::is_dark(); + let changed = { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { + return; + }; + if s.is_dark == now_dark { + false + } else { + s.is_dark = now_dark; + true + } + }; + if changed { + propagate_to_ui(); + refresh_tray_icons(); + } +} + +fn show_threshold_balloon(provider: ProviderId, threshold: u8) { + let payload = { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { + return; + }; + // Reuse the same cooldown as the token-expired balloon: any one + // balloon at a time keeps notifications calm. + if let Some(last) = s.last_balloon_at { + if last.elapsed() < BALLOON_COOLDOWN { + return; + } + } + s.last_balloon_at = Some(Instant::now()); + let strings = s.i18n.strings(); + let provider_label = match provider { + ProviderId::Claude => strings.claude_label.clone(), + ProviderId::ChatGpt => strings.chatgpt_label.clone(), + }; + let title = format!("{provider_label} · {threshold}%"); + let body = if threshold >= 95 { + strings.threshold_95_body.clone() + } else { + strings.threshold_80_body.clone() + }; + (s.msg_hwnd, provider, title, body) + }; + tray::notify(payload.0.to_hwnd(), payload.1, &payload.2, &payload.3); +} + fn show_token_expired_balloon(failed: ProviderId) { let payload = { let mut s = lock_state(); diff --git a/src/bubble.rs b/src/bubble.rs index b124345..a4154de 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -478,10 +478,13 @@ unsafe extern "system" fn wnd_proc( LRESULT(0) } WM_SETTINGCHANGE => { - // Taskbar move / auto-hide toggle / DPI change all post this. - // Just re-clamp into the new work area so the bubble can't end up - // hidden behind the new taskbar position. + // Taskbar move / auto-hide toggle / DPI change / theme toggle + // all post this. Re-clamp into the new work area (bubble must + // not end up hidden behind the new taskbar position) and ask + // the app to re-read the light/dark setting — Windows fires + // this message when the user flips the OS theme in Settings. clamp_into_work_area(hwnd); + crate::app::recheck_theme(); LRESULT(0) } WM_DESTROY => { diff --git a/src/i18n/locales/de.toml b/src/i18n/locales/de.toml index c862d38..136c3e0 100644 --- a/src/i18n/locales/de.toml +++ b/src/i18n/locales/de.toml @@ -41,3 +41,5 @@ token_expired_title = "Claude Code-Sitzung abgelaufen" token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen." chatgpt_token_expired_title = "Codex-Sitzung abgelaufen" chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen." +threshold_80_body = "5-Stunden-Limit naht." +threshold_95_body = "Limit fast erreicht — gönn dir eine Pause." diff --git a/src/i18n/locales/en.toml b/src/i18n/locales/en.toml index f672d49..536b2d9 100644 --- a/src/i18n/locales/en.toml +++ b/src/i18n/locales/en.toml @@ -41,3 +41,5 @@ token_expired_title = "Claude Code session expired" token_expired_body = "Sign in again to keep tracking your usage." chatgpt_token_expired_title = "Codex session expired" chatgpt_token_expired_body = "Sign in again to keep tracking your usage." +threshold_80_body = "Approaching the 5-hour limit." +threshold_95_body = "Limit is close — consider easing up." diff --git a/src/i18n/locales/es.toml b/src/i18n/locales/es.toml index c9875b5..175dc02 100644 --- a/src/i18n/locales/es.toml +++ b/src/i18n/locales/es.toml @@ -41,3 +41,5 @@ token_expired_title = "Sesión de Claude Code caducada" token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso." chatgpt_token_expired_title = "Sesión de Codex caducada" chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso." +threshold_80_body = "Cerca del límite de 5 horas." +threshold_95_body = "Límite casi alcanzado — reduce el ritmo." diff --git a/src/i18n/locales/fr.toml b/src/i18n/locales/fr.toml index 4ff3e8d..41eb703 100644 --- a/src/i18n/locales/fr.toml +++ b/src/i18n/locales/fr.toml @@ -41,3 +41,5 @@ token_expired_title = "Session Claude Code expirée" token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation." chatgpt_token_expired_title = "Session Codex expirée" chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation." +threshold_80_body = "Approche de la limite de 5 heures." +threshold_95_body = "Limite proche — pensez à lever le pied." diff --git a/src/i18n/locales/ja.toml b/src/i18n/locales/ja.toml index 5c96b23..d6fa136 100644 --- a/src/i18n/locales/ja.toml +++ b/src/i18n/locales/ja.toml @@ -41,3 +41,5 @@ token_expired_title = "Claude Codeのセッションが切れました" token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" chatgpt_token_expired_title = "Codexのセッションが切れました" chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" +threshold_80_body = "5時間の上限に近づいています。" +threshold_95_body = "上限に近づきました — ペースを落としましょう。" diff --git a/src/i18n/locales/ko.toml b/src/i18n/locales/ko.toml index 5e9d5ec..c0010fa 100644 --- a/src/i18n/locales/ko.toml +++ b/src/i18n/locales/ko.toml @@ -41,3 +41,5 @@ token_expired_title = "Claude Code 세션 만료" token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." chatgpt_token_expired_title = "Codex 세션 만료" chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." +threshold_80_body = "5시간 한도에 가까워지고 있어요." +threshold_95_body = "한도 임박 — 잠시 쉬어가세요." diff --git a/src/i18n/locales/nl.toml b/src/i18n/locales/nl.toml index d7cdf97..15fc281 100644 --- a/src/i18n/locales/nl.toml +++ b/src/i18n/locales/nl.toml @@ -41,3 +41,5 @@ token_expired_title = "Claude Code-sessie verlopen" token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen." chatgpt_token_expired_title = "Codex-sessie verlopen" chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen." +threshold_80_body = "Je nadert de 5-uurslimiet." +threshold_95_body = "Limiet bijna bereikt — overweeg even gas terug te nemen." diff --git a/src/i18n/locales/zh-TW.toml b/src/i18n/locales/zh-TW.toml index 2c88522..180eb23 100644 --- a/src/i18n/locales/zh-TW.toml +++ b/src/i18n/locales/zh-TW.toml @@ -41,3 +41,5 @@ token_expired_title = "Claude Code 工作階段已過期" token_expired_body = "請重新登入以繼續追蹤使用量。" chatgpt_token_expired_title = "Codex 工作階段已過期" chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。" +threshold_80_body = "接近 5 小時上限。" +threshold_95_body = "上限將至 — 建議稍作休息。" diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 35f445c..fe71fd8 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -61,6 +61,12 @@ pub struct LocaleStrings { pub token_expired_body: String, pub chatgpt_token_expired_title: String, pub chatgpt_token_expired_body: String, + /// Body text for "your usage just crossed 80% of the 5h limit". The + /// title is composed from the provider label + percent so it does not + /// need to be translated separately. + pub threshold_80_body: String, + /// Body text for the 95% threshold balloon. + pub threshold_95_body: String, } #[derive(Deserialize)]