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/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/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..7927ff8a56 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,67 @@ 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, + } + ); + + // 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] +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/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 692d868879..6596f00f32 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/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/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/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)); +} diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 7d2850aabd..fd6a63dfec 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 f6e6812dd3..fc717d3874 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3433,6 +3433,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); } @@ -4651,6 +4655,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); } 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/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/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 diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 2ff86ca3e1..7b843fe4f5 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, @@ -805,6 +831,71 @@ 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 + } + + /// 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; + }; + 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) + { + // 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. + 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..8acbe07a33 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -1619,3 +1619,286 @@ 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 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( + 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)) +}