diff --git a/.github/screenshots/etd.png b/.github/screenshots/etd.png new file mode 100644 index 0000000..e200092 Binary files /dev/null and b/.github/screenshots/etd.png differ diff --git a/README.md b/README.md index 08c8a28..b728972 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,14 @@ The Claude Code tray icon uses the same warm usage colors as the Claude bar. The Hovering over a tray icon shows the usage values for that model. +### Estimated Time to Depletion (ETD) + +Enable **Show ETD** from the right-click menu (off by default) to see how long until you run out at your current pace. When a usage bar is on track to deplete before its window resets, the cell appends the estimate after the remaining time — for example `90% · 1d rem · 13h ETD` means roughly one day until the weekly window resets, but at the current burn rate you would run out in about 13 hours. Cells that are pacing safely show nothing extra. + +![ETD](.github/screenshots/etd.png) + +Inspired by [issue #21](https://github.com/CodeZeno/Claude-Code-Usage-Monitor/issues/21). + ## Diagnostics If you need to troubleshoot startup or visibility issues, run: diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 0eb486d..7931b29 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex-authenticatiefout", codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.", codex_window_title: "Codex-gebruiksmonitor", + show_etd: "Toon ETD", + etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 2b92f36..3290a05 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex Auth Error", codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.", codex_window_title: "Codex Usage Monitor", + show_etd: "Show ETD", + etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index fa448fb..6592c4c 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Erreur d'authentification Codex", codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.", codex_window_title: "Moniteur d'utilisation Codex", + show_etd: "Afficher l'ETD", + etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 5c7bb23..179ebf2 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex-Authentifizierungsfehler", codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.", codex_window_title: "Codex-Nutzungsmonitor", + show_etd: "ETD anzeigen", + etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 0020018..9c1702b 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex 認証エラー", codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。", codex_window_title: "Codex 使用量モニター", + show_etd: "ETD を表示", + etd_suffix: "ETD", + rem: "rem", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 59e3829..80f5608 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex 인증 오류", codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", + show_etd: "ETD 표시", + etd_suffix: "ETD", + rem: "rem", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 39ee702..cb4d54d 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -177,6 +177,9 @@ pub struct Strings { pub codex_token_expired_title: &'static str, pub codex_token_expired_body: &'static str, pub codex_window_title: &'static str, + pub show_etd: &'static str, + pub etd_suffix: &'static str, + pub rem: &'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 691ee77..c665d60 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -43,4 +43,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Erro de Autenticação do Codex", codex_token_expired_body: "Execute 'codex' em um terminal e siga as instruções de login. Depois disso, atualize ou reinicie este aplicativo.", codex_window_title: "Monitor de uso do Codex", + show_etd: "Mostrar ETD", + etd_suffix: "ETD", + rem: "rem", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index 786d0fa..8f31035 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -43,4 +43,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Ошибка авторизации Codex", codex_token_expired_body: "Запустите 'codex' в терминале и следуйте инструкциям для входа. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", + show_etd: "Показывать ETD", + etd_suffix: "ETD", + rem: "rem", }; \ No newline at end of file diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 8e6513e..0dbca7a 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Error de autenticacion de Codex", codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.", codex_window_title: "Monitor de uso de Codex", + show_etd: "Mostrar ETD", + etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 809ebba..84426a7 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -42,5 +42,8 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex 驗證錯誤", codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", + show_etd: "顯示 ETD", + etd_suffix: "ETD", + rem: "rem", second_suffix: "秒", }; diff --git a/src/poller.rs b/src/poller.rs index 5bd02bc..e6017ce 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1097,3 +1097,138 @@ pub fn app_is_past_reset(data: &AppUsageData) -> bool { data.claude_code.as_ref().is_some_and(is_past_reset) || data.codex.as_ref().is_some_and(is_past_reset) } + +/// Rolling-quota window lengths, in seconds (5 hours and 7 days). Kept here, +/// next to the formatting that consumes them, so the ETD feature is +/// self-contained and does not depend on constants defined elsewhere. +pub const SESSION_WINDOW_SECS: u64 = 5 * 3600; +pub const WEEKLY_WINDOW_SECS: u64 = 7 * 86400; + +/// Estimated seconds until the quota is fully consumed, assuming the current +/// burn rate (quota-so-far / time-so-far) holds. Returns `None` unless the +/// projection lands *before* the window resets — i.e. only when the user is +/// genuinely on pace to deplete early. +fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option { + if actual_pct <= 0.0 || actual_pct >= 100.0 { + return None; + } + if remaining_secs == 0 || window_secs == 0 { + return None; + } + let elapsed_secs = window_secs.saturating_sub(remaining_secs); + if elapsed_secs == 0 { + return None; + } + let secs = (100.0 - actual_pct) * (elapsed_secs as f64) / actual_pct; + if !secs.is_finite() || secs < 0.0 { + return None; + } + let secs = secs as u64; + (secs < remaining_secs).then_some(secs) +} + +/// The trailing " rem · 45m ETD" segment for a usage cell, or `None` when the +/// section is not on pace to deplete before reset. The leading `rem` labels the +/// preceding countdown (which `format_line` left unlabeled) so the remaining +/// time and the depletion estimate are distinguishable. Reuses the same coarse, +/// single-unit duration format as the countdown. +pub fn etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option { + let reset = section.resets_at?; + let remaining_secs = reset.duration_since(SystemTime::now()).ok()?.as_secs(); + let secs = etd_secs(section.percentage, remaining_secs, window_secs)?; + let dur = format_countdown_from_secs(secs, strings); + Some(format!( + " {} \u{00b7} {dur} {}", + strings.rem, strings.etd_suffix + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::localization::LanguageId; + use std::time::Duration; + + fn section(pct: f64, remaining: Duration) -> UsageSection { + UsageSection { + percentage: pct, + resets_at: Some(SystemTime::now() + remaining), + } + } + + #[test] + fn etd_suffix_present_when_at_risk() { + // 60% used, 1h remaining of a 2h window → at risk. + let s = section(60.0, Duration::from_secs(3600)); + let out = etd_suffix(&s, 2 * 3600, LanguageId::English.strings()); + let out = out.expect("expected a suffix when at risk"); + assert!(out.contains("ETD"), "suffix was: {out}"); + // Labels the preceding countdown with "rem", then the ETD segment. + assert!(out.starts_with(" rem \u{00b7} "), "suffix was: {out}"); + } + + #[test] + fn etd_suffix_absent_when_safe() { + // 10% used, 4h remaining of a 5h window → safe. + let s = section(10.0, Duration::from_secs(4 * 3600)); + assert_eq!(etd_suffix(&s, 5 * 3600, LanguageId::English.strings()), None); + } + + #[test] + fn etd_suffix_absent_without_reset() { + let s = UsageSection { percentage: 60.0, resets_at: None }; + assert_eq!(etd_suffix(&s, 2 * 3600, LanguageId::English.strings()), None); + } + + #[test] + fn etd_none_when_on_safe_pace() { + // 10% used, 1h elapsed of a 5h window (4h remaining): steady pace at 1h + // is 20%, so 10% is UNDER pace → would not deplete before reset. + // (50% in the first hour would be at-risk, not safe — see the invariant.) + assert_eq!(etd_secs(10.0, 4 * 3600, 5 * 3600), None); + } + + #[test] + fn etd_some_when_at_risk() { + // 60% used, 1h elapsed of a 2h window (1h remaining). + // Remaining 40% at 60%/h needs 40 min < 60 min remaining → at risk. + assert_eq!(etd_secs(60.0, 3600, 2 * 3600), Some(2400)); + } + + #[test] + fn etd_none_at_boundaries() { + assert_eq!(etd_secs(0.0, 3600, 5 * 3600), None); // nothing used + assert_eq!(etd_secs(100.0, 3600, 5 * 3600), None); // already full + assert_eq!(etd_secs(50.0, 5 * 3600, 5 * 3600), None); // elapsed = 0 + assert_eq!(etd_secs(50.0, 0, 5 * 3600), None); // no remaining + assert_eq!(etd_secs(50.0, 3600, 0), None); // no window + } + + #[test] + fn etd_invariant_matches_at_risk_rule() { + // etd_secs is Some iff burn rate exceeds steady pace: + // actual_pct > 100 * elapsed / window. + // Skip a small band around the exact boundary to avoid float flakiness. + let window = 5 * 3600u64; + for remaining in (0..=window).step_by(600) { + let elapsed = window - remaining; + for pct_x10 in 1..1000u64 { + let actual = pct_x10 as f64 / 10.0; + if elapsed == 0 || remaining == 0 { + assert_eq!(etd_secs(actual, remaining, window), None); + continue; + } + let boundary = 100.0 * elapsed as f64 / window as f64; + if (actual - boundary).abs() < 0.05 { + continue; // razor's edge — covered by explicit boundary test + } + let at_risk = actual > boundary; + assert_eq!( + etd_secs(actual, remaining, window).is_some(), + at_risk, + "actual={actual} remaining={remaining} window={window}" + ); + } + } + } +} diff --git a/src/window.rs b/src/window.rs index 7ce1dea..ff87d6d 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicI32, AtomicU32, Ordering}; use std::sync::{Mutex, MutexGuard}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -84,6 +84,7 @@ struct AppState { drag_start_offset: i32, widget_visible: bool, + show_etd: bool, } #[derive(Clone, Debug)] @@ -122,6 +123,7 @@ const IDM_LANG_TRADITIONAL_CHINESE: u16 = 48; const IDM_LANG_RUSSIAN: u16 = 49; const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; +const IDM_SHOW_ETD: u16 = 74; const IDM_MODEL_CODEX: u16 = 61; const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN @@ -211,6 +213,8 @@ struct SettingsFile { last_update_check_unix: Option, #[serde(default = "default_widget_visible")] widget_visible: bool, + #[serde(default)] + show_etd: bool, #[serde(default = "default_show_claude_code")] show_claude_code: bool, #[serde(default = "default_show_codex")] @@ -225,6 +229,7 @@ impl Default for SettingsFile { language: None, last_update_check_unix: None, widget_visible: true, + show_etd: false, show_claude_code: true, show_codex: false, } @@ -280,6 +285,7 @@ fn save_state_settings() { .map(|language| language.code().to_string()), last_update_check_unix: s.last_update_check_unix, widget_visible: s.widget_visible, + show_etd: s.show_etd, show_claude_code: s.show_claude_code, show_codex: s.show_codex, }); @@ -422,6 +428,18 @@ fn refresh_usage_texts(state: &mut AppState) { if let Some(claude_code) = data.claude_code.as_ref() { state.session_text = poller::format_line(&claude_code.session, strings); state.weekly_text = poller::format_line(&claude_code.weekly, strings); + if state.show_etd { + if let Some(s) = + poller::etd_suffix(&claude_code.session, poller::SESSION_WINDOW_SECS, strings) + { + state.session_text.push_str(&s); + } + if let Some(s) = + poller::etd_suffix(&claude_code.weekly, poller::WEEKLY_WINDOW_SECS, strings) + { + state.weekly_text.push_str(&s); + } + } } else if state.show_claude_code { state.session_text = "!".to_string(); state.weekly_text = "!".to_string(); @@ -430,10 +448,24 @@ fn refresh_usage_texts(state: &mut AppState) { if let Some(codex) = data.codex.as_ref() { state.codex_session_text = poller::format_line(&codex.session, strings); state.codex_weekly_text = poller::format_line(&codex.weekly, strings); + if state.show_etd { + if let Some(s) = + poller::etd_suffix(&codex.session, poller::SESSION_WINDOW_SECS, strings) + { + state.codex_session_text.push_str(&s); + } + if let Some(s) = + poller::etd_suffix(&codex.weekly, poller::WEEKLY_WINDOW_SECS, strings) + { + state.codex_weekly_text.push_str(&s); + } + } } else if state.show_codex { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); } + + update_measured_text_width(state); } fn set_window_title(hwnd: HWND, strings: Strings) { @@ -826,6 +858,72 @@ fn active_model_count(show_claude_code: bool, show_codex: bool) -> i32 { (show_claude_code as i32 + show_codex as i32).max(1) } +/// Small trailing padding (device px, unscaled) added after measured text. +const TEXT_MEASURE_PAD: i32 = 6; + +/// Width (device px, already DPI-scaled) of the widest usage-cell text actually +/// shown, recomputed whenever the texts change. The cell column sizes to real +/// content (detailed time / ETD suffix only when present) instead of a fixed +/// worst-case reservation. Falls back to the base column before first measure. +static MEASURED_TEXT_WIDTH: AtomicI32 = AtomicI32::new(0); + +fn current_text_width() -> i32 { + MEASURED_TEXT_WIDTH.load(Ordering::Relaxed).max(sc(TEXT_WIDTH)) +} + +/// Measure a string's pixel width in the same font the widget renders with. +fn measure_text_px(text: &str) -> i32 { + if text.is_empty() { + return 0; + } + unsafe { + let hdc = GetDC(HWND::default()); + let mem = CreateCompatibleDC(hdc); + let font_name = native_interop::wide_str("Segoe UI"); + let font = CreateFontW( + sc(-12), + 0, + 0, + 0, + FW_MEDIUM.0 as i32, + 0, + 0, + 0, + DEFAULT_CHARSET.0 as u32, + OUT_TT_PRECIS.0 as u32, + CLIP_DEFAULT_PRECIS.0 as u32, + CLEARTYPE_QUALITY.0 as u32, + (DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32, + PCWSTR::from_raw(font_name.as_ptr()), + ); + let old = SelectObject(mem, font); + let wide: Vec = text.encode_utf16().collect(); + let mut size = SIZE::default(); + let _ = GetTextExtentPoint32W(mem, &wide, &mut size); + SelectObject(mem, old); + let _ = DeleteObject(font); + let _ = DeleteDC(mem); + ReleaseDC(HWND::default(), hdc); + size.cx + } +} + +/// Recompute the measured cell-text width from the currently-visible texts. +fn update_measured_text_width(state: &AppState) { + let mut max_w = 0; + if state.show_claude_code { + max_w = max_w.max(measure_text_px(&state.session_text)); + max_w = max_w.max(measure_text_px(&state.weekly_text)); + } + if state.show_codex { + max_w = max_w.max(measure_text_px(&state.codex_session_text)); + max_w = max_w.max(measure_text_px(&state.codex_weekly_text)); + } + if max_w > 0 { + MEASURED_TEXT_WIDTH.store(max_w + sc(TEXT_MEASURE_PAD), Ordering::Relaxed); + } +} + fn row_bar_segment_count(active_models: i32) -> i32 { if active_models > 1 { 5 @@ -838,7 +936,7 @@ fn total_widget_width_for(active_models: i32) -> i32 { let bar_segments = row_bar_segment_count(active_models); let model_width = (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * bar_segments - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(TEXT_WIDTH); + + current_text_width(); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -864,6 +962,13 @@ fn total_widget_width() -> i32 { total_widget_width_for(active_models) } +/// Whether the ETD suffix is enabled, read from shared state. Returns false +/// when state is not yet populated (startup) or the lock cannot be acquired. +/// Callers must not hold the state lock. +fn show_etd_enabled() -> bool { + lock_state().as_ref().map_or(false, |s| s.show_etd) +} + fn claude_accent_color() -> Color { Color::from_hex("#D97757") } @@ -1030,6 +1135,7 @@ pub fn run() { drag_start_mouse_x: 0, drag_start_offset: 0, widget_visible: settings.widget_visible, + show_etd: settings.show_etd, }); } @@ -2252,6 +2358,18 @@ unsafe extern "system" fn wnd_proc( do_poll(sh); }); } + IDM_SHOW_ETD => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.show_etd = !s.show_etd; + refresh_usage_texts(s); + } + } + save_state_settings(); + position_at_taskbar(); + render_layered(); + } IDM_LANG_SYSTEM | IDM_LANG_ENGLISH | IDM_LANG_DUTCH @@ -2511,6 +2629,19 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(language_label.as_ptr()), ); + let etd_str = native_interop::wide_str(strings.show_etd); + let etd_flags = if show_etd_enabled() { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + settings_menu, + etd_flags, + IDM_SHOW_ETD as usize, + PCWSTR::from_raw(etd_str.as_ptr()), + ); + let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); let version_label = @@ -2751,7 +2882,7 @@ fn draw_row( fn model_usage_width(segment_count: i32) -> i32 { (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * segment_count - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(TEXT_WIDTH) + + current_text_width() } fn draw_usage_bar( @@ -2824,7 +2955,7 @@ fn draw_usage_bar( let mut text_rect = RECT { left: text_x, top: y, - right: text_x + sc(TEXT_WIDTH), + right: text_x + current_text_width(), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref()));