diff --git a/README.md b/README.md index 12d63a99..c35ea5f7 100644 --- a/README.md +++ b/README.md @@ -174,3 +174,6 @@ The `src/` folder holds the retired C# / WPF app. ## License MIT + +Muse Code subscription allowance is available through the existing CLI session. +See [Muse Code setup, contract and limits](docs/MUSE_CODE.md). diff --git a/crates/codexbar-app/assets/brand/LICENSE.md b/crates/codexbar-app/assets/brand/LICENSE.md index 738b0a13..c9704696 100644 --- a/crates/codexbar-app/assets/brand/LICENSE.md +++ b/crates/codexbar-app/assets/brand/LICENSE.md @@ -1,5 +1,7 @@ # Provider logos +`muse-code.svg` is a CodexBar-drawn M monogram, not a vendor logo. + The SVG marks in this folder come from [Lobe Icons](https://github.com/lobehub/lobe-icons) (`@lobehub/icons-static-svg` 1.95.1, mono variants), under the MIT License below. The logos themselves are trademarks of their owners (OpenAI, Anthropic, GitHub, Anysphere, OpenRouter, OpenCode, diff --git a/crates/codexbar-app/assets/brand/muse-code.svg b/crates/codexbar-app/assets/brand/muse-code.svg new file mode 100644 index 00000000..29c2eda6 --- /dev/null +++ b/crates/codexbar-app/assets/brand/muse-code.svg @@ -0,0 +1 @@ + diff --git a/crates/codexbar-app/src/account_table.rs b/crates/codexbar-app/src/account_table.rs index 251612dd..43da7d6a 100644 --- a/crates/codexbar-app/src/account_table.rs +++ b/crates/codexbar-app/src/account_table.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; -use crate::usage_bar::{Forecast, Projection, usage_bar}; +use crate::usage_bar::{Projection, usage_bar}; use chrono::{DateTime, Utc}; use codexbar_core::{AccountSnapshot, Metric, Provider, format}; use codexbar_store::summary::HistorySummary; @@ -181,6 +181,16 @@ pub fn row_metric(account: &AccountSnapshot) -> Option<&Metric> { primary_or_claude_weekly(account) } +pub fn row_metric_at(account: &AccountSnapshot, now: DateTime) -> Option<&Metric> { + row_metric(account) + .filter(|metric| !account.metric_ended(metric, now)) + .or_else(|| { + account + .status_metrics() + .find(|metric| !account.metric_ended(metric, now)) + }) +} + fn primary_or_claude_weekly(account: &AccountSnapshot) -> Option<&Metric> { let primary = account.status_metrics().next()?; if account.provider() == Provider::Claude @@ -198,8 +208,8 @@ fn primary_or_claude_weekly(account: &AccountSnapshot) -> Option<&Metric> { /// The daily trend of the metric an account's row shows (#246): the account's own trend for its primary metric, else /// that limit's stored series. -fn row_trend(account: &AccountSnapshot) -> &[f64] { - row_metric(account).map_or(&[], |metric| trend_of_metric(account, metric)) +fn row_trend(account: &AccountSnapshot, now: DateTime) -> &[f64] { + row_metric_at(account, now).map_or(&[], |metric| trend_of_metric(account, metric)) } fn trend_of_metric<'a>(account: &'a AccountSnapshot, metric: &Metric) -> &'a [f64] { @@ -298,6 +308,25 @@ pub enum AccountState { Unavailable(String), } +impl AccountState { + /// A successful transport can still carry an old source observation. + pub fn fresh(account: &AccountSnapshot, now: DateTime) -> Self { + if account.observation_stale(now) { + Self::Failed("Muse allowance observation is over 7 minutes old".into()) + } else { + Self::Fresh + } + } + + /// Source age can downgrade fresh usage, while explicit loading/failure/restoration states survive. + pub fn at(state: Option<&Self>, account: &AccountSnapshot, now: DateTime) -> Self { + match state { + None | Some(Self::Fresh) => Self::fresh(account, now), + Some(other) => other.clone(), + } + } +} + /// Refresh state by account id; accounts not listed are fresh. pub type States = HashMap; @@ -549,7 +578,7 @@ impl TableDelegate for AccountTable { }; let assessment = row.assess(self.now); let severity = assessment.severity(); - let primary = row_metric(row); + let primary = row_metric_at(row, self.now); let muted = cx.theme().muted_foreground; // Wide, a standard window has its own columns; the primary pair then shows only what is neither (#195). let own_pair = if self.wide { @@ -560,7 +589,9 @@ impl TableDelegate for AccountTable { match self.columns()[col_ix] { Col::Window(kind, cell) => { - let metric = kind.in_account(row); + let metric = kind + .in_account(row) + .filter(|metric| !row.metric_ended(metric, self.now)); let id = format!("window-{}-{}", kind.key(), row.id().as_str()); let name = metric.map_or(kind.title(), Metric::label); match cell { @@ -701,7 +732,8 @@ impl AccountTable { /// screen readers. fn state_tag(&self, row: &AccountSnapshot, cx: &Context>) -> Option { let id = row.id().as_str(); - let (tag, label, detail) = match self.states.get(id)? { + let state = AccountState::at(self.states.get(id), row, self.now); + let (tag, label, detail) = match &state { AccountState::Fresh => return None, AccountState::Loading => ( Tag::secondary(), @@ -760,7 +792,7 @@ impl AccountTable { .into_any_element(); } let Some((summary, kind)) = self.compact.get(id) else { - return sparkline(row, cx); + return sparkline(row, self.now, cx); }; let text: SharedString = format!("Last 14 days: {}", describe(summary, *kind)).into(); h_flex() @@ -775,7 +807,7 @@ impl AccountTable { let text = text.clone(); move |window, cx| Tooltip::new(text.clone()).build(window, cx) }) - .child(div().flex_1().min_w_0().child(sparkline(row, cx))) + .child(div().flex_1().min_w_0().child(sparkline(row, self.now, cx))) .child( div() .flex_shrink_0() @@ -858,29 +890,22 @@ fn at_reset_cell( .child(text.to_owned()) .into_any_element() }; - // A used-up window the account's credits carry (#150) neither runs out nor is reached: the credits pay for it. - if row.credits_cover(metric) { - return plain( - crate::usage_bar::COVERED, - format!("{name} at reset: {}", crate::usage_bar::COVERED), - muted, - ); - } - let Some(forecast) = Forecast::of(metric, now) else { - // Only a limit with a reset ahead can be forecast; one without (a money-only spend cap) is not applicable. - let limited = (matches!(metric, Metric::Window { .. } | Metric::Quota { .. }) - || matches!(metric, Metric::Spend { limit: Some(_), .. })) - && metric.resets_at().is_some(); - // A used-up limit with no pace left to project still says so, as the Pi dashboard does. - let used_up = metric.used_fraction().is_some_and(|used| used >= 1.0); - let (text, spoken) = if used_up { - ("limit reached", format!("{name} at reset: limit reached")) - } else if limited { - ("no forecast", format!("{name} at reset: no forecast")) + let display = crate::usage_bar::ResetDisplay::of(metric, row, now); + let crate::usage_bar::ResetDisplay::Forecast(forecast) = display else { + let text = display.plain_text(); + let spoken = match display { + crate::usage_bar::ResetDisplay::Unavailable => { + format!("{name}: forecast unavailable, observation stale or window ended") + } + crate::usage_bar::ResetDisplay::NotApplicable => format!("{name} at reset: not applicable"), + _ => format!("{name} at reset: {text}"), + }; + let color = if display == crate::usage_bar::ResetDisplay::Reached { + cx.theme().danger } else { - ("—", format!("{name} at reset: not applicable")) + muted }; - return plain(text, spoken, if used_up { cx.theme().danger } else { muted }); + return plain(text, spoken, color); }; let runway = forecast.runway(); let tint = match runway { @@ -956,19 +981,29 @@ fn bar_cell( } fn missing_used_label(row: &AccountSnapshot) -> String { - if row.provider() == Provider::Gemini { + if matches!(row.provider(), Provider::Gemini | Provider::MuseCode) { "n/a".into() } else { String::new() } } fn missing_limit_label(row: &AccountSnapshot) -> String { - if row.provider() == Provider::Gemini { - "Gemini Models weekly".into() + [ + (Provider::MuseCode, "Quota unavailable"), + (Provider::Gemini, "Gemini Models weekly"), + ] + .into_iter() + .find(|(provider, _)| *provider == row.provider()) + .map_or(String::new(), |(_, label)| label.to_owned()) +} +fn missing_window_label(row: &AccountSnapshot, kind: WindowKind) -> &'static str { + if row.provider() == Provider::MuseCode { + "n/a" } else { - String::new() + kind.title() } } + fn missing_window_bar( kind: WindowKind, row: &AccountSnapshot, @@ -980,7 +1015,7 @@ fn missing_window_bar( if row.provider() == Provider::Gemini && kind == WindowKind::Weekly { bar_cell_without_metric(id, assessment, severity, row, cx) } else { - blank_cell(id, kind.title(), cx.theme().muted_foreground) + blank_cell(id, missing_window_label(row, kind), cx.theme().muted_foreground) } } @@ -992,18 +1027,32 @@ fn bar_cell_without_metric( row: &AccountSnapshot, cx: &gpui_kit::App, ) -> gpui_kit::AnyElement { + if let Some(unavailable) = unavailable_bar(id, row, cx) { + return unavailable; + } + bar(id, &Projection::bare(assessment.pressure()).graded(severity), row, cx) +} + +/// Missing quotas keep their provider's established presentation: Muse n/a, subscriptions SUS n/a, +/// and legacy accounts retain the pressure bar. +fn unavailable_bar(id: &str, row: &AccountSnapshot, cx: &gpui_kit::App) -> Option { + if row.provider() == Provider::MuseCode { + return Some(blank_cell(id, "n/a", cx.theme().muted_foreground)); + } if matches!( row.provider(), Provider::Gemini | Provider::Grok | Provider::XSubscription ) { - return h_flex() - .gap_2() - .items_center() - .child(no_bar(id, cx.theme().muted_foreground)) - .child(crate::sus::badge(None, &format!("{id}-sus"), "Quota unavailable", cx)) - .into_any_element(); + return Some( + h_flex() + .gap_2() + .items_center() + .child(no_bar(id, cx.theme().muted_foreground)) + .child(crate::sus::badge(None, &format!("{id}-sus"), "Quota unavailable", cx)) + .into_any_element(), + ); } - bar(id, &Projection::bare(assessment.pressure()).graded(severity), row, cx) + None } fn no_bar(id: &str, muted: gpui_kit::Hsla) -> gpui_kit::AnyElement { @@ -1049,8 +1098,12 @@ fn limit_label(metric: &Metric) -> String { /// A 14-day pressure trend. A sparkline shows shape, not level (the progress column shows level), /// so each row fits its own range. -fn sparkline(row: &AccountSnapshot, cx: &Context>) -> gpui_kit::AnyElement { - let trend = row_trend(row); +fn sparkline( + row: &AccountSnapshot, + now: DateTime, + cx: &Context>, +) -> gpui_kit::AnyElement { + let trend = row_trend(row, now); if trend.len() < 2 { // Real providers have no history until #85 stores it. return div() @@ -1160,7 +1213,7 @@ mod tests { assert_eq!(row_metric(&row), Some(&models)); assert_eq!(WindowKind::Weekly.in_account(&row), Some(&models)); assert_eq!(WindowKind::FiveHour.in_account(&row), None); - assert_eq!(row_trend(&row), [0.01, 0.02, 0.03]); + assert_eq!(row_trend(&row, now()), [0.01, 0.02, 0.03]); assert_eq!( row.primary(), Some(&apps), @@ -1255,7 +1308,7 @@ mod tests { trend: vec![0.7, 0.76, 0.82], }, )); - assert_eq!(row_trend(&row), [0.7, 0.76, 0.82]); + assert_eq!(row_trend(&row, now()), [0.7, 0.76, 0.82]); let codex = account(vec![window( "5-hour window", 0.2, @@ -1263,14 +1316,14 @@ mod tests { Pace::per_hour(0.1), )]) .with_trend(vec![0.1, 0.2]); - assert_eq!(row_trend(&codex), [0.1, 0.2]); + assert_eq!(row_trend(&codex, now()), [0.1, 0.2]); let no_series = claude(vec![ window("5-hour window", 0.64, Duration::minutes(72), Pace::per_hour(0.12)), window("Weekly", 0.82, Duration::days(2), Pace::per_day(0.05)), ]) .with_trend(vec![0.9, 0.5]); assert!( - row_trend(&no_series).is_empty(), + row_trend(&no_series, now()).is_empty(), "never the 5-hour window's trend under the weekly window" ); } diff --git a/crates/codexbar-app/src/alert_details.rs b/crates/codexbar-app/src/alert_details.rs index 1653cd00..c60baac0 100644 --- a/crates/codexbar-app/src/alert_details.rs +++ b/crates/codexbar-app/src/alert_details.rs @@ -18,7 +18,7 @@ pub fn projected_from(account: &AccountSnapshot, now: DateTime) -> Option &'static str { - match provider { - Provider::Codex => "brand/openai.svg", - Provider::Claude => "brand/claude.svg", - Provider::Copilot => "brand/githubcopilot.svg", - Provider::Cursor => "brand/cursor.svg", - Provider::OpenRouter => "brand/openrouter.svg", - Provider::OpenCode => "brand/opencode.svg", - Provider::Moonshot => "brand/kimi.svg", - Provider::Greptile => "brand/greptile.svg", - Provider::GitHubBilling => "brand/github.svg", - Provider::Gemini => "brand/gemini.svg", - Provider::Grok => "brand/grok.svg", - Provider::XSubscription => "brand/xai.svg", - } -} - -const LOGOS: [(&str, &[u8]); 12] = [ +const LOGOS: [(&str, &[u8]); 13] = [ + ("brand/muse-code.svg", include_bytes!("../assets/brand/muse-code.svg")), ("brand/openai.svg", include_bytes!("../assets/brand/openai.svg")), ("brand/claude.svg", include_bytes!("../assets/brand/claude.svg")), ( @@ -70,7 +54,7 @@ mod tests { #[test] fn every_provider_has_an_embedded_svg_logo() { for provider in Provider::ALL { - let bytes = Assets.load(logo_path(provider)).unwrap().expect("logo"); + let bytes = Assets.load(crate::brand::brand(provider).logo).unwrap().expect("logo"); let text = std::str::from_utf8(&bytes).unwrap(); assert!( text.starts_with(" Brand { - let accent = match provider { - Provider::Codex => 0x10A37F, - Provider::Claude => 0xD97757, - Provider::Copilot => 0x8957E5, - Provider::Cursor => 0x262626, - Provider::OpenRouter => 0x4F52D9, - Provider::OpenCode => 0xB45309, - Provider::Moonshot => 0x1D4ED8, - Provider::Greptile => 0x44A775, - Provider::GitHubBilling => 0x1F2328, - Provider::Gemini => 0x3186FF, - Provider::Grok => 0x2B2B2B, - Provider::XSubscription => 0x0F1419, - }; - Brand { - accent, - logo: crate::assets::logo_path(provider), - } + BRANDS.iter().find(|(id, _)| *id == provider).expect("provider brand").1 } /// The provider for a settings record's provider name (`OpenCodeGo` and `OpenCodeZen` are both OpenCode). pub fn from_settings_name(name: &str) -> Option { - use codexbar_store::settings::names; - Some(match name { - names::CODEX => Provider::Codex, - names::CLAUDE => Provider::Claude, - names::COPILOT => Provider::Copilot, - names::CURSOR => Provider::Cursor, - names::OPENROUTER => Provider::OpenRouter, - names::OPENCODE_GO | names::OPENCODE_ZEN => Provider::OpenCode, - names::MOONSHOT => Provider::Moonshot, - names::GREPTILE => Provider::Greptile, - names::GITHUB_BILLING => Provider::GitHubBilling, - names::GEMINI => Provider::Gemini, - names::GROK => Provider::Grok, - names::X_SUBSCRIPTION => Provider::XSubscription, - _ => return None, - }) + crate::catalog::PROVIDERS + .iter() + .find(|info| info.id == name) + .map(|info| info.provider) } /// White or near-black, whichever reads better on `accent`. @@ -142,5 +208,6 @@ mod tests { assert!(from_settings_name(name).is_some(), "{name}"); } assert_eq!(from_settings_name(names::OPENCODE_ZEN), Some(Provider::OpenCode)); + assert_eq!(from_settings_name("unknown"), None); } } diff --git a/crates/codexbar-app/src/catalog.rs b/crates/codexbar-app/src/catalog.rs index 2ad86a5b..89a88700 100644 --- a/crates/codexbar-app/src/catalog.rs +++ b/crates/codexbar-app/src/catalog.rs @@ -12,6 +12,7 @@ pub struct SecretSpec { } pub struct ProviderInfo { + pub provider: codexbar_core::Provider, /// The WPF `ProviderId` name used in settings. pub id: &'static str, pub display: &'static str, @@ -25,9 +26,10 @@ pub struct ProviderInfo { pub multi_account: bool, } -pub const PROVIDERS: [ProviderInfo; 13] = [ +pub const PROVIDERS: [ProviderInfo; 14] = [ ProviderInfo { id: names::CODEX, + provider: codexbar_core::Provider::Codex, display: "ChatGPT · Codex", sign_in: "Automatic uses the Codex CLI's own ChatGPT sign-in (`codex`). OAuth signs an account in with your browser \ through the Codex CLI and keeps it separate, so several ChatGPT accounts can be added.", @@ -38,6 +40,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::CLAUDE, + provider: codexbar_core::Provider::Claude, display: "Claude", sign_in: "OAuth and Automatic use Claude Code's own sign-in (`claude`). Browser session signs an account in \ through a Claude Code window and keeps it separate, so several Claude accounts can be added.", @@ -48,6 +51,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::COPILOT, + provider: codexbar_core::Provider::Copilot, display: "Copilot", sign_in: "Automatic and Command line use the GitHub CLI's accounts (`gh auth login`); set a username to fetch \ one. OAuth signs an account in with your browser through the GitHub CLI, kept apart from its own \ @@ -60,6 +64,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::CURSOR, + provider: codexbar_core::Provider::Cursor, display: "Cursor", sign_in: "Automatic uses the Cursor app's sign-in. OAuth signs an account in with your browser through the \ Cursor CLI (cursor-agent) and keeps it separate, so several Cursor accounts can be added.", @@ -70,6 +75,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::OPENROUTER, + provider: codexbar_core::Provider::OpenRouter, display: "OpenRouter", sign_in: "Needs an API key from openrouter.ai/keys.", secret: Some(SecretSpec { @@ -82,6 +88,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::OPENCODE_GO, + provider: codexbar_core::Provider::OpenCode, display: "OpenCode Go", sign_in: "OAuth signs an account in with your browser through OpenCode's own approval page, where you pick the \ workspace; it shows that workspace's Go limits and Zen balance. Add an account per workspace. Browser \ @@ -96,6 +103,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::OPENCODE_ZEN, + provider: codexbar_core::Provider::OpenCode, display: "OpenCode Zen", sign_in: "Reads a copied opencode.ai dashboard auth cookie (OpenCode Go's when unset). An OpenCode Go account \ signed in with OAuth already shows its workspace's Zen balance.", @@ -109,6 +117,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::MOONSHOT, + provider: codexbar_core::Provider::Moonshot, display: "Moonshot (Kimi)", sign_in: "Needs an API key from platform.kimi.ai.", secret: Some(SecretSpec { @@ -121,6 +130,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::GREPTILE, + provider: codexbar_core::Provider::Greptile, display: "Greptile", sign_in: "Needs a Greptile organization API key (app.greptile.com > Settings > API). Shows the organization's \ completed code reviews, read with Greptile's read-only review listing; not remaining credits.", @@ -134,6 +144,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::GITHUB_BILLING, + provider: codexbar_core::Provider::GitHubBilling, display: "GitHub Billing", sign_in: "Signs in with your browser through the GitHub CLI, apart from Copilot and the GitHub CLI's own \ accounts, and shows this month's spend across GitHub products for you or an organization you can \ @@ -145,6 +156,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::GEMINI, + provider: codexbar_core::Provider::Gemini, display: "Gemini", sign_in: "Experimental. Connect Antigravity reuses the local agy sign-in or opens the CLI's normal sign-in \ in your default browser, then verifies coding usage. Saved Gemini Apps sessions stay separate; \ @@ -156,6 +168,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::GROK, + provider: codexbar_core::Provider::Grok, display: "Grok", sign_in: "Experimental. Automatic uses the Grok CLI's own sign-in (`grok`). OAuth signs an account in through \ the Grok CLI in a folder of its own, so several xAI accounts can be added. Shows a paid plan's \ @@ -168,6 +181,7 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ }, ProviderInfo { id: names::X_SUBSCRIPTION, + provider: codexbar_core::Provider::XSubscription, display: "X Subscription", sign_in: "Experimental. Grok, Grok Bot and Cursor as one plan. Shows what Grok's Settings > Usage shows: the \ weekly SuperGrok limit and Extra Usage Credits through the Grok CLI's own sign-in (`grok login`), and \ @@ -178,6 +192,16 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ setup_method: AuthMethod::Automatic, multi_account: false, }, + ProviderInfo { + id: names::MUSE_CODE, + provider: codexbar_core::Provider::MuseCode, + display: "Muse Code", + sign_in: "Reads this computer's existing Muse CLI subscription session with `muse usage --json`. Choose Command line. No sign-in or update is started. META_API_KEY is excluded; billing mode and model entitlement are unknown. Only one account can read this session.", + secret: None, + default_method: AuthMethod::CommandLine, + setup_method: AuthMethod::CommandLine, + multi_account: false, + }, ]; pub fn info(provider: &str) -> &'static ProviderInfo { diff --git a/crates/codexbar-app/src/dashboard.rs b/crates/codexbar-app/src/dashboard.rs index 0dad183b..a988b753 100644 --- a/crates/codexbar-app/src/dashboard.rs +++ b/crates/codexbar-app/src/dashboard.rs @@ -392,6 +392,16 @@ impl Dashboard { } let now = this.now; this.table.update(cx, |table, _| table.delegate_mut().set_now(now)); + for account in &this.accounts { + if account.observation_stale(this.now) + && matches!(this.states.get(account.id().as_str()), None | Some(AccountState::Fresh)) + { + this.states.insert( + account.id().as_str().to_owned(), + AccountState::Failed("Muse allowance observation is over 7 minutes old".into()), + ); + } + } // The tooltip's "last known" age advances too; it is only republished when its text changes. this.publish_tooltip(cx); #[cfg(windows)] @@ -927,8 +937,8 @@ impl Dashboard { match outcome { AccountOutcome::Fresh(account) => { let account = crate::prefs_hub::PrefsHub::with_credit_start(cx, account); - self.states - .insert(account.id().as_str().to_owned(), AccountState::Fresh); + let state = AccountState::fresh(&account, self.now); + self.states.insert(account.id().as_str().to_owned(), state); self.placeholders.remove(account.id().as_str()); refreshed.push(account.clone()); accounts.push(account); @@ -1162,7 +1172,11 @@ impl Dashboard { .metrics() .iter() .filter(|metric| !account.credits_cover(metric)) - .any(|metric| metric.exhausts_before_reset(self.now)), + .any(|metric| { + account + .evaluation_time(metric, self.now) + .is_some_and(|at| metric.exhausts_before_reset(at)) + }), health, pressure: assessment.pressure(), }; @@ -1536,6 +1550,92 @@ impl Dashboard { cx.notify(); } + /// The production Muse adapter with a synthetic command runner. No live provider is constructed. + #[cfg(any(test, feature = "capture"))] + pub(crate) fn capture_muse(&mut self, state: &str, cx: &mut Context) { + use codexbar_providers::UsageProvider as _; + assert!(matches!(self.source, DataSource::Demo)); + #[cfg(any(windows, feature = "capture"))] + crate::prefs_hub::PrefsHub::update_widget_builder(cx, |builder| { + builder.tiles = vec![ + codexbar_store::widgets::WidgetTile { + account: "muse-demo".into(), + metric: "5-hour-window".into(), + mode: codexbar_store::widgets::TileMode::Automatic, + }, + codexbar_store::widgets::WidgetTile { + account: "muse-demo".into(), + metric: "weekly".into(), + mode: codexbar_store::widgets::TileMode::Automatic, + }, + ]; + }); + if state == "cached-alert" { + crate::prefs_hub::PrefsHub::update_alert_settings(cx, |settings| settings.enabled = true); + } + let now = crate::muse_fixture::now(state); + self.last_refresh = Some(now); + crate::sus::capture_time(now, cx); + let result = if state == "cached-alert" { + Ok(vec![crate::muse_fixture::cached_alert()]) + } else if state == "reset-after" { + crate::muse_fixture::provider("reset-before").fetch(crate::muse_fixture::now("reset-before")) + } else { + crate::muse_fixture::provider(state).fetch(now) + }; + self.states.clear(); + self.placeholders.clear(); + let account = match result { + Ok(mut accounts) => { + let account = accounts.remove(0); + if account.observation_stale(now) { + self.states.insert( + "muse-demo".into(), + AccountState::Failed("Muse source observation is stale".into()), + ); + } + account + } + Err(error) if state == "timeout" => { + self.states + .insert("muse-demo".into(), AccountState::Failed(error.to_string())); + crate::muse_fixture::provider("measured") + .fetch(crate::muse_fixture::now("measured")) + .unwrap() + .remove(0) + } + Err(error) => { + self.states + .insert("muse-demo".into(), AccountState::Unavailable(error.to_string())); + self.placeholders.insert("muse-demo".into()); + AccountSnapshot::new( + codexbar_core::AccountId::new("muse-demo"), + Provider::MuseCode, + Vec::new(), + now, + ) + .with_label("Demo") + } + }; + self.set_accounts(vec![account], cx); + #[cfg(any(windows, feature = "capture"))] + { + let snapshot = crate::widget_feed::build(&self.accounts, &self.states, &self.layout, now) + .with_builder(crate::prefs_hub::PrefsHub::widget_builder(cx)); + cx.set_global(crate::widget_builder::WidgetFeed(snapshot)); + } + + self.focus_account( + &crate::handoff::FocusRequest { + account: "muse-demo".into(), + metric: None, + }, + cx, + ); + self.view = DashboardView::Usage; + cx.notify(); + } + fn set_accounts(&mut self, mut accounts: Vec, cx: &mut Context) { self.ranked_with = alert_inputs(cx); self.now = crate::sus::now(cx); @@ -1606,7 +1706,7 @@ impl Dashboard { #[cfg(windows)] fn save_widget_snapshot(&mut self, cx: &mut Context) { self.widget_builder = crate::prefs_hub::PrefsHub::widget_builder(cx); - let snapshot = crate::widget_feed::build(&self.accounts, &self.states, &self.layout, Utc::now()) + let snapshot = crate::widget_feed::build(&self.accounts, &self.states, &self.layout, self.now) .with_builder(self.widget_builder.clone()); self.widgets_written = Some(self.now); // The builder's preview reads it (#95), in the demo too; only live data goes to the widgets. @@ -1633,7 +1733,7 @@ impl Dashboard { accounts .iter() .filter_map(|account| { - let metric = crate::account_table::row_metric(account)?; + let metric = crate::account_table::row_metric_at(account, self.now)?; let points = history.points(account.id().as_str(), &metric.key(), since, self.now); let summary = summarize(&points, self.now, GAP_THRESHOLD)?; Some((account.id().as_str().to_owned(), (summary, ValueKind::of(metric)))) @@ -2059,7 +2159,24 @@ impl Dashboard { ) .child(self.group_controls(account, cx)) .children(crate::alert_details::alert_details(&details, self.now, cx)) - .children(crate::focus_cards::subscription_note(account, cx)) + .children(crate::focus_cards::subscription_note(account, self.now, cx)) + .children( + (account.provider() == Provider::MuseCode) + .then(|| self.states.get(account.id().as_str())) + .flatten() + .and_then(|state| match state { + AccountState::Failed(error) => Some( + div() + .text_sm() + .text_color(cx.theme().warning) + .child(format!("Last known allowance. {error}")), + ), + AccountState::Unavailable(error) => { + Some(div().text_sm().text_color(cx.theme().warning).child(error.clone())) + } + _ => None, + }), + ) .children(crate::focus_cards::metric_list(account, self.now, cx)) // What the provider said besides numbers (#75), kept with last-good usage. .children( diff --git a/crates/codexbar-app/src/focus_cards.rs b/crates/codexbar-app/src/focus_cards.rs index 2dc97553..21816c9e 100644 --- a/crates/codexbar-app/src/focus_cards.rs +++ b/crates/codexbar-app/src/focus_cards.rs @@ -234,23 +234,51 @@ fn area_fill(color: Hsla) -> Background { /// Every metric the account reports, compactly (#82): the cards chart two of them, and a provider like Claude can /// return more (model-scoped weekly limits, usage credits, a credit balance). Each says its value, reset and projection. -pub fn subscription_note(account: &AccountSnapshot, cx: &App) -> Option { +pub fn subscription_note(account: &AccountSnapshot, now: DateTime, cx: &App) -> Option { use gpui_kit::{StatefulInteractiveElement as _, TestSupportExt as _}; - if account.provider() != codexbar_core::Provider::Gemini || account.status_metrics().next().is_some() { - return None; - } + let (id, text) = subscription_note_content(account, now)?; Some( div() - .id("gemini-models-weekly-unavailable") + .id(id) .test_support() - .aria_label("Gemini Models weekly: n/a. Other pools are informational.") + .aria_label(text.clone()) .text_sm() .text_color(cx.theme().muted_foreground) - .child("Gemini Models weekly: n/a. Other pools are informational.") + .child(text) .into_any_element(), ) } +fn subscription_note_content(account: &AccountSnapshot, now: DateTime) -> Option<(&'static str, String)> { + if account.provider() == codexbar_core::Provider::MuseCode { + let mut text = + "Percent of allowance used. Billing mode and model scope unknown. SUS and price unavailable.".to_owned(); + for (label, key) in [("5-hour", "5-hour-window"), ("Weekly", "weekly")] { + if !account + .metrics() + .iter() + .any(|m| m.key() == key && !account.metric_ended(m, now)) + { + text.push_str(&format!(" {label}: n/a.")); + } + } + if let Some(at) = account.observed_at() { + text.push_str(&format!(" Observed {}.", at.to_rfc3339())); + } + if let Some(source) = account.allowance_source() { + text.push_str(&format!(" Source: {source}.")); + } + return Some(("muse-quota-note", text)); + } + if account.provider() != codexbar_core::Provider::Gemini || account.status_metrics().next().is_some() { + return None; + } + Some(( + "gemini-models-weekly-unavailable", + "Gemini Models weekly: n/a. Other pools are informational.".into(), + )) +} + pub fn metric_list(account: &AccountSnapshot, now: DateTime, cx: &App) -> Option { use gpui_kit::{InteractiveElement as _, Role, StatefulInteractiveElement as _, TestSupportExt as _}; if account.metrics().len() < 2 { @@ -258,7 +286,12 @@ pub fn metric_list(account: &AccountSnapshot, now: DateTime, cx: &App) -> O } let muted = cx.theme().muted_foreground; let entries = account.metrics().iter().enumerate().map(|(ix, metric)| { + let ended = account.metric_ended(metric, now); + let value = if ended { "n/a".to_owned() } else { metric.used_display() }; let mut detail = Vec::new(); + if ended { + detail.push("window ended; refresh needed".to_owned()); + } if let Some(at) = metric.resets_at().filter(|at| *at > now) { detail.push(format!("resets in {}", format::countdown(at - now))); } @@ -275,9 +308,9 @@ pub fn metric_list(account: &AccountSnapshot, now: DateTime, cx: &App) -> O } let detail = detail.join(" · "); let spoken = if detail.is_empty() { - format!("{}: {}", metric.label(), metric.used_display()) + format!("{}: {}", metric.label(), value.clone()) } else { - format!("{}: {}, {detail}", metric.label(), metric.used_display()) + format!("{}: {}, {detail}", metric.label(), value.clone()) }; let severity = account.assess_metric(metric, now).severity(); v_flex() @@ -299,7 +332,7 @@ pub fn metric_list(account: &AccountSnapshot, now: DateTime, cx: &App) -> O .when(severity >= Severity::Watch, |this| { this.child(div().size_2().rounded_full().bg(severity_color(severity, cx))) }) - .child(div().text_sm().font_semibold().child(metric.used_display())) + .child(div().text_sm().font_semibold().child(value.clone())) .children(crate::sus::metric( account, metric, @@ -345,6 +378,11 @@ pub fn focus_cards( _ => activity_card(account, activity_in_effect(account, choice.activity), cx), }, }; + let card = if account.provider() == codexbar_core::Provider::MuseCode && account.observation_stale(now) { + card.takeaway("Last known allowance. Current capacity and forecast are unavailable.") + } else { + card + }; card.menu(menu).render(cx).into_any_element() }) .collect() @@ -367,6 +405,22 @@ fn limit_card( slot: usize, ring: &crate::ring_size::RingControl, cx: &App, +) -> Card { + if account.metric_ended(metric, now) { + return Card::new(metric.label().to_owned(), "Window ended", div().child("n/a")) + .takeaway("Current allowance n/a; refresh needed"); + } + render_limit(account, metric, view, now, slot, ring, cx) +} + +fn render_limit( + account: &AccountSnapshot, + metric: &Metric, + view: LimitView, + now: DateTime, + slot: usize, + ring: &crate::ring_size::RingControl, + cx: &App, ) -> Card { match view { LimitView::Gauge => share_card(account, now, &[metric.key()], ring, slot, cx), @@ -390,6 +444,7 @@ fn limit_graph<'a>(account: &'a AccountSnapshot, metric: &'a Metric, now: DateTi ) }; Graph { + projection_at: account.evaluation_time(metric, now), metric: Some(metric), curve: comparable(curve), trend, @@ -482,13 +537,7 @@ fn bar_card(account: &AccountSnapshot, metric: &Metric, now: DateTime, slot /// The words in a ring's opening (#234): the forecast's share at the reset, short enough for the ring, or what the /// bars say when there is none ("covered by credits", "limit reached"). The whole forecast is in its label and hover. fn ring_pace(metric: &Metric, account: &AccountSnapshot, now: DateTime) -> Option { - if account.credits_cover(metric) { - return crate::usage_bar::outlook(metric, account, now); - } - match crate::usage_bar::Forecast::of(metric, now) { - Some(forecast) => Some(format!("{} at reset", forecast.share())), - None => crate::usage_bar::outlook(metric, account, now), - } + crate::usage_bar::ResetDisplay::of(metric, account, now).ring_text() } /// One entry in a card's menu: what it shows and what choosing it does. @@ -711,6 +760,9 @@ fn usage_card(account: &AccountSnapshot, now: DateTime, view: Option, view: Option { + projection_at: Option>, /// The limit the takeaway and caption describe. metric: Option<&'a Metric>, curve: Option<&'a codexbar_core::WindowCurve>, @@ -861,7 +914,12 @@ fn graph_card(graph: &Graph, now: DateTime, slot: usize, cx: &App) -> Card let card = match primary { Some(metric) => card - .takeaway(pace_takeaway(metric, now)) + .takeaway( + graph + .projection_at + .map(|at| pace_takeaway(metric, at, now)) + .unwrap_or_else(|| "Current forecast unavailable; refresh needed".into()), + ) .caption(reset_caption(metric, now)), None => card, }; @@ -873,6 +931,15 @@ fn graph_card(graph: &Graph, now: DateTime, slot: usize, cx: &App) -> Card } /// The share of a pool as a ring gauge (#174), or the credits, balance or spend when no metric has a share. +fn missing_share_card(account: &AccountSnapshot) -> Card { + if account.provider() == codexbar_core::Provider::MuseCode { + Card::new("Allowance unavailable", "n/a", div().child("n/a")) + .takeaway("Muse Code did not report a valid allowance") + } else { + Card::new("No limits", "", div()).takeaway("This account reports no limits") + } +} + fn share_card( account: &AccountSnapshot, now: DateTime, @@ -884,12 +951,15 @@ fn share_card( use gpui_kit::{InteractiveElement as _, Role, StatefulInteractiveElement as _, TestSupportExt as _}; // The chosen limits (#218), in the order chosen; any the account no longer reports are skipped, and with none // left the card is automatic again. - let chosen = chosen_rings(account, rings); + let chosen: Vec<_> = chosen_rings(account, rings) + .into_iter() + .filter(|metric| !account.metric_ended(metric, now)) + .collect(); if chosen.len() >= 2 { return rings_card(account, &chosen, now, control, cx); } let Some(metric) = chosen.first().copied().or_else(|| share_metric(account, now)) else { - return Card::new("No limits", "", div()).takeaway("This account reports no limits"); + return missing_share_card(account); }; if let Metric::Credits { balance, .. } = metric { @@ -1121,6 +1191,13 @@ fn share_metric(account: &AccountSnapshot, now: DateTime) -> Option<&Metric .or_else(|| most_severe_after_primary(account, now, has_share)) .or(secondary) .or(Some(primary)) + .filter(|metric| !account.metric_ended(metric, now)) + .or_else(|| { + account + .metrics() + .iter() + .find(|metric| !account.metric_ended(metric, now)) + }) } /// The credits line for the ring's caption when the share took the card (#174): the credits after the headline, @@ -1445,13 +1522,16 @@ fn most_severe_after_primary( .map(|(_, metric)| metric) } -fn pace_takeaway(metric: &Metric, now: DateTime) -> String { +fn pace_takeaway(metric: &Metric, at: DateTime, now: DateTime) -> String { if let Some(days) = metric.days_of_credit() { return format!("About {days:.0} days of credit left"); } // The outlook picks the wording (#206), so the card and its tile agree even at the minute where a limit hit // just before the reset counts as a full share. - match (metric.outlook(now), metric.time_to_limit()) { + let to_limit = metric + .time_to_limit() + .map(|left| (left - (now - at)).max(chrono::Duration::zero())); + match (metric.outlook(at), to_limit) { (Some(outlook @ codexbar_core::Outlook::Early(_)), Some(to_limit)) => format!( "Hits the {} limit in ~{} at this pace, {}", metric.label().to_lowercase(), @@ -1529,6 +1609,68 @@ mod tests { use chrono::Duration; use codexbar_core::Pace; + #[test] + fn muse_notes_describe_only_reported_current_windows_and_keep_source_time() { + use codexbar_providers::UsageProvider as _; + for state in ["measured", "partial", "stale", "reset-before"] { + let account = crate::muse_fixture::provider(state) + .fetch(crate::muse_fixture::now(state)) + .unwrap() + .remove(0); + let (id, note) = subscription_note_content(&account, crate::muse_fixture::now(state)).unwrap(); + assert_eq!(id, "muse-quota-note"); + assert!(note.contains("Billing mode and model scope unknown") && note.contains("Source: mint")); + assert!(note.contains(&account.observed_at().unwrap().to_rfc3339())); + assert_eq!(note.contains("5-hour: n/a"), state == "partial"); + assert!(!note.contains("Weekly: n/a")); + } + let unavailable = AccountSnapshot::new( + codexbar_core::AccountId::new("m"), + codexbar_core::Provider::MuseCode, + vec![], + now(), + ); + let note = subscription_note_content(&unavailable, now()).unwrap().1; + assert!(note.contains("5-hour: n/a") && note.contains("Weekly: n/a")); + assert!(!note.contains("Observed") && !note.contains("Source:")); + assert!(share_metric(&unavailable, now()).is_none()); + let expired = window(0.5, 0.01); + let account = AccountSnapshot::new( + codexbar_core::AccountId::new("m"), + codexbar_core::Provider::MuseCode, + vec![expired.clone()], + now(), + ); + assert!(share_metric(&account, now() + Duration::hours(11)).is_none()); + let mut weekly = expired; + if let Metric::Window { label, resets_at, .. } = &mut weekly { + *label = "Weekly".into(); + *resets_at = now() + Duration::days(5); + } + let sibling = AccountSnapshot::new( + codexbar_core::AccountId::new("m"), + codexbar_core::Provider::MuseCode, + vec![window(0.95, 0.01), weekly], + now(), + ); + assert_eq!( + share_metric(&sibling, now() + Duration::hours(11)).unwrap().label(), + "Weekly" + ); + assert!( + subscription_note_content( + &AccountSnapshot::new( + codexbar_core::AccountId::new("c"), + codexbar_core::Provider::Codex, + vec![], + now() + ), + now() + ) + .is_none() + ); + } + fn window(used: f64, per_hour: f64) -> Metric { Metric::Window { label: "Included usage".into(), @@ -1968,18 +2110,21 @@ mod tests { #[test] fn pace_takeaway_shows_the_projection_or_when_the_limit_is_hit() { + let cached = crate::muse_fixture::cached_alert(); + let at = crate::muse_fixture::base(); + assert!(pace_takeaway(&cached.metrics()[0], at, at + Duration::minutes(5)).contains("~1h 53m")); assert_eq!( - pace_takeaway(&window(0.4, 0.03), now()), + pace_takeaway(&window(0.4, 0.03), now(), now()), "On pace for about 70% by the reset" ); // 60% left at 10%/h: the limit in 6h, four hours before the 10h reset (#206). assert_eq!( - pace_takeaway(&window(0.4, 0.1), now()), + pace_takeaway(&window(0.4, 0.1), now(), now()), "Hits the included usage limit in ~6h at this pace, 4h early" ); // 60% left at 6%/h lands on the 10h reset: a full share, as the tile says, not "hits the limit". assert_eq!( - pace_takeaway(&window(0.4, 0.06), now()), + pace_takeaway(&window(0.4, 0.06), now(), now()), "On pace for about 100% by the reset" ); } diff --git a/crates/codexbar-app/src/main.rs b/crates/codexbar-app/src/main.rs index 38e58b00..999ecd3e 100644 --- a/crates/codexbar-app/src/main.rs +++ b/crates/codexbar-app/src/main.rs @@ -23,6 +23,10 @@ mod history_view; mod locale; mod managed; mod mark; +#[cfg(any(test, feature = "capture"))] +mod muse_fixture; +#[cfg(test)] +mod muse_roundtrip; mod notifications; mod opencode_sign_in; mod package; @@ -47,11 +51,11 @@ mod usage_bar; mod version; #[cfg(test)] mod version_scheme; -#[cfg(windows)] +#[cfg(any(windows, feature = "capture"))] mod widget_builder; -#[cfg(any(windows, test))] +#[cfg(any(windows, test, feature = "capture"))] mod widget_feed; -#[cfg(any(windows, test))] +#[cfg(any(windows, test, feature = "capture"))] #[cfg_attr(not(windows), allow(dead_code))] mod widgets; mod zoom; diff --git a/crates/codexbar-app/src/muse_fixture.rs b/crates/codexbar-app/src/muse_fixture.rs new file mode 100644 index 00000000..1f110950 --- /dev/null +++ b/crates/codexbar-app/src/muse_fixture.rs @@ -0,0 +1,124 @@ +//! Synthetic-only acquisition seam used by tests and the native demo capture. +use chrono::{DateTime, Duration, Utc}; +use codexbar_providers::{ + CommandError, CommandOptions, CommandOutput, CommandRunner, UsageProvider, muse::MuseProvider, +}; +use std::time::Duration as Timeout; + +pub(crate) fn base() -> DateTime { + DateTime::from_timestamp_millis(1793037245064).unwrap() +} +pub(crate) fn now(state: &str) -> DateTime { + if state == "cached-alert" { + base() + Duration::minutes(5) + } else if state == "reset-after" { + base() + Duration::minutes(3) + } else if state == "stale" { + base() + Duration::minutes(8) + } else { + base() + Duration::minutes(1) + } +} +struct Fixture { + text: String, + failure: Option, + success: bool, +} +impl CommandRunner for Fixture { + fn run(&self, _: &str, _: &[&str], _: Timeout) -> Result { + panic!("unscoped command") + } + fn run_with_options( + &self, + program: &str, + args: &[&str], + timeout: Timeout, + options: &CommandOptions, + ) -> Result { + assert_eq!(program, "muse"); + assert_eq!(args, ["usage", "--json"]); + assert_eq!(timeout, Timeout::from_secs(15)); + assert_eq!(options.output_limit, 4 * 1024 * 1024); + assert!(options.remove_env.contains(&"META_API_KEY")); + if let Some(error) = self.failure { + return Err(error); + } + Ok(CommandOutput { + success: self.success, + stdout: self.text.clone(), + stderr: "synthetic Fixture User@example.com".into(), + }) + } +} +pub(crate) fn provider(state: &str) -> impl UsageProvider + use<> { + let text = match state { + "zero" => include_str!("../../codexbar-providers/fixtures/muse/zero.json"), + "exhausted" => include_str!("../../codexbar-providers/fixtures/muse/exhausted.json"), + "partial" => include_str!("../../codexbar-providers/fixtures/muse/partial.json"), + "malformed" => include_str!("../../codexbar-providers/fixtures/muse/malformed-values.json"), + "unsupported" => include_str!("../../codexbar-providers/fixtures/muse/unsupported-schema.json"), + "null" | "payg-unknown" | "expired-entitlement-unknown" => { + include_str!("../../codexbar-providers/fixtures/muse/null-allowance.json") + } + "token-activity-only" => { + r#"{"schemaVersion":1,"allowance":{"asOfMs":1793037245064},"run":{"usage":{"tokens":987654}},"model":"wonder-full"}"# + } + _ => include_str!("../../codexbar-providers/fixtures/muse/measured.json"), + }; + let text = if state == "reset-before" { + let mut payload: serde_json::Value = serde_json::from_str(text).unwrap(); + payload["allowance"]["window"]["resetsAtMs"] = + serde_json::json!((base() + Duration::minutes(2)).timestamp_millis()); + payload["allowance"]["window"]["usedPercent"] = serde_json::json!(95); + payload.to_string() + } else { + text.to_owned() + }; + let failure = match state { + "timeout" => Some(CommandError::TimedOut), + "transport" => Some(CommandError::Failed), + "missing-cli" => Some(CommandError::NotFound), + _ => None, + }; + MuseProvider::new( + Fixture { + text, + failure, + success: state != "rate-command-failure", + }, + std::env::temp_dir(), + "muse-demo".into(), + "Demo".into(), + ) +} + +/// Five-minute-old source reading served from the production adapter's still-valid cache. +/// The short window's observation-anchored projection is just past 100%, while recomputing at wall time is below it. +pub(crate) fn cached_alert() -> codexbar_core::AccountSnapshot { + let text = serde_json::json!({ + "schemaVersion": 1, + "allowance": { + "asOfMs": base().timestamp_millis(), "source": "mint", "tier": "Muse Code Power Usage", + "window": {"usedPercent": 60.5, "resetsAtMs": (base() + Duration::hours(2)).timestamp_millis()}, + "weekly": {"usedPercent": 60, "resetsAtMs": (base() + Duration::days(3)).timestamp_millis()} + } + }) + .to_string(); + let provider = MuseProvider::new( + Fixture { + text, + failure: None, + success: true, + }, + std::env::temp_dir(), + "muse-demo".into(), + "Demo".into(), + ); + let first = provider.fetch(base() + Duration::minutes(1)).unwrap(); + let mut cached = provider.fetch(base() + Duration::minutes(5)).unwrap(); + assert_eq!( + first, cached, + "cache must preserve the original observation and fetch clocks" + ); + cached.remove(0) +} diff --git a/crates/codexbar-app/src/muse_roundtrip.rs b/crates/codexbar-app/src/muse_roundtrip.rs new file mode 100644 index 00000000..4c572e29 --- /dev/null +++ b/crates/codexbar-app/src/muse_roundtrip.rs @@ -0,0 +1,464 @@ +//! Producer -> persisted accounts -> widget file -> actual Windows reader / GPUI preview. +use crate::{ + account_table::{AccountState, States}, + muse_fixture as fixture, widget_feed, + widgets::cards::{self, Config, Size}, +}; +use codexbar_core::{AccountId, AccountSnapshot, Metric, Provider, layout::Layout}; +use codexbar_providers::UsageProvider as _; +use codexbar_store::{ + snapshots, + widgets::{self, WidgetHealth}, +}; +use std::{ + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, +}; +struct Scratch(PathBuf); +impl Scratch { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "codexbar-273-roundtrip-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn muse_adapter_account_and_widget_roundtrip_covers_each_synthetic_state() { + for (state, count, health) in [ + ("measured", 2, WidgetHealth::Fresh), + ("zero", 2, WidgetHealth::Fresh), + ("exhausted", 2, WidgetHealth::Fresh), + ("partial", 1, WidgetHealth::Fresh), + ("stale", 2, WidgetHealth::Stale), + ("malformed", 0, WidgetHealth::Unavailable), + ("null", 0, WidgetHealth::Unavailable), + ("payg-unknown", 0, WidgetHealth::Unavailable), + ("expired-entitlement-unknown", 0, WidgetHealth::Unavailable), + ("timeout", 0, WidgetHealth::Unavailable), + ("rate-command-failure", 0, WidgetHealth::Unavailable), + ("transport", 0, WidgetHealth::Unavailable), + ("missing-cli", 0, WidgetHealth::Unavailable), + ("unsupported", 0, WidgetHealth::Unavailable), + ("token-activity-only", 0, WidgetHealth::Unavailable), + ] { + let now = fixture::now(state); + let mut states = States::new(); + let account = match fixture::provider(state).fetch(now) { + Ok(mut accounts) => accounts.remove(0), + Err(error) => { + states.insert("muse-demo".into(), AccountState::Unavailable(error.to_string())); + AccountSnapshot::new(AccountId::new("muse-demo"), Provider::MuseCode, Vec::new(), now) + } + }; + assert_eq!(account.metrics().len(), count, "{state}"); + let dir = Scratch::new(); + snapshots::save_snapshots(&dir.0, std::slice::from_ref(&account)).unwrap(); + let mut restored = snapshots::load_snapshots(&dir.0); + // A first-fetch placeholder has no source observation and isn't a last-good snapshot. + if count == 0 { + assert!(restored.is_empty()); + restored.push(account.clone()); + } else { + assert_eq!(restored[0], account); + } + let feed = widget_feed::build(&restored, &states, &Layout::default(), now); + assert_eq!(feed.accounts[0].health, health, "{state}"); + assert_eq!(feed.accounts[0].observed_at, account.observed_at()); + assert!( + feed.accounts[0] + .metrics + .iter() + .all(|m| m.sus.is_none() && !m.sus_eligible) + ); + widgets::save_widget_snapshot(&dir.0, &feed).unwrap(); + let loaded = widgets::load_widget_snapshot(&dir.0).unwrap(); + let mut expected = feed.clone(); + for (actual, expected) in loaded + .accounts + .iter() + .flat_map(|a| &a.metrics) + .zip(expected.accounts.iter_mut().flat_map(|a| &mut a.metrics)) + { + if let (Some(actual), Some(wanted)) = (actual.projected_percent, expected.projected_percent) { + assert!( + (actual - wanted).abs() <= wanted.abs().max(1.0) * 1e-12, + "projection must survive JSON round-trip" + ); + // JSON's float parser may round the forecast by one ULP. Every other wire field remains exact. + expected.projected_percent = Some(actual); + } + } + assert_eq!(loaded, expected); + let views = cards::tile_views(&loaded, &Config::default(), Size::Medium, now).unwrap(); + assert_eq!(views.len(), 1); + let serialized = + serde_json::to_string(&cards::render(&Ok(loaded), &Config::default(), Size::Medium, now)).unwrap(); + assert!(!serialized.contains("Fixture User")); + assert!(!serialized.contains("example.com")); + if count == 0 { + assert_eq!(views[0].percent, None); + assert_eq!(views[0].note.as_deref(), Some("Unavailable")); + } + if state == "measured" { + assert_eq!(views[0].percent, Some(15.0)); + assert!(views[0].line.as_ref().unwrap().contains("126%")); + } + if state == "zero" { + assert_eq!(views[0].percent, Some(0.0)); + } + if state == "exhausted" { + assert_eq!(views[0].percent, Some(100.0)); + } + if state == "partial" { + assert_eq!(views[0].percent, Some(5.0)); + assert_eq!(account.metrics()[0].key(), "weekly"); + } + if state == "stale" { + assert!(views[0].note.as_ref().unwrap().contains("Last known")); + assert!(views[0].line.as_ref().unwrap().contains("15% used")); + assert!(!views[0].line.as_ref().unwrap().contains("at reset")); + } + } +} +#[test] +fn failed_refresh_retains_times_and_widget_reader_ages_independently_of_writer() { + let account = fixture::provider("measured") + .fetch(fixture::now("measured")) + .unwrap() + .remove(0); + assert!(fixture::provider("timeout").fetch(fixture::now("measured")).is_err()); + let states = States::from([("muse-demo".into(), AccountState::Failed("Muse usage timed out".into()))]); + let feed = widget_feed::build( + std::slice::from_ref(&account), + &states, + &Layout::default(), + fixture::now("measured"), + ); + assert_eq!(feed.accounts[0].updated_at, Some(account.fetched_at())); + assert_eq!(feed.accounts[0].observed_at, Some(fixture::base())); + assert_eq!(feed.accounts[0].health, WidgetHealth::Stale); + let fresh_feed = widget_feed::build( + std::slice::from_ref(&account), + &States::new(), + &Layout::default(), + fixture::now("measured"), + ); + let old = cards::tile_views(&fresh_feed, &Config::default(), Size::Medium, fixture::now("stale")).unwrap(); + assert!(old[0].note.as_ref().unwrap().contains("Last known")); + assert_eq!(old[0].status.as_deref(), Some("Stale")); + let reset = account.metrics()[0].resets_at().unwrap() + chrono::Duration::seconds(1); + let rolled = cards::tile_views(&fresh_feed, &Config::default(), Size::Medium, reset).unwrap(); + assert_eq!(rolled[0].percent, None); + assert!(rolled[0].note.as_ref().unwrap().contains("reset")); + assert!( + codexbar_core::alerts::evaluate( + &Default::default(), + &Default::default(), + &[account], + fixture::now("stale") + ) + .notify + .is_empty() + ); +} +#[test] +fn existing_provider_wire_shape_is_unchanged_and_unknown_additions_are_compatible() { + let account = AccountSnapshot::new( + AccountId::new("existing"), + Provider::Claude, + vec![Metric::Window { + label: "Weekly".into(), + used: 0.42, + resets_at: fixture::base() + chrono::Duration::days(3), + pace: None, + length: None, + }], + fixture::base(), + ); + let feed = widget_feed::build(&[account], &States::new(), &Layout::default(), fixture::base()); + let value = serde_json::to_value(&feed).unwrap(); + let row = &value["accounts"][0]; + assert!(row.get("observedAt").is_none()); + assert!(row.get("freshForSeconds").is_none()); + assert!(row.get("allowanceSource").is_none()); + assert_eq!(row["metrics"][0]["usedPercent"], 42.0); + assert_eq!(row["metrics"][0]["value"], "42% used"); + assert_eq!(value["version"], 1); + let mut additive = value.clone(); + additive["accounts"][0]["futureUnknownField"] = serde_json::json!("ignored"); + assert_eq!( + serde_json::from_value::(additive).unwrap(), + feed + ); + let dir = Scratch::new(); + snapshots::save_snapshots( + &dir.0, + &codexbar_core::demo::demo_accounts(fixture::base(), &chrono::Utc), + ) + .unwrap(); + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dir.0.join("snapshots.json")).unwrap()).unwrap(); + assert!( + raw["accounts"] + .as_array() + .unwrap() + .iter() + .all(|a| a.get("observedAt").is_none()) + ); + assert_eq!(raw["version"], 4); + assert!(!snapshots::load_snapshots(&dir.0).is_empty()); +} + +#[test] +fn history_does_not_retimestamp_cached_readings_or_merge_configured_accounts() { + let provider = fixture::provider("measured"); + let original = provider.fetch(fixture::now("measured")).unwrap().remove(0); + let mut history = codexbar_store::HistoryStore::in_memory(chrono::Duration::days(30)); + assert_eq!( + history + .record(std::slice::from_ref(&original), fixture::now("measured")) + .unwrap(), + 2 + ); + let cached = provider + .fetch(fixture::now("measured") + chrono::Duration::minutes(3)) + .unwrap(); + assert_eq!( + history + .record(&cached, fixture::now("measured") + chrono::Duration::minutes(3)) + .unwrap(), + 0 + ); + let sample = history.series("muse-demo", "5-hour-window").next().unwrap(); + assert_eq!(sample.at().timestamp(), fixture::base().timestamp()); + assert_eq!( + sample.resets_at(), + original.metrics()[0] + .resets_at() + .map(|at| chrono::DateTime::from_timestamp(at.timestamp(), 0).unwrap()) + ); + let other = codexbar_providers::muse::parse( + include_str!("../../codexbar-providers/fixtures/muse/measured.json"), + fixture::now("measured"), + "other-account", + "Other", + ) + .unwrap(); + assert_eq!(history.record(&[other], fixture::now("measured")).unwrap(), 2); + assert_eq!(history.series("muse-demo", "weekly").count(), 1); + assert_eq!(history.series("other-account", "weekly").count(), 1); +} + +/// Inspect the actual Adaptive Card output rather than a producer-only percentage assertion. +fn bar_styles(value: &serde_json::Value, styles: &mut Vec) { + match value { + serde_json::Value::Object(map) => { + if map.get("type").and_then(serde_json::Value::as_str) == Some("Column") + && map.get("minHeight").and_then(serde_json::Value::as_str) == Some("6px") + { + styles.push(map["style"].as_str().unwrap().to_owned()); + } + for child in map.values() { + bar_styles(child, styles); + } + } + serde_json::Value::Array(values) => { + for child in values { + bar_styles(child, styles); + } + } + _ => {} + } +} + +#[test] +fn exhausted_muse_reaches_actual_widget_reader_as_attention_while_unknown_usage_is_not_healthy() { + use codexbar_store::widgets::{TileMode, WidgetBuilder, WidgetTile}; + for (state, expected, status) in [ + ("measured", Some("good"), None), + ("exhausted", Some("attention"), Some("Limit reached")), + ("stale", Some("default"), Some("Stale")), + ("null", None, None), + ] { + let now = fixture::now(state); + let mut states = States::new(); + let account = match fixture::provider(state).fetch(now) { + Ok(mut accounts) => accounts.remove(0), + Err(error) => { + states.insert("muse-demo".into(), AccountState::Unavailable(error.to_string())); + AccountSnapshot::new(AccountId::new("muse-demo"), Provider::MuseCode, Vec::new(), now) + } + }; + let dir = Scratch::new(); + snapshots::save_snapshots(&dir.0, std::slice::from_ref(&account)).unwrap(); + let accounts = if state == "null" { + vec![account] + } else { + snapshots::load_snapshots(&dir.0) + }; + let snapshot = widget_feed::build(&accounts, &states, &Layout::default(), now).with_builder(WidgetBuilder { + tiles: vec![WidgetTile { + account: "muse-demo".into(), + metric: "weekly".into(), + mode: TileMode::Status, + }], + ..Default::default() + }); + widgets::save_widget_snapshot(&dir.0, &snapshot).unwrap(); + let snapshot = widgets::load_widget_snapshot(&dir.0).unwrap(); + let config = Config { + focus: cards::Focus::Custom, + ..Default::default() + }; + let views = cards::tile_views(&snapshot, &config, Size::Medium, now).unwrap(); + assert_eq!(views[0].status.as_deref(), status, "{state}"); + if state != "measured" { + assert_ne!(views[0].big.as_deref(), Some("OK")); + } + // A bar-mode render checks the filled segment's semantic color, status mode checks the large label. + let mut bar_snapshot = snapshot.clone(); + bar_snapshot.builder.tiles[0].mode = TileMode::Bar; + let mut styles = Vec::new(); + bar_styles( + &cards::render(&Ok(bar_snapshot), &config, Size::Medium, now), + &mut styles, + ); + assert_eq!(styles.first().map(String::as_str), expected, "{state}: {styles:?}"); + let status_card = cards::render(&Ok(snapshot), &config, Size::Medium, now).to_string(); + if state == "exhausted" { + assert!(status_card.contains("Attention")); + assert!(status_card.contains("Limit reached")); + assert!(!styles.iter().any(|style| style == "good")); + } + if matches!(state, "stale" | "null") { + assert!(!styles.iter().any(|style| style == "good")); + assert!(!status_card.contains("\"Good\"")); + } + } +} + +#[test] +fn exhausted_widget_status_respects_informational_pools_and_credit_coverage() { + let window = |label: &str| Metric::Window { + label: label.into(), + used: 1.0, + resets_at: fixture::base() + chrono::Duration::days(2), + pace: None, + length: Some(chrono::Duration::days(7)), + }; + let argon = AccountSnapshot::new( + AccountId::new("argon"), + Provider::Gemini, + vec![window("Argon weekly")], + fixture::base(), + ); + let covered = AccountSnapshot::new( + AccountId::new("covered"), + Provider::Codex, + vec![ + window("Weekly"), + Metric::Credits { + label: "Credits".into(), + balance: Some(12.0), + start: None, + }, + ], + fixture::base(), + ); + let feed = widget_feed::build(&[argon, covered], &States::new(), &Layout::default(), fixture::base()); + for account in &feed.accounts { + assert_eq!(account.metrics[0].status, None); + } + let card = cards::render(&Ok(feed), &Config::default(), Size::Medium, fixture::base()); + let mut styles = Vec::new(); + bar_styles(&card, &mut styles); + assert_eq!(styles, vec!["good", "good"]); + assert!(!card.to_string().contains("Attention")); +} + +#[test] +fn ended_muse_window_cannot_forecast_or_alert_while_its_weekly_sibling_remains_current() { + use codexbar_core::{Severity, alerts}; + let before = fixture::base() + chrono::Duration::minutes(1); + let after = fixture::base() + chrono::Duration::minutes(3); + let account = fixture::provider("reset-before").fetch(before).unwrap().remove(0); + let dir = Scratch::new(); + snapshots::save_snapshots(&dir.0, std::slice::from_ref(&account)).unwrap(); + let account = snapshots::load_snapshots(&dir.0).remove(0); + assert_eq!(account.observed_at(), Some(fixture::base())); + assert_eq!(account.fetched_at(), before); + let short = &account.metrics()[0]; + let weekly = &account.metrics()[1]; + assert!(!account.observation_stale(after)); + assert_eq!( + crate::account_table::row_metric_at(&account, before).unwrap().key(), + "5-hour-window" + ); + assert_eq!( + crate::account_table::row_metric_at(&account, after).unwrap().key(), + "weekly" + ); + assert!(short.resets_at().unwrap() <= after); + assert!(account.evaluation_time(short, short.resets_at().unwrap()).is_none()); + assert!(crate::usage_bar::Forecast::for_account(short, &account, after).is_none()); + assert!(crate::usage_bar::outlook(short, &account, before).is_some()); + assert!( + crate::usage_bar::outlook(short, &account, after).is_none(), + "ended short window must not retain its observation-anchored outlook" + ); + assert!(crate::usage_bar::outlook(weekly, &account, after).is_some()); + let projection = crate::usage_bar::Projection::of_account(short, short.used_fraction().unwrap(), &account, after); + assert_eq!(projection.projected, None); + assert_eq!(account.assess_metric(short, after).severity(), Severity::Normal); + assert_eq!( + account.assess(after).severity(), + account.assess_metric(weekly, after).severity() + ); + let settings = alerts::AlertSettings { + enabled: true, + ..Default::default() + }; + assert!( + !alerts::evaluate(&settings, &Default::default(), std::slice::from_ref(&account), before) + .notify + .is_empty() + ); + assert!( + alerts::evaluate(&settings, &Default::default(), std::slice::from_ref(&account), after) + .notify + .is_empty() + ); + assert!(alerts::account_alerts(&settings, &Default::default(), &account, after).is_empty()); + // Reconciling old-window keys remains independent of whether the ended current window may alert. + let previous_slot = format!( + "{}@{}", + short.key(), + (short.resets_at().unwrap() - chrono::Duration::hours(5)).timestamp() / 3600 + ); + let old_key = alerts::alert_key(account.id().as_str(), &previous_slot, alerts::AlertKind::Usage); + let active = [old_key.clone()].into_iter().collect(); + let reconciled = alerts::evaluate(&settings, &active, std::slice::from_ref(&account), after); + assert!(reconciled.recovered.contains(&old_key)); + assert!(reconciled.notify.is_empty()); + let stale = + account + .clone() + .with_observation(fixture::base() - chrono::Duration::hours(1), Some("mint".into()), 420); + let held = alerts::evaluate(&settings, &active, &[stale], after); + assert!(held.notify.is_empty() && held.recovered.is_empty()); + let feed = widget_feed::build(&[account], &States::new(), &Layout::default(), after); + assert_eq!(feed.accounts[0].metrics[0].projected_percent, None); + assert_eq!(feed.accounts[0].metrics[0].outlook, None); + assert!(feed.accounts[0].metrics[1].projected_percent.is_some()); +} diff --git a/crates/codexbar-app/src/prefs_hub.rs b/crates/codexbar-app/src/prefs_hub.rs index 607888ff..121412cc 100644 --- a/crates/codexbar-app/src/prefs_hub.rs +++ b/crates/codexbar-app/src/prefs_hub.rs @@ -166,7 +166,7 @@ impl PrefsHub { } /// The widget builder's tiles and refresh choice (#95). - #[cfg(windows)] + #[cfg(any(windows, feature = "capture"))] pub fn widget_builder(cx: &App) -> codexbar_store::widgets::WidgetBuilder { cx.try_global::() .map(|hub| hub.prefs.widget_builder().clone()) @@ -174,7 +174,7 @@ impl PrefsHub { } /// Changes the widget builder's choices and saves when they changed. - #[cfg(windows)] + #[cfg(any(windows, feature = "capture"))] pub fn update_widget_builder(cx: &mut App, change: impl FnOnce(&mut codexbar_store::widgets::WidgetBuilder)) { Self::edit(cx, |prefs| prefs.update_widget_builder(change)); cx.refresh_windows(); diff --git a/crates/codexbar-app/src/providers.rs b/crates/codexbar-app/src/providers.rs index 000ba71e..3fc81d84 100644 --- a/crates/codexbar-app/src/providers.rs +++ b/crates/codexbar-app/src/providers.rs @@ -37,6 +37,7 @@ fn enabled_accounts(hub: &SettingsHub, provider: &str) -> Vec { pub fn enabled(hub: &SettingsHub) -> Vec> { let mut providers: Vec> = Vec::new(); + providers.extend(muse_adapters(hub, codexbar_core::home_dir())); providers.extend(codex_adapters(hub)); if let Some(copilot) = copilot(hub) { @@ -194,6 +195,29 @@ pub fn enabled(hub: &SettingsHub) -> Vec> { providers } +/// Muse has one machine-owned CLI session. Construction never launches it or reads its credentials. +fn muse_adapters(hub: &SettingsHub, home: Option) -> Vec> { + let mut providers: Vec> = Vec::new(); + if let Some(home) = home { + for account in enabled_accounts(hub, names::MUSE_CODE) + .into_iter() + .filter(|a| a.method == codexbar_store::settings::AuthMethod::CommandLine) + .take(1) + { + providers.push(Arc::new( + codexbar_providers::muse::MuseProvider::new( + SystemCommandRunner, + home.clone(), + account.id, + account.label, + ) + .with_cache(hub.muse_cache.clone()), + )); + } + } + providers +} + /// One adapter per Codex home (#78): the Codex CLI's own, and one for each account CodexBar signed in. Homes signed in /// to the same ChatGPT identity (the Codex CLI's and a CodexBar account, say) show it once, through the CodexBar /// account that owns it, so one identity never appears twice or loses its owner. @@ -384,7 +408,8 @@ pub fn owned_account_ids(hub: &SettingsHub) -> HashMap { | names::GITHUB_BILLING | names::GEMINI | names::GROK - | names::X_SUBSCRIPTION => record.id.clone(), + | names::X_SUBSCRIPTION + | names::MUSE_CODE => record.id.clone(), names::CODEX if crate::codex_sign_in::is_managed(record) => record.external_id.clone()?, names::CLAUDE if crate::claude_sign_in::is_managed(record) => record.external_id.clone()?, names::CURSOR if crate::cursor_sign_in::is_managed(record) => record.external_id.clone()?, @@ -549,3 +574,53 @@ fn opencode(hub: &SettingsHub) -> Vec> { ))); adapters } + +#[cfg(test)] +mod muse_tests { + use super::*; + use codexbar_store::credentials::MemoryCredentialStore; + use gpui_kit::TestAppContext; + + #[gpui_kit::test] + fn muse_adapter_construction_filters_disabled_wrong_method_and_duplicate_records(cx: &mut TestAppContext) { + let dir = std::env::temp_dir().join(format!("codexbar-muse-adapters-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (records, expected) in [ + (serde_json::json!([]), Vec::<&str>::new()), + ( + serde_json::json!([ + {"id":"disabled","displayLabel":"Demo","providerId":"MuseCode","enabled":false,"authenticationMethod":"CommandLine"}, + {"id":"wrong","displayLabel":"Demo","providerId":"MuseCode","enabled":true,"authenticationMethod":"ApiKey"}, + {"id":"one","providerId":"MuseCode","displayLabel":"Demo","enabled":true,"authenticationMethod":"CommandLine"}, + {"id":"duplicate","displayLabel":"Demo","providerId":"MuseCode","enabled":true,"authenticationMethod":"CommandLine"} + ]), + vec!["one"], + ), + ] { + std::fs::write( + dir.join("settings.json"), + serde_json::json!({"accountConfigurationVersion":1,"accounts":records}).to_string(), + ) + .unwrap(); + cx.update(|cx| { + SettingsHub::init_with(cx, &dir, Arc::new(MemoryCredentialStore::default())); + let hub = SettingsHub::global(cx); + assert!(muse_adapters(hub, None).is_empty()); + let adapters = muse_adapters(hub, Some(dir.clone())); + assert_eq!( + adapters.iter().map(|p| p.account_id().unwrap()).collect::>(), + expected + ); + assert!(adapters.iter().all(|p| p.name() == "Muse Code")); + if let Some(provider) = adapters.first() { + assert_eq!(provider.account_label(), Some("Demo")); + } + let owned = owned_account_ids(hub); + for id in &expected { + assert_eq!(owned.get(*id).map(String::as_str), Some(*id)); + } + }); + } + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/codexbar-app/src/self_capture.rs b/crates/codexbar-app/src/self_capture.rs index 216992cd..5444c5c3 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 gemini_status; mod gemini_weekly; +mod muse; mod sus; /// Starts the capture if `CODEXBAR_CAPTURE_DIR` is set and this is the demo. @@ -35,6 +36,7 @@ pub fn start(cx: &mut App, handle: AnyWindowHandle, dashboard: Entity let _ = std::fs::create_dir_all(&dir); if std::env::var_os("CODEXBAR_CAPTURE_ANTIGRAVITY").is_some() || std::env::var_os("CODEXBAR_CAPTURE_SUS").is_some() + || std::env::var_os("CODEXBAR_CAPTURE_MUSE").is_some() || std::env::var_os("CODEXBAR_CAPTURE_GEMINI_WEEKLY").is_some() || std::env::var_os("CODEXBAR_CAPTURE_GEMINI_STATUS").is_some() { @@ -187,7 +189,7 @@ async fn run( let _ = cx.update(|cx| { cx.update_window(handle, |_, window, cx| { window.render_frame(cx); - let last = if cfg!(windows) { 6 } else { 5 }; + let last = if cfg!(any(windows, feature = "capture")) { 6 } else { 5 }; window.within("settings-sidebar").click(format!("0-{last}"), cx); }) }); @@ -207,6 +209,9 @@ async fn run( if std::env::var_os("CODEXBAR_CAPTURE_GEMINI_STATUS").is_some() { saved.extend(gemini_status::run(cx, handle, dashboard, dir).await); } + if std::env::var_os("CODEXBAR_CAPTURE_MUSE").is_some() { + saved.extend(muse::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/muse.rs b/crates/codexbar-app/src/self_capture/muse.rs new file mode 100644 index 00000000..3862f3e9 --- /dev/null +++ b/crates/codexbar-app/src/self_capture/muse.rs @@ -0,0 +1,108 @@ +//! Real macOS GPUI renders of the production Muse adapter over sanitized fixtures. +use super::{save, settle}; +use crate::{ + dashboard::{Dashboard, DashboardView}, + theme::Appearance, +}; +use gpui_kit::test::TestWindowExt as _; +use gpui_kit::{AnyWindowHandle, AppContext as _, AsyncApp, Entity}; +use std::path::Path; + +pub(super) async fn run( + cx: &mut AsyncApp, + handle: AnyWindowHandle, + dashboard: &Entity, + dir: &Path, +) -> Vec { + let mut saved = Vec::new(); + for state in [ + "measured", + "partial", + "exhausted", + "null", + "timeout", + "stale", + "reset-before", + "reset-after", + "cached-alert", + ] { + cx.update(|cx| { + crate::prefs_hub::PrefsHub::set_appearance(cx, Appearance::Light); + dashboard.update(cx, |d, cx| d.capture_muse(state, cx)); + }); + settle(cx).await; + cx.update(|cx| { + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + assert!(w.try_find("muse-quota-note").is_some()); + assert!(w.try_find("progress-cell-muse-demo-sus").is_none()); + assert!(w.try_find("focus-metric-1-sus").is_none()); + if state == "cached-alert" { + let alert = w.find("alert-strongest").label().unwrap().to_owned(); + assert!(alert.starts_with("Limit soon (projected): 5-hour window"), "{alert}"); + assert!( + alert.contains("resets in 1h 55m") && alert.contains("2m early"), + "{alert}" + ); + assert!( + w.find("at-reset-muse-demo") + .label() + .unwrap() + .contains("runs out in 1h 53m") + ); + } + if state == "reset-after" { + let note = w.find("muse-quota-note"); + assert!(note.label().unwrap().contains("5-hour: n/a")); + assert!(!note.label().unwrap().contains("Weekly: n/a")); + assert_eq!(w.find("used-muse-demo").label(), Some("Weekly: 5%")); + } + if state == "null" { + assert!(w.try_find("focus-card-allowance-unavailable").is_some()); + assert!(w.try_find("focus-card-no-limits").is_none()); + } + if matches!(state, "stale" | "timeout" | "null") { + assert!(w.try_find("state-muse-demo").is_some()); + } + }) + }) + .unwrap(); + let name = format!("muse-{state}-table-detail"); + assert!(save(cx, handle, dir, &name)); + saved.push(name); + // The same TileView consumer drives the packaged Windows card and this GPUI settings preview. + cx.update(|cx| dashboard.update(cx, |d, cx| d.show_view(DashboardView::Settings, cx))); + for entry in ["0-5", "0-5-3"] { + cx.update(|cx| { + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + w.within("settings-sidebar").click(entry, cx); + }) + }) + .unwrap(); + } + settle(cx).await; + let name = format!("muse-{state}-widget-preview"); + assert!(save(cx, handle, dir, &name)); + saved.push(name); + } + cx.update(|cx| dashboard.update(cx, |d, cx| d.show_view(DashboardView::Settings, cx))); + let ix = crate::catalog::PROVIDERS + .iter() + .position(|p| p.id == codexbar_store::settings::names::MUSE_CODE) + .unwrap(); + for entry in ["0-1".to_owned(), format!("0-1-{ix}")] { + cx.update(|cx| { + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + w.within("settings-sidebar").click(entry, cx); + }) + }) + .unwrap(); + } + settle(cx).await; + let name = "muse-settings"; + assert!(save(cx, handle, dir, name)); + saved.push(name.into()); + saved +} diff --git a/crates/codexbar-app/src/settings_hub.rs b/crates/codexbar-app/src/settings_hub.rs index 3d798c50..d82caf4c 100644 --- a/crates/codexbar-app/src/settings_hub.rs +++ b/crates/codexbar-app/src/settings_hub.rs @@ -10,6 +10,7 @@ use crate::catalog; pub struct SettingsHub { settings: Settings, + pub(crate) muse_cache: Arc, /// The folder settings were loaded from; a failed save reloads from here, never from `~/.codexbar` directly. dir: std::path::PathBuf, /// A schema problem found at load: the screen stays read-only so the file is never overwritten. @@ -64,6 +65,7 @@ impl SettingsHub { }); cx.set_global(Self { settings, + muse_cache: Arc::default(), dir: dir.to_owned(), load_error, credentials, diff --git a/crates/codexbar-app/src/settings_view.rs b/crates/codexbar-app/src/settings_view.rs index 6d4a8547..27b3fcbb 100644 --- a/crates/codexbar-app/src/settings_view.rs +++ b/crates/codexbar-app/src/settings_view.rs @@ -73,7 +73,7 @@ pub fn render(start: Option, generation: u32, _: &mut Window, cx: & crate::groups_page::groups_page(cx), appearance_page(), // Widgets exist only on the Windows 11 Widgets board (#94). - #[cfg(windows)] + #[cfg(any(windows, feature = "capture"))] crate::widget_builder::widgets_page(cx), about_page(), ]), @@ -346,14 +346,16 @@ fn account_item(record: AccountRecord) -> SettingItem { ) }) }) - .child( - Button::new(SharedString::from(format!("sus-price-{}", current.id))) - .small() - .ghost() - .label("SUS price…") - .disabled(read_only) - .on_click(move |_, window, cx| crate::sus_price::open(&price_id, window, cx)), - ) + .when(current.provider != names::MUSE_CODE, |this| { + this.child( + Button::new(SharedString::from(format!("sus-price-{}", current.id))) + .small() + .ghost() + .label("SUS price…") + .disabled(read_only) + .on_click(move |_, window, cx| crate::sus_price::open(&price_id, window, cx)), + ) + }) .when(saved_apps, |this| { this.child( Button::new(SharedString::from(format!("forget-apps-{}", current.id))) @@ -739,6 +741,28 @@ fn x_subscription_refusal( } } +/// Provider-specific restrictions apply before changing any account or secret. +fn account_refusal( + provider: codexbar_core::Provider, + method: AuthMethod, + editing: Option<&str>, + accounts: &[AccountRecord], +) -> Option<&'static str> { + match provider { + codexbar_core::Provider::MuseCode => muse_refusal(method, editing, accounts), + codexbar_core::Provider::XSubscription => x_subscription_refusal(method, editing, accounts), + _ => None, + } +} + +fn muse_refusal(method: AuthMethod, editing: Option<&str>, accounts: &[AccountRecord]) -> Option<&'static str> { + let duplicate = accounts + .iter() + .any(|other| other.provider == names::MUSE_CODE && Some(other.id.as_str()) != editing); + (method != AuthMethod::CommandLine || duplicate) + .then_some("Muse Code reads one existing CLI session. Choose Command line and keep one account.") +} + /// Under the secret field: where a saved secret goes, and what the environment variable does. When the variable is set /// right now, the user is told that it is in use until a secret is saved here (#186). Never the value. fn secret_hint(spec: &SecretSpec, source: &SecretSource) -> String { @@ -798,13 +822,12 @@ fn save_account(form: &AccountForm, existing: Option, window: &mu }, }; - if info.id == names::X_SUBSCRIPTION - && let Some(error) = x_subscription_refusal( - method, - existing.as_ref().map(|record| record.id.as_str()), - SettingsHub::global(cx).settings().accounts(), - ) - { + if let Some(error) = account_refusal( + info.provider, + method, + existing.as_ref().map(|record| record.id.as_str()), + SettingsHub::global(cx).settings().accounts(), + ) { *form.error.borrow_mut() = Some(error.into()); window.refresh(); return false; @@ -1122,7 +1145,7 @@ fn confirm_reset(window: &mut Window, cx: &mut App) { }); } -#[cfg(windows)] +#[cfg(any(windows, feature = "capture"))] pub(crate) fn info_item(title: &'static str, body: &'static str) -> SettingItem { SettingItem::render(move |_, _, cx| { v_flex() @@ -1465,6 +1488,33 @@ fn about_page() -> SettingPage { mod tests { use super::*; + #[test] + fn muse_account_validation_is_cli_only_single_session_and_allows_its_own_edit() { + use codexbar_core::Provider; + let first = AccountRecord::new(names::MUSE_CODE, "Demo", AuthMethod::CommandLine); + let other = AccountRecord::new(names::CODEX, "Demo", AuthMethod::Automatic); + assert_eq!( + account_refusal( + Provider::MuseCode, + AuthMethod::CommandLine, + None, + std::slice::from_ref(&other) + ), + None + ); + for method in AuthMethod::ALL { + assert_eq!( + account_refusal(Provider::MuseCode, method, None, &[]).is_none(), + method == AuthMethod::CommandLine + ); + } + let accounts = [other, first.clone()]; + assert!(account_refusal(Provider::MuseCode, AuthMethod::CommandLine, None, &accounts).is_some()); + assert!(account_refusal(Provider::MuseCode, AuthMethod::CommandLine, Some(&first.id), &accounts).is_none()); + assert!(account_refusal(Provider::Codex, AuthMethod::OAuth, None, &accounts).is_none()); + assert!(account_refusal(Provider::XSubscription, AuthMethod::CommandLine, None, &accounts).is_some()); + } + #[test] fn an_x_subscription_account_is_automatic_and_only_one() { let first = AccountRecord::new(names::X_SUBSCRIPTION, "Me", AuthMethod::Automatic); diff --git a/crates/codexbar-app/src/sus.rs b/crates/codexbar-app/src/sus.rs index 03c7177a..7a37fa2d 100644 --- a/crates/codexbar-app/src/sus.rs +++ b/crates/codexbar-app/src/sus.rs @@ -46,7 +46,7 @@ pub fn metric( id: &str, cx: &App, ) -> Option { - if !account.is_status_metric(metric) { + if !account.sus_eligible(metric, now) { return None; } metric_badge(metric, now, id, cx) diff --git a/crates/codexbar-app/src/ui_tests.rs b/crates/codexbar-app/src/ui_tests.rs index a334be7a..cdfc62da 100644 --- a/crates/codexbar-app/src/ui_tests.rs +++ b/crates/codexbar-app/src/ui_tests.rs @@ -8583,3 +8583,159 @@ fn saved_home_quota_shape_uses_models_for_status_and_widgets_only() { assert_eq!(models.sus, Some(25)); assert!(models.sus_eligible); } + +/// The same producer and fixed clocks used for native capture exercise the real table and detail renderers. +#[gpui_kit::test] +fn muse_allowance_states_render_in_both_table_layouts_without_invented_capacity(cx: &mut TestAppContext) { + let settings = TempSettings::new("muse-allowance-presentation", "{}"); + let (handle, dashboard) = open_dashboard(cx, &settings); + for wide in [false, true] { + gpui_kit::VisualTestContext::from_window(handle, cx) + .simulate_resize(size(px(if wide { 2600. } else { 1440. }), px(1000.))); + for state in [ + "measured", + "partial", + "exhausted", + "null", + "stale", + "timeout", + "reset-before", + "reset-after", + ] { + cx.update(|cx| dashboard.update(cx, |d, cx| d.capture_muse(state, cx))); + cx.run_until_parked(); + let note = label(cx, handle, "muse-quota-note").unwrap(); + assert!(note.contains("Billing mode and model scope unknown")); + assert!(!exists(cx, handle, "fleet-sus-100")); + let unavailable = matches!(state, "null"); + let last_known = matches!(state, "stale" | "timeout"); + let row_state = label(cx, handle, "state-muse-demo"); + assert_eq!( + row_state.as_ref().is_some_and(|s| s.contains("Stale")), + last_known, + "{state}: {row_state:?}" + ); + if !wide { + let used = label(cx, handle, "used-muse-demo").unwrap(); + assert!( + if unavailable { + used.contains("n/a") + } else { + used.contains('%') + }, + "{state}: {used}" + ); + let forecast = label(cx, handle, "at-reset-muse-demo"); + if state == "stale" { + assert!(forecast.unwrap().contains("forecast unavailable")); + } + if state == "reset-after" { + assert!(used.starts_with("Weekly: 5%"), "{used}"); + } + } + if state == "partial" || unavailable || state == "reset-after" { + assert!(note.contains("5-hour: n/a")); + } + if unavailable { + assert!(note.contains("Weekly: n/a")); + } + } + } +} + +#[gpui_kit::test] +fn muse_settings_add_edit_and_single_cli_adapter_need_no_credentials(cx: &mut TestAppContext) { + let settings = TempSettings::new("muse-settings-cli", "{}"); + let handle = open_settings_page(cx, &settings, MemoryCredentialStore::default(), 1); + cx.update_window(handle, |_, window, cx| { + window.within("settings-sidebar").click("0-1-13", cx) + }) + .unwrap(); + cx.run_until_parked(); + click(cx, handle, "add-MuseCode"); + type_text(cx, handle, "Synthetic Demo"); + click(cx, handle, "account-save"); + let saved = saved_settings(&settings); + let record = saved.accounts().iter().find(|a| a.provider == "MuseCode").unwrap(); + assert_eq!(record.method, codexbar_store::settings::AuthMethod::CommandLine); + assert_eq!(secret_of(cx, &record.id), None); + click(cx, handle, format!("edit-{}", record.id)); + type_text(cx, handle, "Renamed Demo"); + click(cx, handle, "account-save"); + assert_eq!(saved_settings(&settings).accounts()[0].label, "Renamed Demo"); + assert!(!exists(cx, handle, "account-save")); +} + +struct MuseParsedFixture { + stale: bool, +} +impl UsageProvider for MuseParsedFixture { + fn name(&self) -> &'static str { + "Muse Code" + } + fn account_id(&self) -> Option<&str> { + Some("muse-demo") + } + fn fetch(&self, now: chrono::DateTime) -> Result, ProviderError> { + let age = chrono::Duration::seconds(if self.stale { 480 } else { 60 }); + let observed = now - age; + let mut value: serde_json::Value = + serde_json::from_str(include_str!("../../codexbar-providers/fixtures/muse/measured.json")).unwrap(); + value["allowance"]["asOfMs"] = serde_json::json!(observed.timestamp_millis()); + value["allowance"]["window"]["resetsAtMs"] = + serde_json::json!((observed + chrono::Duration::hours(4)).timestamp_millis()); + value["allowance"]["weekly"]["resetsAtMs"] = + serde_json::json!((observed + chrono::Duration::days(6)).timestamp_millis()); + codexbar_providers::muse::parse(&value.to_string(), now, "muse-demo", "Synthetic Demo").map(|a| vec![a]) + } +} + +#[gpui_kit::test] +fn muse_source_age_reaches_live_refresh_state_and_saved_observation(cx: &mut TestAppContext) { + for stale in [false, true] { + let settings = TempSettings::new(if stale { "muse-live-old" } else { "muse-live-fresh" }, "{}"); + let dashboard = open_live_fixed(cx, &settings, vec![Arc::new(MuseParsedFixture { stale })]); + cx.run_until_parked(); + assert_eq!( + matches!(state(cx, &dashboard, "muse-demo"), AccountState::Failed(_)), + stale + ); + let saved = codexbar_store::snapshots::load_snapshots(&settings.0); + assert_eq!(saved.len(), 1); + assert_eq!( + (saved[0].fetched_at() - saved[0].observed_at().unwrap()).num_seconds(), + if stale { 480 } else { 60 } + ); + assert_eq!(saved[0].allowance_source(), Some("mint")); + } +} + +#[gpui_kit::test] +fn cached_muse_alert_context_matches_source_assessment_but_reset_countdown_uses_wall_time(cx: &mut TestAppContext) { + let settings = TempSettings::new("muse-cached-alert-clock", "{}"); + let (handle, dashboard) = open_dashboard(cx, &settings); + cx.update(|cx| dashboard.update(cx, |d, cx| d.capture_muse("cached-alert", cx))); + cx.run_until_parked(); + let alert = label(cx, handle, "alert-strongest").unwrap(); + assert!(alert.starts_with("Limit soon (projected): 5-hour window."), "{alert}"); + assert!(alert.contains("resets in 1h 55m"), "{alert}"); + assert!(alert.contains("2m early"), "{alert}"); + assert!(!alert.contains("99%"), "{alert}"); + assert!( + label(cx, handle, "projected-status") + .unwrap() + .contains("observed usage alone is Normal") + ); + let reset = label(cx, handle, "at-reset-muse-demo").unwrap(); + assert!( + reset.contains("runs out in 1h 53m"), + "relative forecast must count from wall time: {reset}" + ); + assert!(exists(cx, handle, "focus-history-card-takeaway")); + let account = cx.update(|cx| dashboard.read(cx).account("muse-demo").unwrap().clone()); + assert_eq!(account.observed_at(), Some(crate::muse_fixture::base())); + assert_eq!( + account.fetched_at(), + crate::muse_fixture::base() + chrono::Duration::minutes(1) + ); +} diff --git a/crates/codexbar-app/src/usage_bar.rs b/crates/codexbar-app/src/usage_bar.rs index 5d07649b..18fa6b13 100644 --- a/crates/codexbar-app/src/usage_bar.rs +++ b/crates/codexbar-app/src/usage_bar.rs @@ -190,6 +190,17 @@ pub struct Forecast { } impl Forecast { + pub fn for_account(metric: &Metric, account: &codexbar_core::AccountSnapshot, now: DateTime) -> Option { + let at = account.evaluation_time(metric, now)?; + Self::of(metric, at).map(|mut forecast| { + // The projection belongs to the source reading. Only its relative countdown advances with wall time. + forecast.runs_out_in = forecast + .runs_out_in + .map(|left| (left - (now - at)).max(Duration::zero())); + forecast + }) + } + /// The forecast for `metric` at `now`, when its pace allows one. pub fn of(metric: &Metric, now: DateTime) -> Option { let projected = metric.projected_at_reset(now)?; @@ -254,16 +265,68 @@ fn used_up(metric: &Metric) -> bool { ) && metric.used_fraction().is_some_and(|used| used >= 1.0) } -/// What `account`'s `metric` says about its reset (#234), in the words every surface uses (the focus card's tiles and -/// bar, the widgets): the forecast ("112% at reset · runs out in 5d 4h"), "covered by credits" for a used-up window -/// the account's credits carry (#150), "limit reached" for a used-up limit with no pace left, or nothing. -pub fn outlook(metric: &Metric, account: &codexbar_core::AccountSnapshot, now: DateTime) -> Option { - if account.credits_cover(metric) { - return Some(COVERED.to_owned()); +/// One reset presentation policy for the table, ring, bar and widget feed. Current validity is checked +/// before credit coverage or forecast wording; last-good values never acquire a current forecast. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ResetDisplay { + Unavailable, + Covered, + Forecast(Forecast), + Reached, + NoForecast, + NotApplicable, +} + +impl ResetDisplay { + pub fn of(metric: &Metric, account: &codexbar_core::AccountSnapshot, now: DateTime) -> Self { + let Some(_) = account.evaluation_time(metric, now) else { + return Self::Unavailable; + }; + if account.credits_cover(metric) { + return Self::Covered; + } + if let Some(forecast) = Forecast::for_account(metric, account, now) { + return Self::Forecast(forecast); + } + if used_up(metric) { + return Self::Reached; + } + let limited = matches!( + metric, + Metric::Window { .. } | Metric::Quota { .. } | Metric::Spend { limit: Some(_), .. } + ) && metric.resets_at().is_some(); + if limited { Self::NoForecast } else { Self::NotApplicable } + } + + pub fn outlook(self) -> Option { + match self { + Self::Covered => Some(COVERED.to_owned()), + Self::Forecast(forecast) => Some(forecast.text()), + Self::Reached => Some(REACHED.to_owned()), + _ => None, + } + } + + pub fn ring_text(self) -> Option { + match self { + Self::Forecast(forecast) => Some(format!("{} at reset", forecast.share())), + other => other.outlook(), + } } - Forecast::of(metric, now) - .map(|forecast| forecast.text()) - .or_else(|| used_up(metric).then(|| REACHED.to_owned())) + + pub fn plain_text(self) -> &'static str { + match self { + Self::Unavailable => "n/a", + Self::Covered => COVERED, + Self::Reached => REACHED, + Self::NoForecast => "no forecast", + Self::NotApplicable | Self::Forecast(_) => "—", + } + } +} + +pub fn outlook(metric: &Metric, account: &codexbar_core::AccountSnapshot, now: DateTime) -> Option { + ResetDisplay::of(metric, account, now).outlook() } /// What a bar says about its metric: the share used, the share projected by the reset when the pace allows one, and @@ -290,6 +353,11 @@ impl Projection { /// What `metric` shows at `now`, with `used` as the bar's own share (a credits bar runs on the spend since the /// remembered start, not the metric's used fraction, so the caller says what the fill is). pub fn of(metric: &Metric, used: f64, now: DateTime) -> Self { + Self::with_forecast(metric, used, now, Forecast::of(metric, now)) + } + + /// Render a validated forecast with wall-clock reset/countdown words. The quota share need not be resampled. + fn with_forecast(metric: &Metric, used: f64, now: DateTime, forecast: Option) -> Self { let share = format!("{:.0}% used", used * 100.0); let resets = metric .resets_at() @@ -300,7 +368,6 @@ impl Projection { }; // The share at the reset and when the limit runs out (#234), with how early that is (#206) and the pace. The // bar draws the same share the words say, so a limit hit at the reset is a full bar, not an overrun. - let forecast = Forecast::of(metric, now); let projected = forecast.map(|forecast| forecast.at_reset); let outlook = match forecast { Some(forecast) => { @@ -389,7 +456,19 @@ impl Projection { account: &codexbar_core::AccountSnapshot, now: DateTime, ) -> Self { - let projection = status_projection(Self::of(metric, used, now), account, metric); + let Some(_) = account.evaluation_time(metric, now) else { + let mut projection = Self::of(metric, used, now); + projection.projected = None; + projection.runway = Runway::Ok; + projection.label = format!("{:.0}% used, last known", used * 100.0); + projection.tooltip = projection.label.clone(); + return projection; + }; + let projection = status_projection( + Self::with_forecast(metric, used, now, Forecast::for_account(metric, account, now)), + account, + metric, + ); if account.credits_cover(metric) { projection.calm() } else { @@ -608,6 +687,73 @@ mod tests { } /// The scale (#234): 0 to 125% of the limit, so the limit sits at 0.8 of the bar and 125% at its end. + #[test] + fn cached_forecast_countdowns_advance_without_changing_source_projection_or_early_gap() { + use codexbar_core::{AccountId, AccountSnapshot, Provider, layout::Layout}; + let account = crate::muse_fixture::cached_alert(); + let at = crate::muse_fixture::base(); + let wall = at + Duration::minutes(5); + let short = &account.metrics()[0]; + let original = Forecast::for_account(short, &account, at).unwrap(); + let current = Forecast::for_account(short, &account, wall).unwrap(); + assert_eq!(original.at_reset, current.at_reset); + assert_eq!(original.early, current.early, "an early-before-reset gap does not age"); + assert_eq!(at + original.runs_out_in.unwrap(), wall + current.runs_out_in.unwrap()); + assert!(current.text().contains("runs out in 1h 53m")); + let projection = Projection::of_account(short, short.used_fraction().unwrap(), &account, wall); + assert_eq!(projection.projected, Some(original.at_reset)); + assert!(projection.label.contains("runs out in 1h 53m")); + assert!(projection.tooltip.contains("resets in 1h 55m")); + assert!(projection.tooltip.contains("2m early")); + assert!(!projection.tooltip.contains("1h 58m")); + let feed = crate::widget_feed::build( + std::slice::from_ref(&account), + &Default::default(), + &Layout::default(), + wall, + ); + let rendered = crate::widgets::cards::render( + &Ok(feed), + &Default::default(), + crate::widgets::cards::Size::Medium, + wall, + ) + .to_string(); + assert!(rendered.contains("runs out in 1h 53m")); + assert!(!rendered.contains("runs out in 1h 58m")); + let mut near = short.clone(); + if let Metric::Window { used, pace, .. } = &mut near { + *used = 0.999; + *pace = Some(Pace::per_hour(0.1)); + } + let near_account = AccountSnapshot::new(AccountId::new("near"), Provider::MuseCode, vec![near.clone()], at) + .with_observation(at, Some("mint".into()), 420); + let crossed = Forecast::for_account(&near, &near_account, wall).unwrap(); + assert_eq!(crossed.runs_out_in, Some(Duration::zero())); + assert!(crossed.text().contains("at the limit now")); + let legacy = AccountSnapshot::new( + AccountId::new("legacy"), + Provider::Claude, + account.metrics().to_vec(), + at, + ); + assert_eq!(Forecast::for_account(short, &legacy, wall), Forecast::of(short, wall)); + assert!(Forecast::for_account(short, &account, at + Duration::seconds(421)).is_none()); + let mut ended = short.clone(); + if let Metric::Window { resets_at, .. } = &mut ended { + *resets_at = at + Duration::minutes(4); + } + let partial = AccountSnapshot::new( + AccountId::new("partial"), + Provider::MuseCode, + vec![ended.clone(), account.metrics()[1].clone()], + at, + ) + .with_observation(at, Some("mint".into()), 420); + assert!(Forecast::for_account(&ended, &partial, wall).is_none()); + assert!(Forecast::for_account(&partial.metrics()[1], &partial, wall).is_some()); + } + #[test] fn every_bar_shares_one_scale_with_the_limit_at_four_fifths() { assert!(close(position(0.0), 0.0)); diff --git a/crates/codexbar-app/src/widget_builder.rs b/crates/codexbar-app/src/widget_builder.rs index 72b1ba52..018a4e7e 100644 --- a/crates/codexbar-app/src/widget_builder.rs +++ b/crates/codexbar-app/src/widget_builder.rs @@ -2,7 +2,6 @@ //! mode, the widgets' refresh choice, a preview of the automatic, one-, two- and four-tile layouts, and a reset. //! Widgets on the Widgets board show these tiles when their Customize widget is set to My tiles. -use chrono::Utc; use codexbar_store::widgets::{MAX_TILES, TileMode, WidgetBuilder, WidgetRefresh, WidgetSnapshot, WidgetTile}; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants as _}; use gpui_kit::component::dialog::DialogButtonProps; @@ -180,11 +179,13 @@ fn refresh_item() -> SettingItem { } fn status_color(status: Option<&str>, cx: &App) -> Hsla { - match status { - Some("At risk" | "Limit soon") => cx.theme().danger, - Some(_) => cx.theme().warning, - None => cx.theme().success, - } + let palette = [ + cx.theme().success, + cx.theme().warning, + cx.theme().danger, + cx.theme().muted_foreground, + ]; + palette[cards::StatusTone::of(status) as usize] } fn preview_tile(ix: usize, view: &TileView, compact: bool, cx: &App) -> impl IntoElement + use<> { @@ -253,7 +254,7 @@ fn preview_widget(size: Size, tiles: Tiles, snapshot: &WidgetSnapshot, cx: &App) focus: Focus::Custom, tiles, }; - let views = cards::tile_views(snapshot, &config, size, Utc::now()).unwrap_or_default(); + let views = cards::tile_views(snapshot, &config, size, crate::sus::now(cx)).unwrap_or_default(); let (width, name, ix) = match size { Size::Small => (px(170.), "Small", 0usize), Size::Medium => (px(340.), "Medium", 1), @@ -390,13 +391,15 @@ fn confirm_reset(window: &mut Window, cx: &mut App) { }); } -#[cfg(windows)] +#[cfg(any(windows, feature = "capture"))] pub fn widgets_page(cx: &App) -> SettingPage { let builder = PrefsHub::widget_builder(cx); let snapshot = cx.try_global::().map(|feed| &feed.0); // Windows widgets come only from packaged apps (#94), on Windows 11, and for a self-signed package only with // Developer Mode on. The builder itself works everywhere. + #[cfg(windows)] use crate::widgets::BoardSupport; + #[cfg(windows)] let (title, body): (&'static str, &'static str) = if crate::package::installed().is_none() { ( "Install the package to use widgets", @@ -422,6 +425,11 @@ pub fn widgets_page(cx: &App) -> SettingPage { ), } }; + #[cfg(not(windows))] + let (title, body) = ( + "Widget consumer preview", + "Sanitized macOS GPUI capture of the shared widget reader. Native Windows Widgets board rendering is deferred.", + ); let info = crate::settings_view::info_item(title, body); let mut tiles = SettingGroup::new() .title("My tiles") diff --git a/crates/codexbar-app/src/widget_feed.rs b/crates/codexbar-app/src/widget_feed.rs index 4e93dcfb..1d44a59c 100644 --- a/crates/codexbar-app/src/widget_feed.rs +++ b/crates/codexbar-app/src/widget_feed.rs @@ -31,9 +31,19 @@ pub fn build(accounts: &[AccountSnapshot], states: &States, layout: &Layout, now Some(AccountState::Restored | AccountState::Failed(_)) => WidgetHealth::Stale, Some(AccountState::Loading | AccountState::Unavailable(_)) => WidgetHealth::Unavailable, }; + let health = if account.provider() == codexbar_core::Provider::MuseCode && account.metrics().is_empty() { + WidgetHealth::Unavailable + } else if health == WidgetHealth::Fresh && account.observation_stale(now) { + WidgetHealth::Stale + } else { + health + }; let has_usage = health != WidgetHealth::Unavailable; WidgetAccount { id: id.to_owned(), + observed_at: account.observed_at(), + allowance_source: account.allowance_source().map(str::to_owned), + fresh_for_seconds: account.fresh_for_seconds(), provider: account.provider().key().to_owned(), provider_name: account.provider().display_name().to_owned(), name: account.display_name(), @@ -54,6 +64,18 @@ pub fn build(accounts: &[AccountSnapshot], states: &States, layout: &Layout, now WidgetSnapshot::new(now, groups, accounts) } +/// The feed's status vocabulary differs from dashboard pills for exhausted quotas, which have no pill. +fn metric_status(account: &AccountSnapshot, metric: &Metric, now: DateTime) -> Option { + let assessment = account.assess_metric(metric, now); + // Exhaustion deliberately has no dashboard pill. Widgets still need an explicit status so their + // normal-color fallback cannot advertise an exhausted, uncovered quota as healthy. + if account.is_status_metric(metric) && !account.credits_cover(metric) && assessment.is_exhausted() { + Some("Limit reached".to_owned()) + } else { + assessment.label().map(str::to_owned) + } +} + fn metric(account: &AccountSnapshot, metric: &Metric, now: DateTime) -> WidgetMetric { let value = match metric { Metric::Window { .. } => format!("{} used", metric.used_display()), @@ -77,16 +99,19 @@ fn metric(account: &AccountSnapshot, metric: &Metric, now: DateTime) -> Wid value, used_percent: metric.used_fraction().map(|used| (used * 100.0).clamp(0.0, 100.0)), resets_at: metric.resets_at(), - status: account.assess_metric(metric, now).label().map(str::to_owned), + status: metric_status(account, metric, now), // Past 100 when the limit runs out first (#197); the tile says the figure, never a bar beyond its end. - projected_percent: metric.projected_at_reset(now).map(|share| (share * 100.0).max(0.0)), + projected_percent: account + .evaluation_time(metric, now) + .and_then(|at| metric.projected_at_reset(at)) + .map(|share| (share * 100.0).max(0.0)), // The share at the reset and when the limit runs out (#234), worded as the dashboard's bars word it. outlook: crate::usage_bar::outlook(metric, account, now), sus: codexbar_core::sus::weekly(metric, now) - .filter(|_| account.is_status_metric(metric)) + .filter(|_| account.sus_eligible(metric, now)) .map(|s| s.value()), sus_eligible: codexbar_core::sus::text(metric, now) - .filter(|_| account.is_status_metric(metric)) + .filter(|_| account.sus_eligible(metric, now)) .is_some(), } } diff --git a/crates/codexbar-app/src/widgets/cards.rs b/crates/codexbar-app/src/widgets/cards.rs index 6164d311..252139ce 100644 --- a/crates/codexbar-app/src/widgets/cards.rs +++ b/crates/codexbar-app/src/widgets/cards.rs @@ -203,6 +203,67 @@ fn focused<'a>(focus: &Focus, snapshot: &'a WidgetSnapshot) -> Vec<&'a WidgetAcc .collect() } +/// Resolve a tile's selection before choosing its visual mode. An expired Muse window is a missing +/// current allowance, while valid siblings and all legacy provider reset semantics remain independent. +fn tile_reading<'a>( + account: &'a WidgetAccount, + metric: Option<&str>, + now: DateTime, +) -> Result<&'a codexbar_store::widgets::WidgetMetric, (&'static str, Option)> { + if account.health == WidgetHealth::Unavailable { + return Err(("Unavailable", metric.map(str::to_owned))); + } + let found = match metric { + Some(key) => account.metrics.iter().find(|m| m.key == key), + None => account.metrics.first(), + }; + let Some(found) = found else { + let note = if account.provider == "muse-code" { + "n/a · allowance unavailable for this window" + } else if metric.is_some() { + "This limit isn't reported any more. Choose another in CodexBar's Settings › Widgets." + } else { + "No limits to show" + }; + return Err((note, None)); + }; + if account.provider == "muse-code" && found.resets_at.is_some_and(|at| at <= now) { + return Err(("n/a · allowance window reset; refresh needed", Some(found.key.clone()))); + } + Ok(found) +} + +/// Apply source age even when a file's transport health still says Fresh. +fn health_at(account: &WidgetAccount, now: DateTime) -> WidgetHealth { + let old_observation = account + .observed_at + .zip(account.fresh_for_seconds) + .is_some_and(|(at, seconds)| { + seconds <= 0 + || now + .signed_duration_since(account.updated_at.map_or(at, |fetched| at.min(fetched))) + .num_milliseconds() + > seconds.saturating_mul(1000) + }); + if old_observation { + WidgetHealth::Stale + } else { + account.health + } +} + +/// Forecast words are current-only; last-good usage keeps its actual numeric reading. +fn metric_line(found: &codexbar_store::widgets::WidgetMetric, current: bool) -> String { + match ( + if current { &found.outlook } else { &None }, + if current { found.projected_percent } else { None }, + ) { + (Some(outlook), _) => format!("{}: {}, {outlook}", found.label, found.value), + (None, Some(projected)) => format!("{}: {}, {projected:.0}% at reset", found.label, found.value), + (None, None) => format!("{}: {}", found.label, found.value), + } +} + /// What one tile shows, worked out once for the widget card and the in-app preview (#95). #[derive(Clone, Debug, PartialEq)] pub struct TileView { @@ -244,38 +305,30 @@ impl TileView { /// The tile for `account`'s `metric` (its primary one when `None`), shown as `mode`. fn of(account: &WidgetAccount, metric: Option<&str>, mode: TileMode, now: DateTime) -> Self { - if account.health == WidgetHealth::Unavailable { - // The tap still opens the tile's metric, for when the account recovers. - let mut view = Self::message(Some(&account.id), account.name.clone(), mode, "Unavailable"); - view.metric = metric.map(str::to_owned); - return view; - } - let found = match metric { - Some(key) => account.metrics.iter().find(|m| m.key == key), - None => account.metrics.first(), - }; - let Some(found) = found else { - let note = if metric.is_some() { - "This limit isn't reported any more. Choose another in CodexBar's Settings › Widgets." - } else { - "No limits to show" - }; - return Self::message(Some(&account.id), account.name.clone(), mode, note); + let found = match tile_reading(account, metric, now) { + Ok(found) => found, + Err((note, key)) => { + let mut view = Self::message(Some(&account.id), account.name.clone(), mode, note); + view.metric = key; + return view; + } }; - let freshness = match (account.health, account.updated_at) { + let health = health_at(account, now); + let freshness = match (health, account.observed_at.or(account.updated_at)) { (WidgetHealth::Stale, Some(at)) => Some(format!("Last known, {}", age_label(at, now))), (WidgetHealth::Stale, None) => Some("Last known".to_owned()), _ => None, }; // The tile's own metric decides its status, not the account's most urgent one. - let status = found.status.clone(); + let current = account.provider != "muse-code" || health == WidgetHealth::Fresh; + let status = if current { + found.status.clone() + } else { + Some("Stale".to_owned()) + }; // Adaptive Cards can't fade a bar, so the projection rides on the line in words (#197, #206). A snapshot // written before the wording travelled with it still carries the figure, so the widget keeps it. - let line = Some(match (&found.outlook, found.projected_percent) { - (Some(outlook), _) => format!("{}: {}, {outlook}", found.label, found.value), - (None, Some(projected)) => format!("{}: {}, {projected:.0}% at reset", found.label, found.value), - (None, None) => format!("{}: {}", found.label, found.value), - }); + let line = Some(metric_line(found, current)); let percent_text = found.used_percent.map(|p| format!("{p:.0}%")); let resets = found .resets_at @@ -361,13 +414,36 @@ pub fn tile_views(snapshot: &WidgetSnapshot, config: &Config, size: Size, now: D Some(views) } +/// One portable status palette for the real Adaptive Card and the native preview. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StatusTone { + Good, + Warning, + Attention, + Neutral, +} + +impl StatusTone { + pub fn of(status: Option<&str>) -> Self { + match status { + Some("At risk" | "Limit soon" | "Limit reached") => Self::Attention, + Some("Stale") => Self::Neutral, + Some(_) => Self::Warning, + None => Self::Good, + } + } + + fn card_color(self) -> &'static str { + ["Good", "Warning", "Attention", "Default"][self as usize] + } + fn bar_style(self) -> &'static str { + ["good", "warning", "attention", "default"][self as usize] + } +} + /// A usage bar: two weighted columns, used and left. The color follows the account's status. fn bar(percent: f64, status: Option<&str>) -> Value { - let style = match status { - Some("At risk" | "Limit soon") => "attention", - Some(_) => "warning", - None => "good", - }; + let style = StatusTone::of(status).bar_style(); let used = percent.round().clamp(0.0, 100.0) as u32; let mut columns = Vec::new(); if used > 0 { @@ -383,11 +459,7 @@ fn bar(percent: f64, status: Option<&str>) -> Value { /// The Adaptive Cards color for a status. fn status_color(status: Option<&str>) -> &'static str { - match status { - Some("At risk" | "Limit soon") => "Attention", - Some(_) => "Warning", - None => "Good", - } + StatusTone::of(status).card_color() } /// One tile as a card container. Tapping it opens CodexBar on its account and metric. @@ -603,6 +675,9 @@ mod tests { fn account(id: &str, provider: &str, group: Option<&str>, percent: f64) -> WidgetAccount { WidgetAccount { id: id.into(), + observed_at: None, + allowance_source: None, + fresh_for_seconds: None, provider: provider.into(), provider_name: provider.to_uppercase(), name: format!("{provider} {id}"), diff --git a/crates/codexbar-core/src/account.rs b/crates/codexbar-core/src/account.rs index 2d46cfc1..e5edd3b9 100644 --- a/crates/codexbar-core/src/account.rs +++ b/crates/codexbar-core/src/account.rs @@ -20,28 +20,98 @@ pub enum Provider { Gemini, Grok, XSubscription, + MuseCode, } +struct ProviderMetadata { + provider: Provider, + key: &'static str, + display: &'static str, +} + +// Identity and wording are data. Adding a provider does not grow several dispatch functions. +const PROVIDERS: [ProviderMetadata; 13] = [ + ProviderMetadata { + provider: Provider::Codex, + key: "codex", + display: "ChatGPT · Codex", + }, + ProviderMetadata { + provider: Provider::Claude, + key: "claude", + display: "Claude", + }, + ProviderMetadata { + provider: Provider::Copilot, + key: "copilot", + display: "Copilot", + }, + ProviderMetadata { + provider: Provider::Cursor, + key: "cursor", + display: "Cursor", + }, + ProviderMetadata { + provider: Provider::OpenRouter, + key: "openrouter", + display: "OpenRouter", + }, + ProviderMetadata { + provider: Provider::OpenCode, + key: "opencode", + display: "OpenCode Go + Zen", + }, + ProviderMetadata { + provider: Provider::Moonshot, + key: "moonshot", + display: "Moonshot (Kimi)", + }, + ProviderMetadata { + provider: Provider::Greptile, + key: "greptile", + display: "Greptile", + }, + ProviderMetadata { + provider: Provider::GitHubBilling, + key: "github-billing", + display: "GitHub Billing", + }, + ProviderMetadata { + provider: Provider::Gemini, + key: "gemini", + display: "Gemini", + }, + ProviderMetadata { + provider: Provider::Grok, + key: "grok", + display: "Grok", + }, + ProviderMetadata { + provider: Provider::XSubscription, + key: "x-subscription", + display: "X Subscription", + }, + ProviderMetadata { + provider: Provider::MuseCode, + key: "muse-code", + display: "Muse Code", + }, +]; + impl Provider { /// The product name shown before the account label. pub fn display_name(self) -> &'static str { - match self { - Self::Codex => "ChatGPT · Codex", - Self::Claude => "Claude", - Self::Copilot => "Copilot", - Self::Cursor => "Cursor", - Self::OpenRouter => "OpenRouter", - Self::OpenCode => "OpenCode Go + Zen", - Self::Moonshot => "Moonshot (Kimi)", - Self::Greptile => "Greptile", - Self::GitHubBilling => "GitHub Billing", - Self::Gemini => "Gemini", - Self::Grok => "Grok", - Self::XSubscription => "X Subscription", - } + self.metadata().display + } + + fn metadata(self) -> &'static ProviderMetadata { + PROVIDERS + .iter() + .find(|entry| entry.provider == self) + .expect("provider metadata") } - pub const ALL: [Self; 12] = [ + pub const ALL: [Self; 13] = [ Self::Codex, Self::Claude, Self::Copilot, @@ -54,24 +124,12 @@ impl Provider { Self::Gemini, Self::Grok, Self::XSubscription, + Self::MuseCode, ]; /// A stable key for storage; unlike the display name it never changes with wording. pub fn key(self) -> &'static str { - match self { - Self::Codex => "codex", - Self::Claude => "claude", - Self::Copilot => "copilot", - Self::Cursor => "cursor", - Self::OpenRouter => "openrouter", - Self::OpenCode => "opencode", - Self::Moonshot => "moonshot", - Self::Greptile => "greptile", - Self::GitHubBilling => "github-billing", - Self::Gemini => "gemini", - Self::Grok => "grok", - Self::XSubscription => "x-subscription", - } + self.metadata().key } pub fn from_key(key: &str) -> Option { @@ -186,6 +244,14 @@ struct Subscription { price: Option, } +/// Source observation metadata, separate from the time the adapter collected it. +#[derive(Clone, Debug, PartialEq)] +struct Observation { + at: DateTime, + source: Option, + fresh_for_seconds: i64, +} + /// The latest known state of one account. #[derive(Clone, Debug, PartialEq)] pub struct AccountSnapshot { @@ -199,6 +265,7 @@ pub struct AccountSnapshot { fetched_at: DateTime, /// What the provider said about the account besides numbers ("Usage is delayed by up to an hour"). messages: Vec, + observation: Option>, } impl AccountSnapshot { @@ -214,6 +281,7 @@ impl AccountSnapshot { detail: AccountDetail::default(), fetched_at, messages: Vec::new(), + observation: None, } } @@ -223,6 +291,42 @@ impl AccountSnapshot { self } + pub fn with_observation(mut self, at: DateTime, source: Option, fresh_for_seconds: i64) -> Self { + self.observation = Some(Box::new(Observation { + at, + source, + fresh_for_seconds, + })); + self + } + pub fn observed_at(&self) -> Option> { + self.observation.as_ref().map(|o| o.at) + } + pub fn allowance_source(&self) -> Option<&str> { + self.observation.as_ref()?.source.as_deref() + } + pub fn fresh_for_seconds(&self) -> Option { + self.observation.as_ref().map(|o| o.fresh_for_seconds) + } + pub fn observation_stale(&self, now: DateTime) -> bool { + self.observation.as_ref().is_some_and(|o| { + (now - o.at.min(self.fetched_at)).num_milliseconds() > o.fresh_for_seconds.saturating_mul(1000) + }) + } + /// Muse's last-seen window stops describing current allowance at its actual reset, even while + /// the account observation is still fresh. Other providers keep their existing reset semantics. + pub fn metric_ended(&self, metric: &Metric, now: DateTime) -> bool { + self.provider == Provider::MuseCode && metric.resets_at().is_some_and(|reset| reset <= now) + } + /// Check wall-clock validity first; only then anchor decisions to the source observation. + /// Kept per metric so an ended short window does not hide a valid weekly sibling. + pub fn evaluation_time(&self, metric: &Metric, now: DateTime) -> Option> { + (!self.observation_stale(now) && !self.metric_ended(metric, now)).then(|| self.observed_at().unwrap_or(now)) + } + pub fn supports_sus(&self) -> bool { + self.provider != Provider::MuseCode + } + pub fn messages(&self) -> &[String] { &self.messages } @@ -335,13 +439,22 @@ impl AccountSnapshot { self.metrics.iter().filter(|metric| self.is_status_metric(metric)) } + /// Fresh observations whose alert keys may be reconciled. Ended windows remain here so old-window + /// keys can recover; `evaluation_time` independently prevents them from triggering current alerts. + pub fn fresh_status_metrics(&self, now: DateTime) -> impl Iterator { + self.status_metrics().filter(move |_| !self.observation_stale(now)) + } + + /// SUS requires a supported quota and a current source observation. + pub fn sus_eligible(&self, metric: &Metric, now: DateTime) -> bool { + self.supports_sus() && self.is_status_metric(metric) && self.evaluation_time(metric, now).is_some() + } + /// Information-only pools never color a status or raise risk, even when selected explicitly in a card. pub fn assess_metric(&self, metric: &Metric, now: DateTime) -> Assessment { - if self.is_status_metric(metric) { - assess(metric, now) - } else { - Assessment::default() - } + self.evaluation_time(metric, now) + .filter(|_| self.is_status_metric(metric)) + .map_or_else(Assessment::default, |at| assess(metric, at)) } pub fn primary(&self) -> Option<&Metric> { @@ -366,7 +479,7 @@ impl AccountSnapshot { pub fn assess(&self, now: DateTime) -> Assessment { self.status_metrics() .filter(|metric| !self.credits_cover(metric)) - .map(|metric| assess(metric, now)) + .map(|metric| self.assess_metric(metric, now)) .max() .unwrap_or_default() } diff --git a/crates/codexbar-core/src/alerts.rs b/crates/codexbar-core/src/alerts.rs index 9e58be70..afe4ea16 100644 --- a/crates/codexbar-core/src/alerts.rs +++ b/crates/codexbar-core/src/alerts.rs @@ -9,7 +9,7 @@ use std::collections::BTreeSet; use chrono::{DateTime, Utc}; -use crate::{AccountSnapshot, Currency, Metric, Severity, assess}; +use crate::{AccountSnapshot, Assessment, Currency, Metric, Severity, assess}; /// A usage alert recovers once usage falls this far below the threshold, so a value hovering at the line doesn't /// notify on every refresh. @@ -155,7 +155,7 @@ pub fn evaluate( for account in refreshed { let id = account.id().as_str(); out.recovered.extend(informational_alert_keys(account, active)); - for metric in account.status_metrics() { + for metric in account.fresh_status_metrics(now) { let first_new = out.notify.len(); let current = metric_slot(metric); // An alert already held for this window keeps its slot, so a reset estimate that drifts by a few seconds @@ -309,7 +309,12 @@ pub fn account_alerts( } let id = account.id().as_str(); let mut found: Vec<(usize, f64, AlertDetail)> = Vec::new(); - for (ix, metric) in account.metrics().iter().enumerate() { + for (ix, evaluated) in account + .fresh_status_metrics(now) + .filter_map(|metric| AlertMetric::current(account, metric, now)) + .enumerate() + { + let metric = evaluated.metric; let current = metric_slot(metric); let mut kinds: Vec = Vec::new(); for kind in [ @@ -341,15 +346,14 @@ pub fn account_alerts( continue; } kinds.push(detail( - metric, + &evaluated, kind, alert_key(id, ¤t, kind), settings, notified, - now, )); } - let pressure = assess(metric, now).pressure(); + let pressure = evaluated.assessment.pressure(); found.extend(kinds.into_iter().map(|detail| (ix, pressure, detail))); } found.sort_by(|(ix_a, pressure_a, a), (ix_b, pressure_b, b)| { @@ -363,15 +367,33 @@ pub fn account_alerts( found.into_iter().map(|(_, _, detail)| detail).collect() } +/// An alert's explanation and ordering share the account's validated source clock and assessment. +/// Construction checks actual-now freshness/reset validity before any source-anchored projection. +struct AlertMetric<'a> { + metric: &'a Metric, + at: DateTime, + assessment: Assessment, +} + +impl<'a> AlertMetric<'a> { + fn current(account: &AccountSnapshot, metric: &'a Metric, now: DateTime) -> Option { + Some(Self { + metric, + at: account.evaluation_time(metric, now)?, + assessment: account.assess_metric(metric, now), + }) + } +} + fn detail( - metric: &Metric, + evaluated: &AlertMetric<'_>, kind: AlertKind, key: String, settings: &AlertSettings, notified: bool, - now: DateTime, ) -> AlertDetail { - let severity = assess(metric, now).severity(); + let metric = evaluated.metric; + let severity = evaluated.assessment.severity(); let observed = crate::observed_severity(metric); // The reason follows what actually raised the severity: the pace, or usage already near the limit. let threshold = match kind { @@ -390,7 +412,7 @@ fn detail( value: metric.used_display(), threshold, resets_at: metric.resets_at(), - outlook: metric.outlook(now), + outlook: metric.outlook(evaluated.at), severity, observed, notified, @@ -418,7 +440,10 @@ fn account_condition( if !account.is_status_metric(metric) { return Condition::Clear; } - credit_condition(settings, account, metric, kind, now) + let Some(at) = account.evaluation_time(metric, now) else { + return Condition::NotApplicable; + }; + credit_condition(settings, account, metric, kind, at) } fn credit_condition( @@ -1213,6 +1238,150 @@ mod detail_tests { .collect() } + fn cached_muse() -> AccountSnapshot { + let mut short = metric("5-hour window", 0.605, Duration::hours(2), Some(0.605 / 3.0)); + if let Metric::Window { length, .. } = &mut short { + *length = Some(Duration::hours(5)); + } + let mut weekly = metric("Weekly", 0.60, Duration::days(3), Some(0.60 / 96.0)); + if let Metric::Window { length, .. } = &mut weekly { + *length = Some(Duration::days(7)); + } + AccountSnapshot::new( + AccountId::new("muse-cached"), + Provider::MuseCode, + vec![short, weekly], + now(), + ) + .with_observation(now(), Some("mint".into()), 420) + } + + #[test] + fn cached_muse_alert_details_share_source_clock_with_condition_assessment_and_ranking() { + let snapshot = cached_muse(); + let wall = now() + Duration::minutes(5); + let short = &snapshot.metrics()[0]; + assert!(!snapshot.observation_stale(wall)); + assert_eq!( + assess(short, wall).severity(), + Severity::Normal, + "the old detail clock loses the projection" + ); + assert_eq!(snapshot.assess(wall).severity(), Severity::LimitSoon); + assert!(matches!( + account_condition(&all_on(), &snapshot, short, AlertKind::Critical, wall), + Condition::Triggered + )); + let expected_outlook = short.outlook(now()); + assert!(matches!(expected_outlook, Some(crate::Outlook::Early(_)))); + assert!(matches!(short.outlook(wall), Some(crate::Outlook::Share(share)) if share < 1.0)); + for critical in [true, false] { + let settings = AlertSettings { critical, ..all_on() }; + let first = evaluate(&settings, &BTreeSet::new(), std::slice::from_ref(&snapshot), wall); + assert!(first.notify.iter().any(|alert| alert.metric == "5-hour window")); + let active = first.notify.iter().flat_map(|alert| alert.keys().cloned()).collect(); + assert!( + evaluate(&settings, &active, std::slice::from_ref(&snapshot), wall) + .notify + .is_empty() + ); + let details = account_alerts(&settings, &active, &snapshot, wall); + assert_eq!( + details.len(), + 2, + "both valid sibling windows retain their projected alert" + ); + assert_eq!( + details[0].metric, "5-hour window", + "severity ranking must use the same source assessment" + ); + let detail = &details[0]; + assert_eq!( + detail.kind, + if critical { + AlertKind::Critical + } else { + AlertKind::Warning + } + ); + assert_eq!(detail.severity, snapshot.assess_metric(short, wall).severity()); + assert_eq!(detail.severity, Severity::LimitSoon); + assert_eq!(detail.observed, Severity::Normal); + assert!(detail.is_projected() && detail.notified); + assert_eq!(detail.outlook, expected_outlook); + let summary = detail.summary(wall); + assert!(summary.contains("resets in 1h 55m"), "wall-clock countdown: {summary}"); + assert!(summary.contains("2m early"), "source-anchored outlook: {summary}"); + assert!( + !summary.contains("99%"), + "the explanation cannot contradict the triggered condition: {summary}" + ); + assert_eq!( + detail.severity, + account_alerts(&settings, &active, &snapshot, now())[0].severity + ); + assert_eq!(details[1].metric, "Weekly"); + assert_eq!( + details[1].severity, + snapshot.assess_metric(&snapshot.metrics()[1], wall).severity() + ); + } + } + + #[test] + fn muse_alert_clock_validity_keeps_expiry_staleness_recovery_and_legacy_semantics() { + let snapshot = cached_muse(); + let wall = now() + Duration::minutes(5); + let short = &snapshot.metrics()[0]; + let old_slot = metric_slot(&metric("5-hour window", 0.8, -Duration::hours(3), None)); + let old_key = alert_key(snapshot.id().as_str(), &old_slot, AlertKind::Usage); + let active = [old_key.clone()].into_iter().collect(); + let fresh = evaluate(&all_on(), &active, std::slice::from_ref(&snapshot), wall); + assert!(fresh.recovered.contains(&old_key)); + let stale_at = now() + Duration::seconds(421); + assert!(account_alerts(&all_on(), &active, &snapshot, stale_at).is_empty()); + let stale = evaluate(&all_on(), &active, std::slice::from_ref(&snapshot), stale_at); + assert!(stale.notify.is_empty() && stale.recovered.is_empty()); + let mut ended = short.clone(); + if let Metric::Window { resets_at, .. } = &mut ended { + *resets_at = now() + Duration::minutes(4); + } + let partial = AccountSnapshot::new( + snapshot.id().clone(), + Provider::MuseCode, + vec![ended, snapshot.metrics()[1].clone()], + now(), + ) + .with_observation(now(), Some("mint".into()), 420); + assert!(!partial.observation_stale(wall)); + assert!(matches!( + account_condition(&all_on(), &partial, &partial.metrics()[0], AlertKind::Critical, wall), + Condition::NotApplicable + )); + let details = account_alerts(&all_on(), &active, &partial, wall); + assert_eq!(details.len(), 1); + assert_eq!(details[0].metric, "Weekly"); + let expired = evaluate(&all_on(), &active, &[partial], wall); + assert!(expired.recovered.contains(&old_key)); + assert!(expired.notify.iter().all(|alert| alert.metric == "Weekly")); + let legacy = AccountSnapshot::new( + AccountId::new("legacy"), + Provider::Claude, + snapshot.metrics().to_vec(), + now(), + ); + assert_eq!(legacy.assess_metric(short, wall).severity(), Severity::Normal); + assert!(matches!( + account_condition(&all_on(), &legacy, short, AlertKind::Critical, wall), + Condition::Clear + )); + assert!( + account_alerts(&all_on(), &BTreeSet::new(), &legacy, wall) + .iter() + .all(|detail| detail.metric == "Weekly") + ); + } + #[test] fn strongest_alert_comes_first_in_a_stable_order() { let snapshot = account(vec![ diff --git a/crates/codexbar-core/src/metric.rs b/crates/codexbar-core/src/metric.rs index 44c55519..c9b922b5 100644 --- a/crates/codexbar-core/src/metric.rs +++ b/crates/codexbar-core/src/metric.rs @@ -396,16 +396,22 @@ impl Metric { /// The fraction of the limit used by the reset if the current pace holds (#83): above 1 when it runs out first. /// `None` without a pace, a limit or a reset still ahead, so nothing is projected from too little information. pub fn projected_at_reset(&self, now: DateTime) -> Option { - let pace = match self { - Self::Window { pace, .. } | Self::Quota { pace, .. } | Self::Spend { pace, .. } => (*pace)?, - Self::Balance { .. } | Self::Activity { .. } | Self::Credits { .. } => return None, + // Windows retain the source's raw share above 100%; counted quotas keep their existing capped share. + let (used, pace) = match self { + Self::Window { + used, pace: Some(pace), .. + } => (*used, *pace), + Self::Quota { pace: Some(pace), .. } | Self::Spend { pace: Some(pace), .. } => { + (self.used_fraction()?, *pace) + } + _ => return None, }; - let used = self.used_fraction()?; let left = self.resets_at()? - now; if left <= Duration::zero() || !pace.fraction_per_hour().is_finite() || pace.fraction_per_hour() < 0.0 { return None; } - Some(used + pace.fraction_per_hour() * left.num_seconds() as f64 / 3600.0) + let projected = used + pace.fraction_per_hour() * left.num_seconds() as f64 / 3600.0; + projected.is_finite().then_some(projected) } /// How the pace plays out by the reset (#206): the share it reaches, or how early the limit runs out. A limit diff --git a/crates/codexbar-core/src/sus.rs b/crates/codexbar-core/src/sus.rs index fa499665..8f8cd78f 100644 --- a/crates/codexbar-core/src/sus.rs +++ b/crates/codexbar-core/src/sus.rs @@ -124,6 +124,7 @@ impl Price { pub fn subscription_score(account: &AccountSnapshot, now: DateTime) -> Option { let scores: Vec<_> = account .status_metrics() + .filter(|metric| account.sus_eligible(metric, now)) .filter_map(|metric| weekly(metric, now)) .collect(); (!scores.is_empty()) diff --git a/crates/codexbar-providers/fixtures/muse/README.md b/crates/codexbar-providers/fixtures/muse/README.md new file mode 100644 index 00000000..b077af45 --- /dev/null +++ b/crates/codexbar-providers/fixtures/muse/README.md @@ -0,0 +1,11 @@ +# Muse Code fixtures + +Copied read-only from the shipped Pi contract at +[588be884153122145435e5b05190815bfa54d378](https://github.com/hemsoft-dev/hs-pi-dashboard/tree/588be884153122145435e5b05190815bfa54d378/internal/usage/testdata/muse), +for Muse Code 1.4.13-R6996.1 / schema 1. + +`measured.json` is the sanitized 15% five-hour / 5% weekly reading supplied by the +fleet owner. Every other JSON file is synthetic. The fake identity text in the +null fixture tests that free-form reason/hint never reach UI, persistence or logs. +No fixture demonstrates live account compatibility or a reason-specific auth, +PAYG, entitlement or rate-limit diagnosis. See [the field map and limitations](../../../../docs/MUSE_CODE.md). diff --git a/crates/codexbar-providers/fixtures/muse/exhausted.json b/crates/codexbar-providers/fixtures/muse/exhausted.json new file mode 100644 index 00000000..8153cea4 --- /dev/null +++ b/crates/codexbar-providers/fixtures/muse/exhausted.json @@ -0,0 +1 @@ +{"schemaVersion":1,"allowance":{"asOfMs":1793037245064,"source":"mint","tier":"Muse Code Power Usage","weekly":{"resetsAtMs":1793577600000,"usedPercent":100},"window":{"resetsAtMs":1793053104000,"usedPercent":130}}} diff --git a/crates/codexbar-providers/fixtures/muse/extra-window.json b/crates/codexbar-providers/fixtures/muse/extra-window.json new file mode 100644 index 00000000..58c329c1 --- /dev/null +++ b/crates/codexbar-providers/fixtures/muse/extra-window.json @@ -0,0 +1 @@ +{"schemaVersion":1,"allowance":{"asOfMs":1793037245064,"source":"mint","tier":"Muse Code Power Usage