From 8df505b3685f47f096764646ea0cbb1778ae234f Mon Sep 17 00:00:00 2001 From: anmartin Date: Tue, 3 Nov 2026 14:53:11 +0100 Subject: [PATCH 1/2] feat(provider): add Mistral (Vibe CLI + Studio) as a provider (#205) New `mistral` provider polling api.mistral.ai for both the Vibe CLI and Mistral Studio (La Plateforme): - Credentials: MISTRAL_API_KEY environment variable, then the `mistral:vibe-api-key` generic credential in Windows Credential Manager. - Session row: x-ratelimit-* headers from GET /v1/models. - Long row: calendar-month spend in EUR from GET /v1/billing/usage against monthly_budget from GET /v1/billing/subscription, labelled 30d and resetting at midnight UTC on the 1st. Billing routes the key cannot read (403/404) are skipped without failing the poll. - Wiring: provider descriptor (native menu command 69), poller registration, show_mistral setting key, context-menu entry, translations for the 14 bundled locales, brand mark at U+F008 in brand-marks.ttf (Simple Icons, CC0-1.0), and Mistral layers in the three bundled themes. Checks not run here: cargo fmt/test/build require Windows. The build.rs locale validation was replicated in Python (key parity, placeholders), themes were revalidated as JSON, and every exhaustive match on ProviderId was audited by grep. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- README.md | 3 + src/app_settings.rs | 7 + src/context_menu.rs | 10 + src/icons/brand-marks.ttf | Bin 3000 -> 3100 bytes src/localization/locales/de.toml | 6 + src/localization/locales/en.toml | 6 + src/localization/locales/es.toml | 6 + src/localization/locales/fr.toml | 6 + src/localization/locales/ja.toml | 6 + src/localization/locales/ko.toml | 6 + src/localization/locales/nl.toml | 6 + src/localization/locales/pl.toml | 6 + src/localization/locales/pt-BR.toml | 6 + src/localization/locales/ru.toml | 6 + src/localization/locales/th.toml | 6 + src/localization/locales/tr.toml | 6 + src/localization/locales/zh-CN.toml | 6 + src/localization/locales/zh-TW.toml | 6 + src/localization/mod.rs | 8 + src/poller.rs | 8 +- src/poller/mistral.rs | 503 ++++++++++++++++++++ src/poller/tests.rs | 5 + src/providers.rs | 15 +- src/theme_engine/tests.rs | 2 +- src/theme_engine/theme_expression.rs | 1 + src/theme_engine/theme_limits.rs | 1 + src/themes/classic-usage-widget.json | 308 ++++++++++++ src/themes/classic-vertical.json | 330 ++++++++++++- src/themes/compact-fluent-quad.json | 474 ++++++++++++++++++ src/ui/theme.rs | 2 + tests/windows-vm/cases/vertical-taskbar.ps1 | 6 +- 31 files changed, 1759 insertions(+), 8 deletions(-) create mode 100644 src/poller/mistral.rs diff --git a/README.md b/README.md index 8a62fce5..3ff59113 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ In the default theme, left-click a provider tray icon to show or hide the widget | Cursor | Sign in to Cursor, then enable it in **Providers**. The local session is detected automatically. | | Grok Build | Run `grok login` in the Grok Build CLI, then enable Grok in **Providers**. The signed-in session is detected automatically. | | GitHub Copilot | Sign in with the Copilot CLI (`copilot`, then `/login`) or the GitHub CLI (`gh auth login`), then enable Copilot in **Providers**. The stored login is detected automatically. | +| Mistral (Vibe + Studio) | Set `MISTRAL_API_KEY` or store a Mistral API key in Windows Credential Manager, then enable Mistral in **Providers**. Create the key in Studio under **API keys** at console.mistral.ai. | For OpenCode Go, set `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`, or create `%APPDATA%\opencode-go\config.json`: @@ -90,6 +91,8 @@ Grok Build usage comes from the session the CLI stores in `%USERPROFILE%\.grok\a GitHub Copilot usage comes from `api.github.com/copilot_internal/user`, the endpoint Copilot's own editors and CLI read. The monitor uses the first GitHub login that endpoint accepts, in this order: the `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN` environment variable, the Copilot CLI login, then the GitHub CLI login, both read from Windows Credential Manager. Copilot spends one premium-request pool per calendar month, so the monitor shows it on the long-window row and leaves the short-window row empty. Metered chat and completion allowances, as on Copilot Free, are available to themes through `copilot.limits.chat` and `copilot.limits.completions`. +Mistral usage (Vibe CLI and Studio/La Plateforme) comes from `api.mistral.ai` with a Bearer API key, read from `MISTRAL_API_KEY` or from the `mistral:vibe-api-key` entry in Windows Credential Manager — the same key the Vibe CLI uses. The short-window row reads the `x-ratelimit-*` headers returned by `GET /v1/models`; the long-window row shows the calendar-month spend from `GET /v1/billing/usage` (in EUR) against the `monthly_budget` from `GET /v1/billing/subscription`, resetting at midnight UTC on the 1st, and is labelled `30d`. Billing routes that the key cannot read (403/404) are skipped without failing the poll. + ## Data and privacy The monitor reads local sign-in credentials for enabled providers and sends usage requests directly to their official services. It has no backend service, collects no telemetry, and does not upload credentials or project files. diff --git a/src/app_settings.rs b/src/app_settings.rs index 472e5d20..18aaef00 100644 --- a/src/app_settings.rs +++ b/src/app_settings.rs @@ -66,6 +66,8 @@ pub struct SettingsFile { show_grok: bool, #[serde(default)] show_copilot: bool, + #[serde(default)] + show_mistral: bool, #[serde(default = "default_true")] pub custom_theme_enabled: bool, /// Show what is left of each allowance instead of what has been spent, so @@ -140,6 +142,7 @@ impl Default for SettingsFile { show_cursor: false, show_grok: false, show_copilot: false, + show_mistral: false, custom_theme_enabled: true, usage_countdown: false, use_windows_default_font: true, @@ -239,6 +242,7 @@ impl SettingsFile { ProviderId::Cursor => self.show_cursor, ProviderId::Grok => self.show_grok, ProviderId::Copilot => self.show_copilot, + ProviderId::Mistral => self.show_mistral, } } @@ -287,6 +291,7 @@ impl SettingsFile { ProviderId::Cursor => self.show_cursor = enabled, ProviderId::Grok => self.show_grok = enabled, ProviderId::Copilot => self.show_copilot = enabled, + ProviderId::Mistral => self.show_mistral = enabled, } } @@ -654,6 +659,7 @@ mod tests { ProviderId::Cursor, ProviderId::Grok, ProviderId::Copilot, + ProviderId::Mistral, ])); let json = settings_json(&settings); @@ -664,6 +670,7 @@ mod tests { assert_eq!(json["show_cursor"], true); assert_eq!(json["show_grok"], true); assert_eq!(json["show_copilot"], true); + assert_eq!(json["show_mistral"], true); let decoded = decode_settings(&json.to_string()).unwrap(); assert_eq!(decoded.enabled_providers(), settings.enabled_providers()); diff --git a/src/context_menu.rs b/src/context_menu.rs index bf87f113..05a14ad4 100644 --- a/src/context_menu.rs +++ b/src/context_menu.rs @@ -424,6 +424,13 @@ pub fn classic_context_menu() -> ContextMenuDocument { provider: Provider::Copilot, }, ), + ContextMenuItem::action( + "provider-mistral", + "Mistral", + Action::ToggleProvider { + provider: Provider::Mistral, + }, + ), ], ); let languages = std::iter::once(ContextMenuItem::action( @@ -730,6 +737,9 @@ mod tests { assert!(serde_json::to_string(&menu) .unwrap() .contains("provider-copilot")); + assert!(serde_json::to_string(&menu) + .unwrap() + .contains("provider-mistral")); assert!(menu.items.iter().any(|item| { item.id == "toggle-widget" && matches!( diff --git a/src/icons/brand-marks.ttf b/src/icons/brand-marks.ttf index f05fc76207b78854a762f063c99fcb7195dbd0c4..36f8867f69222b0402c41680129cff9dd4aa4f30 100644 GIT binary patch delta 336 zcmdlXK1X7La(y%d1LFb)1_qP#oXWJfT`MmG`EM8)IGQq26H|^kmM;RztpQ@Q44?q} zedc#S_7fmh$;d6KV0q8b161DuGcJ_nGmke8U7 zn(P~}i-Ccw04SzekY8LfW24@_XABJ7bASr!7#M*h5U>0`;aEJs%~u9y<`+N_h8oqm z2SD_5{;6^kFW542P3B|lXBK5(m^_D3nUQ1i0Y()T7Don#%}*FjIs6$JL>cBVUuO1T zkOrD2sw}E(3WS2nqKZJ;SkxGZ!E8aMgPk3not=!cJ3%y%V)g)XJOADVks!dN477B! r1eY(PC>PKuCPrpP7DiS^Hb! ( + strings.mistral_token_expired_title, + strings.mistral_token_expired_body, + ), } } @@ -149,6 +153,7 @@ pub struct Strings { pub cursor_model: &'static str, pub grok_model: &'static str, pub copilot_model: &'static str, + pub mistral_model: &'static str, pub settings: &'static str, pub start_with_windows: &'static str, pub language: &'static str, @@ -188,12 +193,15 @@ pub struct Strings { pub grok_token_expired_body: &'static str, pub copilot_token_expired_title: &'static str, pub copilot_token_expired_body: &'static str, + pub mistral_token_expired_title: &'static str, + pub mistral_token_expired_body: &'static str, pub codex_window_title: &'static str, pub antigravity_window_title: &'static str, pub opencode_window_title: &'static str, pub cursor_window_title: &'static str, pub grok_window_title: &'static str, pub copilot_window_title: &'static str, + pub mistral_window_title: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/poller.rs b/src/poller.rs index 996b94e2..8b5c9059 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -286,6 +286,7 @@ mod codex; mod copilot; mod cursor; mod grok; +mod mistral; mod opencode; mod retry_after; mod windows_credentials; @@ -298,7 +299,7 @@ struct ProviderPoller { credential_watch: fn(bool) -> CredentialWatchSnapshot, } -const PROVIDER_POLLERS: [ProviderPoller; 7] = [ +const PROVIDER_POLLERS: [ProviderPoller; 8] = [ ProviderPoller { id: ProviderId::Claude, poll: claude::poll_claude_code, @@ -334,6 +335,11 @@ const PROVIDER_POLLERS: [ProviderPoller; 7] = [ poll: copilot::poll_copilot, credential_watch: copilot::credential_watch_snapshot, }, + ProviderPoller { + id: ProviderId::Mistral, + poll: mistral::poll_mistral, + credential_watch: mistral::credential_watch_snapshot, + }, ]; fn provider_poller(provider: ProviderId) -> Option<&'static ProviderPoller> { diff --git a/src/poller/mistral.rs b/src/poller/mistral.rs new file mode 100644 index 00000000..f7591ec7 --- /dev/null +++ b/src/poller/mistral.rs @@ -0,0 +1,503 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; + +use super::windows_credentials; +use super::{build_agent, get_header_i64, PollError}; +use crate::diagnose; +use crate::models::{UsageData, UsageSection}; + +const MISTRAL_MODELS_URL: &str = "https://api.mistral.ai/v1/models"; +const MISTRAL_BILLING_USAGE_URL: &str = "https://api.mistral.ai/v1/billing/usage"; +const MISTRAL_BILLING_SUBSCRIPTION_URL: &str = "https://api.mistral.ai/v1/billing/subscription"; +/// The precedence the Vibe CLI itself gives its key variable. +const API_KEY_ENVIRONMENT: &str = "MISTRAL_API_KEY"; +/// Where the monitor keeps a key the user entered for it; the Vibe CLI keeps +/// its own copy in the OS keyring, which on Windows is Credential Manager. +const CREDENTIAL_TARGET: &str = "mistral:vibe-api-key"; + +const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + +#[derive(Debug, Deserialize)] +struct BillingUsageResponse { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct BillingUsageEntry { + #[serde(default)] + total_cost: Option, + #[serde(default)] + cost: Option, + #[serde(default)] + start_date: Option, + #[serde(default)] + end_date: Option, +} + +#[derive(Debug, Deserialize)] +struct BillingSubscriptionResponse { + #[serde(default)] + monthly_budget: Option, +} + +struct ApiKeyCandidate { + source: String, + key: String, +} + +pub(super) fn poll_mistral() -> Result { + let candidates = api_key_candidates(); + if candidates.is_empty() { + diagnose::log( + "Mistral usage poll failed: no API key found (set MISTRAL_API_KEY or store a key in Windows Credential Manager)", + ); + return Err(PollError::NoCredentials); + } + + let mut last_error = PollError::AuthRequired; + for candidate in &candidates { + match fetch_mistral_usage(&candidate.key) { + Ok(usage) => return Ok(usage), + Err(error @ PollError::AuthRequired) => { + diagnose::log(format!( + "Mistral rejected the key from {}: {error:?}", + candidate.source + )); + last_error = error; + } + Err(error) => return Err(error), + } + } + Err(last_error) +} + +/// Fingerprints only: secrets are hashed, never logged. +pub(super) fn credential_watch_snapshot(_all_sources: bool) -> Vec { + let environment = match non_empty_environment(API_KEY_ENVIRONMENT) { + Some(value) => secret_signature("environment", &value), + None => format!("{API_KEY_ENVIRONMENT}|missing"), + }; + let stored = match windows_credentials::read_generic(CREDENTIAL_TARGET) { + Some(secret) => secret_signature(CREDENTIAL_TARGET, &secret), + None => format!("{CREDENTIAL_TARGET}|missing"), + }; + vec![environment, stored] +} + +fn api_key_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(key) = non_empty_environment(API_KEY_ENVIRONMENT) { + candidates.push(ApiKeyCandidate { + source: format!("environment variable {API_KEY_ENVIRONMENT}"), + key, + }); + } + if let Some(secret) = windows_credentials::read_generic(CREDENTIAL_TARGET) { + candidates.push(ApiKeyCandidate { + source: format!("Windows credential {CREDENTIAL_TARGET}"), + key: secret.trim().to_string(), + }); + } + candidates.into_iter().filter(|candidate| is_header_safe_key(&candidate.key)).collect() +} + +fn fetch_mistral_usage(api_key: &str) -> Result { + let agent = build_agent()?; + + let mut models_response = match agent + .get(MISTRAL_MODELS_URL) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .call() + .and_then(super::check_http_status) + { + Ok(response) => response, + Err(ureq::Error::StatusCode(401 | 403)) => return Err(PollError::AuthRequired), + Err(ureq::Error::StatusCode(code)) => { + diagnose::log(format!("Mistral models endpoint returned status {code}")); + return Err(PollError::HttpStatus(code)); + } + Err(error) => { + diagnose::log_error("Mistral models request failed", error); + return Err(PollError::NetworkError); + } + }; + // The model list itself is only a carrier for the rate-limit headers; the + // body is not needed, but draining it keeps the connection reusable. + let _ = models_response.body_mut().read_json::(); + + let session = rate_limit_section(&models_response); + + // Billing routes need a key with billing scope; without it the account + // still gets its rate-limit window instead of a hard failure. + let (spend, budget) = match fetch_billing(&agent, api_key) { + Ok(billing) => billing, + Err(PollError::AuthRequired) => return Err(PollError::AuthRequired), + Err(error) => { + diagnose::log(format!("Mistral billing endpoints unavailable: {error:?}")); + (None, None) + } + }; + + let monthly = spend.map(|spend| monthly_section(spend, budget, SystemTime::now())); + match (session.is_some(), monthly.is_some()) { + (false, None) => { + diagnose::log("Mistral response carried neither rate-limit headers nor spend data"); + Err(PollError::UnexpectedResponse) + } + _ => Ok(UsageData { + limits: Vec::new(), + session: session.unwrap_or_default(), + weekly: monthly.clone().unwrap_or_default(), + weekly_label: monthly.as_ref().map(|_| "30d".into()), + monthly, + credits: None, + stale: false, + ..Default::default() + }), + } +} + +/// Monthly spend and budget are optional; a 403 on billing routes simply means +/// the key cannot read them. +fn fetch_billing( + agent: &ureq::Agent, + api_key: &str, +) -> Result<(Option, Option), PollError> { + let mut usage_response = match agent + .get(MISTRAL_BILLING_USAGE_URL) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .call() + .and_then(super::check_http_status) + { + Ok(response) => response, + Err(ureq::Error::StatusCode(401)) => return Err(PollError::AuthRequired), + Err(ureq::Error::StatusCode(403 | 404)) => return Ok((None, None)), + Err(ureq::Error::StatusCode(code)) => return Err(PollError::HttpStatus(code)), + Err(error) => { + diagnose::log_error("Mistral billing usage request failed", error); + return Err(PollError::NetworkError); + } + }; + let usage: BillingUsageResponse = usage_response.body_mut().read_json().map_err(|error| { + diagnose::log_error("unable to parse Mistral billing usage response", error); + PollError::UnexpectedResponse + })?; + let spend = monthly_spend(&usage.data, SystemTime::now()); + + let mut subscription_response = match agent + .get(MISTRAL_BILLING_SUBSCRIPTION_URL) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .call() + .and_then(super::check_http_status) + { + Ok(response) => response, + Err(ureq::Error::StatusCode(401)) => return Err(PollError::AuthRequired), + Err(ureq::Error::StatusCode(403 | 404)) => return Ok((Some(spend), None)), + Err(ureq::Error::StatusCode(code)) => return Err(PollError::HttpStatus(code)), + Err(error) => { + diagnose::log_error("Mistral billing subscription request failed", error); + return Err(PollError::NetworkError); + } + }; + let subscription: BillingSubscriptionResponse = subscription_response + .body_mut() + .read_json() + .map_err(|error| { + diagnose::log_error("unable to parse Mistral billing subscription response", error); + PollError::UnexpectedResponse + })?; + let budget = subscription + .monthly_budget + .filter(|value| value.is_finite() && *value > 0.0); + Ok((Some(spend), budget)) +} + +/// Mistral rate limits are reported per minute as response headers on every +/// endpoint; `/v1/models` is the cheapest one to ask. +fn rate_limit_section(response: &super::HttpResponse) -> Option { + let requests = window_from_headers( + get_header_i64(response, "x-ratelimit-limit-requests"), + get_header_i64(response, "x-ratelimit-remaining-requests"), + response + .headers() + .get("x-ratelimit-reset-requests") + .and_then(|value| value.to_str().ok()), + ); + if requests.is_some() { + return requests; + } + window_from_headers( + get_header_i64(response, "x-ratelimit-limit-tokens"), + get_header_i64(response, "x-ratelimit-remaining-tokens"), + response + .headers() + .get("x-ratelimit-reset-tokens") + .and_then(|value| value.to_str().ok()), + ) +} + +fn window_from_headers( + limit: Option, + remaining: Option, + reset: Option<&str>, +) -> Option { + let limit = limit?; + let remaining = remaining?; + if limit <= 0 || remaining < 0 { + return None; + } + let percentage = ((limit - remaining) as f64 / limit as f64 * 100.0).clamp(0.0, 100.0); + let resets_at = + parse_reset_seconds(reset).map(|seconds| SystemTime::now() + Duration::from_secs(seconds)); + Some(UsageSection { + available: true, + percentage, + resets_at, + }) +} + +/// `x-ratelimit-reset-*` is a duration until the window refills, either a bare +/// number of seconds or a suffixed string such as `30s` or `1m`. +fn parse_reset_seconds(value: Option<&str>) -> Option { + let value = value?.trim(); + if value.is_empty() { + return None; + } + if let Ok(seconds) = value.parse::() { + return Some(seconds); + } + let (digits, unit) = value.split_at(value.len().saturating_sub(1)); + let multiplier = match unit { + "s" => 1, + "m" => 60, + "h" => 3_600, + _ => return None, + }; + digits.parse::().ok().map(|value| value * multiplier) +} + +/// Sum the cost entries that overlap the current calendar month. Entries +/// without dates belong to the month the endpoint already scoped to. +fn monthly_spend(entries: &[BillingUsageEntry], now: SystemTime) -> f64 { + let (month_start, month_end) = current_month_bounds(now); + entries + .iter() + .filter(|entry| entry_overlaps_month(entry, month_start, month_end)) + .filter_map(|entry| { + entry + .total_cost + .or(entry.cost) + .filter(|value| value.is_finite() && *value >= 0.0) + }) + .sum() +} + +fn entry_overlaps_month(entry: &BillingUsageEntry, month_start: u64, month_end: u64) -> bool { + let start = entry + .start_date + .as_deref() + .and_then(|value| super::parse_iso8601(Some(value))) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()); + let end = entry + .end_date + .as_deref() + .and_then(|value| super::parse_iso8601(Some(value))) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()); + match (start, end) { + (None, None) => true, + (start, end) => { + let start = start.unwrap_or(0); + let end = end.unwrap_or(u64::MAX); + start < month_end && end >= month_start + } + } +} + +/// Spend against the monthly budget, on the long-window row like Copilot's +/// calendar-month pool; the budgetless reading still reports the reset time. +fn monthly_section(spend: f64, budget: Option, now: SystemTime) -> UsageSection { + let percentage = match budget { + Some(budget) => (spend / budget * 100.0).clamp(0.0, 100.0), + None => 0.0, + }; + UsageSection { + available: true, + percentage, + resets_at: Some(UNIX_EPOCH + Duration::from_secs(next_month_start(now))), + } +} + +fn current_month_bounds(now: SystemTime) -> (u64, u64) { + let now_secs = now.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let (year, month, _) = civil_from_days(now_secs / 86_400); + let start = days_from_civil(year, month, 1) * 86_400; + let (next_year, next_month) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + let end = days_from_civil(next_year, next_month, 1) * 86_400; + (start, end) +} + +fn next_month_start(now: SystemTime) -> u64 { + current_month_bounds(now).1 +} + +/// Days since 1970-01-01 to civil date, after Howard Hinnant's algorithm. +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days as i64 + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era as u64 * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +fn days_from_civil(year: u64, month: u64, day: u64) -> u64 { + let y = if month <= 2 { year - 1 } else { year } as i64; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = (y - era * 400) as u64; + let mp = if month > 2 { month - 3 } else { month + 9 } as u64; + let doy = (153 * mp + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + (era * 146_097 + doe as i64 - 719_468) as u64 +} + +/// Keys go into a header, so anything that could split it is unusable. +fn is_header_safe_key(key: &str) -> bool { + !key.is_empty() && key.bytes().all(|byte| byte.is_ascii_graphic()) +} + +fn non_empty_environment(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn secret_signature(source: &str, value: &str) -> String { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + format!("{source}|present|{}|{:x}", value.len(), hasher.finish()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(cost: Option, start: Option<&str>, end: Option<&str>) -> BillingUsageEntry { + BillingUsageEntry { + total_cost: cost, + cost: None, + start_date: start.map(str::to_string), + end_date: end.map(str::to_string), + } + } + + #[test] + fn billing_entries_sum_within_the_current_month() { + // 2026-11-03T00:00:00Z is inside November 2026. + let now = UNIX_EPOCH + Duration::from_secs(1_793_664_000); + let entries = vec![ + entry(Some(10.5), Some("2026-11-01T00:00:00Z"), Some("2026-11-03T00:00:00Z")), + entry(Some(2.0), Some("2026-10-31T00:00:00Z"), Some("2026-11-01T00:00:00Z")), + entry(Some(99.0), Some("2026-10-01T00:00:00Z"), Some("2026-10-31T00:00:00Z")), + entry(Some(1.25), None, None), + entry(Some(-5.0), None, None), + entry(None, None, None), + ]; + assert_eq!(monthly_spend(&entries, now), 13.75); + } + + #[test] + fn monthly_section_measures_spend_against_the_budget() { + let now = UNIX_EPOCH + Duration::from_secs(1_793_664_000); + let section = monthly_section(25.0, Some(100.0), now); + assert!(section.available); + assert_eq!(section.percentage, 25.0); + // Resets at midnight UTC on 2026-12-01. + assert_eq!( + section.resets_at, + Some(UNIX_EPOCH + Duration::from_secs(1_796_083_200)) + ); + + let uncapped = monthly_section(25.0, None, now); + assert_eq!(uncapped.percentage, 0.0); + assert!(uncapped.resets_at.is_some()); + + let over = monthly_section(250.0, Some(100.0), now); + assert_eq!(over.percentage, 100.0); + } + + #[test] + fn month_bounds_wrap_the_year() { + // 2026-11-30T00:00:00Z: November runs 2026-11-01 .. 2026-12-01. + let november = UNIX_EPOCH + Duration::from_secs(1_796_083_200 - 86_400); + assert_eq!(current_month_bounds(november), (1_793_491_200, 1_796_083_200)); + // 2026-12-31T00:00:00Z: December wraps into January 2027. + let december = UNIX_EPOCH + Duration::from_secs(1_798_761_600 - 86_400); + assert_eq!(current_month_bounds(december), (1_796_083_200, 1_798_761_600)); + } + + #[test] + fn rate_limit_windows_prefer_requests_and_clamp_to_the_limit() { + let window = window_from_headers(Some(100), Some(40), Some("30")).unwrap(); + assert!(window.available); + assert_eq!(window.percentage, 60.0); + assert!(window.resets_at.is_some()); + + let saturated = window_from_headers(Some(100), Some(-5), None).unwrap(); + assert_eq!(saturated.percentage, 100.0); + assert!(saturated.resets_at.is_none()); + + assert!(window_from_headers(Some(0), Some(0), None).is_none()); + assert!(window_from_headers(None, Some(5), None).is_none()); + assert!(window_from_headers(Some(100), None, None).is_none()); + } + + #[test] + fn reset_headers_accept_seconds_and_duration_strings() { + assert_eq!(parse_reset_seconds(Some("45")), Some(45)); + assert_eq!(parse_reset_seconds(Some("30s")), Some(30)); + assert_eq!(parse_reset_seconds(Some("2m")), Some(120)); + assert_eq!(parse_reset_seconds(Some("1h")), Some(3_600)); + assert_eq!(parse_reset_seconds(Some("soon")), None); + assert_eq!(parse_reset_seconds(Some("")), None); + assert_eq!(parse_reset_seconds(None), None); + } + + #[test] + fn keys_that_could_split_a_header_are_refused() { + assert!(is_header_safe_key("abc123XYZ")); + assert!(!is_header_safe_key("")); + assert!(!is_header_safe_key("abc\r\nInjected: yes")); + assert!(!is_header_safe_key("two words")); + } + + #[test] + fn civil_date_round_trips_through_the_days_algorithms() { + for days in [0_u64, 1, 365, 19_000, 21_000, 56_000] { + let (y, m, d) = civil_from_days(days); + assert_eq!(days_from_civil(y, m, d), days); + } + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(21_018), (2027, 7, 19)); + } +} diff --git a/src/poller/tests.rs b/src/poller/tests.rs index a9665996..0f1fc631 100644 --- a/src/poller/tests.rs +++ b/src/poller/tests.rs @@ -139,6 +139,7 @@ fn claude_failure_does_not_block_codex_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::Mistral => unreachable!("Mistral is disabled"), }, ) .expect("codex data should keep the poll successful"); @@ -162,6 +163,7 @@ fn codex_failure_does_not_block_claude_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::Mistral => unreachable!("Mistral is disabled"), }, ) .expect("claude data should keep the poll successful"); @@ -185,6 +187,7 @@ fn returns_first_error_when_no_enabled_provider_succeeds() { ProviderId::Cursor => Err(PollError::NoCredentials), ProviderId::Grok => Err(PollError::NoCredentials), ProviderId::Copilot => Err(PollError::NoCredentials), + ProviderId::Mistral => Err(PollError::NoCredentials), }, ) .expect_err("all-provider failure should return an error"); @@ -305,6 +308,7 @@ fn antigravity_failure_does_not_block_codex_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::Mistral => unreachable!("Mistral is disabled"), }, ) .expect("codex data should keep the poll successful"); @@ -328,6 +332,7 @@ fn opencode_failure_does_not_block_codex_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::Mistral => unreachable!("Mistral is disabled"), }, ) .expect("Codex data should keep the poll successful"); diff --git a/src/providers.rs b/src/providers.rs index aa685b6b..a8da3c34 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -16,6 +16,7 @@ pub enum ProviderId { Cursor = 4, Grok = 5, Copilot = 6, + Mistral = 7, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -34,7 +35,7 @@ pub struct ProviderDescriptor { pub default_enabled: bool, } -pub const PROVIDER_DESCRIPTORS: [ProviderDescriptor; 7] = [ +pub const PROVIDER_DESCRIPTORS: [ProviderDescriptor; 8] = [ ProviderDescriptor { id: ProviderId::Claude, key: "claude", @@ -98,10 +99,19 @@ pub const PROVIDER_DESCRIPTORS: [ProviderDescriptor; 7] = [ native_menu_command_id: 66, default_enabled: false, }, + ProviderDescriptor { + id: ProviderId::Mistral, + key: "mistral", + cache_key: "mistral", + display_name: "Mistral", + settings_description: "Collect usage from Mistral", + native_menu_command_id: 69, + default_enabled: false, + }, ]; impl ProviderId { - pub const ALL: [Self; 7] = [ + pub const ALL: [Self; 8] = [ Self::Claude, Self::Codex, Self::Antigravity, @@ -109,6 +119,7 @@ impl ProviderId { Self::Cursor, Self::Grok, Self::Copilot, + Self::Mistral, ]; pub const fn descriptor(self) -> &'static ProviderDescriptor { diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index 73e7c38d..2474e99b 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -2327,7 +2327,7 @@ fn classic_vertical_stacks_enabled_providers_and_preserves_tray_roots() { ThemeRuntime::from_providers(providers).with_host_dimensions(host_width, 1440); let width = (host_width - 2).min(62); let provider_height = |provider| match provider { - ProviderId::Grok | ProviderId::Copilot => 42, + ProviderId::Grok | ProviderId::Copilot | ProviderId::Mistral => 42, _ => 64, }; let height = providers.iter().map(provider_height).sum(); diff --git a/src/theme_engine/theme_expression.rs b/src/theme_engine/theme_expression.rs index 21fb22f9..ad0e5a2e 100644 --- a/src/theme_engine/theme_expression.rs +++ b/src/theme_engine/theme_expression.rs @@ -523,6 +523,7 @@ pub(super) fn format_usage_line(base: &str, context: &DataContext) -> Option= 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "colour", + "colour": { + "color": "#9D174DFF" + } + }, + "corner_radius": "3" + }, + { + "id": "mistral-tray-low-fill", + "name": "Mistral normal black fill", + "render": "mistral.headline.percentage < 90", + "parent": "mistral-tray-icon", + "x": "3", + "y": "3", + "width": "58", + "height": "58", + "background": { + "type": "colour", + "colour": { + "color": "#E05E00FF" + } + }, + "corner_radius": "1" + }, + { + "id": "mistral-tray-high-fill", + "name": "Mistral high-usage white fill", + "render": "mistral.headline.percentage >= 90", + "parent": "mistral-tray-icon", + "x": "3", + "y": "3", + "width": "58", + "height": "58", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF" + } + }, + "corner_radius": "1" + }, + { + "id": "mistral-tray-placeholder", + "name": "Mistral loading placeholder", + "render": "data.poll_ok == 0 || mistral.available == 0", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "Mi", + "font_family": "Arial Bold", + "font_size": "34", + "weight": "bold", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-low-one-digit", + "name": "Mistral normal one-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display < 9.5 && mistral.headline.percentage < 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "50", + "weight": "bold", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-low-two-digits", + "name": "Mistral normal two-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 9.5 && mistral.headline.display < 99.5 && mistral.headline.percentage < 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "42", + "weight": "bold", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-low-three-digits", + "name": "Mistral normal three-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 99.5 && mistral.headline.percentage < 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "30", + "weight": "bold", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-high-one-digit", + "name": "Mistral high-usage one-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display < 9.5 && mistral.headline.percentage >= 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "50", + "weight": "bold", + "align": "center", + "color": { + "color": "#9D174DFF" + } + } + }, + { + "id": "mistral-tray-high-two-digits", + "name": "Mistral high-usage two-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 9.5 && mistral.headline.display < 99.5 && mistral.headline.percentage >= 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "42", + "weight": "bold", + "align": "center", + "color": { + "color": "#9D174DFF" + } + } + }, + { + "id": "mistral-tray-high-three-digits", + "name": "Mistral high-usage three-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 99.5 && mistral.headline.percentage >= 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "30", + "weight": "bold", + "align": "center", + "color": { + "color": "#9D174DFF" + } + } + } + ] + }, { "id": "opencode-tray-icon", "name": "OpenCode usage", diff --git a/src/themes/classic-vertical.json b/src/themes/classic-vertical.json index 97af21ee..fbd3d547 100644 --- a/src/themes/classic-vertical.json +++ b/src/themes/classic-vertical.json @@ -20,7 +20,7 @@ "offset_y": -52 }, "width": "min(62, max(1, host.width - 2))", - "height": "max(1, providers.count * 64 - (providers.grok.enabled + providers.copilot.enabled) * 22)", + "height": "max(1, providers.count * 64 - (providers.grok.enabled + providers.copilot.enabled + providers.mistral.enabled) * 22)", "mouse_events": { "right_click": "show_context_menu(\"classic-v1\")" }, @@ -1112,6 +1112,14 @@ "width": "canvas.width", "height": "42" }, + { + "id": "mistral-provider", + "name": "Mistral", + "parent": "provider-column", + "render": "providers.mistral.enabled", + "width": "canvas.width", + "height": "42" + }, { "id": "copilot-title", "name": "copilot-title", @@ -1135,6 +1143,29 @@ } } }, + { + "id": "mistral-title", + "name": "mistral-title", + "parent": "mistral-provider", + "render": "system.dark", + "x": "2", + "y": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "content": { + "type": "text", + "template": "Mistral", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#FF7000" + } + } + }, { "id": "copilot-title-light", "name": "copilot-title-light", @@ -1158,6 +1189,29 @@ } } }, + { + "id": "mistral-title-light", + "name": "mistral-title-light", + "parent": "mistral-provider", + "render": "1 - system.dark", + "x": "2", + "y": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "content": { + "type": "text", + "template": "Mistral", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#9A3412" + } + } + }, { "id": "copilot-weekly-value-dark", "name": "copilot-weekly-value-dark", @@ -1181,6 +1235,29 @@ } } }, + { + "id": "mistral-weekly-value-dark", + "name": "mistral-weekly-value-dark", + "parent": "mistral-provider", + "render": "system.dark", + "x": "2", + "y": "18", + "width": "max(1, canvas.width - 4)", + "height": "14", + "content": { + "type": "text", + "template": "{mistral.weekly.label} {mistral.weekly.display:usage_badge}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#F5F5F5" + } + } + }, { "id": "copilot-weekly-value-light", "name": "copilot-weekly-value-light", @@ -1204,6 +1281,29 @@ } } }, + { + "id": "mistral-weekly-value-light", + "name": "mistral-weekly-value-light", + "parent": "mistral-provider", + "render": "1 - system.dark", + "x": "2", + "y": "18", + "width": "max(1, canvas.width - 4)", + "height": "14", + "content": { + "type": "text", + "template": "{mistral.weekly.label} {mistral.weekly.display:usage_badge}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#202020" + } + } + }, { "id": "copilot-weekly-bar", "name": "copilot weekly usage", @@ -1225,6 +1325,28 @@ }, "corner_radius": "1" } + }, + { + "id": "mistral-weekly-bar", + "name": "mistral weekly usage", + "parent": "mistral-provider", + "render": "mistral.weekly.available", + "x": "4", + "y": "33", + "width": "max(1, canvas.width - 8)", + "height": "3", + "content": { + "type": "progress", + "value": "mistral.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#DB2777" + }, + "track": { + "color": "#80808040" + }, + "corner_radius": "1" + } } ] }, @@ -2555,6 +2677,212 @@ } ] }, + { + "id": "mistral-tray-icon", + "name": "Mistral usage", + "render": "providers.mistral.enabled", + "mouse_events": { + "click": "toggle(\"main\", render)", + "double_click": "toggle(\"main\", render)", + "right_click": "show_context_menu(\"classic-v1\")" + }, + "placement": { + "nest": "tray_icon" + }, + "width": "64", + "height": "64", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF" + } + }, + "corner_radius": "3", + "children": [ + { + "id": "mistral-tray-high-outline", + "name": "Mistral high-usage black outline", + "render": "mistral.headline.percentage >= 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "colour", + "colour": { + "color": "#9D174DFF" + } + }, + "corner_radius": "3" + }, + { + "id": "mistral-tray-low-fill", + "name": "Mistral normal black fill", + "render": "mistral.headline.percentage < 90", + "parent": "mistral-tray-icon", + "x": "3", + "y": "3", + "width": "58", + "height": "58", + "background": { + "type": "colour", + "colour": { + "color": "#DB2777FF" + } + }, + "corner_radius": "1" + }, + { + "id": "mistral-tray-high-fill", + "name": "Mistral high-usage white fill", + "render": "mistral.headline.percentage >= 90", + "parent": "mistral-tray-icon", + "x": "3", + "y": "3", + "width": "58", + "height": "58", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF" + } + }, + "corner_radius": "1" + }, + { + "id": "mistral-tray-placeholder", + "name": "Mistral loading placeholder", + "render": "data.poll_ok == 0 || mistral.available == 0", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "Mi", + "font_family": "Arial Bold", + "font_size": "34", + "weight": "bold", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-low-one-digit", + "name": "Mistral normal one-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display < 9.5 && mistral.headline.percentage < 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "50", + "weight": "bold", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-low-two-digits", + "name": "Mistral normal two-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 9.5 && mistral.headline.display < 99.5 && mistral.headline.percentage < 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "42", + "weight": "bold", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-low-three-digits", + "name": "Mistral normal three-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 99.5 && mistral.headline.percentage < 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "30", + "weight": "bold", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-high-one-digit", + "name": "Mistral high-usage one-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display < 9.5 && mistral.headline.percentage >= 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "50", + "weight": "bold", + "align": "center", + "color": { + "color": "#9D174DFF" + } + } + }, + { + "id": "mistral-tray-high-two-digits", + "name": "Mistral high-usage two-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 9.5 && mistral.headline.display < 99.5 && mistral.headline.percentage >= 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "42", + "weight": "bold", + "align": "center", + "color": { + "color": "#9D174DFF" + } + } + }, + { + "id": "mistral-tray-high-three-digits", + "name": "Mistral high-usage three-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 99.5 && mistral.headline.percentage >= 90", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "30", + "weight": "bold", + "align": "center", + "color": { + "color": "#9D174DFF" + } + } + } + ] + }, { "id": "opencode-tray-icon", "name": "OpenCode usage", diff --git a/src/themes/compact-fluent-quad.json b/src/themes/compact-fluent-quad.json index 0a5a9e2e..882b94a5 100644 --- a/src/themes/compact-fluent-quad.json +++ b/src/themes/compact-fluent-quad.json @@ -2784,6 +2784,14 @@ "width": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62", "height": "46" }, + { + "id": "mistral-provider", + "name": "Mistral provider", + "render": "providers.mistral.enabled", + "parent": "provider-row", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62", + "height": "46" + }, { "id": "copilot-pool-segments-dark", "name": "dark Copilot pool segments", @@ -2819,6 +2827,41 @@ "segment_gap": "1" } }, + { + "id": "mistral-pool-segments-dark", + "name": "dark Mistral pool segments", + "render": "system.dark", + "visibility": "100", + "parent": "mistral-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "mistral.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#FF8A00FF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, { "id": "copilot-pool-value-dark", "name": "dark Copilot pool value", @@ -2852,6 +2895,39 @@ } } }, + { + "id": "mistral-pool-value-dark", + "name": "dark Mistral pool value", + "render": "system.dark", + "visibility": "100", + "parent": "mistral-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "62", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{mistral.weekly.display:usage_line}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#FFC49AFF", + "opacity": "1" + } + } + }, { "id": "copilot-pool-segments-light", "name": "light Copilot pool segments", @@ -2887,6 +2963,41 @@ "segment_gap": "1" } }, + { + "id": "mistral-pool-segments-light", + "name": "light Mistral pool segments", + "render": "1 - system.dark", + "visibility": "100", + "parent": "mistral-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "mistral.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#E05E00FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, { "id": "copilot-pool-value-light", "name": "light Copilot pool value", @@ -2919,6 +3030,39 @@ "opacity": "1" } } + }, + { + "id": "mistral-pool-value-light", + "name": "light Mistral pool value", + "render": "1 - system.dark", + "visibility": "100", + "parent": "mistral-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "62", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{mistral.weekly.display:usage_line}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#C24E00FF", + "opacity": "1" + } + } } ] }, @@ -4828,6 +4972,336 @@ } ] }, + { + "id": "mistral-tray-icon", + "name": "Mistral usage", + "render": "0", + "visibility": "100", + "placement": { + "reference": { + "region": "system_tray", + "display": 0 + }, + "nest": "tray_icon", + "horizontal": "right", + "vertical": "bottom", + "surface_horizontal": "right", + "surface_vertical": "bottom", + "offset_x": 0, + "offset_y": 0 + }, + "width": "64", + "height": "64", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "1" + } + }, + "border": null, + "mouse_events": { + "click": "toggle(\"main\", render)", + "double_click": "toggle(\"main\", render)", + "right_click": "show_context_menu(\"classic-v1\")" + }, + "corner_radius": "3", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + }, + "children": [ + { + "id": "mistral-tray-high-outline", + "name": "Mistral high-usage black outline", + "render": "mistral.headline.percentage >= 90", + "visibility": "100", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "colour", + "colour": { + "color": "#9D174DFF", + "opacity": "1" + } + }, + "border": null, + "corner_radius": "3", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mistral-tray-low-fill", + "name": "Mistral normal black fill", + "render": "mistral.headline.percentage < 90", + "visibility": "100", + "parent": "mistral-tray-icon", + "x": "3", + "y": "3", + "width": "58", + "height": "58", + "background": { + "type": "colour", + "colour": { + "color": "#E05E00FF", + "opacity": "1" + } + }, + "border": null, + "corner_radius": "1", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mistral-tray-high-fill", + "name": "Mistral high-usage white fill", + "render": "mistral.headline.percentage >= 90", + "visibility": "100", + "parent": "mistral-tray-icon", + "x": "3", + "y": "3", + "width": "58", + "height": "58", + "background": { + "type": "colour", + "colour": { + "color": "#FFFFFFFF", + "opacity": "1" + } + }, + "border": null, + "corner_radius": "1", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "mistral-tray-placeholder", + "name": "Mistral loading placeholder", + "render": "data.poll_ok == 0 || mistral.available == 0", + "visibility": "100", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "Mi", + "font_family": "Arial Bold", + "font_size": "34", + "weight": "bold", + "rendering": "antialiased", + "contrast": "1.4", + "align": "center", + "color": { + "color": "#FFFFFFFF", + "opacity": "1" + } + } + }, + { + "id": "mistral-tray-low-one-digit", + "name": "Mistral normal one-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display < 9.5 && mistral.headline.percentage < 90", + "visibility": "100", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "50", + "weight": "bold", + "rendering": "antialiased", + "contrast": "1.4", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-low-two-digits", + "name": "Mistral normal two-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 9.5 && mistral.headline.display < 99.5 && mistral.headline.percentage < 90", + "visibility": "100", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "42", + "weight": "bold", + "rendering": "antialiased", + "contrast": "1.4", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-low-three-digits", + "name": "Mistral normal three-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 99.5 && mistral.headline.percentage < 90", + "visibility": "100", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "30", + "weight": "bold", + "rendering": "antialiased", + "contrast": "1.4", + "align": "center", + "color": { + "color": "#FFFFFFFF" + } + } + }, + { + "id": "mistral-tray-high-one-digit", + "name": "Mistral high-usage one-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display < 9.5 && mistral.headline.percentage >= 90", + "visibility": "100", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "50", + "weight": "bold", + "rendering": "antialiased", + "contrast": "1.4", + "align": "center", + "color": { + "color": "#9D174DFF" + } + } + }, + { + "id": "mistral-tray-high-two-digits", + "name": "Mistral high-usage two-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 9.5 && mistral.headline.display < 99.5 && mistral.headline.percentage >= 90", + "visibility": "100", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "42", + "weight": "bold", + "rendering": "antialiased", + "contrast": "1.4", + "align": "center", + "color": { + "color": "#9D174DFF" + } + } + }, + { + "id": "mistral-tray-high-three-digits", + "name": "Mistral high-usage three-digit percentage", + "render": "data.poll_ok && mistral.available && mistral.headline.display >= 99.5 && mistral.headline.percentage >= 90", + "visibility": "100", + "parent": "mistral-tray-icon", + "width": "64", + "height": "64", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{mistral.headline.display:0}", + "font_family": "Arial Bold", + "font_size": "30", + "weight": "bold", + "rendering": "antialiased", + "contrast": "1.4", + "align": "center", + "color": { + "color": "#9D174DFF" + } + } + } + ] + }, { "id": "opencode-tray-icon", "name": "OpenCode usage", diff --git a/src/ui/theme.rs b/src/ui/theme.rs index f9f7a449..c1f8e3a4 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -39,6 +39,7 @@ const UI_FALLBACK_FONT_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/ /// | U+F005 | Cursor | Lobe Icons (MIT) | /// | U+F006 | Grok | Lobe Icons (MIT) | /// | U+F007 | Copilot | Lobe Icons (MIT) | +/// | U+F008 | Mistral | Simple Icons (CC0-1.0) | /// /// Those licences cover the path data. The logos remain the trademarks of their /// owners and identify the provider a setting belongs to. @@ -62,6 +63,7 @@ pub(crate) fn provider_mark_glyph(provider: ProviderId) -> char { ProviderId::Cursor => '\u{f005}', ProviderId::Grok => '\u{f006}', ProviderId::Copilot => '\u{f007}', + ProviderId::Mistral => '\u{f008}', } } diff --git a/tests/windows-vm/cases/vertical-taskbar.ps1 b/tests/windows-vm/cases/vertical-taskbar.ps1 index 480706ed..c9e73258 100644 --- a/tests/windows-vm/cases/vertical-taskbar.ps1 +++ b/tests/windows-vm/cases/vertical-taskbar.ps1 @@ -93,7 +93,7 @@ function Select-VerticalTheme([string]$Path, [switch]$AllProviders, [switch]$Loc language='en'; poll_interval_ms=900000; custom_theme_enabled=$true; active_theme_path=$Path lock_taskbar=[bool]$LockTaskbar show_claude_code=$true; show_codex=[bool]$AllProviders; show_antigravity=[bool]$AllProviders - show_opencode=[bool]$AllProviders; show_cursor=[bool]$AllProviders; show_grok=[bool]$AllProviders; show_copilot=[bool]$AllProviders + show_opencode=[bool]$AllProviders; show_cursor=[bool]$AllProviders; show_grok=[bool]$AllProviders; show_copilot=[bool]$AllProviders; show_mistral=[bool]$AllProviders last_update_check_unix=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } } @@ -229,7 +229,7 @@ try { $fixture.id = 'classic-vertical-sample' $fixture.name = 'Classic Vertical sample usage fixture' foreach ($object in $fixture.surfaces[0].children) { - if ($object.id -match '^(claude|codex|antigravity|opencode|cursor|grok|copilot)-(session|weekly)-') { + if ($object.id -match '^(claude|codex|antigravity|opencode|cursor|grok|copilot|mistral)-(session|weekly)-') { $provider = $Matches[1]; $window = $Matches[2] if ($object.content.type -eq 'progress') { $object.render = '1' @@ -237,7 +237,7 @@ try { } elseif ($object.content.type -eq 'text') { $badge = '{' + $provider + '.' + $window + '.display:usage_badge}' $object.content.template = $object.content.template.Replace($badge, $(if ($window -eq 'session') { '42%' } else { '69%' })) - $object.content.template = $object.content.template.Replace(('{' + $provider + '.weekly.label}'), $(if ($provider -in @('grok','copilot')) { '30d' } else { '7d' })) + $object.content.template = $object.content.template.Replace(('{' + $provider + '.weekly.label}'), $(if ($provider -in @('grok','copilot','mistral')) { '30d' } else { '7d' })) } } } From e78a03e4b14731513aebe4ee1148ffb1d1a7f2c7 Mon Sep 17 00:00:00 2001 From: anmartin Date: Tue, 3 Nov 2026 16:13:39 +0100 Subject: [PATCH 2/2] fix(provider): correct Mistral poll result building and theme test expectations (#205) - Build the UsageData with an early return instead of matching on (bool, Option), which did not type-check. - Drop a u64 -> u64 cast flagged by clippy in days_from_civil. - Update classic theme expectations for the eighth provider: widget width with all providers 725 -> 815, tray-icon surface count 8 -> 9, and the long-window-only segment layers 30 -> 32 in the round-trip test. - Recolour the Mistral tray fill in classic-vertical to the same Mistral orange used in the other bundled themes so the tray roots match the starter theme. - Apply cargo fmt. Verified with cargo fmt --check, cargo check/clippy --target x86_64-pc-windows-gnu --all-targets, and the full test suite (631 tests) run under wine: 620 pass; the 5 remaining failures are identical on unpatched main and are wine environment limits (Segoe UI metrics, shim execution, window lifecycle, winsqlite3 stub). Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- src/poller/mistral.rs | 75 +++++++++++++++++++++----------- src/theme_engine/tests.rs | 14 +++--- src/themes/classic-vertical.json | 2 +- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/poller/mistral.rs b/src/poller/mistral.rs index f7591ec7..5d8eefd8 100644 --- a/src/poller/mistral.rs +++ b/src/poller/mistral.rs @@ -102,7 +102,10 @@ fn api_key_candidates() -> Vec { key: secret.trim().to_string(), }); } - candidates.into_iter().filter(|candidate| is_header_safe_key(&candidate.key)).collect() + candidates + .into_iter() + .filter(|candidate| is_header_safe_key(&candidate.key)) + .collect() } fn fetch_mistral_usage(api_key: &str) -> Result { @@ -145,22 +148,20 @@ fn fetch_mistral_usage(api_key: &str) -> Result { }; let monthly = spend.map(|spend| monthly_section(spend, budget, SystemTime::now())); - match (session.is_some(), monthly.is_some()) { - (false, None) => { - diagnose::log("Mistral response carried neither rate-limit headers nor spend data"); - Err(PollError::UnexpectedResponse) - } - _ => Ok(UsageData { - limits: Vec::new(), - session: session.unwrap_or_default(), - weekly: monthly.clone().unwrap_or_default(), - weekly_label: monthly.as_ref().map(|_| "30d".into()), - monthly, - credits: None, - stale: false, - ..Default::default() - }), + if session.is_none() && monthly.is_none() { + diagnose::log("Mistral response carried neither rate-limit headers nor spend data"); + return Err(PollError::UnexpectedResponse); } + Ok(UsageData { + limits: Vec::new(), + session: session.unwrap_or_default(), + weekly: monthly.clone().unwrap_or_default(), + weekly_label: monthly.as_ref().map(|_| "30d".into()), + monthly, + credits: None, + stale: false, + ..Default::default() + }) } /// Monthly spend and budget are optional; a 403 on billing routes simply means @@ -213,7 +214,10 @@ fn fetch_billing( .body_mut() .read_json() .map_err(|error| { - diagnose::log_error("unable to parse Mistral billing subscription response", error); + diagnose::log_error( + "unable to parse Mistral billing subscription response", + error, + ); PollError::UnexpectedResponse })?; let budget = subscription @@ -253,7 +257,7 @@ fn window_from_headers( ) -> Option { let limit = limit?; let remaining = remaining?; - if limit <= 0 || remaining < 0 { + if limit <= 0 { return None; } let percentage = ((limit - remaining) as f64 / limit as f64 * 100.0).clamp(0.0, 100.0); @@ -340,7 +344,10 @@ fn monthly_section(spend: f64, budget: Option, now: SystemTime) -> UsageSec } fn current_month_bounds(now: SystemTime) -> (u64, u64) { - let now_secs = now.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let now_secs = now + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); let (year, month, _) = civil_from_days(now_secs / 86_400); let start = days_from_civil(year, month, 1) * 86_400; let (next_year, next_month) = if month == 12 { @@ -374,7 +381,7 @@ fn days_from_civil(year: u64, month: u64, day: u64) -> u64 { let y = if month <= 2 { year - 1 } else { year } as i64; let era = if y >= 0 { y } else { y - 399 } / 400; let yoe = (y - era * 400) as u64; - let mp = if month > 2 { month - 3 } else { month + 9 } as u64; + let mp = if month > 2 { month - 3 } else { month + 9 }; let doy = (153 * mp + 2) / 5 + day - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; (era * 146_097 + doe as i64 - 719_468) as u64 @@ -416,9 +423,21 @@ mod tests { // 2026-11-03T00:00:00Z is inside November 2026. let now = UNIX_EPOCH + Duration::from_secs(1_793_664_000); let entries = vec![ - entry(Some(10.5), Some("2026-11-01T00:00:00Z"), Some("2026-11-03T00:00:00Z")), - entry(Some(2.0), Some("2026-10-31T00:00:00Z"), Some("2026-11-01T00:00:00Z")), - entry(Some(99.0), Some("2026-10-01T00:00:00Z"), Some("2026-10-31T00:00:00Z")), + entry( + Some(10.5), + Some("2026-11-01T00:00:00Z"), + Some("2026-11-03T00:00:00Z"), + ), + entry( + Some(2.0), + Some("2026-10-31T00:00:00Z"), + Some("2026-11-01T00:00:00Z"), + ), + entry( + Some(99.0), + Some("2026-10-01T00:00:00Z"), + Some("2026-10-31T00:00:00Z"), + ), entry(Some(1.25), None, None), entry(Some(-5.0), None, None), entry(None, None, None), @@ -450,10 +469,16 @@ mod tests { fn month_bounds_wrap_the_year() { // 2026-11-30T00:00:00Z: November runs 2026-11-01 .. 2026-12-01. let november = UNIX_EPOCH + Duration::from_secs(1_796_083_200 - 86_400); - assert_eq!(current_month_bounds(november), (1_793_491_200, 1_796_083_200)); + assert_eq!( + current_month_bounds(november), + (1_793_491_200, 1_796_083_200) + ); // 2026-12-31T00:00:00Z: December wraps into January 2027. let december = UNIX_EPOCH + Duration::from_secs(1_798_761_600 - 86_400); - assert_eq!(current_month_bounds(december), (1_796_083_200, 1_798_761_600)); + assert_eq!( + current_month_bounds(december), + (1_796_083_200, 1_798_761_600) + ); } #[test] diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index 2474e99b..0d8fe2df 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -1086,13 +1086,13 @@ fn starter_theme_round_trips_and_validates() { .collect::>(); // Classic contains separate light and dark progress layers so the // 1.4.9 palette follows the taskbar mode without runtime recolouring: - // five providers over two windows in two modes, plus Grok and Copilot, - // which fill only the long-window row, plus a credit overlay on that row - // for the three providers that report credits, plus the two eight-segment - // Fable layers. + // five providers over two windows in two modes, plus Grok, Copilot and + // Mistral, which fill only the long-window row, plus a credit overlay on + // that row for the three providers that report credits, plus the two + // eight-segment Fable layers. assert_eq!( segments, - [vec![10; 5 * 2 * 2 + 2 * 2 + 3 * 2], vec![8; 2]].concat() + [vec![10; 5 * 2 * 2 + 3 * 2 + 3 * 2], vec![8; 2]].concat() ); assert!(theme.surfaces[0] .children @@ -2131,7 +2131,7 @@ fn starter_adapts_width_segments_and_collapsed_provider_rows() { ), ( ThemeRuntime::from_providers(ProviderSet::from_enabled(ProviderId::ALL)), - 725, + 815, 2, ), ] { @@ -2207,7 +2207,7 @@ fn starter_has_a_taskbar_widget_and_provider_tray_icons() { theme.surfaces[0].placement.reference.region, ReferenceRegion::SystemTray ); - assert_eq!(theme.surfaces.len(), 8); + assert_eq!(theme.surfaces.len(), 9); assert!(theme.surfaces[1..] .iter() .all(|surface| surface.placement.nest == SurfaceNest::TrayIcon)); diff --git a/src/themes/classic-vertical.json b/src/themes/classic-vertical.json index fbd3d547..14368390 100644 --- a/src/themes/classic-vertical.json +++ b/src/themes/classic-vertical.json @@ -2726,7 +2726,7 @@ "background": { "type": "colour", "colour": { - "color": "#DB2777FF" + "color": "#E05E00FF" } }, "corner_radius": "1"