From 39434bd7181e5b1f6aaa9ec5d0aeb72977f89692 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 22:27:31 +0000 Subject: [PATCH 1/3] Align MCP Cursor remaining_percent with strip ranking MCP get_status used generic exhausted-first ranking and dropped cursor-api / on-demand from the widget snapshot, so Plan could hide Auto and docs claiming strip parity were wrong. Rank Cursor with cursorStripWindow and persist the same extras the strip uses. Co-authored-by: Tyler --- CHANGELOG.md | 3 +- .../src-tauri/src/commands/providers.rs | 111 +++++- .../src-tauri/src/commands/tests.rs | 58 ++++ docs/CLI.md | 4 +- rust/src/cli/mcp.rs | 182 +++++++--- rust/src/cli/statusline.rs | 1 + rust/src/core/constraining.rs | 323 ++++++++++++++++++ rust/src/core/mod.rs | 2 + rust/src/core/widget_snapshot.rs | 53 ++- 9 files changed, 679 insertions(+), 58 deletions(-) create mode 100644 rust/src/core/constraining.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 08447caa..374575bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **A leaked `serve.token` is rotated on Windows, not just Unix.** After SBS-953, a world-readable token was replaced on Unix, but Windows still tightened the DACL and reused the same secret. The ACL is now inspected before tightening; if anyone other than the current user, SYSTEM, or Administrators can read the file, the token is replaced. Closes SBS-1043. ### Fixed +- **MCP `get_status` now ranks Cursor the way the strip does.** After SBS-1055, `remaining_percent` used generic exhausted-first ranking over primary/secondary/tertiary, so a hotter Plan could hide Auto, and the widget snapshot still omitted `cursor-api` / on-demand while the docs claimed strip parity. Cursor now uses `cursorStripWindow` (hottest Auto/API with room, then on-demand, Plan last), those extras persist on the snapshot, and the multi-account seat picker compares the same window. Closes SBS-1076. - **Frontend tests now catch accessibility regressions automatically.** A shared axe assertion checks representative quota cards, mini charts, and update banners in the existing Frontend CI job. Color contrast remains outside jsdom coverage because it requires a rendered browser. Fixes #222. - **`usage --all-accounts` now fetches every configured Codex and Claude account.** Account fetches run with bounded concurrency, preserve configured order, and report failures independently. Text and JSON identify each configured account while the default output remains unchanged. Fixes #274. - **The detached Settings window now reopens where you left it.** Its saved size and position are restored and clamped on screen instead of being overwritten by a second frontend resize on every open. Closes #275. @@ -13,7 +14,7 @@ - **Codex latest-session cost follows transcript time.** Copying or touching an older rollout no longer makes it replace a newer session in local cost summaries. Fixes #271. - **A corrupt `window_geometry.json` no longer wipes other windows' saved positions.** SBS-1024 locked the persist so two surfaces could not drop each other's keys, but a file that would not read or parse still loaded as empty defaults, and the next save replaced the whole file with only the window that just moved. Persist now refuses that write — the same fail-closed rule API keys already use — instead of rewriting siblings to an empty store. Closes SBS-1041. - **Loading settings no longer rewrites another install's start-at-login command.** Every `Settings::load` repaired `HKCU\...\Run\Ceiling` whenever the value was not the quoted path of this process. A portable CLI or a second tree therefore replaced the installed desktop's startup entry, and any extra arguments were stripped. Repair now runs only when this process owns that entry — the same intended exe, or a stale `codexbar-cli.exe` / `codexbar-desktop.exe` sibling in the same directory — and leaves custom arguments and other trees alone. Closes SBS-1053. -- **MCP `get_status` no longer hides an exhausted Weekly behind a healthy session.** Top-level `remaining_percent` copied only `usage.primary`, so a Claude/Codex 5-hour window with room made the advertised cap-check sink look fine while Weekly was already at 100%. It now uses the same constraining-window ranking as the desktop strip across primary, secondary, and tertiary (exhausted first, then highest used %). Closes SBS-1055. +- **MCP `get_status` no longer hides an exhausted Weekly behind a healthy session.** Top-level `remaining_percent` copied only `usage.primary`, so a Claude/Codex 5-hour window with room made the advertised cap-check sink look fine while Weekly was already at 100%. Claude/Codex now rank primary, secondary, and tertiary (exhausted first, then highest used %). Cursor's parallel Auto/API path landed in SBS-1076. Closes SBS-1055. - **Remembered window positions no longer drop a sibling when two surfaces save at once.** `window_geometry.json` was updated with an unlocked read-modify-write, so moving Settings while the float bar or Pop Out also wrote could replace the file with a snapshot that had never seen the other key. Geometry persist now holds the same cross-process state lock as settings and credentials. Closes SBS-1024. - **`codexbar` with no subcommand now runs `usage`.** CLI.md and `--help` already called usage the default command, but a bare `codexbar` printed an error asking for an explicit subcommand. It now does what those docs said. Closes SBS-1026. - **A corrupt `settings.json` is no longer renamed to `.bak` without the state lock.** SBS-954 moved an unparseable file aside so the next save could not overwrite it, but `Settings::load` did that rename before taking the lock. A concurrent `try_update` that had already written a good repair could then have that repair moved to `settings.json.bak`. The unlocked load now only parses; if the file is corrupt (or still carries embedded credentials) it takes the lock, re-reads, and quarantines only then. Closes SBS-1029. diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 32aace78..32600294 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -684,8 +684,8 @@ pub(super) fn most_constrained_per_provider( .find(|existing| existing.provider_id == snapshot.provider_id) { Some(existing) => { - let existing_used = existing.primary.used_percent; - let used = snapshot.primary.used_percent; + let existing_used = snapshot_constraint_used_percent(existing); + let used = snapshot_constraint_used_percent(snapshot); // Ties resolve on account id so the strip does not flicker // between accounts as readings land in different orders. let replace = used > existing_used @@ -752,6 +752,14 @@ fn widget_entry_from_usage_snapshot( if let Some(tertiary) = snap.tertiary.as_ref() { entry = entry.with_tertiary(rate_window_from_snapshot(tertiary)); } + if !snap.extra_rate_windows.is_empty() { + entry = entry.with_extra_rate_windows( + snap.extra_rate_windows + .iter() + .map(named_window_from_snapshot) + .collect(), + ); + } if let Some(email) = snap.account_email.clone() { entry = entry.with_account_email(email); } @@ -771,6 +779,63 @@ fn widget_entry_from_usage_snapshot( Some(entry) } +/// Used % of the window the desktop strip would show for this snapshot. +/// +/// Claude/Codex keep comparing primary so existing seat-picker tests stay +/// stable. Cursor uses `cursorStripWindow` (Auto / API / on-demand), not Plan. +fn snapshot_constraint_used_percent(snapshot: &ProviderUsageSnapshot) -> f64 { + if snapshot.provider_id != "cursor" { + return snapshot.primary.used_percent; + } + let owned = owned_windows_from_snapshot(snapshot); + codexbar::core::constraining_rate_window( + ProviderId::Cursor, + Some(&owned.primary), + owned.secondary.as_ref(), + owned.tertiary.as_ref(), + &owned.extras, + ) + .map(|window| window.used_percent) + .unwrap_or(snapshot.primary.used_percent) +} + +struct OwnedRankWindows { + primary: RateWindow, + secondary: Option, + tertiary: Option, + extras: Vec, +} + +fn owned_windows_from_snapshot(snapshot: &ProviderUsageSnapshot) -> OwnedRankWindows { + OwnedRankWindows { + primary: rate_window_from_snapshot(&snapshot.primary), + secondary: snapshot.secondary.as_ref().map(rate_window_from_snapshot), + tertiary: snapshot.tertiary.as_ref().map(rate_window_from_snapshot), + extras: snapshot + .extra_rate_windows + .iter() + .map(named_window_from_snapshot) + .collect(), + } +} + +fn named_window_from_snapshot(extra: &NamedRateWindowSnapshot) -> codexbar::core::NamedRateWindow { + let mut named = codexbar::core::NamedRateWindow::new( + extra.id.clone(), + extra.title.clone(), + rate_window_from_snapshot(&extra.window), + ); + if let Some(amount) = extra.amount.as_ref() { + let mut money = + codexbar::core::WindowAmount::new(amount.used, amount.currency_code.clone()); + if let Some(limit) = amount.limit { + money = money.with_limit(limit); + } + named = named.with_amount(money); + } + named +} + fn rate_window_from_snapshot(window: &RateWindowSnapshot) -> RateWindow { let resets_at = window.resets_at.as_deref().and_then(|raw| { chrono::DateTime::parse_from_rfc3339(raw) @@ -1672,4 +1737,46 @@ mod widget_snapshot_tests { let entry = widget_entry_from_usage_snapshot(&snap).expect("entry"); assert_eq!(entry.primary.expect("measured primary").used_percent, 0.0); } + + /// SBS-1076: the strip ranks cursor-api / on-demand, but the widget snapshot + /// dropped extras so MCP could not match cursorStripWindow. + #[test] + fn widget_entry_persists_cursor_api_and_on_demand() { + let metadata = instantiate_provider(ProviderId::Cursor).metadata().clone(); + let usage = UsageSnapshot::new(RateWindow::new(95.0)) + .with_secondary(RateWindow::new(55.0)) + .with_extra_rate_window("cursor-api", "API", RateWindow::new(12.0)); + let mut usage = usage; + usage.extra_rate_windows.push( + codexbar::core::NamedRateWindow::new( + "cursor-on-demand", + "On-demand", + RateWindow::new(56.0), + ) + .with_amount(codexbar::core::WindowAmount::new(1_002.16, "USD").with_limit(1_800.0)), + ); + let result = ProviderFetchResult::new(usage, "oauth"); + let snap = ProviderUsageSnapshot::from_fetch_result(ProviderId::Cursor, &metadata, &result); + let entry = widget_entry_from_usage_snapshot(&snap).expect("entry"); + + let ids: Vec<&str> = entry + .extra_rate_windows + .iter() + .map(|extra| extra.id.as_str()) + .collect(); + assert_eq!(ids, vec!["cursor-api", "cursor-on-demand"]); + let on_demand = entry + .extra_rate_windows + .iter() + .find(|extra| extra.id == "cursor-on-demand") + .expect("on-demand"); + let amount = on_demand.amount.as_ref().expect("amount"); + assert_eq!(amount.used, 1002.16); + assert_eq!(amount.limit, Some(1800.0)); + assert_eq!( + entry.constraining_rate_window().map(|w| w.used_percent), + Some(55.0), + "Auto still has room, so persist must not let Plan bind" + ); + } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 62f618c4..5cd523de 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -949,6 +949,64 @@ fn the_taskbar_strip_does_not_flicker_between_tied_accounts() { ); } +fn cursor_account_snapshot( + account_id: &str, + plan: f64, + auto: f64, + api: Option, +) -> ProviderUsageSnapshot { + let metadata = instantiate_provider(ProviderId::Cursor).metadata().clone(); + let mut usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(plan)) + .with_secondary(codexbar::core::RateWindow::new(auto)); + if let Some(api) = api { + usage = + usage.with_extra_rate_window("cursor-api", "API", codexbar::core::RateWindow::new(api)); + } + let result = ProviderFetchResult { + usage, + cost: None, + wayfinder_usage: None, + source_label: "oauth".to_string(), + }; + let mut snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Cursor, &metadata, &result); + snapshot.account_id = Some(account_id.to_string()); + snapshot +} + +/// SBS-1076: picking by Plan used% chose the seat whose blend was hotter even +/// when Auto still had room. The strip ranks Auto/API, so the seat picker must. +#[test] +fn cursor_seat_picker_uses_strip_window_not_plan() { + let cached = vec![ + cursor_account_snapshot("acct-plan-hot", 95.0, 10.0, Some(8.0)), + cursor_account_snapshot("acct-auto-hot", 40.0, 70.0, Some(20.0)), + ]; + + let chosen = super::most_constrained_per_provider(&cached); + assert_eq!(chosen.len(), 1); + assert_eq!( + chosen[0].account_id.as_deref(), + Some("acct-auto-hot"), + "hotter Auto must win over hotter Plan" + ); +} + +#[test] +fn cursor_seat_picker_prefers_api_room_over_exhausted_auto_on_other_seat() { + let cached = vec![ + cursor_account_snapshot("acct-auto-maxed", 40.0, 100.0, Some(15.0)), + cursor_account_snapshot("acct-auto-open", 90.0, 20.0, Some(100.0)), + ]; + + let chosen = super::most_constrained_per_provider(&cached); + assert_eq!( + chosen[0].account_id.as_deref(), + Some("acct-auto-open"), + "Auto with room (20%) is the strip window; the other seat's API 15% is cooler" + ); +} + #[test] fn the_taskbar_strip_skips_providers_that_failed_to_fetch() { let mut errored = account_snapshot("acct-work", 91.0); diff --git a/docs/CLI.md b/docs/CLI.md index b2246f6a..48619da6 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -186,9 +186,9 @@ Tools: | Tool | Source | Notes | |---|---|---| | `list_providers` | widget snapshot + settings | Quota cache presence and whether local spend scanning is supported. | -| `get_usage` | widget snapshot | Remaining quota windows. `period_cost_usd` is the provider's billed / current-period `CostSnapshot.used`, with `cost_period` as the provider's period label (for example `Monthly`). It is **not** this conversation's spend. | +| `get_usage` | widget snapshot | Remaining quota windows, including `extra_rate_windows` (Cursor `cursor-api` and `cursor-on-demand`). `period_cost_usd` is the provider's billed / current-period `CostSnapshot.used`, with `cost_period` as the provider's period label (for example `Monthly`). It is **not** this conversation's spend. | | `get_spend` | local Codex / Claude / Grok logs | Estimated API-value spend for today, 7 days, and 30 days. Not a bill. | -| `get_status` | snapshot + local logs | Compact remaining-quota plus `today_spend`. `remaining_percent` is the constraining window across primary/secondary/tertiary (exhausted first, then highest used %), not `usage.primary` alone. `usage` is the same object as `get_usage` (including `period_cost_usd`). `today_spend` is local estimated log spend for today. | +| `get_status` | snapshot + local logs | Compact remaining-quota plus `today_spend`. `remaining_percent` is the same window the desktop strip shows — not `usage.primary` alone. Claude/Codex rank primary/secondary/tertiary (exhausted first, then highest used %). Cursor uses `cursorStripWindow`: hottest Auto/API with room, then on-demand when included lanes are gone or already billing, Plan only as fallback. `usage` is the same object as `get_usage` (including `extra_rate_windows` and `period_cost_usd`). `today_spend` is local estimated log spend for today. | `session_cost_usd` is not emitted. Older builds stuffed billed period cost into that name. diff --git a/rust/src/cli/mcp.rs b/rust/src/cli/mcp.rs index 1d33b1e5..1b573809 100644 --- a/rust/src/cli/mcp.rs +++ b/rust/src/cli/mcp.rs @@ -22,7 +22,8 @@ use serde::Deserialize; use serde_json::json; use crate::core::{ - ProviderId, RateWindow, WidgetProviderEntry, WidgetSnapshot, WidgetSnapshotStore, + NamedRateWindow, ProviderId, RateWindow, WidgetProviderEntry, WidgetSnapshot, + WidgetSnapshotStore, }; use crate::cost_scanner::{CostScanner, CostSummary, get_cost_usage_report}; use crate::settings::Settings; @@ -95,7 +96,7 @@ impl CeilingMcp { } #[tool( - description = "Cheap compact status: remaining quota from the desktop snapshot plus today's estimated spend from a 1-day local log scan (not the 30-day get_spend walk). Good for 'am I about to hit my cap?' checks. remaining_percent is the constraining window across primary/secondary/tertiary (same ranking as the desktop strip: exhausted first, then highest used %), so an exhausted Weekly is not hidden behind a healthy session. usage.period_cost_usd is billed/current-period cost, not session spend; today_spend is local estimated log spend for today. Use get_spend for 7/30-day totals." + description = "Cheap compact status: remaining quota from the desktop snapshot plus today's estimated spend from a 1-day local log scan (not the 30-day get_spend walk). Good for 'am I about to hit my cap?' checks. remaining_percent is the same window the desktop strip shows: Claude/Codex rank primary/secondary/tertiary (exhausted first, then highest used %); Cursor uses cursorStripWindow (hottest Auto/API with room, then on-demand when included lanes are gone or already billing, Plan only as fallback). usage.extra_rate_windows includes cursor-api and cursor-on-demand. usage.period_cost_usd is billed/current-period cost, not session spend; today_spend is local estimated log spend for today. Use get_spend for 7/30-day totals." )] fn get_status( &self, @@ -120,9 +121,11 @@ impl ServerHandler for CeilingMcp { "Ceiling local usage/spend tools. get_usage reads the desktop widget snapshot \ (cache-only). get_spend scans local Codex/Claude/Grok logs for today, 7 days, and 30 days. \ Prefer get_status for a cheap remaining-quota + today-$ check before starting a large job; \ -it does not run the 30-day spend scan. remaining_percent is the constraining \ -window across primary/secondary/tertiary (exhausted first, then highest used %), \ -not primary alone. usage.period_cost_usd is the \ +it does not run the 30-day spend scan. remaining_percent is the same window \ +the desktop strip shows: Claude/Codex rank primary/secondary/tertiary \ +(exhausted first, then highest used %); Cursor uses cursorStripWindow so Plan \ +does not outrank Auto and cursor-api / on-demand can bind the number. \ +usage.period_cost_usd is the \ provider billed/current-period figure (CostSnapshot.used), not this conversation's spend; \ today_spend / get_spend are estimated API value from local logs, never a billed invoice. \ Account email and login method are omitted unless the server was started with \ @@ -249,6 +252,11 @@ fn entry_usage_json(entry: &WidgetProviderEntry, include_identity: bool) -> serd "primary": window_json(entry.primary.as_ref()), "secondary": window_json(entry.secondary.as_ref()), "tertiary": window_json(entry.tertiary.as_ref()), + "extra_rate_windows": entry + .extra_rate_windows + .iter() + .map(extra_window_json) + .collect::>(), "period_cost_usd": entry.token_usage.as_ref().and_then(|t| t.period_cost_usd), "cost_period": entry.token_usage.as_ref().and_then(|t| t.cost_period.clone()), }) @@ -268,6 +276,28 @@ fn window_json(window: Option<&RateWindow>) -> serde_json::Value { }) } +fn extra_window_json(extra: &NamedRateWindow) -> serde_json::Value { + let mut value = window_json(Some(&extra.window)); + if let Some(obj) = value.as_object_mut() { + obj.insert("id".into(), json!(extra.id)); + obj.insert("title".into(), json!(extra.title)); + obj.insert( + "amount".into(), + extra + .amount + .as_ref() + .map_or(serde_json::Value::Null, |amount| { + json!({ + "used": amount.used, + "limit": amount.limit, + "currency_code": amount.currency_code, + }) + }), + ); + } + value +} + /// Inclusive local calendar days `get_spend` walks for today / 7-day / 30-day totals. const GET_SPEND_DAYS: u32 = 30; @@ -398,12 +428,12 @@ fn status_payload_with_spend( spend_for(cli) }); - // SBS-1055: remaining_percent is the advertised cap-check sink. Copying - // only usage.primary hid an exhausted Claude/Codex Weekly behind a healthy - // session. Rank the slots the widget snapshot carries the same way the - // desktop strip does (capacityPresentation.constrainingWindow). + // SBS-1055 / SBS-1076: remaining_percent is the advertised cap-check sink. + // Copying only usage.primary hid an exhausted Claude/Codex Weekly. Generic + // window_outranks then let Cursor Plan outrank Auto. Use the same ranking + // as the desktop strip, including cursor-api / on-demand extras. let remaining = chosen_entry - .and_then(constraining_rate_window) + .and_then(WidgetProviderEntry::constraining_rate_window) .map(RateWindow::remaining_percent); json!({ @@ -445,50 +475,10 @@ fn choose_status_provider( .map(|e| e.provider) } -/// Window that actually constrains this provider. -/// -/// Mirrors desktop `constrainingWindow` over the slots the widget snapshot -/// carries (primary / secondary / tertiary). Exhausted/maxed outranks -/// everything, then highest used %, then soonest reset. -fn constraining_rate_window(entry: &WidgetProviderEntry) -> Option<&RateWindow> { - let mut best = entry.primary.as_ref(); - for candidate in [entry.secondary.as_ref(), entry.tertiary.as_ref()] - .into_iter() - .flatten() - { - match best { - None => best = Some(candidate), - Some(current) if window_outranks(candidate, current) => best = Some(candidate), - _ => {} - } - } - best -} - -fn window_outranks(candidate: &RateWindow, best: &RateWindow) -> bool { - let candidate_blocking = candidate.is_exhausted(); - let best_blocking = best.is_exhausted(); - if candidate_blocking != best_blocking { - return candidate_blocking; - } - match candidate.used_percent.total_cmp(&best.used_percent) { - std::cmp::Ordering::Greater => true, - std::cmp::Ordering::Less => false, - std::cmp::Ordering::Equal => reset_at_rank(candidate) < reset_at_rank(best), - } -} - -fn reset_at_rank(window: &RateWindow) -> i64 { - window - .resets_at - .map(|dt| dt.timestamp_millis()) - .unwrap_or(i64::MAX) -} - #[cfg(test)] mod tests { use super::*; - use crate::core::{RateWindow, TokenUsageSummary}; + use crate::core::{NamedRateWindow, RateWindow, TokenUsageSummary, WindowAmount}; use chrono::Utc; fn sample_snapshot() -> WidgetSnapshot { @@ -808,11 +798,99 @@ mod tests { Some(soon), None, )); - let window = constraining_rate_window(&entry).expect("window"); + let window = entry.constraining_rate_window().expect("window"); assert_eq!(window.window_minutes, Some(10_080)); assert_eq!(window.remaining_percent(), 50.0); } + fn cursor_entry( + plan: f64, + auto: Option, + extras: Vec, + ) -> WidgetProviderEntry { + let mut entry = WidgetProviderEntry::new(ProviderId::Cursor, Utc::now()) + .with_primary(RateWindow::new(plan)); + if let Some(auto) = auto { + entry = entry.with_secondary(RateWindow::new(auto)); + } + entry.with_extra_rate_windows(extras) + } + + /// SBS-1076: generic window_outranks picks Plan 95% over Auto 55%. + #[test] + fn status_remaining_prefers_cursor_auto_over_hotter_plan() { + let snap = WidgetSnapshot::new(vec![cursor_entry(95.0, Some(55.0), vec![])], Utc::now()); + let payload = status_without_spend_scan(Some(&snap), Some("cursor"), &default_enabled()); + assert_eq!( + payload["remaining_percent"], 45.0, + "Auto must bind remaining_percent, not hotter Plan: {payload}" + ); + assert_eq!(payload["usage"]["primary"]["remaining_percent"], 5.0); + assert_eq!(payload["usage"]["secondary"]["remaining_percent"], 45.0); + } + + /// SBS-1076: exhausted-first ranking would hide API room behind Auto 100%. + #[test] + fn status_remaining_prefers_cursor_api_with_room_over_exhausted_auto() { + let extras = vec![NamedRateWindow::new( + "cursor-api", + "API", + RateWindow::new(40.0), + )]; + let snap = WidgetSnapshot::new(vec![cursor_entry(40.0, Some(100.0), extras)], Utc::now()); + let payload = status_without_spend_scan(Some(&snap), Some("cursor"), &default_enabled()); + assert_eq!( + payload["remaining_percent"], 60.0, + "API with room must bind remaining_percent: {payload}" + ); + assert_eq!( + payload["usage"]["extra_rate_windows"][0]["id"], + "cursor-api" + ); + assert_eq!( + payload["usage"]["extra_rate_windows"][0]["remaining_percent"], + 60.0 + ); + } + + /// SBS-1076: on-demand is the strip window once included lanes are gone. + #[test] + fn status_remaining_surfaces_cursor_on_demand_after_included_exhausted() { + let extras = vec![ + NamedRateWindow::new("cursor-api", "API", RateWindow::new(100.0)), + NamedRateWindow::new("cursor-on-demand", "On-demand", RateWindow::new(56.0)) + .with_amount(WindowAmount::new(1_002.16, "USD").with_limit(1_800.0)), + ]; + let snap = WidgetSnapshot::new(vec![cursor_entry(100.0, Some(100.0), extras)], Utc::now()); + let payload = status_without_spend_scan(Some(&snap), Some("cursor"), &default_enabled()); + assert_eq!(payload["remaining_percent"], 44.0, "payload: {payload}"); + let on_demand = payload["usage"]["extra_rate_windows"] + .as_array() + .unwrap() + .iter() + .find(|w| w["id"] == "cursor-on-demand") + .expect("on-demand extra"); + assert_eq!(on_demand["remaining_percent"], 44.0); + assert_eq!(on_demand["amount"]["used"], 1002.16); + assert_eq!(on_demand["amount"]["limit"], 1800.0); + } + + #[test] + fn usage_includes_cursor_api_and_on_demand_extras() { + let extras = vec![ + NamedRateWindow::new("cursor-api", "API", RateWindow::new(12.0)), + NamedRateWindow::new("cursor-on-demand", "On-demand", RateWindow::new(0.0)) + .with_amount(WindowAmount::new(0.0, "USD").with_limit(1_800.0)), + ]; + let snap = WidgetSnapshot::new(vec![cursor_entry(40.0, Some(20.0), extras)], Utc::now()); + let payload = usage_payload(Some(&snap), Some("cursor"), false); + let extras = payload["providers"][0]["extra_rate_windows"] + .as_array() + .expect("extras"); + let ids: Vec<&str> = extras.iter().map(|w| w["id"].as_str().unwrap()).collect(); + assert_eq!(ids, vec!["cursor-api", "cursor-on-demand"]); + } + #[test] fn status_remaining_is_null_when_no_measured_window_exists() { let entry = WidgetProviderEntry::new(ProviderId::Claude, Utc::now()); diff --git a/rust/src/cli/statusline.rs b/rust/src/cli/statusline.rs index b1a9cccb..08de4092 100644 --- a/rust/src/cli/statusline.rs +++ b/rust/src/cli/statusline.rs @@ -151,6 +151,7 @@ mod tests { primary, secondary: None, tertiary: None, + extra_rate_windows: Vec::new(), credits_remaining: None, code_review_remaining_percent: None, token_usage: None, diff --git a/rust/src/core/constraining.rs b/rust/src/core/constraining.rs new file mode 100644 index 00000000..6a0e3500 --- /dev/null +++ b/rust/src/core/constraining.rs @@ -0,0 +1,323 @@ +//! Constraining-window ranking shared by MCP `get_status` and the widget +//! snapshot seat picker. +//! +//! Mirrors desktop `capacityPresentation.constrainingWindow` / +//! `cursorStripWindow`. Claude/Codex keep exhausted-first ranking across the +//! stored slots. Cursor's Auto and API are parallel pools, so Plan must not +//! outrank Auto and a maxed API must not hide Auto that still has room. + +use super::{NamedRateWindow, ProviderId, RateWindow}; + +const CURSOR_API_ID: &str = "cursor-api"; +const CURSOR_ON_DEMAND_ID: &str = "cursor-on-demand"; + +/// Window that actually constrains this provider — the same pick the desktop +/// strip uses for its one number. +pub fn constraining_rate_window<'a>( + provider: ProviderId, + primary: Option<&'a RateWindow>, + secondary: Option<&'a RateWindow>, + tertiary: Option<&'a RateWindow>, + extras: &'a [NamedRateWindow], +) -> Option<&'a RateWindow> { + if provider == ProviderId::Cursor { + return cursor_strip_window(primary, secondary, extras); + } + generic_constraining_window(primary, secondary, tertiary) +} + +fn generic_constraining_window<'a>( + primary: Option<&'a RateWindow>, + secondary: Option<&'a RateWindow>, + tertiary: Option<&'a RateWindow>, +) -> Option<&'a RateWindow> { + let mut best = primary; + for candidate in [secondary, tertiary].into_iter().flatten() { + match best { + None => best = Some(candidate), + Some(current) if window_outranks(candidate, current) => best = Some(candidate), + _ => {} + } + } + best +} + +/// Exhausted/maxed outranks everything, then highest used %, then soonest reset. +fn window_outranks(candidate: &RateWindow, best: &RateWindow) -> bool { + let candidate_blocking = candidate.is_exhausted(); + let best_blocking = best.is_exhausted(); + if candidate_blocking != best_blocking { + return candidate_blocking; + } + match candidate.used_percent.total_cmp(&best.used_percent) { + std::cmp::Ordering::Greater => true, + std::cmp::Ordering::Less => false, + std::cmp::Ordering::Equal => reset_at_rank(candidate) < reset_at_rank(best), + } +} + +fn reset_at_rank(window: &RateWindow) -> i64 { + window + .resets_at + .map(|dt| dt.timestamp_millis()) + .unwrap_or(i64::MAX) +} + +/// Cursor strip / taskbar readout: show **actionable remaining** capacity. +/// +/// Auto and API are parallel product pools. A maxed API lane does not stop Auto, +/// so the strip prefers the hottest lane that still has room. Only when every +/// actionable lane is exhausted do we surface an exhausted bar (or on-demand). +/// Plan/Monthly never wins the strip when Auto or API is present. +fn cursor_strip_window<'a>( + primary: Option<&'a RateWindow>, + secondary: Option<&'a RateWindow>, + extras: &'a [NamedRateWindow], +) -> Option<&'a RateWindow> { + let api = extras + .iter() + .find(|extra| extra.id == CURSOR_API_ID) + .map(|extra| &extra.window); + let on_demand = extras.iter().find(|extra| extra.id == CURSOR_ON_DEMAND_ID); + let has_on_demand_spend = on_demand + .and_then(|extra| extra.amount.as_ref()) + .is_some_and(|amount| amount.used > 0.0); + if has_on_demand_spend { + return on_demand.map(|extra| &extra.window); + } + + let actionable: Vec<&RateWindow> = [secondary, api].into_iter().flatten().collect(); + if !actionable.is_empty() { + if let Some(with_room) = hottest_with_room(&actionable) { + return Some(with_room); + } + if let Some(extra) = on_demand { + return Some(&extra.window); + } + if let Some(exhausted) = soonest_exhausted(&actionable) { + return Some(exhausted); + } + } + + if let Some(extra) = on_demand + && cursor_on_demand_is_active(primary, &actionable, extra) + { + return Some(&extra.window); + } + + primary +} + +fn hottest_with_room<'a>(windows: &[&'a RateWindow]) -> Option<&'a RateWindow> { + let mut best: Option<&RateWindow> = None; + for candidate in windows.iter().copied() { + if candidate.is_exhausted() { + continue; + } + let replace = match best { + None => true, + Some(current) => { + candidate.used_percent > current.used_percent + || (candidate.used_percent == current.used_percent + && reset_at_rank(candidate) < reset_at_rank(current)) + } + }; + if replace { + best = Some(candidate); + } + } + best +} + +fn soonest_exhausted<'a>(windows: &[&'a RateWindow]) -> Option<&'a RateWindow> { + let mut best: Option<&RateWindow> = None; + for candidate in windows.iter().copied() { + if !candidate.is_exhausted() { + continue; + } + let replace = match best { + None => true, + Some(current) => { + reset_at_rank(candidate) < reset_at_rank(current) + || (reset_at_rank(candidate) == reset_at_rank(current) + && candidate.used_percent > current.used_percent) + } + }; + if replace { + best = Some(candidate); + } + } + best +} + +fn cursor_on_demand_is_active( + primary: Option<&RateWindow>, + actionable: &[&RateWindow], + on_demand: &NamedRateWindow, +) -> bool { + if on_demand + .amount + .as_ref() + .is_some_and(|amount| amount.used > 0.0) + { + return true; + } + if !actionable.is_empty() { + return hottest_with_room(actionable).is_none(); + } + primary.is_some_and(RateWindow::is_exhausted) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::{NamedRateWindow, WindowAmount}; + use chrono::{TimeZone, Utc}; + + fn extra(id: &str, used: f64) -> NamedRateWindow { + NamedRateWindow::new(id, id, RateWindow::new(used)) + } + + fn extra_with_spend(id: &str, used: f64, dollars: f64) -> NamedRateWindow { + NamedRateWindow::new(id, id, RateWindow::new(used)) + .with_amount(WindowAmount::new(dollars, "USD").with_limit(1_800.0)) + } + + fn remaining( + provider: ProviderId, + primary: Option, + secondary: Option, + tertiary: Option, + extras: &[NamedRateWindow], + ) -> Option { + let primary = primary.map(RateWindow::new); + let secondary = secondary.map(RateWindow::new); + let tertiary = tertiary.map(RateWindow::new); + constraining_rate_window( + provider, + primary.as_ref(), + secondary.as_ref(), + tertiary.as_ref(), + extras, + ) + .map(RateWindow::remaining_percent) + } + + /// SBS-1055: generic ranking still surfaces an exhausted Weekly. + #[test] + fn generic_ranking_surfaces_exhausted_weekly_over_healthy_session() { + assert_eq!( + remaining(ProviderId::Claude, Some(42.0), Some(100.0), None, &[]), + Some(0.0) + ); + } + + /// Without cursorStripWindow, generic ranking would pick Plan (5% remaining). + #[test] + fn cursor_prefers_auto_over_hotter_plan() { + assert_eq!( + remaining(ProviderId::Cursor, Some(95.0), Some(55.0), None, &[]), + Some(45.0) + ); + } + + /// Without extras + strip ranking, exhausted Auto would bind remaining 0. + #[test] + fn cursor_prefers_api_with_room_over_exhausted_auto() { + let extras = [extra(CURSOR_API_ID, 40.0)]; + assert_eq!( + remaining(ProviderId::Cursor, Some(40.0), Some(100.0), None, &extras), + Some(60.0) + ); + } + + #[test] + fn cursor_prefers_hottest_open_api_over_auto() { + let extras = [extra(CURSOR_API_ID, 70.0)]; + assert_eq!( + remaining(ProviderId::Cursor, Some(90.0), Some(55.0), None, &extras), + Some(30.0) + ); + } + + #[test] + fn cursor_ignores_maxed_api_when_auto_has_room() { + let extras = [extra(CURSOR_API_ID, 100.0)]; + assert_eq!( + remaining(ProviderId::Cursor, Some(40.0), Some(60.0), None, &extras), + Some(40.0) + ); + } + + #[test] + fn cursor_surfaces_on_demand_after_included_lanes_exhaust() { + let extras = [ + extra(CURSOR_API_ID, 100.0), + extra_with_spend(CURSOR_ON_DEMAND_ID, 56.0, 1_002.16), + ]; + assert_eq!( + remaining(ProviderId::Cursor, Some(100.0), Some(100.0), None, &extras), + Some(44.0) + ); + } + + #[test] + fn cursor_surfaces_zero_spend_on_demand_at_included_boundary() { + let extras = [ + extra(CURSOR_API_ID, 100.0), + extra_with_spend(CURSOR_ON_DEMAND_ID, 0.0, 0.0), + ]; + assert_eq!( + remaining(ProviderId::Cursor, Some(50.0), Some(100.0), None, &extras), + Some(100.0) + ); + } + + #[test] + fn cursor_keeps_unused_on_demand_hidden_while_auto_has_room() { + let extras = [ + extra(CURSOR_API_ID, 100.0), + extra_with_spend(CURSOR_ON_DEMAND_ID, 0.0, 0.0), + ]; + assert_eq!( + remaining(ProviderId::Cursor, Some(100.0), Some(20.0), None, &extras), + Some(80.0) + ); + } + + #[test] + fn cursor_falls_back_to_plan_when_auto_and_api_are_absent() { + assert_eq!( + remaining(ProviderId::Cursor, Some(42.0), None, None, &[]), + Some(58.0) + ); + } + + #[test] + fn cursor_picks_soonest_reset_when_auto_and_api_are_exhausted() { + let soon = Utc.with_ymd_and_hms(2026, 7, 21, 4, 0, 0).unwrap(); + let later = Utc.with_ymd_and_hms(2026, 7, 28, 4, 0, 0).unwrap(); + let auto = RateWindow::with_details(100.0, Some(10_080), Some(later), None); + let api = NamedRateWindow::new( + CURSOR_API_ID, + "API", + RateWindow::with_details(100.0, Some(10_080), Some(soon), None), + ); + let plan = RateWindow::new(50.0); + let extras = [api]; + let window = + constraining_rate_window(ProviderId::Cursor, Some(&plan), Some(&auto), None, &extras) + .expect("window"); + assert_eq!(window.window_minutes, Some(10_080)); + assert_eq!(window.resets_at, Some(soon)); + } + + #[test] + fn generic_ranking_does_not_consult_cursor_api_extras() { + let extras = [extra(CURSOR_API_ID, 90.0)]; + assert_eq!( + remaining(ProviderId::Claude, Some(10.0), Some(20.0), None, &extras), + Some(80.0) + ); + } +} diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index f91c53f6..fc53ba4f 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -5,6 +5,7 @@ mod account_ledger; mod claude_accounts; mod codex_accounts; mod configured_accounts; +mod constraining; mod cost_pricing; mod credential_migration; mod http; @@ -27,6 +28,7 @@ pub use account_ledger::*; pub use claude_accounts::*; pub use codex_accounts::*; pub use configured_accounts::*; +pub use constraining::*; pub use cost_pricing::*; pub use credential_migration::*; pub use http::*; diff --git a/rust/src/core/widget_snapshot.rs b/rust/src/core/widget_snapshot.rs index 3e833845..6c7d7c60 100755 --- a/rust/src/core/widget_snapshot.rs +++ b/rust/src/core/widget_snapshot.rs @@ -5,7 +5,7 @@ #![allow(dead_code)] -use crate::core::{ProviderId, RateWindow}; +use crate::core::{NamedRateWindow, ProviderId, RateWindow}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fs; @@ -121,6 +121,11 @@ pub struct WidgetProviderEntry { /// Tertiary rate limit #[serde(skip_serializing_if = "Option::is_none")] pub tertiary: Option, + /// Extra labeled windows (Cursor `cursor-api` / `cursor-on-demand`, and + /// any other extras the desktop snapshot carries). Needed so MCP can + /// rank the same lanes the strip documents. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra_rate_windows: Vec, /// Credits remaining #[serde(skip_serializing_if = "Option::is_none")] pub credits_remaining: Option, @@ -149,6 +154,7 @@ impl WidgetProviderEntry { primary: None, secondary: None, tertiary: None, + extra_rate_windows: Vec::new(), credits_remaining: None, code_review_remaining_percent: None, token_usage: None, @@ -173,6 +179,27 @@ impl WidgetProviderEntry { self } + pub fn with_extra_rate_windows(mut self, extras: Vec) -> Self { + self.extra_rate_windows = extras; + self + } + + pub fn with_extra_rate_window(mut self, extra: NamedRateWindow) -> Self { + self.extra_rate_windows.push(extra); + self + } + + /// Window that binds MCP `remaining_percent` / the widget seat picker. + pub fn constraining_rate_window(&self) -> Option<&RateWindow> { + super::constraining_rate_window( + self.provider, + self.primary.as_ref(), + self.secondary.as_ref(), + self.tertiary.as_ref(), + &self.extra_rate_windows, + ) + } + pub fn with_credits_remaining(mut self, credits: f64) -> Self { self.credits_remaining = Some(credits); self @@ -390,4 +417,28 @@ mod tests { assert!(snapshot.is_enabled(ProviderId::Codex)); assert!(!snapshot.is_enabled(ProviderId::Claude)); } + + #[test] + fn widget_entry_roundtrips_cursor_extras() { + let extra = NamedRateWindow::new("cursor-api", "API", RateWindow::new(12.0)) + .with_amount(crate::core::WindowAmount::new(0.0, "USD")); + let entry = WidgetProviderEntry::new(ProviderId::Cursor, Utc::now()) + .with_primary(RateWindow::new(40.0)) + .with_extra_rate_window(extra); + let encoded = serde_json::to_value(&entry).unwrap(); + assert_eq!(encoded["extra_rate_windows"][0]["id"], "cursor-api"); + let decoded: WidgetProviderEntry = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.extra_rate_windows[0].id, "cursor-api"); + assert_eq!(decoded.extra_rate_windows[0].window.used_percent, 12.0); + } + + #[test] + fn widget_entry_reads_legacy_snapshots_without_extras() { + let decoded: WidgetProviderEntry = serde_json::from_str( + r#"{"provider":"cursor","updated_at":"2026-08-23T00:00:00Z","primary":{"used_percent":40.0}}"#, + ) + .unwrap(); + assert!(decoded.extra_rate_windows.is_empty()); + assert_eq!(decoded.primary.unwrap().used_percent, 40.0); + } } From c160c56e30433cb4235a6d6b1ba4bf9d4af696bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 22:34:02 +0000 Subject: [PATCH 2/3] Fix Cursor persist test to match on-demand strip ranking On-demand spend binds the strip even when Auto still has room. Assert that, and keep unused on-demand as the Plan-must-not-win case. Co-authored-by: Tyler --- .../src-tauri/src/commands/providers.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 32600294..ad6833b3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1773,10 +1773,38 @@ mod widget_snapshot_tests { let amount = on_demand.amount.as_ref().expect("amount"); assert_eq!(amount.used, 1002.16); assert_eq!(amount.limit, Some(1800.0)); + // Spend already started: the strip surfaces on-demand even while Auto + // still has room. Persist has to keep the amount so that ranking can. + assert_eq!( + entry.constraining_rate_window().map(|w| w.used_percent), + Some(56.0), + "on-demand spend binds the strip window: {entry:?}" + ); + } + + #[test] + fn widget_entry_cursor_ranking_keeps_auto_when_on_demand_is_unused() { + let metadata = instantiate_provider(ProviderId::Cursor).metadata().clone(); + let usage = UsageSnapshot::new(RateWindow::new(95.0)) + .with_secondary(RateWindow::new(55.0)) + .with_extra_rate_window("cursor-api", "API", RateWindow::new(12.0)); + let mut usage = usage; + usage.extra_rate_windows.push( + codexbar::core::NamedRateWindow::new( + "cursor-on-demand", + "On-demand", + RateWindow::new(0.0), + ) + .with_amount(codexbar::core::WindowAmount::new(0.0, "USD").with_limit(1_800.0)), + ); + let result = ProviderFetchResult::new(usage, "oauth"); + let snap = ProviderUsageSnapshot::from_fetch_result(ProviderId::Cursor, &metadata, &result); + let entry = widget_entry_from_usage_snapshot(&snap).expect("entry"); + assert_eq!( entry.constraining_rate_window().map(|w| w.used_percent), Some(55.0), - "Auto still has room, so persist must not let Plan bind" + "unused on-demand must not let Plan outrank Auto" ); } } From 3c84609cd6a76e3ea09925d4c5461db069b291a6 Mon Sep 17 00:00:00 2001 From: Tyler South Date: Mon, 7 Sep 2026 13:53:35 -0400 Subject: [PATCH 3/3] Drop entry from test assert message --- apps/desktop-tauri/src-tauri/src/commands/providers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 21a37c4b..180d6671 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1811,7 +1811,7 @@ mod widget_snapshot_tests { assert_eq!( entry.constraining_rate_window().map(|w| w.used_percent), Some(56.0), - "on-demand spend binds the strip window: {entry:?}" + "on-demand spend binds the strip window" ); }