diff --git a/Cargo.lock b/Cargo.lock index 518016cbfe..9a13634079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -593,6 +593,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -652,11 +662,13 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "chrono-tz", "clap", "dirs", "fluent-templates", "futures", "global-hotkey", + "iana-time-zone", "image", "keyring", "mockito", @@ -3192,6 +3204,15 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -3340,6 +3361,15 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.2", +] + [[package]] name = "phf_shared" version = "0.13.1" diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 4c9626e5d5..f80945326c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -106,6 +106,17 @@ pub struct ProviderUsageSnapshot { pub fetch_duration_ms: Option, } +pub(crate) fn filter_hidden_codex_spark_rows( + snapshot: &mut ProviderUsageSnapshot, + spark_usage_visible: bool, +) { + if snapshot.provider_id == "codex" && !spark_usage_visible { + snapshot + .extra_rate_windows + .retain(|extra| !matches!(extra.id.as_str(), "codex-spark" | "codex-spark-weekly")); + } +} + pub(crate) fn pace_stage_str(stage: codexbar::core::PaceStage) -> &'static str { use codexbar::core::PaceStage; match stage { @@ -420,6 +431,7 @@ pub struct SettingsSnapshot { tray_scale_percent: u16, powertoys_status_pipe_enabled: bool, claude_avoid_keychain_prompts: bool, + codex_spark_usage_visible: bool, disable_keychain_access: bool, provider_metrics: std::collections::HashMap, float_bar_enabled: bool, @@ -456,6 +468,7 @@ pub fn get_settings_snapshot() -> SettingsSnapshot { impl From for SettingsSnapshot { fn from(settings: Settings) -> Self { let avoid_keychain_prompts = settings.claude_avoid_keychain_prompts(); + let codex_spark_usage_visible = settings.codex_spark_usage_visible(); let provider_order = settings.provider_display_order_names(); let enabled_providers = provider_order @@ -503,6 +516,7 @@ impl From for SettingsSnapshot { tray_scale_percent: settings.tray_scale_percent, powertoys_status_pipe_enabled: settings.powertoys_status_pipe_enabled, claude_avoid_keychain_prompts: avoid_keychain_prompts, + codex_spark_usage_visible, disable_keychain_access: settings.disable_keychain_access, provider_metrics, float_bar_enabled: settings.float_bar_enabled, 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 08eabd5df0..9b7b2cbf8d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -116,25 +116,30 @@ pub fn get_provider_detail( .iter() .find(|s| s.provider_id == detail.id) { - detail.email = snap.account_email.clone(); - detail.plan = snap.plan_name.clone(); - detail.organization = snap.account_organization.clone(); - detail.source_label = if snap.source_label.is_empty() { + let mut snapshot = snap.clone(); + super::filter_hidden_codex_spark_rows( + &mut snapshot, + Settings::load().codex_spark_usage_visible(), + ); + detail.email = snapshot.account_email.clone(); + detail.plan = snapshot.plan_name.clone(); + detail.organization = snapshot.account_organization.clone(); + detail.source_label = if snapshot.source_label.is_empty() { None } else { - Some(snap.source_label.clone()) + Some(snapshot.source_label.clone()) }; - detail.last_updated = Some(snap.updated_at.clone()); - if snap.error.is_none() { - detail.session = Some(snap.primary.clone()); - detail.weekly = snap.secondary.clone(); - detail.model_specific = snap.model_specific.clone(); - detail.tertiary = snap.tertiary.clone(); - detail.extra_rate_windows = snap.extra_rate_windows.clone(); - detail.cost = snap.cost.clone(); - detail.pace = snap.pace.clone(); + detail.last_updated = Some(snapshot.updated_at.clone()); + if snapshot.error.is_none() { + detail.session = Some(snapshot.primary.clone()); + detail.weekly = snapshot.secondary.clone(); + detail.model_specific = snapshot.model_specific.clone(); + detail.tertiary = snapshot.tertiary.clone(); + detail.extra_rate_windows = snapshot.extra_rate_windows.clone(); + detail.cost = snapshot.cost.clone(); + detail.pace = snapshot.pace.clone(); } - detail.last_error = snap.error.clone(); + detail.last_error = snapshot.error.clone(); detail.has_snapshot = true; } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 2ab5d8eed9..ff0b1c8d93 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -268,14 +268,14 @@ async fn refresh_provider(app: tauri::AppHandle, id: ProviderId, ctx: FetchConte let snapshot = fetch_provider_snapshot(id, ctx).await; let state = app.state::>(); - if let Ok(mut guard) = state.lock() { + let snapshot = if let Ok(mut guard) = state.lock() { let snapshot = preserve_last_good_transient_failure(&mut guard, id, snapshot); upsert_provider_cache(&mut guard.provider_cache, snapshot.clone()); - drop(guard); - events::emit_provider_updated(&app, &snapshot); + snapshot } else { - events::emit_provider_updated(&app, &snapshot); - } + snapshot + }; + events::emit_provider_updated(&app, &snapshot); } pub(super) fn preserve_last_good_transient_failure( @@ -441,8 +441,13 @@ pub async fn refresh_providers_if_stale(app: tauri::AppHandle) -> Result<(), Str pub fn get_cached_providers( state: tauri::State<'_, Mutex>, ) -> Vec { - state + let mut snapshots = state .lock() .map(|guard| guard.provider_cache.clone()) - .unwrap_or_default() + .unwrap_or_default(); + let spark_usage_visible = Settings::load().codex_spark_usage_visible(); + for snapshot in &mut snapshots { + super::filter_hidden_codex_spark_rows(snapshot, spark_usage_visible); + } + snapshots } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 7af6aa638f..115a796297 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -38,6 +38,7 @@ pub struct SettingsUpdate { pub tray_scale_percent: Option, pub powertoys_status_pipe_enabled: Option, pub claude_avoid_keychain_prompts: Option, + pub codex_spark_usage_visible: Option, pub disable_keychain_access: Option, /// Map of provider CLI name → metric preference label. pub provider_metrics: Option>, @@ -76,6 +77,7 @@ impl SettingsUpdate { || self.reset_time_relative.is_some() || self.menu_bar_display_mode.is_some() || self.provider_metrics.is_some() + || self.codex_spark_usage_visible.is_some() || self.enabled_providers.is_some() || self.ui_language.is_some() } @@ -219,6 +221,9 @@ impl SettingsUpdate { if let Some(v) = self.claude_avoid_keychain_prompts { settings.set_claude_avoid_keychain_prompts(v); } + if let Some(v) = self.codex_spark_usage_visible { + settings.set_codex_spark_usage_visible(v); + } if let Some(v) = self.disable_keychain_access { settings.disable_keychain_access = v; if v { @@ -334,7 +339,6 @@ pub async fn update_settings( if refresh_tray_presentation { crate::tray_bridge::refresh_tray_presentation(&app); } - // Notify other windows (PopOut dashboard, tray, float bar) so they re-read // settings live — e.g. the Display tab's window-scale slider takes effect // immediately instead of only after the PopOut is reopened. diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 412a491764..fff68c06b3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use super::{ - ProviderSummary, ProviderUsageSnapshot, provider_cookie_source_lookup, provider_region_lookup, - validate_external_url, validate_surface_target, + NamedRateWindowSnapshot, ProviderSummary, ProviderUsageSnapshot, provider_cookie_source_lookup, + provider_region_lookup, validate_external_url, validate_surface_target, }; use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; @@ -695,6 +695,35 @@ fn provider_cache_upsert_replaces_existing_provider() { assert_eq!(cache[0].error.as_deref(), Some("new")); } +#[test] +fn hiding_codex_spark_rows_preserves_other_extra_usage() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + cost: None, + source_label: "CLI".to_string(), + }; + let mut snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result); + snapshot.extra_rate_windows = vec![ + NamedRateWindowSnapshot { + id: "codex-spark".to_string(), + title: "Codex Spark 5-hour".to_string(), + window: snapshot.primary.clone(), + }, + NamedRateWindowSnapshot { + id: "credits".to_string(), + title: "Credits".to_string(), + window: snapshot.primary.clone(), + }, + ]; + + super::filter_hidden_codex_spark_rows(&mut snapshot, false); + + assert_eq!(snapshot.extra_rate_windows.len(), 1); + assert_eq!(snapshot.extra_rate_windows[0].id, "credits"); +} + #[test] fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); diff --git a/apps/desktop-tauri/src-tauri/src/events.rs b/apps/desktop-tauri/src-tauri/src/events.rs index 64ea19db68..13bde5f6b6 100644 --- a/apps/desktop-tauri/src-tauri/src/events.rs +++ b/apps/desktop-tauri/src-tauri/src/events.rs @@ -58,6 +58,11 @@ pub fn emit_surface_mode_changed( } pub fn emit_provider_updated(app: &AppHandle, snapshot: &ProviderUsageSnapshot) { + let mut snapshot = snapshot.clone(); + crate::commands::filter_hidden_codex_spark_rows( + &mut snapshot, + codexbar::settings::Settings::load().codex_spark_usage_visible(), + ); let _ = app.emit(PROVIDER_UPDATED, snapshot); } diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index a4df7a1d21..74c34cc8b5 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -370,7 +370,9 @@ pub(crate) fn rebuild_tray_menu(app: &AppHandle) { let settings = Settings::load(); let status_labels = if let Some(st) = app.try_state::>() { let guard = st.lock().unwrap(); - status_labels_for_settings(&settings, &guard.provider_cache, settings.ui_language) + let snapshots = + presentation_snapshots(&guard.provider_cache, settings.codex_spark_usage_visible()); + status_labels_for_settings(&settings, &snapshots, settings.ui_language) } else { vec![] }; @@ -388,7 +390,8 @@ pub fn update_tray_status_items( ) { let catalog = crate::commands::get_provider_catalog(); let settings = Settings::load(); - let status_labels = status_labels_for_settings(&settings, snapshots, settings.ui_language); + let snapshots = presentation_snapshots(snapshots, settings.codex_spark_usage_visible()); + let status_labels = status_labels_for_settings(&settings, &snapshots, settings.ui_language); if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) && let Some(tray) = app.tray_by_id("codexbar-main") @@ -403,11 +406,21 @@ pub(crate) fn refresh_tray_presentation(app: &AppHandle) { .try_state::>() .map(|st| st.lock().unwrap().provider_cache.clone()) .unwrap_or_default(); - update_tray_status_items(app, &snapshots); update_tray_icon_and_tooltip(app, &snapshots); } +fn presentation_snapshots( + snapshots: &[crate::commands::ProviderUsageSnapshot], + spark_usage_visible: bool, +) -> Vec { + let mut snapshots = snapshots.to_vec(); + for snapshot in &mut snapshots { + crate::commands::filter_hidden_codex_spark_rows(snapshot, spark_usage_visible); + } + snapshots +} + /// Update the tray icon pixels and tooltip text to reflect current provider usage. /// /// Behaviour mirrors egui's `choose_tray_update_plan` (rust/src/native_ui/app.rs): @@ -429,7 +442,8 @@ pub fn update_tray_icon_and_tooltip( // ── Icon ───────────────────────────────────────────────────────────── let settings = Settings::load(); - let ordered_snapshots = ordered_snapshot_refs(&settings, snapshots); + let snapshots = presentation_snapshots(snapshots, settings.codex_spark_usage_visible()); + let ordered_snapshots = ordered_snapshot_refs(&settings, &snapshots); let ok_snapshots: Vec<_> = ordered_snapshots .iter() .copied() @@ -458,7 +472,7 @@ pub fn update_tray_icon_and_tooltip( let _ = tray.set_icon(Some(icon)); // ── Tooltip ─────────────────────────────────────────────────────────── - let tooltip = build_tooltip(snapshots, settings.ui_language); + let tooltip = build_tooltip(&snapshots, settings.ui_language); let _ = tray.set_tooltip(Some(tooltip)); } diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 45dcade04c..68444ba103 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -94,6 +94,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: false, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 8a124be175..cfed699510 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -121,6 +121,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: false, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: true, diff --git a/apps/desktop-tauri/src/hooks/useProviders.test.tsx b/apps/desktop-tauri/src/hooks/useProviders.test.tsx index 176fba99f3..81c1f9f336 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.test.tsx +++ b/apps/desktop-tauri/src/hooks/useProviders.test.tsx @@ -130,6 +130,138 @@ describe("useProviders", () => { ]); }); + it("reloads cached provider presentation when settings change", async () => { + const visible = provider("codex"); + visible.extraRateWindows = [ + { + id: "codex-spark", + title: "Spark", + window: visible.primary, + }, + ]; + tauriMocks.getCachedProviders.mockResolvedValueOnce([visible]); + + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await waitFor(() => expect(result.current.providers[0]?.extraRateWindows).toHaveLength(1)); + + tauriMocks.getCachedProviders.mockResolvedValueOnce([provider("codex")]); + act(() => emitProviderEvent("settings-changed", undefined)); + + await waitFor(() => expect(result.current.providers[0]?.extraRateWindows).toHaveLength(0)); + expect(tauriMocks.getCachedProviders).toHaveBeenCalledTimes(2); + }); + + it("discards queued pre-change snapshots before reloading settings presentation", async () => { + vi.useFakeTimers(); + try { + tauriMocks.getCachedProviders.mockResolvedValueOnce([provider("codex")]); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await act(async () => {}); + + const visible = provider("codex", 30); + visible.extraRateWindows = [ + { id: "codex-spark", title: "Spark", window: visible.primary }, + ]; + tauriMocks.getCachedProviders.mockResolvedValueOnce([provider("codex", 40)]); + + await act(async () => { + emitProviderEvent("provider-updated", visible); + emitProviderEvent("settings-changed", undefined); + }); + await act(async () => { + vi.advanceTimersByTime(80); + }); + + expect(result.current.providers[0]?.primary.usedPercent).toBe(40); + expect(result.current.providers[0]?.extraRateWindows).toHaveLength(0); + + act(() => emitProviderEvent("provider-updated", provider("codex", 50))); + act(() => vi.advanceTimersByTime(80)); + expect(result.current.providers[0]?.primary.usedPercent).toBe(50); + } finally { + vi.useRealTimers(); + } + }); + + it("applies post-change events after a pending settings cache reload", async () => { + let resolveReload!: (snapshots: ProviderUsageSnapshot[]) => void; + tauriMocks.getCachedProviders + .mockResolvedValueOnce([provider("codex", 20)]) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveReload = resolve; + }), + ); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await waitFor(() => expect(result.current.providers[0]?.primary.usedPercent).toBe(20)); + + act(() => { + emitProviderEvent("settings-changed", undefined); + emitProviderEvent("provider-updated", provider("codex", 50)); + }); + await act(async () => { + resolveReload([provider("codex", 40)]); + }); + + expect(result.current.providers[0]?.primary.usedPercent).toBe(50); + }); + + it("ignores an initial cache response superseded by a settings reload", async () => { + let resolveInitial!: (snapshots: ProviderUsageSnapshot[]) => void; + tauriMocks.getCachedProviders + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInitial = resolve; + }), + ) + .mockResolvedValueOnce([provider("codex", 40)]); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await waitFor(() => + expect(eventMocks.listeners.get("settings-changed")).toHaveLength(1), + ); + + await act(async () => { + emitProviderEvent("settings-changed", undefined); + emitProviderEvent("provider-updated", provider("codex", 50)); + }); + await act(async () => { + resolveInitial([provider("codex", 20)]); + }); + + expect(result.current.providers[0]?.primary.usedPercent).toBe(50); + }); + + it("does not leave event batching paused when an effect restarts during reload", async () => { + vi.useFakeTimers(); + try { + tauriMocks.getCachedProviders + .mockResolvedValueOnce([provider("codex", 20)]) + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce([provider("codex", 30)]); + const { result, rerender } = renderHook( + ({ delay }) => + useProviders({ + refreshOnMount: false, + initialRefreshDelayMs: delay, + }), + { initialProps: { delay: 0 } }, + ); + await act(async () => {}); + + act(() => emitProviderEvent("settings-changed", undefined)); + rerender({ delay: 1 }); + await act(async () => {}); + act(() => emitProviderEvent("provider-updated", provider("codex", 50))); + act(() => vi.advanceTimersByTime(80)); + + expect(result.current.providers[0]?.primary.usedPercent).toBe(50); + } finally { + vi.useRealTimers(); + } + }); + it("manual refresh uses forced refresh", async () => { const { result } = renderHook(() => useProviders()); diff --git a/apps/desktop-tauri/src/hooks/useProviders.ts b/apps/desktop-tauri/src/hooks/useProviders.ts index f255938ec5..c4a8c06d14 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.ts +++ b/apps/desktop-tauri/src/hooks/useProviders.ts @@ -66,6 +66,8 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes const pendingSnapshotsRef = useRef>(new Map()); const flushTimerRef = useRef(undefined); const resetRefreshTimerRef = useRef(undefined); + const settingsReloadEpochRef = useRef(0); + const settingsReloadingRef = useRef(false); const mergeSnapshots = useCallback((snapshots: ProviderUsageSnapshot[]) => { if (snapshots.length === 0) return; @@ -97,7 +99,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes const queueSnapshot = useCallback((snapshot: ProviderUsageSnapshot) => { pendingSnapshotsRef.current.set(snapshot.providerId, snapshot); - if (flushTimerRef.current !== undefined) return; + if (settingsReloadingRef.current || flushTimerRef.current !== undefined) return; flushTimerRef.current = window.setTimeout(flushPendingSnapshots, 80); }, [flushPendingSnapshots]); @@ -115,9 +117,14 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes let cancelled = false; // Load existing cache first. + const initialEpoch = settingsReloadEpochRef.current; getCachedProviders() .then((cached) => { - if (!cancelled && cached.length > 0) { + if ( + !cancelled && + initialEpoch === settingsReloadEpochRef.current && + cached.length > 0 + ) { mergeSnapshots(cached); } }) @@ -135,6 +142,28 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes }, ); + const unlistenSettings = listen("settings-changed", () => { + const epoch = ++settingsReloadEpochRef.current; + settingsReloadingRef.current = true; + if (flushTimerRef.current !== undefined) { + window.clearTimeout(flushTimerRef.current); + flushTimerRef.current = undefined; + } + pendingSnapshotsRef.current.clear(); + getCachedProviders() + .then((cached) => { + if (!cancelled && epoch === settingsReloadEpochRef.current) { + mergeSnapshots(cached); + } + }) + .finally(() => { + if (!cancelled && epoch === settingsReloadEpochRef.current) { + settingsReloadingRef.current = false; + flushPendingSnapshots(); + } + }); + }); + const unlistenStarted = listen("refresh-started", () => { if (!cancelled) { refreshingRef.current = true; @@ -146,7 +175,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes "refresh-complete", (event) => { if (!cancelled) { - flushPendingSnapshots(); + if (!settingsReloadingRef.current) flushPendingSnapshots(); refreshingRef.current = false; setIsRefreshing(false); setLastRefresh(event.payload); @@ -180,6 +209,8 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes return () => { cancelled = true; + settingsReloadEpochRef.current += 1; + settingsReloadingRef.current = false; if (initialRefreshTimer !== undefined) { window.clearTimeout(initialRefreshTimer); } @@ -193,6 +224,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes } pendingSnapshotsRef.current.clear(); unlistenUpdated.then((fn) => fn()); + unlistenSettings.then((fn) => fn()); unlistenStarted.then((fn) => fn()); unlistenComplete.then((fn) => fn()); }; diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 43cc8563c5..4fb4d007c9 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -243,6 +243,8 @@ export const ALL_LOCALE_KEYS = [ "ProviderClaudeCookiesHelp", "ProviderClaudeAvoidKeychainPrompts", "ProviderClaudeAvoidKeychainPromptsHelp", + "ProviderCodexSparkUsage", + "ProviderCodexSparkUsageHelp", "ProviderCursorCookieSourceHelp", "ProviderCursorCreditsHelp", "AutoFallbackHelp", diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index 85975420f2..f51d1edf90 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -147,6 +147,7 @@ function settings(): SettingsSnapshot { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: false, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 0e8be18e98..1d8663efa1 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -133,6 +133,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: false, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index f778a98013..fb498d4fae 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -35,6 +35,7 @@ import { VertexAiCreds } from "./sections/credentials/VertexAiCreds"; import { JetBrainsCreds } from "./sections/credentials/JetBrainsCreds"; import { KiroCreds } from "./sections/credentials/KiroCreds"; import { ClaudeCreds } from "./sections/credentials/ClaudeCreds"; +import { CodexUsageOptions } from "./sections/credentials/CodexUsageOptions"; import { OpenAiExtras } from "./sections/credentials/OpenAiExtras"; import { TokenAccountsPanel } from "../tokens/TokenAccountsPanel"; import { ApiKeySection } from "./ApiKeySection"; @@ -299,6 +300,7 @@ export function ProviderDetailPane({ onChanged={() => void load(detail.id)} /> + {detail.id === "codex" && } string; +} + +export function CodexUsageOptions({ t }: Props) { + const [value, setValue] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + getSettingsSnapshot() + .then( + (settings) => + !cancelled && setValue(settings.codexSparkUsageVisible), + ) + .catch((e) => !cancelled && setError(String(e))); + return () => { + cancelled = true; + }; + }, []); + + const toggle = async (next: boolean) => { + setSaving(true); + try { + const updated = await updateSettings({ codexSparkUsageVisible: next }); + setValue(updated.codexSparkUsageVisible); + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + }; + + if (value === null) return null; + + return ( +
+

{t("ProviderOptionsTitle")}

+ + {error &&
{error}
} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 698cc2552a..d6cd1bba04 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -69,6 +69,7 @@ const settings: SettingsSnapshot = { trayScalePercent: 100, powertoysStatusPipeEnabled: false, claudeAvoidKeychainPrompts: true, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, 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 319c332dd5..9699f8083e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -52,6 +52,7 @@ const settings: SettingsSnapshot = { uiLanguage: "english", theme: "dark", claudeAvoidKeychainPrompts: true, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index 4c8c46adb1..bd8e5f4cdd 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -70,6 +70,7 @@ describe("Language type", () => { uiLanguage: "spanish", theme: "dark", claudeAvoidKeychainPrompts: true, + codexSparkUsageVisible: true, disableKeychainAccess: false, providerMetrics: {}, floatBarEnabled: false, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index b82a7ccf1b..2179f5de49 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -200,6 +200,7 @@ export interface SettingsSnapshot { trayScalePercent: number; powertoysStatusPipeEnabled: boolean; claudeAvoidKeychainPrompts: boolean; + codexSparkUsageVisible: boolean; disableKeychainAccess: boolean; providerMetrics: Record; floatBarEnabled: boolean; @@ -251,6 +252,7 @@ export interface SettingsUpdate { trayScalePercent?: number; powertoysStatusPipeEnabled?: boolean; claudeAvoidKeychainPrompts?: boolean; + codexSparkUsageVisible?: boolean; disableKeychainAccess?: boolean; /** Map of provider CLI name → metric preference label. */ providerMetrics?: Record; diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9d8278dbbb..596e075ee0 100755 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -40,6 +40,8 @@ anyhow = "1" # Date/time chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.10" +iana-time-zone = "0.1" # Async utilities async-trait = "0.1" diff --git a/rust/src/locale.rs b/rust/src/locale.rs index ed89eea39a..c1657667c1 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -457,6 +457,8 @@ locale_keys! { ProviderClaudeCookiesHelp, ProviderClaudeAvoidKeychainPrompts, ProviderClaudeAvoidKeychainPromptsHelp, + ProviderCodexSparkUsage, + ProviderCodexSparkUsageHelp, ProviderCursorCookieSourceHelp, ProviderCursorCreditsHelp, AutoFallbackHelp, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 7d3d34af57..13a4239518 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude cookies ProviderClaudeCookiesHelp = Browser cookies/sessionKey are preferred because they match Claude's settings usage page. ProviderClaudeAvoidKeychainPrompts = Avoid Keychain prompts ProviderClaudeAvoidKeychainPromptsHelp = Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts. +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = Automatic imports browser cookies or stored sessions. ProviderCursorCreditsHelp = On-demand usage beyond included plan limits. AutoFallbackHelp = Auto falls back to the next source if the preferred one fails. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 051ca3b867..d7a67d206f 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Cookies de Claude ProviderClaudeCookiesHelp = Se prefieren cookies/sessionKey del navegador porque coinciden con la página de uso de configuración de Claude. ProviderClaudeAvoidKeychainPrompts = Evitar avisos de llavero ProviderClaudeAvoidKeychainPromptsHelp = Usar /usr/bin/security para leer credenciales de Claude y evitar avisos de llavero de CodexBar. +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = Importa automáticamente cookies del navegador o sesiones almacenadas. ProviderCursorCreditsHelp = Uso bajo demanda más allá de los límites del plan incluido. AutoFallbackHelp = Auto recurre a la siguiente fuente si la preferida falla. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 68b5ca0f9c..60657acd06 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude cookies ProviderClaudeCookiesHelp = Browser cookies/sessionKey are preferred because they match Claude's settings usage page. ProviderClaudeAvoidKeychainPrompts = Avoid Keychain prompts ProviderClaudeAvoidKeychainPromptsHelp = Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts. +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = Automatic imports browser cookies or stored sessions. ProviderCursorCreditsHelp = On-demand usage beyond included plan limits. AutoFallbackHelp = Auto falls back to the next source if the preferred one fails. diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 20e85c2ddb..455528798a 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude 쿠키 ProviderClaudeCookiesHelp = 브라우저 쿠키/`sessionKey`가 Claude의 설정 사용량 페이지와 일치하므로 권장됩니다. ProviderClaudeAvoidKeychainPrompts = 키체인 메시지 표시 방지 ProviderClaudeAvoidKeychainPromptsHelp = Claude 자격 증명을 읽을 때 `/usr/bin/security`를 사용하여 CodexBar 키체인 권한 창이 뜨지 않도록 합니다. +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = 브라우저 쿠키 또는 저장된 세션을 자동으로 가져옵니다. ProviderCursorCreditsHelp = 기본 요금제 한도를 초과하는 온디맨드 사용량. AutoFallbackHelp = 기본 소스가 실패하면 자동으로 다음 소스로 전환합니다. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 15b8464e7d..4be46d15f5 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude Cookie ProviderClaudeCookiesHelp = 优先使用浏览器 Cookie/sessionKey,因为它与 Claude 设置页的用量一致。 ProviderClaudeAvoidKeychainPrompts = 避免钥匙串提示 ProviderClaudeAvoidKeychainPromptsHelp = 使用 /usr/bin/security 读取 Claude 凭据,避免 CodexBar 的钥匙串提示。 +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = 自动导入浏览器 Cookie 或已保存会话。 ProviderCursorCreditsHelp = 包含计划额度之外的按量计费用量。 AutoFallbackHelp = 当首选来源失败时自动回退到下一个来源。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 4c698f748f..d043630fa4 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -236,6 +236,8 @@ ProviderClaudeCookies = Claude Cookie ProviderClaudeCookiesHelp = 優先使用瀏覽器 Cookie/sessionKey,因為它與 Claude 設定頁的用量一致。 ProviderClaudeAvoidKeychainPrompts = 避免鑰匙串提示 ProviderClaudeAvoidKeychainPromptsHelp = 使用 /usr/bin/security 讀取 Claude 憑據,避免 CodexBar 的鑰匙串提示。 +ProviderCodexSparkUsage = Show Codex Spark usage +ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage. ProviderCursorCookieSourceHelp = 自動匯入瀏覽器 Cookie 或已儲存會話。 ProviderCursorCreditsHelp = 包含計劃額度之外的按量計費用量。 AutoFallbackHelp = 當首選來源失敗時自動回退到下一個來源。 diff --git a/rust/src/providers/claude/cli_reset.rs b/rust/src/providers/claude/cli_reset.rs new file mode 100644 index 0000000000..4026265434 --- /dev/null +++ b/rust/src/providers/claude/cli_reset.rs @@ -0,0 +1,364 @@ +use std::str::FromStr; +use std::sync::OnceLock; + +use chrono::{DateTime, Datelike, Duration, LocalResult, NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; +use regex_lite::Regex; + +use crate::core::{NamedRateWindow, RateWindow}; + +fn regex(cell: &'static OnceLock, pattern: &'static str) -> &'static Regex { + cell.get_or_init(|| Regex::new(pattern).expect("valid Claude CLI regex")) +} + +pub(super) fn parse_percent_line(line: &str) -> Option { + static PERCENT: OnceLock = OnceLock::new(); + let captures = regex( + &PERCENT, + r"(?i)(\d{1,3}(?:\.\d+)?)\s*%\s*(used|spent|consumed|left|remaining|available)", + ) + .captures(line)?; + let value: f64 = captures.get(1)?.as_str().parse().ok()?; + match captures.get(2)?.as_str().to_ascii_lowercase().as_str() { + "left" | "remaining" | "available" => Some((100.0 - value).max(0.0)), + _ => Some(value.min(100.0)), + } +} + +pub(super) fn normalized_for_label_search(text: &str) -> String { + text.chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +pub(super) fn starts_next_usage_section(line: &str, current_label: &str) -> bool { + let normalized = normalized_for_label_search(line); + normalized.starts_with("current") && !normalized.contains(current_label) +} + +pub(super) fn extract_cli_scoped_weekly_limits( + text: &str, + now: DateTime, +) -> Vec { + static LABEL: OnceLock = OnceLock::new(); + static RESET: OnceLock = OnceLock::new(); + let label_re = regex(&LABEL, r"(?i)current\s*week\s*\(([^)]+)\)"); + let reset_re = regex(&RESET, r"(?i)resets.*$"); + let lines: Vec<&str> = text.lines().collect(); + let mut limits = Vec::new(); + + for (idx, line) in lines.iter().enumerate() { + let Some(label_match) = label_re.captures(line).and_then(|captures| captures.get(1)) else { + continue; + }; + let title = normalize_scoped_weekly_title(label_match.as_str()); + if title.is_empty() || normalized_for_label_search(&title) == "allmodels" { + continue; + } + let id = format!( + "claude-weekly-scoped-{}", + slug_claude_model(title.trim_end_matches(" only").trim()) + ); + if id == "claude-weekly-scoped-" + || limits.iter().any(|limit: &NamedRateWindow| limit.id == id) + { + continue; + } + + let mut used_percent = None; + let mut reset_description = None; + let current_label = normalized_for_label_search(line); + for (offset, section_line) in lines.iter().skip(idx).take(14).enumerate() { + if offset > 0 && starts_next_usage_section(section_line, ¤t_label) { + break; + } + used_percent = used_percent.or_else(|| parse_percent_line(section_line)); + reset_description = reset_description.or_else(|| { + reset_re + .find(section_line) + .map(|value| value.as_str().trim().to_string()) + }); + } + let Some(used_percent) = used_percent else { + continue; + }; + let resets_at = reset_description + .as_deref() + .and_then(|reset| parse_claude_reset_date(reset, now, Some(10080))); + limits.push(NamedRateWindow::new( + id, + title, + RateWindow::with_details(used_percent, Some(10080), resets_at, reset_description), + )); + } + + limits +} + +fn slug_claude_model(label: &str) -> String { + let mut slug = String::new(); + let mut previous_dash = false; + for character in label.chars() { + if character.is_ascii_alphanumeric() { + slug.push(character.to_ascii_lowercase()); + previous_dash = false; + } else if !slug.is_empty() && !previous_dash { + slug.push('-'); + previous_dash = true; + } + } + slug.trim_matches('-').to_string() +} + +fn normalize_scoped_weekly_title(label: &str) -> String { + static ONLY_SUFFIX: OnceLock = OnceLock::new(); + let label = label.trim(); + if let Some(suffix) = regex(&ONLY_SUFFIX, r"(?i)only$").find(label) { + let model = label[..suffix.start()].trim_end(); + if !model.is_empty() { + return format!("{model} only"); + } + } + label.to_string() +} + +pub(super) fn parse_claude_reset_date( + text: &str, + now: DateTime, + expected_window_minutes: Option, +) -> Option> { + let system_timezone = iana_time_zone::get_timezone() + .ok() + .and_then(|name| Tz::from_str(&name).ok()) + .unwrap_or(chrono_tz::UTC); + parse_claude_reset_date_in_system_zone(text, now, expected_window_minutes, system_timezone) +} + +pub(super) fn parse_claude_reset_date_in_system_zone( + text: &str, + now: DateTime, + expected_window_minutes: Option, + system_timezone: Tz, +) -> Option> { + let (raw, timezone) = normalize_claude_reset_text(text, system_timezone)?; + let components = parse_claude_reset_components(&raw)?; + let now_local = now.with_timezone(&timezone); + let candidates = match (components.year, components.month, components.day) { + (Some(year), Some(month), Some(day)) => local_reset_occurrences( + timezone, + year, + month, + day, + components.hour, + components.minute, + ), + (None, Some(month), Some(day)) => (now_local.year() - 8..=now_local.year() + 8) + .flat_map(|year| { + local_reset_occurrences( + timezone, + year, + month, + day, + components.hour, + components.minute, + ) + }) + .collect(), + (None, None, None) => (-1..=1) + .flat_map(|offset| { + let date = now_local.date_naive() + Duration::days(offset); + local_reset_occurrences( + timezone, + date.year(), + date.month(), + date.day(), + components.hour, + components.minute, + ) + }) + .collect(), + _ => return None, + }; + + resolve_claude_reset_occurrence(candidates, now, expected_window_minutes) +} + +fn normalize_claude_reset_text(text: &str, system_timezone: Tz) -> Option<(String, Tz)> { + static MONTH_BOUNDARY: OnceLock = OnceLock::new(); + static COMPACT_AT: OnceLock = OnceLock::new(); + let mut raw = text + .trim() + .strip_prefix("Resets") + .or_else(|| text.trim().strip_prefix("resets")) + .unwrap_or(text) + .trim() + .to_string(); + let timezone = raw + .rfind('(') + .filter(|_| raw.ends_with(')')) + .and_then(|start| { + let timezone = Tz::from_str(raw[start + 1..raw.len() - 1].trim()).ok(); + raw.truncate(start); + timezone + }) + .unwrap_or(system_timezone); + raw = raw.replace(" at ", " "); + raw = regex(&MONTH_BOUNDARY, r"(?i)([a-z]{3})(\d)") + .replace(&raw, "$1 $2") + .into_owned(); + raw = regex(&COMPACT_AT, r"(?i)(\d)at(\d)") + .replace(&raw, "$1 $2") + .into_owned(); + (!raw.trim().is_empty()).then(|| (raw.trim().to_string(), timezone)) +} + +struct ClaudeResetComponents { + year: Option, + month: Option, + day: Option, + hour: u32, + minute: u32, +} + +fn parse_claude_reset_components(raw: &str) -> Option { + static DATE_TIME: OnceLock = OnceLock::new(); + static TIME: OnceLock = OnceLock::new(); + let date_time = regex( + &DATE_TIME, + r"(?i)^([a-z]{3})\s+(\d{1,2})(?:,\s*|\s+)(?:(\d{4})(?:,\s*|\s+))?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$", + ); + if let Some(captures) = date_time.captures(raw) { + let month = claude_month(captures.get(1)?.as_str())?; + let day = captures.get(2)?.as_str().parse().ok()?; + let year = captures + .get(3) + .and_then(|value| value.as_str().parse().ok()); + let (hour, minute) = parse_claude_hour( + captures.get(4)?.as_str(), + captures.get(5).map(|value| value.as_str()), + captures.get(6).map(|value| value.as_str()), + )?; + return Some(ClaudeResetComponents { + year, + month: Some(month), + day: Some(day), + hour, + minute, + }); + } + + let captures = regex(&TIME, r"(?i)^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$").captures(raw)?; + let (hour, minute) = parse_claude_hour( + captures.get(1)?.as_str(), + captures.get(2).map(|value| value.as_str()), + captures.get(3).map(|value| value.as_str()), + )?; + Some(ClaudeResetComponents { + year: None, + month: None, + day: None, + hour, + minute, + }) +} + +fn parse_claude_hour( + hour: &str, + minute: Option<&str>, + meridiem: Option<&str>, +) -> Option<(u32, u32)> { + let mut hour = hour.parse::().ok()?; + let minute = minute.unwrap_or("0").parse::().ok()?; + if minute > 59 { + return None; + } + match meridiem.map(str::to_ascii_lowercase).as_deref() { + Some("am") if (1..=12).contains(&hour) => { + if hour == 12 { + hour = 0; + } + } + Some("pm") if (1..=12).contains(&hour) => { + if hour != 12 { + hour += 12; + } + } + Some(_) => return None, + None if hour > 23 => return None, + None => {} + } + Some((hour, minute)) +} + +fn claude_month(month: &str) -> Option { + match month.to_ascii_lowercase().as_str() { + "jan" => Some(1), + "feb" => Some(2), + "mar" => Some(3), + "apr" => Some(4), + "may" => Some(5), + "jun" => Some(6), + "jul" => Some(7), + "aug" => Some(8), + "sep" => Some(9), + "oct" => Some(10), + "nov" => Some(11), + "dec" => Some(12), + _ => None, + } +} + +fn local_reset_occurrences( + timezone: Tz, + year: i32, + month: u32, + day: u32, + hour: u32, + minute: u32, +) -> Vec> { + let Some(naive) = NaiveDate::from_ymd_opt(year, month, day) + .and_then(|date| date.and_hms_opt(hour, minute, 0)) + else { + return Vec::new(); + }; + match timezone.from_local_datetime(&naive) { + LocalResult::Single(value) => vec![value.with_timezone(&Utc)], + LocalResult::Ambiguous(first, second) => { + vec![first.with_timezone(&Utc), second.with_timezone(&Utc)] + } + LocalResult::None => Vec::new(), + } +} + +fn resolve_claude_reset_occurrence( + mut candidates: Vec>, + now: DateTime, + expected_window_minutes: Option, +) -> Option> { + candidates.sort_unstable(); + candidates.dedup(); + let future = candidates + .iter() + .copied() + .find(|candidate| *candidate >= now); + let fallback = candidates.last().copied(); + let future = future.or(fallback)?; + let Some(expected_window) = + expected_window_minutes.map(|minutes| Duration::minutes(minutes.into())) + else { + return Some(future); + }; + let past = candidates + .iter() + .copied() + .rev() + .find(|candidate| *candidate < now); + let past_is_plausible = past.is_some_and(|candidate| now - candidate <= expected_window); + let future_is_plausible = future - now <= expected_window; + if past_is_plausible && !future_is_plausible { + past + } else { + Some(future) + } +} diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index dd0f3ea6bb..83e25f0a03 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -1,10 +1,12 @@ //! Claude provider implementation mod admin_api; +mod cli_reset; mod oauth; mod web_api; use async_trait::async_trait; +use chrono::Utc; use regex_lite::Regex; #[cfg(windows)] use std::os::windows::process::CommandExt; @@ -18,6 +20,12 @@ use crate::core::{ }; use admin_api::ClaudeAdminApiFetcher; +#[cfg(test)] +use cli_reset::parse_claude_reset_date_in_system_zone; +use cli_reset::{ + extract_cli_scoped_weekly_limits, normalized_for_label_search, parse_claude_reset_date, + parse_percent_line, starts_next_usage_section, +}; pub use oauth::ClaudeOAuthFetcher; pub use web_api::ClaudeWebApiFetcher; @@ -444,7 +452,6 @@ impl ClaudeProvider { // Parse session percent: "X% used" or "X% left" let mut session_percent: Option = None; let mut weekly_percent: Option = None; - let mut opus_percent: Option = None; // Look for "Current session" section if let Some(session_pct) = extract_percent_near_label(&clean, "current session") { @@ -458,13 +465,6 @@ impl ClaudeProvider { weekly_percent = Some(weekly_pct); } - // Look for Opus/Sonnet specific - if let Some(opus_pct) = extract_percent_near_label(&clean, "opus") { - opus_percent = Some(opus_pct); - } else if let Some(sonnet_pct) = extract_percent_near_label(&clean, "sonnet") { - opus_percent = Some(sonnet_pct); - } - // Fallback: collect all percentages in order if session_percent.is_none() { let all_percents = extract_all_percents(&clean); @@ -474,14 +474,10 @@ impl ClaudeProvider { if all_percents.len() > 1 && weekly_percent.is_none() { weekly_percent = Some(all_percents[1]); } - if all_percents.len() > 2 && opus_percent.is_none() { - opus_percent = Some(all_percents[2]); - } } if session_percent.is_none() && weekly_percent.is_none() - && opus_percent.is_none() && !is_exhausted_short_form(&clean_lower) { return Err(ProviderError::Parse( @@ -503,6 +499,8 @@ impl ClaudeProvider { None }; let session_reset = session_reset.or(short_form_reset); + let now = Utc::now(); + let scoped_weekly_limits = extract_cli_scoped_weekly_limits(&clean, now); if session_percent.is_none() && is_exhausted_short_form(&clean_lower) { session_percent = Some(100.0); @@ -513,7 +511,9 @@ impl ClaudeProvider { let primary = RateWindow::with_details( session_used, Some(300), // 5 hour session window - None, // Could parse reset time + session_reset + .as_deref() + .and_then(|reset| parse_claude_reset_date(reset, now, Some(300))), session_reset, ); @@ -523,15 +523,16 @@ impl ClaudeProvider { let secondary = RateWindow::with_details( weekly_used, Some(10080), // weekly (7 * 24 * 60) - None, + weekly_reset + .as_deref() + .and_then(|reset| parse_claude_reset_date(reset, now, Some(10080))), weekly_reset, ); usage = usage.with_secondary(secondary); } - if let Some(opus_used) = opus_percent { - let model_specific = RateWindow::with_details(opus_used, Some(10080), None, None); - usage = usage.with_model_specific(model_specific); + for limit in scoped_weekly_limits { + usage.extra_rate_windows.push(limit); } if let Some(method) = login_method { @@ -784,32 +785,6 @@ fn extract_percent_near_label(text: &str, label: &str) -> Option { None } -/// Parse a line containing "X% used", "X% left", "X% remaining", etc. -/// Returns the percentage as used (converts "left" to used) -fn parse_percent_line(line: &str) -> Option { - // Match patterns like "45% used", "55% left", "55% remaining", or "12.5% available". - let re = - Regex::new(r"(\d{1,3}(?:\.\d+)?)\s*%\s*(used|spent|consumed|left|remaining|available)") - .ok()?; - - if let Some(caps) = re.captures(&line.to_lowercase()) - && let Some(value_match) = caps.get(1) - && let Some(kind_match) = caps.get(2) - { - let value: f64 = value_match.as_str().parse().ok()?; - let kind = kind_match.as_str(); - - // Convert to "used" percentage - if matches!(kind, "left" | "remaining" | "available") { - Some((100.0 - value).max(0.0)) - } else { - Some(value.min(100.0)) - } - } else { - None - } -} - /// Extract all percentages from text in order fn extract_all_percents(text: &str) -> Vec { let re = match Regex::new( @@ -839,18 +814,6 @@ fn extract_all_percents(text: &str) -> Vec { results } -fn normalized_for_label_search(text: &str) -> String { - text.chars() - .filter(|c| c.is_alphanumeric()) - .flat_map(|c| c.to_lowercase()) - .collect() -} - -fn starts_next_usage_section(line: &str, current_label: &str) -> bool { - let normalized = normalized_for_label_search(line); - normalized.starts_with("current") && !normalized.contains(current_label) -} - fn is_exhausted_short_form(clean_lower: &str) -> bool { clean_lower.contains("out of extra usage") || clean_lower.contains("hit your limit") } @@ -948,6 +911,8 @@ fn clean_plan_name(text: &str) -> String { #[cfg(test)] mod tests { + use chrono::{DateTime, Utc}; + use super::*; #[test] @@ -1055,9 +1020,110 @@ Status Config Usage let sonnet = result .usage - .model_specific + .extra_rate_windows + .iter() + .find(|window| window.id == "claude-weekly-scoped-sonnet") .expect("sonnet usage should be present"); - assert_eq!(sonnet.used_percent, 1.0); + assert_eq!(sonnet.window.used_percent, 1.0); + } + + #[test] + fn parses_all_cli_model_scoped_weekly_limits() { + let provider = ClaudeProvider::new(); + let output = r#" +Current session +10% used +Resets 12pm (America/Bogota) + +Current week (all models) +20% used +Resets Apr 3, 2pm (America/Bogota) + +Current week (Sonnet only) +30% used +Resets Apr 4, 2pm (America/Bogota) + +Current week (Opus only) +40% used +Resets Apr 5, 2pm (America/Bogota) +"#; + + let result = provider.parse_cli_output(output).expect("should parse"); + + assert_eq!(result.usage.extra_rate_windows.len(), 2); + assert_eq!( + result.usage.extra_rate_windows[0].id, + "claude-weekly-scoped-sonnet" + ); + assert_eq!(result.usage.extra_rate_windows[0].title, "Sonnet only"); + assert_eq!(result.usage.extra_rate_windows[0].window.used_percent, 30.0); + assert_eq!( + result.usage.extra_rate_windows[1].id, + "claude-weekly-scoped-opus" + ); + assert!(result.usage.model_specific.is_none()); + } + + #[test] + fn scoped_weekly_parser_handles_non_ascii_labels_and_reset_prefixes() { + let now = "2026-04-02T18:00:00Z".parse::>().unwrap(); + let limits = extract_cli_scoped_weekly_limits( + "Current week (A€€)\n10% used\nİResets Apr 3 at 2pm (America/Bogota)", + now, + ); + + assert_eq!(limits.len(), 1); + assert_eq!(limits[0].title, "A€€"); + assert_eq!( + limits[0].window.resets_at, + Some("2026-04-03T19:00:00Z".parse().unwrap()) + ); + } + + #[test] + fn resolves_cli_reset_occurrences_in_the_reported_timezone() { + let now = "2026-04-02T18:00:00Z".parse::>().unwrap(); + + assert_eq!( + parse_claude_reset_date("Resets Apr 3, 2027, 2pm (America/Bogota)", now, None), + Some("2027-04-03T19:00:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date("Resets Apr 3, 2pm (America/Bogota)", now, None), + Some("2026-04-03T19:00:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date("Resets 12pm (America/Bogota)", now, None), + Some("2026-04-03T17:00:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date("ResetsApr3at2pm(America/Bogota)", now, None), + Some("2026-04-03T19:00:00Z".parse().unwrap()) + ); + } + + #[test] + fn timezone_less_resets_use_the_supplied_system_zone() { + let now = "2026-03-07T18:00:00Z".parse::>().unwrap(); + + assert_eq!( + parse_claude_reset_date_in_system_zone( + "Resets Mar 8 at 3:30am", + now, + None, + "America/New_York".parse().unwrap(), + ), + Some("2026-03-08T07:30:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date_in_system_zone( + "Resets Mar 8 at 3:30am (America/Los_Angeles)", + now, + None, + "America/New_York".parse().unwrap(), + ), + Some("2026-03-08T10:30:00Z".parse().unwrap()) + ); } #[test] @@ -1092,14 +1158,15 @@ ResetsFeb12at1:29pm(Asia/Calcutta) .used_percent, 4.0 ); - assert_eq!( - result - .usage - .model_specific - .expect("sonnet usage should be present") - .used_percent, - 1.0 - ); + let sonnet = result + .usage + .extra_rate_windows + .iter() + .find(|window| window.id == "claude-weekly-scoped-sonnet") + .expect("sonnet usage should be present"); + assert_eq!(result.usage.extra_rate_windows.len(), 1); + assert_eq!(sonnet.title, "Sonnet only"); + assert_eq!(sonnet.window.used_percent, 1.0); } #[test] diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 2a46e6bffb..95e70000d4 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -329,6 +329,7 @@ fn default_api_region(id: ProviderId) -> &'static str { /// Default for the codex `openai_web_extras` boolean (true = show extras). const DEFAULT_CODEX_OPENAI_WEB_EXTRAS: bool = true; +const DEFAULT_CODEX_SPARK_USAGE_VISIBLE: bool = true; impl Default for Settings { fn default() -> Self { @@ -733,6 +734,18 @@ impl Settings { self.provider_config_mut(id).openai_web_extras = Some(value); } + /// Codex Spark rows are visible by default. + pub fn spark_usage_visible(&self, id: ProviderId) -> bool { + self.provider_configs + .get(&id) + .and_then(|c| c.spark_usage_visible) + .unwrap_or(DEFAULT_CODEX_SPARK_USAGE_VISIBLE) + } + + pub fn set_spark_usage_visible(&mut self, id: ProviderId, value: bool) { + self.provider_config_mut(id).spark_usage_visible = Some(value); + } + /// Per-provider historical-tracking toggle (currently codex-only). pub fn historical_tracking(&self, id: ProviderId) -> bool { self.provider_configs @@ -925,6 +938,12 @@ impl Settings { pub fn set_codex_openai_web_extras(&mut self, v: bool) { self.set_openai_web_extras(ProviderId::Codex, v) } + pub fn codex_spark_usage_visible(&self) -> bool { + self.spark_usage_visible(ProviderId::Codex) + } + pub fn set_codex_spark_usage_visible(&mut self, v: bool) { + self.set_spark_usage_visible(ProviderId::Codex, v) + } pub fn codex_historical_tracking(&self) -> bool { self.historical_tracking(ProviderId::Codex) } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index f3f26ac556..e9ff1f0768 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -788,3 +788,15 @@ fn test_per_provider_defaults_applied() { assert!(!settings.historical_tracking(ProviderId::Codex)); assert!(!settings.avoid_keychain_prompts(ProviderId::Claude)); } + +#[test] +fn codex_spark_usage_visibility_defaults_to_visible_and_roundtrips() { + let mut settings = Settings::default(); + assert!(settings.codex_spark_usage_visible()); + + settings.set_codex_spark_usage_visible(false); + let serialized = serde_json::to_string(&settings).unwrap(); + let loaded: Settings = serde_json::from_str(&serialized).unwrap(); + + assert!(!loaded.codex_spark_usage_visible()); +} diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index f038ea2955..c0b124d4ff 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -262,6 +262,9 @@ pub struct ProviderConfig { /// Codex-only: opt out of OpenAI web "extras" surfaces. #[serde(skip_serializing_if = "Option::is_none")] pub openai_web_extras: Option, + /// Codex-only: show or hide Codex Spark quota rows in presentation. + #[serde(skip_serializing_if = "Option::is_none")] + pub spark_usage_visible: Option, /// Codex-only: enable historical usage tracking in UI. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub historical_tracking: bool,