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
4 changes: 4 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,7 @@ pub struct SettingsSnapshot {
sound_volume: u8,
high_usage_threshold: f64,
critical_usage_threshold: f64,
predictive_pace_warning_enabled: bool,
tray_icon_mode: &'static str,
switcher_shows_icons: bool,
menu_bar_shows_highest_usage: bool,
Expand All @@ -421,6 +422,7 @@ pub struct SettingsSnapshot {
show_all_token_accounts_in_menu: bool,
enable_animations: bool,
reset_time_relative: bool,
show_reset_when_exhausted: bool,
menu_bar_display_mode: String,
hide_personal_info: bool,
update_channel: &'static str,
Expand Down Expand Up @@ -500,6 +502,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,
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,
menu_bar_shows_highest_usage: settings.menu_bar_shows_highest_usage,
Expand All @@ -508,6 +511,7 @@ impl From<Settings> for SettingsSnapshot {
show_all_token_accounts_in_menu: settings.show_all_token_accounts_in_menu,
enable_animations: settings.enable_animations,
reset_time_relative: settings.reset_time_relative,
show_reset_when_exhausted: settings.show_reset_when_exhausted,
menu_bar_display_mode: settings.menu_bar_display_mode,
hide_personal_info: settings.hide_personal_info,
update_channel: update_channel_label(settings.update_channel),
Expand Down
157 changes: 155 additions & 2 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ async fn do_refresh_providers_with_policy(
await_provider_refreshes(handles).await;

let error_count = finish_provider_refresh(&state)?;
update_tray_and_notifications(app, &state, &inputs.settings)?;
update_tray_and_notifications(app, &state, &inputs.settings, &inputs.token_accounts)?;

events::emit_refresh_complete(app, enabled_count, error_count);

Expand Down Expand Up @@ -393,20 +393,22 @@ fn update_tray_and_notifications(
app: &tauri::AppHandle,
state: &tauri::State<'_, Mutex<AppState>>,
settings: &Settings,
token_accounts: &HashMap<ProviderId, ProviderAccountData>,
) -> Result<(), String> {
let cached = {
let guard = state.lock().map_err(|e| e.to_string())?;
guard.provider_cache.clone()
};
crate::tray_bridge::update_tray_status_items(app, &cached);
crate::tray_bridge::update_tray_icon_and_tooltip(app, &cached);
notify_usage_thresholds(state, settings, &cached);
notify_usage_thresholds(state, settings, token_accounts, &cached);
Ok(())
}

fn notify_usage_thresholds(
state: &tauri::State<'_, Mutex<AppState>>,
settings: &Settings,
token_accounts: &HashMap<ProviderId, ProviderAccountData>,
cached: &[ProviderUsageSnapshot],
) {
let cli_map = codexbar::core::cli_name_map();
Expand All @@ -425,11 +427,108 @@ fn notify_usage_thresholds(
snapshot.primary.used_percent,
settings,
);
notify_predictive_pace(
&mut guard.notification_manager,
provider,
snapshot,
token_accounts,
settings,
);
}
}
}
}

fn notify_predictive_pace(
manager: &mut codexbar::notifications::NotificationManager,
provider: ProviderId,
snapshot: &ProviderUsageSnapshot,
token_accounts: &HashMap<ProviderId, ProviderAccountData>,
settings: &Settings,
) {
let enabled = settings.show_notifications && settings.predictive_pace_warning_enabled;
manager.set_predictive_warnings_enabled(provider, enabled);
if !enabled || !matches!(provider, ProviderId::Claude | ProviderId::Codex) {
return;
}

let token_account_id = token_accounts
.get(&provider)
.and_then(ProviderAccountData::active_account)
.map(|account| account.id);
let Some(identity) = predictive_warning_identity(
provider,
&snapshot.source_label,
snapshot.account_email.as_deref(),
token_account_id,
) else {
return;
};
let observed_at = chrono::DateTime::parse_from_rfc3339(&snapshot.updated_at)
.ok()
.map(|date| date.with_timezone(&chrono::Utc));

for (warning_window, window, default_window_minutes) in [
(
codexbar::notifications::PredictiveWarningWindow::Session,
Some(&snapshot.primary),
300,
),
(
codexbar::notifications::PredictiveWarningWindow::Weekly,
snapshot.secondary.as_ref(),
10080,
),
] {
let Some(window) = window else {
continue;
};
let rate_window = RateWindow::with_details(
window.used_percent,
window.window_minutes,
window
.resets_at
.as_deref()
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|date| date.with_timezone(&chrono::Utc)),
window.reset_description.clone(),
);
let Some(pace) =
codexbar::core::UsagePace::weekly(&rate_window, observed_at, default_window_minutes)
else {
continue;
};
manager.check_predictive_pace(
provider,
&identity,
warning_window,
&rate_window,
&pace,
settings,
);
}
}

fn predictive_warning_identity(
provider: ProviderId,
source_label: &str,
account_email: Option<&str>,
token_account_id: Option<uuid::Uuid>,
) -> Option<String> {
if !matches!(provider, ProviderId::Claude | ProviderId::Codex) {
return None;
}
if let Some(id) = token_account_id {
return Some(format!("token-account:{}", id.as_hyphenated()));
}
let source = source_label.trim().to_ascii_lowercase();
let account = account_email?.trim().to_ascii_lowercase();
if source.is_empty() || account.is_empty() {
return None;
}
Some(format!("{source}:{account}"))
}

#[tauri::command]
pub async fn refresh_providers(app: tauri::AppHandle) -> Result<(), String> {
do_refresh_providers(&app).await
Expand All @@ -452,5 +551,59 @@ pub fn get_cached_providers(
for snapshot in &mut snapshots {
super::filter_hidden_codex_spark_rows(snapshot, spark_usage_visible);
}

snapshots
}

#[cfg(test)]
mod predictive_warning_tests {
use super::*;

#[test]
fn predictive_warning_identity_keeps_claude_sources_and_token_accounts_separate() {
let account_id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap();

assert_eq!(
predictive_warning_identity(
ProviderId::Claude,
"cli",
Some("Person@Example.com"),
None,
)
.as_deref(),
Some("cli:person@example.com")
);
assert_eq!(
predictive_warning_identity(
ProviderId::Claude,
"oauth",
Some("Person@Example.com"),
None,
)
.as_deref(),
Some("oauth:person@example.com")
);
assert_eq!(
predictive_warning_identity(
ProviderId::Claude,
"oauth",
Some("Person@Example.com"),
Some(account_id),
)
.as_deref(),
Some("token-account:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
);
}

#[test]
fn predictive_warning_identity_skips_unidentified_accounts() {
assert_eq!(
predictive_warning_identity(ProviderId::Claude, "oauth", None, None),
None
);
assert_eq!(
predictive_warning_identity(ProviderId::Codex, "cli", Some(" "), None),
None
);
}
}
9 changes: 9 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,7 @@ pub struct SettingsUpdate {
pub sound_volume: Option<u8>,
pub high_usage_threshold: Option<f64>,
pub critical_usage_threshold: Option<f64>,
pub predictive_pace_warning_enabled: Option<bool>,
pub tray_icon_mode: Option<String>,
pub switcher_shows_icons: Option<bool>,
pub menu_bar_shows_highest_usage: Option<bool>,
Expand All @@ -25,6 +26,7 @@ pub struct SettingsUpdate {
pub show_all_token_accounts_in_menu: Option<bool>,
pub enable_animations: Option<bool>,
pub reset_time_relative: Option<bool>,
pub show_reset_when_exhausted: Option<bool>,
pub menu_bar_display_mode: Option<String>,
pub hide_personal_info: Option<bool>,
pub update_channel: Option<String>,
Expand Down Expand Up @@ -62,6 +64,7 @@ impl SettingsUpdate {
|| self.critical_usage_threshold.is_some()
|| self.show_as_used.is_some()
|| self.reset_time_relative.is_some()
|| self.show_reset_when_exhausted.is_some()
}

fn rebuilds_tray_menu(&self) -> bool {
Expand Down Expand Up @@ -149,6 +152,9 @@ impl SettingsUpdate {
if let Some(v) = self.reset_time_relative {
settings.reset_time_relative = v;
}
if let Some(v) = self.show_reset_when_exhausted {
settings.show_reset_when_exhausted = v;
}
if let Some(v) = self.menu_bar_display_mode.clone() {
settings.menu_bar_display_mode = v;
}
Expand Down Expand Up @@ -189,6 +195,9 @@ impl SettingsUpdate {
if let Some(v) = self.critical_usage_threshold {
settings.critical_usage_threshold = v.clamp(0.0, 100.0);
}
if let Some(v) = self.predictive_pace_warning_enabled {
settings.predictive_pace_warning_enabled = v;
}
self
}

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function settings(overrides: Partial<SettingsSnapshot> = {}): SettingsSnapshot {
soundVolume: 100,
highUsageThreshold: 70,
criticalUsageThreshold: 90,
predictivePaceWarningEnabled: false,
trayIconMode: "single",
switcherShowsIcons: true,
menuBarShowsHighestUsage: false,
Expand All @@ -79,6 +80,7 @@ function settings(overrides: Partial<SettingsSnapshot> = {}): SettingsSnapshot {
showAllTokenAccountsInMenu: false,
enableAnimations: true,
resetTimeRelative: true,
showResetWhenExhausted: false,
menuBarDisplayMode: "detailed",
hidePersonalInfo: false,
updateChannel: "stable",
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src/components/FormControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ export function Toggle({
checked,
onChange,
label,
ariaLabel,
disabled,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label?: string;
ariaLabel?: string;
disabled?: boolean;
}) {
const input = (
<input
type="checkbox"
className="toggle"
checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop-tauri/src/components/MenuCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ function provider(

function renderCard(
snapshot: ProviderUsageSnapshot,
opts: { showAsUsed?: boolean; onLayoutChange?: () => void } = {},
opts: {
showAsUsed?: boolean;
showResetWhenExhausted?: boolean;
onLayoutChange?: () => void;
} = {},
) {
return render(
<LocaleProvider>
Expand All @@ -87,6 +91,7 @@ function renderCard(
hideEmail={false}
resetTimeRelative={true}
showAsUsed={opts.showAsUsed}
showResetWhenExhausted={opts.showResetWhenExhausted}
onLayoutChange={opts.onLayoutChange}
/>
</LocaleProvider>,
Expand All @@ -110,6 +115,7 @@ describe("MenuCard", () => {
PanelThirtyDayTokens: "30d tokens",
PanelTodayBudget: "today",
PanelUsedSuffix: "used",
ResetsInHoursMinutes: "Resets in {}h {}m",
WayfinderGatewayStatus: "Gateway",
WayfinderModels: "Models",
WayfinderRequests: "Requests",
Expand Down Expand Up @@ -178,6 +184,24 @@ describe("MenuCard", () => {
expect(fill?.style.width).toBe("100%");
});

it("replaces an exhausted percentage with a future reset countdown", async () => {
const snapshot = provider(null, 100, { exhausted: true });
snapshot.primary.resetsAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();

renderCard(snapshot, { showResetWhenExhausted: true });

expect(await screen.findByText(/Resets in 0h/)).toBeInTheDocument();
expect(screen.queryByText("0% left")).not.toBeInTheDocument();
});

it("keeps an exhausted percentage without a concrete future reset", async () => {
renderCard(provider(null, 100, { exhausted: true, resetDescription: "in 2h" }), {
showResetWhenExhausted: true,
});

expect(await screen.findByText("0% left")).toBeInTheDocument();
});

it("renders additional Copilot budget windows", async () => {
const snapshot = provider(null, 20);
snapshot.providerId = "copilot";
Expand Down
Loading
Loading