diff --git a/crates/codexbar-app/src/dashboard.rs b/crates/codexbar-app/src/dashboard.rs index 6a5bd81..e8f0ecc 100644 --- a/crates/codexbar-app/src/dashboard.rs +++ b/crates/codexbar-app/src/dashboard.rs @@ -1803,6 +1803,33 @@ impl Dashboard { cx.notify(); } + /// Two Copilot accounts through the real parser (#318): one 740 premium requests past its 3,000 allowance, in the + /// shape GitHub returns past it, and one exactly at it. Synthetic payloads only; no provider or account is read. + #[cfg(any(test, feature = "capture"))] + pub(crate) fn capture_copilot_overage(&mut self, cx: &mut Context) { + assert!(matches!(self.source, DataSource::Demo)); + let now: DateTime = "2026-10-20T12:00:00Z".parse().unwrap(); + // As in `capture_muse`: refreshed at the fixed time, so the clock's demo refresh doesn't replace the fixture. + self.last_refresh = Some(now); + crate::sus::capture_time(now, cx); + let account = |user: &str, remaining: i64| { + let payload = serde_json::json!({ + "copilot_plan": "business", + "quota_reset_date_utc": "2026-11-01T00:00:00.000Z", + "quota_snapshots": {"premium_interactions": { + "entitlement": 3000, "remaining": remaining, "percent_remaining": 0, "unlimited": false, + }}, + }); + codexbar_providers::copilot::parse_user(&payload.to_string(), user, now).unwrap() + }; + let accounts = vec![account("over-allowance", -740), account("at-allowance", 0)]; + self.states.clear(); + self.placeholders.clear(); + self.set_accounts(accounts, cx); + self.view = DashboardView::Usage; + cx.notify(); + } + /// Keep geometry fixtures independent of the real weekly reset day. #[cfg(test)] pub(crate) fn set_alignment_demo(&mut self, cx: &mut Context) { diff --git a/crates/codexbar-app/src/self_capture.rs b/crates/codexbar-app/src/self_capture.rs index 53bb20a..5d6e2a0 100644 --- a/crates/codexbar-app/src/self_capture.rs +++ b/crates/codexbar-app/src/self_capture.rs @@ -20,6 +20,7 @@ use crate::theme::Appearance; mod antigravity; mod claude_ultra; mod claude_weekly; +mod copilot_overage; mod far_dates; mod gemini_status; mod gemini_weekly; @@ -45,6 +46,7 @@ pub fn start(cx: &mut App, handle: AnyWindowHandle, dashboard: Entity || std::env::var_os("CODEXBAR_CAPTURE_CLAUDE_WEEKLY").is_some() || std::env::var_os("CODEXBAR_CAPTURE_CLAUDE_ULTRA").is_some() || std::env::var_os("CODEXBAR_CAPTURE_FAR_DATES").is_some() + || std::env::var_os("CODEXBAR_CAPTURE_COPILOT_OVERAGE").is_some() { let hub = crate::settings_hub::SettingsHub::global(cx); assert!( @@ -264,6 +266,9 @@ async fn run( if std::env::var_os("CODEXBAR_CAPTURE_FAR_DATES").is_some() { saved.extend(far_dates::run(cx, handle, dashboard, dir).await); } + if std::env::var_os("CODEXBAR_CAPTURE_COPILOT_OVERAGE").is_some() { + saved.extend(copilot_overage::run(cx, handle, dashboard, dir).await); + } // Optional synthetic plan states. The normal live adapters never read these fixture values. if std::env::var_os("CODEXBAR_CAPTURE_PLANS").is_some() { cx.update(|cx| crate::prefs_hub::PrefsHub::set_appearance(cx, Appearance::Light)); diff --git a/crates/codexbar-app/src/self_capture/copilot_overage.rs b/crates/codexbar-app/src/self_capture/copilot_overage.rs new file mode 100644 index 0000000..371afff --- /dev/null +++ b/crates/codexbar-app/src/self_capture/copilot_overage.rs @@ -0,0 +1,55 @@ +//! A Copilot account past its premium allowance (#318) in the real Usage table, beside one exactly at it, through the +//! production parser. +use super::{save, settle}; +use crate::dashboard::Dashboard; +use crate::theme::Appearance; +use gpui_kit::test::TestWindowExt as _; +use gpui_kit::{AnyWindowHandle, AppContext as _, AsyncApp, Entity}; +use std::path::Path; + +/// Each row's used cell and bar, as the table labels them. +const ROWS: [(&str, &str, &str); 2] = [ + ( + "copilot-over-allowance", + "3,000 requests: 3,740", + "100% used, 174% at reset · limit reached", + ), + ( + "copilot-at-allowance", + "3,000 requests: 3,000", + "100% used, 159% at reset · limit reached", + ), +]; + +pub(super) async fn run( + cx: &mut AsyncApp, + handle: AnyWindowHandle, + dashboard: &Entity, + dir: &Path, +) -> Vec { + let mut saved = Vec::new(); + for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] { + cx.update(|cx| crate::prefs_hub::PrefsHub::set_appearance(cx, appearance)); + cx.update(|cx| dashboard.update(cx, |d, cx| d.capture_copilot_overage(cx))); + settle(cx).await; + cx.update(|cx| { + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + for (id, used, bar) in ROWS { + let label = |name: String| { + window + .try_find(gpui_kit::ElementId::Name(name.into())) + .and_then(|found| found.label().map(str::to_owned)) + }; + assert_eq!(label(format!("used-{id}")).as_deref(), Some(used), "{id}"); + assert_eq!(label(format!("progress-cell-{id}")).as_deref(), Some(bar), "{id}"); + } + }) + }) + .expect("the Copilot fixture must reach the actual table"); + let name = format!("copilot-overage-usage-{theme}"); + assert!(save(cx, handle, dir, &name)); + saved.push(name); + } + saved +} diff --git a/crates/codexbar-app/src/ui_tests.rs b/crates/codexbar-app/src/ui_tests.rs index 703bd12..8a5df5b 100644 --- a/crates/codexbar-app/src/ui_tests.rs +++ b/crates/codexbar-app/src/ui_tests.rs @@ -8930,3 +8930,38 @@ fn far_dates_east_of_utc_show_no_date_and_leave_the_others(cx: &mut TestAppConte } assert!(row(cx, "Ordinary").contains("until Nov 2")); } + +/// #318: a Copilot account 740 premium requests past its 3,000 allowance reads 3,740 in the Usage table and stays +/// limit reached, beside one exactly at it, through the real parser as `capture_copilot_overage` sets up for the +/// screenshots. +#[gpui_kit::test] +fn copilot_quota_past_its_allowance_shows_the_overage_and_stays_limit_reached(cx: &mut TestAppContext) { + let settings = TempSettings::new("copilot-overage", "{}"); + let (handle, dashboard) = open_dashboard(cx, &settings); + cx.update(|cx| dashboard.update(cx, |d, cx| d.capture_copilot_overage(cx))); + cx.run_until_parked(); + cx.update_window(handle, |_, window, cx| window.render_frame(cx)) + .unwrap(); + + assert_eq!( + label(cx, handle, "used-copilot-over-allowance").as_deref(), + Some("3,000 requests: 3,740") + ); + assert_eq!( + label(cx, handle, "used-copilot-at-allowance").as_deref(), + Some("3,000 requests: 3,000") + ); + // Both stay limit reached. The forecast follows the pace of what was used, so the account past its allowance + // projects further past it than the one at it, 19.5 days into the month. + assert_eq!( + label(cx, handle, "progress-cell-copilot-over-allowance").as_deref(), + Some("100% used, 174% at reset · limit reached") + ); + assert_eq!( + label(cx, handle, "progress-cell-copilot-at-allowance").as_deref(), + Some("100% used, 159% at reset · limit reached") + ); + // A used-up limit has no pill, past it or at it (#150). + assert!(label(cx, handle, "severity-tag-copilot-over-allowance").is_none()); + assert!(label(cx, handle, "severity-tag-copilot-at-allowance").is_none()); +} diff --git a/crates/codexbar-providers/src/copilot.rs b/crates/codexbar-providers/src/copilot.rs index de91c02..1476524 100644 --- a/crates/codexbar-providers/src/copilot.rs +++ b/crates/codexbar-providers/src/copilot.rs @@ -127,14 +127,10 @@ const QUOTAS: [(&str, &str); 3] = [ ("completions", "Code completions"), ]; -/// A monthly quota with a real ceiling. Unlimited or zero entitlements are not limits. +/// A monthly quota with a real ceiling. Unlimited or zero entitlements are not limits, and a quota whose `remaining` +/// isn't a whole number is left out rather than shown as used up. fn quota_metric(quota: &Value, label: &str, resets_at: DateTime, now: DateTime) -> Option { - let entitlement = quota.get("entitlement").and_then(Value::as_u64).filter(|e| *e > 0)?; - if quota.get("unlimited").and_then(Value::as_bool).unwrap_or(false) { - return None; - } - let remaining = quota.get("remaining").and_then(Value::as_i64).unwrap_or(0).max(0) as u64; - let used = entitlement.saturating_sub(remaining); + let (used, entitlement) = quota_counts(quota)?; let start = resets_at.checked_sub_months(Months::new(1)).unwrap_or(resets_at); let pace = elapsed_pace(used as f64 / entitlement as f64, start, resets_at, now); Some(Metric::Quota { @@ -147,6 +143,26 @@ fn quota_metric(quota: &Value, label: &str, resets_at: DateTime, now: DateT }) } +/// Decode the bounded quota's used and entitled request counts before constructing a metric. +fn quota_counts(quota: &Value) -> Option<(u64, u64)> { + let entitlement = quota.get("entitlement").and_then(Value::as_u64).filter(|e| *e > 0)?; + if quota.get("unlimited").and_then(Value::as_bool).unwrap_or(false) { + return None; + } + let used = quota_used(entitlement, quota.get("remaining")?)?; + Some((used, entitlement)) +} + +/// What a quota has used: its entitlement less what remains. Past the entitlement GitHub reports `remaining` below +/// zero, so the overage is added rather than dropped (#318). A `remaining` above the entitlement means nothing used. +/// `None` when `remaining` isn't a whole number, or when the overage would take the count past `u64::MAX`. +fn quota_used(entitlement: u64, remaining: &Value) -> Option { + match remaining.as_u64() { + Some(left) => Some(entitlement.saturating_sub(left)), + None => entitlement.checked_add(remaining.as_i64()?.unsigned_abs()), + } +} + /// `quota_reset_date_utc` (RFC 3339), falling back to the date-only `quota_reset_date` at midnight UTC. fn reset_date(json: &Value) -> Option> { if let Some(stamp) = json.get("quota_reset_date_utc").and_then(Value::as_str) @@ -1316,6 +1332,178 @@ mod tests { assert_eq!(account.primary().unwrap().used_display(), "1"); } + /// The `key` quota `parse_user` reads from a snapshot with these raw JSON `entitlement` and `remaining` values + /// (`None` leaves the field out), beside an ordinary sibling quota that must stay. + fn quota(key: &str, entitlement: Option<&str>, remaining: Option<&str>, unlimited: bool) -> Option { + let label_of = |key: &str| QUOTAS.iter().find(|(name, _)| *name == key).unwrap().1; + let sibling = if key == "chat" { "completions" } else { "chat" }; + let fields: Vec = [("entitlement", entitlement), ("remaining", remaining)] + .into_iter() + .filter_map(|(name, value)| Some(format!(r#""{name}":{}"#, value?))) + .chain([format!(r#""unlimited":{unlimited}"#)]) + .collect(); + let payload = format!( + r#"{{"quota_reset_date_utc":"2026-11-01T00:00:00.000Z","quota_snapshots":{{"{sibling}":{{"entitlement":50,"remaining":40,"unlimited":false}},"{key}":{{{}}}}}}}"#, + fields.join(",") + ); + let account = parse_user(&payload, "dev", now()).unwrap(); + assert_eq!( + used_and_limit( + account + .metrics() + .iter() + .find(|m| m.label() == label_of(sibling)) + .cloned() + ), + Some((10, 50)), + "the sibling quota is unaffected: {payload}" + ); + account + .metrics() + .iter() + .find(|metric| metric.label() == label_of(key)) + .cloned() + } + + fn used_and_limit(metric: Option) -> Option<(u64, u64)> { + match metric? { + Metric::Quota { used, limit, .. } => Some((used, limit)), + other => panic!("not a quota: {other:?}"), + } + } + + const QUOTA_KEYS: [&str; 3] = ["premium_interactions", "chat", "completions"]; + + #[test] + fn parse_user_quota_past_its_entitlement_counts_the_overage() { + for key in QUOTA_KEYS { + // GitHub's shape past the allowance (#318): `remaining` below zero by the overage. + let over = quota(key, Some("3000"), Some("-740"), false).unwrap(); + assert_eq!(used_and_limit(Some(over.clone())), Some((3740, 3000)), "{key}"); + assert_eq!(over.used_display(), "3,740", "{key}"); + assert_eq!( + over.used_fraction(), + Some(1.0), + "{key}: the share stays at the whole limit" + ); + // One over is not the same as exactly at the limit. + assert_eq!( + used_and_limit(quota(key, Some("300"), Some("-1"), false)), + Some((301, 300)), + "{key}" + ); + assert_eq!( + used_and_limit(quota(key, Some("300"), Some("0"), false)), + Some((300, 300)), + "{key}" + ); + // Under the limit, and with nothing used, as before. + assert_eq!( + used_and_limit(quota(key, Some("1500"), Some("216"), false)), + Some((1284, 1500)), + "{key}" + ); + assert_eq!( + used_and_limit(quota(key, Some("300"), Some("300"), false)), + Some((0, 300)), + "{key}" + ); + // More left than the entitlement is nothing used, as before. + assert_eq!( + used_and_limit(quota(key, Some("300"), Some("5000"), false)), + Some((0, 300)), + "{key}" + ); + } + } + + #[test] + fn parse_user_quota_past_its_entitlement_is_used_up_like_one_at_it() { + let snapshot = |remaining: i64| { + let payload = serde_json::json!({ + "quota_reset_date_utc": "2026-11-01T00:00:00.000Z", + "quota_snapshots": {"premium_interactions": {"entitlement": 3000, "remaining": remaining, "unlimited": false}}, + }); + parse_user(&payload.to_string(), "dev", now()).unwrap() + }; + let at = snapshot(0).assess(now()); + let over = snapshot(-740).assess(now()); + assert!(at.is_exhausted() && over.is_exhausted()); + assert_eq!(over.severity(), at.severity()); + } + + #[test] + fn parse_user_quota_used_stays_exact_at_both_ends_of_the_integers() { + let premium = |entitlement: &str, remaining: &str| { + used_and_limit(quota("premium_interactions", Some(entitlement), Some(remaining), false)) + }; + let max = u64::MAX.to_string(); + let min = i64::MIN.to_string(); + // The most negative `remaining` adds 2^63 without wrapping. + assert_eq!(premium("3000", &min), Some((3000 + (1 << 63), 3000))); + // The largest entitlement: used up, exactly at `u64::MAX` with an overage that just fits, and left out once + // the overage would pass it. + assert_eq!(premium(&max, "0"), Some((u64::MAX, u64::MAX))); + let near = (u64::MAX - 740).to_string(); + assert_eq!(premium(&near, "-740"), Some((u64::MAX, u64::MAX - 740))); + assert_eq!(premium(&near, "-741"), None); + assert_eq!(premium(&max, "-1"), None); + assert_eq!(premium(&max, &min), None); + // A `remaining` past `i64::MAX` is still a whole count left, so nothing is used. + assert_eq!(premium("3000", &max), Some((0, 3000))); + assert_eq!(premium(&max, &max), Some((0, u64::MAX))); + } + + #[test] + fn parse_user_quota_without_a_whole_remaining_is_left_out() { + // A count CodexBar can't read is not shown as the whole entitlement used (#318), and its sibling stays. + for key in ["premium_interactions", "completions"] { + for remaining in [ + None, + Some("null"), + Some("216.5"), + Some("216.0"), + Some("-740.5"), + Some(r#""216""#), + Some("true"), + Some("-1e30"), + // One past `u64::MAX` only fits a float. + Some("18446744073709551616"), + ] { + assert_eq!(quota(key, Some("3000"), remaining, false), None, "{key}: {remaining:?}"); + } + } + } + + #[test] + fn parse_user_quota_entitlement_limits_are_unchanged() { + for key in QUOTA_KEYS { + // No entitlement, or one that isn't a positive whole number, is no limit, whatever remains. + for entitlement in [ + None, + Some("0"), + Some("-5"), + Some("300.5"), + Some(r#""300""#), + Some("null"), + ] { + assert_eq!( + quota(key, entitlement, Some("-740"), false), + None, + "{key}: {entitlement:?}" + ); + } + // Unlimited is no limit, even past an entitlement. + assert_eq!(quota(key, Some("3000"), Some("-740"), true), None, "{key}"); + // The smallest entitlement is a limit. + assert_eq!( + used_and_limit(quota(key, Some("1"), Some("0"), false)), + Some((1, 1)), + "{key}" + ); + } + } + #[test] fn parse_user_date_only_reset_falls_back_to_midnight_utc() { let payload = r#"{"quota_reset_date":"2026-11-01","quota_snapshots":{"premium_interactions":{"entitlement":300,"remaining":290}}}"#;