Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ pub struct SettingsSnapshot {
sound_volume: u8,
high_usage_threshold: f64,
critical_usage_threshold: f64,
provider_usage_thresholds:
std::collections::HashMap<String, codexbar::settings::UsageThresholdOverride>,
predictive_pace_warning_enabled: bool,
tray_icon_mode: &'static str,
switcher_shows_icons: bool,
Expand Down Expand Up @@ -504,6 +506,7 @@ impl From<Settings> 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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub struct SettingsUpdate {
pub sound_volume: Option<u8>,
pub high_usage_threshold: Option<f64>,
pub critical_usage_threshold: Option<f64>,
pub provider_usage_thresholds:
Option<std::collections::HashMap<String, codexbar::settings::UsageThresholdOverride>>,
pub predictive_pace_warning_enabled: Option<bool>,
pub tray_icon_mode: Option<String>,
pub switcher_shows_icons: Option<bool>,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export default function DisplayTab({

{/* ── Menu content ─────────────────────────────────────────── */}
{mode === "menu" && <section className="settings-section">
<h3 className="settings-section__title">Menu Content</h3>
<h3 className="settings-section__title">{t("TabMenu")}</h3>
<div className="settings-section__group">
<Field
label={`${t("WindowScaleLabel")} (${windowScaleDraft}%)`}
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,38 @@ describe("GeneralTab language picker", () => {

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(
<GeneralTab mode="notifications" settings={settings} set={set} saving={false} />,
);
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(
<GeneralTab
mode="notifications"
settings={{
...settings,
providerUsageThresholds: { "codex:session": { high: 80 } },
}}
set={set}
saving={false}
/>,
);
const saved = screen.getByRole("spinbutton", {
name: "Codex · ProviderSession high",
});
fireEvent.change(saved, { target: { value: "" } });
fireEvent.blur(saved);
expect(set).toHaveBeenLastCalledWith({ providerUsageThresholds: {} });
});
});
106 changes: 105 additions & 1 deletion apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand All @@ -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<HTMLInputElement>) => {
if (event.key === "Enter") event.currentTarget.blur();
};
return (
<Field label={label}>
<div className="settings-inline-fields">
<input
type="number"
value={high}
min={0}
max={100}
disabled={disabled}
placeholder={String(inheritedHigh)}
aria-label={`${label} high`}
onChange={(event) => setHigh(event.target.value)}
onBlur={commit}
onKeyDown={blurOnEnter}
/>
<input
type="number"
value={critical}
min={0}
max={100}
disabled={disabled}
placeholder={String(inheritedCritical)}
aria-label={`${label} critical`}
onChange={(event) => setCritical(event.target.value)}
onBlur={commit}
onKeyDown={blurOnEnter}
/>
</div>
</Field>
);
}

export default function GeneralTab({
mode = "general",
settings,
Expand Down Expand Up @@ -151,6 +211,50 @@ export default function GeneralTab({
</Field>
)}
</div>
<div className="settings-section__group">
{(["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 (
<ThresholdOverrideInputs
key={key}
label={
window === "provider"
? providerLabel
: `${providerLabel} · ${t(window === "session" ? "ProviderSession" : "ProviderWeekly")}`
}
value={values[key] ?? {}}
inheritedHigh={
window === "provider"
? settings.highUsageThreshold
: values[provider]?.high ?? settings.highUsageThreshold
}
inheritedCritical={
window === "provider"
? settings.criticalUsageThreshold
: values[provider]?.critical ?? settings.criticalUsageThreshold
}
disabled={saving}
onChange={(value) => {
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 });
}
}}
/>
);
}),
)}
</div>
</section>}

{mode === "notifications" && <section className="settings-section">
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop-tauri/src/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export interface SettingsSnapshot {
soundVolume: number;
highUsageThreshold: number;
criticalUsageThreshold: number;
providerUsageThresholds?: Record<string, UsageThresholdOverride>;
predictivePaceWarningEnabled: boolean;
trayIconMode: TrayIconMode;
switcherShowsIcons: boolean;
Expand Down Expand Up @@ -275,6 +276,7 @@ export interface SettingsUpdate {
soundVolume?: number;
highUsageThreshold?: number;
criticalUsageThreshold?: number;
providerUsageThresholds?: Record<string, UsageThresholdOverride>;
predictivePaceWarningEnabled?: boolean;
trayIconMode?: TrayIconMode;
switcherShowsIcons?: boolean;
Expand Down Expand Up @@ -315,6 +317,11 @@ export interface SettingsUpdate {
floatBarShowResetInline?: boolean;
}

export interface UsageThresholdOverride {
high?: number;
critical?: number;
}

export interface BootstrapState {
contractVersion: string;
providers: ProviderCatalogEntry[];
Expand Down
6 changes: 4 additions & 2 deletions rust/src/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,18 +220,20 @@ impl NotificationManager {
pub fn check_and_notify(
&mut self,
provider: ProviderId,
window: &str,
used_percent: f64,
settings: &Settings,
) {
if !settings.show_notifications {
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
Expand Down
3 changes: 3 additions & 0 deletions rust/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ pub struct Settings {
/// Critical usage threshold for alerts (percentage)
pub critical_usage_threshold: f64,

pub provider_usage_thresholds: HashMap<String, UsageThresholdOverride>,

/// Merge mode: show all enabled providers in a single tray icon
pub merge_tray_icons: bool,

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions rust/src/settings/raw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub(super) struct RawSettings {
sound_volume: u8,
high_usage_threshold: f64,
critical_usage_threshold: f64,
provider_usage_thresholds: HashMap<String, UsageThresholdOverride>,
merge_tray_icons: bool,
tray_icon_mode: TrayIconMode,
#[serde(default = "default_true")]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -441,6 +443,9 @@ impl From<RawSettings> 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,
Expand Down
55 changes: 55 additions & 0 deletions rust/src/settings/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading