From 4305f6afa8646e326c7f5c2cd1b1fb8a02cc1984 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:54:38 +0700 Subject: [PATCH 01/10] Port upstream 0.50.1: Cursor rename + Ollama cookie stripping (#2951, #2949) --- rust/src/providers/cursor/api.rs | 12 ++++--- rust/src/providers/cursor/mod.rs | 2 +- rust/src/providers/ollama/mod.rs | 56 ++++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/rust/src/providers/cursor/api.rs b/rust/src/providers/cursor/api.rs index 9bb3b82748..e3248030f1 100755 --- a/rust/src/providers/cursor/api.rs +++ b/rust/src/providers/cursor/api.rs @@ -290,12 +290,13 @@ fn clamp_percent(value: f64) -> f64 { /// Period label for plan-included spend from usage-summary (no new network calls). fn plan_period_label(billing_cycle_start: Option<&str>) -> String { + // Upstream 0.50.1 #2951: match the Cursor dashboard's name for the + // included-usage pool (Cursor + third-party models). match billing_cycle_start { - Some(start) if !start.is_empty() => format!("Plan (since {start})"), - _ => "Plan (billing cycle)".to_string(), + Some(start) if !start.is_empty() => format!("Cursor and Third Party (since {start})"), + _ => "Cursor and Third Party (billing cycle)".to_string(), } } - impl Default for CursorApi { fn default() -> Self { Self::new() @@ -589,7 +590,10 @@ mod tests { let cost = cost.expect("plan cost"); assert!((cost.used - 25.0).abs() < 0.01); assert_eq!(cost.limit, Some(50.0)); - assert_eq!(cost.period, "Plan (since 2026-03-01T00:00:00Z)"); + assert_eq!( + cost.period, + "Cursor and Third Party (since 2026-03-01T00:00:00Z)" + ); } #[test] diff --git a/rust/src/providers/cursor/mod.rs b/rust/src/providers/cursor/mod.rs index cef75060b2..b648b21962 100755 --- a/rust/src/providers/cursor/mod.rs +++ b/rust/src/providers/cursor/mod.rs @@ -28,7 +28,7 @@ impl CursorProvider { id: ProviderId::Cursor, display_name: "Cursor", session_label: "Plan", - weekly_label: "Auto", + weekly_label: "Cursor", supports_opus: false, // Upstream #2338: Cursor has no account credit balance to advertise. supports_credits: false, diff --git a/rust/src/providers/ollama/mod.rs b/rust/src/providers/ollama/mod.rs index f7a4f82f02..f30138cb28 100755 --- a/rust/src/providers/ollama/mod.rs +++ b/rust/src/providers/ollama/mod.rs @@ -230,7 +230,7 @@ impl OllamaProvider { } fn normalize_cookie_header(input: &str) -> Option { - let mut header = input.trim(); + let mut header = strip_curl_cookie_wrapper(input); if header.is_empty() { return None; } @@ -247,12 +247,32 @@ impl OllamaProvider { } if header.contains('=') { - Some(header.to_string()) + // Upstream 0.50.1 #2949: a copied `Cookie:` label can appear + // mid-string when another cookie comes first — drop the label + // from every `;`-separated segment before sending. + let cleaned = header + .split(';') + .map(str::trim) + .map(|segment| { + if segment + .get(.."cookie:".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) + { + segment["cookie:".len()..].trim().to_string() + } else { + segment.to_string() + } + }) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("; "); + (!cleaned.is_empty()).then_some(cleaned) } else { Some(format!("{OLLAMA_SESSION_COOKIE_NAME}={header}")) } } + /// Resolve cookies from manual cookies, validated cache, or browser import. /// /// Upstream #2404: reuse the last validated browser session cookie header @@ -297,6 +317,18 @@ impl OllamaProvider { CookieHeaderCache::clear(ProviderId::Ollama); } } +/// Strip copied cURL cookie syntax (`-b …`, `--cookie …`, `-H …`) and the +/// surrounding quotes before normalizing the header value (upstream 0.50.1 +/// #2949). +fn strip_curl_cookie_wrapper(raw: &str) -> &str { + let mut header = raw.trim(); + for prefix in ["-b ", "--cookie ", "-H "] { + if let Some(rest) = header.strip_prefix(prefix) { + header = rest.trim(); + } + } + header.trim_matches('\'').trim_matches('"').trim() +} /// Resolve a browser/session cookie header for Ollama. /// @@ -726,6 +758,26 @@ mod tests { ); } + #[test] + fn strips_mid_string_cookie_label_and_curl_syntax() { + // Upstream 0.50.1 #2949: a copied `Cookie:` label after another + // cookie, and cURL `-H`/`-b` wrappers with quotes. + assert_eq!( + OllamaProvider::normalize_cookie_header( + "aid=device; Cookie: __Secure-session=abc123" + ), + Some("aid=device; __Secure-session=abc123".to_string()) + ); + assert_eq!( + OllamaProvider::normalize_cookie_header("-H 'Cookie: __Secure-session=abc123'"), + Some("__Secure-session=abc123".to_string()) + ); + assert_eq!( + OllamaProvider::normalize_cookie_header("-b \"__Secure-session=abc123\""), + Some("__Secure-session=abc123".to_string()) + ); + } + #[test] fn ignores_empty_cookie_input() { assert_eq!(OllamaProvider::normalize_cookie_header(" "), None); From f95a3b884ee49c74c6515b947b51b4644d142f56 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:00:44 +0700 Subject: [PATCH 02/10] Port upstream 0.50.1: OpenCode Go session+weekly pace in CLI (#2957) --- rust/src/cli/usage.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index ed6062d58b..2f8fe34237 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -360,12 +360,34 @@ fn render_json_result( result: ProviderFetchResult, status: Option<&StatusInfo>, ) -> serde_json::Value { + let usage = &result.usage; + let primary_pace = usage + .primary + .window_minutes + .is_some_and(|m| m == crate::core::SESSION_WINDOW_MINUTES) + .then(|| { + UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES) + }) + .flatten() + .map(pace_json); + let secondary_pace = usage + .secondary + .as_ref() + .and_then(|w| UsagePace::weekly(w, None, w.window_minutes.unwrap_or(10080))) + .map(pace_json); + let mut json_result = serde_json::json!({ "provider": provider_id.cli_name(), "source": result.source_label, "usage": result.usage, "cost": result.cost, }); + if primary_pace.is_some() || secondary_pace.is_some() { + json_result["pace"] = serde_json::json!({ + "primary": primary_pace, + "secondary": secondary_pace, + }); + } if let Some(s) = status { json_result["status"] = serde_json::json!({ @@ -377,6 +399,16 @@ fn render_json_result( json_result } +/// Serialize a [`UsagePace`] into a compact JSON object for the `--json` output. +fn pace_json(pace: UsagePace) -> serde_json::Value { + serde_json::json!({ + "stage": format!("{:?}", pace.stage).to_lowercase(), + "deltaPercent": pace.delta_percent, + "expectedUsedPercent": pace.expected_used_percent, + "willLastToReset": pace.will_last_to_reset, + }) +} + fn print_usage_output(output: UsageOutput) -> anyhow::Result<()> { match output { UsageOutput::Text(sections) => { @@ -489,6 +521,16 @@ fn append_usage_window_lines( use_color: bool, ) { append_window_line(lines, metadata.session_label, &usage.primary, use_color); + // Upstream 0.50.1 #2957: pace for the 5-hour session window. + if usage.primary.window_minutes == Some(crate::core::SESSION_WINDOW_MINUTES) { + if let Some(pace) = UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES) { + lines.push(format!( + " Pace: {} {}", + pace.stage.emoji(), + pace.format_status() + )); + } + } append_secondary_window_line( lines, usage.secondary.as_ref(), From 0cbf00b483392e471f8c8f1c09b037c661b86f08 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:10:59 +0700 Subject: [PATCH 03/10] Port upstream 0.50.1: chart axis label centering (#2974) --- apps/desktop-tauri/src/components/MiniBarChart.tsx | 12 ++++++------ apps/desktop-tauri/src/styles.css | 10 +++++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/desktop-tauri/src/components/MiniBarChart.tsx b/apps/desktop-tauri/src/components/MiniBarChart.tsx index 63e15bd023..df9ca989c4 100644 --- a/apps/desktop-tauri/src/components/MiniBarChart.tsx +++ b/apps/desktop-tauri/src/components/MiniBarChart.tsx @@ -79,9 +79,9 @@ export function SimpleBarChart({
{visible.length > 0 && ( <> - {visible[0].date.slice(-5)} - {fmt(max)} - {visible[visible.length - 1].date.slice(-5)} + {visible[0].date.slice(-5)} + {fmt(max)} + {visible[visible.length - 1].date.slice(-5)} )}
@@ -207,9 +207,9 @@ export function StackedBarChart({
{visible.length > 0 && ( <> - {visible[0].day.slice(-5)} - {max.toFixed(1)} - {visible[visible.length - 1].day.slice(-5)} + {visible[0].day.slice(-5)} + {max.toFixed(1)} + {visible[visible.length - 1].day.slice(-5)} )}
diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index f71ed56925..94bb315dc1 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -1845,15 +1845,19 @@ body:has(.tray-panel-reveal) { } .mini-chart__axis { - display: flex; - justify-content: space-between; - align-items: center; + position: relative; font-size: 0.68rem; color: var(--text-muted); font-family: "Cascadia Code", "Fira Code", monospace; padding: 0 2px; } +.mini-chart__axis > span { + position: absolute; + transform: translateX(-50%); + white-space: nowrap; +} + .mini-chart__legend { display: flex; flex-wrap: wrap; From 35aeae71a1a44ef8c0942899e1c2835ef42ec578 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:19:41 +0700 Subject: [PATCH 04/10] Port upstream 0.50.1: Kiro re-authenticate via kiro-cli login (#2340) --- .../src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/commands/system.rs | 45 +++++++++++++++++-- apps/desktop-tauri/src-tauri/src/events.rs | 25 +++++++++++ rust/src/login.rs | 44 +++++++++++++++++- 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 5e8e5b2d2b..3780110bc0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ use codexbar::core::{ }; use codexbar::locale; use codexbar::providers::copilot::{CopilotApi, device_flow::CopilotDeviceFlow}; +use codexbar::login::{self, LoginOutcome, LoginPhase}; use codexbar::secure_file::{self, SecureFileStatus}; use codexbar::settings::{ ApiKeys, Language, ManualCookies, MetricPreference, Settings, ThemePreference, TrayIconMode, diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 5df7c39da7..21c49f2a65 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -261,10 +261,12 @@ pub async fn trigger_provider_login( return run_copilot_device_login(&app).await; } - // TODO(6b): replace fallthrough once LoginPhase events land. The login - // runners live in `codexbar::login` but are async-oriented and tightly - // coupled to the egui UI's phase callbacks. For the Tauri shell we - // currently surface the dashboard URL. + if id == ProviderId::Kiro { + return run_cli_provider_login(&app, &provider_id, "kiro", 120).await; + } + + // For other providers, surface the dashboard URL as the login flow + // is not yet wired through the Tauri shell. if let Some(url) = dashboard_url_for_provider(&provider_id) { return open_url_in_browser(&url); } @@ -273,6 +275,41 @@ pub async fn trigger_provider_login( )) } +/// Run a CLI-based provider login (e.g. Kiro) and emit phase events. +async fn run_cli_provider_login( + app: &tauri::AppHandle, + provider_id: &str, + display_name: &str, + timeout_secs: u64, +) -> Result<(), String> { + let app_handle = app.clone(); + let provider_id_owned = provider_id.to_string(); + let result = login::run_kiro_login(timeout_secs, move |phase| { + let phase_str = match phase { + LoginPhase::Idle => "idle", + LoginPhase::Requesting => "requesting", + LoginPhase::WaitingBrowser => "waiting-browser", + LoginPhase::Complete => "complete", + }; + events::emit_login_phase(&app_handle, &provider_id_owned, phase_str, None); + }) + .await; + + match result.outcome { + LoginOutcome::Success => Ok(()), + LoginOutcome::MissingBinary => Err(format!( + "{display_name} CLI not found. Install it and ensure it is on your PATH." + )), + LoginOutcome::LaunchFailed(e) => { + Err(format!("Failed to launch {display_name} login: {e}")) + } + LoginOutcome::TimedOut => Err(format!("{display_name} login timed out")), + LoginOutcome::Failed { status } => Err(format!( + "{display_name} login failed with exit code {status}" + )), + } +} + async fn run_copilot_device_login(app: &tauri::AppHandle) -> Result<(), String> { let flow = CopilotDeviceFlow::new(); let device = flow diff --git a/apps/desktop-tauri/src-tauri/src/events.rs b/apps/desktop-tauri/src-tauri/src/events.rs index b6b2e61050..2e73b0552e 100644 --- a/apps/desktop-tauri/src-tauri/src/events.rs +++ b/apps/desktop-tauri/src-tauri/src/events.rs @@ -16,6 +16,7 @@ pub const UPDATE_STATE_CHANGED: &str = "update-state-changed"; pub const LOCALE_CHANGED: &str = "locale-changed"; pub const SETTINGS_CHANGED: &str = "settings-changed"; pub const CODEX_ACCOUNTS_UPDATED: &str = "codex-accounts-updated"; +pub const LOGIN_PHASE: &str = "login-phase"; // ── Payloads ───────────────────────────────────────────────────────── @@ -40,6 +41,14 @@ pub struct RefreshStartedPayload { pub provider_ids: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginPhasePayload { + pub provider_id: String, + pub phase: String, + pub auth_link: Option, +} + // ── Emit helpers ───────────────────────────────────────────────────── pub fn emit_surface_mode_changed( @@ -101,3 +110,19 @@ pub fn emit_update_state_changed(app: &AppHandle, payload: &UpdateStatePayload) pub fn emit_settings_changed(app: &AppHandle) { let _ = app.emit(SETTINGS_CHANGED, ()); } + +pub fn emit_login_phase( + app: &AppHandle, + provider_id: &str, + phase: &str, + auth_link: Option<&str>, +) { + let _ = app.emit( + LOGIN_PHASE, + LoginPhasePayload { + provider_id: provider_id.to_string(), + phase: phase.to_string(), + auth_link: auth_link.map(|s| s.to_string()), + }, + ); +} diff --git a/rust/src/login.rs b/rust/src/login.rs index 1a3450a8d2..18c71f0065 100755 --- a/rust/src/login.rs +++ b/rust/src/login.rs @@ -106,7 +106,33 @@ where .await } -/// Generic CLI login runner +/// Run Kiro CLI login +pub async fn run_kiro_login(timeout_secs: u64, on_phase: F) -> LoginResult +where + F: Fn(LoginPhase) + Send + 'static, +{ + // Use Kiro's own binary resolver which checks well-known Windows install + // locations in addition to PATH. + let binary_path = match crate::providers::kiro::find_kiro_cli() { + Some(p) => p, + None => return missing_binary_result("kiro-cli"), + }; + + run_cli_login_path( + &binary_path, + &["login"], + timeout_secs, + on_phase, + &[ + "Successfully logged in", + "Login successful", + "Logged in successfully", + ], + ) + .await +} + +/// Generic CLI login runner (resolves binary via PATH) async fn run_cli_login( binary: &str, args: &[&str], @@ -122,9 +148,23 @@ where Err(_) => return missing_binary_result(binary), }; + run_cli_login_path(&binary_path, args, timeout_secs, on_phase, success_markers).await +} + +/// Generic CLI login runner (uses a pre-resolved binary path) +async fn run_cli_login_path( + binary_path: &std::path::Path, + args: &[&str], + timeout_secs: u64, + on_phase: F, + success_markers: &[&str], +) -> LoginResult +where + F: Fn(LoginPhase) + Send + 'static, +{ on_phase(LoginPhase::Requesting); - let mut child = match spawn_login_process(binary_path.as_path(), args) { + let mut child = match spawn_login_process(binary_path, args) { Ok(c) => c, Err(e) => return launch_failed_result(e), }; From 3adcd14625716f2e8b6845838318b92a2a753171 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:36:01 +0700 Subject: [PATCH 05/10] Port upstream 0.50.1: serve identity follows app redaction per-request (#2960) --- rust/src/cli/dashboard.rs | 2 +- rust/src/cli/serve/dashboard/mod.rs | 9 ++++++--- rust/src/cli/serve/dashboard/source.rs | 24 +++++++++++++++++++----- rust/src/cli/serve/mod.rs | 23 ++++++++++++++--------- rust/src/cli/serve/tests.rs | 18 +++++++++--------- rust/src/cli/usage.rs | 16 ++++++++-------- 6 files changed, 57 insertions(+), 35 deletions(-) diff --git a/rust/src/cli/dashboard.rs b/rust/src/cli/dashboard.rs index 8375ac4650..bf2cb2eab6 100644 --- a/rust/src/cli/dashboard.rs +++ b/rust/src/cli/dashboard.rs @@ -39,7 +39,7 @@ pub async fn run(args: DashboardArgs) -> anyhow::Result<()> { }; let fetch_timeout = parse_timeout(args.timeout)?; - let producer = SnapshotProducer::new(60, identity).with_fetch_timeout(fetch_timeout); + let producer = SnapshotProducer::new(60, Some(identity)).with_fetch_timeout(fetch_timeout); let payload = producer.collect().await.map_err(anyhow::Error::msg)?; let body = if args.pretty { diff --git a/rust/src/cli/serve/dashboard/mod.rs b/rust/src/cli/serve/dashboard/mod.rs index 6c624fd5d0..5b04d71fd8 100644 --- a/rust/src/cli/serve/dashboard/mod.rs +++ b/rust/src/cli/serve/dashboard/mod.rs @@ -17,7 +17,9 @@ use snapshot::DashboardIdentity; #[derive(Clone)] pub struct DashboardState { pub coordinator: SnapshotCoordinator, - pub identity: DashboardIdentity, + /// `None` = follow the app's `hide_personal_info` setting per request + /// (upstream 0.50.1 #2960). + pub identity: Option, pub refresh_seconds: u32, } @@ -33,7 +35,7 @@ impl std::fmt::Debug for DashboardState { impl DashboardState { /// Production wiring: live producer behind the TTL coordinator. - pub fn live(refresh_seconds: u32, identity: DashboardIdentity) -> Self { + pub fn live(refresh_seconds: u32, identity: Option) -> Self { let producer = source::SnapshotProducer::new(refresh_seconds, identity); let coordinator = SnapshotCoordinator::new( std::time::Duration::from_secs(refresh_seconds.max(1) as u64), @@ -51,7 +53,7 @@ impl DashboardState { pub fn stub( build: coordinator::SnapshotBuildFn, ttl_seconds: u32, - identity: DashboardIdentity, + identity: Option, ) -> Self { Self { coordinator: SnapshotCoordinator::new( @@ -62,6 +64,7 @@ impl DashboardState { refresh_seconds: 60, } } + } /// `GET /` — the embedded web dashboard shell. Static per config; no account diff --git a/rust/src/cli/serve/dashboard/source.rs b/rust/src/cli/serve/dashboard/source.rs index cc695e58c4..10d6ca89a5 100644 --- a/rust/src/cli/serve/dashboard/source.rs +++ b/rust/src/cli/serve/dashboard/source.rs @@ -39,7 +39,7 @@ const ACCOUNT_FETCH_TIMEOUT: Duration = Duration::from_secs(75); #[derive(Clone, Debug)] pub struct SnapshotProducer { pub refresh_seconds: u32, - pub identity: DashboardIdentity, + pub identity: Option, pub version: String, /// Outer per-provider fetch envelope; `None` relies on provider-internal /// `web_timeout` alone (`--timeout 0` in the dashboard command). @@ -47,7 +47,7 @@ pub struct SnapshotProducer { } impl SnapshotProducer { - pub fn new(refresh_seconds: u32, identity: DashboardIdentity) -> Self { + pub fn new(refresh_seconds: u32, identity: Option) -> Self { Self { refresh_seconds, identity, @@ -68,6 +68,13 @@ impl SnapshotProducer { async fn collect_inner(&self) -> Result { let settings = Settings::load(); + // Resolve identity: explicit --identity flag wins; otherwise follow + // the app's hide_personal_info setting (upstream 0.50.1 #2960). + let identity = self.identity.unwrap_or(if settings.hide_personal_info { + DashboardIdentity::Redacted + } else { + DashboardIdentity::Full + }); let provider_ids: Vec = settings.get_enabled_provider_ids(); // Concurrent, individually bounded provider fetches; order restored by index. @@ -108,7 +115,7 @@ impl SnapshotProducer { providers, costs, claude_accounts, - identity: self.identity, + identity, generated_at: Utc::now(), refresh_seconds: self.refresh_seconds, version: Some(self.version.clone()), @@ -300,9 +307,16 @@ mod tests { #[test] fn producer_defaults_to_redacted_identity() { - let producer = SnapshotProducer::new(60, DashboardIdentity::Redacted); - assert_eq!(producer.identity, DashboardIdentity::Redacted); + let producer = SnapshotProducer::new(60, Some(DashboardIdentity::Redacted)); + assert_eq!(producer.identity, Some(DashboardIdentity::Redacted)); assert_eq!(producer.refresh_seconds, 60); assert!(!producer.version.is_empty()); } + + #[test] + fn producer_none_identity_follows_settings() { + let producer = SnapshotProducer::new(60, None); + assert_eq!(producer.identity, None); + assert_eq!(producer.refresh_seconds, 60); + } } diff --git a/rust/src/cli/serve/mod.rs b/rust/src/cli/serve/mod.rs index 74f8eb0cba..5aed8ec63f 100644 --- a/rust/src/cli/serve/mod.rs +++ b/rust/src/cli/serve/mod.rs @@ -82,10 +82,11 @@ pub struct ServeArgs { #[arg(long = "allow-plain-http", default_value_t = false)] pub allow_plain_http: bool, - /// Dashboard snapshot identity detail: redacted (default) or full. `full` - /// exposes real account emails to every authorized dashboard client. - #[arg(long, value_parser = ["redacted", "full"], default_value = "redacted")] - pub identity: String, + /// Dashboard snapshot identity detail: redacted or full. When omitted, + /// the identity follows the app's "hide personal info" setting per + /// request (upstream 0.50.1 #2960). + #[arg(long, value_parser = ["redacted", "full"])] + pub identity: Option, } /// Normalized serve bind configuration after startup validation. @@ -98,8 +99,9 @@ struct ServeConfig { /// [`HEAD_READ_TIMEOUT`]; tests inject a short budget (upstream 0.48.0 /// #2684 makes the deadline injectable for exactly this reason). head_read_budget: Duration, - /// Dashboard snapshot identity mode (`redacted` default, `full` opt-in). - identity: DashboardIdentity, + /// Dashboard snapshot identity mode. `None` means follow the app's + /// `hide_personal_info` setting per request (upstream 0.50.1 #2960). + identity: Option, /// Dashboard state (coordinator + producer). Always `Some` from `run`; /// `None` only in pure-transport tests, where dashboard routes answer 503. dashboard: Option, @@ -171,10 +173,13 @@ fn validate_serve_args(args: &ServeArgs) -> anyhow::Result { if args.port == 0 { anyhow::bail!("--port must be between 1 and 65535."); } - // clap's value_parser already rejects anything but redacted|full. - let Some(identity) = DashboardIdentity::parse(&args.identity) else { - anyhow::bail!("--identity must be redacted or full."); + let identity = match args.identity.as_deref() { + Some(raw) => Some( + DashboardIdentity::parse(raw) + .ok_or_else(|| anyhow::anyhow!("--identity must be redacted or full."))?, + ), + None => None, }; let token = resolve_dashboard_token(args.dashboard_token.as_deref())?; diff --git a/rust/src/cli/serve/tests.rs b/rust/src/cli/serve/tests.rs index 419c1a8150..d2210764d8 100644 --- a/rust/src/cli/serve/tests.rs +++ b/rust/src/cli/serve/tests.rs @@ -62,7 +62,7 @@ fn validate_serve_args_accepts_loopback_without_token() { refresh_interval: 60, dashboard_token: None, allow_plain_http: false, - identity: "redacted".into(), + identity: Some("redacted".into()), }) .unwrap(); assert_eq!(config.host, "127.0.0.1"); @@ -77,7 +77,7 @@ fn validate_serve_args_rejects_lan_without_token() { refresh_interval: 60, dashboard_token: None, allow_plain_http: true, - identity: "redacted".into(), + identity: Some("redacted".into()), }) .unwrap_err() .to_string(); @@ -92,7 +92,7 @@ fn validate_serve_args_rejects_lan_without_allow_plain_http() { refresh_interval: 60, dashboard_token: Some("tok".into()), allow_plain_http: false, - identity: "redacted".into(), + identity: Some("redacted".into()), }) .unwrap_err() .to_string(); @@ -154,7 +154,7 @@ fn head_test_config(budget: Duration, token: Option<&str>) -> ServeConfig { port: 8080, token_digest: token.map(|t| sha256_digest(t.as_bytes())), head_read_budget: budget, - identity: DashboardIdentity::Redacted, + identity: Some(DashboardIdentity::Redacted), dashboard: None, } } @@ -629,7 +629,7 @@ fn stub_state_ok() -> dashboard::DashboardState { dashboard::DashboardState::stub( stub_build(DashboardIdMode::Redacted, false, Duration::ZERO), 3600, - DashboardIdMode::Redacted, + Some(DashboardIdMode::Redacted), ) } @@ -780,7 +780,7 @@ async fn snapshot_identity_modes_redact_or_expose() { let state = dashboard::DashboardState::stub( stub_build(DashboardIdMode::Redacted, false, Duration::ZERO), 3600, - DashboardIdMode::Redacted, + Some(DashboardIdMode::Redacted), ); let config = dashboard_test_config(None, Some(state)); let redacted = request_roundtrip_dashboard( @@ -798,7 +798,7 @@ async fn snapshot_identity_modes_redact_or_expose() { let state = dashboard::DashboardState::stub( stub_build(DashboardIdMode::Full, false, Duration::ZERO), 3600, - DashboardIdMode::Full, + Some(DashboardIdMode::Full), ); let config = dashboard_test_config(None, Some(state)); let full = request_roundtrip_dashboard( @@ -814,7 +814,7 @@ async fn snapshot_claude_accounts_nest_under_claude_row() { let state = dashboard::DashboardState::stub( stub_build(DashboardIdMode::Redacted, true, Duration::ZERO), 3600, - DashboardIdMode::Redacted, + Some(DashboardIdMode::Redacted), ); let config = dashboard_test_config(None, Some(state)); let response = request_roundtrip_dashboard( @@ -835,7 +835,7 @@ async fn snapshot_late_build_is_delivered_not_discarded() { let state = dashboard::DashboardState::stub( stub_build(DashboardIdMode::Redacted, false, Duration::from_millis(250)), 3600, - DashboardIdMode::Redacted, + Some(DashboardIdMode::Redacted), ); let config = dashboard_test_config(None, Some(state)); let started = std::time::Instant::now(); diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index 2f8fe34237..7b03aac315 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -522,14 +522,14 @@ fn append_usage_window_lines( ) { append_window_line(lines, metadata.session_label, &usage.primary, use_color); // Upstream 0.50.1 #2957: pace for the 5-hour session window. - if usage.primary.window_minutes == Some(crate::core::SESSION_WINDOW_MINUTES) { - if let Some(pace) = UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES) { - lines.push(format!( - " Pace: {} {}", - pace.stage.emoji(), - pace.format_status() - )); - } + if usage.primary.window_minutes == Some(crate::core::SESSION_WINDOW_MINUTES) + && let Some(pace) = UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES) + { + lines.push(format!( + " Pace: {} {}", + pace.stage.emoji(), + pace.format_status() + )); } append_secondary_window_line( lines, From f4bdb9809a2e6535c3192d6b094b98feddb4117b Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:40:20 +0700 Subject: [PATCH 06/10] Port upstream 0.50.1: Codex routed pricing, auth.json read-only, known-zero history, Antigravity dashboard lanes, Claude OAuth revoked detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2946 Codex routed models pricing — codex_routed_provider() + strip_route_prefix() in cost_pricing.rs; deepseek/, kimi/, opencode/ routes price against matching models.dev provider; unknown provider/ prefixes return None. #2944 Codex auth.json read-only during refresh — codex_external_oauth_sources_allowed setting (default OFF); is_external_oauth + last_refresh fields on CodexCredentials; enforce_external_oauth_gate (8-day staleness window). #2932 Codex known-zero history — known_zero field on CostSummary; set in both cache-debounce and full-scan paths (history_coverage_established && sessions_count == 0); knownZero JSON field + CLI text update. #2963 Antigravity dashboard lanes — quota-bucket dedup in parse_user_status; models sharing the same (remaining_fraction, reset_time) collapse to one lane. #2516 Claude revoked vs missing OAuth — ProviderError::OAuthRevoked variant; revocation detection (401/403 with revoked/invalid_grant/token_revoked); 15-min CLI result cache (LazyLock>>); fetch_via_auto returns cached CLI result when OAuth revoked, and stale cache when all live sources fail. --- rust/src/cli/cost.rs | 16 ++- rust/src/cli/diagnose.rs | 5 +- rust/src/cli/hooks.rs | 7 +- rust/src/core/cost_pricing.rs | 53 +++++++- rust/src/core/cost_pricing_tests.rs | 51 ++++++++ rust/src/core/provider.rs | 81 ++++++++++++ rust/src/cost_scanner.rs | 51 ++++++++ rust/src/providers/antigravity/mod.rs | 11 ++ rust/src/providers/antigravity/tests.rs | 32 +++++ rust/src/providers/claude/mod.rs | 100 ++++++++++++++- rust/src/providers/claude/oauth/mod.rs | 16 +++ rust/src/providers/codex/api.rs | 160 +++++++++++++++++++++++- rust/src/settings.rs | 43 +++++++ rust/src/settings/raw.rs | 8 ++ 14 files changed, 623 insertions(+), 11 deletions(-) diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 77ef28f622..706cc5ab32 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -139,8 +139,12 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32) { println!(" Local cost scanning not available for this provider"); println!(" (Only Codex and Claude have local logs)"); } else if result.summary.sessions_count == 0 { - println!(" No usage data found"); - println!(" Check that you have used {} locally", result.display_name); + if result.summary.known_zero { + println!(" No usage in the last {} days (scan complete)", days); + } else { + println!(" No usage data found"); + println!(" Check that you have used {} locally", result.display_name); + } } else { // Total cost if use_color { @@ -244,6 +248,13 @@ fn print_json_output(results: &[CostResult], pretty: bool, days: u32) -> anyhow: } else { serde_json::Value::Null }, + // Upstream 0.50.1 #2932: known-zero flag — scan completed + // with zero results. null for non-Codex; true/false for Codex. + "knownZero": if r.provider == "codex" { + serde_json::Value::Bool(r.summary.known_zero) + } else { + serde_json::Value::Null + }, // F18 (upstream 0.48.0): pricing completeness. "complete" or // {"partial": {"unpriced_models": [...]}}. "modelPricingCompleteness": match &r.summary.model_pricing_completeness { @@ -339,6 +350,7 @@ mod tests { "tokens": { "input": 0, "output": 0, "cached": 0 }, "sessions_count": 1, "historyCoverageIsEstablished": true, + "knownZero": false, "modelPricingCompleteness": { "partial": { "unpriced_models": ["codex-auto-review"] } }, diff --git a/rust/src/cli/diagnose.rs b/rust/src/cli/diagnose.rs index 28ef7fa122..517598f23d 100644 --- a/rust/src/cli/diagnose.rs +++ b/rust/src/cli/diagnose.rs @@ -370,7 +370,10 @@ fn source_mode_name(mode: SourceMode) -> &'static str { fn error_category(err: &ProviderError) -> &'static str { match err { - ProviderError::AuthRequired | ProviderError::OAuth(_) | ProviderError::NoCookies => "auth", + ProviderError::AuthRequired + | ProviderError::OAuth(_) + | ProviderError::OAuthRevoked(_) + | ProviderError::NoCookies => "auth", ProviderError::Network(_) | ProviderError::Timeout => "network", ProviderError::NotInstalled(_) | ProviderError::UnsupportedSource(_) => "config", ProviderError::Parse(_) => "parse", diff --git a/rust/src/cli/hooks.rs b/rust/src/cli/hooks.rs index 5b66303c46..bcb23d0149 100644 --- a/rust/src/cli/hooks.rs +++ b/rust/src/cli/hooks.rs @@ -376,9 +376,10 @@ fn map_status_level(level: StatusLevel) -> HookProviderStatus { /// Coarse, non-secret category for a refresh failure. Never forwards raw errors. fn hook_refresh_failure_status(error: &ProviderError) -> String { match error { - ProviderError::AuthRequired | ProviderError::NoCookies | ProviderError::OAuth(_) => { - "auth_required".into() - } + ProviderError::AuthRequired + | ProviderError::NoCookies + | ProviderError::OAuth(_) + | ProviderError::OAuthRevoked(_) => "auth_required".into(), ProviderError::Timeout => "timeout".into(), ProviderError::Network(err) => { if err.is_timeout() { diff --git a/rust/src/core/cost_pricing.rs b/rust/src/core/cost_pricing.rs index b635ab7c97..cd78a7c7ae 100755 --- a/rust/src/core/cost_pricing.rs +++ b/rust/src/core/cost_pricing.rs @@ -651,6 +651,46 @@ impl CostUsagePricing { trimmed } + /// Detect a provider-qualified route prefix on a Codex model name and + /// return the matching models.dev provider id (upstream 0.50.1 #2946). + /// + /// Codex rollouts routed through a non-OpenAI backend (DeepSeek, Kimi, + /// OpenCode) carry the provider as a `provider/model` prefix. This returns + /// the models.dev provider id so the cost lookup prices against the right + /// catalog instead of falling back to OpenAI. + /// + /// Known routes: `deepseek/` → "deepseek", `kimi/` → "kimi", + /// `opencode/` → "opencode". The `openai/` prefix is stripped by + /// [`normalize_codex_model`] and priced against the OpenAI catalog as + /// before. Unknown `provider/` prefixes return `None` here so the caller + /// leaves them unpriced rather than guessing. + pub fn codex_routed_provider(model: &str) -> Option<&'static str> { + let trimmed = model.trim(); + let (prefix, _rest) = trimmed.split_once('/')?; + match prefix.to_ascii_lowercase().as_str() { + "deepseek" => Some("deepseek"), + "kimi" => Some("kimi"), + "opencode" => Some("opencode"), + _ => None, + } + } + + /// Strip a known route prefix, returning the model id for a models.dev + /// lookup. Unknown prefixes are left intact (the caller leaves them + /// unpriced). `openai/` is also stripped here for the routed path. + fn strip_route_prefix(model: &str) -> &str { + let trimmed = model.trim(); + if let Some(rest) = trimmed.strip_prefix("openai/") { + return rest; + } + if Self::codex_routed_provider(trimmed).is_some() + && let Some((_prefix, rest)) = trimmed.split_once('/') + { + return rest; + } + trimmed + } + /// Get the display label for a Codex model (e.g. "Research Preview") pub fn codex_display_label(model: &str) -> Option<&'static str> { let key = Self::normalize_codex_model(model); @@ -792,7 +832,18 @@ impl CostUsagePricing { )); } - let pricing = models_dev_pricing::lookup("openai", model)?; + // Upstream 0.50.1 #2946: provider-qualified routed models are priced + // against the matching models.dev provider, not OpenAI. Unknown + // `provider/` prefixes are left unpriced (not guessed as OpenAI). + let (provider_id, lookup_model) = match Self::codex_routed_provider(model) { + Some(routed) => (routed, Self::strip_route_prefix(model)), + None if model.trim().contains('/') && !model.trim().starts_with("openai/") => { + // Unknown route prefix — do not guess. Leave unpriced. + return None; + } + None => ("openai", model), + }; + let pricing = models_dev_pricing::lookup(provider_id, lookup_model)?; let use_tier = pricing .threshold_tokens .is_some_and(|threshold| input_tokens > threshold); diff --git a/rust/src/core/cost_pricing_tests.rs b/rust/src/core/cost_pricing_tests.rs index 8d2fe7a52a..b38695f6e4 100644 --- a/rust/src/core/cost_pricing_tests.rs +++ b/rust/src/core/cost_pricing_tests.rs @@ -299,3 +299,54 @@ fn test_codex_fast_cost_usd_base_model_unsuffixed() { "my-custom-model" ); } + +// ── Upstream 0.50.1 #2946: provider-qualified routed model pricing ────────── + +#[test] +fn codex_routed_provider_detects_known_routes() { + assert_eq!( + CostUsagePricing::codex_routed_provider("deepseek/deepseek-chat"), + Some("deepseek") + ); + assert_eq!( + CostUsagePricing::codex_routed_provider("kimi/kimi-k2"), + Some("kimi") + ); + assert_eq!( + CostUsagePricing::codex_routed_provider("opencode/gpt-5"), + Some("opencode") + ); + // Case-insensitive prefix. + assert_eq!( + CostUsagePricing::codex_routed_provider("DeepSeek/deepseek-chat"), + Some("deepseek") + ); +} + +#[test] +fn codex_routed_provider_returns_none_for_unknown_and_unrouted() { + assert!(CostUsagePricing::codex_routed_provider("acme/model-x").is_none()); + assert!(CostUsagePricing::codex_routed_provider("gpt-5").is_none()); + assert!(CostUsagePricing::codex_routed_provider("deepseek-chat").is_none()); + assert!(CostUsagePricing::codex_routed_provider("openai/gpt-5").is_none()); +} + +#[test] +fn codex_routed_model_with_unknown_prefix_stays_unpriced() { + // An unknown provider/ prefix must NOT fall back to the OpenAI catalog + // (upstream 0.50.1 #2946: unknown prefixes are left unpriced, not guessed). + assert!(CostUsagePricing::codex_cost_usd("acme/secret-model", 1_000, 0, 500).is_none()); +} + +#[test] +fn codex_routed_model_strips_prefix_for_lookup() { + // A known route prefix produces a clean model id for models.dev lookup. + // A nonexistent sub-model returns None (cleanly unpriced) rather than + // falling back to the OpenAI catalog. + assert!( + CostUsagePricing::codex_cost_usd("deepseek/nonexistent-model-xyz", 1_000, 0, 500).is_none() + ); + assert!( + CostUsagePricing::codex_cost_usd("kimi/nonexistent-model-xyz", 1_000, 0, 500).is_none() + ); +} diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 326fc161ff..f26bc9e491 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -555,6 +555,9 @@ pub enum ProviderError { #[error("OAuth error: {0}")] OAuth(String), + #[error("OAuth token revoked: {0}")] + OAuthRevoked(String), + #[error("Parse error: {0}")] Parse(String), @@ -741,6 +744,84 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map } +/// The shipped brand color (hex) for a provider, mirroring the frontend +/// `PROVIDER_ICON_REGISTRY` in `providerIcons.ts`. Used as the default +/// accent color before any per-provider override (#2972). +pub fn brand_color(id: ProviderId) -> &'static str { + match id { + ProviderId::Codex => "#49A3B0", + ProviderId::Claude => "#CC7C5E", + ProviderId::Cursor => "#00BFA5", + ProviderId::Factory => "#FF6B35", + ProviderId::Gemini => "#AB87EA", + ProviderId::Antigravity => "#60BA7E", + ProviderId::Copilot => "#A855F7", + ProviderId::Zai => "#E85A6A", + ProviderId::MiniMax => "#FE603C", + ProviderId::Kiro => "#FF9900", + ProviderId::VertexAI => "#4285F4", + ProviderId::Augment => "#6366F1", + ProviderId::OpenCode => "#3B82F6", + ProviderId::Kimi => "#FE603C", + ProviderId::KimiK2 => "#4C00FF", + ProviderId::Amp => "#DC2626", + ProviderId::Warp => "#6366F1", + ProviderId::Ollama => "#8B95B0", + ProviderId::AzureOpenAI => "#0078D4", + ProviderId::T3Chat => "#8B5CF6", + ProviderId::OpenRouter => "#6B7280", + ProviderId::JetBrains => "#FF3399", + ProviderId::Alibaba => "#FF6A00", + ProviderId::AlibabaTokenPlan => "#FF6A00", + ProviderId::NanoGPT => "#687FA1", + ProviderId::Infini => "#687FA1", + ProviderId::Perplexity => "#1FB8CD", + ProviderId::Abacus => "#7C3AED", + ProviderId::Mistral => "#FF500F", + ProviderId::OpenCodeGo => "#3B82F6", + ProviderId::Kilo => "#5D87FF", + ProviderId::Bedrock => "#FF9900", + ProviderId::Codebuff => "#44FF00", + ProviderId::DeepSeek => "#527DF0", + ProviderId::DeepInfra => "#2A3275", + ProviderId::AiAnd => "#E25C2B", + ProviderId::Windsurf => "#22C55E", + ProviderId::Manus => "#34322D", + ProviderId::MiMo => "#FF6900", + ProviderId::Doubao => "#2563EB", + ProviderId::CommandCode => "#44FF00", + ProviderId::Crof => "#7C3AED", + ProviderId::StepFun => "#999999", + ProviderId::Venice => "#111827", + ProviderId::OpenAIApi => "#10A37F", + ProviderId::Grok => "#111827", + ProviderId::ElevenLabs => "#111827", + ProviderId::Deepgram => "#13EF93", + ProviderId::Groq => "#F55036", + ProviderId::LLMProxy => "#4F46E5", + ProviderId::Chutes => "#FF5C35", + ProviderId::LiteLLM => "#0EA5E9", + ProviderId::Poe => "#5D5FEF", + ProviderId::Devin => "#111827", + ProviderId::Zed => "#084CCF", + ProviderId::CrossModel => "#C084FC", + ProviderId::Qoder => "#2563EB", + ProviderId::CodeBuddy => "#0052D9", + ProviderId::Sakana => "#0EA5E9", + ProviderId::Sub2Api => "#2DC6D8", + ProviderId::Wayfinder => "#14B8A6", + ProviderId::ZenMux => "#6C5CE7", + ProviderId::ClinePass => "#61A3FA", + ProviderId::LongCat => "#FFD100", + ProviderId::Neuralwatt => "#38D98C", + ProviderId::ZoomMate => "#0B5CFF", + ProviderId::QwenCloud => "#615CED", + ProviderId::Notion => "#337EA9", + ProviderId::Xai => "#8E8E93", + ProviderId::Fireworks => "#F25B1C", + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 74c00b3000..945a2ab6da 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -86,6 +86,11 @@ pub struct CostSummary { /// debounce window) or the scan just completed; `false` when the cache is stale /// or empty and a re-scan would be required (upstream 0.48.0 A16). pub history_coverage_established: bool, + /// True when the scan completed with zero results — a *known* zero, not a + /// missing scan. Set only when `history_coverage_established` is true and + /// the scan found no sessions/tokens (upstream 0.50.1 #2932). Never + /// fabricated on incomplete scans. + pub known_zero: bool, /// Period start date pub period_start: Option, /// Period end date @@ -399,6 +404,10 @@ impl CostScanner { &mut seen_pi, ); } + // Upstream 0.50.1 #2932: debounce cache hit with coverage + // established but zero sessions in-range is a known-zero. + summary.known_zero = + summary.history_coverage_established && summary.sessions_count == 0; return (summary, stats); } @@ -434,6 +443,10 @@ impl CostScanner { // A16 (upstream 0.48.0): after a completed scan, coverage IS established // unless cache pruning during save marked a catch-up pending. summary.history_coverage_established = cache.previous_report.is_none(); + // Upstream 0.50.1 #2932: a completed scan with zero results is a + // *known* zero. Only set when coverage is established; an incomplete + // scan must NOT fabricate a zero. + summary.known_zero = summary.history_coverage_established && summary.sessions_count == 0; // OMP / pi-compatible agent sessions (upstream #2269). Dedup by entry id. // Skip when tests inject sessions roots — avoid scanning the real home tree. @@ -1661,4 +1674,42 @@ mod tests { "full scan clears previous_report" ); } + + // ── Upstream 0.50.1 #2932: known-zero history ──────────────────────────── + + #[test] + fn known_zero_is_set_when_scan_completes_with_no_sessions() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + std::fs::create_dir_all(&sessions).unwrap(); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + let (summary, _) = scanner.scan_codex_detailed(None); + assert!(summary.history_coverage_established, "scan completed"); + assert_eq!(summary.sessions_count, 0, "no sessions"); + assert!(summary.known_zero, "completed scan with zero = known-zero"); + } + + #[test] + fn known_zero_is_not_set_when_scan_has_results() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + write_codex_session_fixture(&sessions, "a.jsonl", 100); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + let (summary, _) = scanner.scan_codex_detailed(None); + assert!(summary.history_coverage_established); + assert_eq!(summary.sessions_count, 1); + assert!(!summary.known_zero, "scan with results is not known-zero"); + } } diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index 0bacb36a5a..6ea6b2b3fc 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -448,10 +448,21 @@ impl AntigravityProvider { snapshot = snapshot.with_model_specific(ter); } + // Upstream 0.50.1 #2963: one lane per quota bucket. When Antigravity + // emits multiple model configs that map to the same quota bucket + // (e.g. multiple Claude variants in the same 5h session), show one + // lane per quota bucket, not one per model. Dedup by (remaining, + // reset_time) — models sharing the same quota state collapse. + let mut seen_buckets: Vec<(Option, Option)> = Vec::new(); for config in quota_configs { let Some(quota) = &config.quota_info else { continue; }; + let bucket = (quota.remaining_fraction, quota.reset_time.clone()); + if seen_buckets.contains(&bucket) { + continue; + } + seen_buckets.push(bucket); let title = clean_model_label(model_label(config)); if title.is_empty() { continue; diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index 557614da43..cbd9616e88 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -271,3 +271,35 @@ fn is_agy_cli_command_rejects_unrelated_names() { assert!(!is_agy_cli_command("language_server.exe --csrf_token abc")); assert!(!is_agy_cli_command("")); } + +// ── Upstream 0.50.1 #2963: one lane per quota bucket ────────────────────── + +#[test] +fn multiple_models_in_same_quota_bucket_collapse_to_one_lane() { + // Two Claude variants sharing the same remaining fraction (same 5h + // session bucket) should produce one extra rate window, not two. + let resp = make_response(vec![ + ("Claude 3.5 Sonnet", 0.8), + ("Claude 4 Sonnet", 0.8), + ("Gemini 2.5 Pro Low", 0.5), + ]); + let provider = AntigravityProvider::new(); + let snap = provider.parse_user_status(resp).unwrap(); + assert_eq!( + snap.extra_rate_windows.len(), + 2, + "models sharing a quota bucket collapse to one lane" + ); +} + +#[test] +fn models_in_distinct_quota_buckets_keep_separate_lanes() { + let resp = make_response(vec![ + ("Claude 3.5 Sonnet", 0.8), + ("Claude 4 Sonnet", 0.7), + ("Gemini 2.5 Pro Low", 0.5), + ]); + let provider = AntigravityProvider::new(); + let snap = provider.parse_user_status(resp).unwrap(); + assert_eq!(snap.extra_rate_windows.len(), 3); +} diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index c147797e5b..193e2c0f32 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -13,6 +13,9 @@ use regex_lite::Regex; use std::os::windows::process::CommandExt; #[cfg(windows)] use std::process::{Command as StdCommand, Stdio}; +use std::sync::LazyLock; +use std::sync::Mutex; +use std::time::{Duration, Instant}; use crate::cli::tty_runner::{TtyCommandOptions, TtyCommandRunner}; use crate::core::{ @@ -27,6 +30,52 @@ use cli_reset::{ extract_cli_scoped_weekly_limits, normalized_for_label_search, parse_claude_reset_date, parse_percent_line, starts_next_usage_section, }; + +// ── Upstream 0.50.1 #2516: CLI usage-result cache ──────────────────────────── +// +// When token rotation revokes OAuth access, the auto path falls back to the +// CLI. To avoid hammering the CLI probe on every poll, cache the last +// successful CLI result for 15 minutes. The cache is only consulted when +// OAuth returned `OAuthRevoked` (revoked, not merely expired) so normal +// refresh cycles are unaffected. +const CLI_RESULT_CACHE_TTL: Duration = Duration::from_secs(15 * 60); + +struct CachedCliResult { + result: ProviderFetchResult, + cached_at: Instant, +} + +static CLI_RESULT_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); + +/// Store a successful CLI fetch result in the 15-minute cache. +fn cache_cli_result(result: ProviderFetchResult) { + if let Ok(mut guard) = CLI_RESULT_CACHE.lock() { + *guard = Some(CachedCliResult { + result, + cached_at: Instant::now(), + }); + } +} + +/// Return a cached CLI result if it is still within the TTL. Used when +/// revoked OAuth prevents a live fetch and the CLI should not be re-probed. +fn cached_cli_result() -> Option { + let Ok(guard) = CLI_RESULT_CACHE.lock() else { + return None; + }; + guard + .as_ref() + .filter(|entry| entry.cached_at.elapsed() <= CLI_RESULT_CACHE_TTL) + .map(|entry| entry.result.clone()) +} + +/// Whether the OAuth source failed with a revocation (not just expiry). +/// Revoked tokens should reuse the working CLI fallback; expired/missing +/// tokens should NOT block the normal refresh path. +fn is_oauth_revoked_error(error: &ProviderError) -> bool { + matches!(error, ProviderError::OAuthRevoked(_)) +} pub use oauth::ClaudeOAuthFetcher; pub use web_api::ClaudeWebApiFetcher; @@ -396,18 +445,41 @@ impl ClaudeProvider { return Ok(result); } - if let Some(result) = - record_auto_source(&mut failures, "OAuth", self.fetch_via_oauth(ctx).await) - { + // Upstream 0.50.1 #2516: track whether OAuth failed with a revocation. + let oauth_result = self.fetch_via_oauth(ctx).await; + let oauth_revoked = oauth_result + .as_ref() + .err() + .is_some_and(is_oauth_revoked_error); + if let Some(result) = record_auto_source(&mut failures, "OAuth", oauth_result) { return Ok(result); } + // When OAuth was revoked (not just expired), reuse a cached CLI result + // if still within the 15-minute TTL to avoid re-probing the CLI. + if oauth_revoked && let Some(cached) = cached_cli_result() { + tracing::debug!("Claude OAuth revoked; returning cached CLI result (15-min cache)"); + return Ok(cached); + } + if let Some(result) = record_auto_source(&mut failures, "CLI", self.fetch_via_cli(ctx).await) { + // Cache the CLI result when OAuth was revoked so subsequent polls + // within the TTL reuse it without re-probing. + if oauth_revoked { + cache_cli_result(result.clone()); + } return Ok(result); } + // Upstream 0.50.1 #2516: when all live sources fail, keep the + // last-known quota visible (stale) instead of blanking the UI. + if let Some(cached) = cached_cli_result() { + tracing::debug!("All Claude live sources failed; returning stale cached CLI result"); + return Ok(cached); + } + Err(claude_auto_fetch_error(failures)) } @@ -1412,4 +1484,26 @@ Active days: 2/10 Longest streak: 1 day assert!(matches!(err, ProviderError::Other(_))); } + + // ── Upstream 0.50.1 #2516: revoked vs missing OAuth ──────────────────────── + + #[test] + fn oauth_revoked_error_is_detected() { + assert!(is_oauth_revoked_error(&ProviderError::OAuthRevoked( + "revoked".to_string() + ))); + assert!(!is_oauth_revoked_error(&ProviderError::OAuth( + "expired".to_string() + ))); + assert!(!is_oauth_revoked_error(&ProviderError::AuthRequired)); + } + + #[test] + fn cli_result_cache_round_trips() { + let result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(42.0)), "cli"); + cache_cli_result(result.clone()); + let cached = cached_cli_result().expect("cached result within TTL"); + assert!((cached.usage.primary.used_percent - 42.0).abs() < 0.01); + assert_eq!(cached.source_label, "cli"); + } } diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index da8e9a6c1e..42fb96b252 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -396,6 +396,22 @@ impl ClaudeOAuthFetcher { let retry_after = Self::retry_after_duration(response.headers().get(RETRY_AFTER)); let body = response.text().await.unwrap_or_default(); + // Upstream 0.50.1 #2516: distinguish revoked tokens (keyring ACL + // revocation, token rotation) from merely expired/invalid ones. + // A revoked token exists but the API rejects it with a + // revocation indicator — the CLI fallback should still work. + if status.as_u16() == 401 || status.as_u16() == 403 { + let lower = body.to_ascii_lowercase(); + if lower.contains("revoked") + || lower.contains("invalid_grant") + || lower.contains("token_revoked") + { + return Err(ProviderError::OAuthRevoked( + "OAuth token was revoked. The CLI fallback will be used.".to_string(), + )); + } + } + if status.as_u16() == 401 { return Err(ProviderError::OAuth( "OAuth token invalid or expired. Run `claude` to re-authenticate.".to_string(), diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 828de5308d..69a4ba30ff 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -15,6 +15,10 @@ const DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api"; const USAGE_PATH: &str = "/wham/usage"; const RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits"; const CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(5); +/// How long an external OAuth token set is trusted after the CLI last +/// refreshed it. Matches the CLI's own `needs_refresh` window (8 days) so a +/// token the CLI considers fresh is also trusted here (upstream 0.50.1 #2944). +const EXTERNAL_OAUTH_STALENESS_WINDOW: chrono::TimeDelta = chrono::Duration::days(8); static CREDENTIAL_CACHE: OnceLock>> = OnceLock::new(); @@ -175,6 +179,7 @@ impl CodexApi { })?; let credentials = Self::parse_credentials_json(&content)?; + Self::enforce_external_oauth_gate(&credentials)?; Self::store_cached_credentials(auth_path, modified, credentials.clone()); Ok(credentials) } @@ -190,11 +195,13 @@ impl CodexApi { return Ok(CodexCredentials { access_token: trimmed.to_string(), account_id: None, + is_external_oauth: false, + last_refresh: None, }); } } - // Otherwise, look for tokens object + // Otherwise, look for tokens object (external OAuth source) let tokens = json.get("tokens").ok_or_else(|| { ProviderError::Parse("Codex auth.json exists but contains no tokens.".to_string()) })?; @@ -214,12 +221,49 @@ impl CodexApi { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + // Upstream 0.50.1 #2944: an OAuth token set with a refresh_token is an + // external (CLI-owned) OAuth source. The `last_refresh` timestamp + // (written by the CLI) lets us detect staleness. + let has_refresh_token = tokens + .get("refresh_token") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.trim().is_empty()); + let last_refresh = json + .get("last_refresh") + .and_then(|v| v.as_str()) + .and_then(parse_timestamp); + Ok(CodexCredentials { access_token, account_id, + is_external_oauth: has_refresh_token, + last_refresh, }) } + /// Upstream 0.50.1 #2944: when `codex_external_oauth_sources_allowed` is + /// OFF (the default), stale external OAuth credential files fail closed + /// instead of being used silently. An external OAuth source is an + /// auth.json `tokens` object with a `refresh_token` (CLI-owned OAuth, + /// not an API key). "Stale" means the CLI has not refreshed the token + /// recently (no `last_refresh`, or older than the staleness window). + fn enforce_external_oauth_gate(credentials: &CodexCredentials) -> Result<(), ProviderError> { + if !credentials.is_external_oauth { + return Ok(()); + } + if crate::settings::Settings::load().codex_external_oauth_sources_allowed { + return Ok(()); + } + let now = Utc::now(); + let is_stale = credentials + .last_refresh + .is_none_or(|last| now - last > EXTERNAL_OAUTH_STALENESS_WINDOW); + if is_stale { + return Err(ProviderError::AuthRequired); + } + Ok(()) + } + fn credential_cache() -> &'static Mutex> { CREDENTIAL_CACHE.get_or_init(|| Mutex::new(None)) } @@ -750,6 +794,14 @@ impl Default for CodexApi { struct CodexCredentials { access_token: String, account_id: Option, + /// True when the source is an external OAuth token set (has a + /// `refresh_token`), as opposed to an `OPENAI_API_KEY`. Used by the + /// `codex_external_oauth_sources_allowed` gate (upstream 0.50.1 #2944). + is_external_oauth: bool, + /// `last_refresh` timestamp from auth.json, when present. Used to detect + /// stale external OAuth tokens that should fail closed when the opt-in + /// setting is OFF. + last_refresh: Option>, } struct CachedCodexCredentials { @@ -898,6 +950,23 @@ fn timestamp_to_datetime(timestamp: Option) -> Option> { timestamp.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) } +/// Parse an ISO-8601 / RFC-3339 timestamp from the `last_refresh` field of +/// auth.json. Accepts the same formats the Codex CLI writes. +fn parse_timestamp(raw: &str) -> Option> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(trimmed) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|| { + chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f") + .ok() + .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) + }) +} + fn json_f64(value: &serde_json::Value) -> Option { value .as_f64() @@ -1575,4 +1644,93 @@ mod tests { assert!(tertiary.is_none()); assert_eq!(code_review.unwrap().window_minutes, Some(999)); } + + // ── Upstream 0.50.1 #2944: external OAuth source gate ────────────────── + + #[test] + fn api_key_credentials_are_not_external_oauth() { + let creds = CodexApi::parse_credentials_json(r#"{"OPENAI_API_KEY": "sk-test"}"#) + .expect("credentials"); + assert!(!creds.is_external_oauth); + assert!(creds.last_refresh.is_none()); + assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); + } + + #[test] + fn oauth_tokens_with_refresh_token_are_external_source() { + let creds = CodexApi::parse_credentials_json( + r#"{ + "tokens": { + "access_token": "access", + "refresh_token": "refresh", + "account_id": "acct_123" + } + }"#, + ) + .expect("credentials"); + assert!(creds.is_external_oauth); + assert!(creds.last_refresh.is_none()); + } + + #[test] + fn oauth_tokens_without_refresh_token_are_not_external() { + let creds = CodexApi::parse_credentials_json( + r#"{ + "tokens": { + "access_token": "access", + "account_id": "acct_123" + } + }"#, + ) + .expect("credentials"); + assert!(!creds.is_external_oauth); + } + + #[test] + fn external_oauth_gate_fails_closed_for_stale_tokens() { + let creds = CodexCredentials { + access_token: "access".to_string(), + account_id: None, + is_external_oauth: true, + last_refresh: None, + }; + let err = CodexApi::enforce_external_oauth_gate(&creds) + .expect_err("stale external OAuth must fail closed"); + assert!(matches!(err, ProviderError::AuthRequired)); + } + + #[test] + fn external_oauth_gate_fails_closed_for_old_last_refresh() { + let old = Utc::now() - chrono::Duration::days(10); + let creds = CodexCredentials { + access_token: "access".to_string(), + account_id: None, + is_external_oauth: true, + last_refresh: Some(old), + }; + let err = CodexApi::enforce_external_oauth_gate(&creds) + .expect_err("old external OAuth must fail closed"); + assert!(matches!(err, ProviderError::AuthRequired)); + } + + #[test] + fn external_oauth_gate_passes_fresh_tokens() { + let fresh = Utc::now() - chrono::Duration::hours(1); + let creds = CodexCredentials { + access_token: "access".to_string(), + account_id: None, + is_external_oauth: true, + last_refresh: Some(fresh), + }; + assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); + } + + #[test] + fn parse_timestamp_reads_iso8601() { + assert!(parse_timestamp("2026-08-17T10:00:00Z").is_some()); + assert!(parse_timestamp("2026-08-17T10:00:00.123Z").is_some()); + assert!(parse_timestamp(" 2026-08-17T10:00:00Z ").is_some()); + assert!(parse_timestamp("").is_none()); + assert!(parse_timestamp("not-a-date").is_none()); + } } diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 106a179c8e..db623ee9d4 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -282,6 +282,18 @@ pub struct Settings { /// Alibaba Token Plan API region: "cn" | "intl" | "cn-personal" | "intl-personal". #[serde(default = "default_alibaba_token_plan_region")] pub alibaba_token_plan_region: String, + + /// Opt-in: allow Codex usage reads from external (non-CLI-owned) OAuth + /// credential sources. Default OFF — when disabled, stale external OAuth + /// credential files fail closed instead of being used silently (upstream + /// 0.50.1 #2944). The CLI-owned `auth.json` is always read read-only; this + /// gate only controls whether stale external OAuth tokens are trusted. + #[serde(default)] + pub codex_external_oauth_sources_allowed: bool, + + /// How cost is rendered on provider MenuCards (#2976). + #[serde(default)] + pub cost_summary_display_style: CostSummaryDisplayStyle, } fn default_window_scale_percent() -> u16 { @@ -483,6 +495,8 @@ impl Default for Settings { claude_daily_routines_usage_visible: true, weekly_progress_work_days: None, alibaba_token_plan_region: default_alibaba_token_plan_region(), + codex_external_oauth_sources_allowed: false, + cost_summary_display_style: CostSummaryDisplayStyle::default(), } } } @@ -1116,4 +1130,33 @@ impl Settings { pub fn set_claude_avoid_keychain_prompts(&mut self, v: bool) { self.set_avoid_keychain_prompts(ProviderId::Claude, v) } + + // ── Per-provider accent color override (#2972) ────────────────── + + /// The user-overridden accent color for `id`, or `None` to use the + /// shipped brand color. + pub fn accent_color(&self, id: ProviderId) -> Option<&str> { + self.provider_configs + .get(&id) + .and_then(|c| c.accent_color.as_deref()) + .filter(|s| !s.trim().is_empty()) + } + + /// Set the accent color override for `id`. Pass an empty string or + /// `None` to clear the override and revert to the shipped brand color. + pub fn set_accent_color(&mut self, id: ProviderId, color: Option>) { + let entry = self.provider_config_mut(id); + entry.accent_color = color + .map(Into::into) + .filter(|s: &String| !s.trim().is_empty()); + } + + /// Resolve the effective accent color for `id`: the user override if + /// set, otherwise the shipped brand color from the provider registry. + pub fn effective_accent_color(&self, id: ProviderId) -> String { + if let Some(override_color) = self.accent_color(id) { + return override_color.trim().to_string(); + } + crate::core::brand_color(id).to_string() + } } diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index af21a03745..01c1cebbfe 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -165,6 +165,10 @@ pub(super) struct RawSettings { weekly_progress_work_days: Option, #[serde(default = "default_alibaba_token_plan_region")] alibaba_token_plan_region: String, + #[serde(default)] + codex_external_oauth_sources_allowed: bool, + #[serde(default)] + cost_summary_display_style: CostSummaryDisplayStyle, } impl Default for RawSettings { @@ -261,6 +265,8 @@ impl Default for RawSettings { claude_daily_routines_usage_visible: s.claude_daily_routines_usage_visible, weekly_progress_work_days: s.weekly_progress_work_days, alibaba_token_plan_region: s.alibaba_token_plan_region, + codex_external_oauth_sources_allowed: s.codex_external_oauth_sources_allowed, + cost_summary_display_style: s.cost_summary_display_style, } } } @@ -552,6 +558,8 @@ impl From for Settings { trimmed.to_string() } }, + cost_summary_display_style: raw.cost_summary_display_style, + codex_external_oauth_sources_allowed: raw.codex_external_oauth_sources_allowed, } } } From abc1c85200c12b977100ad1544ff50d9363be679 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:45:08 +0700 Subject: [PATCH 07/10] Port upstream 0.50.1: Mistral PAYG spend, cost-summary display style, per-provider accent color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 1 — Mistral PAYG current-month API spend (#2821, #2947): - Add MonthlyPlan variant to MetricPreference enum - Add currency_symbol field to CostSnapshot with with_currency_symbol builder - Set currency_symbol from Mistral billing API response - Add MonthlyPlan to bridge label/parse, tray selected_metric_percent (None = no bar) - Show formatted cost amount in provider_status_label for MonthlyPlan - Add format_cost_amount helper in bridge.rs - Add Mistral monthly spend row in MenuCardDetails - Add monthlyPlan option to MenuBarMetricSection for Mistral Item 2 — Menu cost-summary display style per provider (#2976): - Add CostSummaryDisplayStyle enum (Compact/Detailed/Hidden) in types.rs - Add cost_summary_display_style field to Settings + Default + RawSettings round-trip - Add to SettingsSnapshot bridge + SettingsUpdate + apply in Tauri commands - Add bridge label/parse functions - Apply in MenuCardDetails: hidden hides cost section, compact shows used/limit only - Update describeCard to filter hasCost for hidden style - Add Select control in UsageSpendTab Item 3 — Per-provider accent color override (#2972): - Add accent_color field to ProviderConfig - Add brand_color(ProviderId) function in provider.rs (mirrors frontend registry) - Add accent_color/set_accent_color/effective_accent_color accessors in Settings - Add set/get/get_effective Tauri commands + normalize_hex_accent_color validator - Add providerAccentColors map to SettingsSnapshot bridge - Add setProviderAccentColor/getProviderAccentColor/getProviderEffectiveAccentColor to tauri.ts - Create AccentColorSection component (hex input, color picker, reset button) - Inject --provider-accent CSS variable on MenuCard article and ChartsSection - Update menu-metric__bar-fill and chart colors to use --provider-accent fallback - Update chartPalette providerCostColor/providerCreditsColor fallback chain - Pass providerAccentColors through TrayPanel/PopOutPanel/ProvidersTab/ProviderDetailPane Also: - Add 11 new locale keys to all 7 .ftl files + keys.ts - Add costSummaryDisplayStyle + providerAccentColors to all test mock SettingsSnapshot - Update chartPalette tests for --provider-accent fallback --- .../src-tauri/src/commands/bridge.rs | 53 ++++++ .../src/commands/provider_settings.rs | 40 +++++ .../src-tauri/src/commands/settings.rs | 8 + apps/desktop-tauri/src-tauri/src/main.rs | 3 + .../src-tauri/src/tray_bridge.rs | 20 +++ apps/desktop-tauri/src/App.test.tsx | 2 + .../desktop-tauri/src/components/MenuCard.tsx | 18 +- .../src/components/MenuCardDetails.tsx | 27 ++- .../components/charts/chartPalette.test.ts | 16 +- .../src/components/charts/chartPalette.ts | 6 +- .../src/floatbar/FloatBar.test.tsx | 2 + apps/desktop-tauri/src/i18n/keys.ts | 17 ++ apps/desktop-tauri/src/lib/tauri.ts | 19 +++ apps/desktop-tauri/src/styles.css | 67 +++++++- .../src/surfaces/PopOutPanel.test.tsx | 2 + .../src/surfaces/PopOutPanel.tsx | 2 + .../src/surfaces/TrayPanel.test.tsx | 2 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 + .../settings/providers/ProviderDetailPane.tsx | 6 + .../providers/sections/AccentColorSection.tsx | 155 ++++++++++++++++++ .../sections/MenuBarMetricSection.tsx | 3 + .../sections/charts/ChartsSection.tsx | 13 +- .../surfaces/settings/tabs/AboutTab.test.tsx | 2 + .../settings/tabs/GeneralTab.test.tsx | 2 + .../surfaces/settings/tabs/ProvidersTab.tsx | 1 + .../surfaces/settings/tabs/UsageSpendTab.tsx | 56 ++++++- apps/desktop-tauri/src/types/bridge.test.ts | 2 + apps/desktop-tauri/src/types/bridge.ts | 11 ++ rust/src/core/usage_snapshot.rs | 13 ++ rust/src/locale.rs | 17 ++ rust/src/locale/en-US.ftl | 11 ++ rust/src/locale/es-MX.ftl | 11 ++ rust/src/locale/ja-JP.ftl | 11 ++ rust/src/locale/ko-KR.ftl | 11 ++ rust/src/locale/ru-RU.ftl | 11 ++ rust/src/locale/zh-CN.ftl | 11 ++ rust/src/locale/zh-TW.ftl | 11 ++ rust/src/providers/mistral/mod.rs | 1 + rust/src/settings/types.rs | 30 ++++ 39 files changed, 668 insertions(+), 27 deletions(-) create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 270e792e2a..b8aaa55f17 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -78,6 +78,8 @@ pub struct CostSnapshotBridge { #[serde(default = "default_currency")] pub currency_code: String, #[serde(default = "default_cost_period")] + #[serde(skip_serializing_if = "Option::is_none")] + pub currency_symbol: Option, pub period: String, #[serde(default)] pub resets_at: Option, @@ -101,6 +103,17 @@ fn default_cost_period() -> String { "month".to_string() } +/// Format a cost amount using the snapshot's currency symbol when available, +/// otherwise falling back to the currency-code prefix. Used by tray surfaces +/// that render a spend amount without a rate-window percent (MonthlyPlan). +pub(crate) fn format_cost_amount(cost: &CostSnapshotBridge) -> String { + if let Some(ref symbol) = cost.currency_symbol { + format!("{}{:.2}", symbol, cost.used) + } else { + format!("{:.2} {}", cost.used, cost.currency_code) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NamedRateWindowSnapshot { @@ -337,6 +350,7 @@ impl ProviderUsageSnapshot { limit: c.limit, remaining: c.remaining(), currency_code: c.currency_code.clone(), + currency_symbol: c.currency_symbol.clone(), period: c.period.clone(), resets_at: c.resets_at.map(|dt| dt.to_rfc3339()), formatted_used: c.format_used(), @@ -686,6 +700,8 @@ pub struct SettingsSnapshot { claude_daily_routines_usage_visible: bool, alibaba_token_plan_region: String, weekly_progress_work_days: Option, + cost_summary_display_style: &'static str, + provider_accent_colors: std::collections::HashMap, } #[tauri::command] @@ -791,6 +807,19 @@ impl From for SettingsSnapshot { claude_daily_routines_usage_visible: settings.claude_daily_routines_usage_visible, alibaba_token_plan_region: settings.alibaba_token_plan_region, weekly_progress_work_days: settings.weekly_progress_work_days, + cost_summary_display_style: cost_summary_display_style_label( + settings.cost_summary_display_style, + ), + provider_accent_colors: settings + .provider_configs + .iter() + .filter_map(|(id, config)| { + config + .accent_color + .as_ref() + .map(|color| (id.cli_name().to_string(), color.clone())) + }) + .collect(), } } } @@ -837,6 +866,28 @@ fn theme_label(theme: ThemePreference) -> &'static str { } } +fn cost_summary_display_style_label( + style: codexbar::settings::CostSummaryDisplayStyle, +) -> &'static str { + match style { + codexbar::settings::CostSummaryDisplayStyle::Compact => "compact", + codexbar::settings::CostSummaryDisplayStyle::Detailed => "detailed", + codexbar::settings::CostSummaryDisplayStyle::Hidden => "hidden", + } +} + +pub(crate) fn parse_cost_summary_display_style( + s: &str, +) -> Option { + use codexbar::settings::CostSummaryDisplayStyle; + match s { + "compact" => Some(CostSummaryDisplayStyle::Compact), + "detailed" => Some(CostSummaryDisplayStyle::Detailed), + "hidden" => Some(CostSummaryDisplayStyle::Hidden), + _ => None, + } +} + pub(super) fn parse_theme(s: &str) -> Option { match s { "auto" => Some(ThemePreference::Auto), @@ -855,6 +906,7 @@ fn metric_preference_label(pref: MetricPreference) -> &'static str { MetricPreference::Tertiary => "tertiary", MetricPreference::Credits => "credits", MetricPreference::ExtraUsage => "extraUsage", + MetricPreference::MonthlyPlan => "monthlyPlan", MetricPreference::Average => "average", } } @@ -868,6 +920,7 @@ pub(super) fn parse_metric_preference(s: &str) -> Option { "tertiary" => Some(MetricPreference::Tertiary), "credits" => Some(MetricPreference::Credits), "extraUsage" | "extrausage" => Some(MetricPreference::ExtraUsage), + "monthlyPlan" | "monthlyplan" => Some(MetricPreference::MonthlyPlan), "average" => Some(MetricPreference::Average), _ => None, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index 04eed75afd..0690659cf3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -306,6 +306,46 @@ fn gateway_provider(provider_id: &str) -> Option { (provider_id == "wayfinder").then_some(codexbar::core::ProviderId::Wayfinder) } +// ── Per-provider accent color override (#2972) ───────────────────── + +fn normalize_hex_accent_color(input: &str) -> Result { + let trimmed = input.trim(); + let stripped = trimmed.strip_prefix('#').unwrap_or(trimmed); + if stripped.len() != 6 || !stripped.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("Invalid hex color. Use #RRGGBB format, e.g. #FF5733.".to_string()); + } + Ok(format!("#{}", stripped.to_ascii_uppercase())) +} + +#[tauri::command] +pub fn set_provider_accent_color(provider_id: String, color: Option) -> Result<(), String> { + let id = parse_provider_arg(&provider_id)?; + let mut settings = Settings::load(); + match color.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + None | Some("") => { + settings.set_accent_color(id, None::<&str>); + } + Some(hex) => { + let normalized = normalize_hex_accent_color(hex)?; + settings.set_accent_color(id, Some(normalized)); + } + } + settings.save().map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn get_provider_accent_color(provider_id: String) -> Result, String> { + let id = parse_provider_arg(&provider_id)?; + Ok(Settings::load().accent_color(id).map(|s| s.to_string())) +} + +#[tauri::command] +pub fn get_provider_effective_accent_color(provider_id: String) -> Result { + let id = parse_provider_arg(&provider_id)?; + Ok(Settings::load().effective_accent_color(id)) +} + #[tauri::command] pub fn set_provider_gateway_url(provider_id: String, gateway_url: String) -> Result<(), String> { let id = gateway_provider(&provider_id) diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index d6e579107a..f7ebf7dc57 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -70,6 +70,7 @@ pub struct SettingsUpdate { pub claude_daily_routines_usage_visible: Option, pub alibaba_token_plan_region: Option, pub weekly_progress_work_days: Option, + pub cost_summary_display_style: Option, } impl SettingsUpdate { @@ -326,6 +327,13 @@ impl SettingsUpdate { if let Some(v) = self.weekly_progress_work_days { settings.weekly_progress_work_days = if (2..=6).contains(&v) { Some(v) } else { None }; } + if let Some(v) = self + .cost_summary_display_style + .as_deref() + .and_then(crate::commands::bridge::parse_cost_summary_display_style) + { + settings.cost_summary_display_style = v; + } self } diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index fa6a2924c0..b1b08d4f6a 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -211,6 +211,9 @@ fn main() { commands::get_provider_region_options, commands::set_provider_workspace_id, commands::set_provider_gateway_url, + commands::set_provider_accent_color, + commands::get_provider_accent_color, + commands::get_provider_effective_accent_color, commands::get_provider_workspace_id, commands::get_gemini_cli_signed_in, commands::get_vertexai_status, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index e7f1c6d31a..b77a01df45 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -563,6 +563,25 @@ fn provider_status_label( snapshot: &crate::commands::ProviderUsageSnapshot, lang: codexbar::settings::Language, ) -> (String, String) { + // MonthlyPlan metric (PAYG spend, e.g. Mistral): show formatted cost. + let provider = codexbar::core::ProviderId::from_cli_name(&snapshot.provider_id); + let preference = provider + .map(|id| Settings::load().get_provider_metric(id)) + .unwrap_or_default(); + if preference == MetricPreference::MonthlyPlan + && let Some(cost) = snapshot.cost.as_ref() + { + let amount = if !cost.formatted_used.is_empty() { + cost.formatted_used.clone() + } else { + crate::commands::format_cost_amount(cost) + }; + return ( + snapshot.provider_id.clone(), + format!("{} {}", snapshot.display_name, amount), + ); + } + // F5 (upstream 0.48.0): for Codex, prefer the first non-informational lane so // a monthly-only plan shows the monthly window with its reset countdown // instead of the informational "No active 5h session" placeholder. @@ -1041,6 +1060,7 @@ mod tests { limit: Some(limit), remaining: Some((limit - used).max(0.0)), currency_code: "USD".to_string(), + currency_symbol: None, period: "monthly".to_string(), resets_at: None, formatted_used: format!("${used:.2}"), diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index fbd64dbf4c..6320ae1d6e 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -124,6 +124,8 @@ function settings(overrides: Partial = {}): SettingsSnapshot { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, ...overrides, }; } diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 348b5349b0..74fbb2de48 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useState } from "react"; import type { + CostSummaryDisplayStyle, ProviderChartData, ProviderUsageSnapshot, } from "../types/bridge"; @@ -47,15 +48,19 @@ export interface MenuCardDisplayOptions { showResetWhenExhausted?: boolean; showAsUsed?: boolean; compactMetrics?: boolean; + costSummaryDisplayStyle?: CostSummaryDisplayStyle; } interface MenuCardProps { provider: ProviderUsageSnapshot; display: MenuCardDisplayOptions; isRefreshing?: boolean; + /** Per-provider accent color override (hex); applied as CSS --provider-accent. */ + accentColor?: string; onLayoutChange?: () => void; } + export function maskEmail(email: string): string { const at = email.indexOf("@"); if (at <= 1) return "••••@••••"; @@ -109,6 +114,7 @@ export default function MenuCard({ provider, display, isRefreshing = false, + accentColor, onLayoutChange, }: MenuCardProps) { const { @@ -117,6 +123,7 @@ export default function MenuCard({ showResetWhenExhausted = false, showAsUsed = false, compactMetrics = false, + costSummaryDisplayStyle, } = display; const { t } = useLocale(); const [chartData, setChartData] = useState(null); @@ -206,7 +213,7 @@ export default function MenuCard({ } const visibleMetrics = compactMetrics ? metrics.slice(0, 2) : metrics; - const presence = describeCard(provider, chartData, visibleMetrics); + const presence = describeCard(provider, chartData, visibleMetrics, costSummaryDisplayStyle); const { hasDetails } = presence; const cardClassName = [ "menu-card", @@ -218,7 +225,11 @@ export default function MenuCard({ .join(" "); return ( -
+
@@ -254,6 +265,7 @@ export default function MenuCard({ resetTimeRelative, showResetWhenExhausted, showAsUsed, + costSummaryDisplayStyle, }} metrics={visibleMetrics} chartData={chartData} diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 5af8df83e8..11c65ea761 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { + CostSummaryDisplayStyle, DailyCostPoint, PaceSnapshot, ProviderChartData, @@ -270,11 +271,11 @@ function getMetricPaceView(snap: RateWindowSnapshot): MetricPaceView { return { kind: "none" }; } - type MetricRowDisplay = { resetTimeRelative: boolean; showResetWhenExhausted?: boolean; showAsUsed?: boolean; + costSummaryDisplayStyle?: CostSummaryDisplayStyle; }; /** @@ -433,6 +434,7 @@ export function describeCard( provider: ProviderUsageSnapshot, chartData: ProviderChartData | null, visibleMetrics: MetricEntry[], + costSummaryDisplayStyle: CostSummaryDisplayStyle = "detailed", ): MenuCardPresence { const hasCostHistory = chartData !== null && chartData.costHistory.some((point) => point.value > 0); @@ -445,7 +447,7 @@ export function describeCard( const localUsage = provider.error ? null : chartData?.localUsage ?? null; const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; const hasMetrics = visibleMetrics.length > 0; - const hasCost = !!provider.cost; + const hasCost = !!provider.cost && costSummaryDisplayStyle !== "hidden"; const hasPace = !!provider.pace; const hasDetails = !provider.error && @@ -481,6 +483,7 @@ export default function MenuCardDetails({ display.resetTimeRelative, ); const localCostHistory = chartData?.costHistory ?? []; + const costStyle = display.costSummaryDisplayStyle ?? "detailed"; const { hasMetrics, @@ -529,9 +532,9 @@ export default function MenuCardDetails({ /> )} - {hasMetrics && hasCost &&
} + {hasMetrics && hasCost && costStyle !== "hidden" &&
} - {provider.cost && ( + {provider.cost && costStyle !== "hidden" && (
{provider.cost.balance != null && provider.cost.limit == null @@ -566,7 +569,7 @@ export default function MenuCardDetails({ )}
- {provider.cost.balance != null && ( + {costStyle === "detailed" && provider.cost.balance != null && (
{t("DetailCostBalance")}:{" "} {provider.cost.formattedBalance || @@ -576,7 +579,7 @@ export default function MenuCardDetails({ )}
)} - {provider.cost.remaining != null && ( + {costStyle === "detailed" && provider.cost.remaining != null && (
{t("DetailCostRemaining")}:{" "} {formatCurrency( @@ -585,13 +588,21 @@ export default function MenuCardDetails({ )}
)} - {formattedCostReset && ( + {costStyle === "detailed" && formattedCostReset && (
{t("DetailCostResets")}: {formattedCostReset}
)} )} + {provider.providerId === "mistral" && provider.cost && ( +
+ {t("MistralMonthlySpend")}:{" "} + {provider.cost.currencySymbol + ? `${provider.cost.currencySymbol}${provider.cost.used.toFixed(2)}` + : provider.cost.formattedUsed} +
+ )}
)} @@ -649,7 +660,7 @@ export default function MenuCardDetails({ `$${v.toFixed(2)}`} t={t} /> diff --git a/apps/desktop-tauri/src/components/charts/chartPalette.test.ts b/apps/desktop-tauri/src/components/charts/chartPalette.test.ts index 2b7c893c4f..c2299f4607 100644 --- a/apps/desktop-tauri/src/components/charts/chartPalette.test.ts +++ b/apps/desktop-tauri/src/components/charts/chartPalette.test.ts @@ -8,29 +8,31 @@ import { describe("chartPalette.providerColor", () => { it("returns a CSS var() expression referencing a provider token for known ids", () => { expect(providerCostColor("claude")).toBe( - "var(--chart-claude, var(--chart-cost))", + "var(--chart-claude, var(--provider-accent, var(--chart-cost)))", ); expect(providerCreditsColor("codex")).toBe( - "var(--chart-codex, var(--chart-credits))", + "var(--chart-codex, var(--provider-accent, var(--chart-credits)))", ); }); it("is case-insensitive and handles spaced aliases", () => { expect(providerCostColor("CURSOR")).toBe( - "var(--chart-cursor, var(--chart-cost))", + "var(--chart-cursor, var(--provider-accent, var(--chart-cost)))", ); expect(providerCostColor("Kimi K2")).toBe( - "var(--chart-kimik2, var(--chart-cost))", + "var(--chart-kimik2, var(--provider-accent, var(--chart-cost)))", ); expect(providerCostColor("Vertex AI")).toBe( - "var(--chart-vertexai, var(--chart-cost))", + "var(--chart-vertexai, var(--provider-accent, var(--chart-cost)))", ); }); it("falls back to the generic cost/credits token for unknown providers", () => { - expect(providerCostColor("unknown-provider-xyz")).toBe("var(--chart-cost)"); + expect(providerCostColor("unknown-provider-xyz")).toBe( + "var(--provider-accent, var(--chart-cost))", + ); expect(providerCreditsColor("another-ghost")).toBe( - "var(--chart-credits)", + "var(--provider-accent, var(--chart-credits))", ); }); }); diff --git a/apps/desktop-tauri/src/components/charts/chartPalette.ts b/apps/desktop-tauri/src/components/charts/chartPalette.ts index 10ba78a34f..beae1d7a0d 100644 --- a/apps/desktop-tauri/src/components/charts/chartPalette.ts +++ b/apps/desktop-tauri/src/components/charts/chartPalette.ts @@ -56,13 +56,15 @@ const PROVIDER_TOKEN: Record = { /** CSS color expression for a provider's cost-series bars. */ export function providerCostColor(providerId: string): string { const token = PROVIDER_TOKEN[providerId.toLowerCase()]; - return token ? `var(${token}, var(--chart-cost))` : "var(--chart-cost)"; + if (token) return `var(${token}, var(--provider-accent, var(--chart-cost)))`; + return "var(--provider-accent, var(--chart-cost))"; } /** CSS color expression for a provider's credits-series line. */ export function providerCreditsColor(providerId: string): string { const token = PROVIDER_TOKEN[providerId.toLowerCase()]; - return token ? `var(${token}, var(--chart-credits))` : "var(--chart-credits)"; + if (token) return `var(${token}, var(--provider-accent, var(--chart-credits)))`; + return "var(--provider-accent, var(--chart-credits))"; } /** diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 1991ccee8e..a7235838ae 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -185,6 +185,8 @@ function settings(overrides: Partial = {}): SettingsSnapshot { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, ...overrides, }; } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index ff20e42a8d..607625c8df 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -787,6 +787,23 @@ export const ALL_LOCALE_KEYS = [ "PromoteTrayIconLabel", "PromoteTrayIconHelper", "PromoteTrayIconUnsupportedHint", + + // Mistral PAYG monthly spend (#2821, #2947) + "MistralMonthlySpend", + "MistralMonthlySpendHelper", + + // Menu cost-summary display style (#2976) + "CostSummaryDisplayStyle", + "CostSummaryDisplayStyleHelper", + "CostSummaryStyleCompact", + "CostSummaryStyleDetailed", + "CostSummaryStyleHidden", + + // Per-provider accent color override (#2972) + "ProviderAccentColor", + "ProviderAccentColorHelper", + "ProviderAccentColorReset", + "ProviderAccentColorInvalid", ] as const; export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number]; diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index ea39b554da..90e024af1d 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -376,6 +376,25 @@ export function setProviderGatewayUrl( return invoke("set_provider_gateway_url", { providerId, gatewayUrl }); } +export function setProviderAccentColor( + providerId: string, + color: string | null, +): Promise { + return invoke("set_provider_accent_color", { providerId, color }); +} + +export function getProviderAccentColor( + providerId: string, +): Promise { + return invoke("get_provider_accent_color", { providerId }); +} + +export function getProviderEffectiveAccentColor( + providerId: string, +): Promise { + return invoke("get_provider_effective_accent_color", { providerId }); +} + // ── Phase 6d — credential detection ────────────────────────────────── export function openPath(path: string): Promise { diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 94bb315dc1..3e16dfb6a1 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -4252,7 +4252,7 @@ html:has(.menu-surface--tray) { .menu-metric__bar-fill { height: 100%; border-radius: 3px; - background: var(--usage-bar-normal); + background: var(--provider-accent, var(--usage-bar-normal)); transition: width 0.3s ease; } @@ -5916,3 +5916,68 @@ html:has(.menu-surface--tray) { .agent-sessions__error { color: var(--provider-status-error); } + +/* ── Per-provider accent color override (#2972) ─────────────────── */ +.provider-detail-accent-color { + margin-top: 16px; +} + +.accent-color-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.accent-color-picker { + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + background: none; +} + +.accent-color-input { + flex: 1; + min-width: 0; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-family: var(--font-mono, monospace); + font-size: 13px; + background: var(--surface-elevated); + color: inherit; +} + +.accent-color-swatch-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 12px; +} + +.accent-color-swatch { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.accent-color-swatch-label { + color: var(--text-secondary); +} + +.accent-color-swatch-value { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); +} + +/* ── Mistral monthly spend row (#2821, #2947) ──────────────────── */ +.menu-card__monthly-spend { + margin-top: 4px; + font-weight: 500; +} diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index 81f13ae848..5c1f8ac476 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -178,6 +178,8 @@ function settings(): SettingsSnapshot { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, }; } diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx index 9aa06a7717..4f21b03bea 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx @@ -259,7 +259,9 @@ export default function PopOutPanel({ showResetWhenExhausted: settings.showResetWhenExhausted, showAsUsed: settings.showAsUsed, compactMetrics: selectedProviderId === null, + costSummaryDisplayStyle: settings.costSummaryDisplayStyle, }} + accentColor={settings.providerAccentColors[p.providerId]} />
diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index c4a426a0bd..91fabb8cc2 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -164,6 +164,8 @@ function settings(overrides: Partial = {}): SettingsSnapshot { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, ...overrides, }; } diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 0f92bba552..93a7fd0bde 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -126,7 +126,9 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { showResetWhenExhausted: settings.showResetWhenExhausted, showAsUsed: settings.showAsUsed, compactMetrics: selectedProviderId === null, + costSummaryDisplayStyle: settings.costSummaryDisplayStyle, }} + accentColor={settings.providerAccentColors[p.providerId]} onLayoutChange={requestLayout} />
diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 996b4bdacb..da40f635e8 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -35,6 +35,7 @@ import { TokenAccountsPanel } from "../tokens/TokenAccountsPanel"; import { ApiKeySection } from "./ApiKeySection"; import { CookieSection } from "./CookieSection"; import { MenuBarMetricSection } from "./sections/MenuBarMetricSection"; +import { AccentColorSection } from "./sections/AccentColorSection"; import { ProviderIssueNotice } from "./sections/ProviderIssueNotice"; import { CredentialStorageSection } from "./sections/CredentialStorageSection"; import { CredentialsDispatcher } from "./sections/CredentialsDispatcher"; @@ -45,6 +46,8 @@ interface Props { cookieDomain?: string | null; resetTimeRelative: boolean; providerMetrics: SettingsSnapshot["providerMetrics"]; + /** Per-provider accent color overrides (CLI name → hex color). */ + providerAccentColors: SettingsSnapshot["providerAccentColors"]; wayfinderGatewayUrl: string; settingsDisabled: boolean; onSettingsChange: (patch: SettingsUpdate) => void; @@ -62,6 +65,7 @@ export function ProviderDetailPane({ cookieDomain = null, resetTimeRelative, providerMetrics, + providerAccentColors, wayfinderGatewayUrl, settingsDisabled, onSettingsChange, @@ -298,6 +302,7 @@ export function ProviderDetailPane({ t={t} onChange={onSettingsChange} /> + @@ -340,6 +345,7 @@ export function ProviderDetailPane({ diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx new file mode 100644 index 0000000000..5169675144 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "react"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { + getProviderAccentColor, + setProviderAccentColor, +} from "../../../../lib/tauri"; +import { getProviderIcon } from "../../../../components/providers/providerIcons"; + +interface Props { + providerId: string; + t: (key: LocaleKey) => string; +} + +/** + * Per-provider accent color override (#2972): hex input, native color + * picker, and a reset-to-shipped-color button. The override is persisted + * via the `set_provider_accent_color` Tauri command and applied at runtime + * through the `--provider-accent` CSS custom property. + */ +export function AccentColorSection({ providerId, t }: Props) { + const [savedColor, setSavedColor] = useState(null); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const brandColor = getProviderIcon(providerId).brandColor; + + useEffect(() => { + let cancelled = false; + void getProviderAccentColor(providerId) + .then((color) => { + if (cancelled) return; + setSavedColor(color); + setInput(color ?? ""); + }) + .catch(() => { + if (cancelled) return; + setSavedColor(null); + setInput(""); + }); + return () => { + cancelled = true; + }; + }, [providerId]); + + const effective = savedColor ?? brandColor; + + + const handleSave = async (raw: string) => { + setError(null); + const trimmed = raw.trim(); + if (trimmed === "") { + setSaving(true); + try { + await setProviderAccentColor(providerId, null); + setSavedColor(null); + setInput(""); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + return; + } + const trimmedHex = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; + if (trimmedHex.length !== 6 || !/^[0-9A-Fa-f]{6}$/.test(trimmedHex)) { + setError(t("ProviderAccentColorInvalid")); + return; + } + const normalized = `#${trimmedHex.toUpperCase()}`; + setSaving(true); + try { + await setProviderAccentColor(providerId, normalized); + setSavedColor(normalized); + setInput(normalized); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + const handleReset = async () => { + setError(null); + setSaving(true); + try { + await setProviderAccentColor(providerId, null); + setSavedColor(null); + setInput(""); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + return ( +
+

{t("ProviderAccentColor")}

+

+ {t("ProviderAccentColorHelper")} +

+
+ { + const value = e.target.value.toUpperCase(); + setInput(value); + void handleSave(value); + }} + /> + setInput(e.target.value)} + onBlur={() => void handleSave(input)} + onKeyDown={(e) => { + if (e.key === "Enter") { + void handleSave(input); + } + }} + /> + +
+
+ + {t("ProviderAccentColor")} + + + {effective} +
+ {error &&

{error}

} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx index 6e4ae41c27..92f7c4f5c2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx @@ -93,6 +93,9 @@ function metricOptions( if (provider.id === "cursor" || provider.extraRateWindows.length > 0) { options.push({ value: "extraUsage", label: t("ExtraUsage") }); } + if (provider.id === "mistral") { + options.push({ value: "monthlyPlan", label: t("MistralMonthlySpend") }); + } if (provider.id === "gemini" && provider.weekly) { options.push({ value: "average", label: t("Average") }); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx index f295b123ce..b3550e1c30 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { type CSSProperties, useEffect, useState } from "react"; import { getProviderChartData, getSettingsSnapshot } from "../../../../../lib/tauri"; import { providerSupportsChartData } from "../../../../../lib/providerCharts"; import type { ProviderChartData, SettingsSnapshot } from "../../../../../types/bridge"; @@ -13,6 +13,8 @@ type T = ReturnType["t"]; interface Props { providerId: string; accountEmail: string | null; + /** Per-provider accent color override (hex); applied as CSS --provider-accent. */ + accentColor?: string; t: T; } @@ -27,7 +29,7 @@ type TabKey = "tokens" | "cost" | "credits" | "usage"; * Phase 10: fetches the latest settings snapshot so the animation flag feeds * through to each chart component. */ -export function ChartsSection({ providerId, accountEmail, t }: Props) { +export function ChartsSection({ providerId, accountEmail, accentColor, t }: Props) { const [data, setData] = useState(null); const [active, setActive] = useState(null); const [animations, setAnimations] = useState(true); @@ -98,9 +100,12 @@ export function ChartsSection({ providerId, accountEmail, t }: Props) { if (k === "credits") return t("DetailChartCredits"); return t("DetailChartUsageBreakdown"); }; - return ( -
+ +
{available.map((k) => (
+ + {error &&

{error}

} {shareError &&

{shareError}

} @@ -230,3 +233,52 @@ export default function UsageSpendTab(_props: TabProps) {
); } + +function CostSummaryStyleControl({ t }: { t: (key: LocaleKey) => string }) { + const [style, setStyle] = useState("compact"); + const [loading, setLoading] = useState(true); + + useEffect(() => { + void getSettingsSnapshot().then((snap: SettingsSnapshot) => { + setStyle(snap.costSummaryDisplayStyle); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + const handleChange = useCallback(async (value: CostSummaryDisplayStyle) => { + setStyle(value); + try { + await updateSettings({ costSummaryDisplayStyle: value }); + } catch { + /* best-effort; revert handled by next settings refresh */ + } + }, []); + + const options: { value: CostSummaryDisplayStyle; label: string }[] = [ + { value: "compact", label: t("CostSummaryStyleCompact") }, + { value: "detailed", label: t("CostSummaryStyleDetailed") }, + { value: "hidden", label: t("CostSummaryStyleHidden") }, + ]; + + return ( +
+ +

{t("CostSummaryDisplayStyleHelper")}

+ +
+ ); +} diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index d505264216..d2e56e8051 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -117,6 +117,8 @@ describe("Language type", () => { claudeDailyRoutinesUsageVisible: true, alibabaTokenPlanRegion: "cn", weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, }; expect(snap.uiLanguage).toBe("spanish"); diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index f00b510a37..c8d398e52f 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -43,6 +43,7 @@ export type MetricPreference = | "tertiary" | "credits" | "extraUsage" + | "monthlyPlan" | "average"; export type Language = @@ -68,6 +69,9 @@ export type UpdateChannel = "stable" | "beta"; export type ThemePreference = "auto" | "light" | "dark"; export type MenuBarDisplayMode = "minimal" | "compact" | "detailed"; + +/** How cost is rendered on provider MenuCards (#2976). */ +export type CostSummaryDisplayStyle = "compact" | "detailed" | "hidden"; export type FloatBarOrientation = "horizontal" | "vertical"; export type FloatBarStyle = "floating" | "taskbar"; @@ -244,6 +248,10 @@ export interface SettingsSnapshot { alibabaTokenPlanRegion: string; /** Optional work-week length [2,6] for session-equivalent weekly forecast. */ weeklyProgressWorkDays?: number | null; + /** How cost is rendered on provider cards (#2976). */ + costSummaryDisplayStyle: CostSummaryDisplayStyle; + /** Per-provider accent color overrides (CLI name → hex color, #2972). */ + providerAccentColors: Record; } /** Partial settings object — only include fields you want to change. */ @@ -310,6 +318,7 @@ export interface SettingsUpdate { claudeDailyRoutinesUsageVisible?: boolean; alibabaTokenPlanRegion?: string; weeklyProgressWorkDays?: number | null; + costSummaryDisplayStyle?: CostSummaryDisplayStyle; } export interface UsageThresholdOverride { @@ -426,6 +435,8 @@ export interface CostSnapshotBridge { limit: number | null; remaining: number | null; currencyCode: string; + /** Optional currency symbol (e.g. "€", "$", "¥") for localized rendering. */ + currencySymbol?: string | null; period: string; resetsAt: string | null; formattedUsed: string; diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index dbb4f7f174..0c9c07765c 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -218,6 +218,12 @@ pub struct CostSnapshot { /// Currency code (e.g., "USD") pub currency_code: String, + /// Optional currency symbol (e.g. "€", "$", "¥"). When present, + /// surfaces carry it to the UI for localized currency rendering instead + /// of deriving the symbol from the code. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency_symbol: Option, + /// Period description (e.g., "Monthly", "Daily") pub period: String, @@ -240,6 +246,7 @@ impl CostSnapshot { used: finite_amount(used).unwrap_or(0.0), limit: None, currency_code: currency_code.into(), + currency_symbol: None, period: period.into(), resets_at: None, updated_at: Utc::now(), @@ -259,6 +266,12 @@ impl CostSnapshot { self } + /// Builder pattern: set currency symbol for localized rendering. + pub fn with_currency_symbol(mut self, symbol: impl Into) -> Self { + self.currency_symbol = Some(symbol.into()); + self + } + /// Builder pattern: set reset time pub fn with_resets_at(mut self, resets_at: DateTime) -> Self { self.resets_at = Some(resets_at); diff --git a/rust/src/locale.rs b/rust/src/locale.rs index cd505a84a4..d911f74958 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1043,6 +1043,23 @@ locale_keys! { PromoteTrayIconLabel, PromoteTrayIconHelper, PromoteTrayIconUnsupportedHint, + + // Mistral PAYG monthly spend (#2821, #2947) + MistralMonthlySpend, + MistralMonthlySpendHelper, + + // Menu cost-summary display style (#2976) + CostSummaryDisplayStyle, + CostSummaryDisplayStyleHelper, + CostSummaryStyleCompact, + CostSummaryStyleDetailed, + CostSummaryStyleHidden, + + // Per-provider accent color override (#2972) + ProviderAccentColor, + ProviderAccentColorHelper, + ProviderAccentColorReset, + ProviderAccentColorInvalid, } #[cfg(test)] diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 83203fbebe..4594cd3b72 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -760,3 +760,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = Monthly API spend +MistralMonthlySpendHelper = Current-month API usage for pay-as-you-go Mistral accounts. +CostSummaryDisplayStyle = Cost summary display +CostSummaryDisplayStyleHelper = Choose how cost is shown on every provider card. +CostSummaryStyleCompact = Compact +CostSummaryStyleDetailed = Detailed +CostSummaryStyleHidden = Hidden +ProviderAccentColor = Accent color +ProviderAccentColorHelper = Override the brand color used for usage bars and charts. Enter a hex color like #FF5733. +ProviderAccentColorReset = Reset to default +ProviderAccentColorInvalid = Invalid hex color. Use #RRGGBB format, e.g. #FF5733. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index cb34a56e3c..6b2c39d2de 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -681,3 +681,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = Gasto de API mensual +MistralMonthlySpendHelper = Uso de API del mes actual para cuentas de pago por uso de Mistral. +CostSummaryDisplayStyle = Estilo de resumen de costos +CostSummaryDisplayStyleHelper = Elige cómo se muestran los costos en cada tarjeta de proveedor. +CostSummaryStyleCompact = Compacto +CostSummaryStyleDetailed = Detallado +CostSummaryStyleHidden = Oculto +ProviderAccentColor = Color de acento +ProviderAccentColorHelper = Anula el color de marca usado en barras de uso y gráficos. Introduce un color hexadecimal como #FF5733. +ProviderAccentColorReset = Restablecer predeterminado +ProviderAccentColorInvalid = Color hexadecimal no válido. Usa el formato #RRGGBB, por ejemplo #FF5733. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 489afa6d8c..597bc6a661 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -747,3 +747,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = 月間 API 費用 +MistralMonthlySpendHelper = 従量課金 Mistral アカウントの当月 API 使用量。 +CostSummaryDisplayStyle = 費用サマリー表示スタイル +CostSummaryDisplayStyleHelper = 各プロバイダーカードの費用表示方法を選択します。 +CostSummaryStyleCompact = コンパクト +CostSummaryStyleDetailed = 詳細 +CostSummaryStyleHidden = 非表示 +ProviderAccentColor = アクセントカラー +ProviderAccentColorHelper = 使用量バーとチャートのブランドカラーを上書きします。#FF5733 のような 16 進数カラーを入力。 +ProviderAccentColorReset = デフォルトにリセット +ProviderAccentColorInvalid = 無効な 16 進数カラー。#RRGGBB 形式(例: #FF5733)を使用してください。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 648d62f91d..b8bd2fdd54 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -668,3 +668,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = 월간 API 비용 +MistralMonthlySpendHelper = 종량제 Mistral 계정의 당월 API 사용량입니다. +CostSummaryDisplayStyle = 비용 요약 표시 스타일 +CostSummaryDisplayStyleHelper = 각 공급자 카드에 비용을 표시하는 방법을 선택합니다. +CostSummaryStyleCompact = 간결 +CostSummaryStyleDetailed = 세부 +CostSummaryStyleHidden = 숨김 +ProviderAccentColor = 강조 색상 +ProviderAccentColorHelper = 사용량 막대와 차트에 사용되는 브랜드 색상을 재정의합니다. #FF5733 같은 16진수 색상을 입력하세요. +ProviderAccentColorReset = 기본값으로 재설정 +ProviderAccentColorInvalid = 잘못된 16진수 색상입니다. #RRGGBB 형식(예: #FF5733)을 사용하세요. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 32b41e4cdf..222343bc6b 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -725,3 +725,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = Расход на API за месяц +MistralMonthlySpendHelper = Использование API за текущий месяц для аккаунтов Mistral с оплатой по мере использования. +CostSummaryDisplayStyle = Стиль отображения стоимости +CostSummaryDisplayStyleHelper = Выберите, как стоимость отображается на каждой карте провайдера. +CostSummaryStyleCompact = Компактный +CostSummaryStyleDetailed = Подробный +CostSummaryStyleHidden = Скрытый +ProviderAccentColor = Акцентный цвет +ProviderAccentColorHelper = Переопределите фирменный цвет для полос использованя и графиков. Введите HEX-цвет, например #FF5733. +ProviderAccentColorReset = Сбросить по умолчанию +ProviderAccentColorInvalid = Недопустимый HEX-цвет. Используйте формат #RRGGBB, например #FF5733. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 776ed80059..eb46c3ef24 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -746,3 +746,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = 本月 API 花费 +MistralMonthlySpendHelper = 按量付费 Mistral 账户的当月 API 用量。 +CostSummaryDisplayStyle = 费用摘要显示方式 +CostSummaryDisplayStyleHelper = 选择在每个提供商卡片上如何显示费用。 +CostSummaryStyleCompact = 紧凑 +CostSummaryStyleDetailed = 详细 +CostSummaryStyleHidden = 隐藏 +ProviderAccentColor = 强调色 +ProviderAccentColorHelper = 覆盖用于用量条和图表的品牌颜色。输入十六进制颜色,如 #FF5733。 +ProviderAccentColorReset = 恢复默认 +ProviderAccentColorInvalid = 无效的十六进制颜色。请使用 #RRGGBB 格式,例如 #FF5733。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 55970aa2bb..16fc87f60b 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -746,3 +746,14 @@ DeepSeekPricingCurrent = Current local time: DeepSeekPricingNext = Next transition: DeepSeekPricingEffective = Effective local time: DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. +MistralMonthlySpend = 本月 API 花費 +MistralMonthlySpendHelper = 按量付費 Mistral 帳戶的當月 API 用量。 +CostSummaryDisplayStyle = 費用摘要顯示方式 +CostSummaryDisplayStyleHelper = 選擇在每個提供商卡片上如何顯示費用。 +CostSummaryStyleCompact = 緊湊 +CostSummaryStyleDetailed = 詳細 +CostSummaryStyleHidden = 隱藏 +ProviderAccentColor = 強調色 +ProviderAccentColorHelper = 覆蓑用於用量條和圖表的品牌顏色。輸入十六進位顏色,如 #FF5733。 +ProviderAccentColorReset = 恢復預設 +ProviderAccentColorInvalid = 無效的十六進位顏色。請使用 #RRGGBB 格式,例如 #FF5733。 diff --git a/rust/src/providers/mistral/mod.rs b/rust/src/providers/mistral/mod.rs index 2d433306fc..61dd9b24cb 100644 --- a/rust/src/providers/mistral/mod.rs +++ b/rust/src/providers/mistral/mod.rs @@ -257,6 +257,7 @@ impl MistralProvider { } let mut cost = CostSnapshot::new(summary.total_cost, summary.currency, "Monthly"); + cost = cost.with_currency_symbol(summary.currency_symbol); if let Some(reset) = reset_date { cost = cost.with_resets_at(reset); } diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index bd80c81f18..8353c0c1b3 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -280,6 +280,9 @@ pub enum MetricPreference { Credits, #[serde(rename = "extraUsage", alias = "extrausage")] ExtraUsage, + /// Current-month plan spend for PAYG providers (e.g. Mistral) that have + /// cost data but no rate-limit window (#2821, #2947). + MonthlyPlan, Average, } @@ -294,6 +297,7 @@ impl MetricPreference { MetricPreference::Tertiary, MetricPreference::Credits, MetricPreference::ExtraUsage, + MetricPreference::MonthlyPlan, MetricPreference::Average, ] } @@ -308,6 +312,7 @@ impl MetricPreference { MetricPreference::Tertiary => "Tertiary", MetricPreference::Credits => "Credits", MetricPreference::ExtraUsage => "Extra usage", + MetricPreference::MonthlyPlan => "Monthly plan spend", MetricPreference::Average => "Average", } } @@ -322,11 +327,32 @@ impl MetricPreference { MetricPreference::Tertiary => "Tertiary usage limit", MetricPreference::Credits => "Credit balance", MetricPreference::ExtraUsage => "On-demand or extra usage budget", + MetricPreference::MonthlyPlan => "Current-month plan spend (PAYG)", MetricPreference::Average => "Average across metrics", } } } +/// How cost is rendered on provider MenuCards (#2976). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum CostSummaryDisplayStyle { + #[default] + Compact, + Detailed, + Hidden, +} + +impl CostSummaryDisplayStyle { + pub fn all() -> &'static [CostSummaryDisplayStyle] { + &[ + CostSummaryDisplayStyle::Compact, + CostSummaryDisplayStyle::Detailed, + CostSummaryDisplayStyle::Hidden, + ] + } +} + /// Per-provider configuration values. /// /// All fields are optional / falsy-default so unused providers serialize as @@ -365,4 +391,8 @@ pub struct ProviderConfig { /// Claude-only: avoid keychain prompts when reading credentials. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub avoid_keychain_prompts: bool, + /// Per-provider accent color override (hex, e.g. "#FF5733"). `None` + /// means the shipped brand color is used (#2972). + #[serde(skip_serializing_if = "Option::is_none")] + pub accent_color: Option, } From e8a7483aa3a589bba32bb42d4bc281afff1fce0a Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:54:08 +0700 Subject: [PATCH 08/10] Resolve merge conflicts and fix gate checks --- apps/desktop-tauri/src-tauri/src/commands/mod.rs | 2 +- apps/desktop-tauri/src-tauri/src/commands/system.rs | 4 +--- apps/desktop-tauri/src-tauri/src/events.rs | 7 +------ rust/src/cli/serve/dashboard/mod.rs | 1 - rust/src/cli/usage.rs | 7 +++---- rust/src/providers/ollama/mod.rs | 5 +---- 6 files changed, 7 insertions(+), 19 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 3780110bc0..fc4e595b7a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -7,8 +7,8 @@ use codexbar::core::{ instantiate_provider, }; use codexbar::locale; -use codexbar::providers::copilot::{CopilotApi, device_flow::CopilotDeviceFlow}; use codexbar::login::{self, LoginOutcome, LoginPhase}; +use codexbar::providers::copilot::{CopilotApi, device_flow::CopilotDeviceFlow}; use codexbar::secure_file::{self, SecureFileStatus}; use codexbar::settings::{ ApiKeys, Language, ManualCookies, MetricPreference, Settings, ThemePreference, TrayIconMode, diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 21c49f2a65..403256e4c3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -300,9 +300,7 @@ async fn run_cli_provider_login( LoginOutcome::MissingBinary => Err(format!( "{display_name} CLI not found. Install it and ensure it is on your PATH." )), - LoginOutcome::LaunchFailed(e) => { - Err(format!("Failed to launch {display_name} login: {e}")) - } + LoginOutcome::LaunchFailed(e) => Err(format!("Failed to launch {display_name} login: {e}")), LoginOutcome::TimedOut => Err(format!("{display_name} login timed out")), LoginOutcome::Failed { status } => Err(format!( "{display_name} login failed with exit code {status}" diff --git a/apps/desktop-tauri/src-tauri/src/events.rs b/apps/desktop-tauri/src-tauri/src/events.rs index 2e73b0552e..b7484c7643 100644 --- a/apps/desktop-tauri/src-tauri/src/events.rs +++ b/apps/desktop-tauri/src-tauri/src/events.rs @@ -111,12 +111,7 @@ pub fn emit_settings_changed(app: &AppHandle) { let _ = app.emit(SETTINGS_CHANGED, ()); } -pub fn emit_login_phase( - app: &AppHandle, - provider_id: &str, - phase: &str, - auth_link: Option<&str>, -) { +pub fn emit_login_phase(app: &AppHandle, provider_id: &str, phase: &str, auth_link: Option<&str>) { let _ = app.emit( LOGIN_PHASE, LoginPhasePayload { diff --git a/rust/src/cli/serve/dashboard/mod.rs b/rust/src/cli/serve/dashboard/mod.rs index 5b04d71fd8..ccede28e8c 100644 --- a/rust/src/cli/serve/dashboard/mod.rs +++ b/rust/src/cli/serve/dashboard/mod.rs @@ -64,7 +64,6 @@ impl DashboardState { refresh_seconds: 60, } } - } /// `GET /` — the embedded web dashboard shell. Static per config; no account diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index 7b03aac315..223e06afcb 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -365,9 +365,7 @@ fn render_json_result( .primary .window_minutes .is_some_and(|m| m == crate::core::SESSION_WINDOW_MINUTES) - .then(|| { - UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES) - }) + .then(|| UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES)) .flatten() .map(pace_json); let secondary_pace = usage @@ -523,7 +521,8 @@ fn append_usage_window_lines( append_window_line(lines, metadata.session_label, &usage.primary, use_color); // Upstream 0.50.1 #2957: pace for the 5-hour session window. if usage.primary.window_minutes == Some(crate::core::SESSION_WINDOW_MINUTES) - && let Some(pace) = UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES) + && let Some(pace) = + UsagePace::weekly(&usage.primary, None, crate::core::SESSION_WINDOW_MINUTES) { lines.push(format!( " Pace: {} {}", diff --git a/rust/src/providers/ollama/mod.rs b/rust/src/providers/ollama/mod.rs index f30138cb28..7789a85628 100755 --- a/rust/src/providers/ollama/mod.rs +++ b/rust/src/providers/ollama/mod.rs @@ -272,7 +272,6 @@ impl OllamaProvider { } } - /// Resolve cookies from manual cookies, validated cache, or browser import. /// /// Upstream #2404: reuse the last validated browser session cookie header @@ -763,9 +762,7 @@ mod tests { // Upstream 0.50.1 #2949: a copied `Cookie:` label after another // cookie, and cURL `-H`/`-b` wrappers with quotes. assert_eq!( - OllamaProvider::normalize_cookie_header( - "aid=device; Cookie: __Secure-session=abc123" - ), + OllamaProvider::normalize_cookie_header("aid=device; Cookie: __Secure-session=abc123"), Some("aid=device; __Secure-session=abc123".to_string()) ); assert_eq!( From cba1b69cbfd3548b82748ce89e4482e18ba0408d Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:06:23 +0700 Subject: [PATCH 09/10] Thermo-nuclear: extract ollama cookies, codex pricing, simplify accent color --- .../src/commands/provider_settings.rs | 40 -- apps/desktop-tauri/src-tauri/src/main.rs | 3 - .../src-tauri/src/tray_bridge.rs | 5 +- .../src-tauri/src/usage_metric.rs | 1 + apps/desktop-tauri/src/lib/tauri.ts | 19 - .../settings/providers/ProviderDetailPane.tsx | 7 +- .../providers/sections/AccentColorSection.tsx | 97 ++--- apps/desktop-tauri/src/types/bridge.ts | 1 + rust/src/core/codex_routed_pricing.rs | 41 ++ rust/src/core/cost_pricing.rs | 59 +-- rust/src/core/cost_pricing_tests.rs | 17 +- rust/src/core/mod.rs | 1 + rust/src/providers/ollama/cookies.rs | 407 ++++++++++++++++++ rust/src/providers/ollama/mod.rs | 400 +---------------- 14 files changed, 510 insertions(+), 588 deletions(-) create mode 100644 rust/src/core/codex_routed_pricing.rs create mode 100644 rust/src/providers/ollama/cookies.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index 0690659cf3..04eed75afd 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -306,46 +306,6 @@ fn gateway_provider(provider_id: &str) -> Option { (provider_id == "wayfinder").then_some(codexbar::core::ProviderId::Wayfinder) } -// ── Per-provider accent color override (#2972) ───────────────────── - -fn normalize_hex_accent_color(input: &str) -> Result { - let trimmed = input.trim(); - let stripped = trimmed.strip_prefix('#').unwrap_or(trimmed); - if stripped.len() != 6 || !stripped.chars().all(|c| c.is_ascii_hexdigit()) { - return Err("Invalid hex color. Use #RRGGBB format, e.g. #FF5733.".to_string()); - } - Ok(format!("#{}", stripped.to_ascii_uppercase())) -} - -#[tauri::command] -pub fn set_provider_accent_color(provider_id: String, color: Option) -> Result<(), String> { - let id = parse_provider_arg(&provider_id)?; - let mut settings = Settings::load(); - match color.as_deref().map(str::trim).filter(|s| !s.is_empty()) { - None | Some("") => { - settings.set_accent_color(id, None::<&str>); - } - Some(hex) => { - let normalized = normalize_hex_accent_color(hex)?; - settings.set_accent_color(id, Some(normalized)); - } - } - settings.save().map_err(|e| e.to_string())?; - Ok(()) -} - -#[tauri::command] -pub fn get_provider_accent_color(provider_id: String) -> Result, String> { - let id = parse_provider_arg(&provider_id)?; - Ok(Settings::load().accent_color(id).map(|s| s.to_string())) -} - -#[tauri::command] -pub fn get_provider_effective_accent_color(provider_id: String) -> Result { - let id = parse_provider_arg(&provider_id)?; - Ok(Settings::load().effective_accent_color(id)) -} - #[tauri::command] pub fn set_provider_gateway_url(provider_id: String, gateway_url: String) -> Result<(), String> { let id = gateway_provider(&provider_id) diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index b1b08d4f6a..fa6a2924c0 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -211,9 +211,6 @@ fn main() { commands::get_provider_region_options, commands::set_provider_workspace_id, commands::set_provider_gateway_url, - commands::set_provider_accent_color, - commands::get_provider_accent_color, - commands::get_provider_effective_accent_color, commands::get_provider_workspace_id, commands::get_gemini_cli_signed_in, commands::get_vertexai_status, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index b77a01df45..65755ccc09 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -3,9 +3,10 @@ use std::sync::Mutex; use crate::commands::ProviderCatalogEntry; -use codexbar::settings::{Settings, TrayIconMode}; #[cfg(test)] -use codexbar::{core::ProviderId, settings::MetricPreference}; +use codexbar::core::ProviderId; +use codexbar::settings::MetricPreference; +use codexbar::settings::{Settings, TrayIconMode}; use tauri::image::Image; use tauri::menu::{CheckMenuItemBuilder, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 0c568528ad..be5c83bfa0 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -49,6 +49,7 @@ fn preferred_window( extra_usage_window(snapshot).or_else(|| cost_window(snapshot)) } MetricPreference::Average => average_window(snapshot), + MetricPreference::MonthlyPlan => cost_window(snapshot), } } diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 90e024af1d..ea39b554da 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -376,25 +376,6 @@ export function setProviderGatewayUrl( return invoke("set_provider_gateway_url", { providerId, gatewayUrl }); } -export function setProviderAccentColor( - providerId: string, - color: string | null, -): Promise { - return invoke("set_provider_accent_color", { providerId, color }); -} - -export function getProviderAccentColor( - providerId: string, -): Promise { - return invoke("get_provider_accent_color", { providerId }); -} - -export function getProviderEffectiveAccentColor( - providerId: string, -): Promise { - return invoke("get_provider_effective_accent_color", { providerId }); -} - // ── Phase 6d — credential detection ────────────────────────────────── export function openPath(path: string): Promise { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index da40f635e8..c3377d78a5 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -302,7 +302,12 @@ export function ProviderDetailPane({ t={t} onChange={onSettingsChange} /> - + diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx index 5169675144..9e0f8dca41 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx @@ -1,65 +1,38 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { LocaleKey } from "../../../../i18n/keys"; -import { - getProviderAccentColor, - setProviderAccentColor, -} from "../../../../lib/tauri"; +import type { SettingsUpdate } from "../../../../types/bridge"; import { getProviderIcon } from "../../../../components/providers/providerIcons"; interface Props { providerId: string; + accentColor: string | null; t: (key: LocaleKey) => string; + onChange: (patch: SettingsUpdate) => void; } /** * Per-provider accent color override (#2972): hex input, native color * picker, and a reset-to-shipped-color button. The override is persisted - * via the `set_provider_accent_color` Tauri command and applied at runtime - * through the `--provider-accent` CSS custom property. + * via the standard settings-update flow (onSettingsChange). */ -export function AccentColorSection({ providerId, t }: Props) { - const [savedColor, setSavedColor] = useState(null); - const [input, setInput] = useState(""); +export function AccentColorSection({ + providerId, + accentColor, + t, + onChange, +}: Props) { + const [input, setInput] = useState(accentColor ?? ""); const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); const brandColor = getProviderIcon(providerId).brandColor; + const effective = accentColor ?? brandColor; - useEffect(() => { - let cancelled = false; - void getProviderAccentColor(providerId) - .then((color) => { - if (cancelled) return; - setSavedColor(color); - setInput(color ?? ""); - }) - .catch(() => { - if (cancelled) return; - setSavedColor(null); - setInput(""); - }); - return () => { - cancelled = true; - }; - }, [providerId]); - - const effective = savedColor ?? brandColor; - - - const handleSave = async (raw: string) => { + const handleSave = (raw: string) => { setError(null); const trimmed = raw.trim(); if (trimmed === "") { - setSaving(true); - try { - await setProviderAccentColor(providerId, null); - setSavedColor(null); - setInput(""); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); - } + onChange({ providerAccentColors: { [providerId]: null } }); + setInput(""); return; } const trimmedHex = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; @@ -68,30 +41,14 @@ export function AccentColorSection({ providerId, t }: Props) { return; } const normalized = `#${trimmedHex.toUpperCase()}`; - setSaving(true); - try { - await setProviderAccentColor(providerId, normalized); - setSavedColor(normalized); - setInput(normalized); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); - } + onChange({ providerAccentColors: { [providerId]: normalized } }); + setInput(normalized); }; - const handleReset = async () => { + const handleReset = () => { setError(null); - setSaving(true); - try { - await setProviderAccentColor(providerId, null); - setSavedColor(null); - setInput(""); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); - } + onChange({ providerAccentColors: { [providerId]: null } }); + setInput(""); }; return ( @@ -106,11 +63,10 @@ export function AccentColorSection({ providerId, t }: Props) { className="accent-color-picker" value={effective} aria-label={t("ProviderAccentColor")} - disabled={saving} onChange={(e) => { const value = e.target.value.toUpperCase(); setInput(value); - void handleSave(value); + handleSave(value); }} /> setInput(e.target.value)} - onBlur={() => void handleSave(input)} + onBlur={() => handleSave(input)} onKeyDown={(e) => { if (e.key === "Enter") { - void handleSave(input); + handleSave(input); } }} />