From 3a407a7c5fc1ae78dbe2829b41f7e5dbd3493635 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:11:15 +0700 Subject: [PATCH 01/13] Show a source hint for the 7 new providers before their first fetch --- .../providers/providerDetailFormat.test.ts | 24 ++++++- .../providers/providerDetailFormat.ts | 71 +++++++++++++++++++ .../sections/IdentitySection.test.tsx | 25 +++++++ .../providers/sections/IdentitySection.tsx | 6 +- 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts index 9166f2d211..6002e98d93 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderDetail } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; -import { buildSubtitle, relativeAgo } from "./providerDetailFormat"; +import { buildSubtitle, providerSourceHintShort, relativeAgo } from "./providerDetailFormat"; /** Identity translator — returns the key verbatim so assertions are stable. */ const t = (k: LocaleKey) => k as string; @@ -122,3 +122,25 @@ describe("relativeAgo", () => { expect(relativeAgo(new Date("2026-07-31T12:01:30Z").toISOString())).toBe("2m"); }); }); + +describe("providerSourceHintShort", () => { + it.each([ + ["synthetic", "ProviderSourceApiShort"], + ["clawrouter", "ProviderSourceApiShort"], + ["ibmbob", "ProviderSourceApiShort"], + ["langdock", "ProviderSourceWebShort"], + ["lithosai", "ProviderSourceWebShort"], + ["museai", "ProviderSourceWebShort"], + ["workbuddy", "ProviderSourceWebShort"], + ])("labels %s by the source its fetcher reports", (id, key) => { + expect(providerSourceHintShort(id, t)).toBe(key); + }); + + it("keeps the older hints and falls back to Auto", () => { + expect(providerSourceHintShort("cursor", t)).toBe("ProviderSourceWebShort"); + expect(providerSourceHintShort("gemini", t)).toBe("ProviderSourceCliShort"); + expect(providerSourceHintShort("copilot", t)).toBe("ProviderSourceOauthShort"); + expect(providerSourceHintShort("kiro", t)).toBe("ProviderSourceKiroEnvShort"); + expect(providerSourceHintShort("codex", t)).toBe("ProviderSourceAutoShort"); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.ts b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.ts index 773c08389a..0ca460e07c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.ts @@ -16,6 +16,77 @@ export function buildSubtitle( return parts.join(" · "); } +/** Source shown before a provider's first fetch (macOS `UsageStore.sourceLabel(for:)` fallback). */ +export function providerSourceHintShort( + providerId: string, + t: (k: LocaleKey) => string, +): string { + switch (providerId.toLowerCase()) { + case "cursor": + case "factory": + case "droid": + case "kimi": + case "kimik2": + case "augment": + case "opencode": + case "amp": + case "ollama": + case "alibaba": + case "infini": + case "manus": + case "mimo": + case "zoommate": + case "notion": + case "t3chat": + case "commandcode": + case "raycast": + case "langdock": + case "lithosai": + case "museai": + case "workbuddy": + return t("ProviderSourceWebShort"); + case "gemini": + case "antigravity": + case "jetbrains": + return t("ProviderSourceCliShort"); + case "copilot": + return t("ProviderSourceOauthShort"); + case "zai": + case "vertexai": + case "openrouter": + case "bedrock": + case "nanogpt": + case "warp": + case "deepinfra": + case "aiand": + case "zenmux": + case "clinepass": + case "neuralwatt": + case "devpass": + case "xkiro": + case "doubao": + case "stepfun": + case "venice": + case "openaiapi": + case "elevenlabs": + case "deepgram": + case "groq": + case "llmproxy": + case "xai": + case "fireworks": + case "meta": + case "vercel": + case "synthetic": + case "clawrouter": + case "ibmbob": + return t("ProviderSourceApiShort"); + case "kiro": + return t("ProviderSourceKiroEnvShort"); + default: + return t("ProviderSourceAutoShort"); + } +} + export function relativeAgo(iso: string): string | null { const t = new Date(iso).getTime(); if (!Number.isFinite(t)) return null; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx index c1f46f61d7..6496db6d05 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx @@ -84,3 +84,28 @@ describe("IdentitySection account privacy for every provider", () => { expect(screen.getByText("person@example.com")).toBeInTheDocument(); }); }); + +describe("IdentitySection source row", () => { + it("falls back to the provider's source hint before the first fetch", () => { + render( + , + ); + expect(screen.getByText("ProviderSourceWebShort")).toBeInTheDocument(); + }); + + it("prefers the fetched source label", () => { + render( + , + ); + expect(screen.getByText("api")).toBeInTheDocument(); + expect(screen.queryByText("ProviderSourceApiShort")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx index 7f44e4222e..3241542261 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx @@ -3,6 +3,7 @@ import type { LocaleKey } from "../../../../i18n/keys"; import { ProviderIcon } from "../../../../components/providers/ProviderIcon"; import { hideOpenAiApiProjectId } from "../../../../lib/openAiApiIdentity"; import { maskEmail } from "../../../../components/MenuCard"; +import { providerSourceHintShort } from "../providerDetailFormat"; interface Props { provider: Pick< @@ -46,7 +47,10 @@ export function IdentitySection({ value: hideOpenAiApiProjectId(displayIdentityValue(provider.plan, t), hideProjectId), }, { label: t("AuthType"), value: provider.authType }, - { label: t("DataSource"), value: provider.sourceLabel }, + { + label: t("DataSource"), + value: provider.sourceLabel || providerSourceHintShort(provider.id, t), + }, ]; const visible = rows.filter( (r): r is { label: string; value: string } => From 3dc6bbb2c769781326cd5606fe2ad31e8dcb9142 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:29:24 +0700 Subject: [PATCH 02/13] Store per-provider quota warnings and Codex options in provider configs Migration #5 folds legacy provider_usage_thresholds (window key, then provider key, per field) into provider_configs..quota_warnings and writes only the new key. Off silences that window's threshold toasts. The bridge exposes providerQuotaWarnings and the Codex options. --- .../src-tauri/src/commands/bridge.rs | 29 ++- .../src-tauri/src/commands/settings.rs | 109 ++++++++- rust/src/notifications.rs | 99 +++++++- rust/src/settings.rs | 27 ++- rust/src/settings/preferences_document.rs | 20 +- .../settings/preferences_document/tests.rs | 54 +++-- rust/src/settings/raw.rs | 13 +- rust/src/settings/tests.rs | 215 ++++++++++++++++-- rust/src/settings/types.rs | 182 ++++++++++++++- 9 files changed, 658 insertions(+), 90 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 14503ee3f9..b1fe5eea9e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -832,8 +832,9 @@ pub struct SettingsSnapshot { notification_sound_paths: codexbar::settings::NotificationSoundPaths, high_usage_threshold: f64, critical_usage_threshold: f64, - provider_usage_thresholds: - std::collections::HashMap, + /// Per-provider quota warnings keyed by CLI name; Global providers are absent. + provider_quota_warnings: + std::collections::HashMap, predictive_pace_warning_enabled: bool, credential_expiry_notifications_enabled: bool, session_quota_notifications_enabled: bool, @@ -882,6 +883,11 @@ pub struct SettingsSnapshot { claude_swap_enabled: bool, claude_swap_executable_path: String, codex_spark_usage_visible: bool, + codex_openai_web_extras: bool, + codex_historical_tracking: bool, + codex_external_oauth_sources_allowed: bool, + codex_local_session_cost_ledger_enabled: bool, + codex_openai_web_battery_saver_enabled: bool, disable_keychain_access: bool, wayfinder_gateway_url: String, provider_metrics: std::collections::HashMap, @@ -943,6 +949,18 @@ impl From for SettingsSnapshot { let claude_swap_enabled = settings.claude_swap_enabled(); let claude_swap_executable_path = settings.claude_swap_executable_path().to_string(); let codex_spark_usage_visible = settings.codex_spark_usage_visible(); + let codex_openai_web_extras = settings.codex_openai_web_extras(); + let codex_historical_tracking = settings.codex_historical_tracking(); + let codex_local_session_cost_ledger_enabled = + settings.local_session_cost_ledger_enabled(ProviderId::Codex); + let codex_openai_web_battery_saver_enabled = + settings.openai_web_battery_saver_enabled(ProviderId::Codex); + let provider_quota_warnings = settings + .provider_configs + .iter() + .filter(|(_, config)| !config.quota_warnings.is_empty()) + .map(|(id, config)| (id.cli_name().to_string(), config.quota_warnings)) + .collect(); let wayfinder_gateway_url = settings.gateway_url(ProviderId::Wayfinder).to_string(); let provider_order = settings.provider_display_order_names(); @@ -978,7 +996,7 @@ impl From for SettingsSnapshot { notification_sound_paths: settings.notification_sound_paths, high_usage_threshold: settings.high_usage_threshold, critical_usage_threshold: settings.critical_usage_threshold, - provider_usage_thresholds: settings.provider_usage_thresholds, + provider_quota_warnings, predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled, credential_expiry_notifications_enabled: settings .credential_expiry_notifications_enabled, @@ -1030,6 +1048,11 @@ impl From for SettingsSnapshot { claude_swap_enabled, claude_swap_executable_path, codex_spark_usage_visible, + codex_openai_web_extras, + codex_historical_tracking, + codex_external_oauth_sources_allowed: settings.codex_external_oauth_sources_allowed, + codex_local_session_cost_ledger_enabled, + codex_openai_web_battery_saver_enabled, disable_keychain_access: settings.disable_keychain_access, wayfinder_gateway_url, provider_metrics, diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 154b7c13b9..3be594f168 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -22,8 +22,10 @@ pub struct SettingsUpdate { pub notification_sound_paths: Option, pub high_usage_threshold: Option, pub critical_usage_threshold: Option, - pub provider_usage_thresholds: - Option>, + /// Map of provider CLI name → that provider's full quota warning settings + /// (replaces the stored value; an empty object means Global). + pub provider_quota_warnings: + Option>, pub predictive_pace_warning_enabled: Option, pub credential_expiry_notifications_enabled: Option, pub session_quota_notifications_enabled: Option, @@ -74,6 +76,11 @@ pub struct SettingsUpdate { pub claude_swap_enabled: Option, pub claude_swap_executable_path: Option, pub codex_spark_usage_visible: Option, + pub codex_openai_web_extras: Option, + pub codex_historical_tracking: Option, + pub codex_external_oauth_sources_allowed: Option, + pub codex_local_session_cost_ledger_enabled: Option, + pub codex_openai_web_battery_saver_enabled: Option, pub disable_keychain_access: Option, /// Map of provider CLI name → metric preference label. pub provider_metrics: Option>, @@ -115,6 +122,8 @@ impl SettingsUpdate { fn refreshes_provider_data(&self) -> bool { self.enabled_providers.is_some() || self.claude_allow_reading_claude_code_credentials.is_some() + || self.codex_openai_web_extras.is_some() + || self.codex_external_oauth_sources_allowed.is_some() || self.alibaba_token_plan_region.is_some() || self.copilot_seat_credit_entitlement.is_some() || self.weekly_progress_work_days.is_some() @@ -131,7 +140,7 @@ impl SettingsUpdate { || self.cost_usage_enabled.is_some() || self.high_usage_threshold.is_some() || self.critical_usage_threshold.is_some() - || self.provider_usage_thresholds.is_some() + || self.provider_quota_warnings.is_some() || self.show_as_used.is_some() || self.reset_time_relative.is_some() || self.show_reset_when_exhausted.is_some() @@ -160,6 +169,7 @@ impl SettingsUpdate { || self.overview_layout.is_some() || self.provider_metrics.is_some() || self.provider_hidden_usage_item_ids.is_some() + || self.provider_quota_warnings.is_some() || self.preferred_currency_code.is_some() || self.codex_spark_usage_visible.is_some() || self.copilot_seat_credit_entitlement.is_some() @@ -389,9 +399,8 @@ impl SettingsUpdate { if let Some(v) = self.critical_usage_threshold { settings.critical_usage_threshold = v.clamp(0.0, 100.0); } - if let Some(values) = self.provider_usage_thresholds.clone() { - settings.provider_usage_thresholds = - codexbar::settings::normalize_usage_threshold_overrides(values); + if let Some(values) = self.provider_quota_warnings.clone() { + apply_provider_quota_warnings(settings, values); } if let Some(v) = self.predictive_pace_warning_enabled { settings.predictive_pace_warning_enabled = v; @@ -474,6 +483,21 @@ impl SettingsUpdate { if let Some(v) = self.codex_spark_usage_visible { settings.set_codex_spark_usage_visible(v); } + if let Some(v) = self.codex_openai_web_extras { + settings.set_codex_openai_web_extras(v); + } + if let Some(v) = self.codex_historical_tracking { + settings.set_codex_historical_tracking(v); + } + if let Some(v) = self.codex_external_oauth_sources_allowed { + settings.codex_external_oauth_sources_allowed = v; + } + if let Some(v) = self.codex_local_session_cost_ledger_enabled { + settings.set_local_session_cost_ledger_enabled(codexbar::core::ProviderId::Codex, v); + } + if let Some(v) = self.codex_openai_web_battery_saver_enabled { + settings.set_openai_web_battery_saver_enabled(codexbar::core::ProviderId::Codex, v); + } if let Some(v) = self.disable_keychain_access { settings.disable_keychain_access = v; if v { @@ -593,6 +617,17 @@ fn apply_provider_metrics( } } +fn apply_provider_quota_warnings( + settings: &mut Settings, + values: std::collections::HashMap, +) { + for (provider, warnings) in values { + if let Ok(provider_id) = super::parse_provider_arg(&provider) { + settings.set_quota_warnings(provider_id, warnings); + } + } +} + fn apply_provider_hidden_usage_item_ids( settings: &mut Settings, values: std::collections::HashMap>, @@ -780,6 +815,68 @@ mod tests { ); } + #[test] + fn codex_options_and_quota_warnings_round_trip_through_the_bridge() { + let patch: SettingsUpdate = serde_json::from_value(serde_json::json!({ + "codexOpenaiWebExtras": false, + "codexHistoricalTracking": true, + "codexExternalOauthSourcesAllowed": true, + "codexLocalSessionCostLedgerEnabled": true, + "codexOpenaiWebBatterySaverEnabled": true, + "providerQuotaWarnings": { + "codex": { + "session": { "high": 60.0, "critical": 85.0 }, + "weekly": { "enabled": false } + }, + "not-a-provider": { "session": { "high": 10.0 } } + } + })) + .expect("bridge patch deserializes"); + assert!(patch.notifies_float_bar()); + assert!(patch.refreshes_tray_presentation()); + assert!(patch.refreshes_provider_data()); + + let mut settings = Settings::default(); + assert!( + settings.codex_openai_web_extras(), + "Windows default stays on" + ); + patch.apply_to(&mut settings).expect("apply"); + + let snapshot = serde_json::to_value(crate::commands::bridge::SettingsSnapshot::from( + settings.clone(), + )) + .expect("snapshot serializes"); + assert_eq!(snapshot["codexOpenaiWebExtras"], false); + assert_eq!(snapshot["codexHistoricalTracking"], true); + assert_eq!(snapshot["codexExternalOauthSourcesAllowed"], true); + assert_eq!(snapshot["codexLocalSessionCostLedgerEnabled"], true); + assert_eq!(snapshot["codexOpenaiWebBatterySaverEnabled"], true); + assert_eq!( + snapshot["providerQuotaWarnings"], + serde_json::json!({ + "codex": { + "session": { "high": 60.0, "critical": 85.0 }, + "weekly": { "enabled": false } + } + }) + ); + assert!(snapshot.get("providerUsageThresholds").is_none()); + assert!(!settings.quota_warning_enabled(codexbar::core::ProviderId::Codex, "weekly")); + + // An empty object returns the provider to Global. + let reset: SettingsUpdate = serde_json::from_value(serde_json::json!({ + "providerQuotaWarnings": { "codex": {} } + })) + .expect("reset patch"); + reset.apply_to(&mut settings).expect("apply reset"); + assert!( + settings + .quota_warnings(codexbar::core::ProviderId::Codex) + .is_empty() + ); + } + #[test] fn apply_advanced_settings_sets_claude_code_credentials_consent() { let mut settings = Settings::default(); diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index a1562bf4fe..87c0f2dc36 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -298,6 +298,12 @@ impl NotificationManager { used_percent: f64, settings: &Settings, ) { + if !settings.quota_warning_enabled(provider, window) { + // Quota warnings set to Off for this provider window: forget its + // sent state so turning them back on starts fresh (Mac parity). + self.clear_threshold_toasts(provider, account, window); + return; + } if !settings.show_notifications { return; } @@ -313,9 +319,7 @@ impl NotificationManager { // Clear only this provider+account+window's threshold toasts so a cool // session on one account cannot re-arm another account's weekly (or // another window on the same account) on the next poll. - self.sent_notifications.retain(|(p, a, w, t)| { - *p != provider || a != account || w != window || !t.is_threshold_toast() - }); + self.clear_threshold_toasts(provider, account, window); None }; @@ -333,6 +337,12 @@ impl NotificationManager { } } + fn clear_threshold_toasts(&mut self, provider: ProviderId, account: &str, window: &str) { + self.sent_notifications.retain(|(p, a, w, t)| { + *p != provider || a != account || w != window || !t.is_threshold_toast() + }); + } + /// Observe a session lane across all session consumers. Informational /// placeholders represent missing data and must not clear or re-arm /// threshold, transition, or hook state in the caller. @@ -970,6 +980,89 @@ mod tests { assert_eq!(manager.predictive_warning_keys.len(), 2); } + #[test] + fn quota_warnings_off_suppresses_threshold_toasts_for_that_window_only() { + use crate::settings::{ProviderQuotaWarnings, QuotaWarningWindow}; + let mut manager = NotificationManager::new(); + let mut settings = Settings::default(); + let account = ""; + let key = |window: &str, kind| { + ( + ProviderId::Claude, + account.to_string(), + window.to_string(), + kind, + ) + }; + + manager.check_and_notify(ProviderId::Claude, account, "session", 80.0, &settings); + assert!( + manager + .sent_notifications + .contains(&key("session", NotificationType::HighUsage)) + ); + + settings.set_quota_warnings( + ProviderId::Claude, + ProviderQuotaWarnings { + session: QuotaWarningWindow { + enabled: Some(false), + high: None, + critical: None, + }, + weekly: QuotaWarningWindow::default(), + }, + ); + manager.check_and_notify(ProviderId::Claude, account, "session", 95.0, &settings); + manager.check_and_notify(ProviderId::Claude, account, "weekly", 80.0, &settings); + assert!( + !manager + .sent_notifications + .iter() + .any(|(_, _, window, _)| window == "session"), + "Off clears and suppresses the session window" + ); + assert!( + manager + .sent_notifications + .contains(&key("weekly", NotificationType::HighUsage)), + "the Global weekly window still warns" + ); + } + + #[test] + fn custom_quota_warning_threshold_decides_when_a_toast_fires() { + use crate::settings::{ProviderQuotaWarnings, QuotaWarningWindow}; + let mut manager = NotificationManager::new(); + let mut settings = Settings::default(); + settings.set_quota_warnings( + ProviderId::Codex, + ProviderQuotaWarnings { + session: QuotaWarningWindow { + enabled: None, + high: Some(40.0), + critical: Some(60.0), + }, + weekly: QuotaWarningWindow::default(), + }, + ); + manager.check_and_notify(ProviderId::Codex, "", "session", 45.0, &settings); + assert!(manager.sent_notifications.contains(&( + ProviderId::Codex, + String::new(), + "session".to_string(), + NotificationType::HighUsage, + ))); + // 45% is below the global 70% high threshold, so weekly stays quiet. + manager.check_and_notify(ProviderId::Codex, "", "weekly", 45.0, &settings); + assert!( + !manager + .sent_notifications + .iter() + .any(|(_, _, window, _)| window == "weekly") + ); + } + #[test] fn session_below_high_does_not_rearm_weekly_high_toast() { // Repro for #198: session cool + weekly hot on every refresh used to diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 662e2b0a4f..6165449576 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -147,8 +147,6 @@ pub struct Settings { /// Critical usage threshold for alerts (percentage) pub critical_usage_threshold: f64, - pub provider_usage_thresholds: HashMap, - /// Merge mode: show all enabled providers in a single tray icon pub merge_tray_icons: bool, @@ -654,7 +652,6 @@ impl Default for Settings { notification_sound_theme: NotificationSoundTheme::default(), high_usage_threshold: 70.0, critical_usage_threshold: 90.0, - provider_usage_thresholds: HashMap::new(), merge_tray_icons: false, // Show single provider by default tray_icon_mode: TrayIconMode::default(), // Single icon by default stacked_tray_top_provider: None, @@ -1392,6 +1389,30 @@ impl Settings { self.provider_config_mut(id).historical_tracking = value; } + /// Codex "Local session cost estimates" option (persisted only). + pub fn local_session_cost_ledger_enabled(&self, id: ProviderId) -> bool { + self.provider_configs + .get(&id) + .is_some_and(|c| c.local_session_cost_ledger_enabled) + } + + pub fn set_local_session_cost_ledger_enabled(&mut self, id: ProviderId, value: bool) { + self.provider_config_mut(id) + .local_session_cost_ledger_enabled = value; + } + + /// Codex OpenAI web battery saver option (persisted only). + pub fn openai_web_battery_saver_enabled(&self, id: ProviderId) -> bool { + self.provider_configs + .get(&id) + .is_some_and(|c| c.openai_web_battery_saver_enabled) + } + + pub fn set_openai_web_battery_saver_enabled(&mut self, id: ProviderId, value: bool) { + self.provider_config_mut(id) + .openai_web_battery_saver_enabled = value; + } + /// Per-provider "avoid keychain prompts" toggle (currently claude-only). pub fn avoid_keychain_prompts(&self, id: ProviderId) -> bool { self.provider_configs diff --git a/rust/src/settings/preferences_document.rs b/rust/src/settings/preferences_document.rs index c11ace899f..7aea5d9b51 100644 --- a/rust/src/settings/preferences_document.rs +++ b/rust/src/settings/preferences_document.rs @@ -49,6 +49,13 @@ pub enum PreferencesError { type Validator = fn(&Value) -> bool; +/// Legacy keys accepted on import only. They are no longer `Settings` fields; +/// the settings loader migrates them, so they are never exported. +const IMPORT_ONLY_PREFERENCES: &[(&str, Validator)] = &[ + // Migration #5: folded into `provider_configs..quota_warnings`. + ("provider_usage_thresholds", provider_usage_thresholds), +]; + /// Portable keys, named after the `Settings` fields, with their validators. const ALLOWED_PREFERENCES: &[(&str, Validator)] = &[ ("refresh_interval_secs", refresh_interval_secs), @@ -63,7 +70,6 @@ const ALLOWED_PREFERENCES: &[(&str, Validator)] = &[ ("sound_enabled", typed::), ("high_usage_threshold", percent), ("critical_usage_threshold", percent), - ("provider_usage_thresholds", provider_usage_thresholds), ("predictive_pace_warning_enabled", typed::), ("show_pace", typed::), ("show_as_used", typed::), @@ -320,7 +326,11 @@ impl PreferencesDocument { fn validated(preferences: Map) -> Result { for (key, value) in &preferences { - let Some((_, valid)) = ALLOWED_PREFERENCES.iter().find(|(name, _)| name == key) else { + let Some((_, valid)) = ALLOWED_PREFERENCES + .iter() + .chain(IMPORT_ONLY_PREFERENCES) + .find(|(name, _)| name == key) + else { return Err(invalid(key)); }; if !value.is_null() && !valid(value) { @@ -357,12 +367,6 @@ impl PreferencesDocument { if let Some(Value::Object(metrics)) = preferences.get_mut("provider_metrics") { metrics.retain(|provider, _| known.contains(provider.as_str())); } - if let Some(Value::Object(thresholds)) = preferences.get_mut("provider_usage_thresholds") { - thresholds.retain(|key, _| { - let provider = key.split_once(':').map_or(key.as_str(), |(id, _)| id); - known.contains(provider) - }); - } if let Some(Value::Array(ids)) = preferences.get_mut("enabled_providers") { // `enabled_providers` is a set; keep exports deterministic. ids.sort_by(|a, b| a.as_str().cmp(&b.as_str())); diff --git a/rust/src/settings/preferences_document/tests.rs b/rust/src/settings/preferences_document/tests.rs index 5fb896f740..9ad0b6ecc7 100644 --- a/rust/src/settings/preferences_document/tests.rs +++ b/rust/src/settings/preferences_document/tests.rs @@ -1,7 +1,5 @@ use super::*; -use crate::settings::{ - Language, ProviderConfig, ThemePreference, TrayIconMode, UsageThresholdOverride, -}; +use crate::settings::{Language, ProviderConfig, ThemePreference, TrayIconMode}; use serde_json::json; use std::collections::BTreeMap; @@ -82,13 +80,6 @@ fn customized_settings() -> Settings { ..Settings::default() }; settings.enabled_providers = ["claude", "codex", "zai"].map(String::from).into(); - settings.provider_usage_thresholds.insert( - "codex:weekly".to_string(), - UsageThresholdOverride { - high: Some(60.0), - critical: None, - }, - ); settings.provider_metrics.insert( "claude".to_string(), crate::settings::MetricPreference::Weekly, @@ -200,13 +191,6 @@ fn export_skips_provider_preferences_unknown_to_this_build() { "future-provider".to_string(), crate::settings::MetricPreference::Weekly, ); - settings.provider_usage_thresholds.insert( - "future-provider:weekly".to_string(), - UsageThresholdOverride { - high: Some(65.0), - critical: None, - }, - ); let exported = PreferencesDocument::from_settings(&settings) .expect("stale provider IDs do not prevent export"); @@ -225,17 +209,32 @@ fn export_skips_provider_preferences_unknown_to_this_build() { .get("future-provider") .is_none() ); - assert!( - preferences["provider_usage_thresholds"] - .get("future-provider:weekly") - .is_none() - ); assert!(preferences["provider_metrics"].get("claude").is_some()); - assert!( - preferences["provider_usage_thresholds"] - .get("codex:weekly") - .is_some() - ); + // Per-provider quota warnings live in `provider_configs`, which is not + // portable, so the legacy threshold key is no longer exported. + assert!(preferences.get("provider_usage_thresholds").is_none()); +} + +#[test] +fn importing_legacy_threshold_overrides_migrates_them_to_quota_warnings() { + let mut settings = Settings::default(); + parse(json!({ + "provider_usage_thresholds": { + "codex:session": { "high": 55 }, + "claude": { "critical": 97 } + } + })) + .expect("legacy key still validates") + .apply_to(&mut settings) + .expect("apply"); + + let codex = settings.quota_warnings(crate::core::ProviderId::Codex); + assert_eq!(codex.session.high, Some(55.0)); + assert!(codex.weekly.is_global()); + let claude = settings.quota_warnings(crate::core::ProviderId::Claude); + assert_eq!(claude.session.critical, Some(97.0)); + assert_eq!(claude.weekly.critical, Some(97.0)); + assert_eq!(claude.weekly.high, None); } #[test] @@ -555,7 +554,6 @@ fn null_restores_the_default() { assert_eq!(settings.window_scale_percent, defaults.window_scale_percent); assert_eq!(settings.theme, defaults.theme); assert_eq!(settings.enabled_providers, defaults.enabled_providers); - assert!(settings.provider_usage_thresholds.is_empty()); assert!(settings.provider_metrics.is_empty()); assert!(settings.provider_order.is_empty()); assert!(settings.show_pace); diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index dbd2a9d879..08c3c9c35a 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -665,6 +665,16 @@ impl From for Settings { .avoid_keychain_prompts = true; } + for config in provider_configs.values_mut() { + config.quota_warnings = config.quota_warnings.normalized(); + } + // Migration #5: legacy per-provider threshold overrides move into + // `provider_configs..quota_warnings`; only the new key is saved. + super::types::migrate_legacy_usage_thresholds( + &mut provider_configs, + raw.provider_usage_thresholds, + ); + let low_power_mode_preference = match raw.low_power_mode_preference { // A legacy writer may know only the boolean and leave the new field // at its serialized default. Preserve the previously enabled state. @@ -695,9 +705,6 @@ impl From for Settings { notification_sound_theme: raw.notification_sound_theme, high_usage_threshold: raw.high_usage_threshold, critical_usage_threshold: raw.critical_usage_threshold, - provider_usage_thresholds: normalize_usage_threshold_overrides( - raw.provider_usage_thresholds, - ), merge_tray_icons: raw.merge_tray_icons, tray_icon_mode: raw.tray_icon_mode, stacked_tray_top_provider: raw diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 65f51e0334..8af7681f0b 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -343,21 +343,26 @@ fn new_warning_and_reset_settings_are_backward_compatible() { assert!(!loaded.credential_expiry_notifications_enabled); } +fn load_settings_json(json: &str) -> Settings { + serde_json::from_str(json).expect("parse settings") +} + +fn custom(high: Option, critical: Option) -> QuotaWarningWindow { + QuotaWarningWindow { + enabled: None, + high, + critical, + } +} + #[test] -fn usage_thresholds_inherit_from_window_provider_and_global_levels() { +fn custom_quota_warnings_win_over_global_thresholds() { let mut settings = Settings::default(); - settings.provider_usage_thresholds.insert( - "codex".into(), - UsageThresholdOverride { - high: Some(75.0), - critical: None, - }, - ); - settings.provider_usage_thresholds.insert( - "codex:weekly".into(), - UsageThresholdOverride { - high: None, - critical: Some(95.0), + settings.set_quota_warnings( + ProviderId::Codex, + ProviderQuotaWarnings { + session: QuotaWarningWindow::default(), + weekly: custom(Some(75.0), Some(95.0)), }, ); @@ -368,6 +373,14 @@ fn usage_thresholds_inherit_from_window_provider_and_global_levels() { critical: 95.0, } ); + // Global window and another provider keep the global values. + assert_eq!( + settings.usage_thresholds(ProviderId::Codex, "session"), + UsageThresholds { + high: 70.0, + critical: 90.0, + } + ); assert_eq!( settings.usage_thresholds(ProviderId::Claude, "session"), UsageThresholds { @@ -375,29 +388,183 @@ fn usage_thresholds_inherit_from_window_provider_and_global_levels() { critical: 90.0, } ); + // A partial Custom value inherits the missing one from global. + settings.set_quota_warnings( + ProviderId::Claude, + ProviderQuotaWarnings { + session: custom(None, Some(98.0)), + weekly: QuotaWarningWindow::default(), + }, + ); + assert_eq!( + settings.usage_thresholds(ProviderId::Claude, "session"), + UsageThresholds { + high: 70.0, + critical: 98.0, + } + ); } #[test] -fn empty_and_out_of_range_threshold_overrides_are_normalized_on_load() { - let loaded: Settings = serde_json::from_str( +fn quota_warning_off_is_per_window_and_keeps_saved_values() { + let mut settings = Settings::default(); + assert!(settings.quota_warning_enabled(ProviderId::Codex, "session")); + settings.set_quota_warnings( + ProviderId::Codex, + ProviderQuotaWarnings { + session: QuotaWarningWindow { + enabled: Some(false), + high: Some(60.0), + critical: Some(80.0), + }, + weekly: QuotaWarningWindow::default(), + }, + ); + assert!(!settings.quota_warning_enabled(ProviderId::Codex, "session")); + assert!(settings.quota_warning_enabled(ProviderId::Codex, "weekly")); + assert!(settings.quota_warning_enabled(ProviderId::Claude, "session")); + + let reloaded = load_settings_json(&serde_json::to_string(&settings).expect("serialize")); + let session = reloaded.quota_warnings(ProviderId::Codex).session; + assert_eq!(session.enabled, Some(false)); + assert_eq!(session.high, Some(60.0)); + assert_eq!(session.critical, Some(80.0)); +} + +#[test] +fn global_quota_warnings_are_not_serialized() { + let mut settings = Settings::default(); + settings.set_quota_warnings(ProviderId::Codex, ProviderQuotaWarnings::default()); + let value = serde_json::to_value(&settings).expect("serialize"); + assert!( + value["provider_configs"]["codex"] + .get("quota_warnings") + .is_none() + ); +} + +#[test] +fn migration_moves_a_window_key_into_quota_warnings() { + let loaded = load_settings_json( + r#"{ "provider_usage_thresholds": { "codex:session": { "high": 80.0 } } }"#, + ); + let codex = loaded.quota_warnings(ProviderId::Codex); + assert_eq!(codex.session, custom(Some(80.0), None)); + assert!(codex.weekly.is_global()); + assert_eq!( + loaded.usage_thresholds(ProviderId::Codex, "session"), + UsageThresholds { + high: 80.0, + critical: 90.0, + } + ); + // Only the new key is written back. + let saved = serde_json::to_value(&loaded).expect("serialize"); + assert!(saved.get("provider_usage_thresholds").is_none()); + assert_eq!( + saved["provider_configs"]["codex"]["quota_warnings"]["session"]["high"], + serde_json::json!(80.0) + ); +} + +#[test] +fn migration_spreads_a_provider_key_over_both_windows() { + let loaded = load_settings_json( + r#"{ "provider_usage_thresholds": { "claude": { "high": 65.0, "critical": 85.0 } } }"#, + ); + let claude = loaded.quota_warnings(ProviderId::Claude); + assert_eq!(claude.session, custom(Some(65.0), Some(85.0))); + assert_eq!(claude.weekly, custom(Some(65.0), Some(85.0))); +} + +#[test] +fn migration_merges_window_and_provider_keys_per_field() { + let loaded = load_settings_json( r#"{ "provider_usage_thresholds": { - "codex": {"high": 120.0}, - "claude": {}, - "codex:weekly": {"critical": -10.0} + "codex": { "high": 75.0 }, + "codex:weekly": { "critical": 95.0 } } }"#, - ) - .expect("parse settings"); + ); + let codex = loaded.quota_warnings(ProviderId::Codex); + assert_eq!(codex.weekly, custom(Some(75.0), Some(95.0))); + assert_eq!(codex.session, custom(Some(75.0), None)); +} - assert_eq!(loaded.provider_usage_thresholds.len(), 2); - assert_eq!(loaded.provider_usage_thresholds["codex"].high, Some(100.0)); +#[test] +fn migration_without_legacy_keys_leaves_every_window_global() { + let loaded = load_settings_json(r#"{ "provider_usage_thresholds": {} }"#); + assert!( + loaded + .provider_configs + .values() + .all(|config| config.quota_warnings.is_empty()) + ); + let loaded = load_settings_json(r#"{ "refresh_interval_secs": 300 }"#); + assert!(loaded.quota_warnings(ProviderId::Codex).is_empty()); +} + +#[test] +fn migration_keeps_existing_quota_warnings() { + let loaded = load_settings_json( + r#"{ + "provider_usage_thresholds": { "codex": { "high": 50.0 } }, + "provider_configs": { + "codex": { "quota_warnings": { "session": { "enabled": false } } } + } + }"#, + ); + let codex = loaded.quota_warnings(ProviderId::Codex); + assert_eq!( + codex.session, + QuotaWarningWindow { + enabled: Some(false), + high: None, + critical: None, + } + ); + assert_eq!(codex.weekly, custom(Some(50.0), None)); +} + +#[test] +fn migration_normalizes_out_of_range_and_unknown_entries() { + let loaded = load_settings_json( + r#"{ + "provider_usage_thresholds": { + "codex": { "high": 120.0 }, + "claude": {}, + "codex:weekly": { "critical": -10.0 }, + "codex:monthly": { "high": 10.0 }, + "future-provider": { "high": 10.0 } + }, + "provider_configs": { + "zai": { "quota_warnings": { "weekly": { "high": 140.0 } } } + } + }"#, + ); + let codex = loaded.quota_warnings(ProviderId::Codex); + assert_eq!(codex.session, custom(Some(100.0), None)); + assert_eq!(codex.weekly, custom(Some(100.0), Some(0.0))); + assert!(loaded.quota_warnings(ProviderId::Claude).is_empty()); assert_eq!( - loaded.provider_usage_thresholds["codex:weekly"].critical, - Some(0.0) + loaded.quota_warnings(ProviderId::Zai).weekly, + custom(Some(100.0), None) ); } +#[test] +fn codex_option_toggles_round_trip_through_settings_json() { + let mut settings = Settings::default(); + settings.set_local_session_cost_ledger_enabled(ProviderId::Codex, true); + settings.set_openai_web_battery_saver_enabled(ProviderId::Codex, true); + let reloaded = load_settings_json(&serde_json::to_string(&settings).expect("serialize")); + assert!(reloaded.local_session_cost_ledger_enabled(ProviderId::Codex)); + assert!(reloaded.openai_web_battery_saver_enabled(ProviderId::Codex)); + assert!(!Settings::default().local_session_cost_ledger_enabled(ProviderId::Codex)); + assert!(!Settings::default().openai_web_battery_saver_enabled(ProviderId::Codex)); +} + #[test] fn float_bar_defaults_are_safe() { let settings = Settings::default(); diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index ca926cb5ab..bd58b3e362 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -60,23 +60,170 @@ pub fn normalize_usage_threshold_overrides( .collect() } +/// The two quota windows that carry per-provider warning settings. +pub const QUOTA_WARNING_WINDOWS: [&str; 2] = ["session", "weekly"]; + +/// One window's per-provider quota warning settings (Mac +/// `QuotaWarningWindowConfig`). Values are *used* percentages. +/// +/// - Global: every field is absent; the global thresholds apply. +/// - Custom: `high` and/or `critical` is set; a missing value inherits global. +/// - Off: `enabled == Some(false)`; saved values are kept for a later Custom. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct QuotaWarningWindow { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub high: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub critical: Option, +} + +impl QuotaWarningWindow { + /// True when the window follows the global settings (nothing stored). + pub fn is_global(&self) -> bool { + self.enabled.is_none() && self.high.is_none() && self.critical.is_none() + } + + /// Clamp stored values to 0..=100 and drop non-finite numbers. + pub fn normalized(self) -> Self { + let clamp = |value: Option| { + value + .filter(|number| number.is_finite()) + .map(|number| number.clamp(0.0, 100.0)) + }; + Self { + enabled: self.enabled, + high: clamp(self.high), + critical: clamp(self.critical), + } + } +} + +/// Per-provider quota warning settings for the session and weekly windows. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ProviderQuotaWarnings { + #[serde(skip_serializing_if = "QuotaWarningWindow::is_global")] + pub session: QuotaWarningWindow, + #[serde(skip_serializing_if = "QuotaWarningWindow::is_global")] + pub weekly: QuotaWarningWindow, +} + +impl ProviderQuotaWarnings { + pub fn is_empty(&self) -> bool { + self.session.is_global() && self.weekly.is_global() + } + + pub fn window(&self, window: &str) -> Option<&QuotaWarningWindow> { + match window { + "session" => Some(&self.session), + "weekly" => Some(&self.weekly), + _ => None, + } + } + + pub fn window_mut(&mut self, window: &str) -> Option<&mut QuotaWarningWindow> { + match window { + "session" => Some(&mut self.session), + "weekly" => Some(&mut self.weekly), + _ => None, + } + } + + pub fn normalized(self) -> Self { + Self { + session: self.session.normalized(), + weekly: self.weekly.normalized(), + } + } +} + +/// Migration #5: fold legacy `provider_usage_thresholds` entries +/// (`` and `:`) into +/// `provider_configs..quota_warnings`. +/// +/// Per field, the window key wins over the provider key; a value neither key +/// sets stays absent and keeps inheriting the global threshold. A window that +/// already has new-format settings is left untouched. +pub(super) fn migrate_legacy_usage_thresholds( + provider_configs: &mut HashMap, + legacy: HashMap, +) { + let known = crate::core::cli_name_map(); + let legacy = normalize_usage_threshold_overrides(legacy); + let mut providers: Vec<(&str, ProviderId)> = legacy + .keys() + .filter_map(|key| { + let provider = key.split_once(':').map_or(key.as_str(), |(p, _)| p); + known.get(provider).map(|id| (provider, *id)) + }) + .collect(); + providers.sort_unstable_by_key(|(provider, _)| *provider); + providers.dedup(); + for (provider_key, provider_id) in providers { + let provider_override = legacy.get(provider_key); + for window in QUOTA_WARNING_WINDOWS { + let window_override = legacy.get(&format!("{provider_key}:{window}")); + let high = window_override + .and_then(|value| value.high) + .or_else(|| provider_override.and_then(|value| value.high)); + let critical = window_override + .and_then(|value| value.critical) + .or_else(|| provider_override.and_then(|value| value.critical)); + if high.is_none() && critical.is_none() { + continue; + } + let config = provider_configs.entry(provider_id).or_default(); + let Some(target) = config.quota_warnings.window_mut(window) else { + continue; + }; + if target.is_global() { + target.high = high; + target.critical = critical; + } + } + } +} + impl Settings { + fn quota_warning_window(&self, provider: ProviderId, window: &str) -> QuotaWarningWindow { + self.provider_configs + .get(&provider) + .and_then(|config| config.quota_warnings.window(window)) + .copied() + .unwrap_or_default() + } + + /// Effective warning thresholds (*used* %) for one provider window: + /// the provider's Custom value, else the global threshold. pub fn usage_thresholds(&self, provider: ProviderId, window: &str) -> UsageThresholds { - let provider_key = provider.cli_name(); - let window_key = format!("{provider_key}:{window}"); - let provider_override = self.provider_usage_thresholds.get(provider_key); - let window_override = self.provider_usage_thresholds.get(&window_key); + let custom = self.quota_warning_window(provider, window); UsageThresholds { - high: window_override - .and_then(|value| value.high) - .or_else(|| provider_override.and_then(|value| value.high)) - .unwrap_or(self.high_usage_threshold), - critical: window_override - .and_then(|value| value.critical) - .or_else(|| provider_override.and_then(|value| value.critical)) - .unwrap_or(self.critical_usage_threshold), + high: custom.high.unwrap_or(self.high_usage_threshold), + critical: custom.critical.unwrap_or(self.critical_usage_threshold), } } + + /// False when the provider window's quota warnings are set to Off. Off + /// suppresses threshold notifications, threshold hooks and bar markers. + pub fn quota_warning_enabled(&self, provider: ProviderId, window: &str) -> bool { + self.quota_warning_window(provider, window) + .enabled + .unwrap_or(true) + } + + pub fn quota_warnings(&self, provider: ProviderId) -> ProviderQuotaWarnings { + self.provider_configs + .get(&provider) + .map(|config| config.quota_warnings) + .unwrap_or_default() + } + + pub fn set_quota_warnings(&mut self, provider: ProviderId, value: ProviderQuotaWarnings) { + self.provider_config_mut(provider).quota_warnings = value.normalized(); + } } /// UI language for the application @@ -418,6 +565,17 @@ pub struct ProviderConfig { /// Codex-only: enable historical usage tracking in UI. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub historical_tracking: bool, + /// Codex-only: keep a local session cost ledger (Mac Options parity; + /// persisted only, no Windows consumer yet). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub local_session_cost_ledger_enabled: bool, + /// Codex-only: OpenAI web battery saver, shown while web extras is on + /// (Mac Options parity; persisted only, no Windows consumer yet). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub openai_web_battery_saver_enabled: bool, + /// Per-window quota warning settings (Global, Custom or Off). + #[serde(default, skip_serializing_if = "ProviderQuotaWarnings::is_empty")] + pub quota_warnings: ProviderQuotaWarnings, /// Claude-only: avoid keychain prompts when reading credentials. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub avoid_keychain_prompts: bool, From 4e894131723a5755cbc77cde2402d926d8327164 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:38:24 +0700 Subject: [PATCH 03/13] Resolve quota warnings per provider window and save migrated thresholds once --- apps/desktop-tauri/src-tauri/src/main.rs | 1 + .../src/components/MenuCard.test.tsx | 9 +- apps/desktop-tauri/src/floatbar/FloatBar.tsx | 22 ++-- .../src/lib/metricRowModel.test.ts | 30 ++++- apps/desktop-tauri/src/lib/metricRowModel.ts | 34 ++--- apps/desktop-tauri/src/lib/quotaWarnings.ts | 52 ++++++++ .../src/surfaces/TrayPanel.test.tsx | 2 +- .../settings/tabs/GeneralTab.test.tsx | 41 +----- .../src/surfaces/settings/tabs/GeneralTab.tsx | 122 ------------------ apps/desktop-tauri/src/types/bridge.ts | 30 ++++- rust/src/settings.rs | 28 ++++ rust/src/settings/raw.rs | 2 + rust/src/settings/tests.rs | 74 +++++++++++ 13 files changed, 246 insertions(+), 201 deletions(-) create mode 100644 apps/desktop-tauri/src/lib/quotaWarnings.ts diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 14cceaf577..e915f7e7f3 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -145,6 +145,7 @@ fn main() { let force_start_visible = std::env::var_os("CODEXBAR_START_VISIBLE").is_some(); let mut settings = codexbar::settings::Settings::load(); settings.apply_display_mode_fold_migration(); + settings.apply_quota_warning_migration(); let launch = launch_behavior( force_start_visible, settings.start_minimized, diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 4dd2acf992..be9976b217 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -1200,14 +1200,15 @@ describe("MenuCard", () => { it.each([ ["global thresholds on a % left bar", false, {}, ["5%", "20%"]], ["global thresholds on a % used bar", true, {}, ["80%", "95%"]], - ["a provider session override", false, { "claude:session": { high: 60 } }, ["5%", "40%"]], + ["a Custom session quota warning", false, { claude: { session: { high: 60 } } }, ["5%", "40%"]], + ["a session quota warning set to Off", false, { claude: { session: { enabled: false } } }, []], ])("draws quota warning markers from %s", async (_case, showAsUsed, overrides, expected) => { const { container } = renderCard(provider(null, 30), { showAsUsed, usageThresholds: { highUsageThreshold: 80, criticalUsageThreshold: 95, - providerUsageThresholds: overrides, + providerQuotaWarnings: overrides, }, }); @@ -1218,7 +1219,9 @@ describe("MenuCard", () => { ), ).toEqual(expected); expect(container.querySelector(".menu-metric__progress")?.getAttribute("aria-valuetext")).toBe( - `${showAsUsed ? 30 : 70}%. Quota warnings: ${expected.join(", ")}`, + expected.length + ? `${showAsUsed ? 30 : 70}%. Quota warnings: ${expected.join(", ")}` + : `${showAsUsed ? 30 : 70}%`, ); }); diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index 5bfdc0bd39..f1e55c270a 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -24,6 +24,7 @@ import { costPeriodShortLabel } from "../lib/costPeriod"; import { describeProviderState } from "../lib/providerState"; import { resetDescriptionFallback, windowDetailText } from "../lib/usageWindows"; import { localizeProviderText } from "../lib/providerText"; +import { type QuotaWarningSettings, resolveQuotaWarning } from "../lib/quotaWarnings"; import type { BootstrapState, ProviderLocalUsageSummary, @@ -33,6 +34,8 @@ import type { import { FLOAT_BAR_CONFIG_CHANGED_EVENT, resizeFloatBar } from "./api"; import "./FloatBar.css"; +const WEEK_MINUTES = 7 * 24 * 60; + function ResetIcon({ size }: { size: number }) { return ( = WEEK_MINUTES ? "weekly" : "session", + ); + const highRemaining = 100 - warning.high; + const critRemaining = 100 - warning.critical; let tone: "ok" | "warn" | "crit" = "ok"; if (state.isProblem) tone = "crit"; else if (!informational) { @@ -513,8 +522,6 @@ export default function FloatBar({ state }: { state: BootstrapState }) { [], ); - const highRemaining = 100 - settings.highUsageThreshold; - const critRemaining = 100 - settings.criticalUsageThreshold; const opacityFraction = Math.max(0.3, Math.min(1, settings.floatBarOpacity / 100)); return ( @@ -543,8 +550,7 @@ export default function FloatBar({ state }: { state: BootstrapState }) { = {}): MetricRowInput { showAsUsed: false, showResetWhenExhausted: false, paceEnabled: true, - usageThresholds: { highUsageThreshold: 70, criticalUsageThreshold: 90, providerUsageThresholds: {} }, + usageThresholds: { highUsageThreshold: 70, criticalUsageThreshold: 90, providerQuotaWarnings: {} }, weeklyProgressWorkDays: null, quotaWarningMarkersVisible: true, workdayTickStyle: "subtle", @@ -299,13 +299,12 @@ describe("metricRowPresentation bar", () => { ]); }); - it("resolves window, provider and global thresholds per field", () => { - const providerUsageThresholds = { - claude: { high: 75 }, - "claude:weekly": { critical: 95 }, - codex: { high: 50, critical: 60 }, + it("resolves Custom quota warnings per window and field, falling back to global", () => { + const providerQuotaWarnings = { + claude: { session: { high: 75 }, weekly: { high: 75, critical: 95 } }, + codex: { session: { high: 50, critical: 60 } }, }; - const usageThresholds = { ...thresholds(70), providerUsageThresholds }; + const usageThresholds = { ...thresholds(70), providerQuotaWarnings }; const weeklyRow = metricRowPresentation( input({ lane: "secondary", snap: weekly(), showAsUsed: true, usageThresholds }), en, @@ -320,6 +319,23 @@ describe("metricRowPresentation bar", () => { expect(tertiaryRow.bar.markers.map((m) => m.percent)).toEqual([75, 95]); }); + it("hides warning markers for a window whose quota warnings are Off", () => { + const usageThresholds = { + ...thresholds(70), + providerQuotaWarnings: { claude: { session: { enabled: false, high: 40, critical: 60 } } }, + }; + expect( + metricRowPresentation(input({ showAsUsed: true, usageThresholds }), en).bar.markers, + ).toEqual([]); + const weeklyRow = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), showAsUsed: true, usageThresholds }), + en, + ); + expect(weeklyRow.bar.markers.filter((m) => m.kind === "warning").map((m) => m.percent)).toEqual([ + 70, 90, + ]); + }); + it("drops edge and duplicate thresholds and never marks extra lanes", () => { expect(metricRowPresentation(input({ usageThresholds: thresholds(0, 100) }), en).bar.markers).toEqual([]); expect(metricRowPresentation(input({ usageThresholds: thresholds(80, 80) }), en).bar.markers).toEqual([ diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index 9569d809c0..31709e34b6 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -7,6 +7,7 @@ import type { WindowPaceSnapshot, WorkdayTickStyle, } from "../types/bridge"; +import { type QuotaWarningSettings, resolveQuotaWarning } from "./quotaWarnings"; import { resetDescriptionFallback } from "./usageWindows"; type Translate = (key: LocaleKey) => string; @@ -15,10 +16,7 @@ export type MetricLane = "primary" | "secondary" | "tertiary" | "extra"; type PaceKind = "session" | "weekly"; -export type UsageThresholdSettings = Pick< - SettingsSnapshot, - "highUsageThreshold" | "criticalUsageThreshold" | "providerUsageThresholds" ->; +export type UsageThresholdSettings = QuotaWarningSettings; interface LanePolicy { thresholdWindow: "session" | "weekly" | null; @@ -238,20 +236,6 @@ function paceMetaText(pace: WindowPaceSnapshot, kind: PaceKind, t: Translate): s return right ? `${left} · ${right}` : left; } -function threshold( - settings: UsageThresholdSettings, - providerId: string, - window: "session" | "weekly", - field: "high" | "critical", -): number { - const overrides = settings.providerUsageThresholds ?? {}; - return ( - overrides[`${providerId}:${window}`]?.[field] ?? - overrides[providerId]?.[field] ?? - (field === "high" ? settings.highUsageThreshold : settings.criticalUsageThreshold) - ); -} - function normalizedMarkerPercents(values: number[]): number[] { const kept: number[] = []; for (const value of values.map(clampPercent)) { @@ -266,13 +250,17 @@ function barMarkers(input: MetricRowInput): UsageBarMarker[] { const policy = LANE_POLICY[input.lane]; const window = policy.thresholdWindow; const settings = input.usageThresholds; - const warnings = + // A window set to Off for this provider shows no warning markers (Mac parity). + const resolved = window && settings && input.quotaWarningMarkersVisible + ? resolveQuotaWarning(settings, input.providerId, window) + : null; + const warnings = + resolved?.enabled ? normalizedMarkerPercents( - (["high", "critical"] as const).map((field) => { - const usedThreshold = threshold(settings, input.providerId, window, field); - return input.showAsUsed ? usedThreshold : 100 - usedThreshold; - }), + [resolved.high, resolved.critical].map((usedThreshold) => + input.showAsUsed ? usedThreshold : 100 - usedThreshold, + ), ) : []; const workDays = input.weeklyProgressWorkDays; diff --git a/apps/desktop-tauri/src/lib/quotaWarnings.ts b/apps/desktop-tauri/src/lib/quotaWarnings.ts new file mode 100644 index 0000000000..ae066bf14a --- /dev/null +++ b/apps/desktop-tauri/src/lib/quotaWarnings.ts @@ -0,0 +1,52 @@ +import type { QuotaWarningWindow, SettingsSnapshot } from "../types/bridge"; + +/** The two windows that carry quota warnings (Mac `QuotaWarningWindow`). */ +export type QuotaWarningWindowId = "session" | "weekly"; + +export const QUOTA_WARNING_WINDOWS: readonly QuotaWarningWindowId[] = ["session", "weekly"]; + +/** Mac `QuotaWarningProviderMode`. */ +export type QuotaWarningMode = "global" | "custom" | "off"; + +export type QuotaWarningSettings = Pick< + SettingsSnapshot, + "highUsageThreshold" | "criticalUsageThreshold" | "providerQuotaWarnings" +>; + +export interface ResolvedQuotaWarning { + mode: QuotaWarningMode; + /** False only when the window is Off for this provider. */ + enabled: boolean; + /** *Used* percentages. */ + high: number; + critical: number; +} + +/** The stored window for a provider; `{}` when it follows the global settings. */ +export function quotaWarningWindow( + settings: Pick, + providerId: string, + window: QuotaWarningWindowId, +): QuotaWarningWindow { + return settings.providerQuotaWarnings?.[providerId]?.[window] ?? {}; +} + +export function quotaWarningMode(value: QuotaWarningWindow): QuotaWarningMode { + if (value.enabled == null && value.high == null && value.critical == null) return "global"; + return value.enabled === false ? "off" : "custom"; +} + +/** Thresholds and on/off state for one provider window, mirroring `Settings::usage_thresholds`. */ +export function resolveQuotaWarning( + settings: QuotaWarningSettings, + providerId: string, + window: QuotaWarningWindowId, +): ResolvedQuotaWarning { + const stored = quotaWarningWindow(settings, providerId, window); + return { + mode: quotaWarningMode(stored), + enabled: stored.enabled !== false, + high: stored.high ?? settings.highUsageThreshold, + critical: stored.critical ?? settings.criticalUsageThreshold, + }; +} diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 66f7624460..e0672b470f 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -1008,7 +1008,7 @@ describe("TrayPanel provider grid", () => { showAsUsed: false, highUsageThreshold: 75, criticalUsageThreshold: 90, - providerUsageThresholds: { "claude:session": { critical: 95 } }, + providerQuotaWarnings: { claude: { session: { critical: 95 } } }, }); await waitFor(() => { 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 eb4e65761b..784e3d6b61 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -329,38 +329,12 @@ describe("GeneralTab language picker", () => { expect(preview).toBeEnabled(); }); - it("saves a window override on blur and clears it to resume inheritance", () => { - const set = vi.fn(); - const { rerender } = render( - , - ); - const input = screen.getByRole("spinbutton", { - name: "ProviderNameCodex · ProviderSession HighUsageAlert", - }); - - fireEvent.change(input, { target: { value: "80" } }); - fireEvent.blur(input); - expect(set).toHaveBeenLastCalledWith({ - providerUsageThresholds: { "codex:session": { high: 80 } }, - }); - - rerender( - , - ); - const saved = screen.getByRole("spinbutton", { - name: "ProviderNameCodex · ProviderSession HighUsageAlert", - }); - fireEvent.change(saved, { target: { value: "" } }); - fireEvent.blur(saved); - expect(set).toHaveBeenLastCalledWith({ providerUsageThresholds: {} }); + it("no longer offers per-provider threshold overrides (they moved to Providers)", () => { + render(); + expect(screen.queryByText("ThresholdOverridesTitle")).toBeNull(); + expect( + screen.queryByRole("spinbutton", { name: /ProviderNameCodex · ProviderSession/ }), + ).toBeNull(); }); }); @@ -667,13 +641,12 @@ describe("GeneralTab notifications pane", () => { expect(screen.getByRole("checkbox", { name: "SoundEnabled" })).toBeInTheDocument(); }); - it("shows % used thresholds and overrides while usage warnings are on", () => { + it("shows % used thresholds while usage warnings are on", () => { render( , ); expect(screen.getByText("WarningAtPercentUsed")).toBeInTheDocument(); expect(screen.getByText("CriticalAtPercentUsed")).toBeInTheDocument(); - expect(screen.getByText("ThresholdOverridesTitle")).toBeInTheDocument(); expect(screen.getByText("ThresholdsCaption")).toBeInTheDocument(); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index 3bc75c2ba7..ebc3909161 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -25,7 +25,6 @@ import type { NotificationSoundPaths, NotificationSoundTheme, ThemePreference, - UsageThresholdOverride, } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; import { CURRENCY_PICKER_OPTIONS } from "../../../lib/currency"; @@ -133,77 +132,10 @@ function fileName(path: string): string { return path.split(/[\\/]/).pop() ?? path; } -function blurOnEnter(event: { key: string; currentTarget: { blur: () => void } }) { - if (event.key === "Enter") event.currentTarget.blur(); -} - function isNotificationSoundTheme(v: string): v is NotificationSoundTheme { return v === "windows" || v === "codexBar"; } -function ThresholdOverrideInputs({ - label, - value, - inheritedHigh, - inheritedCritical, - highLabel, - criticalLabel, - disabled, - onChange, -}: { - label: string; - value: UsageThresholdOverride; - inheritedHigh: number; - inheritedCritical: number; - highLabel: string; - criticalLabel: string; - disabled: boolean; - onChange: (value: UsageThresholdOverride) => void; -}) { - const [high, setHigh] = useState(() => value.high?.toString() ?? ""); - const [critical, setCritical] = useState(() => value.critical?.toString() ?? ""); - useEffect(() => setHigh(value.high?.toString() ?? ""), [value.high]); - useEffect(() => setCritical(value.critical?.toString() ?? ""), [value.critical]); - const commit = () => - onChange({ - high: high === "" ? undefined : Math.min(100, Math.max(0, Number(high))), - critical: - critical === "" ? undefined : Math.min(100, Math.max(0, Number(critical))), - }); - return ( - -
- setHigh(event.target.value)} - onBlur={commit} - onKeyDown={blurOnEnter} - /> - setCritical(event.target.value)} - onBlur={commit} - onKeyDown={blurOnEnter} - /> -
- - ); -} - export default function GeneralTab({ mode = "general", settings, @@ -517,7 +449,6 @@ function NotificationsPane({ // backend, so their switches lock (keeping their saved state) while // threshold warnings are off. Quota depleted & restored has its own key. const alertsMuted = !settings.showNotifications; - const values = settings.providerUsageThresholds ?? {}; return ( <> @@ -679,59 +610,6 @@ function NotificationsPane({ )} - {settings.showNotifications && ( - {t("ThresholdOverridesCaption")}

} - > - {(["codex", "claude"] as const).flatMap((provider) => - (["provider", "session", "weekly"] as const).map((window) => { - const key = window === "provider" ? provider : `${provider}:${window}`; - const providerLabel = - provider === "codex" - ? t("ProviderNameCodex") - : t("ProviderNameClaude"); - return ( - { - const next = { ...values }; - if (value.high === undefined && value.critical === undefined) { - set({ - providerUsageThresholds: Object.fromEntries( - Object.entries(next).filter(([entry]) => entry !== key), - ), - }); - } else { - next[key] = value; - set({ providerUsageThresholds: next }); - } - }} - /> - ); - }), - )} -
- )} ); } diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 1e644b48e5..a9ae6fdb1f 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -197,7 +197,8 @@ export interface SettingsSnapshot { notificationSoundPaths: NotificationSoundPaths; highUsageThreshold: number; criticalUsageThreshold: number; - providerUsageThresholds?: Record; + /** Per-provider quota warnings keyed by CLI name; Global providers are absent. */ + providerQuotaWarnings?: Record; predictivePaceWarningEnabled: boolean; credentialExpiryNotificationsEnabled: boolean; /** Absent on older shells; the UI falls back to `showNotifications`. */ @@ -260,6 +261,12 @@ export interface SettingsSnapshot { /** Path to the cswap executable (Claude only, empty when unset). */ claudeSwapExecutablePath?: string; codexSparkUsageVisible: boolean; + /** Absent on older shells. */ + codexOpenaiWebExtras?: boolean; + codexHistoricalTracking?: boolean; + codexExternalOauthSourcesAllowed?: boolean; + codexLocalSessionCostLedgerEnabled?: boolean; + codexOpenaiWebBatterySaverEnabled?: boolean; disableKeychainAccess: boolean; wayfinderGatewayUrl?: string; providerMetrics: Record; @@ -344,7 +351,8 @@ export interface SettingsUpdate { notificationSoundPaths?: NotificationSoundPaths; highUsageThreshold?: number; criticalUsageThreshold?: number; - providerUsageThresholds?: Record; + /** Replaces each listed provider's whole value; `{}` resets it to Global. */ + providerQuotaWarnings?: Record; predictivePaceWarningEnabled?: boolean; credentialExpiryNotificationsEnabled?: boolean; sessionQuotaNotificationsEnabled?: boolean; @@ -395,6 +403,11 @@ export interface SettingsUpdate { claudeSwapEnabled?: boolean; claudeSwapExecutablePath?: string; codexSparkUsageVisible?: boolean; + codexOpenaiWebExtras?: boolean; + codexHistoricalTracking?: boolean; + codexExternalOauthSourcesAllowed?: boolean; + codexLocalSessionCostLedgerEnabled?: boolean; + codexOpenaiWebBatterySaverEnabled?: boolean; disableKeychainAccess?: boolean; /** Map of provider CLI name → metric preference label. */ providerMetrics?: Record; @@ -432,11 +445,22 @@ export interface SettingsUpdate { providerAccentColors?: Record; } -export interface UsageThresholdOverride { +/** + * One quota warning window. All fields absent = Global (inherit the global + * thresholds). `enabled: false` = Off (values kept for when it turns back on). + * Otherwise Custom; a missing value inherits the global one. + */ +export interface QuotaWarningWindow { + enabled?: boolean; high?: number; critical?: number; } +export interface ProviderQuotaWarnings { + session?: QuotaWarningWindow; + weekly?: QuotaWarningWindow; +} + /** One provider row for Settings → Usage & Spend. */ export interface UsageSpendDailyPoint { day: string; diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 6165449576..88b383eb26 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -474,6 +474,11 @@ pub struct Settings { /// Copy JSON and Export JSON. Sorted and unique. #[serde(default)] pub spend_dashboard_hidden_source_ids: Vec, + + /// Set when the loaded file still carried legacy `provider_usage_thresholds`; + /// the desktop shell saves once at startup so only the new key stays on disk. + #[serde(skip)] + pub(crate) legacy_usage_thresholds_pending: bool, } fn default_window_scale_percent() -> u16 { @@ -717,6 +722,7 @@ impl Default for Settings { weekly_progress_work_days: None, alibaba_token_plan_region: default_alibaba_token_plan_region(), codex_external_oauth_sources_allowed: false, + legacy_usage_thresholds_pending: false, cost_summary_display_style: CostSummaryDisplayStyle::default(), open_codex_usage_logs_enabled: false, hide_native_codex_cost_when_open_codex_present: false, @@ -909,6 +915,28 @@ impl Settings { } } + /// Migration #5 write-back: when the loaded file still had legacy + /// `provider_usage_thresholds` (already folded into `quota_warnings` on + /// read), save once so the file holds only the new key. The desktop shell + /// calls this at startup; tests and helpers that only load never write. + pub fn apply_quota_warning_migration(&mut self) { + self.apply_quota_warning_migration_with(Self::save); + } + + fn apply_quota_warning_migration_with( + &mut self, + persist: impl FnOnce(&Self) -> anyhow::Result<()>, + ) { + if !self.legacy_usage_thresholds_pending { + return; + } + match persist(self) { + Ok(()) => self.legacy_usage_thresholds_pending = false, + // The legacy key stays readable, so the write retries next launch. + Err(error) => tracing::warn!("Failed to persist quota warning migration: {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) { diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 08c3c9c35a..cf84fd421f 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -670,6 +670,7 @@ impl From for Settings { } // Migration #5: legacy per-provider threshold overrides move into // `provider_configs..quota_warnings`; only the new key is saved. + let legacy_usage_thresholds_pending = !raw.provider_usage_thresholds.is_empty(); super::types::migrate_legacy_usage_thresholds( &mut provider_configs, raw.provider_usage_thresholds, @@ -803,6 +804,7 @@ impl From for Settings { hide_native_codex_cost_when_open_codex_present: raw .hide_native_codex_cost_when_open_codex_present, codex_external_oauth_sources_allowed: raw.codex_external_oauth_sources_allowed, + legacy_usage_thresholds_pending, cost_reporting_period: raw.cost_reporting_period, cost_usage_bucket_time_zone: super::normalize_cost_usage_bucket_time_zone( &raw.cost_usage_bucket_time_zone, diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 8af7681f0b..a528d65a77 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -2238,3 +2238,77 @@ fn spend_dashboard_hidden_sources_default_empty_normalize_and_round_trip() { ["codex", "pi"] ); } + +#[test] +fn quota_warning_migration_saves_a_legacy_file_once_without_the_old_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("settings.json"); + std::fs::write( + &path, + r#"{ "provider_usage_thresholds": { "codex:session": { "high": 80.0 } } }"#, + ) + .expect("seed legacy file"); + let mut settings = Settings::load_from_path(Some(&path)); + assert!(settings.legacy_usage_thresholds_pending); + + let mut saves = 0; + settings.apply_quota_warning_migration_with(|s| { + saves += 1; + s.save_to_path(&path) + }); + assert_eq!(saves, 1); + assert!(!settings.legacy_usage_thresholds_pending); + let saved: serde_json::Value = + serde_json::from_str(&crate::secure_file::read_string(&path).expect("read back")) + .expect("saved json"); + assert!(saved.get("provider_usage_thresholds").is_none()); + assert_eq!( + saved["provider_configs"]["codex"]["quota_warnings"]["session"]["high"], + serde_json::json!(80.0) + ); + + // The rewritten file no longer asks for a save. + let mut reloaded = Settings::load_from_path(Some(&path)); + assert!(!reloaded.legacy_usage_thresholds_pending); + reloaded.apply_quota_warning_migration_with(|_| { + saves += 1; + Ok(()) + }); + assert_eq!(saves, 1); +} + +#[test] +fn quota_warning_migration_retries_after_a_failed_save() { + let mut settings = load_settings_json(r#"{ "provider_usage_thresholds": { "claude": {} } }"#); + assert!(settings.legacy_usage_thresholds_pending); + settings.apply_quota_warning_migration_with(|_| Err(anyhow::anyhow!("disk full"))); + assert!(settings.legacy_usage_thresholds_pending); + assert!(!Settings::default().legacy_usage_thresholds_pending); +} + +#[test] +fn custom_quota_warnings_with_explicit_enabled_round_trip() { + let mut settings = Settings::default(); + let window = QuotaWarningWindow { + enabled: Some(true), + high: Some(55.0), + critical: Some(85.0), + }; + settings.set_quota_warnings( + ProviderId::Codex, + ProviderQuotaWarnings { + session: window, + weekly: QuotaWarningWindow::default(), + }, + ); + let reloaded = load_settings_json(&serde_json::to_string(&settings).expect("serialize")); + assert_eq!(reloaded.quota_warnings(ProviderId::Codex).session, window); + assert!(reloaded.quota_warning_enabled(ProviderId::Codex, "session")); + assert_eq!( + reloaded.usage_thresholds(ProviderId::Codex, "session"), + UsageThresholds { + high: 55.0, + critical: 85.0, + } + ); +} From 229d533ce8c0b254a8373494daad468e261e5922 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:40:21 +0700 Subject: [PATCH 04/13] Drop the float bar warning tone for quota warnings set to Off --- .../src/floatbar/FloatBar.test.tsx | 24 +++++++++++++++++++ apps/desktop-tauri/src/floatbar/FloatBar.tsx | 5 ++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index fd6a63dfec..f3c2ab7393 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -689,6 +689,30 @@ describe("FloatBar", () => { }); }); + it("follows a provider's Custom quota warnings and drops the tone when they are Off", async () => { + // 50% used is below the global 70% high threshold, but above a Custom 40%. + tauriMocks.getCachedProviders.mockResolvedValue([snapshot("claude", "Claude", 50)]); + const customWarnings: Partial = { + providerQuotaWarnings: { claude: { session: { enabled: true, high: 40, critical: 90 } } }, + }; + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings(customWarnings)); + const custom = renderFloatBar(bootstrap(customWarnings)); + await waitFor(() => { + expect(custom.container.querySelector(".floatbar__pill--warn")).not.toBeNull(); + }); + custom.unmount(); + + tauriMocks.getCachedProviders.mockResolvedValue([snapshot("claude", "Claude", 95)]); + const offWarnings: Partial = { + providerQuotaWarnings: { claude: { session: { enabled: false } } }, + }; + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings(offWarnings)); + const off = renderFloatBar(bootstrap(offWarnings)); + await waitFor(() => { + expect(off.container.querySelector(".floatbar__pill--ok")).not.toBeNull(); + }); + }); + it("applies critical tone when the provider is exhausted", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("claude", "Claude", 100, { exhausted: true }), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index f1e55c270a..fb84ef1756 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -235,8 +235,9 @@ function ProviderPill({ provider.providerId, (rateWindow.windowMinutes ?? 0) >= WEEK_MINUTES ? "weekly" : "session", ); - const highRemaining = 100 - warning.high; - const critRemaining = 100 - warning.critical; + // A window set to Off keeps only the exhausted tone. + const highRemaining = warning.enabled ? 100 - warning.high : -1; + const critRemaining = warning.enabled ? 100 - warning.critical : -1; let tone: "ok" | "warn" | "crit" = "ok"; if (state.isProblem) tone = "crit"; else if (!informational) { From c10639a5607ded7ed932a79f6e000bc4394ee2ea Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:32:14 +0700 Subject: [PATCH 05/13] Let the shell crate build Settings with the migration flag --- rust/src/settings.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 88b383eb26..fd4bf77100 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -477,8 +477,9 @@ pub struct Settings { /// Set when the loaded file still carried legacy `provider_usage_thresholds`; /// the desktop shell saves once at startup so only the new key stays on disk. + /// Public only so other crates can build `Settings { .., ..Settings::default() }`. #[serde(skip)] - pub(crate) legacy_usage_thresholds_pending: bool, + pub legacy_usage_thresholds_pending: bool, } fn default_window_scale_percent() -> u16 { From a574c8b09e98143d688d489f431fd9cd29487312 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:32:15 +0700 Subject: [PATCH 06/13] Style the provider detail pane as grouped boxes --- .../src/surfaces/settings/settings-layout.css | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css index 4ddd665f0c..e67186b507 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css +++ b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css @@ -1032,3 +1032,246 @@ .usage-spend__share { margin-left: auto; } + +/* ── Provider detail pane (macOS PreferencesProviderDetailView) ────── + The pane is a stack of grouped boxes like the other Settings panes: + header, Usage, Visible usage items, Error, Menu bar metric, + Connection, Token accounts, credential storage, fields, accounts, + Accent color, Quota warnings and Options. */ +.settings-body--provider .provider-detail { + padding: 0 0 16px; + border-radius: 0; + background: transparent; +} + +/* Groups whose sections all render nothing disappear entirely. */ +.provider-detail > .settings-group:has(> .settings-group__box:empty) { + display: none; +} + +/* Header: icon, name and subtitle, borderless Refresh, Enabled switch. */ +.provider-detail-header-group .provider-detail-header { + margin: 0; + padding: 12px 0; +} + +.provider-detail-header-group .provider-detail-title-group { + flex: 1; +} + +.provider-detail-header-group .provider-detail-title { + font-size: 16px; + font-weight: 650; +} + +.provider-detail-header-group .provider-detail-subtitle { + font-size: 12px; + color: var(--text-secondary); +} + +.provider-detail-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.provider-detail-icon-btn:hover:not(:disabled) { + background: rgba(var(--surface-tint-rgb), 0.08); + color: var(--text-primary); +} + +.provider-detail-icon-btn:focus-visible, +.provider-detail-link-btn:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.provider-detail-icon-btn:disabled { + opacity: 0.45; + cursor: default; +} + +.provider-info-row { + display: grid; + grid-template-columns: minmax(72px, max-content) minmax(0, 1fr); + gap: 12px; + padding: 8px 0; + font-size: 13px; + line-height: 1.4; +} + +.provider-info-row__label { + color: var(--text-secondary); +} + +.provider-info-row__value { + min-width: 0; + color: var(--text-primary); + overflow-wrap: anywhere; + font-variant-numeric: tabular-nums; +} + +/* Borderless link buttons: QuickActions links, Use default, Show details. */ +.provider-detail-link-btn { + padding: 0; + border: 0; + background: transparent; + color: var(--accent); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.provider-detail-link-btn:hover:not(:disabled) { + text-decoration: underline; +} + +.provider-detail-link-btn:disabled { + color: var(--text-secondary); + cursor: default; +} + +.provider-detail-links { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + padding: 10px 0; +} + +/* The group title already says "Usage". */ +.provider-usage-group .provider-usage-main > h4 { + display: none; +} + +.provider-usage-group .provider-detail-section { + padding: 12px 0; +} + +/* Error group: a plain row in its box, red only on the title. */ +.provider-detail-error-group .provider-detail-error { + margin: 0; + padding: 10px 0; + border: 0; + border-radius: 0; + background: transparent; + font-size: 13px; +} + +.provider-detail-error-group .provider-detail-error p { + font-size: 12px; +} + +.provider-detail-error-group .provider-detail-error__preview { + color: var(--text-primary); +} + +.provider-detail-error-group .provider-detail-error__full { + font-family: var(--font-mono, monospace); + white-space: pre-wrap; +} + +/* Visible usage items: one switch row per window. */ +.provider-usage-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; + font-size: 13px; + cursor: pointer; +} + +.provider-usage-item--disabled { + cursor: default; + opacity: 0.6; +} + +.provider-usage-item__text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.provider-usage-item__helper { + font-size: 12px; + color: var(--text-secondary); +} + +.provider-detail-group__actions { + display: flex; + justify-content: flex-end; + padding: 10px 0; +} + +/* Quota warnings: window label, Global / Custom / Off, then the values. */ +.quota-warning-row { + display: grid; + grid-template-columns: minmax(88px, max-content) max-content minmax(0, 1fr); + align-items: center; + gap: 8px 12px; + padding: 10px 0; + font-size: 13px; +} + +.quota-warning-row__detail { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + min-width: 0; +} + +.quota-warning-row__inherited { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.quota-warning-field { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.quota-warning-field .number-input { + width: 64px; + font-variant-numeric: tabular-nums; +} + +.provider-quota-warnings.is-dimmed .settings-group__box { + opacity: 0.45; +} + +@media (max-width: 640px) { + .quota-warning-row { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Accent color: hex field, swatch, Use default. */ +.provider-accent-color-group .accent-color-row { + margin: 0; + padding: 10px 0; +} + +.provider-accent-color-group .accent-color-input { + flex: 0 1 140px; +} + +.provider-option-error { + margin: 0; + padding: 0 0 10px; + font-size: 12px; + color: var(--provider-status-error); +} From 4d966e451a8524f727b15235ae0b61a7af6608de Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:32:15 +0700 Subject: [PATCH 07/13] Rebuild the provider detail pane in the macOS order --- .../src-tauri/src/commands/provider_detail.rs | 7 +- .../src/components/FormControls.tsx | 5 +- apps/desktop-tauri/src/hooks/useSettings.ts | 9 + apps/desktop-tauri/src/i18n/keys.ts | 32 +- .../src/surfaces/settings/SettingsSidebar.tsx | 6 +- .../providers/ProviderDetailPane.test.tsx | 252 +++++++++++++ .../settings/providers/ProviderDetailPane.tsx | 340 +++++++++++------- .../providers/providerDetailFormat.test.ts | 2 +- .../providers/providerDetailFormat.ts | 2 +- .../sections/AccentColorSection.test.tsx | 55 ++- .../providers/sections/AccentColorSection.tsx | 83 +++-- .../providers/sections/AutoResumeSection.tsx | 34 +- .../sections/CredentialsDispatcher.tsx | 8 +- .../providers/sections/IdentitySection.tsx | 101 ++++-- .../sections/MenuBarMetricSection.test.tsx | 8 +- .../sections/MenuBarMetricSection.tsx | 38 +- .../sections/OptionalDetailsSection.tsx | 27 +- .../sections/OptionsSection.test.tsx | 141 ++++++++ .../providers/sections/OptionsSection.tsx | 190 ++++++++++ .../sections/ProviderIssueNotice.test.tsx | 70 ++-- .../sections/ProviderIssueNotice.tsx | 85 ++++- .../sections/QuickActionsSection.tsx | 79 ++-- .../sections/QuotaWarningsSection.test.tsx | 113 ++++++ .../sections/QuotaWarningsSection.tsx | 183 ++++++++++ .../UsageItemVisibilitySection.test.tsx | 2 +- .../sections/UsageItemVisibilitySection.tsx | 48 ++- .../providers/sections/UsageSection.tsx | 2 +- .../ClaudeAccountsSection.test.tsx | 18 +- .../credentials/ClaudeAccountsSection.tsx | 12 +- .../sections/credentials/ClaudeCreds.test.tsx | 74 ---- .../sections/credentials/ClaudeCreds.tsx | 119 ------ .../ClaudeSwapAccountsSection.test.tsx | 99 ++--- .../credentials/ClaudeSwapAccountsSection.tsx | 116 ++---- .../credentials/ClaudeSwapPathField.test.tsx | 52 +++ .../credentials/ClaudeSwapPathField.tsx | 50 +++ .../sections/credentials/OpenAiExtras.tsx | 26 +- .../surfaces/settings/settingsTabs.test.ts | 19 + .../src/surfaces/settings/settingsTabs.ts | 16 + .../settings/tabs/AdvancedTab.test.tsx | 6 +- .../surfaces/settings/tabs/AdvancedTab.tsx | 12 - .../surfaces/settings/tabs/ProvidersTab.tsx | 2 + rust/src/locale.rs | 34 +- rust/src/locale/en-US.ftl | 40 ++- rust/src/locale/es-MX.ftl | 40 ++- rust/src/locale/ja-JP.ftl | 40 ++- rust/src/locale/ko-KR.ftl | 40 ++- rust/src/locale/pt-BR.ftl | 40 ++- rust/src/locale/ru-RU.ftl | 40 ++- rust/src/locale/tests.rs | 9 +- rust/src/locale/tr-TR.ftl | 40 ++- rust/src/locale/uk-UA.ftl | 40 ++- rust/src/locale/zh-CN.ftl | 40 ++- rust/src/locale/zh-TW.ftl | 40 ++- 53 files changed, 2123 insertions(+), 863 deletions(-) create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/QuotaWarningsSection.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/QuotaWarningsSection.tsx delete mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeCreds.test.tsx delete mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeCreds.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapPathField.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapPathField.tsx diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 9a0eeb8da0..7ff62bb652 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -227,7 +227,12 @@ pub fn get_provider_detail( detail.pace = snapshot.pace.clone(); detail.open_ai_api_usage = snapshot.open_ai_api_usage.clone(); } - detail.last_error = snapshot.error.clone(); + // Shown verbatim behind "Show details"; redact again so every cache + // path (refresh, seed, restore) reaches the pane scrubbed. + detail.last_error = snapshot + .error + .as_deref() + .map(codexbar::core::SecretRedactor::redact); detail.error_state = Some(snapshot.error_state); detail.has_snapshot = true; } diff --git a/apps/desktop-tauri/src/components/FormControls.tsx b/apps/desktop-tauri/src/components/FormControls.tsx index 61fa4e37c5..8bb4f1e4ca 100644 --- a/apps/desktop-tauri/src/components/FormControls.tsx +++ b/apps/desktop-tauri/src/components/FormControls.tsx @@ -168,15 +168,18 @@ export function SettingsGroup({ title, footer, children, + className, }: { title?: string; footer?: React.ReactNode; children: React.ReactNode; + /** Extra classes on the `section` (e.g. a dimmed or provider-detail variant). */ + className?: string; }) { const id = useId(); return (
{title && ( diff --git a/apps/desktop-tauri/src/hooks/useSettings.ts b/apps/desktop-tauri/src/hooks/useSettings.ts index ac662e0545..9d8d85d9f4 100644 --- a/apps/desktop-tauri/src/hooks/useSettings.ts +++ b/apps/desktop-tauri/src/hooks/useSettings.ts @@ -39,6 +39,15 @@ function applyPatch(current: SettingsSnapshot, patch: SettingsUpdate): SettingsS if (patch.providerMetrics) { next.providerMetrics = { ...current.providerMetrics, ...patch.providerMetrics }; } + // Quota warnings replace one provider's value at a time; an empty value is Global. + if (patch.providerQuotaWarnings) { + const warnings = { ...current.providerQuotaWarnings }; + for (const [id, value] of Object.entries(patch.providerQuotaWarnings)) { + if (Object.keys(value).length === 0) delete warnings[id]; + else warnings[id] = value; + } + next.providerQuotaWarnings = warnings; + } return next as unknown as SettingsSnapshot; } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 1e815c9bce..c105263f19 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -649,8 +649,6 @@ export const ALL_LOCALE_KEYS = [ "WarningAtPercentUsed", "CriticalAtPercentUsed", "ThresholdsCaption", - "ThresholdOverridesTitle", - "ThresholdOverridesCaption", "SectionUsageThresholds", "SectionKeyboard", "SectionUsageRendering", @@ -751,8 +749,6 @@ export const ALL_LOCALE_KEYS = [ "CodexLocalLogsCaption", "CodexLogPathsLabel", "CodexLogPathsHelper", - "AvoidKeychainPromptsLabel", - "AvoidKeychainPromptsHelper", "DisableAllKeychainLabel", "DisableAllKeychainHelper", "CredentialAccessFooter", @@ -1571,6 +1567,34 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendTrackCostsHelper", "UsageSpendTrackingOffTitle", "UsageSpendTrackingOffBody", + "ProviderVisibleUsageItems", + "ProviderVisibleUsageItemsFooter", + "ProviderRestoreDefaults", + "ProviderLastFetchFailedTitle", + "ProviderShowDetails", + "ProviderHideDetails", + "ProviderSectionConnection", + "QuotaWarningsTitle", + "QuotaWarningGlobal", + "QuotaWarningCustom", + "QuotaWarningOff", + "QuotaWarningWarning", + "QuotaWarningCritical", + "QuotaWarningInherited", + "QuotaWarningProviderInherits", + "QuotaWarningProviderMarkersOnly", + "QuotaWarningProviderDisabled", + "ProviderCodexLocalCostEstimates", + "ProviderCodexLocalCostEstimatesHelp", + "ProviderCodexHistoricalTracking", + "ProviderCodexOpenAiWebExtras", + "ProviderCodexOpenAiWebExtrasHelp", + "ProviderCodexExternalOauth", + "ProviderCodexExternalOauthHelp", + "ProviderCodexBatterySaver", + "ProviderCodexBatterySaverHelp", + "ProviderOptionNotUsedOnWindows", + "ClaudeSwapExecutablePathHelp", ] as const; export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number]; diff --git a/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx b/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx index bf032502dd..bffbfa65d9 100644 --- a/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx @@ -13,6 +13,7 @@ import { SIDEBAR_PANES, canReorderProviders, filterProvidersByQuery, + nextEnabledProviders, sortProvidersAlphabetically, type SidebarPane, } from "./settingsTabs"; @@ -174,10 +175,7 @@ export function SettingsSidebar({ }, [enabled, ordered, searchText, snapshots, sorted]); const toggleEnabled = (id: string, on: boolean) => { - const next = new Set(enabled); - if (on) next.add(id); - else next.delete(id); - set({ enabledProviders: ordered.filter((p) => next.has(p.id)).map((p) => p.id) }); + set({ enabledProviders: nextEnabledProviders(ordered, enabled, id, on) }); }; const handleReorder = (ids: string[]) => { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx new file mode 100644 index 0000000000..002f480f3e --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx @@ -0,0 +1,252 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderDetail, SettingsSnapshot } from "../../../types/bridge"; + +const tauri = vi.hoisted(() => ({ + getCredentialStorageStatus: vi.fn(), + getProviderCookieSourceOptions: vi.fn(), + getProviderDetail: vi.fn(), + getProviderGatewayUrl: vi.fn(), + getProviderRegionOptions: vi.fn(), + getTokenAccountProviders: vi.fn(), + openProviderDashboard: vi.fn(), + openProviderStatusPage: vi.fn(), + refreshProviders: vi.fn(), + revokeProviderCredentials: vi.fn(), + setProviderGatewayUrl: vi.fn(), + triggerProviderLogin: vi.fn(), + setProviderAutoResumeAfterQuotaReset: vi.fn(), + setProviderOptionalDetails: vi.fn(), +})); +vi.mock("../../../lib/tauri", () => tauri); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) })); +vi.mock("../../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key, language: "english" }), +})); + +// Sections that load their own data through the bridge are stubbed; the test +// covers where the pane places them, not what they load. +const { stub } = vi.hoisted(() => ({ + stub: (name: string) => () =>
, +})); +vi.mock("./sections/charts/ChartsSection", () => ({ ChartsSection: stub("charts") })); +vi.mock("./sections/UsageDetailsSection", () => ({ UsageDetailsSection: () => null })); +vi.mock("./sections/UsageSourceSection", () => ({ UsageSourceSection: stub("usage-source") })); +vi.mock("./sections/CookieSourceSection", () => ({ CookieSourceSection: () => null })); +vi.mock("./sections/RegionSection", () => ({ RegionSection: () => null })); +vi.mock("./sections/CredentialsDispatcher", () => ({ CredentialsDispatcher: () => null })); +vi.mock("./ApiKeySection", () => ({ ApiKeySection: stub("api-key") })); +vi.mock("./CookieSection", () => ({ CookieSection: () => null })); +vi.mock("../tokens/TokenAccountsPanel", () => ({ TokenAccountsPanel: () => null })); +vi.mock("./sections/credentials/CodexAccountsSection", () => ({ + CodexAccountsSection: stub("codex-accounts"), +})); +vi.mock("./sections/credentials/GrokAccountsSection", () => ({ GrokAccountsSection: () => null })); +vi.mock("./sections/credentials/ClaudeAccountsSection", () => ({ + ClaudeAccountsSection: ({ + claudeSwapEnabled, + claudeSwapExecutablePath, + }: { + claudeSwapEnabled?: boolean; + claudeSwapExecutablePath?: string; + }) => ( +
+ ), +})); + +import { ProviderDetailPane } from "./ProviderDetailPane"; + +function rateWindow(usedPercent: number) { + return { + usedPercent, + remainingPercent: 100 - usedPercent, + windowMinutes: 300, + resetsAt: null, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + }; +} + +function detail(id: string, extra: Partial = {}): ProviderDetail { + return { + id, + displayName: id === "codex" ? "Codex" : "Claude", + enabled: true, + autoResumeAfterQuotaReset: false, + autoResumeSupported: false, + optionalDetailsSupported: false, + optionalDetailsEnabled: false, + email: null, + plan: "Pro", + authType: null, + sourceLabel: "oauth", + organization: null, + lastUpdated: null, + session: rateWindow(30), + weekly: rateWindow(50), + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + cost: null, + pace: null, + lastError: null, + errorState: null, + dashboardUrl: "https://example.invalid/usage", + statusPageUrl: null, + buyCreditsUrl: null, + hasSnapshot: true, + cookieSource: null, + region: null, + ...extra, + }; +} + +const baseSettings = { + enabledProviders: ["codex", "claude"], + resetTimeRelative: true, + showAsUsed: false, + providerMetrics: {}, + providerAccentColors: {}, + copilotSeatCreditEntitlement: null, + hidePersonalInfo: false, + highUsageThreshold: 80, + criticalUsageThreshold: 95, + providerQuotaWarnings: {}, + showNotifications: true, + quotaWarningMarkersVisible: true, + claudeAvoidKeychainPrompts: false, + claudeAllowReadingClaudeCodeCredentials: false, + claudeSwapEnabled: false, + claudeSwapExecutablePath: "", + disableKeychainAccess: false, +} as unknown as SettingsSnapshot; + +const catalog = [{ id: "codex" }, { id: "claude" }, { id: "gemini" }]; + +async function renderPane( + providerId: string, + settings: SettingsSnapshot = baseSettings, + onSettingsChange = vi.fn(), +) { + const view = render( + , + ); + await screen.findByText(providerId === "codex" ? "Codex" : "Claude"); + return { ...view, onSettingsChange }; +} + +describe("ProviderDetailPane", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauri.getProviderCookieSourceOptions.mockResolvedValue([]); + tauri.getProviderRegionOptions.mockResolvedValue([]); + tauri.getCredentialStorageStatus.mockResolvedValue(null); + tauri.getTokenAccountProviders.mockResolvedValue([]); + tauri.refreshProviders.mockResolvedValue(undefined); + }); + + it("orders header, usage, error, connection, fields, accounts and the trailing groups", async () => { + tauri.getProviderDetail.mockResolvedValue( + detail("codex", { lastError: "boom", errorState: "unknown" }), + ); + const { container } = await renderPane("codex"); + const order = [ + ".provider-detail-header-group", + ".provider-usage-group", + ".provider-detail-error-group", + ".provider-metric-group", + ".provider-connection-group", + ".provider-fields-group", + ".provider-accounts-group", + ".provider-accent-color-group", + ".provider-quota-warnings", + ".provider-options-group", + ].map((selector) => container.querySelector(selector)); + expect(order.every(Boolean)).toBe(true); + for (let i = 1; i < order.length; i += 1) { + expect( + order[i - 1]!.compareDocumentPosition(order[i]!) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } + const usage = container.querySelector(".provider-usage-group")!; + expect(within(usage as HTMLElement).getByTestId("charts")).toBeTruthy(); + expect(within(usage as HTMLElement).getByText("ActionUsageDashboard")).toBeTruthy(); + expect(within(usage as HTMLElement).queryByText("ActionRefresh")).toBeNull(); + const accounts = container.querySelector(".provider-accounts-group")!; + expect(within(accounts as HTMLElement).getByTestId("codex-accounts")).toBeTruthy(); + }); + + it("drives the enabled-providers list from the header switch", async () => { + tauri.getProviderDetail.mockResolvedValue(detail("codex")); + const { onSettingsChange } = await renderPane("codex"); + const toggle = screen.getByRole("checkbox", { name: "ProviderEnabled" }); + expect(toggle).toBeChecked(); + await act(async () => fireEvent.click(toggle)); + expect(onSettingsChange).toHaveBeenCalledWith({ enabledProviders: ["claude"] }); + }); + + it("shows the header switch off for a disabled provider and turns it on in catalog order", async () => { + tauri.getProviderDetail.mockResolvedValue(detail("codex")); + const settings = { ...baseSettings, enabledProviders: ["claude"] } as SettingsSnapshot; + const { onSettingsChange } = await renderPane("codex", settings); + const toggle = screen.getByRole("checkbox", { name: "ProviderEnabled" }); + expect(toggle).not.toBeChecked(); + await act(async () => fireEvent.click(toggle)); + expect(onSettingsChange).toHaveBeenCalledWith({ enabledProviders: ["codex", "claude"] }); + }); + + it("refreshes from the header button", async () => { + tauri.getProviderDetail.mockResolvedValue(detail("codex")); + await renderPane("codex"); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "ActionRefresh" }))); + expect(tauri.refreshProviders).toHaveBeenCalledTimes(1); + }); + + it("shows the claude-swap path field only while claude-swap is on", async () => { + tauri.getProviderDetail.mockResolvedValue(detail("claude")); + const { container, unmount } = await renderPane("claude"); + expect(screen.queryByRole("textbox", { name: "ClaudeSwapExecutablePath" })).toBeNull(); + expect(screen.getByTestId("claude-accounts")).toHaveAttribute("data-swap", "false"); + expect(container.querySelector(".provider-fields-group")).toBeTruthy(); + unmount(); + + const on = { + ...baseSettings, + claudeSwapEnabled: true, + claudeSwapExecutablePath: "C:/tools/cswap.exe", + } as SettingsSnapshot; + const view = await renderPane("claude", on); + const fields = view.container.querySelector(".provider-fields-group") as HTMLElement; + const input = within(fields).getByRole("textbox", { name: "ClaudeSwapExecutablePath" }); + expect(input).toHaveValue("C:/tools/cswap.exe"); + expect(screen.getByTestId("claude-accounts")).toHaveAttribute("data-swap", "true"); + expect(screen.getByTestId("claude-accounts")).toHaveAttribute("data-path", "C:/tools/cswap.exe"); + }); + + it("locks Avoid keychain prompts while Credential Manager access is disabled", async () => { + tauri.getProviderDetail.mockResolvedValue(detail("claude")); + const settings = { ...baseSettings, disableKeychainAccess: true } as SettingsSnapshot; + const { container } = await renderPane("claude", settings); + const options = container.querySelector(".provider-options-group") as HTMLElement; + const row = within(options).getByText("ProviderClaudeAvoidKeychainPrompts").closest(".settings-field") as HTMLElement; + expect(within(row).getByRole("checkbox")).toBeDisabled(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index e861806d5a..9aab24e818 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -1,9 +1,13 @@ import { useCallback, useEffect, useReducer, useState } from "react"; import type { + ProviderCatalogEntry, ProviderUsageSnapshot, SettingsSnapshot, SettingsUpdate, } from "../../../types/bridge"; +import { SettingsGroup } from "../../../components/FormControls"; +import { formatRelativeUpdated } from "../../../lib/relativeTime"; +import { nextEnabledProviders } from "../settingsTabs"; import { useLocale } from "../../../hooks/useLocale"; import { providerAllowsPace } from "../../../lib/providerPace"; import { @@ -30,8 +34,6 @@ import { buildSubtitle } from "./providerDetailFormat"; import { IdentitySection } from "./sections/IdentitySection"; import { UsageSection } from "./sections/UsageSection"; import { UsageItemVisibilitySection } from "./sections/UsageItemVisibilitySection"; -import { AutoResumeSection } from "./sections/AutoResumeSection"; -import { OptionalDetailsSection } from "./sections/OptionalDetailsSection"; import { PaceSection } from "./sections/PaceSection"; import { UsageDetailsSection } from "./sections/UsageDetailsSection"; import { CostSection } from "./sections/CostSection"; @@ -41,7 +43,6 @@ import { CookieSourceSection } from "./sections/CookieSourceSection"; import { UsageSourceSection } from "./sections/UsageSourceSection"; import { shouldShowCookieSource } from "./sections/usageSourcePolicy"; import { RegionSection } from "./sections/RegionSection"; -import { CopilotSeatCreditOptions } from "./sections/credentials/CopilotSeatCreditOptions"; import { CodexAccountsSection } from "./sections/credentials/CodexAccountsSection"; import { ClaudeAccountsSection } from "./sections/credentials/ClaudeAccountsSection"; import { GrokAccountsSection } from "./sections/credentials/GrokAccountsSection"; @@ -59,9 +60,16 @@ import { } from "./sections/WayfinderGatewaySection"; import { AzureApiVersionSection } from "./sections/AzureApiVersionSection"; import { MuseBrowserTeamSection } from "./sections/MuseBrowserTeamSection"; +import { QuotaWarningsSection } from "./sections/QuotaWarningsSection"; +import { OptionsSection } from "./sections/OptionsSection"; +import { ClaudeSwapPathField } from "./sections/credentials/ClaudeSwapPathField"; interface Props { providerId: string | null; + /** Full settings snapshot: header switch, Quota warnings and Options read from it. */ + settings: SettingsSnapshot; + /** Provider catalog, for writing `enabledProviders` in catalog order. */ + catalog: readonly Pick[]; /** Live tray snapshot for `providerId`; feeds the details moved out of the tray card. */ liveSnapshot?: ProviderUsageSnapshot | null; cookieDomain?: string | null; @@ -79,14 +87,16 @@ interface Props { } /** - * Orchestrates the Settings → Providers right-hand detail pane. - * - * Top-level port of - * `rust/src/native_ui/preferences.rs::render_provider_detail_panel` - * (lines 4301–6698). + * Settings > Providers detail pane, in the macOS order + * (`PreferencesProviderDetailView`): header, Usage, Visible usage items, + * Error, Menu bar metric, Connection, Token accounts (then the Windows-only + * credential storage), Fields, Accounts, Accent color, Quota warnings and + * Options. Groups whose sections render nothing are hidden by CSS. */ export function ProviderDetailPane({ providerId, + settings, + catalog, liveSnapshot = null, cookieDomain = null, resetTimeRelative, @@ -327,6 +337,21 @@ export function ProviderDetailPane({ } }; + const enabled = settings.enabledProviders.includes(detail.id); + const handleToggleEnabled = (on: boolean) => + onSettingsChange({ + enabledProviders: nextEnabledProviders(catalog, settings.enabledProviders, detail.id, on), + }); + const updatedMs = detail.lastUpdated ? Date.parse(detail.lastUpdated) : Number.NaN; + const updatedText = Number.isFinite(updatedMs) + ? formatRelativeUpdated(updatedMs, t) + : busy + ? t("SectionRefreshing") + : detail.lastError || detail.errorState + ? t("ProviderTextUnavailable") + : t("ProviderNotFetchedYetTitle"); + const showClaudeSwapPath = detail.id === "claude" && (settings.claudeSwapEnabled ?? false); + return (
void handleRefresh()} + busy={busy} /> - {detail.id === "codex" && ( - - )} - {detail.id === "claude" && ( - + - )} - {detail.id === "grok" && ( - - )} - - {detail.lastError && ( - - )} + + + + + - - - - {isGatewayProviderId(detail.id) && - gatewayLoadedProviderId === detail.id && ( - - dispatch({ type: "SET_GATEWAY_DRAFT", draft }) - } - onSave={() => void saveGateway()} - t={t} - /> + + {(detail.lastError || detail.errorState) && ( + )} + - - - - - - {shouldShowCookieSource(detail.id, detail.usageSource) && ( - + - )} - {detail.id === "muse" && ( - + )} + {detail.id === "muse" && ( + + )} + - )} - - {detail.id === "azureopenai" && ( - + )} + {isGatewayProviderId(detail.id) && + gatewayLoadedProviderId === detail.id && ( + + dispatch({ type: "SET_GATEWAY_DRAFT", draft }) + } + onSave={() => void saveGateway()} + t={t} + /> + )} + + + + {tokenProviderIds.has(detail.id) && ( + + )} + + + - )} - + - - {tokenProviderIds.has(detail.id) && ( - + + + - )} - - + )} + + + + {detail.id === "codex" && ( + + )} + {detail.id === "claude" && ( + + )} + {detail.id === "grok" && ( + + )} + + + - -
); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts index 6002e98d93..c035cee5e5 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts @@ -66,7 +66,7 @@ describe("buildSubtitle", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-31T00:00:00Z")); try { - expect(buildSubtitle(detail, t)).toBe("oauth · DetailUpdatedPrefix 30d"); + expect(buildSubtitle(detail, t)).toBe("oauth • DetailUpdatedPrefix 30d"); } finally { vi.useRealTimers(); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.ts b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.ts index 0ca460e07c..865a61f434 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.ts @@ -13,7 +13,7 @@ export function buildSubtitle( } else if (!detail.hasSnapshot) { parts.push(t("ProviderUsageNotFetchedYet")); } - return parts.join(" · "); + return parts.join(" • "); } /** Source shown before a provider's first fetch (macOS `UsageStore.sourceLabel(for:)` fallback). */ diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.test.tsx index 6a5a385660..16d5c4404c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.test.tsx @@ -1,21 +1,62 @@ -import { render } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { AccentColorSection } from "./AccentColorSection"; +const t = (key: string) => key; + describe("AccentColorSection", () => { it("uses the native color input as the only color preview", () => { const { container } = render( - key} - onChange={vi.fn()} - />, + , ); expect(container.querySelector('input[type="color"]')).toHaveValue("#123456"); expect(container.querySelector(".accent-color-swatch-row")).toBeNull(); expect(container.querySelector(".accent-color-swatch")).toBeNull(); }); + + it("renders a titled group with the hex field before the color well", () => { + const { container } = render( + , + ); + expect(screen.getByRole("heading", { name: "ProviderAccentColor" })).toBeTruthy(); + expect(screen.getByText("ProviderAccentColorHelper")).toBeTruthy(); + const inputs = Array.from(container.querySelectorAll(".accent-color-row input")); + expect(inputs.map((input) => input.getAttribute("type"))).toEqual(["text", "color"]); + }); + + it("shows Use default only while an override is saved and clears it", async () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + expect(screen.queryByRole("button", { name: "ProviderAccentColorReset" })).toBeNull(); + + rerender(); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("#ABCDEF"); + await act(async () => + fireEvent.click(screen.getByRole("button", { name: "ProviderAccentColorReset" })), + ); + expect(onChange).toHaveBeenCalledWith({ providerAccentColors: { codex: null } }); + }); + + it("normalizes a typed hex value and rejects an invalid one", async () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole("textbox"); + await act(async () => { + fireEvent.change(input, { target: { value: "12ab34" } }); + fireEvent.keyDown(input, { key: "Enter" }); + }); + expect(onChange).toHaveBeenCalledWith({ providerAccentColors: { codex: "#12AB34" } }); + + onChange.mockClear(); + await act(async () => { + fireEvent.change(input, { target: { value: "zz" } }); + fireEvent.blur(input); + }); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole("alert").textContent).toBe("ProviderAccentColorInvalid"); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx index 112e09f856..e079c6f278 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx @@ -1,28 +1,33 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { LocaleKey } from "../../../../i18n/keys"; import type { SettingsUpdate } from "../../../../types/bridge"; import { getProviderIcon } from "../../../../components/providers/providerIcons"; +import { SettingsGroup } from "../../../../components/FormControls"; interface Props { providerId: string; accentColor: string | null; + disabled?: boolean; t: (key: LocaleKey) => string; onChange: (patch: SettingsUpdate) => void; } /** - * Per-provider accent color override (#2972): hex input, native color - * picker, and a reset-to-shipped-color button. The override is persisted - * via the standard settings-update flow (onSettingsChange). + * Accent color group (macOS `ProviderAccentColorSection`): a hex field, the + * native color well and "Use default", which appears only while an override + * is saved. The override is persisted through the settings-update flow. */ export function AccentColorSection({ providerId, accentColor, + disabled = false, t, onChange, }: Props) { const [input, setInput] = useState(accentColor ?? ""); const [error, setError] = useState(null); + // Follow saves made elsewhere (another window, or the reset below). + useEffect(() => setInput(accentColor ?? ""), [accentColor]); const brandColor = getProviderIcon(providerId).brandColor; const effective = accentColor ?? brandColor; @@ -31,7 +36,7 @@ export function AccentColorSection({ setError(null); const trimmed = raw.trim(); if (trimmed === "") { - onChange({ providerAccentColors: { [providerId]: null } }); + if (accentColor !== null) onChange({ providerAccentColors: { [providerId]: null } }); setInput(""); return; } @@ -41,8 +46,10 @@ export function AccentColorSection({ return; } const normalized = `#${trimmedHex.toUpperCase()}`; - onChange({ providerAccentColors: { [providerId]: normalized } }); setInput(normalized); + if (normalized !== accentColor) { + onChange({ providerAccentColors: { [providerId]: normalized } }); + } }; const handleReset = () => { @@ -52,49 +59,51 @@ export function AccentColorSection({ }; return ( -
-

{t("ProviderAccentColor")}

-

- {t("ProviderAccentColorHelper")} -

+
- { - const value = e.target.value.toUpperCase(); - setInput(value); - handleSave(value); - }} - /> setInput(e.target.value)} onBlur={() => handleSave(input)} onKeyDown={(e) => { - if (e.key === "Enter") { - handleSave(input); - } + if (e.key === "Enter") handleSave(input); }} /> - + handleSave(e.target.value)} + /> + {accentColor !== null && ( + + )}
- {error &&

{error}

} -
+ {error && ( +

+ {error} +

+ )} + ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AutoResumeSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AutoResumeSection.tsx index ca7b2935f0..6a220ac280 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AutoResumeSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AutoResumeSection.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { LocaleKey } from "../../../../i18n/keys"; import { setProviderAutoResumeAfterQuotaReset } from "../../../../lib/tauri"; +import { Field, Toggle } from "../../../../components/FormControls"; interface Props { providerId: string; @@ -11,7 +12,10 @@ interface Props { onChanged: () => void; } -/** Opt-in control for reopening the exact local Codex or Claude CLI session. */ +/** + * Opt-in switch for reopening the exact local Codex or Claude CLI session. + * Windows-only; renders as a row of the provider's Options group. + */ export function AutoResumeSection({ providerId, enabled, @@ -40,25 +44,19 @@ export function AutoResumeSection({ }; return ( -
-

{t("ProviderAutoResumeTitle")}

- - {error &&
{error}
} -
+ + {error &&

{error}

} + ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx index 69036bb68a..b04edc0a0f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx @@ -3,7 +3,6 @@ import { GeminiCliCreds } from "./credentials/GeminiCliCreds"; import { VertexAiCreds } from "./credentials/VertexAiCreds"; import { JetBrainsCreds } from "./credentials/JetBrainsCreds"; import { KiroCreds } from "./credentials/KiroCreds"; -import { ClaudeCreds } from "./credentials/ClaudeCreds"; import { OpenAiExtras } from "./credentials/OpenAiExtras"; import { OpenRouterManagementCreds } from "./credentials/OpenRouterManagementCreds"; @@ -15,8 +14,7 @@ interface Props { /** * Dispatch the appropriate Phase-6d credential component based on the * current provider. Providers without a bespoke credentials UI render - * nothing. Mirrors the `provider_id == ProviderId::*` chain in - * `rust/src/native_ui/preferences.rs::render_provider_detail_panel`. + * nothing. Claude and Codex switches live in the Options group instead. */ export function CredentialsDispatcher({ providerId, t }: Props) { switch (providerId) { @@ -28,10 +26,6 @@ export function CredentialsDispatcher({ providerId, t }: Props) { return ; case "kiro": return ; - case "claude": - return ; - case "codex": - return ; case "openaiapi": return ; case "litellm": diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx index 3241542261..f7390888cf 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx @@ -1,6 +1,7 @@ import type { ProviderDetail } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; import { ProviderIcon } from "../../../../components/providers/ProviderIcon"; +import { Toggle } from "../../../../components/FormControls"; import { hideOpenAiApiProjectId } from "../../../../lib/openAiApiIdentity"; import { maskEmail } from "../../../../components/MenuCard"; import { providerSourceHintShort } from "../providerDetailFormat"; @@ -13,20 +14,30 @@ interface Props { subtitle: string; t: (key: LocaleKey) => string; hidePersonalInfo?: boolean; + /** "Updated" row value; the row is omitted when absent. */ + updatedText?: string | null; + /** Header switch bound to the enabled-providers list; omitted when absent. */ + enabled?: boolean; + onToggleEnabled?: (on: boolean) => void; + onRefresh?: () => void; + busy?: boolean; } /** - * Header block: provider icon + display name + identity rows - * (account, plan, auth type, data source). - * - * Port of the identity portion of - * `rust/src/native_ui/preferences.rs::render_provider_detail_panel` (~4301). + * Header group of the provider pane (macOS `ProviderDetailHeaderRow` + + * `ProviderDetailInfoRows`): icon, name and subtitle, a borderless Refresh + * button and the Enabled switch, then Source / Updated / Account / Auth / Plan. */ export function IdentitySection({ provider, subtitle, t, hidePersonalInfo = false, + updatedText = null, + enabled, + onToggleEnabled, + onRefresh, + busy = false, }: Props) { const hideProjectId = provider.id === "openaiapi" && hidePersonalInfo; // Same masking as the tray card (MenuCard), for every provider. @@ -37,51 +48,81 @@ export function IdentitySection({ : hidePersonalInfo ? maskEmailsInText(provider.organization) : provider.organization; - const rows: { label: string; value: string | null }[] = [ + const rows: { key: string; label: string; value: string | null }[] = [ { + key: "source", + label: t("Source"), + value: provider.sourceLabel || providerSourceHintShort(provider.id, t), + }, + { key: "updated", label: t("Updated"), value: updatedText }, + { + key: "account", label: t("Account"), value: hideOpenAiApiProjectId(account, hideProjectId), }, + { key: "auth", label: t("AuthType"), value: provider.authType }, { + key: "plan", label: t("Plan"), value: hideOpenAiApiProjectId(displayIdentityValue(provider.plan, t), hideProjectId), }, - { label: t("AuthType"), value: provider.authType }, - { - label: t("DataSource"), - value: provider.sourceLabel || providerSourceHintShort(provider.id, t), - }, ]; const visible = rows.filter( - (r): r is { label: string; value: string } => + (r): r is { key: string; label: string; value: string } => !!r.value && r.value.length > 0, ); return ( -
-
- -
-
{provider.displayName}
-
{subtitle}
+
+
+
+ +
+
{provider.displayName}
+ {subtitle &&
{subtitle}
} +
+ {onRefresh && ( + + )} + {onToggleEnabled && ( + + )}
+ {visible.map((r) => ( +
+ {r.label} + {r.value} +
+ ))}
- {visible.length > 0 && ( -
- {visible.map((r) => ( -
-
{r.label}
-
{r.value}
-
- ))} -
- )} -
+
); } /** Organization labels can embed the account email (e.g. "me@x.com's Organization"). */ -function maskEmailsInText(value: string | null): string | null { +export function maskEmailsInText(value: string | null): string | null { if (!value) return value; return value.replace(/[^\s@'"]+@[^\s@'"]+/g, (email) => maskEmail(email)); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx index cd6a558e8c..3f43161dc1 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx @@ -202,12 +202,12 @@ describe("MenuBarMetricSection", () => { expect(screen.getByRole("combobox")).toHaveValue("extraUsage"); }); - it("offers only Automatic for Aixy, even with extra budget windows", () => { + it("hides the picker for Aixy, which offers only Automatic", () => { const aixy = provider(); aixy.id = "aixy"; aixy.displayName = "Aixy"; aixy.weekly = rateWindow(40); - render( + const { container } = render( { />, ); - const options = screen.getAllByRole("option").map((option) => option.textContent); - expect(options).toEqual(["Automatic"]); - expect(screen.getByRole("combobox")).toHaveValue("automatic"); + expect(container.innerHTML).toBe(""); }); it("offers the provider-declared lane labels in the metric picker", () => { const base = provider(false); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx index b2b56f60ea..8435fc7c34 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx @@ -7,6 +7,7 @@ import type { } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; import { localizeWindowLabel } from "../../../../lib/windowLabels"; +import { Field, Select, SettingsGroup } from "../../../../components/FormControls"; interface Props { provider: ProviderDetail; @@ -50,29 +51,26 @@ export function MenuBarMetricSection({ } }; + // Mac shows the picker only when there is a choice to make; a saved choice + // the provider no longer offers adds an option, so it stays visible then. + if (options.length <= 1) return null; + return ( -
-

{t("TrayDisplayTitle")}

- -

{t("MenuBarMetricHelper")}

- {error &&

{error}

} -
+ onChange={(value) => handleChange(value as MetricPreference)} + /> + + {error &&

{error}

} + ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.tsx index 8234c30f44..4c26450e48 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { LocaleKey } from "../../../../i18n/keys"; import { setProviderOptionalDetails } from "../../../../lib/tauri"; +import { Field, Toggle } from "../../../../components/FormControls"; interface Props { providerId: string; @@ -28,7 +29,10 @@ const OPTIONAL_DETAILS_COPY: Record = { }, }; -/** Opt-in for a provider's extra breakdown (LiteLLM model activity, Claude workspace spend). */ +/** + * Opt-in for a provider's extra breakdown (LiteLLM model activity, Claude + * workspace spend); renders as a row of the provider's Options group. + */ export function OptionalDetailsSection({ providerId, enabled, @@ -58,21 +62,16 @@ export function OptionalDetailsSection({ }; return ( -
-

{t("ProviderOptionalDetailsTitle")}

- - {error &&
{error}
} -
+ + {error &&

{error}

} + ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.test.tsx new file mode 100644 index 0000000000..d86dc817fb --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.test.tsx @@ -0,0 +1,141 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { OptionsSection, type OptionSettings } from "./OptionsSection"; + +vi.mock("../../../../lib/tauri", () => ({ + setProviderAutoResumeAfterQuotaReset: vi.fn(), + setProviderOptionalDetails: vi.fn(), +})); + +const t = (key: string) => key; + +const detail = (id: string) => ({ + id, + autoResumeAfterQuotaReset: false, + autoResumeSupported: false, + optionalDetailsEnabled: false, + optionalDetailsSupported: false, +}); + +function settings(extra: Partial = {}): OptionSettings { + return { + codexOpenaiWebExtras: undefined, + codexHistoricalTracking: false, + codexExternalOauthSourcesAllowed: false, + codexLocalSessionCostLedgerEnabled: false, + codexOpenaiWebBatterySaverEnabled: false, + claudeAvoidKeychainPrompts: false, + claudeAllowReadingClaudeCodeCredentials: false, + claudeSwapEnabled: false, + disableKeychainAccess: false, + ...extra, + } as OptionSettings; +} + +function toggle(label: string) { + return screen.getByRole("checkbox", { name: label }); +} + +describe("OptionsSection", () => { + it("lists the Codex options with web extras on by default and marks unused ones", () => { + render( + , + ); + const labels = Array.from(document.querySelectorAll(".settings-field__label")).map( + (label) => label.textContent, + ); + expect(labels).toEqual([ + "ProviderCodexLocalCostEstimates", + "ProviderCodexHistoricalTracking", + "ProviderCodexOpenAiWebExtras", + "ProviderCodexExternalOauth", + "ProviderCodexBatterySaver", + ]); + expect(toggle("ProviderCodexOpenAiWebExtras")).toBeChecked(); + expect(screen.getByText(/ProviderCodexHistoryHelp ProviderOptionNotUsedOnWindows/)).toBeTruthy(); + expect(screen.queryByText(/ProviderCodexExternalOauthHelp ProviderOptionNotUsedOnWindows/)).toBeNull(); + }); + + it("hides the battery saver while OpenAI web extras are off", () => { + render( + , + ); + expect(screen.queryByText("ProviderCodexBatterySaver")).toBeNull(); + }); + + it("writes the Codex option that was switched", async () => { + const onSettingsChange = vi.fn(); + render( + , + ); + await act(async () => fireEvent.click(toggle("ProviderCodexExternalOauth"))); + expect(onSettingsChange).toHaveBeenCalledWith({ codexExternalOauthSourcesAllowed: true }); + await act(async () => fireEvent.click(toggle("ProviderCodexOpenAiWebExtras"))); + expect(onSettingsChange).toHaveBeenLastCalledWith({ codexOpenaiWebExtras: false }); + }); + + it("lists the Claude options and locks Avoid keychain prompts under the Advanced switch", async () => { + const onSettingsChange = vi.fn(); + const { rerender } = render( + , + ); + expect(toggle("ProviderClaudeAvoidKeychainPrompts")).toBeEnabled(); + await act(async () => fireEvent.click(toggle("ClaudeSwapEnable"))); + expect(onSettingsChange).toHaveBeenCalledWith({ claudeSwapEnabled: true }); + + rerender( + , + ); + expect(toggle("ProviderClaudeAvoidKeychainPrompts")).toBeDisabled(); + expect(toggle("ProviderClaudeAllowReadingClaudeCodeCredentials")).toBeEnabled(); + }); + + it("renders an empty group for a provider without options", () => { + const { container } = render( + , + ); + expect(container.querySelector(".settings-group__box")?.childElementCount).toBe(0); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.tsx new file mode 100644 index 0000000000..58b6df345e --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.tsx @@ -0,0 +1,190 @@ +import type { + ProviderDetail, + SettingsSnapshot, + SettingsUpdate, +} from "../../../../types/bridge"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { Field, SettingsGroup, Toggle } from "../../../../components/FormControls"; +import { AutoResumeSection } from "./AutoResumeSection"; +import { OptionalDetailsSection } from "./OptionalDetailsSection"; + +export type OptionSettings = Pick< + SettingsSnapshot, + | "codexOpenaiWebExtras" + | "codexHistoricalTracking" + | "codexExternalOauthSourcesAllowed" + | "codexLocalSessionCostLedgerEnabled" + | "codexOpenaiWebBatterySaverEnabled" + | "claudeAvoidKeychainPrompts" + | "claudeAllowReadingClaudeCodeCredentials" + | "claudeSwapEnabled" + | "disableKeychainAccess" +>; + +interface Props { + detail: Pick< + ProviderDetail, + | "id" + | "autoResumeAfterQuotaReset" + | "autoResumeSupported" + | "optionalDetailsEnabled" + | "optionalDetailsSupported" + >; + settings: OptionSettings; + disabled: boolean; + t: (key: LocaleKey) => string; + onSettingsChange: (patch: SettingsUpdate) => void; + /** Reload the provider detail after a provider-scoped command saved. */ + onReload: () => void; +} + +interface OptionRow { + key: string; + label: LocaleKey; + help: LocaleKey; + checked: boolean; + patch: (on: boolean) => SettingsUpdate; + /** Saved but not read on Windows yet; adds the "Not used on Windows yet" line. */ + inert?: boolean; + locked?: boolean; +} + +function codexRows(settings: OptionSettings): OptionRow[] { + // Windows keeps OpenAI web extras on by default (the Mac default is off). + const webExtras = settings.codexOpenaiWebExtras ?? true; + const rows: OptionRow[] = [ + { + key: "local-cost", + label: "ProviderCodexLocalCostEstimates", + help: "ProviderCodexLocalCostEstimatesHelp", + checked: settings.codexLocalSessionCostLedgerEnabled ?? false, + patch: (on) => ({ codexLocalSessionCostLedgerEnabled: on }), + inert: true, + }, + { + key: "history", + label: "ProviderCodexHistoricalTracking", + help: "ProviderCodexHistoryHelp", + checked: settings.codexHistoricalTracking ?? false, + patch: (on) => ({ codexHistoricalTracking: on }), + inert: true, + }, + { + key: "web-extras", + label: "ProviderCodexOpenAiWebExtras", + help: "ProviderCodexOpenAiWebExtrasHelp", + checked: webExtras, + patch: (on) => ({ codexOpenaiWebExtras: on }), + }, + { + key: "external-oauth", + label: "ProviderCodexExternalOauth", + help: "ProviderCodexExternalOauthHelp", + checked: settings.codexExternalOauthSourcesAllowed ?? false, + patch: (on) => ({ codexExternalOauthSourcesAllowed: on }), + }, + ]; + if (webExtras) { + rows.push({ + key: "battery-saver", + label: "ProviderCodexBatterySaver", + help: "ProviderCodexBatterySaverHelp", + checked: settings.codexOpenaiWebBatterySaverEnabled ?? false, + patch: (on) => ({ codexOpenaiWebBatterySaverEnabled: on }), + inert: true, + }); + } + return rows; +} + +function claudeRows(settings: OptionSettings): OptionRow[] { + return [ + { + key: "claude-code-credentials", + label: "ProviderClaudeAllowReadingClaudeCodeCredentials", + help: "ProviderClaudeAllowReadingClaudeCodeCredentialsHelp", + checked: settings.claudeAllowReadingClaudeCodeCredentials ?? false, + patch: (on) => ({ claudeAllowReadingClaudeCodeCredentials: on }), + }, + { + // Its help already says the option is not enforced on Windows yet. + key: "avoid-keychain", + label: "ProviderClaudeAvoidKeychainPrompts", + help: "ProviderClaudeAvoidKeychainPromptsHelp", + checked: settings.claudeAvoidKeychainPrompts, + patch: (on) => ({ claudeAvoidKeychainPrompts: on }), + // Advanced > "Disable all Credential Manager access" overrides it. + locked: settings.disableKeychainAccess, + }, + { + key: "claude-swap", + label: "ClaudeSwapEnable", + help: "ClaudeSwapHint", + checked: settings.claudeSwapEnabled ?? false, + patch: (on) => ({ claudeSwapEnabled: on }), + }, + ]; +} + +/** + * Options group (macOS `settingsToggles`), last in the pane: the provider's + * persisted switches, then the Windows-only automatic session resume. + */ +export function OptionsSection({ + detail, + settings, + disabled, + t, + onSettingsChange, + onReload, +}: Props) { + const rows = + detail.id === "codex" + ? codexRows(settings) + : detail.id === "claude" + ? claudeRows(settings) + : []; + + return ( + + {/* Mac lists Claude's "Show workspace spend" first. */} + + {rows.map((row) => ( + + onSettingsChange(row.patch(on))} + /> + + ))} + + + ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx index 00c96db7a4..6a60d08faa 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { ProviderDetail } from "../../../../types/bridge"; import { ProviderIssueNotice } from "./ProviderIssueNotice"; @@ -9,44 +9,58 @@ const detail = { errorState: "needsAuthentication", } as ProviderDetail; +const strings: Record = { + ProviderIssueAuthRequired: "Sign-in required", + ProviderIssueUnknown: "Usage unavailable", + ProviderIssuePrivacySafeDetail: "Details are hidden here to protect account data.", + ProviderLastFetchFailedTitle: "Last {} fetch failed:", + ProviderShowDetails: "Show details", + ProviderHideDetails: "Hide details", + ActionCopyError: "Copy error", +}; +const t = vi.fn((key: string) => strings[key] ?? key); + describe("ProviderIssueNotice", () => { - it("renders a categorized notice without rendering the raw diagnostic", () => { - // Raw diagnostic text only ever lives on lastError; the notice must - // classify from the backend's errorState and never echo the text. - const raw = - "cookie=super-secret; authentication required at https://private.example.test"; - const shownDetail = { ...detail, lastError: raw } as ProviderDetail; - const t = vi.fn((key: string) => ({ - ProviderIssueAuthRequired: "Sign-in required", - ProviderIssuePrivacySafeDetail: "Details are hidden here to protect account data.", - })[key] ?? key); - - render(); + it("shows the Mac title and the privacy-safe preview, hiding the diagnostic until asked", () => { + const raw = "authentication required for me@example.com at https://private.example.test"; + render(); const status = screen.getByRole("status"); - expect(status).toHaveTextContent("Cursor: Sign-in required"); - expect(status).toHaveTextContent( - "Details are hidden here to protect account data.", + expect(status).toHaveTextContent("Last Cursor fetch failed:"); + expect(status).toHaveTextContent("Sign-in required"); + expect(status).toHaveTextContent("Details are hidden here to protect account data."); + expect(status).not.toHaveTextContent(/private\.example\.test/); + expect(screen.getByRole("button", { name: "Copy error" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Show details" })); + expect(status).toHaveTextContent(raw); + expect(screen.getByRole("button", { name: "Hide details" })).toHaveAttribute( + "aria-expanded", + "true", ); - expect(status).not.toHaveTextContent(/super-secret|private\.example\.test/i); - expect(status).not.toHaveTextContent(/cookie=/i); - }); - it("falls back to the unknown state when the bridge omits errorState", () => { - const t = vi.fn((key: string) => ({ - ProviderIssueUnknown: "Usage unavailable", - ProviderIssuePrivacySafeDetail: "Details are hidden here to protect account data.", - })[key] ?? key); + fireEvent.click(screen.getByRole("button", { name: "Hide details" })); + expect(status).not.toHaveTextContent(/private\.example\.test/); + }); + it("masks emails in the expanded details under Hide Personal Info", () => { render( , ); + fireEvent.click(screen.getByRole("button", { name: "Show details" })); + const status = screen.getByRole("status"); + expect(status).toHaveTextContent("token expired for"); + expect(status).not.toHaveTextContent("person@example.com"); + }); - expect(screen.getByRole("status")).toHaveTextContent( - "Cursor: Usage unavailable", - ); + it("falls back to the unknown state and offers no details without a diagnostic", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent("Usage unavailable"); + expect(screen.queryByRole("button", { name: "Show details" })).toBeNull(); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx index d20154d81e..d1080b889d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx @@ -1,22 +1,91 @@ +import { useCallback, useEffect, useRef, useState } from "react"; import type { ProviderDetail } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; import { describeProviderState } from "../../../../lib/providerState"; +import { maskEmailsInText } from "./IdentitySection"; interface Props { - detail: ProviderDetail; + detail: Pick; t: (key: LocaleKey) => string; + hidePersonalInfo?: boolean; } -export function ProviderIssueNotice({ detail, t }: Props) { +/** + * Error group of the provider pane (macOS `ProviderErrorView`): "Last X fetch + * failed:" with a copy button, a short preview, and Show/Hide details for the + * full text. The preview is the backend's privacy-safe state label; the full + * text is the backend-redacted `lastError`, with emails masked under Hide + * Personal Info, and only appears once the user asks for it. + */ +export function ProviderIssueNotice({ detail, t, hidePersonalInfo = false }: Props) { + const [expanded, setExpanded] = useState(false); const state = describeProviderState(detail.errorState ?? "unknown"); - const title = `${detail.displayName}: ${t(state.labelKey)}`; + const preview = t(state.labelKey); + const raw = detail.lastError?.trim() || null; + const full = raw && hidePersonalInfo ? maskEmailsInText(raw) : raw; + const title = t("ProviderLastFetchFailedTitle").replace("{}", detail.displayName); return ( -
-
- {title} +
+
+
+
+ {title} + +
+

{preview}

+ {!expanded &&

{t("ProviderIssuePrivacySafeDetail")}

} + {full && ( + + )} + {expanded && full &&

{full}

} +
-

{t("ProviderIssuePrivacySafeDetail")}

-
+ + ); +} + +function ErrorCopyButton({ text, t }: { text: string; t: (key: LocaleKey) => string }) { + const [copied, setCopied] = useState(false); + const timer = useRef(null); + useEffect( + () => () => { + if (timer.current != null) window.clearTimeout(timer.current); + }, + [], + ); + const handleCopy = useCallback(() => { + navigator.clipboard?.writeText(text).catch(() => {}); + setCopied(true); + if (timer.current != null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), 900); + }, [text]); + const label = copied ? t("PanelCopied") : t("ActionCopyError"); + return ( + ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuickActionsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuickActionsSection.tsx index 63964a6ab3..49ca4b2520 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuickActionsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuickActionsSection.tsx @@ -2,9 +2,8 @@ import type { ProviderDetail } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; interface Props { - provider: ProviderDetail; + provider: Pick; busy: boolean; - onRefresh: () => void; onSwitchAccount: () => void; onOpenDashboard: () => void; onOpenStatusPage: () => void; @@ -13,71 +12,45 @@ interface Props { } /** - * Quick-action toolbar. Mirrors the six buttons in - * `rust/src/native_ui/preferences.rs::render_provider_detail_panel`. - * Buttons that have no backing URL on the provider are omitted entirely - * (egui parity: the button only renders when the action is meaningful). + * Windows-only link row at the end of the Usage group. Refresh lives in the + * header; links without a URL on the provider are omitted, and the row + * renders nothing when no link applies. */ export function QuickActionsSection({ provider, busy, - onRefresh, onSwitchAccount, onOpenDashboard, onOpenStatusPage, onBuyCredits, t, }: Props) { + const links: { key: string; label: LocaleKey; onClick: () => void; disabled?: boolean }[] = []; + if (provider.dashboardUrl) { + links.push({ key: "switch", label: "ActionSwitchAccount", onClick: onSwitchAccount, disabled: busy }); + links.push({ key: "dashboard", label: "ActionUsageDashboard", onClick: onOpenDashboard }); + } + if (provider.statusPageUrl) { + links.push({ key: "status", label: "ActionStatusPage", onClick: onOpenStatusPage }); + } + if (provider.buyCreditsUrl) { + links.push({ key: "credits", label: "ActionBuyCredits", onClick: onBuyCredits }); + } + if (links.length === 0) return null; + return ( -
-

{t("QuickActions")}

-
+
+ {links.map((link) => ( - {provider.dashboardUrl && ( - - )} - {provider.dashboardUrl && ( - - )} - {provider.statusPageUrl && ( - - )} - {provider.buyCreditsUrl && ( - - )} -
-
+ ))} +
); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuotaWarningsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuotaWarningsSection.test.tsx new file mode 100644 index 0000000000..364d46a606 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuotaWarningsSection.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { SettingsSnapshot } from "../../../../types/bridge"; +import { QuotaWarningsSection } from "./QuotaWarningsSection"; + +const t = (key: string) => key; + +type QuotaSettings = Pick< + SettingsSnapshot, + | "highUsageThreshold" + | "criticalUsageThreshold" + | "providerQuotaWarnings" + | "showNotifications" + | "quotaWarningMarkersVisible" +>; + +function settings(extra: Partial = {}): QuotaSettings { + return { + highUsageThreshold: 80, + criticalUsageThreshold: 95, + providerQuotaWarnings: {}, + showNotifications: true, + quotaWarningMarkersVisible: true, + ...extra, + }; +} + +function renderSection(value: QuotaSettings, onChange = vi.fn()) { + const view = render( + , + ); + return { ...view, onChange }; +} + +function row(window: "session" | "weekly") { + return document.querySelector(`.quota-warning-row[data-window="${window}"]`) as HTMLElement; +} + +describe("QuotaWarningsSection", () => { + it("shows Global with the inherited thresholds by default", () => { + renderSection(settings()); + const session = row("session"); + expect(within(session).getByRole("radio", { name: "QuotaWarningGlobal" })).toHaveAttribute( + "aria-checked", + "true", + ); + expect(session.textContent).toContain("QuotaWarningInherited"); + expect(screen.getByText("QuotaWarningProviderInherits")).toBeTruthy(); + }); + + it("switches a window to Custom with the global values and keeps the other window", async () => { + const { onChange } = renderSection( + settings({ providerQuotaWarnings: { codex: { weekly: { enabled: false } } } }), + ); + await act(async () => + fireEvent.click(within(row("session")).getByRole("radio", { name: "QuotaWarningCustom" })), + ); + expect(onChange).toHaveBeenCalledWith({ + providerQuotaWarnings: { + codex: { weekly: { enabled: false }, session: { enabled: true, high: 80, critical: 95 } }, + }, + }); + }); + + it("edits Custom thresholds as used percentages clamped to 0-100", async () => { + const { onChange } = renderSection( + settings({ + providerQuotaWarnings: { codex: { session: { enabled: true, high: 70, critical: 90 } } }, + }), + ); + const warning = screen.getByRole("spinbutton", { name: "ProviderSessionLabel QuotaWarningWarning" }); + expect(warning).toHaveValue(70); + await act(async () => fireEvent.change(warning, { target: { value: "120" } })); + expect(onChange).toHaveBeenLastCalledWith({ + providerQuotaWarnings: { codex: { session: { enabled: true, high: 100, critical: 90 } } }, + }); + }); + + it("keeps custom values when turned Off and writes an empty window for Global", async () => { + const { onChange } = renderSection( + settings({ + providerQuotaWarnings: { codex: { session: { enabled: true, high: 70, critical: 90 } } }, + }), + ); + await act(async () => + fireEvent.click(within(row("session")).getByRole("radio", { name: "QuotaWarningOff" })), + ); + expect(onChange).toHaveBeenLastCalledWith({ + providerQuotaWarnings: { codex: { session: { enabled: false, high: 70, critical: 90 } } }, + }); + await act(async () => + fireEvent.click(within(row("session")).getByRole("radio", { name: "QuotaWarningGlobal" })), + ); + expect(onChange).toHaveBeenLastCalledWith({ + providerQuotaWarnings: { codex: { session: {} } }, + }); + }); + + it("dims and locks the group while notifications and markers are both off", () => { + const { container } = renderSection( + settings({ showNotifications: false, quotaWarningMarkersVisible: false }), + ); + expect(container.querySelector(".provider-quota-warnings.is-dimmed")).toBeTruthy(); + expect(screen.getByText("QuotaWarningProviderDisabled")).toBeTruthy(); + for (const radio of screen.getAllByRole("radio")) expect(radio).toBeDisabled(); + }); + + it("explains the markers-only state", () => { + const { container } = renderSection(settings({ showNotifications: false })); + expect(container.querySelector(".is-dimmed")).toBeNull(); + expect(screen.getByText("QuotaWarningProviderMarkersOnly")).toBeTruthy(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuotaWarningsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuotaWarningsSection.tsx new file mode 100644 index 0000000000..a10d7008ac --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuotaWarningsSection.tsx @@ -0,0 +1,183 @@ +import type { + QuotaWarningWindow, + SettingsSnapshot, + SettingsUpdate, +} from "../../../../types/bridge"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { NumberInput, SettingsGroup } from "../../../../components/FormControls"; +import { + QUOTA_WARNING_WINDOWS, + quotaWarningMode, + quotaWarningWindow, + type QuotaWarningMode, + type QuotaWarningWindowId, +} from "../../../../lib/quotaWarnings"; + +type QuotaSettings = Pick< + SettingsSnapshot, + | "highUsageThreshold" + | "criticalUsageThreshold" + | "providerQuotaWarnings" + | "showNotifications" + | "quotaWarningMarkersVisible" +>; + +interface Props { + providerId: string; + settings: QuotaSettings; + disabled: boolean; + t: (key: LocaleKey) => string; + onChange: (patch: SettingsUpdate) => void; +} + +const WINDOW_LABEL: Record = { + session: "ProviderSessionLabel", + weekly: "ProviderWeeklyLabel", +}; + +const MODES: { mode: QuotaWarningMode; label: LocaleKey }[] = [ + { mode: "global", label: "QuotaWarningGlobal" }, + { mode: "custom", label: "QuotaWarningCustom" }, + { mode: "off", label: "QuotaWarningOff" }, +]; + +/** + * Per-provider quota warnings (macOS `ProviderQuotaWarningSettingsView`): + * Session and Weekly rows, each Global, Custom or Off. Values are *used* %, + * like the global thresholds on the Notifications tab. Dimmed and locked while + * both quota notifications and usage-bar markers are off. + */ +export function QuotaWarningsSection({ + providerId, + settings, + disabled, + t, + onChange, +}: Props) { + const controlsEnabled = + settings.showNotifications || settings.quotaWarningMarkersVisible; + const footer = settings.showNotifications + ? t("QuotaWarningProviderInherits") + : settings.quotaWarningMarkersVisible + ? t("QuotaWarningProviderMarkersOnly") + : t("QuotaWarningProviderDisabled"); + const locked = disabled || !controlsEnabled; + + // A provider patch replaces that provider's whole value, so keep the other window. + const write = (window: QuotaWarningWindowId, next: QuotaWarningWindow) => { + const current = settings.providerQuotaWarnings?.[providerId] ?? {}; + onChange({ + providerQuotaWarnings: { [providerId]: { ...current, [window]: next } }, + }); + }; + + const setMode = (window: QuotaWarningWindowId, mode: QuotaWarningMode) => { + const stored = quotaWarningWindow(settings, providerId, window); + const current = quotaWarningMode(stored); + if (mode === current) return; + if (mode === "global") { + write(window, {}); + } else if (mode === "custom") { + write(window, { + enabled: true, + high: stored.high ?? settings.highUsageThreshold, + critical: stored.critical ?? settings.criticalUsageThreshold, + }); + } else { + // Off keeps custom values so switching back to Custom restores them. + write( + window, + current === "custom" + ? { enabled: false, high: stored.high, critical: stored.critical } + : { enabled: false }, + ); + } + }; + + const setThreshold = ( + window: QuotaWarningWindowId, + field: "high" | "critical", + value: number, + ) => { + const stored = quotaWarningWindow(settings, providerId, window); + write(window, { + enabled: true, + high: stored.high ?? settings.highUsageThreshold, + critical: stored.critical ?? settings.criticalUsageThreshold, + [field]: Math.min(100, Math.max(0, Math.round(value))), + }); + }; + + return ( + {footer}

} + > + {QUOTA_WARNING_WINDOWS.map((window) => { + const stored = quotaWarningWindow(settings, providerId, window); + const mode = quotaWarningMode(stored); + const label = t(WINDOW_LABEL[window]); + return ( +
+ {label} +
+ {MODES.map(({ mode: option, label: optionLabel }) => ( + + ))} +
+
+ {mode === "global" && ( + + {t("QuotaWarningInherited") + .replace("{}", String(settings.highUsageThreshold)) + .replace("{}", String(settings.criticalUsageThreshold))} + + )} + {mode === "custom" && ( + <> + + + + )} +
+
+ ); + })} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx index ce331eab95..0c992d99df 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx @@ -65,7 +65,7 @@ describe("UsageItemVisibilitySection", () => { }, }); - fireEvent.click(screen.getByRole("button", { name: "WindowRestore" })); + fireEvent.click(screen.getByRole("button", { name: "ProviderRestoreDefaults" })); expect(onChange).toHaveBeenLastCalledWith({ providerHiddenUsageItemIds: { codex: [] }, }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.tsx index 460be33ddd..f4b94089aa 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { ProviderDetail, SettingsUpdate } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; +import { SettingsGroup } from "../../../../components/FormControls"; import { localizeWindowLabel } from "../../../../lib/windowLabels"; interface Props { @@ -10,7 +11,11 @@ interface Props { onChange: (patch: SettingsUpdate) => void; } -/** Presentation-only visibility controls for the provider's emitted metric rows. */ +/** + * "Visible usage items" group (macOS `ProviderUsageItemsVisibilitySection`): + * one checkbox per emitted usage row plus Restore Defaults. Presentation only; + * hidden rows are dropped from the tray card and Overview. + */ export function UsageItemVisibilitySection({ provider, disabled, @@ -46,38 +51,45 @@ export function UsageItemVisibilitySection({ }; return ( -
-
-

{t("ProviderOptionsTitle")}

- -
+ {t("ProviderVisibleUsageItemsFooter")}

} + > {items.map((item) => ( -
+
+ +
+ ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index 0a35e3b802..0f85ce7871 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -88,7 +88,7 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { } return ( -
+

{t("ProviderUsage")}

{bars.map((b) => ( { accounts: [], error: null, }); - mocks.getSettingsSnapshot.mockResolvedValue({ - claudeSwapEnabled: false, - claudeSwapExecutablePath: "", - }); + mocks.getSettingsSnapshot.mockResolvedValue({}); }); it("offers to save the discovered account and switches only saved inactive accounts", async () => { @@ -136,4 +133,17 @@ describe("ClaudeAccountsSection", () => { rerender(); expect(screen.queryByText("30% PanelUsedSuffix")).toBeNull(); }); + + it("shows the claude-swap list only while claude-swap is on", async () => { + const { rerender } = render(); + await screen.findByText(current.email); + expect(screen.queryByText("ClaudeSwapTitle")).toBeNull(); + expect(mocks.claudeSwapAccountsList).not.toHaveBeenCalled(); + mocks.claudeSwapAccountsList.mockResolvedValue({ + enabled: true, executableConfigured: false, accounts: [], error: null, + }); + rerender(); + await screen.findByText("ClaudeSwapTitle"); + expect(screen.getByText("ClaudeSwapStatusNoExecutable")).toBeTruthy(); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx index 06c0711212..a1292b13ae 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeAccountsSection.tsx @@ -30,12 +30,17 @@ export function ClaudeAccountsSection({ hidePersonalInfo = false, showAsUsed = false, resetTimeRelative = true, + claudeSwapEnabled = false, + claudeSwapExecutablePath = "", }: { t: (key: LocaleKey) => string; language?: Language; hidePersonalInfo?: boolean; showAsUsed?: boolean; resetTimeRelative?: boolean; + /** Settings `claudeSwapEnabled`; the claude-swap list renders only while on. */ + claudeSwapEnabled?: boolean; + claudeSwapExecutablePath?: string; }) { const [accounts, setAccounts] = useState([]); const [busy, setBusy] = useState(false); @@ -205,7 +210,12 @@ export function ClaudeAccountsSection({ )}
- + ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeCreds.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeCreds.test.tsx deleted file mode 100644 index 5658bef21c..0000000000 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeCreds.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { SettingsSnapshot } from "../../../../../types/bridge"; -import { ClaudeCreds } from "./ClaudeCreds"; - -const tauriMocks = vi.hoisted(() => ({ - getSettingsSnapshot: vi.fn(), - updateSettings: vi.fn(), -})); - -vi.mock("../../../../../lib/tauri", async (importOriginal) => ({ - ...(await importOriginal()), - ...tauriMocks, -})); - -type ClaudeSettingsSnapshot = Pick< - SettingsSnapshot, - | "claudeAvoidKeychainPrompts" - | "claudeDailyRoutinesUsageVisible" - | "claudeAllowReadingClaudeCodeCredentials" ->; - -function snapshot( - overrides: Partial = {}, -): ClaudeSettingsSnapshot { - return { - claudeAvoidKeychainPrompts: false, - claudeDailyRoutinesUsageVisible: true, - claudeAllowReadingClaudeCodeCredentials: false, - ...overrides, - }; -} - -describe("ClaudeCreds", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders the Claude Code credentials consent toggle, off by default", async () => { - tauriMocks.getSettingsSnapshot.mockResolvedValue(snapshot()); - - render( key} />); - - const checkbox = await screen.findByRole("checkbox", { - name: "ProviderClaudeAllowReadingClaudeCodeCredentials ProviderClaudeAllowReadingClaudeCodeCredentialsHelp", - }); - expect(checkbox).not.toBeChecked(); - expect( - screen.getByText("ProviderClaudeAllowReadingClaudeCodeCredentialsHelp"), - ).toBeInTheDocument(); - }); - - it("toggling the consent checkbox calls updateSettings and reflects the response", async () => { - tauriMocks.getSettingsSnapshot.mockResolvedValue(snapshot()); - tauriMocks.updateSettings.mockResolvedValue( - snapshot({ claudeAllowReadingClaudeCodeCredentials: true }), - ); - - render( key} />); - - const checkbox = await screen.findByRole("checkbox", { - name: "ProviderClaudeAllowReadingClaudeCodeCredentials ProviderClaudeAllowReadingClaudeCodeCredentialsHelp", - }); - - fireEvent.click(checkbox); - - await waitFor(() => - expect(tauriMocks.updateSettings).toHaveBeenCalledWith({ - claudeAllowReadingClaudeCodeCredentials: true, - }), - ); - await waitFor(() => expect(checkbox).toBeChecked()); - }); -}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeCreds.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeCreds.tsx deleted file mode 100644 index b930ce28cd..0000000000 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeCreds.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useEffect, useState } from "react"; -import type { LocaleKey } from "../../../../../i18n/keys"; -import { getSettingsSnapshot, updateSettings } from "../../../../../lib/tauri"; - -interface Props { - t: (key: LocaleKey) => string; -} - -/** - * Claude-specific credential/options. - * - * Port of the `ProviderId::Claude` branch of the "Options" block in - * `rust/src/native_ui/preferences.rs::render_provider_detail_panel`. - * Exposes "Avoid keychain prompts" and "Allow reading Claude Code's - * credentials" (OAuth consent gate added in 76d3f010 — this toggle is the UI - * surface for that setting). Usage-row visibility is handled by the shared - * provider detail visibility section. - * The broader `disable_keychain_access` master switch lives in Advanced. - */ -export function ClaudeCreds({ t }: Props) { - const [avoidKeychain, setAvoidKeychain] = useState(null); - const [allowReadingClaudeCodeCredentials, setAllowReadingClaudeCodeCredentials] = - useState(null); - const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); - - useEffect(() => { - let cancelled = false; - getSettingsSnapshot() - .then((s) => { - if (cancelled) return; - setAvoidKeychain(s.claudeAvoidKeychainPrompts); - setAllowReadingClaudeCodeCredentials( - s.claudeAllowReadingClaudeCodeCredentials ?? false, - ); - }) - .catch((e) => !cancelled && setError(String(e))); - return () => { - cancelled = true; - }; - }, []); - - const toggleAvoidKeychain = async (next: boolean) => { - setSaving(true); - try { - const updated = await updateSettings({ - claudeAvoidKeychainPrompts: next, - }); - setAvoidKeychain(updated.claudeAvoidKeychainPrompts); - } catch (e) { - setError(String(e)); - } finally { - setSaving(false); - } - }; - - const toggleAllowReadingClaudeCodeCredentials = async (next: boolean) => { - setSaving(true); - try { - const updated = await updateSettings({ - claudeAllowReadingClaudeCodeCredentials: next, - }); - setAllowReadingClaudeCodeCredentials( - updated.claudeAllowReadingClaudeCodeCredentials ?? next, - ); - } catch (e) { - setError(String(e)); - } finally { - setSaving(false); - } - }; - - if ( - avoidKeychain === null || - allowReadingClaudeCodeCredentials === null - ) - return null; - - return ( -
-

{t("CredentialsSectionTitle")}

- - - {error &&
{error}
} -
- ); -} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx index 7686bac465..23725650ec 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.test.tsx @@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({ claudeReconciliationState: vi.fn(), getSettingsSnapshot: vi.fn(), getCurrencyRates: vi.fn(), - updateSettings: vi.fn(), })); const events = vi.hoisted(() => ({ listen: vi.fn<(event: string, listener: () => void) => Promise<() => void>>(), @@ -69,10 +68,7 @@ describe("ClaudeSwapAccountsSection", () => { vi.resetAllMocks(); events.listen.mockResolvedValue(() => {}); mocks.claudeReconciliationState.mockResolvedValue(null); - mocks.getSettingsSnapshot.mockResolvedValue({ - claudeSwapEnabled: false, - claudeSwapExecutablePath: "", - }); + mocks.getSettingsSnapshot.mockResolvedValue({}); mocks.getCurrencyRates.mockResolvedValue({ rates: { USD: 1, TRY: 48.5 } }); mocks.claudeSwapAccountsList.mockResolvedValue({ enabled: false, @@ -82,30 +78,45 @@ describe("ClaudeSwapAccountsSection", () => { }); }); - it("shows the disabled status and persists an explicit opt-in", async () => { - render(); - await screen.findByText("ClaudeSwapTitle"); - expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(false); - mocks.updateSettings.mockResolvedValue(undefined); - mocks.claudeSwapAccountsList.mockResolvedValue( - enabledState([active, switchable]), + it("renders nothing while claude-swap is off and lists accounts once it is on", async () => { + const { container, rerender } = render( + , ); - await act(async () => fireEvent.click(screen.getByRole("checkbox"))); - expect(mocks.updateSettings).toHaveBeenCalledWith({ claudeSwapEnabled: true }); + await act(async () => {}); + expect(container.innerHTML).toBe(""); + expect(mocks.claudeSwapAccountsList).not.toHaveBeenCalled(); + + mocks.claudeSwapAccountsList.mockResolvedValue(enabledState([active, switchable])); + rerender(); + await screen.findByText("work@example.com"); + expect(screen.getByText("ClaudeSwapTitle")).toBeTruthy(); + expect(screen.getByText("ClaudeSwapStatusReady")).toBeTruthy(); + expect(screen.queryByRole("checkbox")).toBeNull(); + expect(screen.queryByRole("textbox")).toBeNull(); }); - it("switches only actionable inactive accounts and reports success", async () => { - mocks.getSettingsSnapshot.mockResolvedValue({ - claudeSwapEnabled: true, - claudeSwapExecutablePath: "~/bin/cswap", + it("reloads the list when the executable path changes", async () => { + mocks.claudeSwapAccountsList.mockResolvedValue({ + enabled: true, executableConfigured: false, accounts: [], error: null, }); + const { rerender } = render( + , + ); + await screen.findByText("ClaudeSwapStatusNoExecutable"); + mocks.claudeSwapAccountsList.mockResolvedValue(enabledState([active])); + rerender(); + await screen.findByText("work@example.com"); + expect(mocks.claudeSwapAccountsList).toHaveBeenCalledTimes(2); + }); + + it("switches only actionable inactive accounts and reports success", async () => { mocks.claudeSwapAccountsList.mockResolvedValue( enabledState([active, switchable, blocked]), ); mocks.claudeSwapAccountSwitch.mockResolvedValue({ generation: 1, status: "succeeded", providerRefreshGeneration: 1, detail: "published", }); - render(); + render(); await screen.findByText("work@example.com"); expect(screen.getByText("TokenAccountActive")).toBeTruthy(); @@ -121,10 +132,6 @@ describe("ClaudeSwapAccountsSection", () => { }); it("renders no action button when the adapter is read-only", async () => { - mocks.getSettingsSnapshot.mockResolvedValue({ - claudeSwapEnabled: true, - claudeSwapExecutablePath: "~/bin/cswap", - }); // Read-only adapters project `action: null` for every row, including // inactive rows that would otherwise be switchable. mocks.claudeSwapAccountsList.mockResolvedValue( @@ -134,7 +141,7 @@ describe("ClaudeSwapAccountsSection", () => { { ...active, id: "claude-swap:4", slot: 4, label: "foreign@example.com", status: "foreign_credential", action: null }, ]), ); - render(); + render(); await screen.findByText("work@example.com"); expect(screen.getByText("personal@example.com")).toBeTruthy(); @@ -146,51 +153,25 @@ describe("ClaudeSwapAccountsSection", () => { }); it("surfaces adapter errors without offering a switch", async () => { - mocks.getSettingsSnapshot.mockResolvedValue({ - claudeSwapEnabled: true, - claudeSwapExecutablePath: "~/bin/cswap", - }); mocks.claudeSwapAccountsList.mockResolvedValue({ enabled: true, executableConfigured: true, accounts: [], error: "claude-swap did not respond within 30 seconds.", }); - render(); + render(); await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("did not respond"), ); expect(screen.queryByText("ClaudeSwapSwitchButton")).toBeNull(); }); - it("allows retrying the same executable path after a failed save", async () => { - render(); - await act(async () => {}); - const input = screen.getByRole("textbox"); - mocks.updateSettings.mockRejectedValueOnce("Save failed."); - await act(async () => { - fireEvent.change(input, { target: { value: "C:/tools/cswap.exe" } }); - fireEvent.blur(input); - }); - expect(screen.getByRole("alert").textContent).toContain("Save failed."); - mocks.updateSettings.mockResolvedValueOnce(undefined); - await act(async () => fireEvent.blur(input)); - expect(mocks.updateSettings).toHaveBeenCalledTimes(2); - expect(mocks.updateSettings).toHaveBeenLastCalledWith({ - claudeSwapExecutablePath: "C:/tools/cswap.exe", - }); - }); - it("shows a switch failure without claiming success", async () => { - mocks.getSettingsSnapshot.mockResolvedValue({ - claudeSwapEnabled: true, - claudeSwapExecutablePath: "~/bin/cswap", - }); mocks.claudeSwapAccountsList.mockResolvedValue( enabledState([active, switchable]), ); mocks.claudeSwapAccountSwitch.mockRejectedValue("cswap failed."); - render(); + render(); await screen.findByText("personal@example.com"); await act(async () => fireEvent.click(screen.getByText("ClaudeSwapSwitchButton")), @@ -206,15 +187,11 @@ describe("ClaudeSwapAccountsSection", () => { action: "reauthenticate", error: "claude-swap reports the live credential belongs to a different account.", }; - mocks.getSettingsSnapshot.mockResolvedValue({ - claudeSwapEnabled: true, - claudeSwapExecutablePath: "~/bin/cswap", - }); mocks.claudeSwapAccountsList.mockResolvedValue(enabledState([foreign])); mocks.claudeSwapAccountReauthenticate.mockResolvedValue({ generation: 2, status: "succeeded", providerRefreshGeneration: 2, detail: "published", }); - render(); + render(); await screen.findByText("work@example.com"); await act(async () => fireEvent.click(screen.getByText("ClaudeSwapReauthenticateButton")), @@ -250,15 +227,11 @@ describe("ClaudeSwapAccountsSection", () => { provenance: "source_reported_last_good", }, }; - mocks.getSettingsSnapshot.mockResolvedValue({ - claudeSwapEnabled: true, - claudeSwapExecutablePath: "~/bin/cswap", - preferredCurrencyCode: "TRY", - }); + mocks.getSettingsSnapshot.mockResolvedValue({ preferredCurrencyCode: "TRY" }); mocks.claudeSwapAccountsList.mockResolvedValue(enabledState([account])); render( - + , ); await screen.findByText("personal@example.com"); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx index 22006be463..7461e3bfa8 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapAccountsSection.tsx @@ -12,8 +12,6 @@ import { claudeSwapAccountsList, claudeSwapAccountReauthenticate, claudeSwapAccountSwitch, - getSettingsSnapshot, - updateSettings, } from "../../../../../lib/tauri"; import { localClaudeReconciliationOutcome, @@ -24,6 +22,10 @@ import { useCurrency } from "../../../../../hooks/CurrencyProvider"; interface Props { t: (key: LocaleKey) => string; language?: Language; + /** Settings `claudeSwapEnabled`; the switch lives in Claude > Options. */ + enabled: boolean; + /** Settings `claudeSwapExecutablePath`; the field lives in Claude's Fields group. */ + executablePath: string; } const EMPTY_STATE: ClaudeSwapAccountsState = { @@ -99,13 +101,16 @@ function languageLocale(language: Language): string { * * This list is deliberately separate from the built-in saved Claude Code * accounts above: it is display + explicit activation only, is hidden while the - * integration is disabled or unconfigured, and CodexBar never reads or stores - * cswap credentials. + * integration is disabled, and CodexBar never reads or stores cswap credentials. + * The opt-in switch and executable path are settings rows elsewhere in the + * pane; this list reloads whenever either changes. */ -export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { - const [enabled, setEnabled] = useState(false); - const [executablePath, setExecutablePath] = useState(""); - const [pathDraft, setPathDraft] = useState(""); +export function ClaudeSwapAccountsSection({ + t, + language = "english", + enabled, + executablePath, +}: Props) { const [state, setState] = useState(EMPTY_STATE); const locale = languageLocale(language); const { format: formatCurrency } = useCurrency(); @@ -118,26 +123,19 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { const reload = useCallback(async () => { const next = await claudeSwapAccountsList(); - if (mounted.current) { - setState(next); - setEnabled(next.enabled); - } + if (mounted.current) setState(next); }, []); useEffect(() => { mounted.current = true; - void getSettingsSnapshot() - .then((settings) => { - if (!mounted.current) return; - setEnabled(settings.claudeSwapEnabled ?? false); - const path = settings.claudeSwapExecutablePath ?? ""; - setExecutablePath(path); - setPathDraft(path); - }) - .catch((e) => { - if (mounted.current) setError(String(e)); - }); + if (!enabled) { + setState(EMPTY_STATE); + return () => { + mounted.current = false; + }; + } const load = () => { + setError(null); void reload().catch((e) => { if (mounted.current) setError(String(e)); }); @@ -148,7 +146,7 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { mounted.current = false; void unlisten.then((fn) => fn()).catch(() => {}); }; - }, [reload]); + }, [enabled, executablePath, reload]); useEffect(() => { const outcome = localClaudeReconciliationOutcome(snapshot, operation?.generation ?? null); @@ -162,34 +160,6 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { } }, [operation, snapshot, t]); - const runSettings = async ( - patch: { claudeSwapEnabled?: boolean; claudeSwapExecutablePath?: string }, - ) => { - setBusy(true); - setError(null); - setMessage(null); - try { - await updateSettings(patch); - // Persist the accepted path in local state so a later blur with the same - // value is a no-op, while a failed save leaves the draft untouched and - // remains retryable. - if (mounted.current && patch.claudeSwapExecutablePath !== undefined) { - setExecutablePath(patch.claudeSwapExecutablePath); - } - await reload(); - } catch (e) { - if (mounted.current) setError(String(e)); - } finally { - if (mounted.current) setBusy(false); - } - }; - - const savePath = async () => { - const next = pathDraft.trim(); - if (next === executablePath.trim()) return; - await runSettings({ claudeSwapExecutablePath: next }); - }; - const runAccountAction = async (account: ClaudeSwapAccount) => { setBusy(true); setError(null); @@ -215,44 +185,16 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { } }; - const status = !enabled - ? t("ClaudeSwapStatusDisabled") - : !state.executableConfigured - ? t("ClaudeSwapStatusNoExecutable") - : t("ClaudeSwapStatusReady"); + if (!enabled) return null; + + const status = !state.executableConfigured + ? t("ClaudeSwapStatusNoExecutable") + : t("ClaudeSwapStatusReady"); return (

{t("ClaudeSwapTitle")}

-

{t("ClaudeSwapHint")}

- - - {enabled &&

{status}

} +

{status}

{reconciling &&

{t("ClaudeSwapReconciling")}

} {error && (
@@ -269,7 +211,7 @@ export function ClaudeSwapAccountsSection({ t, language = "english" }: Props) { {message}
)} - {enabled && state.executableConfigured && state.accounts.length === 0 && !state.error && ( + {state.executableConfigured && state.accounts.length === 0 && !state.error && (

{t("ClaudeSwapEmpty")}

)}
    diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapPathField.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapPathField.test.tsx new file mode 100644 index 0000000000..de9d497e9e --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapPathField.test.tsx @@ -0,0 +1,52 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ClaudeSwapPathField } from "./ClaudeSwapPathField"; + +const t = (key: string) => key; + +describe("ClaudeSwapPathField", () => { + it("saves a changed path on blur and on Enter, and skips unchanged values", async () => { + const onSettingsChange = vi.fn(); + render( + , + ); + const input = screen.getByRole("textbox", { name: "ClaudeSwapExecutablePath" }); + expect((input as HTMLInputElement).value).toBe("~/bin/cswap"); + await act(async () => fireEvent.blur(input)); + expect(onSettingsChange).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.change(input, { target: { value: " C:/tools/cswap.exe " } }); + fireEvent.keyDown(input, { key: "Enter" }); + }); + expect(onSettingsChange).toHaveBeenLastCalledWith({ + claudeSwapExecutablePath: "C:/tools/cswap.exe", + }); + }); + + it("allows retrying the same path while the saved value has not changed", async () => { + const onSettingsChange = vi.fn(); + render(); + const input = screen.getByRole("textbox"); + await act(async () => { + fireEvent.change(input, { target: { value: "C:/tools/cswap.exe" } }); + fireEvent.blur(input); + }); + // A failed save leaves `value` unchanged, so the next blur saves again. + await act(async () => fireEvent.blur(input)); + expect(onSettingsChange).toHaveBeenCalledTimes(2); + expect(onSettingsChange).toHaveBeenLastCalledWith({ + claudeSwapExecutablePath: "C:/tools/cswap.exe", + }); + }); + + it("follows the saved value when it changes elsewhere", () => { + const { rerender } = render( + {}} />, + ); + rerender( {}} />); + const input = screen.getByRole("textbox") as HTMLInputElement; + expect(input.value).toBe("b"); + expect(input.disabled).toBe(true); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapPathField.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapPathField.tsx new file mode 100644 index 0000000000..ecb16c985f --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/ClaudeSwapPathField.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import type { LocaleKey } from "../../../../../i18n/keys"; +import type { SettingsUpdate } from "../../../../../types/bridge"; +import { Field } from "../../../../../components/FormControls"; + +interface Props { + /** Saved `claudeSwapExecutablePath`. */ + value: string; + disabled: boolean; + t: (key: LocaleKey) => string; + onSettingsChange: (patch: SettingsUpdate) => void; +} + +/** + * claude-swap executable path (macOS Claude Fields row), shown only while + * "Read accounts from claude-swap" is on. Saves on blur or Enter. A failed save + * leaves the saved value unchanged, so the same draft stays retryable. + */ +export function ClaudeSwapPathField({ value, disabled, t, onSettingsChange }: Props) { + const [draft, setDraft] = useState(value); + useEffect(() => setDraft(value), [value]); + + const save = () => { + const next = draft.trim(); + if (next === value.trim()) return; + onSettingsChange({ claudeSwapExecutablePath: next }); + }; + + return ( + + setDraft(e.target.value)} + onBlur={save} + onKeyDown={(e) => { + if (e.key === "Enter") save(); + }} + /> + + ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx index 565bc3fe16..26e298c501 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx @@ -11,17 +11,11 @@ interface Props { } /** - * OpenAI/Codex-specific detail help. - * - * Port of the help strings below the `ProviderId::Codex` toggles in - * `rust/src/native_ui/preferences.rs::render_provider_detail_panel` (~6625). - * The toggles themselves (`codex_historical_tracking`, - * `codex_openai_web_extras`) are not yet persisted through - * `update_settings` in the Tauri bridge, so this component shows the - * upstream hint copy only. The toggles will be surfaced once they join - * the SettingsUpdate bridge (tracked alongside Phase 6e token-accounts). + * Workspace, organization or base-URL field for API providers that need one + * next to their key (OpenAI API project ID, LiteLLM base URL, and so on). + * Renders nothing for other providers. */ -export function OpenAiExtras({ providerId = "codex", t }: Props) { +export function OpenAiExtras({ providerId = "openaiapi", t }: Props) { const [projectId, setProjectId] = useState(""); const [savedProjectId, setSavedProjectId] = useState(""); const [busy, setBusy] = useState(false); @@ -94,17 +88,7 @@ export function OpenAiExtras({ providerId = "codex", t }: Props) { ); } - return ( -
    -

    {t("CredentialsSectionTitle")}

    -
    - {t("ProviderCodexHistoryHelp")} -
    -
    - {t("CredsOpenAiHistoryHelp")} -
    -
    - ); + return null; } const WORKSPACE_EXTRA_IDS: Record = { diff --git a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts index f0a4b189ab..7c75c61d4f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts +++ b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.test.ts @@ -11,6 +11,7 @@ import { parseSettingsTabRequest, providersTabRequest, sortProvidersAlphabetically, + nextEnabledProviders, } from "./settingsTabs"; describe("isSettingsTab", () => { @@ -182,3 +183,21 @@ describe("canReorderProviders", () => { expect(canReorderProviders(false, "cl")).toBe(false); }); }); + +describe("nextEnabledProviders", () => { + const catalog = [{ id: "codex" }, { id: "claude" }, { id: "cursor" }]; + + it("adds a provider in catalog order", () => { + expect(nextEnabledProviders(catalog, ["cursor", "codex"], "claude", true)).toEqual([ + "codex", + "claude", + "cursor", + ]); + }); + + it("removes a provider and drops ids missing from the catalog", () => { + expect(nextEnabledProviders(catalog, ["codex", "gone", "cursor"], "codex", false)).toEqual([ + "cursor", + ]); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts index 65e80808f5..807161cc15 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts +++ b/apps/desktop-tauri/src/surfaces/settings/settingsTabs.ts @@ -112,3 +112,19 @@ export function sortProvidersAlphabetically( export function canReorderProviders(sortedAlphabetically: boolean, query: string): boolean { return !sortedAlphabetically && query.trim() === ""; } + +/** + * The enabled provider ids after switching `id` on or off, in catalog order. + * Shared by the sidebar row switches and the provider header switch. + */ +export function nextEnabledProviders( + catalog: readonly { id: string }[], + enabled: Iterable, + id: string, + on: boolean, +): string[] { + const next = new Set(enabled); + if (on) next.add(id); + else next.delete(id); + return catalog.filter((p) => next.has(p.id)).map((p) => p.id); +} 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 d5d21f0ceb..f39085e4db 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -229,12 +229,13 @@ describe("AdvancedTab Mac grouped layout", () => { } }); - it("locks the Claude keychain-prompt option while Credential Manager access is off", () => { + it("shows the credential-access footer while Credential Manager access is off", () => { const set = vi.fn(); const { rerender } = render( , ); - expect(screen.getByRole("checkbox", { name: "AvoidKeychainPromptsLabel" })).toBeEnabled(); + // The Claude keychain-prompt option lives in Providers > Claude > Options (Mac parity). + expect(screen.queryByRole("checkbox", { name: "AvoidKeychainPromptsLabel" })).toBeNull(); expect(screen.queryByText("CredentialAccessFooter")).toBeNull(); fireEvent.click(screen.getByRole("checkbox", { name: "DisableAllKeychainLabel" })); @@ -247,7 +248,6 @@ describe("AdvancedTab Mac grouped layout", () => { saving={false} />, ); - expect(screen.getByRole("checkbox", { name: "AvoidKeychainPromptsLabel" })).toBeDisabled(); expect(screen.getByText("CredentialAccessFooter")).toBeInTheDocument(); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx index 96f213bc9f..9c9a589bf9 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx @@ -110,18 +110,6 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { onChange={(v) => set({ disableKeychainAccess: v })} /> - - set({ claudeAvoidKeychainPrompts: v })} - /> - Providers detail (Mac parity) + ProviderVisibleUsageItems, + ProviderVisibleUsageItemsFooter, + ProviderRestoreDefaults, + ProviderLastFetchFailedTitle, + ProviderShowDetails, + ProviderHideDetails, + ProviderSectionConnection, + QuotaWarningsTitle, + QuotaWarningGlobal, + QuotaWarningCustom, + QuotaWarningOff, + QuotaWarningWarning, + QuotaWarningCritical, + QuotaWarningInherited, + QuotaWarningProviderInherits, + QuotaWarningProviderMarkersOnly, + QuotaWarningProviderDisabled, + ProviderCodexLocalCostEstimates, + ProviderCodexLocalCostEstimatesHelp, + ProviderCodexHistoricalTracking, + ProviderCodexOpenAiWebExtras, + ProviderCodexOpenAiWebExtrasHelp, + ProviderCodexExternalOauth, + ProviderCodexExternalOauthHelp, + ProviderCodexBatterySaver, + ProviderCodexBatterySaverHelp, + ProviderOptionNotUsedOnWindows, + ClaudeSwapExecutablePathHelp, } #[cfg(test)] diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index af3d44c708..14c7fcb0ff 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -517,8 +517,6 @@ SessionQuotaNotificationsHelper = Notifies when a provider's session quota hits WarningAtPercentUsed = Warning at % used CriticalAtPercentUsed = Critical at % used ThresholdsCaption = Used percentages for session and weekly windows unless a provider overrides them. -ThresholdOverridesTitle = Provider thresholds -ThresholdOverridesCaption = Codex and Claude can use their own warning and critical % used. Leave a field empty to use the value above it. SectionUsageThresholds = Usage Thresholds SectionKeyboard = Keyboard shortcut SectionUsageRendering = Usage rendering @@ -617,8 +615,6 @@ CodexLocalLogsTitle = Codex local logs CodexLocalLogsCaption = Add Codex home or sessions directories for local cost estimates. Separate multiple paths with semicolons. CodexLogPathsLabel = Codex log paths CodexLogPathsHelper = A .codex root is accepted; it will scan the sessions folder inside it. -AvoidKeychainPromptsLabel = Avoid keychain prompts (Claude) -AvoidKeychainPromptsHelper = Not enforced on Windows yet: Claude credentials are still read as usual. The choice is saved for when it ships. DisableAllKeychainLabel = Disable Credential Manager access DisableAllKeychainHelper = Not enforced on Windows yet: CodexBar still uses Credential Manager, DPAPI and browser cookies. The choice is saved for when blocking ships. CredentialAccessFooter = While Credential Manager access is disabled, the Claude keychain-prompt option stays locked. @@ -977,8 +973,8 @@ CostSummaryStyleCompact = Compact CostSummaryStyleDetailed = Detailed CostSummaryStyleHidden = Hidden ProviderAccentColor = Accent color -ProviderAccentColorHelper = Override the brand color used for usage bars and charts. Enter a hex color like #FF5733. -ProviderAccentColorReset = Reset to default +ProviderAccentColorHelper = Sets the color of this provider's usage bars and charts. +ProviderAccentColorReset = Use default ProviderAccentColorInvalid = Invalid hex color. Use #RRGGBB format, e.g. #FF5733. ProviderAutoResumeTitle = Automatic session resume @@ -1028,7 +1024,7 @@ ClaudeSwapEnableHelp = Disabled by default. Requires an installed cswap (github. ClaudeSwapExecutablePath = claude-swap executable ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = claude-swap integration is off. -ClaudeSwapStatusNoExecutable = Set the cswap executable path below. +ClaudeSwapStatusNoExecutable = Set the cswap executable path to list accounts. ClaudeSwapStatusReady = External accounts reported by claude-swap. ClaudeSwapEmpty = claude-swap reported no accounts. ClaudeSwapSwitchButton = Switch via claude-swap @@ -1533,3 +1529,33 @@ UsageSpendTrackCosts = Track costs UsageSpendTrackCostsHelper = Scan local logs to estimate spend. Off also hides local cost estimates on cards and the float bar. UsageSpendTrackingOffTitle = Cost tracking is off UsageSpendTrackingOffBody = Turn on Track costs below to scan local logs and estimate spend. + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = Visible usage items +ProviderVisibleUsageItemsFooter = Choose which usage rows appear in this provider's tray card and Overview. +ProviderRestoreDefaults = Restore Defaults +ProviderLastFetchFailedTitle = Last { "{}" } fetch failed: +ProviderShowDetails = Show details +ProviderHideDetails = Hide details +ProviderSectionConnection = Connection +QuotaWarningsTitle = Quota warnings +QuotaWarningGlobal = Global +QuotaWarningCustom = Custom +QuotaWarningOff = Off +QuotaWarningWarning = Warning +QuotaWarningCritical = Critical +QuotaWarningInherited = Inherited: Warning { "{}" }% used, Critical { "{}" }% used +QuotaWarningProviderInherits = Uses the global quota warning settings unless a window is customized here. +QuotaWarningProviderMarkersOnly = Quota warning notifications are disabled globally. These settings still control usage-bar markers. +QuotaWarningProviderDisabled = Quota warning notifications and usage-bar markers are disabled. Enable either to edit these saved settings. +ProviderCodexLocalCostEstimates = Local session cost estimates +ProviderCodexLocalCostEstimatesHelp = Uses this PC's Codex sessions instead of the selected managed account's session history. Works with organization API keys and does not require OpenAI billing or administrator access. Uses locally cached or bundled model prices without making a network request. This provider-specific toggle does not enable cost summaries for other providers. +ProviderCodexHistoricalTracking = Historical tracking +ProviderCodexOpenAiWebExtras = OpenAI web extras +ProviderCodexOpenAiWebExtrasHelp = Optional. Turn this on to show code review, usage breakdown, and credits history via chatgpt.com. +ProviderCodexExternalOauth = External Codex OAuth sources +ProviderCodexExternalOauthHelp = Explicitly allow read-only fallback to legacy Codex and OpenCode OAuth files. CodexBar never refreshes or writes those external credentials. Off by default because this shares another app's OAuth session with Codex usage requests. +ProviderCodexBatterySaver = OpenAI web battery saver +ProviderCodexBatterySaverHelp = Limits background chatgpt.com refreshes to reduce battery and network usage. Dashboard extras may stay stale until you refresh them manually. +ProviderOptionNotUsedOnWindows = Not used on Windows yet; the choice is saved for when it ships. +ClaudeSwapExecutablePathHelp = Path to the cswap executable (github.com/realiti4/claude-swap). diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 4faf351baa..16a11b2c0a 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -490,8 +490,6 @@ SessionQuotaNotificationsHelper = Avisa cuando la cuota de sesión de un proveed WarningAtPercentUsed = Advertencia al % usado CriticalAtPercentUsed = Crítico al % usado ThresholdsCaption = Porcentajes usados para las ventanas de sesión y semanales, salvo que un proveedor los reemplace. -ThresholdOverridesTitle = Umbrales por proveedor -ThresholdOverridesCaption = Codex y Claude pueden usar su propio % usado de advertencia y crítico. Deja un campo vacío para usar el valor superior. SectionUsageThresholds = Umbrales de uso SectionKeyboard = Teclado SectionUsageRendering = Representación de uso @@ -635,8 +633,6 @@ CodexLocalLogsTitle = Logs locales de Codex CodexLocalLogsCaption = Agrega directorios home o sessions de Codex para estimaciones de costo locales. Separa varias rutas con punto y coma. CodexLogPathsLabel = Rutas de logs de Codex CodexLogPathsHelper = Se acepta una raíz .codex; se escaneará la carpeta sessions dentro de ella. -AvoidKeychainPromptsLabel = Evitar avisos de llavero (Claude) -AvoidKeychainPromptsHelper = Aún no se aplica en Windows: las credenciales de Claude se siguen leyendo como siempre. La opción se guarda para cuando esté disponible. DisableAllKeychainLabel = Deshabilitar el acceso al Administrador de credenciales DisableAllKeychainHelper = Aún no se aplica en Windows: CodexBar sigue usando el Administrador de credenciales, DPAPI y las cookies del navegador. La opción se guarda para cuando llegue el bloqueo. CredentialAccessFooter = Mientras el acceso al Administrador de credenciales esté deshabilitado, la opción de avisos del llavero de Claude queda bloqueada. @@ -976,8 +972,8 @@ CostSummaryStyleCompact = Compacto CostSummaryStyleDetailed = Detallado CostSummaryStyleHidden = Oculto ProviderAccentColor = Color de acento -ProviderAccentColorHelper = Anula el color de marca usado en barras de uso y gráficos. Introduce un color hexadecimal como #FF5733. -ProviderAccentColorReset = Restablecer predeterminado +ProviderAccentColorHelper = Define el color de las barras de uso y los gráficos de este proveedor. +ProviderAccentColorReset = Usar predeterminado ProviderAccentColorInvalid = Color hexadecimal no válido. Usa el formato #RRGGBB, por ejemplo #FF5733. ProviderAutoResumeTitle = Reanudación automática de sesión ProviderAutoResumeAfterQuotaReset = Reabrir la sesión de CLI cuando se reinicie la cuota @@ -1025,7 +1021,7 @@ ClaudeSwapEnableHelp = Desactivado por defecto. Requiere cswap instalado (github ClaudeSwapExecutablePath = Ejecutable de claude-swap ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = La integración con claude-swap está desactivada. -ClaudeSwapStatusNoExecutable = Define abajo la ruta del ejecutable cswap. +ClaudeSwapStatusNoExecutable = Define la ruta del ejecutable cswap para ver las cuentas. ClaudeSwapStatusReady = Cuentas externas reportadas por claude-swap. ClaudeSwapEmpty = claude-swap no reportó cuentas. ClaudeSwapSwitchButton = Cambiar con claude-swap @@ -1530,3 +1526,33 @@ UsageSpendTrackCosts = Registrar costos UsageSpendTrackCostsHelper = Analiza los registros locales para estimar el gasto. Desactivado también oculta los costos locales estimados en las tarjetas y la barra flotante. UsageSpendTrackingOffTitle = El registro de costos está desactivado UsageSpendTrackingOffBody = Activa Registrar costos abajo para analizar los registros locales y estimar el gasto. + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = Elementos de uso visibles +ProviderVisibleUsageItemsFooter = Elige qué filas de uso aparecen en la tarjeta de la bandeja de este proveedor y en Overview. +ProviderRestoreDefaults = Restaurar valores predeterminados +ProviderLastFetchFailedTitle = Falló la última consulta de { "{}" }: +ProviderShowDetails = Mostrar detalles +ProviderHideDetails = Ocultar detalles +ProviderSectionConnection = Conexión +QuotaWarningsTitle = Avisos de cuota +QuotaWarningGlobal = Global +QuotaWarningCustom = Personalizado +QuotaWarningOff = Desactivado +QuotaWarningWarning = Aviso +QuotaWarningCritical = Crítico +QuotaWarningInherited = Heredado: aviso al { "{}" }% usado, crítico al { "{}" }% usado +QuotaWarningProviderInherits = Usa la configuración global de avisos de cuota, salvo que personalices una ventana aquí. +QuotaWarningProviderMarkersOnly = Las notificaciones de avisos de cuota están desactivadas globalmente. Esta configuración sigue controlando los marcadores de las barras de uso. +QuotaWarningProviderDisabled = Las notificaciones de avisos de cuota y los marcadores de las barras de uso están desactivados. Activa alguno para editar esta configuración guardada. +ProviderCodexLocalCostEstimates = Estimaciones de costo de sesiones locales +ProviderCodexLocalCostEstimatesHelp = Usa las sesiones de Codex de este equipo en lugar del historial de sesiones de la cuenta administrada seleccionada. Funciona con claves de API de organización y no requiere facturación de OpenAI ni acceso de administrador. Usa precios de modelos guardados en caché o incluidos sin hacer solicitudes de red. Este interruptor solo afecta a este proveedor y no activa resúmenes de costos para otros. +ProviderCodexHistoricalTracking = Seguimiento histórico +ProviderCodexOpenAiWebExtras = Extras web de OpenAI +ProviderCodexOpenAiWebExtrasHelp = Opcional. Actívalo para mostrar la revisión de código, el desglose de uso y el historial de créditos desde chatgpt.com. +ProviderCodexExternalOauth = Fuentes OAuth externas de Codex +ProviderCodexExternalOauthHelp = Permite de forma explícita usar como respaldo, en solo lectura, los archivos OAuth heredados de Codex y OpenCode. CodexBar nunca actualiza ni escribe esas credenciales externas. Desactivado de forma predeterminada porque comparte la sesión OAuth de otra app con las solicitudes de uso de Codex. +ProviderCodexBatterySaver = Ahorro de batería web de OpenAI +ProviderCodexBatterySaverHelp = Limita las actualizaciones de chatgpt.com en segundo plano para reducir el uso de batería y red. Los extras del panel pueden quedar desactualizados hasta que los actualices manualmente. +ProviderOptionNotUsedOnWindows = Aún no se usa en Windows; la elección se guarda para cuando esté disponible. +ClaudeSwapExecutablePathHelp = Ruta al ejecutable cswap (github.com/realiti4/claude-swap). diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 298eb546f8..6ccdb78f6b 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -490,8 +490,6 @@ SessionQuotaNotificationsHelper = プロバイダーのセッションクォー WarningAtPercentUsed = 警告(使用率 %) CriticalAtPercentUsed = 重大(使用率 %) ThresholdsCaption = プロバイダーで上書きしない限り、セッションと週間の枠に使う使用率です。 -ThresholdOverridesTitle = プロバイダー別のしきい値 -ThresholdOverridesCaption = Codex と Claude は独自の警告・重大の使用率を設定できます。空欄にすると上位の値を使います。 SectionUsageThresholds = 使用量しきい値 SectionKeyboard = キーボード SectionUsageRendering = 使用量の表示 @@ -590,8 +588,6 @@ CodexLocalLogsTitle = Codex ローカルログ CodexLocalLogsCaption = ローカルのコスト見積もりに使う Codex のホームまたは sessions ディレクトリを追加します。複数のパスはセミコロンで区切ってください。 CodexLogPathsLabel = Codex ログパス CodexLogPathsHelper = .codex ルートも指定できます。その中の sessions フォルダをスキャンします。 -AvoidKeychainPromptsLabel = キーチェーン要求を出さない(Claude) -AvoidKeychainPromptsHelper = Windows ではまだ適用されません。Claude の資格情報は通常どおり読み取られます。この選択は機能の提供に備えて保存されます。 DisableAllKeychainLabel = 資格情報マネージャーへのアクセスを無効化 DisableAllKeychainHelper = Windows ではまだ適用されません。CodexBar は引き続き資格情報マネージャー、DPAPI、ブラウザーの Cookie を使います。この選択はブロック機能の提供に備えて保存されます。 CredentialAccessFooter = 資格情報マネージャーへのアクセスを無効にしている間、Claude のキーチェーンプロンプトのオプションはロックされます。 @@ -977,8 +973,8 @@ CostSummaryStyleCompact = コンパクト CostSummaryStyleDetailed = 詳細 CostSummaryStyleHidden = 非表示 ProviderAccentColor = アクセントカラー -ProviderAccentColorHelper = 使用量バーとチャートのブランドカラーを上書きします。#FF5733 のような 16 進数カラーを入力。 -ProviderAccentColorReset = デフォルトにリセット +ProviderAccentColorHelper = このプロバイダーの使用量バーとチャートの色を設定します。 +ProviderAccentColorReset = デフォルトを使用 ProviderAccentColorInvalid = 無効な 16 進数カラー。#RRGGBB 形式(例: #FF5733)を使用してください。 ProviderAutoResumeTitle = セッションの自動再開 ProviderAutoResumeAfterQuotaReset = クォータのリセット時に CLI セッションを再度開く @@ -1026,7 +1022,7 @@ ClaudeSwapEnableHelp = 既定では無効です。cswap (github.com/realiti4/cla ClaudeSwapExecutablePath = claude-swap 実行ファイル ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = claude-swap 連携はオフです。 -ClaudeSwapStatusNoExecutable = 下で cswap 実行ファイルのパスを設定してください。 +ClaudeSwapStatusNoExecutable = アカウントを表示するには cswap 実行ファイルのパスを設定してください。 ClaudeSwapStatusReady = claude-swap が報告した外部アカウント。 ClaudeSwapEmpty = claude-swap がアカウントを報告しませんでした。 ClaudeSwapSwitchButton = claude-swap で切り替え @@ -1531,3 +1527,33 @@ UsageSpendTrackCosts = コストを記録 UsageSpendTrackCostsHelper = ローカルログを読み取ってコストを推定します。オフにするとカードとフローティングバーのローカル推定コストも非表示になります。 UsageSpendTrackingOffTitle = コスト記録はオフです UsageSpendTrackingOffBody = 下の「コストを記録」をオンにすると、ローカルログを読み取ってコストを推定します。 + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = 表示する使用量項目 +ProviderVisibleUsageItemsFooter = このプロバイダーのトレイカードと Overview に表示する使用量の行を選びます。 +ProviderRestoreDefaults = デフォルトに戻す +ProviderLastFetchFailedTitle = 前回の { "{}" } の取得に失敗しました: +ProviderShowDetails = 詳細を表示 +ProviderHideDetails = 詳細を隠す +ProviderSectionConnection = 接続 +QuotaWarningsTitle = クォータ警告 +QuotaWarningGlobal = グローバル +QuotaWarningCustom = カスタム +QuotaWarningOff = オフ +QuotaWarningWarning = 警告 +QuotaWarningCritical = 重大 +QuotaWarningInherited = 継承: 警告 { "{}" }% 使用、重大 { "{}" }% 使用 +QuotaWarningProviderInherits = ここでウィンドウをカスタマイズしない限り、グローバルのクォータ警告設定を使います。 +QuotaWarningProviderMarkersOnly = クォータ警告の通知はグローバルで無効です。これらの設定は使用量バーのマーカーを引き続き制御します。 +QuotaWarningProviderDisabled = クォータ警告の通知と使用量バーのマーカーは無効です。保存済みの設定を編集するには、どちらかを有効にしてください。 +ProviderCodexLocalCostEstimates = ローカルセッションのコスト見積もり +ProviderCodexLocalCostEstimatesHelp = 選択した管理アカウントのセッション履歴の代わりに、この PC の Codex セッションを使います。組織の API キーで動作し、OpenAI の請求や管理者アクセスは不要です。ネットワーク要求を行わず、ローカルにキャッシュまたは同梱されたモデル価格を使います。このプロバイダー専用の設定は、他のプロバイダーのコスト概要を有効にしません。 +ProviderCodexHistoricalTracking = 履歴の記録 +ProviderCodexOpenAiWebExtras = OpenAI Web の追加機能 +ProviderCodexOpenAiWebExtrasHelp = 任意。オンにすると、chatgpt.com 経由でコードレビュー、使用量の内訳、クレジット履歴を表示します。 +ProviderCodexExternalOauth = 外部の Codex OAuth ソース +ProviderCodexExternalOauthHelp = 従来の Codex と OpenCode の OAuth ファイルへの読み取り専用フォールバックを明示的に許可します。CodexBar はこれらの外部認証情報を更新したり書き込んだりしません。別のアプリの OAuth セッションを Codex の使用量要求と共有するため、既定ではオフです。 +ProviderCodexBatterySaver = OpenAI Web の省電力 +ProviderCodexBatterySaverHelp = chatgpt.com のバックグラウンド更新を制限して、バッテリーとネットワークの使用量を減らします。ダッシュボードの追加情報は手動で更新するまで古いままの場合があります。 +ProviderOptionNotUsedOnWindows = Windows ではまだ使われません。選択内容は対応時のために保存されます。 +ClaudeSwapExecutablePathHelp = cswap 実行ファイルのパス (github.com/realiti4/claude-swap)。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 959e2a6cd0..8e0070661a 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -490,8 +490,6 @@ SessionQuotaNotificationsHelper = 제공업체의 세션 할당량이 0% 남았 WarningAtPercentUsed = 경고 (사용률 %) CriticalAtPercentUsed = 위험 (사용률 %) ThresholdsCaption = 제공업체가 재정의하지 않는 한 세션 및 주간 창에 쓰이는 사용률입니다. -ThresholdOverridesTitle = 제공업체별 임계값 -ThresholdOverridesCaption = Codex와 Claude는 자체 경고 및 위험 사용률을 쓸 수 있습니다. 비워 두면 상위 값을 사용합니다. SectionUsageThresholds = 사용량 임계값 SectionKeyboard = 키보드 SectionUsageRendering = 사용량 렌더링 @@ -635,8 +633,6 @@ CodexLocalLogsTitle = Codex 로컬 로그 CodexLocalLogsCaption = 로컬 비용 추정을 위해 Codex 홈 또는 sessions 디렉터리를 추가합니다. 여러 경로는 세미콜론으로 구분하세요. CodexLogPathsLabel = Codex 로그 경로 CodexLogPathsHelper = .codex 루트도 사용할 수 있으며, 그 안의 sessions 폴더를 스캔합니다. -AvoidKeychainPromptsLabel = 키체인 창 표시 방지 (Claude) -AvoidKeychainPromptsHelper = 아직 Windows에서 적용되지 않습니다. Claude 자격 증명은 평소처럼 읽습니다. 이 선택은 기능이 나올 때를 위해 저장됩니다. DisableAllKeychainLabel = 자격 증명 관리자 액세스 비활성화 DisableAllKeychainHelper = 아직 Windows에서 적용되지 않습니다. CodexBar는 계속 자격 증명 관리자, DPAPI 및 브라우저 쿠키를 사용합니다. 이 선택은 차단 기능이 나올 때를 위해 저장됩니다. CredentialAccessFooter = 자격 증명 관리자 액세스가 비활성화된 동안 Claude 키체인 프롬프트 옵션은 잠겨 있습니다. @@ -976,8 +972,8 @@ CostSummaryStyleCompact = 간결 CostSummaryStyleDetailed = 세부 CostSummaryStyleHidden = 숨김 ProviderAccentColor = 강조 색상 -ProviderAccentColorHelper = 사용량 막대와 차트에 사용되는 브랜드 색상을 재정의합니다. #FF5733 같은 16진수 색상을 입력하세요. -ProviderAccentColorReset = 기본값으로 재설정 +ProviderAccentColorHelper = 이 공급자의 사용량 막대와 차트 색상을 설정합니다. +ProviderAccentColorReset = 기본값 사용 ProviderAccentColorInvalid = 잘못된 16진수 색상입니다. #RRGGBB 형식(예: #FF5733)을 사용하세요. ProviderAutoResumeTitle = 세션 자동 재개 ProviderAutoResumeAfterQuotaReset = 할당량이 초기화되면 CLI 세션 다시 열기 @@ -1025,7 +1021,7 @@ ClaudeSwapEnableHelp = 기본값은 꺼짐입니다. cswap(github.com/realiti4/c ClaudeSwapExecutablePath = claude-swap 실행 파일 ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = claude-swap 연동이 꺼져 있습니다. -ClaudeSwapStatusNoExecutable = 아래에 cswap 실행 파일 경로를 설정하세요. +ClaudeSwapStatusNoExecutable = 계정을 표시하려면 cswap 실행 파일 경로를 설정하세요. ClaudeSwapStatusReady = claude-swap이 보고한 외부 계정입니다. ClaudeSwapEmpty = claude-swap이 보고한 계정이 없습니다. ClaudeSwapSwitchButton = claude-swap으로 전환 @@ -1530,3 +1526,33 @@ UsageSpendTrackCosts = 비용 추적 UsageSpendTrackCostsHelper = 로컬 로그를 검사해 비용을 추정합니다. 끄면 카드와 플로팅 바의 로컬 예상 비용도 숨겨집니다. UsageSpendTrackingOffTitle = 비용 추적이 꺼져 있습니다 UsageSpendTrackingOffBody = 아래에서 비용 추적을 켜면 로컬 로그를 검사해 비용을 추정합니다. + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = 표시할 사용량 항목 +ProviderVisibleUsageItemsFooter = 이 공급자의 트레이 카드와 Overview에 표시할 사용량 행을 선택합니다. +ProviderRestoreDefaults = 기본값 복원 +ProviderLastFetchFailedTitle = 마지막 { "{}" } 가져오기 실패: +ProviderShowDetails = 세부 정보 보기 +ProviderHideDetails = 세부 정보 숨기기 +ProviderSectionConnection = 연결 +QuotaWarningsTitle = 할당량 경고 +QuotaWarningGlobal = 전역 +QuotaWarningCustom = 사용자 지정 +QuotaWarningOff = 끄기 +QuotaWarningWarning = 경고 +QuotaWarningCritical = 위험 +QuotaWarningInherited = 상속됨: 경고 { "{}" }% 사용, 위험 { "{}" }% 사용 +QuotaWarningProviderInherits = 여기서 기간을 사용자 지정하지 않으면 전역 할당량 경고 설정을 사용합니다. +QuotaWarningProviderMarkersOnly = 할당량 경고 알림이 전역적으로 꺼져 있습니다. 이 설정은 여전히 사용량 막대 표시를 제어합니다. +QuotaWarningProviderDisabled = 할당량 경고 알림과 사용량 막대 표시가 꺼져 있습니다. 저장된 설정을 편집하려면 둘 중 하나를 켜세요. +ProviderCodexLocalCostEstimates = 로컬 세션 비용 추정 +ProviderCodexLocalCostEstimatesHelp = 선택한 관리 계정의 세션 기록 대신 이 PC의 Codex 세션을 사용합니다. 조직 API 키로 작동하며 OpenAI 결제나 관리자 권한이 필요하지 않습니다. 네트워크 요청 없이 로컬에 캐시되었거나 번들된 모델 가격을 사용합니다. 이 공급자 전용 설정은 다른 공급자의 비용 요약을 켜지 않습니다. +ProviderCodexHistoricalTracking = 기록 추적 +ProviderCodexOpenAiWebExtras = OpenAI 웹 추가 기능 +ProviderCodexOpenAiWebExtrasHelp = 선택 사항입니다. 켜면 chatgpt.com을 통해 코드 리뷰, 사용량 분석, 크레딧 기록을 표시합니다. +ProviderCodexExternalOauth = 외부 Codex OAuth 소스 +ProviderCodexExternalOauthHelp = 레거시 Codex 및 OpenCode OAuth 파일로의 읽기 전용 대체를 명시적으로 허용합니다. CodexBar는 이 외부 자격 증명을 새로 고치거나 쓰지 않습니다. 다른 앱의 OAuth 세션을 Codex 사용량 요청과 공유하므로 기본값은 꺼짐입니다. +ProviderCodexBatterySaver = OpenAI 웹 배터리 절약 +ProviderCodexBatterySaverHelp = chatgpt.com 백그라운드 새로 고침을 제한해 배터리와 네트워크 사용량을 줄입니다. 대시보드 추가 정보는 수동으로 새로 고칠 때까지 오래된 상태일 수 있습니다. +ProviderOptionNotUsedOnWindows = 아직 Windows에서는 사용되지 않습니다. 선택은 지원될 때를 위해 저장됩니다. +ClaudeSwapExecutablePathHelp = cswap 실행 파일 경로 (github.com/realiti4/claude-swap). diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index f3fb7b0e84..1adb8d5eb3 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -506,8 +506,6 @@ SessionQuotaNotificationsHelper = Avisa quando a cota de sessão de um provedor WarningAtPercentUsed = Aviso em % usado CriticalAtPercentUsed = Crítico em % usado ThresholdsCaption = Porcentagens usadas para as janelas de sessão e semanais, a menos que um provedor as substitua. -ThresholdOverridesTitle = Limites por provedor -ThresholdOverridesCaption = Codex e Claude podem usar seu próprio % usado de aviso e crítico. Deixe um campo vazio para usar o valor acima. SectionUsageThresholds = Limites de uso SectionKeyboard = Teclado SectionUsageRendering = Renderização de uso @@ -585,8 +583,6 @@ CodexLocalLogsTitle = Registros locais do Codex CodexLocalLogsCaption = Adicione diretórios iniciais ou de sessões do Codex para estimativas de custos locais. Separe vários caminhos com ponto e vírgula. CodexLogPathsLabel = Caminhos de log do Codex CodexLogPathsHelper = Uma raiz .codex é aceita; ele verificará a pasta de sessões dentro dela. -AvoidKeychainPromptsLabel = Evite avisos de chaveiro (Claude) -AvoidKeychainPromptsHelper = Ainda não é aplicado no Windows: as credenciais do Claude continuam sendo lidas normalmente. A escolha fica salva para quando o recurso chegar. DisableAllKeychainLabel = Desativar o acesso ao Gerenciador de Credenciais DisableAllKeychainHelper = Ainda não é aplicado no Windows: o CodexBar continua usando o Gerenciador de Credenciais, a DPAPI e os cookies do navegador. A escolha fica salva para quando o bloqueio chegar. CredentialAccessFooter = Enquanto o acesso ao Gerenciador de Credenciais estiver desativado, a opção de avisos de chaves do Claude fica bloqueada. @@ -926,8 +922,8 @@ CostSummaryStyleCompact = Compacto CostSummaryStyleDetailed = Detalhado CostSummaryStyleHidden = Oculto ProviderAccentColor = Cor de destaque -ProviderAccentColorHelper = Substitua a cor da marca usada nas barras e gráficos de uso. Insira uma cor hexadecimal como #FF5733. -ProviderAccentColorReset = Redefinir para o padrão +ProviderAccentColorHelper = Define a cor das barras de uso e dos gráficos deste provedor. +ProviderAccentColorReset = Usar padrão ProviderAccentColorInvalid = Cor hexadecimal inválida. Use o formato #RRGGBB, por ex. #FF5733. ProviderAutoResumeTitle = Retomada automática da sessão @@ -972,7 +968,7 @@ ClaudeSwapEnableHelp = Desativado por padrão. Requer um cswap instalado (github ClaudeSwapExecutablePath = Executável do claude-swap ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = a integração claude-swap está desativada. -ClaudeSwapStatusNoExecutable = Defina o caminho executável do cswap abaixo. +ClaudeSwapStatusNoExecutable = Defina o caminho do executável do cswap para listar as contas. ClaudeSwapStatusReady = Contas externas reportadas por Claude-swap. ClaudeSwapEmpty = claude-swap não relatou nenhuma conta. ClaudeSwapSwitchButton = Trocar via claude-swap @@ -1535,3 +1531,33 @@ UsageSpendTrackCosts = Rastrear custos UsageSpendTrackCostsHelper = Analisa os registros locais para estimar o gasto. Desativado também oculta os custos locais estimados nos cartões e na barra flutuante. UsageSpendTrackingOffTitle = O rastreamento de custos está desativado UsageSpendTrackingOffBody = Ative Rastrear custos abaixo para analisar os registros locais e estimar o gasto. + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = Itens de uso visíveis +ProviderVisibleUsageItemsFooter = Escolha quais linhas de uso aparecem no cartão da bandeja deste provedor e na visão geral. +ProviderRestoreDefaults = Restaurar padrões +ProviderLastFetchFailedTitle = A última busca de { "{}" } falhou: +ProviderShowDetails = Mostrar detalhes +ProviderHideDetails = Ocultar detalhes +ProviderSectionConnection = Conexão +QuotaWarningsTitle = Avisos de cota +QuotaWarningGlobal = Global +QuotaWarningCustom = Personalizado +QuotaWarningOff = Desligado +QuotaWarningWarning = Aviso +QuotaWarningCritical = Crítico +QuotaWarningInherited = Herdado: aviso em { "{}" }% usado, crítico em { "{}" }% usado +QuotaWarningProviderInherits = Usa as configurações globais de aviso de cota, a menos que uma janela seja personalizada aqui. +QuotaWarningProviderMarkersOnly = As notificações de aviso de cota estão desativadas globalmente. Estas configurações ainda controlam os marcadores das barras de uso. +QuotaWarningProviderDisabled = As notificações de aviso de cota e os marcadores das barras de uso estão desativados. Ative um deles para editar estas configurações salvas. +ProviderCodexLocalCostEstimates = Estimativas de custo de sessões locais +ProviderCodexLocalCostEstimatesHelp = Usa as sessões do Codex deste PC em vez do histórico de sessões da conta gerenciada selecionada. Funciona com chaves de API de organização e não exige faturamento da OpenAI nem acesso de administrador. Usa preços de modelos em cache local ou incluídos, sem fazer solicitações de rede. Esta opção vale só para este provedor e não ativa resumos de custo para outros. +ProviderCodexHistoricalTracking = Rastreamento histórico +ProviderCodexOpenAiWebExtras = Extras web da OpenAI +ProviderCodexOpenAiWebExtrasHelp = Opcional. Ative para mostrar revisão de código, detalhamento de uso e histórico de créditos via chatgpt.com. +ProviderCodexExternalOauth = Fontes OAuth externas do Codex +ProviderCodexExternalOauthHelp = Permite explicitamente o uso somente leitura, como alternativa, dos arquivos OAuth legados do Codex e do OpenCode. O CodexBar nunca atualiza nem grava essas credenciais externas. Desligado por padrão porque compartilha a sessão OAuth de outro app com as solicitações de uso do Codex. +ProviderCodexBatterySaver = Economia de bateria da web OpenAI +ProviderCodexBatterySaverHelp = Limita as atualizações em segundo plano do chatgpt.com para reduzir o uso de bateria e rede. Os extras do painel podem ficar desatualizados até você atualizá-los manualmente. +ProviderOptionNotUsedOnWindows = Ainda não é usado no Windows; a escolha fica salva para quando estiver disponível. +ClaudeSwapExecutablePathHelp = Caminho do executável cswap (github.com/realiti4/claude-swap). diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 69260b953a..3078c01e09 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -517,8 +517,6 @@ SessionQuotaNotificationsHelper = Сообщает, когда сессионн WarningAtPercentUsed = Предупреждение при % использования CriticalAtPercentUsed = Критично при % использования ThresholdsCaption = Процент использования для сессионных и недельных окон, если провайдер не задаёт свои значения. -ThresholdOverridesTitle = Пороги провайдеров -ThresholdOverridesCaption = Codex и Claude могут использовать свои пороги предупреждения и критичности. Оставьте поле пустым, чтобы взять значение уровнем выше. SectionUsageThresholds = Пороги использования SectionKeyboard = Клавиатура SectionUsageRendering = Рендеринг использования @@ -617,8 +615,6 @@ CodexLocalLogsTitle = Локальные журналы Codex CodexLocalLogsCaption = Добавьте домашний каталог Codex или каталоги сеансов для получения местной сметы затрат. Разделяйте несколько путей точкой с запятой. CodexLogPathsLabel = Пути журналов Codex CodexLogPathsHelper = Принимается корень .codex; он просканирует папку сеансов внутри него. -AvoidKeychainPromptsLabel = Избегайте подсказок хранилища учетных данных (Claude) -AvoidKeychainPromptsHelper = Пока не применяется в Windows: учётные данные Claude по-прежнему читаются как обычно. Выбор сохраняется до появления функции. DisableAllKeychainLabel = Отключить доступ к диспетчеру учётных данных DisableAllKeychainHelper = Пока не применяется в Windows: CodexBar по-прежнему использует диспетчер учётных данных, DPAPI и файлы cookie браузера. Выбор сохраняется до появления блокировки. CredentialAccessFooter = Пока доступ к диспетчеру учётных данных отключён, параметр запросов связки ключей для Claude заблокирован. @@ -977,8 +973,8 @@ CostSummaryStyleCompact = Компактный CostSummaryStyleDetailed = Подробный CostSummaryStyleHidden = Скрытый ProviderAccentColor = Акцентный цвет -ProviderAccentColorHelper = Переопределите фирменный цвет для полос использованя и графиков. Введите HEX-цвет, например #FF5733. -ProviderAccentColorReset = Сбросить по умолчанию +ProviderAccentColorHelper = Задаёт цвет полос использования и графиков этого провайдера. +ProviderAccentColorReset = По умолчанию ProviderAccentColorInvalid = Недопустимый HEX-цвет. Используйте формат #RRGGBB, например #FF5733. ProviderAutoResumeTitle = Автопродолжение сессии ProviderAutoResumeAfterQuotaReset = Открывать сессию CLI заново после сброса квоты @@ -1026,7 +1022,7 @@ ClaudeSwapEnableHelp = По умолчанию выключено. Нужен у ClaudeSwapExecutablePath = Путь к claude-swap ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = Интеграция с claude-swap выключена. -ClaudeSwapStatusNoExecutable = Укажите ниже путь к cswap. +ClaudeSwapStatusNoExecutable = Укажите путь к cswap, чтобы показать аккаунты. ClaudeSwapStatusReady = Внешние аккаунты из claude-swap. ClaudeSwapEmpty = claude-swap не вернул ни одного аккаунта. ClaudeSwapSwitchButton = Переключить через claude-swap @@ -1531,3 +1527,33 @@ UsageSpendTrackCosts = Отслеживать затраты UsageSpendTrackCostsHelper = Сканировать локальные журналы для оценки расходов. Если выключено, локальные расчетные затраты также скрываются на карточках и в плавающей панели. UsageSpendTrackingOffTitle = Отслеживание затрат выключено UsageSpendTrackingOffBody = Включите «Отслеживать затраты» ниже, чтобы сканировать локальные журналы и оценивать расходы. + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = Видимые строки использования +ProviderVisibleUsageItemsFooter = Выберите, какие строки использования показываются в карточке провайдера в трее и в обзоре. +ProviderRestoreDefaults = Восстановить по умолчанию +ProviderLastFetchFailedTitle = Последний запрос { "{}" } не удался: +ProviderShowDetails = Показать подробности +ProviderHideDetails = Скрыть подробности +ProviderSectionConnection = Подключение +QuotaWarningsTitle = Предупреждения о квоте +QuotaWarningGlobal = Общие +QuotaWarningCustom = Свои +QuotaWarningOff = Выкл. +QuotaWarningWarning = Предупреждение +QuotaWarningCritical = Критично +QuotaWarningInherited = Наследуется: предупреждение при { "{}" }% использования, критично при { "{}" }% +QuotaWarningProviderInherits = Используются общие настройки предупреждений о квоте, если окно не настроено здесь. +QuotaWarningProviderMarkersOnly = Уведомления о квоте отключены глобально. Эти настройки по-прежнему управляют отметками на полосах использования. +QuotaWarningProviderDisabled = Уведомления о квоте и отметки на полосах использования отключены. Включите что-то одно, чтобы изменить сохранённые настройки. +ProviderCodexLocalCostEstimates = Оценка стоимости локальных сеансов +ProviderCodexLocalCostEstimatesHelp = Использует сеансы Codex на этом ПК вместо истории сеансов выбранной управляемой учётной записи. Работает с API-ключами организации и не требует биллинга OpenAI или прав администратора. Использует локально кешированные или встроенные цены моделей без сетевых запросов. Этот переключатель действует только для этого провайдера и не включает сводки затрат для других. +ProviderCodexHistoricalTracking = Отслеживание истории +ProviderCodexOpenAiWebExtras = Веб-дополнения OpenAI +ProviderCodexOpenAiWebExtrasHelp = Необязательно. Включите, чтобы показывать проверку кода, разбивку использования и историю кредитов через chatgpt.com. +ProviderCodexExternalOauth = Внешние источники OAuth для Codex +ProviderCodexExternalOauthHelp = Явно разрешает резервное чтение (только чтение) устаревших OAuth-файлов Codex и OpenCode. CodexBar никогда не обновляет и не записывает эти внешние учётные данные. По умолчанию выключено, так как OAuth-сеанс другого приложения используется для запросов использования Codex. +ProviderCodexBatterySaver = Экономия заряда для веб-OpenAI +ProviderCodexBatterySaverHelp = Ограничивает фоновые обновления chatgpt.com, чтобы снизить расход батареи и трафика. Дополнения панели могут устаревать, пока вы не обновите их вручную. +ProviderOptionNotUsedOnWindows = Пока не используется в Windows; выбор сохраняется на будущее. +ClaudeSwapExecutablePathHelp = Путь к исполняемому файлу cswap (github.com/realiti4/claude-swap). diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 5c4c11b029..99bc5aff87 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -636,8 +636,8 @@ fn resource_key_names(resource: &str) -> HashSet<&str> { .collect() } -/// The Claude keychain-prompt option has no Windows consumer yet, so both of its -/// helpers (Advanced pane and Providers > Claude) must say so instead of +/// The Claude keychain-prompt option has no Windows consumer yet, so its helper +/// (Providers > Claude > Options) must say so instead of /// promising skipped credential reads or a macOS `/usr/bin/security` path. #[test] fn avoid_keychain_prompts_helper_says_it_is_not_enforced_on_windows() { @@ -685,11 +685,6 @@ fn avoid_keychain_prompts_helper_says_it_is_not_enforced_on_windows() { ]; assert_eq!(expected.len(), Language::all().len()); for (language, text) in expected { - assert_eq!( - get_text(language, LocaleKey::AvoidKeychainPromptsHelper), - text, - "{language:?}" - ); assert_eq!( get_text(language, LocaleKey::ProviderClaudeAvoidKeychainPromptsHelp), text, diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 3565591012..c5377a6640 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -517,8 +517,6 @@ SessionQuotaNotificationsHelper = Bir sağlayıcının oturum kotası %0 kaldı WarningAtPercentUsed = Uyarı (% kullanım) CriticalAtPercentUsed = Kritik (% kullanım) ThresholdsCaption = Bir sağlayıcı geçersiz kılmadıkça oturum ve haftalık pencereler için kullanım yüzdeleri. -ThresholdOverridesTitle = Sağlayıcı eşikleri -ThresholdOverridesCaption = Codex ve Claude kendi uyarı ve kritik kullanım yüzdelerini kullanabilir. Üstteki değeri kullanmak için alanı boş bırakın. SectionUsageThresholds = Kullanım Eşikleri SectionKeyboard = Klavye SectionUsageRendering = Kullanım gösterimi @@ -617,8 +615,6 @@ CodexLocalLogsTitle = Codex yerel günlükleri CodexLocalLogsCaption = Yerel maliyet tahminleri için Codex ana klasörlerini veya oturum dizinlerini ekleyin. Birden fazla yolu noktalı virgülle ayırın. CodexLogPathsLabel = Codex günlük yolları CodexLogPathsHelper = Bir .codex kökü kabul edilir; içindeki sessions klasörü taranır. -AvoidKeychainPromptsLabel = Anahtar zinciri istemlerinden kaçın (Claude) -AvoidKeychainPromptsHelper = Windows'ta henüz uygulanmıyor: Claude kimlik bilgileri her zamanki gibi okunur. Seçim, özellik geldiğinde kullanılmak üzere kaydedilir. DisableAllKeychainLabel = Kimlik Bilgileri Yöneticisi erişimini devre dışı bırak DisableAllKeychainHelper = Windows'ta henüz uygulanmıyor: CodexBar Kimlik Bilgileri Yöneticisi'ni, DPAPI'yi ve tarayıcı çerezlerini kullanmaya devam eder. Seçim, engelleme geldiğinde kullanılmak üzere kaydedilir. CredentialAccessFooter = Kimlik Bilgileri Yöneticisi erişimi kapalıyken Claude anahtar zinciri istemi seçeneği kilitli kalır. @@ -945,8 +941,8 @@ CostSummaryStyleCompact = Kompakt CostSummaryStyleDetailed = Ayrıntılı CostSummaryStyleHidden = Gizli ProviderAccentColor = Vurgu rengi -ProviderAccentColorHelper = Kullanım çubukları ve grafiklerde kullanılan marka rengini değiştirir. #FF5733 gibi bir onaltılık renk girin. -ProviderAccentColorReset = Varsayılana sıfırla +ProviderAccentColorHelper = Bu sağlayıcının kullanım çubuklarının ve grafiklerinin rengini ayarlar. +ProviderAccentColorReset = Varsayılanı kullan ProviderAccentColorInvalid = Geçersiz onaltılık renk. #RRGGBB biçimini kullanın, örn. #FF5733. ProviderAutoResumeTitle = Otomatik oturum devam ettirme ProviderAutoResumeAfterQuotaReset = Kota sıfırlanınca CLI oturumunu yeniden aç @@ -994,7 +990,7 @@ ClaudeSwapEnableHelp = Varsayılan olarak kapalıdır. Kurulu bir cswap gerekir ClaudeSwapExecutablePath = claude-swap yürütülebilir dosyası ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = claude-swap entegrasyonu kapalı. -ClaudeSwapStatusNoExecutable = Aşağıda cswap yürütülebilir dosya yolunu ayarlayın. +ClaudeSwapStatusNoExecutable = Hesapları listelemek için cswap yürütülebilir dosya yolunu ayarlayın. ClaudeSwapStatusReady = claude-swap tarafından bildirilen harici hesaplar. ClaudeSwapEmpty = claude-swap hesap bildirmedi. ClaudeSwapSwitchButton = claude-swap ile değiştir @@ -1531,3 +1527,33 @@ UsageSpendTrackCosts = Maliyetleri izle UsageSpendTrackCostsHelper = Harcamayı tahmin etmek için yerel günlükleri tarar. Kapalıyken kartlardaki ve kayan çubuktaki yerel tahmini maliyetler de gizlenir. UsageSpendTrackingOffTitle = Maliyet izleme kapalı UsageSpendTrackingOffBody = Yerel günlükleri taramak ve harcamayı tahmin etmek için aşağıdan Maliyetleri izle seçeneğini açın. + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = Görünür kullanım öğeleri +ProviderVisibleUsageItemsFooter = Bu sağlayıcının tepsi kartında ve Genel bakışta hangi kullanım satırlarının görüneceğini seçin. +ProviderRestoreDefaults = Varsayılanları geri yükle +ProviderLastFetchFailedTitle = Son { "{}" } getirme işlemi başarısız oldu: +ProviderShowDetails = Ayrıntıları göster +ProviderHideDetails = Ayrıntıları gizle +ProviderSectionConnection = Bağlantı +QuotaWarningsTitle = Kota uyarıları +QuotaWarningGlobal = Genel +QuotaWarningCustom = Özel +QuotaWarningOff = Kapalı +QuotaWarningWarning = Uyarı +QuotaWarningCritical = Kritik +QuotaWarningInherited = Devralınan: Uyarı %{ "{}" } kullanımda, Kritik %{ "{}" } kullanımda +QuotaWarningProviderInherits = Burada bir pencere özelleştirilmedikçe genel kota uyarısı ayarları kullanılır. +QuotaWarningProviderMarkersOnly = Kota uyarısı bildirimleri genel olarak kapalı. Bu ayarlar kullanım çubuğu işaretlerini yine de denetler. +QuotaWarningProviderDisabled = Kota uyarısı bildirimleri ve kullanım çubuğu işaretleri kapalı. Kayıtlı ayarları düzenlemek için birini açın. +ProviderCodexLocalCostEstimates = Yerel oturum maliyet tahminleri +ProviderCodexLocalCostEstimatesHelp = Seçili yönetilen hesabın oturum geçmişi yerine bu bilgisayardaki Codex oturumlarını kullanır. Kuruluş API anahtarlarıyla çalışır; OpenAI faturalandırması veya yönetici erişimi gerekmez. Ağ isteği yapmadan yerel önbellekteki veya paketlenmiş model fiyatlarını kullanır. Bu sağlayıcıya özel ayar, diğer sağlayıcılar için maliyet özetlerini açmaz. +ProviderCodexHistoricalTracking = Geçmiş izleme +ProviderCodexOpenAiWebExtras = OpenAI web ekstraları +ProviderCodexOpenAiWebExtrasHelp = İsteğe bağlı. chatgpt.com üzerinden kod incelemesi, kullanım dökümü ve kredi geçmişini göstermek için açın. +ProviderCodexExternalOauth = Harici Codex OAuth kaynakları +ProviderCodexExternalOauthHelp = Eski Codex ve OpenCode OAuth dosyalarına salt okunur geri dönüşe açıkça izin verir. CodexBar bu harici kimlik bilgilerini asla yenilemez veya yazmaz. Başka bir uygulamanın OAuth oturumunu Codex kullanım istekleriyle paylaştığı için varsayılan olarak kapalıdır. +ProviderCodexBatterySaver = OpenAI web pil tasarrufu +ProviderCodexBatterySaverHelp = Pil ve ağ kullanımını azaltmak için arka plandaki chatgpt.com yenilemelerini sınırlar. Pano ekstraları siz elle yenileyene kadar eski kalabilir. +ProviderOptionNotUsedOnWindows = Henüz Windows'ta kullanılmıyor; seçim, desteklendiğinde kullanılmak üzere kaydedilir. +ClaudeSwapExecutablePathHelp = cswap yürütülebilir dosyasının yolu (github.com/realiti4/claude-swap). diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 7848a1ca20..c558fcd3c2 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -517,8 +517,6 @@ SessionQuotaNotificationsHelper = Повідомляє, коли сесійна WarningAtPercentUsed = Попередження при % використання CriticalAtPercentUsed = Критично при % використання ThresholdsCaption = Відсотки використання для сесійних і тижневих вікон, якщо провайдер не задає власні. -ThresholdOverridesTitle = Пороги провайдерів -ThresholdOverridesCaption = Codex і Claude можуть мати власні пороги попередження та критичності. Залиште поле порожнім, щоб узяти значення рівнем вище. SectionUsageThresholds = Пороги використання SectionKeyboard = Клавіатура SectionUsageRendering = Відображення використання @@ -617,8 +615,6 @@ CodexLocalLogsTitle = Локальні журнали Codex CodexLocalLogsCaption = Додайте домашню папку Codex або папки сеансів, щоб оцінювати витрати локально. Кілька шляхів розділяйте крапкою з комою. CodexLogPathsLabel = Шляхи до журналів Codex CodexLogPathsHelper = Можна вказати корінь .codex – тоді буде проскановано папку sessions усередині нього. -AvoidKeychainPromptsLabel = Без запитів сховища облікових даних (Claude) -AvoidKeychainPromptsHelper = Поки не застосовується в Windows: облікові дані Claude і далі зчитуються як звичайно. Вибір зберігається до появи функції. DisableAllKeychainLabel = Вимкнути доступ до диспетчера облікових даних DisableAllKeychainHelper = Поки не застосовується в Windows: CodexBar і далі використовує диспетчер облікових даних, DPAPI та файли cookie браузера. Вибір зберігається до появи блокування. CredentialAccessFooter = Поки доступ до диспетчера облікових даних вимкнено, параметр запитів сховища ключів для Claude заблоковано. @@ -977,8 +973,8 @@ CostSummaryStyleCompact = Компактно CostSummaryStyleDetailed = Докладно CostSummaryStyleHidden = Приховано ProviderAccentColor = Акцентний колір -ProviderAccentColorHelper = Замінити фірмовий колір смуг використання й діаграм. Введіть HEX-колір, наприклад #FF5733. -ProviderAccentColorReset = Скинути до типового +ProviderAccentColorHelper = Задає колір смуг використання й графіків цього провайдера. +ProviderAccentColorReset = Типовий ProviderAccentColorInvalid = Недійсний HEX-колір. Використовуйте формат #RRGGBB, наприклад #FF5733. ProviderAutoResumeTitle = Автоматичне продовження сеансу @@ -1028,7 +1024,7 @@ ClaudeSwapEnableHelp = За замовчуванням вимкнено. Пот ClaudeSwapExecutablePath = Шлях до claude-swap ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = Інтеграцію з claude-swap вимкнено. -ClaudeSwapStatusNoExecutable = Укажіть нижче шлях до cswap. +ClaudeSwapStatusNoExecutable = Укажіть шлях до cswap, щоб показати акаунти. ClaudeSwapStatusReady = Зовнішні облікові записи з claude-swap. ClaudeSwapEmpty = claude-swap не повернув жодного облікового запису. ClaudeSwapSwitchButton = Перемкнути через claude-swap @@ -1533,3 +1529,33 @@ UsageSpendTrackCosts = Відстежувати витрати UsageSpendTrackCostsHelper = Сканувати локальні журнали для оцінки витрат. Якщо вимкнено, локальні орієнтовні витрати також приховуються на картках і плаваючій панелі. UsageSpendTrackingOffTitle = Відстеження витрат вимкнено UsageSpendTrackingOffBody = Увімкніть «Відстежувати витрати» нижче, щоб сканувати локальні журнали й оцінювати витрати. + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = Видимі рядки використання +ProviderVisibleUsageItemsFooter = Виберіть, які рядки використання показуються в картці провайдера в треї та в огляді. +ProviderRestoreDefaults = Відновити типові +ProviderLastFetchFailedTitle = Останній запит { "{}" } не вдався: +ProviderShowDetails = Показати подробиці +ProviderHideDetails = Сховати подробиці +ProviderSectionConnection = Підключення +QuotaWarningsTitle = Попередження про квоту +QuotaWarningGlobal = Загальні +QuotaWarningCustom = Власні +QuotaWarningOff = Вимк. +QuotaWarningWarning = Попередження +QuotaWarningCritical = Критично +QuotaWarningInherited = Успадковано: попередження при { "{}" }% використання, критично при { "{}" }% +QuotaWarningProviderInherits = Використовуються загальні налаштування попереджень про квоту, якщо вікно не налаштовано тут. +QuotaWarningProviderMarkersOnly = Сповіщення про квоту вимкнено глобально. Ці налаштування й надалі керують позначками на смугах використання. +QuotaWarningProviderDisabled = Сповіщення про квоту й позначки на смугах використання вимкнено. Увімкніть щось одне, щоб змінити збережені налаштування. +ProviderCodexLocalCostEstimates = Оцінка вартості локальних сеансів +ProviderCodexLocalCostEstimatesHelp = Використовує сеанси Codex на цьому ПК замість історії сеансів вибраного керованого облікового запису. Працює з API-ключами організації й не потребує білінгу OpenAI чи прав адміністратора. Використовує локально кешовані або вбудовані ціни моделей без мережевих запитів. Цей перемикач діє лише для цього провайдера й не вмикає зведення витрат для інших. +ProviderCodexHistoricalTracking = Відстеження історії +ProviderCodexOpenAiWebExtras = Веб-доповнення OpenAI +ProviderCodexOpenAiWebExtrasHelp = Необов'язково. Увімкніть, щоб показувати перевірку коду, розбивку використання та історію кредитів через chatgpt.com. +ProviderCodexExternalOauth = Зовнішні джерела OAuth для Codex +ProviderCodexExternalOauthHelp = Явно дозволяє резервне читання (лише читання) застарілих OAuth-файлів Codex і OpenCode. CodexBar ніколи не оновлює й не записує ці зовнішні облікові дані. Типово вимкнено, бо OAuth-сеанс іншої програми використовується для запитів використання Codex. +ProviderCodexBatterySaver = Економія заряду для веб-OpenAI +ProviderCodexBatterySaverHelp = Обмежує фонові оновлення chatgpt.com, щоб зменшити витрату заряду й трафіку. Доповнення панелі можуть застарівати, доки ви не оновите їх вручну. +ProviderOptionNotUsedOnWindows = Поки не використовується в Windows; вибір зберігається на майбутнє. +ClaudeSwapExecutablePathHelp = Шлях до виконуваного файлу cswap (github.com/realiti4/claude-swap). diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 5e85e02b24..1f1f1ca3fd 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -490,8 +490,6 @@ SessionQuotaNotificationsHelper = 当服务商的会话额度剩余 0% 以及重 WarningAtPercentUsed = 警告(已用 %) CriticalAtPercentUsed = 严重(已用 %) ThresholdsCaption = 会话和每周窗口使用的已用百分比,服务商可单独覆盖。 -ThresholdOverridesTitle = 服务商阈值 -ThresholdOverridesCaption = Codex 和 Claude 可以使用自己的警告和严重已用百分比。留空则使用上一级的值。 SectionUsageThresholds = 用量阈值 SectionKeyboard = 键盘 SectionUsageRendering = 用量展示 @@ -590,8 +588,6 @@ CodexLocalLogsTitle = Codex 本地日志 CodexLocalLogsCaption = 添加 Codex 主目录或 sessions 目录,用于估算本地成本。多个路径请用分号分隔。 CodexLogPathsLabel = Codex 日志路径 CodexLogPathsHelper = 可以填写 .codex 根目录;应用会扫描其中的 sessions 文件夹。 -AvoidKeychainPromptsLabel = 避免钥匙串弹窗(Claude) -AvoidKeychainPromptsHelper = Windows 上尚未生效:仍会照常读取 Claude 凭据。此选项会保存,待该功能上线后使用。 DisableAllKeychainLabel = 禁用凭据管理器访问 DisableAllKeychainHelper = Windows 上尚未生效:CodexBar 仍会使用凭据管理器、DPAPI 和浏览器 Cookie。此选项会保存,待拦截功能上线后使用。 CredentialAccessFooter = 停用凭据管理器访问期间,Claude 钥匙串提示选项保持锁定。 @@ -977,8 +973,8 @@ CostSummaryStyleCompact = 紧凑 CostSummaryStyleDetailed = 详细 CostSummaryStyleHidden = 隐藏 ProviderAccentColor = 强调色 -ProviderAccentColorHelper = 覆盖用于用量条和图表的品牌颜色。输入十六进制颜色,如 #FF5733。 -ProviderAccentColorReset = 恢复默认 +ProviderAccentColorHelper = 设置此提供商的用量条和图表颜色。 +ProviderAccentColorReset = 使用默认 ProviderAccentColorInvalid = 无效的十六进制颜色。请使用 #RRGGBB 格式,例如 #FF5733。 ProviderAutoResumeTitle = 自动恢复会话 ProviderAutoResumeAfterQuotaReset = 配额重置时重新打开 CLI 会话 @@ -1026,7 +1022,7 @@ ClaudeSwapEnableHelp = 默认关闭。需要已安装 cswap (github.com/realiti4 ClaudeSwapExecutablePath = claude-swap 可执行文件 ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = claude-swap 集成已关闭。 -ClaudeSwapStatusNoExecutable = 请在下方设置 cswap 可执行文件路径。 +ClaudeSwapStatusNoExecutable = 设置 cswap 可执行文件路径以列出账户。 ClaudeSwapStatusReady = claude-swap 报告的外部账号。 ClaudeSwapEmpty = claude-swap 未报告任何账号。 ClaudeSwapSwitchButton = 通过 claude-swap 切换 @@ -1531,3 +1527,33 @@ UsageSpendTrackCosts = 跟踪费用 UsageSpendTrackCostsHelper = 扫描本地日志以估算花费。关闭后,卡片和浮动栏上的本地估算费用也会隐藏。 UsageSpendTrackingOffTitle = 费用跟踪已关闭 UsageSpendTrackingOffBody = 在下方开启“跟踪费用”即可扫描本地日志并估算花费。 + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = 可见用量项目 +ProviderVisibleUsageItemsFooter = 选择此提供商的托盘卡片和概览中显示哪些用量行。 +ProviderRestoreDefaults = 恢复默认 +ProviderLastFetchFailedTitle = 上次获取 { "{}" } 失败: +ProviderShowDetails = 显示详情 +ProviderHideDetails = 隐藏详情 +ProviderSectionConnection = 连接 +QuotaWarningsTitle = 配额警告 +QuotaWarningGlobal = 全局 +QuotaWarningCustom = 自定义 +QuotaWarningOff = 关闭 +QuotaWarningWarning = 警告 +QuotaWarningCritical = 严重 +QuotaWarningInherited = 继承:已用 { "{}" }% 时警告,已用 { "{}" }% 时严重 +QuotaWarningProviderInherits = 除非在此自定义某个窗口,否则使用全局配额警告设置。 +QuotaWarningProviderMarkersOnly = 配额警告通知已全局关闭。这些设置仍控制用量条标记。 +QuotaWarningProviderDisabled = 配额警告通知和用量条标记均已关闭。启用其中之一即可编辑这些已保存的设置。 +ProviderCodexLocalCostEstimates = 本地会话费用估算 +ProviderCodexLocalCostEstimatesHelp = 使用此电脑上的 Codex 会话,而不是所选托管账户的会话历史。适用于组织 API 密钥,无需 OpenAI 账单或管理员权限。使用本地缓存或内置的模型价格,不发起网络请求。此提供商专用开关不会为其他提供商启用费用摘要。 +ProviderCodexHistoricalTracking = 历史跟踪 +ProviderCodexOpenAiWebExtras = OpenAI 网页附加功能 +ProviderCodexOpenAiWebExtrasHelp = 可选。开启后通过 chatgpt.com 显示代码审查、用量明细和额度历史。 +ProviderCodexExternalOauth = 外部 Codex OAuth 来源 +ProviderCodexExternalOauthHelp = 明确允许以只读方式回退到旧版 Codex 和 OpenCode OAuth 文件。CodexBar 从不刷新或写入这些外部凭据。默认关闭,因为这会把其他应用的 OAuth 会话用于 Codex 用量请求。 +ProviderCodexBatterySaver = OpenAI 网页省电 +ProviderCodexBatterySaverHelp = 限制 chatgpt.com 后台刷新,以减少电池和网络消耗。仪表板附加信息可能在手动刷新前保持旧数据。 +ProviderOptionNotUsedOnWindows = Windows 上尚未使用;该选择会保存,待支持后生效。 +ClaudeSwapExecutablePathHelp = cswap 可执行文件的路径 (github.com/realiti4/claude-swap)。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 7545b81235..a6db6b823e 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -490,8 +490,6 @@ SessionQuotaNotificationsHelper = 當提供者的工作階段額度剩餘 0% 以 WarningAtPercentUsed = 警告(已用 %) CriticalAtPercentUsed = 嚴重(已用 %) ThresholdsCaption = 工作階段和每週視窗使用的已用百分比,提供者可單獨覆寫。 -ThresholdOverridesTitle = 提供者臨界值 -ThresholdOverridesCaption = Codex 和 Claude 可以使用自己的警告和嚴重已用百分比。留空則使用上一層的值。 SectionUsageThresholds = 用量臨界值 SectionKeyboard = 鍵盤 SectionUsageRendering = 用量展示 @@ -590,8 +588,6 @@ CodexLocalLogsTitle = Codex 本機日誌 CodexLocalLogsCaption = 新增 Codex 主目錄或 sessions 目錄以估算本機成本。多個路徑請以分號分隔。 CodexLogPathsLabel = Codex 日誌路徑 CodexLogPathsHelper = 可以填寫 .codex 根目錄;應用程式會掃描其中的 sessions 資料夾。 -AvoidKeychainPromptsLabel = 避免鑰匙串彈窗(Claude) -AvoidKeychainPromptsHelper = Windows 上尚未生效:仍會照常讀取 Claude 憑據。此選項會儲存,待該功能上線後使用。 DisableAllKeychainLabel = 停用認證管理員存取 DisableAllKeychainHelper = Windows 上尚未生效:CodexBar 仍會使用認證管理員、DPAPI 和瀏覽器 Cookie。此選項會儲存,待封鎖功能上線後使用。 CredentialAccessFooter = 停用認證管理員存取期間,Claude 鑰匙串提示選項保持鎖定。 @@ -977,8 +973,8 @@ CostSummaryStyleCompact = 緊湊 CostSummaryStyleDetailed = 詳細 CostSummaryStyleHidden = 隱藏 ProviderAccentColor = 強調色 -ProviderAccentColorHelper = 覆蓑用於用量條和圖表的品牌顏色。輸入十六進位顏色,如 #FF5733。 -ProviderAccentColorReset = 恢復預設 +ProviderAccentColorHelper = 設定此供應商的用量條和圖表顏色。 +ProviderAccentColorReset = 使用預設值 ProviderAccentColorInvalid = 無效的十六進位顏色。請使用 #RRGGBB 格式,例如 #FF5733。 ProviderAutoResumeTitle = 自動恢復工作階段 ProviderAutoResumeAfterQuotaReset = 配額重設時重新開啟 CLI 工作階段 @@ -1026,7 +1022,7 @@ ClaudeSwapEnableHelp = 預設停用。需要已安裝 cswap (github.com/realiti4 ClaudeSwapExecutablePath = claude-swap 可執行檔 ClaudeSwapExecutablePathPlaceholder = ~/.local/bin/cswap ClaudeSwapStatusDisabled = claude-swap 整合已關閉。 -ClaudeSwapStatusNoExecutable = 請在下方設定 cswap 可執行檔路徑。 +ClaudeSwapStatusNoExecutable = 設定 cswap 可執行檔路徑以列出帳號。 ClaudeSwapStatusReady = 由 claude-swap 回報的外部帳號。 ClaudeSwapEmpty = claude-swap 未回報任何帳號。 ClaudeSwapSwitchButton = 透過 claude-swap 切換 @@ -1531,3 +1527,33 @@ UsageSpendTrackCosts = 追蹤費用 UsageSpendTrackCostsHelper = 掃描本機記錄以估算花費。關閉後,卡片和浮動列上的本機估算費用也會隱藏。 UsageSpendTrackingOffTitle = 費用追蹤已關閉 UsageSpendTrackingOffBody = 在下方開啟「追蹤費用」即可掃描本機記錄並估算花費。 + +# Settings > Providers detail (Mac parity) +ProviderVisibleUsageItems = 可見用量項目 +ProviderVisibleUsageItemsFooter = 選擇此供應商的系統匣卡片和概覽中顯示哪些用量列。 +ProviderRestoreDefaults = 恢復預設值 +ProviderLastFetchFailedTitle = 上次擷取 { "{}" } 失敗: +ProviderShowDetails = 顯示詳細資料 +ProviderHideDetails = 隱藏詳細資料 +ProviderSectionConnection = 連線 +QuotaWarningsTitle = 配額警告 +QuotaWarningGlobal = 全域 +QuotaWarningCustom = 自訂 +QuotaWarningOff = 關閉 +QuotaWarningWarning = 警告 +QuotaWarningCritical = 嚴重 +QuotaWarningInherited = 繼承:已用 { "{}" }% 時警告,已用 { "{}" }% 時嚴重 +QuotaWarningProviderInherits = 除非在此自訂某個視窗,否則使用全域配額警告設定。 +QuotaWarningProviderMarkersOnly = 配額警告通知已全域關閉。這些設定仍控制用量條標記。 +QuotaWarningProviderDisabled = 配額警告通知和用量條標記均已關閉。啟用其中之一即可編輯這些已儲存的設定。 +ProviderCodexLocalCostEstimates = 本機工作階段費用估算 +ProviderCodexLocalCostEstimatesHelp = 使用此電腦上的 Codex 工作階段,而非所選受管帳戶的工作階段記錄。適用於組織 API 金鑰,不需要 OpenAI 帳單或管理員權限。使用本機快取或內建的模型價格,不發出網路要求。此供應商專用開關不會為其他供應商啟用費用摘要。 +ProviderCodexHistoricalTracking = 歷史追蹤 +ProviderCodexOpenAiWebExtras = OpenAI 網頁附加功能 +ProviderCodexOpenAiWebExtrasHelp = 選用。開啟後透過 chatgpt.com 顯示程式碼審查、用量明細和額度記錄。 +ProviderCodexExternalOauth = 外部 Codex OAuth 來源 +ProviderCodexExternalOauthHelp = 明確允許以唯讀方式回退到舊版 Codex 和 OpenCode OAuth 檔案。CodexBar 從不重新整理或寫入這些外部認證。預設關閉,因為這會把其他應用程式的 OAuth 工作階段用於 Codex 用量要求。 +ProviderCodexBatterySaver = OpenAI 網頁省電 +ProviderCodexBatterySaverHelp = 限制 chatgpt.com 背景重新整理,以減少電池和網路用量。儀表板附加資訊可能在手動重新整理前維持舊資料。 +ProviderOptionNotUsedOnWindows = Windows 上尚未使用;此選擇會儲存,待支援後生效。 +ClaudeSwapExecutablePathHelp = cswap 執行檔的路徑 (github.com/realiti4/claude-swap)。 From 1dd8ffd602507e3ae68ac7c6126e3df5dbf56bb6 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:46:11 +0700 Subject: [PATCH 08/13] Show the provider error group only for a recorded fetch error --- .../settings/providers/ProviderDetailPane.test.tsx | 6 ++++++ .../src/surfaces/settings/providers/ProviderDetailPane.tsx | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx index 002f480f3e..363311e18b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx @@ -194,6 +194,12 @@ describe("ProviderDetailPane", () => { expect(within(accounts as HTMLElement).getByTestId("codex-accounts")).toBeTruthy(); }); + it("leaves out the error group when the last fetch recorded no error", async () => { + tauri.getProviderDetail.mockResolvedValue(detail("codex", { errorState: "ready" })); + const { container } = await renderPane("codex"); + expect(container.querySelector(".provider-detail-error-group")).toBeNull(); + }); + it("drives the enabled-providers list from the header switch", async () => { tauri.getProviderDetail.mockResolvedValue(detail("codex")); const { onSettingsChange } = await renderPane("codex"); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 9aab24e818..6ba054ce93 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -342,12 +342,15 @@ export function ProviderDetailPane({ onSettingsChange({ enabledProviders: nextEnabledProviders(catalog, settings.enabledProviders, detail.id, on), }); + // macOS shows the error group only for a recorded fetch error; every + // snapshot carries an errorState, including "ready" after a good fetch. + const hasFetchError = Boolean(detail.lastError?.trim()); const updatedMs = detail.lastUpdated ? Date.parse(detail.lastUpdated) : Number.NaN; const updatedText = Number.isFinite(updatedMs) ? formatRelativeUpdated(updatedMs, t) : busy ? t("SectionRefreshing") - : detail.lastError || detail.errorState + : hasFetchError ? t("ProviderTextUnavailable") : t("ProviderNotFetchedYetTitle"); const showClaudeSwapPath = detail.id === "claude" && (settings.claudeSwapEnabled ?? false); @@ -412,7 +415,7 @@ export function ProviderDetailPane({ onChange={onSettingsChange} /> - {(detail.lastError || detail.errorState) && ( + {hasFetchError && ( )} From 73012affa87f88dc5e1ed91c0cc0f460697cd928 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:13:57 +0700 Subject: [PATCH 09/13] Drop the unused settings type import from the metric row model --- apps/desktop-tauri/src/lib/metricRowModel.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index 31709e34b6..9ae780eb16 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -3,7 +3,6 @@ import type { LocaleKey } from "../i18n/keys"; import type { PaceStage, RateWindowSnapshot, - SettingsSnapshot, WindowPaceSnapshot, WorkdayTickStyle, } from "../types/bridge"; From 37e79c9e795e5ca966a1f8d8b0403bf7f6328fe0 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:13:57 +0700 Subject: [PATCH 10/13] Show provider error details in the body font The full fetch error is a sentence, not code. Also space stacked usage rows 12px apart as on macOS. --- .../desktop-tauri/src/surfaces/settings/settings-layout.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css index e67186b507..5ad02069b3 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css +++ b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css @@ -1156,6 +1156,11 @@ padding: 12px 0; } +/* macOS: usage rows stack with 12px between them. */ +.provider-usage-main .menu-metric + .menu-metric { + margin-top: 12px; +} + /* Error group: a plain row in its box, red only on the title. */ .provider-detail-error-group .provider-detail-error { margin: 0; @@ -1175,7 +1180,6 @@ } .provider-detail-error-group .provider-detail-error__full { - font-family: var(--font-mono, monospace); white-space: pre-wrap; } From 7a009a69767807ce3d2c14fc6cfe885048fefe46 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:13:58 +0700 Subject: [PATCH 11/13] Draw Settings usage rows with the card's metric row Usage bars in the provider detail pane now follow Show usage as used (% left by default), carry the per-window quota-warning markers and the provider accent, and show the window pace as the meta line. The separate Pace section is folded in and removed (SPEC 1.2: Pace becomes the meta line). --- apps/desktop-tauri/src/styles.css | 58 -------- .../settings/providers/ProviderDetailPane.tsx | 22 +-- .../providers/sections/PaceSection.test.tsx | 40 ------ .../providers/sections/PaceSection.tsx | 47 ------- .../providers/sections/UsageSection.test.tsx | 119 ++++++++++++++-- .../providers/sections/UsageSection.tsx | 131 +++++------------- 6 files changed, 154 insertions(+), 263 deletions(-) delete mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx delete mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index fc717d3874..0f76ed5156 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -1819,32 +1819,6 @@ body:has(.tray-panel-reveal), } } -.provider-usage-bar { - display: flex; - flex-direction: column; - gap: 3px; - margin-bottom: 8px; -} - -.provider-usage-bar__header { - display: flex; - justify-content: space-between; - font-size: 0.72rem; -} - -.provider-usage-bar__label { - color: var(--text-secondary); -} - -.provider-usage-bar__pct { - color: var(--text-primary); - font-weight: 600; -} - -.provider-usage-bar__status { - color: var(--text-secondary); -} - .provider-usage-bar__track { height: 5px; border-radius: 3px; @@ -1859,12 +1833,6 @@ body:has(.tray-panel-reveal), transition: width 0.3s; } -.provider-usage-bar__reset, -.provider-usage-bar__detail { - font-size: 0.68rem; - color: var(--text-muted); -} - .provider-detail-actions { display: flex; gap: 8px; @@ -2041,32 +2009,6 @@ body:has(.tray-panel-reveal), white-space: nowrap; } -.provider-detail-pace__stage { - font-size: 0.86rem; - font-weight: 600; - color: var(--provider-row-text-primary); -} - -.provider-detail-pace__stage[data-stage="far_behind"], -.provider-detail-pace__stage[data-stage="behind"] { - color: var(--provider-status-error); -} - -.provider-detail-pace__stage[data-stage="far_ahead"], -.provider-detail-pace__stage[data-stage="ahead"] { - color: var(--provider-status-stale); -} - -.provider-detail-pace__aux { - font-size: 0.78rem; - color: var(--provider-row-text-secondary); - margin-top: 2px; -} - -.provider-usage-bar__pct[data-exhausted="true"] { - color: var(--provider-status-error); -} - .provider-usage-bar__fill[data-exhausted="true"] { background: var(--provider-status-error); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 6ba054ce93..5beca49b12 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -10,6 +10,7 @@ import { formatRelativeUpdated } from "../../../lib/relativeTime"; import { nextEnabledProviders } from "../settingsTabs"; import { useLocale } from "../../../hooks/useLocale"; import { providerAllowsPace } from "../../../lib/providerPace"; +import { providerBarAccent } from "../../../components/providers/providerIcons"; import { getCredentialStorageStatus, getProviderCookieSourceOptions, @@ -34,7 +35,6 @@ import { buildSubtitle } from "./providerDetailFormat"; import { IdentitySection } from "./sections/IdentitySection"; import { UsageSection } from "./sections/UsageSection"; import { UsageItemVisibilitySection } from "./sections/UsageItemVisibilitySection"; -import { PaceSection } from "./sections/PaceSection"; import { UsageDetailsSection } from "./sections/UsageDetailsSection"; import { CostSection } from "./sections/CostSection"; import { QuickActionsSection } from "./sections/QuickActionsSection"; @@ -376,14 +376,18 @@ export function ProviderDetailPane({ - diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx deleted file mode 100644 index e14d476759..0000000000 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import type { PaceSnapshot } from "../../../../types/bridge"; -import { PaceSection } from "./PaceSection"; - -const pace: PaceSnapshot = { - stage: "behind", - deltaPercent: -10, - willLastToReset: true, - etaSeconds: null, - expectedUsedPercent: 40, - actualUsedPercent: 30, -}; - -describe("PaceSection", () => { - it("renders the pace of an unblocked window", () => { - render( key} />); - - expect(screen.getByText("DetailPaceTitle")).toBeInTheDocument(); - expect(screen.getByText("DetailPaceBehind")).toBeInTheDocument(); - }); - - it("hides the pace of a window blocked by an exhausted monthly pool", () => { - const future = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); - const { container } = render( - key} />, - ); - - expect(container).toBeEmptyDOMElement(); - }); - - it("shows the pace again once the monthly pool reset has passed", () => { - const past = new Date(Date.now() - 60 * 1000).toISOString(); - render( - key} />, - ); - - expect(screen.getByText("DetailPaceTitle")).toBeInTheDocument(); - }); -}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx deleted file mode 100644 index 6e1893cdce..0000000000 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { PaceSnapshot } from "../../../../types/bridge"; -import type { LocaleKey } from "../../../../i18n/keys"; -import { formatEta } from "../../../../lib/formatEta"; -import { useMonthlyLimitBlockNow } from "../../../../hooks/useMonthlyLimitBlockNow"; -import { isMonthlyLimitBlockActive } from "../../../../lib/monthlyLimitBlock"; - -interface Props { - pace: PaceSnapshot | null; - t: (key: LocaleKey) => string; -} - -const STAGE_TO_KEY: Record = { - on_track: "DetailPaceOnTrack", - slightly_ahead: "DetailPaceSlightlyAhead", - ahead: "DetailPaceAhead", - far_ahead: "DetailPaceFarAhead", - slightly_behind: "DetailPaceSlightlyBehind", - behind: "DetailPaceBehind", - far_behind: "DetailPaceFarBehind", -}; - -/** 展示配额节奏状态及其辅助说明。 */ -export function PaceSection({ pace, t }: Props) { - const monthlyLimitBlockNow = useMonthlyLimitBlockNow([pace?.monthlyLimitBlock]); - // Upstream 0.69.0 #4091 drops the pace of a window blocked by a longer - // exhausted pool; its own resets cannot restore access. - if (!pace || isMonthlyLimitBlockActive(pace.monthlyLimitBlock, monthlyLimitBlockNow)) { - return null; - } - - const stageLabel = t(STAGE_TO_KEY[pace.stage]); - const aux = pace.willLastToReset - ? t("DetailPaceWillLastToReset") - : pace.etaSeconds !== null - ? `${t("DetailPaceRunsOutIn")} ${formatEta(pace.etaSeconds)}` - : null; - - return ( -
    -

    {t("DetailPaceTitle")}

    -
    - {stageLabel} -
    - {aux &&
    {aux}
    } -
    - ); -} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx index f0df371d94..85f30d2f2d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx @@ -74,6 +74,9 @@ describe("UsageSection", () => { tauriMocks.getLocaleStrings.mockResolvedValue( buildBundle({ InventoryAvailableCount: "{} available", + PanelLeftSuffix: "left", + PanelUsedSuffix: "used", + UsageBarQuotaWarnings: "Quota warnings", ProviderLabelLimitResetCredits: "Limit reset credits", ProviderTextTokens: "{} tokens", }), @@ -89,7 +92,8 @@ describe("UsageSection", () => { ); expect(await screen.findByText("Additional Budget")).toBeInTheDocument(); - expect(screen.getByText("42%")).toBeInTheDocument(); + // Show usage as used is off by default, so rows read "% left" like the card. + expect(screen.getByText("58% left")).toBeInTheDocument(); }); it.each([false, true])( @@ -129,9 +133,9 @@ describe("UsageSection", () => { "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", ]); - detailLines.forEach((line) => expect(line).toHaveClass("provider-usage-bar__detail")); + detailLines.forEach((line) => expect(line).toHaveClass("menu-metric__detail")); expect(screen.queryByText(/Resets .*EUR/)).not.toBeInTheDocument(); - expect(container.querySelectorAll(".provider-usage-bar__reset")).toHaveLength( + expect(container.querySelectorAll(".menu-metric__reset")).toHaveLength( hasReset ? 2 : 0, ); }, @@ -171,8 +175,9 @@ describe("UsageSection", () => { ); const label = await screen.findByText("ProviderSessionLabel"); - expect(label.parentElement).toHaveTextContent("ProviderTextNoActiveSession"); - expect(label.parentElement?.querySelector(".provider-usage-bar__track")).toBeNull(); + const row = label.closest(".menu-metric"); + expect(row).toHaveTextContent("ProviderTextNoActiveSession"); + expect(row?.querySelector(".menu-metric__progress")).toBeNull(); }); function kimiDetailBlockedUntil(blockResetsAt: string) { @@ -208,15 +213,15 @@ describe("UsageSection", () => { ); const label = await screen.findByText("ProviderSessionLabel"); - const row = label.closest(".provider-usage-bar"); + const row = label.closest(".menu-metric"); expect(row).toHaveTextContent(/^ProviderSessionLabelPanelBlockedByMonthlyLimit$/); - expect(row?.querySelector(".provider-usage-bar__track")).toBeNull(); - expect(row?.querySelector(".provider-usage-bar__reset")).toBeNull(); + expect(row?.querySelector(".menu-metric__progress")).toBeNull(); + expect(row?.querySelector(".menu-metric__reset")).toBeNull(); // The pool's own row keeps its bar, exhausted state and reset. - const pool = screen.getByText("Total usage").closest(".provider-usage-bar"); - expect(pool?.querySelector(".provider-usage-bar__track")).not.toBeNull(); + const pool = screen.getByText("Total usage").closest(".menu-metric"); + expect(pool?.querySelector(".menu-metric__progress")).not.toBeNull(); expect(pool).toHaveTextContent("DetailWindowExhausted"); - expect(pool?.querySelector(".provider-usage-bar__reset")).not.toBeNull(); + expect(pool?.querySelector(".menu-metric__reset")).not.toBeNull(); }); it("shows the raw window again once the monthly pool reset has passed", async () => { @@ -233,10 +238,10 @@ describe("UsageSection", () => { ); const label = await screen.findByText("ProviderSessionLabel"); - const row = label.closest(".provider-usage-bar"); - expect(row).toHaveTextContent("0%"); + const row = label.closest(".menu-metric"); + expect(row).toHaveTextContent("100% left"); expect(row).not.toHaveTextContent("PanelBlockedByMonthlyLimit"); - expect(row?.querySelector(".provider-usage-bar__track")).not.toBeNull(); + expect(row?.querySelector(".menu-metric__progress")).not.toBeNull(); }); it("renders discrete inventory without turning it into a quota bar", async () => { @@ -259,7 +264,7 @@ describe("UsageSection", () => { ); expect(await screen.findByText(/Limit Reset Credits: 2 available/)).toBeInTheDocument(); - expect(container.querySelector(".provider-usage-bar__track")).toBeNull(); + expect(container.querySelector(".menu-metric__progress")).toBeNull(); }); it("shows provider-declared lane labels in settings bars", async () => { @@ -335,4 +340,88 @@ describe("UsageSection", () => { expect(screen.getAllByRole("heading", { name: "Model activity" })).toHaveLength(1); expect(screen.getByText(/Plain: 1/)).toBeInTheDocument(); }); + + it("draws Custom quota-warning markers at % left and follows show-as-used", async () => { + const detail = provider(); + detail.id = "codex"; + detail.session = rateWindow(42); + detail.weekly = rateWindow(10); + detail.extraRateWindows = []; + const thresholds = { + highUsageThreshold: 70, + criticalUsageThreshold: 90, + providerQuotaWarnings: { + codex: { session: { high: 30, critical: 50 }, weekly: { enabled: false } }, + }, + }; + const markerX = (row: Element | null) => + Array.from(row?.querySelectorAll(".menu-metric__progress-warning") ?? []).map((marker) => + marker.getAttribute("x"), + ); + + const { container, rerender } = render( + + key} + /> + , + ); + + const session = (await screen.findByText("ProviderSessionLabel")).closest(".menu-metric"); + expect(session).toHaveTextContent("58% left"); + // Custom 30/50 % used sits at 70% and 50% of a "% left" bar. + expect(markerX(session)).toEqual(["50%", "70%"]); + // Weekly is Off for Codex: no markers. + const weekly = screen.getByText("ProviderWeeklyLabel").closest(".menu-metric"); + expect(markerX(weekly)).toEqual([]); + const section = container.querySelector(".provider-usage-main"); + expect(section?.style.getPropertyValue("--provider-accent")).toBe("#10a37f"); + + rerender( + + key} + /> + , + ); + + const usedSession = screen.getByText("ProviderSessionLabel").closest(".menu-metric"); + expect(usedSession).toHaveTextContent("42% used"); + expect(markerX(usedSession)).toEqual(["30%", "50%"]); + }); + + it("puts the window pace on the meta line under its bar", async () => { + const detail = provider(); + detail.extraRateWindows = []; + detail.session = null; + detail.weekly = { + ...rateWindow(20), + windowMinutes: 7 * 24 * 60, + pace: { + stage: "far_behind", + deltaPercent: -14, + expectedUsedPercent: 34, + actualUsedPercent: 20, + etaSeconds: null, + willLastToReset: true, + }, + }; + + render( + + key} /> + , + ); + + const weekly = (await screen.findByText("ProviderWeeklyLabel")).closest(".menu-metric"); + expect(weekly?.querySelector(".menu-metric__meta")).not.toBeNull(); + expect(weekly?.querySelector(".menu-metric__progress-pace")).not.toBeNull(); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index 0f85ce7871..68ca40cf33 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -1,26 +1,29 @@ -import type { - ProviderDisplayDetail, - ProviderInventoryItem, - ProviderDetail, - RateWindowSnapshot, -} from "../../../../types/bridge"; +import type { CSSProperties } from "react"; +import type { ProviderDetail, RateWindowSnapshot } from "../../../../types/bridge"; import { InventoryItemRow } from "../../../../components/InventoryRows"; import { groupProviderDisplayDetails, ProviderDisplayRow, } from "../../../../components/ProviderDisplayRow"; +import CardMetricRow, { type MetricRowDisplay } from "../../../../components/card/CardMetricRow"; import type { LocaleKey } from "../../../../i18n/keys"; -import { useFormattedResetTime } from "../../../../hooks/useFormattedResetTime"; +import type { ResetTimeFormatMode } from "../../../../hooks/useFormattedResetTime"; import { useMonthlyLimitBlockNow } from "../../../../hooks/useMonthlyLimitBlockNow"; -import { isMonthlyLimitBlockActive } from "../../../../lib/monthlyLimitBlock"; +import type { MetricLane } from "../../../../lib/metricRowModel"; import { isUsageItemVisible } from "../../../../lib/usageItemVisibility"; -import { resetDescriptionFallback, windowDetailText } from "../../../../lib/usageWindows"; -import { localizeProviderText } from "../../../../lib/providerText"; import { localizeProviderLabel, localizeWindowLabel } from "../../../../lib/windowLabels"; interface Props { provider: ProviderDetail; resetTimeRelative: boolean; + /** + * The card's row settings (% left or used, pace, quota-warning markers, + * workday ticks). `resetTimeRelative` and the monthly block clock are + * filled in here. + */ + display?: Omit; + /** Bar fill color (the provider accent); falls back to the theme bar color. */ + accentColor?: string; t: (key: LocaleKey) => string; } @@ -28,18 +31,22 @@ interface BarSpec { key: string; label: string; rate: RateWindowSnapshot; + lane: MetricLane; + resetFormatMode?: ResetTimeFormatMode; } /** - * Stacked usage bars — session / weekly / model-specific / tertiary. - * Mirrors the bars in - * `rust/src/native_ui/preferences.rs::render_provider_detail_panel`. + * Usage rows (session / weekly / model-specific / tertiary / extras), drawn + * with the tray card's metric row as in macOS `PreferencesProviderDetailView`: + * bars follow "Show usage as used", carry the quota-warning markers, and the + * pace becomes the meta line under the bar. */ -export function UsageSection({ provider, resetTimeRelative, t }: Props) { +export function UsageSection({ provider, resetTimeRelative, display, accentColor, t }: Props) { const bars: BarSpec[] = []; if (provider.session && isUsageItemVisible(provider.hiddenUsageItemIds, "primary")) { bars.push({ key: "session", + lane: "primary", label: localizeWindowLabel(provider.primaryLabel ?? undefined, t) || t("ProviderSessionLabel"), rate: provider.session, }); @@ -47,6 +54,7 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { if (provider.weekly && isUsageItemVisible(provider.hiddenUsageItemIds, "secondary")) { bars.push({ key: "weekly", + lane: "secondary", label: localizeWindowLabel(provider.secondaryLabel ?? undefined, t) || t("ProviderWeeklyLabel"), rate: provider.weekly, }); @@ -54,6 +62,7 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { if (provider.modelSpecific && isUsageItemVisible(provider.hiddenUsageItemIds, "model-specific")) { bars.push({ key: "modelSpecific", + lane: "tertiary", label: t("DetailWindowModelSpecific"), rate: provider.modelSpecific, }); @@ -61,6 +70,7 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { if (provider.tertiary && isUsageItemVisible(provider.hiddenUsageItemIds, "tertiary")) { bars.push({ key: "tertiary", + lane: "tertiary", label: t("DetailWindowTertiary"), rate: provider.tertiary, }); @@ -73,6 +83,8 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { key: extra.id, label: extra.title, rate: extra.window, + lane: "extra", + resetFormatMode: extra.id === "reset-credits" ? "expires" : "reset", }); } @@ -88,16 +100,20 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { } return ( -
    +

    {t("ProviderUsage")}

    {bars.map((b) => ( - ))} {inventory.map((item) => ( @@ -133,76 +149,3 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) {
    ); } - -function UsageBar({ - label, - rate, - blocked, - resetTimeRelative, - t, -}: { - label: string; - rate: RateWindowSnapshot; - blocked: boolean; - resetTimeRelative: boolean; - t: (key: LocaleKey) => string; -}) { - const usedPct = Number.isFinite(rate.usedPercent) ? Math.max(0, rate.usedPercent) : 0; - const pct = Math.min(100, usedPct); - const isInformational = rate.isInformational === true; - const detailText = localizeProviderText(windowDetailText(rate), t) || null; - const formattedReset = useFormattedResetTime( - blocked ? null : rate.resetsAt, - blocked ? null : resetDescriptionFallback(rate), - resetTimeRelative, - ); - if (blocked) { - // Upstream 0.69.0 #4091 status row: title and status, no bar or reset. - return ( -
    -
    - {label} - {t("PanelBlockedByMonthlyLimit")} -
    -
    - ); - } - const resetHint = formattedReset - ? resetTimeRelative || !rate.resetsAt - ? formattedReset - : `${t("MetricResetsIn")} ${formattedReset}` - : null; - - return ( -
    -
    - {label} - - {isInformational - ? localizeProviderText(rate.resetDescription?.trim(), t) || formattedReset || "—" - : rate.isExhausted - ? usedPct > 100 - ? `${usedPct.toFixed(0)}%` - : t("DetailWindowExhausted") - : `${usedPct.toFixed(0)}%`} - -
    - {!isInformational && ( -
    -
    -
    - )} - {!isInformational && resetHint && ( - {resetHint} - )} - {detailText && {detailText}} -
    - ); -} From 763d4c3a0d1686d16fc7b6037f65a6a7e6f7886a Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:39:24 +0700 Subject: [PATCH 12/13] Say that Off quota warnings still fire threshold hooks --- rust/src/settings/types.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index bd58b3e362..031ce2f7e7 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -207,7 +207,9 @@ impl Settings { } /// False when the provider window's quota warnings are set to Off. Off - /// suppresses threshold notifications, threshold hooks and bar markers. + /// suppresses threshold notifications and bar markers. Threshold hooks + /// still fire at the window's thresholds, as on macOS, where quota hooks + /// are independent of the notification preferences. pub fn quota_warning_enabled(&self, provider: ProviderId, window: &str) -> bool { self.quota_warning_window(provider, window) .enabled From e30eee66eb4f677410950d87dc741ffd895f36a4 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:39:24 +0700 Subject: [PATCH 13/13] Pick the float bar quota warning window by card lane --- .../src/floatbar/FloatBar.test.tsx | 34 ++++++++++++++ apps/desktop-tauri/src/floatbar/FloatBar.tsx | 44 ++++++++++++++++--- apps/desktop-tauri/src/lib/metricRowModel.ts | 5 +++ 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index f3c2ab7393..70034c4c43 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -713,6 +713,40 @@ describe("FloatBar", () => { }); }); + it("picks the quota warning window by card lane, not by window length", async () => { + // A monthly primary lane is the Session window for cards and toasts, so + // Session Off drops its tone even though the window is longer than a week. + const monthly = { ...rateWindow(95), windowMinutes: 30 * 24 * 60 }; + tauriMocks.getCachedProviders.mockResolvedValue([ + { ...snapshot("claude", "Claude", 95), primary: monthly, selectedMetric: monthly }, + ]); + const sessionOff: Partial = { + providerQuotaWarnings: { claude: { session: { enabled: false } } }, + }; + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings(sessionOff)); + const off = renderFloatBar(bootstrap(sessionOff)); + await waitFor(() => { + expect(off.container.querySelector(".floatbar__pill--ok")).not.toBeNull(); + }); + off.unmount(); + + // A selected secondary lane with no window length is the Weekly window. + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("claude", "Claude", 10, { + informational: true, + secondary: { used: 50 }, + }), + ]); + const weeklyCustom: Partial = { + providerQuotaWarnings: { claude: { weekly: { high: 40, critical: 90 } } }, + }; + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings(weeklyCustom)); + const weekly = renderFloatBar(bootstrap(weeklyCustom)); + await waitFor(() => { + expect(weekly.container.querySelector(".floatbar__pill--warn")).not.toBeNull(); + }); + }); + it("applies critical tone when the provider is exhausted", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("claude", "Claude", 100, { exhausted: true }), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index fb84ef1756..f5f7d591f6 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -24,17 +24,42 @@ import { costPeriodShortLabel } from "../lib/costPeriod"; import { describeProviderState } from "../lib/providerState"; import { resetDescriptionFallback, windowDetailText } from "../lib/usageWindows"; import { localizeProviderText } from "../lib/providerText"; +import { type MetricLane, quotaWarningWindowForLane } from "../lib/metricRowModel"; import { type QuotaWarningSettings, resolveQuotaWarning } from "../lib/quotaWarnings"; import type { BootstrapState, ProviderLocalUsageSummary, ProviderUsageSnapshot, + RateWindowSnapshot, SettingsSnapshot, } from "../types/bridge"; import { FLOAT_BAR_CONFIG_CHANGED_EVENT, resizeFloatBar } from "./api"; import "./FloatBar.css"; -const WEEK_MINUTES = 7 * 24 * 60; +function sameWindow(a: RateWindowSnapshot, b: RateWindowSnapshot): boolean { + return ( + a.usedPercent === b.usedPercent && + a.windowMinutes === b.windowMinutes && + a.resetsAt === b.resetsAt && + a.resetDescription === b.resetDescription && + a.isExhausted === b.isExhausted && + a.isInformational === b.isInformational + ); +} + +/** + * The card lane the selected metric came from, so the pill tone follows the + * same quota warning window as the card markers and the toasts (primary is + * Session, secondary and tertiary are Weekly). `null` for a window that is not + * one of the three lanes, such as an icon fallback extra window. + */ +function selectedMetricLane(provider: ProviderUsageSnapshot): MetricLane | null { + const selected = provider.selectedMetric; + if (sameWindow(selected, provider.primary)) return "primary"; + if (provider.secondary && sameWindow(selected, provider.secondary)) return "secondary"; + if (provider.tertiary && sameWindow(selected, provider.tertiary)) return "tertiary"; + return null; +} function ResetIcon({ size }: { size: number }) { return ( @@ -229,12 +254,17 @@ function ProviderPill({ const displayPercent = showAsUsed ? used : remaining; const displaySuffix = showAsUsed ? usedSuffix : remainingSuffix; const state = describeProviderState(provider.errorState); - // Tone follows this provider's quota warning thresholds for the shown window. - const warning = resolveQuotaWarning( - thresholds, - provider.providerId, - (rateWindow.windowMinutes ?? 0) >= WEEK_MINUTES ? "weekly" : "session", - ); + // Tone follows this provider's quota warning thresholds for the shown lane; + // a window outside the lanes keeps the global thresholds. + const lane = selectedMetricLane(provider); + const warningWindow = lane ? quotaWarningWindowForLane(lane) : null; + const warning = warningWindow + ? resolveQuotaWarning(thresholds, provider.providerId, warningWindow) + : { + enabled: true, + high: thresholds.highUsageThreshold, + critical: thresholds.criticalUsageThreshold, + }; // A window set to Off keeps only the exhausted tone. const highRemaining = warning.enabled ? 100 - warning.high : -1; const critRemaining = warning.enabled ? 100 - warning.critical : -1; diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index 9ae780eb16..40f72c8714 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -33,6 +33,11 @@ const LANE_POLICY: Record = { extra: { thresholdWindow: null, workdayTicks: false, pace: "exactWindow" }, }; +/** The quota warning window a lane follows (`null` for extra windows), as notifications do. */ +export function quotaWarningWindowForLane(lane: MetricLane): "session" | "weekly" | null { + return LANE_POLICY[lane].thresholdWindow; +} + const PACE_DIRECTION: Record = { on_track: "onPace", slightly_ahead: "deficit",