mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-09 00:20:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed9b8b2042 |
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claude-code-usage-bubble"
|
name = "claude-code-usage-bubble"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "claude-code-usage-bubble"
|
name = "claude-code-usage-bubble"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||||
|
|||||||
+102
-30
@@ -468,39 +468,57 @@ fn apply_results(
|
|||||||
results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>,
|
results: Vec<(ProviderId, Result<UsageWindows, usage::Error>)>,
|
||||||
) -> Vec<ProviderId> {
|
) -> Vec<ProviderId> {
|
||||||
let mut auth_failures = Vec::new();
|
let mut auth_failures = Vec::new();
|
||||||
let mut s = lock_state();
|
let mut crossings: Vec<(ProviderId, u8)> = Vec::new();
|
||||||
let Some(s) = s.as_mut() else {
|
{
|
||||||
return auth_failures;
|
let mut guard = lock_state();
|
||||||
};
|
let Some(state) = guard.as_mut() else {
|
||||||
if results.is_empty() {
|
return auth_failures;
|
||||||
return auth_failures;
|
};
|
||||||
}
|
if results.is_empty() {
|
||||||
let strings = s.i18n.strings().clone();
|
return auth_failures;
|
||||||
let mut any_ok = false;
|
}
|
||||||
for (id, outcome) in results {
|
let strings = state.i18n.strings().clone();
|
||||||
match outcome {
|
let mut any_ok = false;
|
||||||
Ok(windows) => {
|
for (id, outcome) in results {
|
||||||
let entry = s.snapshots.entry(id).or_default();
|
match outcome {
|
||||||
entry.windows = windows;
|
Ok(windows) => {
|
||||||
entry.primary_text = i18n::format_window(&windows.primary, &strings);
|
let entry = state.snapshots.entry(id).or_default();
|
||||||
entry.secondary_text = i18n::format_window(&windows.secondary, &strings);
|
let old_pct = entry.windows.primary.utilization;
|
||||||
any_ok = true;
|
let new_pct = windows.primary.utilization;
|
||||||
}
|
// Fire a balloon only the cycle a provider CROSSES a
|
||||||
Err(usage::Error::AuthRequired | usage::Error::TokenExpired) => {
|
// threshold so the user is nudged once, not on every
|
||||||
auth_failures.push(id);
|
// subsequent poll while parked above it.
|
||||||
let entry = s.snapshots.entry(id).or_default();
|
for threshold in [80u8, 95u8] {
|
||||||
entry.primary_text = "!".into();
|
if old_pct < threshold as f64 && new_pct >= threshold as f64 {
|
||||||
entry.secondary_text = "!".into();
|
crossings.push((id, threshold));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
}
|
||||||
log::warn!("provider {id:?} poll failed: {e}");
|
entry.windows = windows;
|
||||||
let entry = s.snapshots.entry(id).or_default();
|
entry.primary_text = i18n::format_window(&windows.primary, &strings);
|
||||||
entry.primary_text = "…".into();
|
entry.secondary_text = i18n::format_window(&windows.secondary, &strings);
|
||||||
entry.secondary_text = "…".into();
|
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
|
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) {
|
fn show_token_expired_balloon(failed: ProviderId) {
|
||||||
let payload = {
|
let payload = {
|
||||||
let mut s = lock_state();
|
let mut s = lock_state();
|
||||||
|
|||||||
+6
-3
@@ -478,10 +478,13 @@ unsafe extern "system" fn wnd_proc(
|
|||||||
LRESULT(0)
|
LRESULT(0)
|
||||||
}
|
}
|
||||||
WM_SETTINGCHANGE => {
|
WM_SETTINGCHANGE => {
|
||||||
// Taskbar move / auto-hide toggle / DPI change all post this.
|
// Taskbar move / auto-hide toggle / DPI change / theme toggle
|
||||||
// Just re-clamp into the new work area so the bubble can't end up
|
// all post this. Re-clamp into the new work area (bubble must
|
||||||
// hidden behind the new taskbar position.
|
// 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);
|
clamp_into_work_area(hwnd);
|
||||||
|
crate::app::recheck_theme();
|
||||||
LRESULT(0)
|
LRESULT(0)
|
||||||
}
|
}
|
||||||
WM_DESTROY => {
|
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."
|
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
||||||
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
|
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
|
||||||
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
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."
|
token_expired_body = "Sign in again to keep tracking your usage."
|
||||||
chatgpt_token_expired_title = "Codex session expired"
|
chatgpt_token_expired_title = "Codex session expired"
|
||||||
chatgpt_token_expired_body = "Sign in again to keep tracking your usage."
|
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."
|
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_title = "Sesión de Codex caducada"
|
||||||
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
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."
|
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
||||||
chatgpt_token_expired_title = "Session Codex expirée"
|
chatgpt_token_expired_title = "Session Codex expirée"
|
||||||
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
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 = "使用状況の追跡を続けるには再度サインインしてください。"
|
token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||||
chatgpt_token_expired_title = "Codexのセッションが切れました"
|
chatgpt_token_expired_title = "Codexのセッションが切れました"
|
||||||
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。"
|
||||||
|
threshold_80_body = "5時間の上限に近づいています。"
|
||||||
|
threshold_95_body = "上限に近づきました — ペースを落としましょう。"
|
||||||
|
|||||||
@@ -41,3 +41,5 @@ token_expired_title = "Claude Code 세션 만료"
|
|||||||
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
||||||
chatgpt_token_expired_title = "Codex 세션 만료"
|
chatgpt_token_expired_title = "Codex 세션 만료"
|
||||||
chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요."
|
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."
|
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
||||||
chatgpt_token_expired_title = "Codex-sessie verlopen"
|
chatgpt_token_expired_title = "Codex-sessie verlopen"
|
||||||
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
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 = "請重新登入以繼續追蹤使用量。"
|
token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||||
chatgpt_token_expired_title = "Codex 工作階段已過期"
|
chatgpt_token_expired_title = "Codex 工作階段已過期"
|
||||||
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
|
chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。"
|
||||||
|
threshold_80_body = "接近 5 小時上限。"
|
||||||
|
threshold_95_body = "上限將至 — 建議稍作休息。"
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ pub struct LocaleStrings {
|
|||||||
pub token_expired_body: String,
|
pub token_expired_body: String,
|
||||||
pub chatgpt_token_expired_title: String,
|
pub chatgpt_token_expired_title: String,
|
||||||
pub chatgpt_token_expired_body: 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)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
Reference in New Issue
Block a user