diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index d23cb73b18..2b63e4abfe 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -413,6 +413,8 @@ pub struct SettingsSnapshot { sound_volume: u8, high_usage_threshold: f64, critical_usage_threshold: f64, + provider_usage_thresholds: + std::collections::HashMap, predictive_pace_warning_enabled: bool, tray_icon_mode: &'static str, switcher_shows_icons: bool, @@ -504,6 +506,7 @@ impl From for SettingsSnapshot { sound_volume: settings.sound_volume, high_usage_threshold: settings.high_usage_threshold, critical_usage_threshold: settings.critical_usage_threshold, + provider_usage_thresholds: settings.provider_usage_thresholds, predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled, tray_icon_mode: tray_icon_mode_label(settings.tray_icon_mode), switcher_shows_icons: settings.switcher_shows_icons, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 7ffd9f7275..2ae9b55846 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -426,9 +426,18 @@ fn notify_usage_thresholds( { guard.notification_manager.check_and_notify( provider, + "session", snapshot.primary.used_percent, settings, ); + if let Some(weekly) = &snapshot.secondary { + guard.notification_manager.check_and_notify( + provider, + "weekly", + weekly.used_percent, + settings, + ); + } guard.notification_manager.check_session_transition( provider, snapshot.primary.used_percent, diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index a4a38d8f6a..15d4e558e1 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -17,6 +17,8 @@ pub struct SettingsUpdate { pub sound_volume: Option, pub high_usage_threshold: Option, pub critical_usage_threshold: Option, + pub provider_usage_thresholds: + Option>, pub predictive_pace_warning_enabled: Option, pub tray_icon_mode: Option, pub switcher_shows_icons: Option, @@ -68,6 +70,7 @@ impl SettingsUpdate { || self.codex_custom_sessions_dirs.is_some() || self.high_usage_threshold.is_some() || self.critical_usage_threshold.is_some() + || self.provider_usage_thresholds.is_some() || self.show_as_used.is_some() || self.reset_time_relative.is_some() || self.show_reset_when_exhausted.is_some() @@ -201,6 +204,10 @@ 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(v) = self.predictive_pace_warning_enabled { settings.predictive_pace_warning_enabled = v; } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx index 36ef7376e2..21dd00bd34 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx @@ -105,7 +105,7 @@ export default function DisplayTab({ {/* ── Menu content ─────────────────────────────────────────── */} {mode === "menu" &&
-

Menu Content

+

{t("TabMenu")}

{ expect(set).toHaveBeenCalledWith({ predictivePaceWarningEnabled: true }); }); + + 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: "Codex · ProviderSession high", + }); + + fireEvent.change(input, { target: { value: "80" } }); + fireEvent.blur(input); + expect(set).toHaveBeenLastCalledWith({ + providerUsageThresholds: { "codex:session": { high: 80 } }, + }); + + rerender( + , + ); + const saved = screen.getByRole("spinbutton", { + name: "Codex · ProviderSession high", + }); + fireEvent.change(saved, { target: { value: "" } }); + fireEvent.blur(saved); + expect(set).toHaveBeenLastCalledWith({ providerUsageThresholds: {} }); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index 2533058542..1332ea885d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -3,7 +3,7 @@ import { useLocale } from "../../../hooks/useLocale"; import { invoke } from "@tauri-apps/api/core"; import { playNotificationSound } from "../../../lib/tauri"; import { Field, NumberInput, Select, Toggle } from "../../../components/FormControls"; -import type { Language, LanguageOption } from "../../../types/bridge"; +import type { Language, LanguageOption, UsageThresholdOverride } from "../../../types/bridge"; import type { TabProps } from "../../Settings"; const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ @@ -24,6 +24,66 @@ const REFRESH_CADENCE_OPTIONS: { value: string; label: string }[] = [ { value: "3600", label: "1 hour" }, ]; +function ThresholdOverrideInputs({ + label, + value, + inheritedHigh, + inheritedCritical, + disabled, + onChange, +}: { + label: string; + value: UsageThresholdOverride; + inheritedHigh: number; + inheritedCritical: number; + 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))), + }); + const blurOnEnter = (event: React.KeyboardEvent) => { + if (event.key === "Enter") event.currentTarget.blur(); + }; + return ( + +
+ setHigh(event.target.value)} + onBlur={commit} + onKeyDown={blurOnEnter} + /> + setCritical(event.target.value)} + onBlur={commit} + onKeyDown={blurOnEnter} + /> +
+
+ ); +} + export default function GeneralTab({ mode = "general", settings, @@ -151,6 +211,50 @@ export default function GeneralTab({
)}
+
+ {(["codex", "claude"] as const).flatMap((provider) => + (["provider", "session", "weekly"] as const).map((window) => { + const key = window === "provider" ? provider : `${provider}:${window}`; + const values = settings.providerUsageThresholds ?? {}; + const providerLabel = provider === "codex" ? "Codex" : "Claude"; + 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 }); + } + }} + /> + ); + }), + )} +
} {mode === "notifications" &&
diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 98befdf668..b7d9e8064d 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -215,6 +215,7 @@ export interface SettingsSnapshot { soundVolume: number; highUsageThreshold: number; criticalUsageThreshold: number; + providerUsageThresholds?: Record; predictivePaceWarningEnabled: boolean; trayIconMode: TrayIconMode; switcherShowsIcons: boolean; @@ -275,6 +276,7 @@ export interface SettingsUpdate { soundVolume?: number; highUsageThreshold?: number; criticalUsageThreshold?: number; + providerUsageThresholds?: Record; predictivePaceWarningEnabled?: boolean; trayIconMode?: TrayIconMode; switcherShowsIcons?: boolean; @@ -315,6 +317,11 @@ export interface SettingsUpdate { floatBarShowResetInline?: boolean; } +export interface UsageThresholdOverride { + high?: number; + critical?: number; +} + export interface BootstrapState { contractVersion: string; providers: ProviderCatalogEntry[]; diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index 61fa723721..b7462349a9 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -220,6 +220,7 @@ impl NotificationManager { pub fn check_and_notify( &mut self, provider: ProviderId, + window: &str, used_percent: f64, settings: &Settings, ) { @@ -227,11 +228,12 @@ impl NotificationManager { return; } + let thresholds = settings.usage_thresholds(provider, window); let notification_type = if used_percent >= 100.0 { Some(NotificationType::Exhausted) - } else if used_percent >= settings.critical_usage_threshold { + } else if used_percent >= thresholds.critical { Some(NotificationType::CriticalUsage) - } else if used_percent >= settings.high_usage_threshold { + } else if used_percent >= thresholds.high { Some(NotificationType::HighUsage) } else { // Reset notifications if usage dropped diff --git a/rust/src/settings.rs b/rust/src/settings.rs index e440c85860..30472ad7e3 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -66,6 +66,8 @@ 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, @@ -365,6 +367,7 @@ impl Default for Settings { sound_volume: 100, 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 switcher_shows_icons: true, diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 4e2c2a5574..8feefb288f 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -24,6 +24,7 @@ pub(super) struct RawSettings { sound_volume: u8, high_usage_threshold: f64, critical_usage_threshold: f64, + provider_usage_thresholds: HashMap, merge_tray_icons: bool, tray_icon_mode: TrayIconMode, #[serde(default = "default_true")] @@ -154,6 +155,7 @@ impl Default for RawSettings { sound_volume: s.sound_volume, high_usage_threshold: s.high_usage_threshold, critical_usage_threshold: s.critical_usage_threshold, + provider_usage_thresholds: HashMap::new(), merge_tray_icons: s.merge_tray_icons, tray_icon_mode: s.tray_icon_mode, switcher_shows_icons: s.switcher_shows_icons, @@ -441,6 +443,9 @@ impl From for Settings { sound_volume: raw.sound_volume, 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, switcher_shows_icons: raw.switcher_shows_icons, diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 5be477359c..f4e79243a8 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -27,6 +27,61 @@ fn new_warning_and_reset_settings_are_backward_compatible() { assert!(!loaded.predictive_pace_warning_enabled); } +#[test] +fn usage_thresholds_inherit_from_window_provider_and_global_levels() { + 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), + }, + ); + + assert_eq!( + settings.usage_thresholds(ProviderId::Codex, "weekly"), + UsageThresholds { + high: 75.0, + critical: 95.0, + } + ); + assert_eq!( + settings.usage_thresholds(ProviderId::Claude, "session"), + UsageThresholds { + high: 70.0, + critical: 90.0, + } + ); +} + +#[test] +fn empty_and_out_of_range_threshold_overrides_are_normalized_on_load() { + let loaded: Settings = serde_json::from_str( + r#"{ + "provider_usage_thresholds": { + "codex": {"high": 120.0}, + "claude": {}, + "codex:weekly": {"critical": -10.0} + } + }"#, + ) + .expect("parse settings"); + + assert_eq!(loaded.provider_usage_thresholds.len(), 2); + assert_eq!(loaded.provider_usage_thresholds["codex"].high, Some(100.0)); + assert_eq!( + loaded.provider_usage_thresholds["codex:weekly"].critical, + Some(0.0) + ); +} + #[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 753f2affec..f3736fbd60 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -1,5 +1,60 @@ use super::*; +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct UsageThresholdOverride { + pub high: Option, + pub critical: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct UsageThresholds { + pub high: f64, + pub critical: f64, +} + +pub fn normalize_usage_threshold_overrides( + values: HashMap, +) -> HashMap { + let known = crate::core::cli_name_map(); + values + .into_iter() + .filter_map(|(key, mut value)| { + let (provider, window) = key + .split_once(':') + .map_or((key.as_str(), None), |(provider, window)| { + (provider, Some(window)) + }); + if !known.contains_key(provider) + || window.is_some_and(|window| !matches!(window, "session" | "weekly")) + { + return None; + } + value.high = value.high.map(|number| number.clamp(0.0, 100.0)); + value.critical = value.critical.map(|number| number.clamp(0.0, 100.0)); + (value.high.is_some() || value.critical.is_some()).then_some((key, value)) + }) + .collect() +} + +impl Settings { + 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); + 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), + } + } +} + /// UI language for the application #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")]