diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs index b8eadc0275f..37183b3a7dd 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs @@ -42,6 +42,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index b3839776e08..033f24a9b96 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -41,6 +41,8 @@ pub struct RateWindowSnapshot { pub reserve_will_last_to_reset: bool, #[serde(default)] pub reserve_eta_seconds: Option, + #[serde(default)] + pub pace: Option, /// Set while a longer exhausted pool (Kimi's monthly membership) blocks /// this window; presentation only, the raw percentages above stay as is. #[serde(default)] @@ -67,6 +69,7 @@ impl RateWindowSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: rw.description_is_detail, } @@ -404,7 +407,8 @@ impl ProviderUsageSnapshot { }); let primary_pace = primary_pace.flatten(); - let blocked = BlockedWindows::evaluate(id, usage, chrono::Utc::now()); + let now = chrono::Utc::now(); + let blocked = BlockedWindows::evaluate(id, usage, now); let pace = primary_pace.as_ref().map(|p| PaceSnapshot { stage: pace::stage_str(p.stage).to_string(), delta_percent: p.delta_percent, @@ -424,12 +428,22 @@ impl ProviderUsageSnapshot { }); let secondary_pace = secondary_pace.flatten(); - let primary_snap = RateWindowSnapshot::from_rate_window(&usage.primary) - .with_quota_block(blocked.primary, &blocked); + let lane = + |window: &RateWindow, is_blocked: bool, default_minutes: u32| RateWindowSnapshot { + pace: allows_pace + .then(|| pace::WindowPaceSnapshot::for_window(window, default_minutes, now)) + .flatten(), + ..RateWindowSnapshot::from_rate_window(window) + .with_quota_block(is_blocked, &blocked) + }; + let primary_snap = lane( + &usage.primary, + blocked.primary, + pace::SESSION_FALLBACK_MINUTES, + ); let secondary_snap = usage.secondary.as_ref().map(|sw| { - let mut s = RateWindowSnapshot::from_rate_window(sw) - .with_quota_block(blocked.secondary, &blocked); + let mut s = lane(sw, blocked.secondary, pace::WEEKLY_FALLBACK_MINUTES); if let Some(ref p) = secondary_pace { s = s.with_pace_reserve(p); } @@ -471,13 +485,14 @@ impl ProviderUsageSnapshot { .clone() .unwrap_or_else(|| metadata.weekly_label.to_string()) }), - model_specific: usage.model_specific.as_ref().map(|w| { - RateWindowSnapshot::from_rate_window(w) - .with_quota_block(blocked.model_specific, &blocked) - }), - tertiary: usage.tertiary.as_ref().map(|w| { - RateWindowSnapshot::from_rate_window(w).with_quota_block(blocked.tertiary, &blocked) - }), + model_specific: usage + .model_specific + .as_ref() + .map(|w| lane(w, blocked.model_specific, pace::WEEKLY_FALLBACK_MINUTES)), + tertiary: usage + .tertiary + .as_ref() + .map(|w| lane(w, blocked.tertiary, pace::WEEKLY_FALLBACK_MINUTES)), // F5 (upstream 0.48.0): label the tertiary lane by its duration cadence // so surfaces (MenuCard, CLI, tray) can show "Monthly" instead of the // generic "DetailWindowTertiary" slot key. @@ -496,8 +511,7 @@ impl ProviderUsageSnapshot { .map(|(extra, &is_blocked)| NamedRateWindowSnapshot { id: extra.id.clone(), title: extra.title.clone(), - window: RateWindowSnapshot::from_rate_window(&extra.window) - .with_quota_block(is_blocked, &blocked), + window: lane(&extra.window, is_blocked, pace::WEEKLY_FALLBACK_MINUTES), fallback_lane: extra.fallback_lane, icon_fallback: extra.icon_fallback, }) @@ -600,6 +614,7 @@ impl ProviderUsageSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }, @@ -1157,6 +1172,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs index 7930c362939..b43801b98b6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -1,3 +1,9 @@ +use super::*; +use chrono::{DateTime, Utc}; + +pub(crate) const SESSION_FALLBACK_MINUTES: u32 = 300; +pub(crate) const WEEKLY_FALLBACK_MINUTES: u32 = 10_080; + pub(crate) fn stage_str(stage: codexbar::core::PaceStage) -> &'static str { use codexbar::core::PaceStage; match stage { @@ -10,3 +16,181 @@ pub(crate) fn stage_str(stage: codexbar::core::PaceStage) -> &'static str { PaceStage::FarBehind => "far_behind", } } + +/// Pace of one rate window against the time elapsed in it. +/// `delta_percent` is actual minus expected used percent, so a positive delta +/// is a deficit and a negative one a reserve. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowPaceSnapshot { + pub stage: String, + pub delta_percent: f64, + pub expected_used_percent: f64, + pub actual_used_percent: f64, + #[serde(default)] + pub eta_seconds: Option, + #[serde(default)] + pub will_last_to_reset: bool, +} + +impl WindowPaceSnapshot { + pub(crate) fn for_window( + window: &RateWindow, + default_window_minutes: u32, + now: DateTime, + ) -> Option { + if window.is_informational { + return None; + } + let pace = codexbar::core::UsagePace::weekly(window, Some(now), default_window_minutes)?; + Some(Self { + stage: stage_str(pace.stage).to_string(), + delta_percent: pace.delta_percent, + expected_used_percent: pace.expected_used_percent, + actual_used_percent: pace.actual_used_percent, + eta_seconds: pace.eta_seconds, + will_last_to_reset: pace.will_last_to_reset, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, TimeZone}; + use codexbar::core::UsageSnapshot; + + fn fixed_now() -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 10, 12, 0, 0).unwrap() + } + + fn resetting_in( + now: DateTime, + used: f64, + window: Option, + minutes: i64, + ) -> RateWindow { + RateWindow::with_details(used, window, Some(now + Duration::minutes(minutes)), None) + } + + fn snapshot(usage: UsageSnapshot, pace_authoritative: bool) -> ProviderUsageSnapshot { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let mut result = ProviderFetchResult::new(usage, "web"); + if !pace_authoritative { + result = result.with_non_authoritative_pace(); + } + ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None) + } + + fn expected_used(window: &RateWindowSnapshot) -> f64 { + window + .pace + .as_ref() + .expect("lane pace") + .expected_used_percent + } + + #[test] + fn reserve_is_a_negative_delta_that_lasts_to_reset() { + let window = resetting_in(fixed_now(), 30.0, Some(10_080), 5_040); + + assert_eq!( + WindowPaceSnapshot::for_window(&window, WEEKLY_FALLBACK_MINUTES, fixed_now()), + Some(WindowPaceSnapshot { + stage: "far_behind".to_string(), + delta_percent: -20.0, + expected_used_percent: 50.0, + actual_used_percent: 30.0, + eta_seconds: None, + will_last_to_reset: true, + }) + ); + } + + #[test] + fn deficit_is_a_positive_delta_with_an_eta() { + let window = resetting_in(fixed_now(), 55.0, Some(10_080), 5_040); + + let pace = WindowPaceSnapshot::for_window(&window, WEEKLY_FALLBACK_MINUTES, fixed_now()) + .expect("pace"); + + assert_eq!(pace.stage, "slightly_ahead"); + assert_eq!(pace.delta_percent, 5.0); + assert!(!pace.will_last_to_reset); + assert!((pace.eta_seconds.expect("eta") - 247_418.18).abs() < 0.01); + } + + #[test] + fn informational_window_has_no_pace() { + let mut window = resetting_in(fixed_now(), 30.0, Some(10_080), 5_040); + window.is_informational = true; + + assert_eq!( + WindowPaceSnapshot::for_window(&window, WEEKLY_FALLBACK_MINUTES, fixed_now()), + None + ); + } + + #[test] + fn session_lane_defaults_to_a_300_minute_window() { + let primary = resetting_in(Utc::now(), 60.0, None, 150); + + let snap = snapshot(UsageSnapshot::new(primary), true); + + assert!((expected_used(&snap.primary) - 50.0).abs() < 0.1); + assert_eq!(snap.primary.pace.as_ref().unwrap().stage, "ahead"); + } + + #[test] + fn weekly_lanes_default_to_a_10080_minute_window() { + let now = Utc::now(); + let mut usage = UsageSnapshot::new(RateWindow::new(10.0)) + .with_secondary(resetting_in(now, 40.0, None, 5_040)) + .with_tertiary(resetting_in(now, 45.0, None, 5_040)) + .with_extra_rate_window("sonnet", "Sonnet", resetting_in(now, 50.0, None, 5_040)); + usage.model_specific = Some(resetting_in(now, 55.0, None, 5_040)); + + let snap = snapshot(usage, true); + + assert!((expected_used(snap.secondary.as_ref().unwrap()) - 50.0).abs() < 0.1); + assert!((expected_used(snap.tertiary.as_ref().unwrap()) - 50.0).abs() < 0.1); + assert!((expected_used(snap.model_specific.as_ref().unwrap()) - 50.0).abs() < 0.1); + assert!((expected_used(&snap.extra_rate_windows[0].window) - 50.0).abs() < 0.1); + } + + #[test] + fn informational_primary_keeps_the_weekly_lane_pace() { + let usage = UsageSnapshot::new(RateWindow::informational("No session limit")) + .with_secondary(resetting_in(Utc::now(), 30.0, Some(10_080), 5_040)); + + let snap = snapshot(usage, true); + + assert_eq!(snap.primary.pace, None); + let weekly = snap.secondary.as_ref().unwrap().pace.as_ref().unwrap(); + assert_eq!(weekly.stage, "far_behind"); + assert!(weekly.will_last_to_reset); + } + + #[test] + fn non_authoritative_pace_leaves_every_lane_empty() { + let now = Utc::now(); + let usage = UsageSnapshot::new(resetting_in(now, 60.0, Some(300), 150)) + .with_secondary(resetting_in(now, 30.0, Some(10_080), 5_040)); + + let snap = snapshot(usage, false); + + assert_eq!(snap.primary.pace, None); + assert_eq!(snap.secondary.as_ref().unwrap().pace, None); + } + + #[test] + fn window_pace_serializes_in_camel_case() { + let window = resetting_in(fixed_now(), 30.0, Some(10_080), 5_040); + let pace = WindowPaceSnapshot::for_window(&window, WEEKLY_FALLBACK_MINUTES, fixed_now()); + + assert_eq!( + serde_json::to_string(&pace).unwrap(), + r#"{"stage":"far_behind","deltaPercent":-20.0,"expectedUsedPercent":50.0,"actualUsedPercent":30.0,"etaSeconds":null,"willLastToReset":true}"# + ); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 14457ae57d1..01a56ea6420 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1618,6 +1618,7 @@ mod reset_backfill_tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, description_is_detail: false, monthly_limit_block: None, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs index 1311db6344c..df74ed2ba85 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs @@ -110,6 +110,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, } diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 305abe3dd9b..b00be948827 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -181,6 +181,7 @@ mod tests { reserve_percent: None, reserve_description: None, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, reserve_will_last_to_reset: false, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index c1a50e9ac18..b1539626864 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -693,6 +693,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }, @@ -709,6 +710,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }), @@ -726,6 +728,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }), @@ -777,6 +780,13 @@ mod tests { fake_snapshot_with(id, display, used_percent, None, None, None) } + fn used_view_settings() -> Settings { + Settings { + show_as_used: true, + ..Settings::default() + } + } + fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { crate::commands::NamedRateWindowSnapshot { id: "additional_budget".to_string(), @@ -795,6 +805,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, description_is_detail: false, monthly_limit_block: None, }, @@ -916,7 +927,7 @@ mod tests { } #[test] fn selected_tray_percent_uses_cursor_extra_usage_cost() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); let snapshot = fake_snapshot_with( "cursor", @@ -932,7 +943,7 @@ mod tests { } #[test] fn selected_tray_percent_tracks_extra_rate_window() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Copilot, MetricPreference::ExtraUsage); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); @@ -942,7 +953,7 @@ mod tests { } #[test] fn copilot_automatic_tracks_highest_extra_rate_window() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -1030,7 +1041,7 @@ mod tests { } #[test] fn selected_tray_percent_falls_back_when_extra_usage_missing() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); let snapshot = fake_snapshot_with("cursor", "Cursor", 10.0, Some(72.0), None, None); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -1038,7 +1049,7 @@ mod tests { } #[test] fn single_meaningful_secondary_quota_uses_full_single_meter() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; let (primary, secondary) = selected_tray_percents(&snapshot, &settings); @@ -1047,7 +1058,7 @@ mod tests { } #[test] fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), Some(30.0), None); snapshot.primary.is_informational = true; @@ -1057,7 +1068,7 @@ mod tests { } #[test] fn two_meaningful_quotas_keep_two_meter_layout() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::Session); let snapshot = fake_snapshot_with("cursor", "Cursor", 15.0, Some(40.0), None, None); let (primary, secondary) = selected_tray_percents(&snapshot, &settings); @@ -1066,7 +1077,7 @@ mod tests { } #[test] fn informational_primary_skips_session_and_automatic_phantom_zero() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Claude, MetricPreference::Session); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; @@ -1082,7 +1093,7 @@ mod tests { } #[test] fn claude_automatic_prefers_weekly_when_model_exhausted() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 40.0, Some(22.0), None, None); snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { used_percent: 100.0, @@ -1096,6 +1107,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, description_is_detail: false, monthly_limit_block: None, }); @@ -1109,7 +1121,7 @@ mod tests { } #[test] fn automatic_prefers_exhausted_weekly_over_low_session() { - let settings = Settings::default(); + let settings = used_view_settings(); let snapshot = fake_snapshot_with("codex", "Codex", 20.0, Some(100.0), None, None); let (primary, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(primary, 100.0); @@ -1121,7 +1133,7 @@ mod tests { } #[test] fn automatic_picks_highest_among_model_and_extra_windows() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("gemini", "Gemini", 10.0, Some(30.0), Some(40.0), None); snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { @@ -1136,6 +1148,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, description_is_detail: false, monthly_limit_block: None, }); diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index e443735870a..8a606b7e9ba 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -25,6 +25,7 @@ fn fake_snapshot_with( reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }; @@ -83,6 +84,7 @@ fn single_plan_uses_highest_provider_for_icon_and_summary() { let settings = Settings { tray_icon_mode: TrayIconMode::Single, menu_bar_shows_highest_usage: true, + show_as_used: true, ..Settings::default() }; let snapshots = vec![ @@ -157,6 +159,7 @@ fn stacked_plan_resolves_distinct_preferences_once() { tray_icon_mode: TrayIconMode::Stacked, stacked_tray_top_provider: Some("claude".to_string()), stacked_tray_bottom_provider: Some("codex".to_string()), + show_as_used: true, ..Settings::default() }; let snapshots = vec![ @@ -211,6 +214,7 @@ fn stacked_plan_falls_back_around_stale_and_duplicate_preferences() { tray_icon_mode: TrayIconMode::Stacked, stacked_tray_top_provider: Some("missing".to_string()), stacked_tray_bottom_provider: Some("claude".to_string()), + show_as_used: true, ..Settings::default() }; let snapshots = vec![ @@ -236,6 +240,7 @@ fn stacked_plan_falls_back_around_stale_and_duplicate_preferences() { fn one_provider_stacked_mode_falls_back_to_single_provider_bars() { let settings = Settings { tray_icon_mode: TrayIconMode::Stacked, + show_as_used: true, ..Settings::default() }; let snapshots = vec![fake_snapshot_with( @@ -265,6 +270,7 @@ fn one_healthy_provider_never_uses_stacked_renderer() { let settings = Settings { tray_icon_mode: TrayIconMode::Stacked, menu_bar_shows_percent: true, + show_as_used: true, ..Settings::default() }; let healthy = fake_snapshot("codex", "Codex", 30.0); @@ -341,6 +347,7 @@ fn render_icon_delegates_to_resolved_stacked_renderer() { tray_icon_mode: TrayIconMode::Stacked, stacked_tray_top_provider: Some("claude".to_string()), stacked_tray_bottom_provider: Some("codex".to_string()), + show_as_used: true, ..Settings::default() }; let snapshots = vec![ @@ -362,6 +369,13 @@ fn codex_headline_skips_informational_primary() { assert_eq!(codex_lane_headline_window(&snapshot).used_percent, 25.0); } +fn used_view_settings() -> Settings { + Settings { + show_as_used: true, + ..Settings::default() + } +} + fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { crate::commands::NamedRateWindowSnapshot { id: "additional_budget".to_string(), @@ -380,6 +394,7 @@ fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }, @@ -388,7 +403,7 @@ fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { #[test] fn selected_tray_percent_uses_cursor_extra_usage_cost() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); let snapshot = fake_snapshot_with( "cursor", @@ -407,7 +422,7 @@ fn selected_tray_percent_uses_cursor_extra_usage_cost() { #[test] fn selected_tray_percent_tracks_extra_rate_window() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Copilot, MetricPreference::ExtraUsage); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); @@ -420,7 +435,7 @@ fn selected_tray_percent_tracks_extra_rate_window() { #[test] fn copilot_automatic_tracks_highest_extra_rate_window() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); @@ -523,7 +538,7 @@ fn missing_automatic_window_does_not_look_like_available_remaining_progress() { #[test] fn selected_tray_percent_falls_back_when_extra_usage_missing() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); let snapshot = fake_snapshot_with("cursor", "Cursor", 10.0, Some(72.0), None, None); @@ -534,7 +549,7 @@ fn selected_tray_percent_falls_back_when_extra_usage_missing() { #[test] fn single_meaningful_secondary_quota_uses_full_single_meter() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; @@ -546,7 +561,7 @@ fn single_meaningful_secondary_quota_uses_full_single_meter() { #[test] fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), Some(30.0), None); snapshot.primary.is_informational = true; @@ -558,7 +573,7 @@ fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { #[test] fn two_meaningful_quotas_keep_two_meter_layout() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::Session); let snapshot = fake_snapshot_with("cursor", "Cursor", 15.0, Some(40.0), None, None); @@ -570,7 +585,7 @@ fn two_meaningful_quotas_keep_two_meter_layout() { #[test] fn informational_primary_skips_session_and_automatic_phantom_zero() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Claude, MetricPreference::Session); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; @@ -589,7 +604,7 @@ fn informational_primary_skips_session_and_automatic_phantom_zero() { #[test] fn claude_automatic_prefers_weekly_when_model_exhausted() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 40.0, Some(22.0), None, None); snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { used_percent: 100.0, @@ -603,6 +618,7 @@ fn claude_automatic_prefers_weekly_when_model_exhausted() { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }); @@ -619,7 +635,7 @@ fn claude_automatic_prefers_weekly_when_model_exhausted() { #[test] fn automatic_prefers_exhausted_weekly_over_low_session() { - let settings = Settings::default(); + let settings = used_view_settings(); let snapshot = fake_snapshot_with("codex", "Codex", 20.0, Some(100.0), None, None); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -634,7 +650,7 @@ fn automatic_prefers_exhausted_weekly_over_low_session() { #[test] fn automatic_picks_highest_among_model_and_extra_windows() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("gemini", "Gemini", 10.0, Some(30.0), Some(40.0), None); snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { used_percent: 55.0, @@ -648,6 +664,7 @@ fn automatic_picks_highest_among_model_and_extra_windows() { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }); diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index cab5254a13e..2266daa4e42 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -401,6 +401,7 @@ fn derived_window(used_percent: f64, resets_at: Option) -> RateWindowSna reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, } diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index eaed2c2ec4a..2ab6b9edb59 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -23,6 +23,7 @@ vi.mock("@tauri-apps/api/event", () => eventMocks); import { LocaleProvider } from "../i18n/LocaleProvider"; import { buildBundle } from "../test/localeHarness"; import { loadStyles, ruleBlock } from "../test/styles"; +import type { UsageThresholdSettings } from "../lib/metricRowModel"; import type { ProviderUsageSnapshot, WayfinderUsageSnapshot } from "../types/bridge"; import MenuCard from "./MenuCard"; @@ -91,6 +92,8 @@ function renderCard( compactOverview?: boolean; onLayoutChange?: () => void; costSummaryDisplayStyle?: "compact" | "detailed" | "hidden"; + usageThresholds?: UsageThresholdSettings | null; + weeklyProgressWorkDays?: number | null; } = {}, ) { return render( @@ -105,6 +108,8 @@ function renderCard( showPace: opts.showPace, compactOverview: opts.compactOverview, costSummaryDisplayStyle: opts.costSummaryDisplayStyle, + usageThresholds: opts.usageThresholds, + weeklyProgressWorkDays: opts.weeklyProgressWorkDays, }} onLayoutChange={opts.onLayoutChange} /> @@ -130,7 +135,6 @@ describe("MenuCard", () => { PanelFiveHours: "5h", PanelBlockedByMonthlyLimit: "Blocked by monthly limit", PanelOnPaceBudget: "On-pace budget", - PanelReserveSuffix: "in reserve", PanelPeriodCost: "{} cost", PanelPeriodTokens: "{} tokens", CostPeriodShortMonthToDate: "MTD", @@ -140,6 +144,15 @@ describe("MenuCard", () => { ResetsInHoursMinutes: "Resets in {}h {}m", ResetsInMinutes: "Resets in {}m", ResetsInDaysHours: "Resets in {}d {}h", + ResetsInHoursOnly: "Resets in {}h", + DurationHoursMinutes: "{}h {}m", + PaceOnPace: "On pace", + PaceInReserve: "{}% in reserve", + PaceInDeficit: "{}% in deficit", + PaceLastsUntilReset: "Lasts until reset", + PaceRunsOutIn: "Runs out in {}", + UsageBarQuotaWarnings: "Quota warnings", + UsageBarWorkDays: "Work days", NextExpiresInHoursMinutes: "Next expires in {}h {}m", NextExpiresInMinutes: "Next expires in {}m", NextExpiresInDaysHours: "Next expires in {}d {}h", @@ -291,8 +304,8 @@ describe("MenuCard", () => { expect(await screen.findByText("35% used")).toBeInTheDocument(); expect(screen.queryByText("65% left")).not.toBeInTheDocument(); - const fill = document.querySelector(".menu-metric__bar-fill"); - expect(fill?.style.width).toBe("35%"); + const fill = document.querySelector(".menu-metric__progress-fill"); + expect(fill?.getAttribute("width")).toBe("35%"); }); it("shows an additional balance description below the meter and reset time", async () => { @@ -310,7 +323,7 @@ describe("MenuCard", () => { const description = await screen.findByText("750 / 1000 credits left"); expect(description).toHaveClass("menu-metric__detail"); - expect(screen.getByText(/Resets in/)).toBeInTheDocument(); + expect(screen.getByText("Resets in 1h")).toHaveClass("menu-metric__reset"); }); it("does not repeat a reset-phrase description as an extra detail", async () => { @@ -321,20 +334,21 @@ describe("MenuCard", () => { renderCard(snapshot); - await screen.findByText(/Resets in/); + expect(await screen.findByText("Resets in 5h")).toHaveClass("menu-metric__reset"); expect(document.querySelectorAll(".menu-metric__reset")).toHaveLength(1); expect(document.querySelectorAll(".menu-metric__detail")).toHaveLength(0); expect(screen.queryByText("Resets in 3h")).not.toBeInTheDocument(); }); - it("displays over-quota usage without overflowing the bar", async () => { + it("clamps over-quota usage to a full bar and 100% used", async () => { renderCard(provider(null, 115, { exhausted: true, resetDescription: "115% used" }), { showAsUsed: true, }); - expect(await screen.findAllByText("115% used")).not.toHaveLength(0); - const fill = document.querySelector(".menu-metric__bar-fill"); - expect(fill?.style.width).toBe("100%"); + expect(await screen.findByText("100% used")).toHaveClass("menu-metric__percent"); + expect(screen.queryByText("115% used")).not.toBeInTheDocument(); + const fill = document.querySelector(".menu-metric__progress-fill"); + expect(fill?.getAttribute("width")).toBe("100%"); }); it("replaces an exhausted percentage with a future reset countdown", async () => { @@ -343,7 +357,8 @@ describe("MenuCard", () => { renderCard(snapshot, { showResetWhenExhausted: true }); - expect(await screen.findByText(/Resets in \d+m/)).toBeInTheDocument(); + expect(await screen.findByText("Resets in 1h")).toHaveClass("menu-metric__reset"); + expect(document.querySelector(".menu-metric__percent")).toBeNull(); expect(screen.queryByText("0% left")).not.toBeInTheDocument(); }); @@ -493,7 +508,8 @@ describe("MenuCard", () => { const title = await screen.findByText("Requests"); expect(title.parentElement).not.toHaveTextContent("100% left"); - expect(title.parentElement?.querySelector(".menu-metric__bar")).toBeNull(); + expect(title.parentElement?.querySelector(".menu-metric__progress")).toBeNull(); + expect(document.querySelectorAll(".menu-metric__progress")).toHaveLength(1); expect(screen.getByText("7 requests")).toBeInTheDocument(); }); @@ -518,7 +534,8 @@ describe("MenuCard", () => { const title = await screen.findByText("Reset credits"); expect(screen.getByText("2 reset credits available")).toBeInTheDocument(); expect(screen.getByText(/Next expires in/)).toBeInTheDocument(); - expect(title.parentElement?.querySelector(".menu-metric__bar")).toBeNull(); + expect(title.parentElement?.querySelector(".menu-metric__progress")).toBeNull(); + expect(document.querySelectorAll(".menu-metric__progress")).toHaveLength(1); }); it("keeps reset credit count in absolute reset-time mode", async () => { @@ -710,14 +727,26 @@ describe("MenuCard", () => { etaSeconds: 90 * 60, willLastToReset: false, }; - snapshot.primary = rateWindow(31, { - windowMinutes: 7 * 24 * 60, - resetsAt: resetAt.toISOString(), - }); + snapshot.primary = { + ...rateWindow(31, { + windowMinutes: 7 * 24 * 60, + resetsAt: resetAt.toISOString(), + }), + pace: { + stage: "far_ahead", + deltaPercent: 20, + expectedUsedPercent: 11, + actualUsedPercent: 31, + etaSeconds: 90 * 60, + willLastToReset: false, + }, + }; const { container } = renderCard(snapshot, { showPace: false }); expect(await screen.findByText("69% left")).toBeInTheDocument(); + expect(container.querySelector(".menu-metric__meta")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric__progress-pace")).not.toBeInTheDocument(); expect(container.querySelector(".menu-card__pace")).not.toBeInTheDocument(); expect(screen.queryByText("On-pace budget")).not.toBeInTheDocument(); expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); @@ -733,12 +762,20 @@ describe("MenuCard", () => { windowMinutes: 5 * 60, resetsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), }); - snapshot.secondary = rateWindow(23, { - windowMinutes: 7 * 24 * 60, - resetsAt: resetAt.toISOString(), - reservePercent: 34, - reserveWillLastToReset: true, - }); + snapshot.secondary = { + ...rateWindow(23, { + windowMinutes: 7 * 24 * 60, + resetsAt: resetAt.toISOString(), + }), + pace: { + stage: "far_behind", + deltaPercent: -34, + expectedUsedPercent: 57, + actualUsedPercent: 23, + etaSeconds: null, + willLastToReset: true, + }, + }; snapshot.pace = { stage: "far_ahead", deltaPercent: 20, @@ -755,6 +792,8 @@ describe("MenuCard", () => { expect(container.querySelector(".menu-card__pace")).not.toBeInTheDocument(); expect(screen.queryByText("On-pace budget")).not.toBeInTheDocument(); expect(screen.queryByText(/in reserve/)).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric__meta")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric__progress-pace")).not.toBeInTheDocument(); expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); }); @@ -772,9 +811,15 @@ describe("MenuCard", () => { ...rateWindow(0, { windowMinutes: 7 * 24 * 60, resetsAt: shortReset, - reservePercent: 30, - reserveWillLastToReset: true, }), + pace: { + stage: "far_behind", + deltaPercent: -30, + expectedUsedPercent: 30, + actualUsedPercent: 0, + etaSeconds: null, + willLastToReset: true, + }, monthlyLimitBlock: block, }; snapshot.secondaryLabel = "Code 5-hour"; @@ -825,11 +870,13 @@ describe("MenuCard", () => { expect(blockedRows[0]).toHaveTextContent(/^Code 7-dayBlocked by monthly limit$/); expect(blockedRows[1]).toHaveTextContent(/^Code 5-hourBlocked by monthly limit$/); for (const row of blockedRows) { - expect(row.querySelector(".menu-metric__bar")).toBeNull(); - expect(row.querySelector(".menu-metric__pct")).toBeNull(); + expect(row.querySelector(".menu-metric__progress")).toBeNull(); + expect(row.querySelector(".menu-metric__percent")).toBeNull(); expect(row.querySelector(".menu-metric__reset")).toBeNull(); + expect(row.querySelector(".menu-metric__meta")).toBeNull(); } // The pool row keeps its own bar, percent, reset and exhausted label. + expect(container.querySelectorAll(".menu-metric__progress")).toHaveLength(1); expect(screen.getByText("Total usage")).toBeInTheDocument(); expect(screen.getAllByText("100% used")).toHaveLength(1); expect(screen.queryByText("0% used")).not.toBeInTheDocument(); @@ -1085,17 +1132,119 @@ describe("MenuCard", () => { ).not.toBeInTheDocument(); }); - it("keeps the reserve row when timing data is incomplete", async () => { + it("puts the percent after the title and the reset at the end of the same line", async () => { + const snapshot = provider(null, 37); + snapshot.primary.resetsAt = new Date(Date.now() + 90 * 60 * 1000).toISOString(); + + const { container } = renderCard(snapshot); + + expect(await screen.findByText("63% left")).toHaveClass("menu-metric__percent"); + const head = container.querySelector(".menu-metric__head"); + expect(Array.from(head?.children ?? [], (node) => node.textContent)).toEqual([ + "ProviderSessionLabel 63% left", + "Resets in 1h 30m", + ]); + }); + + it("shows a reserve as a green pace stripe and a pace line under the bar", async () => { const snapshot = provider(null, 20); - snapshot.primary = rateWindow(20, { - reservePercent: 12, - reserveWillLastToReset: true, - }); + snapshot.secondaryLabel = "Weekly"; + snapshot.secondary = { + ...rateWindow(40, { + windowMinutes: 7 * 24 * 60, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), + }), + pace: { + stage: "behind", + deltaPercent: -12, + expectedUsedPercent: 52, + actualUsedPercent: 40, + etaSeconds: null, + willLastToReset: true, + }, + }; - renderCard(snapshot); + const { container } = renderCard(snapshot); - expect(await screen.findByText("12% in reserve")).toBeInTheDocument(); - expect(screen.queryByText("On-pace budget")).not.toBeInTheDocument(); + expect(await screen.findByText("12% in reserve · Lasts until reset")).toHaveClass( + "menu-metric__meta", + ); + const stripes = container.querySelectorAll(".menu-metric__progress-pace"); + expect(stripes).toHaveLength(1); + expect(stripes[0].getAttribute("x")).toBe("48%"); + expect(stripes[0].getAttribute("data-deficit")).toBe("false"); + }); + + it("shows a deficit as a red pace stripe and says when the window runs out", async () => { + const snapshot = provider(null, 20); + snapshot.secondaryLabel = "Weekly"; + snapshot.secondary = { + ...rateWindow(40, { + windowMinutes: 7 * 24 * 60, + resetsAt: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(), + }), + pace: { + stage: "ahead", + deltaPercent: 9, + expectedUsedPercent: 31, + actualUsedPercent: 40, + etaSeconds: 2.5 * 60 * 60, + willLastToReset: false, + }, + }; + + const { container } = renderCard(snapshot, { showAsUsed: true }); + + expect(await screen.findByText("9% in deficit · Runs out in 2h 30m")).toHaveClass( + "menu-metric__meta", + ); + const stripe = container.querySelector(".menu-metric__progress-pace"); + expect(stripe?.getAttribute("x")).toBe("31%"); + expect(stripe?.getAttribute("data-deficit")).toBe("true"); + }); + + it.each([ + ["global thresholds on a % left bar", false, {}, ["5%", "20%"]], + ["global thresholds on a % used bar", true, {}, ["80%", "95%"]], + ["a provider session override", false, { "claude:session": { high: 60 } }, ["5%", "40%"]], + ])("draws quota warning markers from %s", async (_case, showAsUsed, overrides, expected) => { + const { container } = renderCard(provider(null, 30), { + showAsUsed, + usageThresholds: { + highUsageThreshold: 80, + criticalUsageThreshold: 95, + providerUsageThresholds: overrides, + }, + }); + + await screen.findByText(showAsUsed ? "30% used" : "70% left"); + expect( + Array.from(container.querySelectorAll(".menu-metric__progress-warning"), (node) => + node.getAttribute("x"), + ), + ).toEqual(expected); + expect(container.querySelector(".menu-metric__progress")?.getAttribute("aria-valuetext")).toBe( + `${showAsUsed ? 30 : 70}%. Quota warnings: ${expected.join(", ")}`, + ); + }); + + it("draws work day ticks on the weekly bar only", async () => { + const snapshot = provider(null, 20, {}); + snapshot.primary.windowMinutes = 5 * 60; + snapshot.secondaryLabel = "Weekly"; + snapshot.secondary = rateWindow(40, { windowMinutes: 7 * 24 * 60 }); + + const { container } = renderCard(snapshot, { weeklyProgressWorkDays: 5 }); + + await screen.findByText("60% left"); + const bars = container.querySelectorAll(".menu-metric__progress"); + expect(bars).toHaveLength(2); + expect(bars[0].querySelectorAll(".menu-metric__progress-workday")).toHaveLength(0); + expect( + Array.from(bars[1].querySelectorAll(".menu-metric__progress-workday"), (node) => + node.getAttribute("x"), + ), + ).toEqual(["20%", "40%", "60%", "80%"]); }); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index caf641622da..8e02537ff8f 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -20,6 +20,7 @@ import type { DeepSeekPricingStatus } from "../types/bridge"; import { isUsageItemVisible } from "../lib/usageItemVisibility"; import { localizeWindowLabel } from "../lib/windowLabels"; import { useMonthlyLimitBlockNow } from "../hooks/useMonthlyLimitBlockNow"; +import type { UsageThresholdSettings } from "../lib/metricRowModel"; /** Small copy-to-clipboard button matching macOS CopyIconButton (doc.on.doc → checkmark). */ function CopyIconButton({ text }: { text: string }) { @@ -60,6 +61,8 @@ export interface MenuCardDisplayOptions { */ compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; + usageThresholds?: UsageThresholdSettings | null; + weeklyProgressWorkDays?: number | null; } interface MenuCardProps { @@ -120,6 +123,8 @@ export default function MenuCard({ showAsUsed = false, compactOverview = false, costSummaryDisplayStyle, + usageThresholds = null, + weeklyProgressWorkDays = null, } = display; const { t, language } = useLocale(); const [chartData, setChartData] = useState(null); @@ -188,6 +193,7 @@ export default function MenuCard({ id: "primary", label: localizeWindowLabel(provider.primaryLabel, t, language, provider.primary.windowMinutes) || t("DetailWindowPrimary"), snap: provider.primary, + lane: "primary" as const, }, ]), ]; @@ -196,6 +202,7 @@ export default function MenuCard({ id: "secondary", label: localizeWindowLabel(provider.secondaryLabel, t) || t("DetailWindowSecondary"), snap: provider.secondary, + lane: "secondary", sessionEquivalentForecast: provider.sessionEquivalentForecast, }); if (provider.modelSpecific) @@ -203,6 +210,7 @@ export default function MenuCard({ id: "model-specific", label: t("DetailWindowModelSpecific"), snap: provider.modelSpecific, + lane: "tertiary", }); if (provider.tertiary) metrics.push({ @@ -211,6 +219,7 @@ export default function MenuCard({ // of the generic "DetailWindowTertiary" slot key when tertiaryLabel is set. label: localizeWindowLabel(provider.tertiaryLabel, t) || t("DetailWindowTertiary"), snap: provider.tertiary, + lane: "tertiary", }); for (const extra of provider.extraRateWindows ?? []) { metrics.push({ @@ -219,6 +228,7 @@ export default function MenuCard({ localizeWindowLabel(extra.title, t, language, extra.window.windowMinutes, extra.id) || extra.title, snap: extra.window, + lane: "extra", resetFormatMode: extra.id === "reset-credits" ? "expires" : "reset", }); } @@ -323,6 +333,8 @@ export default function MenuCard({ compactOverview, costSummaryDisplayStyle, monthlyLimitBlockNow, + usageThresholds, + weeklyProgressWorkDays, }} metrics={visibleMetrics} chartData={chartData} diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index a1a20bfa395..c643af05da3 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { CostSummaryDisplayStyle, DailyCostPoint, @@ -35,31 +35,18 @@ import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; import { isMonthlyLimitBlockActive } from "../lib/monthlyLimitBlock"; import { periodCostLabel, periodTokensLabel } from "../lib/costPeriod"; import { providerCostPeriodTitle } from "../lib/providerLabels"; -import { resetDescriptionFallback, windowDetailText } from "../lib/usageWindows"; +import { windowDetailText } from "../lib/usageWindows"; import { localizeProviderText } from "../lib/providerText"; import { localizeProviderLabel } from "../lib/windowLabels"; import { isDetailSectionVisible } from "../lib/usageItemVisibility"; import PaceDetailsChart from "./PaceDetailsChart"; - -/** Format a reserve description from raw pace data at render time. */ -function formatReserveDescription( - snap: RateWindowSnapshot, - t: (key: LocaleKey) => string, -): string | null { - if (snap.reservePercent == null) return null; - if (snap.reserveWillLastToReset) { - return t("PanelReserveLastsUntilReset"); - } - const eta = snap.reserveEtaSeconds; - if (eta == null) return null; - const h = Math.floor(eta / 3600); - if (h >= 24) { - return t("PanelReserveRunsOutInDaysHours") - .replace("{}", String(Math.floor(h / 24))) - .replace("{}", String(h % 24)); - } - return t("PanelReserveRunsOutInHours").replace("{}", String(h)); -} +import UsageProgressBar from "./UsageProgressBar"; +import { + metricResetText, + metricRowPresentation, + type MetricLane, + type UsageThresholdSettings, +} from "../lib/metricRowModel"; /** Upstream session-quota estimate: "Estimated: {n} session quota(s) left". */ function formatSessionEquivalentEstimate( @@ -297,43 +284,24 @@ function paceStageKey(stage: PaceSnapshot["stage"]): LocaleKey { } } -type UsageLevel = "normal" | "high" | "critical" | "exhausted"; const WEEKLY_WINDOW_MINUTES = 7 * 24 * 60; -function levelOf(remainPct: number, exhausted: boolean): UsageLevel { - if (exhausted) return "exhausted"; - if (remainPct <= 5) return "critical"; - if (remainPct <= 25) return "high"; - return "normal"; -} - export interface MetricEntry { id: string; label: string; snap: RateWindowSnapshot; + lane: MetricLane; resetFormatMode?: ResetTimeFormatMode; sessionEquivalentForecast?: SessionEquivalentForecastSnapshot | null; } -type MetricPaceView = - | { kind: "budget"; budget: PaceBudget } - | { kind: "reserve"; percent: number } - | { kind: "none" }; - -function getMetricPaceView(snap: RateWindowSnapshot): MetricPaceView { - if (snap.isExhausted) return { kind: "none" }; - +function metricPaceBudget(snap: RateWindowSnapshot): PaceBudget | null { + if (snap.isExhausted) return null; const isWeeklyWindow = snap.windowMinutes != null && snap.windowMinutes >= WEEKLY_WINDOW_MINUTES; - const budget = isWeeklyWindow ? getPaceBudget(snap) : null; - if (budget) return { kind: "budget", budget }; - - if (snap.reservePercent != null) { - return { kind: "reserve", percent: snap.reservePercent }; - } - - return { kind: "none" }; + return isWeeklyWindow ? getPaceBudget(snap) : null; } + type MetricRowDisplay = { resetTimeRelative: boolean; showResetWhenExhausted?: boolean; @@ -342,17 +310,25 @@ type MetricRowDisplay = { compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; monthlyLimitBlockNow?: number; + usageThresholds?: UsageThresholdSettings | null; + weeklyProgressWorkDays?: number | null; }; -/** - * Single metric row inside the card — mirrors upstream `MetricRow`: - * • title (body / medium) - * • UsageProgressBar (capsule, 6pt) - * • HStack: "N% used" · · reset countdown (right-aligned, secondary) - */ +function useCountdownNow(ticking: boolean): number { + const [, setTick] = useState(0); + useEffect(() => { + if (!ticking) return; + const id = window.setInterval(() => setTick((tick) => tick + 1), 30_000); + return () => window.clearInterval(id); + }, [ticking]); + return Date.now(); +} + function MetricRow({ title, snap, + lane, + providerId, exhaustedLabel, display, expanded, @@ -362,6 +338,8 @@ function MetricRow({ }: { title: string; snap: RateWindowSnapshot; + lane: MetricLane; + providerId: string; exhaustedLabel: string; display: MetricRowDisplay; expanded: boolean; @@ -377,6 +355,8 @@ function MetricRow({ showAsUsed = false, compactOverview = false, monthlyLimitBlockNow, + usageThresholds = null, + weeklyProgressWorkDays = null, } = display; // Upstream 0.69.0 #4091: a longer exhausted pool (Kimi's monthly membership) // blocks this window until the pool resets. Raw percentages stay untouched. @@ -385,36 +365,18 @@ function MetricRow({ monthlyLimitBlockNow ?? Date.now(), ); const isInformational = snap.isInformational === true; - const usedPct = Number.isFinite(snap.usedPercent) ? Math.max(0, snap.usedPercent) : 0; - const barPct = Math.min(100, usedPct); - const remain = 100 - usedPct; - const displayPct = showAsUsed ? usedPct : Math.max(0, remain); - const barDisplayPct = showAsUsed ? barPct : Math.max(0, Math.min(100, remain)); - const displayLabel = showAsUsed ? t("PanelUsedSuffix") : t("PanelLeftSuffix"); - const level = levelOf(remain, snap.isExhausted); - const detailText = localizeProviderText(windowDetailText(snap), t) || null; - const resetText = useFormattedResetTime( - blocked ? null : snap.resetsAt, - isInformational || blocked ? null : resetDescriptionFallback(snap), + const informationalReset = useFormattedResetTime( + isInformational && !blocked ? snap.resetsAt : null, + null, resetTimeRelative, resetFormatMode ?? "reset", ); - const infoPrimary = - localizeProviderText(snap.resetDescription?.trim(), t) || resetText || "—"; - const resetTarget = snap.resetsAt ? Date.parse(snap.resetsAt) : Number.NaN; - const replacesPercent = - showResetWhenExhausted && - snap.isExhausted && - Number.isFinite(resetTarget) && - resetTarget > Date.now() && - resetText !== null; - const paceView = showPace ? getMetricPaceView(snap) : { kind: "none" as const }; - const reserveDescription = formatReserveDescription(snap, t); - const forecastText = formatSessionEquivalentEstimate(sessionEquivalentForecast); + const now = useCountdownNow(!isInformational && !blocked && snap.resetsAt != null); + const detailText = localizeProviderText(windowDetailText(snap), t) || null; if (blocked) { // Upstream `MetricRow` status layout: title plus one secondary status line, - // no bar, percent, reset, pace, reserve, or forecast. The pool's own row - // keeps its reset; the shorter resets cannot restore access. + // no bar, percent, reset, pace, or forecast. The pool's own row keeps its + // reset; the shorter resets cannot restore access. return (
{title} @@ -422,39 +384,68 @@ function MetricRow({
); } + if (isInformational) { + const infoPrimary = + localizeProviderText(snap.resetDescription?.trim(), t) || informationalReset || "—"; + return ( +
+ {title} +
+ {infoPrimary} + {!compactOverview && + snap.resetDescription?.trim() && + informationalReset && + informationalReset !== infoPrimary && ( + {informationalReset} + )} +
+ {!compactOverview && detailText && ( +
{detailText}
+ )} +
+ ); + } + const row = metricRowPresentation( + { + snap, + lane, + providerId, + resetText: metricResetText(snap, resetTimeRelative, now, t), + compact: compactOverview, + showAsUsed, + showResetWhenExhausted, + paceEnabled: showPace, + usageThresholds, + weeklyProgressWorkDays, + now, + }, + t, + ); + const budget = showPace && !compactOverview ? metricPaceBudget(snap) : null; + const forecastText = formatSessionEquivalentEstimate(sessionEquivalentForecast); return (
- {title} - {!isInformational && ( -
-
-
- )} -
- - {isInformational - ? infoPrimary - : replacesPercent - ? resetText - : `${Math.round(displayPct)}% ${displayLabel}`} - - {!compactOverview && isInformational && - snap.resetDescription?.trim() && - resetText && - resetText !== infoPrimary && ( - {resetText} +
+ + {title} + {row.percentText && ( + <> + {" "} + {row.percentText} + )} - {!compactOverview && !isInformational && resetText && !replacesPercent && ( - {resetText} - )} + + {row.resetText && {row.resetText}}
+ + {row.metaText &&
{row.metaText}
} {!compactOverview && detailText && (
{detailText}
)} - {!compactOverview && !isInformational && snap.isExhausted && ( + {!compactOverview && snap.isExhausted && (
{exhaustedLabel}
)} - {!compactOverview && !isInformational && paceView.kind === "budget" && ( + {budget && (
{expanded &&
{[ - [t("PanelNow"), paceView.budget.now], - [t("PanelOneHour"), paceView.budget.nextHour], - [t("PanelFiveHours"), paceView.budget.nextFiveHours], - [t("PanelTodayBudget"), paceView.budget.today], + [t("PanelNow"), budget.now], + [t("PanelOneHour"), budget.nextHour], + [t("PanelFiveHours"), budget.nextFiveHours], + [t("PanelTodayBudget"), budget.today], ].map(([label, value]) => ( {label} {formatBudget(Number(value))}% @@ -480,15 +470,7 @@ function MetricRow({ {expanded && }
)} - {!compactOverview && !isInformational && paceView.kind === "reserve" && ( -
- {Math.round(paceView.percent)}% {t("PanelReserveSuffix")} - {reserveDescription && ( - {reserveDescription} - )} -
- )} - {!compactOverview && showPace && !isInformational && forecastText && ( + {!compactOverview && showPace && forecastText && (
{forecastText}
@@ -663,6 +645,8 @@ export default function MenuCardDetails({ key={m.id} title={m.label} snap={m.snap} + lane={m.lane} + providerId={provider.providerId} exhaustedLabel={t("DetailWindowExhausted")} display={metricDisplay} expanded={expandedPaceWindow === m.id} diff --git a/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx b/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx new file mode 100644 index 00000000000..a2fd5b5e4c4 --- /dev/null +++ b/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx @@ -0,0 +1,143 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { UsageBarModel } from "../lib/metricRowModel"; +import { loadStyles, ruleBlock } from "../test/styles"; +import UsageProgressBar from "./UsageProgressBar"; + +function bar(overrides: Partial = {}): UsageBarModel { + return { + fillPercent: 63, + valuePercent: 63, + valueText: "63%", + pacePercent: null, + paceDeficit: false, + markers: [], + ...overrides, + }; +} + +function attrs(container: HTMLElement, selector: string, name: string): Array { + return Array.from(container.querySelectorAll(selector), (node) => node.getAttribute(name)); +} + +function maskCutsOf(container: HTMLElement, group: Element | null) { + const id = group?.getAttribute("mask")?.match(/^url\(#(.+)\)$/)?.[1]; + const mask = id ? container.querySelector(`mask[id="${id}"]`) : null; + return Array.from(mask?.querySelectorAll("rect[fill='black']") ?? [], (rect) => ({ + x: rect.getAttribute("x"), + width: rect.getAttribute("width"), + opacity: rect.getAttribute("fill-opacity"), + })); +} + +describe("UsageProgressBar", () => { + it("exposes the value and markers as an accessible progress bar", () => { + render( + , + ); + const progress = screen.getByRole("progressbar", { name: "Session" }); + expect(progress.getAttribute("aria-valuenow")).toBe("63"); + expect(progress.getAttribute("aria-valuemin")).toBe("0"); + expect(progress.getAttribute("aria-valuemax")).toBe("100"); + expect(progress.getAttribute("aria-valuetext")).toBe("63%. Quota warnings: 10%, 30%"); + expect(progress.getAttribute("height")).toBe("6"); + }); + + it("draws a full rounded track and a fill as wide as the shown percent", () => { + const { container } = render(); + expect(attrs(container, ".menu-metric__progress-track", "width")).toEqual(["100%"]); + expect(attrs(container, ".menu-metric__progress-track", "rx")).toEqual(["3"]); + expect(attrs(container, ".menu-metric__progress-fill", "width")).toEqual(["37.5%"]); + expect(attrs(container, ".menu-metric__progress-fill", "rx")).toEqual(["3"]); + }); + + it("leaves an empty bar without a fill or masks", () => { + const { container } = render(); + expect(container.querySelector(".menu-metric__progress-fill")).toBeNull(); + expect(container.querySelector("mask")).toBeNull(); + expect(container.querySelector("[mask]")).toBeNull(); + }); + + it("punches 5px gaps for quota warnings and draws a 1px stripe in each", () => { + const { container } = render( + , + ); + expect(attrs(container, ".menu-metric__progress-warning", "x")).toEqual(["10%", "30%"]); + expect(attrs(container, ".menu-metric__progress-warning", "width")).toEqual(["1", "1"]); + expect(attrs(container, ".menu-metric__progress-warning", "transform")).toEqual([ + "translate(-0.5 0)", + "translate(-0.5 0)", + ]); + const fillGroup = container.querySelector(".menu-metric__progress-fill")?.parentElement ?? null; + expect(maskCutsOf(container, fillGroup)).toEqual([ + { x: "10%", width: "5", opacity: "0.9" }, + { x: "30%", width: "5", opacity: "0.9" }, + ]); + }); + + it("draws work day ticks in the lower half without punching the bar", () => { + const { container } = render( + , + ); + expect(attrs(container, ".menu-metric__progress-workday", "x")).toEqual(["20%", "40%"]); + expect(attrs(container, ".menu-metric__progress-workday", "y")).toEqual(["3", "3"]); + expect(attrs(container, ".menu-metric__progress-workday", "height")).toEqual(["3", "3"]); + expect(container.querySelector("mask")).toBeNull(); + }); + + it.each([ + [false, "false"], + [true, "true"], + ])("cuts a 6px gap for the pace stripe and marks paceDeficit %s as deficit %s", (paceDeficit, deficit) => { + const { container } = render( + , + ); + const stripe = container.querySelector(".menu-metric__progress-pace"); + expect(stripe?.getAttribute("x")).toBe("40%"); + expect(stripe?.getAttribute("width")).toBe("2"); + expect(stripe?.getAttribute("transform")).toBe("translate(-1 0)"); + expect(stripe?.getAttribute("data-deficit")).toBe(deficit); + const tick = container.querySelector(".menu-metric__progress-workday"); + const pacedGroup = tick?.parentElement ?? null; + expect(pacedGroup?.contains(container.querySelector(".menu-metric__progress-track"))).toBe(true); + expect(pacedGroup?.contains(stripe ?? null)).toBe(false); + expect(maskCutsOf(container, pacedGroup)).toEqual([{ x: "40%", width: "6", opacity: "0.9" }]); + }); + + it("colors the bar from the panel tokens and the pace stripe by direction", () => { + const css = loadStyles(); + expect(ruleBlock(css, ".menu-metric__progress-track")).toContain("fill: var(--usage-bar-track)"); + expect(ruleBlock(css, ".menu-metric__progress-fill")).toContain( + "fill: var(--provider-accent, var(--usage-bar-normal))", + ); + expect(ruleBlock(css, ".menu-metric__progress-warning")).toContain( + "fill: color-mix(in srgb, var(--text-primary) 68%, transparent)", + ); + expect(ruleBlock(css, ".menu-metric__progress-workday")).toContain( + "fill: color-mix(in srgb, var(--text-primary) 30%, transparent)", + ); + expect(ruleBlock(css, ".menu-metric__progress-pace")).toContain("fill: var(--mac-pace-ok, #34c759)"); + expect(ruleBlock(css, '.menu-metric__progress-pace[data-deficit="true"]')).toContain( + "fill: var(--mac-pace-bad, #ff3b30)", + ); + }); +}); diff --git a/apps/desktop-tauri/src/components/UsageProgressBar.tsx b/apps/desktop-tauri/src/components/UsageProgressBar.tsx new file mode 100644 index 00000000000..a541325ed8d --- /dev/null +++ b/apps/desktop-tauri/src/components/UsageProgressBar.tsx @@ -0,0 +1,105 @@ +import { useId } from "react"; +import type { UsageBarModel } from "../lib/metricRowModel"; + +const HEIGHT = 6; +const RADIUS = 3; +const CUT_OPACITY = 0.9; +const WARNING_CUT = 5; +const WARNING_STRIPE = 1; +const WORKDAY_TICK = 1; +const PACE_CUT = 6; +const PACE_STRIPE = 2; + +function centered(percent: number, width: number) { + return { x: `${percent}%`, width, transform: `translate(${-width / 2} 0)` }; +} + +function CutMask({ id, cuts, width }: { id: string; cuts: number[]; width: number }) { + return ( + + + {cuts.map((percent) => ( + + ))} + + ); +} + +export default function UsageProgressBar({ bar, label }: { bar: UsageBarModel; label: string }) { + const id = useId().replace(/:/g, ""); + const warnings = bar.markers.filter((marker) => marker.kind === "warning").map((marker) => marker.percent); + const workdays = bar.markers.filter((marker) => marker.kind === "workday").map((marker) => marker.percent); + const pace = bar.pacePercent; + return ( + + {warnings.length > 0 && } + {pace != null && } + + 0 ? `url(#${id}-warnings)` : undefined}> + + {bar.fillPercent > 0 && ( + + )} + + {warnings.map((percent) => ( + + ))} + {workdays.map((percent) => ( + + ))} + + {pace != null && ( + + )} + + ); +} diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx index f4578071b9c..247792fc619 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -349,8 +349,11 @@ describe("useTrayPanelLayout sizing", () => { timeout: 3000, }); + // A follow-up pass can lift the cap while it measures; wait for it to settle. + await waitFor(() => expect(surface.style.maxHeight).toBe("400px"), { + timeout: 3000, + }); expect(windowMocks.setSize.mock.calls[0][0]).toEqual({ width: 465, height: 200 }); expect(lastResize()).toEqual({ width: 465, height: 600 }); - expect(surface.style.maxHeight).toBe("400px"); }); }); diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 647f1bb1359..bca1f5588cb 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -240,11 +240,22 @@ export const ALL_LOCALE_KEYS = [ "ActionBuyCredits", "PaceOnTrack", "PaceBehind", + "PaceOnPace", + "PaceInReserve", + "PaceInDeficit", + "PaceLastsUntilReset", + "PaceRunsOutIn", + "PaceRunsOutNow", + "PaceProjectedEmptyIn", + "PaceProjectedEmptyNow", + "UsageBarQuotaWarnings", + "UsageBarWorkDays", "MetricResetsIn", "SectionUsageBreakdown", "SectionCost", "ResetInProgress", "TomorrowAt", + "ResetTomorrowAt", "UsedPercent", "RemainingPercent", "RemainingAmount", @@ -297,6 +308,7 @@ export const ALL_LOCALE_KEYS = [ "ResetsInDaysHours", "ResetsInHoursMinutes", "ResetsInMinutes", + "ResetsInDaysMinutes", "NextExpiresInDaysHours", "NextExpiresInHoursMinutes", "NextExpiresInMinutes", @@ -807,11 +819,7 @@ export const ALL_LOCALE_KEYS = [ "PanelOneHour", "PanelFiveHours", "PanelTodayBudget", - "PanelReserveSuffix", "PanelBlockedByMonthlyLimit", - "PanelReserveLastsUntilReset", - "PanelReserveRunsOutInDaysHours", - "PanelReserveRunsOutInHours", "FloatBarNoProviders", "FloatBarRemainingSuffix", "FloatBarShowCost", @@ -1457,6 +1465,7 @@ export const ALL_LOCALE_KEYS = [ "ProviderTextExpiresIn", "ProviderTextCycleEndsIn", "DurationDaysHours", + "DurationDaysMinutes", "DurationHoursMinutes", "DurationDays", "DurationHours", diff --git a/apps/desktop-tauri/src/lib/metricRowModel.test.ts b/apps/desktop-tauri/src/lib/metricRowModel.test.ts new file mode 100644 index 00000000000..abfccaefe0d --- /dev/null +++ b/apps/desktop-tauri/src/lib/metricRowModel.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from "vitest"; +import { fromFtl } from "../test/localeHarness"; +import type { RateWindowSnapshot, WindowPaceSnapshot } from "../types/bridge"; +import { + metricResetText, + metricRowPresentation, + type MetricRowInput, +} from "./metricRowModel"; + +const en = fromFtl("en-US.ftl"); +const NOW = new Date(2026, 9, 10, 15, 40).getTime(); +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +function rateWindow(overrides: Partial = {}): RateWindowSnapshot { + return { + usedPercent: 37, + remainingPercent: 63, + windowMinutes: 300, + resetsAt: null, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + ...overrides, + }; +} + +function pace(overrides: Partial = {}): WindowPaceSnapshot { + return { + stage: "behind", + deltaPercent: -23.4, + expectedUsedPercent: 60, + actualUsedPercent: 36.6, + etaSeconds: null, + willLastToReset: true, + ...overrides, + }; +} + +function input(overrides: Partial = {}): MetricRowInput { + return { + snap: rateWindow(), + lane: "primary", + providerId: "claude", + resetText: null, + compact: false, + showAsUsed: false, + showResetWhenExhausted: false, + paceEnabled: true, + usageThresholds: { highUsageThreshold: 70, criticalUsageThreshold: 90, providerUsageThresholds: {} }, + weeklyProgressWorkDays: null, + now: NOW, + ...overrides, + }; +} + +function thresholds(highUsageThreshold: number, criticalUsageThreshold = 90) { + return { highUsageThreshold, criticalUsageThreshold }; +} + +function weekly(overrides: Partial = {}): RateWindowSnapshot { + return rateWindow({ usedPercent: 36.6, remainingPercent: 63.4, windowMinutes: 10080, pace: pace(), ...overrides }); +} + +function resetIn(ms: number, relative = true) { + const snap = rateWindow({ resetsAt: new Date(NOW + ms).toISOString() }); + return metricResetText(snap, relative, NOW, en)?.replace(/\u202f/g, " "); +} + +describe("metricResetText", () => { + it.each([ + [2 * HOUR, "Resets in 2h"], + [HOUR + 59 * MINUTE + 30_000, "Resets in 2h"], + [2 * HOUR + 5 * MINUTE, "Resets in 2h 5m"], + [DAY + 3 * HOUR, "Resets in 1d 3h"], + [DAY + 5 * MINUTE, "Resets in 1d 5m"], + [2 * DAY, "Resets in 2d"], + [45_000, "Resets in 1m"], + [500, "Resets now"], + [-MINUTE, "Resets now"], + ])("counts %d ms down to %j with ceiling minutes", (ms, expected) => { + expect(resetIn(ms)).toBe(expected); + }); + + it.each([ + [new Date(2026, 9, 10, 18, 5), "Resets 6:05 PM"], + [new Date(2026, 9, 11, 9, 24), "Resets tomorrow, 9:24 AM"], + [new Date(2026, 9, 14, 9, 24), "Resets Oct 14, 9:24 AM"], + ])("formats the absolute reset %s as today, tomorrow or a date", (date, expected) => { + expect(resetIn(date.getTime() - NOW, false)).toBe(expected); + }); + + it("falls back to the provider reset description without a timestamp", () => { + expect(metricResetText(rateWindow({ resetDescription: "resets in 3h" }), true, NOW, en)).toBe( + "Resets in 3h", + ); + expect( + metricResetText( + rateWindow({ resetDescription: "750 / 1000 credits left", descriptionIsDetail: true }), + true, + NOW, + en, + ), + ).toBeNull(); + expect(metricResetText(rateWindow({ resetsAt: "not a date" }), true, NOW, en)).toBeNull(); + }); +}); + +describe("metricRowPresentation percent and reset", () => { + it.each([ + [37, false, "63% left"], + [37, true, "37% used"], + [37.5, false, "62% left"], + [36.5, false, "64% left"], + [99.6, false, "<1% left"], + [0.4, true, "<1% used"], + [115, true, "100% used"], + [115, false, "0% left"], + [-4, false, "100% left"], + ])("labels %d%% used with showAsUsed %s as %j", (used, showAsUsed, expected) => { + const row = metricRowPresentation( + input({ snap: rateWindow({ usedPercent: used, remainingPercent: 100 - used }), showAsUsed }), + en, + ); + expect(row.percentText).toBe(expected); + }); + + it("keeps the reset text beside the title and drops it in compact rows", () => { + expect(metricRowPresentation(input({ resetText: "Resets in 2h" }), en).resetText).toBe("Resets in 2h"); + expect( + metricRowPresentation(input({ resetText: "Resets in 2h", compact: true }), en).resetText, + ).toBeNull(); + }); + + it("replaces an exhausted percentage with a future reset, even in compact rows", () => { + const snap = rateWindow({ + usedPercent: 100, + remainingPercent: 0, + isExhausted: true, + resetsAt: new Date(NOW + HOUR).toISOString(), + }); + const row = metricRowPresentation( + input({ snap, resetText: "Resets in 1h", showResetWhenExhausted: true, compact: true }), + en, + ); + expect(row.percentText).toBeNull(); + expect(row.resetText).toBe("Resets in 1h"); + }); + + it("keeps the exhausted percentage when the reset already passed", () => { + const snap = rateWindow({ + usedPercent: 100, + remainingPercent: 0, + isExhausted: true, + resetsAt: new Date(NOW - HOUR).toISOString(), + }); + const row = metricRowPresentation( + input({ snap, resetText: "Resets now", showResetWhenExhausted: true }), + en, + ); + expect(row.percentText).toBe("0% left"); + }); +}); + +describe("metricRowPresentation pace", () => { + it("shows reserve and the lasts-until-reset note on a weekly window", () => { + const row = metricRowPresentation(input({ lane: "secondary", snap: weekly() }), en); + expect(row.metaText).toBe("23% in reserve · Lasts until reset"); + expect(row.bar.pacePercent).toBe(40); + expect(row.bar.paceDeficit).toBe(false); + }); + + it("shows a weekly deficit with its run-out countdown", () => { + const snap = weekly({ + usedPercent: 52.6, + remainingPercent: 47.4, + pace: pace({ + stage: "ahead", + deltaPercent: 12.6, + expectedUsedPercent: 40, + actualUsedPercent: 52.6, + etaSeconds: 2 * 86_400 + 5 * 3600, + willLastToReset: false, + }), + }); + const row = metricRowPresentation(input({ lane: "secondary", snap, showAsUsed: true }), en); + expect(row.metaText).toBe("13% in deficit · Runs out in 2d 5h"); + expect(row.bar.pacePercent).toBe(40); + expect(row.bar.paceDeficit).toBe(true); + }); + + it("projects a session window empty instead of running out", () => { + const snap = rateWindow({ + usedPercent: 54, + remainingPercent: 46, + pace: pace({ + stage: "slightly_ahead", + deltaPercent: 4, + expectedUsedPercent: 50, + actualUsedPercent: 54, + etaSeconds: 5400, + willLastToReset: false, + }), + }); + expect(metricRowPresentation(input({ snap }), en).metaText).toBe( + "4% in deficit · Projected empty in 1h 30m", + ); + const now = { ...snap, pace: { ...snap.pace!, etaSeconds: 0 } }; + expect(metricRowPresentation(input({ snap: now }), en).metaText).toBe( + "4% in deficit · Projected empty now", + ); + }); + + it("calls a rounded zero delta or an on-track stage on pace without a pace marker", () => { + const tiny = metricRowPresentation( + input({ lane: "secondary", snap: weekly({ pace: pace({ deltaPercent: -0.4 }) }) }), + en, + ); + expect(tiny.metaText).toBe("On pace · Lasts until reset"); + const onTrack = metricRowPresentation( + input({ + lane: "secondary", + snap: weekly({ pace: pace({ stage: "on_track", deltaPercent: 3, willLastToReset: false }) }), + }), + en, + ); + expect(onTrack.metaText).toBe("On pace"); + expect(onTrack.bar.pacePercent).toBeNull(); + }); + + it("runs a weekly window out now even below the 3% expected floor", () => { + const snap = weekly({ + pace: pace({ stage: "far_ahead", deltaPercent: 40, expectedUsedPercent: 2, etaSeconds: 0, willLastToReset: false }), + }); + expect(metricRowPresentation(input({ lane: "secondary", snap }), en).metaText).toBe( + "40% in deficit · Runs out now", + ); + const later = weekly({ + pace: pace({ stage: "far_ahead", deltaPercent: 40, expectedUsedPercent: 2, etaSeconds: 3600, willLastToReset: false }), + }); + expect(metricRowPresentation(input({ lane: "secondary", snap: later }), en).metaText).toBeNull(); + }); + + it.each<[string, Partial]>([ + ["pace is turned off", { lane: "secondary", snap: weekly(), paceEnabled: false }], + ["the window is used up", { lane: "secondary", snap: weekly({ usedPercent: 100, remainingPercent: 0 }) }], + ["a session expects under 3%", { snap: rateWindow({ pace: pace({ expectedUsedPercent: 2 }) }) }], + ["the lane is tertiary", { lane: "tertiary", snap: weekly() }], + ["an extra lane is not 5h or 7d", { lane: "extra", snap: weekly({ windowMinutes: 1440 }) }], + ["the primary window length is unknown", { snap: rateWindow({ windowMinutes: null, pace: pace() }) }], + ["the window has no pace", { lane: "secondary", snap: weekly({ pace: null }) }], + ])("hides pace when %s", (_, overrides) => { + const row = metricRowPresentation(input(overrides), en); + expect(row.metaText).toBeNull(); + expect(row.bar.pacePercent).toBeNull(); + }); + + it("shows pace on extra lanes that are exactly a session or a week", () => { + const row = metricRowPresentation(input({ lane: "extra", snap: weekly() }), en); + expect(row.metaText).toBe("23% in reserve · Lasts until reset"); + }); + + it("hides the pace line in compact rows but keeps the bar marker", () => { + const row = metricRowPresentation(input({ lane: "secondary", snap: weekly(), compact: true }), en); + expect(row.metaText).toBeNull(); + expect(row.bar.pacePercent).toBe(40); + }); +}); + +describe("metricRowPresentation bar", () => { + it.each([ + [37, false, 63], + [37, true, 37], + [99.6, false, 0], + [0.3, false, 100], + [115, true, 100], + [115, false, 0], + [62.4, false, 37.6], + ])("fills %d%% used with showAsUsed %s to %d%%", (used, showAsUsed, fill) => { + const row = metricRowPresentation( + input({ snap: rateWindow({ usedPercent: used, remainingPercent: 100 - used }), showAsUsed }), + en, + ); + expect(row.bar.fillPercent).toBeCloseTo(fill, 9); + }); + + it("places the Windows usage thresholds as quota warning markers", () => { + expect(metricRowPresentation(input(), en).bar.markers).toEqual([ + { percent: 10, kind: "warning" }, + { percent: 30, kind: "warning" }, + ]); + expect(metricRowPresentation(input({ showAsUsed: true }), en).bar.markers).toEqual([ + { percent: 70, kind: "warning" }, + { percent: 90, kind: "warning" }, + ]); + }); + + it("resolves window, provider and global thresholds per field", () => { + const providerUsageThresholds = { + claude: { high: 75 }, + "claude:weekly": { critical: 95 }, + codex: { high: 50, critical: 60 }, + }; + const usageThresholds = { ...thresholds(70), providerUsageThresholds }; + const weeklyRow = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), showAsUsed: true, usageThresholds }), + en, + ); + expect(weeklyRow.bar.markers.map((m) => m.percent)).toEqual([75, 95]); + const sessionRow = metricRowPresentation(input({ showAsUsed: true, usageThresholds }), en); + expect(sessionRow.bar.markers.map((m) => m.percent)).toEqual([75, 90]); + const tertiaryRow = metricRowPresentation( + input({ lane: "tertiary", snap: weekly(), showAsUsed: true, usageThresholds }), + en, + ); + expect(tertiaryRow.bar.markers.map((m) => m.percent)).toEqual([75, 95]); + }); + + it("drops edge and duplicate thresholds and never marks extra lanes", () => { + expect(metricRowPresentation(input({ usageThresholds: thresholds(0, 100) }), en).bar.markers).toEqual([]); + expect(metricRowPresentation(input({ usageThresholds: thresholds(80, 80) }), en).bar.markers).toEqual([ + { percent: 20, kind: "warning" }, + ]); + expect(metricRowPresentation(input({ lane: "extra", snap: weekly() }), en).bar.markers).toEqual([]); + }); + + it("draws no warning markers without threshold settings", () => { + expect(metricRowPresentation(input({ usageThresholds: null }), en).bar.markers).toEqual([]); + }); + + it("adds work day ticks to a seven-day secondary window only", () => { + const row = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), weeklyProgressWorkDays: 5, usageThresholds: thresholds(40) }), + en, + ); + expect(row.bar.markers).toEqual([ + { percent: 10, kind: "warning" }, + { percent: 20, kind: "workday" }, + { percent: 40, kind: "workday" }, + { percent: 60, kind: "warning" }, + { percent: 80, kind: "workday" }, + ]); + for (const overrides of [ + { lane: "primary" as const, snap: weekly() }, + { lane: "secondary" as const, snap: weekly({ windowMinutes: 1440 }) }, + { lane: "secondary" as const, snap: weekly(), weeklyProgressWorkDays: 1 }, + { lane: "secondary" as const, snap: weekly(), weeklyProgressWorkDays: 8 }, + { lane: "secondary" as const, snap: weekly(), weeklyProgressWorkDays: null }, + ]) { + const kinds = metricRowPresentation(input({ weeklyProgressWorkDays: 5, ...overrides }), en).bar.markers.map( + (m) => m.kind, + ); + expect(kinds).toEqual(["warning", "warning"]); + } + }); + + it("describes the value and markers for assistive technology", () => { + expect(metricRowPresentation(input(), en).bar).toMatchObject({ + valuePercent: 63, + valueText: "63%. Quota warnings: 10%, 30%", + }); + const row = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), weeklyProgressWorkDays: 3, showAsUsed: true }), + en, + ); + expect(row.bar.valuePercent).toBe(37); + expect(row.bar.valueText).toBe("37%. Quota warnings: 70%, 90%. Work days: 33%, 67%"); + expect( + metricRowPresentation(input({ lane: "extra", snap: weekly({ pace: null }) }), en).bar.valueText, + ).toBe("63%"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts new file mode 100644 index 00000000000..a2bbeda95b5 --- /dev/null +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -0,0 +1,338 @@ +import { normalizeResetDescription } from "../hooks/useFormattedResetTime"; +import type { LocaleKey } from "../i18n/keys"; +import type { + PaceStage, + RateWindowSnapshot, + SettingsSnapshot, + WindowPaceSnapshot, +} from "../types/bridge"; +import { resetDescriptionFallback } from "./usageWindows"; + +type Translate = (key: LocaleKey) => string; + +export type MetricLane = "primary" | "secondary" | "tertiary" | "extra"; + +type PaceKind = "session" | "weekly"; + +export type UsageThresholdSettings = Pick< + SettingsSnapshot, + "highUsageThreshold" | "criticalUsageThreshold" | "providerUsageThresholds" +>; + +interface LanePolicy { + thresholdWindow: "session" | "weekly" | null; + workdayTicks: boolean; + pace: "byWindowLength" | "exactWindow" | "never"; +} + +const SESSION_MINUTES = 300; +const WEEK_MINUTES = 7 * 24 * 60; + +const LANE_POLICY: Record = { + primary: { thresholdWindow: "session", workdayTicks: false, pace: "byWindowLength" }, + secondary: { thresholdWindow: "weekly", workdayTicks: true, pace: "byWindowLength" }, + tertiary: { thresholdWindow: "weekly", workdayTicks: false, pace: "never" }, + extra: { thresholdWindow: null, workdayTicks: false, pace: "exactWindow" }, +}; + +const PACE_DIRECTION: Record = { + on_track: "onPace", + slightly_ahead: "deficit", + ahead: "deficit", + far_ahead: "deficit", + slightly_behind: "reserve", + behind: "reserve", + far_behind: "reserve", +}; + +interface CountdownKeys { + daysHours: LocaleKey; + daysMinutes: LocaleKey; + days: LocaleKey; + hoursMinutes: LocaleKey; + hours: LocaleKey; + minutes: LocaleKey; +} + +const RESET_KEYS: CountdownKeys = { + daysHours: "ResetsInDaysHours", + daysMinutes: "ResetsInDaysMinutes", + days: "ResetsInDaysOnly", + hoursMinutes: "ResetsInHoursMinutes", + hours: "ResetsInHoursOnly", + minutes: "ResetsInMinutes", +}; + +const DURATION_KEYS: CountdownKeys = { + daysHours: "DurationDaysHours", + daysMinutes: "DurationDaysMinutes", + days: "DurationDays", + hoursMinutes: "DurationHoursMinutes", + hours: "DurationHours", + minutes: "DurationMinutes", +}; + +export interface MetricRowInput { + snap: RateWindowSnapshot; + lane: MetricLane; + providerId: string; + resetText: string | null; + compact: boolean; + showAsUsed: boolean; + showResetWhenExhausted: boolean; + paceEnabled: boolean; + usageThresholds: UsageThresholdSettings | null; + weeklyProgressWorkDays: number | null; + now: number; +} + +export interface UsageBarMarker { + percent: number; + kind: "warning" | "workday"; +} + +export interface UsageBarModel { + fillPercent: number; + valuePercent: number; + valueText: string; + pacePercent: number | null; + paceDeficit: boolean; + markers: UsageBarMarker[]; +} + +export interface MetricRowPresentation { + percentText: string | null; + resetText: string | null; + metaText: string | null; + bar: UsageBarModel; +} + +function fill(template: string, ...values: Array): string { + return values.reduce((text, value) => text.replace("{}", String(value)), template); +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +/** `%.0f` rounds exact halves to even. */ +function roundHalfEven(value: number): number { + const rounded = Math.round(value); + return Math.abs(value % 1) === 0.5 && rounded % 2 !== 0 ? rounded - 1 : rounded; +} + +function percentLabel(percent: number, suffix: string): string { + if (percent > 0 && percent < 1) return `<1% ${suffix}`; + return `${roundHalfEven(percent)}% ${suffix}`; +} + +function countdownParts(seconds: number) { + if (seconds < 1) return null; + const total = Math.max(1, Math.ceil(seconds / 60)); + return { days: Math.floor(total / 1440), hours: Math.floor(total / 60) % 24, minutes: total % 60 }; +} + +function formatCountdown(seconds: number, keys: CountdownKeys, t: Translate): string | null { + const parts = countdownParts(seconds); + if (!parts) return null; + const { days, hours, minutes } = parts; + if (days > 0) { + if (hours > 0) return fill(t(keys.daysHours), days, hours); + if (minutes > 0) return fill(t(keys.daysMinutes), days, minutes); + return fill(t(keys.days), days); + } + if (hours > 0) { + return minutes > 0 ? fill(t(keys.hoursMinutes), hours, minutes) : fill(t(keys.hours), hours); + } + return fill(t(keys.minutes), minutes); +} + +function intlLocale(t: Translate): string | undefined { + const locale = t("IntlLocale"); + return /^[a-z]{2}-[A-Z]{2}$/.test(locale) ? locale : undefined; +} + +function sameLocalDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() + ); +} + +function absoluteResetDescription(target: Date, now: Date, t: Translate): string { + const locale = intlLocale(t); + const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }); + if (sameLocalDay(target, now)) return time.format(target); + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + if (sameLocalDay(target, tomorrow)) return fill(t("ResetTomorrowAt"), time.format(target)); + return new Intl.DateTimeFormat(locale, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(target); +} + +export function metricResetText( + snap: RateWindowSnapshot, + relative: boolean, + now: number, + t: Translate, +): string | null { + const target = snap.resetsAt ? Date.parse(snap.resetsAt) : Number.NaN; + if (!Number.isFinite(target)) { + return normalizeResetDescription(resetDescriptionFallback(snap), t); + } + if (!relative) { + return fill(t("ResetsAtLabel"), absoluteResetDescription(new Date(target), new Date(now), t)); + } + return formatCountdown((target - now) / 1000, RESET_KEYS, t) ?? t("ProviderTextResetsNow"); +} + +function paceKind(lane: MetricLane, windowMinutes: number | null): PaceKind | null { + const rule = LANE_POLICY[lane].pace; + if (rule === "never" || windowMinutes == null) return null; + if (rule === "exactWindow") { + if (windowMinutes === SESSION_MINUTES) return "session"; + return windowMinutes === WEEK_MINUTES ? "weekly" : null; + } + return windowMinutes <= SESSION_MINUTES ? "session" : "weekly"; +} + +function visiblePace(input: MetricRowInput): { pace: WindowPaceSnapshot; kind: PaceKind } | null { + const { snap } = input; + const pace = snap.pace; + if (!input.paceEnabled || !pace || !(snap.usedPercent < 100)) return null; + if (!Number.isFinite(pace.expectedUsedPercent) || !Number.isFinite(pace.actualUsedPercent)) return null; + const kind = paceKind(input.lane, snap.windowMinutes); + if (!kind) return null; + const expectsEnough = pace.expectedUsedPercent >= 3; + if (kind === "session" ? !expectsEnough : !expectsEnough && pace.etaSeconds !== 0) return null; + return { pace, kind }; +} + +function paceMetaText(pace: WindowPaceSnapshot, kind: PaceKind, t: Translate): string { + const delta = Math.round(Math.abs(pace.deltaPercent)); + const direction = PACE_DIRECTION[pace.stage]; + const left = + delta === 0 || direction === "onPace" + ? t("PaceOnPace") + : fill(t(direction === "deficit" ? "PaceInDeficit" : "PaceInReserve"), delta); + let right: string | null = null; + if (pace.willLastToReset) { + right = t("PaceLastsUntilReset"); + } else if (pace.etaSeconds != null) { + const duration = formatCountdown(pace.etaSeconds, DURATION_KEYS, t); + if (kind === "session") { + right = duration ? fill(t("PaceProjectedEmptyIn"), duration) : t("PaceProjectedEmptyNow"); + } else { + right = duration ? fill(t("PaceRunsOutIn"), duration) : t("PaceRunsOutNow"); + } + } + return right ? `${left} · ${right}` : left; +} + +function threshold( + settings: UsageThresholdSettings, + providerId: string, + window: "session" | "weekly", + field: "high" | "critical", +): number { + const overrides = settings.providerUsageThresholds ?? {}; + return ( + overrides[`${providerId}:${window}`]?.[field] ?? + overrides[providerId]?.[field] ?? + (field === "high" ? settings.highUsageThreshold : settings.criticalUsageThreshold) + ); +} + +function normalizedMarkerPercents(values: number[]): number[] { + const kept: number[] = []; + for (const value of values.map(clampPercent)) { + if (value > 0 && value < 100 && !kept.some((other) => Math.abs(other - value) < 0.001)) { + kept.push(value); + } + } + return kept; +} + +function barMarkers(input: MetricRowInput): UsageBarMarker[] { + const policy = LANE_POLICY[input.lane]; + const window = policy.thresholdWindow; + const settings = input.usageThresholds; + const warnings = + window && settings + ? normalizedMarkerPercents( + (["high", "critical"] as const).map((field) => { + const usedThreshold = threshold(settings, input.providerId, window, field); + return input.showAsUsed ? usedThreshold : 100 - usedThreshold; + }), + ) + : []; + const workDays = input.weeklyProgressWorkDays; + const showsWorkdays = + policy.workdayTicks && + input.snap.windowMinutes === WEEK_MINUTES && + workDays != null && + workDays >= 2 && + workDays <= 7; + const workdays = showsWorkdays + ? normalizedMarkerPercents( + Array.from({ length: workDays - 1 }, (_, index) => ((index + 1) * 100) / workDays), + ).filter((day) => !warnings.some((warning) => Math.abs(warning - day) < 0.001)) + : []; + return [ + ...warnings.map((percent) => ({ percent, kind: "warning" as const })), + ...workdays.map((percent) => ({ percent, kind: "workday" as const })), + ].sort((a, b) => a.percent - b.percent); +} + +function barValueText(valuePercent: number, markers: UsageBarMarker[], t: Translate): string { + const parts = [`${valuePercent}%`]; + const groups: Array<[UsageBarMarker["kind"], LocaleKey]> = [ + ["warning", "UsageBarQuotaWarnings"], + ["workday", "UsageBarWorkDays"], + ]; + for (const [kind, key] of groups) { + const percents = markers.filter((marker) => marker.kind === kind).map((marker) => `${Math.round(marker.percent)}%`); + if (percents.length > 0) parts.push(`${t(key)}: ${percents.join(", ")}`); + } + return parts.join(". "); +} + +function renderedFillPercent(percent: number): number { + const shown = Math.round(percent); + if (shown <= 0) return 0; + if (shown >= 100) return 100; + return percent; +} + +export function metricRowPresentation(input: MetricRowInput, t: Translate): MetricRowPresentation { + const { snap, showAsUsed } = input; + const used = Number.isFinite(snap.usedPercent) ? Math.max(0, snap.usedPercent) : 0; + const shown = clampPercent(showAsUsed ? used : 100 - used); + const resetTarget = snap.resetsAt ? Date.parse(snap.resetsAt) : Number.NaN; + const replacesPercent = + input.showResetWhenExhausted && snap.isExhausted && resetTarget > input.now && input.resetText !== null; + const visible = visiblePace(input); + const markers = barMarkers(input); + const valuePercent = Math.round(shown); + const pacePercent = + visible && PACE_DIRECTION[visible.pace.stage] !== "onPace" + ? clampPercent(showAsUsed ? visible.pace.expectedUsedPercent : 100 - visible.pace.expectedUsedPercent) + : null; + return { + percentText: replacesPercent + ? null + : percentLabel(shown, t(showAsUsed ? "PanelUsedSuffix" : "PanelLeftSuffix")), + resetText: input.compact && !replacesPercent ? null : input.resetText, + metaText: visible && !input.compact ? paceMetaText(visible.pace, visible.kind, t) : null, + bar: { + fillPercent: renderedFillPercent(shown), + valuePercent, + valueText: barValueText(valuePercent, markers, t), + pacePercent, + paceDeficit: visible ? visible.pace.actualUsedPercent > visible.pace.expectedUsedPercent : false, + markers, + }, + }; +} diff --git a/apps/desktop-tauri/src/lib/providerText.test.ts b/apps/desktop-tauri/src/lib/providerText.test.ts index 7905617c1af..1871172b29e 100644 --- a/apps/desktop-tauri/src/lib/providerText.test.ts +++ b/apps/desktop-tauri/src/lib/providerText.test.ts @@ -1,20 +1,8 @@ -import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import type { LocaleKey } from "../i18n/keys"; +import { fromFtl } from "../test/localeHarness"; import { localizeDates, localizeProviderText } from "./providerText"; import { localizeProviderLabel } from "./windowLabels"; -/** Translator backed by the shipped Fluent file, with `{ "{}" }` unescaped. */ -function fromFtl(file: string) { - const text = readFileSync(`${import.meta.dirname}/../../../../rust/src/locale/${file}`, "utf8"); - const table = new Map(); - for (const line of text.split(/\r?\n/)) { - const m = /^([A-Za-z][\w-]*)\s*=\s?(.*)$/.exec(line); - if (m) table.set(m[1], m[2].replace(/\{ "\{\}" \}/g, "{}")); - } - return (key: LocaleKey) => table.get(key) ?? key; -} - const en = fromFtl("en-US.ftl"); const ru = fromFtl("ru-RU.ftl"); diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 6f78479c4ee..6b9731e266c 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3700,14 +3700,11 @@ html:has(.menu-surface--tray) { } .menu-surface--tray .menu-metric__pct, .menu-surface--tray .menu-metric__reset, +.menu-surface--tray .menu-metric__meta, .menu-surface--tray .menu-metric__status, .menu-surface--tray .menu-metric__detail { font-size: 10px; } -.menu-surface--tray .menu-metric__reserve { - margin-top: 0; - font-size: 10px; -} /* Secondary charts and pace are available inside the Usage details disclosure. */ .menu-surface--tray .menu-card__local-grid { gap: 9px 20px; @@ -4152,14 +4149,26 @@ html:has(.menu-surface--tray) { color: var(--text-primary); } -/* Header row — title left, "N% left" right. Mirrors the macOS compact menu - layout where the remaining-percent label sits on the same baseline as the - metric title with the progress bar directly underneath. */ .menu-metric__head { display: flex; + flex-wrap: wrap; align-items: baseline; - justify-content: space-between; - gap: 8px; + column-gap: 24px; + row-gap: 2px; + min-width: 0; +} + +.menu-metric__head .menu-metric__title { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menu-metric__head .menu-metric__reset { + margin-left: auto; + max-width: 100%; } /* UsageProgressBar — Capsule track + Capsule fill, .frame(height: 6) */ @@ -4177,9 +4186,47 @@ html:has(.menu-surface--tray) { transition: width 0.3s ease; } -.menu-metric__bar-fill[data-level="high"] { background: var(--usage-bar-high); } -.menu-metric__bar-fill[data-level="critical"] { background: var(--usage-bar-critical); } -.menu-metric__bar-fill[data-level="exhausted"] { background: var(--usage-bar-exhausted); } +.menu-metric__progress { + display: block; + width: 100%; + height: 6px; + overflow: hidden; +} + +.menu-metric__progress-track { + fill: var(--usage-bar-track); +} + +.menu-metric__progress-fill { + fill: var(--provider-accent, var(--usage-bar-normal)); +} + +.menu-metric__progress-warning { + fill: color-mix(in srgb, var(--text-primary) 68%, transparent); +} + +.menu-metric__progress-workday { + fill: color-mix(in srgb, var(--text-primary) 30%, transparent); +} + +.menu-metric__progress-pace { + fill: var(--mac-pace-ok, #34c759); +} + +.menu-metric__progress-pace[data-deficit="true"] { + fill: var(--mac-pace-bad, #ff3b30); +} + +.menu-metric__meta { + font-size: 11px; + color: var(--text-secondary); + min-width: 0; + overflow-wrap: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} /* HStack(.firstTextBaseline) percent label — reset countdown */ .menu-metric__row { @@ -4253,10 +4300,6 @@ html:has(.menu-surface--tray) { text-overflow: ellipsis; white-space: nowrap; } -.menu-metric__reserve { - opacity: 0.7; - font-size: 10px; -} .menu-metric__budget { display: flex; diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 19d69ad9a82..37b1f77daec 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -855,6 +855,23 @@ describe("TrayPanel provider grid", () => { }); }); + it("draws quota warning markers at the notification thresholds", async () => { + const { container } = renderTrayPanel([provider("claude", "Claude", 20)], { + showAsUsed: false, + highUsageThreshold: 75, + criticalUsageThreshold: 90, + providerUsageThresholds: { "claude:session": { critical: 95 } }, + }); + + await waitFor(() => { + expect( + Array.from(container.querySelectorAll(".menu-metric__progress-warning"), (node) => + node.getAttribute("x"), + ), + ).toEqual(["5%", "25%"]); + }); + }); + it("hides provider grid icons when the display setting is disabled", async () => { const { container } = renderTrayPanel( [provider("codex", "Codex"), provider("claude", "Claude")], diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index d9888e2ceda..cf585c4b6c2 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -89,6 +89,8 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { compactOverview: selectedProviderId === null && settings.overviewLayout !== "detailed", costSummaryDisplayStyle: settings.costSummaryDisplayStyle, + usageThresholds: settings, + weeklyProgressWorkDays: settings.weeklyProgressWorkDays ?? null, }} accentColor={settings.providerAccentColors[p.providerId]} onLayoutChange={requestLayout} diff --git a/apps/desktop-tauri/src/test/localeHarness.tsx b/apps/desktop-tauri/src/test/localeHarness.tsx index 01673fd5946..844378e6c16 100644 --- a/apps/desktop-tauri/src/test/localeHarness.tsx +++ b/apps/desktop-tauri/src/test/localeHarness.tsx @@ -1,6 +1,18 @@ -import { ALL_LOCALE_KEYS } from "../i18n/keys"; +import { readFileSync } from "node:fs"; +import { ALL_LOCALE_KEYS, type LocaleKey } from "../i18n/keys"; import type { Language, LocaleStrings } from "../types/bridge"; +/** Translator backed by the shipped Fluent file, with `{ "{}" }` unescaped. */ +export function fromFtl(file: string): (key: LocaleKey) => string { + const text = readFileSync(`${import.meta.dirname}/../../../../rust/src/locale/${file}`, "utf8"); + const table = new Map(); + for (const line of text.split(/\r?\n/)) { + const m = /^([A-Za-z][\w-]*)\s*=\s?(.*)$/.exec(line); + if (m) table.set(m[1], m[2].replace(/\{ "\{\}" \}/g, "{}")); + } + return (key) => table.get(key) ?? key; +} + /** * Build a complete `LocaleStrings` bundle whose entries are the key names * themselves, so tests can assert on "the translator returned *something* diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index fdbe34554cc..dbb975a02d8 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -618,6 +618,8 @@ export interface RateWindowSnapshot { reserveDescription: string | null; reserveWillLastToReset?: boolean; reserveEtaSeconds?: number | null; + /** Pace of this window; absent when the provider's pace is not authoritative or the window is informational. */ + pace?: WindowPaceSnapshot | null; /** Set while a longer exhausted pool blocks this window; raw percentages stay the provider data. */ monthlyLimitBlock?: MonthlyLimitBlock | null; } @@ -649,8 +651,27 @@ export interface CostSnapshotBridge { alwaysVisible?: boolean; } +export type PaceStage = + | "on_track" + | "slightly_ahead" + | "ahead" + | "far_ahead" + | "slightly_behind" + | "behind" + | "far_behind"; + +/** Pace of one rate window. A positive `deltaPercent` is a deficit, a negative one a reserve. */ +export interface WindowPaceSnapshot { + stage: PaceStage; + deltaPercent: number; + expectedUsedPercent: number; + actualUsedPercent: number; + etaSeconds?: number | null; + willLastToReset: boolean; +} + export interface PaceSnapshot { - stage: "on_track" | "slightly_ahead" | "ahead" | "far_ahead" | "slightly_behind" | "behind" | "far_behind"; + stage: PaceStage; deltaPercent: number; willLastToReset: boolean; etaSeconds: number | null; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index fd5ef31d070..0f3904ef599 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -466,6 +466,16 @@ locale_keys! { // Main popup - Pace status PaceOnTrack, PaceBehind, + PaceOnPace, + PaceInReserve, + PaceInDeficit, + PaceLastsUntilReset, + PaceRunsOutIn, + PaceRunsOutNow, + PaceProjectedEmptyIn, + PaceProjectedEmptyNow, + UsageBarQuotaWarnings, + UsageBarWorkDays, // Main popup - Reset prefix MetricResetsIn, @@ -477,6 +487,7 @@ locale_keys! { // Main popup - Usage/reset labels ResetInProgress, TomorrowAt, + ResetTomorrowAt, UsedPercent, RemainingPercent, RemainingAmount, @@ -537,6 +548,7 @@ locale_keys! { ResetsInDaysHours, ResetsInHoursMinutes, ResetsInMinutes, + ResetsInDaysMinutes, NextExpiresInDaysHours, NextExpiresInHoursMinutes, NextExpiresInMinutes, @@ -1085,11 +1097,7 @@ locale_keys! { PanelOneHour, PanelFiveHours, PanelTodayBudget, - PanelReserveSuffix, PanelBlockedByMonthlyLimit, - PanelReserveLastsUntilReset, - PanelReserveRunsOutInDaysHours, - PanelReserveRunsOutInHours, FloatBarNoProviders, FloatBarRemainingSuffix, FloatBarShowCost, @@ -1747,6 +1755,7 @@ locale_keys! { ProviderTextExpiresIn, ProviderTextCycleEndsIn, DurationDaysHours, + DurationDaysMinutes, DurationHoursMinutes, DurationDays, DurationHours, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 94d30118c9c..06e082dedaf 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -227,11 +227,22 @@ ActionCopyError = Copy error ActionBuyCredits = Buy credits... PaceOnTrack = On track PaceBehind = Behind +PaceOnPace = On pace +PaceInReserve = { "{}" }% in reserve +PaceInDeficit = { "{}" }% in deficit +PaceLastsUntilReset = Lasts until reset +PaceRunsOutIn = Runs out in { "{}" } +PaceRunsOutNow = Runs out now +PaceProjectedEmptyIn = Projected empty in { "{}" } +PaceProjectedEmptyNow = Projected empty now +UsageBarQuotaWarnings = Quota warnings +UsageBarWorkDays = Work days MetricResetsIn = Resets in SectionUsageBreakdown = Usage Breakdown SectionCost = Cost ResetInProgress = Resetting... TomorrowAt = Tomorrow at { "{}" } +ResetTomorrowAt = tomorrow, { "{}" } UsedPercent = { "{:.0}" }% used RemainingPercent = { "{:.0}" }% remaining RemainingAmount = { "{:.2}" } remaining @@ -284,6 +295,7 @@ ResetsInShort = Resets in ResetsInDaysHours = Resets in { "{}" }d { "{}" }h ResetsInHoursMinutes = Resets in { "{}" }h { "{}" }m ResetsInMinutes = Resets in { "{}" }m +ResetsInDaysMinutes = Resets in { "{}" }d { "{}" }m NextExpiresInDaysHours = Next expires in { "{}" }d { "{}" }h NextExpiresInHoursMinutes = Next expires in { "{}" }h { "{}" }m NextExpiresInMinutes = Next expires in { "{}" }m @@ -673,11 +685,7 @@ PanelNow = now PanelOneHour = 1h PanelFiveHours = 5h PanelTodayBudget = today -PanelReserveSuffix = in reserve PanelBlockedByMonthlyLimit = Blocked by monthly limit -PanelReserveLastsUntilReset = Lasts until reset -PanelReserveRunsOutInDaysHours = Runs out in { "{}" }d { "{}" }h -PanelReserveRunsOutInHours = Runs out in { "{}" }h FloatBarNoProviders = No providers FloatBarRemainingSuffix = remaining BannerCheckingForUpdates = Checking for updates… @@ -1417,6 +1425,7 @@ ProviderTextRenewsIn = Renews in { "{}" } ProviderTextExpiresIn = Expires in { "{}" } ProviderTextCycleEndsIn = Cycle ends in { "{}" } DurationDaysHours = { "{}" }d { "{}" }h +DurationDaysMinutes = { "{}" }d { "{}" }m DurationHoursMinutes = { "{}" }h { "{}" }m DurationDays = { "{}" }d DurationHours = { "{}" }h diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 01da040375f..efbd5e4c4f3 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -200,11 +200,22 @@ ActionCopyError = Copiar error ActionBuyCredits = Comprar créditos... PaceOnTrack = En ritmo PaceBehind = Atrasado +PaceOnPace = Al ritmo previsto +PaceInReserve = { "{}" }% de reserva +PaceInDeficit = { "{}" }% de déficit +PaceLastsUntilReset = Dura hasta el reinicio +PaceRunsOutIn = Se agota en { "{}" } +PaceRunsOutNow = Se agota ahora +PaceProjectedEmptyIn = Agotamiento previsto en { "{}" } +PaceProjectedEmptyNow = Agotamiento previsto ahora +UsageBarQuotaWarnings = Avisos de cuota +UsageBarWorkDays = Días laborables del progreso semanal MetricResetsIn = Reinicia en SectionUsageBreakdown = Desglose de uso SectionCost = Costo ResetInProgress = Reiniciando... TomorrowAt = Mañana a las { "{}" } +ResetTomorrowAt = mañana, { "{}" } UsedPercent = { "{:.0}" }% usado RemainingPercent = { "{:.0}" }% restante RemainingAmount = { "{:.2}" } restante @@ -257,6 +268,7 @@ ResetsInShort = Reinicia en ResetsInDaysHours = Reinicia en { "{}" }d { "{}" }h ResetsInHoursMinutes = Reinicia en { "{}" }h { "{}" }m ResetsInMinutes = Reinicia en { "{}" }m +ResetsInDaysMinutes = Reinicia en { "{}" }d { "{}" }m NextExpiresInDaysHours = Próximo vence en { "{}" }d { "{}" }h NextExpiresInHoursMinutes = Próximo vence en { "{}" }h { "{}" }m NextExpiresInMinutes = Próximo vence en { "{}" }m @@ -691,11 +703,7 @@ PanelNow = ahora PanelOneHour = 1h PanelFiveHours = 5h PanelTodayBudget = hoy -PanelReserveSuffix = en reserva PanelBlockedByMonthlyLimit = Bloqueado por el límite mensual -PanelReserveLastsUntilReset = Dura hasta el reinicio -PanelReserveRunsOutInDaysHours = Se agota en { "{}" }d { "{}" }h -PanelReserveRunsOutInHours = Se agota en { "{}" }h FloatBarNoProviders = Sin proveedores FloatBarRemainingSuffix = restante BannerCheckingForUpdates = Buscando actualizaciones… @@ -1414,6 +1422,7 @@ ProviderTextRenewsIn = Se renueva en { "{}" } ProviderTextExpiresIn = Vence en { "{}" } ProviderTextCycleEndsIn = El ciclo termina en { "{}" } DurationDaysHours = { "{}" } d { "{}" } h +DurationDaysMinutes = { "{}" } d { "{}" } min DurationHoursMinutes = { "{}" } h { "{}" } min DurationDays = { "{}" } d DurationHours = { "{}" } h diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index ad7f714bcb9..b09af738287 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -200,11 +200,22 @@ ActionCopyError = エラーをコピー ActionBuyCredits = クレジットを購入… PaceOnTrack = 順調 PaceBehind = 遅れ +PaceOnPace = 想定ペース +PaceInReserve = { "{}" }% 余裕 +PaceInDeficit = { "{}" }% 不足 +PaceLastsUntilReset = リセットまで持ちます +PaceRunsOutIn = { "{}" } 後に使い切る見込み +PaceRunsOutNow = まもなく使い切ります +PaceProjectedEmptyIn = { "{}" } 後に枯渇する見込み +PaceProjectedEmptyNow = まもなく枯渇する見込み +UsageBarQuotaWarnings = クォータ警告 +UsageBarWorkDays = 週間進捗の作業日 MetricResetsIn = リセットまで SectionUsageBreakdown = 使用量の内訳 SectionCost = コスト ResetInProgress = リセット中... TomorrowAt = 明日 { "{}" } +ResetTomorrowAt = 明日 { "{}" } UsedPercent = 使用済み { "{:.0}" }% RemainingPercent = 残り { "{:.0}" }% RemainingAmount = 残り { "{:.2}" } @@ -257,6 +268,7 @@ ResetsInShort = リセットまで ResetsInDaysHours = リセットまで { "{}" }日 { "{}" }時間 ResetsInHoursMinutes = リセットまで { "{}" }時間 { "{}" }分 ResetsInMinutes = リセットまで { "{}" }分 +ResetsInDaysMinutes = リセットまで { "{}" }日 { "{}" }分 NextExpiresInDaysHours = 次回の有効期限まで { "{}" }日 { "{}" }時間 NextExpiresInHoursMinutes = 次回の有効期限まで { "{}" }時間 { "{}" }分 NextExpiresInMinutes = 次回の有効期限まで { "{}" }分 @@ -646,11 +658,7 @@ PanelNow = 現在 PanelOneHour = 1時間 PanelFiveHours = 5時間 PanelTodayBudget = 今日 -PanelReserveSuffix = 予備 PanelBlockedByMonthlyLimit = 月間上限によりブロック中 -PanelReserveLastsUntilReset = リセットまで持ちます -PanelReserveRunsOutInDaysHours = 残り { "{}" }日 { "{}" }時間 -PanelReserveRunsOutInHours = 残り { "{}" }時間 FloatBarNoProviders = プロバイダーなし FloatBarRemainingSuffix = 残り BannerCheckingForUpdates = 更新を確認中… @@ -1415,6 +1423,7 @@ ProviderTextRenewsIn = { "{}" } 後に更新 ProviderTextExpiresIn = { "{}" } 後に期限切れ ProviderTextCycleEndsIn = サイクル終了まで { "{}" } DurationDaysHours = { "{}" } 日 { "{}" } 時間 +DurationDaysMinutes = { "{}" } 日 { "{}" } 分 DurationHoursMinutes = { "{}" } 時間 { "{}" } 分 DurationDays = { "{}" } 日 DurationHours = { "{}" } 時間 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 794ad768e47..b71361f19d5 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -200,11 +200,22 @@ ActionCopyError = 오류 복사 ActionBuyCredits = 크레딧 구매... PaceOnTrack = 정상 진행 PaceBehind = 지연됨 +PaceOnPace = 정상 속도 +PaceInReserve = { "{}" }% 여유 +PaceInDeficit = { "{}" }% 부족 +PaceLastsUntilReset = 리셋까지 지속 +PaceRunsOutIn = { "{}" } 후 소진 +PaceRunsOutNow = 지금 소진 +PaceProjectedEmptyIn = { "{}" } 후 소진 예상 +PaceProjectedEmptyNow = 지금 소진 예상 +UsageBarQuotaWarnings = 할당량 경고 +UsageBarWorkDays = 주간 진행률 근무일 MetricResetsIn = 초기화까지 SectionUsageBreakdown = 사용량 내역 SectionCost = 비용 ResetInProgress = 초기화 중... TomorrowAt = 내일 { "{}" } +ResetTomorrowAt = 내일 { "{}" } UsedPercent = { "{:.0}" }% 사용됨 RemainingPercent = { "{:.0}" }% 남음 RemainingAmount = { "{:.2}" } 남음 @@ -257,6 +268,7 @@ ResetsInShort = 초기화까지 ResetsInDaysHours = 초기화까지 { "{}" }일 { "{}" }시간 ResetsInHoursMinutes = 초기화까지 { "{}" }시간 { "{}" }분 ResetsInMinutes = 초기화까지 { "{}" }분 +ResetsInDaysMinutes = 초기화까지 { "{}" }일 { "{}" }분 NextExpiresInDaysHours = 다음 만료까지 { "{}" }일 { "{}" }시간 NextExpiresInHoursMinutes = 다음 만료까지 { "{}" }시간 { "{}" }분 NextExpiresInMinutes = 다음 만료까지 { "{}" }분 @@ -691,11 +703,7 @@ PanelNow = 지금 PanelOneHour = 1시간 PanelFiveHours = 5시간 PanelTodayBudget = 오늘 -PanelReserveSuffix = 예비 PanelBlockedByMonthlyLimit = 월간 한도로 차단됨 -PanelReserveLastsUntilReset = 리셋까지 지속 -PanelReserveRunsOutInDaysHours = { "{}" }일 { "{}" }시간 후 소진 -PanelReserveRunsOutInHours = { "{}" }시간 후 소진 FloatBarNoProviders = 공급자 없음 FloatBarRemainingSuffix = 남음 BannerCheckingForUpdates = 업데이트 확인 중… @@ -1414,6 +1422,7 @@ ProviderTextRenewsIn = { "{}" } 후 갱신 ProviderTextExpiresIn = { "{}" } 후 만료 ProviderTextCycleEndsIn = 주기 종료까지 { "{}" } DurationDaysHours = { "{}" }일 { "{}" }시간 +DurationDaysMinutes = { "{}" }일 { "{}" }분 DurationHoursMinutes = { "{}" }시간 { "{}" }분 DurationDays = { "{}" }일 DurationHours = { "{}" }시간 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index b72df9bfba4..2835706f4ab 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -223,11 +223,22 @@ ActionCopyError = Copiar erro ActionBuyCredits = Comprar créditos... PaceOnTrack = Dentro do ritmo PaceBehind = Atrasado +PaceOnPace = No ritmo +PaceInReserve = { "{}" }% em reserva +PaceInDeficit = { "{}" }% em déficit +PaceLastsUntilReset = Dura até a renovação +PaceRunsOutIn = Esgota em { "{}" } +PaceRunsOutNow = Esgota agora +PaceProjectedEmptyIn = Esgotamento previsto em { "{}" } +PaceProjectedEmptyNow = Esgotamento previsto agora +UsageBarQuotaWarnings = Alertas de cota +UsageBarWorkDays = Dias úteis no progresso semanal MetricResetsIn = Renova em SectionUsageBreakdown = Detalhamento do uso SectionCost = Custo ResetInProgress = Renovando... TomorrowAt = Amanhã às { "{}" } +ResetTomorrowAt = amanhã, { "{}" } UsedPercent = { "{:.0}" }% usado RemainingPercent = { "{:.0}" }% restante RemainingAmount = { "{:.2}" } restante @@ -279,6 +290,7 @@ ResetsInShort = Renova em ResetsInDaysHours = Renova em { "{}" }d { "{}" }h ResetsInHoursMinutes = Renova em { "{}" }h { "{}" }min ResetsInMinutes = Renova em { "{}" }min +ResetsInDaysMinutes = Renova em { "{}" }d { "{}" }min NextExpiresInDaysHours = O próximo expira em { "{}" }d { "{}" }h NextExpiresInHoursMinutes = O próximo expira em { "{}" }h { "{}" }min NextExpiresInMinutes = O próximo expira em { "{}" }min @@ -644,11 +656,7 @@ PanelNow = agora PanelOneHour = 1h PanelFiveHours = 5h PanelTodayBudget = hoje -PanelReserveSuffix = na reserva PanelBlockedByMonthlyLimit = Bloqueado pelo limite mensal -PanelReserveLastsUntilReset = Dura até a renovação -PanelReserveRunsOutInDaysHours = Esgota em { "{}" }d { "{}" }h -PanelReserveRunsOutInHours = Esgota em { "{}" }h FloatBarNoProviders = Nenhum provedor FloatBarRemainingSuffix = restante BannerCheckingForUpdates = Verificando atualizações… @@ -1419,6 +1427,7 @@ ProviderTextRenewsIn = Renova em { "{}" } ProviderTextExpiresIn = Expira em { "{}" } ProviderTextCycleEndsIn = O ciclo termina em { "{}" } DurationDaysHours = { "{}" } d { "{}" } h +DurationDaysMinutes = { "{}" } d { "{}" } min DurationHoursMinutes = { "{}" } h { "{}" } min DurationDays = { "{}" } d DurationHours = { "{}" } h diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 1034d439a24..7446881d6f7 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -227,11 +227,22 @@ ActionCopyError = Ошибка копирования ActionBuyCredits = Купить кредиты... PaceOnTrack = На ходу PaceBehind = позади +PaceOnPace = В темпе +PaceInReserve = { "{}" }% в резерве +PaceInDeficit = { "{}" }% в дефиците +PaceLastsUntilReset = Длится до сброса +PaceRunsOutIn = Закончится через { "{}" } +PaceRunsOutNow = Сейчас заканчивается +PaceProjectedEmptyIn = По прогнозу закончится через { "{}" } +PaceProjectedEmptyNow = По прогнозу закончится сейчас +UsageBarQuotaWarnings = Предупреждения о квотах +UsageBarWorkDays = Рабочие дни для недельного прогресса MetricResetsIn = Сброс через SectionUsageBreakdown = Распределение использования SectionCost = Стоимость ResetInProgress = Сброс... TomorrowAt = Завтра в { "{}" } +ResetTomorrowAt = завтра, { "{}" } UsedPercent = Использовано { "{:.0}" }% RemainingPercent = { "{:.0}" }% осталось RemainingAmount = { "{:.2}" } осталось @@ -284,6 +295,7 @@ ResetsInShort = Сброс через ResetsInDaysHours = Сброс через { "{}" } д { "{}" } ч ResetsInHoursMinutes = Сброс через { "{}" } ч { "{}" } мин ResetsInMinutes = Сброс через { "{}" } мин +ResetsInDaysMinutes = Сброс через { "{}" } д { "{}" } мин NextExpiresInDaysHours = Срок действия следующего истекает через { "{}" }d { "{}" }h NextExpiresInHoursMinutes = Срок действия следующего истекает через { "{}" }h { "{}" }m NextExpiresInMinutes = Срок действия следующего истекает через { "{}" }m @@ -673,11 +685,7 @@ PanelNow = сейчас PanelOneHour = 1 час PanelFiveHours = 5 часов PanelTodayBudget = сегодня -PanelReserveSuffix = в резерве PanelBlockedByMonthlyLimit = Заблокировано месячным лимитом -PanelReserveLastsUntilReset = Длится до сброса -PanelReserveRunsOutInDaysHours = Заканчивается через { "{}" }d { "{}" }h -PanelReserveRunsOutInHours = Заканчивается через { "{}" }h FloatBarNoProviders = Нет провайдеров FloatBarRemainingSuffix = оставшееся BannerCheckingForUpdates = Проверка обновлений… @@ -1415,6 +1423,7 @@ ProviderTextRenewsIn = Продление через { "{}" } ProviderTextExpiresIn = Истекает через { "{}" } ProviderTextCycleEndsIn = Цикл закончится через { "{}" } DurationDaysHours = { "{}" } д { "{}" } ч +DurationDaysMinutes = { "{}" } д { "{}" } мин DurationHoursMinutes = { "{}" } ч { "{}" } мин DurationDays = { "{}" } д DurationHours = { "{}" } ч diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 7f64280fa80..9e9443e30ef 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -156,11 +156,8 @@ fn test_japanese_tray_panel_locale_values_are_translated() { (LocaleKey::PanelOneHour, "1時間"), (LocaleKey::PanelFiveHours, "5時間"), (LocaleKey::PanelTodayBudget, "今日"), - (LocaleKey::PanelReserveSuffix, "予備"), - ( - LocaleKey::PanelReserveLastsUntilReset, - "リセットまで持ちます", - ), + (LocaleKey::PaceOnPace, "想定ペース"), + (LocaleKey::PaceLastsUntilReset, "リセットまで持ちます"), ( LocaleKey::PanelShowAllProviders, "すべてのプロバイダーを表示", @@ -191,8 +188,8 @@ fn test_chinese_tray_panel_locale_values_are_translated() { (LocaleKey::PanelOneHour, "1小时"), (LocaleKey::PanelFiveHours, "5小时"), (LocaleKey::PanelTodayBudget, "今日"), - (LocaleKey::PanelReserveSuffix, "储备"), - (LocaleKey::PanelReserveLastsUntilReset, "持续到重置"), + (LocaleKey::PaceOnPace, "节奏正常"), + (LocaleKey::PaceLastsUntilReset, "持续到重置"), ( LocaleKey::PanelEstimatedFromLocalLogs, "根据本地日志估算;可能与账单不同", @@ -224,8 +221,8 @@ fn test_korean_tray_panel_locale_values_are_translated() { (LocaleKey::PanelOneHour, "1시간"), (LocaleKey::PanelFiveHours, "5시간"), (LocaleKey::PanelTodayBudget, "오늘"), - (LocaleKey::PanelReserveSuffix, "예비"), - (LocaleKey::PanelReserveLastsUntilReset, "리셋까지 지속"), + (LocaleKey::PaceOnPace, "정상 속도"), + (LocaleKey::PaceLastsUntilReset, "리셋까지 지속"), ( LocaleKey::PanelEstimatedFromLocalLogs, "로컬 로그에서 추정; 청구서와 다를 수 있음", @@ -257,11 +254,8 @@ fn test_spanish_tray_panel_locale_values_are_translated() { (LocaleKey::PanelOneHour, "1h"), (LocaleKey::PanelFiveHours, "5h"), (LocaleKey::PanelTodayBudget, "hoy"), - (LocaleKey::PanelReserveSuffix, "en reserva"), - ( - LocaleKey::PanelReserveLastsUntilReset, - "Dura hasta el reinicio", - ), + (LocaleKey::PaceOnPace, "Al ritmo previsto"), + (LocaleKey::PaceLastsUntilReset, "Dura hasta el reinicio"), ( LocaleKey::PanelEstimatedFromLocalLogs, "Estimado desde logs locales; puede diferir de tu factura", diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 93fb051a153..bd2ed507618 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -227,11 +227,22 @@ ActionCopyError = Kopyalama hatası ActionBuyCredits = Kredi satın al... PaceOnTrack = Planlandığı gibi PaceBehind = Geride +PaceOnPace = Beklenen tempoda +PaceInReserve = %{ "{}" } rezervde +PaceInDeficit = %{ "{}" } açıkta +PaceLastsUntilReset = Sıfırlamaya kadar sürer +PaceRunsOutIn = { "{}" } içinde biter +PaceRunsOutNow = Şimdi biter +PaceProjectedEmptyIn = { "{}" } içinde tükenmesi tahmin ediliyor +PaceProjectedEmptyNow = Şimdi tükenmesi tahmin ediliyor +UsageBarQuotaWarnings = Kota uyarıları +UsageBarWorkDays = Haftalık ilerleme iş günleri MetricResetsIn = Sıfırlanmaya kalan SectionUsageBreakdown = Kullanım Dağılımı SectionCost = Maliyet ResetInProgress = Sıfırlanıyor... TomorrowAt = Yarın saat { "{}" } +ResetTomorrowAt = yarın, { "{}" } UsedPercent = %{ "{:.0}" } kullanıldı RemainingPercent = %{ "{:.0}" } kaldı RemainingAmount = { "{:.2}" } kaldı @@ -284,6 +295,7 @@ ResetsInShort = Sıfırlanmaya kalan ResetsInDaysHours = Sıfırlanmaya { "{}" } g { "{}" } sa ResetsInHoursMinutes = Sıfırlanmaya { "{}" } sa { "{}" } dk ResetsInMinutes = Sıfırlanmaya { "{}" } dk +ResetsInDaysMinutes = Sıfırlanmaya { "{}" } g { "{}" } dk NextExpiresInDaysHours = Sonraki süre bitimine { "{}" } g { "{}" } sa NextExpiresInHoursMinutes = Sonraki süre bitimine { "{}" } sa { "{}" } dk NextExpiresInMinutes = Sonraki süre bitimine { "{}" } dk @@ -673,11 +685,7 @@ PanelNow = şimdi PanelOneHour = 1 sa PanelFiveHours = 5 sa PanelTodayBudget = bugün -PanelReserveSuffix = yedekte PanelBlockedByMonthlyLimit = Aylık limit nedeniyle engellendi -PanelReserveLastsUntilReset = Sıfırlamaya kadar sürer -PanelReserveRunsOutInDaysHours = { "{}" } g { "{}" } sa içinde tükenir -PanelReserveRunsOutInHours = { "{}" } sa içinde tükenir FloatBarNoProviders = Sağlayıcı yok FloatBarRemainingSuffix = kaldı BannerCheckingForUpdates = Güncellemeler denetleniyor… @@ -1415,6 +1423,7 @@ ProviderTextRenewsIn = { "{}" } sonra yenilenir ProviderTextExpiresIn = { "{}" } sonra sona eriyor ProviderTextCycleEndsIn = Döngü { "{}" } sonra bitiyor DurationDaysHours = { "{}" } g { "{}" } sa +DurationDaysMinutes = { "{}" } g { "{}" } dk DurationHoursMinutes = { "{}" } sa { "{}" } dk DurationDays = { "{}" } g DurationHours = { "{}" } sa diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 185a41b80c9..cac3dabe7d3 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -227,11 +227,22 @@ ActionCopyError = Копіювати помилку ActionBuyCredits = Купити кредити... PaceOnTrack = За графіком PaceBehind = Позаду +PaceOnPace = В темпі +PaceInReserve = { "{}" }% в резерві +PaceInDeficit = { "{}" }% в дефіциті +PaceLastsUntilReset = Вистачить до скидання +PaceRunsOutIn = Закінчується за { "{}" } +PaceRunsOutNow = Зараз закінчується +PaceProjectedEmptyIn = Передбачається порожній у { "{}" } +PaceProjectedEmptyNow = Зараз проектується порожнім +UsageBarQuotaWarnings = Попередження про квоту +UsageBarWorkDays = Щотижневі робочі дні MetricResetsIn = Скинеться через SectionUsageBreakdown = Розподіл використання SectionCost = Вартість ResetInProgress = Скидання... TomorrowAt = Завтра о { "{}" } +ResetTomorrowAt = завтра, { "{}" } UsedPercent = Використано { "{:.0}" }% RemainingPercent = Залишилося { "{:.0}" }% RemainingAmount = Залишилося { "{:.2}" } @@ -284,6 +295,7 @@ ResetsInShort = Скинеться через ResetsInDaysHours = Скинеться через { "{}" } дн. { "{}" } год ResetsInHoursMinutes = Скинеться через { "{}" } год { "{}" } хв ResetsInMinutes = Скинеться через { "{}" } хв +ResetsInDaysMinutes = Скинеться через { "{}" } дн. { "{}" } хв NextExpiresInDaysHours = Наступне спливає через { "{}" } дн. { "{}" } год NextExpiresInHoursMinutes = Наступне спливає через { "{}" } год { "{}" } хв NextExpiresInMinutes = Наступне спливає через { "{}" } хв @@ -673,11 +685,7 @@ PanelNow = зараз PanelOneHour = 1 год PanelFiveHours = 5 год PanelTodayBudget = сьогодні -PanelReserveSuffix = у запасі PanelBlockedByMonthlyLimit = Заблоковано місячним лімітом -PanelReserveLastsUntilReset = Вистачить до скидання -PanelReserveRunsOutInDaysHours = Закінчиться через { "{}" } дн. { "{}" } год -PanelReserveRunsOutInHours = Закінчиться через { "{}" } год FloatBarNoProviders = Немає провайдерів FloatBarRemainingSuffix = залишилося BannerCheckingForUpdates = Перевірка оновлень… @@ -1417,6 +1425,7 @@ ProviderTextRenewsIn = Поновлення через { "{}" } ProviderTextExpiresIn = Спливає через { "{}" } ProviderTextCycleEndsIn = Цикл закінчиться через { "{}" } DurationDaysHours = { "{}" } д { "{}" } год +DurationDaysMinutes = { "{}" } д { "{}" } хв DurationHoursMinutes = { "{}" } год { "{}" } хв DurationDays = { "{}" } д DurationHours = { "{}" } год diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 176946e6791..b60e376fbb0 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -200,11 +200,22 @@ ActionCopyError = 复制错误 ActionBuyCredits = 购买额度... PaceOnTrack = 进度正常 PaceBehind = 进度滞后 +PaceOnPace = 节奏正常 +PaceInReserve = 余量 { "{}" }% +PaceInDeficit = 超额 { "{}" }% +PaceLastsUntilReset = 持续到重置 +PaceRunsOutIn = 预计 { "{}" } 后耗尽 +PaceRunsOutNow = 即将耗尽 +PaceProjectedEmptyIn = 预计 { "{}" } 后耗尽 +PaceProjectedEmptyNow = 即将耗尽 +UsageBarQuotaWarnings = 配额预警 +UsageBarWorkDays = 工作日刻度线 MetricResetsIn = 重置于 SectionUsageBreakdown = 用量明细 SectionCost = 费用 ResetInProgress = 正在重置... TomorrowAt = 明天 { "{}" } +ResetTomorrowAt = 明天 { "{}" } UsedPercent = 已使用 { "{:.0}" }% RemainingPercent = 剩余 { "{:.0}" }% RemainingAmount = 剩余 { "{:.2}" } @@ -257,6 +268,7 @@ ResetsInShort = 重置于 ResetsInDaysHours = { "{}" } 天 { "{}" } 小时后重置 ResetsInHoursMinutes = { "{}" } 小时 { "{}" } 分钟后重置 ResetsInMinutes = { "{}" } 分钟后重置 +ResetsInDaysMinutes = { "{}" } 天 { "{}" } 分钟后重置 NextExpiresInDaysHours = 下一张将在 { "{}" } 天 { "{}" } 小时后过期 NextExpiresInHoursMinutes = 下一张将在 { "{}" } 小时 { "{}" } 分钟后过期 NextExpiresInMinutes = 下一张将在 { "{}" } 分钟后过期 @@ -646,11 +658,7 @@ PanelNow = 现在 PanelOneHour = 1小时 PanelFiveHours = 5小时 PanelTodayBudget = 今日 -PanelReserveSuffix = 储备 PanelBlockedByMonthlyLimit = 已被月度限额阻止 -PanelReserveLastsUntilReset = 持续到重置 -PanelReserveRunsOutInDaysHours = { "{}" }天{ "{}" }小时后耗尽 -PanelReserveRunsOutInHours = { "{}" }小时后耗尽 FloatBarNoProviders = 无提供商 FloatBarRemainingSuffix = 剩余 BannerCheckingForUpdates = 正在检查更新… @@ -1415,6 +1423,7 @@ ProviderTextRenewsIn = { "{}" } 后续订 ProviderTextExpiresIn = { "{}" } 后到期 ProviderTextCycleEndsIn = 周期将在 { "{}" } 后结束 DurationDaysHours = { "{}" } 天 { "{}" } 小时 +DurationDaysMinutes = { "{}" } 天 { "{}" } 分钟 DurationHoursMinutes = { "{}" } 小时 { "{}" } 分钟 DurationDays = { "{}" } 天 DurationHours = { "{}" } 小时 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index b10ee45da60..8ffe2551017 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -200,11 +200,22 @@ ActionCopyError = 複製錯誤 ActionBuyCredits = 購買額度... PaceOnTrack = 進度正常 PaceBehind = 進度滯後 +PaceOnPace = 用量正常 +PaceInReserve = 比目前進度少用 { "{}" }% +PaceInDeficit = 比目前進度多用 { "{}" }% +PaceLastsUntilReset = 持續到重置 +PaceRunsOutIn = { "{}" } 後用完 +PaceRunsOutNow = 現在用完 +PaceProjectedEmptyIn = 預估 { "{}" } 後用完 +PaceProjectedEmptyNow = 預估現在用完 +UsageBarQuotaWarnings = 配額提醒 +UsageBarWorkDays = 每週進度工作日標記 MetricResetsIn = 重置於 SectionUsageBreakdown = 用量明細 SectionCost = 費用 ResetInProgress = 正在重置... TomorrowAt = 明天 { "{}" } +ResetTomorrowAt = 明天 { "{}" } UsedPercent = 已使用 { "{:.0}" }% RemainingPercent = 剩餘 { "{:.0}" }% RemainingAmount = 剩餘 { "{:.2}" } @@ -257,6 +268,7 @@ ResetsInShort = 重置於 ResetsInDaysHours = { "{}" } 天 { "{}" } 小時後重置 ResetsInHoursMinutes = { "{}" } 小時 { "{}" } 分鐘後重置 ResetsInMinutes = { "{}" } 分鐘後重置 +ResetsInDaysMinutes = { "{}" } 天 { "{}" } 分鐘後重置 NextExpiresInDaysHours = 下一張將在 { "{}" } 天 { "{}" } 小時後過期 NextExpiresInHoursMinutes = 下一張將在 { "{}" } 小時 { "{}" } 分鐘後過期 NextExpiresInMinutes = 下一張將在 { "{}" } 分鐘後過期 @@ -646,11 +658,7 @@ PanelNow = 現在 PanelOneHour = 1小時 PanelFiveHours = 5小時 PanelTodayBudget = 今日 -PanelReserveSuffix = 儲備 PanelBlockedByMonthlyLimit = 已被月度限額阻擋 -PanelReserveLastsUntilReset = 持續到重置 -PanelReserveRunsOutInDaysHours = { "{}" }天{ "{}" }小時後耗盡 -PanelReserveRunsOutInHours = { "{}" }小時後耗盡 FloatBarNoProviders = 無提供商 FloatBarRemainingSuffix = 剩餘 BannerCheckingForUpdates = 正在檢查更新… @@ -1415,6 +1423,7 @@ ProviderTextRenewsIn = { "{}" } 後續訂 ProviderTextExpiresIn = { "{}" } 後到期 ProviderTextCycleEndsIn = 週期將在 { "{}" } 後結束 DurationDaysHours = { "{}" } 天 { "{}" } 小時 +DurationDaysMinutes = { "{}" } 天 { "{}" } 分鐘 DurationHoursMinutes = { "{}" } 小時 { "{}" } 分鐘 DurationDays = { "{}" } 天 DurationHours = { "{}" } 小時 diff --git a/rust/src/settings.rs b/rust/src/settings.rs index e3ee9f68833..7324d6afce6 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -605,7 +605,7 @@ impl Default for Settings { menu_bar_shows_highest_usage: false, menu_bar_shows_percent: false, menu_bar_color_pace: false, - show_as_used: true, // Show as "used" by default + show_as_used: false, enable_animations: true, // Animations enabled by default reset_time_relative: true, // Show relative times by default show_reset_when_exhausted: false, diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 06188918ca4..2d7d051c0b8 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -1514,3 +1514,27 @@ fn cookie_denial_round_trips_through_settings_persistence() { assert_eq!(loaded.cookie_source(ProviderId::Codex), "off"); assert!(!loaded.openai_web_extras(ProviderId::Codex)); } + +#[test] +fn show_as_used_defaults_to_left_without_a_saved_value() { + let dir = tempfile::tempdir().unwrap(); + let new_install = dir.path().join("missing.json"); + let without_key = dir.path().join("settings.json"); + std::fs::write(&without_key, r#"{ "enabled_providers": ["claude"] }"#).unwrap(); + + assert!(!Settings::load_from_path(Some(&new_install)).show_as_used); + assert!(!Settings::load_from_path(Some(&without_key)).show_as_used); +} + +#[test] +fn show_as_used_keeps_a_saved_used_preference() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write( + &path, + r#"{ "enabled_providers": ["claude"], "show_as_used": true }"#, + ) + .unwrap(); + + assert!(Settings::load_from_path(Some(&path)).show_as_used); +}