From afd41066a39356e75627296904e96e8b7ce7cf2d Mon Sep 17 00:00:00 2001 From: HemSoft Date: Mon, 26 Oct 2026 17:07:08 -0400 Subject: [PATCH 1/8] feat: add Muse Code allowance to dashboard and widgets (#273) --- README.md | 3 + crates/codexbar-app/assets/brand/LICENSE.md | 3 + .../codexbar-app/assets/brand/muse-code.svg | 1 + crates/codexbar-app/src/account_table.rs | 25 +- crates/codexbar-app/src/assets.rs | 4 +- crates/codexbar-app/src/brand.rs | 2 + crates/codexbar-app/src/catalog.rs | 11 +- crates/codexbar-app/src/dashboard.rs | 110 ++++- crates/codexbar-app/src/focus_cards.rs | 44 +- crates/codexbar-app/src/main.rs | 10 +- crates/codexbar-app/src/muse_fixture.rs | 80 ++++ crates/codexbar-app/src/muse_roundtrip.rs | 245 ++++++++++ crates/codexbar-app/src/prefs_hub.rs | 4 +- crates/codexbar-app/src/providers.rs | 20 +- crates/codexbar-app/src/self_capture.rs | 7 +- crates/codexbar-app/src/self_capture/muse.rs | 78 ++++ crates/codexbar-app/src/settings_hub.rs | 2 + crates/codexbar-app/src/settings_view.rs | 33 +- crates/codexbar-app/src/sus.rs | 2 +- crates/codexbar-app/src/usage_bar.rs | 15 +- crates/codexbar-app/src/widget_builder.rs | 12 +- crates/codexbar-app/src/widget_feed.rs | 18 +- crates/codexbar-app/src/widgets/cards.rs | 58 ++- crates/codexbar-core/src/account.rs | 55 ++- crates/codexbar-core/src/alerts.rs | 7 +- crates/codexbar-core/src/metric.rs | 8 +- crates/codexbar-core/src/sus.rs | 3 + .../fixtures/muse/README.md | 11 + .../fixtures/muse/exhausted.json | 1 + .../fixtures/muse/extra-window.json | 1 + .../fixtures/muse/malformed-values.json | 1 + .../fixtures/muse/measured.json | 1 + .../fixtures/muse/null-allowance.json | 1 + .../fixtures/muse/partial.json | 1 + .../fixtures/muse/reset-beyond-window.json | 1 + .../fixtures/muse/truncated.json | 1 + .../fixtures/muse/unsupported-schema.json | 1 + .../fixtures/muse/zero.json | 1 + crates/codexbar-providers/src/command.rs | 77 ++- crates/codexbar-providers/src/lib.rs | 3 +- crates/codexbar-providers/src/muse.rs | 437 ++++++++++++++++++ .../tests/command_runner.rs | 56 ++- crates/codexbar-store/src/history.rs | 2 +- crates/codexbar-store/src/settings.rs | 11 +- crates/codexbar-store/src/snapshots.rs | 27 +- crates/codexbar-store/src/widgets.rs | 10 + docs/MUSE_CODE.md | 128 +++++ 47 files changed, 1566 insertions(+), 66 deletions(-) create mode 100644 crates/codexbar-app/assets/brand/muse-code.svg create mode 100644 crates/codexbar-app/src/muse_fixture.rs create mode 100644 crates/codexbar-app/src/muse_roundtrip.rs create mode 100644 crates/codexbar-app/src/self_capture/muse.rs create mode 100644 crates/codexbar-providers/fixtures/muse/README.md create mode 100644 crates/codexbar-providers/fixtures/muse/exhausted.json create mode 100644 crates/codexbar-providers/fixtures/muse/extra-window.json create mode 100644 crates/codexbar-providers/fixtures/muse/malformed-values.json create mode 100644 crates/codexbar-providers/fixtures/muse/measured.json create mode 100644 crates/codexbar-providers/fixtures/muse/null-allowance.json create mode 100644 crates/codexbar-providers/fixtures/muse/partial.json create mode 100644 crates/codexbar-providers/fixtures/muse/reset-beyond-window.json create mode 100644 crates/codexbar-providers/fixtures/muse/truncated.json create mode 100644 crates/codexbar-providers/fixtures/muse/unsupported-schema.json create mode 100644 crates/codexbar-providers/fixtures/muse/zero.json create mode 100644 crates/codexbar-providers/src/muse.rs create mode 100644 docs/MUSE_CODE.md 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..957b6449 100644 --- a/crates/codexbar-app/assets/brand/LICENSE.md +++ b/crates/codexbar-app/assets/brand/LICENSE.md @@ -1,5 +1,8 @@ # 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..a5d8fcc9 100644 --- a/crates/codexbar-app/src/account_table.rs +++ b/crates/codexbar-app/src/account_table.rs @@ -701,7 +701,14 @@ 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 stale = AccountState::Failed("Muse allowance observation is over 7 minutes old".into()); + let state = + if row.observation_stale(self.now) && matches!(self.states.get(id), None | Some(AccountState::Fresh)) { + &stale + } else { + self.states.get(id)? + }; + let (tag, label, detail) = match state { AccountState::Fresh => return None, AccountState::Loading => ( Tag::secondary(), @@ -866,7 +873,10 @@ fn at_reset_cell( muted, ); } - let Some(forecast) = Forecast::of(metric, now) else { + if row.observation_stale(now) { + return plain("n/a", format!("{name}: forecast unavailable, stale observation"), muted); + } + let Some(forecast) = Forecast::of(metric, row.projection_time(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(_), .. })) @@ -956,13 +966,16 @@ 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::MuseCode { + return "Quota unavailable".into(); + } if row.provider() == Provider::Gemini { "Gemini Models weekly".into() } else { @@ -977,6 +990,9 @@ fn missing_window_bar( id: &str, cx: &gpui_kit::App, ) -> gpui_kit::AnyElement { + if row.provider() == Provider::MuseCode { + return blank_cell(id, "n/a", cx.theme().muted_foreground); + } if row.provider() == Provider::Gemini && kind == WindowKind::Weekly { bar_cell_without_metric(id, assessment, severity, row, cx) } else { @@ -992,6 +1008,9 @@ fn bar_cell_without_metric( row: &AccountSnapshot, cx: &gpui_kit::App, ) -> gpui_kit::AnyElement { + if row.provider() == Provider::MuseCode { + return blank_cell(id, "n/a", cx.theme().muted_foreground); + } if matches!( row.provider(), Provider::Gemini | Provider::Grok | Provider::XSubscription diff --git a/crates/codexbar-app/src/assets.rs b/crates/codexbar-app/src/assets.rs index af84696d..4e6b2735 100644 --- a/crates/codexbar-app/src/assets.rs +++ b/crates/codexbar-app/src/assets.rs @@ -20,10 +20,12 @@ pub fn logo_path(provider: Provider) -> &'static str { Provider::Gemini => "brand/gemini.svg", Provider::Grok => "brand/grok.svg", Provider::XSubscription => "brand/xai.svg", + Provider::MuseCode => "brand/muse-code.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")), ( diff --git a/crates/codexbar-app/src/brand.rs b/crates/codexbar-app/src/brand.rs index 193eeb6a..7854de75 100644 --- a/crates/codexbar-app/src/brand.rs +++ b/crates/codexbar-app/src/brand.rs @@ -35,6 +35,7 @@ pub fn brand(provider: Provider) -> Brand { Provider::Gemini => 0x3186FF, Provider::Grok => 0x2B2B2B, Provider::XSubscription => 0x0F1419, + Provider::MuseCode => 0x0866FF, }; Brand { accent, @@ -58,6 +59,7 @@ pub fn from_settings_name(name: &str) -> Option { names::GEMINI => Provider::Gemini, names::GROK => Provider::Grok, names::X_SUBSCRIPTION => Provider::XSubscription, + names::MUSE_CODE => Provider::MuseCode, _ => return None, }) } diff --git a/crates/codexbar-app/src/catalog.rs b/crates/codexbar-app/src/catalog.rs index 2ad86a5b..813cf7db 100644 --- a/crates/codexbar-app/src/catalog.rs +++ b/crates/codexbar-app/src/catalog.rs @@ -25,7 +25,7 @@ pub struct ProviderInfo { pub multi_account: bool, } -pub const PROVIDERS: [ProviderInfo; 13] = [ +pub const PROVIDERS: [ProviderInfo; 14] = [ ProviderInfo { id: names::CODEX, display: "ChatGPT · Codex", @@ -178,6 +178,15 @@ pub const PROVIDERS: [ProviderInfo; 13] = [ setup_method: AuthMethod::Automatic, multi_account: false, }, + ProviderInfo { + id: names::MUSE_CODE, + 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..55cfbe05 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,12 @@ 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 = if account.observation_stale(self.now) { + AccountState::Failed("Muse allowance observation is over 7 minutes old".into()) + } else { + AccountState::Fresh + }; + self.states.insert(account.id().as_str().to_owned(), state); self.placeholders.remove(account.id().as_str()); refreshed.push(account.clone()); accounts.push(account); @@ -1536,6 +1550,79 @@ impl Dashboard { cx.notify(); } + /// The production Muse adapter with a synthetic command runner. No live provider is constructed. + #[cfg(feature = "capture")] + pub(crate) fn capture_muse(&mut self, state: &str, cx: &mut Context) { + use codexbar_providers::UsageProvider as _; + assert!(crate::is_demo() && matches!(self.source, DataSource::Demo)); + 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, + }, + ]; + }); + let now = crate::muse_fixture::now(state); + self.last_refresh = Some(now); + crate::sus::capture_time(now, cx); + let result = 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); + 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 +1693,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. @@ -2060,6 +2147,23 @@ 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( + (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..a3eda4df 100644 --- a/crates/codexbar-app/src/focus_cards.rs +++ b/crates/codexbar-app/src/focus_cards.rs @@ -236,6 +236,31 @@ fn area_fill(color: Hsla) -> Background { /// 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 { use gpui_kit::{StatefulInteractiveElement as _, TestSupportExt as _}; + 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) { + 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( + div() + .id("muse-quota-note") + .test_support() + .aria_label(text.clone()) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(text) + .into_any_element(), + ); + } if account.provider() != codexbar_core::Provider::Gemini || account.status_metrics().next().is_some() { return None; } @@ -345,6 +370,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() @@ -390,6 +420,7 @@ fn limit_graph<'a>(account: &'a AccountSnapshot, metric: &'a Metric, now: DateTi ) }; Graph { + projection_at: account.projection_time(now), metric: Some(metric), curve: comparable(curve), trend, @@ -482,10 +513,13 @@ 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.observation_stale(now) { + return None; + } if account.credits_cover(metric) { return crate::usage_bar::outlook(metric, account, now); } - match crate::usage_bar::Forecast::of(metric, now) { + match crate::usage_bar::Forecast::of(metric, account.projection_time(now)) { Some(forecast) => Some(format!("{} at reset", forecast.share())), None => crate::usage_bar::outlook(metric, account, now), } @@ -711,6 +745,7 @@ fn usage_card(account: &AccountSnapshot, now: DateTime, view: Option, view: Option { + projection_at: DateTime, /// The limit the takeaway and caption describe. metric: Option<&'a Metric>, curve: Option<&'a codexbar_core::WindowCurve>, @@ -861,7 +897,7 @@ 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(pace_takeaway(metric, graph.projection_at)) .caption(reset_caption(metric, now)), None => card, }; @@ -889,6 +925,10 @@ fn share_card( return rings_card(account, &chosen, now, control, cx); } let Some(metric) = chosen.first().copied().or_else(|| share_metric(account, now)) else { + if account.provider() == codexbar_core::Provider::MuseCode { + return Card::new("Allowance unavailable", "n/a", div().child("n/a")) + .takeaway("Muse Code did not report a valid allowance"); + } return Card::new("No limits", "", div()).takeaway("This account reports no limits"); }; 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..2a5acfd9 --- /dev/null +++ b/crates/codexbar-app/src/muse_fixture.rs @@ -0,0 +1,80 @@ +//! 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 == "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 failure = match state { + "timeout" => Some(CommandError::TimedOut), + "transport" => Some(CommandError::Failed), + "missing-cli" => Some(CommandError::NotFound), + _ => None, + }; + MuseProvider::new( + Fixture { + text: text.into(), + failure, + success: state != "rate-command-failure", + }, + std::env::temp_dir(), + "muse-demo".into(), + "Demo".into(), + ) +} diff --git a/crates/codexbar-app/src/muse_roundtrip.rs b/crates/codexbar-app/src/muse_roundtrip.rs new file mode 100644 index 00000000..01c3ad7a --- /dev/null +++ b/crates/codexbar-app/src/muse_roundtrip.rs @@ -0,0 +1,245 @@ +//! 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(); + assert_eq!(loaded, feed); + 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, None); + 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); +} 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..2114a3ef 100644 --- a/crates/codexbar-app/src/providers.rs +++ b/crates/codexbar-app/src/providers.rs @@ -37,6 +37,23 @@ fn enabled_accounts(hub: &SettingsHub, provider: &str) -> Vec { pub fn enabled(hub: &SettingsHub) -> Vec> { let mut providers: Vec> = Vec::new(); + if let Some(home) = codexbar_core::home_dir() { + 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.extend(codex_adapters(hub)); if let Some(copilot) = copilot(hub) { @@ -384,7 +401,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()?, 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..93d573b2 --- /dev/null +++ b/crates/codexbar-app/src/self_capture/muse.rs @@ -0,0 +1,78 @@ +//! 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"] { + 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 == "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..fdb9703d 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))) @@ -798,6 +800,17 @@ fn save_account(form: &AccountForm, existing: Option, window: &mu }, }; + if info.id == names::MUSE_CODE + && (method != AuthMethod::CommandLine + || SettingsHub::global(cx).settings().accounts().iter().any(|other| { + other.provider == names::MUSE_CODE && existing.as_ref().is_none_or(|record| record.id != other.id) + })) + { + *form.error.borrow_mut() = + Some("Muse Code reads one existing CLI session. Choose Command line and keep one account.".into()); + window.refresh(); + return false; + } if info.id == names::X_SUBSCRIPTION && let Some(error) = x_subscription_refusal( method, @@ -1122,7 +1135,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() diff --git a/crates/codexbar-app/src/sus.rs b/crates/codexbar-app/src/sus.rs index 03c7177a..48dce9d4 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.supports_sus() || !account.is_status_metric(metric) { return None; } metric_badge(metric, now, id, cx) diff --git a/crates/codexbar-app/src/usage_bar.rs b/crates/codexbar-app/src/usage_bar.rs index 5d07649b..ab919c37 100644 --- a/crates/codexbar-app/src/usage_bar.rs +++ b/crates/codexbar-app/src/usage_bar.rs @@ -261,7 +261,10 @@ pub fn outlook(metric: &Metric, account: &codexbar_core::AccountSnapshot, now: D if account.credits_cover(metric) { return Some(COVERED.to_owned()); } - Forecast::of(metric, now) + if account.observation_stale(now) { + return None; + } + Forecast::of(metric, account.projection_time(now)) .map(|forecast| forecast.text()) .or_else(|| used_up(metric).then(|| REACHED.to_owned())) } @@ -389,7 +392,15 @@ impl Projection { account: &codexbar_core::AccountSnapshot, now: DateTime, ) -> Self { - let projection = status_projection(Self::of(metric, used, now), account, metric); + if account.observation_stale(now) { + let mut projection = Self::of(metric, used, account.projection_time(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::of(metric, used, account.projection_time(now)), account, metric); if account.credits_cover(metric) { projection.calm() } else { diff --git a/crates/codexbar-app/src/widget_builder.rs b/crates/codexbar-app/src/widget_builder.rs index 72b1ba52..527b0859 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; @@ -253,7 +252,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 +389,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 +423,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..734db11c 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(), @@ -79,14 +89,16 @@ fn metric(account: &AccountSnapshot, metric: &Metric, now: DateTime) -> Wid resets_at: metric.resets_at(), status: account.assess_metric(metric, now).label().map(str::to_owned), // 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: metric + .projected_at_reset(account.projection_time(now)) + .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.supports_sus() && account.is_status_metric(metric)) .map(|s| s.value()), sus_eligible: codexbar_core::sus::text(metric, now) - .filter(|_| account.is_status_metric(metric)) + .filter(|_| account.supports_sus() && account.is_status_metric(metric)) .is_some(), } } diff --git a/crates/codexbar-app/src/widgets/cards.rs b/crates/codexbar-app/src/widgets/cards.rs index 6164d311..8cf24273 100644 --- a/crates/codexbar-app/src/widgets/cards.rs +++ b/crates/codexbar-app/src/widgets/cards.rs @@ -255,27 +255,60 @@ impl TileView { None => account.metrics.first(), }; let Some(found) = found else { - let note = if metric.is_some() { + 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 Self::message(Some(&account.id), account.name.clone(), mode, note); }; - let freshness = match (account.health, account.updated_at) { + if account.provider == "muse-code" && found.resets_at.is_some_and(|at| at <= now) { + let mut view = Self::message( + Some(&account.id), + account.name.clone(), + mode, + "n/a · allowance window reset; refresh needed", + ); + view.metric = Some(found.key.clone()); + return view; + } + 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) + }); + let health = if old_observation { + WidgetHealth::Stale + } else { + account.health + }; + 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 { None }; // 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( + 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), + }, + ); let percent_text = found.used_percent.map(|p| format!("{p:.0}%")); let resets = found .resets_at @@ -300,7 +333,11 @@ impl TileView { TileMode::Balance => (Some(found.label.clone()), Some(found.value.clone()), None, freshness), TileMode::Status => ( line, - Some(status.clone().unwrap_or_else(|| "OK".to_owned())), + Some( + status + .clone() + .unwrap_or_else(|| if !current { "Stale" } else { "OK" }.to_owned()), + ), None, freshness, ), @@ -603,6 +640,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..ca9cbf63 100644 --- a/crates/codexbar-core/src/account.rs +++ b/crates/codexbar-core/src/account.rs @@ -20,6 +20,7 @@ pub enum Provider { Gemini, Grok, XSubscription, + MuseCode, } impl Provider { @@ -38,10 +39,11 @@ impl Provider { Self::Gemini => "Gemini", Self::Grok => "Grok", Self::XSubscription => "X Subscription", + Self::MuseCode => "Muse Code", } } - pub const ALL: [Self; 12] = [ + pub const ALL: [Self; 13] = [ Self::Codex, Self::Claude, Self::Copilot, @@ -54,6 +56,7 @@ impl Provider { Self::Gemini, Self::Grok, Self::XSubscription, + Self::MuseCode, ]; /// A stable key for storage; unlike the display name it never changes with wording. @@ -71,6 +74,7 @@ impl Provider { Self::Gemini => "gemini", Self::Grok => "grok", Self::XSubscription => "x-subscription", + Self::MuseCode => "muse-code", } } @@ -186,6 +190,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 +211,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 +227,7 @@ impl AccountSnapshot { detail: AccountDetail::default(), fetched_at, messages: Vec::new(), + observation: None, } } @@ -223,6 +237,36 @@ 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) + }) + } + /// Forecasts of last-seen allowance stay anchored to the provider's observation. + pub fn projection_time(&self, now: DateTime) -> DateTime { + self.observed_at().unwrap_or(now) + } + pub fn supports_sus(&self) -> bool { + self.provider != Provider::MuseCode + } + pub fn messages(&self) -> &[String] { &self.messages } @@ -337,8 +381,8 @@ impl AccountSnapshot { /// 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) + if self.is_status_metric(metric) && !self.observation_stale(now) { + assess(metric, self.projection_time(now)) } else { Assessment::default() } @@ -364,9 +408,12 @@ impl AccountSnapshot { /// A fully used window doesn't block an account /// that can still spend credits (#150). pub fn assess(&self, now: DateTime) -> Assessment { + if self.observation_stale(now) { + return Assessment::default(); + } self.status_metrics() .filter(|metric| !self.credits_cover(metric)) - .map(|metric| assess(metric, now)) + .map(|metric| assess(metric, self.projection_time(now))) .max() .unwrap_or_default() } diff --git a/crates/codexbar-core/src/alerts.rs b/crates/codexbar-core/src/alerts.rs index 9e58be70..6b77125f 100644 --- a/crates/codexbar-core/src/alerts.rs +++ b/crates/codexbar-core/src/alerts.rs @@ -153,6 +153,9 @@ pub fn evaluate( ) -> Evaluation { let mut out = Evaluation::default(); for account in refreshed { + if account.observation_stale(now) { + continue; + } let id = account.id().as_str(); out.recovered.extend(informational_alert_keys(account, active)); for metric in account.status_metrics() { @@ -304,7 +307,7 @@ pub fn account_alerts( account: &AccountSnapshot, now: DateTime, ) -> Vec { - if !settings.enabled { + if !settings.enabled || account.observation_stale(now) { return Vec::new(); } let id = account.id().as_str(); @@ -418,7 +421,7 @@ fn account_condition( if !account.is_status_metric(metric) { return Condition::Clear; } - credit_condition(settings, account, metric, kind, now) + credit_condition(settings, account, metric, kind, account.projection_time(now)) } fn credit_condition( diff --git a/crates/codexbar-core/src/metric.rs b/crates/codexbar-core/src/metric.rs index 44c55519..13b6a3c0 100644 --- a/crates/codexbar-core/src/metric.rs +++ b/crates/codexbar-core/src/metric.rs @@ -400,12 +400,16 @@ impl Metric { Self::Window { pace, .. } | Self::Quota { pace, .. } | Self::Spend { pace, .. } => (*pace)?, Self::Balance { .. } | Self::Activity { .. } | Self::Credits { .. } => return None, }; - let used = self.used_fraction()?; + let used = match self { + Self::Window { used, .. } => *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..c34bb84a 100644 --- a/crates/codexbar-core/src/sus.rs +++ b/crates/codexbar-core/src/sus.rs @@ -122,6 +122,9 @@ impl Price { /// Average one account's eligible pools. Missing scores are excluded. pub fn subscription_score(account: &AccountSnapshot, now: DateTime) -> Option { + if !account.supports_sus() || account.observation_stale(now) { + return None; + } let scores: Vec<_> = account .status_metrics() .filter_map(|metric| weekly(metric, now)) 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