From 7952ab67cf3a497e781d34b83dbc679bc4265e0e Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:21:44 +0700 Subject: [PATCH 1/6] Add Menu Bar and Menu pane settings keys (Mac parity PR 3) New keys menu_bar_icon_style (legacy files follow menu_bar_shows_percent), merged_overview_selected_providers (canonical, max 6), quota_warning_markers_visible and workday_tick_style, each portable and round-tripped. Work days accept 2..=7; the patch accepts null for Automatic. The retired Display mode "minimal" folds into Show most-used provider once (.display-mode-fold-v1); the tray reads menu_bar_icon_style. --- .../src-tauri/src/commands/bridge.rs | 8 + .../src-tauri/src/commands/settings.rs | 126 ++++++++- .../src-tauri/src/tray_bridge.rs | 4 +- .../src-tauri/src/tray_presentation.rs | 6 +- .../src-tauri/src/tray_presentation_tests.rs | 54 +++- rust/src/core/session_equivalent_forecast.rs | 19 +- rust/src/settings.rs | 89 +++++- rust/src/settings/preferences_document.rs | 16 ++ .../settings/preferences_document/tests.rs | 25 ++ rust/src/settings/raw.rs | 53 +++- rust/src/settings/tests.rs | 259 ++++++++++++++++++ rust/src/settings/types.rs | 71 +++++ 12 files changed, 710 insertions(+), 20 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 96e3e4ace5..a76e637999 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -844,6 +844,10 @@ pub struct SettingsSnapshot { switcher_shows_icons: bool, menu_bar_shows_highest_usage: bool, menu_bar_shows_percent: bool, + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle, + merged_overview_selected_providers: Vec, + quota_warning_markers_visible: bool, + workday_tick_style: codexbar::settings::WorkdayTickStyle, menu_bar_color_pace: bool, providers_sorted_alphabetically: bool, show_as_used: bool, @@ -982,6 +986,10 @@ impl From for SettingsSnapshot { switcher_shows_icons: settings.switcher_shows_icons, menu_bar_shows_highest_usage: settings.menu_bar_shows_highest_usage, menu_bar_shows_percent: settings.menu_bar_shows_percent, + menu_bar_icon_style: settings.menu_bar_icon_style, + merged_overview_selected_providers: settings.merged_overview_selected_providers, + quota_warning_markers_visible: settings.quota_warning_markers_visible, + workday_tick_style: settings.workday_tick_style, menu_bar_color_pace: settings.menu_bar_color_pace, providers_sorted_alphabetically: settings.providers_sorted_alphabetically, show_as_used: settings.show_as_used, diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 88f256381f..c1210ad131 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -34,6 +34,10 @@ pub struct SettingsUpdate { pub switcher_shows_icons: Option, pub menu_bar_shows_highest_usage: Option, pub menu_bar_shows_percent: Option, + pub menu_bar_icon_style: Option, + pub merged_overview_selected_providers: Option>, + pub quota_warning_markers_visible: Option, + pub workday_tick_style: Option, pub menu_bar_color_pace: Option, pub providers_sorted_alphabetically: Option, pub show_as_used: Option, @@ -91,7 +95,9 @@ pub struct SettingsUpdate { /// Optional user-entered Copilot seat AI-credit allowance; `null` clears it. #[serde(default, deserialize_with = "deserialize_double_option")] pub copilot_seat_credit_entitlement: Option>, - pub weekly_progress_work_days: Option, + /// Work-week length (2..=7); `null` or an out-of-range value selects Automatic. + #[serde(default, deserialize_with = "deserialize_double_option")] + pub weekly_progress_work_days: Option>, pub cost_summary_display_style: Option, pub open_codex_usage_logs_enabled: Option, pub hide_native_codex_cost_when_open_codex_present: Option, @@ -139,6 +145,7 @@ impl SettingsUpdate { || self.switcher_shows_icons.is_some() || self.menu_bar_shows_highest_usage.is_some() || self.menu_bar_shows_percent.is_some() + || self.menu_bar_icon_style.is_some() || self.menu_bar_color_pace.is_some() || self.show_as_used.is_some() || self.reset_time_relative.is_some() @@ -295,6 +302,28 @@ impl SettingsUpdate { } if let Some(v) = self.menu_bar_shows_percent { settings.menu_bar_shows_percent = v; + // Legacy callers that still send the flag drive the new key too. + if self.menu_bar_icon_style.is_none() { + settings.menu_bar_icon_style = if v { + codexbar::settings::MenuBarIconStyle::Percent + } else { + codexbar::settings::MenuBarIconStyle::Bars + }; + } + } + if let Some(v) = self.menu_bar_icon_style { + settings.menu_bar_icon_style = v; + // Keep the legacy flag in step for one release so a downgrade keeps the look. + settings.menu_bar_shows_percent = v == codexbar::settings::MenuBarIconStyle::Percent; + } + if let Some(ids) = self.merged_overview_selected_providers.clone() { + settings.set_merged_overview_selected_providers(ids); + } + if let Some(v) = self.quota_warning_markers_visible { + settings.quota_warning_markers_visible = v; + } + if let Some(v) = self.workday_tick_style { + settings.workday_tick_style = v; } if let Some(v) = self.menu_bar_color_pace { settings.menu_bar_color_pace = v; @@ -437,7 +466,8 @@ impl SettingsUpdate { ); } if let Some(v) = self.weekly_progress_work_days { - settings.weekly_progress_work_days = if (2..=6).contains(&v) { Some(v) } else { None }; + settings.weekly_progress_work_days = + codexbar::settings::normalize_weekly_progress_work_days(v); } if let Some(v) = self .cost_summary_display_style @@ -494,11 +524,14 @@ impl SettingsUpdate { } } -fn deserialize_double_option<'de, D>(deserializer: D) -> Result>, D::Error> +/// Present key → `Some(value)`, explicit `null` → `Some(None)`; a missing key +/// stays `None` through `#[serde(default)]`. +fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, + T: Deserialize<'de>, { - Ok(Some(Option::::deserialize(deserializer)?)) + Ok(Some(Option::::deserialize(deserializer)?)) } fn normalize_custom_sessions_dirs(dirs: Vec) -> Vec { @@ -859,6 +892,91 @@ mod tests { assert!(!settings.menu_bar_color_pace); } + #[test] + fn menu_bar_icon_style_update_repaints_tray_and_mirrors_legacy_flag() { + let mut settings = Settings::default(); + let update: SettingsUpdate = + serde_json::from_str(r#"{"menuBarIconStyle":"percent"}"#).unwrap(); + assert!(update.refreshes_tray_presentation()); + update.apply_display_settings(&mut settings); + assert_eq!( + settings.menu_bar_icon_style, + codexbar::settings::MenuBarIconStyle::Percent + ); + assert!(settings.menu_bar_shows_percent); + + let update: SettingsUpdate = + serde_json::from_str(r#"{"menuBarIconStyle":"bars"}"#).unwrap(); + update.apply_display_settings(&mut settings); + assert_eq!( + settings.menu_bar_icon_style, + codexbar::settings::MenuBarIconStyle::Bars + ); + assert!(!settings.menu_bar_shows_percent); + } + + #[test] + fn menu_pane_keys_apply_from_camel_case_patch() { + let mut settings = Settings::default(); + let update: SettingsUpdate = serde_json::from_str( + r#"{"quotaWarningMarkersVisible":false,"workdayTickStyle":"high_contrast", + "mergedOverviewSelectedProviders":["claude","Codex","claude","gemini","cursor","copilot","zai","kimi"], + "weeklyProgressWorkDays":7}"#, + ) + .unwrap(); + update + .apply_display_settings(&mut settings) + .apply_advanced_settings(&mut settings); + assert!(!settings.quota_warning_markers_visible); + assert_eq!( + settings.workday_tick_style, + codexbar::settings::WorkdayTickStyle::HighContrast + ); + assert_eq!( + settings.merged_overview_selected_providers, + ["claude", "codex", "gemini", "cursor", "copilot", "zai"] + .map(String::from) + .to_vec() + ); + assert_eq!(settings.weekly_progress_work_days, Some(7)); + + let update: SettingsUpdate = + serde_json::from_str(r#"{"weeklyProgressWorkDays":8}"#).unwrap(); + update + .apply_display_settings(&mut settings) + .apply_advanced_settings(&mut settings); + assert_eq!(settings.weekly_progress_work_days, None); + + settings.weekly_progress_work_days = Some(5); + let update: SettingsUpdate = + serde_json::from_str(r#"{"weeklyProgressWorkDays":null}"#).unwrap(); + assert!(update.refreshes_provider_data()); + update + .apply_display_settings(&mut settings) + .apply_advanced_settings(&mut settings); + assert_eq!( + settings.weekly_progress_work_days, None, + "null selects Automatic" + ); + } + + #[test] + fn settings_snapshot_emits_snake_case_style_values() { + let settings = Settings { + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, + workday_tick_style: codexbar::settings::WorkdayTickStyle::HighContrast, + ..Settings::default() + }; + let json = serde_json::to_value(crate::commands::SettingsSnapshot::from(settings)).unwrap(); + assert_eq!(json["menuBarIconStyle"], "percent"); + assert_eq!(json["workdayTickStyle"], "high_contrast"); + assert_eq!(json["quotaWarningMarkersVisible"], true); + assert_eq!( + json["mergedOverviewSelectedProviders"], + serde_json::json!([]) + ); + } + #[test] fn providers_sidebar_sort_applies_without_touching_order_or_tray() { let mut settings = Settings::default(); diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index c35f8f66ea..795ecf80e6 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -388,8 +388,8 @@ pub(crate) fn refresh_tray_presentation(app: &AppHandle) { /// Update the tray icon pixels and tooltip text to reflect current provider usage. /// /// Behaviour mirrors egui's `choose_tray_update_plan` (rust/src/native_ui/app.rs): -/// - If `menu_bar_shows_highest_usage` is on OR `menu_bar_display_mode == "minimal"`, -/// render the bar from the healthy provider with the highest session usage. +/// - If `menu_bar_shows_highest_usage` is on (legacy Display mode "minimal" is +/// folded into it on load), render the bar from the healthy provider with the highest session usage. /// - Otherwise render from the first enabled healthy provider (catalog order). /// - When any provider exposes a weekly/secondary window, the icon shows both /// bars from the same picked provider. diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs index 2dadce0734..510a68bbe3 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -63,8 +63,8 @@ impl<'a> TrayPresentationPlan<'a> { .filter(|snapshot| snapshot.error.is_none()) .collect::>(); let has_error = healthy.is_empty() && !snapshots.is_empty(); - let prefer_highest = - settings.menu_bar_shows_highest_usage || settings.menu_bar_display_mode == "minimal"; + // The retired Display mode "minimal" is folded into this flag once on load. + let prefer_highest = settings.menu_bar_shows_highest_usage; let selected = pick_tray_provider(&healthy, prefer_highest); let icon_pace = tray_pace_color(settings, selected); @@ -202,7 +202,7 @@ fn resolve_single_provider_icon_plan( secondary_percent: Option, has_error: bool, ) -> TrayIconPlan { - if settings.menu_bar_shows_percent { + if settings.menu_bar_icon_style == codexbar::settings::MenuBarIconStyle::Percent { TrayIconPlan::Percent { percent: primary_percent, has_error, 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 ca2f71a022..57c9ec6227 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -270,7 +270,7 @@ fn one_provider_stacked_mode_falls_back_to_single_provider_bars() { fn one_healthy_provider_never_uses_stacked_renderer() { let settings = Settings { tray_icon_mode: TrayIconMode::Stacked, - menu_bar_shows_percent: true, + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, show_as_used: true, ..Settings::default() }; @@ -295,7 +295,7 @@ fn one_healthy_provider_never_uses_stacked_renderer() { #[test] fn all_errors_produce_error_styled_zero_percent_plan() { let settings = Settings { - menu_bar_shows_percent: true, + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, ..Settings::default() }; let mut snapshot = fake_snapshot("codex", "Codex", 30.0); @@ -737,7 +737,7 @@ fn tray_pace_color_is_opt_in_and_follows_canonical_stage() { }; let colored_percent_settings = Settings { menu_bar_color_pace: true, - menu_bar_shows_percent: true, + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, ..Settings::default() }; let render = |settings: &Settings, snapshots: &[ProviderUsageSnapshot]| { @@ -762,7 +762,7 @@ fn tray_pace_color_is_opt_in_and_follows_canonical_stage() { let percent_normal = render( &Settings { - menu_bar_shows_percent: true, + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, ..Settings::default() }, &unpaced, @@ -808,3 +808,49 @@ fn stacked_tray_icon_keeps_usage_colours_when_pace_tint_is_enabled() { TrayPresentationPlan::resolve(&unpaced_settings, &unpaced, None).render_icon() ); } + +#[test] +fn icon_style_not_the_legacy_percent_flag_picks_the_renderer() { + let snapshots = vec![fake_snapshot("codex", "Codex", 30.0)]; + let bars = Settings { + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Bars, + menu_bar_shows_percent: true, + show_as_used: true, + ..Settings::default() + }; + assert!(matches!( + TrayPresentationPlan::resolve(&bars, &snapshots, None).icon, + TrayIconPlan::Bars { .. } + )); + let percent = Settings { + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, + menu_bar_shows_percent: false, + show_as_used: true, + ..Settings::default() + }; + assert_eq!( + TrayPresentationPlan::resolve(&percent, &snapshots, None).icon, + TrayIconPlan::Percent { + percent: 30.0, + has_error: false, + } + ); +} + +#[test] +fn retired_minimal_display_mode_no_longer_forces_most_used_provider() { + // After the one-shot fold, turning "Show most-used provider" off must win. + let settings = Settings { + tray_icon_mode: TrayIconMode::Single, + menu_bar_display_mode: "minimal".to_string(), + menu_bar_shows_highest_usage: false, + show_as_used: true, + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + assert_eq!(plan.status_rows[0].snapshot.provider_id, "codex"); +} diff --git a/rust/src/core/session_equivalent_forecast.rs b/rust/src/core/session_equivalent_forecast.rs index 6df3233a69..ec93234e39 100644 --- a/rust/src/core/session_equivalent_forecast.rs +++ b/rust/src/core/session_equivalent_forecast.rs @@ -462,8 +462,9 @@ fn is_plausible_reset( /// Remaining seconds until weekly reset, optionally counting only work days. /// -/// `work_days` in `[2, 6]` keeps ISO weekdays `1..=work_days` (Mon..). -/// Anything else falls back to wall-clock remaining time. +/// `work_days` in `[2, 7]` keeps ISO weekdays `1..=work_days` (Mon..); 7 counts +/// every day, which equals wall-clock time. Anything else falls back to +/// wall-clock remaining time. pub fn effective_remaining_seconds( now: DateTime, resets_at: DateTime, @@ -473,7 +474,7 @@ pub fn effective_remaining_seconds( let Some(work_days) = work_days else { return wall; }; - if !(2..=6).contains(&work_days) { + if !(2..=7).contains(&work_days) { return wall; } @@ -1198,7 +1199,19 @@ mod tests { let reset = now + Duration::days(2); let wall = effective_remaining_seconds(now, reset, None); assert!((effective_remaining_seconds(now, reset, Some(1)) - wall).abs() < 1e-9); + assert!((effective_remaining_seconds(now, reset, Some(8)) - wall).abs() < 1e-9); + } + + #[test] + fn seven_work_days_count_every_day() { + // A full week that spans a weekend: 7 work days equals wall clock, + // 5 work days drops the two weekend days. + let now = ts(1_700_400_000); + let reset = now + Duration::days(7); + let wall = effective_remaining_seconds(now, reset, None); assert!((effective_remaining_seconds(now, reset, Some(7)) - wall).abs() < 1e-9); + let five = effective_remaining_seconds(now, reset, Some(5)); + assert!((wall - five - 2.0 * 86_400.0).abs() < 1e-9); } #[test] diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 2ff86ca3e1..c741e8ce05 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -172,10 +172,30 @@ pub struct Settings { #[serde(default)] pub menu_bar_shows_highest_usage: bool, - /// Replace bar-only tray display with provider branding plus percent text where supported + /// Legacy "Show percent in tray" flag. No longer read: it only seeds + /// `menu_bar_icon_style` for files written before that key existed. + /// Kept (and still written) for one release so a downgrade keeps the style. #[serde(default)] pub menu_bar_shows_percent: bool, + /// How the single tray icon is drawn: meter bars or icon + percent. + /// Legacy files without the key follow `menu_bar_shows_percent`. + #[serde(default)] + pub menu_bar_icon_style: MenuBarIconStyle, + + /// Providers shown in the merged Overview, in provider order, at most + /// [`MERGED_OVERVIEW_PROVIDER_LIMIT`]. Empty = the first enabled ones. + #[serde(default)] + pub merged_overview_selected_providers: Vec, + + /// Draw quota-warning threshold ticks on usage bars. + #[serde(default = "default_true")] + pub quota_warning_markers_visible: bool, + + /// How workday boundaries appear on weekly usage bars. + #[serde(default)] + pub workday_tick_style: WorkdayTickStyle, + /// Colour the Windows tray usage indicator by pace when enabled. #[serde(default)] pub menu_bar_color_pace: bool, @@ -215,7 +235,9 @@ pub struct Settings { #[serde(default = "default_true")] pub show_pace: bool, - /// Menu bar display mode: "minimal", "compact", or "detailed" + /// Retired "Display mode" row ("minimal", "compact", "detailed"). Kept + /// readable for one release; "minimal" is folded into + /// `menu_bar_shows_highest_usage` once on load (`.display-mode-fold-v1`). pub menu_bar_display_mode: String, /// Overview card layout: "detailed" or "compact". @@ -397,7 +419,7 @@ pub struct Settings { #[serde(default)] pub claude_allow_reading_claude_code_credentials: bool, - /// Optional work-week length [2,6] for session-equivalent weekly forecast. + /// Optional work-week length [2,7] for session-equivalent weekly forecast. /// `None` uses wall-clock time until weekly reset. #[serde(default)] pub weekly_progress_work_days: Option, @@ -621,6 +643,10 @@ impl Default for Settings { switcher_shows_icons: true, menu_bar_shows_highest_usage: false, menu_bar_shows_percent: false, + menu_bar_icon_style: MenuBarIconStyle::Bars, + merged_overview_selected_providers: Vec::new(), + quota_warning_markers_visible: true, + workday_tick_style: WorkdayTickStyle::Subtle, menu_bar_color_pace: false, providers_sorted_alphabetically: false, show_as_used: false, @@ -722,6 +748,7 @@ impl Settings { if settings_file_override().is_none() { settings.start_at_login = Self::sync_start_at_login_registry(); settings.apply_promote_tray_default_migration(); + settings.apply_display_mode_fold_migration(); } // One-shot migration: materialize the persisted hidden usage-item list @@ -805,6 +832,62 @@ impl Settings { } } + /// Marker written after the one-shot Display mode "minimal" fold (Mac parity PR 3). + fn display_mode_fold_marker_path() -> Option { + crate::logging::config_root().map(|p| p.join(".display-mode-fold-v1")) + } + + /// The Display mode row is gone. "Minimal" used to force the most-used + /// provider into the tray, so carry that into `menu_bar_shows_highest_usage` + /// once. Returns true when the settings changed. Later opt-outs stick + /// because the marker remains. + fn fold_minimal_display_mode(&mut self, already_migrated: bool) -> bool { + if already_migrated + || self.menu_bar_shows_highest_usage + || !self + .menu_bar_display_mode + .trim() + .eq_ignore_ascii_case("minimal") + { + return false; + } + self.menu_bar_shows_highest_usage = true; + true + } + + fn apply_display_mode_fold_migration(&mut self) { + let Some(marker) = Self::display_mode_fold_marker_path() else { + return; + }; + self.apply_display_mode_fold_with_marker(&marker, Self::save); + } + + fn apply_display_mode_fold_with_marker( + &mut self, + marker: &Path, + persist: impl FnOnce(&Self) -> anyhow::Result<()>, + ) { + let already_migrated = marker.exists(); + if self.fold_minimal_display_mode(already_migrated) + && let Err(error) = persist(self) + { + tracing::warn!("Failed to persist display mode fold migration: {error}"); + } + if !already_migrated && let Some(parent) = marker.parent() { + // Best-effort marker dir creation; the write below reports failure. + let _created_dir = std::fs::create_dir_all(parent); + if let Err(error) = std::fs::write(marker, b"1") { + tracing::warn!("Failed to write display mode fold marker: {error}"); + } + } + } + + /// Store the merged Overview selection: canonical ids, deduplicated, + /// at most [`MERGED_OVERVIEW_PROVIDER_LIMIT`]. + pub fn set_merged_overview_selected_providers(&mut self, ids: Vec) { + self.merged_overview_selected_providers = raw::normalize_merged_overview_selection(ids); + } + /// Save settings to disk pub fn save(&self) -> anyhow::Result<()> { let path = Self::settings_path() diff --git a/rust/src/settings/preferences_document.rs b/rust/src/settings/preferences_document.rs index 5656914039..2b4344745b 100644 --- a/rust/src/settings/preferences_document.rs +++ b/rust/src/settings/preferences_document.rs @@ -72,6 +72,15 @@ const ALLOWED_PREFERENCES: &[(&str, Validator)] = &[ ("hide_personal_info", typed::), ("menu_bar_shows_highest_usage", typed::), ("menu_bar_shows_percent", typed::), + ("menu_bar_icon_style", |v| one_of(v, &["bars", "percent"])), + ( + "merged_overview_selected_providers", + merged_overview_selection, + ), + ("quota_warning_markers_visible", typed::), + ("workday_tick_style", |v| { + one_of(v, &["hidden", "subtle", "high_contrast"]) + }), ("tray_icon_mode", typed::), ("switcher_shows_icons", typed::), ("providers_sorted_alphabetically", typed::), @@ -223,6 +232,13 @@ fn provider_id_list(value: &Value) -> bool { }) } +fn merged_overview_selection(value: &Value) -> bool { + value + .as_array() + .is_some_and(|items| items.len() <= super::MERGED_OVERVIEW_PROVIDER_LIMIT) + && provider_id_list(value) +} + fn provider_metrics(value: &Value) -> bool { let known = known_provider_ids(); value.as_object().is_some_and(|map| { diff --git a/rust/src/settings/preferences_document/tests.rs b/rust/src/settings/preferences_document/tests.rs index 7d0a80eef4..ac77e058f9 100644 --- a/rust/src/settings/preferences_document/tests.rs +++ b/rust/src/settings/preferences_document/tests.rs @@ -45,6 +45,10 @@ fn customized_settings() -> Settings { hide_personal_info: true, menu_bar_shows_highest_usage: true, menu_bar_shows_percent: true, + menu_bar_icon_style: crate::settings::MenuBarIconStyle::Percent, + merged_overview_selected_providers: vec!["claude".to_string(), "codex".to_string()], + quota_warning_markers_visible: false, + workday_tick_style: crate::settings::WorkdayTickStyle::HighContrast, tray_icon_mode: TrayIconMode::PerProvider, switcher_shows_icons: false, providers_sorted_alphabetically: true, @@ -259,6 +263,27 @@ fn rejection_matrix_names_the_offending_key() { json!({ "overview_layout": "huge" }), "overview_layout", ), + ( + "bad menu bar icon style", + json!({ "menu_bar_icon_style": "critters" }), + "menu_bar_icon_style", + ), + ( + "bad workday tick style", + json!({ "workday_tick_style": "highContrast" }), + "workday_tick_style", + ), + ( + "overview selection over six", + json!({ "merged_overview_selected_providers": + ["codex", "claude", "gemini", "cursor", "copilot", "zai", "kimi"] }), + "merged_overview_selected_providers", + ), + ( + "overview selection unknown provider", + json!({ "merged_overview_selected_providers": ["codex", "nope"] }), + "merged_overview_selected_providers", + ), ( "threshold over 100", json!({ "high_usage_threshold": 100.5 }), diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 1cceb24ae8..4d8888a4b7 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -15,6 +15,28 @@ fn canonicalize_provider_id_list(ids: impl IntoIterator) -> Vec, + legacy_shows_percent: bool, +) -> MenuBarIconStyle { + stored + .and_then(MenuBarIconStyle::parse) + .unwrap_or(if legacy_shows_percent { + MenuBarIconStyle::Percent + } else { + MenuBarIconStyle::Bars + }) +} + +/// Canonical ids, deduplicated, capped at [`MERGED_OVERVIEW_PROVIDER_LIMIT`]. +pub(crate) fn normalize_merged_overview_selection(ids: Vec) -> Vec { + let mut ids = canonicalize_provider_id_list(ids); + ids.truncate(MERGED_OVERVIEW_PROVIDER_LIMIT); + ids +} + fn canonicalize_provider_metrics( metrics: HashMap, ) -> HashMap { @@ -125,6 +147,16 @@ pub(super) struct RawSettings { switcher_shows_icons: bool, menu_bar_shows_highest_usage: bool, menu_bar_shows_percent: bool, + /// `None` when the file predates the key; it then follows `menu_bar_shows_percent`. + /// Kept as a string so an unknown value never fails the whole file. + #[serde(default)] + menu_bar_icon_style: Option, + #[serde(default)] + merged_overview_selected_providers: Vec, + #[serde(default = "default_true")] + quota_warning_markers_visible: bool, + #[serde(default)] + workday_tick_style: Option, #[serde(default)] menu_bar_color_pace: bool, #[serde(default)] @@ -314,6 +346,10 @@ impl Default for RawSettings { switcher_shows_icons: s.switcher_shows_icons, menu_bar_shows_highest_usage: s.menu_bar_shows_highest_usage, menu_bar_shows_percent: s.menu_bar_shows_percent, + menu_bar_icon_style: Some(s.menu_bar_icon_style.as_str().to_string()), + merged_overview_selected_providers: s.merged_overview_selected_providers, + quota_warning_markers_visible: s.quota_warning_markers_visible, + workday_tick_style: Some(s.workday_tick_style.as_str().to_string()), menu_bar_color_pace: s.menu_bar_color_pace, providers_sorted_alphabetically: s.providers_sorted_alphabetically, show_as_used: s.show_as_used, @@ -662,6 +698,19 @@ impl From for Settings { switcher_shows_icons: raw.switcher_shows_icons, menu_bar_shows_highest_usage: raw.menu_bar_shows_highest_usage, menu_bar_shows_percent: raw.menu_bar_shows_percent, + menu_bar_icon_style: resolve_menu_bar_icon_style( + raw.menu_bar_icon_style.as_deref(), + raw.menu_bar_shows_percent, + ), + merged_overview_selected_providers: normalize_merged_overview_selection( + raw.merged_overview_selected_providers, + ), + quota_warning_markers_visible: raw.quota_warning_markers_visible, + workday_tick_style: raw + .workday_tick_style + .as_deref() + .and_then(WorkdayTickStyle::parse) + .unwrap_or_default(), menu_bar_color_pace: raw.menu_bar_color_pace, providers_sorted_alphabetically: raw.providers_sorted_alphabetically, show_as_used: raw.show_as_used, @@ -720,7 +769,9 @@ impl From for Settings { claude_daily_routines_usage_visible: raw.claude_daily_routines_usage_visible, claude_allow_reading_claude_code_credentials: raw .claude_allow_reading_claude_code_credentials, - weekly_progress_work_days: raw.weekly_progress_work_days, + weekly_progress_work_days: normalize_weekly_progress_work_days( + raw.weekly_progress_work_days, + ), alibaba_token_plan_region: { let trimmed = raw.alibaba_token_plan_region.trim(); if trimmed.is_empty() { diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index cc773d7a2c..5342f95e24 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -1619,3 +1619,262 @@ fn adaptive_refresh_defaults_on_for_new_installs_only() { .expect("deserialize explicit fixed refresh"); assert!(!explicit_off.adaptive_refresh); } + +// ── Mac parity PR 3: Menu Bar / Menu pane keys ────────────────────────── + +#[test] +fn menu_bar_icon_style_defaults_to_bars_for_new_installs() { + assert_eq!( + Settings::default().menu_bar_icon_style, + MenuBarIconStyle::Bars + ); +} + +#[test] +fn menu_bar_icon_style_migrates_from_legacy_percent_flag() { + let legacy_percent: Settings = + serde_json::from_str(r#"{ "enabled_providers": [], "menu_bar_shows_percent": true }"#) + .expect("legacy percent file"); + assert_eq!( + legacy_percent.menu_bar_icon_style, + MenuBarIconStyle::Percent + ); + + let legacy_bars: Settings = + serde_json::from_str(r#"{ "enabled_providers": [], "menu_bar_shows_percent": false }"#) + .expect("legacy bars file"); + assert_eq!(legacy_bars.menu_bar_icon_style, MenuBarIconStyle::Bars); + + let legacy_missing: Settings = serde_json::from_str(r#"{ "enabled_providers": [] }"#) + .expect("legacy file without either key"); + assert_eq!(legacy_missing.menu_bar_icon_style, MenuBarIconStyle::Bars); +} + +#[test] +fn stored_menu_bar_icon_style_wins_over_the_legacy_flag() { + let bars: Settings = serde_json::from_str( + r#"{ "enabled_providers": [], "menu_bar_shows_percent": true, "menu_bar_icon_style": "bars" }"#, + ) + .expect("stored bars"); + assert_eq!(bars.menu_bar_icon_style, MenuBarIconStyle::Bars); + + let percent: Settings = serde_json::from_str( + r#"{ "enabled_providers": [], "menu_bar_shows_percent": false, "menu_bar_icon_style": "percent" }"#, + ) + .expect("stored percent"); + assert_eq!(percent.menu_bar_icon_style, MenuBarIconStyle::Percent); + + let unknown: Settings = serde_json::from_str( + r#"{ "enabled_providers": [], "menu_bar_shows_percent": true, "menu_bar_icon_style": "sparkles" }"#, + ) + .expect("an unknown style must not fail the whole file"); + assert_eq!(unknown.menu_bar_icon_style, MenuBarIconStyle::Percent); +} + +#[test] +fn menu_bar_icon_style_round_trips() { + for style in [MenuBarIconStyle::Bars, MenuBarIconStyle::Percent] { + let settings = Settings { + menu_bar_icon_style: style, + // The legacy flag disagrees on purpose: the stored key must win. + menu_bar_shows_percent: style == MenuBarIconStyle::Bars, + ..Settings::default() + }; + let json = serde_json::to_string(&settings).expect("serialize icon style"); + assert!(json.contains(&format!(r#""menu_bar_icon_style":"{}""#, style.as_str()))); + let loaded: Settings = serde_json::from_str(&json).expect("deserialize icon style"); + assert_eq!(loaded.menu_bar_icon_style, style); + } +} + +#[test] +fn merged_overview_selection_defaults_empty_and_round_trips() { + assert!( + Settings::default() + .merged_overview_selected_providers + .is_empty() + ); + let legacy: Settings = + serde_json::from_str(r#"{ "enabled_providers": [] }"#).expect("legacy file"); + assert!(legacy.merged_overview_selected_providers.is_empty()); + + let settings = Settings { + merged_overview_selected_providers: vec!["claude".into(), "codex".into()], + ..Settings::default() + }; + let json = serde_json::to_string(&settings).expect("serialize overview selection"); + assert!(json.contains(r#""merged_overview_selected_providers":["claude","codex"]"#)); + let loaded: Settings = serde_json::from_str(&json).expect("deserialize overview selection"); + assert_eq!( + loaded.merged_overview_selected_providers, + vec!["claude".to_string(), "codex".to_string()] + ); +} + +#[test] +fn merged_overview_selection_is_canonical_deduped_and_capped_at_six() { + let loaded: Settings = serde_json::from_str( + r#"{ "enabled_providers": [], "merged_overview_selected_providers": + ["Codex", "codex", "not-a-provider", "claude", "gemini", "cursor", "copilot", "zai", "kimi"] }"#, + ) + .expect("parse overview selection"); + assert_eq!( + loaded.merged_overview_selected_providers, + ["codex", "claude", "gemini", "cursor", "copilot", "zai"] + .map(String::from) + .to_vec() + ); + + let mut settings = Settings::default(); + settings.set_merged_overview_selected_providers( + [ + "codex", "claude", "gemini", "cursor", "copilot", "zai", "kimi", + ] + .map(String::from) + .to_vec(), + ); + assert_eq!( + settings.merged_overview_selected_providers.len(), + MERGED_OVERVIEW_PROVIDER_LIMIT + ); +} + +#[test] +fn quota_warning_markers_default_on_and_round_trip() { + assert!(Settings::default().quota_warning_markers_visible); + let legacy: Settings = + serde_json::from_str(r#"{ "enabled_providers": [] }"#).expect("legacy file"); + assert!(legacy.quota_warning_markers_visible); + + let hidden = Settings { + quota_warning_markers_visible: false, + ..Settings::default() + }; + let json = serde_json::to_string(&hidden).expect("serialize markers"); + assert!(json.contains(r#""quota_warning_markers_visible":false"#)); + let loaded: Settings = serde_json::from_str(&json).expect("deserialize markers"); + assert!(!loaded.quota_warning_markers_visible); +} + +#[test] +fn workday_tick_style_defaults_subtle_and_round_trips() { + assert_eq!( + Settings::default().workday_tick_style, + WorkdayTickStyle::Subtle + ); + let legacy: Settings = + serde_json::from_str(r#"{ "enabled_providers": [] }"#).expect("legacy file"); + assert_eq!(legacy.workday_tick_style, WorkdayTickStyle::Subtle); + + for style in [ + WorkdayTickStyle::Hidden, + WorkdayTickStyle::Subtle, + WorkdayTickStyle::HighContrast, + ] { + let settings = Settings { + workday_tick_style: style, + ..Settings::default() + }; + let json = serde_json::to_string(&settings).expect("serialize tick style"); + assert!(json.contains(&format!(r#""workday_tick_style":"{}""#, style.as_str()))); + let loaded: Settings = serde_json::from_str(&json).expect("deserialize tick style"); + assert_eq!(loaded.workday_tick_style, style); + } + + let unknown: Settings = + serde_json::from_str(r#"{ "enabled_providers": [], "workday_tick_style": "neon" }"#) + .expect("an unknown tick style must not fail the whole file"); + assert_eq!(unknown.workday_tick_style, WorkdayTickStyle::Subtle); +} + +#[test] +fn weekly_progress_work_days_accepts_two_through_seven() { + for days in 2..=7u8 { + let loaded: Settings = serde_json::from_str(&format!( + r#"{{ "enabled_providers": [], "weekly_progress_work_days": {days} }}"# + )) + .expect("parse work days"); + assert_eq!(loaded.weekly_progress_work_days, Some(days)); + } + for days in [0u8, 1, 8, 200] { + let loaded: Settings = serde_json::from_str(&format!( + r#"{{ "enabled_providers": [], "weekly_progress_work_days": {days} }}"# + )) + .expect("parse out-of-range work days"); + assert_eq!( + loaded.weekly_progress_work_days, None, + "{days} is out of range" + ); + } +} + +#[test] +fn minimal_display_mode_folds_into_show_most_used_once() { + let dir = tempfile::tempdir().expect("tempdir"); + let marker = dir.path().join(".display-mode-fold-v1"); + let mut settings: Settings = + serde_json::from_str(r#"{ "enabled_providers": [], "menu_bar_display_mode": "minimal" }"#) + .expect("legacy minimal file"); + assert!(!settings.menu_bar_shows_highest_usage); + + let mut persisted = 0; + settings.apply_display_mode_fold_with_marker(&marker, |_| { + persisted += 1; + Ok(()) + }); + assert!(settings.menu_bar_shows_highest_usage); + assert_eq!(persisted, 1, "the fold is saved once"); + assert!(marker.exists(), "the marker is written"); + // The field stays readable for one release. + assert_eq!(settings.menu_bar_display_mode, "minimal"); + + // The user turns "Show most-used provider" off afterwards: it stays off. + settings.menu_bar_shows_highest_usage = false; + settings.apply_display_mode_fold_with_marker(&marker, |_| { + persisted += 1; + Ok(()) + }); + assert!(!settings.menu_bar_shows_highest_usage); + assert_eq!(persisted, 1, "no second save once the marker exists"); +} + +#[test] +fn display_mode_fold_leaves_other_modes_alone_but_writes_the_marker() { + let dir = tempfile::tempdir().expect("tempdir"); + let marker = dir.path().join(".display-mode-fold-v1"); + let mut settings: Settings = + serde_json::from_str(r#"{ "enabled_providers": [], "menu_bar_display_mode": "detailed" }"#) + .expect("detailed file"); + let mut persisted = 0; + settings.apply_display_mode_fold_with_marker(&marker, |_| { + persisted += 1; + Ok(()) + }); + assert!(!settings.menu_bar_shows_highest_usage); + assert_eq!(persisted, 0); + assert!(marker.exists()); + + // A file switched to minimal after the marker exists is not folded. + settings.menu_bar_display_mode = "minimal".into(); + settings.apply_display_mode_fold_with_marker(&marker, |_| { + persisted += 1; + Ok(()) + }); + assert!(!settings.menu_bar_shows_highest_usage); + assert_eq!(persisted, 0); +} + +#[test] +fn stacked_tray_mode_survives_load_with_the_new_keys() { + let loaded: Settings = serde_json::from_str( + r#"{ "enabled_providers": ["codex", "claude"], "tray_icon_mode": "stacked", + "menu_bar_icon_style": "percent", "workday_tick_style": "high_contrast" }"#, + ) + .expect("stacked file"); + assert_eq!(loaded.tray_icon_mode, TrayIconMode::Stacked); + let json = serde_json::to_string(&loaded).expect("serialize"); + let reloaded: Settings = serde_json::from_str(&json).expect("reload"); + assert_eq!(reloaded.tray_icon_mode, TrayIconMode::Stacked); + assert_eq!(reloaded.menu_bar_icon_style, MenuBarIconStyle::Percent); + assert_eq!(reloaded.workday_tick_style, WorkdayTickStyle::HighContrast); +} diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index 13b3ecd31c..ca926cb5ab 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -447,3 +447,74 @@ pub struct ProviderConfig { #[serde(skip_serializing_if = "Option::is_none")] pub seat_credit_entitlement: Option, } + +/// How the single tray icon is drawn (Mac "Menu bar style"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MenuBarIconStyle { + /// Two-row usage meter. + #[default] + Bars, + /// Provider icon plus the percent text. + Percent, +} + +impl MenuBarIconStyle { + /// Lenient parse; unknown values return `None` so callers pick a fallback. + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "bars" => Some(Self::Bars), + "percent" => Some(Self::Percent), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Bars => "bars", + Self::Percent => "percent", + } + } +} + +/// How workday boundaries appear on weekly usage bars. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum WorkdayTickStyle { + Hidden, + #[default] + Subtle, + HighContrast, +} + +impl WorkdayTickStyle { + /// Lenient parse; accepts `high_contrast` and `highContrast`. + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().replace('-', "_").as_str() { + "hidden" => Some(Self::Hidden), + "subtle" => Some(Self::Subtle), + "high_contrast" | "highcontrast" => Some(Self::HighContrast), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Hidden => "hidden", + Self::Subtle => "subtle", + Self::HighContrast => "high_contrast", + } + } +} + +/// Most providers the merged Overview can show (Mac `overviewProviderLimit`). +pub const MERGED_OVERVIEW_PROVIDER_LIMIT: usize = 6; + +/// Highest accepted `weekly_progress_work_days` value. +pub const WEEKLY_PROGRESS_WORK_DAYS_MAX: u8 = 7; + +/// Normalise a work-week length: `Some(2..=7)` passes, anything else is +/// `None` (Automatic, wall-clock pacing). +pub fn normalize_weekly_progress_work_days(value: Option) -> Option { + value.filter(|days| (2..=WEEKLY_PROGRESS_WORK_DAYS_MAX).contains(days)) +} From 43293e0ee805b5671a06f47c7209189bacad50c7 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:49:54 +0700 Subject: [PATCH 2/6] Split Display into the Mac Menu Bar and Menu panes --- apps/desktop-tauri/src/App.test.tsx | 4 + .../desktop-tauri/src/components/MenuCard.tsx | 7 + .../src/components/UsageProgressBar.test.tsx | 15 + .../src/components/UsageProgressBar.tsx | 13 +- .../src/components/card/CardMetricRow.tsx | 8 +- .../src/floatbar/FloatBar.test.tsx | 4 + .../src/hooks/useTrayPanelController.ts | 12 +- apps/desktop-tauri/src/i18n/keys.ts | 37 +++ .../src/lib/metricRowModel.test.ts | 25 ++ apps/desktop-tauri/src/lib/metricRowModel.ts | 11 +- .../src/lib/overviewProviders.test.ts | 48 +++ .../src/lib/overviewProviders.ts | 49 +++ apps/desktop-tauri/src/lib/providerOrder.ts | 8 +- apps/desktop-tauri/src/styles.css | 4 + .../src/surfaces/Settings.test.tsx | 9 +- apps/desktop-tauri/src/surfaces/Settings.tsx | 8 +- .../src/surfaces/TrayPanel.test.tsx | 60 +++- apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 + .../src/surfaces/settings/SettingsSheet.tsx | 93 ++++++ .../settings/SwitcherShortcutsDialog.tsx | 82 +---- .../src/surfaces/settings/settings-layout.css | 19 ++ .../surfaces/settings/tabs/AboutTab.test.tsx | 4 + .../settings/tabs/AdvancedTab.test.tsx | 30 +- .../surfaces/settings/tabs/AdvancedTab.tsx | 55 ---- .../settings/tabs/CostPeriodControl.test.tsx | 88 ++++++ .../settings/tabs/CostPeriodControl.tsx | 99 +++--- .../settings/tabs/DisplayTab.test.tsx | 169 ---------- .../src/surfaces/settings/tabs/DisplayTab.tsx | 298 ------------------ .../settings/tabs/GeneralTab.test.tsx | 4 + .../settings/tabs/MenuBarTab.test.tsx | 207 ++++++++++++ .../src/surfaces/settings/tabs/MenuBarTab.tsx | 240 ++++++++++++++ .../surfaces/settings/tabs/MenuTab.test.tsx | 232 ++++++++++++++ .../src/surfaces/settings/tabs/MenuTab.tsx | 267 ++++++++++++++++ .../settings/tabs/UsageSpendTab.test.tsx | 103 +----- .../surfaces/settings/tabs/UsageSpendTab.tsx | 72 +---- apps/desktop-tauri/src/types/bridge.test.ts | 4 + apps/desktop-tauri/src/types/bridge.ts | 17 +- rust/src/locale.rs | 39 +++ rust/src/locale/en-US.ftl | 63 +++- rust/src/locale/es-MX.ftl | 39 +++ rust/src/locale/ja-JP.ftl | 39 +++ rust/src/locale/ko-KR.ftl | 39 +++ rust/src/locale/pt-BR.ftl | 39 +++ rust/src/locale/ru-RU.ftl | 39 +++ rust/src/locale/tr-TR.ftl | 39 +++ rust/src/locale/uk-UA.ftl | 39 +++ rust/src/locale/zh-CN.ftl | 39 +++ rust/src/locale/zh-TW.ftl | 39 +++ 48 files changed, 1986 insertions(+), 874 deletions(-) create mode 100644 apps/desktop-tauri/src/lib/overviewProviders.test.ts create mode 100644 apps/desktop-tauri/src/lib/overviewProviders.ts create mode 100644 apps/desktop-tauri/src/surfaces/settings/SettingsSheet.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.test.tsx delete mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx delete mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/MenuTab.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/MenuTab.tsx diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 656cb68fc8..fe76c8244d 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -90,6 +90,10 @@ function settings(overrides: Partial = {}): SettingsSnapshot { switcherShowsIcons: true, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarIconStyle: "bars", + mergedOverviewSelectedProviders: [], + quotaWarningMarkersVisible: true, + workdayTickStyle: "subtle", menuBarColorPace: false, providersSortedAlphabetically: false, showAsUsed: true, diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 2d42e3ece8..7c12032f39 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -3,6 +3,7 @@ import type { CostSummaryDisplayStyle, ProviderChartData, ProviderUsageSnapshot, + WorkdayTickStyle, } from "../types/bridge"; import { getProviderChartData } from "../lib/tauri"; import { useLocale } from "../hooks/useLocale"; @@ -36,7 +37,9 @@ export interface MenuCardDisplayOptions { compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; usageThresholds?: UsageThresholdSettings | null; + quotaWarningMarkersVisible?: boolean; weeklyProgressWorkDays?: number | null; + workdayTickStyle?: WorkdayTickStyle; } interface MenuCardProps { @@ -146,7 +149,9 @@ export default function MenuCard({ compactOverview = false, costSummaryDisplayStyle, usageThresholds = null, + quotaWarningMarkersVisible = true, weeklyProgressWorkDays = null, + workdayTickStyle = "subtle", } = display; const { t, language } = useLocale(); const [chartData, setChartData] = useState(null); @@ -221,7 +226,9 @@ export default function MenuCard({ compactOverview, monthlyLimitBlockNow, usageThresholds, + quotaWarningMarkersVisible, weeklyProgressWorkDays, + workdayTickStyle, }; const cardClassName = [ diff --git a/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx b/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx index a2fd5b5e4c..2cb190bc64 100644 --- a/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx +++ b/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx @@ -12,6 +12,7 @@ function bar(overrides: Partial = {}): UsageBarModel { pacePercent: null, paceDeficit: false, markers: [], + workdayTickStyle: "subtle", ...overrides, }; } @@ -101,6 +102,20 @@ describe("UsageProgressBar", () => { expect(container.querySelector("mask")).toBeNull(); }); + it("draws high contrast work day ticks 2px wide at full height", () => { + const { container } = render( + , + ); + const tick = container.querySelector(".menu-metric__progress-workday"); + expect(tick?.getAttribute("y")).toBe("0"); + expect(tick?.getAttribute("height")).toBe("6"); + expect(tick?.getAttribute("width")).toBe("2"); + expect(tick?.classList.contains("menu-metric__progress-workday--high-contrast")).toBe(true); + }); + it.each([ [false, "false"], [true, "true"], diff --git a/apps/desktop-tauri/src/components/UsageProgressBar.tsx b/apps/desktop-tauri/src/components/UsageProgressBar.tsx index a541325ed8..671280e452 100644 --- a/apps/desktop-tauri/src/components/UsageProgressBar.tsx +++ b/apps/desktop-tauri/src/components/UsageProgressBar.tsx @@ -6,7 +6,9 @@ const RADIUS = 3; const CUT_OPACITY = 0.9; const WARNING_CUT = 5; const WARNING_STRIPE = 1; -const WORKDAY_TICK = 1; +// macOS UsageProgressBar: subtle ticks are 1px and half height, high contrast +// ticks are 2px and full height (opacity lives in the CSS classes). +const WORKDAY_TICK = { subtle: 1, high_contrast: 2 } as const; const PACE_CUT = 6; const PACE_STRIPE = 2; @@ -45,6 +47,7 @@ export default function UsageProgressBar({ bar, label }: { bar: UsageBarModel; l 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; + const highContrastTicks = bar.workdayTickStyle === "high_contrast"; return ( ( ))} diff --git a/apps/desktop-tauri/src/components/card/CardMetricRow.tsx b/apps/desktop-tauri/src/components/card/CardMetricRow.tsx index ff343f39e2..a2d45fe10c 100644 --- a/apps/desktop-tauri/src/components/card/CardMetricRow.tsx +++ b/apps/desktop-tauri/src/components/card/CardMetricRow.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import type { RateWindowSnapshot } from "../../types/bridge"; +import type { RateWindowSnapshot, WorkdayTickStyle } from "../../types/bridge"; import { useLocale } from "../../hooks/useLocale"; import { useFormattedResetTime, @@ -32,7 +32,9 @@ export interface MetricRowDisplay { compactOverview?: boolean; monthlyLimitBlockNow?: number; usageThresholds?: UsageThresholdSettings | null; + quotaWarningMarkersVisible?: boolean; weeklyProgressWorkDays?: number | null; + workdayTickStyle?: WorkdayTickStyle; } function useCountdownNow(ticking: boolean): number { @@ -74,7 +76,9 @@ export default function CardMetricRow({ compactOverview = false, monthlyLimitBlockNow, usageThresholds = null, + quotaWarningMarkersVisible = true, weeklyProgressWorkDays = null, + workdayTickStyle = "subtle", } = 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. @@ -132,7 +136,9 @@ export default function CardMetricRow({ showResetWhenExhausted, paceEnabled: showPace, usageThresholds, + quotaWarningMarkersVisible, weeklyProgressWorkDays, + workdayTickStyle, now, }, t, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 1e3c6d5b74..ece25d6a42 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -157,6 +157,10 @@ function settings(overrides: Partial = {}): SettingsSnapshot { switcherShowsIcons: true, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarIconStyle: "bars", + mergedOverviewSelectedProviders: [], + quotaWarningMarkersVisible: true, + workdayTickStyle: "subtle", menuBarColorPace: false, providersSortedAlphabetically: false, showAsUsed: true, diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 477effcb02..f0ff2f6b1c 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -25,6 +25,7 @@ import type { MenuFooterRow } from "../components/MenuSurface"; import { accountMenuAction } from "../lib/accountMenuRow"; import { orderProviderSnapshots } from "../lib/providerOrder"; import { clampTrayScalePercent } from "../lib/trayScale"; +import { selectOverviewProviders } from "../lib/overviewProviders"; import { hydrateProviderSlots, orderedEnabledProviderSlots, @@ -155,7 +156,8 @@ export function useTrayPanelController(state: BootstrapState) { if (sorted.length + 1 > DENSE_OVERVIEW_THRESHOLD && !gridExpanded) { return denseTrayProviders.slice(0, 4); } - return sorted; + // Settings > Menu Bar > Overview providers: the chosen ones, else the first six. + return selectOverviewProviders(sorted, settings.mergedOverviewSelectedProviders ?? []); } // Detail: show ONLY the selected provider (macOS behavior — no appended errors) const match = sorted.find((p) => p.providerId === selectedProviderId); @@ -163,7 +165,13 @@ export function useTrayPanelController(state: BootstrapState) { return sorted; } return [match]; - }, [denseTrayProviders, sorted, selectedProviderId, gridExpanded]); + }, [ + denseTrayProviders, + sorted, + selectedProviderId, + gridExpanded, + settings.mergedOverviewSelectedProviders, + ]); const actionProvider = selectedProviderId !== null diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 91291b5bd3..94727f066e 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -1508,6 +1508,43 @@ export const ALL_LOCALE_KEYS = [ "DurationHours", "DurationMinutes", "DurationMonths", + "MenuBarIconSectionTitle", + "MenuBarIconStyleLabel", + "MenuBarIconStyleHelper", + "MenuBarIconStyleBars", + "MenuBarIconStylePercent", + "CombinedIconSectionTitle", + "CombinedIconStyleLabel", + "CombinedIconStyleSwitcher", + "CombinedIconStyleStacked", + "ShowEveryProviderInTrayMenuLabel", + "ShowEveryProviderInTrayMenuHelper", + "SwitcherRowsLabel", + "SwitcherRowsProviderIcons", + "SwitcherRowsWeeklyProgress", + "OverviewProvidersLabel", + "OverviewProvidersConfigure", + "OverviewProvidersChooseUpTo", + "OverviewProvidersDefaultSummary", + "QuotaWarningMarkersLabel", + "QuotaWarningMarkersHelper", + "WorkDaysLabel", + "WorkDaysCount", + "WorkdayTickAppearanceLabel", + "WorkdayTickAppearanceHelper", + "WorkdayTickHidden", + "WorkdayTickSubtle", + "WorkdayTickHighContrast", + "UsageBarsFillLabel", + "UsageBarsFillRemaining", + "UsageBarsFillUsed", + "ResetTimesLabel", + "ResetTimesCountdown", + "ResetTimesClockTime", + "MenuUsageSectionTitle", + "MenuContentSectionTitle", + "MenuCostSummarySectionTitle", + "MenuWindowsSectionTitle", ] as const; export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number]; diff --git a/apps/desktop-tauri/src/lib/metricRowModel.test.ts b/apps/desktop-tauri/src/lib/metricRowModel.test.ts index abfccaefe0..971534a96d 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.test.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.test.ts @@ -51,6 +51,8 @@ function input(overrides: Partial = {}): MetricRowInput { paceEnabled: true, usageThresholds: { highUsageThreshold: 70, criticalUsageThreshold: 90, providerUsageThresholds: {} }, weeklyProgressWorkDays: null, + quotaWarningMarkersVisible: true, + workdayTickStyle: "subtle", now: NOW, ...overrides, }; @@ -356,6 +358,29 @@ describe("metricRowPresentation bar", () => { } }); + it("hides quota warning markers when the toggle is off", () => { + const row = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), weeklyProgressWorkDays: 5, quotaWarningMarkersVisible: false }), + en, + ); + expect(row.bar.markers.map((m) => m.kind)).toEqual(["workday", "workday", "workday", "workday"]); + expect(metricRowPresentation(input({ quotaWarningMarkersVisible: false }), en).bar.valueText).toBe("63%"); + }); + + it("hides work day ticks for the hidden style and passes the visible style to the bar", () => { + const hidden = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), weeklyProgressWorkDays: 5, workdayTickStyle: "hidden" }), + en, + ); + expect(hidden.bar.markers.map((m) => m.kind)).toEqual(["warning", "warning"]); + const strong = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), weeklyProgressWorkDays: 7, workdayTickStyle: "high_contrast" }), + en, + ); + expect(strong.bar.workdayTickStyle).toBe("high_contrast"); + expect(strong.bar.markers.filter((m) => m.kind === "workday")).toHaveLength(6); + }); + it("describes the value and markers for assistive technology", () => { expect(metricRowPresentation(input(), en).bar).toMatchObject({ valuePercent: 63, diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index a2bbeda95b..9569d809c0 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -5,6 +5,7 @@ import type { RateWindowSnapshot, SettingsSnapshot, WindowPaceSnapshot, + WorkdayTickStyle, } from "../types/bridge"; import { resetDescriptionFallback } from "./usageWindows"; @@ -82,7 +83,11 @@ export interface MetricRowInput { showResetWhenExhausted: boolean; paceEnabled: boolean; usageThresholds: UsageThresholdSettings | null; + /** Settings > Menu > Show quota warning markers. */ + quotaWarningMarkersVisible: boolean; weeklyProgressWorkDays: number | null; + /** Settings > Menu > Workday tick appearance; "hidden" draws no workday ticks. */ + workdayTickStyle: WorkdayTickStyle; now: number; } @@ -98,6 +103,8 @@ export interface UsageBarModel { pacePercent: number | null; paceDeficit: boolean; markers: UsageBarMarker[]; + /** How `workday` markers are drawn. */ + workdayTickStyle: Exclude; } export interface MetricRowPresentation { @@ -260,7 +267,7 @@ function barMarkers(input: MetricRowInput): UsageBarMarker[] { const window = policy.thresholdWindow; const settings = input.usageThresholds; const warnings = - window && settings + window && settings && input.quotaWarningMarkersVisible ? normalizedMarkerPercents( (["high", "critical"] as const).map((field) => { const usedThreshold = threshold(settings, input.providerId, window, field); @@ -271,6 +278,7 @@ function barMarkers(input: MetricRowInput): UsageBarMarker[] { const workDays = input.weeklyProgressWorkDays; const showsWorkdays = policy.workdayTicks && + input.workdayTickStyle !== "hidden" && input.snap.windowMinutes === WEEK_MINUTES && workDays != null && workDays >= 2 && @@ -333,6 +341,7 @@ export function metricRowPresentation(input: MetricRowInput, t: Translate): Metr pacePercent, paceDeficit: visible ? visible.pace.actualUsedPercent > visible.pace.expectedUsedPercent : false, markers, + workdayTickStyle: input.workdayTickStyle === "high_contrast" ? "high_contrast" : "subtle", }, }; } diff --git a/apps/desktop-tauri/src/lib/overviewProviders.test.ts b/apps/desktop-tauri/src/lib/overviewProviders.test.ts new file mode 100644 index 0000000000..86fc026b07 --- /dev/null +++ b/apps/desktop-tauri/src/lib/overviewProviders.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + MERGED_OVERVIEW_PROVIDER_LIMIT, + effectiveOverviewSelection, + selectOverviewProviders, + toggleOverviewProvider, +} from "./overviewProviders"; + +const ids = ["codex", "claude", "gemini", "cursor", "copilot", "zai", "kimi", "amp"]; +const snaps = ids.map((providerId) => ({ providerId })); + +describe("selectOverviewProviders", () => { + it("shows the first six providers when nothing is selected", () => { + expect(MERGED_OVERVIEW_PROVIDER_LIMIT).toBe(6); + expect(selectOverviewProviders(snaps, []).map((p) => p.providerId)).toEqual(ids.slice(0, 6)); + }); + + it("shows the selected providers in provider order", () => { + expect(selectOverviewProviders(snaps, ["amp", "codex"]).map((p) => p.providerId)).toEqual(["codex", "amp"]); + }); + + it("falls back to the first six when no selected provider is present", () => { + expect(selectOverviewProviders(snaps, ["gone"]).map((p) => p.providerId)).toEqual(ids.slice(0, 6)); + }); + + it("never shows more than six even for a longer saved list", () => { + expect(selectOverviewProviders(snaps, ids)).toHaveLength(6); + }); +}); + +describe("toggleOverviewProvider", () => { + it("starts from the default six when unchecking", () => { + expect(toggleOverviewProvider(ids, [], "claude", false)).toEqual(["codex", "gemini", "cursor", "copilot", "zai"]); + }); + + it("refuses a seventh provider", () => { + expect(toggleOverviewProvider(ids, [], "kimi", true)).toEqual(ids.slice(0, 6)); + }); + + it("adds below the limit and keeps provider order", () => { + expect(toggleOverviewProvider(ids, ["amp"], "claude", true)).toEqual(["claude", "amp"]); + }); + + it("saves an empty list when the last provider is unchecked", () => { + expect(toggleOverviewProvider(ids, ["amp"], "amp", false)).toEqual([]); + expect(effectiveOverviewSelection(ids, [])).toEqual(ids.slice(0, 6)); + }); +}); diff --git a/apps/desktop-tauri/src/lib/overviewProviders.ts b/apps/desktop-tauri/src/lib/overviewProviders.ts new file mode 100644 index 0000000000..49a13cb35c --- /dev/null +++ b/apps/desktop-tauri/src/lib/overviewProviders.ts @@ -0,0 +1,49 @@ +/** Mirrors `MERGED_OVERVIEW_PROVIDER_LIMIT` in `rust/src/settings/types.rs` (macOS limit). */ +export const MERGED_OVERVIEW_PROVIDER_LIMIT = 6; + +/** + * Providers shown on the tray panel's Overview, in `ordered` order: the saved + * selection when any of it is still present, otherwise the first six + * (macOS `mergedOverviewSelectedProviders`; empty means "first six"). + */ +export function selectOverviewProviders( + ordered: readonly T[], + selection: readonly string[], +): T[] { + const chosen = new Set(selection); + const picked = ordered.filter((provider) => chosen.has(provider.providerId)); + return (picked.length > 0 ? picked : ordered).slice(0, MERGED_OVERVIEW_PROVIDER_LIMIT); +} + +/** The ids the Overview shows today, for the Settings summary and dialog. */ +export function effectiveOverviewSelection( + enabledInOrder: readonly string[], + selection: readonly string[], +): string[] { + return selectOverviewProviders( + enabledInOrder.map((providerId) => ({ providerId })), + selection, + ).map((provider) => provider.providerId); +} + +/** + * The selection after checking or unchecking one provider in the Configure + * sheet. Starts from what Overview shows today, keeps provider order, and + * never exceeds the limit. Unchecking the last one saves an empty list, + * which means the default first six again. + */ +export function toggleOverviewProvider( + enabledInOrder: readonly string[], + selection: readonly string[], + providerId: string, + checked: boolean, +): string[] { + const current = new Set(effectiveOverviewSelection(enabledInOrder, selection)); + if (checked) { + if (current.size >= MERGED_OVERVIEW_PROVIDER_LIMIT) return [...current]; + current.add(providerId); + } else { + current.delete(providerId); + } + return enabledInOrder.filter((id) => current.has(id)); +} diff --git a/apps/desktop-tauri/src/lib/providerOrder.ts b/apps/desktop-tauri/src/lib/providerOrder.ts index 7ec13e6ae8..709909e4c2 100644 --- a/apps/desktop-tauri/src/lib/providerOrder.ts +++ b/apps/desktop-tauri/src/lib/providerOrder.ts @@ -1,11 +1,13 @@ import type { ProviderCatalogEntry, ProviderUsageSnapshot } from "../types/bridge"; -export function orderProviderSnapshots( - providers: ProviderUsageSnapshot[], +export function orderProviderSnapshots< + T extends Pick, +>( + providers: T[], catalog: ProviderCatalogEntry[], enabledProviderIds: string[], providerOrder: string[] = [], -): ProviderUsageSnapshot[] { +): T[] { const order = new Map(); const orderedIds = providerOrder.length > 0 ? providerOrder diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index d6a478c5fb..46e5af3887 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3421,6 +3421,10 @@ html:has(.menu-surface--tray) { fill: color-mix(in srgb, var(--text-primary) 30%, transparent); } +.menu-metric__progress-workday--high-contrast { + fill: color-mix(in srgb, var(--text-primary) 85%, transparent); +} + .menu-metric__progress-pace { fill: var(--mac-pace-ok, #34c759); } diff --git a/apps/desktop-tauri/src/surfaces/Settings.test.tsx b/apps/desktop-tauri/src/surfaces/Settings.test.tsx index b0b84ba3d0..202c6782e0 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.test.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.test.tsx @@ -42,8 +42,11 @@ vi.mock("../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => k vi.mock("./settings/tabs/GeneralTab", () => ({ default: ({ mode }: { mode: string }) =>
general:{mode}
, })); -vi.mock("./settings/tabs/DisplayTab", () => ({ - default: ({ mode }: { mode: string }) =>
display:{mode}
, +vi.mock("./settings/tabs/MenuBarTab", () => ({ + default: () =>
menuBar
, +})); +vi.mock("./settings/tabs/MenuTab", () => ({ + default: () =>
menu
, })); vi.mock("./settings/tabs/AdvancedTab", () => ({ default: () =>
advanced
, @@ -124,7 +127,7 @@ describe("Settings sidebar", () => { expect(title()).toBe("TabGeneral"); fireEvent.click(screen.getByRole("button", { name: "TabMenuBar" })); expect(title()).toBe("TabMenuBar"); - expect(screen.getByTestId("tab").textContent).toBe("display:menuBar"); + expect(screen.getByTestId("tab").textContent).toBe("menuBar"); fireEvent.click(row("claude")); expect(title()).toBe("Claude"); expect(screen.getByTestId("tab").textContent).toBe("providers:claude"); diff --git a/apps/desktop-tauri/src/surfaces/Settings.tsx b/apps/desktop-tauri/src/surfaces/Settings.tsx index a7e07474d9..15e5760eb6 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.tsx @@ -21,7 +21,8 @@ import { } from "./settings/settingsTabs"; import { SettingsSidebar } from "./settings/SettingsSidebar"; import GeneralTab from "./settings/tabs/GeneralTab"; -import DisplayTab from "./settings/tabs/DisplayTab"; +import MenuBarTab from "./settings/tabs/MenuBarTab"; +import MenuTab from "./settings/tabs/MenuTab"; import AdvancedTab from "./settings/tabs/AdvancedTab"; import AboutTab from "./settings/tabs/AboutTab"; import ProvidersTab from "./settings/tabs/ProvidersTab"; @@ -225,8 +226,7 @@ export default function Settings({ )} {activeTab === "menuBar" && ( - )} {activeTab === "menu" && ( - + )} {activeTab === "usageSpend" && ( diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 11a77c9a91..1bbcbb5f94 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -155,6 +155,10 @@ function settings(overrides: Partial = {}): SettingsSnapshot { switcherShowsIcons: true, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarIconStyle: "bars", + mergedOverviewSelectedProviders: [], + quotaWarningMarkersVisible: true, + workdayTickStyle: "subtle", menuBarColorPace: false, providersSortedAlphabetically: false, showAsUsed: true, @@ -212,6 +216,20 @@ function bootstrap( }; } +/** The first `count` test providers, enabled and ordered as in the catalog. */ +function overviewOrder(count: number): Partial { + const ids = TEST_PROVIDER_CATALOG.slice(0, count).map(([id]) => id); + return { enabledProviders: ids, providerOrder: ids }; +} + +function overviewCatalog(count: number): ProviderCatalogEntry[] { + return TEST_PROVIDER_CATALOG.slice(0, count).map(([id, displayName]) => ({ + id, + displayName, + cookieDomain: null, + })); +} + function renderTrayPanel( providers: ProviderUsageSnapshot[], settingsOverrides: Partial = {}, @@ -753,6 +771,42 @@ describe("TrayPanel provider grid", () => { expect(container.querySelectorAll(".menu-metric")).toHaveLength(3); }); + it("shows the first six providers on Overview when none are chosen", async () => { + const providers = TEST_PROVIDER_CATALOG.slice(0, 8).map(([id, displayName], index) => + provider(id, displayName, (index * 7) % 100), + ); + const { container } = renderTrayPanel(providers, overviewOrder(8), overviewCatalog(8)); + + await waitFor(() => { + expect(container.querySelectorAll(".menu-stack__item")).toHaveLength(6); + }); + expect( + Array.from(container.querySelectorAll(".card-header__name"), (node) => node.textContent), + ).toEqual(TEST_PROVIDER_CATALOG.slice(0, 6).map(([, displayName]) => displayName)); + }); + + it("shows only the chosen Overview providers, in provider order", async () => { + const providers = TEST_PROVIDER_CATALOG.slice(0, 8).map(([id, displayName], index) => + provider(id, displayName, (index * 7) % 100), + ); + const [first, , third, , , , seventh] = TEST_PROVIDER_CATALOG; + const { container } = renderTrayPanel( + providers, + { + ...overviewOrder(8), + mergedOverviewSelectedProviders: [seventh[0], first[0], third[0], "not-present"], + }, + overviewCatalog(8), + ); + + await waitFor(() => { + expect(container.querySelectorAll(".menu-stack__item")).toHaveLength(3); + }); + expect( + Array.from(container.querySelectorAll(".card-header__name"), (node) => node.textContent), + ).toEqual([first[1], third[1], seventh[1]]); + }); + it("collapses and expands the full provider catalog in the dense tray grid", async () => { const providers = TEST_PROVIDER_CATALOG.map(([id, displayName], index) => provider(id, displayName, (index * 7) % 100), @@ -789,9 +843,9 @@ describe("TrayPanel provider grid", () => { ); }); expect(grid?.getAttribute("data-expanded")).toBe("true"); - expect(container.querySelectorAll(".menu-stack__item")).toHaveLength( - providers.length, - ); + // The expanded grid lists every provider; Overview still shows the + // Settings > Menu Bar > Overview providers default, the first six. + expect(container.querySelectorAll(".menu-stack__item")).toHaveLength(6); for (const [id, displayName] of TEST_PROVIDER_CATALOG) { expect( container.querySelector(`.provider-grid__item[aria-label="${displayName}"]`), diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 66da64f9b0..aea467b0f1 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -90,7 +90,9 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { selectedProviderId === null && settings.overviewLayout !== "detailed", costSummaryDisplayStyle: settings.costSummaryDisplayStyle, usageThresholds: settings, + quotaWarningMarkersVisible: settings.quotaWarningMarkersVisible ?? true, weeklyProgressWorkDays: settings.weeklyProgressWorkDays ?? null, + workdayTickStyle: settings.workdayTickStyle ?? "subtle", }} accentColor={settings.providerAccentColors[p.providerId]} onLayoutChange={requestLayout} diff --git a/apps/desktop-tauri/src/surfaces/settings/SettingsSheet.tsx b/apps/desktop-tauri/src/surfaces/settings/SettingsSheet.tsx new file mode 100644 index 0000000000..d46bfbfa1a --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/SettingsSheet.tsx @@ -0,0 +1,93 @@ +import type React from "react"; +import { useEffect, useId, useRef } from "react"; +import { useLocale } from "../../hooks/useLocale"; + +const FOCUSABLE = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** Keeps Tab and Shift+Tab inside the modal sheet. */ +function trapTab(dialog: HTMLElement, event: React.KeyboardEvent) { + const focusable = Array.from(dialog.querySelectorAll(FOCUSABLE)); + if (focusable.length === 0) { + event.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + const outside = !focusable.includes(active as HTMLElement); + if (event.shiftKey && (active === first || outside)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (active === last || outside)) { + event.preventDefault(); + first.focus(); + } +} + +/** + * In-window modal sheet for a Settings sub-page. Tab stays inside the sheet, + * Escape, a backdrop click or Done closes it, and focus returns to the control + * that opened it. Changes inside save at once, like the rest of Settings. + */ +export default function SettingsSheet({ + title, + onClose, + children, +}: { + title: string; + onClose: () => void; + children: React.ReactNode; +}) { + const { t } = useLocale(); + const titleId = useId(); + const dialogRef = useRef(null); + + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + dialogRef.current?.focus(); + return () => opener?.focus?.(); + }, []); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
{ + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } else if (event.key === "Tab" && dialogRef.current) { + trapTab(dialogRef.current, event); + } + }} + > +
+

+ {title} +

+
+
{children}
+
+ +
+
+
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.tsx b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.tsx index 7d2dd0082d..d2f5e0090f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.tsx @@ -1,36 +1,11 @@ -import type React from "react"; -import { useEffect, useId, useRef } from "react"; import { useLocale } from "../../hooks/useLocale"; +import SettingsSheet from "./SettingsSheet"; import SwitcherShortcutsSection from "./SwitcherShortcutsSection"; import type { TabProps } from "./settingsTabs"; -const FOCUSABLE = - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; - -/** Keeps Tab and Shift+Tab inside the modal sheet. */ -function trapTab(dialog: HTMLElement, event: React.KeyboardEvent) { - const focusable = Array.from(dialog.querySelectorAll(FOCUSABLE)); - if (focusable.length === 0) { - event.preventDefault(); - return; - } - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = document.activeElement; - const outside = !focusable.includes(active as HTMLElement); - if (event.shiftKey && (active === first || outside)) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && (active === last || outside)) { - event.preventDefault(); - first.focus(); - } -} - /** * In-window sheet for the provider-switcher keys, opened from - * Settings > General > Portable preferences. Tab stays inside the sheet, - * Escape or Done closes it and focus returns to the button that opened it. Every change saves at once, + * Settings > General > Portable preferences. Every change saves at once, * like the rest of Settings. */ export default function SwitcherShortcutsDialog({ @@ -40,56 +15,9 @@ export default function SwitcherShortcutsDialog({ onClose, }: TabProps & { onClose: () => void }) { const { t } = useLocale(); - const titleId = useId(); - const dialogRef = useRef(null); - - useEffect(() => { - const opener = document.activeElement as HTMLElement | null; - dialogRef.current?.focus(); - return () => opener?.focus?.(); - }, []); - return ( -
{ - if (event.target === event.currentTarget) onClose(); - }} - > -
{ - if (event.key === "Escape") { - event.stopPropagation(); - onClose(); - } else if (event.key === "Tab" && dialogRef.current) { - trapTab(dialogRef.current, event); - } - }} - > -
-

- {t("SwitcherShortcutsTitle")} -

-
-
- -
-
- -
-
-
+ + + ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css index 04f2dd9196..f31e27ba98 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css +++ b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css @@ -837,3 +837,22 @@ bottom: 12px; margin: 0; } + +/* Settings > Menu Bar > Overview providers sheet: one checkbox per provider. */ +.overview-providers-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 0; +} + +/* Settings > Menu > Cost summary: History window menu plus the custom day count. */ +.cost-period-control { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.cost-period-control .number-input { + width: 72px; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 15ff6d9533..1766a9da58 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -66,6 +66,10 @@ const settings: SettingsSnapshot = { switcherShowsIcons: true, menuBarShowsHighestUsage: true, menuBarShowsPercent: true, + menuBarIconStyle: "percent", + mergedOverviewSelectedProviders: [], + quotaWarningMarkersVisible: true, + workdayTickStyle: "subtle", menuBarColorPace: false, providersSortedAlphabetically: false, showAsUsed: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx index 03f8868229..d5d21f0ceb 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -34,6 +34,10 @@ const settings: SettingsSnapshot = { switcherShowsIcons: true, menuBarShowsHighestUsage: true, menuBarShowsPercent: true, + menuBarIconStyle: "percent", + mergedOverviewSelectedProviders: [], + quotaWarningMarkersVisible: true, + workdayTickStyle: "subtle", menuBarColorPace: false, providersSortedAlphabetically: false, showAsUsed: false, @@ -188,26 +192,11 @@ describe("AdvancedTab", () => { expect(screen.getAllByText("HooksEnableLabel")).toHaveLength(1); }); - it("toggles Stay Awake independently of agent session discovery", () => { - const set = vi.fn(); - render( - , - ); - - const toggle = screen - .getByText("AgentSessionsStayAwakeLabel") - .closest(".settings-field") - ?.querySelector('input[type="checkbox"]'); - expect(toggle).not.toBeNull(); - expect(toggle).not.toBeDisabled(); - expect(toggle).not.toBeChecked(); - - fireEvent.click(toggle as Element); - expect(set).toHaveBeenCalledWith({ stayAwakeEnabled: true }); + it("leaves Agent sessions to Settings > Menu", () => { + render(); + expect(screen.queryByText("AgentSessionsTitle")).toBeNull(); + expect(screen.queryByText("AgentSessionsStayAwakeLabel")).toBeNull(); + expect(screen.queryByText("AgentSessionsSshHostsLabel")).toBeNull(); }); }); @@ -223,7 +212,6 @@ describe("AdvancedTab Mac grouped layout", () => { expect(titles).toEqual([ "PrivacyTitle", "CodexLocalLogsTitle", - "AgentSessionsTitle", "SectionLocalIntegrations", "NetworkProxyTitle", "HooksTitle", diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx index 87a376816f..96f213bc9f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx @@ -15,10 +15,6 @@ function parseCodexSessionsDirs(value: string): string[] { .filter(Boolean); } -function parseSshHosts(value: string): string[] { - return value.split(/[,\n]/).map((host) => host.trim()).filter(Boolean); -} - export default function AdvancedTab({ settings, set, saving }: TabProps) { const { t } = useLocale(); const [diagnosticsStatus, setDiagnosticsStatus] = useState( @@ -27,9 +23,6 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { const [codexDirsDraft, setCodexDirsDraft] = useState(() => formatCodexSessionsDirs(settings.codexCustomSessionsDirs), ); - const [sshHostsDraft, setSshHostsDraft] = useState(() => - (settings.agentSessionSshHosts ?? []).join(", "), - ); const [proxyUrlDraft, setProxyUrlDraft] = useState(() => settings.httpProxyUrl ?? "", ); @@ -60,10 +53,6 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { } }, [saving, settings.codexCustomSessionsDirs]); - useEffect(() => { - if (!saving) setSshHostsDraft((settings.agentSessionSshHosts ?? []).join(", ")); - }, [saving, settings.agentSessionSshHosts]); - useEffect(() => { if (!saving) setProxyUrlDraft(settings.httpProxyUrl ?? ""); }, [saving, settings.httpProxyUrl]); @@ -158,50 +147,6 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { - - - set({ agentSessionsEnabled: v })} - /> - - - set({ stayAwakeEnabled: v })} - /> - - - setSshHostsDraft(event.target.value)} - onBlur={() => set({ agentSessionSshHosts: parseSshHosts(sshHostsDraft) })} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - /> - - - = { + CostPeriodHistoryWindow: "History window", + CostPeriodMonthToDate: "Month to date", + CostPeriodAll: "All available history", + CostPeriodToday: "Today", + CostPeriodLastDays: "Last {} days", + CostPeriodCustom: "Custom days", + CostPeriodCustomDays: "Custom days count", +}; +const t = (key: string) => EN[key] ?? key; + +function renderControl(value: string, onChange = vi.fn()) { + const view = render( + + + , + ); + return { ...view, onChange }; +} + +const picker = () => screen.getByRole("combobox", { name: "History window" }) as HTMLSelectElement; + +describe("CostPeriodControl", () => { + it("is named by its Field and shows the saved preset", () => { + renderControl("month-to-date"); + expect(picker().value).toBe("month-to-date"); + expect(picker().className).toBe("select select--menu"); + }); + + it("saves a preset choice", () => { + const { onChange } = renderControl("month-to-date"); + fireEvent.change(picker(), { target: { value: "rolling:90" } }); + expect(onChange).toHaveBeenCalledWith("rolling:90"); + }); + + it("offers a custom day count and saves one valid value on blur", () => { + const { onChange } = renderControl("month-to-date"); + fireEvent.change(picker(), { target: { value: "custom" } }); + expect(onChange).not.toHaveBeenCalled(); + const input = screen.getByLabelText("Custom days count") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "400" } }); + expect(input.getAttribute("aria-invalid")).toBe("true"); + fireEvent.change(input, { target: { value: "0" } }); + // Typing "1" on the way to "14" saves nothing and keeps the input open. + fireEvent.change(input, { target: { value: "1" } }); + expect(screen.getByLabelText("Custom days count")).toBe(input); + fireEvent.change(input, { target: { value: "14" } }); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.blur(input); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("rolling:14"); + expect(input.getAttribute("aria-invalid")).toBe("false"); + }); + + it("commits a valid custom day count on Enter", () => { + const { onChange } = renderControl("month-to-date"); + fireEvent.change(picker(), { target: { value: "custom" } }); + const input = screen.getByLabelText("Custom days count"); + fireEvent.change(input, { target: { value: "45" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("rolling:45"); + }); + + it("starts in custom mode for a saved count that is not a preset", () => { + renderControl("rolling:14"); + expect((screen.getByLabelText("Custom days count") as HTMLInputElement).value).toBe("14"); + expect(picker().value).toBe("custom"); + }); + + it("follows a value saved elsewhere", () => { + const { rerender } = renderControl("rolling:14"); + rerender( + + + , + ); + expect(picker().value).toBe("all"); + expect(screen.queryByLabelText("Custom days count")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx index 5e0c517a2d..272dea4f8a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useFieldAria } from "../../../components/FormControls"; import type { LocaleKey } from "../../../i18n/keys"; import { MAX_ROLLING_DAYS, @@ -16,6 +17,9 @@ const CUSTOM_OPTION = "custom"; * day count in 1..=365. `value` is the persisted form (`rolling:N`, * `month-to-date`, `all`). A custom count is committed on blur or Enter, once * it is valid, so typing a multi-digit value triggers one rescan. + * + * Renders inside a `Field` (Settings > Menu > Cost summary), which names the + * picker. */ export default function CostPeriodControl({ value, @@ -40,6 +44,7 @@ export default function CostPeriodControl({ } }, [value]); + const fieldAria = useFieldAria(); const customInvalid = customMode && customText !== "" && customPeriodRaw(customText) === null; const commitCustom = () => { const raw = customPeriodRaw(customText); @@ -47,59 +52,53 @@ export default function CostPeriodControl({ }; return ( -
- -

{t("CostPeriodHistoryWindowHelper")}

-
- { + const next = event.target.value; + if (next === CUSTOM_OPTION) { + // Nothing is saved until a valid count is typed. + setCustomMode(true); + setCustomText(String(rollingDays(value) ?? "")); + return; + } + setCustomMode(false); + onChange(next); + }} + > + {PRESET_COST_PERIODS.map((raw) => ( + + ))} + + + {customMode && ( + { - const next = event.target.value; - if (next === CUSTOM_OPTION) { - // Nothing is saved until a valid count is typed. - setCustomMode(true); - setCustomText(String(rollingDays(value) ?? "")); - return; + aria-label={t("CostPeriodCustomDays")} + aria-invalid={customInvalid} + onChange={(event) => setCustomText(event.target.value)} + onBlur={commitCustom} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitCustom(); } - setCustomMode(false); - onChange(next); }} - > - {PRESET_COST_PERIODS.map((raw) => ( - - ))} - - - {customMode && ( - setCustomText(event.target.value)} - onBlur={commitCustom} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - commitCustom(); - } - }} - /> - )} -
+ /> + )}
); } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx deleted file mode 100644 index bc118b31ed..0000000000 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("../../../hooks/useLocale", () => ({ - useLocale: () => ({ t: (key: string) => key, language: "english" }), -})); -// The FloatBar section pulls in its own bridge dependencies; it is irrelevant -// to the display controls under test. -vi.mock("../../../floatbar/SettingsSection", () => ({ - default: () => null, -})); - -import DisplayTab from "./DisplayTab"; -import type { SettingsSnapshot } from "../../../types/bridge"; - -const baseSettings = { - enabledProviders: ["codex", "claude"], - trayIconMode: "single", - stackedTrayTopProvider: null, - stackedTrayBottomProvider: null, - trayPanelAlwaysOnTop: false, - switcherShowsIcons: false, - menuBarShowsHighestUsage: false, - menuBarShowsPercent: false, - menuBarColorPace: false, - providersSortedAlphabetically: false, - menuBarDisplayMode: "detailed", - overviewLayout: "detailed", - windowScalePercent: 100, - showAsUsed: false, - showAllTokenAccountsInMenu: false, - resetTimeRelative: false, - showResetWhenExhausted: false, - showPace: false, -} as unknown as SettingsSnapshot; - -function renderTab( - set: (patch: Record) => void, - mode: "menuBar" | "menu" = "menu", -) { - return render( - , - ); -} - -describe("DisplayTab menu settings", () => { - it("offers the tray Panel scale as its only slider, with no PopOut window scale", () => { - const { container } = renderTab(vi.fn()); - - expect( - Array.from(container.querySelectorAll('input[type="range"]')).map((input) => - input.getAttribute("aria-label"), - ), - ).toEqual(["PanelScaleLabel"]); - }); - - it("shows the saved Panel scale and saves the new one when the slider is released", async () => { - const set = vi.fn(); - await act(async () => { - render( - , - ); - }); - - const slider = screen.getByRole("slider", { name: "PanelScaleLabel" }); - expect(slider).toHaveValue("150"); - expect(screen.getByText("PanelScaleLabel (150%)")).toBeInTheDocument(); - - fireEvent.change(slider, { target: { value: "125" } }); - expect(screen.getByText("PanelScaleLabel (125%)")).toBeInTheDocument(); - expect(set).not.toHaveBeenCalled(); - fireEvent.pointerUp(slider); - - expect(set).toHaveBeenCalledTimes(1); - expect(set).toHaveBeenCalledWith({ trayScalePercent: 125 }); - }); - - it("updates the exhausted reset display preference", () => { - const set = vi.fn(); - renderTab(set); - - fireEvent.click(screen.getByRole("checkbox", { name: "ShowResetWhenExhausted" })); - - expect(set).toHaveBeenCalledWith({ showResetWhenExhausted: true }); - }); - - it("updates the show pace preference", () => { - const set = vi.fn(); - renderTab(set); - - fireEvent.click(screen.getByRole("checkbox", { name: "ShowPace" })); - - expect(set).toHaveBeenCalledWith({ showPace: true }); - }); - - it("updates the Overview layout preference", () => { - const set = vi.fn(); - renderTab(set); - - fireEvent.change(screen.getByRole("combobox"), { - target: { value: "compact" }, - }); - - expect(set).toHaveBeenCalledWith({ overviewLayout: "compact" }); - }); - - it("updates the tray panel always-on-top preference", () => { - const set = vi.fn(); - renderTab(set); - - fireEvent.click( - screen.getByRole("checkbox", { name: "TrayPanelAlwaysOnTopLabel" }), - ); - - expect(set).toHaveBeenCalledWith({ trayPanelAlwaysOnTop: true }); - }); - - it("updates the tray pace color preference", () => { - const set = vi.fn(); - renderTab(set, "menuBar"); - - fireEvent.click(screen.getByRole("checkbox", { name: "ColorPaceInTray" })); - - expect(set).toHaveBeenCalledWith({ menuBarColorPace: true }); - }); -}); - -describe("DisplayTab stacked tray providers", () => { - it("persists explicit top and bottom provider choices", () => { - const set = vi.fn(); - render( - , - ); - const selects = screen.getAllByRole("combobox"); - - fireEvent.change(selects[1], { target: { value: "claude" } }); - fireEvent.change(selects[2], { target: { value: "codex" } }); - - expect(set).toHaveBeenCalledWith({ stackedTrayTopProvider: "claude" }); - expect(set).toHaveBeenCalledWith({ stackedTrayBottomProvider: "codex" }); - }); -}); - -describe("DisplayTab switcher shortcuts", () => { - it("no longer hosts the provider switcher shortcuts on the Menu tab", () => { - renderTab(vi.fn(), "menu"); - expect(screen.queryByText("SwitcherShortcutsTitle")).toBeNull(); - expect(screen.queryByRole("button", { name: "SwitcherShortcutReset" })).toBeNull(); - }); -}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx deleted file mode 100644 index 572b91beda..0000000000 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import { useEffect, useState } from "react"; -import { useDraftNumber } from "../../../hooks/useDraftNumber"; -import { useLocale } from "../../../hooks/useLocale"; -import { Field, Select, Toggle } from "../../../components/FormControls"; -import type { - MenuBarDisplayMode, - OverviewLayout, - ProviderCatalogEntry, - TrayIconMode, - TrayVisibilityStatusDto, -} from "../../../types/bridge"; -import type { TabProps } from "../settingsTabs"; -import FloatBarSettingsSection from "../../../floatbar/SettingsSection"; -import { getTrayVisibilityStatus } from "../../../lib/tauri"; -import { - clampTrayScalePercent, - TRAY_SCALE_MAX, - TRAY_SCALE_MIN, - TRAY_SCALE_STEP, -} from "../../../lib/trayScale"; - -export default function DisplayTab({ - mode = "menu", - settings, - set, - saving, - providers = [], -}: TabProps & { - mode?: "menuBar" | "menu"; - providers?: ProviderCatalogEntry[]; -}) { - const { t } = useLocale(); - const [trayVisibility, setTrayVisibility] = useState(null); - const panelScale = useDraftNumber(clampTrayScalePercent(settings.trayScalePercent)); - const commitPanelScale = () => { - panelScale.commit(panelScale.draft, (value) => set({ trayScalePercent: value })); - }; - - useEffect(() => { - getTrayVisibilityStatus() - .then(setTrayVisibility) - .catch(() => setTrayVisibility(null)); - }, []); - - const providerName = new Map( - providers.map((provider) => [provider.id, provider.displayName]), - ); - const stackedProviderOptions = settings.enabledProviders.map((providerId) => ({ - value: providerId, - label: providerName.get(providerId) ?? providerId, - })); - return ( - <> - {/* ── Menu bar ─────────────────────────────────────────────── */} - {mode === "menuBar" &&
-

{t("MenuBar")}

-
- - - provider.value !== settings.stackedTrayBottomProvider, - ), - ]} - onChange={(provider) => - set({ stackedTrayTopProvider: provider }) - } - /> - - - - set({ menuBarDisplayMode: v as MenuBarDisplayMode }) - } - /> - - - set({ promoteTrayIcon: v })} - /> - -
-
} - - {/* ── Menu content ─────────────────────────────────────────── */} - {mode === "menu" &&
-

{t("TabMenu")}

-
- - set({ trayPanelAlwaysOnTop: v })} - /> - - - set({ showAsUsed: v })} - /> - - - panelScale.setDraft(Number(e.target.value))} - onPointerUp={commitPanelScale} - onTouchEnd={commitPanelScale} - onBlur={commitPanelScale} - onKeyUp={commitPanelScale} - aria-label={t("PanelScaleLabel")} - /> - -
-
} - - {mode === "menu" && ( - - )} - - ); -} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 80aa9cc2b9..eb4e65761b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -69,6 +69,10 @@ const settings: SettingsSnapshot = { switcherShowsIcons: true, menuBarShowsHighestUsage: true, menuBarShowsPercent: true, + menuBarIconStyle: "percent", + mergedOverviewSelectedProviders: [], + quotaWarningMarkersVisible: true, + workdayTickStyle: "subtle", menuBarColorPace: false, providersSortedAlphabetically: false, showAsUsed: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx new file mode 100644 index 0000000000..bb0a6c5f1b --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.test.tsx @@ -0,0 +1,207 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + getTrayVisibilityStatus: vi.fn(), +})); +vi.mock("../../../lib/tauri", () => tauriMocks); +vi.mock("../../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key, language: "english" }), +})); + +import MenuBarTab from "./MenuBarTab"; +import type { ProviderCatalogEntry, SettingsSnapshot } from "../../../types/bridge"; + +const catalog: ProviderCatalogEntry[] = [ + "codex", + "claude", + "gemini", + "cursor", + "copilot", + "zai", + "kimi", + "amp", +].map((id) => ({ id, displayName: id.toUpperCase(), cookieDomain: null })); + +const baseSettings = { + enabledProviders: ["codex", "claude", "gemini", "cursor", "copilot", "zai", "kimi"], + providerOrder: [], + trayIconMode: "single", + stackedTrayTopProvider: null, + stackedTrayBottomProvider: null, + switcherShowsIcons: true, + menuBarShowsHighestUsage: false, + menuBarShowsPercent: false, + menuBarIconStyle: "bars", + menuBarColorPace: false, + promoteTrayIcon: false, + mergedOverviewSelectedProviders: [], +} as unknown as SettingsSnapshot; + +function renderTab(overrides: Partial = {}, set = vi.fn()) { + const settings = { ...baseSettings, ...overrides } as SettingsSnapshot; + const view = render(); + return { ...view, set, settings }; +} + +const select = (name: string) => screen.getByRole("combobox", { name }) as HTMLSelectElement; +const fieldLabels = (region: HTMLElement) => + Array.from(region.querySelectorAll(".settings-field__label"), (n) => n.textContent); + +beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getTrayVisibilityStatus.mockResolvedValue({ support: "supported" }); +}); + +describe("MenuBarTab layout", () => { + it("has the Mac Icon and Combined icon sections with Pin to Taskbar last in Icon", async () => { + renderTab(); + expect(screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual([ + "MenuBarIconSectionTitle", + "CombinedIconSectionTitle", + ]); + expect(fieldLabels(screen.getByRole("region", { name: "MenuBarIconSectionTitle" }))).toEqual([ + "MenuBarIconStyleLabel", + "ColorPaceInTray", + "PromoteTrayIconLabel", + ]); + expect(fieldLabels(screen.getByRole("region", { name: "CombinedIconSectionTitle" }))).toEqual([ + "CombinedIconStyleLabel", + "ShowEveryProviderInTrayMenuLabel", + "SwitcherRowsLabel", + "PreferHighestUsage", + "OverviewProvidersLabel", + ]); + await waitFor(() => + expect((screen.getByRole("checkbox", { name: "PromoteTrayIconLabel" }) as HTMLInputElement).disabled).toBe(false), + ); + }); + + it("drops the Windows Display mode row", () => { + renderTab(); + expect(screen.queryByText("DisplayModeLabel")).toBeNull(); + }); + + it("keeps Pin to Taskbar disabled where Windows cannot promote the icon", async () => { + tauriMocks.getTrayVisibilityStatus.mockResolvedValue({ support: "unsupported" }); + renderTab(); + expect(await screen.findByText("PromoteTrayIconUnsupportedHint")).toBeInTheDocument(); + expect((screen.getByRole("checkbox", { name: "PromoteTrayIconLabel" }) as HTMLInputElement).disabled).toBe(true); + }); +}); + +describe("MenuBarTab Icon section", () => { + it("saves the Menu bar style", () => { + const { set } = renderTab(); + expect(select("MenuBarIconStyleLabel").value).toBe("bars"); + fireEvent.change(select("MenuBarIconStyleLabel"), { target: { value: "percent" } }); + expect(set).toHaveBeenCalledWith({ menuBarIconStyle: "percent" }); + }); + + it("disables the Menu bar style while the icon is stacked", () => { + renderTab({ trayIconMode: "stacked" }); + expect(select("MenuBarIconStyleLabel").disabled).toBe(true); + }); + + it("updates the Color Pace Indicator", () => { + const { set } = renderTab(); + fireEvent.click(screen.getByRole("checkbox", { name: "ColorPaceInTray" })); + expect(set).toHaveBeenCalledWith({ menuBarColorPace: true }); + }); +}); + +describe("MenuBarTab Combined icon section", () => { + it("maps Switcher and Stacked onto the tray icon mode", () => { + const { set } = renderTab(); + expect(select("CombinedIconStyleLabel").value).toBe("switcher"); + fireEvent.change(select("CombinedIconStyleLabel"), { target: { value: "stacked" } }); + expect(set).toHaveBeenCalledWith({ trayIconMode: "stacked" }); + }); + + it("shows Per provider as Switcher with every provider in the tray menu", () => { + const { set } = renderTab({ trayIconMode: "perProvider" }); + expect(select("CombinedIconStyleLabel").value).toBe("switcher"); + const every = screen.getByRole("checkbox", { name: "ShowEveryProviderInTrayMenuLabel" }) as HTMLInputElement; + expect(every.checked).toBe(true); + fireEvent.click(every); + expect(set).toHaveBeenCalledWith({ trayIconMode: "single" }); + }); + + it("turns on every provider in the tray menu", () => { + const { set } = renderTab(); + fireEvent.click(screen.getByRole("checkbox", { name: "ShowEveryProviderInTrayMenuLabel" })); + expect(set).toHaveBeenCalledWith({ trayIconMode: "perProvider" }); + }); + + it("persists explicit top and bottom rows when stacked", () => { + const { set } = renderTab({ trayIconMode: "stacked" }); + expect(screen.getByRole("checkbox", { name: "ShowEveryProviderInTrayMenuLabel" })).toBeDisabled(); + expect(screen.getByRole("checkbox", { name: "PreferHighestUsage" })).toBeDisabled(); + fireEvent.change(select("StackedTrayTopProvider"), { target: { value: "claude" } }); + fireEvent.change(select("StackedTrayBottomProvider"), { target: { value: "codex" } }); + expect(set).toHaveBeenCalledWith({ stackedTrayTopProvider: "claude" }); + expect(set).toHaveBeenCalledWith({ stackedTrayBottomProvider: "codex" }); + }); + + it("maps Switcher rows to switcherShowsIcons", () => { + const { set } = renderTab(); + expect(select("SwitcherRowsLabel").value).toBe("icons"); + fireEvent.change(select("SwitcherRowsLabel"), { target: { value: "weekly" } }); + expect(set).toHaveBeenCalledWith({ switcherShowsIcons: false }); + }); + + it("updates Show most-used provider", () => { + const { set } = renderTab(); + fireEvent.click(screen.getByRole("checkbox", { name: "PreferHighestUsage" })); + expect(set).toHaveBeenCalledWith({ menuBarShowsHighestUsage: true }); + }); +}); + +describe("MenuBarTab Overview providers", () => { + it("summarizes the default as the first six enabled providers", () => { + renderTab(); + expect(screen.getByText("OverviewProvidersDefaultSummary")).toBeInTheDocument(); + }); + + it("summarizes a saved selection by name in provider order", () => { + renderTab({ mergedOverviewSelectedProviders: ["gemini", "codex"] }); + expect(screen.getByText("CODEX, GEMINI")).toBeInTheDocument(); + }); + + it("opens a sheet with the first six checked and the seventh blocked at the limit", () => { + renderTab(); + fireEvent.click(screen.getByRole("button", { name: "OverviewProvidersConfigure" })); + const sheet = screen.getByRole("dialog", { name: "OverviewProvidersChooseUpTo" }); + const boxes = within(sheet).getAllByRole("checkbox") as HTMLInputElement[]; + expect(boxes.map((box) => box.getAttribute("aria-label"))).toEqual([ + "CODEX", + "CLAUDE", + "GEMINI", + "CURSOR", + "COPILOT", + "ZAI", + "KIMI", + ]); + expect(boxes.map((box) => box.checked)).toEqual([true, true, true, true, true, true, false]); + expect(boxes[6].disabled).toBe(true); + }); + + it("saves the selection without the unchecked provider, in provider order", () => { + const { set } = renderTab(); + fireEvent.click(screen.getByRole("button", { name: "OverviewProvidersConfigure" })); + fireEvent.click(screen.getByRole("checkbox", { name: "CLAUDE" })); + expect(set).toHaveBeenCalledWith({ + mergedOverviewSelectedProviders: ["codex", "gemini", "cursor", "copilot", "zai"], + }); + }); + + it("adds a provider below the limit and closes with Done", () => { + const { set } = renderTab({ mergedOverviewSelectedProviders: ["zai", "codex"] }); + fireEvent.click(screen.getByRole("button", { name: "OverviewProvidersConfigure" })); + expect((screen.getByRole("checkbox", { name: "KIMI" }) as HTMLInputElement).disabled).toBe(false); + fireEvent.click(screen.getByRole("checkbox", { name: "KIMI" })); + expect(set).toHaveBeenCalledWith({ mergedOverviewSelectedProviders: ["codex", "zai", "kimi"] }); + fireEvent.click(screen.getByRole("button", { name: "DialogDone" })); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx new file mode 100644 index 0000000000..8ee7cc0373 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/MenuBarTab.tsx @@ -0,0 +1,240 @@ +import { useEffect, useMemo, useState } from "react"; +import { useLocale } from "../../../hooks/useLocale"; +import { Field, Select, SettingsGroup, Toggle } from "../../../components/FormControls"; +import type { + MenuBarIconStyle, + ProviderCatalogEntry, + TrayVisibilityStatusDto, +} from "../../../types/bridge"; +import type { TabProps } from "../settingsTabs"; +import { getTrayVisibilityStatus } from "../../../lib/tauri"; +import { orderProviderSnapshots } from "../../../lib/providerOrder"; +import { + MERGED_OVERVIEW_PROVIDER_LIMIT, + effectiveOverviewSelection, + toggleOverviewProvider, +} from "../../../lib/overviewProviders"; +import SettingsSheet from "../SettingsSheet"; + +type CombinedIconStyle = "switcher" | "stacked"; + +/** + * Settings > Menu Bar, laid out like the macOS "Menu Bar" pane: the tray icon + * look ("Icon") and how several providers share the one icon ("Combined + * icon"). Windows always merges providers into one tray icon, so the Mac + * "Merge icons" switch is not shown. + */ +export default function MenuBarTab({ + settings, + set, + saving, + providers = [], +}: TabProps & { providers?: ProviderCatalogEntry[] }) { + const { t } = useLocale(); + const [trayVisibility, setTrayVisibility] = useState(null); + const [configuringOverview, setConfiguringOverview] = useState(false); + + useEffect(() => { + getTrayVisibilityStatus() + .then(setTrayVisibility) + .catch(() => setTrayVisibility(null)); + }, []); + + const providerName = useMemo( + () => new Map(providers.map((provider) => [provider.id, provider.displayName])), + [providers], + ); + const enabledInOrder = useMemo( + () => + orderProviderSnapshots( + settings.enabledProviders.map((providerId) => ({ + providerId, + displayName: providerName.get(providerId) ?? providerId, + })), + providers, + settings.enabledProviders, + settings.providerOrder, + ), + [providerName, providers, settings.enabledProviders, settings.providerOrder], + ); + const enabledIds = enabledInOrder.map((provider) => provider.providerId); + const savedSelection = settings.mergedOverviewSelectedProviders ?? []; + const overviewIds = effectiveOverviewSelection(enabledIds, savedSelection); + const overviewIsDefault = !enabledIds.some((id) => savedSelection.includes(id)); + const overviewSummary = overviewIsDefault + ? t("OverviewProvidersDefaultSummary").replace("{}", String(MERGED_OVERVIEW_PROVIDER_LIMIT)) + : overviewIds.map((id) => providerName.get(id) ?? id).join(", "); + + const stacked = settings.trayIconMode === "stacked"; + const combinedStyle: CombinedIconStyle = stacked ? "stacked" : "switcher"; + const stackedProviderOptions = enabledInOrder.map((provider) => ({ + value: provider.providerId, + label: provider.displayName, + })); + const pinSupported = trayVisibility?.support === "supported"; + + return ( + <> + + + { + if (v === combinedStyle) return; + set({ trayIconMode: v === "stacked" ? "stacked" : "single" }); + }} + /> + + {stacked && ( + <> + + provider.value !== settings.stackedTrayTopProvider, + ), + ]} + onChange={(provider) => set({ stackedTrayBottomProvider: provider })} + /> + + + )} + + set({ trayIconMode: v ? "perProvider" : "single" })} + /> + + + set({ showAsUsed: v === "used" })} + /> + + + set({ quotaWarningMarkersVisible: v })} + /> + + + set({ showPace: v })} + /> + + + set({ workdayTickStyle: v as WorkdayTickStyle })} + /> + + + panelScale.setDraft(Number(e.target.value))} + onPointerUp={commitPanelScale} + onTouchEnd={commitPanelScale} + onBlur={commitPanelScale} + onKeyUp={commitPanelScale} + aria-label={t("PanelScaleLabel")} + /> + + + + + + setSshHostsDraft(event.target.value)} + onBlur={() => set({ agentSessionSshHosts: parseSshHosts(sshHostsDraft) })} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + + + + + + - - - {periodError &&

{periodError}

} {error &&

{error}

} {shareError &&

{shareError}

} @@ -504,52 +483,3 @@ function ProjectsPanel({ ); } - -function CostSummaryStyleControl({ t }: { t: (key: LocaleKey) => string }) { - const [style, setStyle] = useState("compact"); - const [loading, setLoading] = useState(true); - - useEffect(() => { - void getSettingsSnapshot().then((snap: SettingsSnapshot) => { - setStyle(snap.costSummaryDisplayStyle); - setLoading(false); - }).catch(() => setLoading(false)); - }, []); - - const handleChange = useCallback(async (value: CostSummaryDisplayStyle) => { - setStyle(value); - try { - await updateSettings({ costSummaryDisplayStyle: value }); - } catch { - /* best-effort; revert handled by next settings refresh */ - } - }, []); - - const options: { value: CostSummaryDisplayStyle; label: string }[] = [ - { value: "compact", label: t("CostSummaryStyleCompact") }, - { value: "detailed", label: t("CostSummaryStyleDetailed") }, - { value: "hidden", label: t("CostSummaryStyleHidden") }, - ]; - - return ( -
- -

{t("CostSummaryDisplayStyleHelper")}

- -
- ); -} diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index 6aaf48b765..4146573b88 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -95,6 +95,10 @@ describe("Language type", () => { switcherShowsIcons: true, menuBarShowsHighestUsage: true, menuBarShowsPercent: true, + menuBarIconStyle: "percent", + mergedOverviewSelectedProviders: [], + quotaWarningMarkersVisible: true, + workdayTickStyle: "subtle", menuBarColorPace: false, providersSortedAlphabetically: false, showAsUsed: false, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 2ca2a31c51..c39c785a3d 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -75,6 +75,10 @@ export type OverviewLayout = "detailed" | "compact"; /** How cost is rendered on provider MenuCards (#2976). */ export type CostSummaryDisplayStyle = "compact" | "detailed" | "hidden"; +/** Settings > Menu Bar > Menu bar style (`menu_bar_icon_style`). */ +export type MenuBarIconStyle = "bars" | "percent"; +/** Settings > Menu > Workday tick appearance (`workday_tick_style`). */ +export type WorkdayTickStyle = "hidden" | "subtle" | "high_contrast"; export type FloatBarOrientation = "horizontal" | "vertical"; export type FloatBarStyle = "floating" | "taskbar"; @@ -204,7 +208,13 @@ export interface SettingsSnapshot { stackedTrayBottomProvider?: string | null; switcherShowsIcons: boolean; menuBarShowsHighestUsage: boolean; + /** Legacy mirror of `menuBarIconStyle === "percent"`; kept one release. */ menuBarShowsPercent: boolean; + menuBarIconStyle: MenuBarIconStyle; + /** Overview cards, at most 6; empty means the first 6 enabled providers. */ + mergedOverviewSelectedProviders: string[]; + quotaWarningMarkersVisible: boolean; + workdayTickStyle: WorkdayTickStyle; menuBarColorPace: boolean; /** Settings sidebar lists providers alphabetically, enabled first. Display only. */ providersSortedAlphabetically: boolean; @@ -287,7 +297,7 @@ export interface SettingsSnapshot { * Snapshot-side null and absent are equivalent. */ copilotSeatCreditEntitlement?: number | null; - /** Optional work-week length [2,6] for session-equivalent weekly forecast. */ + /** Optional work-week length [2,7]; null or absent means Automatic. */ weeklyProgressWorkDays?: number | null; /** How cost is rendered on provider cards (#2976). */ costSummaryDisplayStyle: CostSummaryDisplayStyle; @@ -339,6 +349,10 @@ export interface SettingsUpdate { switcherShowsIcons?: boolean; menuBarShowsHighestUsage?: boolean; menuBarShowsPercent?: boolean; + menuBarIconStyle?: MenuBarIconStyle; + mergedOverviewSelectedProviders?: string[]; + quotaWarningMarkersVisible?: boolean; + workdayTickStyle?: WorkdayTickStyle; menuBarColorPace?: boolean; providersSortedAlphabetically?: boolean; showAsUsed?: boolean; @@ -398,6 +412,7 @@ export interface SettingsUpdate { alibabaTokenPlanRegion?: string; /** Optional user-entered Copilot seat AI-credit allowance; null clears it. */ copilotSeatCreditEntitlement?: number | null; + /** 2..=7 work days; null selects Automatic. */ weeklyProgressWorkDays?: number | null; costSummaryDisplayStyle?: CostSummaryDisplayStyle; openCodexUsageLogsEnabled?: boolean; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index cc27f5a176..f0398687f9 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1798,6 +1798,45 @@ locale_keys! { DurationHours, DurationMinutes, DurationMonths, + + // Settings > Menu Bar and Menu panes (Mac parity) + MenuBarIconSectionTitle, + MenuBarIconStyleLabel, + MenuBarIconStyleHelper, + MenuBarIconStyleBars, + MenuBarIconStylePercent, + CombinedIconSectionTitle, + CombinedIconStyleLabel, + CombinedIconStyleSwitcher, + CombinedIconStyleStacked, + ShowEveryProviderInTrayMenuLabel, + ShowEveryProviderInTrayMenuHelper, + SwitcherRowsLabel, + SwitcherRowsProviderIcons, + SwitcherRowsWeeklyProgress, + OverviewProvidersLabel, + OverviewProvidersConfigure, + OverviewProvidersChooseUpTo, + OverviewProvidersDefaultSummary, + QuotaWarningMarkersLabel, + QuotaWarningMarkersHelper, + WorkDaysLabel, + WorkDaysCount, + WorkdayTickAppearanceLabel, + WorkdayTickAppearanceHelper, + WorkdayTickHidden, + WorkdayTickSubtle, + WorkdayTickHighContrast, + UsageBarsFillLabel, + UsageBarsFillRemaining, + UsageBarsFillUsed, + ResetTimesLabel, + ResetTimesCountdown, + ResetTimesClockTime, + MenuUsageSectionTitle, + MenuContentSectionTitle, + MenuCostSummarySectionTitle, + MenuWindowsSectionTitle, } #[cfg(test)] diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index d547eef392..32338cc4b9 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -81,7 +81,7 @@ ResetTimeRelativeHelper = Show "2h 30m" instead of "3:00 PM" ShowResetWhenExhausted = Show reset time when exhausted ShowResetWhenExhaustedHelper = Replace an exhausted percentage with its live reset countdown ShowPace = Show pace -ShowPaceHelper = Show usage pace, on-pace budgets, and forecast text in provider cards +ShowPaceHelper = Show the ahead/behind stripe and forecast text on usage bars. TrayIcon = Tray Icon MergeTrayIcons = Merge Tray Icons MergeTrayIconsHelper = Show all providers in a single tray icon @@ -437,14 +437,14 @@ UsageSpendColSource = Source UsageSpendColDate = Date UsageSpendColCost = Cost UsageSpendColTokens = Tokens -AgentSessionsTitle = Agent Sessions +AgentSessionsTitle = Agent sessions AgentSessionsProviderPi = Pi AgentSessionsProviderOmp = OMP -AgentSessionsEnableLabel = Show active Codex, Claude, and Pi/OMP sessions -AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. -AgentSessionsStayAwakeLabel = Stay awake while a local agent session is live -AgentSessionsStayAwakeHelper = Prevents idle sleep while a local Codex, Claude, or Pi/OMP process is running. The display can still turn off, and closing the lid still sleeps the PC. Disabled by default. -AgentSessionsSshHostsLabel = SSH hosts +AgentSessionsEnableLabel = Agent sessions +AgentSessionsEnableHelper = Show local and SSH-discovered Codex, Claude, and Pi/OMP sessions in the tray panel. +AgentSessionsStayAwakeLabel = Stay Awake +AgentSessionsStayAwakeHelper = Prevent idle system sleep while a local agent process is running, even when idle. Uses battery power; does not prevent lid-close or display sleep. +AgentSessionsSshHostsLabel = Additional SSH hosts AgentSessionsSshHostsHelper = Comma-separated SSH targets. Connections use non-interactive mode and short timeouts. AgentSessionsLoading = Looking for agent sessions... AgentSessionsEmpty = No active agent sessions. @@ -577,15 +577,15 @@ TrayIconModeHelper = Single unified icon or one icon per enabled provider. TrayIconModeSingle = Single TrayIconModePerProvider = Per provider TrayIconModeStacked = Stacked providers -StackedTrayTopProvider = Top provider -StackedTrayBottomProvider = Bottom provider +StackedTrayTopProvider = Top row +StackedTrayBottomProvider = Bottom row ShowProviderIcons = Show provider icons ShowProviderIconsHelper = Display provider icons in the tray switcher. -PreferHighestUsage = Prefer highest usage -PreferHighestUsageHelper = Show the provider closest to its limit in the merged tray display. +PreferHighestUsage = Show most-used provider +PreferHighestUsageHelper = The tray icon auto-shows the provider closest to its rate limit. ShowPercentInTray = Show percent in tray ShowPercentInTrayHelper = Replace usage bar with provider branding + percentage text. -ColorPaceInTray = Color tray usage by pace +ColorPaceInTray = Color Pace Indicator ColorPaceInTrayHelper = Color the Windows tray usage indicator green when behind pace and red when ahead. DisplayModeLabel = Display mode DisplayModeHelper = Level of detail shown in the menu bar label. @@ -1468,3 +1468,42 @@ DurationDays = { "{}" }d DurationHours = { "{}" }h DurationMinutes = { "{}" }m DurationMonths = { "{}" } mo + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = Icon +MenuBarIconStyleLabel = Menu bar style +MenuBarIconStyleHelper = How the tray icon is drawn. +MenuBarIconStyleBars = Meter bars +MenuBarIconStylePercent = Icon & percent +CombinedIconSectionTitle = Combined icon +CombinedIconStyleLabel = Combined icon style +CombinedIconStyleSwitcher = Switcher +CombinedIconStyleStacked = Stacked +ShowEveryProviderInTrayMenuLabel = Show every provider in the tray menu +ShowEveryProviderInTrayMenuHelper = Adds a status row for each enabled provider to the tray icon right-click menu. +SwitcherRowsLabel = Switcher rows +SwitcherRowsProviderIcons = Provider icons +SwitcherRowsWeeklyProgress = Weekly progress +OverviewProvidersLabel = Overview providers +OverviewProvidersConfigure = Configure… +OverviewProvidersChooseUpTo = Choose up to { "{}" } providers +OverviewProvidersDefaultSummary = First { "{}" } enabled providers +QuotaWarningMarkersLabel = Show quota warning markers +QuotaWarningMarkersHelper = Draw threshold tick marks on usage bars when quota warnings are configured. +WorkDaysLabel = Work days +WorkDaysCount = { "{}" } days +WorkdayTickAppearanceLabel = Workday tick appearance +WorkdayTickAppearanceHelper = Choose how workday boundaries appear on weekly usage bars. +WorkdayTickHidden = Hidden +WorkdayTickSubtle = Subtle +WorkdayTickHighContrast = High contrast +UsageBarsFillLabel = Usage bars fill +UsageBarsFillRemaining = As remaining +UsageBarsFillUsed = As used +ResetTimesLabel = Reset times +ResetTimesCountdown = Countdown +ResetTimesClockTime = Clock time +MenuUsageSectionTitle = Usage +MenuContentSectionTitle = Content +MenuCostSummarySectionTitle = Cost summary +MenuWindowsSectionTitle = Windows diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index d20f236b6c..1b1388596c 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1465,3 +1465,42 @@ DurationDays = { "{}" } d DurationHours = { "{}" } h DurationMinutes = { "{}" } min DurationMonths = { "{}" } mes. + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = Icono +MenuBarIconStyleLabel = Estilo de la barra de menú +MenuBarIconStyleHelper = Cómo se dibuja el icono de la bandeja. +MenuBarIconStyleBars = Barras de medidor +MenuBarIconStylePercent = Icono y porcentaje +CombinedIconSectionTitle = Icono combinado +CombinedIconStyleLabel = Estilo del icono combinado +CombinedIconStyleSwitcher = Selector +CombinedIconStyleStacked = Apilado +ShowEveryProviderInTrayMenuLabel = Mostrar todos los proveedores en el menú de la bandeja +ShowEveryProviderInTrayMenuHelper = Agrega una fila de estado por cada proveedor activado al menú contextual del icono de la bandeja. +SwitcherRowsLabel = Filas del selector +SwitcherRowsProviderIcons = Iconos de proveedores +SwitcherRowsWeeklyProgress = Progreso semanal +OverviewProvidersLabel = Proveedores de Overview +OverviewProvidersConfigure = Configurar… +OverviewProvidersChooseUpTo = Elige hasta { "{}" } proveedores +OverviewProvidersDefaultSummary = Primeros { "{}" } proveedores activados +QuotaWarningMarkersLabel = Mostrar marcadores de aviso de cuota +QuotaWarningMarkersHelper = Dibuja marcas de umbral en las barras de uso cuando hay avisos de cuota configurados. +WorkDaysLabel = Días laborables +WorkDaysCount = { "{}" } días +WorkdayTickAppearanceLabel = Apariencia de las marcas de días laborables +WorkdayTickAppearanceHelper = Elige cómo aparecen los límites de los días laborables en las barras de uso semanal. +WorkdayTickHidden = Oculto +WorkdayTickSubtle = Sutil +WorkdayTickHighContrast = Alto contraste +UsageBarsFillLabel = Relleno de las barras de uso +UsageBarsFillRemaining = Como restante +UsageBarsFillUsed = Como usado +ResetTimesLabel = Horas de restablecimiento +ResetTimesCountdown = Cuenta regresiva +ResetTimesClockTime = Hora del reloj +MenuUsageSectionTitle = Uso +MenuContentSectionTitle = Contenido +MenuCostSummarySectionTitle = Resumen de costos +MenuWindowsSectionTitle = Windows diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 22e0ca97ba..8090e55a72 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1466,3 +1466,42 @@ DurationDays = { "{}" } 日 DurationHours = { "{}" } 時間 DurationMinutes = { "{}" } 分 DurationMonths = { "{}" } か月 + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = アイコン +MenuBarIconStyleLabel = メニューバーのスタイル +MenuBarIconStyleHelper = トレイアイコンの描画方法。 +MenuBarIconStyleBars = メーターバー +MenuBarIconStylePercent = アイコンとパーセント +CombinedIconSectionTitle = 統合アイコン +CombinedIconStyleLabel = 統合アイコンのスタイル +CombinedIconStyleSwitcher = 切り替え +CombinedIconStyleStacked = 積み重ね +ShowEveryProviderInTrayMenuLabel = トレイメニューにすべてのプロバイダーを表示 +ShowEveryProviderInTrayMenuHelper = 有効な各プロバイダーの状態行をトレイアイコンの右クリックメニューに追加します。 +SwitcherRowsLabel = 切り替えの行 +SwitcherRowsProviderIcons = プロバイダーアイコン +SwitcherRowsWeeklyProgress = 週間の進捗 +OverviewProvidersLabel = Overview のプロバイダー +OverviewProvidersConfigure = 設定… +OverviewProvidersChooseUpTo = 最大 { "{}" } 件のプロバイダーを選択 +OverviewProvidersDefaultSummary = 有効な最初の { "{}" } 件のプロバイダー +QuotaWarningMarkersLabel = クォータ警告マーカーを表示 +QuotaWarningMarkersHelper = クォータ警告が設定されている場合、使用量バーにしきい値の目盛りを描画します。 +WorkDaysLabel = 稼働日 +WorkDaysCount = { "{}" } 日 +WorkdayTickAppearanceLabel = 稼働日の目盛りの表示 +WorkdayTickAppearanceHelper = 週間使用量バーで稼働日の境界をどう表示するかを選びます。 +WorkdayTickHidden = 非表示 +WorkdayTickSubtle = 控えめ +WorkdayTickHighContrast = 高コントラスト +UsageBarsFillLabel = 使用量バーの塗り +UsageBarsFillRemaining = 残り量で表示 +UsageBarsFillUsed = 使用量で表示 +ResetTimesLabel = リセット時刻 +ResetTimesCountdown = カウントダウン +ResetTimesClockTime = 時刻 +MenuUsageSectionTitle = 使用量 +MenuContentSectionTitle = コンテンツ +MenuCostSummarySectionTitle = コストの概要 +MenuWindowsSectionTitle = Windows diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 067a7e634e..2179ba953f 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1465,3 +1465,42 @@ DurationDays = { "{}" }일 DurationHours = { "{}" }시간 DurationMinutes = { "{}" }분 DurationMonths = { "{}" }개월 + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = 아이콘 +MenuBarIconStyleLabel = 메뉴 바 스타일 +MenuBarIconStyleHelper = 트레이 아이콘을 그리는 방식입니다. +MenuBarIconStyleBars = 미터 막대 +MenuBarIconStylePercent = 아이콘 및 퍼센트 +CombinedIconSectionTitle = 통합 아이콘 +CombinedIconStyleLabel = 통합 아이콘 스타일 +CombinedIconStyleSwitcher = 전환기 +CombinedIconStyleStacked = 쌓기 +ShowEveryProviderInTrayMenuLabel = 트레이 메뉴에 모든 공급자 표시 +ShowEveryProviderInTrayMenuHelper = 활성화된 각 공급자의 상태 행을 트레이 아이콘의 오른쪽 클릭 메뉴에 추가합니다. +SwitcherRowsLabel = 전환기 행 +SwitcherRowsProviderIcons = 공급자 아이콘 +SwitcherRowsWeeklyProgress = 주간 진행률 +OverviewProvidersLabel = Overview 공급자 +OverviewProvidersConfigure = 구성… +OverviewProvidersChooseUpTo = 최대 { "{}" }개의 공급자 선택 +OverviewProvidersDefaultSummary = 활성화된 처음 { "{}" }개의 공급자 +QuotaWarningMarkersLabel = 할당량 경고 표시 보기 +QuotaWarningMarkersHelper = 할당량 경고가 구성되어 있으면 사용량 막대에 임계값 눈금을 그립니다. +WorkDaysLabel = 근무일 +WorkDaysCount = { "{}" }일 +WorkdayTickAppearanceLabel = 근무일 눈금 모양 +WorkdayTickAppearanceHelper = 주간 사용량 막대에 근무일 경계를 표시하는 방식을 선택합니다. +WorkdayTickHidden = 숨김 +WorkdayTickSubtle = 은은하게 +WorkdayTickHighContrast = 고대비 +UsageBarsFillLabel = 사용량 막대 채우기 +UsageBarsFillRemaining = 남은 양으로 +UsageBarsFillUsed = 사용한 양으로 +ResetTimesLabel = 재설정 시간 +ResetTimesCountdown = 카운트다운 +ResetTimesClockTime = 시각 +MenuUsageSectionTitle = 사용량 +MenuContentSectionTitle = 콘텐츠 +MenuCostSummarySectionTitle = 비용 요약 +MenuWindowsSectionTitle = Windows diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index a1f4692d1d..3fcc7a9f73 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1470,3 +1470,42 @@ DurationDays = { "{}" } d DurationHours = { "{}" } h DurationMinutes = { "{}" } min DurationMonths = { "{}" } mês(es) + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = Ícone +MenuBarIconStyleLabel = Estilo da barra de menus +MenuBarIconStyleHelper = Como o ícone da bandeja é desenhado. +MenuBarIconStyleBars = Barras de medidor +MenuBarIconStylePercent = Ícone e porcentagem +CombinedIconSectionTitle = Ícone combinado +CombinedIconStyleLabel = Estilo do ícone combinado +CombinedIconStyleSwitcher = Alternador +CombinedIconStyleStacked = Empilhado +ShowEveryProviderInTrayMenuLabel = Mostrar todos os provedores no menu da bandeja +ShowEveryProviderInTrayMenuHelper = Adiciona uma linha de status para cada provedor ativado ao menu de clique direito do ícone da bandeja. +SwitcherRowsLabel = Linhas do alternador +SwitcherRowsProviderIcons = Ícones de provedores +SwitcherRowsWeeklyProgress = Progresso semanal +OverviewProvidersLabel = Provedores da visão geral +OverviewProvidersConfigure = Configurar… +OverviewProvidersChooseUpTo = Escolha até { "{}" } provedores +OverviewProvidersDefaultSummary = Primeiros { "{}" } provedores ativados +QuotaWarningMarkersLabel = Mostrar marcadores de aviso de cota +QuotaWarningMarkersHelper = Desenha marcas de limite nas barras de uso quando há avisos de cota configurados. +WorkDaysLabel = Dias úteis +WorkDaysCount = { "{}" } dias +WorkdayTickAppearanceLabel = Aparência das marcas de dias úteis +WorkdayTickAppearanceHelper = Escolha como os limites dos dias úteis aparecem nas barras de uso semanal. +WorkdayTickHidden = Oculto +WorkdayTickSubtle = Sutil +WorkdayTickHighContrast = Alto contraste +UsageBarsFillLabel = Preenchimento das barras de uso +UsageBarsFillRemaining = Como restante +UsageBarsFillUsed = Como usado +ResetTimesLabel = Horários de redefinição +ResetTimesCountdown = Contagem regressiva +ResetTimesClockTime = Horário +MenuUsageSectionTitle = Uso +MenuContentSectionTitle = Conteúdo +MenuCostSummarySectionTitle = Resumo de custos +MenuWindowsSectionTitle = Windows diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 20a0e5c0e5..6b12d711c9 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1466,3 +1466,42 @@ DurationDays = { "{}" } д DurationHours = { "{}" } ч DurationMinutes = { "{}" } мин DurationMonths = { "{}" } мес. + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = Значок +MenuBarIconStyleLabel = Стиль значка +MenuBarIconStyleHelper = Как отображается значок в трее. +MenuBarIconStyleBars = Шкалы +MenuBarIconStylePercent = Значок и процент +CombinedIconSectionTitle = Общий значок +CombinedIconStyleLabel = Стиль общего значка +CombinedIconStyleSwitcher = Переключатель +CombinedIconStyleStacked = Стопкой +ShowEveryProviderInTrayMenuLabel = Показывать всех провайдеров в меню трея +ShowEveryProviderInTrayMenuHelper = Добавляет строку состояния для каждого включённого провайдера в контекстное меню значка в трее. +SwitcherRowsLabel = Строки переключателя +SwitcherRowsProviderIcons = Значки провайдеров +SwitcherRowsWeeklyProgress = Недельный прогресс +OverviewProvidersLabel = Провайдеры обзора +OverviewProvidersConfigure = Настроить… +OverviewProvidersChooseUpTo = Выберите до { "{}" } провайдеров +OverviewProvidersDefaultSummary = Первые { "{}" } включённых провайдеров +QuotaWarningMarkersLabel = Показывать отметки предупреждений о квоте +QuotaWarningMarkersHelper = Рисует отметки порогов на полосах использования, если настроены предупреждения о квоте. +WorkDaysLabel = Рабочие дни +WorkDaysCount = { "{}" } дн. +WorkdayTickAppearanceLabel = Вид отметок рабочих дней +WorkdayTickAppearanceHelper = Выберите, как границы рабочих дней отображаются на недельных полосах использования. +WorkdayTickHidden = Скрыты +WorkdayTickSubtle = Неяркие +WorkdayTickHighContrast = Высокий контраст +UsageBarsFillLabel = Заполнение полос использования +UsageBarsFillRemaining = Как остаток +UsageBarsFillUsed = Как использовано +ResetTimesLabel = Время сброса +ResetTimesCountdown = Обратный отсчёт +ResetTimesClockTime = Время на часах +MenuUsageSectionTitle = Использование +MenuContentSectionTitle = Содержимое +MenuCostSummarySectionTitle = Сводка расходов +MenuWindowsSectionTitle = Windows diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 3265ff65d2..6db04aaf18 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1466,3 +1466,42 @@ DurationDays = { "{}" } g DurationHours = { "{}" } sa DurationMinutes = { "{}" } dk DurationMonths = { "{}" } ay + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = Simge +MenuBarIconStyleLabel = Menü çubuğu stili +MenuBarIconStyleHelper = Tepsi simgesinin nasıl çizileceği. +MenuBarIconStyleBars = Ölçüm çubukları +MenuBarIconStylePercent = Simge ve yüzde +CombinedIconSectionTitle = Birleşik simge +CombinedIconStyleLabel = Birleşik simge stili +CombinedIconStyleSwitcher = Değiştirici +CombinedIconStyleStacked = Yığılmış +ShowEveryProviderInTrayMenuLabel = Tepsi menüsünde tüm sağlayıcıları göster +ShowEveryProviderInTrayMenuHelper = Etkin her sağlayıcı için tepsi simgesinin sağ tık menüsüne bir durum satırı ekler. +SwitcherRowsLabel = Değiştirici satırları +SwitcherRowsProviderIcons = Sağlayıcı simgeleri +SwitcherRowsWeeklyProgress = Haftalık ilerleme +OverviewProvidersLabel = Genel bakış sağlayıcıları +OverviewProvidersConfigure = Yapılandır… +OverviewProvidersChooseUpTo = En fazla { "{}" } sağlayıcı seçin +OverviewProvidersDefaultSummary = Etkin ilk { "{}" } sağlayıcı +QuotaWarningMarkersLabel = Kota uyarı işaretlerini göster +QuotaWarningMarkersHelper = Kota uyarıları yapılandırıldığında kullanım çubuklarına eşik işaretleri çizer. +WorkDaysLabel = İş günleri +WorkDaysCount = { "{}" } gün +WorkdayTickAppearanceLabel = İş günü işaretlerinin görünümü +WorkdayTickAppearanceHelper = İş günü sınırlarının haftalık kullanım çubuklarında nasıl görüneceğini seçin. +WorkdayTickHidden = Gizli +WorkdayTickSubtle = Belirsiz +WorkdayTickHighContrast = Yüksek kontrast +UsageBarsFillLabel = Kullanım çubuklarının dolgusu +UsageBarsFillRemaining = Kalan olarak +UsageBarsFillUsed = Kullanılan olarak +ResetTimesLabel = Sıfırlama zamanları +ResetTimesCountdown = Geri sayım +ResetTimesClockTime = Saat +MenuUsageSectionTitle = Kullanım +MenuContentSectionTitle = İçerik +MenuCostSummarySectionTitle = Maliyet özeti +MenuWindowsSectionTitle = Windows diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 416eecbbe9..903136ae8e 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1468,3 +1468,42 @@ DurationDays = { "{}" } д DurationHours = { "{}" } год DurationMinutes = { "{}" } хв DurationMonths = { "{}" } міс. + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = Значок +MenuBarIconStyleLabel = Стиль значка +MenuBarIconStyleHelper = Як відображається значок у треї. +MenuBarIconStyleBars = Шкали +MenuBarIconStylePercent = Значок і відсоток +CombinedIconSectionTitle = Спільний значок +CombinedIconStyleLabel = Стиль спільного значка +CombinedIconStyleSwitcher = Перемикач +CombinedIconStyleStacked = Стосом +ShowEveryProviderInTrayMenuLabel = Показувати всіх провайдерів у меню трею +ShowEveryProviderInTrayMenuHelper = Додає рядок стану для кожного ввімкненого провайдера до контекстного меню значка в треї. +SwitcherRowsLabel = Рядки перемикача +SwitcherRowsProviderIcons = Значки провайдерів +SwitcherRowsWeeklyProgress = Тижневий прогрес +OverviewProvidersLabel = Провайдери огляду +OverviewProvidersConfigure = Налаштувати… +OverviewProvidersChooseUpTo = Виберіть до { "{}" } провайдерів +OverviewProvidersDefaultSummary = Перші { "{}" } увімкнених провайдерів +QuotaWarningMarkersLabel = Показувати позначки попереджень про квоту +QuotaWarningMarkersHelper = Малює позначки порогів на смугах використання, якщо налаштовано попередження про квоту. +WorkDaysLabel = Робочі дні +WorkDaysCount = { "{}" } дн. +WorkdayTickAppearanceLabel = Вигляд позначок робочих днів +WorkdayTickAppearanceHelper = Виберіть, як межі робочих днів відображаються на тижневих смугах використання. +WorkdayTickHidden = Приховано +WorkdayTickSubtle = Неяскраві +WorkdayTickHighContrast = Високий контраст +UsageBarsFillLabel = Заповнення смуг використання +UsageBarsFillRemaining = Як залишок +UsageBarsFillUsed = Як використане +ResetTimesLabel = Час скидання +ResetTimesCountdown = Зворотний відлік +ResetTimesClockTime = Час на годиннику +MenuUsageSectionTitle = Використання +MenuContentSectionTitle = Вміст +MenuCostSummarySectionTitle = Зведення витрат +MenuWindowsSectionTitle = Windows diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 4c07dc3116..d99ad7f53b 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1466,3 +1466,42 @@ DurationDays = { "{}" } 天 DurationHours = { "{}" } 小时 DurationMinutes = { "{}" } 分钟 DurationMonths = { "{}" } 个月 + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = 图标 +MenuBarIconStyleLabel = 菜单栏样式 +MenuBarIconStyleHelper = 托盘图标的绘制方式。 +MenuBarIconStyleBars = 计量条 +MenuBarIconStylePercent = 图标和百分比 +CombinedIconSectionTitle = 合并图标 +CombinedIconStyleLabel = 合并图标样式 +CombinedIconStyleSwitcher = 切换器 +CombinedIconStyleStacked = 堆叠 +ShowEveryProviderInTrayMenuLabel = 在托盘菜单中显示所有提供商 +ShowEveryProviderInTrayMenuHelper = 在托盘图标的右键菜单中为每个已启用的提供商添加一行状态。 +SwitcherRowsLabel = 切换器行 +SwitcherRowsProviderIcons = 提供商图标 +SwitcherRowsWeeklyProgress = 每周进度 +OverviewProvidersLabel = 概览提供商 +OverviewProvidersConfigure = 配置… +OverviewProvidersChooseUpTo = 最多选择 { "{}" } 个提供商 +OverviewProvidersDefaultSummary = 前 { "{}" } 个已启用的提供商 +QuotaWarningMarkersLabel = 显示配额警告标记 +QuotaWarningMarkersHelper = 配置配额警告后,在用量条上绘制阈值刻度。 +WorkDaysLabel = 工作日 +WorkDaysCount = { "{}" } 天 +WorkdayTickAppearanceLabel = 工作日刻度外观 +WorkdayTickAppearanceHelper = 选择工作日分界在每周用量条上的显示方式。 +WorkdayTickHidden = 隐藏 +WorkdayTickSubtle = 淡化 +WorkdayTickHighContrast = 高对比度 +UsageBarsFillLabel = 用量条填充 +UsageBarsFillRemaining = 按剩余 +UsageBarsFillUsed = 按已用 +ResetTimesLabel = 重置时间 +ResetTimesCountdown = 倒计时 +ResetTimesClockTime = 时钟时间 +MenuUsageSectionTitle = 用量 +MenuContentSectionTitle = 内容 +MenuCostSummarySectionTitle = 费用摘要 +MenuWindowsSectionTitle = Windows diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index dc08ec73c4..601c9bbcdd 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1466,3 +1466,42 @@ DurationDays = { "{}" } 天 DurationHours = { "{}" } 小時 DurationMinutes = { "{}" } 分鐘 DurationMonths = { "{}" } 個月 + +# Settings > Menu Bar and Menu panes (Mac parity) +MenuBarIconSectionTitle = 圖示 +MenuBarIconStyleLabel = 選單欄樣式 +MenuBarIconStyleHelper = 系統匣圖示的繪製方式。 +MenuBarIconStyleBars = 計量條 +MenuBarIconStylePercent = 圖示與百分比 +CombinedIconSectionTitle = 合併圖示 +CombinedIconStyleLabel = 合併圖示樣式 +CombinedIconStyleSwitcher = 切換器 +CombinedIconStyleStacked = 堆疊 +ShowEveryProviderInTrayMenuLabel = 在系統匣選單中顯示所有供應商 +ShowEveryProviderInTrayMenuHelper = 在系統匣圖示的右鍵選單中為每個已啟用的供應商新增一列狀態。 +SwitcherRowsLabel = 切換器列 +SwitcherRowsProviderIcons = 供應商圖示 +SwitcherRowsWeeklyProgress = 每週進度 +OverviewProvidersLabel = 概覽供應商 +OverviewProvidersConfigure = 設定… +OverviewProvidersChooseUpTo = 最多選擇 { "{}" } 個供應商 +OverviewProvidersDefaultSummary = 前 { "{}" } 個已啟用的供應商 +QuotaWarningMarkersLabel = 顯示配額警告標記 +QuotaWarningMarkersHelper = 設定配額警告後,在用量條上繪製閾值刻度。 +WorkDaysLabel = 工作日 +WorkDaysCount = { "{}" } 天 +WorkdayTickAppearanceLabel = 工作日刻度外觀 +WorkdayTickAppearanceHelper = 選擇工作日分界在每週用量條上的顯示方式。 +WorkdayTickHidden = 隱藏 +WorkdayTickSubtle = 淡化 +WorkdayTickHighContrast = 高對比 +UsageBarsFillLabel = 用量條填充 +UsageBarsFillRemaining = 按剩餘 +UsageBarsFillUsed = 按已用 +ResetTimesLabel = 重設時間 +ResetTimesCountdown = 倒數計時 +ResetTimesClockTime = 時鐘時間 +MenuUsageSectionTitle = 用量 +MenuContentSectionTitle = 內容 +MenuCostSummarySectionTitle = 費用摘要 +MenuWindowsSectionTitle = Windows From 5c975531c1319514213bb5a825877bb229d5e93f Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:59:30 +0700 Subject: [PATCH 3/6] Test that bars and percent tray icons draw different pixels --- .../src-tauri/src/tray_presentation_tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 57c9ec6227..7927ff8a56 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -835,6 +835,24 @@ fn icon_style_not_the_legacy_percent_flag_picks_the_renderer() { has_error: false, } ); + + // The two styles draw different tray pixels for the same usage. + let (bar_pixels, bar_w, bar_h) = + TrayPresentationPlan::resolve(&bars, &snapshots, None).render_icon(); + let (percent_pixels, percent_w, percent_h) = + TrayPresentationPlan::resolve(&percent, &snapshots, None).render_icon(); + assert_eq!((bar_w, bar_h), (percent_w, percent_h)); + let changed = bar_pixels + .as_chunks::<4>() + .0 + .iter() + .zip(percent_pixels.as_chunks::<4>().0.iter()) + .filter(|(a, b)| a != b) + .count(); + assert!( + changed > 0, + "bars and percent icons rendered identical pixels" + ); } #[test] From 72fd99b3078f9763e09937dab4445ce461cda263 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:27:29 +0700 Subject: [PATCH 4/6] Run the Display mode fold from desktop startup, not every settings load Settings::load runs in unit tests and CLI helpers, so the fold wrote its marker into the real config dir during cargo test. The desktop shell now runs it once at startup. A failed save no longer writes the marker, so the fold retries. --- apps/desktop-tauri/src-tauri/src/main.rs | 3 ++- rust/src/settings.rs | 12 ++++++++++-- rust/src/settings/tests.rs | 24 ++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 145962c8c3..14cceaf577 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -143,7 +143,8 @@ fn main() { let proof_config = proof_harness::ProofConfig::from_env(); let is_proof_mode = proof_config.is_some(); let force_start_visible = std::env::var_os("CODEXBAR_START_VISIBLE").is_some(); - let settings = codexbar::settings::Settings::load(); + let mut settings = codexbar::settings::Settings::load(); + settings.apply_display_mode_fold_migration(); let launch = launch_behavior( force_start_visible, settings.start_minimized, diff --git a/rust/src/settings.rs b/rust/src/settings.rs index c741e8ce05..7b843fe4f5 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -748,7 +748,6 @@ impl Settings { if settings_file_override().is_none() { settings.start_at_login = Self::sync_start_at_login_registry(); settings.apply_promote_tray_default_migration(); - settings.apply_display_mode_fold_migration(); } // One-shot migration: materialize the persisted hidden usage-item list @@ -855,7 +854,14 @@ impl Settings { true } - fn apply_display_mode_fold_migration(&mut self) { + /// Run the one-shot Display mode fold against the default config root. + /// The desktop shell calls this once at startup rather than from + /// [`Settings::load`], so unit tests and helpers that load settings never + /// write the marker into the real config dir. A `CODEXBAR_CONFIG` run skips it. + pub fn apply_display_mode_fold_migration(&mut self) { + if settings_file_override().is_some() { + return; + } let Some(marker) = Self::display_mode_fold_marker_path() else { return; }; @@ -871,7 +877,9 @@ impl Settings { if self.fold_minimal_display_mode(already_migrated) && let Err(error) = persist(self) { + // No marker: the fold retries on the next launch instead of being lost. tracing::warn!("Failed to persist display mode fold migration: {error}"); + return; } if !already_migrated && let Some(parent) = marker.parent() { // Best-effort marker dir creation; the write below reports failure. diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 5342f95e24..8acbe07a33 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -1864,6 +1864,30 @@ fn display_mode_fold_leaves_other_modes_alone_but_writes_the_marker() { assert_eq!(persisted, 0); } +#[test] +fn display_mode_fold_retries_when_the_save_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let marker = dir.path().join(".display-mode-fold-v1"); + let mut settings: Settings = + serde_json::from_str(r#"{ "enabled_providers": [], "menu_bar_display_mode": "minimal" }"#) + .expect("legacy minimal file"); + + settings.apply_display_mode_fold_with_marker(&marker, |_| anyhow::bail!("disk full")); + assert!(!marker.exists(), "no marker after a failed save"); + + let mut reloaded: Settings = + serde_json::from_str(r#"{ "enabled_providers": [], "menu_bar_display_mode": "minimal" }"#) + .expect("legacy minimal file"); + let mut persisted = 0; + reloaded.apply_display_mode_fold_with_marker(&marker, |_| { + persisted += 1; + Ok(()) + }); + assert!(reloaded.menu_bar_shows_highest_usage); + assert_eq!(persisted, 1, "the next launch saves the fold"); + assert!(marker.exists()); +} + #[test] fn stacked_tray_mode_survives_load_with_the_new_keys() { let loaded: Settings = serde_json::from_str( From ad3c5d8ce16607e90e0f073aad71563680948940 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:27:29 +0700 Subject: [PATCH 5/6] Show the 7-day lane on the switcher's weekly progress track The track read the primary (session) lane while Settings labels the row Weekly progress. Prefer the most-used 7-day core lane like macOS switcherWeeklyWindow, falling back to primary. --- .../src/components/ProviderGrid.tsx | 10 ++---- .../src/components/providerGridUtils.test.ts | 36 ++++++++++++++++++- .../src/components/providerGridUtils.ts | 23 ++++++++++++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/apps/desktop-tauri/src/components/ProviderGrid.tsx b/apps/desktop-tauri/src/components/ProviderGrid.tsx index 32bbf60743..be7c7a99ab 100644 --- a/apps/desktop-tauri/src/components/ProviderGrid.tsx +++ b/apps/desktop-tauri/src/components/ProviderGrid.tsx @@ -3,7 +3,7 @@ import type { ProviderUsageSnapshot } from "../types/bridge"; import { ProviderIcon } from "./providers/ProviderIcon"; import { getProviderIcon } from "./providers/providerIcons"; import { useLocale } from "../hooks/useLocale"; -import { prioritizeProviders } from "./providerGridUtils"; +import { prioritizeProviders, switcherWeeklyPercent } from "./providerGridUtils"; export default function ProviderGrid({ providers, @@ -66,12 +66,8 @@ export default function ProviderGrid({ if (expanded === undefined) setUncontrolledExpanded(next); onExpandedChange?.(next); }; - const gridPercent = (provider: ProviderUsageSnapshot) => { - const pct = showAsUsed - ? provider.primary.usedPercent - : provider.primary.remainingPercent; - return Math.max(0, Math.min(100, pct)); - }; + const gridPercent = (provider: ProviderUsageSnapshot) => + switcherWeeklyPercent(provider, showAsUsed); const totalItems = providers.length + 1; const shouldCollapse = totalItems > 32; const collapsedProviders = useMemo( diff --git a/apps/desktop-tauri/src/components/providerGridUtils.test.ts b/apps/desktop-tauri/src/components/providerGridUtils.test.ts index e6336ad02d..2166c7acc5 100644 --- a/apps/desktop-tauri/src/components/providerGridUtils.test.ts +++ b/apps/desktop-tauri/src/components/providerGridUtils.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { ProviderUsageSnapshot } from "../types/bridge"; -import { prioritizeProviders } from "./providerGridUtils"; +import { prioritizeProviders, switcherWeeklyPercent } from "./providerGridUtils"; function provider(id: string): ProviderUsageSnapshot { return { @@ -106,3 +106,37 @@ describe("prioritizeProviders", () => { expect(new Set(ids(prioritized))).toEqual(new Set(ids(unprioritized))); }); }); + +function lane(usedPercent: number, windowMinutes: number | null): ProviderUsageSnapshot["primary"] { + return { ...provider("x").primary, usedPercent, remainingPercent: 100 - usedPercent, windowMinutes }; +} + +describe("switcherWeeklyPercent", () => { + it("shows the 7-day lane, not the session lane", () => { + const codex = { ...provider("codex"), primary: lane(42, 300), secondary: lane(63, 10080) }; + expect(switcherWeeklyPercent(codex, true)).toBe(63); + expect(switcherWeeklyPercent(codex, false)).toBe(37); + }); + + it("picks the most used 7-day lane when both core lanes are weekly", () => { + const both = { ...provider("p"), primary: lane(20, 10080), secondary: lane(70, 10080) }; + expect(switcherWeeklyPercent(both, true)).toBe(70); + }); + + it("ignores weekly tertiary and extra lanes", () => { + const scoped = { + ...provider("claude"), + primary: lane(10, 300), + secondary: lane(30, 10080), + tertiary: lane(95, 10080), + extraRateWindows: [{ id: "scoped", title: "Scoped", window: lane(99, 10080) }], + }; + expect(switcherWeeklyPercent(scoped, true)).toBe(30); + }); + + it("falls back to the primary lane without a 7-day lane", () => { + const monthly = { ...provider("p"), primary: lane(25, 43200), secondary: lane(80, 300) }; + expect(switcherWeeklyPercent(monthly, true)).toBe(25); + expect(switcherWeeklyPercent({ ...provider("p"), primary: lane(140, null) }, true)).toBe(100); + }); +}); diff --git a/apps/desktop-tauri/src/components/providerGridUtils.ts b/apps/desktop-tauri/src/components/providerGridUtils.ts index d3a966b3b3..a1ebc8efe8 100644 --- a/apps/desktop-tauri/src/components/providerGridUtils.ts +++ b/apps/desktop-tauri/src/components/providerGridUtils.ts @@ -10,3 +10,26 @@ export function prioritizeProviders( const selected = providers[selectedIndex]; return [selected, ...providers.slice(0, selectedIndex), ...providers.slice(selectedIndex + 1)]; } + +const WEEK_MINUTES = 7 * 24 * 60; + +/** + * The lane the switcher's progress track shows. Like macOS + * `UsageSnapshot.switcherWeeklyWindow`, it prefers a real 7-day lane (the + * most used one) because the row is labelled "Weekly progress", and falls back + * to the primary lane. Only the core primary and secondary lanes are read: + * tertiary and extra lanes are model-scoped carve-outs on some providers + * (macOS drops Claude's for the same reason), so they stay on the card. + */ +export function switcherWeeklyPercent(provider: ProviderUsageSnapshot, showAsUsed: boolean): number { + type Lane = ProviderUsageSnapshot["primary"]; + const weekly = [provider.primary, provider.secondary].filter( + (window): window is Lane => window?.windowMinutes === WEEK_MINUTES, + ); + const lane = weekly.reduce( + (most, window) => (most === null || window.usedPercent > most.usedPercent ? window : most), + null, + ) ?? provider.primary; + const pct = showAsUsed ? lane.usedPercent : lane.remainingPercent; + return Math.max(0, Math.min(100, pct)); +} From 59b963171425a4e4a9c47f847c367d0041e0a4c8 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:39:41 +0700 Subject: [PATCH 6/6] Show provider titles on the tray switcher in Weekly progress mode With Switcher rows set to Weekly progress the tray grid hid both icons and labels, leaving blank buttons with only coloured tracks. Show the title above the weekly track in uniform columns, like the macOS progress switcher. Icons mode is unchanged. --- apps/desktop-tauri/src/styles.css | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 46e5af3887..a0d2e0e171 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -4643,6 +4643,33 @@ html:has(.menu-surface--tray) { margin-top: 2px; } +/* Switcher rows "Weekly progress": no icons, so each button shows its title + above the weekly track, like the macOS progress switcher. Uniform columns + across the panel; long titles truncate. */ +.menu-surface--tray .provider-grid--no-icons { + grid-template-columns: repeat(auto-fit, minmax(52px, 1fr)); + gap: 6px 6px; +} + +.menu-surface--tray .provider-grid--no-icons .provider-grid__item { + width: auto; + min-width: 0; + min-height: 30px; + padding: 3px 4px; + gap: 2px; +} + +.menu-surface--tray .provider-grid--no-icons .provider-grid__label { + display: block; + max-width: 100%; + font-size: 11px; +} + +.menu-surface--tray .provider-grid--no-icons .provider-grid__weekly-track { + width: 100%; + max-width: 44px; +} + .provider-grid__item--more { color: var(--text-secondary); }