diff --git a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs index 942122c0..c00c171f 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs @@ -279,6 +279,7 @@ mod tests { id: "claude-routines".to_string(), title: "Daily Routines".to_string(), window: window_resetting_at(Some("2026-07-21T03:00:00Z")), + amount: None, }); let mut times = snapshot_reset_times(&snapshot); diff --git a/apps/desktop-tauri/src-tauri/src/capacity_events.rs b/apps/desktop-tauri/src-tauri/src/capacity_events.rs index dcfc8775..1b16b404 100644 --- a/apps/desktop-tauri/src-tauri/src/capacity_events.rs +++ b/apps/desktop-tauri/src-tauri/src/capacity_events.rs @@ -1000,10 +1000,68 @@ mod tests { id: id.into(), title: title.into(), window: window(used, reset), + amount: None, }); snapshot } + #[test] + fn third_and_model_windows_are_observed_without_displacing_core_slots() { + // Neither slot reached this map before, so their resets and exhaustions + // were invisible to events. Both share a cadence with a core window in + // the wild (Claude's model pool is 7 days like its weekly), and the map + // is keyed by id — a shared key silently drops one of the two. + let now = Utc::now(); + let reset = now + Duration::hours(3); + let mut snapshot = snapshot(now, 10.0, reset); + snapshot.secondary = Some(RateWindowSnapshot { + window_minutes: Some(10_080), + ..window(20.0, reset) + }); + snapshot.secondary_label = Some("Weekly".into()); + snapshot.model_specific = Some(RateWindowSnapshot { + window_minutes: Some(10_080), + ..window(96.0, reset) + }); + snapshot.tertiary = Some(RateWindowSnapshot { + window_minutes: Some(43_200), + ..window(57.0, reset) + }); + snapshot.tertiary_label = Some("Monthly".into()); + + let (windows, extra_ids) = observed_windows(&snapshot); + + assert_eq!(windows["weekly"].used_percent, 20.0); + assert_eq!(windows["model"].used_percent, 96.0); + assert_eq!(windows["monthly"].used_percent, 57.0); + assert_eq!(windows["monthly"].label, "Monthly"); + // Not "extras": an extra id appearing for the first time announces a + // granted allowance, and these windows have been there all along. + assert!(!extra_ids.contains("model")); + assert!(!extra_ids.contains("monthly")); + } + + #[test] + fn a_third_window_sharing_a_cadence_falls_back_to_its_own_id() { + let now = Utc::now(); + let reset = now + Duration::hours(3); + let mut snapshot = snapshot(now, 10.0, reset); + snapshot.secondary = Some(RateWindowSnapshot { + window_minutes: Some(10_080), + ..window(20.0, reset) + }); + snapshot.secondary_label = Some("Weekly".into()); + snapshot.tertiary = Some(RateWindowSnapshot { + window_minutes: Some(10_080), + ..window(80.0, reset) + }); + + let (windows, _) = observed_windows(&snapshot); + + assert_eq!(windows["weekly"].used_percent, 20.0); + assert_eq!(windows["tertiary"].used_percent, 80.0); + } + fn with_inactive( mut snapshot: ProviderUsageSnapshot, id: &str, diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 089215f1..b0fc6719 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -67,6 +67,30 @@ pub struct NamedRateWindowSnapshot { pub id: String, pub title: String, pub window: RateWindowSnapshot, + /// Money behind this lane, for providers that denominate it that way. + pub amount: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowAmountBridge { + pub used: f64, + pub limit: Option, + pub currency_code: String, + pub formatted_used: String, + pub formatted_limit: Option, +} + +impl WindowAmountBridge { + fn from_amount(amount: &codexbar::core::WindowAmount) -> Self { + Self { + used: amount.used, + limit: amount.limit, + currency_code: amount.currency_code.clone(), + formatted_used: amount.format_used(), + formatted_limit: amount.format_limit(), + } + } } #[derive(Debug, Clone, Serialize)] @@ -283,6 +307,7 @@ impl ProviderUsageSnapshot { id: extra.id.clone(), title: extra.title.clone(), window: RateWindowSnapshot::from_rate_window(&extra.window), + amount: extra.amount.as_ref().map(WindowAmountBridge::from_amount), }) .collect(), inactive_rate_windows: usage diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 19dac898..143b51c6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -993,11 +993,13 @@ fn hiding_codex_spark_rows_preserves_other_extra_usage() { id: "codex-spark".to_string(), title: "Codex Spark 5-hour".to_string(), window: snapshot.primary.clone(), + amount: None, }, NamedRateWindowSnapshot { id: "credits".to_string(), title: "Credits".to_string(), window: snapshot.primary.clone(), + amount: None, }, ]; diff --git a/apps/desktop-tauri/src-tauri/src/enforcement.rs b/apps/desktop-tauri/src-tauri/src/enforcement.rs index 6acd1b17..34b62b79 100644 --- a/apps/desktop-tauri/src-tauri/src/enforcement.rs +++ b/apps/desktop-tauri/src-tauri/src/enforcement.rs @@ -380,6 +380,7 @@ mod tests { id: "codex-spark-weekly".into(), title: "Codex Spark Weekly".into(), window: window(Some(10_080)), + amount: None, }); tracker.annotate(&mut with_spark); @@ -399,6 +400,7 @@ mod tests { id: "codex-spark-weekly".into(), title: "Codex Spark Weekly".into(), window: window(Some(10_080)), + amount: None, }); tracker.annotate(&mut with_spark); diff --git a/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs b/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs index c34aab55..6fd43381 100644 --- a/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs +++ b/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs @@ -2228,6 +2228,7 @@ mod tests { id: "cursor-api".into(), title: "API".into(), window: rate_window(100.0, Some(10_080)), + amount: None, }); let readout = constraining_readout(&snapshot); @@ -2247,6 +2248,7 @@ mod tests { id: "cursor-api".into(), title: "API".into(), window: rate_window(40.0, Some(10_080)), + amount: None, }); let readout = constraining_readout(&snapshot); @@ -2265,6 +2267,7 @@ mod tests { id: "cursor-api".into(), title: " ".into(), window: rate_window(70.0, Some(10_080)), + amount: None, }); let readout = constraining_readout(&snapshot); @@ -2291,6 +2294,7 @@ mod tests { id: "cursor-api".into(), title: "API".into(), window: rate_window(30.0, Some(10_080)), + amount: None, }); let readout = constraining_readout(&snapshot); @@ -2315,6 +2319,7 @@ mod tests { id: "cursor-api".into(), title: "API".into(), window: soon, + amount: None, }); let readout = constraining_readout(&snapshot); diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index ac14e44f..dbda41ea 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1367,6 +1367,7 @@ mod tests { reserve_will_last_to_reset: false, reserve_eta_seconds: None, }, + amount: None, } } diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 9cd1534f..cc819837 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -112,6 +112,7 @@ describe("MenuCard", () => { PanelOneHour: "1h", PanelFiveHours: "5h", PanelOnPaceBudget: "On-pace budget", + PanelAmountOf: "of", PanelReserveSuffix: "in reserve", PanelThirtyDayCost: "30d cost", PanelThirtyDayTokens: "30d tokens", @@ -234,6 +235,52 @@ describe("MenuCard", () => { expect(screen.getByText("58% left")).toBeInTheDocument(); }); + it("shows the money behind a lane that is denominated in currency", async () => { + const snapshot = provider(null, 20); + snapshot.providerId = "cursor"; + snapshot.extraRateWindows = [ + { + id: "cursor-on-demand", + title: "On-demand", + window: rateWindow(35), + amount: { + used: 3.5, + limit: 10, + currencyCode: "USD", + formattedUsed: "$3.50", + formattedLimit: "$10.00", + }, + }, + ]; + + renderCard(snapshot); + + expect(await screen.findByText("On-demand")).toBeInTheDocument(); + expect(screen.getByText("$3.50 of $10.00")).toBeInTheDocument(); + }); + + it("shows a lane's spend alone when it has no limit", async () => { + const snapshot = provider(null, 20); + snapshot.extraRateWindows = [ + { + id: "cursor-on-demand", + title: "On-demand", + window: rateWindow(0), + amount: { + used: 12.34, + limit: null, + currencyCode: "USD", + formattedUsed: "$12.34", + formattedLimit: null, + }, + }, + ]; + + renderCard(snapshot); + + expect(await screen.findByText("$12.34")).toBeInTheDocument(); + }); + it("renders inactive windows as text without inventing a percentage", async () => { const snapshot = provider(null, 40); snapshot.secondary = rateWindow(55); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 6b58cd26..d0e2a7d0 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -5,6 +5,7 @@ import type { ProviderLocalUsageSummary, ProviderUsageSnapshot, RateWindowSnapshot, + WindowAmountBridge, } from "../types/bridge"; import { getProviderChartData } from "../lib/tauri"; import { useLocale } from "../hooks/useLocale"; @@ -246,6 +247,8 @@ interface MetricEntry { id: string; label: string; snap: RateWindowSnapshot; + /** Money behind this lane, for providers that meter in currency. */ + amount?: WindowAmountBridge | null; } interface InactiveMetricEntry { @@ -284,6 +287,7 @@ function getMetricPaceView(snap: RateWindowSnapshot): MetricPaceView { function MetricRow({ title, snap, + amount, exhaustedLabel, resetTimeRelative, showResetWhenExhausted, @@ -293,6 +297,7 @@ function MetricRow({ }: { title: string; snap: RateWindowSnapshot; + amount?: WindowAmountBridge | null; exhaustedLabel: string; resetTimeRelative: boolean; showResetWhenExhausted: boolean; @@ -338,6 +343,13 @@ function MetricRow({ {resetText} )} + {amount && ( +
+ {amount.formattedLimit + ? `${amount.formattedUsed} ${t("PanelAmountOf")} ${amount.formattedLimit}` + : amount.formattedUsed} +
+ )} {snap.isExhausted && (
{exhaustedLabel}
)} @@ -496,6 +508,7 @@ export default function MenuCard({ id: `extra-${extra.id}`, label: extra.title, snap: extra.window, + amount: extra.amount, }); } for (const inactive of provider.inactiveRateWindows ?? []) { @@ -635,6 +648,7 @@ export default function MenuCard({ key={m.id} title={m.label} snap={m.snap} + amount={m.amount} exhaustedLabel={t("DetailWindowExhausted")} resetTimeRelative={resetTimeRelative} showResetWhenExhausted={showResetWhenExhausted} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index f0f80280..1cfe001e 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -496,6 +496,7 @@ export const ALL_LOCALE_KEYS = [ "PanelUsedSuffix", "PanelLeftSuffix", "PanelOnPaceBudget", + "PanelAmountOf", "PanelNow", "PanelOneHour", "PanelFiveHours", diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 342a22d8..7d09eca5 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -4854,6 +4854,17 @@ html:has(.menu-surface--tray) { color: var(--provider-status-error); } +/* Money behind a lane that is natively denominated in currency. */ +.menu-metric__amount { + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); +} + +.menu-surface--tray .menu-metric__amount { + font-size: 10px; +} + .menu-metric--inactive { gap: 2px; padding: 6px 8px; diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index d2ba337d..d737cb21 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -399,6 +399,18 @@ export interface RateWindowSnapshot { reserveEtaSeconds?: number | null; } +/** + * Money behind a single metered lane. Distinct from CostSnapshotBridge, which + * is one figure for the whole provider and cannot describe individual windows. + */ +export interface WindowAmountBridge { + used: number; + limit: number | null; + currencyCode: string; + formattedUsed: string; + formattedLimit: string | null; +} + export interface CostSnapshotBridge { used: number; limit: number | null; @@ -434,6 +446,7 @@ export interface ProviderUsageSnapshot { id: string; title: string; window: RateWindowSnapshot; + amount?: WindowAmountBridge | null; }>; inactiveRateWindows?: Array<{ id: string; @@ -1006,6 +1019,7 @@ export interface ProviderDetail { id: string; title: string; window: RateWindowSnapshot; + amount?: WindowAmountBridge | null; }>; cost: CostSnapshotBridge | null; diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 9d266637..4c2fd8f3 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -38,12 +38,56 @@ pub struct WayfinderRouteSummary { pub saved: f64, } +/// A monetary reading attached to a metered window. +/// +/// Some providers meter lanes that are natively denominated in money rather +/// than percent (Cursor's on-demand overdraft, bonus credit pools). The +/// percentage still drives the bar, but the amount is the number the user +/// actually cares about, and it has nowhere else to live: `CostSnapshot` is a +/// single slot per provider and cannot describe individual lanes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WindowAmount { + /// Amount consumed in this window. + pub used: f64, + /// Amount the window is metered against, when the provider states one. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Currency code (e.g. "USD"). + pub currency_code: String, +} + +impl WindowAmount { + pub fn new(used: f64, currency_code: impl Into) -> Self { + Self { + used: finite_amount(used).unwrap_or(0.0), + limit: None, + currency_code: currency_code.into(), + } + } + + pub fn with_limit(mut self, limit: f64) -> Self { + self.limit = finite_amount(limit); + self + } + + pub fn format_used(&self) -> String { + format_currency(self.used, &self.currency_code) + } + + pub fn format_limit(&self) -> Option { + self.limit.map(|l| format_currency(l, &self.currency_code)) + } +} + /// A labeled extra usage window surfaced by provider APIs. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NamedRateWindow { pub id: String, pub title: String, pub window: RateWindow, + /// Money behind this lane, when the provider denominates it that way. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount: Option, } /// Explicit enforcement state for a known limit window, so surfaces never have @@ -129,8 +173,15 @@ impl NamedRateWindow { id: id.into(), title: title.into(), window, + amount: None, } } + + /// Attach the money this lane is denominated in. + pub fn with_amount(mut self, amount: WindowAmount) -> Self { + self.amount = Some(amount); + self + } } /// Kind of temporary promotional signal surfaced beside normal meters. diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 8a94ff8e..d87cc6ce 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -736,6 +736,7 @@ locale_keys! { PanelUsedSuffix, PanelLeftSuffix, PanelOnPaceBudget, + PanelAmountOf, PanelNow, PanelOneHour, PanelFiveHours, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 83692c47..fb91b4ef 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -491,6 +491,7 @@ PanelActual = Actual PanelUsedSuffix = used PanelLeftSuffix = left PanelOnPaceBudget = On-pace budget +PanelAmountOf = of PanelNow = now PanelOneHour = 1h PanelFiveHours = 5h diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 10ff75e4..0684e152 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -491,6 +491,7 @@ PanelActual = 实际 PanelUsedSuffix = 已用 PanelLeftSuffix = 剩余 PanelOnPaceBudget = 按节奏预算 +PanelAmountOf = 共 PanelNow = 现在 PanelOneHour = 1 小时 PanelFiveHours = 5 小时 diff --git a/rust/src/providers/cursor/api.rs b/rust/src/providers/cursor/api.rs index 3f582d4c..da0c2475 100755 --- a/rust/src/providers/cursor/api.rs +++ b/rust/src/providers/cursor/api.rs @@ -4,7 +4,7 @@ use crate::core::{ CostSnapshot, InactiveRateWindow, NamedRateWindow, PromoSignal, ProviderError, RateWindow, - UsageSnapshot, + UsageSnapshot, WindowAmount, }; use crate::providers::browser_cookie_header; use chrono::{DateTime, Utc}; @@ -13,6 +13,10 @@ use serde::Deserialize; const BASE_URL: &str = "https://cursor.com"; const COOKIE_DOMAINS: [&str; 2] = ["cursor.com", "cursor.sh"]; const NOT_ENFORCED: &str = "Not currently enforced by Cursor"; +const ON_DEMAND_ID: &str = "cursor-on-demand"; +const ON_DEMAND_TITLE: &str = "On-demand"; +const ON_DEMAND_PERIOD: &str = "On-demand"; +const MONTHLY_PERIOD: &str = "Monthly"; pub(super) type CursorUsageResult = (UsageSnapshot, Option); @@ -184,29 +188,48 @@ impl CursorApi { .and_then(|t| t.on_demand.as_ref()) }); - if let Some(window) = on_demand_window(on_demand, window_minutes, billing_end) { - extras.push(window); - } + let (metered, notice) = on_demand_windows(on_demand, window_minutes, billing_end); + extras.extend(metered); + inactives.extend(notice); - cost_snapshot = Self::on_demand_cost(on_demand, billing_end) + // On-demand owns the cost slot: it is the only Cursor lane that + // bills real money. Plan usage is metered at internal rates and + // charged nothing, so it must never outrank actual spend here. + cost_snapshot = Self::on_demand_cost(on_demand, billing_end, ON_DEMAND_PERIOD) .or_else(|| plan_cost_snapshot(plan, billing_end)); } else if let Some(overall) = &individual.overall { // Overall is a single reported pool — keep it as monthly + cost only. monthly_percent = Self::usage_percent(overall); - cost_snapshot = Self::on_demand_cost(Some(overall), billing_end); + cost_snapshot = Self::on_demand_cost(Some(overall), billing_end, MONTHLY_PERIOD); + + // `overall` does not replace on-demand: the overdraft meter is + // reported separately and would otherwise never be read here. + let on_demand = individual.on_demand.as_ref().or_else(|| { + summary + .team_usage + .as_ref() + .and_then(|t| t.on_demand.as_ref()) + }); + let (metered, notice) = on_demand_windows(on_demand, window_minutes, billing_end); + extras.extend(metered); + inactives.extend(notice); } } else if let Some(team) = &summary.team_usage { if let Some(pooled) = &team.pooled { monthly_percent = Self::usage_percent(pooled); - cost_snapshot = Self::on_demand_cost(Some(pooled), billing_end); - } - if let Some(window) = - on_demand_window(team.on_demand.as_ref(), window_minutes, billing_end) - { - extras.push(window); } + let (metered, notice) = + on_demand_windows(team.on_demand.as_ref(), window_minutes, billing_end); + extras.extend(metered); + inactives.extend(notice); + // Same rule as the individual branch: on-demand is the billed lane, + // so it takes the cost slot ahead of the pooled plan allowance. + cost_snapshot = + Self::on_demand_cost(team.on_demand.as_ref(), billing_end, ON_DEMAND_PERIOD); if cost_snapshot.is_none() { - cost_snapshot = Self::on_demand_cost(team.on_demand.as_ref(), billing_end); + cost_snapshot = team.pooled.as_ref().and_then(|pooled| { + Self::on_demand_cost(Some(pooled), billing_end, MONTHLY_PERIOD) + }); } } @@ -261,6 +284,7 @@ impl CursorApi { fn on_demand_cost( on_demand: Option<&OnDemandUsage>, billing_end: Option>, + period: &str, ) -> Option { let usage = on_demand?; if usage.enabled == Some(false) { @@ -281,7 +305,7 @@ impl CursorApi { return None; } - let mut cost = CostSnapshot::new(used_cents / 100.0, "USD", "Monthly"); + let mut cost = CostSnapshot::new(used_cents / 100.0, "USD", period); if limit_cents > 0.0 { cost = cost.with_limit(limit_cents / 100.0); } @@ -431,23 +455,56 @@ fn promotional_window( )) } -fn on_demand_window( +/// Cursor reports on-demand two ways: as a capped pool that can be metered, or +/// as uncapped spend with no limit at all. Uncapped overdraft is still real +/// money, so surface it as an explicit non-metering window instead of dropping +/// it on the floor for want of a denominator. +fn on_demand_windows( on_demand: Option<&OnDemandUsage>, window_minutes: Option, billing_end: Option>, -) -> Option { - let usage = on_demand?; +) -> (Option, Option) { + let Some(usage) = on_demand else { + return (None, None); + }; if usage.enabled == Some(false) { - return None; + return (None, None); } - let percent = CursorApi::usage_percent(usage)?; - Some(NamedRateWindow::new( - "cursor-on-demand", - "On-demand", - RateWindow::with_details(percent, window_minutes, billing_end, None), - )) + + if let Some(percent) = CursorApi::usage_percent(usage) { + let mut window = NamedRateWindow::new( + ON_DEMAND_ID, + ON_DEMAND_TITLE, + RateWindow::with_details(percent, window_minutes, billing_end, None), + ); + // The percentage drives the bar, but overdraft is money — show it. + if let Some(amount) = on_demand_amount(usage) { + window = window.with_amount(amount); + } + return (Some(window), None); + } + + // No limit and no remaining: nothing to meter against, but the spend itself + // is worth showing once the plan is exhausted. + let used_cents = usage.used.unwrap_or(0); + if used_cents <= 0 { + return (None, None); + } + let spent = WindowAmount::new(used_cents as f64 / 100.0, "USD").format_used(); + let notice = InactiveRateWindow::new( + ON_DEMAND_ID, + ON_DEMAND_TITLE, + format!("{spent} spent, no spend limit set"), + ); + (None, Some(notice)) } +/// The plan lane expressed in currency. +/// +/// Note this is *not* money owed. Cursor prices included usage at internal +/// rates, and an exported usage report shows every such event as "Included" or +/// "Free" with no charge. Only the on-demand lane is real billing, which is why +/// it takes the cost slot ahead of this. fn plan_cost_snapshot( plan: &PlanUsage, billing_end: Option>, @@ -472,6 +529,22 @@ fn plan_cost_snapshot( Some(cost) } +/// Dollars behind an on-demand lane, for display beside its meter. +fn on_demand_amount(usage: &OnDemandUsage) -> Option { + // Treat an omitted `used` as zero, the way `usage_percent` does, so a + // reported cap still renders instead of the amount vanishing entirely. + let used = usage.used.unwrap_or(0) as f64; + let mut amount = WindowAmount::new(used / 100.0, "USD"); + if let Some(limit) = usage + .limit + .or_else(|| usage.remaining.map(|r| r + usage.used.unwrap_or(0))) + .filter(|&l| l > 0) + { + amount = amount.with_limit(limit as f64 / 100.0); + } + Some(amount) +} + fn membership_label(membership: Option<&str>) -> Option { membership.map(|t| match t.to_lowercase().as_str() { "enterprise" => "Cursor Enterprise".to_string(), @@ -740,15 +813,111 @@ mod tests { assert!((on_demand.window.used_percent - 35.0).abs() < 0.01); assert!(on_demand.window.resets_at.is_some()); + // The overdraft meter carries its own dollars, so it stays visible even + // though the single cost slot belongs to plan spend. + let amount = on_demand.amount.as_ref().expect("on-demand carries money"); + assert!((amount.used - 3.50).abs() < 0.01); + assert_eq!(amount.limit, Some(10.0)); + assert_eq!(amount.format_used(), "$3.50"); + // Lane format with only total → Auto/API inactive assert_eq!(usage.inactive_rate_windows.len(), 2); - let cost = cost.expect("cost should exist from on-demand usage"); + // On-demand is the only real-money lane, so it owns the cost slot. + let cost = cost.expect("on-demand cost"); assert!((cost.used - 3.5).abs() < 0.01); assert_eq!(cost.limit, Some(10.0)); + assert_eq!(cost.period, "On-demand"); + } + + #[test] + fn test_cursor_uncapped_on_demand_reports_spend() { + // On-demand enabled with real spend but no cap: there is no denominator + // to meter, so the spend surfaces as an explicit non-metering window. + let json = r#"{ + "billingCycleEnd": "2026-09-01T00:00:00Z", + "membershipType": "pro", + "individualUsage": { + "plan": { + "used": 2000, + "limit": 2000, + "totalPercentUsed": 100.0 + }, + "onDemand": { + "enabled": true, + "used": 1234 + } + } + }"#; + + let summary = parse_summary(json); + let (usage, cost) = api().build_result(summary, None).unwrap(); + + assert!( + !usage + .extra_rate_windows + .iter() + .any(|w| w.id == "cursor-on-demand"), + "no cap means no meter" + ); + let notice = inactive(&usage, "cursor-on-demand"); + assert_eq!(notice.title, "On-demand"); + assert_eq!(notice.description, "$12.34 spent, no spend limit set"); + + let cost = cost.expect("uncapped on-demand still reports spend"); + assert!((cost.used - 12.34).abs() < 0.01); + assert_eq!(cost.limit, None); + assert_eq!(cost.period, "On-demand"); + } + + #[test] + fn test_cursor_on_demand_surfaces_alongside_overall() { + let json = r#"{ + "individualUsage": { + "overall": {"used": 2500, "limit": 2500}, + "onDemand": {"enabled": true, "used": 900, "limit": 5000} + } + }"#; + + let summary = parse_summary(json); + let (usage, cost) = api().build_result(summary, None).unwrap(); + + assert!((usage.primary.used_percent - 100.0).abs() < 0.01); + let on_demand = extra(&usage, "cursor-on-demand"); + assert!((on_demand.window.used_percent - 18.0).abs() < 0.01); + + // `overall` remains the reported total pool cost. + let cost = cost.expect("overall cost"); + assert!((cost.used - 25.0).abs() < 0.01); assert_eq!(cost.period, "Monthly"); } + #[test] + fn test_cursor_zero_spend_uncapped_on_demand_is_silent() { + let json = r#"{ + "individualUsage": { + "plan": {"used": 100, "limit": 1000, "totalPercentUsed": 10.0}, + "onDemand": {"enabled": true, "used": 0} + } + }"#; + + let summary = parse_summary(json); + let (usage, _) = api().build_result(summary, None).unwrap(); + + assert!( + !usage + .inactive_rate_windows + .iter() + .any(|w| w.id == "cursor-on-demand") + ); + assert!( + !usage + .extra_rate_windows + .iter() + .any(|w| w.id == "cursor-on-demand") + ); + } + #[test] fn test_cursor_disabled_on_demand_is_ignored() { let json = r#"{ @@ -792,6 +961,48 @@ mod tests { assert!(usage.extra_rate_windows.is_empty()); } + #[test] + fn test_cursor_on_demand_cap_renders_without_a_used_field() { + // Cursor can report a cap with no `used`. That is zero spend against a + // real limit, not an absent amount. + let json = r#"{ + "individualUsage": { + "plan": {"used": 100, "limit": 1000, "totalPercentUsed": 10.0}, + "onDemand": {"enabled": true, "limit": 2000} + } + }"#; + + let summary = parse_summary(json); + let (usage, _) = api().build_result(summary, None).unwrap(); + + let amount = extra(&usage, "cursor-on-demand") + .amount + .as_ref() + .expect("a cap is still an amount"); + assert_eq!(amount.used, 0.0); + assert_eq!(amount.limit, Some(20.0)); + } + + #[test] + fn test_cursor_team_on_demand_outranks_pooled_for_cost() { + // On-demand is the billed lane for teams too, so it must not be + // shadowed by the pooled plan allowance. + let json = r#"{ + "teamUsage": { + "pooled": {"used": 5000, "limit": 10000}, + "onDemand": {"enabled": true, "used": 750, "limit": 2000} + } + }"#; + + let summary = parse_summary(json); + let (usage, cost) = api().build_result(summary, None).unwrap(); + + assert!((usage.primary.used_percent - 50.0).abs() < 0.01); + let cost = cost.expect("on-demand cost"); + assert!((cost.used - 7.5).abs() < 0.01); + assert_eq!(cost.period, "On-demand"); + } + #[test] fn test_cursor_team_pooled_fallback() { let summary = parse_summary(r#"{"teamUsage":{"pooled":{"used":5000,"limit":10000}}}"#);