diff --git a/apps/desktop-tauri/src-tauri/permissions/commands.toml b/apps/desktop-tauri/src-tauri/permissions/commands.toml index 0bd54af7..e1619522 100644 --- a/apps/desktop-tauri/src-tauri/permissions/commands.toml +++ b/apps/desktop-tauri/src-tauri/permissions/commands.toml @@ -75,6 +75,7 @@ commands.allow = [ "get_launch_block_reason", "get_work_area_rect", "play_notification_sound", + "send_test_notification", "open_external_url", "reanchor_tray_panel", "quit_app", diff --git a/apps/desktop-tauri/src-tauri/src/capacity_events.rs b/apps/desktop-tauri/src-tauri/src/capacity_events.rs index c8c1f0e2..00d8677f 100644 --- a/apps/desktop-tauri/src-tauri/src/capacity_events.rs +++ b/apps/desktop-tauri/src-tauri/src/capacity_events.rs @@ -525,7 +525,11 @@ fn inactive_windows(snapshot: &ProviderUsageSnapshot) -> HashMap .collect() } -fn ignored_capacity_window(snapshot: &ProviderUsageSnapshot, id: &str, title: &str) -> bool { +pub(crate) fn ignored_capacity_window( + snapshot: &ProviderUsageSnapshot, + id: &str, + title: &str, +) -> bool { if snapshot.provider_id != "cursor" { return false; } @@ -535,7 +539,7 @@ fn ignored_capacity_window(snapshot: &ProviderUsageSnapshot, id: &str, title: &s || identity.contains("ondemand") } -fn semantic_inactive_window_id(provider_id: &str, id: &str, title: &str) -> String { +pub(crate) fn semantic_inactive_window_id(provider_id: &str, id: &str, title: &str) -> String { let title_id = normalize_window_id(title); if let Some(core_id) = core_window_id(&title_id) { return core_id.to_string(); @@ -592,7 +596,7 @@ fn to_observed_window(label: &str, window: &RateWindowSnapshot) -> Option) -> String { +pub(crate) fn semantic_window_id(label: &str, window_minutes: Option) -> String { let normalized = normalize_window_id(label); if matches!( normalized.as_str(), @@ -624,15 +628,15 @@ fn normalize_window_id(value: &str) -> String { .to_string() } -fn observation_scope(snapshot: &ProviderUsageSnapshot) -> String { - let identity = snapshot - .account_email - .as_deref() - .or(snapshot.account_organization.as_deref()) - .unwrap_or("anonymous"); +pub(crate) fn observation_scope(snapshot: &ProviderUsageSnapshot) -> String { + // Scope by BOTH account identifiers, not either/or: the same email can + // belong to different organizations (e.g. a personal vs a business + // workspace) with distinct limits, and those must never share a baseline. + let email = snapshot.account_email.as_deref().unwrap_or(""); + let organization = snapshot.account_organization.as_deref().unwrap_or(""); let raw = format!( - "{}|{}|{}", - snapshot.provider_id, snapshot.source_label, identity + "{}|{}|{}|{}", + snapshot.provider_id, snapshot.source_label, email, organization ); format!("{}:{:016x}", snapshot.provider_id, fnv1a64(raw.as_bytes())) } @@ -729,6 +733,7 @@ mod tests { id: id.into(), title: title.into(), description: "Not currently limited".into(), + state: "notEnforced".into(), }); snapshot } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index c33ca172..676f55cf 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -75,6 +75,9 @@ pub struct InactiveRateWindowSnapshot { pub id: String, pub title: String, pub description: String, + /// "notEnforced" (provider reported no active limit) or "unavailable" (the + /// window dropped out of an otherwise-successful response). + pub state: String, } #[derive(Debug, Clone, Serialize)] @@ -275,6 +278,7 @@ impl ProviderUsageSnapshot { id: window.id.clone(), title: window.title.clone(), description: window.description.clone(), + state: window.state.as_str().to_string(), }) .collect(), promo_signals: usage @@ -557,6 +561,7 @@ pub struct SettingsSnapshot { float_bar_style: String, taskbar_widget_open_on_hover: bool, float_bar_density: String, + float_bar_information_mode: String, float_bar_contrast: String, float_bar_click_through: bool, float_bar_provider_ids: Vec, @@ -657,6 +662,7 @@ impl From for SettingsSnapshot { float_bar_style: settings.float_bar_style, taskbar_widget_open_on_hover: settings.taskbar_widget_open_on_hover, float_bar_density: settings.float_bar_density, + float_bar_information_mode: settings.float_bar_information_mode, float_bar_contrast, float_bar_click_through: settings.float_bar_click_through, float_bar_provider_ids: settings.float_bar_provider_ids, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 5fab9bc0..6cd2e966 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -286,8 +286,18 @@ async fn refresh_provider(app: tauri::AppHandle, id: ProviderId, ctx: FetchConte let state = app.state::>(); let (snapshot, capacity_events, notifications_armed) = if let Ok(mut guard) = state.lock() { - let snapshot = preserve_last_good_transient_failure(&mut guard, id, snapshot); + let mut snapshot = preserve_last_good_transient_failure(&mut guard, id, snapshot); + // Detect resets/lifts against the real response first, so the synthetic + // `unavailable` rows added next are never mistaken for a lifted window. let capacity_events = guard.capacity_event_observer.observe(&snapshot); + let unavailable = guard.enforcement_tracker.annotate(&mut snapshot); + if !unavailable.is_empty() { + tracing::debug!( + provider = %snapshot.provider_id, + windows = ?unavailable, + "windows dropped out of a successful response; marked unavailable" + ); + } let notifications_armed = guard.notification_manager.notifications_are_armed(); upsert_provider_cache(&mut guard.provider_cache, snapshot.clone()); (snapshot, capacity_events, notifications_armed) diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index eea204fe..0dc68718 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -58,6 +58,7 @@ pub struct SettingsUpdate { pub float_bar_style: Option, pub taskbar_widget_open_on_hover: Option, pub float_bar_density: Option, + pub float_bar_information_mode: Option, pub float_bar_contrast: Option, pub float_bar_click_through: Option, pub float_bar_provider_ids: Option>, @@ -283,6 +284,7 @@ impl SettingsUpdate { style: self.float_bar_style.clone(), open_on_hover: self.taskbar_widget_open_on_hover, density: self.float_bar_density.clone(), + information_mode: self.float_bar_information_mode.clone(), contrast: self.float_bar_contrast.clone(), click_through: self.float_bar_click_through, provider_ids: self.float_bar_provider_ids.clone(), diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 07189e18..03000fc9 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -247,6 +247,14 @@ pub fn play_notification_sound() -> Result<(), String> { Ok(()) } +/// Show a real Windows toast on demand so the user can confirm notifications +/// are delivered on their machine. Runs the full toast pipeline synchronously +/// and surfaces any failure back to the Settings button. +#[tauri::command] +pub fn send_test_notification() -> Result<(), String> { + codexbar::notifications::send_test_notification() +} + /// Reposition the flyout window so its bottom-right corner stays anchored to /// the system-tray area. Called from the frontend after dynamic resize. /// diff --git a/apps/desktop-tauri/src-tauri/src/enforcement.rs b/apps/desktop-tauri/src-tauri/src/enforcement.rs new file mode 100644 index 00000000..c5dad284 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/enforcement.rs @@ -0,0 +1,405 @@ +//! Cross-snapshot enforcement tracking. +//! +//! A window that vanishes from an otherwise-successful provider response is not +//! the same as a window the provider explicitly lifted. The provider adapters +//! already emit the explicit "not currently enforced" case as an inactive +//! window; this tracker adds the missing third state, `unavailable`, by +//! remembering which windows a provider was reporting and flagging any that +//! quietly drop out. Surfaces can then show honest uncertainty instead of +//! silently losing a limit or fabricating a percentage for it. +//! +//! Scope is `provider | data source | account identity` (shared with the +//! capacity-event observer), so accounts, sources, and providers never bleed +//! into each other. State is process-local: the first read of each scope after +//! launch is a fresh baseline that never emits `unavailable`, mirroring the +//! observer's re-baseline-on-launch behaviour so changes that happened while +//! Ceiling was closed are not replayed as surprises. + +use std::collections::HashMap; + +use crate::capacity_events::{ + ignored_capacity_window, observation_scope, semantic_inactive_window_id, semantic_window_id, +}; +use crate::commands::{InactiveRateWindowSnapshot, ProviderUsageSnapshot}; + +/// Providers Ceiling treats as first-class subscription meters. Only these are +/// tracked: minor or experimental providers have noisier payloads where an +/// omitted window is routine, and flagging those would be more noise than +/// signal. Keep this list small and deliberate. +const FIRST_CLASS_PROVIDERS: &[&str] = + &["claude", "codex", "cursor", "copilot", "gemini", "factory"]; + +/// Only core subscription windows are tracked. Conditional bonus pools (promos, +/// Codex Spark, Copilot additional budget, on-demand credits) legitimately come +/// and go, so treating their disappearance as `unavailable` would be a false +/// alarm. These are exactly the ids `core_window_id` / the primary-label match +/// in `capacity_events` resolve to. +const CORE_WINDOW_IDS: &[&str] = &[ + "session", "weekly", "monthly", "plan", "auto", "api", "total", +]; + +const UNAVAILABLE_DESCRIPTION: &str = "Not reported in the latest update"; + +fn is_core_window(id: &str) -> bool { + CORE_WINDOW_IDS.contains(&id) +} + +#[derive(Debug, Default)] +pub struct EnforcementTracker { + /// scope -> the set of core windows we expect to see, as (id -> last-seen + /// title). Once a scope has seen a core window it stays expected until it + /// reappears, so a window that stays missing keeps being flagged every + /// refresh (not just the first one after it drops out). + expected_windows: HashMap>, +} + +impl EnforcementTracker { + pub fn new() -> Self { + Self::default() + } + + /// Append `unavailable` inactive rows for expected core windows that dropped + /// out of this successful snapshot. Additive only — it never edits or + /// removes a real window, so it cannot regress metered display or event + /// detection. Returns the titles flagged unavailable this refresh (logging). + pub fn annotate(&mut self, snapshot: &mut ProviderUsageSnapshot) -> Vec { + // An errored snapshot is not a successful response; do not treat a + // window's absence as meaningful, and do not disturb the baseline. + if snapshot.error.is_some() { + return Vec::new(); + } + if !FIRST_CLASS_PROVIDERS.contains(&snapshot.provider_id.as_str()) { + return Vec::new(); + } + + let scope = observation_scope(snapshot); + let present = present_window_identities(snapshot); + + // The first read of a scope this process is a fresh baseline: record + // what is present and never flag, so changes that happened while Ceiling + // was closed are not replayed as surprises. + let Some(expected) = self.expected_windows.get_mut(&scope) else { + self.expected_windows.insert(scope, present); + return Vec::new(); + }; + + // Anything we still expect but is absent now is unavailable. Missing + // windows are intentionally retained in `expected` so they keep being + // flagged until they reappear. + let mut missing: Vec<(String, String)> = expected + .iter() + .filter(|(id, _)| !present.contains_key(id.as_str())) + .map(|(id, title)| (id.clone(), title.clone())) + .collect(); + missing.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut newly_unavailable = Vec::new(); + for (id, title) in missing { + snapshot + .inactive_rate_windows + .push(InactiveRateWindowSnapshot { + id: id.clone(), + title: title.clone(), + description: UNAVAILABLE_DESCRIPTION.to_string(), + state: "unavailable".to_string(), + }); + newly_unavailable.push(title); + } + + // Merge the present set in: refresh titles and add newly-seen windows, + // while keeping still-missing windows expected. + for (id, title) in present { + expected.insert(id, title); + } + newly_unavailable + } +} + +/// The semantic id -> title of every window the provider is currently +/// reporting, whether metered (primary/secondary) or explicitly inactive. +/// Mirrors the capacity-event observer's identity scheme so a window keeps the +/// same id as it moves between tracked and not-enforced. +fn present_window_identities(snapshot: &ProviderUsageSnapshot) -> HashMap { + let mut out = HashMap::new(); + let mut record = |id: String, title: &str| { + if is_core_window(&id) { + out.insert(id, title.to_string()); + } + }; + + let primary_label = snapshot.primary_label.as_deref().unwrap_or("Plan"); + record( + semantic_window_id(primary_label, snapshot.primary.window_minutes), + primary_label, + ); + + if let Some(secondary) = snapshot.secondary.as_ref() { + let label = snapshot.secondary_label.as_deref().unwrap_or("Secondary"); + record(semantic_window_id(label, secondary.window_minutes), label); + } + + for extra in &snapshot.extra_rate_windows { + if ignored_capacity_window(snapshot, &extra.id, &extra.title) { + continue; + } + record( + semantic_inactive_window_id(&snapshot.provider_id, &extra.id, &extra.title), + &extra.title, + ); + } + + for inactive in &snapshot.inactive_rate_windows { + if ignored_capacity_window(snapshot, &inactive.id, &inactive.title) { + continue; + } + record( + semantic_inactive_window_id(&snapshot.provider_id, &inactive.id, &inactive.title), + &inactive.title, + ); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::{NamedRateWindowSnapshot, RateWindowSnapshot}; + + fn window(minutes: Option) -> RateWindowSnapshot { + RateWindowSnapshot { + used_percent: 40.0, + remaining_percent: 60.0, + window_minutes: minutes, + resets_at: None, + reset_description: None, + is_exhausted: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + } + } + + fn codex_snapshot() -> ProviderUsageSnapshot { + ProviderUsageSnapshot { + provider_id: "codex".into(), + display_name: "Codex".into(), + primary: window(Some(300)), + primary_label: Some("5-hour".into()), + secondary: Some(window(Some(10_080))), + secondary_label: Some("Weekly".into()), + model_specific: None, + tertiary: None, + extra_rate_windows: Vec::new(), + inactive_rate_windows: Vec::new(), + promo_signals: Vec::new(), + reset_credits_available: None, + cost: None, + plan_name: None, + account_email: Some("person@example.com".into()), + source_label: "oauth".into(), + updated_at: "2026-07-17T00:00:00Z".into(), + error: None, + pace: None, + account_organization: None, + tray_status_label: None, + fetch_duration_ms: None, + wayfinder_usage: None, + } + } + + fn unavailable_titles(snapshot: &ProviderUsageSnapshot) -> Vec { + snapshot + .inactive_rate_windows + .iter() + .filter(|w| w.state == "unavailable") + .map(|w| w.title.clone()) + .collect() + } + + #[test] + fn first_read_is_a_baseline_and_never_flags_absence() { + let mut tracker = EnforcementTracker::new(); + let mut only_five_hour = codex_snapshot(); + only_five_hour.secondary = None; + only_five_hour.secondary_label = None; + + assert!(tracker.annotate(&mut only_five_hour).is_empty()); + assert!(only_five_hour.inactive_rate_windows.is_empty()); + } + + #[test] + fn a_window_that_drops_out_becomes_unavailable() { + let mut tracker = EnforcementTracker::new(); + // Baseline: both 5-hour and weekly present. + tracker.annotate(&mut codex_snapshot()); + + // Next read omits the weekly window entirely. + let mut dropped = codex_snapshot(); + dropped.secondary = None; + dropped.secondary_label = None; + + let flagged = tracker.annotate(&mut dropped); + assert_eq!(flagged, vec!["Weekly".to_string()]); + assert_eq!(unavailable_titles(&dropped), vec!["Weekly".to_string()]); + assert_eq!(dropped.inactive_rate_windows[0].state, "unavailable"); + } + + #[test] + fn a_persistently_missing_window_is_flagged_every_refresh() { + // Regression: the unavailable state must be sticky, not one-shot. A + // window that stays gone must keep being reported on every refresh, or + // the indicator would flash once and then silently disappear. + let mut tracker = EnforcementTracker::new(); + tracker.annotate(&mut codex_snapshot()); + + for _ in 0..3 { + let mut dropped = codex_snapshot(); + dropped.secondary = None; + dropped.secondary_label = None; + assert_eq!(tracker.annotate(&mut dropped), vec!["Weekly".to_string()]); + assert_eq!(unavailable_titles(&dropped), vec!["Weekly".to_string()]); + } + } + + #[test] + fn an_explicitly_lifted_window_is_not_unavailable() { + let mut tracker = EnforcementTracker::new(); + tracker.annotate(&mut codex_snapshot()); + + // Weekly is reported as inactive (provider lifted it), not absent. + let mut lifted = codex_snapshot(); + lifted.secondary = None; + lifted.secondary_label = None; + lifted + .inactive_rate_windows + .push(InactiveRateWindowSnapshot { + id: "codex-weekly".into(), + title: "Weekly".into(), + description: "Not currently enforced".into(), + state: "notEnforced".into(), + }); + + assert!(tracker.annotate(&mut lifted).is_empty()); + assert!(unavailable_titles(&lifted).is_empty()); + } + + #[test] + fn a_returning_window_stops_being_flagged() { + let mut tracker = EnforcementTracker::new(); + tracker.annotate(&mut codex_snapshot()); + + let mut dropped = codex_snapshot(); + dropped.secondary = None; + dropped.secondary_label = None; + assert_eq!(tracker.annotate(&mut dropped).len(), 1); + + // Weekly comes back on the following read. + let mut restored = codex_snapshot(); + assert!(tracker.annotate(&mut restored).is_empty()); + assert!(unavailable_titles(&restored).is_empty()); + } + + #[test] + fn errored_snapshot_is_ignored_and_does_not_disturb_the_baseline() { + let mut tracker = EnforcementTracker::new(); + tracker.annotate(&mut codex_snapshot()); + + let mut errored = codex_snapshot(); + errored.secondary = None; + errored.error = Some("network".into()); + assert!(tracker.annotate(&mut errored).is_empty()); + assert!(errored.inactive_rate_windows.is_empty()); + + // The failed read must not have consumed the baseline: a later + // successful read that still omits weekly is flagged. + let mut dropped = codex_snapshot(); + dropped.secondary = None; + dropped.secondary_label = None; + assert_eq!(tracker.annotate(&mut dropped), vec!["Weekly".to_string()]); + } + + #[test] + fn non_first_class_providers_are_not_tracked() { + let mut tracker = EnforcementTracker::new(); + let mut first = codex_snapshot(); + first.provider_id = "venice".into(); + tracker.annotate(&mut first); + + let mut dropped = codex_snapshot(); + dropped.provider_id = "venice".into(); + dropped.secondary = None; + dropped.secondary_label = None; + assert!(tracker.annotate(&mut dropped).is_empty()); + assert!(dropped.inactive_rate_windows.is_empty()); + } + + #[test] + fn different_accounts_do_not_flag_each_other() { + let mut tracker = EnforcementTracker::new(); + tracker.annotate(&mut codex_snapshot()); + + // A different account's first read is its own baseline, even though it + // omits weekly relative to the first account. + let mut other = codex_snapshot(); + other.account_email = Some("other@example.com".into()); + other.secondary = None; + other.secondary_label = None; + assert!(tracker.annotate(&mut other).is_empty()); + assert!(other.inactive_rate_windows.is_empty()); + } + + #[test] + fn different_organizations_under_the_same_email_do_not_flag_each_other() { + // The same email can span a personal and a business workspace with + // different limits; switching between them must not reuse a baseline. + let mut tracker = EnforcementTracker::new(); + tracker.annotate(&mut codex_snapshot()); + + let mut other_org = codex_snapshot(); + other_org.account_organization = Some("org_business".into()); + other_org.secondary = None; + other_org.secondary_label = None; + assert!(tracker.annotate(&mut other_org).is_empty()); + assert!(other_org.inactive_rate_windows.is_empty()); + } + + #[test] + fn conditional_bonus_windows_are_never_flagged_unavailable() { + // Bonus pools (Spark, promos, additional budget) legitimately end. Their + // disappearance must not be reported as an unreadable core limit. + let mut tracker = EnforcementTracker::new(); + let mut with_spark = codex_snapshot(); + with_spark.extra_rate_windows.push(NamedRateWindowSnapshot { + id: "codex-spark-weekly".into(), + title: "Codex Spark Weekly".into(), + window: window(Some(10_080)), + }); + tracker.annotate(&mut with_spark); + + // Spark disappears on the next successful read; core windows are intact. + let mut dropped = codex_snapshot(); + assert!(tracker.annotate(&mut dropped).is_empty()); + assert!(dropped.inactive_rate_windows.is_empty()); + } + + #[test] + fn a_core_window_that_drops_out_is_still_flagged() { + // The valuable signal is preserved: a genuine subscription window that + // vanishes is reported, even though bonus pools are ignored. + let mut tracker = EnforcementTracker::new(); + let mut with_spark = codex_snapshot(); + with_spark.extra_rate_windows.push(NamedRateWindowSnapshot { + id: "codex-spark-weekly".into(), + title: "Codex Spark Weekly".into(), + window: window(Some(10_080)), + }); + tracker.annotate(&mut with_spark); + + let mut dropped = codex_snapshot(); + dropped.secondary = None; + dropped.secondary_label = None; + assert_eq!(tracker.annotate(&mut dropped), vec!["Weekly".to_string()]); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs index c1ba6ab1..68bd957a 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs @@ -107,6 +107,7 @@ pub struct SettingsPatch { pub style: Option, pub open_on_hover: Option, pub density: Option, + pub information_mode: Option, pub contrast: Option, pub click_through: Option, pub provider_ids: Option>, @@ -126,6 +127,7 @@ impl SettingsPatch { && self.style.is_none() && self.open_on_hover.is_none() && self.density.is_none() + && self.information_mode.is_none() && self.contrast.is_none() && self.click_through.is_none() && self.provider_ids.is_none() @@ -164,6 +166,10 @@ impl SettingsPatch { if let Some(v) = &self.density { settings.float_bar_density = codexbar::settings::normalize_float_bar_density(v); } + if let Some(v) = &self.information_mode { + settings.float_bar_information_mode = + codexbar::settings::normalize_float_bar_information_mode(v); + } if let Some(v) = &self.contrast { settings.float_bar_contrast = Some(codexbar::settings::normalize_float_bar_contrast(v)); } @@ -218,6 +224,22 @@ mod tests { assert!(SettingsPatch::default().is_empty()); } + #[test] + fn information_mode_only_change_is_a_non_empty_patch() { + // A mode-only change must count as a float-bar patch so the detached + // bar is told to re-fetch (after_settings_saved notifies when the patch + // is non-empty), and so it applies the new mode on disk. + let patch = SettingsPatch { + information_mode: Some("calm".into()), + ..SettingsPatch::default() + }; + assert!(!patch.is_empty()); + + let mut settings = Settings::default(); + patch.apply(&mut settings); + assert_eq!(settings.float_bar_information_mode, "calm"); + } + #[test] fn settings_patch_apply_only_writes_present_fields() { let mut s = Settings { diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index ee9c981b..2fa9f031 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -5,6 +5,7 @@ use std::time::Duration; mod auto_refresh; mod capacity_events; mod commands; +mod enforcement; mod events; mod floatbar; mod geometry_store; @@ -209,6 +210,7 @@ fn main() { commands::get_launch_block_reason, commands::get_work_area_rect, commands::play_notification_sound, + commands::send_test_notification, commands::open_external_url, commands::reanchor_tray_panel, commands::quit_app, diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 1d5f0c0e..c64a82ef 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -137,6 +137,9 @@ pub struct AppState { pub notification_manager: codexbar::notifications::NotificationManager, /// Provider-authoritative reset observer persisted separately from settings. pub capacity_event_observer: crate::capacity_events::CapacityEventObserver, + /// Tracks which windows each provider scope is reporting so a window that + /// drops out of a successful response can be surfaced as `unavailable`. + pub enforcement_tracker: crate::enforcement::EnforcementTracker, /// Instant when the tray panel was last shown — used to suppress /// spurious blur-dismiss during the show animation on Windows. pub last_shown_at: Option, @@ -197,6 +200,7 @@ impl AppState { proof_config: None, notification_manager: codexbar::notifications::NotificationManager::new(), capacity_event_observer: crate::capacity_events::CapacityEventObserver::load_default(), + enforcement_tracker: crate::enforcement::EnforcementTracker::new(), last_shown_at: None, startup_tray_blur_grace_until: None, suppress_geometry_capture_until: None, diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 0d433617..d191d671 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -112,6 +112,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { floatBarStyle: "floating", taskbarWidgetOpenOnHover: true, floatBarDensity: "standard", + floatBarInformationMode: "exact", floatBarContrast: "auto", floatBarClickThrough: false, floatBarProviderIds: [], diff --git a/apps/desktop-tauri/src/components/PlanStatusCard.test.tsx b/apps/desktop-tauri/src/components/PlanStatusCard.test.tsx index 76f7307d..d319c784 100644 --- a/apps/desktop-tauri/src/components/PlanStatusCard.test.tsx +++ b/apps/desktop-tauri/src/components/PlanStatusCard.test.tsx @@ -197,4 +197,33 @@ describe("PlanStatusCard", () => { expect(screen.getByText("not currently enforced")).toBeTruthy(); expect(screen.queryByText("lifted")).toBeNull(); }); + + it("separates unavailable windows from not-enforced ones", () => { + render( + , + ); + + expect(screen.getByText("not currently enforced")).toBeTruthy(); + expect(screen.getByText("unavailable")).toBeTruthy(); + // The unavailable window must not be lumped under "not currently enforced". + expect(screen.getByText("Weekly")).toBeTruthy(); + }); }); diff --git a/apps/desktop-tauri/src/components/PlanStatusCard.tsx b/apps/desktop-tauri/src/components/PlanStatusCard.tsx index 31cecae7..78c946d2 100644 --- a/apps/desktop-tauri/src/components/PlanStatusCard.tsx +++ b/apps/desktop-tauri/src/components/PlanStatusCard.tsx @@ -6,8 +6,8 @@ import { useFormattedResetTime } from "../hooks/useFormattedResetTime"; import { useLocale } from "../hooks/useLocale"; import { capacityFreshness, + codexResetCredits, glanceMeters, - resetCreditsAvailable, type ConstrainingWindow, } from "../lib/capacityPresentation"; @@ -40,9 +40,13 @@ function pressureLabel(level: string): string | null { return null; } -function inactiveWindowSummary(provider: ProviderUsageSnapshot): string | null { +function inactiveWindowSummary( + provider: ProviderUsageSnapshot, + state: "notEnforced" | "unavailable", +): string | null { const labels = [...new Set( (provider.inactiveRateWindows ?? []) + .filter((window) => (window.state ?? "notEnforced") === state) .map((window) => window.title.trim()) .filter(Boolean), )]; @@ -165,8 +169,9 @@ export default function PlanStatusCard({ const meters = glanceMeters(provider); const freshness = capacityFreshness(provider); const planName = displayPlanName(provider.planName, provider.displayName); - const inactiveSummary = inactiveWindowSummary(provider); - const resetCredits = resetCreditsAvailable(provider); + const notEnforcedSummary = inactiveWindowSummary(provider, "notEnforced"); + const unavailableSummary = inactiveWindowSummary(provider, "unavailable"); + const resetCredits = codexResetCredits(provider); const className = [ "plan-status-card", @@ -205,7 +210,9 @@ export default function PlanStatusCard({ )} {resetCredits != null && ( - + ↻ {resetCredits} {resetCredits === 1 ? "reset available" : "resets available"} )} @@ -235,15 +242,24 @@ export default function PlanStatusCard({ hero={false} /> ))} - {inactiveSummary && ( + {notEnforcedSummary && (
- {inactiveSummary} + {notEnforcedSummary} not currently enforced
)} + {unavailableSummary && ( +
+ + + {unavailableSummary} + + unavailable +
+ )} )} diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.css b/apps/desktop-tauri/src/floatbar/FloatBar.css index 08371526..2b015d0b 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.css +++ b/apps/desktop-tauri/src/floatbar/FloatBar.css @@ -287,6 +287,51 @@ body.floatbar-window #root { .floatbar__reset--emphasis .floatbar__reset-time { font-weight: 650; } +/* ── Calm information mode ───────────────────────────────────────────── + The pill leads with a trustworthy pace state + next reset and becomes an + expandable button. No new motion, so reduced-motion is unaffected. */ +.floatbar__pill--calm { + cursor: pointer; +} +.floatbar__pill--calm:focus-visible { + outline: 2px solid color-mix(in srgb, var(--ceiling-accent, #26b5ce) 70%, white); + outline-offset: 1px; +} +.floatbar__pace { + font-size: 0.86em; + font-weight: 650; + line-height: 1; + white-space: nowrap; +} +.floatbar__pace--steady { + color: color-mix(in srgb, #34d399 62%, white); +} +.floatbar__pace--watch { + color: color-mix(in srgb, #f59e0b 68%, white); +} +.floatbar__calm-reset { + display: inline-flex; + align-items: center; + gap: calc(3px * var(--floatbar-scale, 1)); + opacity: 0.66; + line-height: 1; +} +.floatbar__calm-sep { + opacity: 0.5; +} +.floatbar__pct--calm { + padding-left: calc(6px * var(--floatbar-scale, 1)); + margin-left: calc(2px * var(--floatbar-scale, 1)); + border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent); + font-size: 0.98em; +} +.floatbar--light-bg .floatbar__pace--steady { + color: color-mix(in srgb, #059669 78%, black); +} +.floatbar--light-bg .floatbar__pace--watch { + color: color-mix(in srgb, #b45309 82%, black); +} + .floatbar--vertical .floatbar__text { flex-direction: column; align-items: center; diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 97f93921..f6fe501f 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -137,6 +137,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { floatBarStyle: "floating", taskbarWidgetOpenOnHover: true, floatBarDensity: "standard", + floatBarInformationMode: "exact", floatBarContrast: "light-text", floatBarClickThrough: false, floatBarProviderIds: [], @@ -221,6 +222,73 @@ describe("FloatBar", () => { expect(titles[1]).toMatch(/Claude: 20% used/); }); + it("calm mode leads with a pace state and expands to the exact % on click", async () => { + const calmProvider: ProviderUsageSnapshot = { + ...snapshot("codex", "Codex", 60, { + resetsAt: new Date(Date.now() + 2 * 3600_000).toISOString(), + }), + updatedAt: new Date().toISOString(), + pace: { + windowLabel: "Weekly", + stage: "on_track", + deltaPercent: 0, + willLastToReset: true, + etaSeconds: null, + expectedUsedPercent: 60, + actualUsedPercent: 60, + }, + }; + tauriMocks.getCachedProviders.mockResolvedValue([calmProvider]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarInformationMode: "calm" }), + ); + + const { container } = renderFloatBar( + bootstrap({ floatBarInformationMode: "calm" }), + ); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill--calm")).toBeInTheDocument(); + }); + + // Calm leads with the trustworthy pace state; the exact % is hidden until + // the user expands, and the pill is a keyboard-accessible button. + expect(container.querySelector(".floatbar__pace")).toHaveTextContent("On pace"); + expect(container.querySelector(".floatbar__pct--calm")).toBeNull(); + const pill = container.querySelector(".floatbar__pill--calm")!; + expect(pill.getAttribute("role")).toBe("button"); + expect(pill.getAttribute("tabindex")).toBe("0"); + + act(() => { + pill.click(); + }); + expect(container.querySelector(".floatbar__pct--calm")).toHaveTextContent("60%"); + }); + + it("calm + compact stays non-blank and drops the window·reset row", async () => { + // No fresh pace + compact density (which hides the window/reset): the pill + // must still show the exact % rather than collapse to just the icon, and + // the calm reset row (with its separator) must not render. + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("codex", "Codex", 60, { + resetsAt: new Date(Date.now() + 3600_000).toISOString(), + }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarInformationMode: "calm", floatBarDensity: "compact" }), + ); + + const { container } = renderFloatBar( + bootstrap({ floatBarInformationMode: "calm", floatBarDensity: "compact" }), + ); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill--calm")).toBeInTheDocument(); + }); + + expect(container.querySelector(".floatbar__calm-reset")).toBeNull(); + expect(container.querySelector(".floatbar__calm-sep")).toBeNull(); + expect(container.querySelector(".floatbar__pct--calm")).toHaveTextContent("60%"); + }); + it("does not render hypothetical local costs from the legacy setting", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("codex", "Codex", 75), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index bcd04bbf..04071d9b 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, } from "react"; import { listen } from "@tauri-apps/api/event"; @@ -36,9 +37,11 @@ import { capacityFreshness, constrainingWindow, activePromoBoosts, + calmPresentation, type CapacityFreshness, type ConstrainingWindow, } from "../lib/capacityPresentation"; +import type { FloatBarInformationMode } from "../types/bridge"; import "./FloatBar.css"; function ResetIcon({ size }: { size: number }) { @@ -117,6 +120,10 @@ function ProviderPill({ usedSuffix, remainingSuffix, capacityEventKind, + informationMode, + isCompact, + expanded, + onToggleExpand, }: { provider: ProviderUsageSnapshot; highRemaining: number; @@ -128,6 +135,10 @@ function ProviderPill({ usedSuffix: string; remainingSuffix: string; capacityEventKind?: CapacityEventPayload["kind"]; + informationMode: FloatBarInformationMode; + isCompact: boolean; + expanded: boolean; + onToggleExpand: () => void; }) { // Keep the number, label, and tone tied to the same displayed window. const hero = floatBarWindow(provider); @@ -171,17 +182,93 @@ function ProviderPill({ .filter(Boolean) .join("\n"); + const pillClass = [ + "floatbar__pill", + `floatbar__pill--${tone}`, + freshness !== "live" ? `floatbar__pill--${freshness}` : null, + capacityEventKind ? "floatbar__pill--capacity-event" : null, + capacityEventKind ? `floatbar__pill--${capacityEventKind}` : null, + ] + .filter(Boolean) + .join(" "); + + // Calm mode: lead with a trustworthy pace state and the next reset; exact % + // stays one keyboard/click away. The pill becomes an expandable button, so it + // opts out of the native drag region (drag the bar by its handle instead). + if (informationMode === "calm") { + const calm = calmPresentation(provider, hero); + // Compact hides the window·reset row, so calm degrades to pace-or-exact. + // Otherwise show exact only when there is no pace and no reset time to show. + // Either way the pill is never blank. + const showResetRow = !isCompact && calm.hasReset; + const showExact = + expanded || (isCompact ? !calm.pace : !calm.pace && !inlineReset); + const onKeyToggle = (event: ReactKeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggleExpand(); + } + }; + // Announce what the pill actually shows, not the exact % it hides — screen + // readers should hear the pace/reset state, and the % only once revealed. + const calmLabel = [ + `${provider.displayName}:`, + calm.pace?.label ?? null, + showResetRow + ? `${hero.label}${resetText ? ` ${resetText}` : ""}` + : null, + showExact ? `${label} ${displaySuffix}` : null, + stateChip ? `state ${stateChip}` : null, + ] + .filter(Boolean) + .join(" "); + return ( +
event.stopPropagation()} + style={{ "--brand": brand } as CSSProperties} + > + + + + + {calm.pace ? ( + + {calm.pace.label} + + ) : null} + {showResetRow ? ( + + {hero.label} + {inlineReset ? ( + <> + + · + + + {inlineReset} + + ) : null} + + ) : null} + {showExact ? ( + {label} + ) : null} + +
+ ); + } + return (
>(new Set()); + const toggleExpanded = useCallback((key: string) => { + setExpandedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); const startDrag = useCallback((event: MouseEvent) => { if (event.button !== 0 || lockedRef.current || menuOpenRef.current) return; @@ -405,6 +502,8 @@ export default function FloatBar({ state }: { state: BootstrapState }) { ? settings.floatBarDensity : "standard"; const contrast = settings.floatBarContrast ?? "light-text"; + const informationMode: FloatBarInformationMode = + settings.floatBarInformationMode === "calm" ? "calm" : "exact"; const darkText = contrast === "dark-text" || (contrast === "auto" && systemPrefersLight); const filterIds = settings.floatBarProviderIds; @@ -459,6 +558,8 @@ export default function FloatBar({ state }: { state: BootstrapState }) { scale, showResetInline, settings.resetTimeRelative, + informationMode, + expandedKeys, menuOpen, ]); @@ -503,7 +604,7 @@ export default function FloatBar({ state }: { state: BootstrapState }) { return (
toggleExpanded(providerKey(p))} /> ))}
diff --git a/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx b/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx index 2aa23927..a67aa3c9 100644 --- a/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx +++ b/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx @@ -17,6 +17,7 @@ const settings = { floatBarStyle: "floating", taskbarWidgetOpenOnHover: true, floatBarDensity: "standard", + floatBarInformationMode: "exact", floatBarContrast: "auto", floatBarShowCost: false, floatBarShowResetInline: false, diff --git a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx index fd5695aa..20a298f1 100644 --- a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx +++ b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx @@ -4,6 +4,7 @@ import type { FloatBarOrientation, FloatBarContrast, FloatBarDensity, + FloatBarInformationMode, SettingsSnapshot, SettingsUpdate, } from "../types/bridge"; @@ -150,6 +151,22 @@ export default function FloatBarSettingsSection({ settings, saving, set }: Props onChange={(v) => set({ floatBarDensity: v as FloatBarDensity })} /> + +