mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-06-06 18:12:46 +00:00
feat: threshold balloons + dark/light auto-follow
- Threshold balloons fire the cycle utilization crosses 80% or 95%
on either provider, with the title showing "{Provider} · {N}%" and
body translated per shipped locale. Reuses the existing
BALLOON_COOLDOWN so notifications stay calm.
- Dark/light auto-follow: bubble's WM_SETTINGCHANGE handler now calls
app::recheck_theme(), which re-reads HKCU\…\Personalize, updates
state.is_dark if changed, and triggers a UI repaint + tray refresh.
Windows posts WM_SETTINGCHANGE to every top-level window when the
user toggles light/dark in Settings, so the bubble repaints in
near-real-time.
- Adds 2 new i18n keys (threshold_80_body, threshold_95_body) across
all eight shipped locales.
Bumps version to 0.1.5.
This commit is contained in:
+102
-30
@@ -468,39 +468,57 @@ fn apply_results(
|
||||
results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>,
|
||||
) -> Vec<ProviderId> {
|
||||
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();
|
||||
|
||||
+6
-3
@@ -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 => {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = "上限に近づきました — ペースを落としましょう。"
|
||||
|
||||
@@ -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 = "한도 임박 — 잠시 쉬어가세요."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = "上限將至 — 建議稍作休息。"
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user