diff --git a/.gitattributes b/.gitattributes index ceb10cdb3b..9da598bc8b 100755 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,6 @@ rust/src/agent_sessions/fixtures/** -text # Bash scripts must remain LF for Linux and WSL execution. *.sh text eol=lf + +# Upstream Claude PTY captures are byte-exact copies (they contain CR bytes); never EOL-convert them. +rust/src/providers/fixtures/claude/*.ansi -text diff --git a/CHANGELOG.md b/CHANGELOG.md index c054768f2c..7a60980f5a 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,68 @@ # Changelog -## [Windows] 0.61.0 - Unreleased +## [Windows] 0.70.0 - 2026-10-02 + +Windows port of upstream CodexBar **0.60.3 → 0.70.0**: every provider, cost/usage, privacy, tray, CLI and reliability port from upstream 0.60.4 through 0.70.0 integrated across the release branch. + +### Added + +- Providers: port Hyper (session-first Hypercredit balance), GitKraken AI (personal weekly credits and org pool sharing), Bifrost (self-hosted gateway budgets, rate limits and model spend), Atlas Cloud (account USD balance), Aixy (key usage and applicable budgets), ElevenLabs (endpoint override and `XI_API_KEY` alias), Helmcode (reset fallback and tenant dashboard URL), v0 (billing dashboard URL), and Hugging Face (identity cache, billing-permission message, API-only without cookies) ([#619](https://github.com/nesszer/Win-CodexBar/pull/619), [#618](https://github.com/nesszer/Win-CodexBar/pull/618), [#676](https://github.com/nesszer/Win-CodexBar/pull/676), [#661](https://github.com/nesszer/Win-CodexBar/pull/661), [#726](https://github.com/nesszer/Win-CodexBar/pull/726), [#723](https://github.com/nesszer/Win-CodexBar/pull/723), [#724](https://github.com/nesszer/Win-CodexBar/pull/724), [#725](https://github.com/nesszer/Win-CodexBar/pull/725)). +- Providers: save labeled token accounts for Kimi web sessions, Doubao Ark API keys, and OpenCode Go keys-or-sessions, with per-account source routing and fail-closed credential isolation ([#620](https://github.com/nesszer/Win-CodexBar/pull/620)). +- Providers: StepFun credit plans with a Credit label and no invented reset; StepFun pricing parity for Sol repricing cutoffs, Cyber rates, and Daybreak aliases ([#710](https://github.com/nesszer/Win-CodexBar/pull/710), [#734](https://github.com/nesszer/Win-CodexBar/pull/734)). +- Providers: prefer twelve preferred currencies from one shared catalog ([#679](https://github.com/nesszer/Win-CodexBar/pull/679), [#609](https://github.com/nesszer/Win-CodexBar/pull/609)). +- Providers: Antigravity Gemini 3.1 Pro pricing aliases with effort tiers, priced through the shared models.dev catalog ([#732](https://github.com/nesszer/Win-CodexBar/pull/732)). +- Providers: Kimi Code API responses that carry only a monthly pool or a partial ratio pool now parse and render correctly ([#730](https://github.com/nesszer/Win-CodexBar/pull/730)). +- Providers: Kimi web access tokens import from Chromium local storage, including `user-*` profiles, via the shared browser detector ([#649](https://github.com/nesszer/Win-CodexBar/pull/649)). +- Providers: Venice accepts Clerk session cookies with Bearer auth and falls back per browser for web sessions ([#626](https://github.com/nesszer/Win-CodexBar/pull/626), [#729](https://github.com/nesszer/Win-CodexBar/pull/729)). +- Providers: OpenRouter diagnostics and Activity summary ([#703](https://github.com/nesszer/Win-CodexBar/pull/703)). +- Usage & Spend: re-land the daily ledger, capped to the newest 30 rows with a "Show all" expander ([#715](https://github.com/nesszer/Win-CodexBar/pull/715)). +- Usage & Spend: attribute Nous OpenCodex ledger rows to Nous Portal spend and price unpriced OpenCodex rows by billing route instead of reporting zero ([#682](https://github.com/nesszer/Win-CodexBar/pull/682), [#731](https://github.com/nesszer/Win-CodexBar/pull/731)). +- Usage & Spend: price Codex Priority turns from local trace evidence, with a historical cutoff so pre-cutover snapshots keep their original rates ([#698](https://github.com/nesszer/Win-CodexBar/pull/698)). +- Usage & Spend: cost reporting periods — a `CostReportingPeriod` core with range-aware cost scanners, `cost --period` and period-aware serve `/cost`, and a History window setting with period-aware spend surfaces ([#648](https://github.com/nesszer/Win-CodexBar/pull/648), [#653](https://github.com/nesszer/Win-CodexBar/pull/653), [#665](https://github.com/nesszer/Win-CodexBar/pull/665)). +- Usage & Spend: provider quota burndown charting in the tray panel details ([#739](https://github.com/nesszer/Win-CodexBar/pull/739)). +- Tray: re-land stranded tray pace colors ([#716](https://github.com/nesszer/Win-CodexBar/pull/716)). +- Tray: refresh sixteen provider brand accents, dropping the retired Crof entry ([#719](https://github.com/nesszer/Win-CodexBar/pull/719)). +- Codex: re-land the stranded Codex scanner with OpenCodex numeric parsing and reserve pricing ([#714](https://github.com/nesszer/Win-CodexBar/pull/714)). +- Integrations: disclose the provider lane label in Settings usage bars and the metric picker ([#738](https://github.com/nesszer/Win-CodexBar/pull/738)). + +### Fixed + +- Antigravity: honor unusable `ANTIGRAVITY_CLI_PATH` overrides instead of silently discovering another CLI, explain the offline fallback reason, and keep cost follow-ups — lower bounds, withheld reads, and a `--refresh` path for unknown models ([#615](https://github.com/nesszer/Win-CodexBar/pull/615), [#672](https://github.com/nesszer/Win-CodexBar/pull/672), [#706](https://github.com/nesszer/Win-CodexBar/pull/706)). +- Antigravity: preserve grouped quotas including weekly-only Starter allowances and honor explicit quota-window cadence; usage probes no longer leave MCP server processes behind ([#694](https://github.com/nesszer/Win-CodexBar/pull/694), [#695](https://github.com/nesszer/Win-CodexBar/pull/695)). +- Claude: scan claude-swap session homes for cost reporting without double counting ([#670](https://github.com/nesszer/Win-CodexBar/pull/670)). +- Claude: show Web limit reset credits with opt-in retry semantics ([#689](https://github.com/nesszer/Win-CodexBar/pull/689)). +- Claude: preserve threshold warnings across repeated CLI identity gaps ([#667](https://github.com/nesszer/Win-CodexBar/pull/667)). +- Claude: replay CLI cursor redraws onto a virtual screen so differential redraws keep unchanged cells ([#633](https://github.com/nesszer/Win-CodexBar/pull/633)). +- Claude: respect read-only claude-swap adapters by hiding switching and re-authentication actions ([#631](https://github.com/nesszer/Win-CodexBar/pull/631)). +- Claude: replace unusable `claude setup-token` recovery advice with sign-in guidance and OAuth-override removal instructions ([#742](https://github.com/nesszer/Win-CodexBar/pull/742)). +- Kimi: give stale CLI credentials clear sign-in guidance and mark windows blocked by an exhausted monthly limit ([#691](https://github.com/nesszer/Win-CodexBar/pull/691), [#697](https://github.com/nesszer/Win-CodexBar/pull/697)). +- Kimi: check the reset arithmetic before building reset timestamps so oversized dashboard values can no longer abort the refresh ([#740](https://github.com/nesszer/Win-CodexBar/pull/740)). +- Codex: confirm unused rolling weekly resets and reset the weekly baseline on plan change ([#629](https://github.com/nesszer/Win-CodexBar/pull/629), [#690](https://github.com/nesszer/Win-CodexBar/pull/690)). +- Codex: retry auth.json publication races ([#688](https://github.com/nesszer/Win-CodexBar/pull/688)). +- Codex: retain the inherited counter origin for direct fork chains and accept unused Codex analytics dashboard URLs ([#677](https://github.com/nesszer/Win-CodexBar/pull/677), [#627](https://github.com/nesszer/Win-CodexBar/pull/627)). +- Codex: skip unchanged Codex cost cache rewrites ([#666](https://github.com/nesszer/Win-CodexBar/pull/666)). +- DeepSeek: read the Chrome `platform.deepseek.com` session and keep the last balance visible through same-session transport failures ([#709](https://github.com/nesszer/Win-CodexBar/pull/709)). +- GitKraken: honor `Retry-After` on 429 responses ([#727](https://github.com/nesszer/Win-CodexBar/pull/727)). +- OpenRouter: add API key and Management key guidance ([#686](https://github.com/nesszer/Win-CodexBar/pull/686)). +- CLI: show a provider history line with provenance and token totals ([#708](https://github.com/nesszer/Win-CodexBar/pull/708)). +- Privacy: redact stored process environments shown in diagnostics ([#722](https://github.com/nesszer/Win-CodexBar/pull/722)). +- Runtime: stop the app from stealing focus at launch ([#713](https://github.com/nesszer/Win-CodexBar/pull/713)). +- Runtime: fix the reset refresh timer for resets more than 24.8 days away ([#720](https://github.com/nesszer/Win-CodexBar/pull/720)). +- Tray: fix float bar pills for informational metrics ([#733](https://github.com/nesszer/Win-CodexBar/pull/733)). +- Tray: make the tray panel the default layout ([#712](https://github.com/nesszer/Win-CodexBar/pull/712)). +- CI: make the bootstrap catalog test hermetic so it no longer reads host settings ([#711](https://github.com/nesszer/Win-CodexBar/pull/711)). +- CI: keep Codex cost fixtures on the local day to stop the 00:00-01:00 UTC test flake ([#721](https://github.com/nesszer/Win-CodexBar/pull/721)). + +### Changed + +- Docs: remove the retired Crof provider from the Rust README ([#728](https://github.com/nesszer/Win-CodexBar/pull/728)). +- Docs: describe the tray-panel-only layout in the Windows docs after the pop-out retirement (glue commit `ed2440c3`). + +> Release artifacts are unsigned (SignPath onboarding pending); verify them against the attached `.sha256` sidecar files. + +--- + +## [Windows] 0.61.0 - 2026-09-15 Windows port of upstream CodexBar **0.60.3 → 0.61.0**: new subscription and spend providers (Nous Portal, Replicate, CodeRabbit, Muse Code), richer @@ -13,9 +75,16 @@ menu-bar layout. - Grok: Settings and tray **Add account** flow matching Codex/Claude — isolated `grok login --oauth`, save current CLI login, switch, and remove without logging out the active session. - Replicate: cookie-authenticated monthly spend and optional prepaid credit balance from the billing page, with user and organization account isolation. - DeepSeek: show reported per-model spend in the provider details while preserving the billing currency, reporting period, zero values, and incomplete-total safeguards. +- CLI: show provider-supplied history in usage text with its source period, known zero, and cost provenance (reported, estimated, or includes estimates), plus input-plus-output token totals for OpenRouter Activity. ### Fixed +- Antigravity: a set but unusable `ANTIGRAVITY_CLI_PATH` (empty, missing, or not a file) now turns off automatic `agy` discovery instead of falling back to another CLI that could start an interactive login during a background refresh; a running desktop app still answers first, offline history is still shown, and unsetting the variable restores discovery. +- Kimi: when the Kimi Code CLI credential is stale or rejected and web auth has no token to try, direct the user to run `kimi` or add a Kimi Code API key in Settings, keeping the web fallback and leaving CLI-owned credentials read-only. A rejected web token still reports the web error, and a Kimi Code API 403 reports a permission or quota denial instead of a sign-in problem. +- Kimi Code: while the monthly membership pool is exhausted, show the shorter Code windows as “Blocked by monthly limit” without their own usage bar, reset, or pace forecast, keeping the raw usage in the CLI, tray icon, and float bar; the block lifts at the pool's reset even before the next refresh. +- Kimi Code: accept ratio-pool Code API responses that report only some windows, keep an unreported weekly quota absent instead of showing 0%, and show the monthly Total usage reported by the Code API without browser authentication. - Claude: when Hide Personal Info is enabled, keep saved account rows distinguishable with stable localized `Account N` labels and matching redacted tooltips. +- Codex: a fork of a fork whose parent has no token snapshot before the child forked now inherits the whole chain's cumulative counter origin instead of staying unresolved; ancestor chains deeper than 64 stay unresolved, and the Codex cost cache is rebuilt once (cache schema 5). +- Codex: a fork larger than the per-refresh scan budget continues where the last refresh stopped instead of restarting from the top and never finishing, and a finished fork that grows is no longer billed for usage it inherited from its parent. --- diff --git a/Cargo.lock b/Cargo.lock index defe3b590a..bc2e338c05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -655,7 +655,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codexbar" -version = "0.60.3" +version = "0.70.0" dependencies = [ "aes-gcm", "anyhow", @@ -695,7 +695,7 @@ dependencies = [ [[package]] name = "codexbar-desktop-tauri" -version = "0.60.3" +version = "0.70.0" dependencies = [ "chrono", "codexbar", diff --git a/README.es-MX.md b/README.es-MX.md index dbd389b54f..389d3186b8 100644 --- a/README.es-MX.md +++ b/README.es-MX.md @@ -102,7 +102,6 @@ Consulta el historial completo en [CHANGELOG.md](CHANGELOG.md). | Xiaomi MiMo | Cookies | Saldo, Plan de tokens | | Doubao | Clave API | Límites de solicitudes | | Command Code | Cookies | Créditos mensuales, Créditos comprados | -| Crof | Clave API | Créditos, Cuota de solicitudes | | StepFun | Token Oasis | 5h, Semanal, Refresco de token | | Venice | Clave API | Saldo USD / DIEM | | OpenAI | Admin API / Clave API | Uso, Solicitudes, Costo con ámbito de proyecto, Saldo de créditos | diff --git a/README.md b/README.md index 2e052ad2f9..e03dbc5362 100755 --- a/README.md +++ b/README.md @@ -109,13 +109,13 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | Xiaomi MiMo | Cookies | Balance, Token Plan | | Doubao | API Key | Request Limits | | Command Code | Cookies | Monthly Credits, Purchased Credits | -| Crof | API Key | Credits, Request Quota | | StepFun | Oasis Token | 5h, Weekly, Token refresh | -| Venice | API Key | USD / DIEM Balance | +| Venice | API Key / Web session | USD / DIEM Balance, Bundled credits (web session expires in about 60 s) | | OpenAI | Admin API / API Key | Usage, Requests, Project-scoped cost, Credit Balance | | Grok | Cookies / auth.json | Billing | | Helmcode (also NaN Builders) | Browser cookies / manual Cookie header | Per-model token quotas, reset windows, Helmcode prepaid balance | | Replicate | Cookies / token accounts | Monthly spend, credit balance | +| Aixy | API Key / token accounts | Applicable budget balances, 7-day key usage | | ElevenLabs | API Key | Subscription Credits, Voice Slots | | Deepgram | API Key | Project Usage | | Groq | API Key | Enterprise Metrics | diff --git a/README.tr-TR.md b/README.tr-TR.md index 47ab7bb13b..0ae7410775 100644 --- a/README.tr-TR.md +++ b/README.tr-TR.md @@ -107,7 +107,6 @@ Tüm geçmişi [CHANGELOG.md](CHANGELOG.md) dosyasında görebilirsiniz. | Xiaomi MiMo | Çerezler | Bakiye, Token Planı | | Doubao | API Anahtarı | İstek Sınırları | | Command Code | Çerezler | Aylık Krediler, Satın Alınan Krediler | -| Crof | API Anahtarı | Krediler, İstek Kotası | | StepFun | Oasis Tokenı | 5 saatlik, Haftalık, Token yenileme | | Venice | API Anahtarı | USD / DIEM Bakiyesi | | OpenAI | Yönetici API'si / API Anahtarı | Kullanım, İstekler, Proje kapsamlı maliyet, Kredi Bakiyesi | diff --git a/README.zh-CN.md b/README.zh-CN.md index 2776c0fa95..55550f15e8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,7 +14,7 @@ ## 功能特性 -- **56 个 AI 服务商** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、Crof、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI +- **56 个 AI 服务商** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI - **系统托盘图标** — 动态双条进度显示会话与周用量 - **Floating Bar** — 可选的置顶透明用量条,支持方向、透明度和点击穿透控制 - **浏览器 Cookie 导入** — Chrome、Edge、Brave、Firefox(Windows DPAPI 解密) @@ -217,7 +217,6 @@ codexbar cost -p codex # 本地成本(JSONL 日志) | 小米 MiMo | Cookies | 余额、Token 套餐 | | Doubao | API Key | 请求限制 | | Command Code | Cookies | 月度 Credits、已购 Credits | -| Crof | API Key | Credits、请求配额 | | StepFun | Oasis Token | 5h、周用量 | | Venice | API Key | USD / DIEM 余额 | | OpenAI | Admin API / API Key | 用量、请求数、余额 | diff --git a/README.zh-TW.md b/README.zh-TW.md index cbda22a135..b2f3dfbe65 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -14,7 +14,7 @@ ## 功能特性 -- **56 個 AI 提供者** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、Crof、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI +- **56 個 AI 提供者** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI - **系統系統匣圖示** — 動態雙條進度顯示會話與周用量 - **Floating Bar** — 可選的置頂透明用量條,支援方向、透明度和點選穿透控制 - **瀏覽器 Cookie 匯入** — Chrome、Edge、Brave、Firefox(Windows DPAPI 解密) @@ -217,7 +217,6 @@ codexbar cost -p codex # 本機成本(JSONL 日誌) | 小米 MiMo | Cookies | 餘額、Token 套餐 | | Doubao | API Key | 請求限制 | | Command Code | Cookies | 月度 Credits、已購 Credits | -| Crof | API Key | Credits、請求配額 | | StepFun | Oasis Token | 5h、周用量 | | Venice | API Key | USD / DIEM 餘額 | | OpenAI | Admin API / API Key | 用量、請求數、餘額 | diff --git a/apps/desktop-tauri/package.json b/apps/desktop-tauri/package.json index 7eb5637e84..3bbaacadba 100644 --- a/apps/desktop-tauri/package.json +++ b/apps/desktop-tauri/package.json @@ -1,7 +1,7 @@ { "name": "desktop-tauri", "private": true, - "version": "0.60.3", + "version": "0.70.0", "packageManager": "pnpm@11.25.0", "type": "module", "scripts": { diff --git a/apps/desktop-tauri/src-tauri/Cargo.toml b/apps/desktop-tauri/src-tauri/Cargo.toml index f447d1363a..baa6e1c3ee 100644 --- a/apps/desktop-tauri/src-tauri/Cargo.toml +++ b/apps/desktop-tauri/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codexbar-desktop-tauri" -version = "0.60.3" +version = "0.70.0" edition = "2024" publish = false diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume.rs b/apps/desktop-tauri/src-tauri/src/auto_resume.rs index 030fcaeff3..35260c2648 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume.rs @@ -6,13 +6,12 @@ use codexbar::agent_sessions::{ AgentSession, AgentSessionProvider, AgentSessionSource, AgentSessionState, - LocalAgentSessionScanner, SessionFocusResult, + LocalAgentSessionScanner, }; use codexbar::core::{ProviderId, TokenAccountStore}; use codexbar::settings::Settings; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::process::Command; use std::sync::Mutex; use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; @@ -20,10 +19,6 @@ use crate::state::AppState; use tauri::Manager; const MAX_SESSION_ID_LEN: usize = 256; -#[cfg(windows)] -const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; -#[cfg(windows)] -const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum QuotaSlot { @@ -633,19 +628,22 @@ async fn resume_captured_session( } if let Some(session) = matching_session(&target, &result.sessions) { // A live process already owns this exact session. Reopening it would - // create a duplicate terminal, so focus the existing window instead. + // create a duplicate terminal, so point the user at the existing + // window instead. This runs after a background refresh, not a user + // action: flash the window's taskbar button rather than restoring and + // activating it over the app the user is working in. if session.pid.is_some() { if !resume_attempt_is_still_valid(app, &target, operation, account_identity) { clear_provider_if_resume_owner(app, target.provider, operation); return; } - let focus_result = codexbar::agent_sessions::focus_session(session); - let succeeded = matches!(focus_result, SessionFocusResult::Focused); + let attention = codexbar::agent_sessions::request_session_attention(session); + let succeeded = attention.is_ok(); tracing::info!( provider = target.provider.cli_name(), - focus_result = ?focus_result, + attention = ?attention, succeeded, - "captured CLI session is already running; attempting to focus it" + "captured CLI session is already running; flashing its window" ); finish_resume_attempt(app, &target, operation, succeeded); return; @@ -804,14 +802,15 @@ fn launch_resume(target: &ResumeTarget) -> Result<(), String> { .ok_or_else(|| format!("{} CLI was not found", target.provider.display_name()))?; let command = build_resume_command(target, executable)?; - let mut process = Command::new(&command.program); - process.args(&command.args).current_dir(&command.cwd); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - process.creation_flags(CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP); - } - process.spawn().map(|_| ()).map_err(|error| { + // This runs after a background refresh, not a user action: the console + // starts minimized and inactive, so it waits on the taskbar instead of + // taking focus from the app the user is working in. + codexbar::host::console_launch::spawn_minimized_console( + &command.program, + &command.args, + &command.cwd, + ) + .map_err(|error| { format!( "failed to launch {} CLI: {error}", target.provider.display_name() @@ -835,28 +834,9 @@ fn build_resume_command( _ => return Err("provider does not support auto-resume".to_string()), }; - #[cfg(windows)] - if executable - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "cmd" | "bat")) - { - let command_line = std::iter::once(quote_cmd_arg(&executable.to_string_lossy())) - .chain(cli_args.iter().map(|arg| quote_cmd_arg(arg))) - .collect::>() - .join(" "); - return Ok(ResumeCommand { - program: PathBuf::from("cmd.exe"), - args: vec![ - "/d".to_string(), - "/s".to_string(), - "/c".to_string(), - command_line, - ], - cwd: target.cwd.clone(), - }); - } - + // Batch shims such as npm's `claude.cmd` stay the program here; + // `spawn_minimized_console` runs them through cmd.exe with quoting cmd.exe + // parses. Ok(ResumeCommand { program: executable, args: cli_args, @@ -864,11 +844,6 @@ fn build_resume_command( }) } -#[cfg(windows)] -fn quote_cmd_arg(value: &str) -> String { - format!("\"{}\"", value.replace('"', "\\\"")) -} - #[cfg(test)] #[path = "auto_resume_tests.rs"] mod auto_resume_tests; diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs index 07b289498f..b8eadc0275 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs @@ -42,6 +42,8 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, } } @@ -86,22 +88,24 @@ mod tests { ); } - #[cfg(windows)] #[test] - fn cmd_wrapper_preserves_exact_resume_arguments() { + fn batch_shims_keep_exact_resume_arguments() { + // cmd.exe wrapping for `.cmd` shims happens at launch, in + // `codexbar::host::console_launch`, which tests its own quoting. let command = build_resume_command( &target(ProviderId::Claude), PathBuf::from(r"C:\Program Files\Claude\claude.cmd"), ) .unwrap(); - assert_eq!(command.program, PathBuf::from("cmd.exe")); - assert_eq!(&command.args[..3], ["/d", "/s", "/c"]); assert_eq!( - command.args[3], - r#""C:\Program Files\Claude\claude.cmd" "--resume" "12345678-1234-1234-1234-123456789abc""# + command.program, + PathBuf::from(r"C:\Program Files\Claude\claude.cmd") + ); + assert_eq!( + command.args, + ["--resume", "12345678-1234-1234-1234-123456789abc"] ); - assert!(!command.args[3].contains("prompt")); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a..f78ea2a031 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -1,8 +1,15 @@ +mod openai_usage; +#[cfg(test)] +mod openai_usage_tests; pub(crate) mod pace; +mod quota_block; mod status; +pub(crate) use openai_usage::OpenAiApiUsageSnapshot; +pub use quota_block::MonthlyLimitBlockSnapshot; pub(crate) use status::{compact_tray_status_label, friendly_provider_error}; use super::*; +use codexbar::core::BlockedWindows; // ── Bridge snapshot types ──────────────────────────────────────────── @@ -23,6 +30,9 @@ pub struct RateWindowSnapshot { pub is_exhausted: bool, #[serde(default)] pub is_informational: bool, + /// `reset_description` is a detail line (for example spend amounts), not reset wording. + #[serde(default)] + pub description_is_detail: bool, #[serde(default)] pub reserve_percent: Option, #[serde(default)] @@ -31,6 +41,10 @@ pub struct RateWindowSnapshot { pub reserve_will_last_to_reset: bool, #[serde(default)] pub reserve_eta_seconds: Option, + /// Set while a longer exhausted pool (Kimi's monthly membership) blocks + /// this window; presentation only, the raw percentages above stay as is. + #[serde(default)] + pub monthly_limit_block: Option, } /// Serde default for [`RateWindowSnapshot::remaining_percent`] — the common @@ -53,6 +67,8 @@ impl RateWindowSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: rw.description_is_detail, } } @@ -125,7 +141,19 @@ fn default_cost_period() -> 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 { +pub(crate) fn format_cost_amount( + cost: &CostSnapshotBridge, + rates_cache: Option<&CurrencyRateCache>, +) -> String { + if let Some((amount, currency)) = + crate::commands::convert_preferred_amount(rates_cache, cost.used, &cost.currency_code) + { + // The canonical core symbol table (shared with CLI/tray formatting). + return codexbar::core::format_currency(amount, ¤cy); + } + if !cost.formatted_used.is_empty() { + return cost.formatted_used.clone(); + } if let Some(ref symbol) = cost.currency_symbol { format!("{}{:.2}", symbol, cost.used) } else { @@ -159,6 +187,32 @@ pub struct PaceSnapshot { pub expected_used_percent: f64, #[serde(default)] pub actual_used_percent: f64, + /// Block of the window this pace comes from (upstream hides its pace). + #[serde(default)] + pub monthly_limit_block: Option, +} + +/// One burndown chart point (RFC 3339 capture time + remaining percent). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaBurndownPointSnapshot { + pub captured_at: String, + pub remaining_percent: f64, +} + +/// Recorded remaining-quota burndown for one series (session / weekly), +/// upstream 0.70.0 #4085. `captured_at` of the last sample drives the +/// capture-age caption; the chart is empty when there is no current window. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaBurndownSnapshot { + /// `session` or `weekly`. + pub series: String, + pub window_minutes: u32, + pub start: String, + pub reset: String, + pub samples: Vec, + pub ideal: [QuotaBurndownPointSnapshot; 2], } /// Session-equivalent weekly forecast for Claude/Codex menu secondary line. @@ -206,6 +260,8 @@ pub struct ProviderDisplayProgressSnapshot { #[serde(rename_all = "camelCase")] pub struct ProviderDisplayDetailSnapshot { pub id: String, + #[serde(default)] + pub section_title: Option, pub title: String, pub value: String, pub secondary_value: Option, @@ -267,8 +323,15 @@ pub struct ProviderUsageSnapshot { pub fetch_duration_ms: Option, #[serde(default)] pub wayfinder_usage: Option, + /// Per-day OpenAI Admin API history for the daily usage chart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_ai_api_usage: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub session_equivalent_forecast: Option, + /// Recorded remaining-quota burndown for the selected window + /// (upstream 0.70.0 #4085); Codex and Claude only. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub quota_burndown: Option, } fn default_display_name() -> String { @@ -337,6 +400,7 @@ impl ProviderUsageSnapshot { }); let primary_pace = primary_pace.flatten(); + let blocked = BlockedWindows::evaluate(id, usage, chrono::Utc::now()); let pace = primary_pace.as_ref().map(|p| PaceSnapshot { stage: pace::stage_str(p.stage).to_string(), delta_percent: p.delta_percent, @@ -344,6 +408,7 @@ impl ProviderUsageSnapshot { eta_seconds: p.eta_seconds, expected_used_percent: p.expected_used_percent, actual_used_percent: p.actual_used_percent, + monthly_limit_block: MonthlyLimitBlockSnapshot::for_pace(usage, &blocked), }); // Compute pace for secondary window (weekly) to derive reserve info @@ -355,10 +420,12 @@ impl ProviderUsageSnapshot { }); let secondary_pace = secondary_pace.flatten(); - let primary_snap = RateWindowSnapshot::from_rate_window(&usage.primary); + let primary_snap = RateWindowSnapshot::from_rate_window(&usage.primary) + .with_quota_block(blocked.primary, &blocked); let secondary_snap = usage.secondary.as_ref().map(|sw| { - let mut s = RateWindowSnapshot::from_rate_window(sw); + let mut s = RateWindowSnapshot::from_rate_window(sw) + .with_quota_block(blocked.secondary, &blocked); if let Some(ref p) = secondary_pace { s = s.with_pace_reserve(p); } @@ -376,6 +443,12 @@ impl ProviderUsageSnapshot { &usage.primary, usage.secondary.as_ref(), ); + let quota_burndown = quota_burndown_for( + id, + account_key.as_deref(), + &usage.primary, + usage.secondary.as_ref(), + ); Self { provider_id: id.cli_name().to_string(), @@ -394,14 +467,13 @@ impl ProviderUsageSnapshot { .clone() .unwrap_or_else(|| metadata.weekly_label.to_string()) }), - model_specific: usage - .model_specific - .as_ref() - .map(RateWindowSnapshot::from_rate_window), - tertiary: usage - .tertiary - .as_ref() - .map(RateWindowSnapshot::from_rate_window), + model_specific: usage.model_specific.as_ref().map(|w| { + RateWindowSnapshot::from_rate_window(w) + .with_quota_block(blocked.model_specific, &blocked) + }), + tertiary: usage.tertiary.as_ref().map(|w| { + RateWindowSnapshot::from_rate_window(w).with_quota_block(blocked.tertiary, &blocked) + }), // F5 (upstream 0.48.0): label the tertiary lane by its duration cadence // so surfaces (MenuCard, CLI, tray) can show "Monthly" instead of the // generic "DetailWindowTertiary" slot key. @@ -416,10 +488,12 @@ impl ProviderUsageSnapshot { extra_rate_windows: usage .extra_rate_windows .iter() - .map(|extra| NamedRateWindowSnapshot { + .zip(&blocked.extra) + .map(|(extra, &is_blocked)| NamedRateWindowSnapshot { id: extra.id.clone(), title: extra.title.clone(), - window: RateWindowSnapshot::from_rate_window(&extra.window), + window: RateWindowSnapshot::from_rate_window(&extra.window) + .with_quota_block(is_blocked, &blocked), fallback_lane: extra.fallback_lane, }) .collect(), @@ -438,6 +512,7 @@ impl ProviderUsageSnapshot { .iter() .map(|detail| ProviderDisplayDetailSnapshot { id: detail.id().to_string(), + section_title: detail.section_title().map(ToOwned::to_owned), title: detail.title().to_string(), value: detail.value().to_string(), secondary_value: detail.secondary_value().map(ToOwned::to_owned), @@ -492,7 +567,9 @@ impl ProviderUsageSnapshot { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: result.wayfinder_usage.clone(), + open_ai_api_usage: result.open_ai_api_usage.as_ref().map(Into::into), session_equivalent_forecast, + quota_burndown, } } @@ -518,6 +595,8 @@ impl ProviderUsageSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, }, primary_label: Some(metadata.session_label.to_string()), secondary: None, @@ -542,7 +621,9 @@ impl ProviderUsageSnapshot { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + open_ai_api_usage: None, session_equivalent_forecast: None, + quota_burndown: None, } } } @@ -615,6 +696,74 @@ fn session_equivalent_forecast_for( }) } +/// Build the recorded burndown for the live window (session or weekly lane) +/// from the persisted history; `None` when the window is expired or unknown. +/// Codex and Claude only, mirroring the forecast gate. +fn quota_burndown_for( + id: ProviderId, + account_key: Option<&str>, + session: &RateWindow, + weekly: Option<&RateWindow>, +) -> Option { + if !matches!(id, ProviderId::Claude | ProviderId::Codex) { + return None; + } + let now = chrono::Utc::now(); + let provider_id = id.cli_name(); + // Persisted history feeds the chart; loading merges it into the + // process-local store once per scope. + let histories = codexbar::core::load_persisted_history(provider_id, account_key); + + let live = |series: &str, window: &RateWindow| -> Option { + let model = codexbar::core::QuotaBurndownModel::build( + &series_entries(&histories, series), + window, + now, + )?; + Some(QuotaBurndownSnapshot { + series: series.to_string(), + window_minutes: window.window_minutes?, + start: model.start.to_rfc3339(), + reset: model.reset.to_rfc3339(), + samples: model + .samples + .iter() + .map(|sample| QuotaBurndownPointSnapshot { + captured_at: sample.captured_at.to_rfc3339(), + remaining_percent: sample.remaining_percent, + }) + .collect(), + ideal: [ + QuotaBurndownPointSnapshot { + captured_at: model.ideal[0].captured_at.to_rfc3339(), + remaining_percent: model.ideal[0].remaining_percent, + }, + QuotaBurndownPointSnapshot { + captured_at: model.ideal[1].captured_at.to_rfc3339(), + remaining_percent: model.ideal[1].remaining_percent, + }, + ], + }) + }; + + // Upstream prefers the shortest current window (session over weekly). + live("session", session).or_else(|| weekly.and_then(|w| live("weekly", w))) +} + +fn series_entries( + histories: &[codexbar::core::PlanUtilizationSeriesHistory], + series: &str, +) -> Vec { + histories + .iter() + .find(|history| match series { + "session" => history.name == codexbar::core::PlanUtilizationSeriesName::Session, + _ => history.name == codexbar::core::PlanUtilizationSeriesName::Weekly, + }) + .map(|history| history.entries.clone()) + .unwrap_or_default() +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BootstrapState { @@ -641,6 +790,7 @@ pub struct ProviderCatalogEntry { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SettingsSnapshot { + preferred_currency_code: String, enabled_providers: Vec, provider_order: Vec, refresh_interval_secs: u64, @@ -661,9 +811,12 @@ pub struct SettingsSnapshot { predictive_pace_warning_enabled: bool, show_pace: bool, tray_icon_mode: &'static str, + stacked_tray_top_provider: Option, + stacked_tray_bottom_provider: Option, switcher_shows_icons: bool, menu_bar_shows_highest_usage: bool, menu_bar_shows_percent: bool, + menu_bar_color_pace: bool, show_as_used: bool, show_all_token_accounts_in_menu: bool, enable_animations: bool, @@ -716,12 +869,18 @@ pub struct SettingsSnapshot { cost_summary_display_style: &'static str, open_codex_usage_logs_enabled: bool, hide_native_codex_cost_when_open_codex_present: bool, + /// History window as its persisted raw form (`rolling:N`, + /// `month-to-date`, `all`). + cost_reporting_period: String, provider_accent_colors: std::collections::HashMap, } #[tauri::command] pub fn get_bootstrap_state() -> BootstrapState { - let settings = Settings::load(); + bootstrap_state_for(Settings::load()) +} + +pub(crate) fn bootstrap_state_for(settings: Settings) -> BootstrapState { BootstrapState { contract_version: "v1", providers: provider_catalog_for(&settings), @@ -763,6 +922,7 @@ impl From for SettingsSnapshot { .collect(); Self { + preferred_currency_code: settings.preferred_currency_code, enabled_providers, provider_order, refresh_interval_secs: settings.refresh_interval_secs, @@ -783,9 +943,12 @@ impl From for SettingsSnapshot { predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled, show_pace: settings.show_pace, tray_icon_mode: tray_icon_mode_label(settings.tray_icon_mode), + stacked_tray_top_provider: settings.stacked_tray_top_provider, + stacked_tray_bottom_provider: settings.stacked_tray_bottom_provider, switcher_shows_icons: settings.switcher_shows_icons, menu_bar_shows_highest_usage: settings.menu_bar_shows_highest_usage, menu_bar_shows_percent: settings.menu_bar_shows_percent, + menu_bar_color_pace: settings.menu_bar_color_pace, show_as_used: settings.show_as_used, show_all_token_accounts_in_menu: settings.show_all_token_accounts_in_menu, enable_animations: settings.enable_animations, @@ -842,6 +1005,7 @@ impl From for SettingsSnapshot { open_codex_usage_logs_enabled: settings.open_codex_usage_logs_enabled, hide_native_codex_cost_when_open_codex_present: settings .hide_native_codex_cost_when_open_codex_present, + cost_reporting_period: settings.cost_reporting_period.raw(), provider_accent_colors: settings .provider_configs .iter() @@ -876,6 +1040,7 @@ fn tray_icon_mode_label(mode: TrayIconMode) -> &'static str { match mode { TrayIconMode::Single => "single", TrayIconMode::PerProvider => "perProvider", + TrayIconMode::Stacked => "stacked", } } @@ -980,6 +1145,8 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs new file mode 100644 index 0000000000..58c91192a6 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs @@ -0,0 +1,103 @@ +//! Bridge DTOs for the OpenAI Admin API per-day usage history (`openAiApiUsage`). +//! +//! Mirrors `OpenAiApiUsageSnapshot` in `src/types/bridge.ts`. Times are epoch seconds and +//! counts are non-negative integers, as in upstream 0.66.0's card payload. + +use codexbar::core::{ + OpenAiApiDailyUsage, OpenAiApiLineItemCost, OpenAiApiModelUsage, OpenAiApiUsageHistory, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenAiApiUsageSnapshot { + pub history_days: u32, + #[serde(default)] + pub project_id: Option, + #[serde(default)] + pub daily: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenAiApiDailyUsageSnapshot { + pub start_time: i64, + pub end_time: i64, + pub cost_usd: f64, + pub requests: u64, + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + #[serde(default)] + pub line_items: Vec, + #[serde(default)] + pub models: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenAiApiLineItemSnapshot { + pub name: String, + pub cost_usd: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenAiApiModelUsageSnapshot { + pub name: String, + pub requests: u64, + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, +} + +impl From<&OpenAiApiUsageHistory> for OpenAiApiUsageSnapshot { + fn from(history: &OpenAiApiUsageHistory) -> Self { + Self { + history_days: history.history_days, + project_id: history.project_id.clone(), + daily: history.daily.iter().map(Into::into).collect(), + } + } +} + +impl From<&OpenAiApiDailyUsage> for OpenAiApiDailyUsageSnapshot { + fn from(day: &OpenAiApiDailyUsage) -> Self { + Self { + start_time: day.start_time, + end_time: day.end_time, + cost_usd: day.cost_usd, + requests: day.requests, + input_tokens: day.input_tokens, + cached_input_tokens: day.cached_input_tokens, + output_tokens: day.output_tokens, + total_tokens: day.total_tokens, + line_items: day.line_items.iter().map(Into::into).collect(), + models: day.models.iter().map(Into::into).collect(), + } + } +} + +impl From<&OpenAiApiLineItemCost> for OpenAiApiLineItemSnapshot { + fn from(item: &OpenAiApiLineItemCost) -> Self { + Self { + name: item.name.clone(), + cost_usd: item.cost_usd, + } + } +} + +impl From<&OpenAiApiModelUsage> for OpenAiApiModelUsageSnapshot { + fn from(model: &OpenAiApiModelUsage) -> Self { + Self { + name: model.name.clone(), + requests: model.requests, + input_tokens: model.input_tokens, + cached_input_tokens: model.cached_input_tokens, + output_tokens: model.output_tokens, + total_tokens: model.total_tokens, + } + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs new file mode 100644 index 0000000000..5c0343c32f --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs @@ -0,0 +1,108 @@ +use super::openai_usage::OpenAiApiUsageSnapshot; +use crate::commands::ProviderUsageSnapshot; +use codexbar::core::{ + OpenAiApiDailyUsage, OpenAiApiLineItemCost, OpenAiApiModelUsage, OpenAiApiUsageHistory, + ProviderFetchResult, ProviderId, RateWindow, UsageSnapshot, instantiate_provider, +}; + +fn history() -> OpenAiApiUsageHistory { + OpenAiApiUsageHistory { + history_days: 30, + project_id: Some("proj_abc".to_string()), + daily: vec![OpenAiApiDailyUsage { + start_time: 1_700_000_000, + end_time: 1_700_086_400, + cost_usd: 14.75, + requests: 10, + input_tokens: 1300, + cached_input_tokens: 250, + output_tokens: 700, + total_tokens: 2000, + line_items: vec![OpenAiApiLineItemCost { + name: "Text tokens".to_string(), + cost_usd: 12.5, + }], + models: vec![OpenAiApiModelUsage { + name: "gpt-5.2".to_string(), + requests: 7, + input_tokens: 1000, + cached_input_tokens: 250, + output_tokens: 500, + total_tokens: 1500, + }], + }], + } +} + +fn snapshot(history: Option) -> ProviderUsageSnapshot { + let metadata = instantiate_provider(ProviderId::OpenAIApi) + .metadata() + .clone(); + let mut result = + ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(0.0)), "admin-api"); + result.open_ai_api_usage = history; + ProviderUsageSnapshot::from_fetch_result(ProviderId::OpenAIApi, &metadata, &result, None) +} + +#[test] +fn fetch_result_history_reaches_the_bridge_as_camel_case_epoch_seconds() { + let json = serde_json::to_value(snapshot(Some(history()))).unwrap(); + assert_eq!( + json["openAiApiUsage"], + serde_json::json!({ + "historyDays": 30, + "projectId": "proj_abc", + "daily": [{ + "startTime": 1_700_000_000, + "endTime": 1_700_086_400, + "costUsd": 14.75, + "requests": 10, + "inputTokens": 1300, + "cachedInputTokens": 250, + "outputTokens": 700, + "totalTokens": 2000, + "lineItems": [{"name": "Text tokens", "costUsd": 12.5}], + "models": [{ + "name": "gpt-5.2", + "requests": 7, + "inputTokens": 1000, + "cachedInputTokens": 250, + "outputTokens": 500, + "totalTokens": 1500, + }], + }], + }) + ); +} + +#[test] +fn missing_history_is_omitted_from_the_bridge_payload() { + let json = serde_json::to_value(snapshot(None)).unwrap(); + assert!(json.get("openAiApiUsage").is_none()); +} + +#[test] +fn history_round_trips_through_the_bridge_payload() { + let original = snapshot(Some(history())); + let decoded: ProviderUsageSnapshot = + serde_json::from_value(serde_json::to_value(&original).unwrap()).unwrap(); + assert_eq!(decoded.open_ai_api_usage, original.open_ai_api_usage); + assert_eq!( + decoded.open_ai_api_usage.unwrap().project_id.as_deref(), + Some("proj_abc") + ); +} + +#[test] +fn an_empty_history_serializes_a_null_project_and_no_days() { + let empty = OpenAiApiUsageHistory { + history_days: 30, + project_id: None, + daily: Vec::new(), + }; + let json = serde_json::to_value(OpenAiApiUsageSnapshot::from(&empty)).unwrap(); + assert_eq!( + json, + serde_json::json!({"historyDays": 30, "projectId": null, "daily": []}) + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/quota_block.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/quota_block.rs new file mode 100644 index 0000000000..91865dfc23 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/quota_block.rs @@ -0,0 +1,155 @@ +//! Bridge shape of a longer exhausted quota that blocks a shorter window +//! (upstream 0.69.0 #4091, Kimi's monthly membership pool). +//! +//! Upstream evaluates `menuCard.blockingQuota` whenever it builds the menu +//! card, so a blocked row recovers as soon as the pool resets. Bridge +//! snapshots outlive their fetch (the provider cache keeps the last good +//! snapshot through failed refreshes), so each blocked window carries the +//! pool's reset and the frontend re-checks it at render time. + +use super::*; + +/// Set on a window while a longer exhausted pool blocks it. Presentation only: +/// the window's raw percentages stay the provider's data. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MonthlyLimitBlockSnapshot { + /// RFC 3339 reset of the exhausted pool; the block lifts then. `None` when + /// the pool's reset is unknown, so the block holds until a refresh. + #[serde(default)] + pub resets_at: Option, +} + +impl MonthlyLimitBlockSnapshot { + pub(super) fn for_window(blocked: bool, blocks: &BlockedWindows) -> Option { + blocked.then(|| Self { + resets_at: blocks.resets_at.map(|reset| reset.to_rfc3339()), + }) + } + + /// Block of the window the provider-level pace derives from: the weekly + /// lane when the primary is an informational stand-in, else the primary. + pub(super) fn for_pace( + usage: &codexbar::core::UsageSnapshot, + blocks: &BlockedWindows, + ) -> Option { + let blocked = if usage.primary.is_informational { + blocks.secondary + } else { + blocks.primary + }; + Self::for_window(blocked, blocks) + } +} + +impl RateWindowSnapshot { + pub(super) fn with_quota_block(mut self, blocked: bool, blocks: &BlockedWindows) -> Self { + self.monthly_limit_block = MonthlyLimitBlockSnapshot::for_window(blocked, blocks); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + use codexbar::core::UsageSnapshot; + + fn monthly_pool(reset: Option>) -> RateWindow { + RateWindow::with_details(100.0, Some(43_200), reset, None) + } + + fn bridge_snapshot(id: ProviderId, usage: UsageSnapshot) -> ProviderUsageSnapshot { + let result = ProviderFetchResult::new(usage, "web"); + let metadata = instantiate_provider(id).metadata().clone(); + ProviderUsageSnapshot::from_fetch_result(id, &metadata, &result, None) + } + + #[test] + fn exhausted_kimi_monthly_pool_marks_shorter_windows_without_changing_raw_usage() { + let reset = Utc::now() + Duration::days(20); + let primary = RateWindow::with_details( + 0.0, + Some(10_080), + Some(Utc::now() + Duration::days(3)), + None, + ); + let usage = UsageSnapshot::new(primary).with_extra_rate_window( + "kimi-monthly", + "Total usage", + monthly_pool(Some(reset)), + ); + + let snapshot = bridge_snapshot(ProviderId::Kimi, usage.clone()); + let block = MonthlyLimitBlockSnapshot { + resets_at: Some(reset.to_rfc3339()), + }; + assert_eq!(snapshot.primary.monthly_limit_block, Some(block.clone())); + assert_eq!(snapshot.primary.used_percent, 0.0); + assert_eq!( + snapshot.extra_rate_windows[0].window.monthly_limit_block, + None + ); + // Upstream drops the blocked metric's pace forecast; the provider-level + // pace comes from the primary here, so it carries the same block. + let pace = snapshot.pace.as_ref().expect("primary pace"); + assert_eq!(pace.monthly_limit_block, Some(block)); + + let json = serde_json::to_value(&snapshot).unwrap(); + assert_eq!( + json["primary"]["monthlyLimitBlock"]["resetsAt"], + reset.to_rfc3339() + ); + assert!(json["extraRateWindows"][0]["window"]["monthlyLimitBlock"].is_null()); + assert!(json["pace"]["monthlyLimitBlock"].is_object()); + + // Other providers never derive a blocker from a same-named window. + let snapshot = bridge_snapshot(ProviderId::Codex, usage); + assert_eq!(snapshot.primary.monthly_limit_block, None); + assert_eq!(snapshot.pace.as_ref().unwrap().monthly_limit_block, None); + } + + #[test] + fn unknown_monthly_reset_blocks_without_a_lift_time() { + let usage = UsageSnapshot::new(RateWindow::with_details(0.0, Some(10_080), None, None)) + .with_extra_rate_window("kimi-monthly", "Total usage", monthly_pool(None)); + + let snapshot = bridge_snapshot(ProviderId::Kimi, usage); + assert_eq!( + snapshot.primary.monthly_limit_block, + Some(MonthlyLimitBlockSnapshot { resets_at: None }) + ); + let json = serde_json::to_value(&snapshot).unwrap(); + assert!(json["primary"]["monthlyLimitBlock"].is_object()); + assert!(json["primary"]["monthlyLimitBlock"]["resetsAt"].is_null()); + } + + #[test] + fn informational_primary_takes_the_pace_block_from_the_weekly_lane() { + let reset = Utc::now() + Duration::days(20); + let mut usage = UsageSnapshot::new(RateWindow::informational("No active session")) + .with_extra_rate_window("kimi-monthly", "Total usage", monthly_pool(Some(reset))); + usage.secondary = Some(RateWindow::with_details( + 10.0, + Some(10_080), + Some(Utc::now() + Duration::days(3)), + None, + )); + + let snapshot = bridge_snapshot(ProviderId::Kimi, usage); + assert_eq!(snapshot.primary.monthly_limit_block, None); + assert!(snapshot.secondary.unwrap().monthly_limit_block.is_some()); + assert!(snapshot.pace.unwrap().monthly_limit_block.is_some()); + } + + #[test] + fn snapshots_without_the_field_deserialize_unblocked() { + let window: RateWindowSnapshot = + serde_json::from_value(serde_json::json!({ "usedPercent": 40.0 })).unwrap(); + assert_eq!(window.monthly_limit_block, None); + let pace: PaceSnapshot = + serde_json::from_value(serde_json::json!({ "stage": "on_track", "deltaPercent": 0.0 })) + .unwrap(); + assert_eq!(pace.monthly_limit_block, None); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs index 6498eec07d..04bbb1ac48 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs @@ -32,6 +32,10 @@ fn compact_reset_description( return Some(format_compact_reset_countdown(dt, lang)); } + if window.description_is_detail { + return None; + } + window .reset_description .as_deref() @@ -128,3 +132,44 @@ pub(crate) fn friendly_provider_error(id: ProviderId, error: &str) -> String { trimmed.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use codexbar::core::RateWindow; + use codexbar::settings::Language; + + #[test] + fn detail_backed_description_is_never_a_tray_reset_label() { + let window = RateWindowSnapshot::from_rate_window( + &RateWindow::with_details( + 13.0, + None, + None, + Some("34.07 EUR / 255.00 EUR · 220.93 EUR remaining".to_string()), + ) + .with_description_as_detail(), + ); + + assert!(window.description_is_detail); + assert_eq!(compact_tray_status_label(&window, Language::English), "13%"); + } + + #[test] + fn detail_backed_window_keeps_its_countdown_when_reset_is_known() { + let window = RateWindowSnapshot::from_rate_window( + &RateWindow::with_details( + 13.0, + None, + Some(chrono::Utc::now() + chrono::Duration::minutes(125)), + Some("34.07 EUR / 255.00 EUR · 220.93 EUR remaining".to_string()), + ) + .with_description_as_detail(), + ); + + let label = compact_tray_status_label(&window, Language::English); + + assert!(label.starts_with("13% • "), "{label}"); + assert!(!label.contains("EUR"), "{label}"); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 4561dd9129..9cdc0e7dfe 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -9,6 +9,7 @@ use crate::commands::bridge::RateWindowSnapshot; use crate::state::AppState; use chrono::{DateTime, Utc}; use codexbar::core::{OpenAIDashboardCacheStore, RateWindow}; +use codexbar::cost_reporting_period::{CostReportingPeriod, CostTimeZone}; use codexbar::cost_scanner::{ CostScanner, CostSummary, get_daily_cost_history, get_daily_token_history, }; @@ -63,8 +64,18 @@ pub struct DailyUsageBreakdown { #[serde(rename_all = "camelCase")] pub struct ProviderLocalUsageSummary { pub today_cost: Option, + /// Always the trailing 30 days (the published PowerToys pipe reads it). pub thirty_day_cost: Option, pub thirty_day_tokens: Option, + /// Cost over the selected History window (`reporting_period`). + #[serde(default)] + pub period_cost: Option, + /// Tokens over the selected History window. + #[serde(default)] + pub period_tokens: Option, + /// Raw reporting period the `period_*` fields cover; empty when unknown. + #[serde(default)] + pub reporting_period: String, pub latest_tokens: Option, pub top_model: Option, pub estimate_note: String, @@ -194,10 +205,17 @@ fn build_provider_chart_data_with_cancel( .cloned() .map(|(date, tokens)| DailyTokenPoint { date, tokens }) .collect(); - let local_usage = snapshot - .local_summary - .as_ref() - .and_then(|summary| local_usage_summary_from_cost_summary(&provider_id, summary)); + let period = current_reporting_period(); + let local_usage = snapshot.local_summary.as_ref().and_then(|summary| { + let period_summary = + period_summary_for(&provider_id, period, summary, cancel.as_deref()); + local_usage_summary_from_cost_summary( + &provider_id, + summary, + period, + period_summary.as_ref(), + ) + }); store_local_usage_summary(&provider_id, local_usage.clone()); ( cost_history, @@ -221,7 +239,14 @@ fn build_provider_chart_data_with_cancel( tokens: day.total_tokens, }) .collect(); - let local_usage = muse_local_usage_summary(&report, locale::current_language()); + let period = current_reporting_period(); + let period_report = muse_period_report(period, &report, cancel.as_deref()); + let local_usage = muse_local_usage_summary( + &report, + &period_report, + period, + locale::current_language(), + ); ( Vec::new(), tokens_history, @@ -331,16 +356,40 @@ fn map_quota_window_history( } } +/// The History window the desktop is configured to report. +fn current_reporting_period() -> CostReportingPeriod { + codexbar::settings::Settings::load().cost_reporting_period +} + +/// The period summary for `provider_id`, reusing the 30-day scan when the +/// selected window is exactly the trailing 30 days. +fn period_summary_for( + provider_id: &str, + period: CostReportingPeriod, + thirty_day: &CostSummary, + cancel: Option<&AtomicBool>, +) -> Option { + if period == CostReportingPeriod::Rolling(30) { + return Some(thirty_day.clone()); + } + scan_local_cost(provider_id, period, cancel) +} + fn local_usage_summary_from_cost_summary( provider_id: &str, summary: &CostSummary, + period: CostReportingPeriod, + period_summary: Option<&CostSummary>, ) -> Option { - let total_tokens = total_tokens(summary); - let has_usage = summary.sessions_count > 0 || summary.total_cost_usd > 0.0 || total_tokens > 0; + let thirty_tokens = total_tokens(summary); + let has_usage = summary.sessions_count > 0 || summary.total_cost_usd > 0.0 || thirty_tokens > 0; has_usage.then(|| ProviderLocalUsageSummary { today_cost: None, thirty_day_cost: non_zero_f64(summary.total_cost_usd), - thirty_day_tokens: non_zero_u64(total_tokens), + thirty_day_tokens: non_zero_u64(thirty_tokens), + period_cost: period_summary.and_then(|s| non_zero_f64(s.total_cost_usd)), + period_tokens: period_summary.and_then(|s| non_zero_u64(total_tokens(s))), + reporting_period: period.raw(), latest_tokens: None, top_model: top_model(summary), estimate_note: localized_estimate_note(provider_id, locale::current_language()), @@ -388,27 +437,35 @@ fn load_local_usage_summary_with_unknown_models( provider_id: &str, cancel: Option<&AtomicBool>, ) -> (Option, HashSet) { + let period = current_reporting_period(); if provider_id == "muse" { let summary = if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { None } else { let report = muse_local_usage::scan(30, cancel); - muse_local_usage_summary(&report, locale::current_language()) + let period_report = muse_period_report(period, &report, cancel); + muse_local_usage_summary(&report, &period_report, period, locale::current_language()) }; return (summary, HashSet::new()); } - let Some(thirty_day) = scan_local_cost(provider_id, 30, cancel) else { + let Some(thirty_day) = scan_local_cost(provider_id, CostReportingPeriod::Rolling(30), cancel) + else { return (None, HashSet::new()); }; if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { return (None, HashSet::new()); } - let today = scan_local_cost(provider_id, 1, cancel).unwrap_or_default(); - let unknown_models = thirty_day + let today = + scan_local_cost(provider_id, CostReportingPeriod::Rolling(1), cancel).unwrap_or_default(); + let period_summary = period_summary_for(provider_id, period, &thirty_day, cancel); + let mut unknown_models: HashSet = thirty_day .unknown_models .union(&today.unknown_models) .cloned() .collect(); + if let Some(period_summary) = period_summary.as_ref() { + unknown_models.extend(period_summary.unknown_models.iter().cloned()); + } let thirty_day_tokens = total_tokens(&thirty_day); let latest_tokens = total_tokens(&today); @@ -424,6 +481,13 @@ fn load_local_usage_summary_with_unknown_models( today_cost: non_zero_f64(today.total_cost_usd), thirty_day_cost: non_zero_f64(thirty_day.total_cost_usd), thirty_day_tokens: non_zero_u64(thirty_day_tokens), + period_cost: period_summary + .as_ref() + .and_then(|s| non_zero_f64(s.total_cost_usd)), + period_tokens: period_summary + .as_ref() + .and_then(|s| non_zero_u64(total_tokens(s))), + reporting_period: period.raw(), latest_tokens: non_zero_u64(latest_tokens), top_model: top_model(&thirty_day), estimate_note: localized_estimate_note(provider_id, lang), @@ -433,8 +497,23 @@ fn load_local_usage_summary_with_unknown_models( ) } +/// The Muse report for the selected period, reusing the 30-day scan when the +/// selection is exactly the trailing 30 days. +fn muse_period_report( + period: CostReportingPeriod, + thirty_day: &muse_local_usage::Report, + cancel: Option<&AtomicBool>, +) -> muse_local_usage::Report { + if period == CostReportingPeriod::Rolling(30) { + return thirty_day.clone(); + } + muse_local_usage::scan(period.scan_days(Utc::now()), cancel) +} + fn muse_local_usage_summary( report: &muse_local_usage::Report, + period_report: &muse_local_usage::Report, + period: CostReportingPeriod, lang: codexbar::settings::Language, ) -> Option { if !report.is_available() || !report.is_complete() { @@ -445,6 +524,12 @@ fn muse_local_usage_summary( today_cost: None, thirty_day_cost: None, thirty_day_tokens: Some(total_tokens), + period_cost: None, + period_tokens: period_report + .is_complete() + .then_some(period_report.total_tokens) + .flatten(), + reporting_period: period.raw(), latest_tokens: report.today_tokens, top_model: report.top_model.clone(), estimate_note: locale::get_text(lang, LocaleKey::PanelEstimatedFromLocalLogsMuse), @@ -460,9 +545,24 @@ pub(crate) fn load_provider_local_usage_summary( struct CachedLocalUsage { loaded_at: Instant, + /// `CostReportingPeriod::identity` the entry was built for; a period or + /// month change makes the entry stale before its TTL. + period_identity: String, summary: Option, } +fn local_usage_period_identity() -> String { + current_reporting_period().identity(Utc::now(), CostTimeZone::Local) +} + +/// Cache a completed scan under the reporting window it actually used. The +/// summary timestamp preserves the month for month-to-date identities. +fn local_usage_summary_period_identity(summary: &ProviderLocalUsageSummary) -> Option { + let period = CostReportingPeriod::parse(&summary.reporting_period)?; + let scanned_at = DateTime::::from_timestamp_millis(summary.token_cost_updated_at_ms)?; + Some(period.identity(scanned_at, CostTimeZone::Local)) +} + fn local_usage_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) @@ -547,8 +647,10 @@ fn load_local_usage_summary_cached( cancel: Option<&AtomicBool>, ) -> Option { let cache = local_usage_cache(); + let period_identity = local_usage_period_identity(); if let Ok(guard) = cache.lock() && let Some(entry) = guard.get(provider_id) + && entry.period_identity == period_identity && token_cost_cache_is_fresh(Some(entry.loaded_at), Instant::now(), LOCAL_USAGE_TTL) { return entry.summary.clone(); @@ -568,11 +670,22 @@ fn load_local_usage_summary_cached( } fn store_local_usage_summary(provider_id: &str, summary: Option) { + let period_identity = match summary.as_ref() { + Some(summary) => { + let Some(period_identity) = local_usage_summary_period_identity(summary) else { + tracing::warn!("Skipping local usage cache entry with invalid period metadata"); + return; + }; + period_identity + } + None => local_usage_period_identity(), + }; if let Ok(mut guard) = local_usage_cache().lock() { guard.insert( provider_id.to_string(), CachedLocalUsage { loaded_at: Instant::now(), + period_identity, summary, }, ); @@ -585,11 +698,13 @@ fn record_local_usage_fetch_failure(provider_id: &str, failure: CostFetchFailure } else { Instant::now() }; + let period_identity = local_usage_period_identity(); if let Ok(mut guard) = local_usage_cache().lock() { guard.insert( provider_id.to_string(), CachedLocalUsage { loaded_at, + period_identity, summary: None, }, ); @@ -637,10 +752,10 @@ fn localized_estimate_note(provider_id: &str, lang: codexbar::settings::Language fn scan_local_cost( provider_id: &str, - days: u32, + period: CostReportingPeriod, cancel: Option<&AtomicBool>, ) -> Option { - let scanner = CostScanner::new(days); + let scanner = CostScanner::for_period(period); match provider_id { "codex" => Some(scanner.scan_codex_with_cancel(cancel)), "claude" => Some(scanner.scan_claude_with_cancel(cancel)), @@ -744,120 +859,4 @@ pub(crate) fn load_openai_dashboard_chart_data_for_test( } #[cfg(test)] -mod tests { - use super::{ - CostFetchFailure, ProviderLocalUsageSummary, cost_fetch_failure_allows_early_retry, - localized_estimate_note, muse_local_usage_summary, token_cost_cache_is_fresh, - }; - use crate::commands::is_provider_cache_fresh; - use codexbar::providers::muse::local_usage::{DailyUsage, Report}; - use codexbar::settings::Language; - use codexbar::spend_contract::LocalHistoryCoverage; - use std::time::{Duration, Instant}; - - #[test] - fn token_cost_age_does_not_use_provider_quota_age() { - let now = Instant::now(); - let token_loaded = now - Duration::from_secs(31); - let provider_updated = now; - assert!(!token_cost_cache_is_fresh( - Some(token_loaded), - now, - Duration::from_secs(30) - )); - assert!(is_provider_cache_fresh( - Some(provider_updated), - Duration::from_secs(30) - )); - } - - #[test] - fn fast_cost_failures_allow_the_next_pass_to_retry() { - assert!(cost_fetch_failure_allows_early_retry( - CostFetchFailure::Failed - )); - assert!(!cost_fetch_failure_allows_early_retry( - CostFetchFailure::TimedOut - )); - } - - #[test] - fn local_usage_summary_serializes_token_cost_timestamp() { - let summary = ProviderLocalUsageSummary { - today_cost: Some(1.0), - thirty_day_cost: Some(2.0), - thirty_day_tokens: Some(300), - latest_tokens: Some(40), - top_model: Some("gpt-5".to_string()), - estimate_note: "estimated".to_string(), - token_cost_updated_at_ms: 1234, - }; - - let json = serde_json::to_value(summary).expect("serialize summary"); - assert_eq!( - json.get("tokenCostUpdatedAtMs").and_then(|v| v.as_i64()), - Some(1234) - ); - } - - #[test] - fn muse_local_usage_summary_exposes_complete_tokens_without_cost() { - let report = Report { - daily: vec![DailyUsage { - day: "2026-09-20".to_string(), - input_tokens: 10, - output_tokens: 2, - cache_read_tokens: 0, - cache_write_tokens: 0, - reasoning_tokens: 0, - total_tokens: 12, - request_count: 1, - models: vec![("muse-spark-1.3".to_string(), 12)], - }], - total_tokens: Some(12), - today_tokens: Some(12), - session_count: 1, - top_model: Some("muse-spark-1.3".to_string()), - coverage: LocalHistoryCoverage::Complete, - }; - let summary = muse_local_usage_summary(&report, codexbar::settings::Language::default()) - .expect("complete history is visible"); - assert_eq!(summary.today_cost, None); - assert_eq!(summary.thirty_day_cost, None); - assert_eq!(summary.thirty_day_tokens, Some(12)); - assert_eq!(summary.latest_tokens, Some(12)); - assert_eq!(summary.top_model.as_deref(), Some("muse-spark-1.3")); - - let partial = Report { - coverage: LocalHistoryCoverage::Partial, - ..report - }; - assert!( - muse_local_usage_summary(&partial, codexbar::settings::Language::default()).is_none() - ); - } - - #[test] - fn japanese_estimate_note_is_localized() { - assert_eq!( - localized_estimate_note("codex", Language::Japanese), - "ローカルログから推定したもので、請求書と異なる場合があります" - ); - assert_eq!( - localized_estimate_note("claude", Language::Japanese), - "ClaudeのローカルログからAPIレートで推定したもので、トークン総数が請求書と異なる場合があります" - ); - } - - #[test] - fn english_estimate_note_is_localized() { - assert_eq!( - localized_estimate_note("codex", Language::English), - "Estimated from local logs; may differ from your bill" - ); - assert_eq!( - localized_estimate_note("claude", Language::English), - "Estimated from local Claude logs at API rates; token totals may differ from your bill" - ); - } -} +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs new file mode 100644 index 0000000000..f0d19e9adf --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs @@ -0,0 +1,187 @@ +use super::{ + CostFetchFailure, ProviderLocalUsageSummary, cost_fetch_failure_allows_early_retry, + local_usage_summary_from_cost_summary, local_usage_summary_period_identity, + localized_estimate_note, muse_local_usage_summary, token_cost_cache_is_fresh, +}; +use crate::commands::is_provider_cache_fresh; +use codexbar::cost_reporting_period::{CostReportingPeriod, CostTimeZone}; +use codexbar::cost_scanner::CostSummary; +use codexbar::providers::muse::local_usage::{DailyUsage, Report}; +use codexbar::settings::Language; +use codexbar::spend_contract::LocalHistoryCoverage; +use std::time::{Duration, Instant}; + +#[test] +fn token_cost_age_does_not_use_provider_quota_age() { + let now = Instant::now(); + let token_loaded = now - Duration::from_secs(31); + let provider_updated = now; + assert!(!token_cost_cache_is_fresh( + Some(token_loaded), + now, + Duration::from_secs(30) + )); + assert!(is_provider_cache_fresh( + Some(provider_updated), + Duration::from_secs(30) + )); +} + +#[test] +fn fast_cost_failures_allow_the_next_pass_to_retry() { + assert!(cost_fetch_failure_allows_early_retry( + CostFetchFailure::Failed + )); + assert!(!cost_fetch_failure_allows_early_retry( + CostFetchFailure::TimedOut + )); +} + +#[test] +fn local_usage_summary_serializes_token_cost_timestamp() { + let summary = ProviderLocalUsageSummary { + today_cost: Some(1.0), + thirty_day_cost: Some(2.0), + thirty_day_tokens: Some(300), + period_cost: Some(3.0), + period_tokens: Some(400), + reporting_period: "month-to-date".to_string(), + latest_tokens: Some(40), + top_model: Some("gpt-5".to_string()), + estimate_note: "estimated".to_string(), + token_cost_updated_at_ms: 1234, + }; + + let json = serde_json::to_value(summary).expect("serialize summary"); + assert_eq!( + json.get("tokenCostUpdatedAtMs").and_then(|v| v.as_i64()), + Some(1234) + ); +} + +#[test] +fn local_usage_cache_identity_uses_the_summary_period_and_timestamp() { + let scanned_at = chrono::DateTime::parse_from_rfc3339("2026-09-30T23:00:00Z") + .expect("valid timestamp") + .with_timezone(&chrono::Utc); + let summary = ProviderLocalUsageSummary { + today_cost: None, + thirty_day_cost: None, + thirty_day_tokens: None, + period_cost: None, + period_tokens: None, + reporting_period: "month-to-date".to_string(), + latest_tokens: None, + top_model: None, + estimate_note: String::new(), + token_cost_updated_at_ms: scanned_at.timestamp_millis(), + }; + + assert_eq!( + local_usage_summary_period_identity(&summary), + Some(CostReportingPeriod::MonthToDate.identity(scanned_at, CostTimeZone::Local)) + ); +} + +#[test] +fn muse_local_usage_summary_exposes_complete_tokens_without_cost() { + let report = Report { + daily: vec![DailyUsage { + day: "2026-09-20".to_string(), + input_tokens: 10, + output_tokens: 2, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + total_tokens: 12, + request_count: 1, + models: vec![("muse-spark-1.3".to_string(), 12)], + }], + total_tokens: Some(12), + today_tokens: Some(12), + session_count: 1, + top_model: Some("muse-spark-1.3".to_string()), + coverage: LocalHistoryCoverage::Complete, + }; + let period = CostReportingPeriod::Rolling(30); + let summary = muse_local_usage_summary( + &report, + &report, + period, + codexbar::settings::Language::default(), + ) + .expect("complete history is visible"); + assert_eq!(summary.period_tokens, Some(12)); + assert_eq!(summary.reporting_period, "rolling:30"); + assert_eq!(summary.today_cost, None); + assert_eq!(summary.thirty_day_cost, None); + assert_eq!(summary.thirty_day_tokens, Some(12)); + assert_eq!(summary.latest_tokens, Some(12)); + assert_eq!(summary.top_model.as_deref(), Some("muse-spark-1.3")); + + let partial = Report { + coverage: LocalHistoryCoverage::Partial, + ..report + }; + assert!( + muse_local_usage_summary( + &partial, + &partial, + period, + codexbar::settings::Language::default() + ) + .is_none() + ); +} + +#[test] +fn local_usage_keeps_thirty_day_fixed_while_period_follows_selection() { + let thirty = CostSummary { + total_cost_usd: 30.0, + input_tokens: 3_000, + output_tokens: 0, + ..CostSummary::default() + }; + let month = CostSummary { + total_cost_usd: 5.0, + input_tokens: 400, + output_tokens: 100, + ..CostSummary::default() + }; + let summary = local_usage_summary_from_cost_summary( + "claude", + &thirty, + CostReportingPeriod::MonthToDate, + Some(&month), + ) + .expect("usage is visible"); + assert_eq!(summary.thirty_day_cost, Some(30.0)); + assert_eq!(summary.thirty_day_tokens, Some(3_000)); + assert_eq!(summary.period_cost, Some(5.0)); + assert_eq!(summary.period_tokens, Some(500)); + assert_eq!(summary.reporting_period, "month-to-date"); +} + +#[test] +fn japanese_estimate_note_is_localized() { + assert_eq!( + localized_estimate_note("codex", Language::Japanese), + "ローカルログから推定したもので、請求書と異なる場合があります" + ); + assert_eq!( + localized_estimate_note("claude", Language::Japanese), + "ClaudeのローカルログからAPIレートで推定したもので、トークン総数が請求書と異なる場合があります" + ); +} + +#[test] +fn english_estimate_note_is_localized() { + assert_eq!( + localized_estimate_note("codex", Language::English), + "Estimated from local logs; may differ from your bill" + ); + assert_eq!( + localized_estimate_note("claude", Language::English), + "Estimated from local Claude logs at API rates; token totals may differ from your bill" + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index 317cdc9f5e..da3d6b90e4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -191,15 +191,22 @@ impl ClaudeSwapMutationOutcome { fn validate_claude_swap_operation( operation: ClaudeSwapAccountOperation, + list: &ClaudeSwapAccountList, account: &ClaudeSwapAccountRow, ) -> Result<(), String> { let expected_action = match operation { ClaudeSwapAccountOperation::Switch => ClaudeSwapAccountAction::Switch, ClaudeSwapAccountOperation::Reauthenticate => ClaudeSwapAccountAction::Reauthenticate, }; - if claude_swap::action_for_account(account) == Some(expected_action) { + if claude_swap::action_for_account(list, account) == Some(expected_action) { return Ok(()); } + if !list.supports_account_switching { + return Err( + "This claude-swap adapter is read-only and does not support account switching." + .to_string(), + ); + } match operation { ClaudeSwapAccountOperation::Switch if account.is_active => { Err("That claude-swap account is already active.".to_string()) @@ -230,7 +237,7 @@ fn run_claude_swap_operation( let before = claude_swap::read_account_list(&config.executable_path) .map_err(|error| error.to_string())?; let account = account_row_for_slot(&before, slot)?; - validate_claude_swap_operation(operation, account)?; + validate_claude_swap_operation(operation, &before, account)?; let result = claude_swap::switch_account(&config.executable_path, slot) .map_err(|error| error.to_string())?; @@ -413,10 +420,15 @@ pub async fn claude_account_switch( mod tests { use super::*; - fn account_row(active: bool, status: &str) -> ClaudeSwapAccountRow { + fn account_list( + active: bool, + status: &str, + supports_account_switching: bool, + ) -> ClaudeSwapAccountList { let raw = serde_json::json!({ "schemaVersion": 1, "activeAccountNumber": if active { serde_json::json!(1) } else { serde_json::Value::Null }, + "supportsAccountSwitching": supports_account_switching, "accounts": [{ "number": 1, "email": "test@example.com", @@ -426,6 +438,10 @@ mod tests { }); codexbar::providers::claude::claude_swap::parse_account_list(&raw.to_string()) .expect("fixture should parse") + } + + fn account_row(active: bool, status: &str) -> ClaudeSwapAccountRow { + account_list(active, status, true) .accounts .into_iter() .next() @@ -434,18 +450,63 @@ mod tests { #[test] fn operation_validation_uses_the_projected_action_state() { - let switchable = account_row(false, "ok"); + let switchable = account_list(false, "ok", true); assert!( - validate_claude_swap_operation(ClaudeSwapAccountOperation::Switch, &switchable).is_ok() + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Switch, + &switchable, + &switchable.accounts[0] + ) + .is_ok() ); - let blocked = account_row(false, "no_credentials"); + let blocked = account_list(false, "no_credentials", true); assert_eq!( - validate_claude_swap_operation(ClaudeSwapAccountOperation::Switch, &blocked), + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Switch, + &blocked, + &blocked.accounts[0] + ), Err("That claude-swap account is not available for switching.".to_string()) ); } + #[test] + fn read_only_adapter_rejects_switch_and_reauthenticate() { + let expected = Err( + "This claude-swap adapter is read-only and does not support account switching." + .to_string(), + ); + let inactive = account_list(false, "ok", false); + assert_eq!( + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Switch, + &inactive, + &inactive.accounts[0] + ), + expected + ); + let foreign = account_list(true, "foreign_credential", false); + assert_eq!( + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Reauthenticate, + &foreign, + &foreign.accounts[0] + ), + expected + ); + // The same row is accepted when the adapter supports switching. + let switching = account_list(true, "foreign_credential", true); + assert!( + validate_claude_swap_operation( + ClaudeSwapAccountOperation::Reauthenticate, + &switching, + &switching.accounts[0] + ) + .is_ok() + ); + } + #[test] fn reauthentication_confirmation_requires_a_live_ok_status() { assert!(reauthentication_is_repaired(&account_row(true, "ok"))); diff --git a/apps/desktop-tauri/src-tauri/src/commands/currency.rs b/apps/desktop-tauri/src-tauri/src/commands/currency.rs new file mode 100644 index 0000000000..3f508c5981 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/currency.rs @@ -0,0 +1,231 @@ +use std::{collections::HashMap, path::PathBuf}; + +use codexbar::currency::{ + CURRENCIES, PreferredCurrency, convert_amount, fetch_exchange_rates, sanitize_rates, +}; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tokio::sync::Mutex; + +const CACHE_MAX_AGE_SECS: i64 = 24 * 60 * 60; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersistedRates { + fetched_at_unix: i64, + rates: HashMap, +} + +/// Everything the web UI needs to display preferred currencies. Rust owns the +/// model (supported codes, fallback rates, sanitizing, USD-pivot conversion); +/// the frontend formats with `Intl` only. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrencyRatesSnapshot { + /// Merged offline-fallback + persisted/fetched rates, sanitized. + pub rates: HashMap, + /// Codes `format_display_currency` accepts for `preferredCode`. + pub supported_codes: Vec<&'static str>, + /// Normalized preference the snapshot was built for ("AUTO" or a code). + pub preferred_code: String, +} + +/// Shared cache behind the settings command and the tray's cost formatting. +/// Owns the merged rate table and the preference so both paths agree between +/// a fetch and the next read (no second source of truth on disk). +#[derive(Default)] +pub struct CurrencyRateCache { + inner: Mutex, +} + +#[derive(Default)] +struct CacheState { + loaded: bool, + persisted: Option, + /// Merged, sanitized rates (fallback + persisted), cached alongside. + rates: HashMap, + /// Normalized preference the cached table was built for. + preferred_code: String, +} + +#[tauri::command] +pub async fn get_currency_rates( + app: tauri::AppHandle, + cache: State<'_, CurrencyRateCache>, + preferred_currency_code: String, +) -> Result { + let preferred = PreferredCurrency::parse(&preferred_currency_code); + let mut state = cache.inner.lock().await; + if !state.loaded { + state.persisted = read_persisted_rates(); + state.rates = merged_rates(state.persisted.as_ref()); + state.loaded = true; + } + + if !preferred.is_auto() { + let now = unix_now(); + let fresh = state.persisted.as_ref().is_some_and(|cached| { + let age = now.saturating_sub(cached.fetched_at_unix); + (0..CACHE_MAX_AGE_SECS).contains(&age) + }); + if !fresh { + match fetch_exchange_rates().await { + Ok(rates) => { + let entry = PersistedRates { + fetched_at_unix: now, + rates, + }; + persist_rates(&entry); + state.persisted = Some(entry); + state.rates = merged_rates(state.persisted.as_ref()); + crate::tray_bridge::refresh_tray_presentation(&app); + } + Err(error) => { + tracing::debug!(%error, "currency rates unavailable; using cached or offline rates") + } + } + } + } + state.preferred_code = preferred.raw(); + + Ok(CurrencyRatesSnapshot { + rates: state.rates.clone(), + supported_codes: CURRENCIES.iter().map(|c| c.code).collect(), + preferred_code: preferred.raw(), + }) +} + +/// Read the shared cache for the tray path (blocking on the async mutex; the +/// tray runs off the async runtime). Returns merged rates plus the last +/// command-observed preference, or `None` when the cache was never loaded — +/// the caller then falls back to offline fallback rates via `merged_rates`. +pub(crate) fn cached_rates_and_preference( + cache: &CurrencyRateCache, +) -> Option<(HashMap, String)> { + let state = cache.inner.blocking_lock(); + (!state.rates.is_empty() || state.loaded) + .then(|| (state.rates.clone(), state.preferred_code.clone())) + .filter(|_| state.loaded) +} + +/// Convert `amount` from `source_code` to the saved preferred currency for +/// the tray. Reads the shared cache (synced by `get_currency_rates`); falls +/// back to persisted disk rates when the cache was never touched so a tray +/// render before the first settings load still converts consistently. +pub(crate) fn convert_preferred_amount( + cache: Option<&CurrencyRateCache>, + amount: f64, + source_code: &str, +) -> Option<(f64, String)> { + let PreferredCurrency::Code(target) = + PreferredCurrency::parse(&codexbar::settings::Settings::load().preferred_currency_code) + else { + return None; + }; + let rates = match cache { + Some(cache) => match cached_rates_and_preference(cache) { + Some((rates, _)) if !rates.is_empty() => rates, + _ => merged_rates(read_persisted_rates().as_ref()), + }, + None => merged_rates(read_persisted_rates().as_ref()), + }; + convert_amount(amount, source_code, target, &rates) + .map(|converted| (converted, (*target).to_string())) +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64) + .unwrap_or_default() +} + +fn cache_path() -> Option { + codexbar::settings::Settings::settings_path()? + .parent() + .map(|parent| parent.join("currency-rates.json")) +} + +fn read_persisted_rates() -> Option { + let path = cache_path()?; + let bytes = std::fs::read(path).ok()?; + let mut cached: PersistedRates = serde_json::from_slice(&bytes).ok()?; + // Sanitizing is core-owned (single model, see the thermo review): the + // shell only delegates. + cached.rates = sanitize_rates(&cached.rates); + (!cached.rates.is_empty()).then_some(cached) +} + +fn merged_rates(cached: Option<&PersistedRates>) -> HashMap { + let mut rates = codexbar::currency::fallback_rates(); + if let Some(cached) = cached { + for (code, rate) in sanitize_rates(&cached.rates) { + rates.insert(code, rate); + } + } + rates +} + +fn persist_rates(cached: &PersistedRates) { + let Some(path) = cache_path() else { return }; + let Some(parent) = path.parent() else { return }; + let write_result = (|| -> Result<(), Box> { + std::fs::create_dir_all(parent)?; + let bytes = serde_json::to_vec(cached)?; + codexbar::atomic_file::write_atomic(&path, &bytes)?; + Ok(()) + })(); + if let Err(error) = write_result { + tracing::debug!(%error, "could not persist currency rate cache"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fallback_table_covers_every_preferred_currency() { + let rates = codexbar::currency::fallback_rates(); + assert_eq!(rates.len(), CURRENCIES.len()); + for currency in CURRENCIES { + assert!( + rates + .get(currency.code) + .is_some_and(|rate| rate.is_finite() && *rate > 0.0) + ); + } + } + + #[test] + fn persisted_rates_are_sanitized_before_merging() { + let rates = HashMap::from([ + ("USD".to_string(), 1.0), + ("TRY".to_string(), 48.0), + ("EUR".to_string(), f64::NAN), + ("BTC".to_string(), 100.0), + ]); + let clean = sanitize_rates(&rates); + assert_eq!(clean.get("TRY"), Some(&48.0)); + assert!(!clean.contains_key("EUR")); + assert!(!clean.contains_key("BTC")); + } + + #[test] + fn merged_rates_prefer_cached_values_over_fallback() { + let cached = PersistedRates { + fetched_at_unix: unix_now(), + rates: HashMap::from([("USD".to_string(), 1.0), ("TRY".to_string(), 40.0)]), + }; + let rates = merged_rates(Some(&cached)); + assert_eq!(rates.get("TRY"), Some(&40.0)); + // Unsupported codes in the cache never widen the table. + assert_eq!(rates.len(), CURRENCIES.len()); + } + + #[test] + fn cache_reads_return_loaded_state_and_skip_when_empty() { + let cache = CurrencyRateCache::default(); + assert!(cached_rates_and_preference(&cache).is_none()); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/last_good_owner_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/last_good_owner_tests.rs new file mode 100644 index 0000000000..e9c7753d8c --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/last_good_owner_tests.rs @@ -0,0 +1,160 @@ +//! Owner-checked last-good retention in the provider refresh shell. +//! +//! A failure tied to a live session may keep the cached snapshot only when the +//! same session produced that snapshot. Scenarios use DeepSeek's Chrome +//! session balance, the provider that declares owned transport failures. + +use super::ProviderUsageSnapshot; +use super::providers::{preserve_last_good_transient_failure, record_last_good_owner}; +use crate::state::AppState; +use codexbar::core::{ + LastGoodOwner, ProviderError, ProviderFetchResult, ProviderId, ProviderStateKind, RateWindow, + UsageSnapshot, instantiate_provider, +}; + +const MEASURED_AT: &str = "2026-09-01T00:00:00Z"; + +fn owner(profile: &str, token: &str) -> LastGoodOwner { + LastGoodOwner::derive("deepseek-platform-balance", profile, token).expect("owner") +} + +fn cached_balance() -> ProviderUsageSnapshot { + let metadata = instantiate_provider(ProviderId::DeepSeek) + .metadata() + .clone(); + let result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(0.0)), "web"); + let mut snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::DeepSeek, &metadata, &result, None); + snapshot.updated_at = MEASURED_AT.to_string(); + snapshot +} + +fn failed_refresh() -> ProviderUsageSnapshot { + let metadata = instantiate_provider(ProviderId::DeepSeek) + .metadata() + .clone(); + ProviderUsageSnapshot::from_error( + ProviderId::DeepSeek, + &metadata, + "Timeout".to_string(), + ProviderStateKind::Unknown, + ) +} + +fn state_with_cached_balance(cached_owner: Option) -> AppState { + let mut state = AppState::new(); + state.provider_cache.push(cached_balance()); + record_last_good_owner(&mut state, ProviderId::DeepSeek, cached_owner); + state +} + +fn refresh_failure(state: &mut AppState, error: &ProviderError) -> ProviderUsageSnapshot { + preserve_last_good_transient_failure(state, ProviderId::DeepSeek, failed_refresh(), error) +} + +#[test] +fn matching_session_keeps_the_balance_and_its_original_time() { + let session = owner("chrome:Default", "token-a"); + let mut state = state_with_cached_balance(Some(session.clone())); + + let kept = refresh_failure( + &mut state, + &ProviderError::Timeout.with_failure_owner(Some(session)), + ); + + assert_eq!(kept.error, None); + assert_eq!(kept.updated_at, MEASURED_AT); +} + +#[test] +fn different_profile_or_token_shows_the_error() { + for failed_session in [ + owner("chrome:Profile 1", "token-a"), + owner("chrome:Default", "token-b"), + ] { + let mut state = state_with_cached_balance(Some(owner("chrome:Default", "token-a"))); + let shown = refresh_failure( + &mut state, + &ProviderError::Timeout.with_failure_owner(Some(failed_session)), + ); + assert_eq!(shown.error.as_deref(), Some("Timeout")); + } +} + +#[test] +fn failure_without_a_session_owner_fails_closed() { + let mut state = state_with_cached_balance(Some(owner("chrome:Default", "token-a"))); + + let shown = refresh_failure(&mut state, &ProviderError::Timeout.with_failure_owner(None)); + + assert_eq!(shown.error.as_deref(), Some("Timeout")); +} + +#[test] +fn balance_without_an_owner_is_never_retained() { + // An API-key balance, a decoded cache, or a seeded snapshot has no owner. + let session = owner("chrome:Default", "token-a"); + let mut state = state_with_cached_balance(None); + + let shown = refresh_failure( + &mut state, + &ProviderError::Timeout.with_failure_owner(Some(session)), + ); + + assert_eq!(shown.error.as_deref(), Some("Timeout")); +} + +#[test] +fn unattributed_transport_failure_does_not_retain_deepseek_balances() { + let mut state = state_with_cached_balance(Some(owner("chrome:Default", "token-a"))); + + let shown = refresh_failure(&mut state, &ProviderError::Timeout); + + assert_eq!(shown.error.as_deref(), Some("Timeout")); +} + +#[test] +fn fresh_snapshot_replaces_or_clears_the_owner() { + let first = owner("chrome:Default", "token-a"); + let second = owner("chrome:Default", "token-b"); + let mut state = state_with_cached_balance(Some(first.clone())); + + record_last_good_owner(&mut state, ProviderId::DeepSeek, Some(second.clone())); + assert_eq!( + state.last_good_owners.get(&ProviderId::DeepSeek), + Some(&second) + ); + let shown = refresh_failure( + &mut state, + &ProviderError::Timeout.with_failure_owner(Some(first)), + ); + assert_eq!(shown.error.as_deref(), Some("Timeout")); + + record_last_good_owner(&mut state, ProviderId::DeepSeek, None); + assert!(state.last_good_owners.is_empty()); +} + +#[test] +fn other_providers_keep_their_existing_failure_policy() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(42.0)), "OAuth"); + let good = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None); + let failed = ProviderUsageSnapshot::from_error( + ProviderId::Codex, + &metadata, + "Timeout".to_string(), + ProviderStateKind::Unknown, + ); + let mut state = AppState::new(); + state.provider_cache.push(good); + + let kept = preserve_last_good_transient_failure( + &mut state, + ProviderId::Codex, + failed, + &ProviderError::Timeout, + ); + + assert_eq!(kept.error, None); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index fdfaeda967..2d5392ff54 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -24,6 +24,7 @@ use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; mod chart; +mod currency; mod spend_contract; mod tokens; mod updater; @@ -75,10 +76,18 @@ pub use surface::*; pub use system::*; pub(crate) use usage_items::*; +#[cfg(test)] +mod last_good_owner_tests; +#[cfg(test)] +mod session_cookie_scope_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod token_account_source_tests; + pub use chart::*; +pub use currency::*; pub use spend_contract::*; pub use tokens::*; pub use updater::*; @@ -104,6 +113,25 @@ fn canonical_provider_arg(provider_id: &str) -> Result { Ok(parse_provider_arg(provider_id)?.cli_name().to_string()) } +fn provider_dashboard_url(id: ProviderId, settings: &Settings) -> Option { + match id { + ProviderId::MiniMax => Some( + codexbar::providers::MiniMaxProvider::dashboard_url_for_region(Some( + settings.api_region(id), + )), + ), + ProviderId::Kimi => Some( + codexbar::providers::KimiRegion::from_settings(Some(settings.api_region(id))) + .console_url() + .to_string(), + ), + _ => instantiate_provider(id) + .metadata() + .dashboard_url + .map(str::to_string), + } +} + fn validate_single_line_secret(value: &str, field: &str, max_len: usize) -> Result<(), String> { let trimmed = value.trim(); if trimmed.is_empty() { diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 418bd5c146..574700058b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -23,11 +23,24 @@ pub struct ProviderDetail { // Usage windows — reuse existing RateWindowSnapshot shape. pub session: Option, pub weekly: Option, + /// Provider-declared label for the session (primary) lane; None when the + /// provider uses the generic "Session" wording. + pub primary_label: Option, + /// Provider-declared label for the weekly (secondary) lane; None when the + /// provider uses the generic "Weekly" wording. + pub secondary_label: Option, pub model_specific: Option, pub tertiary: Option, /// Locale key naming the tertiary lane when it carries a semantic label /// beyond "Tertiary" (upstream F5). Drives the settings metric picker. pub tertiary_label_key: Option<&'static str>, + /// Id of the extra rate window holding the provider's monthly plan + /// allowance (upstream 0.70.0 #4072). Offers the Monthly Plan metric in + /// the settings metric picker. + pub monthly_plan_window_id: Option<&'static str>, + /// Metric picker label for the primary lane when it is not a session + /// window (upstream `menuBarLayoutPrimaryLabel`). + pub primary_metric_label: Option<&'static str>, pub extra_rate_windows: Vec, pub usage_items: Vec, pub hidden_usage_item_ids: Vec, @@ -37,6 +50,9 @@ pub struct ProviderDetail { // Cost / pace. pub cost: Option, pub pace: Option, + /// Per-day OpenAI Admin API history for the Settings daily usage chart. + #[serde(skip_serializing_if = "Option::is_none")] + pub open_ai_api_usage: Option, // Error / state. pub last_error: Option, @@ -73,15 +89,7 @@ pub(crate) fn build_provider_detail( let provider = instantiate_provider(id); let metadata = provider.metadata(); let resume_supported = auto_resume_supported(id); - let dashboard_url = if id == codexbar::core::ProviderId::MiniMax { - Some( - codexbar::providers::MiniMaxProvider::dashboard_url_for_region(Some( - settings.api_region(id), - )), - ) - } else { - metadata.dashboard_url.map(|s| s.to_string()) - }; + let dashboard_url = provider_dashboard_url(id, &settings); let detail = ProviderDetail { id: id.cli_name().to_string(), @@ -97,9 +105,13 @@ pub(crate) fn build_provider_detail( last_updated: None, session: None, weekly: None, + primary_label: None, + secondary_label: None, model_specific: None, tertiary: None, tertiary_label_key: metadata.tertiary_label_key, + monthly_plan_window_id: provider.monthly_plan_window_id(), + primary_metric_label: provider.menu_bar_primary_label(), extra_rate_windows: Vec::new(), usage_items: Vec::new(), hidden_usage_item_ids: settings.hidden_usage_item_ids(id), @@ -107,6 +119,7 @@ pub(crate) fn build_provider_detail( display_details: Vec::new(), cost: None, pace: None, + open_ai_api_usage: None, last_error: None, error_state: None, dashboard_url: dashboard_url.clone(), @@ -154,6 +167,14 @@ pub fn get_provider_detail( detail.email = snapshot.account_email.clone(); detail.plan = snapshot.plan_name.clone(); detail.organization = snapshot.account_organization.clone(); + if parsed_provider_id == ProviderId::Helmcode { + detail.dashboard_url = Some( + codexbar::providers::helmcode::dashboard_url_for_organization( + snapshot.account_organization.as_deref(), + ) + .to_string(), + ); + } detail.source_label = if snapshot.source_label.is_empty() { None } else { @@ -165,13 +186,15 @@ pub fn get_provider_detail( super::usage_item_descriptors(Some(&snapshot), &settings, parsed_provider_id); detail.session = Some(snapshot.primary.clone()); detail.weekly = snapshot.secondary.clone(); - detail.model_specific = snapshot.model_specific.clone(); + detail.primary_label = snapshot.primary_label.clone(); + detail.secondary_label = snapshot.secondary_label.clone(); detail.tertiary = snapshot.tertiary.clone(); detail.extra_rate_windows = snapshot.extra_rate_windows.clone(); detail.inventory = snapshot.inventory.clone(); detail.display_details = snapshot.display_details.clone(); detail.cost = snapshot.cost.clone(); detail.pace = snapshot.pace.clone(); + detail.open_ai_api_usage = snapshot.open_ai_api_usage.clone(); } detail.last_error = snapshot.error.clone(); detail.error_state = Some(snapshot.error_state); diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs index 80675e1eca..a92aa1bdc4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs @@ -87,7 +87,7 @@ fn provider_cache_can_skip_refresh( .iter() .any(|snapshot| snapshot.provider_id == id.cli_name()) }); - if !force && crate::proof_harness::seed_usage_json_active() && cache_has_all { + if !force && state.provider_cache_seeded && cache_has_all { return true; } !force @@ -125,6 +125,38 @@ pub(super) fn complete_provider_refresh( mod tests { use super::*; + #[test] + fn validated_seed_pins_only_complete_nonforced_cache() { + let mut state = AppState::new(); + state.provider_cache.push( + crate::proof_harness::parse_seed_usage_snapshot( + r#"{"providerId":"codex","primary":{"usedPercent":25.0}}"#, + ) + .unwrap(), + ); + assert!(!provider_cache_can_skip_refresh( + &state, + false, + &[ProviderId::Codex] + )); + state.provider_cache_seeded = true; + assert!(provider_cache_can_skip_refresh( + &state, + false, + &[ProviderId::Codex] + )); + assert!(!provider_cache_can_skip_refresh( + &state, + true, + &[ProviderId::Codex] + )); + assert!(!provider_cache_can_skip_refresh( + &state, + false, + &[ProviderId::Codex, ProviderId::Claude] + )); + } + #[test] fn stale_inputs_cannot_reserve_a_new_generation_after_invalidation() { let mut state = AppState::new(); 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 f9344fcfba..07d0a70c01 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -226,6 +226,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::Replicate, "helmcode" => ProviderId::Helmcode, "typesafe" => ProviderId::TypeSafe, + "hyper" => ProviderId::Hyper, _ => return None, }) } @@ -272,6 +273,7 @@ fn region_provider(provider_id: &str) -> Option { "alibabatokenplan" => ProviderId::AlibabaTokenPlan, "zai" => ProviderId::Zai, "minimax" => ProviderId::MiniMax, + "kimi" => ProviderId::Kimi, _ => return None, }) } @@ -284,6 +286,10 @@ pub(crate) fn provider_region_lookup(settings: &Settings, provider_id: &str) -> )) .settings_value() .to_string() + } else if id == codexbar::core::ProviderId::Kimi { + codexbar::providers::KimiRegion::from_settings(Some(settings.api_region(id))) + .settings_value() + .to_string() } else { settings.api_region(id).to_string() } @@ -329,6 +335,7 @@ fn workspace_provider(provider_id: &str) -> Option { "xai" => ProviderId::Xai, "v0" => ProviderId::V0, "helmcode" => ProviderId::Helmcode, + "gitkraken" => ProviderId::GitKraken, _ => return None, }) } @@ -384,7 +391,7 @@ fn litellm_workspace_change_allowed( mod tests { use codexbar::core::ProviderId; - use super::{litellm_workspace_change_allowed, workspace_provider}; + use super::{gateway_provider, litellm_workspace_change_allowed, workspace_provider}; #[test] fn maps_opencode_go_workspace_provider() { @@ -394,6 +401,51 @@ mod tests { ); } + #[test] + fn fetch_context_carries_saved_gateway_urls_for_every_gateway_provider() { + use codexbar::settings::{ApiKeys, ManualCookies, Settings}; + use std::collections::HashMap; + + let mut settings = Settings::default(); + for (id, url) in [ + (ProviderId::Wayfinder, "http://localhost:8787"), + (ProviderId::Bifrost, "https://bifrost.example.com"), + (ProviderId::Aixy, "https://aixy.example.com/prefix"), + ] { + settings.set_gateway_url(id, url); + let ctx = super::super::providers::build_fetch_context( + id, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.gateway_url.as_deref(), Some(url), "{id:?}"); + } + + let ctx = super::super::providers::build_fetch_context( + ProviderId::Codex, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.gateway_url, None); + } + + #[test] + fn maps_gitkraken_organization_provider() { + assert_eq!(workspace_provider("gitkraken"), Some(ProviderId::GitKraken)); + } + + #[test] + fn gateway_provider_exposes_gateway_providers_only() { + assert_eq!(gateway_provider("wayfinder"), Some(ProviderId::Wayfinder)); + assert_eq!(gateway_provider("bifrost"), Some(ProviderId::Bifrost)); + assert_eq!(gateway_provider("aixy"), Some(ProviderId::Aixy)); + assert_eq!(gateway_provider("codex"), None); + } + #[test] fn litellm_endpoint_change_requires_reentering_saved_key() { assert!( @@ -445,7 +497,19 @@ pub fn get_provider_workspace_id(provider_id: String) -> Result, } fn gateway_provider(provider_id: &str) -> Option { - (provider_id == "wayfinder").then_some(codexbar::core::ProviderId::Wayfinder) + match provider_id { + "wayfinder" => Some(codexbar::core::ProviderId::Wayfinder), + "bifrost" => Some(codexbar::core::ProviderId::Bifrost), + "aixy" => Some(codexbar::core::ProviderId::Aixy), + _ => None, + } +} + +#[tauri::command] +pub fn get_provider_gateway_url(provider_id: String) -> Result { + let id = gateway_provider(&provider_id) + .ok_or_else(|| format!("Provider '{provider_id}' does not expose a gateway URL"))?; + Ok(Settings::load().gateway_url(id).to_string()) } #[tauri::command] @@ -453,8 +517,21 @@ pub fn set_provider_gateway_url(provider_id: String, gateway_url: String) -> Res let id = gateway_provider(&provider_id) .ok_or_else(|| format!("Provider '{provider_id}' does not expose a gateway URL"))?; let gateway_url = gateway_url.trim(); - codexbar::providers::wayfinder::parse_gateway_url(gateway_url) - .map_err(|error| error.to_string())?; + match id { + codexbar::core::ProviderId::Wayfinder => { + codexbar::providers::wayfinder::parse_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } + codexbar::core::ProviderId::Bifrost => { + codexbar::providers::bifrost::validate_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } + codexbar::core::ProviderId::Aixy => { + codexbar::providers::aixy::validate_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } + _ => unreachable!("gateway_provider only returns gateway providers"), + } let mut settings = Settings::load(); settings.set_gateway_url(id, gateway_url.to_string()); @@ -781,6 +858,31 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option( + lang, + "auto", + "Prefer a signed-in Hyper browser session, then fall back to an API key.", + "", + None, + ), + cookie_option( + lang, + "manual", + "", + "Paste a Cookie header from hyper.charm.land.", + None, + ), + cookie_option( + lang, + "off", + "", + "", + Some("Use only the configured API key."), + ), + ], _ => Vec::new(), } } @@ -820,6 +922,14 @@ pub fn region_options_for(provider_id: &str) -> Vec { .to_string(), }, ], + "kimi" => codexbar::providers::KimiRegion::ALL + .iter() + .copied() + .map(|region| RegionOption { + value: region.settings_value().to_string(), + label: region.display_name().to_string(), + }) + .collect(), "alibabatokenplan" => codexbar::providers::AlibabaTokenPlanRegion::ALL .iter() .copied() diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b869..1bbd137e68 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -6,9 +6,12 @@ use super::warning_identity::WarningIdentity; use super::*; use chrono::{Local, Utc}; use codexbar::core::HookUsageWindow; +use codexbar::notifications::WarningScope; use serde::Serialize; use std::sync::Arc; +mod reset_backfill; + const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -39,6 +42,7 @@ pub(crate) fn invalidate_account_usage( state.is_refreshing = false; state.provider_refresh_started_at = None; state.transient_provider_failure_counts.remove(&id); + state.last_good_owners.remove(&id); state.auto_resume.clear_provider(id); state .provider_cache @@ -83,23 +87,58 @@ pub(crate) fn build_fetch_context( .and_then(|override_data| override_data.env_override.as_ref()); let active_token_api_key = active_token_env.and_then(|env| env.values().next().cloned()); let usage_source = SourceMode::parse(settings.usage_source(id)).unwrap_or_default(); + let token_account_kind = token_override.as_ref().map(|account| account.kind); // Selected token-account key overrides a stored provider apiKey (upstream #2271 / #1183). - let api_key = active_token_api_key.or(stored_api_key); + let api_key = active_token_api_key.clone().or(stored_api_key); let has_kimi_code_api_key = id == ProviderId::Kimi && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); let has_opencodego_api_key = id == ProviderId::OpenCodeGo && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); - let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = - if id.cookie_domain().is_none() { - let source_mode = if active_token_env.is_some() { - SourceMode::OAuth + let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = if id + .cookie_domain() + .is_none() + { + // The #725 wallet rule (#725 keeps Auto for wallet providers) folds into + // the account-source decision; other no-cookie providers still honor + // cookie_source below (the account-source pack's fix for the #619 merge). + let keeps_auto = + usage_source == SourceMode::Auto && provider.token_account_preserves_auto_source(); + // A wallet provider keeps its saved usage source even with a selected + // token account: rewriting it to OAuth would skip the wallet read + // (#725 / upstream base-source resolver). + if keeps_auto || active_token_env.is_none() { + // A wallet provider keeps its saved usage source even with a + // selected token account: rewriting it to OAuth would skip the + // wallet read (#725 / upstream base-source resolver). + if active_token_env.is_some() { + (usage_source, None, false) } else { - usage_source - }; - (source_mode, None, false) + match cookie_source { + // #433: an explicitly selected, non-empty Claude manual cookie is + // authoritative. Do not let an active OAuth token account silently + // replace it; this keeps tray refresh behavior aligned with diagnose, + // whose Claude Auto path tries the supplied Web cookie before OAuth. + "manual" + if provider.manual_cookie_precedes_token_account() + && stored_cookie + .as_deref() + .is_some_and(|cookie| !cookie.trim().is_empty()) => + { + (SourceMode::Web, stored_cookie.clone(), false) + } + "auto" | "browser" | "web" => (usage_source, None, false), + _ => (usage_source, None, false), + } + } } else { match cookie_source { + // Opt-in web providers keep their default credential lane + // unless the usage source is explicitly Web; a stored or + // browser cookie must not turn Auto into Web. + _ if provider.web_is_opt_in() && usage_source != SourceMode::Web => { + (usage_source, None, false) + } // #433: an explicitly selected, non-empty Claude manual cookie is // authoritative. Do not let an active OAuth token account silently // replace it; this keeps tray refresh behavior aligned with diagnose, @@ -113,6 +152,24 @@ pub(crate) fn build_fetch_context( (SourceMode::Web, stored_cookie.clone(), false) } _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), + // Charm Hyper: the cookie source only picks the session, and + // the usage source keeps routing. Off and an empty Manual + // source never import a browser session, while Auto keeps its + // API-key fallback. + "off" | "manual" if provider.cookie_source_scopes_session_only() => { + let cookie_header = if cookie_source == "manual" { + active_token_cookie.clone().or(stored_cookie) + } else { + None + }; + let source_mode = if provider.available_sources().contains(&usage_source) { + usage_source + } else { + SourceMode::Auto + }; + let cookie_missing = cookie_header.is_none(); + (source_mode, cookie_header, cookie_missing) + } "off" if provider_uses_oauth_without_cookies(id, usage_source) => { (SourceMode::OAuth, None, false) } @@ -127,7 +184,7 @@ pub(crate) fn build_fetch_context( "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), "off" => (SourceMode::Cli, None, false), "manual" => { - let cookie_header = active_token_cookie.or(stored_cookie); + let cookie_header = active_token_cookie.clone().or(stored_cookie); let fails_closed_without_cookie = cookie_header.is_none() && provider.manual_empty_cookie_policy() == ManualEmptyCookiePolicy::FailClosedWeb; @@ -159,28 +216,135 @@ pub(crate) fn build_fetch_context( "auto" | "browser" | "web" => { // Claude resolves its cached cookie and browser fallback inside // the provider; other providers retain the shell fallback. - let cookie_header = active_token_cookie.or(stored_cookie).or_else(|| { - if defer_provider_browser_cookie_lookup { - None - } else { - provider_cookie_domain(id, settings).and_then(|domain| { - codexbar::browser::cookies::get_cookie_header(domain) - .ok() - .filter(|h| !h.is_empty()) - }) - } - }); + let cookie_header = + active_token_cookie.clone().or(stored_cookie).or_else(|| { + if defer_provider_browser_cookie_lookup { + None + } else { + provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }) + } + }); (usage_source, cookie_header, false) } _ => (usage_source, stored_cookie, false), } - }; + } + } else { + match cookie_source { + // #433: an explicitly selected, non-empty Claude manual cookie is + // authoritative. Do not let an active OAuth token account silently + // replace it; this keeps tray refresh behavior aligned with diagnose, + // whose Claude Auto path tries the supplied Web cookie before OAuth. + "manual" + if provider.manual_cookie_precedes_token_account() + && stored_cookie + .as_deref() + .is_some_and(|cookie| !cookie.trim().is_empty()) => + { + (SourceMode::Web, stored_cookie.clone(), false) + } + _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), + // Opt-in web providers keep their default credential lane + // unless the usage source is explicitly Web; a stored or + // browser cookie must not turn Auto into Web. + _ if provider.web_is_opt_in() && usage_source != SourceMode::Web => { + (usage_source, None, false) + } + // Charm Hyper: the cookie source only picks the session, and + // the usage source keeps routing. Off and an empty Manual + // source never import a browser session, while Auto keeps its + // API-key fallback. + "off" | "manual" if provider.cookie_source_scopes_session_only() => { + let cookie_header = if cookie_source == "manual" { + active_token_cookie.clone().or(stored_cookie) + } else { + None + }; + let source_mode = if provider.available_sources().contains(&usage_source) { + usage_source + } else { + SourceMode::Auto + }; + let cookie_missing = cookie_header.is_none(); + (source_mode, cookie_header, cookie_missing) + } + "off" if provider_uses_oauth_without_cookies(id, usage_source) => { + (SourceMode::OAuth, None, false) + } + "off" + if (has_kimi_code_api_key || has_opencodego_api_key) + && usage_source == SourceMode::Auto => + { + (SourceMode::Auto, None, false) + } + // Droid/Factory: cookie-off must never scrape browser cookies. Map to + // Cli (API-only in the provider) so Auto does not fall through to web. + "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), + "off" => (SourceMode::Cli, None, false), + "manual" => { + let cookie_header = active_token_cookie.clone().or(stored_cookie); + let fails_closed_without_cookie = cookie_header.is_none() + && provider.manual_empty_cookie_policy() + == ManualEmptyCookiePolicy::FailClosedWeb; + let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key) + && usage_source == SourceMode::Auto + { + SourceMode::Auto + } else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source) { + // Grok Switch writes ~/.grok/auth.json. Leftover grok.com + // cookies must not force Web, or Weekly/notifications keep + // showing the previous browser account. + mode + } else if cookie_header.is_some() { + SourceMode::Web + } else if fails_closed_without_cookie { + // The provider owns this policy; Web with no header means + // it fails closed instead of importing a browser account + // the user did not select. + SourceMode::Web + } else if provider_uses_oauth_without_cookies(id, usage_source) { + SourceMode::OAuth + } else { + SourceMode::Cli + }; + (source_mode, cookie_header, fails_closed_without_cookie) + } + // `browser` is accepted as a legacy alias from older settings. + "auto" | "browser" | "web" => { + // Claude resolves its cached cookie and browser fallback inside + // the provider; other providers retain the shell fallback. + let cookie_header = active_token_cookie.clone().or(stored_cookie).or_else(|| { + if defer_provider_browser_cookie_lookup { + None + } else { + provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }) + } + }); + (usage_source, cookie_header, false) + } + _ => (usage_source, stored_cookie, false), + } + }; // Cookie-web providers (Cursor, OpenCode, …) reject SourceMode::Cli. The shell // historically mapped "manual + no cookie" to Cli, which surfaces as // "Source mode 'Cli' not supported". Remap to Web and try browser cookies - // unless the user explicitly disabled cookies ("off"). - if source_mode == SourceMode::Cli && cookie_source != "off" && !provider.supports_cli() { + // unless the user explicitly disabled cookies ("off"). Providers whose + // cookie source only scopes the session (Charm Hyper) own this contract in + // the provider, so the shell must not remap their source mode. + if source_mode == SourceMode::Cli + && cookie_source != "off" + && !provider.supports_cli() + && !provider.cookie_source_scopes_session_only() + { if cookie_header .as_deref() .map(str::trim) @@ -213,22 +377,60 @@ pub(crate) fn build_fetch_context( let workspace_id = settings.workspace_id(id).trim().to_string(); let api_region = settings.api_region(id).trim().to_string(); - let gateway_url = (id == ProviderId::Wayfinder && !settings.gateway_url(id).is_empty()) - .then(|| settings.gateway_url(id).to_string()); + // Every gateway-style provider (Wayfinder, Bifrost, Aixy) stores its base + // URL here; providers without one report an empty string. + let gateway_url = Some(settings.gateway_url(id)) + .filter(|url| !url.is_empty()) + .map(str::to_owned); // Local-first Auto providers (OpenCode Go) flip to web-first when a // token account or manual cookie source scopes the session to web creds. let auto_prefer_web = token_override.is_some() || cookie_source == "manual"; + // These upstream account types are explicit identity selections. Keep the + // provider's saved region/source settings intact, but project the selected + // credential into the route required by that account. + let (cookie_header, api_key) = match (id, token_account_kind, usage_source) { + (ProviderId::Kimi, Some(_), _) => (active_token_cookie.clone(), None), + (ProviderId::Doubao, Some(_), _) => (None, active_token_api_key.clone()), + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::ApiKey), + SourceMode::Auto, + ) => (None, active_token_api_key.clone()), + (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey), _) => { + (cookie_header, api_key) + } + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::Cookie), + SourceMode::Auto, + ) => (active_token_cookie.clone(), api_key), + _ => (cookie_header, api_key), + }; + let source_mode = token_override + .as_ref() + .and_then(|account| account.effective_source_mode(usage_source)) + .unwrap_or(source_mode); + let token_account_isolated = token_override.is_some() + && matches!( + id, + ProviderId::Kimi | ProviderId::Doubao | ProviderId::OpenCodeGo + ); + FetchContext { source_mode, manual_cookie_header: cookie_header, manual_cookie_missing: fails_closed_without_cookie, api_key, + token_account_kind, + token_account_isolated, workspace_id: (!workspace_id.is_empty()).then_some(workspace_id), seat_credit_entitlement: settings.seat_credit_entitlement(id), api_region: (!api_region.is_empty()).then_some(api_region), gateway_url, - auto_prefer_web, + auto_prefer_web: auto_prefer_web + && !(id == ProviderId::OpenCodeGo + && token_account_kind == Some(codexbar::core::TokenAccountKind::ApiKey)), ..FetchContext::default() } } @@ -334,6 +536,9 @@ pub(crate) fn invalidate_provider_refresh_and_prune_disabled( guard .transient_provider_failure_counts .retain(|id, _| enabled_ids.contains(id)); + guard + .last_good_owners + .retain(|id, _| enabled_ids.contains(id)); guard .provider_cache_updated_at_by_provider .retain(|id, _| enabled_ids.contains(id)); @@ -565,7 +770,7 @@ async fn refresh_provider( token_account_id: Option, hooks_enabled: bool, ) { - let (snapshot, account_identity, failure_policy) = + let (snapshot, account_identity, retention) = fetch_provider_snapshot(id, ctx, token_account_id).await; let fresh_snapshot = snapshot.error.is_none(); @@ -584,8 +789,12 @@ async fn refresh_provider( &mut guard, id, snapshot, - failure_policy, + retention.policy, + &retention.failure_ownership, ); + if fresh_snapshot { + record_last_good_owner(&mut guard, id, retention.fresh_owner); + } // F6 (upstream 0.48.0): backfill missing reset timestamps from the // cached snapshot before persisting and publishing. let cached = guard @@ -594,7 +803,7 @@ async fn refresh_provider( .find(|c| c.provider_id == snapshot.provider_id && c.error.is_none()) .cloned(); let mut snapshot = snapshot; - codex_reset_backfill(&mut snapshot, cached.as_ref()); + reset_backfill::codex_reset_backfill(&mut snapshot, cached.as_ref()); upsert_provider_cache(&mut guard.provider_cache, snapshot.clone()); if fresh_snapshot { guard @@ -678,82 +887,6 @@ fn dispatch_usage_updated_hook( ); } -/// F6 (upstream 0.48.0 UsageStore+CodexResetBackfill): backfill missing -/// `resets_at` / `reset_description` on fresh Codex windows from the cached -/// lane data when the cached reset is still future. z.ai five-hour cached -/// resets use the same plausibility bound as the provider parser, so an -/// impossible rejected reset cannot be restored from the cache. Fresh -/// `used_percent` is untouched; only reset metadata is backfilled. -/// -/// This remains provider-scoped by design (upstream: "Provider-specific by -/// design"): only Codex and z.ai carry the relevant bounded reset semantics. -/// -/// Applies to the bridge snapshot before publishing so every surface (tray, -/// CLI, frontend) sees the backfilled reset instead of a missing one. -pub(super) fn codex_reset_backfill( - snapshot: &mut ProviderUsageSnapshot, - cached: Option<&ProviderUsageSnapshot>, -) { - let Some(cached) = cached else { return }; - if !matches!(snapshot.provider_id.as_str(), "codex" | "zai") { - return; - } - - // Backfill each slot from the corresponding cached slot. - backfill_slot_window( - &snapshot.provider_id, - &mut snapshot.primary, - &cached.primary, - ); - if let (Some(fresh), Some(cached_sec)) = (&mut snapshot.secondary, &cached.secondary) { - backfill_slot_window(&snapshot.provider_id, fresh, cached_sec); - } - // Tertiary (monthly/other): the Codex bridge doesn't normally populate this, - // but the slot exists for forward-compat. Backfill when available. - if let (Some(fresh), Some(cached_ter)) = (&mut snapshot.tertiary, &cached.tertiary) { - backfill_slot_window(&snapshot.provider_id, fresh, cached_ter); - } -} - -/// Backfill `resets_at` and `reset_description` on a fresh window from the -/// cached window whose reset is still in the future. `used_percent` is never -/// overwritten (upstream: "fresh used_percent untouched"). -fn backfill_slot_window( - provider_id: &str, - fresh: &mut bridge::RateWindowSnapshot, - cached: &bridge::RateWindowSnapshot, -) { - if fresh.resets_at.is_some() { - return; - } - let Some(cached_reset) = &cached.resets_at else { - return; - }; - // Only backfill when the cached reset is still future — a stale reset is - // worse than a missing one. - let Ok(cached_dt) = chrono::DateTime::parse_from_rfc3339(cached_reset) else { - return; - }; - let now = chrono::Utc::now(); - if cached_dt <= now { - return; - } - // A missing z.ai five-hour reset can mean the provider rejected an - // impossible future timestamp. Do not let equally impossible cached - // evidence undo that rejection, but preserve a plausible cached reset. - if provider_id == "zai" - && fresh.window_minutes == Some(300) - && cached_dt > now + chrono::Duration::minutes(5 * 60 + 1) - { - return; - } - fresh.resets_at = Some(cached_reset.clone()); - fresh.reset_description = fresh - .reset_description - .clone() - .or_else(|| cached.reset_description.clone()); -} - #[cfg(test)] pub(super) fn preserve_last_good_transient_failure( guard: &mut AppState, @@ -762,7 +895,30 @@ pub(super) fn preserve_last_good_transient_failure( error: &codexbar::core::ProviderError, ) -> ProviderUsageSnapshot { let policy = instantiate_provider(id).last_good_failure_policy_for_error(error); - preserve_last_good_transient_failure_with_policy(guard, id, snapshot, Some(policy)) + preserve_last_good_transient_failure_with_policy( + guard, + id, + snapshot, + Some(policy), + &error.failure_ownership(), + ) +} + +/// Remember which live session produced the fresh snapshot now cached for +/// `id`, or forget any earlier owner when the fresh snapshot has none. +pub(super) fn record_last_good_owner( + guard: &mut AppState, + id: ProviderId, + owner: Option, +) { + match owner { + Some(owner) => { + guard.last_good_owners.insert(id, owner); + } + None => { + guard.last_good_owners.remove(&id); + } + } } fn preserve_last_good_transient_failure_with_policy( @@ -770,6 +926,7 @@ fn preserve_last_good_transient_failure_with_policy( id: ProviderId, snapshot: ProviderUsageSnapshot, policy: Option, + ownership: &codexbar::core::FailureOwnership, ) -> ProviderUsageSnapshot { let Some(error) = snapshot.error.as_deref() else { guard.transient_provider_failure_counts.remove(&id); @@ -781,6 +938,12 @@ fn preserve_last_good_transient_failure_with_policy( guard.transient_provider_failure_counts.remove(&id); return snapshot; } + // A failure tied to a live session may keep only a snapshot that the same + // session produced. Anything else shows the error. + if !ownership.allows_retention(guard.last_good_owners.get(&id)) { + guard.transient_provider_failure_counts.remove(&id); + return snapshot; + } let Some(mut previous) = guard .provider_cache @@ -841,20 +1004,28 @@ fn preserve_last_good_transient_failure_with_policy( } } +/// What a refresh tells the shell about keeping or replacing the last good +/// snapshot. +#[derive(Default)] +struct RefreshRetention { + /// Failure handling when a prior good snapshot exists. + policy: Option, + /// Session the failed request belonged to, when the provider can tell. + failure_ownership: codexbar::core::FailureOwnership, + /// Session that produced a fresh snapshot. + fresh_owner: Option, +} + async fn fetch_provider_snapshot( id: ProviderId, ctx: FetchContext, token_account_id: Option, -) -> ( - ProviderUsageSnapshot, - Option, - Option, -) { +) -> (ProviderUsageSnapshot, Option, RefreshRetention) { let provider = instantiate_provider(id); let metadata = provider.metadata().clone(); let started = std::time::Instant::now(); - let (mut snapshot, account_identity, failure_policy) = + let (mut snapshot, account_identity, retention) = match tokio::time::timeout(provider_fetch_timeout(id, &ctx), provider.fetch_usage(&ctx)) .await { @@ -868,7 +1039,10 @@ async fn fetch_provider_snapshot( token_account_id, ), account_identity, - None, + RefreshRetention { + fresh_owner: result.last_good_owner.clone(), + ..RefreshRetention::default() + }, ) } Ok(Err(e)) => { @@ -881,7 +1055,11 @@ async fn fetch_provider_snapshot( provider.error_state_kind(&e), ), None, - Some(policy), + RefreshRetention { + policy: Some(policy), + failure_ownership: e.failure_ownership(), + fresh_owner: None, + }, ) } Err(_) => { @@ -895,13 +1073,17 @@ async fn fetch_provider_snapshot( provider.error_state_kind(&error), ), None, - Some(policy), + RefreshRetention { + policy: Some(policy), + failure_ownership: error.failure_ownership(), + fresh_owner: None, + }, ) } }; record_provider_fetch_duration(id, &mut snapshot, started); - (snapshot, account_identity, failure_policy) + (snapshot, account_identity, retention) } fn record_provider_fetch_duration( @@ -976,12 +1158,24 @@ fn notify_usage_thresholds( snapshot.account_organization.as_deref(), token_account_id, ); + // Hooks keep their own per-source baselines (edge-triggered, first sample + // never fires), so they stay on the source key; only toast dedupe below + // bridges account-identity gaps. let account = warning_identity.threshold_key(); + let scope = warning_identity.gap_scope(); // Skip all session consumers for synthetic/no-session // placeholders (e.g. Claude OAuth five_hour: null). + let session_account = resolve_toast_account( + &mut guard.notification_manager, + provider, + &scope, + "session", + &snapshot.primary, + settings, + ); if guard.notification_manager.check_session_lane( provider, - &account, + &session_account, snapshot.primary.used_percent, snapshot.primary.is_informational, settings, @@ -997,9 +1191,17 @@ fn notify_usage_thresholds( if let Some(weekly) = &snapshot.secondary && !weekly.is_informational { + let weekly_account = resolve_toast_account( + &mut guard.notification_manager, + provider, + &scope, + "weekly", + weekly, + settings, + ); guard.notification_manager.check_and_notify( provider, - &account, + &weekly_account, "weekly", weekly.used_percent, settings, @@ -1024,6 +1226,34 @@ fn notify_usage_thresholds( } } +/// Account key a toast lane is deduped under (see `NotificationManager::resolve_warning_account`). +/// Informational placeholders are not observed, matching `check_session_lane`. +fn resolve_toast_account( + manager: &mut codexbar::notifications::NotificationManager, + provider: ProviderId, + scope: &WarningScope, + window: &str, + lane: &RateWindowSnapshot, + settings: &Settings, +) -> String { + if lane.is_informational { + return scope.key().to_string(); + } + let resets_at = lane + .resets_at + .as_deref() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .map(|date| date.with_timezone(&chrono::Utc)); + manager.resolve_warning_account( + provider, + scope, + window, + lane.used_percent, + resets_at, + settings, + ) +} + fn dispatch_quota_hooks( settings: &Settings, provider: ProviderId, @@ -1301,12 +1531,10 @@ mod predictive_warning_tests { } } } - #[cfg(test)] mod reset_backfill_tests { use super::*; use crate::commands::bridge::{ProviderUsageSnapshot, RateWindowSnapshot}; - fn win(used: f64, resets_at: Option<&str>) -> RateWindowSnapshot { RateWindowSnapshot { used_percent: used, @@ -1320,9 +1548,10 @@ mod reset_backfill_tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, + monthly_limit_block: None, } } - fn codex_snapshot(primary: RateWindowSnapshot) -> ProviderUsageSnapshot { ProviderUsageSnapshot { provider_id: "codex".into(), @@ -1351,35 +1580,34 @@ mod reset_backfill_tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + open_ai_api_usage: None, session_equivalent_forecast: None, + quota_burndown: None, } } - #[test] fn f6_backfills_future_cached_reset() { // Cached has a future resets_at; fresh has none → backfilled. let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); let cached = codex_snapshot(win(50.0, Some(&future))); let mut fresh = codex_snapshot(win(30.0, None)); - codex_reset_backfill(&mut fresh, Some(&cached)); + reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); assert_eq!(fresh.primary.resets_at.as_deref(), Some(future.as_str())); // used_percent is NOT overwritten. assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); } - #[test] fn f6_does_not_backfill_stale_cached_reset() { // Cached reset is in the past → not backfilled. let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339(); let cached = codex_snapshot(win(50.0, Some(&past))); let mut fresh = codex_snapshot(win(30.0, None)); - codex_reset_backfill(&mut fresh, Some(&cached)); + reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); assert!( fresh.primary.resets_at.is_none(), "stale reset not backfilled" ); } - #[test] fn f6_does_not_overwrite_existing_resets_at() { // Fresh already has resets_at → cached not applied. @@ -1387,10 +1615,9 @@ mod reset_backfill_tests { let future2 = (chrono::Utc::now() + chrono::Duration::hours(5)).to_rfc3339(); let cached = codex_snapshot(win(50.0, Some(&future2))); let mut fresh = codex_snapshot(win(30.0, Some(&future1))); - codex_reset_backfill(&mut fresh, Some(&cached)); + reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); assert_eq!(fresh.primary.resets_at.as_deref(), Some(future1.as_str())); } - #[test] fn f6_skips_non_codex_provider() { let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); @@ -1398,10 +1625,9 @@ mod reset_backfill_tests { cached.provider_id = "claude".into(); let mut fresh = codex_snapshot(win(30.0, None)); fresh.provider_id = "claude".into(); - codex_reset_backfill(&mut fresh, Some(&cached)); + reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); assert!(fresh.primary.resets_at.is_none(), "non-codex skip"); } - #[test] fn zai_five_hour_backfill_rejects_impossible_cached_reset() { for (offset, should_backfill) in [ @@ -1414,16 +1640,15 @@ mod reset_backfill_tests { let mut fresh = codex_snapshot(win(30.0, None)); fresh.provider_id = "zai".into(); fresh.primary.reset_description = Some("5-hour".into()); - codex_reset_backfill(&mut fresh, Some(&cached)); + reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); assert_eq!(fresh.primary.resets_at.is_some(), should_backfill); assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); } } - #[test] fn f6_skips_when_no_cached_snapshot() { let mut fresh = codex_snapshot(win(30.0, None)); - codex_reset_backfill(&mut fresh, None); + reset_backfill::codex_reset_backfill(&mut fresh, None); assert!(fresh.primary.resets_at.is_none()); } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs new file mode 100644 index 0000000000..1311db6344 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs @@ -0,0 +1,252 @@ +use crate::commands::bridge::{ProviderUsageSnapshot, RateWindowSnapshot}; + +/// F6 (upstream 0.48.0 UsageStore+CodexResetBackfill): backfill missing +/// `resets_at` / `reset_description` on fresh Codex windows from the cached +/// lane data when the cached reset is still future. z.ai five-hour cached +/// resets use the same plausibility bound as the provider parser, so an +/// impossible rejected reset cannot be restored from the cache. Fresh +/// `used_percent` is untouched; only reset metadata is backfilled. +/// +/// This remains provider-scoped by design (upstream: "Provider-specific by +/// design"): only Codex and z.ai carry the relevant bounded reset semantics. +/// +/// Applies to the bridge snapshot before publishing so every surface (tray, +/// CLI, frontend) sees the backfilled reset instead of a missing one. +pub(super) fn codex_reset_backfill( + snapshot: &mut ProviderUsageSnapshot, + cached: Option<&ProviderUsageSnapshot>, +) { + let Some(cached) = cached else { return }; + if !matches!(snapshot.provider_id.as_str(), "codex" | "zai") { + return; + } + // A subscription change starts a new quota baseline: reset times from the + // previous plan are not evidence for the new one. Unknown plans never block. + if snapshot.provider_id == "codex" && codex_plan_changed(cached, snapshot) { + return; + } + + // Backfill each slot from the corresponding cached slot. + backfill_slot_window( + &snapshot.provider_id, + &mut snapshot.primary, + &cached.primary, + ); + if let (Some(fresh), Some(cached_sec)) = (&mut snapshot.secondary, &cached.secondary) { + backfill_slot_window(&snapshot.provider_id, fresh, cached_sec); + } + // Codex rarely populates tertiary windows, but keep this forward-compatible. + if let (Some(fresh), Some(cached_ter)) = (&mut snapshot.tertiary, &cached.tertiary) { + backfill_slot_window(&snapshot.provider_id, fresh, cached_ter); + } +} + +/// True only when both snapshots report a known plan and the plans differ. +fn codex_plan_changed(cached: &ProviderUsageSnapshot, fresh: &ProviderUsageSnapshot) -> bool { + let normalize = |plan: &Option| { + plan.as_deref() + .map(str::trim) + .filter(|plan| !plan.is_empty()) + .map(str::to_lowercase) + }; + matches!( + (normalize(&cached.plan_name), normalize(&fresh.plan_name)), + (Some(cached), Some(fresh)) if cached != fresh + ) +} + +/// Backfill `resets_at` and `reset_description` on a fresh window from the +/// cached window whose reset is still in the future. `used_percent` is never +/// overwritten (upstream: "fresh used_percent untouched"). +fn backfill_slot_window( + provider_id: &str, + fresh: &mut RateWindowSnapshot, + cached: &RateWindowSnapshot, +) { + if fresh.resets_at.is_some() { + return; + } + let Some(cached_reset) = &cached.resets_at else { + return; + }; + // A stale reset is worse than a missing one. + let Ok(cached_dt) = chrono::DateTime::parse_from_rfc3339(cached_reset) else { + return; + }; + let now = chrono::Utc::now(); + if cached_dt <= now { + return; + } + // A missing z.ai reset can represent a rejected timestamp; do not restore + // equally implausible cached evidence. + if provider_id == "zai" + && fresh.window_minutes == Some(300) + && cached_dt > now + chrono::Duration::minutes(5 * 60 + 1) + { + return; + } + fresh.resets_at = Some(cached_reset.clone()); + fresh.reset_description = fresh + .reset_description + .clone() + .or_else(|| cached.reset_description.clone()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::bridge::RateWindowSnapshot; + + fn win(used: f64, resets_at: Option<&str>) -> RateWindowSnapshot { + RateWindowSnapshot { + used_percent: used, + remaining_percent: 100.0 - used, + window_minutes: Some(300), + resets_at: resets_at.map(String::from), + reset_description: None, + is_exhausted: false, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, + } + } + + fn codex_snapshot(primary: RateWindowSnapshot) -> ProviderUsageSnapshot { + ProviderUsageSnapshot { + provider_id: "codex".into(), + display_name: "Codex".into(), + primary, + primary_label: None, + secondary: None, + secondary_label: None, + model_specific: None, + tertiary: None, + tertiary_label: None, + extra_rate_windows: Vec::new(), + inventory: Vec::new(), + display_details: Vec::new(), + cost: None, + plan_name: None, + account_email: None, + subscription: None, + source_label: String::new(), + has_successful_claude_cli_quota: false, + updated_at: "2026-01-01T00:00:00Z".into(), + error: None, + error_state: codexbar::core::ProviderStateKind::Ready, + pace: None, + account_organization: None, + tray_status_label: None, + fetch_duration_ms: None, + wayfinder_usage: None, + quota_burndown: None, + open_ai_api_usage: None, + session_equivalent_forecast: None, + } + } + + #[test] + fn f6_backfills_future_cached_reset() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let cached = codex_snapshot(win(50.0, Some(&future))); + let mut fresh = codex_snapshot(win(30.0, None)); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.as_deref(), Some(future.as_str())); + assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); + } + + #[test] + fn f6_does_not_backfill_stale_cached_reset() { + let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339(); + let cached = codex_snapshot(win(50.0, Some(&past))); + let mut fresh = codex_snapshot(win(30.0, None)); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!( + fresh.primary.resets_at.is_none(), + "stale reset not backfilled" + ); + } + + #[test] + fn f6_does_not_overwrite_existing_resets_at() { + let future1 = (chrono::Utc::now() + chrono::Duration::hours(3)).to_rfc3339(); + let future2 = (chrono::Utc::now() + chrono::Duration::hours(5)).to_rfc3339(); + let cached = codex_snapshot(win(50.0, Some(&future2))); + let mut fresh = codex_snapshot(win(30.0, Some(&future1))); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.as_deref(), Some(future1.as_str())); + } + + #[test] + fn codex_backfill_skips_when_known_plans_differ() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let mut cached = codex_snapshot(win(80.0, Some(&future))); + cached.plan_name = Some("Plus".into()); + let mut fresh = codex_snapshot(win(5.0, None)); + fresh.plan_name = Some("Pro".into()); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!(fresh.primary.resets_at.is_none(), "plan change baseline"); + } + + #[test] + fn codex_backfill_keeps_baseline_for_same_or_unknown_plan() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + for (cached_plan, fresh_plan) in [ + (Some("Plus"), Some(" plus ")), + (Some("Plus"), None), + (None, Some("Pro")), + (Some("Plus"), Some(" ")), + ] { + let mut cached = codex_snapshot(win(80.0, Some(&future))); + cached.plan_name = cached_plan.map(str::to_string); + let mut fresh = codex_snapshot(win(5.0, None)); + fresh.plan_name = fresh_plan.map(str::to_string); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!( + fresh.primary.resets_at.as_deref(), + Some(future.as_str()), + "{cached_plan:?} -> {fresh_plan:?}" + ); + } + } + + #[test] + fn f6_skips_non_codex_provider() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let mut cached = codex_snapshot(win(50.0, Some(&future))); + cached.provider_id = "claude".into(); + let mut fresh = codex_snapshot(win(30.0, None)); + fresh.provider_id = "claude".into(); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!(fresh.primary.resets_at.is_none(), "non-codex skip"); + } + + #[test] + fn zai_five_hour_backfill_rejects_impossible_cached_reset() { + for (offset, should_backfill) in [ + (chrono::Duration::hours(1), true), + (chrono::Duration::hours(10), false), + ] { + let future = (chrono::Utc::now() + offset).to_rfc3339(); + let mut cached = codex_snapshot(win(50.0, Some(&future))); + cached.provider_id = "zai".into(); + let mut fresh = codex_snapshot(win(30.0, None)); + fresh.provider_id = "zai".into(); + fresh.primary.reset_description = Some("5-hour".into()); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.is_some(), should_backfill); + assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); + } + } + + #[test] + fn f6_skips_when_no_cached_snapshot() { + let mut fresh = codex_snapshot(win(30.0, None)); + codex_reset_backfill(&mut fresh, None); + assert!(fresh.primary.resets_at.is_none()); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs new file mode 100644 index 0000000000..d5cf6229d1 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs @@ -0,0 +1,120 @@ +//! Fetch-context routing for providers whose cookie source only scopes the +//! browser session (Charm Hyper): the usage source keeps routing, and off or +//! an empty manual source never hands the provider a browser session. + +use std::collections::HashMap; + +use codexbar::core::{FetchContext, ProviderId, SourceMode}; +use codexbar::settings::{ApiKeys, Language, ManualCookies, Settings}; + +fn hyper_context( + cookie_source: Option<&str>, + usage_source: &str, + cookie: Option<&str>, +) -> FetchContext { + let mut settings = Settings::default(); + if let Some(cookie_source) = cookie_source { + settings.set_cookie_source(ProviderId::Hyper, cookie_source); + } + settings.set_usage_source(ProviderId::Hyper, usage_source); + let mut cookies = ManualCookies::default(); + if let Some(cookie) = cookie { + cookies.set("hyper", cookie); + } + let mut api_keys = ApiKeys::default(); + api_keys.set("hyper", "fixture-key", None); + super::build_fetch_context( + ProviderId::Hyper, + &settings, + &cookies, + &api_keys, + &HashMap::new(), + ) +} + +#[test] +fn hyper_defaults_to_automatic_session_with_the_provider_owning_the_browser_read() { + // No stored cookie: the shell must not read the browser itself, so the + // API source never touches cookies and Auto/Web import inside the provider. + for (usage_source, expected) in [ + ("auto", SourceMode::Auto), + ("web", SourceMode::Web), + ("oauth", SourceMode::OAuth), + ] { + let ctx = hyper_context(None, usage_source, None); + assert_eq!(ctx.source_mode, expected, "{usage_source}"); + assert!(ctx.manual_cookie_header.is_none()); + assert!(!ctx.manual_cookie_missing); + assert_eq!(ctx.api_key.as_deref(), Some("fixture-key")); + } +} + +#[test] +fn hyper_manual_cookie_keeps_the_usage_source() { + for (usage_source, expected) in [ + ("auto", SourceMode::Auto), + ("web", SourceMode::Web), + ("oauth", SourceMode::OAuth), + ] { + let ctx = hyper_context(Some("manual"), usage_source, Some("session=fixture")); + assert_eq!(ctx.source_mode, expected, "{usage_source}"); + assert_eq!(ctx.manual_cookie_header.as_deref(), Some("session=fixture")); + assert!(!ctx.manual_cookie_missing); + } +} + +#[test] +fn hyper_empty_manual_source_falls_back_to_the_key_without_a_browser_session() { + let ctx = hyper_context(Some("manual"), "auto", None); + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); + assert_eq!(ctx.api_key.as_deref(), Some("fixture-key")); +} + +#[test] +fn hyper_cookie_off_ignores_a_stored_cookie_and_keeps_the_usage_source() { + for (usage_source, expected) in [ + ("auto", SourceMode::Auto), + ("web", SourceMode::Web), + ("oauth", SourceMode::OAuth), + ] { + let ctx = hyper_context(Some("off"), usage_source, Some("session=fixture")); + assert_eq!(ctx.source_mode, expected, "{usage_source}"); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); + } +} + +#[test] +fn hyper_unsupported_usage_source_routes_to_auto() { + for cookie_source in ["off", "manual"] { + let ctx = hyper_context(Some(cookie_source), "cli", None); + assert_eq!(ctx.source_mode, SourceMode::Auto, "{cookie_source}"); + assert!(ctx.manual_cookie_missing); + } +} + +#[test] +fn hyper_exposes_upstream_cookie_source_picker() { + let settings = Settings::default(); + assert_eq!( + super::provider_cookie_source_lookup(&settings, "hyper").as_deref(), + Some("auto") + ); + let options = super::cookie_source_options_for("hyper", Language::English); + let values: Vec<_> = options.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); + let descriptions: Vec<_> = options + .iter() + .map(|option| option.description.as_deref()) + .collect(); + assert_eq!( + descriptions, + vec![ + Some("Prefer a signed-in Hyper browser session, then fall back to an API key."), + Some("Paste a Cookie header from hyper.charm.land."), + Some("Use only the configured API key."), + ] + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index df0e6621a9..9d0348171b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -7,6 +7,7 @@ use super::*; #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct SettingsUpdate { + pub preferred_currency_code: Option, pub enabled_providers: Option>, pub refresh_interval_secs: Option, pub adaptive_refresh: Option, @@ -26,9 +27,12 @@ pub struct SettingsUpdate { pub predictive_pace_warning_enabled: Option, pub show_pace: Option, pub tray_icon_mode: Option, + pub stacked_tray_top_provider: Option, + pub stacked_tray_bottom_provider: Option, pub switcher_shows_icons: Option, pub menu_bar_shows_highest_usage: Option, pub menu_bar_shows_percent: Option, + pub menu_bar_color_pace: Option, pub show_as_used: Option, pub show_all_token_accounts_in_menu: Option, pub enable_animations: Option, @@ -85,6 +89,8 @@ pub struct SettingsUpdate { pub cost_summary_display_style: Option, pub open_codex_usage_logs_enabled: Option, pub hide_native_codex_cost_when_open_codex_present: Option, + /// History window: `rolling:N` (1..=365), `month-to-date`, or `all`. + pub cost_reporting_period: Option, } impl SettingsUpdate { @@ -103,6 +109,7 @@ impl SettingsUpdate { || self.low_power_mode_preference.is_some() || self.adaptive_refresh.is_some() || self.codex_custom_sessions_dirs.is_some() + || self.cost_reporting_period.is_some() || self.high_usage_threshold.is_some() || self.critical_usage_threshold.is_some() || self.provider_usage_thresholds.is_some() @@ -121,17 +128,22 @@ impl SettingsUpdate { fn refreshes_tray_presentation(&self) -> bool { self.tray_icon_mode.is_some() + || self.stacked_tray_top_provider.is_some() + || self.stacked_tray_bottom_provider.is_some() || self.switcher_shows_icons.is_some() || self.menu_bar_shows_highest_usage.is_some() || self.menu_bar_shows_percent.is_some() + || self.menu_bar_color_pace.is_some() || self.show_as_used.is_some() || self.reset_time_relative.is_some() || self.menu_bar_display_mode.is_some() || self.overview_layout.is_some() || self.provider_metrics.is_some() || self.provider_hidden_usage_item_ids.is_some() + || self.preferred_currency_code.is_some() || self.codex_spark_usage_visible.is_some() || self.copilot_seat_credit_entitlement.is_some() + || self.cost_reporting_period.is_some() || self.enabled_providers.is_some() || self.ui_language.is_some() } @@ -167,6 +179,13 @@ impl SettingsUpdate { if let Some(v) = self.refresh_all_providers_on_menu_open { settings.refresh_all_providers_on_menu_open = v; } + if let Some(v) = self + .cost_reporting_period + .as_deref() + .and_then(codexbar::cost_reporting_period::CostReportingPeriod::parse) + { + settings.cost_reporting_period = v; + } if let Some(v) = self.open_codex_usage_logs_enabled { settings.open_codex_usage_logs_enabled = v; } @@ -190,6 +209,12 @@ impl SettingsUpdate { { settings.tray_icon_mode = mode; } + if let Some(provider) = self.stacked_tray_top_provider.clone() { + settings.stacked_tray_top_provider = normalize_optional_provider_id(provider); + } + if let Some(provider) = self.stacked_tray_bottom_provider.clone() { + settings.stacked_tray_bottom_provider = normalize_optional_provider_id(provider); + } if let Some(v) = self.provider_metrics.clone() { apply_provider_metrics(settings, v); } @@ -200,6 +225,13 @@ impl SettingsUpdate { } fn apply_general_settings(self, settings: &mut Settings) -> Result { + if let Some(value) = self.preferred_currency_code.as_deref() { + let normalized = codexbar::currency::normalize_preferred_currency(value); + if !value.trim().eq_ignore_ascii_case("AUTO") && normalized == "AUTO" { + return Err(format!("Unsupported preferred currency: {value}")); + } + settings.preferred_currency_code = normalized; + } if let Some(v) = self.start_at_login { settings.set_start_at_login(v).map_err(|e| e.to_string())?; } @@ -258,6 +290,9 @@ impl SettingsUpdate { if let Some(v) = self.menu_bar_shows_percent { settings.menu_bar_shows_percent = v; } + if let Some(v) = self.menu_bar_color_pace { + settings.menu_bar_color_pace = v; + } if let Some(v) = self.show_all_token_accounts_in_menu { settings.show_all_token_accounts_in_menu = v; } @@ -417,6 +452,11 @@ impl SettingsUpdate { { return Err(format!("Invalid low power mode preference: {value}")); } + if let Some(value) = self.cost_reporting_period.as_deref() + && codexbar::cost_reporting_period::CostReportingPeriod::parse(value).is_none() + { + return Err(format!("Invalid cost reporting period: {value}")); + } if let Some(value) = self.copilot_seat_credit_entitlement { settings.set_seat_credit_entitlement(codexbar::core::ProviderId::Copilot, value)?; } @@ -482,10 +522,16 @@ fn parse_tray_icon_mode(s: &str) -> Option { match s { "single" => Some(TrayIconMode::Single), "perProvider" => Some(TrayIconMode::PerProvider), + "stacked" => Some(TrayIconMode::Stacked), _ => None, } } +fn normalize_optional_provider_id(value: String) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + fn parse_update_channel(s: &str) -> Option { match s { "stable" => Some(UpdateChannel::Stable), @@ -506,7 +552,8 @@ pub async fn update_settings( let mut settings = Settings::load(); let notify_float_bar = patch.notifies_float_bar(); let refresh_provider_data = patch.refreshes_provider_data(); - let clear_local_usage_cache = patch.codex_custom_sessions_dirs.is_some(); + let clear_local_usage_cache = + patch.codex_custom_sessions_dirs.is_some() || patch.cost_reporting_period.is_some(); let rebuild_tray_menu = patch.rebuilds_tray_menu(); let refresh_tray_presentation = patch.refreshes_tray_presentation(); let tray_promotion_changed = patch.changes_tray_promotion(); @@ -572,6 +619,25 @@ pub async fn update_settings( mod tests { use super::*; + #[test] + fn preferred_currency_patch_accepts_supported_codes_and_rejects_unknown_codes() { + let mut settings = Settings::default(); + SettingsUpdate { + preferred_currency_code: Some("try".to_string()), + ..SettingsUpdate::default() + } + .apply_to(&mut settings) + .expect("TRY is supported"); + assert_eq!(settings.preferred_currency_code, "TRY"); + + let result = SettingsUpdate { + preferred_currency_code: Some("BTC".to_string()), + ..SettingsUpdate::default() + } + .apply_to(&mut settings); + assert!(matches!(result, Err(error) if error.contains("Unsupported preferred currency"))); + } + #[test] fn only_data_affecting_settings_refresh_providers() { assert!( @@ -684,6 +750,27 @@ mod tests { assert_eq!(settings.seat_credit_entitlement(ProviderId::Copilot), None); } + #[test] + fn cost_reporting_period_update_is_validated_and_applied() { + use codexbar::cost_reporting_period::CostReportingPeriod; + + let mut settings = Settings::default(); + let bad: SettingsUpdate = + serde_json::from_str(r#"{"costReportingPeriod":"rolling:0"}"#).unwrap(); + let error = bad + .apply_to(&mut settings) + .expect_err("zero-day window must be rejected"); + assert_eq!(error, "Invalid cost reporting period: rolling:0"); + + let good: SettingsUpdate = + serde_json::from_str(r#"{"costReportingPeriod":"month-to-date"}"#).unwrap(); + good.apply_to(&mut settings).expect("valid period applies"); + assert_eq!( + settings.cost_reporting_period, + CostReportingPeriod::MonthToDate + ); + } + #[test] fn display_settings_that_affect_tray_trigger_presentation_refresh() { assert!( @@ -700,6 +787,63 @@ mod tests { } .refreshes_tray_presentation() ); + assert!( + SettingsUpdate { + menu_bar_color_pace: Some(true), + ..Default::default() + } + .refreshes_tray_presentation() + ); + assert!( + SettingsUpdate { + stacked_tray_top_provider: Some("claude".to_string()), + ..Default::default() + } + .refreshes_tray_presentation() + ); + } + + #[test] + fn apply_display_settings_updates_tray_pace_color() { + let mut settings = Settings::default(); + assert!(!settings.menu_bar_color_pace); + + SettingsUpdate { + menu_bar_color_pace: Some(true), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(settings.menu_bar_color_pace); + + SettingsUpdate { + menu_bar_color_pace: Some(false), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(!settings.menu_bar_color_pace); + } + + #[test] + fn stacked_tray_update_accepts_mode_and_clears_automatic_provider() { + let mut settings = Settings { + stacked_tray_top_provider: Some("codex".to_string()), + ..Settings::default() + }; + + SettingsUpdate { + tray_icon_mode: Some("stacked".to_string()), + stacked_tray_top_provider: Some(String::new()), + stacked_tray_bottom_provider: Some("claude".to_string()), + ..Default::default() + } + .apply_provider_settings(&mut settings); + + assert_eq!(settings.tray_icon_mode, TrayIconMode::Stacked); + assert_eq!(settings.stacked_tray_top_provider, None); + assert_eq!( + settings.stacked_tray_bottom_provider.as_deref(), + Some("claude") + ); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs b/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs index 4403ba7ad0..e083b05178 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs @@ -1,13 +1,16 @@ //! Upstream 0.53 Usage & Spend accounting bridge. +use codexbar::cost_reporting_period::CostReportingPeriod; use codexbar::cost_scanner::CostScanner; use codexbar::settings::Settings; -use codexbar::spend_contract::{SpendContract, build_local_spend_contract_from_summary}; +use codexbar::spend_contract::{SpendContract, build_contract_from_period_summary}; +/// `period` is a raw reporting period (`rolling:N`, `month-to-date`, `all`); +/// without a valid one the saved `cost_reporting_period` applies. #[tauri::command] pub async fn get_spend_contract( provider_id: String, - history_days: Option, + period: Option, include_open_codex: Option, ) -> Result { let provider = provider_id.trim().to_ascii_lowercase(); @@ -16,11 +19,16 @@ pub async fn get_spend_contract( "Spend contract is unavailable for provider: {provider}" )); } - let days = history_days.unwrap_or(30); let include_import = include_open_codex.unwrap_or(false) && provider == "codex"; + if include_import { + // Upstream 0.60.4: price imported OpenCodex rows from a fresh catalog. + codexbar::spend_contract::refresh_opencodex_pricing_if_needed().await; + } tauri::async_runtime::spawn_blocking(move || { - let history_days = if days == 0 { 365 } else { days.clamp(1, 365) }; - let scanner = CostScanner::new(history_days); + let settings = Settings::load(); + let period = + CostReportingPeriod::resolve_request(period.as_deref(), settings.cost_reporting_period); + let scanner = CostScanner::for_period(period); let summary = match provider.as_str() { "codex" => scanner.scan_codex(), "claude" => scanner.scan_claude(), @@ -28,10 +36,9 @@ pub async fn get_spend_contract( "opencodego" => scanner.scan_opencodego_with_cancel(None), _ => unreachable!(), }; - let settings = Settings::load(); - build_local_spend_contract_from_summary( + build_contract_from_period_summary( &provider, - history_days, + period, include_import, settings.hide_native_codex_cost_when_open_codex_present && provider == "codex", settings.hide_personal_info, diff --git a/apps/desktop-tauri/src-tauri/src/commands/surface.rs b/apps/desktop-tauri/src-tauri/src/commands/surface.rs index b2f4aeb4ee..c0ec61bdff 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/surface.rs @@ -1,4 +1,5 @@ use super::*; +use crate::shell::activation::Activation; // ── Surface-mode commands ──────────────────────────────────────────── @@ -11,8 +12,15 @@ pub fn set_surface_mode( let mode = SurfaceMode::parse(&mode).ok_or_else(|| format!("unknown surface mode: {mode}"))?; let target = validate_surface_target(mode, target)?; - crate::shell::transition_to_target(window.app_handle(), mode, target, None) - .map(|mode| mode.as_str().to_string()) + // The frontend only calls this from a click (e.g. a Settings tab). + crate::shell::transition_to_target( + window.app_handle(), + mode, + target, + None, + Activation::UserAction, + ) + .map(|mode| mode.as_str().to_string()) } #[tauri::command] @@ -66,7 +74,7 @@ pub async fn open_settings_window(app: tauri::AppHandle, tab: String) -> Result< /// on Windows. #[tauri::command] pub async fn open_flyout_window(app: tauri::AppHandle) -> Result<(), String> { - crate::shell::flyout_window::open_or_focus(&app, None) + crate::shell::flyout_window::open_or_focus(&app, None, Activation::UserAction) } /// Reveal the flyout window after the frontend's first layout pass. Called by @@ -75,6 +83,8 @@ pub async fn open_flyout_window(app: tauri::AppHandle) -> Result<(), String> { /// blank/backing frame. /// /// No-ops when the flyout window doesn't exist or no one-shot reveal is pending. +/// The window takes focus only as far as the pending reveal's activation +/// allows (see `shell::activation`). #[tauri::command] pub fn reveal_tray_panel_window( app: tauri::AppHandle, @@ -86,17 +96,16 @@ pub fn reveal_tray_panel_window( return Ok(()); }; let mut guard = state.lock().map_err(|e| e.to_string())?; - if !guard.take_pending_flyout_reveal() { + let Some(activation) = guard.take_pending_flyout_reveal() else { return Ok(()); - } + }; drop(guard); window.show().map_err(|e| e.to_string())?; state .lock() .map_err(|e| e.to_string())? .mark_tray_panel_shown(std::time::Instant::now()); - window.set_focus().map_err(|e| e.to_string())?; - Ok(()) + crate::shell::activation::apply(&window, activation) } #[tauri::command] diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 9ae17f57ce..4355089961 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -205,36 +205,9 @@ pub fn quit_app(app: tauri::AppHandle) { } fn dashboard_url_for_provider(provider_id: &str) -> Option { - if provider_id == ProviderId::MiniMax.cli_name() { - let settings = Settings::load(); - return Some( - codexbar::providers::MiniMaxProvider::dashboard_url_for_region(Some( - settings.api_region(ProviderId::MiniMax), - )), - ); - } - - // OpenRouter's Usage Dashboard is the Activity page. Resolve it from the - // provider metadata before the legacy API-key catalog entry, which still - // points at the credits settings page. - if provider_id == ProviderId::OpenRouter.cli_name() { - return instantiate_provider(ProviderId::OpenRouter) - .metadata() - .dashboard_url - .map(|s| s.to_string()); - } - - if let Some(url) = codexbar::settings::get_api_key_providers() - .into_iter() - .find(|p| p.id.cli_name() == provider_id) - .and_then(|p| p.dashboard_url.map(|s| s.to_string())) - { - return Some(url); - } - let id = ProviderId::from_cli_name(provider_id)?; - let provider = instantiate_provider(id); - provider.metadata().dashboard_url.map(|s| s.to_string()) + let settings = Settings::load(); + provider_dashboard_url(id, &settings) } fn status_page_url_for_provider(provider_id: &str) -> Option { @@ -244,10 +217,25 @@ fn status_page_url_for_provider(provider_id: &str) -> Option { } #[tauri::command] -pub fn open_provider_dashboard(provider_id: String) -> Result<(), String> { +pub fn open_provider_dashboard( + state: tauri::State<'_, Mutex>, + provider_id: String, +) -> Result<(), String> { let provider_id = canonical_provider_arg(&provider_id)?; - let url = dashboard_url_for_provider(&provider_id) - .ok_or_else(|| format!("No dashboard URL registered for provider '{provider_id}'"))?; + let url = if provider_id == ProviderId::Helmcode.cli_name() { + let organization = state.lock().ok().and_then(|guard| { + guard + .provider_cache + .iter() + .find(|snapshot| snapshot.provider_id == provider_id) + .and_then(|snapshot| snapshot.account_organization.clone()) + }); + codexbar::providers::helmcode::dashboard_url_for_organization(organization.as_deref()) + .to_string() + } else { + dashboard_url_for_provider(&provider_id) + .ok_or_else(|| format!("No dashboard URL registered for provider '{provider_id}'"))? + }; open_url_in_browser(&url) } @@ -441,7 +429,7 @@ mod tests { fn dashboard_url_resolves_from_codex_provider_metadata() { assert_eq!( dashboard_url_for_provider("codex").as_deref(), - Some("https://chatgpt.com/codex/settings/usage") + Some("https://chatgpt.com/codex/cloud/settings/analytics#usage") ); } diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 444e93da0a..e6d40f5a81 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -276,6 +276,20 @@ fn minimax_region_lookup_normalizes_legacy_china_value() { assert_eq!(provider_region_lookup(&s, "minimax").as_deref(), Some("cn")); } +#[test] +fn kimi_region_lookup_defaults_to_china_and_roundtrips_international() { + let mut settings = Settings::default(); + assert_eq!( + provider_region_lookup(&settings, "kimi").as_deref(), + Some("china") + ); + super::provider_region_set(&mut settings, "kimi", "international".to_string()).unwrap(); + assert_eq!( + provider_region_lookup(&settings, "kimi").as_deref(), + Some("international") + ); +} + #[test] fn minimax_cookie_domain_follows_selected_region() { let mut s = Settings::default(); @@ -464,6 +478,55 @@ fn fetch_context_grok_explicit_web_still_uses_manual_cookie() { ); } +#[test] +fn fetch_context_zed_default_and_stored_cookie_keep_editor_credential_lane() { + // Zed browser billing is opt-in: neither the default manual cookie source + // nor a stored cookie may turn Auto into Web. + let ctx = super::build_fetch_context( + ProviderId::Zed, + &Settings::default(), + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert!(ctx.manual_cookie_header.is_none()); + assert!(!ctx.manual_cookie_missing); + + let mut cookies = ManualCookies::default(); + cookies.set("zed", "zed.session=stored"); + let ctx = super::build_fetch_context( + ProviderId::Zed, + &Settings::default(), + &cookies, + &ApiKeys::default(), + &HashMap::new(), + ); + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert!(ctx.manual_cookie_header.is_none()); +} + +#[test] +fn fetch_context_zed_explicit_web_uses_stored_cookie() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Zed, "web"); + let mut cookies = ManualCookies::default(); + cookies.set("zed", "zed.session=stored"); + let ctx = super::build_fetch_context( + ProviderId::Zed, + &settings, + &cookies, + &ApiKeys::default(), + &HashMap::new(), + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("zed.session=stored") + ); +} + #[test] fn fetch_context_opencode_empty_manual_remaps_to_web() { let settings = Settings::default(); @@ -482,6 +545,139 @@ fn fetch_context_opencode_empty_manual_remaps_to_web() { assert_eq!(ctx.source_mode, SourceMode::Web); } +#[test] +fn kimi_selected_account_forces_web_and_keeps_saved_region() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Kimi, "oauth"); + settings.set_api_region(ProviderId::Kimi, "international"); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-kimi-session")); + accounts.insert(ProviderId::Kimi, data); + + let ctx = super::build_fetch_context( + ProviderId::Kimi, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("kimi-auth=selected-kimi-session") + ); + assert_eq!(ctx.api_key, None); + assert_eq!(ctx.api_region.as_deref(), Some("international")); + assert!(ctx.token_account_isolated); + assert_eq!(settings.usage_source(ProviderId::Kimi), "oauth"); + assert_eq!(settings.api_region(ProviderId::Kimi), "international"); +} + +#[test] +fn doubao_selected_account_forces_ark_api_and_ignores_saved_source() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Doubao, "cli"); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-ark-key")); + accounts.insert(ProviderId::Doubao, data); + + let ctx = super::build_fetch_context( + ProviderId::Doubao, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::OAuth); + assert_eq!(ctx.api_key.as_deref(), Some("selected-ark-key")); + assert!(ctx.token_account_isolated); +} + +#[test] +fn opencodego_selected_api_account_overrides_global_key_without_changing_explicit_source() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); + let mut keys = ApiKeys::default(); + keys.set("opencodego", "global-key", None); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-account-key")); + accounts.insert(ProviderId::OpenCodeGo, data); + + let ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert_eq!(ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(!ctx.auto_prefer_web); + assert!(ctx.token_account_isolated); + + for cookie_source in ["off", "manual"] { + settings.set_cookie_source(ProviderId::OpenCodeGo, cookie_source); + settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); + let auto_ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + assert_eq!(auto_ctx.source_mode, SourceMode::Auto); + assert_eq!(auto_ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(auto_ctx.manual_cookie_header.is_none()); + } + + for (saved_source, expected_source) in [("web", SourceMode::Web), ("cli", SourceMode::Cli)] { + settings.set_cookie_source(ProviderId::OpenCodeGo, "off"); + settings.set_usage_source(ProviderId::OpenCodeGo, saved_source); + let explicit_ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + assert_eq!(explicit_ctx.source_mode, expected_source); + } +} + +#[test] +fn opencodego_selected_cookie_account_uses_web_route() { + let settings = Settings::default(); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Web", "Cookie: session=selected-session")); + accounts.insert(ProviderId::OpenCodeGo, data); + + let ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("Cookie: session=selected-session") + ); + assert_eq!( + ctx.token_account_kind, + Some(codexbar::core::TokenAccountKind::Cookie) + ); + assert!(ctx.token_account_isolated); +} + #[test] fn fetch_context_replicate_empty_manual_fails_closed_without_browser_import() { let settings = Settings::default(); @@ -703,6 +899,40 @@ fn fetch_context_includes_minimax_region() { assert_eq!(ctx.api_region.as_deref(), Some("cn")); } +#[test] +fn provider_dashboard_url_uses_selected_regional_console() { + let mut settings = Settings::default(); + settings.set_api_region(ProviderId::MiniMax, "cn"); + settings.set_api_region(ProviderId::Kimi, "international"); + + assert_eq!( + super::provider_dashboard_url(ProviderId::MiniMax, &settings).as_deref(), + Some("https://platform.minimaxi.com/user-center/payment/coding-plan?cycle_type=3") + ); + assert_eq!( + super::provider_dashboard_url(ProviderId::Kimi, &settings).as_deref(), + Some("https://www.kimi.ai/code/console") + ); +} + +/// Provider metadata is the only source of the provider dashboard link; the +/// API-key catalog URL is the key-management link shown next to the key field. +/// A provider that only has a catalog URL must get a metadata URL instead of +/// silently borrowing the key page. +#[test] +fn api_key_catalog_providers_have_metadata_dashboard_urls() { + let settings = Settings::default(); + for provider in codexbar::settings::get_api_key_providers() { + if provider.dashboard_url.is_some() { + assert!( + super::provider_dashboard_url(provider.id, &settings).is_some(), + "{:?} has an API-key page but no metadata dashboard URL", + provider.id + ); + } + } +} + #[test] fn fetch_context_token_account_uses_web_cookie_header() { let settings = Settings::default(); @@ -948,6 +1178,23 @@ fn provider_detail_roundtrips_through_serde() { assert!(json.contains("\"statusPageUrl\"")); } +#[test] +fn provider_detail_carries_openai_daily_usage_only_when_present() { + let (mut detail, _settings, _id) = + super::build_provider_detail("openaiapi").expect("known provider"); + assert!(detail.open_ai_api_usage.is_none()); + let json = serde_json::to_string(&detail).expect("serialize"); + assert!(!json.contains("openAiApiUsage")); + + detail.open_ai_api_usage = Some(super::OpenAiApiUsageSnapshot { + history_days: 30, + project_id: None, + daily: Vec::new(), + }); + let json = serde_json::to_string(&detail).expect("serialize"); + assert!(json.contains("\"openAiApiUsage\":{\"historyDays\":30")); +} + #[test] fn usage_item_descriptors_keep_raw_ids_and_redact_titles() { let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); @@ -955,12 +1202,14 @@ fn usage_item_descriptors_keep_raw_ids_and_redact_titles() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let mut snapshot = ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None); @@ -988,6 +1237,62 @@ fn usage_item_descriptors_keep_raw_ids_and_redact_titles() { assert!(!items[2].available); } +#[test] +fn usage_item_descriptors_include_detail_sections() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + cost: None, + wayfinder_usage: None, + inventory: Vec::new(), + display_details: vec![ + codexbar::core::ProviderDisplayDetail::new("d1", "Rate limits", "5") + .expect("valid detail"), + codexbar::core::ProviderDisplayDetail::new("d2", "Rate limits", "6") + .expect("valid detail"), + codexbar::core::ProviderDisplayDetail::new("d3", "owner@example.com quota", "1") + .expect("valid detail"), + ], + source_label: "OAuth".to_string(), + has_successful_claude_cli_quota: false, + pace_authoritative: true, + account_identity: None, + open_ai_api_usage: None, + last_good_owner: None, + }; + let snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None); + + let mut settings = Settings { + hide_personal_info: true, + ..Settings::default() + }; + settings.set_hidden_usage_item_ids( + ProviderId::Codex, + vec!["detailSection:owner@example.com quota".to_string()], + ); + + let items = super::usage_item_descriptors(Some(&snapshot), &settings, ProviderId::Codex); + + // First three items are the distinct detail sections, redacted titles. + assert_eq!(items[0].id, "detailSection:Rate limits"); + assert_eq!(items[0].title, "Rate limits"); + assert!(items[0].available); + // Duplicate title is deduped to one descriptor; the email title is redacted. + assert!( + !items[1..] + .iter() + .any(|item| item.title.contains("owner@example.com")) + ); + // The hidden detail section is a placeholder, redacted in the stored ID. + let hidden = items + .iter() + .find(|item| item.id.starts_with("detailSection:") && !item.available) + .expect("hidden detail placeholder"); + assert!(hidden.id.contains("owner@example.com quota")); + assert_eq!(hidden.title, "Hidden quota"); +} + #[test] fn pace_stage_serializes_to_snake_case_string() { use codexbar::core::PaceStage; @@ -1080,6 +1385,7 @@ fn provider_inventory_maps_to_the_bridge_without_token_ids() { .with_display_detail( ProviderDisplayDetail::new("credits", "Used this cycle", "12") .and_then(|row| row.with_secondary_value("Monthly refill: 100")) + .and_then(|row| row.with_section_title("Credit usage")) .and_then(|row| row.with_progress(12.0, 100.0)), ); let metadata = instantiate_provider(ProviderId::Grok).metadata().clone(); @@ -1094,6 +1400,15 @@ fn provider_inventory_maps_to_the_bridge_without_token_ids() { ); assert_eq!(snapshot.display_details.len(), 1); assert_eq!(snapshot.display_details[0].value, "12"); + assert_eq!( + snapshot.display_details[0].section_title.as_deref(), + Some("Credit usage") + ); + let snapshot_json = serde_json::to_value(&snapshot).unwrap(); + assert_eq!( + snapshot_json["displayDetails"][0]["sectionTitle"], + "Credit usage" + ); assert_eq!( snapshot.display_details[0].secondary_value.as_deref(), Some("Monthly refill: 100") @@ -1179,12 +1494,14 @@ fn provider_cache_upsert_replaces_existing_provider() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let mut first = ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None); @@ -1207,12 +1524,14 @@ fn provider_cache_prunes_disabled_providers() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let codex = ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None); @@ -1243,12 +1562,14 @@ fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -1280,12 +1601,14 @@ fn codex_transient_transport_failure_helper_uses_typed_policy() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None); @@ -1316,12 +1639,14 @@ fn claude_repeated_auth_failure_surfaces_error() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -1359,12 +1684,14 @@ fn claude_cloudflare_challenge_retains_prior_usage_while_surfaceing_guidance() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -1413,12 +1740,14 @@ fn claude_cloudflare_challenge_keeps_prior_usage_when_guidance_surfaces() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "Web".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let mut good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -1464,12 +1793,14 @@ fn claude_cli_parse_failure_keeps_last_good_every_time() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "CLI".to_string(), has_successful_claude_cli_quota: true, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -1511,12 +1842,14 @@ fn claude_hard_credentials_missing_does_not_preserve_stale() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -1715,12 +2048,14 @@ fn japanese_provider_snapshot_localizes_weekly_label() { usage, cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let snapshot = @@ -1749,12 +2084,14 @@ fn japanese_provider_snapshot_localizes_pace_reserve_description() { usage, cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), has_successful_claude_cli_quota: false, pace_authoritative: true, account_identity: None, + last_good_owner: None, }; let snapshot = @@ -1837,6 +2174,13 @@ fn minimax_region_options_match_upstream_hosts() { ); } +#[test] +fn kimi_region_options_match_regional_hosts() { + let opts = super::region_options_for("kimi"); + let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["china", "international"]); +} + #[test] fn region_options_empty_for_non_regional_provider() { assert!(super::region_options_for("claude").is_empty()); @@ -1918,7 +2262,10 @@ fn external_url_validator_rejects_non_web_and_control_urls() { #[test] fn bootstrap_payload_exposes_every_provider_variant() { - let payload = super::get_bootstrap_state(); + // Built from default settings instead of `Settings::load()` so a retired + // provider enabled in the developer's real settings.json cannot change + // the catalog size. + let payload = super::bootstrap_state_for(Settings::default()); let catalog_ids: std::collections::HashSet = payload .providers @@ -1965,3 +2312,46 @@ fn bootstrap_payload_exposes_every_provider_variant() { assert!(encoded.contains("\"providers\"")); assert!(encoded.contains("\"settings\"")); } + +// Issue #684: the catalog size depends only on the settings passed in. A +// deprecated provider that is still enabled (the state that made the old +// test read 79 entries on a developer machine) is listed exactly once, and +// building the payload twice from the same settings gives the same catalog. +#[test] +fn bootstrap_catalog_depends_only_on_supplied_settings() { + let active_count = ProviderId::all() + .iter() + .filter(|provider| !provider.is_deprecated()) + .count(); + + let mut settings = Settings::default(); + settings + .enabled_providers + .insert(ProviderId::KimiK2.cli_name().to_string()); + + let first = super::bootstrap_state_for(settings.clone()); + let second = super::bootstrap_state_for(settings); + + let ids = |payload: &super::BootstrapState| -> Vec { + payload + .providers + .iter() + .map(|entry| entry.id.clone()) + .collect() + }; + assert_eq!(ids(&first), ids(&second)); + assert_eq!(first.providers.len(), active_count + 1); + assert_eq!( + ids(&first) + .iter() + .filter(|id| id.as_str() == ProviderId::KimiK2.cli_name()) + .count(), + 1 + ); + assert!( + !ids(&first) + .iter() + .any(|id| id.as_str() == ProviderId::CrossModel.cli_name()), + "a deprecated provider that is not enabled stays hidden" + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/token_account_source_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/token_account_source_tests.rs new file mode 100644 index 0000000000..ee1cee114e --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/token_account_source_tests.rs @@ -0,0 +1,74 @@ +//! Source-mode resolution for selected token accounts. + +use std::collections::HashMap; + +use codexbar::core::{ + ProviderAccountData, ProviderId, SourceMode, TokenAccount, instantiate_provider, +}; +use codexbar::settings::{ApiKeys, ManualCookies, Settings}; + +fn token_accounts(id: ProviderId, token: &str) -> HashMap { + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", token)); + HashMap::from([(id, data)]) +} + +fn context_with_usage_source( + id: ProviderId, + usage_source: &str, + accounts: &HashMap, +) -> codexbar::core::FetchContext { + let mut settings = Settings::default(); + settings.set_usage_source(id, usage_source); + super::build_fetch_context( + id, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + accounts, + ) +} + +#[test] +fn huggingface_token_account_keeps_auto_so_the_wallet_is_still_read() { + let accounts = token_accounts(ProviderId::HuggingFace, "hf_account_token"); + let ctx = context_with_usage_source(ProviderId::HuggingFace, "auto", &accounts); + + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert_eq!(ctx.api_key.as_deref(), Some("hf_account_token")); +} + +#[test] +fn huggingface_token_account_with_explicit_api_source_stays_api_only() { + let accounts = token_accounts(ProviderId::HuggingFace, "hf_account_token"); + let ctx = context_with_usage_source(ProviderId::HuggingFace, "oauth", &accounts); + + assert_eq!(ctx.source_mode, SourceMode::OAuth); + assert_eq!(ctx.api_key.as_deref(), Some("hf_account_token")); +} + +#[test] +fn huggingface_token_account_with_an_unsupported_stored_source_falls_back_to_api() { + let accounts = token_accounts(ProviderId::HuggingFace, "hf_account_token"); + for stale in ["web", "cli"] { + let ctx = context_with_usage_source(ProviderId::HuggingFace, stale, &accounts); + assert_eq!(ctx.source_mode, SourceMode::OAuth, "{stale}"); + } +} + +#[test] +fn huggingface_without_a_token_account_follows_the_usage_source() { + let ctx = context_with_usage_source(ProviderId::HuggingFace, "auto", &HashMap::new()); + assert_eq!(ctx.source_mode, SourceMode::Auto); +} + +#[test] +fn only_huggingface_keeps_auto_for_token_accounts() { + for id in ProviderId::all() { + assert_eq!( + instantiate_provider(*id).token_account_preserves_auto_source(), + *id == ProviderId::HuggingFace, + "{id:?}" + ); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs index fd34eba492..17bf96a776 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs @@ -81,6 +81,53 @@ pub(crate) fn usage_item_descriptors( let mut seen = std::collections::HashSet::new(); let mut items = Vec::new(); + // Detail sections (upstream 0.62.0 #3638): one descriptor per distinct + // display-detail title, hidden independently of the metric rows. Detail + // IDs round-trip as-is (no metric prefix), so they bypass the `push` + // helper below. + if let Some(snapshot) = snapshot { + let mut detail_titles = std::collections::HashSet::new(); + for detail in &snapshot.display_details { + if detail_titles.insert(detail.title.clone()) { + let redacted = PersonalInfoRedactor::redact_emails_in_text( + Some(&detail.title), + settings.hide_personal_info, + ) + .unwrap_or_default(); + let id = format!( + "{}{}", + codexbar::settings::USAGE_ITEM_DETAIL_SECTION_PREFIX, + redacted + ); + if seen.insert(id.clone()) { + items.push(ProviderUsageItemSnapshot { + id, + title: redacted, + available: true, + }); + } + } + } + } + for id in &hidden { + if !id.starts_with(codexbar::settings::USAGE_ITEM_DETAIL_SECTION_PREFIX) { + continue; + } + // Placeholder for a hidden detail section the provider no longer + // reports: the title is recovered from the stored ID. + if seen.insert(id.clone()) { + items.push(ProviderUsageItemSnapshot { + id: id.clone(), + title: redacted_usage_item_title( + id.strip_prefix(codexbar::settings::USAGE_ITEM_DETAIL_SECTION_PREFIX) + .unwrap_or(id.as_str()), + settings, + ), + available: false, + }); + } + } + let mut push = |raw_id: &str, title: &str, available: bool| { let id = usage_item_id(raw_id); if seen.insert(id.clone()) { @@ -126,6 +173,9 @@ pub(crate) fn usage_item_descriptors( } for id in hidden { + if id.starts_with(codexbar::settings::USAGE_ITEM_DETAIL_SECTION_PREFIX) { + continue; + } let raw_id = id .strip_prefix(codexbar::settings::USAGE_ITEM_METRIC_PREFIX) .unwrap_or(id.as_str()); diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index 01f562e111..fffaa0df27 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -1,8 +1,12 @@ -//! Usage & Spend settings tab: 7-day / 30-day local cost aggregates. +//! Usage & Spend settings tab: 7-day / 30-day compat columns plus the +//! selected reporting period (History window). -use codexbar::cost_scanner::{CostScanner, CostSummary}; +use chrono::Utc; +use codexbar::cost_reporting_period::{CostReportingPeriod, CostTimeZone}; +use codexbar::cost_scanner::CostScanner; use codexbar::spend_contract::{ - SpendContract, build_local_spend_contract, build_local_spend_contract_from_summary, + SpendContract, build_contract_from_period_summary, build_local_spend_contract, + build_local_spend_contract_for_period, build_local_spend_contract_from_summary, }; use serde::Serialize; use tauri::State; @@ -26,8 +30,21 @@ pub struct UsageSpendRow { pub display_name: String, pub seven_day: Option, pub thirty_day: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub seven_day_estimate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thirty_day_estimate: Option, pub seven_day_tokens: Option, pub thirty_day_tokens: Option, + /// The token figure is a floor from an incomplete scan ("at least N"). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub seven_day_tokens_lower_bound: bool, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub thirty_day_tokens_lower_bound: bool, + /// Cost over the selected reporting period (History window). + pub period_cost: Option, + /// Tokens over the selected reporting period. + pub period_tokens: Option, pub currency: String, pub source: String, /// Included in the shared Overview spend denominator. @@ -50,6 +67,8 @@ struct SpendValues { thirty_day: Option, seven_day_tokens: Option, thirty_day_tokens: Option, + period_cost: Option, + period_tokens: Option, source: String, refreshing: bool, stale_updated_at: Option, @@ -60,6 +79,8 @@ struct SpendValues { pub struct UsageSpendSummary { pub rows: Vec, pub contract: SpendContract, + /// Raw reporting period the rows' `period*` columns were built for. + pub reporting_period: String, pub reporting_day: String, pub dashboard_timezone: String, } @@ -126,6 +147,13 @@ impl UsageSpendCoordinator { self.current = None; true } + + /// The cached summary for `key`, unless the caller forces a rebuild. + fn reusable(&self, key: &str, force_refresh: bool) -> Option<&CachedUsageSpendSummary> { + self.cache + .as_ref() + .filter(|existing| !force_refresh && existing.key == key) + } } static USAGE_SPEND_COORDINATOR: OnceLock> = OnceLock::new(); @@ -181,7 +209,7 @@ struct BuiltUsageSpendSummary { #[tauri::command] pub async fn get_usage_spend_summary( state: State<'_, Mutex>, - history_days: Option, + period: Option, force_refresh: Option, ) -> Result { let cached = { @@ -189,10 +217,15 @@ pub async fn get_usage_spend_summary( guard.provider_cache.clone() }; - let selected_days = history_days.unwrap_or(30); + // An explicit `period` wins; otherwise use the saved History window. + let period = CostReportingPeriod::resolve_request( + period.as_deref(), + codexbar::settings::Settings::load().cost_reporting_period, + ); let force_refresh = force_refresh.unwrap_or(false); + refresh_opencodex_pricing_before_build(&cached, period, force_refresh).await; let built = tauri::async_runtime::spawn_blocking(move || { - build_usage_spend_summary_cached(&cached, selected_days, force_refresh) + build_usage_spend_summary_cached(&cached, period, force_refresh) }) .await .map_err(|e| format!("usage spend worker failed: {e}"))??; @@ -202,7 +235,7 @@ pub async fn get_usage_spend_summary( .map(|guard| guard.provider_cache.clone())?; let current_key = usage_spend_cache_key( ¤t_cached, - selected_days, + period, &codexbar::settings::Settings::load(), ); if current_key != built.key { @@ -216,6 +249,27 @@ pub async fn get_usage_spend_summary( Ok(built.summary) } +/// Refreshes models.dev prices for the OpenCodex ledger when this request +/// will rebuild the summary (upstream 0.60.4 fresh-load refresh). A cached +/// summary read never starts network work. +async fn refresh_opencodex_pricing_before_build( + cached: &[ProviderUsageSnapshot], + period: CostReportingPeriod, + force_refresh: bool, +) { + let settings = codexbar::settings::Settings::load(); + if !settings.open_codex_usage_logs_enabled { + return; + } + let key = usage_spend_cache_key(cached, period, &settings); + let rebuilds = usage_spend_coordinator() + .lock() + .is_ok_and(|coordinator| coordinator.reusable(&key, force_refresh).is_none()); + if rebuilds { + codexbar::spend_contract::refresh_opencodex_pricing_if_needed().await; + } +} + #[tauri::command] pub fn write_usage_spend_export(path: String, payload: String) -> Result<(), String> { const MAX_EXPORT_BYTES: usize = 8 * 1024 * 1024; @@ -231,19 +285,16 @@ pub fn write_usage_spend_export(path: String, payload: String) -> Result<(), Str fn build_usage_spend_summary_cached( cached: &[ProviderUsageSnapshot], - selected_days: u32, + period: CostReportingPeriod, force_refresh: bool, ) -> Result { let settings = codexbar::settings::Settings::load(); - let key = usage_spend_cache_key(cached, selected_days, &settings); + let key = usage_spend_cache_key(cached, period, &settings); { let guard = usage_spend_coordinator() .lock() .map_err(|error| error.to_string())?; - if !force_refresh - && let Some(existing) = guard.cache.as_ref() - && existing.key == key - { + if let Some(existing) = guard.reusable(&key, force_refresh) { return Ok(BuiltUsageSpendSummary { key: existing.key.clone(), summary: existing.summary.clone(), @@ -257,7 +308,7 @@ fn build_usage_spend_summary_cached( .map_err(|error| error.to_string())?; coordinator.begin(key.clone()) }; - let summary = build_usage_spend_summary(cached, selected_days, &settings, force_refresh); + let summary = build_usage_spend_summary(cached, period, &settings, force_refresh); let refreshing = summary_is_refreshing(&summary); let codex_scan_pause_reason = codexbar::core::JsonlScanner::load_cache_status(codexbar::core::ProviderId::Codex, None) @@ -299,12 +350,12 @@ fn build_usage_spend_summary_cached( fn usage_spend_cache_key( cached: &[ProviderUsageSnapshot], - selected_days: u32, + period: CostReportingPeriod, settings: &codexbar::settings::Settings, ) -> String { usage_spend_cache_key_with_privacy( cached, - selected_days, + &period.identity(Utc::now(), CostTimeZone::Local), settings.open_codex_usage_logs_enabled, settings.hide_native_codex_cost_when_open_codex_present, settings.hide_personal_info, @@ -313,7 +364,7 @@ fn usage_spend_cache_key( fn usage_spend_cache_key_with_privacy( cached: &[ProviderUsageSnapshot], - selected_days: u32, + period_identity: &str, include_opencodex: bool, hide_native: bool, hide_personal_info: bool, @@ -347,7 +398,7 @@ fn usage_spend_cache_key_with_privacy( format!( "{}|{}|{}|{}|{}|{}", chrono::Local::now().date_naive(), - selected_days, + period_identity, include_opencodex, hide_native, hide_personal_info, @@ -357,10 +408,12 @@ fn usage_spend_cache_key_with_privacy( fn build_usage_spend_summary( cached: &[ProviderUsageSnapshot], - selected_days: u32, + period: CostReportingPeriod, settings: &codexbar::settings::Settings, force_refresh: bool, ) -> UsageSpendSummary { + let now = Utc::now(); + let period_scan_days = period.scan_days(now); let include_opencodex = settings.open_codex_usage_logs_enabled; let hide_native = settings.hide_native_codex_cost_when_open_codex_present; let pi_selected = settings.enabled_providers.iter().any(|id| id == "pi") @@ -377,34 +430,44 @@ fn build_usage_spend_summary( }; let mut codex_scan_options = codex_scan_options; codex_scan_options.include_pi_sessions = include_pi_in_native; + // Any period other than 7d/30d costs one extra scan per provider. let ( - (codex_7_summary, codex_30_summary), - (claude_7_summary, claude_30_summary), - (pi_7_summary, pi_30_summary), + (codex_7_summary, codex_30_summary, codex_period_summary), + (claude_7_summary, claude_30_summary, claude_period_summary), + (pi_7_summary, pi_30_summary, pi_period_summary), ) = std::thread::scope(|scope| { let codex = scope.spawn(move || { - ( - CostScanner::new(7) + let seven = CostScanner::new(7) + .with_options(codex_scan_options) + .scan_codex(); + let thirty = CostScanner::new(30) + .with_options(codex_scan_options) + .scan_codex(); + let selected = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period) .with_options(codex_scan_options) - .scan_codex(), - CostScanner::new(30) - .with_options(codex_scan_options) - .scan_codex(), - ) + .scan_codex() + }); + (seven, thirty, selected) }); let claude = scope.spawn(|| { - ( - CostScanner::new(7) - .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native), - CostScanner::new(30) - .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native), - ) + let seven = CostScanner::new(7) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native); + let thirty = CostScanner::new(30) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native); + let selected = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native) + }); + (seven, thirty, selected) }); let pi = scope.spawn(|| { - ( - CostScanner::new(7).scan_pi(), - CostScanner::new(30).scan_pi(), - ) + let seven = CostScanner::new(7).scan_pi(); + let thirty = CostScanner::new(30).scan_pi(); + let selected = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period).scan_pi() + }); + (seven, thirty, selected) }); ( codex.join().expect("Codex spend scan worker panicked"), @@ -438,6 +501,22 @@ fn build_usage_spend_summary( settings.hide_personal_info, codex_30_summary.clone(), ); + let codex_period_contract = build_contract_from_period_summary( + "codex", + period, + include_opencodex, + hide_native, + settings.hide_personal_info, + codex_period_summary, + ); + let pi_period_contract = build_contract_from_period_summary( + "pi", + period, + false, + false, + settings.hide_personal_info, + pi_period_summary, + ); let pi_7_contract = build_local_spend_contract_from_summary( "pi", 7, @@ -460,7 +539,7 @@ fn build_usage_spend_summary( if include_opencodex { // OpenCodex is an enrichment source, never a standalone provider row. // Publish routed subscriptions even when no live provider snapshot exists. - for id in ["codex", "opencodego", "kimi", "deepseek"] { + for id in ["codex", "opencodego", "kimi", "deepseek", "nous"] { let contract = match id { "codex" => None, _ => Some(build_local_spend_contract(id, 30, true)), @@ -496,12 +575,16 @@ fn build_usage_spend_summary( }) .unwrap_or_else(|| provider_id.clone()); + let mut local_cost_estimates = None; + let mut token_lower_bounds = (false, false); let spend = match provider_id.as_str() { "codex" => SpendValues { seven_day: codex_7_contract.known_cost_usd, thirty_day: codex_30_contract.known_cost_usd, seven_day_tokens: total_token_mix(&codex_7_contract.token_mix), thirty_day_tokens: total_token_mix(&codex_30_contract.token_mix), + period_cost: codex_period_contract.known_cost_usd, + period_tokens: total_token_mix(&codex_period_contract.token_mix), source: if include_opencodex && !codex_30_contract.imports.is_empty() { "local logs + OpenCodex".to_string() } else { @@ -523,6 +606,12 @@ fn build_usage_spend_summary( .input_tokens .saturating_add(claude_30_summary.output_tokens), ), + period_cost: Some(claude_period_summary.total_cost_usd), + period_tokens: Some( + claude_period_summary + .input_tokens + .saturating_add(claude_period_summary.output_tokens), + ), source: "local logs".to_string(), refreshing: false, stale_updated_at: None, @@ -532,19 +621,25 @@ fn build_usage_spend_summary( thirty_day: pi_30_contract.known_cost_usd, seven_day_tokens: total_token_mix(&pi_7_contract.token_mix), thirty_day_tokens: total_token_mix(&pi_30_contract.token_mix), + period_cost: pi_period_contract.known_cost_usd, + period_tokens: total_token_mix(&pi_period_contract.token_mix), source: "local Pi/OMP history".to_string(), refreshing: !pi_30_summary.history_coverage_established, stale_updated_at: None, }, - "opencodego" | "kimi" | "deepseek" if include_opencodex => { + "opencodego" | "kimi" | "deepseek" | "nous" if include_opencodex => { let seven = build_local_spend_contract(&provider_id, 7, true); let thirty = build_local_spend_contract(&provider_id, 30, true); if !thirty.imports.is_empty() { + let selected = + build_local_spend_contract_for_period(&provider_id, period, true); SpendValues { seven_day: seven.known_cost_usd, thirty_day: thirty.known_cost_usd, seven_day_tokens: total_token_mix(&seven.token_mix), thirty_day_tokens: total_token_mix(&thirty.token_mix), + period_cost: selected.known_cost_usd, + period_tokens: total_token_mix(&selected.token_mix), source: if provider_id == "opencodego" { "local logs + OpenCodex".to_string() } else { @@ -554,52 +649,76 @@ fn build_usage_spend_summary( stale_updated_at: None, } } else { - cached_spend(cached_snapshot) + cached_spend(cached_snapshot, period, now) } } "cursor" => { let seven = codexbar::providers::cursor::local_csv::summarize(7); let thirty = codexbar::providers::cursor::local_csv::summarize(30); if thirty.row_count > 0 { + let selected = + codexbar::providers::cursor::local_csv::summarize(period_scan_days); SpendValues { seven_day: (seven.row_count > 0).then_some(seven.total_cost_usd), thirty_day: Some(thirty.total_cost_usd), seven_day_tokens: (seven.row_count > 0).then_some(seven.total_tokens), thirty_day_tokens: Some(thirty.total_tokens), + period_cost: (selected.row_count > 0).then_some(selected.total_cost_usd), + period_tokens: (selected.row_count > 0).then_some(selected.total_tokens), source: "local Cursor tokscale cache".to_string(), refreshing: false, stale_updated_at: None, } } else { - cached_spend(cached_snapshot) + cached_spend(cached_snapshot, period, now) } } "grok" => { let seven = codexbar::providers::grok::local_sessions::summarize(7); let thirty = codexbar::providers::grok::local_sessions::summarize(30); - let mut spend = cached_spend(cached_snapshot); + let selected = + codexbar::providers::grok::local_sessions::summarize(period_scan_days); + let mut spend = cached_spend(cached_snapshot, period, now); spend.seven_day_tokens = (seven.session_count > 0).then_some(seven.total_tokens); spend.thirty_day_tokens = (thirty.session_count > 0).then_some(thirty.total_tokens); + spend.period_tokens = (selected.session_count > 0).then_some(selected.total_tokens); if thirty.session_count > 0 { spend.source = "local Grok sessions".to_string(); } spend } "antigravity" => { - use codexbar::providers::antigravity::local_sessions::LocalHistoryCoverage; - let seven = codexbar::providers::antigravity::local_sessions::summarize(7); - let thirty = codexbar::providers::antigravity::local_sessions::summarize(30); - let mut spend = cached_spend(cached_snapshot); - spend.seven_day_tokens = matches!(seven.coverage, LocalHistoryCoverage::Complete) - .then_some(seven.total_tokens); - spend.thirty_day_tokens = matches!(thirty.coverage, LocalHistoryCoverage::Complete) - .then_some(thirty.total_tokens); - if matches!(thirty.coverage, LocalHistoryCoverage::Complete) { - spend.source = "local Antigravity history".to_string(); + use codexbar::providers::antigravity::local_sessions; + let seven = local_sessions::summarize(7); + let thirty = local_sessions::summarize(30); + let selected = local_sessions::summarize(period_scan_days); + let mut spend = cached_spend(cached_snapshot, period, now); + // Upstream 0.64: the app refreshes unknown-model pricing in the + // background; a later read (provider refresh or Refresh) reprices. + if let Some(refresh) = local_sessions::background_pricing_refresh(&thirty) { + tauri::async_runtime::spawn(refresh); + } + { + use codexbar::providers::antigravity::local_sessions::LocalHistoryCoverage; + spend.seven_day_tokens = + matches!(seven.coverage, LocalHistoryCoverage::Complete) + .then_some(seven.total_tokens); + spend.thirty_day_tokens = + matches!(thirty.coverage, LocalHistoryCoverage::Complete) + .then_some(thirty.total_tokens); + spend.period_tokens = + matches!(selected.coverage, LocalHistoryCoverage::Complete) + .then_some(selected.total_tokens); + if matches!(thirty.coverage, LocalHistoryCoverage::Complete) { + spend.source = "local Antigravity history".to_string(); + } } + let spend = antigravity_spend_values(spend, &seven, &thirty); + token_lower_bounds = (seven.lower_bound, thirty.lower_bound); + local_cost_estimates = Some((seven.cost_estimate, thirty.cost_estimate)); spend } - _ => cached_spend(cached_snapshot), + _ => cached_spend(cached_snapshot, period, now), }; let currency = cached_snapshot @@ -618,13 +737,22 @@ fn build_usage_spend_summary( .collect() }) .unwrap_or_default(); + let (seven_day_estimate, thirty_day_estimate) = local_cost_estimates + .map(|(seven, thirty)| (Some(seven), Some(thirty))) + .unwrap_or((None, None)); rows.push(UsageSpendRow { provider_id: provider_id.clone(), display_name, seven_day: spend.seven_day, thirty_day: spend.thirty_day, + seven_day_estimate, + thirty_day_estimate, seven_day_tokens: spend.seven_day_tokens, thirty_day_tokens: spend.thirty_day_tokens, + seven_day_tokens_lower_bound: token_lower_bounds.0, + thirty_day_tokens_lower_bound: token_lower_bounds.1, + period_cost: spend.period_cost, + period_tokens: spend.period_tokens, currency, source: spend.source, included_in_overview: include_in_shared_overview( @@ -638,36 +766,32 @@ fn build_usage_spend_summary( }); } - let history_days = if selected_days == 0 { - 365 - } else { - selected_days.clamp(1, 365) - }; - let selected_summary: CostSummary = match history_days { - 7 => codex_7_summary, - 30 => codex_30_summary, - days => CostScanner::new(days) - .with_options(codex_scan_options) - .scan_codex(), - }; - let contract = build_local_spend_contract_from_summary( - "codex", - history_days, - include_opencodex, - hide_native, - settings.hide_personal_info, - selected_summary, - ); + let contract = codex_period_contract; let reporting_day = last_included_reporting_day(&contract); let dashboard_timezone = codexbar::core::local_timezone_name(); UsageSpendSummary { rows, contract, + reporting_period: period.raw(), reporting_day, dashboard_timezone, } } +/// Reuse the fixed scan when it already covers the selected period. +fn selected_period_scan( + period: CostReportingPeriod, + seven: &T, + thirty: &T, + scan: impl FnOnce() -> T, +) -> T { + match period { + CostReportingPeriod::Rolling(7) => seven.clone(), + CostReportingPeriod::Rolling(30) => thirty.clone(), + _ => scan(), + } +} + /// Pi is an alternate local-history view over rows that may already be /// projected into Codex or Claude. Keep it out of the shared denominator so /// enabling Pi cannot double-count the same physical usage. @@ -701,13 +825,64 @@ fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix) -> Option saw.then_some(total) } -fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { +fn antigravity_spend_values( + mut spend: SpendValues, + seven: &codexbar::spend_contract::LocalTokenHistorySummary, + thirty: &codexbar::spend_contract::LocalTokenHistorySummary, +) -> SpendValues { + use codexbar::spend_contract::LocalHistoryCoverage; + + spend.seven_day = seven.total_usd(); + spend.thirty_day = thirty.total_usd(); + // Exact for a complete scan, a floor for a lower bound, unknown otherwise. + spend.seven_day_tokens = seven.published_tokens(); + spend.thirty_day_tokens = thirty.published_tokens(); + if spend.thirty_day.is_some() { + spend.source = "local Antigravity history · API list-price estimate".to_string(); + } else if thirty.cost_estimate.known_subtotal_usd.is_some() { + spend.source = "local Antigravity history · known API list-price subtotal".to_string(); + } else if thirty.coverage == LocalHistoryCoverage::Complete { + spend.source = "local Antigravity history · unpriced".to_string(); + } + spend +} + +/// Provider-reported daily costs bucket by UTC day, so the selected period is +/// resolved in UTC here. `None` when no day falls inside the window. +fn period_cost_from_daily( + daily: &[super::bridge::CostDailyPointBridge], + period: CostReportingPeriod, + now: chrono::DateTime, +) -> Option { + let earliest = daily + .iter() + .filter_map(|point| chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d").ok()) + .min(); + let bounds = period.bounds(now, CostTimeZone::UTC, earliest); + let mut total = 0.0; + let mut saw = false; + for point in daily { + if bounds.contains_day_key(&point.day) { + total += point.amount; + saw = true; + } + } + saw.then_some(total) +} + +fn cached_spend( + snapshot: Option<&ProviderUsageSnapshot>, + reporting_period: CostReportingPeriod, + now: chrono::DateTime, +) -> SpendValues { let Some(snapshot) = snapshot else { return SpendValues { seven_day: None, thirty_day: None, seven_day_tokens: None, thirty_day_tokens: None, + period_cost: None, + period_tokens: None, source: "unavailable".to_string(), refreshing: false, stale_updated_at: None, @@ -719,6 +894,8 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { thirty_day: None, seven_day_tokens: None, thirty_day_tokens: None, + period_cost: None, + period_tokens: None, source: if snapshot.error.is_some() { "unavailable".to_string() } else { @@ -739,6 +916,7 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { } else { let today = chrono::Utc::now().date_naive(); let seven_cutoff = today - chrono::Duration::days(6); + let thirty_cutoff = today - chrono::Duration::days(29); let mut seven = 0.0; let mut thirty = 0.0; let mut saw_seven = false; @@ -747,7 +925,9 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { let Ok(day) = chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d") else { continue; }; - if day > today { + // Providers with long daily history (Bedrock keeps 14 months) must + // not widen the fixed 30-day column. + if day > today || day < thirty_cutoff { continue; } thirty += point.amount; @@ -759,11 +939,21 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { } (saw_seven.then_some(seven), saw_thirty.then_some(thirty)) }; + let period_cost = if cost.daily.is_empty() { + // A provider-reported 30-day total only answers a 30-day selection. + (reporting_period == CostReportingPeriod::Rolling(30)) + .then_some(thirty_day) + .flatten() + } else { + period_cost_from_daily(&cost.daily, reporting_period, now) + }; SpendValues { seven_day, thirty_day, seven_day_tokens: None, thirty_day_tokens: None, + period_cost, + period_tokens: None, source: if period.is_empty() { snapshot.source_label.clone() } else { @@ -778,6 +968,29 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { mod cache_key_tests { use super::*; + fn local_history( + total_tokens: u64, + coverage: codexbar::spend_contract::LocalHistoryCoverage, + known_subtotal_usd: Option, + unpriced: u32, + ) -> codexbar::spend_contract::LocalTokenHistorySummary { + codexbar::spend_contract::LocalTokenHistorySummary { + total_tokens, + session_count: if total_tokens > 0 { 1 } else { 0 }, + coverage, + cost_estimate: codexbar::spend_contract::LocalCostEstimate { + known_subtotal_usd, + coverage: codexbar::spend_contract::CostCoverageCounts { + estimated: if known_subtotal_usd.is_some() { 1 } else { 0 }, + unpriced, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + } + } + #[test] fn invalidated_owner_clears_orphaned_indexing_activity() { let mut coordinator = UsageSpendCoordinator::default(); @@ -820,10 +1033,55 @@ mod cache_key_tests { ); } + fn empty_summary() -> UsageSpendSummary { + UsageSpendSummary { + reporting_period: "rolling:30".to_string(), + rows: Vec::new(), + contract: SpendContract { + provider_id: "codex".to_string(), + reporting_period: "rolling:30".to_string(), + history_days: 30, + known_cost_usd: None, + known_zero: false, + provenance: codexbar::spend_contract::CostProvenance::Unknown, + price_coverage: Default::default(), + price_coverage_ratio: None, + history_coverage_established: false, + token_mix: Default::default(), + conversation_count: 0, + models: Vec::new(), + projects: Vec::new(), + conversations: Vec::new(), + daily: Vec::new(), + hourly_activity: Vec::new(), + project_source_status: None, + custom_pricing_active: false, + imports: Vec::new(), + }, + reporting_day: "2026-10-01".to_string(), + dashboard_timezone: "UTC".to_string(), + } + } + + #[test] + fn only_a_summary_rebuild_may_refresh_opencodex_pricing() { + let mut coordinator = UsageSpendCoordinator::default(); + assert!(coordinator.reusable("day|30", false).is_none()); + coordinator.cache = Some(CachedUsageSpendSummary { + key: "day|30".to_string(), + summary: empty_summary(), + refresh_owner: None, + }); + // A cached read starts no network work; a forced or new key rebuilds. + assert!(coordinator.reusable("day|30", false).is_some()); + assert!(coordinator.reusable("day|30", true).is_none()); + assert!(coordinator.reusable("day|7", false).is_none()); + } + #[test] fn privacy_mode_is_part_of_usage_spend_cache_identity() { - let public = usage_spend_cache_key_with_privacy(&[], 30, false, false, false); - let private = usage_spend_cache_key_with_privacy(&[], 30, false, false, true); + let public = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, false); + let private = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, true); assert_ne!(public, private); } @@ -834,4 +1092,61 @@ mod cache_key_tests { assert!(include_in_shared_overview("claude", false, true)); assert!(!include_in_shared_overview("codex", false, false)); } + + #[test] + fn antigravity_partial_history_exposes_only_the_known_subtotal() { + use codexbar::spend_contract::LocalHistoryCoverage; + + let seven = local_history(100, LocalHistoryCoverage::Partial, Some(1.25), 0); + let thirty = local_history(200, LocalHistoryCoverage::Partial, Some(2.50), 0); + let spend = antigravity_spend_values( + cached_spend(None, CostReportingPeriod::Rolling(30), chrono::Utc::now()), + &seven, + &thirty, + ); + + assert_eq!(spend.seven_day, None); + assert_eq!(spend.thirty_day, None); + assert_eq!(spend.seven_day_tokens, None); + assert_eq!(spend.thirty_day_tokens, None); + assert!(spend.source.contains("known API list-price subtotal")); + } + + #[test] + fn antigravity_lower_bound_history_publishes_floors_not_exact_totals() { + use codexbar::spend_contract::LocalHistoryCoverage; + + let mut seven = local_history(100, LocalHistoryCoverage::Partial, Some(1.25), 0); + seven.lower_bound = true; + let withheld = local_history(0, LocalHistoryCoverage::Partial, None, 0); + let spend = antigravity_spend_values( + cached_spend(None, CostReportingPeriod::Rolling(30), chrono::Utc::now()), + &seven, + &withheld, + ); + + assert_eq!(spend.seven_day, None); + assert_eq!(spend.seven_day_tokens, Some(100)); + assert_eq!(spend.thirty_day, None); + assert_eq!(spend.thirty_day_tokens, None); + } + + #[test] + fn antigravity_complete_empty_history_is_a_known_zero() { + use codexbar::spend_contract::LocalHistoryCoverage; + + let seven = local_history(0, LocalHistoryCoverage::Complete, None, 0); + let thirty = local_history(0, LocalHistoryCoverage::Complete, None, 0); + let spend = antigravity_spend_values( + cached_spend(None, CostReportingPeriod::Rolling(30), chrono::Utc::now()), + &seven, + &thirty, + ); + + assert_eq!(spend.seven_day, Some(0.0)); + assert_eq!(spend.thirty_day, Some(0.0)); + assert_eq!(spend.seven_day_tokens, Some(0)); + assert_eq!(spend.thirty_day_tokens, Some(0)); + assert!(spend.source.contains("API list-price estimate")); + } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs new file mode 100644 index 0000000000..7187b98187 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs @@ -0,0 +1,182 @@ +use super::*; +use codexbar::cost_scanner::CostSummary; + +#[test] +fn invalidated_owner_clears_orphaned_indexing_activity() { + let mut coordinator = UsageSpendCoordinator::default(); + let owner = coordinator.begin("account:old".to_string()); + + assert!(coordinator.clear_if_indexing(&owner)); + assert!(!coordinator.is_current(&owner)); +} + +#[test] +fn old_owner_cleanup_cannot_clear_a_replacement() { + let mut coordinator = UsageSpendCoordinator::default(); + let old = coordinator.begin("account:old".to_string()); + let replacement = coordinator.begin("account:new".to_string()); + + assert!(!coordinator.clear_if_indexing(&old)); + assert!(coordinator.is_current(&replacement)); +} + +#[test] +fn settings_replacement_preserves_an_intentional_pause() { + let mut coordinator = UsageSpendCoordinator::default(); + let old = coordinator.begin("settings:old".to_string()); + let replacement = coordinator.begin("settings:new".to_string()); + let status = codexbar::core::CachedCostReadStatus { + codex_scan_pause_reason: Some(codexbar::core::CodexScanPauseReason::NoProgress), + ..Default::default() + }; + mark_refresh_paused_if_codex_scan_paused( + &mut coordinator, + &replacement, + true, + status.codex_scan_pause_reason.as_ref(), + ); + + assert!(!coordinator.clear_if_indexing(&old)); + assert_eq!( + coordinator.current.as_ref().map(|(_, phase)| *phase), + Some(UsageSpendRefreshPhase::Paused) + ); +} + +#[test] +fn privacy_mode_is_part_of_usage_spend_cache_identity() { + let public = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, false); + let private = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, true); + assert_ne!(public, private); +} + +#[test] +fn selected_period_scan_reuses_fixed_windows_and_scans_other_periods() { + let seven = CostSummary { + total_cost_usd: 7.0, + ..CostSummary::default() + }; + let thirty = CostSummary { + total_cost_usd: 30.0, + ..CostSummary::default() + }; + let mut scanned = false; + let selected = selected_period_scan(CostReportingPeriod::Rolling(7), &seven, &thirty, || { + scanned = true; + CostSummary::default() + }); + assert_eq!(selected.total_cost_usd, 7.0); + assert!(!scanned); + + let selected = selected_period_scan(CostReportingPeriod::Rolling(90), &seven, &thirty, || { + scanned = true; + CostSummary { + total_cost_usd: 90.0, + ..CostSummary::default() + } + }); + assert_eq!(selected.total_cost_usd, 90.0); + assert!(scanned); +} + +fn identity(period: CostReportingPeriod, now: &str) -> String { + let now = chrono::DateTime::parse_from_rfc3339(now) + .expect("valid instant") + .with_timezone(&Utc); + period.identity(now, CostTimeZone::UTC) +} + +#[test] +fn selected_period_is_part_of_usage_spend_cache_identity() { + let now = "2026-09-15T12:00:00Z"; + let month = identity(CostReportingPeriod::MonthToDate, now); + let rolling = identity(CostReportingPeriod::Rolling(30), now); + let all = identity(CostReportingPeriod::AllAvailable, now); + assert_ne!(month, rolling); + assert_ne!(rolling, all); + assert_ne!( + usage_spend_cache_key_with_privacy(&[], &month, false, false, false), + usage_spend_cache_key_with_privacy(&[], &rolling, false, false, false) + ); +} + +#[test] +fn month_to_date_identity_changes_across_a_month_boundary() { + let last_day = identity(CostReportingPeriod::MonthToDate, "2026-09-30T23:00:00Z"); + let first_day = identity(CostReportingPeriod::MonthToDate, "2026-10-01T01:00:00Z"); + assert_ne!(last_day, first_day); + assert_ne!( + usage_spend_cache_key_with_privacy(&[], &last_day, false, false, false), + usage_spend_cache_key_with_privacy(&[], &first_day, false, false, false) + ); +} + +#[test] +fn daily_period_cost_follows_the_selected_window() { + let daily = ["2026-09-01", "2026-09-10", "2026-08-31"] + .into_iter() + .map(|day| super::super::bridge::CostDailyPointBridge { + day: day.to_string(), + amount: 1.0, + }) + .collect::>(); + let now = chrono::DateTime::parse_from_rfc3339("2026-09-15T12:00:00Z") + .expect("valid instant") + .with_timezone(&Utc); + assert_eq!( + period_cost_from_daily(&daily, CostReportingPeriod::MonthToDate, now), + Some(2.0) + ); + assert_eq!( + period_cost_from_daily(&daily, CostReportingPeriod::AllAvailable, now), + Some(3.0) + ); + assert_eq!( + period_cost_from_daily(&daily, CostReportingPeriod::Rolling(3), now), + None + ); +} + +#[test] +fn long_provider_daily_history_does_not_widen_the_fixed_columns() { + // Bedrock reports 14 months of daily spend; the 7d / 30d columns keep + // their windows while the selected period still sees the whole history. + let today = Utc::now().date_naive(); + let day = |ago: i64| { + serde_json::json!({ + "day": (today - chrono::Duration::days(ago)).format("%Y-%m-%d").to_string(), + "amount": 1.0, + }) + }; + let cost: super::super::bridge::CostSnapshotBridge = + serde_json::from_value(serde_json::json!({ + "used": 99.0, + "period": "Monthly", + "daily": [day(0), day(6), day(7), day(29), day(30), day(200)], + })) + .expect("cost fixture"); + let mut snapshot = ProviderUsageSnapshot::from_error( + codexbar::core::ProviderId::Bedrock, + codexbar::core::instantiate_provider(codexbar::core::ProviderId::Bedrock).metadata(), + "unused".to_string(), + codexbar::core::ProviderStateKind::Unknown, + ); + snapshot.cost = Some(cost); + + let spend = cached_spend( + Some(&snapshot), + CostReportingPeriod::AllAvailable, + Utc::now(), + ); + assert_eq!(spend.seven_day, Some(2.0)); + assert_eq!(spend.thirty_day, Some(4.0)); + assert_eq!(spend.period_cost, Some(6.0)); +} + +#[test] +fn pi_history_is_an_alternate_view_not_a_shared_overview_source() { + assert!(!include_in_shared_overview("pi", true, true)); + assert!(include_in_shared_overview("codex", true, false)); + assert!(include_in_shared_overview("claude", false, true)); + assert!(!include_in_shared_overview("codex", false, false)); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs b/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs index 7a9bd92b88..1d9e8d7efe 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/warning_identity.rs @@ -1,4 +1,5 @@ use codexbar::core::ProviderId; +use codexbar::notifications::WarningScope; #[derive(Debug, Clone, PartialEq, Eq)] enum WarningSourceLane { @@ -106,6 +107,21 @@ impl WarningIdentity { } } + /// How this identity takes part in threshold-warning continuity across + /// identity gaps. Only resolved emails and unresolved Claude CLI/OAuth + /// samples are bridged; token accounts and organizations stay independent. + pub(super) fn gap_scope(&self) -> WarningScope { + let key = self.threshold_key(); + match (&self.account, self.unresolved_key()) { + (WarningAccountState::Unresolved, _) => WarningScope::Unresolved(key), + (WarningAccountState::Email(_), Some(unresolved)) => WarningScope::Resolved { + account: key, + unresolved, + }, + _ => WarningScope::Independent(key), + } + } + pub(super) fn predictive_key(&self) -> Option { match &self.account { WarningAccountState::Token(id) => Some(format!("token-account:{}", id.as_hyphenated())), @@ -223,4 +239,44 @@ mod tests { Some("cli:person@example.com") ); } + + #[test] + fn gap_scope_bridges_only_resolved_email_and_unresolved_claude_sources() { + let unresolved = WarningIdentity::new(ProviderId::Claude, "cli", None, None, None); + assert_eq!( + unresolved.gap_scope(), + WarningScope::Unresolved("claude:cli:unknown".to_string()) + ); + let resolved = WarningIdentity::new( + ProviderId::Claude, + "cli", + Some("Person@Example.com"), + None, + None, + ); + assert_eq!( + resolved.gap_scope(), + WarningScope::Resolved { + account: "person@example.com".to_string(), + unresolved: "claude:cli:unknown".to_string(), + } + ); + let token = WarningIdentity::new( + ProviderId::Claude, + "cli", + Some("person@example.com"), + None, + Some(uuid::Uuid::nil()), + ); + assert!(matches!(token.gap_scope(), WarningScope::Independent(_))); + let org = WarningIdentity::new(ProviderId::Claude, "cli", None, Some("Acme"), None); + assert_eq!( + org.gap_scope(), + WarningScope::Independent("org:acme".to_string()) + ); + let web = WarningIdentity::new(ProviderId::Claude, "web", Some("a@b.c"), None, None); + assert!(matches!(web.gap_scope(), WarningScope::Independent(_))); + let codex = WarningIdentity::new(ProviderId::Codex, "cli", Some("a@b.c"), None, None); + assert!(matches!(codex.gap_scope(), WarningScope::Independent(_))); + } } diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/window.rs b/apps/desktop-tauri/src-tauri/src/floatbar/window.rs index aabf23f042..fe6ed1858a 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/window.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/window.rs @@ -179,7 +179,7 @@ pub(super) fn recover_onto_primary>( if window.is_minimized().unwrap_or(false) || is_windows_minimized_position(position.x, position.y) { - let _ = window.unminimize(); + let _ = window.restore_without_activation(); } if window.set_physical_position(target).is_err() { return false; @@ -230,10 +230,10 @@ pub fn opacity_to_alpha(opacity: u8) -> u8 { ((clamped as u32) * 255 / 100) as u8 } -/// Open the floating-bar window, or focus + reapply attributes if already -/// open. Position is restored from the geometry store keyed by -/// `floatbar`; on first launch the window is centered horizontally near -/// the top of the primary monitor. +/// Open the floating-bar window, or re-show it and reapply attributes if +/// already open. The bar never takes focus. Position is restored from the +/// geometry store keyed by `floatbar`; on first launch the window is +/// centered horizontally near the top of the primary monitor. pub fn show( app: &tauri::AppHandle, opacity: u8, @@ -266,6 +266,9 @@ pub fn show( .resizable(false) .always_on_top(true) .skip_taskbar(true) + // Show with SW_SHOWNOACTIVATE: a plain first show activates the bar + // and takes focus from the app the user is typing in. + .focused(false) // Pin Dark: the floatbar is the only window without a theme pin, and // WebView2 resolves prefers-color-scheme per shared process profile, // so an unpinned (light-default) webview flips the Settings window's @@ -381,7 +384,10 @@ pub trait WindowGeometry { fn is_minimized(&self) -> tauri::Result; fn primary_monitor(&self) -> tauri::Result>; fn available_monitors(&self) -> tauri::Result>; - fn unminimize(&self) -> tauri::Result<()>; + /// Un-minimize without taking the foreground. The bar is never meant to + /// be focused, and a plain `unminimize` activates it (see + /// [`crate::shell::activation::restore_minimized_without_activation`]). + fn restore_without_activation(&self) -> tauri::Result<()>; fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()>; } @@ -404,7 +410,14 @@ impl WindowGeometry for tauri::WebviewWindow { fn available_monitors(&self) -> tauri::Result> { tauri::WebviewWindow::available_monitors(self) } - fn unminimize(&self) -> tauri::Result<()> { + fn restore_without_activation(&self) -> tauri::Result<()> { + // Queued on the main thread ahead of the calls below, so the restore + // lands before `unminimize` re-reads the state and before the caller + // moves the window. + let window = self.clone(); + tauri::WebviewWindow::run_on_main_thread(self, move || { + crate::shell::activation::restore_minimized_without_activation(&window); + })?; tauri::WebviewWindow::unminimize(self) } fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()> { @@ -431,7 +444,11 @@ impl WindowGeometry for tauri::Window { fn available_monitors(&self) -> tauri::Result> { tauri::Window::available_monitors(self) } - fn unminimize(&self) -> tauri::Result<()> { + fn restore_without_activation(&self) -> tauri::Result<()> { + let window = self.clone(); + tauri::Window::run_on_main_thread(self, move || { + crate::shell::activation::restore_minimized_without_activation(&window); + })?; tauri::Window::unminimize(self) } fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()> { diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index df9a86e148..2f0b60d7bf 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -19,6 +19,7 @@ mod surface_target; mod tray_accounts; mod tray_bridge; mod tray_menu; +mod tray_presentation; mod tray_visibility; mod usage_metric; mod window_positioner; @@ -51,13 +52,21 @@ fn should_hide_close_request(mode: SurfaceMode) -> bool { /// /// Spawned because building the flyout window synchronously can deadlock on /// Windows (see `shell::flyout_window::open_or_focus`). +/// +/// Launch and relaunch are not clicks in CodexBar, so the panel only asks +/// Windows for the foreground (`Activation::IfAllowed`): a launch from Start +/// or Explorer gets focus, a login-time or background launch does not. fn open_primary_window(app: &tauri::AppHandle, delay: Duration) { let app = app.clone(); tauri::async_runtime::spawn(async move { if !delay.is_zero() { tokio::time::sleep(delay).await; } - if let Err(error) = shell::flyout_window::open_or_focus(&app, None) { + if let Err(error) = shell::flyout_window::open_or_focus( + &app, + None, + shell::activation::Activation::IfAllowed, + ) { tracing::warn!(%error, "failed to open the tray panel window"); } }); @@ -140,21 +149,42 @@ fn main() { let mut initial_state = AppState::new(); initial_state.proof_config = proof_config; - // Proof-harness seed: CODEXBAR_SEED_USAGE_JSON plants one synthetic Codex - // ProviderUsageSnapshot before the event loop and any WebView read. The - // cache timestamp makes the seeded cache count as fresh so the first - // frontend refresh-if-stale call does not evict the synthetic data. - if let Some(snapshot) = proof_harness::seed_usage_snapshot_from_env() { - tracing::info!( - "proof-harness: seeded provider snapshot for '{}'", - snapshot.provider_id - ); - initial_state.provider_cache.push(snapshot); - initial_state.provider_cache_updated_at = Some(std::time::Instant::now()); + // Validate the complete proof seed before installing any snapshots, so an + // invalid multi-provider fixture cannot leave a partial cache behind. + if let Some(snapshots) = + proof_harness::seed_usage_snapshots_from_env(initial_state.proof_config.as_ref()) + { + let seeded_at = std::time::Instant::now(); + for snapshot in &snapshots { + tracing::info!( + "proof-harness: seeded provider snapshot for '{}'", + snapshot.provider_id + ); + if let Some(provider) = codexbar::core::ProviderId::from_cli_name(&snapshot.provider_id) + { + initial_state + .provider_cache_updated_at_by_provider + .insert(provider, seeded_at); + } + } + initial_state.provider_cache.extend(snapshots); + initial_state.provider_cache_seeded = true; + initial_state.provider_cache_updated_at = Some(seeded_at); + } + + let context = tauri::generate_context!(); + if is_proof_mode { + // Proof runs show surfaces for automation but never take focus. + shell::activation::suppress_all(); + } else { + // If CodexBar is already running, the single-instance plugin hands + // this launch off to it; pass our foreground permission along first. + shell::activation::grant_foreground_to_running_instance(&context.config().identifier); } tauri::Builder::default() .manage(Mutex::new(initial_state)) + .manage(commands::CurrencyRateCache::default()) .plugin(shortcut_bridge::plugin()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { @@ -166,6 +196,7 @@ fn main() { commands::get_bootstrap_state, commands::get_provider_catalog, commands::get_settings_snapshot, + commands::get_currency_rates, commands::list_agent_sessions, commands::focus_agent_session, commands::update_settings, @@ -253,6 +284,7 @@ fn main() { commands::get_provider_region_options, commands::set_provider_workspace_id, commands::set_provider_gateway_url, + commands::get_provider_gateway_url, commands::get_provider_workspace_id, commands::get_gemini_cli_signed_in, commands::get_vertexai_status, @@ -415,7 +447,7 @@ fn main() { _ => {} } }) - .run(tauri::generate_context!()) + .run(context) .expect("failed to run CodexBar desktop shell"); } diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 99012ae7c3..e02f6c8262 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -33,8 +33,11 @@ pub struct PowerToysProviderSnapshot { secondary_label: Option, secondary: Option, today_cost: Option, + /// Published pipe contract: always the trailing 30 days, independent of + /// the History window setting (`cost_reporting_period`). thirty_day_cost: Option, latest_tokens: Option, + /// Published pipe contract: always the trailing 30 days. thirty_day_tokens: Option, top_model: Option, updated_at: String, @@ -178,6 +181,8 @@ mod tests { reserve_percent: None, reserve_description: None, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, reserve_will_last_to_reset: false, } } @@ -211,6 +216,8 @@ mod tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + quota_burndown: None, + open_ai_api_usage: None, session_equivalent_forecast: None, }); let value = serde_json::to_value(snapshot).unwrap(); @@ -227,6 +234,10 @@ mod tests { today_cost: Some(1.25), thirty_day_cost: Some(12.5), thirty_day_tokens: Some(42_000), + // A different History window must not change the pipe values. + period_cost: Some(99.0), + period_tokens: Some(99_000), + reporting_period: "month-to-date".to_string(), latest_tokens: Some(1_200), top_model: Some("gpt-5".to_string()), estimate_note: "cached".to_string(), @@ -261,7 +272,9 @@ mod tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + open_ai_api_usage: None, session_equivalent_forecast: None, + quota_burndown: None, }); let value = serde_json::to_value(snapshot).unwrap(); diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 4a08484f39..24c245c19a 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -15,21 +15,27 @@ //! //! In proof mode the shell immediately transitions to the requested surface //! and suppresses blur-dismiss so the window stays visible for automated -//! screenshot capture. +//! screenshot capture. Surfaces are shown without being activated: no +//! `set_focus`, no foreground change (see `shell::activation::suppress_all`), +//! so a proof run never takes focus from the app the user is working in. //! -//! `CODEXBAR_SEED_USAGE_JSON=` additionally seeds one synthetic, -//! bridge-shaped Codex [`ProviderUsageSnapshot`] into the provider cache at -//! launch (before the first event/WebView read) and pins it against refresh -//! eviction for the run. Malformed files log a warning and the shell -//! continues without seeding — proof runs must never crash on the seed. - +//! `CODEXBAR_SEED_USAGE_JSON=` additionally seeds either the legacy +//! synthetic Codex [`ProviderUsageSnapshot`] object or, in valid proof mode, +//! a nonempty array of unique supported provider snapshots. The validated set +//! is installed before the first event/WebView read and pinned against refresh +//! eviction. Malformed files log a warning and the shell continues without +//! seeding — proof runs must never crash on the seed. + +use std::collections::HashSet; use std::sync::Mutex; +use codexbar::core::ProviderId; use serde::Serialize; use tauri::{AppHandle, Manager}; use crate::commands::{CostSnapshotBridge, ProviderUsageSnapshot, RateWindowSnapshot}; use crate::shell; +use crate::shell::activation::Activation; use crate::state::AppState; use crate::surface::SurfaceMode; use crate::surface_target::{SurfaceTarget, is_supported_settings_tab}; @@ -116,7 +122,8 @@ pub fn activate(app: &AppHandle) { if target == SurfaceMode::PopOut { tracing::info!("proof-harness: opening the tray-panel flyout window"); // Called from the async setup task, so building the window is safe. - if let Err(err) = shell::flyout_window::open_or_focus(app, None) { + // Proof automation must never take focus from the user's app. + if let Err(err) = shell::flyout_window::open_or_focus(app, None, Activation::Never) { tracing::error!("proof-harness: flyout open FAILED: {err}"); } return; @@ -134,12 +141,25 @@ pub fn activate(app: &AppHandle) { position, ); - match shell::transition_to_target(app, target, config.surface_target(), position) { + match shell::transition_to_target( + app, + target, + config.surface_target(), + position, + Activation::Never, + ) { Ok(mode) => tracing::info!("proof-harness: transition succeeded → {mode:?}"), Err(err) => tracing::error!("proof-harness: transition FAILED: {err}"), } } +/// Seeded snapshots must use an exact bridge provider id, not a CLI alias. +fn is_seedable_provider_id(provider_id: &str) -> bool { + ProviderId::all() + .iter() + .any(|provider| provider.cli_name() == provider_id) +} + /// Bottom inset (physical px) kept between the proof panel's bottom edge and /// the monitor work-area bottom (#265). const PROOF_BOTTOM_INSET_PX: i32 = 8; @@ -260,22 +280,17 @@ pub fn is_proof_mode(app: &AppHandle) -> bool { // ── Provider-usage seed (CODEXBAR_SEED_USAGE_JSON) ─────────────────── /// Environment variable pointing at a JSON file with one synthetic, -/// bridge-shaped `ProviderUsageSnapshot` for the codex provider. +/// bridge-shaped Codex snapshot or a proof-only array of provider snapshots. pub const SEED_USAGE_ENV_VAR: &str = "CODEXBAR_SEED_USAGE_JSON"; -/// Whether a seed path was configured at launch. While set, the provider -/// cache is pinned fresh so the synthetic snapshot is never evicted by an -/// automatic refresh during a proof/capture run. -pub fn seed_usage_json_active() -> bool { - std::env::var_os(SEED_USAGE_ENV_VAR).is_some() -} - /// Read and validate the seed file referenced by `CODEXBAR_SEED_USAGE_JSON`. /// /// Returns `None` (with a warn, never a crash) when the variable is unset, -/// the file is unreadable, the JSON is malformed, or the snapshot is not -/// for the `codex` provider. -pub fn seed_usage_snapshot_from_env() -> Option { +/// the file is unreadable, or the seed does not satisfy the selected legacy +/// object or proof-only array contract. +pub fn seed_usage_snapshots_from_env( + proof_config: Option<&ProofConfig>, +) -> Option> { let path = std::env::var_os(SEED_USAGE_ENV_VAR)?; let path = std::path::PathBuf::from(path); let raw = match std::fs::read_to_string(&path) { @@ -288,8 +303,8 @@ pub fn seed_usage_snapshot_from_env() -> Option { return None; } }; - match parse_seed_usage_snapshot(&raw) { - Ok(snapshot) => Some(snapshot), + match parse_seed_usage_snapshots(&raw, proof_config) { + Ok(snapshots) => Some(snapshots), Err(msg) => { tracing::warn!("{SEED_USAGE_ENV_VAR}: {msg} in {}", path.display()); None @@ -319,6 +334,51 @@ pub fn parse_seed_usage_snapshot(json: &str) -> Result, +) -> Result, String> { + if !json.trim_start().starts_with('[') { + return parse_seed_usage_snapshot(json).map(|snapshot| vec![snapshot]); + } + if !proof_config.is_some_and(is_valid_proof_config) { + return Err("provider snapshot arrays require valid proof mode".into()); + } + + let mut snapshots: Vec = + serde_json::from_str(json).map_err(|e| format!("malformed JSON: {e}"))?; + if snapshots.is_empty() { + return Err("provider snapshot array must not be empty".into()); + } + + let mut providers = HashSet::with_capacity(snapshots.len()); + for snapshot in &mut snapshots { + if !is_seedable_provider_id(&snapshot.provider_id) { + return Err(format!( + "unsupported snapshot providerId '{}', ignoring", + snapshot.provider_id + )); + } + if !providers.insert(snapshot.provider_id.clone()) { + return Err(format!( + "duplicate snapshot providerId '{}', ignoring", + snapshot.provider_id + )); + } + normalize_seed_snapshot(snapshot); + } + Ok(snapshots) +} + +fn normalize_seed_snapshot(snapshot: &mut ProviderUsageSnapshot) { normalize_rate_window(&mut snapshot.primary); snapshot.secondary.as_mut().map(normalize_rate_window); snapshot.model_specific.as_mut().map(normalize_rate_window); @@ -331,8 +391,11 @@ pub fn parse_seed_usage_snapshot(json: &str) -> Result bool { + SurfaceMode::parse(&config.target_surface) + .is_some_and(|mode| proof_payload_is_supported(mode, config.target_payload.as_deref())) } /// Recompute `remaining_percent` from `used_percent` (matching the canonical @@ -584,4 +647,72 @@ mod tests { assert_eq!(cost.period, "month"); assert_eq!(cost.formatted_used, "$12.50"); } + + fn valid_proof_config() -> ProofConfig { + ProofConfig { + target_surface: "trayPanel".into(), + settings_tab: None, + target_payload: None, + } + } + + fn seed_snapshot(provider_id: &str, used_percent: f64) -> serde_json::Value { + serde_json::json!({ + "providerId": provider_id, + "primary": { "usedPercent": used_percent, "windowMinutes": 300 } + }) + } + + #[test] + fn seed_snapshot_arrays_preserve_legacy_codex_object_behavior() { + let json = seed_snapshot("codex", 61.0).to_string(); + let snapshots = parse_seed_usage_snapshots(&json, None).expect("legacy seed parses"); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].provider_id, "codex"); + assert_eq!(snapshots[0].primary.remaining_percent, 39.0); + } + + #[test] + fn seed_snapshot_array_normalizes_multiple_supported_providers() { + let json = serde_json::json!([seed_snapshot("codex", 61.0), seed_snapshot("claude", 24.0)]) + .to_string(); + let snapshots = parse_seed_usage_snapshots(&json, Some(&valid_proof_config())) + .expect("supported snapshots parse in proof mode"); + + assert_eq!(snapshots.len(), 2); + assert_eq!(snapshots[0].provider_id, "codex"); + assert_eq!(snapshots[0].primary.remaining_percent, 39.0); + assert_eq!(snapshots[1].provider_id, "claude"); + assert_eq!(snapshots[1].primary.remaining_percent, 76.0); + assert!( + snapshots + .iter() + .all(|snapshot| !snapshot.updated_at.is_empty()) + ); + } + + #[test] + fn seed_snapshot_arrays_reject_empty_unknown_duplicate_and_non_finite_values() { + let proof = valid_proof_config(); + for json in [ + "[]".to_string(), + serde_json::json!([seed_snapshot("unknown-provider", 1.0)]).to_string(), + // CLI aliases are not bridge provider ids. + serde_json::json!([seed_snapshot("openai", 1.0)]).to_string(), + serde_json::json!([seed_snapshot("codex", 1.0), seed_snapshot("codex", 2.0)]) + .to_string(), + r#"[{"providerId":"codex","primary":{"usedPercent":1e400}}]"#.to_string(), + ] { + assert!( + parse_seed_usage_snapshots(&json, Some(&proof)).is_err(), + "{json}" + ); + } + } + + #[test] + fn seed_snapshot_arrays_are_rejected_without_valid_proof_config() { + let json = serde_json::json!([seed_snapshot("codex", 10.0)]).to_string(); + assert!(parse_seed_usage_snapshots(&json, None).is_err()); + } } diff --git a/apps/desktop-tauri/src-tauri/src/shell/activation.rs b/apps/desktop-tauri/src-tauri/src/shell/activation.rs new file mode 100644 index 0000000000..a3b49b2812 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/shell/activation.rs @@ -0,0 +1,261 @@ +//! When a CodexBar window may take keyboard focus. +//! +//! CodexBar is a tray utility. It takes the foreground only on a direct user +//! action: a tray click, a tray menu item, the global hotkey, or a click on a +//! control inside one of its own windows. Startup, a second instance handing +//! off to this one, refreshes, events and proof automation may show a +//! surface, but they must not move the foreground away from the app the user +//! is working in. +//! +//! `WebviewWindow::set_focus` is not a polite request on Windows. tao 0.34.8 +//! (`src/platform_impl/windows/window.rs`, `Window::set_focus`, lines +//! 175-186) calls `force_window_active` (lines 1500-1527), which retries a +//! refused `SetForegroundWindow` after injecting a synthetic Alt press and +//! release through `SendInput` ("a little hack which can 'steal' the +//! foreground window permission"). That bypasses the foreground lock Windows +//! applies to background processes, and the injected Alt can open the menu +//! bar of the app the user is typing in. So `set_focus` is reserved for +//! [`Activation::UserAction`]. +//! +//! Showing is kept apart from focusing: every CodexBar window is built with +//! `focused(false)` (`"focus": false` for `main` in `tauri.conf.json`), which +//! makes tao show it with `SW_SHOWNOACTIVATE` instead of `SW_SHOW` (tao +//! `window_state.rs`, `WindowFlags::apply_diff`, lines 325-337). [`apply`] is +//! the only place the focusing step happens. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use tauri::WebviewWindow; + +/// Why a surface is being shown, which decides whether it may take focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Activation { + /// A direct user action (tray click, tray menu item, hotkey, a click in + /// CodexBar's own UI). Windows lets the process that received the input + /// take the foreground, so the window is focused. + UserAction, + /// Nothing the user clicked in CodexBar, but they may still expect the + /// window in front: a launch from Start or Explorer, or a second instance + /// they started handing off to this one. Windows is asked once with a + /// plain `SetForegroundWindow`; if the foreground lock refuses, the + /// window stays visible without focus. + IfAllowed, + /// Background work (proof automation, hide-to-tray recovery). The + /// foreground is never touched. + Never, +} + +/// Set once at startup in proof mode (`CODEXBAR_PROOF_MODE`): surfaces are +/// shown for automation but never activated, whatever the caller asked for. +static SUPPRESSED: AtomicBool = AtomicBool::new(false); + +/// Stop every CodexBar window from taking focus for the rest of the process. +pub fn suppress_all() { + SUPPRESSED.store(true, Ordering::Relaxed); +} + +fn is_suppressed() -> bool { + SUPPRESSED.load(Ordering::Relaxed) +} + +/// What [`apply`] does for one activation request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FocusStep { + /// Leave the foreground alone. + Skip, + /// `WebviewWindow::set_focus` (tao `force_window_active`). + Force, + /// One plain `SetForegroundWindow` that Windows may refuse. + Request, +} + +fn focus_step(activation: Activation, suppressed: bool) -> FocusStep { + if suppressed { + return FocusStep::Skip; + } + match activation { + Activation::UserAction => FocusStep::Force, + Activation::IfAllowed => FocusStep::Request, + Activation::Never => FocusStep::Skip, + } +} + +/// Focus an already shown window as far as `activation` allows. +/// +/// Call it after `WebviewWindow::show`. Both go through the event loop in +/// order, so the window is visible by the time the foreground request runs. +pub fn apply(window: &WebviewWindow, activation: Activation) -> Result<(), String> { + match focus_step(activation, is_suppressed()) { + FocusStep::Skip => Ok(()), + FocusStep::Force => window.set_focus().map_err(|e| e.to_string()), + FocusStep::Request => request_foreground(window), + } +} + +#[cfg(windows)] +fn request_foreground(window: &WebviewWindow) -> Result<(), String> { + let target = window.clone(); + window + .run_on_main_thread(move || { + let Some(hwnd) = root_hwnd(&target) else { + return; + }; + // SAFETY: `hwnd` is the live top-level window of `target`, which + // this closure keeps alive; these calls only read window state or + // ask the window manager for the foreground, and never write + // through the handle. + unsafe { + if IsWindowVisible(hwnd) == 0 || IsIconic(hwnd) != 0 { + return; + } + if GetForegroundWindow() == hwnd { + return; + } + if SetForegroundWindow(hwnd) == 0 { + tracing::debug!( + "shell: Windows kept the current foreground window; the surface stays unfocused" + ); + } + } + }) + .map_err(|e| e.to_string()) +} + +#[cfg(not(windows))] +fn request_foreground(_window: &WebviewWindow) -> Result<(), String> { + Ok(()) +} + +/// Let the CodexBar instance that is already running take the foreground when +/// this process hands off to it. +/// +/// A second launch is usually a user action (Start menu, Explorer, a +/// shortcut), so this process may set the foreground but the running one may +/// not. `tauri-plugin-single-instance` 2.4.1 (`src/platform_impl/windows.rs`) +/// finds the running instance through a hidden window of class `{id}-sic` +/// and title `{id}-siw`, sends it `WM_COPYDATA` and exits. Passing our +/// foreground permission on with `AllowSetForegroundWindow` first lets the +/// running instance's [`Activation::IfAllowed`] request succeed without the +/// `SendInput` Alt hack. Does nothing when no other instance is running. +#[cfg(windows)] +pub fn grant_foreground_to_running_instance(identifier: &str) { + let class = wide(&format!("{identifier}-sic")); + let title = wide(&format!("{identifier}-siw")); + // SAFETY: both buffers are NUL-terminated UTF-16 strings that outlive the + // call; the returned handle is only passed back to user32 as a value. + let hwnd = unsafe { FindWindowW(class.as_ptr(), title.as_ptr()) }; + if hwnd == 0 { + return; + } + let mut pid = 0u32; + // SAFETY: `pid` is a live out-parameter owned by this frame. + unsafe { GetWindowThreadProcessId(hwnd, &mut pid) }; + if pid == 0 || pid == std::process::id() { + return; + } + // SAFETY: plain value arguments; the call only updates the window + // manager's foreground permission for `pid`. + if unsafe { AllowSetForegroundWindow(pid) } == 0 { + tracing::debug!("shell: could not pass foreground permission to the running instance"); + } +} + +#[cfg(not(windows))] +pub fn grant_foreground_to_running_instance(_identifier: &str) {} + +/// Restore a minimized window without activating it. Run it on the main +/// thread. +/// +/// tao's `unminimize` uses `ShowWindow(SW_RESTORE)`, which activates the +/// window (tao `window_state.rs`, `WindowFlags::apply_diff`, lines 390-402). +/// `SW_SHOWNOACTIVATE` restores the previous size and position and leaves +/// the foreground where it is. Call `unminimize` afterwards to refresh tao's +/// cached minimized flag: tao re-reads `IsIconic` before it diffs +/// (`window.rs`, `Window::set_minimized`, lines 584-598), finds the window +/// already restored and issues no `SW_RESTORE`. +#[cfg(windows)] +pub fn restore_minimized_without_activation(window: &impl raw_window_handle::HasWindowHandle) { + const SW_SHOWNOACTIVATE: i32 = 4; + let Some(hwnd) = root_hwnd(window) else { + return; + }; + // SAFETY: `hwnd` is the live top-level window behind `window`; IsIconic + // only reads its state and ShowWindow only changes its show state. + unsafe { + if IsIconic(hwnd) != 0 { + ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + } +} + +/// Other platforms have no foreground-lock hack to avoid, so the runtime's +/// own `unminimize` does the restore. +#[cfg(not(windows))] +pub fn restore_minimized_without_activation(_window: &impl raw_window_handle::HasWindowHandle) {} + +#[cfg(windows)] +fn root_hwnd(window: &impl raw_window_handle::HasWindowHandle) -> Option { + const GA_ROOT: u32 = 2; + let handle = window.window_handle().ok()?; + let raw_window_handle::RawWindowHandle::Win32(h) = handle.as_raw() else { + return None; + }; + let inner = h.hwnd.get(); + // SAFETY: `inner` is a live window handle from tao; GetAncestor only reads + // the window tree. + let root = unsafe { GetAncestor(inner, GA_ROOT) }; + Some(if root != 0 { root } else { inner }) +} + +#[cfg(windows)] +fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[cfg(windows)] +#[link(name = "user32")] +// SAFETY: FFI declarations for user32 window-manager calls; every call site +// passes live window handles, NUL-terminated strings or caller-owned +// out-parameters. +unsafe extern "system" { + fn GetAncestor(hwnd: isize, flags: u32) -> isize; + fn IsWindowVisible(hwnd: isize) -> i32; + fn IsIconic(hwnd: isize) -> i32; + fn GetForegroundWindow() -> isize; + fn SetForegroundWindow(hwnd: isize) -> i32; + fn FindWindowW(class_name: *const u16, window_name: *const u16) -> isize; + fn GetWindowThreadProcessId(hwnd: isize, process_id: *mut u32) -> u32; + fn AllowSetForegroundWindow(process_id: u32) -> i32; + fn ShowWindow(hwnd: isize, cmd_show: i32) -> i32; +} + +#[cfg(test)] +mod tests { + use super::{Activation, FocusStep, focus_step}; + + #[test] + fn user_actions_take_focus() { + assert_eq!(focus_step(Activation::UserAction, false), FocusStep::Force); + } + + #[test] + fn launches_and_handoffs_only_ask_windows_for_the_foreground() { + assert_eq!(focus_step(Activation::IfAllowed, false), FocusStep::Request); + } + + #[test] + fn background_work_never_touches_the_foreground() { + assert_eq!(focus_step(Activation::Never, false), FocusStep::Skip); + } + + #[test] + fn proof_mode_never_activates_any_surface() { + for activation in [ + Activation::UserAction, + Activation::IfAllowed, + Activation::Never, + ] { + assert_eq!(focus_step(activation, true), FocusStep::Skip); + } + } +} diff --git a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs index 9993269b1a..8a79e311c0 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/dwm.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/dwm.rs @@ -264,11 +264,13 @@ fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { } } - // Force frame recalculation + // Force frame recalculation. SWP_NOACTIVATE: without it SetWindowPos + // activates the window, and this runs on every surface transition. const SWP_FRAMECHANGED: u32 = 0x0020; const SWP_NOMOVE: u32 = 0x0002; const SWP_NOSIZE: u32 = 0x0001; const SWP_NOZORDER: u32 = 0x0004; + const SWP_NOACTIVATE: u32 = 0x0010; SetWindowPos( hwnd, 0, @@ -276,7 +278,7 @@ fn force_dark_caption_inner(win: &tauri::WebviewWindow, keep_resize: bool) { 0, 0, 0, - SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER, + SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, ); } } diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs index 40fbd16b6a..0a2f1dae7a 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -20,6 +20,7 @@ use std::time::{Duration, Instant}; use codexbar::settings::Settings; use tauri::{AppHandle, Manager, PhysicalPosition, WebviewUrl}; +use super::activation::{self, Activation}; use crate::geometry_store::{self, StoredSize}; use crate::state::AppState; use crate::surface::SurfaceMode; @@ -72,14 +73,21 @@ pub fn is_open(app: &AppHandle) -> bool { .is_some_and(|w| w.is_visible().unwrap_or(false)) } -/// Build (first open) or show + focus (subsequent opens) the flyout window at +/// Build (first open) or show (subsequent opens) the flyout window at /// `position`, if given, else the default tray-anchored position. +/// `activation` says whether it may take focus once shown (see +/// [`super::activation`]); on first open the frontend reveals the window, so +/// the request is stored and applied by `reveal_tray_panel_window`. /// /// `WebviewWindowBuilder::build` deadlocks when called synchronously from a /// Tauri command on Windows (see `commands/surface.rs::open_settings_window` /// precedent) — callers must invoke this from an async context (an `async` /// command, or `tauri::async_runtime::spawn`), never a sync command handler. -pub fn open_or_focus(app: &AppHandle, position: Option<(i32, i32)>) -> Result<(), String> { +pub fn open_or_focus( + app: &AppHandle, + position: Option<(i32, i32)>, + activation: Activation, +) -> Result<(), String> { let settings = Settings::load(); if let Some(window) = app.get_webview_window(FLYOUT_LABEL) { apply_window_always_on_top(&window, settings.tray_panel_always_on_top)?; @@ -91,7 +99,7 @@ pub fn open_or_focus(app: &AppHandle, position: Option<(i32, i32)>) -> Result<() reanchor(app)?; } window.show().map_err(|e| e.to_string())?; - window.set_focus().map_err(|e| e.to_string())?; + activation::apply(&window, activation)?; if show_grace_starts_now(false) { mark_shown(app); } @@ -127,6 +135,10 @@ pub fn open_or_focus(app: &AppHandle, position: Option<(i32, i32)>) -> Result<() // `dragDropEnabled: false` in tauri.conf.json for why this must be // disabled explicitly on every window that hosts that grid. .disable_drag_drop_handler() + // Showing never activates the flyout by itself (tao shows it with + // SW_SHOWNOACTIVATE); `activation::apply` decides whether it takes + // focus. + .focused(false) .visible(false); if let (Some(min_w), Some(min_h)) = (props.min_width, props.min_height) { builder = builder.min_inner_size(min_w, min_h); @@ -152,7 +164,7 @@ pub fn open_or_focus(app: &AppHandle, position: Option<(i32, i32)>) -> Result<() if show_grace_starts_now(true) { mark_shown(app); } - arm_reveal(app)?; + arm_reveal(app, activation)?; Ok(()) } @@ -181,11 +193,14 @@ fn show_grace_starts_now(first_build_hidden: bool) -> bool { !first_build_hidden } -fn arm_reveal(app: &AppHandle) -> Result<(), String> { +fn arm_reveal(app: &AppHandle, activation: Activation) -> Result<(), String> { let state = app .try_state::>() .ok_or_else(|| "app state unavailable".to_string())?; - state.lock().map_err(|e| e.to_string())?.arm_flyout_reveal(); + state + .lock() + .map_err(|e| e.to_string())? + .arm_flyout_reveal(activation); Ok(()) } @@ -214,7 +229,8 @@ pub fn toggle_with_blur_consume(app: &AppHandle, position: Option<(i32, i32)>) { } else { // Tray-toggle is fire-and-forget; open_or_focus surfaces its own // errors via tracing, so the toggle call site discards the result. - let _open = open_or_focus(app, position); + // Both callers (tray left-click, global hotkey) are user actions. + let _open = open_or_focus(app, position, Activation::UserAction); } } diff --git a/apps/desktop-tauri/src-tauri/src/shell/mod.rs b/apps/desktop-tauri/src-tauri/src/shell/mod.rs index a692281f62..3741d5446d 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/mod.rs @@ -6,6 +6,7 @@ use std::sync::{LazyLock, Mutex}; use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; +pub mod activation; pub(crate) mod dwm; pub mod flyout_window; mod geometry; diff --git a/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs b/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs index a9abc81493..9518ba3657 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/settings_window.rs @@ -3,18 +3,24 @@ use tauri::{Emitter, Manager, PhysicalPosition, WebviewUrl}; +use super::activation::{self, Activation}; + const SETTINGS_LABEL: &str = "settings"; const SETTINGS_WIDTH: f64 = 720.0; const SETTINGS_HEIGHT: f64 = 580.0; /// Open the detached Settings window, or focus it if already open. /// +/// Every caller is a user action (the tray menu or a Settings button in +/// CodexBar's own UI), so the window takes focus; proof mode still keeps it +/// unfocused (see [`super::activation`]). +/// /// When the window already exists, emits `settings-change-tab` so the /// frontend can switch to the requested tab without a full reload. pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { window.show().map_err(|e| e.to_string())?; - window.set_focus().map_err(|e| e.to_string())?; + activation::apply(&window, Activation::UserAction)?; app.emit_to(SETTINGS_LABEL, "settings-change-tab", tab) .map_err(|e| e.to_string())?; return Ok(()); @@ -22,6 +28,8 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { let url = WebviewUrl::App(format!("index.html?window=settings&tab={tab}").into()); + // `focused(false)`: showing the window never activates it by itself; + // `activation::apply` below decides whether it takes focus. let win = tauri::WebviewWindowBuilder::new(app, SETTINGS_LABEL, url) .title("CodexBar Settings") .inner_size(SETTINGS_WIDTH, SETTINGS_HEIGHT) @@ -29,6 +37,7 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { .shadow(false) .theme(Some(tauri::Theme::Dark)) .resizable(true) + .focused(false) .build() .map_err(|e| e.to_string())?; @@ -48,7 +57,7 @@ pub fn open_or_focus(app: &tauri::AppHandle, tab: &str) -> Result<(), String> { let _ = win.set_position(PhysicalPosition::new(x, y)); } - Ok(()) + activation::apply(&win, Activation::UserAction) } /// Dismiss Settings without exiting CodexBar. diff --git a/apps/desktop-tauri/src-tauri/src/shell/transition.rs b/apps/desktop-tauri/src-tauri/src/shell/transition.rs index 0d2b3784bb..6b079ebc1b 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/transition.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/transition.rs @@ -11,6 +11,7 @@ use crate::surface::{SurfaceMode, SurfaceTransition, WindowProperties}; use crate::surface_target::SurfaceTarget; use crate::window_positioner::{self, PanelSize, Rect}; +use super::activation::Activation; use super::geometry::surface_panel_size; use super::position::default_surface_position; use super::window::{apply_window_layout, apply_window_properties, show_window}; @@ -75,11 +76,14 @@ pub(super) enum TransitionResolution { }, } +/// Move `main` to `mode`/`target`. `activation` says whether the surface may +/// take focus once shown (see [`super::activation`]). pub fn transition_to_target( app: &AppHandle, mode: SurfaceMode, target: SurfaceTarget, position: Option<(i32, i32)>, + activation: Activation, ) -> Result { apply_transition_request_with_strategy( app, @@ -89,6 +93,7 @@ pub fn transition_to_target( position, }, false, + activation, ) } @@ -96,14 +101,16 @@ fn apply_transition_request_with_strategy( app: &AppHandle, request: ShellTransitionRequest, force_same_mode_apply: bool, + activation: Activation, ) -> Result { - apply_transition_request(app, request, force_same_mode_apply) + apply_transition_request(app, request, force_same_mode_apply, activation) } fn apply_transition_request( app: &AppHandle, request: ShellTransitionRequest, force_same_mode_apply: bool, + activation: Activation, ) -> Result { let _transition_guard = SHELL_TRANSITION_SERIAL.lock().unwrap(); let window = app @@ -128,11 +135,17 @@ fn apply_transition_request( .or_else(|| preserved_visible_mode_change_position(&window, &resolution)); match resolution { - TransitionResolution::ModeChange { transition, target } => { - apply_transition(app, &window, &transition, &previous, target, position) - } + TransitionResolution::ModeChange { transition, target } => apply_transition( + app, + &window, + &transition, + &previous, + target, + position, + activation, + ), TransitionResolution::SameModeRetarget { mode, target } => { - apply_same_mode_target_update(app, &window, mode, target, position) + apply_same_mode_target_update(app, &window, mode, target, position, activation) } TransitionResolution::SameModeReopen { mode, target } => { let transition = SurfaceTransition { @@ -140,7 +153,15 @@ fn apply_transition_request( to: mode, properties: mode.window_properties(), }; - apply_transition(app, &window, &transition, &previous, target, position) + apply_transition( + app, + &window, + &transition, + &previous, + target, + position, + activation, + ) } TransitionResolution::Noop { mode } => Ok(mode), } @@ -413,6 +434,7 @@ fn apply_same_mode_target_update( mode: SurfaceMode, target: SurfaceTarget, position: Option<(i32, i32)>, + activation: Activation, ) -> Result { if let Some((x, y)) = position { let _ = window.set_position(os_position(window, x, y)); @@ -428,7 +450,7 @@ fn apply_same_mode_target_update( }, )?; events::emit_surface_mode_changed(app, mode, mode, target); - if show_window(window).is_ok() && mode == SurfaceMode::TrayPanel { + if show_window(window, activation).is_ok() && mode == SurfaceMode::TrayPanel { mark_tray_panel_shown(app); } Ok(mode) @@ -441,6 +463,7 @@ pub(super) fn apply_transition( previous: &SurfaceSnapshot, current_target: SurfaceTarget, position: Option<(i32, i32)>, + activation: Activation, ) -> Result { if let Some((x, y)) = position { let _ = window.set_position(os_position(window, x, y)); @@ -468,7 +491,7 @@ pub(super) fn apply_transition( // ever target Hidden/PopOut/Settings, none of which defer their // own reveal.) if needs_show { - let _ = show_window(window); + let _ = show_window(window, activation); } clamp_current_window_to_work_area(window); @@ -477,8 +500,10 @@ pub(super) fn apply_transition( Err(err) => { let recovery = recovery_snapshot_for_failed_transition(transition, previous, ¤t_target); + // Putting the previous surface back is not something the user + // asked for, so it never takes focus. if let Err(recovery_err) = restore_recovery_surface(&recovery, |mode, properties| { - apply_window_properties(window, mode, properties) + apply_window_properties(window, mode, properties, Activation::Never) }) { let hidden = hidden_surface_snapshot(); if let Err(hide_err) = window.hide().map_err(|e| e.to_string()) { diff --git a/apps/desktop-tauri/src-tauri/src/shell/window.rs b/apps/desktop-tauri/src-tauri/src/shell/window.rs index 256926ccc9..470e8c0187 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/window.rs @@ -9,6 +9,7 @@ use crate::surface::{SurfaceMode, SurfaceTransition, WindowProperties}; use crate::surface_target::SurfaceTarget; use super::SHELL_TRANSITION_SERIAL; +use super::activation::Activation; use super::transition::{SurfaceSnapshot, apply_transition, current_surface_snapshot}; pub(super) struct HideToTrayPlan { @@ -22,10 +23,11 @@ pub fn apply_window_properties( window: &WebviewWindow, mode: SurfaceMode, props: &WindowProperties, + activation: Activation, ) -> Result<(), String> { let needs_show = apply_window_layout(window, mode, props)?; if needs_show { - show_window(window)?; + show_window(window, activation)?; } Ok(()) } @@ -149,12 +151,12 @@ fn capped_logical_size(window: &WebviewWindow, width: f64, height: f64) -> (f64, (width.min(max_width), height.min(max_height)) } -/// Make the window visible and give it input focus. -pub fn show_window(window: &WebviewWindow) -> Result<(), String> { - let map_err = |e: tauri::Error| e.to_string(); - window.show().map_err(map_err)?; - window.set_focus().map_err(map_err)?; - Ok(()) +/// Make the window visible, then focus it as far as `activation` allows (see +/// [`super::activation`]). `main` is created with `"focus": false`, so `show` +/// itself never activates it. +pub fn show_window(window: &WebviewWindow, activation: Activation) -> Result<(), String> { + window.show().map_err(|e| e.to_string())?; + super::activation::apply(window, activation) } pub fn hide_to_tray_if_current

( @@ -181,7 +183,16 @@ where }; if let Some(transition) = plan.transition { - apply_transition(app, &window, &transition, &plan.previous, plan.target, None).map(Some) + apply_transition( + app, + &window, + &transition, + &plan.previous, + plan.target, + None, + Activation::Never, + ) + .map(Some) } else { let _ = window.hide(); Ok(Some(SurfaceMode::Hidden)) diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 10d626ec43..4d9fa3c1bb 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use crate::commands::ProviderUsageSnapshot; use crate::proof_harness::ProofConfig; +use crate::shell::activation::Activation; use crate::surface::{SurfaceMode, SurfaceStateMachine, SurfaceTransition}; use crate::surface_target::SurfaceTarget; @@ -121,6 +122,9 @@ pub struct AppState { pub tray_anchor: Option, pub provider_cache: Vec, pub transient_provider_failure_counts: HashMap, + /// Live session behind each provider's cached good snapshot, for + /// owner-checked last-good retention. In memory only. + pub last_good_owners: HashMap, pub provider_cache_updated_at: Option, /// Per-provider freshness for scoped background refreshes. The aggregate /// timestamp cannot tell a dedicated auto-resume watcher whether its own @@ -143,6 +147,8 @@ pub struct AppState { pub installer_path: Option, /// Proof-harness configuration (set when `CODEXBAR_PROOF_MODE` is active). pub proof_config: Option, + /// True only after a validated proof seed was installed at startup. + pub provider_cache_seeded: bool, /// Persistent notification manager — tracks which alerts have fired to prevent spam. pub notification_manager: codexbar::notifications::NotificationManager, /// Instant when the tray panel was last shown — used to suppress @@ -154,8 +160,9 @@ pub struct AppState { /// One-shot grace for a blur event caused while revealing the tray panel /// during explicit startup. pub startup_tray_blur_grace_until: Option, - /// One-shot permission for frontend layout code to reveal a newly opened flyout. - pub flyout_reveal_pending: bool, + /// One-shot permission for frontend layout code to reveal a newly opened + /// flyout, carrying whether the revealed window may take focus. + pub flyout_reveal_pending: Option, /// Active while a user gesture (resize drag, HTML5 drag-reorder) is /// running a Win32 modal loop that transiently steals focus from the /// WebView2 child. `(began, until)` — `until` is the hard expiry; @@ -193,6 +200,7 @@ impl AppState { tray_anchor: None, provider_cache: Vec::new(), transient_provider_failure_counts: HashMap::new(), + last_good_owners: HashMap::new(), provider_cache_updated_at: None, provider_cache_updated_at_by_provider: HashMap::new(), provider_refresh_started_at: None, @@ -204,11 +212,12 @@ impl AppState { last_update_check_ms: None, installer_path: None, proof_config: None, + provider_cache_seeded: false, notification_manager: codexbar::notifications::NotificationManager::new(), last_shown_at: None, last_blur_dismissed_at: None, startup_tray_blur_grace_until: None, - flyout_reveal_pending: false, + flyout_reveal_pending: None, gesture_blur_guard: None, auto_resume: crate::auto_resume::AutoResumeState::default(), } @@ -247,16 +256,17 @@ impl AppState { .is_some_and(|until| now <= until) } - pub fn arm_flyout_reveal(&mut self) { - self.flyout_reveal_pending = true; + pub fn arm_flyout_reveal(&mut self, activation: Activation) { + self.flyout_reveal_pending = Some(activation); } pub fn clear_flyout_reveal(&mut self) { - self.flyout_reveal_pending = false; + self.flyout_reveal_pending = None; } - pub fn take_pending_flyout_reveal(&mut self) -> bool { - std::mem::take(&mut self.flyout_reveal_pending) + /// Consume the pending reveal, returning how the flyout may be activated. + pub fn take_pending_flyout_reveal(&mut self) -> Option { + self.flyout_reveal_pending.take() } /// Arm the gesture blur guard for 15s. Called when the frontend reports @@ -336,6 +346,7 @@ impl AppState { #[cfg(test)] mod tests { use super::AppState; + use crate::shell::activation::Activation; use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; @@ -460,27 +471,39 @@ mod tests { fn hidden_flyout_cannot_be_revealed_by_stale_layout_work() { let mut state = AppState::new(); - assert!(!state.take_pending_flyout_reveal()); + assert_eq!(state.take_pending_flyout_reveal(), None); } #[test] fn pending_flyout_reveal_is_consumed_once() { let mut state = AppState::new(); - state.arm_flyout_reveal(); + state.arm_flyout_reveal(Activation::UserAction); - assert!(state.take_pending_flyout_reveal()); - assert!(!state.take_pending_flyout_reveal()); + assert_eq!( + state.take_pending_flyout_reveal(), + Some(Activation::UserAction) + ); + assert_eq!(state.take_pending_flyout_reveal(), None); + } + + #[test] + fn pending_flyout_reveal_keeps_the_requested_activation() { + let mut state = AppState::new(); + + state.arm_flyout_reveal(Activation::Never); + + assert_eq!(state.take_pending_flyout_reveal(), Some(Activation::Never)); } #[test] fn pending_flyout_reveal_can_be_cleared_without_revealing() { let mut state = AppState::new(); - state.arm_flyout_reveal(); + state.arm_flyout_reveal(Activation::IfAllowed); state.clear_flyout_reveal(); - assert!(!state.take_pending_flyout_reveal()); + assert_eq!(state.take_pending_flyout_reveal(), None); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs index c2ffcea9b6..f30a3de790 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs @@ -498,7 +498,7 @@ mod tests { true, ordinals[&with_nickname.id], ), - "user@example.com — Work" + "Account 2" ); assert_eq!( codex_account_menu_label( @@ -512,7 +512,7 @@ mod tests { let hidden = codex_accounts_menu(&accounts, Some(&without_nickname), Language::English, true); - assert_eq!(hidden.children[0].label, "user@example.com — Work"); + assert_eq!(hidden.children[0].label, "Account 2"); assert_eq!(hidden.children[1].label, "Account 1"); assert_eq!(hidden.children[0].checked, Some(false)); assert!(!hidden.children[0].disabled); @@ -522,7 +522,7 @@ mod tests { hidden.children[1].id.as_deref(), Some(format!("switch_codex_account:{}", without_nickname.id).as_str()) ); - assert!(hidden.children[0].label.contains("Work")); + assert!(hidden.children[0].label.contains("Account")); assert!(!hidden.children[1].label.contains('@')); assert!(!hidden.children[1].label.contains("example.com")); assert!(!hidden.children[1].label.contains("personal")); @@ -534,7 +534,7 @@ mod tests { true, ); assert_eq!(reversed.children[0].label, "Account 1"); - assert_eq!(reversed.children[1].label, "user@example.com — Work"); + assert_eq!(reversed.children[1].label, "Account 2"); let visible = codex_accounts_menu(&[with_nickname], None, Language::English, false); assert_eq!(visible.children[0].label, "user@example.com — Work"); diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index cb11675767..8341f012e1 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -3,22 +3,18 @@ use std::sync::Mutex; use crate::commands::ProviderCatalogEntry; -#[cfg(test)] -use codexbar::core::ProviderId; -use codexbar::settings::MetricPreference; -use codexbar::settings::{Settings, TrayIconMode}; +use codexbar::settings::Settings; use tauri::image::Image; use tauri::menu::{CheckMenuItemBuilder, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Manager}; -use codexbar::tray::{render_bar_icon_rgba, render_percent_icon_rgba}; - use crate::shell; use crate::state::{AppState, TrayAnchor}; #[cfg(test)] use crate::tray_menu::build_tray_menu; use crate::tray_menu::{TrayMenuEntry, build_tray_menu_with}; +use crate::tray_presentation::{TrayPresentationPlan, headline_window}; #[derive(Debug, Clone, Copy)] struct MonitorScaleInfo { @@ -294,7 +290,11 @@ fn handle_menu_event(app: &AppHandle, id: &str) { // default position (same placement chain the old TrayPanel // transition used) when no explicit position is given. crate::auto_refresh::note_menu_open(); - let _ = shell::flyout_window::open_or_focus(app, None); + let _ = shell::flyout_window::open_or_focus( + app, + None, + shell::activation::Activation::UserAction, + ); } Some(MenuAction::Refresh) => { let handle = app.clone(); @@ -335,9 +335,13 @@ fn handle_menu_event(app: &AppHandle, id: &str) { pub(crate) fn rebuild_tray_menu(app: &AppHandle) { let catalog = crate::commands::get_provider_catalog(); let settings = Settings::load(); + let rates_cache = app + .try_state::() + .map(|state| state.inner()); let status_labels = if let Some(st) = app.try_state::>() { let guard = st.lock().unwrap(); - status_labels_for_settings(&settings, &guard.provider_cache, settings.ui_language) + TrayPresentationPlan::resolve(&settings, &guard.provider_cache, rates_cache) + .status_labels(settings.ui_language) } else { vec![] }; @@ -355,7 +359,11 @@ pub fn update_tray_status_items( ) { let catalog = crate::commands::get_provider_catalog(); let settings = Settings::load(); - let status_labels = status_labels_for_settings(&settings, snapshots, settings.ui_language); + let rates_cache = app + .try_state::() + .map(|state| state.inner()); + let status_labels = TrayPresentationPlan::resolve(&settings, snapshots, rates_cache) + .status_labels(settings.ui_language); if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) && let Some(tray) = app.tray_by_id("codexbar-main") @@ -393,228 +401,19 @@ pub fn update_tray_icon_and_tooltip( return; }; - // ── Icon ───────────────────────────────────────────────────────────── let settings = Settings::load(); - let snapshots = snapshots.to_vec(); - let ordered_snapshots = ordered_snapshot_refs(&settings, &snapshots); - let ok_snapshots: Vec<_> = ordered_snapshots - .iter() - .copied() - .filter(|s| s.error.is_none()) - .collect(); - let all_error = ok_snapshots.is_empty() && !snapshots.is_empty(); - - let prefer_highest = settings.menu_bar_shows_highest_usage - || settings.menu_bar_display_mode.as_str() == "minimal"; - - let picked = pick_tray_provider(&ok_snapshots, prefer_highest); - - let (session_pct, weekly_pct) = match picked { - Some(s) => selected_tray_percents(s, &settings), - None => ( - ok_snapshots - .iter() - .map(|s| selected_tray_percents(s, &settings).0) - .fold(0.0_f64, f64::max), - None, - ), - }; - - let (rgba, w, h) = render_tray_icon_for_settings(&settings, session_pct, weekly_pct, all_error); + let rates_cache = app + .try_state::() + .map(|state| state.inner()); + let plan = TrayPresentationPlan::resolve(&settings, snapshots, rates_cache); + let (rgba, w, h) = plan.render_icon(); let icon = Image::new_owned(rgba, w, h); let _ = tray.set_icon(Some(icon)); - // ── Tooltip ─────────────────────────────────────────────────────────── - let tooltip = build_tooltip(&snapshots, settings.ui_language); + let tooltip = build_tooltip(snapshots, settings.ui_language); let _ = tray.set_tooltip(Some(tooltip)); } -fn status_labels_for_settings( - settings: &Settings, - snapshots: &[crate::commands::ProviderUsageSnapshot], - lang: codexbar::settings::Language, -) -> Vec<(String, String)> { - let ordered_snapshots = ordered_snapshot_refs(settings, snapshots); - let healthy: Vec<_> = ordered_snapshots - .into_iter() - .filter(|s| s.error.is_none()) - .collect(); - if settings.tray_icon_mode == TrayIconMode::PerProvider { - return healthy - .into_iter() - .map(|s| provider_status_label(s, lang)) - .collect::>(); - } - - let Some(selected) = pick_tray_provider( - &healthy, - settings.menu_bar_shows_highest_usage || settings.menu_bar_display_mode == "minimal", - ) else { - return vec![]; - }; - - let (_, label) = provider_status_label(selected, lang); - vec![("status_summary".to_string(), label)] -} - -fn ordered_snapshot_refs<'a>( - settings: &Settings, - snapshots: &'a [crate::commands::ProviderUsageSnapshot], -) -> Vec<&'a crate::commands::ProviderUsageSnapshot> { - let order = settings - .provider_display_order_names() - .into_iter() - .enumerate() - .map(|(index, provider_id)| (provider_id, index)) - .collect::>(); - let mut ordered = snapshots.iter().collect::>(); - ordered.sort_by(|a, b| { - let a_order = order.get(&a.provider_id); - let b_order = order.get(&b.provider_id); - match (a_order, b_order) { - (Some(a_order), Some(b_order)) if a_order != b_order => a_order.cmp(b_order), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - _ => a.display_name.cmp(&b.display_name), - } - }); - ordered -} - -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), - ); - } - - let label = crate::commands::compact_tray_status_label(headline_window(snapshot), lang); - ( - snapshot.provider_id.clone(), - format!("{} {}", snapshot.display_name, label), - ) -} - -/// Window that headline tray surfaces should label for a provider. -/// -/// 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. -/// -/// Shared by the tray menu rows (`provider_status_label`) and the tray tooltip -/// (`build_tooltip`) so the two cannot drift apart. -fn headline_window( - snapshot: &crate::commands::ProviderUsageSnapshot, -) -> &crate::commands::RateWindowSnapshot { - if snapshot.provider_id == "codex" { - codex_lane_headline_window(snapshot) - } else { - &snapshot.primary - } -} - -/// F5 (upstream 0.48.0): pick the first non-informational Codex lane in -/// session → weekly → monthly order. When all lanes are informational -/// (no active session at all), fall back to the primary for the -/// "No active 5h session" placeholder. -pub(crate) fn codex_lane_headline_window( - snapshot: &crate::commands::ProviderUsageSnapshot, -) -> &crate::commands::RateWindowSnapshot { - if !snapshot.primary.is_informational { - return &snapshot.primary; - } - if let Some(ref secondary) = snapshot.secondary - && !secondary.is_informational - { - return secondary; - } - if let Some(ref tertiary) = snapshot.tertiary - && !tertiary.is_informational - { - return tertiary; - } - &snapshot.primary -} - -fn render_tray_icon_for_settings( - settings: &Settings, - session_pct: f64, - weekly_pct: Option, - all_error: bool, -) -> (Vec, u32, u32) { - if settings.menu_bar_shows_percent { - render_percent_icon_rgba(session_pct, all_error) - } else { - render_bar_icon_rgba(session_pct, weekly_pct, all_error) - } -} - -/// Pick the provider whose usage the tray icon should render. -/// -/// Exposed so that the unit tests can exercise both `highest` and `first` -/// paths without needing a live Tauri app handle. -fn pick_tray_provider<'a>( - ok_snapshots: &'a [&'a crate::commands::ProviderUsageSnapshot], - prefer_highest: bool, -) -> Option<&'a crate::commands::ProviderUsageSnapshot> { - if ok_snapshots.is_empty() { - return None; - } - if prefer_highest { - ok_snapshots.iter().copied().max_by(|a, b| { - a.primary - .used_percent - .partial_cmp(&b.primary.used_percent) - .unwrap_or(std::cmp::Ordering::Equal) - }) - } else { - Some(ok_snapshots[0]) - } -} - -fn selected_tray_percents( - snapshot: &crate::commands::ProviderUsageSnapshot, - settings: &Settings, -) -> (f64, Option) { - let (selected, companion) = - crate::usage_metric::selected_usage_icon_windows(snapshot, settings); - ( - display_metric_percent(&selected, settings.show_as_used), - companion - .as_ref() - .map(|window| display_metric_percent(window, settings.show_as_used)), - ) -} - -fn display_metric_percent(window: &crate::commands::RateWindowSnapshot, show_as_used: bool) -> f64 { - if window.is_informational { - return 0.0; - } - if window.is_exhausted || window.used_percent >= 100.0 { - return if show_as_used { 100.0 } else { 0.0 }; - } - - let used_percent = window.used_percent; - let used = used_percent.clamp(0.0, 100.0); - if show_as_used { used } else { 100.0 - used } -} - /// Build a compact multi-line tooltip string from provider snapshots. fn build_tooltip( snapshots: &[crate::commands::ProviderUsageSnapshot], @@ -732,7 +531,13 @@ fn build_native_menu_entry( #[cfg(test)] mod tests { use super::*; + use crate::surface::SurfaceMode; + use crate::tray_presentation::{ + codex_lane_headline_window, pick_tray_provider, selected_tray_percents, + }; + use codexbar::core::ProviderId; + use codexbar::settings::MetricPreference; fn sample_provider_catalog() -> Vec { vec![ @@ -888,6 +693,8 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, }, primary_label: None, secondary: secondary_percent.map(|pct| crate::commands::RateWindowSnapshot { @@ -902,6 +709,8 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, }), secondary_label: None, model_specific: None, @@ -917,6 +726,8 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, }), tertiary_label: None, extra_rate_windows: Vec::new(), @@ -952,6 +763,8 @@ mod tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + quota_burndown: None, + open_ai_api_usage: None, session_equivalent_forecast: None, } } @@ -981,114 +794,36 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, + monthly_limit_block: None, }, } } - #[test] fn pick_tray_provider_highest_picks_max_primary() { let a = fake_snapshot("codex", "Codex", 30.0); let b = fake_snapshot("claude", "Claude", 72.5); let c = fake_snapshot("gemini", "Gemini", 50.0); let refs: Vec<&crate::commands::ProviderUsageSnapshot> = vec![&a, &b, &c]; - let picked = pick_tray_provider(&refs, /* prefer_highest = */ true) .expect("highest mode should pick a provider"); assert_eq!(picked.provider_id, "claude"); } - #[test] fn pick_tray_provider_first_preserves_catalog_order() { let a = fake_snapshot("codex", "Codex", 30.0); let b = fake_snapshot("claude", "Claude", 72.5); let refs: Vec<&crate::commands::ProviderUsageSnapshot> = vec![&a, &b]; - let picked = pick_tray_provider(&refs, /* prefer_highest = */ false) .expect("non-highest mode should still pick the first entry"); assert_eq!(picked.provider_id, "codex"); } - #[test] fn pick_tray_provider_none_when_empty() { let refs: Vec<&crate::commands::ProviderUsageSnapshot> = vec![]; assert!(pick_tray_provider(&refs, true).is_none()); assert!(pick_tray_provider(&refs, false).is_none()); } - - #[test] - fn status_labels_per_provider_mode_lists_each_healthy_provider() { - let settings = Settings { - tray_icon_mode: TrayIconMode::PerProvider, - provider_order: codexbar::settings::normalize_provider_order(&[ - "claude".to_string(), - "codex".to_string(), - ]), - ..Settings::default() - }; - let snapshots = vec![ - fake_snapshot("codex", "Codex", 30.0), - fake_snapshot("claude", "Claude", 72.0), - ]; - - let labels = status_labels_for_settings( - &settings, - &snapshots, - codexbar::settings::Language::English, - ); - - assert_eq!( - labels, - vec![ - ("claude".to_string(), "Claude 72%".to_string()), - ("codex".to_string(), "Codex 30%".to_string()), - ] - ); - } - - #[test] - fn status_labels_single_mode_collapses_to_selected_provider() { - let settings = Settings { - tray_icon_mode: TrayIconMode::Single, - menu_bar_shows_highest_usage: true, - ..Settings::default() - }; - let snapshots = vec![ - fake_snapshot("codex", "Codex", 30.0), - fake_snapshot("claude", "Claude", 72.0), - ]; - - let labels = status_labels_for_settings( - &settings, - &snapshots, - codexbar::settings::Language::English, - ); - - assert_eq!( - labels, - vec![("status_summary".to_string(), "Claude 72%".to_string())] - ); - } - - #[test] - fn tray_icon_renderer_uses_percent_mode_when_enabled() { - let bar_settings = Settings { - menu_bar_shows_percent: false, - ..Settings::default() - }; - let percent_settings = Settings { - menu_bar_shows_percent: true, - ..Settings::default() - }; - - let (bar, bar_w, bar_h) = - render_tray_icon_for_settings(&bar_settings, 72.0, Some(40.0), false); - let (percent, pct_w, pct_h) = - render_tray_icon_for_settings(&percent_settings, 72.0, Some(40.0), false); - - assert_eq!((bar_w, bar_h), (pct_w, pct_h)); - assert_ne!(bar, percent); - } - #[test] fn tooltip_uses_compact_status_labels() { let mut claude = fake_snapshot("claude", "Claude", 13.0); @@ -1165,14 +900,19 @@ mod tests { "{japanese_tooltip}" ); - let (_, english_label) = - provider_status_label(&claude, codexbar::settings::Language::English); - let (_, japanese_label) = - provider_status_label(&claude, codexbar::settings::Language::Japanese); + let settings = Settings::default(); + let snapshots = vec![claude]; + let rates_cache = None; + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, rates_cache); + let english_label = plan.status_labels(codexbar::settings::Language::English)[0] + .1 + .clone(); + let japanese_label = plan.status_labels(codexbar::settings::Language::Japanese)[0] + .1 + .clone(); assert!(english_label.contains("Resets in"), "{english_label}"); assert!(japanese_label.contains("リセットまで"), "{japanese_label}"); } - #[test] fn selected_tray_percent_uses_cursor_extra_usage_cost() { let mut settings = Settings::default(); @@ -1185,37 +925,28 @@ mod tests { Some(72.0), Some((15.0, 100.0)), ); - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 15.0); assert_eq!(secondary, Some(20.0)); } - #[test] fn selected_tray_percent_tracks_extra_rate_window() { let mut settings = Settings::default(); settings.set_provider_metric(ProviderId::Copilot, MetricPreference::ExtraUsage); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); assert_eq!(secondary, None); } - #[test] fn copilot_automatic_tracks_highest_extra_rate_window() { let settings = Settings::default(); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); } - #[test] fn selected_tray_percent_respects_remaining_display_mode() { let mut settings = Settings { @@ -1231,13 +962,10 @@ mod tests { Some(72.0), Some((15.0, 100.0)), ); - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 85.0); assert_eq!(secondary, Some(80.0)); } - #[test] fn exhausted_automatic_window_never_renders_as_remaining_progress() { let mut settings = Settings { @@ -1257,15 +985,12 @@ mod tests { .as_mut() .expect("monthly quota") .is_exhausted = true; - let (remaining, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(remaining, 0.0); - settings.show_as_used = true; let (used, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(used, 100.0); } - #[test] fn full_automatic_window_without_exhausted_flag_has_zero_remaining_progress() { let mut settings = Settings { @@ -1285,15 +1010,12 @@ mod tests { .as_mut() .expect("monthly quota") .is_exhausted = false; - let (remaining, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(remaining, 0.0); - settings.show_as_used = true; let (used, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(used, 100.0); } - #[test] fn missing_automatic_window_does_not_look_like_available_remaining_progress() { let settings = Settings { @@ -1302,79 +1024,61 @@ mod tests { }; let mut snapshot = fake_snapshot_with("opencodego", "OpenCode Go", 0.0, None, None, None); snapshot.primary.is_informational = true; - let (remaining, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(remaining, 0.0); } - #[test] fn selected_tray_percent_falls_back_when_extra_usage_missing() { let mut settings = Settings::default(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); let snapshot = fake_snapshot_with("cursor", "Cursor", 10.0, Some(72.0), None, None); - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 72.0); } - #[test] fn single_meaningful_secondary_quota_uses_full_single_meter() { let settings = Settings::default(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); assert_eq!(secondary, None); } - #[test] fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { let settings = Settings::default(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), Some(30.0), None); snapshot.primary.is_informational = true; - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); assert_eq!(secondary, Some(30.0)); } - #[test] fn two_meaningful_quotas_keep_two_meter_layout() { let mut settings = Settings::default(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::Session); let snapshot = fake_snapshot_with("cursor", "Cursor", 15.0, Some(40.0), None, None); - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 15.0); assert_eq!(secondary, Some(40.0)); } - #[test] fn informational_primary_skips_session_and_automatic_phantom_zero() { let mut settings = Settings::default(); settings.set_provider_metric(ProviderId::Claude, MetricPreference::Session); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; - // Session preference must not paint the synthetic 0% primary; // it falls through to Automatic which prefers weekly (42%). let (primary, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(primary, 42.0); assert_ne!(primary, 0.0); - // Automatic also prefers weekly over informational primary. settings.set_provider_metric(ProviderId::Claude, MetricPreference::Automatic); let (primary, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(primary, 42.0); } - #[test] fn claude_automatic_prefers_weekly_when_model_exhausted() { let settings = Settings::default(); @@ -1391,33 +1095,29 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, + monthly_limit_block: None, }); - let (primary, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(primary, 22.0); - // Explicit model override is untouched. let mut overridden = settings.clone(); overridden.set_provider_metric(ProviderId::Claude, MetricPreference::Model); let (primary, _) = selected_tray_percents(&snapshot, &overridden); assert_eq!(primary, 100.0); } - #[test] fn automatic_prefers_exhausted_weekly_over_low_session() { let settings = Settings::default(); let snapshot = fake_snapshot_with("codex", "Codex", 20.0, Some(100.0), None, None); - let (primary, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(primary, 100.0); - // Explicit session override still wins. let mut overridden = settings.clone(); overridden.set_provider_metric(ProviderId::Codex, MetricPreference::Session); let (primary, _) = selected_tray_percents(&snapshot, &overridden); assert_eq!(primary, 20.0); } - #[test] fn automatic_picks_highest_among_model_and_extra_windows() { let settings = Settings::default(); @@ -1435,20 +1135,19 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + description_is_detail: false, + monthly_limit_block: None, }); snapshot.extra_rate_windows.push(fake_extra_window(90.0)); - let (primary, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(primary, 90.0); } - #[test] fn f5_headline_prefers_non_informational_primary() { let snapshot = fake_snapshot_with("codex", "Codex", 50.0, Some(20.0), Some(30.0), None); let headline = codex_lane_headline_window(&snapshot); assert!((headline.used_percent - 50.0).abs() < f64::EPSILON); } - #[test] fn f5_headline_falls_back_to_secondary_when_primary_informational() { let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(25.0), Some(30.0), None); @@ -1456,7 +1155,6 @@ mod tests { let headline = codex_lane_headline_window(&snapshot); assert!((headline.used_percent - 25.0).abs() < f64::EPSILON); } - #[test] fn f5_headline_falls_back_to_tertiary_when_primary_and_secondary_informational() { let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(35.0), None); @@ -1465,7 +1163,6 @@ mod tests { let headline = codex_lane_headline_window(&snapshot); assert!((headline.used_percent - 35.0).abs() < f64::EPSILON); } - #[test] fn f5_headline_returns_primary_when_all_informational() { let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(0.0), None); diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs new file mode 100644 index 0000000000..f701d059c0 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -0,0 +1,410 @@ +//! Pure tray presentation policy shared by the native tray surfaces. + +use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; +use codexbar::settings::{Language, MetricPreference, Settings, TrayIconMode}; +use codexbar::tray::{ + TrayPaceColor, render_bar_icon_rgba_with_pace, render_percent_icon_rgba_with_pace, + render_stacked_bar_icon_rgba, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum TrayIconPlan { + Bars { + primary_percent: f64, + secondary_percent: Option, + has_error: bool, + }, + Percent { + percent: f64, + has_error: bool, + }, + Stacked { + top_percent: f64, + bottom_percent: f64, + has_error: bool, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TrayStatusKey { + Summary, + Provider, +} + +#[derive(Debug, Clone, Copy)] +struct TrayStatusRow<'a> { + key: TrayStatusKey, + snapshot: &'a ProviderUsageSnapshot, +} + +/// Fully resolved tray presentation, independent of Tauri and operating-system state. +/// +/// The plan is the single policy boundary for provider ordering, mode-specific +/// selection, metric selection, status rows, and icon renderer choice. +pub(crate) struct TrayPresentationPlan<'a> { + settings: &'a Settings, + icon: TrayIconPlan, + /// Optional pace tint for the single-provider bar and percent icons + /// (upstream 0.59.0 pace colors). Stacked icons keep usage-level colours. + icon_pace: Option, + status_rows: Vec>, + rates_cache: Option<&'a crate::commands::CurrencyRateCache>, +} + +impl<'a> TrayPresentationPlan<'a> { + pub(crate) fn resolve( + settings: &'a Settings, + snapshots: &'a [ProviderUsageSnapshot], + rates_cache: Option<&'a crate::commands::CurrencyRateCache>, + ) -> Self { + let ordered = ordered_snapshot_refs(settings, snapshots); + let healthy = ordered + .into_iter() + .filter(|snapshot| snapshot.error.is_none()) + .collect::>(); + let has_error = healthy.is_empty() && !snapshots.is_empty(); + let prefer_highest = + settings.menu_bar_shows_highest_usage || settings.menu_bar_display_mode == "minimal"; + let selected = pick_tray_provider(&healthy, prefer_highest); + let icon_pace = tray_pace_color(settings, selected); + + let (icon, status_rows) = match settings.tray_icon_mode { + TrayIconMode::Stacked => { + if let Some((top, bottom)) = pick_stacked_tray_providers(&healthy, settings) { + ( + TrayIconPlan::Stacked { + top_percent: selected_tray_percents(top, settings).0, + bottom_percent: selected_tray_percents(bottom, settings).0, + has_error, + }, + vec![ + TrayStatusRow { + key: TrayStatusKey::Provider, + snapshot: top, + }, + TrayStatusRow { + key: TrayStatusKey::Provider, + snapshot: bottom, + }, + ], + ) + } else { + let percents = selected + .map(|snapshot| selected_tray_percents(snapshot, settings)) + .unwrap_or((0.0, None)); + let rows = healthy + .first() + .map(|snapshot| TrayStatusRow { + key: TrayStatusKey::Provider, + snapshot, + }) + .into_iter() + .collect(); + ( + resolve_single_provider_icon_plan( + settings, percents.0, percents.1, has_error, + ), + rows, + ) + } + } + TrayIconMode::PerProvider => { + let percents = selected + .map(|snapshot| selected_tray_percents(snapshot, settings)) + .unwrap_or_else(|| fallback_percents(&healthy, settings)); + let rows = healthy + .iter() + .copied() + .map(|snapshot| TrayStatusRow { + key: TrayStatusKey::Provider, + snapshot, + }) + .collect(); + ( + resolve_single_provider_icon_plan(settings, percents.0, percents.1, has_error), + rows, + ) + } + TrayIconMode::Single => { + let percents = selected + .map(|snapshot| selected_tray_percents(snapshot, settings)) + .unwrap_or_else(|| fallback_percents(&healthy, settings)); + let rows = selected + .map(|snapshot| TrayStatusRow { + key: TrayStatusKey::Summary, + snapshot, + }) + .into_iter() + .collect(); + ( + resolve_single_provider_icon_plan(settings, percents.0, percents.1, has_error), + rows, + ) + } + }; + + let icon_pace = match icon { + TrayIconPlan::Stacked { .. } => None, + TrayIconPlan::Bars { .. } | TrayIconPlan::Percent { .. } => icon_pace, + }; + + Self { + settings, + icon, + icon_pace, + status_rows, + rates_cache, + } + } + + pub(crate) fn render_icon(&self) -> (Vec, u32, u32) { + match self.icon { + TrayIconPlan::Bars { + primary_percent, + secondary_percent, + has_error, + } => render_bar_icon_rgba_with_pace( + primary_percent, + secondary_percent, + has_error, + self.icon_pace, + ), + TrayIconPlan::Percent { percent, has_error } => { + render_percent_icon_rgba_with_pace(percent, has_error, self.icon_pace) + } + TrayIconPlan::Stacked { + top_percent, + bottom_percent, + has_error, + } => render_stacked_bar_icon_rgba(top_percent, bottom_percent, has_error), + } + } + + pub(crate) fn status_labels(&self, language: Language) -> Vec<(String, String)> { + self.status_rows + .iter() + .map(|row| { + let (_, label) = + provider_status_label(row.snapshot, self.settings, language, self.rates_cache); + let key = match row.key { + TrayStatusKey::Summary => "status_summary".to_string(), + TrayStatusKey::Provider => row.snapshot.provider_id.clone(), + }; + (key, label) + }) + .collect() + } +} + +fn resolve_single_provider_icon_plan( + settings: &Settings, + primary_percent: f64, + secondary_percent: Option, + has_error: bool, +) -> TrayIconPlan { + if settings.menu_bar_shows_percent { + TrayIconPlan::Percent { + percent: primary_percent, + has_error, + } + } else { + TrayIconPlan::Bars { + primary_percent, + secondary_percent, + has_error, + } + } +} + +/// Pace tint for the provider that drives a single-provider tray icon. +/// +/// Opt-in through `menu_bar_color_pace`; on-track and unknown stages keep the +/// normal usage-level colour. +fn tray_pace_color( + settings: &Settings, + selected: Option<&ProviderUsageSnapshot>, +) -> Option { + if !settings.menu_bar_color_pace { + return None; + } + selected + .and_then(|snapshot| snapshot.pace.as_ref()) + .and_then(|pace| TrayPaceColor::from_stage(&pace.stage)) +} + +fn fallback_percents( + healthy: &[&ProviderUsageSnapshot], + settings: &Settings, +) -> (f64, Option) { + ( + healthy + .iter() + .map(|snapshot| selected_tray_percents(snapshot, settings).0) + .fold(0.0_f64, f64::max), + None, + ) +} + +fn ordered_snapshot_refs<'a>( + settings: &Settings, + snapshots: &'a [ProviderUsageSnapshot], +) -> Vec<&'a ProviderUsageSnapshot> { + let order = settings + .provider_display_order_names() + .into_iter() + .enumerate() + .map(|(index, provider_id)| (provider_id, index)) + .collect::>(); + let mut ordered = snapshots.iter().collect::>(); + ordered.sort_by(|a, b| { + let a_order = order.get(&a.provider_id); + let b_order = order.get(&b.provider_id); + match (a_order, b_order) { + (Some(a_order), Some(b_order)) if a_order != b_order => a_order.cmp(b_order), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + _ => a.display_name.cmp(&b.display_name), + } + }); + ordered +} + +fn provider_status_label( + snapshot: &ProviderUsageSnapshot, + settings: &Settings, + language: Language, + rates_cache: Option<&crate::commands::CurrencyRateCache>, +) -> (String, String) { + let provider = codexbar::core::ProviderId::from_cli_name(&snapshot.provider_id); + let preference = provider + .map(|id| settings.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, rates_cache) + }; + return ( + snapshot.provider_id.clone(), + format!("{} {}", snapshot.display_name, amount), + ); + } + + let label = crate::commands::compact_tray_status_label(headline_window(snapshot), language); + ( + snapshot.provider_id.clone(), + format!("{} {}", snapshot.display_name, label), + ) +} + +/// Window that headline tray surfaces should label for a provider. +pub(crate) fn headline_window(snapshot: &ProviderUsageSnapshot) -> &RateWindowSnapshot { + if snapshot.provider_id == "codex" { + codex_lane_headline_window(snapshot) + } else { + &snapshot.primary + } +} + +/// Pick the first non-informational Codex lane in session, weekly, monthly order. +pub(crate) fn codex_lane_headline_window(snapshot: &ProviderUsageSnapshot) -> &RateWindowSnapshot { + if !snapshot.primary.is_informational { + return &snapshot.primary; + } + if let Some(ref secondary) = snapshot.secondary + && !secondary.is_informational + { + return secondary; + } + if let Some(ref tertiary) = snapshot.tertiary + && !tertiary.is_informational + { + return tertiary; + } + &snapshot.primary +} + +/// Resolve a stable top/bottom pair while retaining stale saved preferences. +fn pick_stacked_tray_providers<'a>( + healthy: &[&'a ProviderUsageSnapshot], + settings: &Settings, +) -> Option<(&'a ProviderUsageSnapshot, &'a ProviderUsageSnapshot)> { + if healthy.len() < 2 { + return None; + } + + let preferred = |provider_id: Option<&str>| { + provider_id.and_then(|id| { + healthy + .iter() + .copied() + .find(|snapshot| snapshot.provider_id == id) + }) + }; + let preferred_bottom = preferred(settings.stacked_tray_bottom_provider.as_deref()); + let top = preferred(settings.stacked_tray_top_provider.as_deref()).or_else(|| { + healthy.iter().copied().find(|snapshot| { + preferred_bottom.map(|bottom| bottom.provider_id.as_str()) + != Some(snapshot.provider_id.as_str()) + }) + })?; + let bottom = preferred_bottom + .filter(|snapshot| snapshot.provider_id != top.provider_id) + .or_else(|| { + healthy + .iter() + .copied() + .find(|snapshot| snapshot.provider_id != top.provider_id) + })?; + + Some((top, bottom)) +} + +pub(crate) fn pick_tray_provider<'a>( + healthy: &[&'a ProviderUsageSnapshot], + prefer_highest: bool, +) -> Option<&'a ProviderUsageSnapshot> { + if prefer_highest { + healthy.iter().copied().max_by(|a, b| { + a.primary + .used_percent + .partial_cmp(&b.primary.used_percent) + .unwrap_or(std::cmp::Ordering::Equal) + }) + } else { + healthy.first().copied() + } +} + +pub(crate) fn selected_tray_percents( + snapshot: &ProviderUsageSnapshot, + settings: &Settings, +) -> (f64, Option) { + let (selected, companion) = + crate::usage_metric::selected_usage_icon_windows(snapshot, settings); + ( + display_metric_percent(&selected, settings.show_as_used), + companion + .as_ref() + .map(|window| display_metric_percent(window, settings.show_as_used)), + ) +} + +fn display_metric_percent(window: &RateWindowSnapshot, show_as_used: bool) -> f64 { + if window.is_informational { + return 0.0; + } + if window.is_exhausted || window.used_percent >= 100.0 { + return if show_as_used { 100.0 } else { 0.0 }; + } + + let used = window.used_percent.clamp(0.0, 100.0); + if show_as_used { used } else { 100.0 - used } +} + +#[cfg(test)] +#[path = "tray_presentation_tests.rs"] +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs new file mode 100644 index 0000000000..af3ee9ccce --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -0,0 +1,791 @@ +use super::*; +use codexbar::core::{ProviderId, ProviderStateKind}; + +fn fake_snapshot(id: &str, display_name: &str, used_percent: f64) -> ProviderUsageSnapshot { + fake_snapshot_with(id, display_name, used_percent, None, None, None) +} + +fn fake_snapshot_with( + id: &str, + display_name: &str, + used_percent: f64, + secondary_percent: Option, + tertiary_percent: Option, + cost: Option<(f64, f64)>, +) -> ProviderUsageSnapshot { + let window = |percent: f64| RateWindowSnapshot { + used_percent: percent, + remaining_percent: 100.0 - percent, + window_minutes: None, + resets_at: None, + reset_description: None, + is_exhausted: false, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, + }; + + ProviderUsageSnapshot { + provider_id: id.into(), + display_name: display_name.into(), + primary: window(used_percent), + primary_label: None, + secondary: secondary_percent.map(window), + secondary_label: None, + model_specific: None, + tertiary: tertiary_percent.map(window), + tertiary_label: None, + extra_rate_windows: Vec::new(), + inventory: Vec::new(), + display_details: Vec::new(), + cost: cost.map(|(used, limit)| crate::commands::CostSnapshotBridge { + used, + 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}"), + formatted_limit: Some(format!("${limit:.2}")), + balance: None, + formatted_balance: None, + balance_updated_at: None, + account_id: None, + daily: Vec::new(), + always_visible: false, + }), + plan_name: None, + account_email: None, + subscription: None, + source_label: String::new(), + has_successful_claude_cli_quota: false, + updated_at: "2025-01-01T00:00:00Z".into(), + error: None, + error_state: ProviderStateKind::Ready, + pace: None, + account_organization: None, + open_ai_api_usage: None, + tray_status_label: None, + fetch_duration_ms: None, + wayfinder_usage: None, + session_equivalent_forecast: None, + quota_burndown: None, + } +} + +#[test] +fn single_plan_uses_highest_provider_for_icon_and_summary() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Single, + menu_bar_shows_highest_usage: true, + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert_eq!( + plan.icon, + TrayIconPlan::Bars { + primary_percent: 72.0, + secondary_percent: None, + has_error: false, + } + ); + assert_eq!( + plan.status_labels(Language::English), + vec![("status_summary".to_string(), "Claude 72%".to_string())] + ); +} + +#[test] +fn single_plan_borrows_selected_snapshot_from_stable_input() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Single, + menu_bar_shows_highest_usage: true, + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + // `resolve` drops its temporary ordered/healthy vectors before returning. + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert!(std::ptr::eq(plan.status_rows[0].snapshot, &snapshots[1])); +} + +#[test] +fn per_provider_plan_preserves_configured_order_for_status_rows() { + let settings = Settings { + tray_icon_mode: TrayIconMode::PerProvider, + provider_order: codexbar::settings::normalize_provider_order(&[ + "claude".to_string(), + "codex".to_string(), + ]), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + let labels = + TrayPresentationPlan::resolve(&settings, &snapshots, None).status_labels(Language::English); + + assert_eq!( + labels, + vec![ + ("claude".to_string(), "Claude 72%".to_string()), + ("codex".to_string(), "Codex 30%".to_string()), + ] + ); +} + +#[test] +fn stacked_plan_resolves_distinct_preferences_once() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + stacked_tray_top_provider: Some("claude".to_string()), + stacked_tray_bottom_provider: Some("codex".to_string()), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + fake_snapshot("gemini", "Gemini", 44.0), + ]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert_eq!( + plan.icon, + TrayIconPlan::Stacked { + top_percent: 72.0, + bottom_percent: 30.0, + has_error: false, + } + ); + assert_eq!( + plan.status_labels(Language::English), + vec![ + ("claude".to_string(), "Claude 72%".to_string()), + ("codex".to_string(), "Codex 30%".to_string()), + ] + ); +} + +#[test] +fn stacked_plan_borrows_both_snapshots_from_stable_input() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + stacked_tray_top_provider: Some("claude".to_string()), + stacked_tray_bottom_provider: Some("codex".to_string()), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + // The plan retains references to the caller-owned snapshots, not the + // temporary vector of references used during selection. + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert!(std::ptr::eq(plan.status_rows[0].snapshot, &snapshots[1])); + assert!(std::ptr::eq(plan.status_rows[1].snapshot, &snapshots[0])); +} + +#[test] +fn stacked_plan_falls_back_around_stale_and_duplicate_preferences() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + stacked_tray_top_provider: Some("missing".to_string()), + stacked_tray_bottom_provider: Some("claude".to_string()), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert_eq!( + plan.icon, + TrayIconPlan::Stacked { + top_percent: 30.0, + bottom_percent: 72.0, + has_error: false, + } + ); + assert_eq!(plan.status_rows[0].snapshot.provider_id, "codex"); + assert_eq!(plan.status_rows[1].snapshot.provider_id, "claude"); +} + +#[test] +fn one_provider_stacked_mode_falls_back_to_single_provider_bars() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + ..Settings::default() + }; + let snapshots = vec![fake_snapshot_with( + "codex", + "Codex", + 30.0, + Some(65.0), + None, + None, + )]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert_eq!( + plan.icon, + TrayIconPlan::Bars { + primary_percent: 65.0, + secondary_percent: Some(30.0), + has_error: false, + } + ); + assert_eq!(plan.status_rows.len(), 1); +} + +#[test] +fn one_healthy_provider_never_uses_stacked_renderer() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + menu_bar_shows_percent: true, + ..Settings::default() + }; + let healthy = fake_snapshot("codex", "Codex", 30.0); + let mut failed = fake_snapshot("claude", "Claude", 72.0); + failed.error = Some("offline".to_string()); + let snapshots = vec![healthy, failed]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert_eq!( + plan.icon, + TrayIconPlan::Percent { + percent: 30.0, + has_error: false, + } + ); + assert_eq!(plan.status_rows.len(), 1); + assert_eq!(plan.status_rows[0].snapshot.provider_id, "codex"); +} + +#[test] +fn all_errors_produce_error_styled_zero_percent_plan() { + let settings = Settings { + menu_bar_shows_percent: true, + ..Settings::default() + }; + let mut snapshot = fake_snapshot("codex", "Codex", 30.0); + snapshot.error = Some("offline".to_string()); + let snapshots = vec![snapshot]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert_eq!( + plan.icon, + TrayIconPlan::Percent { + percent: 0.0, + has_error: true, + } + ); + assert!(plan.status_rows.is_empty()); +} + +#[test] +fn plan_uses_selected_metric_and_remaining_display_mode() { + let mut settings = Settings { + show_as_used: false, + ..Settings::default() + }; + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); + let snapshots = vec![fake_snapshot_with( + "cursor", + "Cursor", + 10.0, + Some(20.0), + Some(72.0), + Some((15.0, 100.0)), + )]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert_eq!( + plan.icon, + TrayIconPlan::Bars { + primary_percent: 85.0, + secondary_percent: Some(80.0), + has_error: false, + } + ); +} + +#[test] +fn render_icon_delegates_to_resolved_stacked_renderer() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + stacked_tray_top_provider: Some("claude".to_string()), + stacked_tray_bottom_provider: Some("codex".to_string()), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 40.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + let plan = TrayPresentationPlan::resolve(&settings, &snapshots, None); + + assert_eq!( + plan.render_icon(), + render_stacked_bar_icon_rgba(72.0, 40.0, false) + ); +} + +#[test] +fn codex_headline_skips_informational_primary() { + let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(25.0), Some(30.0), None); + snapshot.primary.is_informational = true; + + assert_eq!(codex_lane_headline_window(&snapshot).used_percent, 25.0); +} +fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { + crate::commands::NamedRateWindowSnapshot { + id: "additional_budget".to_string(), + title: "Additional Budget".to_string(), + fallback_lane: false, + window: crate::commands::RateWindowSnapshot { + used_percent: percent, + remaining_percent: 100.0 - percent, + window_minutes: None, + resets_at: None, + reset_description: None, + is_exhausted: false, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, + }, + } +} + +#[test] +fn selected_tray_percent_uses_cursor_extra_usage_cost() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); + let snapshot = fake_snapshot_with( + "cursor", + "Cursor", + 10.0, + Some(20.0), + Some(72.0), + Some((15.0, 100.0)), + ); + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 15.0); + assert_eq!(secondary, Some(20.0)); +} + +#[test] +fn selected_tray_percent_tracks_extra_rate_window() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Copilot, MetricPreference::ExtraUsage); + let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); + snapshot.extra_rate_windows.push(fake_extra_window(42.0)); + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 42.0); + assert_eq!(secondary, None); +} + +#[test] +fn copilot_automatic_tracks_highest_extra_rate_window() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); + snapshot.extra_rate_windows.push(fake_extra_window(42.0)); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 42.0); +} + +#[test] +fn selected_tray_percent_respects_remaining_display_mode() { + let mut settings = Settings { + show_as_used: false, + ..Settings::default() + }; + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); + let snapshot = fake_snapshot_with( + "cursor", + "Cursor", + 10.0, + Some(20.0), + Some(72.0), + Some((15.0, 100.0)), + ); + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 85.0); + assert_eq!(secondary, Some(80.0)); +} + +#[test] +fn exhausted_automatic_window_never_renders_as_remaining_progress() { + let mut settings = Settings { + show_as_used: false, + ..Settings::default() + }; + let mut snapshot = fake_snapshot_with( + "opencodego", + "OpenCode Go", + 20.0, + Some(60.0), + Some(40.0), + None, + ); + snapshot + .tertiary + .as_mut() + .expect("monthly quota") + .is_exhausted = true; + + let (remaining, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(remaining, 0.0); + + settings.show_as_used = true; + let (used, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(used, 100.0); +} + +#[test] +fn full_automatic_window_without_exhausted_flag_has_zero_remaining_progress() { + let mut settings = Settings { + show_as_used: false, + ..Settings::default() + }; + let mut snapshot = fake_snapshot_with( + "opencodego", + "OpenCode Go", + 20.0, + Some(60.0), + Some(100.0), + None, + ); + snapshot + .tertiary + .as_mut() + .expect("monthly quota") + .is_exhausted = false; + + let (remaining, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(remaining, 0.0); + + settings.show_as_used = true; + let (used, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(used, 100.0); +} + +#[test] +fn missing_automatic_window_does_not_look_like_available_remaining_progress() { + let settings = Settings { + show_as_used: false, + ..Settings::default() + }; + let mut snapshot = fake_snapshot_with("opencodego", "OpenCode Go", 0.0, None, None, None); + snapshot.primary.is_informational = true; + + let (remaining, _) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(remaining, 0.0); +} + +#[test] +fn selected_tray_percent_falls_back_when_extra_usage_missing() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); + let snapshot = fake_snapshot_with("cursor", "Cursor", 10.0, Some(72.0), None, None); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 72.0); +} + +#[test] +fn single_meaningful_secondary_quota_uses_full_single_meter() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); + snapshot.primary.is_informational = true; + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 42.0); + assert_eq!(secondary, None); +} + +#[test] +fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), Some(30.0), None); + snapshot.primary.is_informational = true; + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 42.0); + assert_eq!(secondary, Some(30.0)); +} + +#[test] +fn two_meaningful_quotas_keep_two_meter_layout() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::Session); + let snapshot = fake_snapshot_with("cursor", "Cursor", 15.0, Some(40.0), None, None); + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 15.0); + assert_eq!(secondary, Some(40.0)); +} + +#[test] +fn informational_primary_skips_session_and_automatic_phantom_zero() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Claude, MetricPreference::Session); + let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); + snapshot.primary.is_informational = true; + + // Session preference must not paint the synthetic 0% primary; + // it falls through to Automatic which prefers weekly (42%). + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 42.0); + assert_ne!(primary, 0.0); + + // Automatic also prefers weekly over informational primary. + settings.set_provider_metric(ProviderId::Claude, MetricPreference::Automatic); + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 42.0); +} + +#[test] +fn claude_automatic_prefers_weekly_when_model_exhausted() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot_with("claude", "Claude", 40.0, Some(22.0), None, None); + snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { + used_percent: 100.0, + remaining_percent: 0.0, + window_minutes: Some(10080), + resets_at: None, + reset_description: None, + is_exhausted: true, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, + }); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 22.0); + + // Explicit model override is untouched. + let mut overridden = settings.clone(); + overridden.set_provider_metric(ProviderId::Claude, MetricPreference::Model); + let (primary, _) = selected_tray_percents(&snapshot, &overridden); + assert_eq!(primary, 100.0); +} + +#[test] +fn automatic_prefers_exhausted_weekly_over_low_session() { + let settings = Settings::default(); + let snapshot = fake_snapshot_with("codex", "Codex", 20.0, Some(100.0), None, None); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 100.0); + + // Explicit session override still wins. + let mut overridden = settings.clone(); + overridden.set_provider_metric(ProviderId::Codex, MetricPreference::Session); + let (primary, _) = selected_tray_percents(&snapshot, &overridden); + assert_eq!(primary, 20.0); +} + +#[test] +fn automatic_picks_highest_among_model_and_extra_windows() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot_with("gemini", "Gemini", 10.0, Some(30.0), Some(40.0), None); + snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { + used_percent: 55.0, + remaining_percent: 45.0, + window_minutes: None, + resets_at: None, + reset_description: None, + is_exhausted: false, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, + }); + snapshot.extra_rate_windows.push(fake_extra_window(90.0)); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 90.0); +} + +#[test] +fn f5_headline_prefers_non_informational_primary() { + let snapshot = fake_snapshot_with("codex", "Codex", 50.0, Some(20.0), Some(30.0), None); + let headline = codex_lane_headline_window(&snapshot); + assert!((headline.used_percent - 50.0).abs() < f64::EPSILON); +} + +#[test] +fn f5_headline_falls_back_to_secondary_when_primary_informational() { + let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(25.0), Some(30.0), None); + snapshot.primary.is_informational = true; + let headline = codex_lane_headline_window(&snapshot); + assert!((headline.used_percent - 25.0).abs() < f64::EPSILON); +} + +#[test] +fn f5_headline_falls_back_to_tertiary_when_primary_and_secondary_informational() { + let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(35.0), None); + snapshot.primary.is_informational = true; + snapshot.secondary.as_mut().unwrap().is_informational = true; + let headline = codex_lane_headline_window(&snapshot); + assert!((headline.used_percent - 35.0).abs() < f64::EPSILON); +} + +#[test] +fn f5_headline_returns_primary_when_all_informational() { + let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(0.0), None); + snapshot.primary.is_informational = true; + if let Some(sec) = &mut snapshot.secondary { + sec.is_informational = true; + } + if let Some(ter) = &mut snapshot.tertiary { + ter.is_informational = true; + } + let headline = codex_lane_headline_window(&snapshot); + // Falls back to primary (the placeholder) when all are informational. + assert!(headline.is_informational); +} + +#[test] +fn tray_pace_color_is_opt_in_and_follows_canonical_stage() { + let pace = |stage: &str, delta_percent: f64| crate::commands::PaceSnapshot { + stage: stage.to_string(), + delta_percent, + will_last_to_reset: true, + eta_seconds: None, + expected_used_percent: 40.0, + actual_used_percent: 32.0, + monthly_limit_block: None, + }; + let with_pace = |pace: Option| { + let mut snapshot = fake_snapshot("codex", "Codex", 50.0); + snapshot.pace = pace; + vec![snapshot] + }; + let default_settings = Settings::default(); + let colored_settings = Settings { + menu_bar_color_pace: true, + ..Settings::default() + }; + let colored_percent_settings = Settings { + menu_bar_color_pace: true, + menu_bar_shows_percent: true, + ..Settings::default() + }; + let render = |settings: &Settings, snapshots: &[ProviderUsageSnapshot]| { + TrayPresentationPlan::resolve(settings, snapshots, None).render_icon() + }; + + let unpaced = with_pace(None); + let behind_snapshots = with_pace(Some(pace("behind", -8.0))); + let ahead_snapshots = with_pace(Some(pace("ahead", 8.0))); + let on_track_snapshots = with_pace(Some(pace("on_track", 1.0))); + + let normal = render(&default_settings, &unpaced); + let disabled = render(&default_settings, &behind_snapshots); + let behind = render(&colored_settings, &behind_snapshots); + let ahead = render(&colored_settings, &ahead_snapshots); + let on_track = render(&colored_settings, &on_track_snapshots); + + assert_eq!(normal, disabled); + assert_eq!(normal, on_track); + assert_ne!(normal, behind); + assert_ne!(behind, ahead); + + let percent_normal = render( + &Settings { + menu_bar_shows_percent: true, + ..Settings::default() + }, + &unpaced, + ); + let percent_behind = render(&colored_percent_settings, &behind_snapshots); + assert_ne!(percent_normal, percent_behind); +} + +#[test] +fn stacked_tray_icon_keeps_usage_colours_when_pace_tint_is_enabled() { + let mut codex = fake_snapshot("codex", "Codex", 50.0); + codex.pace = Some(crate::commands::PaceSnapshot { + stage: "far_ahead".to_string(), + delta_percent: 20.0, + will_last_to_reset: false, + eta_seconds: None, + expected_used_percent: 30.0, + actual_used_percent: 50.0, + monthly_limit_block: None, + }); + let claude = fake_snapshot("claude", "Claude", 20.0); + let snapshots = vec![codex.clone(), claude.clone()]; + let unpaced = vec![ + ProviderUsageSnapshot { + pace: None, + ..codex + }, + claude, + ]; + let stacked = |color_pace: bool| Settings { + tray_icon_mode: TrayIconMode::Stacked, + menu_bar_color_pace: color_pace, + ..Settings::default() + }; + + let stacked_settings = stacked(true); + let plan = TrayPresentationPlan::resolve(&stacked_settings, &snapshots, None); + assert!(matches!(plan.icon, TrayIconPlan::Stacked { .. })); + assert_eq!(plan.icon_pace, None); + let unpaced_settings = stacked(false); + assert_eq!( + plan.render_icon(), + TrayPresentationPlan::resolve(&unpaced_settings, &unpaced, None).render_icon() + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index f6e8f5f532..d33eb79eef 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -63,6 +63,7 @@ fn same_window(left: &RateWindowSnapshot, right: &RateWindowSnapshot) -> bool { && left.window_minutes == right.window_minutes && left.resets_at == right.resets_at && left.reset_description == right.reset_description + && left.description_is_detail == right.description_is_detail && left.is_informational == right.is_informational } @@ -94,10 +95,45 @@ fn preferred_window( extra_usage_window(snapshot).or_else(|| cost_window(snapshot)) } MetricPreference::Average => average_window(snapshot), - MetricPreference::MonthlyPlan => cost_window(snapshot), + // Upstream 0.70.0 (#4072): the provider's plan allowance. A missing + // or unknown plan falls back to the primary (included API) allowance, + // never to a spend window. Providers without a plan window fall + // through to Automatic. + MetricPreference::MonthlyPlan => { + let window_id = provider.and_then(monthly_plan_window_id)?; + plan_window(snapshot, window_id) + .or_else(|| non_informational(Some(&snapshot.primary))) + .cloned() + } } } +/// The provider-declared monthly plan allowance, when the snapshot carries a +/// known value for it. Providers without a plan window return `None`. +#[cfg(test)] +pub(crate) fn monthly_plan_window( + snapshot: &ProviderUsageSnapshot, + provider: Option, +) -> Option<&RateWindowSnapshot> { + plan_window(snapshot, provider.and_then(monthly_plan_window_id)?) +} + +fn monthly_plan_window_id(provider: ProviderId) -> Option<&'static str> { + codexbar::core::instantiate_provider(provider).monthly_plan_window_id() +} + +fn plan_window<'a>( + snapshot: &'a ProviderUsageSnapshot, + window_id: &str, +) -> Option<&'a RateWindowSnapshot> { + snapshot + .extra_rate_windows + .iter() + .find(|extra| extra.id == window_id) + .map(|extra| &extra.window) + .filter(|window| !window.is_informational) +} + fn automatic_window( snapshot: &ProviderUsageSnapshot, provider: Option, @@ -136,6 +172,20 @@ fn automatic_window( return None; } + if policy.prefers_secondary_window { + let primary = non_informational(Some(&snapshot.primary)); + let secondary = non_informational(snapshot.secondary.as_ref()); + let preferred = primary + .into_iter() + .chain(secondary) + .find(|window| automatic_window_is_exhausted(window)) + .or(secondary) + .or(primary); + if let Some(window) = preferred { + return Some(window.clone()); + } + } + let mut windows = Vec::with_capacity(4 + snapshot.extra_rate_windows.len()); windows.push(&snapshot.primary); windows.extend(snapshot.secondary.iter()); @@ -180,6 +230,9 @@ struct AutomaticMetricPolicy { /// is a dead end for Automatic selection. False for providers whose /// fallback lanes (seat credits) should still be considered. missing_core_is_terminal: bool, + /// Whether the secondary lane represents the provider unless a core lane + /// is exhausted (upstream's LiteLLM team-budget resolver). + prefers_secondary_window: bool, } fn automatic_metric_policy(provider: Option) -> AutomaticMetricPolicy { @@ -189,12 +242,16 @@ fn automatic_metric_policy(provider: Option) -> AutomaticMetricPolic let missing_core_is_terminal = |id: ProviderId| { codexbar::core::instantiate_provider(id).automatic_metric_missing_core_is_terminal() }; + let prefers_secondary = |id: ProviderId| { + codexbar::core::instantiate_provider(id).automatic_metric_prefers_secondary_window() + }; match provider { Some(ProviderId::Antigravity) => AutomaticMetricPolicy { prefers_available_window: true, prioritizes_exhausted_window: false, uses_extra_windows: false, missing_core_is_terminal: true, + prefers_secondary_window: false, }, // Cursor's monthly Auto lane is the semantic weekly pace. Grok Bot is // a named extra allowance and must stay available through the explicit @@ -204,18 +261,21 @@ fn automatic_metric_policy(provider: Option) -> AutomaticMetricPolic prioritizes_exhausted_window: prioritizes(ProviderId::Cursor), uses_extra_windows: false, missing_core_is_terminal: true, + prefers_secondary_window: false, }, Some(id) => AutomaticMetricPolicy { prefers_available_window: false, prioritizes_exhausted_window: prioritizes(id), uses_extra_windows: true, missing_core_is_terminal: missing_core_is_terminal(id), + prefers_secondary_window: prefers_secondary(id), }, None => AutomaticMetricPolicy { prefers_available_window: false, prioritizes_exhausted_window: true, uses_extra_windows: true, missing_core_is_terminal: false, + prefers_secondary_window: false, }, } } @@ -267,6 +327,8 @@ fn derived_window(used_percent: f64, resets_at: Option) -> RateWindowSna reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, } } @@ -352,6 +414,8 @@ mod tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + quota_burndown: None, + open_ai_api_usage: None, session_equivalent_forecast: None, } } @@ -483,6 +547,38 @@ mod tests { ); } + #[test] + fn kimi_monthly_only_snapshot_selects_the_total_usage_lane() { + // Upstream 0.60.5 #3694: a Code API response may report only the + // monthly Total usage pool. The weekly lane is then an informational + // placeholder, and every metric preference lands on the monthly lane. + let mut snapshot = snapshot(); + snapshot.provider_id = "kimi".to_string(); + snapshot.primary = RateWindowSnapshot { + is_informational: true, + ..window(0.0) + }; + snapshot.secondary = None; + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: "kimi-monthly".to_string(), + title: "Total usage".to_string(), + window: window(100.0), + fallback_lane: false, + }]; + + for preference in [ + MetricPreference::Automatic, + MetricPreference::Session, + MetricPreference::Weekly, + ] { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Kimi, preference); + let selected = selected_usage_window(&snapshot, &settings); + assert!(!selected.is_informational, "{preference:?}"); + assert_eq!(selected.used_percent, 100.0, "{preference:?}"); + } + } + #[test] fn opencodego_automatic_prefers_explicitly_exhausted_window_over_higher_percentage() { let mut snapshot = snapshot(); @@ -604,6 +700,119 @@ mod tests { assert_eq!(selected.remaining_percent, 60.0); } + fn mistral_snapshot(plan: Option) -> ProviderUsageSnapshot { + let mut snapshot = snapshot(); + snapshot.provider_id = "mistral".to_string(); + snapshot.display_name = "Mistral".to_string(); + snapshot.primary = window(2.0); + snapshot.primary_label = Some("Included API".to_string()); + snapshot.secondary = None; + snapshot.extra_rate_windows = plan + .map(|window| crate::commands::NamedRateWindowSnapshot { + id: codexbar::providers::mistral::MONTHLY_PLAN_WINDOW_ID.to_string(), + title: "Monthly Plan".to_string(), + window, + fallback_lane: false, + }) + .into_iter() + .collect(); + snapshot + } + + fn metric_settings(provider: ProviderId, preference: MetricPreference) -> Settings { + let mut settings = Settings::default(); + settings.set_provider_metric(provider, preference); + settings + } + + #[test] + fn mistral_monthly_plan_selects_the_vibe_plan_window() { + let mut snapshot = mistral_snapshot(Some(window(42.0))); + snapshot.primary = window(80.0); + + let monthly_plan = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); + assert_eq!( + selected_usage_window(&snapshot, &monthly_plan).used_percent, + 42.0 + ); + // Included API and Automatic stay separate choices (upstream #4072). + let included_api = metric_settings(ProviderId::Mistral, MetricPreference::Session); + assert_eq!( + selected_usage_window(&snapshot, &included_api).used_percent, + 80.0 + ); + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 80.0 + ); + let presentation = + crate::commands::ProviderUsagePresentationSnapshot::new(snapshot, &monthly_plan); + assert_eq!(presentation.selected_metric.used_percent, 42.0); + } + + #[test] + fn mistral_monthly_plan_falls_back_to_included_api_without_a_known_plan() { + let settings = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); + let unknown_plan = RateWindowSnapshot { + is_informational: true, + ..window(0.0) + }; + + for snapshot in [mistral_snapshot(None), mistral_snapshot(Some(unknown_plan))] { + let selected = selected_usage_window(&snapshot, &settings); + assert_eq!(selected.used_percent, 2.0); + assert!(!selected.is_informational); + } + } + + #[test] + fn mistral_monthly_plan_never_selects_a_spend_window() { + let mut snapshot = mistral_snapshot(None); + snapshot.cost = Some(crate::commands::CostSnapshotBridge { + used: 45.0, + limit: Some(50.0), + remaining: Some(5.0), + currency_code: "EUR".to_string(), + currency_symbol: Some("€".to_string()), + period: "month".to_string(), + resets_at: None, + formatted_used: "€45.00".to_string(), + formatted_limit: Some("€50.00".to_string()), + balance: None, + balance_updated_at: None, + account_id: None, + formatted_balance: None, + daily: Vec::new(), + always_visible: false, + }); + let settings = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); + + assert_eq!( + selected_usage_window(&snapshot, &settings).used_percent, + 2.0 + ); + } + + #[test] + fn monthly_plan_without_a_provider_plan_window_falls_through_to_automatic() { + let mut snapshot = snapshot(); + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: codexbar::providers::mistral::MONTHLY_PLAN_WINDOW_ID.to_string(), + title: "Monthly Plan".to_string(), + window: window(90.0), + fallback_lane: false, + }]; + let settings = metric_settings(ProviderId::Codex, MetricPreference::MonthlyPlan); + + // Codex declares no plan window, so a same-named lane is not special + // and the choice resolves like Automatic (highest real window). + assert_eq!( + selected_usage_window(&snapshot, &settings).used_percent, + selected_usage_window(&snapshot, &Settings::default()).used_percent + ); + assert!(monthly_plan_window(&snapshot, Some(ProviderId::Codex)).is_none()); + } + #[test] fn presentation_payload_flattens_the_snapshot_and_selected_metric() { let presentation = crate::commands::ProviderUsagePresentationSnapshot::new( @@ -680,4 +889,56 @@ mod tests { ); assert!(restored.hidden_usage_item_ids.is_empty()); } + + fn litellm_budgets(personal: f64, team: Option) -> ProviderUsageSnapshot { + let mut snapshot = snapshot(); + snapshot.provider_id = "litellm".to_string(); + snapshot.primary = window(personal); + snapshot.secondary = team.map(window); + snapshot + } + + #[test] + fn litellm_automatic_prefers_the_team_budget_over_a_fuller_personal_budget() { + let snapshot = litellm_budgets(60.0, Some(7.0)); + + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 7.0 + ); + let (selected, companion) = selected_usage_icon_windows(&snapshot, &Settings::default()); + assert_eq!(selected.used_percent, 7.0); + assert_eq!(companion.map(|window| window.used_percent), Some(60.0)); + } + + #[test] + fn litellm_automatic_shows_an_exhausted_budget_first() { + let personal_exhausted = litellm_budgets(100.0, Some(7.0)); + assert_eq!( + selected_usage_window(&personal_exhausted, &Settings::default()).used_percent, + 100.0 + ); + + let team_exhausted = litellm_budgets(40.0, Some(100.0)); + assert_eq!( + selected_usage_window(&team_exhausted, &Settings::default()).used_percent, + 100.0 + ); + } + + #[test] + fn litellm_automatic_uses_the_only_budget_and_explicit_choices_still_win() { + let personal_only = litellm_budgets(25.0, None); + assert_eq!( + selected_usage_window(&personal_only, &Settings::default()).used_percent, + 25.0 + ); + + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::LiteLLM, MetricPreference::Session); + assert_eq!( + selected_usage_window(&litellm_budgets(60.0, Some(7.0)), &settings).used_percent, + 60.0 + ); + } } diff --git a/apps/desktop-tauri/src-tauri/tauri.conf.json b/apps/desktop-tauri/src-tauri/tauri.conf.json index 99017595a6..a1f15a26d9 100644 --- a/apps/desktop-tauri/src-tauri/tauri.conf.json +++ b/apps/desktop-tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CodexBar Desktop", - "version": "0.60.3", + "version": "0.70.0", "identifier": "com.codexbar.desktop", "build": { "beforeDevCommand": "pnpm run dev", @@ -23,6 +23,7 @@ "decorations": false, "shadow": false, "visible": false, + "focus": false, "skipTaskbar": true, "alwaysOnTop": true, "theme": "Dark", diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 10e46694eb..937f3cca18 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -89,6 +89,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { switcherShowsIcons: true, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarColorPace: false, showAsUsed: true, showAllTokenAccountsInMenu: false, enableAnimations: true, diff --git a/apps/desktop-tauri/src/App.tsx b/apps/desktop-tauri/src/App.tsx index 4b39c0286f..610020c678 100644 --- a/apps/desktop-tauri/src/App.tsx +++ b/apps/desktop-tauri/src/App.tsx @@ -16,6 +16,7 @@ import { LocaleProvider } from "./i18n/LocaleProvider"; import type { BootstrapState, ThemePreference } from "./types/bridge"; import type { SurfaceSnapshot } from "./hooks/useSurfaceSnapshot"; import { useDeepSeekPricingStatus } from "./hooks/useDeepSeekPricingStatus"; +import { CurrencyProvider } from "./hooks/CurrencyProvider"; const Settings = lazy(() => import("./surfaces/Settings")); const FloatBar = lazy(() => import("./floatbar/FloatBar")); @@ -48,7 +49,9 @@ function initialSettingsTab(): string { export default function App() { return ( - + + + ); } diff --git a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx index 2cd1c79c0a..a6e9edeb03 100644 --- a/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx +++ b/apps/desktop-tauri/src/components/CodexAccountsMenu.test.tsx @@ -202,7 +202,7 @@ describe("CodexAccountsMenu", () => { expect(tauriMocks.codexAccountSwitch).toHaveBeenCalledWith("2"); expect(tauriMocks.refreshProviders).toHaveBeenCalledTimes(1); }); - it("uses an opaque ordinal only for the ambient account while hideEmail is on", async () => { + it("uses an opaque ordinal for every account while hideEmail is on", async () => { const { container: hidden } = renderMenu(true, { accounts: [account("1", { source: "ambient" }), account("2")], accountOrdinals: { "1": 1, "2": 2 }, @@ -213,6 +213,8 @@ describe("CodexAccountsMenu", () => { hidden.querySelectorAll(".codex-menu-accounts__email").length, ).toBe(2); }); + // Upstream 0.60.5 #3702 (141ecf642): every account is redacted, not only + // ambient ones — managed accounts get the generic label too. const ambientLabel = hidden.querySelectorAll( ".codex-menu-accounts__email", )[0] as HTMLElement; @@ -221,10 +223,12 @@ describe("CodexAccountsMenu", () => { expect(ambientLabel.firstChild?.textContent).not.toContain("@"); expect(ambientLabel.firstChild?.textContent).not.toContain("example.com"); - const managedEmail = hidden.querySelectorAll( + const managedLabel = hidden.querySelectorAll( ".codex-menu-accounts__email", )[1] as HTMLElement; - expect(managedEmail.textContent).toBe("user-2@example.com"); + expect(managedLabel.getAttribute("title")).toBe("Account 2"); + expect(managedLabel.textContent).toBe("Account 2"); + expect(managedLabel.textContent).not.toContain("user-2@example.com"); const switches = hidden.querySelectorAll( ".codex-menu-accounts__switch", ) as NodeListOf; @@ -247,7 +251,7 @@ describe("CodexAccountsMenu", () => { expect(rawEmail.getAttribute("title")).toBe("user-2@example.com"); }); - it("uses canonical opaque ordinals supplied by the account bridge", async () => { + it("uses canonical opaque ordinals for every account supplied by the bridge", async () => { const first = account("uuid-b", { emailHint: "alice@example.com", nickname: "team@example.com", @@ -263,12 +267,16 @@ describe("CodexAccountsMenu", () => { accountOrdinals: { "uuid-b": 2, "uuid-a": 1 }, snapshots: {}, }); + // Upstream 0.60.5 #3702 (141ecf642): every account is redacted, not only + // ambient ones — managed accounts get the generic label too. await screen.findByText("Account 2"); + await screen.findByText("Account 1"); const labels = container.querySelectorAll(".codex-menu-accounts__email"); expect(labels[0].firstChild?.textContent).toBe("Account 2"); - expect(labels[1].textContent).toBe("bob@example.com — Private workspace"); + expect(labels[1].textContent).toBe("Account 1"); expect(labels[0].firstChild?.textContent).not.toContain("@"); - expect(labels[1].textContent).toContain("Private workspace"); + expect(labels[1].textContent).not.toContain("bob@example.com"); + expect(labels[1].textContent).not.toContain("Private workspace"); }); }); diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index c8ec795546..3b45d1c87c 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const tauriMocks = vi.hoisted(() => ({ @@ -55,7 +55,7 @@ function rateWindow( function provider( error: string | null, usedPercent = 0, - opts: { exhausted?: boolean; resetDescription?: string | null } = {}, + opts: { exhausted?: boolean; resetDescription?: string | null; resetsAt?: string | null } = {}, ): ProviderUsageSnapshot { return { providerId: "claude", @@ -119,16 +119,21 @@ describe("MenuCard", () => { buildBundle({ ActionCopyError: "Copy error", ApiSpendTitle: "API spend", + AtlasCloudAvailableBalance: "Available balance", + AtlasCloudBalance: "Atlas Cloud balance", DetailPaceRunsOutIn: "Runs out in", PanelEstimatedFromLocalLogs: "Estimated from local logs", PanelLeftSuffix: "left", PanelNow: "now", PanelOneHour: "1h", PanelFiveHours: "5h", + PanelBlockedByMonthlyLimit: "Blocked by monthly limit", PanelOnPaceBudget: "On-pace budget", PanelReserveSuffix: "in reserve", - PanelThirtyDayCost: "30d cost", - PanelThirtyDayTokens: "30d tokens", + PanelPeriodCost: "{} cost", + PanelPeriodTokens: "{} tokens", + CostPeriodShortMonthToDate: "MTD", + CostPeriodShortDays: "{}d", PanelTodayBudget: "today", PanelUsedSuffix: "used", ResetsInHoursMinutes: "Resets in {}h {}m", @@ -146,6 +151,7 @@ describe("MenuCard", () => { WayfinderOffline: "Gateway offline", WayfinderDryRun: "Dry run", WayfinderMissingKeys: "Missing keys", + UsageSpendTokens: "tokens", DeepSeekPricingTitle: "DeepSeek pricing", DeepSeekPricingStandard: "Standard / pre-schedule", DeepSeekPricingPeak: "Peak hours", @@ -160,12 +166,16 @@ describe("MenuCard", () => { tauriMocks.getProviderChartData.mockResolvedValue({ providerId: "claude", costHistory: [{ date: "2026-05-24", value: 1.23 }], + tokensHistory: [{ date: "2026-05-24", tokens: 14_200 }], creditsHistory: [], usageBreakdown: [], localUsage: { todayCost: null, thirtyDayCost: 1.23, thirtyDayTokens: 584_000, + periodCost: 1.23, + periodTokens: 584_000, + reportingPeriod: "rolling:30", latestTokens: null, topModel: "glim-4.6", estimateNote: "Estimated from local logs", @@ -310,7 +320,7 @@ describe("MenuCard", () => { expect(screen.getByText("58% left")).toBeInTheDocument(); }); - it("limits quota rows and suppresses supplemental content in compact Overview", async () => { + it("shows every quota row in compact Overview (upstream 0.62.0 #2616)", async () => { const snapshot = provider(null, 20, { resetDescription: "Resets in 2h" }); snapshot.secondary = rateWindow(42, { windowMinutes: 7 * 24 * 60 }); snapshot.secondaryLabel = "Weekly"; @@ -321,10 +331,8 @@ describe("MenuCard", () => { expect(await screen.findByText("Session")).toBeInTheDocument(); expect(screen.getByText("ProviderWeeklyLabel")).toBeInTheDocument(); - expect(screen.queryByText("ProviderMonthly")).not.toBeInTheDocument(); - expect(document.querySelectorAll(".menu-metric")).toHaveLength(2); - expect(document.querySelector(".menu-metric__reset")).toBeNull(); - expect(document.querySelector(".menu-card__more")).toBeNull(); + expect(screen.getByText("ProviderMonthly")).toBeInTheDocument(); + expect(document.querySelectorAll(".menu-metric")).toHaveLength(3); }); it("localizes Claude scoped weekly extra-window labels", async () => { @@ -356,6 +364,46 @@ describe("MenuCard", () => { expect(await screen.findByText("Fable only")).toBeInTheDocument(); }); + it.each([false, true])( + "shows detail-backed amounts as their own line, never as reset text (resetsAt: %s)", + async (hasReset) => { + const resetsAt = hasReset + ? new Date(Date.now() + 3 * 60 * 60 * 1000 + 30_000).toISOString() + : null; + const snapshot = provider(null, 75, { + resetDescription: "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", + resetsAt, + }); + snapshot.providerId = "mistral"; + snapshot.primary.descriptionIsDetail = true; + snapshot.selectedMetric.descriptionIsDetail = true; + snapshot.extraRateWindows = [ + { + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: { + ...rateWindow(13, { + resetDescription: "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + resetsAt, + }), + descriptionIsDetail: true, + }, + }, + ]; + + renderCard(snapshot); + + const primaryDetail = await screen.findByText( + "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", + ); + const planDetail = screen.getByText("34.07 EUR / 255.00 EUR · 220.93 EUR remaining"); + expect(primaryDetail).toHaveClass("menu-metric__detail"); + expect(planDetail).toHaveClass("menu-metric__detail"); + expect(screen.queryByText(/Resets .*EUR/)).not.toBeInTheDocument(); + expect(document.querySelectorAll(".menu-metric__reset")).toHaveLength(hasReset ? 2 : 0); + }, + ); + it("renders informational metrics without quota percentages", async () => { const snapshot = provider(null, 20); snapshot.extraRateWindows = [ @@ -585,6 +633,148 @@ describe("MenuCard", () => { expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); }); + function kimiBlockedByMonthlyPool(blockResetsAt: string | null, poolResetsAt: string) { + const hour = 60 * 60 * 1000; + const shortReset = new Date(Date.now() + 3 * hour).toISOString(); + const block = { resetsAt: blockResetsAt }; + const snapshot = provider(null); + snapshot.providerId = "kimi"; + snapshot.displayName = "Kimi"; + snapshot.primaryLabel = "Code 7-day"; + // Raw provider percentages stay untouched (0% used): the block alone + // decides the presentation, as in upstream `blockingQuotaMetrics`. + snapshot.primary = { + ...rateWindow(0, { + windowMinutes: 7 * 24 * 60, + resetsAt: shortReset, + reservePercent: 30, + reserveWillLastToReset: true, + }), + monthlyLimitBlock: block, + }; + snapshot.secondaryLabel = "Code 5-hour"; + snapshot.secondary = { + ...rateWindow(0, { windowMinutes: 5 * 60, resetsAt: shortReset }), + monthlyLimitBlock: block, + }; + snapshot.sessionEquivalentForecast = { + estimatedWindowsToExhaustWeekly: 4, + windowsUntilReset: 6, + availableWindowsUntilReset: 6, + sampleCount: 3, + weeklyResetsAt: shortReset, + weeklyUsedPercent: 0, + }; + snapshot.extraRateWindows = [ + { + id: "kimi-monthly", + title: "Total usage", + window: rateWindow(100, { + windowMinutes: 30 * 24 * 60, + exhausted: true, + resetsAt: poolResetsAt, + }), + }, + ]; + snapshot.pace = { + stage: "far_behind", + deltaPercent: -40, + expectedUsedPercent: 40, + actualUsedPercent: 0, + etaSeconds: null, + willLastToReset: true, + monthlyLimitBlock: block, + }; + return snapshot; + } + + it("shows only the title and status for windows blocked by an exhausted monthly pool", async () => { + const poolReset = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); + const { container } = renderCard(kimiBlockedByMonthlyPool(poolReset, poolReset), { + showAsUsed: true, + }); + + expect(await screen.findAllByText("Blocked by monthly limit")).toHaveLength(2); + const blockedRows = container.querySelectorAll(".menu-metric--blocked"); + expect(blockedRows).toHaveLength(2); + expect(blockedRows[0]).toHaveTextContent(/^Code 7-dayBlocked by monthly limit$/); + expect(blockedRows[1]).toHaveTextContent(/^Code 5-hourBlocked by monthly limit$/); + for (const row of blockedRows) { + expect(row.querySelector(".menu-metric__bar")).toBeNull(); + expect(row.querySelector(".menu-metric__pct")).toBeNull(); + expect(row.querySelector(".menu-metric__reset")).toBeNull(); + } + // The pool row keeps its own bar, percent, reset and exhausted label. + expect(screen.getByText("Total usage")).toBeInTheDocument(); + expect(screen.getAllByText("100% used")).toHaveLength(1); + expect(screen.queryByText("0% used")).not.toBeInTheDocument(); + expect(container.querySelectorAll(".menu-metric__reset")).toHaveLength(1); + expect(container.querySelectorAll(".menu-metric__exhausted")).toHaveLength(1); + // No pace, reserve, budget or session forecast for blocked windows. + expect(screen.queryByText(/in reserve/)).not.toBeInTheDocument(); + expect(screen.queryByText("On-pace budget")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-card__pace")).not.toBeInTheDocument(); + }); + + it("keeps a block without a known pool reset", async () => { + const poolReset = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); + const { container } = renderCard(kimiBlockedByMonthlyPool(null, poolReset), { + showAsUsed: true, + }); + + expect(await screen.findAllByText("Blocked by monthly limit")).toHaveLength(2); + expect(container.querySelector(".menu-card__pace")).not.toBeInTheDocument(); + }); + + it("ignores a cached block whose monthly pool reset already passed", async () => { + const past = new Date(Date.now() - 60 * 1000).toISOString(); + const { container } = renderCard(kimiBlockedByMonthlyPool(past, past), { + showAsUsed: true, + }); + + expect(await screen.findAllByText("0% used")).toHaveLength(2); + expect(screen.queryByText("Blocked by monthly limit")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric--blocked")).toBeNull(); + expect(container.querySelector(".menu-card__pace")).toBeInTheDocument(); + }); + + it("lifts the block when the monthly pool resets while the card stays open", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-01T00:00:00Z")); + const poolReset = new Date("2026-06-01T00:10:00Z").toISOString(); + const { container } = renderCard(kimiBlockedByMonthlyPool(poolReset, poolReset), { + showAsUsed: true, + }); + await act(async () => {}); + expect(screen.getAllByText("Blocked by monthly limit")).toHaveLength(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10 * 60 * 1000 - 1000); + }); + expect(screen.getAllByText("Blocked by monthly limit")).toHaveLength(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(screen.queryByText("Blocked by monthly limit")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric--blocked")).toBeNull(); + expect(screen.getAllByText("0% used")).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps windows that are not blocked untouched", async () => { + const snapshot = provider(null, 0, {}); + snapshot.providerId = "kimi"; + renderCard(snapshot, { showAsUsed: true }); + + expect(await screen.findByText("0% used")).toBeInTheDocument(); + expect(screen.queryByText("Blocked by monthly limit")).not.toBeInTheDocument(); + }); + it("renders local token and cost totals after chart data loads", async () => { const { container } = renderCard(provider(null)); @@ -601,6 +791,91 @@ describe("MenuCard", () => { expect(details.open).toBe(true); }); + it("includes the matching daily token count in the local cost tooltip", async () => { + const snapshot = provider(null); + snapshot.providerId = "codex"; + snapshot.displayName = "Codex"; + tauriMocks.getProviderChartData.mockResolvedValue({ + providerId: "codex", + costHistory: [{ date: "2026-05-24", value: 1.23 }], + creditsHistory: [], + usageBreakdown: [], + localUsage: { + todayCost: null, + thirtyDayCost: 1.23, + thirtyDayTokens: 14_200, + latestTokens: null, + topModel: "gpt-5", + estimateNote: "Estimated from local logs", + tokenCostUpdatedAtMs: 1234, + }, + tokensHistory: [{ date: "2026-05-24", tokens: 14_200 }], + tokensIncomplete: false, + }); + + const { container } = renderCard(snapshot); + const bar = await waitFor(() => { + const element = container.querySelector(".menu-card__local-chart span"); + if (!element) throw new Error("local chart bar not rendered yet"); + return element; + }); + + expect(bar).toHaveAttribute("title", "2026-05-24: $1.23 · 14K tokens"); + }); + + it("labels local totals with the selected History window instead of 30 days", async () => { + tauriMocks.getProviderChartData.mockResolvedValue({ + providerId: "claude", + costHistory: [{ date: "2026-05-24", value: 1.23 }], + creditsHistory: [], + usageBreakdown: [], + localUsage: { + todayCost: null, + // The fixed 30-day fields must not leak into the period rows. + thirtyDayCost: 1.23, + thirtyDayTokens: 584_000, + periodCost: 7.5, + periodTokens: 2_000_000, + reportingPeriod: "month-to-date", + latestTokens: null, + topModel: "glim-4.6", + estimateNote: "Estimated from local logs", + tokenCostUpdatedAtMs: 1234, + }, + }); + + renderCard(provider(null)); + + expect(await screen.findByText("MTD cost")).toBeInTheDocument(); + expect(screen.getByText("MTD tokens")).toBeInTheDocument(); + expect(screen.getByText("$7.50")).toBeInTheDocument(); + expect(screen.getByText("2M")).toBeInTheDocument(); + expect(screen.queryByText("30d cost")).not.toBeInTheDocument(); + expect(screen.queryByText("584K")).not.toBeInTheDocument(); + }); + + it("renders provider display details once and hides them in compact overview", async () => { + const snapshot = provider(null); + snapshot.displayDetails = [ + { + id: "atlascloud-available", + sectionTitle: null, + title: "Available balance", + value: "$95.50", + secondaryValue: null, + progress: null, + }, + ]; + + const detailed = renderCard(snapshot); + expect(await screen.findByText("Available balance: $95.50")).toBeInTheDocument(); + expect(screen.getAllByText("Available balance: $95.50")).toHaveLength(1); + detailed.unmount(); + + renderCard(snapshot, { compactOverview: true }); + expect(screen.queryByText("Available balance: $95.50")).not.toBeInTheDocument(); + }); + it("places Claude accounts above metrics and the collapsed usage details", async () => { tauriMocks.claudeAccountsList.mockResolvedValue([ { id: "a", email: "a@example.com", organization: "Personal", isActive: true, isSaved: true }, diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 493ff08e98..516a6e7571 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -9,6 +9,7 @@ import { useLocale } from "../hooks/useLocale"; import { formatRelativeUpdated } from "../lib/relativeTime"; import type { LocaleKey } from "../i18n/keys"; import { providerSupportsChartData } from "../lib/providerCharts"; +import { hideOpenAiApiProjectId } from "../lib/openAiApiIdentity"; import MenuCardDetails, { describeCard, type MetricEntry } from "./MenuCardDetails"; import CodexAccountsMenu from "./CodexAccountsMenu"; import ClaudeAccountsMenu from "./ClaudeAccountsMenu"; @@ -17,6 +18,7 @@ import { DEEPSEEK_PRICING_EVENT } from "../hooks/useDeepSeekPricingStatus"; import { getDeepSeekPricingStatus } from "../lib/tauri"; import type { DeepSeekPricingStatus } from "../types/bridge"; import { isUsageItemVisible } from "../lib/usageItemVisibility"; +import { useMonthlyLimitBlockNow } from "../hooks/useMonthlyLimitBlockNow"; /** Small copy-to-clipboard button matching macOS CopyIconButton (doc.on.doc → checkmark). */ function CopyIconButton({ text }: { text: string }) { @@ -194,7 +196,13 @@ export default function MenuCard({ ? maskEmail(provider.accountEmail) : provider.accountEmail : null; - const planName = !isWayfinder ? displayPlanName(provider.planName, t) : null; + const planName = !isWayfinder + ? displayPlanName(provider.planName, t) + : null; + const displayedPlanName = hideOpenAiApiProjectId( + planName, + provider.providerId === "openaiapi" && hideEmail, + ); const metrics: MetricEntry[] = [ ...(isWayfinder @@ -238,9 +246,15 @@ export default function MenuCard({ resetFormatMode: extra.id === "reset-credits" ? "expires" : "reset", }); } - const visibleMetrics = metrics - .filter((metric) => isUsageItemVisible(provider.hiddenUsageItemIds, metric.id)) - .slice(0, compactOverview ? 2 : metrics.length); + const visibleMetrics = metrics.filter((metric) => + isUsageItemVisible(provider.hiddenUsageItemIds, metric.id), + ); + // Snapshots are cached across failed refreshes, so monthly-limit blocks are + // re-checked against the clock and lift when the pool's reset passes. + const monthlyLimitBlockNow = useMonthlyLimitBlockNow([ + ...visibleMetrics.map((metric) => metric.snap.monthlyLimitBlock), + provider.pace?.monthlyLimitBlock, + ]); const presence = describeCard( provider, @@ -249,6 +263,7 @@ export default function MenuCard({ costSummaryDisplayStyle, showPace, compactOverview, + monthlyLimitBlockNow, ); const { hasDetails } = presence; const cardClassName = [ @@ -285,8 +300,8 @@ export default function MenuCard({ ? provider.updatedAt : formatRelativeUpdated(Date.parse(provider.updatedAt), t)} - {planName && ( - {planName} + {displayedPlanName && ( + {displayedPlanName} )} )} @@ -322,6 +337,7 @@ export default function MenuCard({ showAsUsed, compactOverview, costSummaryDisplayStyle, + monthlyLimitBlockNow, }} metrics={visibleMetrics} chartData={chartData} diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910..217b5c8545 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -2,9 +2,10 @@ import { useState } from "react"; import type { CostSummaryDisplayStyle, DailyCostPoint, - ProviderDisplayDetail, + DailyTokenPoint, PaceSnapshot, ProviderInventoryItem, + OpenAiApiUsageSnapshot, ProviderChartData, ProviderLocalUsageSummary, ProviderUsageSnapshot, @@ -22,8 +23,19 @@ import type { LocaleKey } from "../i18n/keys"; import { paceCategory } from "../surfaces/tray/paceCategory"; import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; import { InventoryItemRow } from "./InventoryRows"; +import { + groupProviderDisplayDetails, + ProviderDisplayRow, +} from "./ProviderDisplayRow"; +import { OpenAIApiUsageChart } from "./OpenAIApiUsageChart"; import { QuotaWindowHistory } from "./QuotaWindowHistory"; +import QuotaBurndownChart from "./QuotaBurndownChart"; import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; +import { isMonthlyLimitBlockActive } from "../lib/monthlyLimitBlock"; +import { periodCostLabel, periodTokensLabel } from "../lib/costPeriod"; +import { providerCostPeriodTitle } from "../lib/providerLabels"; +import { resetDescriptionFallback, windowDetailText } from "../lib/usageWindows"; +import { isDetailSectionVisible } from "../lib/usageItemVisibility"; import PaceDetailsChart from "./PaceDetailsChart"; /** Format a reserve description from raw pace data at render time. */ @@ -96,6 +108,17 @@ function formatCompactCount(value: number | null): string { ); } +function formatLocalUsagePointTitle( + point: DailyCostPoint, + tokenCount: number | undefined, + tokenLabel: string, +): string { + if (point.value == null && tokenCount == null) return point.date; + const cost = point.value == null ? "—" : formatCurrency(point.value, "USD"); + const tokens = tokenCount == null ? "—" : `${formatCompactCount(tokenCount)} ${tokenLabel}`; + return `${point.date}: ${cost} · ${tokens}`; +} + function formatBudget(value: number): string { return value < 10 ? value.toFixed(1).replace(/\.0$/, "") @@ -106,12 +129,17 @@ function LocalUsageBlock({ providerId, summary, costHistory, + tokensHistory, }: { providerId: string; summary: ProviderLocalUsageSummary; costHistory: DailyCostPoint[]; + tokensHistory: DailyTokenPoint[]; }) { const { t } = useLocale(); + // The selected History window; the histogram below stays a fixed 30 days. + const { reportingPeriod, periodCost, periodTokens } = summary; + const isCodex = providerId === "codex"; const isMuse = providerId === "muse"; const visibleHistory = costHistory.slice(-30); @@ -119,6 +147,7 @@ function LocalUsageBlock({ ...visibleHistory.flatMap((point) => (point.value == null ? [] : [point.value])), 0, ); + const tokensByDate = new Map(tokensHistory.map((point) => [point.date, point.tokens])); return (

@@ -137,17 +166,19 @@ function LocalUsageBlock({ {!isMuse && (
- {t("PanelThirtyDayCost")} + + {periodCostLabel(reportingPeriod, t)} + - {summary.thirtyDayCost != null - ? formatCurrency(summary.thirtyDayCost, "USD") - : "—"} + {periodCost != null ? formatCurrency(periodCost, "USD") : "—"}
)}
- {t("PanelThirtyDayTokens")} - {formatCompactCount(summary.thirtyDayTokens)} + + {periodTokensLabel(reportingPeriod, t)} + + {formatCompactCount(periodTokens)}
{!isMuse && (
@@ -166,7 +197,11 @@ function LocalUsageBlock({ height: `${point.value == null || maxCost <= 0 ? 1 : Math.max(4, Math.round((point.value / maxCost) * 64))}px`, opacity: point.value == null ? 0 : undefined, }} - title={point.value == null ? point.date : `${point.date}: ${formatCurrency(point.value, "USD")}`} + title={formatLocalUsagePointTitle( + point, + tokensByDate.get(point.date), + t("UsageSpendTokens"), + )} /> ))}
@@ -294,6 +329,7 @@ type MetricRowDisplay = { showAsUsed?: boolean; compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; + monthlyLimitBlockNow?: number; }; /** @@ -328,7 +364,14 @@ function MetricRow({ showPace = true, showAsUsed = false, compactOverview = false, + monthlyLimitBlockNow, } = display; + // Upstream 0.69.0 #4091: a longer exhausted pool (Kimi's monthly membership) + // blocks this window until the pool resets. Raw percentages stay untouched. + const blocked = isMonthlyLimitBlockActive( + snap.monthlyLimitBlock, + monthlyLimitBlockNow ?? Date.now(), + ); const isInformational = snap.isInformational === true; const usedPct = Number.isFinite(snap.usedPercent) ? Math.max(0, snap.usedPercent) : 0; const barPct = Math.min(100, usedPct); @@ -337,9 +380,10 @@ function MetricRow({ const barDisplayPct = showAsUsed ? barPct : Math.max(0, Math.min(100, remain)); const displayLabel = showAsUsed ? t("PanelUsedSuffix") : t("PanelLeftSuffix"); const level = levelOf(remain, snap.isExhausted); + const detailText = windowDetailText(snap); const resetText = useFormattedResetTime( - snap.resetsAt, - isInformational ? null : snap.resetDescription, + blocked ? null : snap.resetsAt, + isInformational || blocked ? null : resetDescriptionFallback(snap), resetTimeRelative, resetFormatMode ?? "reset", ); @@ -354,6 +398,17 @@ function MetricRow({ const paceView = showPace ? getMetricPaceView(snap) : { kind: "none" as const }; const reserveDescription = formatReserveDescription(snap, t); const forecastText = formatSessionEquivalentEstimate(sessionEquivalentForecast); + if (blocked) { + // Upstream `MetricRow` status layout: title plus one secondary status line, + // no bar, percent, reset, pace, reserve, or forecast. The pool's own row + // keeps its reset; the shorter resets cannot restore access. + return ( +
+ {title} + {t("PanelBlockedByMonthlyLimit")} +
+ ); + } return (
{title} @@ -380,6 +435,9 @@ function MetricRow({ {resetText} )}
+ {!compactOverview && detailText && ( +
{detailText}
+ )} {!compactOverview && !isInformational && snap.isExhausted && (
{exhaustedLabel}
)} @@ -437,8 +495,11 @@ export interface MenuCardPresence { hasCreditsHistory: boolean; hasUsageBreakdown: boolean; hasQuotaWindowHistory: boolean; + hasBurndown: boolean; localUsage: ProviderChartData["localUsage"] | null; wayfinderUsage: ProviderUsageSnapshot["wayfinderUsage"] | null; + /** Per-day OpenAI Admin API history; `openaiapi` only, and only with data. */ + openAiApiUsage: OpenAiApiUsageSnapshot | null; hasDetails: boolean; } @@ -463,6 +524,7 @@ export function describeCard( costSummaryDisplayStyle: CostSummaryDisplayStyle = "detailed", showPace = true, compactOverview = false, + monthlyLimitBlockNow: number = Date.now(), ): MenuCardPresence { const hasCostHistory = chartData !== null && chartData.costHistory.some((point) => point.value != null); @@ -472,11 +534,18 @@ export function describeCard( chartData !== null && chartData.usageBreakdown.length > 0; const hasQuotaWindowHistory = chartData !== null && (chartData.quotaWindowHistory?.windows.length ?? 0) > 0; + const hasBurndown = provider.quotaBurndown != null; const hasCharts = hasCostHistory || hasCreditsHistory || hasUsageBreakdown || hasQuotaWindowHistory; const isWayfinder = provider.providerId === "wayfinder"; const localUsage = provider.error ? null : chartData?.localUsage ?? null; const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; + const openAiApiUsage = + provider.providerId === "openaiapi" && + !provider.error && + (provider.openAiApiUsage?.daily.length ?? 0) > 0 + ? (provider.openAiApiUsage ?? null) + : null; const hasMetrics = visibleMetrics.length > 0; const hasInventory = !provider.error && (provider.inventory?.length ?? 0) > 0; const hasDisplayDetails = !provider.error && (provider.displayDetails?.length ?? 0) > 0; @@ -486,7 +555,8 @@ export function describeCard( const hasPace = showPace && providerAllowsPace(provider.providerId, provider.sourceLabel) && - !!provider.pace; + !!provider.pace && + !isMonthlyLimitBlockActive(provider.pace.monthlyLimitBlock, monthlyLimitBlockNow); const hasDetails = !provider.error && (hasMetrics || @@ -496,7 +566,8 @@ export function describeCard( hasPace || hasCharts || !!localUsage || - !!wayfinderUsage) && + !!wayfinderUsage || + !!openAiApiUsage) && // Compact Overview suppresses supplemental sections entirely; a card // whose only content would be suppressed renders header-only so no empty // divider or details container appears. @@ -512,8 +583,10 @@ export function describeCard( hasCreditsHistory, hasUsageBreakdown, hasQuotaWindowHistory, + hasBurndown, localUsage, wayfinderUsage, + openAiApiUsage, hasDetails, }; } @@ -540,7 +613,16 @@ export default function MenuCardDetails({ display.resetTimeRelative, ); const localCostHistory = chartData?.costHistory ?? []; + const localTokensHistory = chartData?.tokensHistory ?? []; const costStyle = display.costSummaryDisplayStyle ?? "detailed"; + const displayDetailGroups = groupProviderDisplayDetails( + provider.displayDetails ?? [], + ); + const costPeriod = providerCostPeriodTitle( + provider.providerId, + provider.cost?.period ?? "", + t, + ); const { hasMetrics, @@ -553,8 +635,10 @@ export default function MenuCardDetails({ hasCreditsHistory, hasUsageBreakdown, hasQuotaWindowHistory, + hasBurndown, localUsage, wayfinderUsage, + openAiApiUsage, } = presence; return ( @@ -595,20 +679,37 @@ export default function MenuCardDetails({ ))}
)} - {!provider.error && hasDisplayDetails && !compactOverview && ( -
- {provider.displayDetails?.map((detail, index) => ( - - ))} -
- )} - {!provider.error && hasDisplayDetails && ( -
- {provider.displayDetails?.map((detail, index) => ( - - ))} -
+ displayDetailGroups + .map((group) => ({ + ...group, + rows: group.rows.filter((detail) => + isDetailSectionVisible(provider.hiddenUsageItemIds, detail.title), + ), + })) + .filter((group) => group.rows.length > 0) + .map((group) => ( +
+ {group.title && ( +
+ {group.title} +
+ )} + {group.rows.map((detail, index) => ( + + ))} +
+ )) )} {wayfinderUsage && !compactOverview && } @@ -689,6 +790,21 @@ export default function MenuCardDetails({ )} + {!compactOverview && openAiApiUsage && ( +
+ {t("OpenAIChartTitle")} +
+ {/* Tray cards open often; skip the bar entrance animation there. */} + +
+
+ )} + {!compactOverview && (localUsage || hasPace || hasCharts) && (
{t("PanelUsageDetails")} @@ -698,6 +814,7 @@ export default function MenuCardDetails({ providerId={provider.providerId} summary={localUsage} costHistory={localCostHistory} + tokensHistory={localTokensHistory} /> )} @@ -778,6 +895,9 @@ export default function MenuCardDetails({ {hasQuotaWindowHistory && ( )} + {hasBurndown && provider.quotaBurndown && ( + + )} )} @@ -786,26 +906,3 @@ export default function MenuCardDetails({ ); } - -function DisplayDetailRow({ detail }: { detail: ProviderDisplayDetail }) { - const progress = detail.progress; - const progressPercent = progress && Number.isFinite(progress.used) && Number.isFinite(progress.total) && progress.total > 0 - ? Math.max(0, Math.min(100, (progress.used / progress.total) * 100)) - : null; - - return ( -
-
- {detail.title}: {detail.value} - {detail.secondaryValue && ( - {detail.secondaryValue} - )} -
- {progressPercent != null && ( -
-
-
- )} -
- ); -} diff --git a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx new file mode 100644 index 0000000000..fa72167b1c --- /dev/null +++ b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx @@ -0,0 +1,187 @@ +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + getProviderChartData: vi.fn(), + getDeepSeekPricingStatus: vi.fn(), + getLocaleStrings: vi.fn(), + setUiLanguage: vi.fn(), + claudeAccountsList: vi.fn(), +})); + +const eventMocks = vi.hoisted(() => ({ + listen: vi.fn(), +})); + +vi.mock("../lib/tauri", async (importOriginal) => ({ + ...(await importOriginal()), + ...tauriMocks, +})); +vi.mock("@tauri-apps/api/event", () => eventMocks); + +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { buildBundle } from "../test/localeHarness"; +import type { OpenAiApiUsageSnapshot, ProviderUsageSnapshot } from "../types/bridge"; +import MenuCard from "./MenuCard"; +import { describeCard } from "./MenuCardDetails"; + +const START = 1_788_220_800; + +function usage(days = 2): OpenAiApiUsageSnapshot { + return { + historyDays: 30, + projectId: null, + daily: Array.from({ length: days }, (_, i) => ({ + startTime: START + i * 86_400, + endTime: START + (i + 1) * 86_400, + costUsd: 1 + i, + requests: 10, + inputTokens: 1000, + cachedInputTokens: 100, + outputTokens: 400, + totalTokens: 1400, + lineItems: [{ name: "Text tokens", costUsd: 1 + i }], + models: [], + })), + }; +} + +function rateWindow() { + return { + usedPercent: 10, + remainingPercent: 90, + windowMinutes: null, + resetsAt: null, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + reserveWillLastToReset: false, + reserveEtaSeconds: null, + }; +} + +function snapshot( + providerId: string, + openAiApiUsage: OpenAiApiUsageSnapshot | null, + error: string | null = null, +): ProviderUsageSnapshot { + return { + providerId, + displayName: providerId, + primary: rateWindow(), + selectedMetric: rateWindow(), + primaryLabel: "Session", + secondary: null, + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + cost: null, + planName: null, + accountEmail: null, + sourceLabel: "api", + updatedAt: "2026-05-24T00:00:00Z", + error, + errorState: "unknown", + pace: null, + accountOrganization: null, + trayStatusLabel: null, + fetchDurationMs: null, + openAiApiUsage, + }; +} + +function renderCard( + snap: ProviderUsageSnapshot, + compactOverview = false, + hidePersonalInfo = false, +) { + return render( + + + , + ); +} + +describe("MenuCard OpenAI daily usage section", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.claudeAccountsList.mockResolvedValue([]); + tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle({})); + tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null); + tauriMocks.getProviderChartData.mockResolvedValue({ + providerId: "openaiapi", + costHistory: [], + creditsHistory: [], + usageBreakdown: [], + localUsage: null, + }); + eventMocks.listen.mockResolvedValue(() => {}); + }); + + it("renders a collapsed Daily usage section for openaiapi", async () => { + const { container } = renderCard(snapshot("openaiapi", usage())); + await waitFor(() => { + expect(container.querySelector(".menu-card__daily-usage")).not.toBeNull(); + }); + const section = container.querySelector(".menu-card__daily-usage") as HTMLDetailsElement; + expect(section.tagName).toBe("DETAILS"); + expect(section.open).toBe(false); + expect(section.querySelector("summary")).toHaveTextContent("OpenAIChartTitle"); + expect(section.querySelectorAll('[role="option"]')).toHaveLength(2); + }); + + it("does not show another provider's OpenAI payload (provider siloing)", async () => { + const { container } = renderCard(snapshot("claude", usage())); + await waitFor(() => { + expect(tauriMocks.getProviderChartData).toHaveBeenCalled(); + }); + expect(container.querySelector(".menu-card__daily-usage")).toBeNull(); + expect(container.querySelector('[role="option"]')).toBeNull(); + }); + + it("masks the Admin project id in the card identity when privacy is enabled", async () => { + const provider = snapshot("openaiapi", usage()); + provider.planName = "Admin API: proj-private"; + const { container } = renderCard(provider, false, true); + await waitFor(() => expect(container.querySelector(".menu-card__plan-badge")).not.toBeNull()); + expect(container.querySelector(".menu-card__plan-badge")).toHaveTextContent("Admin API: ••••"); + expect(container).not.toHaveTextContent("proj-private"); + }); + + it("hides the section on error, in compact overview and with an empty window", async () => { + const errored = renderCard(snapshot("openaiapi", usage(), "boom")); + await waitFor(() => expect(tauriMocks.getLocaleStrings).toHaveBeenCalled()); + expect(errored.container.querySelector(".menu-card__daily-usage")).toBeNull(); + errored.unmount(); + + const compact = renderCard(snapshot("openaiapi", usage()), true); + expect(compact.container.querySelector(".menu-card__daily-usage")).toBeNull(); + compact.unmount(); + + const empty = renderCard(snapshot("openaiapi", usage(0))); + expect(empty.container.querySelector(".menu-card__daily-usage")).toBeNull(); + }); +}); + +describe("describeCard OpenAI usage presence", () => { + it("is present only for openaiapi with days and no error", () => { + expect(describeCard(snapshot("openaiapi", usage()), null, []).openAiApiUsage).not.toBeNull(); + expect(describeCard(snapshot("claude", usage()), null, []).openAiApiUsage).toBeNull(); + expect(describeCard(snapshot("openaiapi", usage(0)), null, []).openAiApiUsage).toBeNull(); + expect(describeCard(snapshot("openaiapi", null), null, []).openAiApiUsage).toBeNull(); + expect(describeCard(snapshot("openaiapi", usage(), "boom"), null, []).openAiApiUsage).toBeNull(); + }); + + it("makes a card that only has daily usage count as having details", () => { + expect(describeCard(snapshot("openaiapi", usage()), null, []).hasDetails).toBe(true); + expect(describeCard(snapshot("openaiapi", usage(0)), null, []).hasDetails).toBe(false); + }); + + it("keeps compact overview header-only when daily usage is the only content", () => { + expect(describeCard(snapshot("openaiapi", usage()), null, [], "detailed", true, true).hasDetails).toBe(false); + }); +}); diff --git a/apps/desktop-tauri/src/components/OpenAIApiUsageChart.test.tsx b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.test.tsx new file mode 100644 index 0000000000..f3d157fecf --- /dev/null +++ b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.test.tsx @@ -0,0 +1,234 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { LocaleKey } from "../i18n/keys"; +import type { + OpenAiApiDailyUsageSnapshot, + OpenAiApiUsageSnapshot, +} from "../types/bridge"; +import { OpenAIApiUsageChart } from "./OpenAIApiUsageChart"; + +const DAY = 86_400; +// 2026-09-01T00:00:00Z +const START = 1_788_220_800; + +const strings: Partial> = { + OpenAIChartMore: "+{} more", + OpenAIChartWindowToday: "Today", + OpenAIChartWindowDays: "Last {} days", + UsageSpendRequests: "requests", +}; +const t = (key: LocaleKey) => strings[key] ?? key; + +function day( + index: number, + overrides: Partial = {}, +): OpenAiApiDailyUsageSnapshot { + return { + startTime: START + index * DAY, + endTime: START + (index + 1) * DAY, + costUsd: 1 + index, + requests: 10 * (index + 1), + inputTokens: 1_000 * (index + 1), + cachedInputTokens: 100 * (index + 1), + outputTokens: 500 * (index + 1), + totalTokens: 1_500 * (index + 1), + lineItems: [{ name: "Text tokens", costUsd: 1 + index }], + models: [ + { + name: `gpt-model-${index}`, + requests: 10 * (index + 1), + inputTokens: 1_000 * (index + 1), + cachedInputTokens: 100 * (index + 1), + outputTokens: 500 * (index + 1), + totalTokens: 1_500 * (index + 1), + }, + ], + ...overrides, + }; +} + +function usage(days: OpenAiApiDailyUsageSnapshot[], historyDays = 30): OpenAiApiUsageSnapshot { + return { historyDays, projectId: null, daily: days }; +} + +function renderChart(snapshot: OpenAiApiUsageSnapshot, onLayoutChange?: () => void) { + return render( + , + ); +} + +function bars() { + return screen.getAllByRole("option"); +} + +describe("OpenAIApiUsageChart", () => { + it("selects the latest day by default and shows its detail", () => { + renderChart(usage([day(0), day(1), day(2)])); + expect(bars()).toHaveLength(3); + expect(bars()[2]).toHaveAttribute("aria-selected", "true"); + expect(bars()[2]).toHaveAttribute("tabindex", "0"); + expect(bars()[0]).toHaveAttribute("tabindex", "-1"); + const detail = document.querySelector(".openai-usage__detail") as HTMLElement; + expect(within(detail).getByText("2026-09-03")).toBeInTheDocument(); + // Day cost in the stat grid plus the single line item carrying the same cost. + expect(within(detail).getAllByText("$3.00")).toHaveLength(2); + expect(within(detail).getByText("30")).toBeInTheDocument(); + expect(within(detail).getByText("3,000")).toBeInTheDocument(); + expect(within(detail).getByText("300")).toBeInTheDocument(); + expect(within(detail).getByText("1,500")).toBeInTheDocument(); + expect(within(detail).getByText("gpt-model-2")).toBeInTheDocument(); + }); + + it("shows the window total in the footer", () => { + renderChart(usage([day(0), day(1), day(2)])); + expect(screen.getByText("Last 30 days").nextElementSibling).toHaveTextContent("$6.00"); + }); + + it("labels a one-day window Today", () => { + renderChart(usage([day(0)], 1)); + expect(screen.getByText("Today")).toBeInTheDocument(); + }); + + it("selects a day on hover", () => { + renderChart(usage([day(0), day(1), day(2)])); + fireEvent.mouseEnter(bars()[0]); + expect(bars()[0]).toHaveAttribute("aria-selected", "true"); + expect(document.querySelector(".openai-usage__day")).toHaveTextContent("2026-09-01"); + expect(screen.getByText("gpt-model-0")).toBeInTheDocument(); + expect(screen.queryByText("gpt-model-2")).toBeNull(); + }); + + it("moves the selection with Left/Right/Home/End and keeps focus on the bar", () => { + renderChart(usage([day(0), day(1), day(2), day(3)])); + const list = screen.getByRole("listbox"); + fireEvent.keyDown(list, { key: "ArrowLeft" }); + expect(bars()[2]).toHaveAttribute("aria-selected", "true"); + expect(bars()[2]).toHaveFocus(); + fireEvent.keyDown(list, { key: "Home" }); + expect(bars()[0]).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(list, { key: "ArrowLeft" }); + expect(bars()[0]).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(list, { key: "End" }); + expect(bars()[3]).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(list, { key: "ArrowRight" }); + expect(bars()[3]).toHaveAttribute("aria-selected", "true"); + expect(bars()[3]).toHaveAttribute("tabindex", "0"); + }); + + it("keeps the selected day when a refresh delivers new data", () => { + const { rerender } = renderChart(usage([day(0), day(1), day(2)])); + fireEvent.mouseEnter(bars()[1]); + rerender( + , + ); + expect(bars()).toHaveLength(4); + expect(bars()[1]).toHaveAttribute("aria-selected", "true"); + }); + + it("toggles between Cost and Tokens with the tab strip", () => { + renderChart(usage([day(0), day(1)])); + const cost = screen.getByRole("tab", { name: "OpenAIChartMetricCost" }); + const tokens = screen.getByRole("tab", { name: "OpenAIChartMetricTokens" }); + expect(cost).toHaveAttribute("aria-selected", "true"); + expect(tokens).toHaveAttribute("tabindex", "-1"); + expect(bars()[1]).toHaveAttribute("aria-label", "2026-09-02: $2.00"); + + fireEvent.click(tokens); + expect(tokens).toHaveAttribute("aria-selected", "true"); + expect(bars()[1]).toHaveAttribute("aria-label", "2026-09-02: 3K"); + expect(screen.getByText("Last 30 days").nextElementSibling).toHaveTextContent("4.5K"); + expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", tokens.id); + }); + + it("moves between the metric tabs with the arrow keys", () => { + renderChart(usage([day(0), day(1)])); + const cost = screen.getByRole("tab", { name: "OpenAIChartMetricCost" }); + const tokens = screen.getByRole("tab", { name: "OpenAIChartMetricTokens" }); + fireEvent.keyDown(cost, { key: "ArrowRight" }); + expect(tokens).toHaveAttribute("aria-selected", "true"); + expect(tokens).toHaveFocus(); + fireEvent.keyDown(tokens, { key: "ArrowLeft" }); + expect(cost).toHaveAttribute("aria-selected", "true"); + expect(cost).toHaveFocus(); + }); + + it("caps line items and models at five rows plus a more row", () => { + const many = day(0, { + lineItems: Array.from({ length: 7 }, (_, i) => ({ name: `item-${i}`, costUsd: 7 - i })), + models: Array.from({ length: 6 }, (_, i) => ({ + name: `model-${i}`, + requests: 1, + inputTokens: 1, + cachedInputTokens: 0, + outputTokens: 1, + totalTokens: 2, + })), + }); + renderChart(usage([many])); + expect(screen.getByText("item-4")).toBeInTheDocument(); + expect(screen.queryByText("item-5")).toBeNull(); + expect(screen.getByText("+2 more")).toBeInTheDocument(); + expect(screen.getByText("model-4")).toBeInTheDocument(); + expect(screen.queryByText("model-5")).toBeNull(); + expect(screen.getByText("+1 more")).toBeInTheDocument(); + }); + + it("formats sub-cent costs and zero-token days", () => { + renderChart( + usage([ + day(0, { + costUsd: 0.004, + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + requests: 0, + lineItems: [], + models: [], + }), + ]), + ); + const detail = document.querySelector(".openai-usage__detail") as HTMLElement; + expect(within(detail).getByText("<$0.01")).toBeInTheDocument(); + expect(within(detail).queryByText("OpenAIChartLineItems")).toBeNull(); + expect(within(detail).queryByText("OpenAIChartModels")).toBeNull(); + }); + + it("keeps the full history in a horizontally scrollable chart", () => { + const days = Array.from({ length: 90 }, (_, i) => day(i)); + renderChart(usage(days, 90)); + expect(bars()).toHaveLength(90); + expect(screen.getByText("Last 90 days")).toBeInTheDocument(); + const chart = document.querySelector(".chart--scrollable"); + expect(chart).toBeInTheDocument(); + expect(Number(chart?.querySelector("svg")?.getAttribute("width"))).toBeGreaterThan(280); + expect(bars()[0]).toHaveAttribute("aria-label", "2026-09-01: $1.00"); + expect(bars()[89]).toHaveAttribute("aria-label", "2026-11-29: $90.00"); + expect(screen.getByText("Last 90 days").nextElementSibling).toHaveTextContent( + "$4,095.00", + ); + }); + + it("shows the empty message when the window has no days", () => { + renderChart(usage([])); + expect(screen.getByText("DetailChartEmpty")).toBeInTheDocument(); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("asks the host to re-measure when the detail panel changes", async () => { + const onLayoutChange = vi.fn(); + renderChart(usage([day(0), day(1)]), onLayoutChange); + await vi.waitFor(() => expect(onLayoutChange).toHaveBeenCalledTimes(1)); + fireEvent.mouseEnter(bars()[0]); + await vi.waitFor(() => expect(onLayoutChange).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx new file mode 100644 index 0000000000..a24c640c0a --- /dev/null +++ b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx @@ -0,0 +1,239 @@ +import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from "react"; +import type { LocaleKey } from "../i18n/keys"; +import type { + OpenAiApiDailyUsageSnapshot, + OpenAiApiUsageSnapshot, +} from "../types/bridge"; +import { BarChart } from "./charts/BarChart"; +import { providerCostColor } from "./charts/chartPalette"; + +/** + * Per-UTC-day chart for the OpenAI Admin API (upstream 0.66.0). Cost or token + * bars, one per day; hovering or focusing a bar selects the day and fills the + * detail panel (requests, token split, line items, models). USD only. + */ + +type Metric = "cost" | "tokens"; +type T = (key: LocaleKey) => string; + +const METRICS: readonly Metric[] = ["cost", "tokens"]; +const MAX_DETAIL_ROWS = 5; + +const usdFormat = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); +const compactFormat = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); +const countFormat = new Intl.NumberFormat("en-US"); + +function formatUsd(value: number): string { + if (value > 0 && value < 0.01) return "<$0.01"; + return usdFormat.format(value); +} + +function dayLabel(day: OpenAiApiDailyUsageSnapshot): string { + return new Date(day.startTime * 1000).toISOString().slice(0, 10); +} + +function metricValue(day: OpenAiApiDailyUsageSnapshot, metric: Metric): number { + return metric === "cost" ? day.costUsd : day.totalTokens; +} + +function formatMetric(value: number, metric: Metric): string { + return metric === "cost" ? formatUsd(value) : compactFormat.format(value); +} + +function windowLabel(days: number, t: T): string { + return days === 1 + ? t("OpenAIChartWindowToday") + : t("OpenAIChartWindowDays").replace("{}", String(days)); +} + +interface DetailRow { + key: string; + name: string; + value: string; +} + +function DetailList({ + title, + rows, + t, +}: { + title: string; + rows: DetailRow[]; + t: T; +}) { + if (rows.length === 0) return null; + const hidden = rows.length - MAX_DETAIL_ROWS; + return ( +
+
{title}
+
    + {rows.slice(0, MAX_DETAIL_ROWS).map((row) => ( +
  • + {row.name} + {row.value} +
  • + ))} + {hidden > 0 && ( +
  • + {t("OpenAIChartMore").replace("{}", String(hidden))} +
  • + )} +
+
+ ); +} + +function DayDetail({ day, t }: { day: OpenAiApiDailyUsageSnapshot; t: T }) { + const stats: Array<[LocaleKey, string]> = [ + ["OpenAIChartMetricCost", formatUsd(day.costUsd)], + ["OpenAIChartRequests", countFormat.format(day.requests)], + ["OpenAIChartInputTokens", countFormat.format(day.inputTokens)], + ["OpenAIChartCachedTokens", countFormat.format(day.cachedInputTokens)], + ["OpenAIChartOutputTokens", countFormat.format(day.outputTokens)], + ]; + const lineItems = day.lineItems.map((item, index) => ({ + key: `${item.name}-${index}`, + name: item.name, + value: formatUsd(item.costUsd), + })); + const models = day.models.map((model, index) => ({ + key: `${model.name}-${index}`, + name: model.name, + value: `${compactFormat.format(model.totalTokens)} · ${countFormat.format(model.requests)} ${t( + "UsageSpendRequests", + )}`, + })); + return ( +
+
{dayLabel(day)}
+
+ {stats.map(([labelKey, value]) => ( +
+
{t(labelKey)}
+
{value}
+
+ ))} +
+ + +
+ ); +} + +export interface OpenAIApiUsageChartProps { + usage: OpenAiApiUsageSnapshot; + animations: boolean; + t: T; + /** Popover hosts resize to content; called after the detail panel height may change. */ + onLayoutChange?: () => void; +} + +export function OpenAIApiUsageChart({ + usage, + animations, + t, + onLayoutChange, +}: OpenAIApiUsageChartProps) { + const [metric, setMetric] = useState("cost"); + // Selection is keyed by bucket start so it survives a refresh; null = latest day. + const [selectedStart, setSelectedStart] = useState(null); + const baseId = useId(); + const tabRefs = useRef>([]); + const days = usage.daily; + const points = useMemo( + () => + days.map((day) => ({ + label: dayLabel(day), + value: metricValue(day, metric), + })), + [days, metric], + ); + + const layoutKey = `${metric}:${selectedStart ?? "latest"}:${days.length}`; + const layoutCallback = useRef(onLayoutChange); + layoutCallback.current = onLayoutChange; + useEffect(() => { + const frame = requestAnimationFrame(() => layoutCallback.current?.()); + return () => cancelAnimationFrame(frame); + }, [layoutKey]); + + if (days.length === 0) { + return ( +
+
{t("DetailChartEmpty")}
+
+ ); + } + + const found = days.findIndex((day) => day.startTime === selectedStart); + const selectedIndex = found >= 0 ? found : days.length - 1; + const total = days.reduce((sum, day) => sum + metricValue(day, metric), 0); + const tabLabel = (m: Metric) => + t(m === "cost" ? "OpenAIChartMetricCost" : "OpenAIChartMetricTokens"); + const tabId = (m: Metric) => `${baseId}-tab-${m}`; + const panelId = `${baseId}-panel`; + + const onTabKeyDown = (e: KeyboardEvent) => { + const current = METRICS.indexOf(metric); + let next: number; + if (e.key === "ArrowLeft") next = (current + METRICS.length - 1) % METRICS.length; + else if (e.key === "ArrowRight") next = (current + 1) % METRICS.length; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = METRICS.length - 1; + else return; + e.preventDefault(); + setMetric(METRICS[next]); + tabRefs.current[next]?.focus(); + }; + + return ( +
+
+ {METRICS.map((m, index) => ( + + ))} +
+
+ formatMetric(v, metric)} + animations={animations} + emptyMessage={t("DetailChartEmpty")} + scrollable + selection={{ + index: selectedIndex, + onSelect: (index) => setSelectedStart(days[index]?.startTime ?? null), + }} + /> +
{t("OpenAIChartHint")}
+ +
+ {windowLabel(usage.historyDays, t)} + {formatMetric(total, metric)} +
+
+
+ ); +} diff --git a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx index 86982b66e4..b39fbe3ef3 100644 --- a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx +++ b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx @@ -1,5 +1,34 @@ import type { ProviderDisplayDetail } from "../types/bridge"; +const MAX_ROWS_PER_SECTION = 24; + +export interface ProviderDisplayDetailGroup { + id: number; + title: string | null; + rows: ProviderDisplayDetail[]; +} + +/** Group consecutive provider details and cap each rendered section. */ +export function groupProviderDisplayDetails( + details: ProviderDisplayDetail[], +): ProviderDisplayDetailGroup[] { + const groups: ProviderDisplayDetailGroup[] = []; + for (const detail of details) { + const title = detail.sectionTitle ?? null; + const current = groups[groups.length - 1]; + if ( + current && + current.title === title && + current.rows.length < MAX_ROWS_PER_SECTION + ) { + current.rows.push(detail); + } else { + groups.push({ id: groups.length, title, rows: [detail] }); + } + } + return groups; +} + /** * One transient provider detail line: "{title}: {value} [secondary]" * plus an optional clamped progress bar. @@ -34,11 +63,21 @@ export function ProviderDisplayRow({
{detail.title}: {detail.value} {detail.secondaryValue && secondaryClassName && ( - {detail.secondaryValue} + <> + {" "} + {detail.secondaryValue} + )}
{progressPercent != null && ( -
+
)} diff --git a/apps/desktop-tauri/src/components/QuotaBurndownChart.test.tsx b/apps/desktop-tauri/src/components/QuotaBurndownChart.test.tsx new file mode 100644 index 0000000000..94398def35 --- /dev/null +++ b/apps/desktop-tauri/src/components/QuotaBurndownChart.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import QuotaBurndownChart from "./QuotaBurndownChart"; +import type { QuotaBurndownSnapshot } from "../types/bridge"; +import { ALL_LOCALE_KEYS } from "../i18n/keys"; + +const t = (key: string): string => { + expect(ALL_LOCALE_KEYS).toContain(key); + return key; +}; + +const NOW = Date.parse("2026-09-30T12:00:00Z"); + +function hoursAgo(hours: number): string { + return new Date(NOW - hours * 3600 * 1000).toISOString(); +} + +function snapshot(overrides?: Partial): QuotaBurndownSnapshot { + return { + series: "session", + windowMinutes: 300, + start: hoursAgo(3), + reset: new Date(NOW + 2 * 3600 * 1000).toISOString(), + samples: [ + { capturedAt: hoursAgo(2), remainingPercent: 80 }, + { capturedAt: hoursAgo(1), remainingPercent: 55 }, + { capturedAt: new Date(NOW).toISOString(), remainingPercent: 40 }, + ], + ideal: [ + { capturedAt: hoursAgo(3), remainingPercent: 100 }, + { capturedAt: new Date(NOW + 2 * 3600 * 1000).toISOString(), remainingPercent: 0 }, + ], + ...overrides, + }; +} + +describe("QuotaBurndownChart", () => { + it("renders the observed line, ideal guide, remaining caption and capture age", () => { + render(); + + const svg = document.querySelector("svg"); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute("data-series")).toBe("session"); + + const observed = document.querySelector(".quota-burndown-chart__observed"); + // Final point: 40% remaining -> y = 44.80 in the 76px viewBox. + expect(observed?.getAttribute("points")).toContain("44.80"); + + const ideal = document.querySelector(".quota-burndown-chart__ideal"); + expect(ideal).not.toBeNull(); + + // 40% remaining at the last capture. + expect(document.querySelector(".quota-burndown-chart__caption")?.textContent).toContain( + "40%", + ); + }); + + it("shows calendar dates on the axis for weekly-length windows and compact times for sessions", () => { + const start = "2026-09-23T12:00:00Z"; + const reset = "2026-09-30T12:00:00Z"; + render( + , + ); + const axis = document.querySelector(".quota-burndown-chart__axis"); + // A 7-day window: boundary labels include a month name. + expect(axis?.textContent).toMatch(/Sep/); + }); + + it("renders nothing without samples or a valid window span", () => { + const { container } = render( + , + ); + expect(container.querySelector("svg")).toBeNull(); + + const { container: container2 } = render( + , + ); + expect(container2.querySelector("svg")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx b/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx new file mode 100644 index 0000000000..5c62a79591 --- /dev/null +++ b/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx @@ -0,0 +1,123 @@ +import type { QuotaBurndownSnapshot, QuotaBurndownPoint } from "../types/bridge"; +import type { LocaleKey } from "../i18n/keys"; +import { formatRelativeUpdated } from "../lib/relativeTime"; + +const CHART_WIDTH = 300; +const CHART_HEIGHT = 76; +const CHART_PADDING = 4; + +function xFor(timeMs: number, startMs: number, endMs: number): number { + const span = Math.max(1, endMs - startMs); + const fraction = Math.min(1, Math.max(0, (timeMs - startMs) / span)); + return CHART_PADDING + fraction * (CHART_WIDTH - 2 * CHART_PADDING); +} + +function yFor(remainingPercent: number): number { + const clamped = Math.min(100, Math.max(0, remainingPercent)); + return ( + CHART_HEIGHT - + CHART_PADDING - + (clamped / 100) * (CHART_HEIGHT - 2 * CHART_PADDING) + ); +} + +function polyline(points: QuotaBurndownPoint[], startMs: number, endMs: number): string { + return points + .map((point) => { + const timeMs = Date.parse(point.capturedAt); + const x = xFor(timeMs, startMs, endMs); + const y = yFor(point.remainingPercent); + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); +} + +/** + * Recorded remaining-quota burndown (upstream 0.70.0 #4085): the solid line + * joins recorded remaining percentages (never extended past the last + * capture), the dashed line is the even-use guide from 100% at the window + * start to 0% at reset. Expired windows never reach this component (the + * backend model yields None for them). + */ +export default function QuotaBurndownChart({ + burndown, + t, + nowMs = Date.now(), +}: { + burndown: QuotaBurndownSnapshot; + t: (key: LocaleKey) => string; + nowMs?: number; +}) { + if (burndown.samples.length === 0) return null; + + const startMs = Date.parse(burndown.start); + const resetMs = Date.parse(burndown.reset); + if (!Number.isFinite(startMs) || !Number.isFinite(resetMs) || resetMs <= startMs) { + return null; + } + + const last = burndown.samples[burndown.samples.length - 1]; + const lastMs = Date.parse(last.capturedAt); + const idealStartX = xFor(Date.parse(burndown.ideal[0].capturedAt), startMs, resetMs); + const idealStartY = yFor(burndown.ideal[0].remainingPercent); + const idealEndX = xFor(Date.parse(burndown.ideal[1].capturedAt), startMs, resetMs); + const idealEndY = yFor(burndown.ideal[1].remainingPercent); + const remaining = Math.round(last.remainingPercent); + const captureAge = formatRelativeUpdated(lastMs, t, nowMs); + + return ( +
+ + + + + + +
+ {formatBoundary(burndown.start, resetMs - startMs)} + {formatBoundary(burndown.reset, resetMs - startMs)} +
+
+ {`${remaining}% ${t("PanelLeftSuffix")}`} + {`${t("ClaudeSwapHistoricalUsage")} · ${captureAge}`} +
+
+ ); +} + +/** Weekly/monthly-length windows show a calendar date; session ones stay compact. */ +function formatBoundary(iso: string, spanMs: number): string { + const date = new Date(iso); + if (!Number.isFinite(date.getTime())) return "—"; + const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + if (spanMs >= 24 * 60 * 60 * 1000) { + const day = date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }); + return `${day} ${time}`; + } + return time; +} diff --git a/apps/desktop-tauri/src/components/UsageSpendDailyLedger.test.tsx b/apps/desktop-tauri/src/components/UsageSpendDailyLedger.test.tsx new file mode 100644 index 0000000000..e8c55b0e54 --- /dev/null +++ b/apps/desktop-tauri/src/components/UsageSpendDailyLedger.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => + ({ + UsageSpendDailyLedger: "Daily ledger", + UsageSpendDailyLedgerHelper: "Recorded daily cost and token totals", + UsageSpendNoDailyData: "No daily history yet.", + UsageSpendColDate: "Date", + UsageSpendColCost: "Cost", + UsageSpendColTokens: "Tokens", + UsageSpendUnknown: "Unknown", + UsageSpendTokens: "tokens", + UsageSpendShowAll: "Show all", + UsageSpendShowLess: "Show less", + })[key] ?? key, + }), +})); + +import { + DAILY_LEDGER_COLLAPSED_ROWS, + UsageSpendDailyLedger, + dailyLedgerVisibleRows, +} from "./UsageSpendDailyLedger"; + +/** `days` consecutive UTC days in oldest-first order, like the contract's daily history. */ +function dailyHistory(days: number) { + return Array.from({ length: days }, (_, index) => ({ + day: new Date(Date.UTC(2026, 0, 1) + index * 86_400_000).toISOString().slice(0, 10), + costUsd: index, + totalTokens: index, + })); +} + +describe("dailyLedgerVisibleRows", () => { + // Translated from upstream v0.67.0 SpendDashboardDailyLedgerTests + // `ledger expansion preserves every day in newest first order` (#3998). + it.each([0, 7, 30, 31, 365])( + "ledger expansion preserves every day in newest first order (%i days)", + (days) => { + const daily = dailyHistory(days); + const collapsed = dailyLedgerVisibleRows(daily, false, 30); + expect(collapsed).toHaveLength(Math.min(days, 30)); + expect(collapsed).toEqual(daily.slice(-30).reverse()); + const expanded = dailyLedgerVisibleRows(daily, true, 30); + expect(expanded).toEqual([...daily].reverse()); + }, + ); + + it("collapses to the upstream default of 30 rows", () => { + expect(DAILY_LEDGER_COLLAPSED_ROWS).toBe(30); + expect(dailyLedgerVisibleRows(dailyHistory(45), false)).toHaveLength(30); + }); +}); + +describe("UsageSpendDailyLedger", () => { + it("renders newest daily rows and keeps zero distinct from unknown", () => { + render( + , + ); + + const table = screen.getByRole("table", { name: "Daily ledger" }); + const rows = within(table).getAllByRole("row"); + expect(rows[1]).toHaveTextContent("2026-09-13"); + expect(rows[1]).toHaveTextContent("$0.00"); + expect(rows[1]).toHaveTextContent("0 tokens"); + expect(rows[2]).toHaveTextContent("2026-09-12"); + expect(rows[2]).toHaveTextContent("$0.12"); + expect(rows[2]).toHaveTextContent("Unknown"); + expect(rows[3]).toHaveTextContent("2026-09-11"); + expect(rows[3]).toHaveTextContent("Unknown"); + expect(rows[3]).toHaveTextContent("8,100 tokens"); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("starts long ledgers with the newest 30 rows and a Show all control", () => { + render(); + + const table = screen.getByRole("table", { name: "Daily ledger" }); + let bodyRows = within(table).getAllByRole("row").slice(1); + expect(bodyRows).toHaveLength(30); + expect(bodyRows[0]).toHaveTextContent("2026-01-31"); + expect(bodyRows[29]).toHaveTextContent("2026-01-02"); + expect(table).not.toHaveTextContent("2026-01-01"); + + const toggle = screen.getByRole("button", { name: "Show all (31)" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(toggle); + + bodyRows = within(table).getAllByRole("row").slice(1); + expect(bodyRows).toHaveLength(31); + expect(bodyRows[30]).toHaveTextContent("2026-01-01"); + const collapse = screen.getByRole("button", { name: "Show less" }); + expect(collapse).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(collapse); + expect(within(table).getAllByRole("row").slice(1)).toHaveLength(30); + }); + + it("shows an explicit empty state when no daily points are available", () => { + render(); + + expect(screen.getByText("No daily history yet.")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/desktop-tauri/src/components/UsageSpendDailyLedger.tsx b/apps/desktop-tauri/src/components/UsageSpendDailyLedger.tsx new file mode 100644 index 0000000000..48646742c4 --- /dev/null +++ b/apps/desktop-tauri/src/components/UsageSpendDailyLedger.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { useLocale } from "../hooks/useLocale"; +import type { SpendContract } from "../types/bridge"; +import { + formatSpendTokens, + formatUsd, +} from "../lib/usageSpendSharing"; + +type DailyPoints = SpendContract["daily"]; + +/** Upstream `SpendDailyLedger.collapsedRowCount`: long ranges start with the newest 30 days. */ +export const DAILY_LEDGER_COLLAPSED_ROWS = 30; + +/** + * Newest-first ledger rows. Collapsed shows the newest `collapsedRowCount` days; expanded shows the + * complete history (upstream `spendDailyLedgerVisibleSummaries`). + */ +export function dailyLedgerVisibleRows( + daily: DailyPoints, + showsAllRows: boolean, + collapsedRowCount: number = DAILY_LEDGER_COLLAPSED_ROWS, +): DailyPoints { + const newestFirst = [...daily].sort((a, b) => b.day.localeCompare(a.day)); + return showsAllRows ? newestFirst : newestFirst.slice(0, collapsedRowCount); +} + +export function UsageSpendDailyLedger({ + daily, +}: { + daily: DailyPoints; +}) { + const { t } = useLocale(); + const [showsAllRows, setShowsAllRows] = useState(false); + const rows = dailyLedgerVisibleRows(daily, showsAllRows); + + return ( +
+

{t("UsageSpendDailyLedger")}

+

{t("UsageSpendDailyLedgerHelper")}

+ {rows.length === 0 ? ( +

{t("UsageSpendNoDailyData")}

+ ) : ( +
+ + + + + + + + + + {rows.map((point) => ( + + + + + + ))} + +
{t("UsageSpendColDate")}{t("UsageSpendColCost")}{t("UsageSpendColTokens")}
{point.day}{point.costUsd == null ? t("UsageSpendUnknown") : formatUsd(point.costUsd, "USD")}{formatSpendTokens(point.totalTokens, t("UsageSpendTokens"), t("UsageSpendUnknown"))}
+
+ )} + {daily.length > DAILY_LEDGER_COLLAPSED_ROWS && ( + + )} +
+ ); +} diff --git a/apps/desktop-tauri/src/components/charts/BarChart.test.tsx b/apps/desktop-tauri/src/components/charts/BarChart.test.tsx index 12934d08e3..cb8e202caa 100644 --- a/apps/desktop-tauri/src/components/charts/BarChart.test.tsx +++ b/apps/desktop-tauri/src/components/charts/BarChart.test.tsx @@ -1,5 +1,5 @@ -import { render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { BarChart } from "./BarChart"; describe("BarChart calendar slots", () => { @@ -48,3 +48,73 @@ describe("BarChart calendar slots", () => { expect((labels[1] as HTMLElement).style.transform).toBe(""); }); }); + +describe("BarChart controlled selection", () => { + const data = [ + { label: "a", value: 1 }, + { label: "b", value: 2 }, + { label: "c", value: 3 }, + ]; + + function setup(index = 2) { + const onSelect = vi.fn(); + const utils = render( + , + ); + return { onSelect, ...utils }; + } + + it("exposes a listbox of options with a roving tabindex", () => { + setup(1); + expect(screen.getByRole("listbox")).toHaveAttribute("aria-orientation", "horizontal"); + const options = screen.getAllByRole("option"); + expect(options.map((o) => o.getAttribute("aria-selected"))).toEqual(["false", "true", "false"]); + expect(options.map((o) => o.getAttribute("tabindex"))).toEqual(["-1", "0", "-1"]); + expect(options[1]).toHaveAttribute("data-selected", "true"); + expect(options[1]).toHaveAttribute("opacity", "1"); + expect(options[0]).toHaveAttribute("opacity", "0.6"); + }); + + it("reports hover and focus as a selection change", () => { + const { onSelect } = setup(); + const options = screen.getAllByRole("option"); + fireEvent.mouseEnter(options[0]); + expect(onSelect).toHaveBeenLastCalledWith(0); + fireEvent.focus(options[1]); + expect(onSelect).toHaveBeenLastCalledWith(1); + }); + + it("navigates with the arrow, Home and End keys and clamps at the ends", () => { + const { onSelect } = setup(1); + const list = screen.getByRole("listbox"); + fireEvent.keyDown(list, { key: "ArrowLeft" }); + expect(onSelect).toHaveBeenLastCalledWith(0); + fireEvent.keyDown(list, { key: "ArrowRight" }); + expect(onSelect).toHaveBeenLastCalledWith(2); + fireEvent.keyDown(list, { key: "Home" }); + expect(onSelect).toHaveBeenLastCalledWith(0); + fireEvent.keyDown(list, { key: "End" }); + expect(onSelect).toHaveBeenLastCalledWith(2); + onSelect.mockClear(); + fireEvent.keyDown(list, { key: "Tab" }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("does not open the hover tooltip in selection mode", () => { + const { container } = setup(); + fireEvent.mouseMove(container.querySelector("svg") as SVGElement, { clientX: 10, clientY: 10 }); + expect(container.querySelector(".chart__tooltip")).toBeNull(); + }); + + it("stays a plain image chart without a selection prop", () => { + const { container } = render(); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(container.querySelector("[data-selected]")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/components/charts/BarChart.tsx b/apps/desktop-tauri/src/components/charts/BarChart.tsx index 0151811f57..a47c083c41 100644 --- a/apps/desktop-tauri/src/components/charts/BarChart.tsx +++ b/apps/desktop-tauri/src/components/charts/BarChart.tsx @@ -1,10 +1,11 @@ -import { useMemo, useRef, useState } from "react"; +import { type KeyboardEvent, useMemo, useRef, useState } from "react"; import { useChartAnimation } from "./useChartAnimation"; import { WIDTH, getBarCenter, getBarWidth, getBarX, + getScrollableChartWidth, shouldRenderCenterMax, } from "./chartGeometry"; @@ -25,6 +26,16 @@ export interface BarChartPoint { value: number | null; } +/** + * Controlled bar selection. When set, the bars form a roving-tabindex listbox: + * hover or focus selects a bar, Left/Right move the selection, Home/End jump to + * the first/last bar, and the hover tooltip is left to the caller's detail view. + */ +export interface BarChartSelection { + index: number; + onSelect: (index: number) => void; +} + export interface BarChartProps { data: BarChartPoint[]; color?: string; @@ -35,6 +46,9 @@ export interface BarChartProps { animations?: boolean; /** Optional empty-state message rendered when `data.length === 0`. */ emptyMessage?: string; + selection?: BarChartSelection; + /** Keep narrow bars readable by making long series horizontally scrollable. */ + scrollable?: boolean; } const DEFAULT_COLOR = "var(--chart-cost)"; @@ -48,10 +62,13 @@ export function BarChart({ ariaLabel, animations = true, emptyMessage, + selection, + scrollable = false, }: BarChartProps) { const fmt = valueFormatter ?? ((v: number) => v.toFixed(2)); const containerRef = useRef(null); const [hover, setHover] = useState<{ i: number; x: number; y: number } | null>(null); + const barRefs = useRef>([]); const anim = useChartAnimation(data.length, animations, [ data.length, @@ -80,7 +97,11 @@ export function BarChart({ ); } - const barWidth = getBarWidth(data.length); + const chartWidth = scrollable ? getScrollableChartWidth(data.length) : WIDTH; + const chartClass = scrollable + ? "chart chart--bar chart--scrollable" + : "chart chart--bar"; + const barWidth = getBarWidth(data.length, chartWidth); const plotHeight = Math.max(1, height - 4); const onMove = (e: React.MouseEvent, i: number) => { @@ -91,26 +112,52 @@ export function BarChart({ }; const onLeave = () => setHover(null); + const selectedIndex = selection + ? Math.min(Math.max(selection.index, 0), data.length - 1) + : -1; + const onKeyDown = (e: KeyboardEvent) => { + if (!selection) return; + let next: number; + if (e.key === "ArrowLeft") next = selectedIndex - 1; + else if (e.key === "ArrowRight") next = selectedIndex + 1; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = data.length - 1; + else return; + e.preventDefault(); + next = Math.min(Math.max(next, 0), data.length - 1); + selection.onSelect(next); + barRefs.current[next]?.focus(); + }; + return ( -
+
{data.map((p, i) => { const base = p.value == null ? 1 : p.value === 0 ? 1 : Math.max(3, (p.value / max) * plotHeight); const eased = anim.barProgress(i); const barH = base * eased; - const x = getBarX(i, data.length); + const x = getBarX(i, data.length, chartWidth); const y = height - barH; const isPeak = i === peakIndex && barH > CAP_HEIGHT; const bodyH = isPeak ? Math.max(0, barH - CAP_HEIGHT) : barH; const bodyY = isPeak ? y + CAP_HEIGHT : y; const isHovered = hover?.i === i; + const isSelected = i === selectedIndex; + let opacity = 0.9; + if (p.value == null) opacity = 0; + else if (p.value === 0) opacity = 0.25; + else if (selection) opacity = isSelected ? 1 : 0.6; + else if (isHovered) opacity = 1; return ( @@ -120,11 +167,29 @@ export function BarChart({ width={barWidth} height={bodyH} fill={color} - opacity={p.value == null ? 0 : p.value === 0 ? 0.25 : isHovered ? 1 : 0.9} + opacity={opacity} rx={1} className="chart__bar" - onMouseMove={p.value == null ? undefined : (e) => onMove(e, i)} - onMouseLeave={onLeave} + {...(selection + ? { + ref: (node: SVGRectElement | null) => { + barRefs.current[i] = node; + }, + role: "option", + "aria-selected": isSelected, + "aria-label": p.value == null ? p.label : `${p.label}: ${fmt(p.value)}`, + tabIndex: isSelected ? 0 : -1, + "data-selected": isSelected ? "true" : "false", + onMouseEnter: () => selection.onSelect(i), + onFocus: () => selection.onSelect(i), + } + : { + onMouseMove: + p.value == null + ? undefined + : (e: React.MouseEvent) => onMove(e, i), + onMouseLeave: onLeave, + })} > {p.value == null ? p.label : `${p.label}: ${fmt(p.value)}`} @@ -146,18 +211,26 @@ export function BarChart({ ); })} -
- +
+ {data[0].label} {shouldRenderCenterMax(data.length) && ( - {fmt(max)} + + {fmt(max)} + )} - + {data[data.length - 1].label}
- {hover && !anim.running && ( + {!selection && hover && !anim.running && (
{ it("renders the center max label for three or more points", () => { expect(shouldRenderCenterMax(3)).toBe(true); }); + + it("allocates enough scrollable width to keep long-series bars visible", () => { + const width = getScrollableChartWidth(90); + const lastBarRight = getBarCenter(89, 90, width) + getBarWidth(90, width) / 2; + + expect(width).toBeGreaterThan(WIDTH); + expect(getBarWidth(90, width)).toBeCloseTo(1.5); + expect(lastBarRight).toBeLessThanOrEqual(width - DATE_EDGE_PADDING); + }); }); diff --git a/apps/desktop-tauri/src/components/charts/chartGeometry.ts b/apps/desktop-tauri/src/components/charts/chartGeometry.ts index f3de6d951d..b428908d56 100644 --- a/apps/desktop-tauri/src/components/charts/chartGeometry.ts +++ b/apps/desktop-tauri/src/components/charts/chartGeometry.ts @@ -3,22 +3,30 @@ export const DATE_EDGE_PADDING = 36; export const BAR_GAP = 2; export const PLOT_WIDTH = WIDTH - DATE_EDGE_PADDING * 2; export const AXIS_MAX_X = WIDTH / 2; +const SCROLLABLE_BAR_WIDTH = 1.5; export function shouldRenderCenterMax(count: number): boolean { return count >= 3; } -export function getBarWidth(count: number): number { +export function getScrollableChartWidth(count: number): number { const barCount = Math.max(1, count); - return Math.max(1, (PLOT_WIDTH - (barCount - 1) * BAR_GAP) / barCount); + const plotWidth = barCount * SCROLLABLE_BAR_WIDTH + (barCount - 1) * BAR_GAP; + return Math.max(WIDTH, DATE_EDGE_PADDING * 2 + plotWidth); } -export function getBarX(index: number, count: number): number { - return DATE_EDGE_PADDING + index * (getBarWidth(count) + BAR_GAP); +export function getBarWidth(count: number, width = WIDTH): number { + const barCount = Math.max(1, count); + const plotWidth = width - DATE_EDGE_PADDING * 2; + return Math.max(1, (plotWidth - (barCount - 1) * BAR_GAP) / barCount); +} + +export function getBarX(index: number, count: number, width = WIDTH): number { + return DATE_EDGE_PADDING + index * (getBarWidth(count, width) + BAR_GAP); } -export function getBarCenter(index: number, count: number): number { - return getBarX(index, count) + getBarWidth(count) / 2; +export function getBarCenter(index: number, count: number, width = WIDTH): number { + return getBarX(index, count, width) + getBarWidth(count, width) / 2; } export interface BarGeometry { @@ -28,9 +36,9 @@ export interface BarGeometry { center: (index: number) => number; } -export function getBarGeometry(count: number): BarGeometry { - const barWidth = getBarWidth(count); - const x = (index: number) => getBarX(index, count); +export function getBarGeometry(count: number, width = WIDTH): BarGeometry { + const barWidth = getBarWidth(count, width); + const x = (index: number) => getBarX(index, count, width); return { barWidth, diff --git a/apps/desktop-tauri/src/components/charts/useChartAnimation.test.ts b/apps/desktop-tauri/src/components/charts/useChartAnimation.test.ts new file mode 100644 index 0000000000..2eeabef1f1 --- /dev/null +++ b/apps/desktop-tauri/src/components/charts/useChartAnimation.test.ts @@ -0,0 +1,36 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useChartAnimation } from "./useChartAnimation"; + +describe("useChartAnimation long series", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("finishes long-series entrance animation after the bounded stagger", () => { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const id = ++nextFrame; + frames.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); + + const { result, unmount } = renderHook(() => useChartAnimation(366, true)); + act(() => { + const first = frames.get(1); + if (!first) throw new Error("expected initial animation frame"); + frames.delete(1); + first(0); + }); + act(() => { + const next = frames.get(2); + if (!next) throw new Error("expected follow-up animation frame"); + frames.delete(2); + next(1_200); + }); + + expect(result.current.running).toBe(false); + expect(result.current.barProgress(365)).toBe(1); + unmount(); + }); +}); diff --git a/apps/desktop-tauri/src/components/charts/useChartAnimation.ts b/apps/desktop-tauri/src/components/charts/useChartAnimation.ts index f1e94ccecf..94417ffac9 100644 --- a/apps/desktop-tauri/src/components/charts/useChartAnimation.ts +++ b/apps/desktop-tauri/src/components/charts/useChartAnimation.ts @@ -19,6 +19,7 @@ import { useEffect, useRef, useState } from "react"; */ export const TOTAL_ANIMATION_MS = 600; export const STAGGER_PER_BAR_MS = 20; +const MAX_STAGGERED_ITEMS = 30; const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); @@ -42,6 +43,9 @@ export function useChartAnimation( const rafRef = useRef(null); const prefersReduced = usePrefersReducedMotion(); const skip = !enabled || prefersReduced || count === 0; + // Keep long daily histories animated without making the entrance delay grow + // linearly with every bucket. Items after the first 30 start together. + const staggeredCount = Math.min(count, MAX_STAGGERED_ITEMS); useEffect(() => { if (skip) { @@ -52,7 +56,7 @@ export function useChartAnimation( startRef.current = null; setElapsed(0); - const totalMs = TOTAL_ANIMATION_MS + count * STAGGER_PER_BAR_MS; + const totalMs = TOTAL_ANIMATION_MS + staggeredCount * STAGGER_PER_BAR_MS; const tick = (now: number) => { if (startRef.current == null) startRef.current = now; @@ -74,10 +78,11 @@ export function useChartAnimation( const progress = skip ? 1 : clamp01(elapsed / TOTAL_ANIMATION_MS); const barProgress = (i: number) => { if (skip) return 1; - const barElapsed = Math.max(0, elapsed - i * STAGGER_PER_BAR_MS); + const stagger = Math.min(i, MAX_STAGGERED_ITEMS) * STAGGER_PER_BAR_MS; + const barElapsed = Math.max(0, elapsed - stagger); return easeOut(clamp01(barElapsed / TOTAL_ANIMATION_MS)); }; - const totalMs = TOTAL_ANIMATION_MS + count * STAGGER_PER_BAR_MS; + const totalMs = TOTAL_ANIMATION_MS + staggeredCount * STAGGER_PER_BAR_MS; const running = !skip && elapsed < totalMs; return { progress: easeOut(progress), barProgress, running }; diff --git a/apps/desktop-tauri/src/components/codexAccountDisplay.test.ts b/apps/desktop-tauri/src/components/codexAccountDisplay.test.ts index 8422bc1afe..325421a3e0 100644 --- a/apps/desktop-tauri/src/components/codexAccountDisplay.test.ts +++ b/apps/desktop-tauri/src/components/codexAccountDisplay.test.ts @@ -86,12 +86,12 @@ describe("Codex account display labels", () => { expect(label).not.toContain("C:/private"); }); - it("redacts only the ambient account while preserving managed labels", () => { + it("redacts every account under hide personal info (ambient + managed)", () => { const system = account("11111111-1111-1111-1111-111111111111", "system"); const managed = account("22222222-2222-2222-2222-222222222222", "managed"); const accounts = [ { ...system, source: "ambient" as const, nickname: "Private System Name" }, - { ...managed, nickname: "Work" }, + { ...managed, source: "managedByApp" as const, nickname: "Work" }, ]; const hidden = buildCodexAccountSurfaceLabels( accounts, @@ -100,10 +100,15 @@ describe("Codex account display labels", () => { true, "Account", ); + // Upstream 0.60.5 #3702 (141ecf642): every account is redacted, not only + // ambient ones — managed accounts get the generic label too. expect(hidden[system.id]).toBe("Account 1"); expect(hidden[system.id]).not.toContain("@"); expect(hidden[system.id]).not.toContain("Private System Name"); - expect(hidden[managed.id]).toBe("same@example.com — Work"); + expect(hidden[managed.id]).toBe("Account 2"); + expect(hidden[managed.id]).not.toContain("@"); + expect(hidden[managed.id]).not.toContain("Work"); + expect(hidden[system.id]).not.toBe(hidden[managed.id]); const reordered = buildCodexAccountSurfaceLabels( [accounts[1], accounts[0]], @@ -113,6 +118,7 @@ describe("Codex account display labels", () => { "Account", ); expect(reordered[system.id]).toBe("Account 1"); + expect(reordered[managed.id]).toBe("Account 2"); const visible = buildCodexAccountSurfaceLabels( accounts, diff --git a/apps/desktop-tauri/src/components/codexAccountDisplay.ts b/apps/desktop-tauri/src/components/codexAccountDisplay.ts index 3c72eaf036..67276caf33 100644 --- a/apps/desktop-tauri/src/components/codexAccountDisplay.ts +++ b/apps/desktop-tauri/src/components/codexAccountDisplay.ts @@ -39,10 +39,10 @@ export function buildCodexAccountDisplayNames( /** * Project the labels rendered by account rows in the tray and Settings. * The redaction rule mirrors `CodexAccount::privacy_safe_display_name` in the - * Rust model: privacy mode redacts only the ambient/System account; managed - * account labels keep the canonical display-name projection. Client-side - * projection keeps the relabel reactive when the setting toggles without a - * refetch. + * Rust model (upstream 0.60.5 #3702, 141ecf642): privacy mode redacts EVERY + * account, not only the ambient/System one — managed accounts get the same + * generic label too. Client-side projection keeps the relabel reactive when + * the setting toggles without a refetch. */ export function buildCodexAccountSurfaceLabels( accounts: readonly CodexAccount[], @@ -55,7 +55,7 @@ export function buildCodexAccountSurfaceLabels( return Object.fromEntries( accounts.map((account) => { - if (hidePersonalInfo && account.source === "ambient") { + if (hidePersonalInfo) { return [account.id, `${accountWord.trim()} ${accountOrdinals[account.id]}`]; } return [account.id, displayNames[account.id]]; diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg new file mode 100644 index 0000000000..224bb877f1 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-aixy.svg @@ -0,0 +1 @@ +Aixy diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-atlascloud.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-atlascloud.svg new file mode 100644 index 0000000000..843383df48 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-atlascloud.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-crof.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-crof.svg deleted file mode 100644 index fdde018b8f..0000000000 --- a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-crof.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts index 27c677024b..2396b1f92e 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts @@ -1,11 +1,112 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { TEST_PROVIDER_CATALOG } from "../../test/providerCatalog"; import { PROVIDER_ICON_REGISTRY } from "./providerIcons"; +// Repository root, from apps/desktop-tauri/src/components/providers. +const REPO_ROOT = import.meta.dirname + "/../../../../../"; + +/** + * The `ProviderId::X => "#RRGGBB"` rows of the Rust `brand_color` table in + * `rust/src/core/provider.rs`, keyed by the lowercased variant name (which + * is the registry id for every provider). + */ +function rustBrandColors(): Map { + // CI checks out with CRLF line endings; normalize so the "\n}\n" search below matches. + const source = readFileSync(REPO_ROOT + "rust/src/core/provider.rs", "utf8").replace(/\r\n/g, "\n"); + const start = source.indexOf("pub fn brand_color(id: ProviderId)"); + const end = source.indexOf("\n}\n", start); + const rows = new Map(); + if (start < 0 || end < 0) return rows; + for (const match of source + .slice(start, end) + .matchAll(/ProviderId::(\w+) => "(#[0-9A-Fa-f]{6})"/g)) { + rows.set(match[1].toLowerCase(), match[2].toLowerCase()); + } + return rows; +} + +/** `--chart-: rgb(r, g, b);` provider tokens from styles.css, as hex. */ +function chartTokens(): Array<[string, string]> { + const css = readFileSync(import.meta.dirname + "/../../styles.css", "utf8"); + const tokens: Array<[string, string]> = []; + for (const match of css.matchAll( + /--chart-([a-z0-9]+): rgb\((\d+), (\d+), (\d+)\);/g, + )) { + const hex = [match[2], match[3], match[4]] + .map((channel) => Number(channel).toString(16).padStart(2, "0")) + .join(""); + tokens.push([match[1], `#${hex}`]); + } + return tokens; +} + +// Upstream 0.70.0 palette audit (#4075, docs/provider-palette.md): the 16 +// accents it adopted. The Rust side pins the same values and checks their +// contrast against the previous Windows accents. +const ADOPTED_ACCENTS: Array<[string, string]> = [ + ["abacus", "#814ee8"], + ["amp", "#f34e3f"], + ["augment", "#1aa049"], + ["bedrock", "#01a88d"], + ["clinepass", "#5487c8"], + ["codebuff", "#00ff95"], + ["commandcode", "#8c4edd"], + ["cursor", "#f54e00"], + ["deepseek", "#4d6bfe"], + ["devin", "#317cff"], + ["kiro", "#9046ff"], + ["longcat", "#29e154"], + ["mistral", "#ff5229"], + ["neuralwatt", "#d55934"], + ["sub2api", "#14b8a6"], + ["venice", "#3c8fdd"], +]; + describe("provider icon registry", () => { it("has explicit icon metadata for every provider in the catalog", () => { for (const [id] of TEST_PROVIDER_CATALOG) { expect(PROVIDER_ICON_REGISTRY[id], id).toBeDefined(); } }); + + it("ships the upstream Atlas Cloud glyph tinted by the brand color", () => { + const svg = PROVIDER_ICON_REGISTRY.atlascloud.svgPath; + expect(svg).toContain(" { + expect(PROVIDER_ICON_REGISTRY).not.toHaveProperty("crof"); + }); + + it("uses the accents adopted by the upstream 0.70.0 palette audit", () => { + for (const [id, color] of ADOPTED_ACCENTS) { + expect(PROVIDER_ICON_REGISTRY[id]?.brandColor.toLowerCase(), id).toBe(color); + } + }); + + it("matches the Rust brand_color table for every provider", () => { + const rust = rustBrandColors(); + expect(rust.size).toBeGreaterThan(0); + expect([...rust.keys()].sort()).toEqual(Object.keys(PROVIDER_ICON_REGISTRY).sort()); + for (const [id, color] of rust) { + expect(PROVIDER_ICON_REGISTRY[id]?.brandColor.toLowerCase(), id).toBe(color); + } + }); + + it("keeps provider chart tokens on the brand color", () => { + // Grok's chart token has been pure black since before the registry + // existed (xAI shares it), so it is the one intentional exception. + const checked = chartTokens().filter( + ([id]) => id !== "grok" && PROVIDER_ICON_REGISTRY[id] !== undefined, + ); + expect(checked.map(([id]) => id)).toEqual( + expect.arrayContaining(["amp", "augment", "codebuff", "cursor", "deepseek", "kiro", "mistral"]), + ); + for (const [id, color] of checked) { + expect(color, `--chart-${id}`).toBe(PROVIDER_ICON_REGISTRY[id].brandColor.toLowerCase()); + } + }); }); diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 2fdc4380d7..be03571581 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -6,6 +6,7 @@ import abacus from "./icons/ProviderIcon-abacus.svg?raw"; import alibaba from "./icons/ProviderIcon-alibaba.svg?raw"; import amp from "./icons/ProviderIcon-amp.svg?raw"; import antigravity from "./icons/ProviderIcon-antigravity.svg?raw"; +import atlascloud from "./icons/ProviderIcon-atlascloud.svg?raw"; import augment from "./icons/ProviderIcon-augment.svg?raw"; import bedrock from "./icons/ProviderIcon-bedrock.svg?raw"; import claude from "./icons/ProviderIcon-claude.svg?raw"; @@ -14,13 +15,13 @@ import coderabbit from "./icons/ProviderIcon-coderabbit.svg?raw"; import codex from "./icons/ProviderIcon-codex.svg?raw"; import commandcode from "./icons/ProviderIcon-commandcode.svg?raw"; import copilot from "./icons/ProviderIcon-copilot.svg?raw"; -import crof from "./icons/ProviderIcon-crof.svg?raw"; import crossmodel from "./icons/ProviderIcon-crossmodel.svg?raw"; import cursor from "./icons/ProviderIcon-cursor.svg?raw"; import deepgram from "./icons/ProviderIcon-deepgram.svg?raw"; import deepinfra from "./icons/ProviderIcon-deepinfra.svg?raw"; import fireworks from "./icons/ProviderIcon-fireworks.svg?raw"; import aiand from "./icons/ProviderIcon-aiand.svg?raw"; +import aixy from "./icons/ProviderIcon-aixy.svg?raw"; import clinepass from "./icons/ProviderIcon-clinepass.svg?raw"; import longcat from "./icons/ProviderIcon-longcat.svg?raw"; import neuralwatt from "./icons/ProviderIcon-neuralwatt.svg?raw"; @@ -94,6 +95,7 @@ const RAW: Record = { alibaba: tint(alibaba), amp: tint(amp), antigravity: tint(antigravity), + atlascloud: tint(atlascloud), augment: tint(augment), bedrock: tint(bedrock), claude: tint(claude), @@ -102,13 +104,13 @@ const RAW: Record = { codex: tint(codex), commandcode: tint(commandcode), copilot: tint(copilot), - crof: tint(crof), crossmodel: tint(crossmodel), cursor: tint(cursor), deepgram: tint(deepgram), deepinfra: tint(deepinfra), fireworks: tint(fireworks), aiand: tint(aiand), + aixy: tint(aixy), clinepass: tint(clinepass), longcat: tint(longcat), neuralwatt: tint(neuralwatt), @@ -162,44 +164,48 @@ const RAW: Record = { export const PROVIDER_ICON_REGISTRY: Record = { alibaba: { id: "alibaba", brandColor: "#ff6a00", fallbackLetter: "阿", svgPath: RAW.alibaba }, alibabatokenplan: { id: "alibabatokenplan", brandColor: "#ff6a00", fallbackLetter: "阿", svgPath: RAW.alibaba }, - amp: { id: "amp", brandColor: "#dc2626", fallbackLetter: "⚡", svgPath: RAW.amp }, + amp: { id: "amp", brandColor: "#f34e3f", fallbackLetter: "⚡", svgPath: RAW.amp }, antigravity: { id: "antigravity", brandColor: "#60ba7e", fallbackLetter: "◉", svgPath: RAW.antigravity }, - augment: { id: "augment", brandColor: "#6366f1", fallbackLetter: "A", svgPath: RAW.augment }, + augment: { id: "augment", brandColor: "#1aa049", fallbackLetter: "A", svgPath: RAW.augment }, claude: { id: "claude", brandColor: "#cc7c5e", fallbackLetter: "◈", svgPath: RAW.claude }, pi: { id: "pi", brandColor: "#7c3aed", fallbackLetter: "P" }, - codebuff: { id: "codebuff", brandColor: "#44ff00", fallbackLetter: "B", svgPath: RAW.codebuff }, + codebuff: { id: "codebuff", brandColor: "#00ff95", fallbackLetter: "B", svgPath: RAW.codebuff }, coderabbit: { id: "coderabbit", brandColor: "#ff5c35", fallbackLetter: "C", svgPath: RAW.coderabbit }, codex: { id: "codex", brandColor: "#49a3b0", fallbackLetter: "◆", svgPath: RAW.codex }, copilot: { id: "copilot", brandColor: "#a855f7", fallbackLetter: "⬡", svgPath: RAW.copilot }, - cursor: { id: "cursor", brandColor: "#00bfa5", fallbackLetter: "▸", svgPath: RAW.cursor }, + cursor: { id: "cursor", brandColor: "#f54e00", fallbackLetter: "▸", svgPath: RAW.cursor }, deepgram: { id: "deepgram", brandColor: "#13ef93", fallbackLetter: "D", svgPath: RAW.deepgram }, deepinfra: { id: "deepinfra", brandColor: "#2a3275", fallbackLetter: "D", svgPath: RAW.deepinfra }, fireworks: { id: "fireworks", brandColor: "#f25b1c", fallbackLetter: "F", svgPath: RAW.fireworks }, aiand: { id: "aiand", brandColor: "#e25c2b", fallbackLetter: "&", svgPath: RAW.aiand }, - clinepass: { id: "clinepass", brandColor: "#61a3fa", fallbackLetter: "C", svgPath: RAW.clinepass }, - longcat: { id: "longcat", brandColor: "#ffd100", fallbackLetter: "L", svgPath: RAW.longcat }, - neuralwatt: { id: "neuralwatt", brandColor: "#38d98c", fallbackLetter: "N", svgPath: RAW.neuralwatt }, + clinepass: { id: "clinepass", brandColor: "#5487c8", fallbackLetter: "C", svgPath: RAW.clinepass }, + longcat: { id: "longcat", brandColor: "#29e154", fallbackLetter: "L", svgPath: RAW.longcat }, + neuralwatt: { id: "neuralwatt", brandColor: "#d55934", fallbackLetter: "N", svgPath: RAW.neuralwatt }, zoommate: { id: "zoommate", brandColor: "#0B5CFF", fallbackLetter: "Z", svgPath: RAW.zoommate }, zenmux: { id: "zenmux", brandColor: "#6c5ce7", fallbackLetter: "Z", svgPath: RAW.zenmux }, - deepseek: { id: "deepseek", brandColor: "#527df0", fallbackLetter: "D", svgPath: RAW.deepseek }, + deepseek: { id: "deepseek", brandColor: "#4d6bfe", fallbackLetter: "D", svgPath: RAW.deepseek }, elevenlabs: { id: "elevenlabs", brandColor: "#111827", fallbackLetter: "E", svgPath: RAW.elevenlabs }, factory: { id: "factory", brandColor: "#ff6b35", fallbackLetter: "◎", svgPath: RAW.factory }, gemini: { id: "gemini", brandColor: "#ab87ea", fallbackLetter: "✦", svgPath: RAW.gemini }, grok: { id: "grok", brandColor: "#111827", fallbackLetter: "G", svgPath: RAW.grok }, groq: { id: "groq", brandColor: "#f55036", fallbackLetter: "G", svgPath: RAW.groq }, + bifrost: { id: "bifrost", brandColor: "#33c09e", fallbackLetter: "B" }, + aixy: { id: "aixy", brandColor: "#123650", fallbackLetter: "A", svgPath: RAW.aixy }, + gitkraken: { id: "gitkraken", brandColor: "#179287", fallbackLetter: "G" }, huggingface: { id: "huggingface", brandColor: "#ffd21e", fallbackLetter: "H", svgPath: RAW.huggingface }, + hyper: { id: "hyper", brandColor: "#ff60ff", fallbackLetter: "H" }, helmcode: { id: "helmcode", brandColor: "#4f46e5", fallbackLetter: "H" }, v0: { id: "v0", brandColor: "#111827", fallbackLetter: "V" }, typesafe: { id: "typesafe", brandColor: "#2563eb", fallbackLetter: "T" }, jetbrains: { id: "jetbrains", brandColor: "#ff3399", fallbackLetter: "J", svgPath: RAW.jetbrains }, kilo: { id: "kilo", brandColor: "#5d87ff", fallbackLetter: "K", svgPath: RAW.kilo }, - bedrock: { id: "bedrock", brandColor: "#ff9900", fallbackLetter: "B", svgPath: RAW.bedrock }, + bedrock: { id: "bedrock", brandColor: "#01a88d", fallbackLetter: "B", svgPath: RAW.bedrock }, kimi: { id: "kimi", brandColor: "#fe603c", fallbackLetter: "☽", svgPath: RAW.kimi }, kimik2: { id: "kimik2", brandColor: "#4c00ff", fallbackLetter: "☽", svgPath: RAW.kimi }, - kiro: { id: "kiro", brandColor: "#ff9900", fallbackLetter: "K", svgPath: RAW.kiro }, + kiro: { id: "kiro", brandColor: "#9046ff", fallbackLetter: "K", svgPath: RAW.kiro }, llmproxy: { id: "llmproxy", brandColor: "#4f46e5", fallbackLetter: "L", svgPath: RAW.llmproxy }, minimax: { id: "minimax", brandColor: "#fe603c", fallbackLetter: "M", svgPath: RAW.minimax }, - mistral: { id: "mistral", brandColor: "#ff500f", fallbackLetter: "M", svgPath: RAW.mistral }, + mistral: { id: "mistral", brandColor: "#ff5229", fallbackLetter: "M", svgPath: RAW.mistral }, muse: { id: "muse", brandColor: "#0668e1", fallbackLetter: "M", svgPath: RAW.muse }, ollama: { id: "ollama", brandColor: "#8b95b0", fallbackLetter: "○", svgPath: RAW.ollama }, azureopenai: { id: "azureopenai", brandColor: "#0078d4", fallbackLetter: "A" }, @@ -216,25 +222,25 @@ export const PROVIDER_ICON_REGISTRY: Record = { // Aliases / Rust-side normalizations without their own SVG. nanogpt: { id: "nanogpt", brandColor: "#687fa1", fallbackLetter: "N" }, infini: { id: "infini", brandColor: "#687fa1", fallbackLetter: "I" }, - abacus: { id: "abacus", brandColor: "#7c3aed", fallbackLetter: "A", svgPath: RAW.abacus }, + abacus: { id: "abacus", brandColor: "#814ee8", fallbackLetter: "A", svgPath: RAW.abacus }, + atlascloud: { id: "atlascloud", brandColor: "#5975F5", fallbackLetter: "A", svgPath: RAW.atlascloud }, manus: { id: "manus", brandColor: "#34322d", fallbackLetter: "M", svgPath: RAW.manus }, mimo: { id: "mimo", brandColor: "#ff6900", fallbackLetter: "M", svgPath: RAW.mimo }, doubao: { id: "doubao", brandColor: "#2563eb", fallbackLetter: "D", svgPath: RAW.doubao }, - commandcode: { id: "commandcode", brandColor: "#44ff00", fallbackLetter: "C", svgPath: RAW.commandcode }, - crof: { id: "crof", brandColor: "#7c3aed", fallbackLetter: "C", svgPath: RAW.crof }, + commandcode: { id: "commandcode", brandColor: "#8c4edd", fallbackLetter: "C", svgPath: RAW.commandcode }, crossmodel: { id: "crossmodel", brandColor: "#c084fc", fallbackLetter: "X", svgPath: RAW.crossmodel }, qoder: { id: "qoder", brandColor: "#2563eb", fallbackLetter: "Q", svgPath: RAW.qoder }, replicate: { id: "replicate", brandColor: "#000000", fallbackLetter: "R", svgPath: RAW.replicate }, codebuddy: { id: "codebuddy", brandColor: "#0052d9", fallbackLetter: "C" }, sakana: { id: "sakana", brandColor: "#0ea5e9", fallbackLetter: "S", svgPath: RAW.sakana }, stepfun: { id: "stepfun", brandColor: "#999999", fallbackLetter: "S", svgPath: RAW.stepfun }, - sub2api: { id: "sub2api", brandColor: "#2dc6d8", fallbackLetter: "S", svgPath: RAW.sub2api }, - venice: { id: "venice", brandColor: "#111827", fallbackLetter: "V", svgPath: RAW.venice }, + sub2api: { id: "sub2api", brandColor: "#14b8a6", fallbackLetter: "S", svgPath: RAW.sub2api }, + venice: { id: "venice", brandColor: "#3c8fdd", fallbackLetter: "V", svgPath: RAW.venice }, openaiapi: { id: "openaiapi", brandColor: "#10a37f", fallbackLetter: "O" }, chutes: { id: "chutes", brandColor: "#ff5c35", fallbackLetter: "C" }, litellm: { id: "litellm", brandColor: "#0ea5e9", fallbackLetter: "L" }, poe: { id: "poe", brandColor: "#5d5fef", fallbackLetter: "P" }, - devin: { id: "devin", brandColor: "#111827", fallbackLetter: "D" }, + devin: { id: "devin", brandColor: "#317cff", fallbackLetter: "D" }, zed: { id: "zed", brandColor: "#084ccf", fallbackLetter: "Z" }, qwencloud: { id: "qwencloud", brandColor: "#615CED", fallbackLetter: "Q" }, notion: { id: "notion", brandColor: "#337EA9", fallbackLetter: "N", svgPath: RAW.notion }, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 88860e087d..3ac433a7c6 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -47,6 +47,7 @@ type RateWindowOptions = { informational?: boolean; resetsAt?: string | null; resetDescription?: string | null; + descriptionIsDetail?: boolean; }; function rateWindow( @@ -61,6 +62,7 @@ function rateWindow( resetDescription: opts.resetDescription ?? null, isExhausted: opts.exhausted ?? false, isInformational: opts.informational, + descriptionIsDetail: opts.descriptionIsDetail, reservePercent: null, reserveDescription: null, }; @@ -76,6 +78,7 @@ function snapshot( errorState?: ProviderUsageSnapshot["errorState"]; resetsAt?: string | null; resetDescription?: string | null; + descriptionIsDetail?: boolean; informational?: boolean; secondary?: { used: number; @@ -153,6 +156,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { switcherShowsIcons: true, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarColorPace: false, showAsUsed: true, showAllTokenAccountsInMenu: false, enableAnimations: true, @@ -230,7 +234,8 @@ describe("FloatBar", () => { ProviderIssueSessionExpired: "Session expired", ProviderIssueLocalRuntimeOffline: "Local runtime offline", ProviderIssueUnknown: "Usage unavailable", - FloatBarThirtyDayShort: "30d", + CostPeriodShortMonthToDate: "MTD", + CostPeriodShortDays: "{}d", FloatBarNoProviders: "No providers", FloatBarRemainingSuffix: "remaining", }), @@ -325,37 +330,123 @@ describe("FloatBar", () => { }); }); + it("never shows a detail-backed description as reset text", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("claude", "Claude", 13, { + resetDescription: "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + descriptionIsDetail: true, + }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarShowResetInline: true }), + ); + + const { container } = renderFloatBar(bootstrap({ floatBarShowResetInline: true })); + await waitFor(() => { + const pill = container.querySelector(".floatbar__pill"); + expect(pill?.getAttribute("title")).toBe("Claude: 13% used"); + expect(container.querySelector(".floatbar__reset")).toBeNull(); + }); + }); + it("keeps an informational primary window when no secondary window is available", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ - snapshot("claude", "Claude", 10, { informational: true }), + snapshot("claude", "Claude", 0, { + informational: true, + resetDescription: "No active 5h session", + }), ]); tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); const { container } = renderFloatBar(bootstrap()); await waitFor(() => { - expect(container.querySelector(".floatbar__pill")?.getAttribute("title")).toContain( - "Claude: 10% used", + expect(container.querySelector(".floatbar__pill")?.getAttribute("title")).toBe( + "Claude: No active 5h session", ); }); }); it("keeps an informational primary window when the secondary window is informational", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ - snapshot("claude", "Claude", 10, { + snapshot("claude", "Claude", 0, { informational: true, - secondary: { used: 90, informational: true }, + resetDescription: "No active 5h session", + secondary: { used: 90, informational: true, resetDescription: "Weekly unavailable" }, }), ]); tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); const { container } = renderFloatBar(bootstrap()); await waitFor(() => { - expect(container.querySelector(".floatbar__pill")?.getAttribute("title")).toContain( - "Claude: 10% used", + expect(container.querySelector(".floatbar__pill")?.getAttribute("title")).toBe( + "Claude: No active 5h session", ); }); }); + it("shows an informational metric's text instead of a percentage or reset wording", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("litellm", "LiteLLM", 0, { + informational: true, + resetDescription: "No budget set", + }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ enabledProviders: ["litellm"], floatBarShowResetInline: true }), + ); + + const { container } = renderFloatBar( + bootstrap({ enabledProviders: ["litellm"], floatBarShowResetInline: true }), + ); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill")).not.toBeNull(); + }); + const pill = container.querySelector(".floatbar__pill"); + expect(pill?.getAttribute("title")).toBe("LiteLLM: No budget set"); + expect(pill?.querySelector(".floatbar__pct")?.textContent).toBe("No budget set"); + expect(pill?.textContent).not.toMatch(/%|Resets/); + expect(pill?.querySelector(".floatbar__reset")).toBeNull(); + expect(pill?.classList.contains("floatbar__pill--ok")).toBe(true); + }); + + it("keeps the neutral tone for an informational metric in remaining mode", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("cursor", "Cursor", 100, { + informational: true, + resetDescription: "$12.40 API-rate", + }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ enabledProviders: ["cursor"], showAsUsed: false }), + ); + + const { container } = renderFloatBar( + bootstrap({ enabledProviders: ["cursor"], showAsUsed: false }), + ); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill")).not.toBeNull(); + }); + const pill = container.querySelector(".floatbar__pill"); + expect(pill?.getAttribute("title")).toBe("Cursor: $12.40 API-rate"); + expect(pill?.classList.contains("floatbar__pill--ok")).toBe(true); + }); + + it("uses an em dash for an informational metric without text and keeps a timestamp reset", async () => { + const resetsAt = new Date(Date.now() + (2 * 60 + 5) * 60_000).toISOString(); + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("codex", "Codex", 0, { informational: true, resetsAt }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); + + const { container } = renderFloatBar(bootstrap()); + await waitFor(() => { + expect(container.querySelector(".floatbar__pill")?.getAttribute("title")).toMatch( + /^Codex: —\nResets in 2h \d+m$/, + ); + }); + expect(container.querySelector(".floatbar__pct")?.textContent).toBe("—"); + }); + it("sorts providers by their selected rate window", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("claude", "Claude", 90, { @@ -388,6 +479,9 @@ describe("FloatBar", () => { todayCost: 1.25, thirtyDayCost: 12.5, thirtyDayTokens: 1000, + periodCost: 12.5, + periodTokens: 1000, + reportingPeriod: "rolling:30", latestTokens: 200, topModel: "gpt-5", estimateNote: "Estimated from local logs", @@ -409,6 +503,9 @@ describe("FloatBar", () => { todayCost: 1.25, thirtyDayCost: 12.5, thirtyDayTokens: 1000, + periodCost: 12.5, + periodTokens: 1000, + reportingPeriod: "rolling:30", latestTokens: 200, topModel: "gpt-5", estimateNote: "Estimated from local logs", @@ -427,6 +524,38 @@ describe("FloatBar", () => { ); }); + it("shows the selected History window cost with its short label", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([snapshot("codex", "Codex", 75)]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarShowCost: true, costReportingPeriod: "month-to-date" }), + ); + tauriMocks.getProviderLocalUsageSummary.mockResolvedValue({ + todayCost: 1.25, + // Fixed 30-day compat field stays different from the selected window. + thirtyDayCost: 12.5, + thirtyDayTokens: 1000, + periodCost: 4.75, + periodTokens: 300, + reportingPeriod: "month-to-date", + latestTokens: 200, + topModel: "gpt-5", + estimateNote: "Estimated from local logs", + tokenCostUpdatedAtMs: 1234, + }); + + const { container } = renderFloatBar( + bootstrap({ floatBarShowCost: true, costReportingPeriod: "month-to-date" }), + ); + + await waitFor(() => { + const items = Array.from(container.querySelectorAll(".floatbar__cost-item")).map( + (item) => item.textContent, + ); + expect(items).toEqual(["Today$1.25", "MTD$4.75"]); + }); + expect(container.textContent).not.toContain("$12.50"); + }); + it("does not scan local costs by default", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("codex", "Codex", 75), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index e8d71af8fe..19cbeeab36 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -10,6 +10,7 @@ import { import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useFormattedResetTime } from "../hooks/useFormattedResetTime"; +import { useCurrency } from "../hooks/CurrencyProvider"; import { useLocale } from "../hooks/useLocale"; import { useProviders } from "../hooks/useProviders"; import { @@ -19,7 +20,9 @@ import { } from "../lib/tauri"; import { ProviderIcon } from "../components/providers/ProviderIcon"; import { getProviderIcon } from "../components/providers/providerIcons"; +import { costPeriodShortLabel } from "../lib/costPeriod"; import { describeProviderState } from "../lib/providerState"; +import { resetDescriptionFallback } from "../lib/usageWindows"; import type { BootstrapState, ProviderLocalUsageSummary, @@ -92,7 +95,10 @@ type FloatBarCostSummary = { providerId: string; displayName: string; todayCost: number | null; - thirtyDayCost: number | null; + /** Cost over the selected History window. */ + periodCost: number | null; + /** Raw History window `periodCost` covers. */ + period: string; }; type FloatBarCostTarget = { @@ -106,34 +112,30 @@ function providerCostKey(provider: ProviderUsageSnapshot): string { } function hasLocalCost(summary: ProviderLocalUsageSummary | null): summary is ProviderLocalUsageSummary { - return summary?.todayCost != null || summary?.thirtyDayCost != null; -} - -function formatUsd(value: number | null): string | null { - if (value == null || !Number.isFinite(value)) return null; - return `$${value.toFixed(2)}`; + return summary?.todayCost != null || summary?.periodCost != null; } function CostPill({ summary, scale, todayLabel, - thirtyDayLabel, + periodLabel, estimateLabel, }: { summary: FloatBarCostSummary; scale: number; todayLabel: string; - thirtyDayLabel: string; + periodLabel: string; estimateLabel: string; }) { - const today = formatUsd(summary.todayCost); - const thirtyDay = formatUsd(summary.thirtyDayCost); + const { format } = useCurrency(); + const today = summary.todayCost == null ? null : format(summary.todayCost, "USD"); + const periodCost = summary.periodCost == null ? null : format(summary.periodCost, "USD"); const iconSize = Math.round(10 * scale); const brand = getProviderIcon(summary.providerId).brandColor; const title = [ today ? `${todayLabel} ${today}` : null, - thirtyDay ? `${thirtyDayLabel} ${thirtyDay}` : null, + periodCost ? `${periodLabel} ${periodCost}` : null, ] .filter(Boolean) .join(" / "); @@ -159,13 +161,13 @@ function CostPill({ )} - {thirtyDay && ( + {periodCost && ( - {thirtyDayLabel} + {periodLabel} - {thirtyDay} + {periodCost} )} @@ -182,6 +184,11 @@ function CostPill({ * Color follows usage: green default, amber when remaining drops below the * high-usage threshold, red when remaining is below the critical threshold * or the provider is exhausted. + * + * An informational metric (no budget set, a balance line, no active session) + * has no quota percentage. Like the tray card and the tray tooltip, the pill + * shows its text instead, keeps the neutral tone, and never turns that text + * into reset wording. */ function ProviderPill({ provider, @@ -207,21 +214,29 @@ function ProviderPill({ stateLabel: string; }) { const rateWindow = provider.selectedMetric; + const informational = rateWindow.isInformational === true; const remaining = Math.max(0, Math.min(100, rateWindow.remainingPercent)); const used = Math.max(0, Math.min(100, rateWindow.usedPercent)); const displayPercent = showAsUsed ? used : remaining; const displaySuffix = showAsUsed ? usedSuffix : remainingSuffix; const state = describeProviderState(provider.errorState); - const exhausted = rateWindow.isExhausted || state.isProblem; let tone: "ok" | "warn" | "crit" = "ok"; - if (exhausted || remaining <= critRemaining) tone = "crit"; - else if (remaining <= highRemaining) tone = "warn"; + if (state.isProblem) tone = "crit"; + else if (!informational) { + if (rateWindow.isExhausted || remaining <= critRemaining) tone = "crit"; + else if (remaining <= highRemaining) tone = "warn"; + } const brand = getProviderIcon(provider.providerId).brandColor; - const label = state.isProblem ? stateLabel : `${Math.round(displayPercent)}%`; + const infoText = rateWindow.resetDescription?.trim() || "—"; + const label = state.isProblem + ? stateLabel + : informational + ? infoText + : `${Math.round(displayPercent)}%`; const resetText = useFormattedResetTime( rateWindow.resetsAt, - rateWindow.resetDescription, + informational ? null : resetDescriptionFallback(rateWindow), resetRelative, ); const resetSuffix = resetText ? `\n${resetText}` : ""; @@ -237,7 +252,9 @@ function ProviderPill({ title={ state.isProblem ? `${provider.displayName}: ${stateLabel}` - : `${provider.displayName}: ${label} ${displaySuffix}${resetSuffix}` + : informational + ? `${provider.displayName}: ${infoText}${resetSuffix}` + : `${provider.displayName}: ${label} ${displaySuffix}${resetSuffix}` } data-tauri-drag-region style={{ "--brand": brand } as CSSProperties} @@ -382,7 +399,8 @@ export default function FloatBar({ state }: { state: BootstrapState }) { providerId: target.providerId, displayName: target.displayName, todayCost: localUsage.todayCost, - thirtyDayCost: localUsage.thirtyDayCost, + periodCost: localUsage.periodCost, + period: localUsage.reportingPeriod, } satisfies FloatBarCostSummary; }), ) @@ -403,13 +421,14 @@ export default function FloatBar({ state }: { state: BootstrapState }) { return () => { cancelled = true; }; - }, [visibleCostTargets]); + // A History window change re-reads the local usage summaries. + }, [visibleCostTargets, settings.costReportingPeriod]); const visibleCosts = visible .map((provider) => localCosts[providerCostKey(provider)]) .filter((summary): summary is FloatBarCostSummary => Boolean(summary)); const visibleCostValuesKey = visibleCosts - .map((summary) => `${summary.key}:${summary.todayCost ?? ""}:${summary.thirtyDayCost ?? ""}`) + .map((summary) => `${summary.key}:${summary.todayCost ?? ""}:${summary.period ?? ""}:${summary.periodCost ?? ""}`) .join("|"); // Keep the native floatbar window fitted when late data/fonts/icons change layout. const lastResizeRef = useRef<{ w: number; h: number } | null>(null); @@ -522,7 +541,7 @@ export default function FloatBar({ state }: { state: BootstrapState }) { summary={summary} scale={scale} todayLabel={t("PanelToday")} - thirtyDayLabel={t("FloatBarThirtyDayShort")} + periodLabel={costPeriodShortLabel(summary.period, t)} estimateLabel={t("OverviewSpendEstimate")} /> ))} diff --git a/apps/desktop-tauri/src/hooks/CurrencyProvider.tsx b/apps/desktop-tauri/src/hooks/CurrencyProvider.tsx new file mode 100644 index 0000000000..153a949a44 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/CurrencyProvider.tsx @@ -0,0 +1,91 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrencyRates, getSettingsSnapshot } from "../lib/tauri"; +import { formatDisplayCurrency } from "../lib/currency"; + +interface CurrencyContextValue { + preferredCode: string; + rates: Record; + format: (amount: number | null | undefined, sourceCode: string, sourceSymbol?: string | null) => string; +} + +const EMPTY_RATES: Record = {}; + +const CurrencyContext = createContext({ + preferredCode: "AUTO", + rates: EMPTY_RATES, + format: (amount, sourceCode, sourceSymbol) => + formatDisplayCurrency(amount, sourceCode, "AUTO", EMPTY_RATES, sourceSymbol), +}); + +export function CurrencyProvider({ children }: { children: ReactNode }) { + const [preferredCode, setPreferredCode] = useState("AUTO"); + const [rates, setRates] = useState>(EMPTY_RATES); + const requestId = useRef(0); + + // Single subscription path: the backend's `settings-changed` broadcast + // (the same event `useSettings` consumes). Re-fetch the settings snapshot + // and reload rates only when the preference actually changed. + const reloadForPreference = useCallback((preference: string) => { + const selected = preference.trim().toUpperCase() || "AUTO"; + setPreferredCode(selected); + if (selected === "AUTO") { + requestId.current += 1; + setRates(EMPTY_RATES); + return; + } + const id = ++requestId.current; + void getCurrencyRates(selected) + .then((snapshot) => { + if (requestId.current === id) setRates(snapshot.rates); + }) + .catch(() => { + // Keep the last known table; exchange-rate availability never blocks + // app surfaces. The backend merged fallback rates into the snapshot. + }); + }, []); + + useEffect(() => { + let cancelled = false; + let unlisten: UnlistenFn | null = null; + + void getSettingsSnapshot() + .then((settings) => { + if (!cancelled) reloadForPreference(settings.preferredCurrencyCode ?? "AUTO"); + }) + .catch(() => {}); + + listen("settings-changed", () => { + void getSettingsSnapshot() + .then((settings) => { + if (!cancelled) reloadForPreference(settings.preferredCurrencyCode ?? "AUTO"); + }) + .catch(() => {}); + }) + .then((fn) => { + if (cancelled) { + fn(); + } else { + unlisten = fn; + } + }) + .catch(() => {}); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [reloadForPreference]); + + const format = useCallback( + (amount: number | null | undefined, sourceCode: string, sourceSymbol?: string | null) => + formatDisplayCurrency(amount, sourceCode, preferredCode, rates, sourceSymbol), + [preferredCode, rates], + ); + + return {children}; +} + +export function useCurrency(): CurrencyContextValue { + return useContext(CurrencyContext); +} diff --git a/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.test.tsx b/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.test.tsx new file mode 100644 index 0000000000..27c7d48084 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.test.tsx @@ -0,0 +1,49 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MonthlyLimitBlock } from "../types/bridge"; +import { isMonthlyLimitBlockActive } from "../lib/monthlyLimitBlock"; +import { useMonthlyLimitBlockNow } from "./useMonthlyLimitBlockNow"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function Probe({ block }: { block: MonthlyLimitBlock | null }) { + const now = useMonthlyLimitBlockNow([block]); + return ( + {isMonthlyLimitBlockActive(block, now) ? "blocked" : "open"} + ); +} + +describe("useMonthlyLimitBlockNow", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-01T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("re-renders exactly when a weeks-away pool reset passes", async () => { + render(); + expect(screen.getByTestId("state")).toHaveTextContent("blocked"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20 * DAY_MS - 1); + }); + expect(screen.getByTestId("state")).toHaveTextContent("blocked"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.getByTestId("state")).toHaveTextContent("open"); + }); + + it("keeps a block without a known reset", async () => { + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(365 * DAY_MS); + }); + expect(screen.getByTestId("state")).toHaveTextContent("blocked"); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.ts b/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.ts new file mode 100644 index 0000000000..c186738aca --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useMonthlyLimitBlockNow.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import type { MonthlyLimitBlock } from "../types/bridge"; +import { monthlyLimitBlockResetMs } from "../lib/monthlyLimitBlock"; + +// Browsers clamp timers above 2^31-1 ms (about 24.8 days); re-arm before that. +const MAX_WAIT_MS = 6 * 60 * 60 * 1000; + +/** + * Current time for monthly-limit block checks. Schedules a re-render when the + * earliest future block reset passes, so a cached snapshot unblocks on time + * instead of waiting for the next refresh. + */ +export function useMonthlyLimitBlockNow( + blocks: readonly (MonthlyLimitBlock | null | undefined)[], +): number { + const [tick, setTick] = useState(0); + const now = Date.now(); + let nextReset: number | null = null; + for (const block of blocks) { + const reset = monthlyLimitBlockResetMs(block); + if (reset !== null && reset > now && (nextReset === null || reset < nextReset)) { + nextReset = reset; + } + } + + useEffect(() => { + if (nextReset === null) return; + const wait = Math.min(Math.max(nextReset - Date.now(), 0), MAX_WAIT_MS); + const id = window.setTimeout(() => setTick((value) => value + 1), wait); + return () => window.clearTimeout(id); + }, [nextReset, tick]); + + return now; +} diff --git a/apps/desktop-tauri/src/hooks/useProviders.test.tsx b/apps/desktop-tauri/src/hooks/useProviders.test.tsx index 0f07ad3878..93601110d9 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.test.tsx +++ b/apps/desktop-tauri/src/hooks/useProviders.test.tsx @@ -62,6 +62,13 @@ function provider(id: string, usedPercent = 20): ProviderUsageSnapshot { }; } +function providerResettingAt(id: string, resetsAt: string): ProviderUsageSnapshot { + const snapshot = provider(id); + return { ...snapshot, primary: { ...snapshot.primary, resetsAt } }; +} + +const DAY_MS = 24 * 60 * 60 * 1_000; + function emitProviderEvent(event: string, payload: unknown) { for (const listener of eventMocks.listeners.get(event) ?? []) { listener({ payload }); @@ -403,6 +410,75 @@ describe("useProviders", () => { } }); + it("refreshes once the soonest reset has passed", async () => { + vi.useFakeTimers({ now: new Date("2026-10-01T00:00:00Z") }); + try { + tauriMocks.getCachedProviders.mockResolvedValue([ + providerResettingAt("codex", "2026-10-01T00:10:00Z"), + ]); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await act(async () => {}); + expect(result.current.providers).toHaveLength(1); + + // The timer waits one second past the reset. + await act(async () => { + vi.advanceTimersByTime(10 * 60_000); + }); + expect(tauriMocks.refreshProviders).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1_000); + }); + expect(tauriMocks.refreshProviders).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("waits for a reset beyond the 32-bit timer limit instead of refreshing at once", async () => { + vi.useFakeTimers({ now: new Date("2026-10-01T00:00:00Z") }); + try { + // 30 days is past the 2^31 - 1 ms (about 24.8 days) that setTimeout can + // hold. A longer delay runs at once, which forced a refresh on every + // provider update. + tauriMocks.getCachedProviders.mockResolvedValue([ + providerResettingAt("cursor", "2026-10-31T00:00:00Z"), + ]); + const { result } = renderHook(() => useProviders({ refreshOnMount: false })); + await act(async () => {}); + expect(result.current.providers).toHaveLength(1); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + expect(tauriMocks.refreshProviders).not.toHaveBeenCalled(); + + // A fresh snapshot with the same reset re-arms the timer; it must still wait. + act(() => { + emitProviderEvent("provider-updated", providerResettingAt("cursor", "2026-10-31T00:00:00Z")); + }); + await act(async () => { + vi.advanceTimersByTime(80); + }); + await act(async () => { + vi.advanceTimersByTime(25 * DAY_MS); + }); + expect(tauriMocks.refreshProviders).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(5 * DAY_MS - 60_080); + }); + expect(tauriMocks.refreshProviders).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1_000); + }); + expect(tauriMocks.refreshProviders).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it("clears each provider from refresh state as it completes", async () => { const { result } = renderHook(() => useProviders({ refreshOnMount: false }), diff --git a/apps/desktop-tauri/src/hooks/useProviders.ts b/apps/desktop-tauri/src/hooks/useProviders.ts index 00cf92c8f5..7eff36af3c 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.ts +++ b/apps/desktop-tauri/src/hooks/useProviders.ts @@ -11,6 +11,13 @@ import { refreshProvidersIfStale, } from "../lib/tauri"; +/** + * Longest delay `setTimeout` can hold: browsers, WebView2 included, store it + * as a signed 32-bit integer. A longer delay wraps around and fires early: + * a reset 24.9 to 49.7 days away would refresh at once. + */ +const MAX_TIMER_DELAY_MS = 2_147_483_647; + export interface UseProvidersOptions { /** * Delay the automatic stale-aware refresh on mount. Tray/menu surfaces use @@ -276,11 +283,20 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes if (nextReset === undefined) return; - const delay = Math.max(5_000, nextReset - now + 1_000); - resetRefreshTimerRef.current = window.setTimeout(() => { - resetRefreshTimerRef.current = undefined; - refresh(); - }, delay); + const resetAt = nextReset; + const armResetRefresh = () => { + const remaining = resetAt - Date.now() + 1_000; + if (remaining > MAX_TIMER_DELAY_MS) { + // Too far away for one timer: wait the longest delay, then re-check. + resetRefreshTimerRef.current = window.setTimeout(armResetRefresh, MAX_TIMER_DELAY_MS); + return; + } + resetRefreshTimerRef.current = window.setTimeout(() => { + resetRefreshTimerRef.current = undefined; + refresh(); + }, Math.max(5_000, remaining)); + }; + armResetRefresh(); return () => { if (resetRefreshTimerRef.current !== undefined) { diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx index 7dbf6d6f7d..1e70c015ff 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -258,7 +258,7 @@ describe("useTrayPanelLayout sizing", () => { await nudgePass(result, 417, "421px"); // → 421 → 526 phys expect(lastResize()).toEqual({ width: 328, height: 421 }); expect(surface.style.maxHeight).toBe("421px"); - }); + }, 30_000); // 8 bounded 3s settling passes + 3s readiness can exceed Vitest's 5s default. it("reconciles to the applied physical frame after an OS snap (no churn, no cycle)", async () => { // Deliberate 5-physical snap: requesting 539 logical (→674 phys) yields an diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 90c0984f49..d9e3d2b0ed 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -76,6 +76,8 @@ export const ALL_LOCALE_KEYS = [ "ProviderDisabled", "ProviderInfo", "ProviderUsage", + "AtlasCloudAvailableBalance", + "AtlasCloudBalance", "PanelUsageDetails", "AuthType", "DataSource", @@ -96,6 +98,11 @@ export const ALL_LOCALE_KEYS = [ "WayfinderGatewayLabel", "WayfinderGatewayHelp", "WayfinderGatewayStatus", + "BifrostGatewayTitle", + "BifrostGatewayHelp", + "AixyGatewayTitle", + "AixyGatewayLabel", + "AixyGatewayHelp", "WayfinderModels", "WayfinderRequests", "WayfinderTokens", @@ -284,6 +291,10 @@ export const ALL_LOCALE_KEYS = [ "MenuBarMetric", "MenuBarMetricHelper", "UsageSource", + "ProviderZedBrowserSession", + "ProviderZedUsageSourceAutoHelp", + "ProviderZedUsageSourceApiHelp", + "ProviderZedUsageSourceWebHelp", "ProviderNoCodexAccountsDetected", "ProviderCodexAutoImportHelp", "ProviderCodexHistoryHelp", @@ -395,7 +406,6 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendTitle", "UsageSpendCaption", "UsageSpendModels", - "UsageSpendAllTime", "UsageSpendOpenCodexImport", "UsageSpendHideNativeCodex", "UsageSpendSpend", @@ -415,8 +425,12 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendHourlyActivity", "UsageSpendRequests", "UsageSpendTokens", + "UsageSpendKnownSubtotal", "UsageSpendAllTimeHistory", "UsageSpendCustomPricing", + "UsageSpendDailyLedger", + "UsageSpendDailyLedgerHelper", + "UsageSpendNoDailyData", "OverviewSpendTitle", "OverviewSpendProviderCoverage", "OverviewSpendEstimate", @@ -434,6 +448,9 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendCol30d", "UsageSpendColCurrency", "UsageSpendColSource", + "UsageSpendColDate", + "UsageSpendColCost", + "UsageSpendColTokens", "UsageSpendShareFailed", "UsageSpendShareEmpty", "UsageSpendShare", @@ -507,6 +524,8 @@ export const ALL_LOCALE_KEYS = [ "SectionUsageRendering", "SectionTime", "SectionLanguage", + "PreferredCurrencyLabel", + "PreferredCurrencyHelper", "SectionCredentialsSecurity", "SectionDebug", "SectionApiKeys", @@ -540,12 +559,17 @@ export const ALL_LOCALE_KEYS = [ "TrayIconModeHelper", "TrayIconModeSingle", "TrayIconModePerProvider", + "TrayIconModeStacked", + "StackedTrayTopProvider", + "StackedTrayBottomProvider", "ShowProviderIcons", "ShowProviderIconsHelper", "PreferHighestUsage", "PreferHighestUsageHelper", "ShowPercentInTray", "ShowPercentInTrayHelper", + "ColorPaceInTray", + "ColorPaceInTrayHelper", "DisplayModeLabel", "DisplayModeHelper", "DisplayModeDetailed", @@ -624,6 +648,19 @@ export const ALL_LOCALE_KEYS = [ "DetailChartCredits", "DetailChartUsageBreakdown", "DetailChartEmpty", + "OpenAIChartTitle", + "OpenAIChartMetricCost", + "OpenAIChartMetricTokens", + "OpenAIChartHint", + "OpenAIChartRequests", + "OpenAIChartInputTokens", + "OpenAIChartCachedTokens", + "OpenAIChartOutputTokens", + "OpenAIChartLineItems", + "OpenAIChartModels", + "OpenAIChartMore", + "OpenAIChartWindowToday", + "OpenAIChartWindowDays", "DetailUpdatedPrefix", "PanelAllProviders", "PanelAllProvidersShort", @@ -633,8 +670,6 @@ export const ALL_LOCALE_KEYS = [ "PanelMenu", "PanelCopied", "PanelToday", - "PanelThirtyDayCost", - "PanelThirtyDayTokens", "PanelLatestTokens", "PanelThirtyDayCostHistogram", "PanelTopModelPrefix", @@ -651,10 +686,10 @@ export const ALL_LOCALE_KEYS = [ "PanelFiveHours", "PanelTodayBudget", "PanelReserveSuffix", + "PanelBlockedByMonthlyLimit", "PanelReserveLastsUntilReset", "PanelReserveRunsOutInDaysHours", "PanelReserveRunsOutInHours", - "FloatBarThirtyDayShort", "FloatBarNoProviders", "FloatBarRemainingSuffix", "FloatBarShowCost", @@ -888,6 +923,8 @@ export const ALL_LOCALE_KEYS = [ // Mistral PAYG monthly spend (#2821, #2947) "MistralMonthlySpend", "MistralMonthlySpendHelper", + // Mistral Monthly Plan menu bar metric (upstream 0.70.0 #4072) + "MetricMonthlyPlan", // Menu cost-summary display style (#2976) "CostSummaryDisplayStyle", @@ -906,6 +943,28 @@ export const ALL_LOCALE_KEYS = [ "ProviderAutoResumeTitle", "ProviderAutoResumeAfterQuotaReset", "ProviderAutoResumeAfterQuotaResetHelper", + + // Recorded remaining-quota burndown (upstream 0.70.0 #4085) + "BurndownChartTitle", + "BurndownChartAriaLabel", + + // History window for local cost surfaces (upstream 0.67.0 reporting periods) + "CostPeriodHistoryWindow", + "CostPeriodHistoryWindowHelper", + "CostPeriodMonthToDate", + "CostPeriodAll", + "CostPeriodToday", + "CostPeriodLastDays", + "CostPeriodCustom", + "CostPeriodCustomDays", + "CostPeriodShortMonthToDate", + "CostPeriodShortAll", + "CostPeriodShortDays", + "PanelPeriodCost", + "PanelPeriodTokens", + "OverviewSpendPeriodTitle", + "UsageSpendModelsPeriodCaption", + "UsageSpendProjectsPeriodCaption", ] as const; export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number]; diff --git a/apps/desktop-tauri/src/lib/costPeriod.test.ts b/apps/desktop-tauri/src/lib/costPeriod.test.ts new file mode 100644 index 0000000000..fd8547865f --- /dev/null +++ b/apps/desktop-tauri/src/lib/costPeriod.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import type { LocaleKey } from "../i18n/keys"; +import { + DEFAULT_COST_PERIOD, + PRESET_COST_PERIODS, + costPeriodLabel, + costPeriodShortLabel, + customPeriodRaw, + isPresetCostPeriod, + normalizeCostPeriod, + parseCostPeriod, + periodCostLabel, + periodTokensLabel, + rollingDays, +} from "./costPeriod"; + +const EN: Partial> = { + CostPeriodMonthToDate: "Month to date", + CostPeriodAll: "All available history", + CostPeriodToday: "Today", + CostPeriodLastDays: "Last {} days", + CostPeriodShortMonthToDate: "MTD", + CostPeriodShortAll: "All", + CostPeriodShortDays: "{}d", + PanelPeriodCost: "{} cost", + PanelPeriodTokens: "{} tokens", +}; +const t = (key: LocaleKey) => EN[key] ?? key; + +describe("parseCostPeriod", () => { + it("reads the persisted forms", () => { + expect(parseCostPeriod("month-to-date")).toEqual({ kind: "monthToDate" }); + expect(parseCostPeriod("all")).toEqual({ kind: "all" }); + expect(parseCostPeriod("rolling:7")).toEqual({ kind: "rolling", days: 7 }); + expect(parseCostPeriod(" rolling:90 ")).toEqual({ kind: "rolling", days: 90 }); + }); + + it("clamps counts above 365 and rejects zero and unreadable values", () => { + expect(parseCostPeriod("rolling:4000")).toEqual({ kind: "rolling", days: 365 }); + expect(parseCostPeriod("rolling:0")).toBeNull(); + expect(parseCostPeriod("rolling:-3")).toBeNull(); + expect(parseCostPeriod("rolling:seven")).toBeNull(); + expect(parseCostPeriod("weekly")).toBeNull(); + expect(parseCostPeriod("")).toBeNull(); + expect(parseCostPeriod(undefined)).toBeNull(); + }); +}); + +describe("normalizeCostPeriod", () => { + it("keeps valid periods and falls back to 30 days otherwise", () => { + expect(normalizeCostPeriod("month-to-date")).toBe("month-to-date"); + expect(normalizeCostPeriod("rolling:9999")).toBe("rolling:365"); + expect(normalizeCostPeriod("rolling:0")).toBe(DEFAULT_COST_PERIOD); + expect(normalizeCostPeriod(undefined)).toBe("rolling:30"); + }); +}); + +describe("customPeriodRaw", () => { + it("accepts integers in 1..=365 only", () => { + expect(customPeriodRaw("1")).toBe("rolling:1"); + expect(customPeriodRaw(" 14 ")).toBe("rolling:14"); + expect(customPeriodRaw("365")).toBe("rolling:365"); + expect(customPeriodRaw("0")).toBeNull(); + expect(customPeriodRaw("366")).toBeNull(); + expect(customPeriodRaw("1.5")).toBeNull(); + expect(customPeriodRaw("-2")).toBeNull(); + expect(customPeriodRaw("")).toBeNull(); + expect(customPeriodRaw("abc")).toBeNull(); + }); +}); + +describe("presets", () => { + it("lists month to date, 1/7/30/90/365 days, and all", () => { + expect([...PRESET_COST_PERIODS]).toEqual([ + "month-to-date", + "rolling:1", + "rolling:7", + "rolling:30", + "rolling:90", + "rolling:365", + "all", + ]); + expect(isPresetCostPeriod("rolling:7")).toBe(true); + expect(isPresetCostPeriod("rolling:14")).toBe(false); + expect(rollingDays("rolling:14")).toBe(14); + expect(rollingDays("all")).toBeNull(); + }); +}); + +describe("labels", () => { + it("builds long labels", () => { + expect(costPeriodLabel("month-to-date", t)).toBe("Month to date"); + expect(costPeriodLabel("all", t)).toBe("All available history"); + expect(costPeriodLabel("rolling:1", t)).toBe("Today"); + expect(costPeriodLabel("rolling:14", t)).toBe("Last 14 days"); + expect(costPeriodLabel(undefined, t)).toBe("Last 30 days"); + }); + + it("builds short and menu labels", () => { + expect(costPeriodShortLabel("month-to-date", t)).toBe("MTD"); + expect(costPeriodShortLabel("all", t)).toBe("All"); + expect(costPeriodShortLabel("rolling:7", t)).toBe("7d"); + expect(periodCostLabel("month-to-date", t)).toBe("MTD cost"); + expect(periodTokensLabel("rolling:90", t)).toBe("90d tokens"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/costPeriod.ts b/apps/desktop-tauri/src/lib/costPeriod.ts new file mode 100644 index 0000000000..f03f1d8b64 --- /dev/null +++ b/apps/desktop-tauri/src/lib/costPeriod.ts @@ -0,0 +1,118 @@ +import type { LocaleKey } from "../i18n/keys"; + +/** + * History window ("cost reporting period") helpers. The persisted form mirrors + * `codexbar::cost_reporting_period::CostReportingPeriod::raw`: + * `rolling:N` (1..=365), `month-to-date`, or `all`. + */ + +export const DEFAULT_COST_PERIOD = "rolling:30"; +export const MAX_ROLLING_DAYS = 365; + +export type ParsedCostPeriod = + | { kind: "rolling"; days: number } + | { kind: "monthToDate" } + | { kind: "all" }; + +type Translate = (key: LocaleKey) => string; + +/** Presets offered by the picker, in display order. Custom day counts are separate. */ +export const PRESET_COST_PERIODS = [ + "month-to-date", + "rolling:1", + "rolling:7", + "rolling:30", + "rolling:90", + "rolling:365", + "all", +] as const; + +/** Parse a persisted period. Unknown forms and `rolling:0` return null; counts above 365 clamp. */ +export function parseCostPeriod(raw: string | null | undefined): ParsedCostPeriod | null { + const value = raw?.trim(); + if (!value) return null; + if (value === "month-to-date") return { kind: "monthToDate" }; + if (value === "all") return { kind: "all" }; + const match = /^rolling:(\d+)$/.exec(value); + if (!match) return null; + const days = Number.parseInt(match[1], 10); + if (!Number.isSafeInteger(days) || days <= 0) return null; + return { kind: "rolling", days: Math.min(days, MAX_ROLLING_DAYS) }; +} + +function toRaw(parsed: ParsedCostPeriod): string { + switch (parsed.kind) { + case "monthToDate": + return "month-to-date"; + case "all": + return "all"; + case "rolling": + return `rolling:${parsed.days}`; + } +} + +/** Canonical persisted form, or the 30-day default when the value is missing or unreadable. */ +export function normalizeCostPeriod(raw: string | null | undefined): string { + const parsed = parseCostPeriod(raw); + return parsed ? toRaw(parsed) : DEFAULT_COST_PERIOD; +} + +/** Persisted form for a typed custom day count; null unless it is an integer in 1..=365. */ +export function customPeriodRaw(input: string): string | null { + const text = input.trim(); + if (!/^\d+$/.test(text)) return null; + const days = Number.parseInt(text, 10); + if (!Number.isSafeInteger(days) || days < 1 || days > MAX_ROLLING_DAYS) return null; + return `rolling:${days}`; +} + +export function isPresetCostPeriod(raw: string): boolean { + return (PRESET_COST_PERIODS as readonly string[]).includes(raw); +} + +/** Day count of a rolling period, or null for month-to-date / all. */ +export function rollingDays(raw: string | null | undefined): number | null { + const parsed = parseCostPeriod(raw); + return parsed?.kind === "rolling" ? parsed.days : null; +} + +function fill(template: string, value: string): string { + return template.replace("{}", value); +} + +/** Long label, for headings and captions ("Month to date", "Last 7 days"). */ +export function costPeriodLabel(raw: string | null | undefined, t: Translate): string { + const parsed = parseCostPeriod(raw ?? DEFAULT_COST_PERIOD) ?? { kind: "rolling", days: 30 }; + switch (parsed.kind) { + case "monthToDate": + return t("CostPeriodMonthToDate"); + case "all": + return t("CostPeriodAll"); + case "rolling": + return parsed.days === 1 + ? t("CostPeriodToday") + : fill(t("CostPeriodLastDays"), String(parsed.days)); + } +} + +/** Compact label, for menu rows and the float bar ("MTD", "7d", "All"). */ +export function costPeriodShortLabel(raw: string | null | undefined, t: Translate): string { + const parsed = parseCostPeriod(raw ?? DEFAULT_COST_PERIOD) ?? { kind: "rolling", days: 30 }; + switch (parsed.kind) { + case "monthToDate": + return t("CostPeriodShortMonthToDate"); + case "all": + return t("CostPeriodShortAll"); + case "rolling": + return fill(t("CostPeriodShortDays"), String(parsed.days)); + } +} + +/** Menu row label for the selected window ("MTD cost"). */ +export function periodCostLabel(raw: string | null | undefined, t: Translate): string { + return fill(t("PanelPeriodCost"), costPeriodShortLabel(raw, t)); +} + +export function periodTokensLabel(raw: string | null | undefined, t: Translate): string { + return fill(t("PanelPeriodTokens"), costPeriodShortLabel(raw, t)); +} diff --git a/apps/desktop-tauri/src/lib/currency.test.ts b/apps/desktop-tauri/src/lib/currency.test.ts new file mode 100644 index 0000000000..12e8a5e38f --- /dev/null +++ b/apps/desktop-tauri/src/lib/currency.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { formatDisplayCurrency, sumDisplayCurrencyAmounts } from "./currency"; + +// Rust owns the currency model (`codexbar::currency`); these rates mirror the +// merged snapshot the backend returns so the format tests stay deterministic. +const RATES: Record = { + USD: 1, + GBP: 0.79, + EUR: 0.92, + TRY: 48.5, + // The twelve 0.67.0 additions (upstream fallback rates, 2026-09-24). + NZD: 1.761, + SEK: 9.908, + NOK: 9.48, + DKK: 6.554, + PLN: 3.838, + BRL: 5.117, + MXN: 17.47, + ZAR: 16.36, + THB: 33.37, + IDR: 17836, + VND: 25962, + UAH: 44.86, +}; + +describe("preferred currency display", () => { + it("converts through the USD pivot and formats with Intl", () => { + const display = formatDisplayCurrency(10, "USD", "TRY", RATES); + expect(display).not.toContain("10.00"); + expect(display).toMatch(/485/); + }); + + it("converts each added currency through the USD pivot", () => { + for (const code of ["NZD", "SEK", "NOK", "DKK", "PLN", "BRL", "MXN", "ZAR", "THB", "IDR", "VND", "UAH"]) { + const rate = RATES[code]; + // The rendered string carries the Intl symbol prefix (e.g. "NZ$17.61", + // "17 618 Rp"), so assert on the exact converted digits. + const expected = new Intl.NumberFormat(undefined, { + style: "currency", + currency: code, + }).format(10 * rate); + expect(formatDisplayCurrency(10, "USD", code, RATES)).toBe(expected); + // Round trip back through the pivot. + expect(formatDisplayCurrency(10 * rate, code, "USD", RATES)).toMatch(/10(\.00)?$/); + } + expect(formatDisplayCurrency(10, "EUR", "SEK", RATES)).toMatch(/107\.7/); + }); + + it("formats zero-decimal currencies without fractional units", () => { + const vnd = formatDisplayCurrency(0.01, "USD", "VND", RATES); + expect(vnd).toMatch(/260/); + expect(vnd).not.toMatch(/[.,]\d{1,2}\D*$/); + expect(formatDisplayCurrency(1, "USD", "JPY", { ...RATES, JPY: 154 })).toMatch(/154(?![.,]\d)/); + }); + + it("keeps AUTO, credits, unknown units, and missing-rate values in source units", () => { + expect(formatDisplayCurrency(4.25, "USD", "AUTO", RATES)).toMatch(/4\.25/); + expect(formatDisplayCurrency(4.25, "Credits", "TRY", RATES)).toBe("4.25 Credits"); + expect(formatDisplayCurrency(10, "USD", "TRY", {}, "$")).toBe("$10.00"); + }); + + it("prefers the source symbol when both codes match", () => { + expect(formatDisplayCurrency(4.25, "USD", "USD", RATES, "$")).toBe("$4.25"); + }); + + it("sums only converted overview rows and reports incomplete coverage", () => { + const result = sumDisplayCurrencyAmounts( + [ + { amount: 10, currency: "USD" }, + { amount: 10, currency: "EUR" }, + { amount: 5, currency: "Credits" }, + ], + "TRY", + RATES, + ); + expect(result.included).toBe(2); + expect(result.considered).toBe(3); + expect(result.total).toBeCloseTo(10 * 48.5 + (10 / 0.92) * 48.5, 8); + }); + + it("sums USD-only rows unchanged under AUTO", () => { + const result = sumDisplayCurrencyAmounts( + [ + { amount: 10, currency: "USD" }, + { amount: 10, currency: "EUR" }, + ], + "AUTO", + RATES, + ); + expect(result.included).toBe(1); + expect(result.total).toBe(10); + }); + + it("sums a row in each added currency into the TRY total", () => { + const result = sumDisplayCurrencyAmounts( + [ + { amount: 10, currency: "NZD" }, + { amount: 10, currency: "SEK" }, + { amount: 10, currency: "UAH" }, + ], + "TRY", + RATES, + ); + expect(result.included).toBe(3); + expect(result.considered).toBe(3); + const expected = ((10 / 1.761) + (10 / 9.908) + (10 / 44.86)) * 48.5; + expect(result.total).toBeCloseTo(expected, 6); + }); +}); diff --git a/apps/desktop-tauri/src/lib/currency.ts b/apps/desktop-tauri/src/lib/currency.ts new file mode 100644 index 0000000000..110744de74 --- /dev/null +++ b/apps/desktop-tauri/src/lib/currency.ts @@ -0,0 +1,103 @@ +import { CURRENCY_CATALOG } from "./currencyCatalog.generated"; + +// The catalog (order, symbols, offline rates) is generated from rust/src/currency.rs. +export const SUPPORTED_CURRENCIES: readonly string[] = CURRENCY_CATALOG.map((entry) => entry.code); + +export const FALLBACK_CURRENCY_RATES: Record = Object.fromEntries( + CURRENCY_CATALOG.map((entry) => [entry.code, entry.fallbackRate]), +); + +export const CURRENCY_PICKER_OPTIONS: ReadonlyArray<{ value: string; label: string }> = + CURRENCY_CATALOG.map((entry) => ({ value: entry.code, label: `${entry.code} (${entry.symbol})` })); + +export function normalizePreferredCurrency(value: string | null | undefined): string { + const code = value?.trim().toUpperCase() || "AUTO"; + return code === "AUTO" || SUPPORTED_CURRENCIES.includes(code) ? code : "AUTO"; +} + +export function convertCurrencyAmount( + amount: number, + sourceCode: string, + targetCode: string, + rates: Record, +): number | null { + if (!Number.isFinite(amount)) return null; + const source = sourceCode.trim().toUpperCase(); + const target = targetCode.trim().toUpperCase(); + if (!SUPPORTED_CURRENCIES.includes(source) || !SUPPORTED_CURRENCIES.includes(target)) return null; + if (source === target) return amount; + const sourceRate = source === "USD" ? 1 : rates[source]; + const targetRate = target === "USD" ? 1 : rates[target]; + if (!Number.isFinite(sourceRate) || sourceRate <= 0 || !Number.isFinite(targetRate) || targetRate <= 0) return null; + const result = (amount / sourceRate) * targetRate; + return Number.isFinite(result) ? result : null; +} + +function formatOriginal(amount: number, code: string, symbol?: string | null): string { + if (symbol) return `${symbol}${amount.toFixed(2)}`; + if (!/^[A-Z]{3}$/.test(code)) return `${amount.toFixed(2)} ${code}`; + try { + return new Intl.NumberFormat("en-US", { style: "currency", currency: code }).format(amount); + } catch { + return `${amount.toFixed(2)} ${code}`; + } +} + +export function formatDisplayCurrency( + amount: number | null | undefined, + sourceCode: string, + preferredCode: string, + rates: Record, + sourceSymbol?: string | null, +): string { + if (amount == null || !Number.isFinite(amount)) return "—"; + const trimmedSource = sourceCode.trim(); + const source = trimmedSource.toUpperCase(); + const sourceLabel = /^[A-Za-z]{3}$/.test(trimmedSource) ? source : trimmedSource; + const preferred = normalizePreferredCurrency(preferredCode); + if (preferred === "AUTO") return formatOriginal(amount, sourceLabel, sourceSymbol); + const converted = convertCurrencyAmount(amount, source, preferred, rates); + if (converted == null) return formatOriginal(amount, sourceLabel, sourceSymbol); + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: preferred, + }).format(converted); + } catch { + return `${converted.toFixed(2)} ${preferred}`; + } +} + +export function mergeValidCurrencyRates(input: Record): Record { + const rates = { ...FALLBACK_CURRENCY_RATES }; + for (const code of SUPPORTED_CURRENCIES) { + const value = input[code]; + if (Number.isFinite(value) && value > 0 && (code !== "USD" || Math.abs(value - 1) <= Number.EPSILON)) { + rates[code] = value; + } + } + return rates; +} + +export function sumDisplayCurrencyAmounts( + rows: Array<{ amount: number | null | undefined; currency: string }>, + preferredCode: string, + rates: Record, +): { total: number | null; included: number; considered: number } { + const target = normalizePreferredCurrency(preferredCode); + let total = 0; + let included = 0; + for (const row of rows) { + if (row.amount == null || !Number.isFinite(row.amount)) continue; + let amount: number | null; + if (target === "AUTO") { + amount = row.currency.trim().toUpperCase() === "USD" ? row.amount : null; + } else { + amount = convertCurrencyAmount(row.amount, row.currency || "USD", target, rates); + } + if (amount == null) continue; + total += amount; + included += 1; + } + return { total: included > 0 && Number.isFinite(total) ? total : null, included, considered: rows.length }; +} diff --git a/apps/desktop-tauri/src/lib/currencyCatalog.generated.ts b/apps/desktop-tauri/src/lib/currencyCatalog.generated.ts new file mode 100644 index 0000000000..8ca3e7089f --- /dev/null +++ b/apps/desktop-tauri/src/lib/currencyCatalog.generated.ts @@ -0,0 +1,32 @@ +// Generated from rust/src/currency.rs (CURRENCIES). Do not edit by hand. +// Regenerate (PowerShell): $env:UPDATE_CURRENCY_CATALOG = '1'; cargo test -p codexbar currency_catalog +export const CURRENCY_CATALOG = [ + { code: "USD", symbol: "$", fallbackRate: 1 }, + { code: "GBP", symbol: "£", fallbackRate: 0.79 }, + { code: "EUR", symbol: "€", fallbackRate: 0.92 }, + { code: "CZK", symbol: "Kč", fallbackRate: 21 }, + { code: "CNY", symbol: "¥", fallbackRate: 7.27 }, + { code: "JPY", symbol: "¥", fallbackRate: 154 }, + { code: "KRW", symbol: "₩", fallbackRate: 1428.9 }, + { code: "CAD", symbol: "$", fallbackRate: 1.38 }, + { code: "AUD", symbol: "$", fallbackRate: 1.55 }, + { code: "HKD", symbol: "$", fallbackRate: 7.8 }, + { code: "TWD", symbol: "NT$", fallbackRate: 32.3 }, + { code: "SGD", symbol: "$", fallbackRate: 1.34 }, + { code: "INR", symbol: "₹", fallbackRate: 84.5 }, + { code: "CHF", symbol: "Fr.", fallbackRate: 0.8 }, + { code: "AED", symbol: "د.إ", fallbackRate: 3.6725 }, + { code: "TRY", symbol: "₺", fallbackRate: 48.5 }, + { code: "NZD", symbol: "$", fallbackRate: 1.761 }, + { code: "SEK", symbol: "kr", fallbackRate: 9.908 }, + { code: "NOK", symbol: "kr", fallbackRate: 9.48 }, + { code: "DKK", symbol: "kr", fallbackRate: 6.554 }, + { code: "PLN", symbol: "zł", fallbackRate: 3.838 }, + { code: "BRL", symbol: "R$", fallbackRate: 5.117 }, + { code: "MXN", symbol: "$", fallbackRate: 17.47 }, + { code: "ZAR", symbol: "R", fallbackRate: 16.36 }, + { code: "THB", symbol: "฿", fallbackRate: 33.37 }, + { code: "IDR", symbol: "Rp", fallbackRate: 17836 }, + { code: "VND", symbol: "₫", fallbackRate: 25962 }, + { code: "UAH", symbol: "₴", fallbackRate: 44.86 }, +] as const; diff --git a/apps/desktop-tauri/src/lib/monthlyLimitBlock.test.ts b/apps/desktop-tauri/src/lib/monthlyLimitBlock.test.ts new file mode 100644 index 0000000000..94600734a3 --- /dev/null +++ b/apps/desktop-tauri/src/lib/monthlyLimitBlock.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { isMonthlyLimitBlockActive, monthlyLimitBlockResetMs } from "./monthlyLimitBlock"; + +const NOW = Date.parse("2026-06-01T00:00:00Z"); + +describe("monthlyLimitBlock", () => { + it("is inactive without a block", () => { + expect(isMonthlyLimitBlockActive(undefined, NOW)).toBe(false); + expect(isMonthlyLimitBlockActive(null, NOW)).toBe(false); + }); + + it("holds until the monthly pool reset passes", () => { + const block = { resetsAt: "2026-06-01T00:00:01Z" }; + expect(isMonthlyLimitBlockActive(block, NOW)).toBe(true); + expect(isMonthlyLimitBlockActive(block, NOW + 1000)).toBe(false); + expect(isMonthlyLimitBlockActive(block, NOW + 60_000)).toBe(false); + }); + + it("holds while the pool reset is unknown or unreadable", () => { + expect(isMonthlyLimitBlockActive({ resetsAt: null }, NOW)).toBe(true); + expect(isMonthlyLimitBlockActive({ resetsAt: "not a date" }, NOW)).toBe(true); + expect(monthlyLimitBlockResetMs({ resetsAt: null })).toBeNull(); + expect(monthlyLimitBlockResetMs({ resetsAt: "not a date" })).toBeNull(); + }); + + it("parses the RFC 3339 reset the backend sends", () => { + expect(monthlyLimitBlockResetMs({ resetsAt: "2026-06-30T12:00:00+00:00" })).toBe( + Date.parse("2026-06-30T12:00:00Z"), + ); + }); +}); diff --git a/apps/desktop-tauri/src/lib/monthlyLimitBlock.ts b/apps/desktop-tauri/src/lib/monthlyLimitBlock.ts new file mode 100644 index 0000000000..cc0dd21526 --- /dev/null +++ b/apps/desktop-tauri/src/lib/monthlyLimitBlock.ts @@ -0,0 +1,24 @@ +import type { MonthlyLimitBlock } from "../types/bridge"; + +/** Reset of a block in epoch ms; `null` when unknown or unparseable. */ +export function monthlyLimitBlockResetMs( + block: MonthlyLimitBlock | null | undefined, +): number | null { + if (!block?.resetsAt) return null; + const reset = Date.parse(block.resetsAt); + return Number.isNaN(reset) ? null : reset; +} + +/** + * Whether a longer exhausted pool still blocks a window at `now` (upstream + * 0.69.0 #4091). A block without a known reset holds, as upstream does for an + * exhausted pool with no reset; a known reset lifts it once reached. + */ +export function isMonthlyLimitBlockActive( + block: MonthlyLimitBlock | null | undefined, + now: number, +): boolean { + if (!block) return false; + const reset = monthlyLimitBlockResetMs(block); + return reset === null || reset > now; +} diff --git a/apps/desktop-tauri/src/lib/openAiApiIdentity.test.ts b/apps/desktop-tauri/src/lib/openAiApiIdentity.test.ts new file mode 100644 index 0000000000..6160b5b8d6 --- /dev/null +++ b/apps/desktop-tauri/src/lib/openAiApiIdentity.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { hideOpenAiApiProjectId } from "./openAiApiIdentity"; + +describe("hideOpenAiApiProjectId", () => { + it("masks only the project id in OpenAI identity labels", () => { + expect(hideOpenAiApiProjectId("Admin API: proj-private", true)).toBe("Admin API: ••••"); + expect(hideOpenAiApiProjectId("Project: proj-private", true)).toBe("Project: ••••"); + }); + + it("preserves visible identity text when privacy is off", () => { + expect(hideOpenAiApiProjectId("Admin API: proj-private", false)).toBe("Admin API: proj-private"); + }); + + it("does not alter unrelated labels", () => { + expect(hideOpenAiApiProjectId("Admin API", true)).toBe("Admin API"); + expect(hideOpenAiApiProjectId("gpt-5", true)).toBe("gpt-5"); + expect(hideOpenAiApiProjectId(null, true)).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/lib/openAiApiIdentity.ts b/apps/desktop-tauri/src/lib/openAiApiIdentity.ts new file mode 100644 index 0000000000..4e31c20c5b --- /dev/null +++ b/apps/desktop-tauri/src/lib/openAiApiIdentity.ts @@ -0,0 +1,8 @@ +/** Mask OpenAI Admin project ids in generic identity labels when privacy is on. */ +export function hideOpenAiApiProjectId( + value: string | null, + hidePersonalInfo: boolean, +): string | null { + if (!hidePersonalInfo || !value) return value; + return value.replace(/^(Admin API|Project):(\s*).+$/i, "$1:$2••••"); +} diff --git a/apps/desktop-tauri/src/lib/providerLabels.test.ts b/apps/desktop-tauri/src/lib/providerLabels.test.ts new file mode 100644 index 0000000000..bb2a78a6fc --- /dev/null +++ b/apps/desktop-tauri/src/lib/providerLabels.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { LocaleKey } from "../i18n/keys"; +import type { ProviderDisplayDetail } from "../types/bridge"; +import { + providerCostPeriodTitle, + providerDisplayDetailTitle, +} from "./providerLabels"; + +const translate = (key: LocaleKey) => `translated:${key}`; + +describe("provider labels", () => { + it("localizes Atlas Cloud display labels and preserves other provider titles", () => { + const atlasBalance: ProviderDisplayDetail = { + id: "atlascloud-available", + sectionTitle: null, + title: "Available balance", + value: "$95.50", + secondaryValue: null, + progress: null, + }; + const other: ProviderDisplayDetail = { + ...atlasBalance, + id: "other", + title: "Credits", + }; + + expect(providerDisplayDetailTitle(atlasBalance, translate)).toBe( + "translated:AtlasCloudAvailableBalance", + ); + expect(providerDisplayDetailTitle(other, translate)).toBe("Credits"); + }); + + it("localizes Atlas Cloud balance period and preserves other periods", () => { + expect( + providerCostPeriodTitle("atlascloud", "Atlas Cloud balance", translate), + ).toBe("translated:AtlasCloudBalance"); + expect(providerCostPeriodTitle("other", "This month", translate)).toBe("This month"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/providerLabels.ts b/apps/desktop-tauri/src/lib/providerLabels.ts new file mode 100644 index 0000000000..7dd97d6778 --- /dev/null +++ b/apps/desktop-tauri/src/lib/providerLabels.ts @@ -0,0 +1,29 @@ +import type { LocaleKey } from "../i18n/keys"; +import type { ProviderDisplayDetail } from "../types/bridge"; + +type Translate = (key: LocaleKey) => string; + +const DISPLAY_DETAIL_TITLE_KEYS: ReadonlyMap = new Map([ + ["atlascloud-available", "AtlasCloudAvailableBalance"], +]); + +const COST_PERIOD_KEYS: ReadonlyMap = new Map([ + ["atlascloud:Atlas Cloud balance", "AtlasCloudBalance"], +]); + +export function providerDisplayDetailTitle( + detail: ProviderDisplayDetail, + t: Translate, +): string { + const key = DISPLAY_DETAIL_TITLE_KEYS.get(detail.id); + return key ? t(key) : detail.title; +} + +export function providerCostPeriodTitle( + providerId: string, + period: string, + t: Translate, +): string { + const key = COST_PERIOD_KEYS.get(`${providerId}:${period}`); + return key ? t(key) : period; +} diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 6ccd63c0de..07bb6fe7bc 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -45,6 +45,7 @@ import type { CodexAccountsStateBridge, CodexSwitchResult, DeepSeekPricingStatus, + CurrencyRatesSnapshot, } from "../types/bridge"; export const claudeAccountsList = () => invoke("claude_accounts_list"); @@ -87,6 +88,10 @@ export function getSettingsSnapshot(): Promise { return invoke("get_settings_snapshot"); } +export function getCurrencyRates(preferredCurrencyCode: string): Promise { + return invoke("get_currency_rates", { preferredCurrencyCode }); +} + export function updateSettings( patch: SettingsUpdate, ): Promise { @@ -284,9 +289,10 @@ export function getProviderLocalUsageSummary( return invoke("get_provider_local_usage_summary", { providerId }); } -export function getUsageSpendSummary(options?: { historyDays?: number; forceRefresh?: boolean }): Promise { +/** `period` is a raw reporting period; omitted means the saved `costReportingPeriod`. */ +export function getUsageSpendSummary(options?: { period?: string; forceRefresh?: boolean }): Promise { return invoke("get_usage_spend_summary", { - historyDays: options?.historyDays ?? null, + period: options?.period ?? null, forceRefresh: options?.forceRefresh ?? null, }); } @@ -297,11 +303,11 @@ export function writeUsageSpendExport(path: string, payload: string): Promise { return invoke("get_spend_contract", { providerId, - historyDays: options?.historyDays ?? null, + period: options?.period ?? null, includeOpenCodex: options?.includeOpenCodex ?? null, }); } @@ -450,6 +456,10 @@ export function setProviderGatewayUrl( return invoke("set_provider_gateway_url", { providerId, gatewayUrl }); } +export function getProviderGatewayUrl(providerId: string): Promise { + return invoke("get_provider_gateway_url", { providerId }); +} + export function getProviderAzureApiVersion(providerId: string): Promise { return invoke("get_provider_azure_api_version", { providerId }); } diff --git a/apps/desktop-tauri/src/lib/usageItemVisibility.ts b/apps/desktop-tauri/src/lib/usageItemVisibility.ts index 21e86589dd..b4d9c9344f 100644 --- a/apps/desktop-tauri/src/lib/usageItemVisibility.ts +++ b/apps/desktop-tauri/src/lib/usageItemVisibility.ts @@ -1,13 +1,27 @@ export const USAGE_ITEM_METRIC_PREFIX = "metric:"; +export const USAGE_ITEM_DETAIL_SECTION_PREFIX = "detailSection:"; /** Stable bridge ID for a metric or extra usage row. */ export function usageItemId(rawMetricId: string): string { return `${USAGE_ITEM_METRIC_PREFIX}${rawMetricId}`; } +/** Stable bridge ID for a provider detail section (upstream 0.62.0 #3638). */ +export function detailSectionItemId(rawSectionTitle: string): string { + return `${USAGE_ITEM_DETAIL_SECTION_PREFIX}${rawSectionTitle}`; +} + export function isUsageItemVisible( hiddenUsageItemIds: readonly string[] | undefined, rawMetricId: string, ): boolean { return !hiddenUsageItemIds?.includes(usageItemId(rawMetricId)); } + +/** A detail section is visible unless its title-based ID is hidden. */ +export function isDetailSectionVisible( + hiddenUsageItemIds: readonly string[] | undefined, + rawSectionTitle: string, +): boolean { + return !hiddenUsageItemIds?.includes(detailSectionItemId(rawSectionTitle)); +} diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts index bf693ce7b8..d53586ebbd 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts @@ -1,15 +1,50 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + formatSpendTokens, formatUsageSpendReportingDay, + formatSpendMetric, + formatUsd, filterUsageSpendSummaryForOverview, renderUsageSpendSharePng, + usageSpendShareColumns, usageSpendShareFooter, usageSpendSubscriptionCaption, } from "./usageSpendSharing"; import type { SpendContract, UsageSpendRow, UsageSpendSummary } from "../types/bridge"; describe("usage spend sharing", () => { + it("labels a mixed-pricing subtotal without presenting it as a total", () => { + expect(formatSpendMetric(null, 1_500, "USD", "tokens", 0.0125)).toMatch(/^≥.* known/); + }); + + it("marks a lower-bound token count with a floor sign and leaves exact counts bare", () => { + const floor = formatSpendMetric(null, 1_500, "USD", "tokens", 0.0125, undefined, true); + expect(floor).toContain(`≥${(1_500).toLocaleString()} tokens`); + expect(floor).toMatch(/^≥.* known/); + const exact = formatSpendMetric(null, 1_500, "USD", "tokens", null, undefined, false); + expect(exact).toBe(`${(1_500).toLocaleString()} tokens`); + }); + + it("keeps the localized subtotal label next to a lower-bound token count", () => { + const metric = formatSpendMetric(null, 500, "USD", "Token", 6, "{} (teilweise)", true); + expect(metric).toBe(`${formatUsd(6, "USD")} (teilweise) · ≥500 Token`); + }); + + it("uses the caller's localized subtotal label", () => { + const metric = formatSpendMetric(null, 500, "USD", "Token", 6, "{} (teilweise)"); + expect(metric).toBe(`${formatUsd(6, "USD")} (teilweise) · 500 Token`); + expect(metric).not.toContain("known"); + }); + + it("renders a complete known-zero total instead of a subtotal", () => { + const metric = formatSpendMetric(0, 0, "USD", "tokens", 9); + expect(metric).not.toBe("—"); + expect(metric).not.toContain("≥"); + expect(metric).not.toContain("9.00"); + expect(metric).toContain("0 tokens"); + }); + it.each([ [0, "0 subscriptions"], [1, "1 subscription"], @@ -25,6 +60,15 @@ describe("usage spend sharing", () => { expect(formatUsageSpendReportingDay("2026-10-01", "Pacific/Norfolk")).toBe("Oct 1, 2026"); }); + it("keeps a zero token count distinct from an unknown one", () => { + expect(formatSpendTokens(0, "tokens", "Unknown")).toBe("0 tokens"); + expect(formatSpendTokens(8100, "tokens", "Unknown")).toBe("8,100 tokens"); + expect(formatSpendTokens(null, "tokens", "Unknown")).toBe("Unknown"); + expect(formatSpendTokens(undefined, "tokens", "Unknown")).toBe("Unknown"); + expect(formatSpendTokens(-1, "tokens", "Unknown")).toBe("Unknown"); + expect(formatSpendTokens(Number.NaN, "tokens", "Unknown")).toBe("Unknown"); + }); + it("falls back for an invalid dashboard timezone or date", () => { expect(formatUsageSpendReportingDay("2026-10-01", "Not/AZone")).toBe("2026-10-01"); expect(formatUsageSpendReportingDay("2026-02-31", "UTC")).toBe("2026-02-31"); @@ -34,11 +78,14 @@ describe("usage spend sharing", () => { expect( usageSpendShareFooter({ contract: {} as SpendContract, + reportingPeriod: "rolling:30", rows: [{ providerId: "codex", displayName: "Codex", sevenDay: null, thirtyDay: null, + periodCost: null, + periodTokens: null, currency: "USD", source: "local", includedInOverview: true, @@ -52,6 +99,7 @@ describe("usage spend sharing", () => { it("keeps hidden sources out of the Overview share summary", () => { const summary: UsageSpendSummary = { contract: {} as SpendContract, + reportingPeriod: "rolling:30", reportingDay: "2026-09-19", dashboardTimezone: "UTC", rows: [ @@ -60,6 +108,8 @@ describe("usage spend sharing", () => { displayName: "Codex", sevenDay: 1, thirtyDay: 2, + periodCost: 1, + periodTokens: null, currency: "USD", source: "local", includedInOverview: true, @@ -69,6 +119,8 @@ describe("usage spend sharing", () => { displayName: "Claude", sevenDay: 3, thirtyDay: 4, + periodCost: 3, + periodTokens: null, currency: "USD", source: "hidden", includedInOverview: false, @@ -86,12 +138,22 @@ describe("usage spend sharing", () => { // account emails or provider-identity secrets. The renderer draws exactly // these cells; keep the invariant comment on renderUsageSpendSharePng in // sync if this list ever grows. - const renderedCells = ["displayName", "sevenDay", "thirtyDay", "currency", "source"] as const; + const renderedCells = [ + "displayName", + "periodCost", + "periodTokens", + "sevenDay", + "thirtyDay", + "currency", + "source", + ] as const; const row: UsageSpendRow = { providerId: "codex", displayName: "Codex", sevenDay: 1, thirtyDay: 2, + periodCost: 2, + periodTokens: null, currency: "USD", source: "local", includedInOverview: true, @@ -101,7 +163,37 @@ describe("usage spend sharing", () => { ); // Nothing outside the drawn cells may be read by the renderer, and no // UsageSpendRow field may carry account-identity data (no email/org). - expect(Object.keys(row).filter((key) => /email|org|token|account/i.test(key))).toEqual([]); + expect(Object.keys(row).filter((key) => /email|org|account|secret|(access|auth|api|refresh)token/i.test(key))).toEqual([]); expect(unsafeKeys).toEqual(["providerId", "includedInOverview"]); }); + + it("adds a labelled History window column while keeping the fixed 7 and 30 day columns", () => { + const row: UsageSpendRow = { + providerId: "codex", + displayName: "Codex", + sevenDay: 1, + thirtyDay: 2, + periodCost: 9, + periodTokens: 900, + currency: "USD", + source: "local", + includedInOverview: true, + }; + const withPeriod = usageSpendShareColumns("Month to date"); + expect(withPeriod.headers).toEqual([ + "Provider", + "Month to date", + "7 days", + "30 days", + "Currency", + "Source", + ]); + const cells = withPeriod.cellsFor(row); + expect(cells).toHaveLength(withPeriod.headers.length); + expect(cells).toHaveLength(withPeriod.colW.length); + expect(cells[1]).toContain("900"); + expect(cells[2]).not.toContain("900"); + }); + + }); diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.ts index 2246e98505..f1488ba27c 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.ts @@ -124,21 +124,65 @@ export function formatUsd(value: number | null | undefined, currency: string): s } } -/** Canonical "cost · tokens" cell for spend tables and share renders. */ +/** + * Canonical "cost · tokens" cell for spend tables and share renders. + * `knownSubtotalTemplate` is the localized subtotal label; `{}` is the amount. + * `tokensAreLowerBound` marks a token floor from an incomplete scan ("≥N"). + */ export function formatSpendMetric( cost: number | null | undefined, tokens: number | null | undefined, currency: string, tokenLabel: string, + knownSubtotal?: number | null, + knownSubtotalTemplate = "≥{} known", + tokensAreLowerBound = false, ): string { const parts: string[] = []; - if (cost != null && Number.isFinite(cost)) parts.push(formatUsd(cost, currency)); + if (cost != null && Number.isFinite(cost)) { + parts.push(formatUsd(cost, currency)); + } else if (knownSubtotal != null && Number.isFinite(knownSubtotal)) { + parts.push(knownSubtotalTemplate.replace("{}", formatUsd(knownSubtotal, currency))); + } if (tokens != null && Number.isFinite(tokens)) { - parts.push(`${Math.max(0, tokens).toLocaleString()} ${tokenLabel}`); + parts.push(`${tokensAreLowerBound ? "≥" : ""}${Math.max(0, tokens).toLocaleString()} ${tokenLabel}`); } return parts.length > 0 ? parts.join(" · ") : "—"; } +/** Canonical token cell for spend ledgers; a missing or negative count is unknown, zero is known. */ +export function formatSpendTokens( + tokens: number | null | undefined, + tokenLabel: string, + unknownLabel: string, +): string { + if (tokens == null || !Number.isFinite(tokens) || tokens < 0) return unknownLabel; + return `${tokens.toLocaleString()} ${tokenLabel}`; +} + +/** + * Share-card columns: the selected History window (`periodCost` / `periodTokens`) + * follows the provider name, then the fixed 7 and 30 day compatibility columns. + */ +export function usageSpendShareColumns(periodLabel: string): { + headers: string[]; + colW: number[]; + cellsFor: (row: UsageSpendRow) => string[]; +} { + return { + headers: ["Provider", periodLabel, "7 days", "30 days", "Currency", "Source"], + colW: [150, 130, 100, 100, 70, 130], + cellsFor: (row) => [ + row.displayName, + formatSpendMetric(row.periodCost, row.periodTokens, row.currency, "tokens"), + formatSpendMetric(row.sevenDay, row.sevenDayTokens, row.currency, "tokens"), + formatSpendMetric(row.thirtyDay, row.thirtyDayTokens, row.currency, "tokens"), + row.currency || "USD", + row.source, + ], + }; +} + /** * Render the sanitized share-card PNG. * @@ -148,12 +192,16 @@ export function formatSpendMetric( * states the guarantee. Keep it that way — do not add account fields to the * drawn cells or the footer. */ -export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: string): string { +export function renderUsageSpendSharePng( + summary: UsageSpendSummary, + title: string, + periodLabel: string, +): string { const rows = summary.rows; const pad = 24; const rowH = 28; const headerH = 48; - const colW = [160, 100, 100, 80, 160]; + const { headers, cellsFor, colW } = usageSpendShareColumns(periodLabel); const width = pad * 2 + colW.reduce((a, b) => a + b, 0); const height = pad * 2 + headerH + Math.max(1, rows.length) * rowH + 52; const canvas = document.createElement("canvas"); @@ -177,7 +225,6 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri ctx.font = "12px system-ui,Segoe UI,sans-serif"; ctx.fillText("Win-CodexBar · local estimates · no account emails", pad, pad + 36); - const headers = ["Provider", "7 days", "30 days", "Currency", "Source"]; let x = pad; const y0 = pad + headerH; ctx.fillStyle = "#9fb0c8"; @@ -200,13 +247,7 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri } else { rows.forEach((row, index) => { const y = y0 + (index + 1) * rowH; - const cells = [ - row.displayName, - formatSpendMetric(row.sevenDay, row.sevenDayTokens, row.currency, "tokens"), - formatSpendMetric(row.thirtyDay, row.thirtyDayTokens, row.currency, "tokens"), - row.currency || "USD", - row.source, - ]; + const cells = cellsFor(row); let cellX = pad; cells.forEach((cell, cellIndex) => { ctx.fillStyle = cellIndex === 0 ? "#e7ecf3" : "#c5d0e0"; @@ -250,10 +291,11 @@ export function shareUsageSpendPng( summary: UsageSpendSummary | null, title: string, filename: string, + periodLabel: string, ): string | null { if (!summary) return "UsageSpendShareEmpty"; try { - const dataUrl = renderUsageSpendSharePng(summary, title); + const dataUrl = renderUsageSpendSharePng(summary, title, periodLabel); if (!dataUrl) return "UsageSpendShareFailed"; downloadPng(dataUrl, filename); return null; diff --git a/apps/desktop-tauri/src/lib/usageWindows.test.ts b/apps/desktop-tauri/src/lib/usageWindows.test.ts index 2876b33810..e5183d7153 100644 --- a/apps/desktop-tauri/src/lib/usageWindows.test.ts +++ b/apps/desktop-tauri/src/lib/usageWindows.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { RateWindowSnapshot } from "../types/bridge"; -import { selectSingleMetricUsageWindow } from "./usageWindows"; +import { + resetDescriptionFallback, + selectSingleMetricUsageWindow, + windowDetailText, +} from "./usageWindows"; function rateWindow( overrides: Partial = {}, @@ -46,3 +50,32 @@ describe("selectSingleMetricUsageWindow", () => { expect(selectSingleMetricUsageWindow({ primary, secondary })).toBe(primary); }); }); + +describe("detail-backed descriptions", () => { + const amounts = "34.07 EUR / 255.00 EUR · 220.93 EUR remaining"; + + it("keeps ordinary descriptions as the reset fallback and shows no detail line", () => { + const window = rateWindow({ resetDescription: "Resets in 2h" }); + + expect(resetDescriptionFallback(window)).toBe("Resets in 2h"); + expect(windowDetailText(window)).toBeNull(); + }); + + it("moves a detail-backed description out of the reset fallback", () => { + const window = rateWindow({ resetDescription: ` ${amounts} `, descriptionIsDetail: true }); + + expect(resetDescriptionFallback(window)).toBeNull(); + expect(windowDetailText(window)).toBe(amounts); + }); + + it("ignores blank and informational detail-backed descriptions", () => { + expect( + windowDetailText(rateWindow({ resetDescription: " ", descriptionIsDetail: true })), + ).toBeNull(); + expect( + windowDetailText( + rateWindow({ resetDescription: amounts, descriptionIsDetail: true, isInformational: true }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/lib/usageWindows.ts b/apps/desktop-tauri/src/lib/usageWindows.ts index 9a55daf098..9179e1dce3 100644 --- a/apps/desktop-tauri/src/lib/usageWindows.ts +++ b/apps/desktop-tauri/src/lib/usageWindows.ts @@ -12,3 +12,22 @@ export function selectSingleMetricUsageWindow( ? secondary : primary; } + +/** + * Reset-wording fallback for a window. A detail-backed description (for + * example spend amounts) is never reset text, so it must not reach + * `normalizeResetDescription` through the reset formatter. + */ +export function resetDescriptionFallback( + window: Pick, +): string | null { + return window.descriptionIsDetail ? null : window.resetDescription; +} + +/** Secondary detail line for a detail-backed window, or null. */ +export function windowDetailText( + window: Pick, +): string | null { + if (!window.descriptionIsDetail || window.isInformational) return null; + return window.resetDescription?.trim() || null; +} diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index c3465d0ec0..6cd04ae833 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -95,26 +95,26 @@ --chart-claude: rgb(204, 124, 94); --chart-codex: rgb(73, 163, 176); --chart-gemini: rgb(171, 135, 234); - --chart-cursor: rgb(0, 191, 165); + --chart-cursor: rgb(245, 78, 0); --chart-copilot: rgb(168, 85, 247); --chart-jetbrains: rgb(255, 51, 153); --chart-antigravity: rgb(96, 186, 126); - --chart-augment: rgb(99, 102, 241); - --chart-amp: rgb(220, 38, 38); + --chart-augment: rgb(26, 160, 73); + --chart-amp: rgb(243, 78, 63); --chart-factory: rgb(255, 107, 53); --chart-droid: rgb(255, 107, 53); --chart-kimi: rgb(254, 96, 60); --chart-kimik2: rgb(76, 0, 255); - --chart-kiro: rgb(255, 153, 0); + --chart-kiro: rgb(144, 70, 255); --chart-opencode: rgb(59, 130, 246); --chart-minimax: rgb(254, 96, 60); --chart-vertexai: rgb(66, 133, 244); --chart-zai: rgb(232, 90, 106); --chart-alibaba: rgb(255, 106, 0); --chart-nanogpt: rgb(104, 127, 161); - --chart-mistral: rgb(255, 80, 15); - --chart-codebuff: rgb(68, 255, 0); - --chart-deepseek: rgb(82, 125, 240); + --chart-mistral: rgb(255, 82, 41); + --chart-codebuff: rgb(0, 255, 149); + --chart-deepseek: rgb(77, 107, 254); --chart-windsurf: rgb(34, 197, 94); --chart-elevenlabs: rgb(17, 24, 39); --chart-deepgram: rgb(19, 239, 147); @@ -2037,6 +2037,10 @@ body:has(.tray-panel-reveal) { font-weight: 600; } +.provider-usage-bar__status { + color: var(--text-secondary); +} + .provider-usage-bar__track { height: 5px; border-radius: 3px; @@ -2051,7 +2055,8 @@ body:has(.tray-panel-reveal) { transition: width 0.3s; } -.provider-usage-bar__reset { +.provider-usage-bar__reset, +.provider-usage-bar__detail { font-size: 0.68rem; color: var(--text-muted); } @@ -2655,6 +2660,21 @@ body:has(.tray-panel-reveal) { height: auto; } +.chart--scrollable { + overflow-x: auto; + overflow-y: hidden; +} + +.chart--scrollable .chart__svg { + flex: 0 0 auto; + width: auto; + max-width: none; +} + +.chart--scrollable .chart__axis { + flex: 0 0 auto; +} + .chart__axis { position: relative; font-size: 0.7rem; @@ -3055,6 +3075,166 @@ body:has(.tray-panel-reveal) { margin-right: 6px; } +/* Selectable bars (OpenAI daily usage): keyboard focus ring on the active bar. */ +.chart__bar[role="option"] { + cursor: pointer; +} + +.chart__bar[role="option"]:focus { + outline: none; +} + +.chart__bar[role="option"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.chart__bar[data-selected="true"] { + stroke: var(--text-primary); + stroke-width: 1; +} + +/* ── OpenAI Admin API daily usage (tray card + Settings charts) ───── */ + +.openai-usage { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.openai-usage__tabs { + display: flex; + gap: 4px; +} + +.openai-usage__tab { + background: transparent; + color: var(--text-secondary); + border: 1px solid transparent; + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; +} + +.openai-usage__tab:hover { + color: var(--text-primary); +} + +.openai-usage__tab[data-active="true"] { + color: var(--text-primary); + border-color: var(--panel-border); + background: var(--provider-row-bg); +} + +.openai-usage__tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.openai-usage__panel { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.openai-usage__hint { + font-size: 10px; + color: var(--text-muted); +} + +.openai-usage__detail { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 11px; + color: var(--text-primary); +} + +.openai-usage__day { + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.openai-usage__stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); + gap: 6px 16px; + margin: 0; +} + +.openai-usage__stats > div { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.openai-usage__stats dt { + font-size: 10px; + color: var(--text-secondary); +} + +.openai-usage__stats dd { + margin: 0; + font-variant-numeric: tabular-nums; +} + +.openai-usage__list-title { + font-size: 10px; + color: var(--text-secondary); + margin-bottom: 2px; +} + +.openai-usage__list ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.openai-usage__list li { + display: flex; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.openai-usage__name { + min-width: 0; + overflow-wrap: anywhere; +} + +.openai-usage__amount { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); +} + +.openai-usage__list li.openai-usage__more { + color: var(--text-muted); +} + +.openai-usage__footer { + display: flex; + justify-content: space-between; + gap: 12px; + padding-top: 6px; + border-top: 1px solid var(--panel-border); + font-size: 11px; + color: var(--text-secondary); +} + +.openai-usage__footer strong { + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + @media (prefers-reduced-motion: reduce) { .chart__bar, .chart__line, @@ -3533,7 +3713,9 @@ html:has(.menu-surface--tray) { max-width: 100%; } .menu-surface--tray .menu-metric__pct, -.menu-surface--tray .menu-metric__reset { +.menu-surface--tray .menu-metric__reset, +.menu-surface--tray .menu-metric__status, +.menu-surface--tray .menu-metric__detail { font-size: 10px; } .menu-surface--tray .menu-metric__reserve { @@ -4059,6 +4241,15 @@ html:has(.menu-surface--tray) { white-space: nowrap; } +/* Detail-backed window description (for example spend amounts): its own + secondary line under the metric row, never reset wording. */ +.menu-metric__detail { + font-size: 11px; + color: var(--text-secondary); + min-width: 0; + overflow-wrap: anywhere; +} + .menu-metric__reset { font-size: 11px; color: var(--text-secondary); @@ -4083,6 +4274,17 @@ html:has(.menu-surface--tray) { font-weight: 600; color: var(--provider-status-error); } + +/* Upstream 0.69.0 #4091 status row: a window blocked by a longer exhausted + pool shows its title and one secondary footnote line instead of a bar. */ +.menu-metric__status { + font-size: 11px; + color: var(--text-secondary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .menu-metric__reserve { opacity: 0.7; font-size: 10px; @@ -4765,6 +4967,53 @@ html:has(.menu-surface--tray) { color: var(--text-primary); font-variant-numeric: tabular-nums; } + +/* ── Recorded quota burndown (upstream 0.70.0 #4085) ─────────────── */ +.quota-burndown-chart { + display: flex; + flex-direction: column; + gap: 5px; + padding-top: 2px; +} +.quota-burndown-chart svg { + width: 100%; + height: 76px; + overflow: visible; +} +.quota-burndown-chart__grid { + stroke: var(--usage-bar-track); + stroke-width: 1; +} +.quota-burndown-chart__ideal { + stroke: var(--text-secondary); + stroke-width: 1.5; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; +} +.quota-burndown-chart__observed { + fill: none; + stroke: var(--accent); + stroke-width: 2; + vector-effect: non-scaling-stroke; +} +.quota-burndown-chart__point { + fill: var(--accent); + vector-effect: non-scaling-stroke; +} +.quota-burndown-chart__axis { + display: flex; + justify-content: space-between; + color: var(--text-secondary); + font-size: 9px; +} +.quota-burndown-chart__caption { + display: flex; + justify-content: space-between; + gap: 10px; + color: var(--text-secondary); + font-size: var(--font-caption); + font-variant-numeric: tabular-nums; +} .menu-metric__bar, .menu-card__pace-track { height: var(--bar-height); @@ -4796,7 +5045,9 @@ html:has(.menu-surface--tray) { } .menu-metric__pct, -.menu-metric__reset { +.menu-metric__reset, +.menu-metric__status, +.menu-metric__detail { font-size: var(--font-caption); } .menu-card__cost-line, @@ -5385,6 +5636,23 @@ html:has(.menu-surface--tray) { font-size: var(--font-caption); } +.usage-spend-daily { + margin-top: 20px; +} + +.usage-spend-daily h4 { + margin: 0; +} + +.usage-spend-daily__table { + overflow-x: auto; + margin-top: 10px; +} + +.usage-spend-daily__toggle { + margin-top: 8px; +} + /* About update controls block */ .about-update-controls { display: flex; @@ -5741,31 +6009,6 @@ html:has(.menu-surface--tray) { 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; diff --git a/apps/desktop-tauri/src/surfaces/Settings.tsx b/apps/desktop-tauri/src/surfaces/Settings.tsx index 21b5828dda..f3fb995d44 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.tsx @@ -253,7 +253,13 @@ export default function Settings({ state, initialTab: propTab }: { state: Bootst )} {activeTab === "menuBar" && ( - + )} {activeTab === "menu" && ( diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx new file mode 100644 index 0000000000..0f6263c089 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx @@ -0,0 +1,193 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + getCachedProviders: vi.fn(), + refreshProviders: vi.fn(), + refreshProvidersIfStale: vi.fn(), + getSettingsSnapshot: vi.fn(), + updateSettings: vi.fn(), + getUpdateState: vi.fn(), + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + applyUpdate: vi.fn(), + dismissUpdate: vi.fn(), + openReleasePage: vi.fn(), + setSurfaceMode: vi.fn(), + dismissTrayPanel: vi.fn(), + beginFlyoutGesture: vi.fn().mockResolvedValue(undefined), + endFlyoutGesture: vi.fn().mockResolvedValue(undefined), + openSettingsWindow: vi.fn(), + quitApp: vi.fn(), + getWorkAreaRect: vi.fn(), + reanchorTrayPanel: vi.fn(), + revealTrayPanelWindow: vi.fn(), + flyoutStoredSize: vi.fn().mockResolvedValue(null), + setFlyoutSize: vi.fn().mockResolvedValue(undefined), + openProviderDashboard: vi.fn(), + openProviderStatusPage: vi.fn(), + getProviderChartData: vi.fn(), + getCurrentSurfaceState: vi.fn(), + getLocaleStrings: vi.fn(), + setUiLanguage: vi.fn(), + getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), + getUsageSpendSummary: vi.fn(), + claudeReconciliationState: vi.fn().mockResolvedValue(null), +})); + +const eventMocks = vi.hoisted(() => ({ + listen: vi.fn(), +})); + +const windowMocks = vi.hoisted(() => ({ + getCurrentWindow: vi.fn(() => ({ + setSize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + scaleFactor: vi.fn().mockResolvedValue(1), + onResized: vi.fn().mockResolvedValue(() => {}), + innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + })), + LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), + PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })), +})); + +vi.mock("../lib/tauri", () => tauriMocks); +vi.mock("@tauri-apps/api/event", () => eventMocks); +vi.mock("@tauri-apps/api/window", () => windowMocks); + +import TrayPanel from "./TrayPanel"; +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { buildBundle } from "../test/localeHarness"; +import type { ProviderUsageSnapshot, SettingsSnapshot } from "../types/bridge"; + +function rateWindow(used: number) { + return { + usedPercent: used, + remainingPercent: 100 - used, + windowMinutes: null, + resetsAt: null, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + }; +} + +const codex: ProviderUsageSnapshot = { + providerId: "codex", + displayName: "Codex", + primary: rateWindow(35), + selectedMetric: rateWindow(35), + primaryLabel: "Monthly", + secondary: null, + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + cost: null, + planName: null, + accountEmail: null, + sourceLabel: "auto", + updatedAt: "2026-05-24T00:00:00Z", + error: null, + errorState: "ready", + pace: null, + accountOrganization: null, + trayStatusLabel: null, + fetchDurationMs: null, +}; + +const settings = { + enabledProviders: ["codex"], + refreshIntervalSecs: 300, + adaptiveRefresh: false, + refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, + highUsageThreshold: 70, + criticalUsageThreshold: 90, + trayIconMode: "single", + switcherShowsIcons: true, + showAsUsed: true, + resetTimeRelative: true, + overviewLayout: "detailed", + hidePersonalInfo: false, + uiLanguage: "english", + theme: "dark", + windowScalePercent: 125, + trayScalePercent: 100, + trayPanelAlwaysOnTop: false, + providerMetrics: {}, + costSummaryDisplayStyle: "compact", + costReportingPeriod: "rolling:7", + providerAccentColors: {}, +} as unknown as SettingsSnapshot; + +describe("TrayPanel History window", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getCachedProviders.mockResolvedValue([codex]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings); + tauriMocks.updateSettings.mockResolvedValue(settings); + tauriMocks.refreshProviders.mockResolvedValue(undefined); + tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined); + tauriMocks.dismissTrayPanel.mockResolvedValue(undefined); + tauriMocks.reanchorTrayPanel.mockResolvedValue(undefined); + tauriMocks.getWorkAreaRect.mockResolvedValue({ x: 0, y: 0, width: 1440, height: 900 }); + tauriMocks.getCurrentSurfaceState.mockResolvedValue({ + mode: "trayPanel", + target: { kind: "summary" }, + }); + tauriMocks.getUpdateState.mockResolvedValue({ + status: "idle", + version: null, + error: null, + progress: null, + releaseUrl: null, + canDownload: false, + canApply: false, + lastCheckedAt: null, + }); + tauriMocks.getProviderChartData.mockResolvedValue({ + providerId: "codex", + costHistory: [], + creditsHistory: [], + usageBreakdown: [], + localUsage: null, + }); + tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle({})); + eventMocks.listen.mockResolvedValue(() => {}); + }); + + it("totals the Overview spend over the selected History window", async () => { + tauriMocks.getUsageSpendSummary.mockResolvedValue({ + contract: {}, + reportingPeriod: "rolling:7", + reportingDay: "2026-09-29", + dashboardTimezone: "UTC", + rows: [ + { + providerId: "codex", + displayName: "Codex", + sevenDay: 9, + thirtyDay: 2, + periodCost: 9, + periodTokens: 900, + currency: "USD", + source: "local", + includedInOverview: true, + }, + ], + }); + + render( + + + , + ); + + expect(await screen.findByText("OverviewSpendPeriodTitle")).toBeInTheDocument(); + expect(screen.getByText("$9.00")).toBeInTheDocument(); + expect(screen.queryByText("$2.00")).not.toBeInTheDocument(); + // The scan carries no explicit window: the backend resolves the saved one. + expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledWith(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 4655c3b58f..10573600fe 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -145,6 +145,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { switcherShowsIcons: true, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarColorPace: false, showAsUsed: true, showAllTokenAccountsInMenu: false, enableAnimations: true, @@ -319,6 +320,7 @@ describe("TrayPanel provider grid", () => { it("offers an Overview share snapshot using only included spend rows", async () => { tauriMocks.getUsageSpendSummary.mockResolvedValue({ contract: {}, + reportingPeriod: "rolling:30", reportingDay: "2026-09-19", dashboardTimezone: "UTC", rows: [ @@ -327,6 +329,8 @@ describe("TrayPanel provider grid", () => { displayName: "Codex", sevenDay: 1, thirtyDay: 2, + periodCost: 2, + periodTokens: null, currency: "USD", source: "local", includedInOverview: true, @@ -336,17 +340,36 @@ describe("TrayPanel provider grid", () => { displayName: "Claude", sevenDay: 3, thirtyDay: 4, + periodCost: 4, + periodTokens: null, currency: "USD", source: "hidden", includedInOverview: false, }, + // A known subtotal is a partial estimate: counted for coverage, not in the total. + { + providerId: "antigravity", + displayName: "Antigravity", + sevenDay: null, + thirtyDay: null, + periodCost: null, + periodTokens: null, + thirtyDayEstimate: { + knownSubtotalUsd: 9, + coverage: { priced: 0, unpriced: 1, unmetered: 0, estimated: 1 }, + }, + currency: "USD", + source: "known subtotal", + includedInOverview: true, + }, ], }); renderTrayPanel([provider("codex", "Codex", 35)]); expect(await screen.findByRole("button", { name: "UsageSpendShare" })).toBeInTheDocument(); - expect(screen.getByText(/1 of 1 OverviewSpendProviderCoverage/)).toBeInTheDocument(); + expect(screen.getByText("~$2.00")).toBeInTheDocument(); + expect(screen.getByText(/1 of 2 OverviewSpendProviderCoverage/)).toBeInTheDocument(); }); it("dismisses the tray panel on unmodified Escape", async () => { @@ -427,7 +450,9 @@ describe("TrayPanel provider grid", () => { PanelAllProviders: "すべてのプロバイダー", PanelAllProvidersShort: "すべて", PanelLatestTokens: "最新トークン", - PanelThirtyDayCost: "30日間のコスト", + CostPeriodShortDays: "{}日", + PanelPeriodCost: "{}間のコスト", + PanelPeriodTokens: "{}間のトークン", PanelTopModelPrefix: "トップモデル", PanelEstimatedFromLocalLogs: "ローカルログから推定", PanelZoom: "ズーム", @@ -445,6 +470,9 @@ describe("TrayPanel provider grid", () => { todayCost: null, thirtyDayCost: 1.23, thirtyDayTokens: 584_000, + periodCost: 1.23, + periodTokens: 584_000, + reportingPeriod: "rolling:30", latestTokens: 1200, topModel: "gpt-5.5", estimateNote: "Estimated from local logs", @@ -599,7 +627,7 @@ describe("TrayPanel provider grid", () => { ).toEqual(["Codex", "Claude", "Cursor", "Factory", "Gemini"]); }); - it("keeps compact Overview limited to two quota rows when explicitly selected", async () => { + it("shows all quota rows in compact Overview when explicitly selected (0.62.0 #2616)", async () => { const { container } = renderTrayPanel( [providerWithThreeQuotaWindows("codex", "Codex")], { overviewLayout: "compact" }, @@ -609,7 +637,7 @@ describe("TrayPanel provider grid", () => { expect(container.querySelector(".menu-stack__item")).not.toBeNull(); }); - expect(container.querySelectorAll(".menu-metric")).toHaveLength(2); + expect(container.querySelectorAll(".menu-metric")).toHaveLength(3); }); it("uses independent columns for a wide user-sized overview", async () => { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 2cfdd609e1..5e2f96e0bd 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -2,6 +2,9 @@ import { Fragment, useEffect, useState, type CSSProperties } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { BootstrapState, ProviderUsageSnapshot, UsageSpendSummary } from "../types/bridge"; import type { LocaleKey } from "../i18n/keys"; +import { costPeriodLabel, costPeriodShortLabel } from "../lib/costPeriod"; +import { useCurrency } from "../hooks/CurrencyProvider"; +import { sumDisplayCurrencyAmounts } from "../lib/currency"; import { beginFlyoutGesture, getUsageSpendSummary, @@ -28,9 +31,9 @@ import { /** Provider IDs that have a dashboard URL in the backend */ const HAS_DASHBOARD = new Set([ - "abacus", "alibaba", "alibabatokenplan", "amp", "augment", + "abacus", "alibaba", "alibabatokenplan", "amp", "atlascloud", "augment", "azureopenai", "bedrock", "claude", "codex", "codebuff", - "aiand", "commandcode", "copilot", "crof", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", + "aiand", "aixy", "commandcode", "copilot", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", "infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", "replicate", "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", @@ -196,7 +199,11 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { />
{selectedProviderId === null && ( - provider.providerId)} t={t} /> + provider.providerId)} + period={settings.costReportingPeriod} + t={t} + /> )}
{useWideColumns @@ -326,49 +333,70 @@ function TrayResizeHandles() { ); } -function OverviewSpendSummary({ providerIds, t }: { providerIds: string[]; t: (key: LocaleKey) => string }) { +function OverviewSpendSummary({ + providerIds, + period, + t, +}: { + providerIds: string[]; + /** Saved History window; a change rescans. Omitted means the backend default. */ + period?: string; + t: (key: LocaleKey) => string; +}) { + const { preferredCode, rates, format } = useCurrency(); const [summary, setSummary] = useState(null); const [shareError, setShareError] = useState(null); useEffect(() => { let cancelled = false; - void getUsageSpendSummary({ historyDays: 30 }) + // No explicit period: the backend resolves the saved History window. + void getUsageSpendSummary() .then((value) => { if (!cancelled) setSummary(value); }) .catch(() => { if (!cancelled) setSummary(null); }); return () => { cancelled = true; }; - }, [providerIds.join("|")]); + }, [providerIds.join("|"), period]); // Overview consumes the same backend spend catalog as Usage & Spend. Do not // restrict accounting to whichever cards happen to be rendered in this tray. const overviewSummary = summary ? filterUsageSpendSummaryForOverview(summary) : null; if (!overviewSummary) return null; + // The summary names the History window its period columns cover. + const summaryPeriod = overviewSummary.reportingPeriod; + const title = t("OverviewSpendPeriodTitle").replace("{}", costPeriodShortLabel(summaryPeriod, t)); const onShare = () => { setShareError(null); const error = shareUsageSpendPng( overviewSummary, - t("OverviewSpendTitle"), + title, `codexbar-overview-usage-${overviewSummary.reportingDay}.png`, + costPeriodLabel(summaryPeriod, t), ); if (error) setShareError(t(error as LocaleKey)); }; const rows = overviewSummary.rows; - const summable = rows.filter((row) => (row.currency || "USD") === "USD"); - const known = summable.filter((row) => row.thirtyDay != null && Number.isFinite(row.thirtyDay)); - if (known.length === 0) return null; - const total = known.reduce((sum, row) => sum + (row.thirtyDay ?? 0), 0); - const partial = known.length < rows.length; - const formatter = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 2 }); + const target = preferredCode.trim().toUpperCase() || "AUTO"; + const aggregate = sumDisplayCurrencyAmounts( + rows.map((row) => ({ amount: row.periodCost, currency: row.currency || "USD" })), + target, + rates, + ); + if (aggregate.total == null && aggregate.considered === 0) return null; + const partial = aggregate.included < aggregate.considered; return (
- {t("OverviewSpendTitle")} - {partial ? "~" : ""}{formatter.format(total)} + {title} + + {aggregate.total == null + ? "—" + : `${partial ? "~" : ""}${new Intl.NumberFormat(undefined, { style: "currency", currency: target === "AUTO" ? "USD" : target, maximumFractionDigits: 2 }).format(aggregate.total)}`} +
- {known.length} of {rows.length} {t("OverviewSpendProviderCoverage")} · {t("OverviewSpendEstimate")} + {aggregate.included} of {aggregate.considered} {t("OverviewSpendProviderCoverage")} · {t("OverviewSpendEstimate")}
-
- - {t("ProviderAccentColor")} - - - {effective} -
{error &&

{error}

} ); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.test.tsx new file mode 100644 index 0000000000..36189a47a8 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { CostSnapshotBridge } from "../../../../types/bridge"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { CostSection } from "./CostSection"; + +describe("CostSection", () => { + it("does not present the zero usage carrier as spend for balance-only providers", () => { + const cost: CostSnapshotBridge = { + used: 0, + limit: null, + remaining: null, + currencyCode: "USD", + period: "Atlas Cloud balance", + resetsAt: null, + formattedUsed: "$0.00", + formattedLimit: null, + balance: 95.5, + formattedBalance: "$95.50", + }; + + const { container } = render( + key} />, + ); + + expect(container.firstChild).toBeNull(); + expect(screen.queryByText("DetailCostUsed")).toBeNull(); + expect(screen.queryByText("$0.00")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.tsx index e1a078f29a..5ed7b41c0a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CostSection.tsx @@ -1,5 +1,6 @@ import type { CostSnapshotBridge } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; +import { useCurrency } from "../../../../hooks/CurrencyProvider"; interface Props { cost: CostSnapshotBridge | null; @@ -11,11 +12,21 @@ interface Props { * `rust/src/native_ui/preferences.rs::render_provider_detail_panel`. */ export function CostSection({ cost, t }: Props) { + const { format, preferredCode } = useCurrency(); if (!cost) return null; + const formatCost = (amount: number | null, original: string | null | undefined) => + preferredCode === "AUTO" && original ? original : format(amount, cost.currencyCode, cost.currencySymbol); + + const balanceOnly = + cost.balance != null && + cost.limit == null && + cost.used === 0 && + (cost.daily?.length ?? 0) === 0; + if (balanceOnly) return null; const rows: { label: string; value: string | null }[] = [ - { label: t("DetailCostUsed"), value: cost.formattedUsed }, - { label: t("DetailCostLimit"), value: cost.formattedLimit }, + { label: t("DetailCostUsed"), value: formatCost(cost.used, cost.formattedUsed) }, + { label: t("DetailCostLimit"), value: cost.limit == null ? null : formatCost(cost.limit, cost.formattedLimit) }, { label: t("DetailCostResets"), value: cost.resetsAt ?? null, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx index 76c2652ecd..9e57c58d1d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx @@ -40,6 +40,7 @@ export function CredentialsDispatcher({ providerId, t }: Props) { case "zed": case "sub2api": case "xai": + case "gitkraken": return ; case "openrouter": return ; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx new file mode 100644 index 0000000000..5631cf95bb --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { IdentitySection } from "./IdentitySection"; + +type IdentityDetails = Parameters[0]["provider"]; + +function detail(overrides: Partial = {}): IdentityDetails { + return { + id: "openaiapi", + displayName: "OpenAI API", + organization: "Project: proj-private", + plan: "Admin API: proj-private", + email: null, + authType: null, + sourceLabel: null, + ...overrides, + }; +} + +const t = (key: LocaleKey) => key; + +describe("IdentitySection OpenAI project privacy", () => { + it("masks project ids in account and plan rows when privacy is enabled", () => { + render( + , + ); + + expect(screen.getByText("Project: ••••")).toBeInTheDocument(); + expect(screen.getByText("Admin API: ••••")).toBeInTheDocument(); + expect(screen.queryByText("proj-private")).toBeNull(); + }); + + it("keeps project ids visible when privacy is disabled", () => { + render(); + + expect(screen.getAllByText(/proj-private/)).toHaveLength(2); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx index 624d5d1b07..7fb2b68dbe 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx @@ -1,11 +1,16 @@ import type { ProviderDetail } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; import { ProviderIcon } from "../../../../components/providers/ProviderIcon"; +import { hideOpenAiApiProjectId } from "../../../../lib/openAiApiIdentity"; interface Props { - provider: ProviderDetail; + provider: Pick< + ProviderDetail, + "id" | "displayName" | "email" | "organization" | "plan" | "authType" | "sourceLabel" + >; subtitle: string; t: (key: LocaleKey) => string; + hidePersonalInfo?: boolean; } /** @@ -15,10 +20,22 @@ interface Props { * Port of the identity portion of * `rust/src/native_ui/preferences.rs::render_provider_detail_panel` (~4301). */ -export function IdentitySection({ provider, subtitle, t }: Props) { +export function IdentitySection({ + provider, + subtitle, + t, + hidePersonalInfo = false, +}: Props) { + const hideProjectId = provider.id === "openaiapi" && hidePersonalInfo; const rows: { label: string; value: string | null }[] = [ - { label: t("Account"), value: provider.email ?? provider.organization }, - { label: t("Plan"), value: displayIdentityValue(provider.plan, t) }, + { + label: t("Account"), + value: hideOpenAiApiProjectId(provider.email ?? provider.organization, hideProjectId), + }, + { + label: t("Plan"), + value: hideOpenAiApiProjectId(displayIdentityValue(provider.plan, t), hideProjectId), + }, { label: t("AuthType"), value: provider.authType }, { label: t("DataSource"), value: provider.sourceLabel }, ]; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx index 46f0e21cd2..5f53ab69bb 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx @@ -36,6 +36,25 @@ function provider(extra = true): ProviderDetail { }; } +function mistral(observed = true): ProviderDetail { + return { + ...provider(false), + id: "mistral", + displayName: "Mistral", + primaryMetricLabel: "Included API", + monthlyPlanWindowId: "mistral-monthly-plan", + session: observed ? rateWindow(2) : null, + extraRateWindows: observed + ? [{ id: "mistral-monthly-plan", title: "Monthly Plan", window: rateWindow(42) }] + : [], + hasSnapshot: observed, + }; +} + +function optionNames() { + return screen.getAllByRole("option").map((option) => option.textContent); +} + function rateWindow(usedPercent: number) { return { usedPercent, @@ -122,5 +141,125 @@ describe("MenuBarMetricSection", () => { expect(onChange).toHaveBeenCalledWith({ providerMetrics: { copilot: "extraUsage" }, }); + expect(screen.queryByRole("option", { name: "MetricMonthlyPlan" })).toBeNull(); + }); + + it("offers Included API and Monthly Plan for a provider plan window", () => { + const onChange = vi.fn(); + render( + key} + onChange={onChange} + />, + ); + + // Upstream 0.70.0: Automatic, Included API and Monthly Plan only. The + // plan window does not add a separate Extra usage choice. + expect(optionNames()).toEqual(["Automatic", "Included API", "MetricMonthlyPlan"]); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "monthlyPlan" } }); + expect(onChange).toHaveBeenCalledWith({ + providerMetrics: { mistral: "monthlyPlan" }, + }); + }); + + it("offers Monthly Plan before the first Mistral snapshot", () => { + render( + key} + onChange={vi.fn()} + />, + ); + + expect(optionNames()).toEqual(["Automatic", "Included API", "MetricMonthlyPlan"]); + expect(screen.getByRole("combobox")).toHaveValue("monthlyPlan"); }); + + it("keeps a saved choice the provider no longer offers readable", () => { + render( + key} + onChange={vi.fn()} + />, + ); + + expect(optionNames()).toEqual([ + "Automatic", + "Included API", + "MetricMonthlyPlan", + "ExtraUsage", + ]); + expect(screen.getByRole("combobox")).toHaveValue("extraUsage"); + }); + + it("offers only Automatic for Aixy, even with extra budget windows", () => { + const aixy = provider(); + aixy.id = "aixy"; + aixy.displayName = "Aixy"; + aixy.weekly = rateWindow(40); + render( + key} + onChange={vi.fn()} + />, + ); + + const options = screen.getAllByRole("option").map((option) => option.textContent); + expect(options).toEqual(["Automatic"]); + expect(screen.getByRole("combobox")).toHaveValue("automatic"); + }); +it("offers the provider-declared lane labels in the metric picker", () => { + const base = provider(false); + base.id = "litellm"; + base.displayName = "LiteLLM"; + base.weekly = rateWindow(30); + base.primaryLabel = "Personal budget"; + base.secondaryLabel = "Team budget"; + + render( + key} + onChange={vi.fn()} + />, + ); + + expect(screen.getByRole("option", { name: "Personal budget" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Team budget" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "ProviderSessionLabel" })).not.toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "ProviderWeeklyLabel" })).not.toBeInTheDocument(); + }); + +it("keeps the generic metric labels when the provider declares none", () => { + const base = provider(false); + base.weekly = rateWindow(30); + + render( + key} + onChange={vi.fn()} + />, + ); + + expect(screen.getByRole("option", { name: "Automatic" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "ProviderSessionLabel" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "ProviderWeeklyLabel" })).toBeInTheDocument(); + }); + }); 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 7bc89e0d7d..aba0f75ce3 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.tsx @@ -20,6 +20,8 @@ interface MetricOption { label: string; } +const AUTOMATIC_ONLY_PROVIDERS: ReadonlySet = new Set(["aixy"]); + export function MenuBarMetricSection({ provider, providerMetrics, @@ -28,7 +30,9 @@ export function MenuBarMetricSection({ onChange, }: Props) { const [error, setError] = useState(null); - const selected = providerMetrics[provider.id] ?? "automatic"; + const selected = AUTOMATIC_ONLY_PROVIDERS.has(provider.id) + ? "automatic" + : providerMetrics[provider.id] ?? "automatic"; const options = metricOptions(provider, selected, t); const handleChange = (value: MetricPreference) => { @@ -76,13 +80,25 @@ function metricOptions( selected: MetricPreference, t: (key: LocaleKey) => string, ): MetricOption[] { + // Aixy's primary budget depends on which limits currently apply to the key, + // so a fixed session/weekly lane would be misleading. Offer Automatic only. + if (AUTOMATIC_ONLY_PROVIDERS.has(provider.id)) { + return [{ value: "automatic", label: t("Automatic") }]; + } + const options: MetricOption[] = [ { value: "automatic", label: t("Automatic") }, - { value: "session", label: t("ProviderSessionLabel") }, + { + value: "session", + label: + provider.primaryMetricLabel ?? + provider.primaryLabel ?? + t("ProviderSessionLabel"), + }, ]; if (provider.weekly) { - options.push({ value: "weekly", label: t("ProviderWeeklyLabel") }); + options.push({ value: "weekly", label: provider.secondaryLabel || t("ProviderWeeklyLabel") }); } if (provider.modelSpecific) { options.push({ value: "model", label: t("DetailWindowModelSpecific") }); @@ -95,21 +111,36 @@ function metricOptions( ), }); } - if (provider.id === "cursor" || provider.extraRateWindows.length > 0) { + // The monthly plan window has its own choice below, so it does not make + // Extra usage available on its own. + const extraWindows = provider.extraRateWindows.filter( + (extra) => extra.id !== provider.monthlyPlanWindowId, + ); + if (provider.id === "cursor" || extraWindows.length > 0) { options.push({ value: "extraUsage", label: t("ExtraUsage") }); } - if (provider.id === "mistral") { - options.push({ value: "monthlyPlan", label: t("MistralMonthlySpend") }); + // Upstream 0.70.0 (#4072): providers that publish a monthly plan + // allowance window offer it for the menu bar and widgets. + if (provider.monthlyPlanWindowId) { + options.push({ value: "monthlyPlan", label: t("MetricMonthlyPlan") }); } if (provider.id === "gemini" && provider.weekly) { options.push({ value: "average", label: t("Average") }); } if (!options.some((option) => option.value === selected)) { + const labelKey = SAVED_ONLY_LABEL_KEYS[selected]; options.push({ value: selected, - label: selected === "credits" ? t("CreditsLabel") : selected, + label: labelKey ? t(labelKey) : selected, }); } return options; } + +/** Labels for saved choices the provider no longer offers. */ +const SAVED_ONLY_LABEL_KEYS: Partial> = { + credits: "CreditsLabel", + extraUsage: "ExtraUsage", + monthlyPlan: "MetricMonthlyPlan", +}; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx new file mode 100644 index 0000000000..e14d476759 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { PaceSnapshot } from "../../../../types/bridge"; +import { PaceSection } from "./PaceSection"; + +const pace: PaceSnapshot = { + stage: "behind", + deltaPercent: -10, + willLastToReset: true, + etaSeconds: null, + expectedUsedPercent: 40, + actualUsedPercent: 30, +}; + +describe("PaceSection", () => { + it("renders the pace of an unblocked window", () => { + render( key} />); + + expect(screen.getByText("DetailPaceTitle")).toBeInTheDocument(); + expect(screen.getByText("DetailPaceBehind")).toBeInTheDocument(); + }); + + it("hides the pace of a window blocked by an exhausted monthly pool", () => { + const future = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); + const { container } = render( + key} />, + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("shows the pace again once the monthly pool reset has passed", () => { + const past = new Date(Date.now() - 60 * 1000).toISOString(); + render( + key} />, + ); + + expect(screen.getByText("DetailPaceTitle")).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx index ea1f30009f..6e1893cdce 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/PaceSection.tsx @@ -1,6 +1,8 @@ import type { PaceSnapshot } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; import { formatEta } from "../../../../lib/formatEta"; +import { useMonthlyLimitBlockNow } from "../../../../hooks/useMonthlyLimitBlockNow"; +import { isMonthlyLimitBlockActive } from "../../../../lib/monthlyLimitBlock"; interface Props { pace: PaceSnapshot | null; @@ -19,7 +21,12 @@ const STAGE_TO_KEY: Record = { /** 展示配额节奏状态及其辅助说明。 */ export function PaceSection({ pace, t }: Props) { - if (!pace) return null; + const monthlyLimitBlockNow = useMonthlyLimitBlockNow([pace?.monthlyLimitBlock]); + // Upstream 0.69.0 #4091 drops the pace of a window blocked by a longer + // exhausted pool; its own resets cannot restore access. + if (!pace || isMonthlyLimitBlockActive(pace.monthlyLimitBlock, monthlyLimitBlockNow)) { + return null; + } const stageLabel = t(STAGE_TO_KEY[pace.stage]); const aux = pace.willLastToReset diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/RegionSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/RegionSection.tsx index 77ed218f79..e1a95c64aa 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/RegionSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/RegionSection.tsx @@ -12,7 +12,7 @@ interface Props { } /** - * API-region dropdown for Alibaba / Z.ai / MiniMax. + * API-region dropdown for providers with regional endpoints, including Kimi. * * Port of the region ComboBox rows in * `rust/src/native_ui/preferences.rs::render_provider_detail_panel`. diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx index cf7ca9b884..a448d86b28 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx @@ -86,6 +86,51 @@ describe("UsageSection", () => { expect(screen.getByText("42%")).toBeInTheDocument(); }); + it.each([false, true])( + "renders detail-backed amounts as a detail line, not reset text (resetsAt: %s)", + async (hasReset) => { + const detail = provider(); + const resetsAt = hasReset + ? new Date(Date.now() + 3 * 60 * 60 * 1000 + 30_000).toISOString() + : null; + detail.session = { + ...rateWindow(75), + resetsAt, + resetDescription: "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", + descriptionIsDetail: true, + }; + detail.extraRateWindows = [ + { + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: { + ...rateWindow(13), + resetsAt, + resetDescription: "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + descriptionIsDetail: true, + }, + }, + ]; + + const { container } = render( + + key} /> + , + ); + + const detailLines = await screen.findAllByText(/EUR remaining/); + expect(detailLines.map((line) => line.textContent)).toEqual([ + "19.17 EUR / 25.50 EUR · 6.33 EUR remaining", + "34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + ]); + detailLines.forEach((line) => expect(line).toHaveClass("provider-usage-bar__detail")); + expect(screen.queryByText(/Resets .*EUR/)).not.toBeInTheDocument(); + expect(container.querySelectorAll(".provider-usage-bar__reset")).toHaveLength( + hasReset ? 2 : 0, + ); + }, + ); + it("filters only hidden metric and extra rows", async () => { const detail = provider(); detail.weekly = rateWindow(30); @@ -124,6 +169,70 @@ describe("UsageSection", () => { expect(label.parentElement?.querySelector(".provider-usage-bar__track")).toBeNull(); }); + function kimiDetailBlockedUntil(blockResetsAt: string) { + const detail = provider(); + detail.id = "kimi"; + detail.displayName = "Kimi"; + detail.session = { + ...rateWindow(0), + resetsAt: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(), + monthlyLimitBlock: { resetsAt: blockResetsAt }, + }; + detail.extraRateWindows = [ + { + id: "kimi-monthly", + title: "Total usage", + window: { ...rateWindow(100), isExhausted: true, resetsAt: blockResetsAt }, + }, + ]; + return detail; + } + + it("shows a window blocked by the monthly pool as title and status only", async () => { + const poolReset = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); + + render( + + key} + /> + , + ); + + const label = await screen.findByText("ProviderSessionLabel"); + const row = label.closest(".provider-usage-bar"); + expect(row).toHaveTextContent(/^ProviderSessionLabelPanelBlockedByMonthlyLimit$/); + expect(row?.querySelector(".provider-usage-bar__track")).toBeNull(); + expect(row?.querySelector(".provider-usage-bar__reset")).toBeNull(); + // The pool's own row keeps its bar, exhausted state and reset. + const pool = screen.getByText("Total usage").closest(".provider-usage-bar"); + expect(pool?.querySelector(".provider-usage-bar__track")).not.toBeNull(); + expect(pool).toHaveTextContent("DetailWindowExhausted"); + expect(pool?.querySelector(".provider-usage-bar__reset")).not.toBeNull(); + }); + + it("shows the raw window again once the monthly pool reset has passed", async () => { + const past = new Date(Date.now() - 60 * 1000).toISOString(); + + render( + + key} + /> + , + ); + + const label = await screen.findByText("ProviderSessionLabel"); + const row = label.closest(".provider-usage-bar"); + expect(row).toHaveTextContent("0%"); + expect(row).not.toHaveTextContent("PanelBlockedByMonthlyLimit"); + expect(row?.querySelector(".provider-usage-bar__track")).not.toBeNull(); + }); + it("renders discrete inventory without turning it into a quota bar", async () => { const detail = provider(); detail.session = null; @@ -146,4 +255,36 @@ describe("UsageSection", () => { expect(await screen.findByText(/Limit Reset Credits: 2 available/)).toBeInTheDocument(); expect(container.querySelector(".provider-usage-bar__track")).toBeNull(); }); + + it("shows provider-declared lane labels in settings bars", async () => { + const detail = provider(); + detail.weekly = rateWindow(30); + detail.primaryLabel = "Personal budget"; + detail.secondaryLabel = "Team budget"; + + render( + + key} /> + , + ); + + expect(await screen.findByText("Personal budget")).toBeInTheDocument(); + expect(screen.getByText("Team budget")).toBeInTheDocument(); + expect(screen.queryByText("ProviderSessionLabel")).not.toBeInTheDocument(); + expect(screen.queryByText("ProviderWeeklyLabel")).not.toBeInTheDocument(); + }); + + it("keeps the generic labels when the provider declares none", async () => { + const detail = provider(); + detail.weekly = rateWindow(30); + + render( + + key} /> + , + ); + + expect(await screen.findByText("ProviderSessionLabel")).toBeInTheDocument(); + expect(screen.getByText("ProviderWeeklyLabel")).toBeInTheDocument(); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index b2d24dbdfc..d882bdaf29 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -5,10 +5,16 @@ import type { RateWindowSnapshot, } from "../../../../types/bridge"; import { InventoryItemRow } from "../../../../components/InventoryRows"; -import { ProviderDisplayRow } from "../../../../components/ProviderDisplayRow"; +import { + groupProviderDisplayDetails, + ProviderDisplayRow, +} from "../../../../components/ProviderDisplayRow"; import type { LocaleKey } from "../../../../i18n/keys"; import { useFormattedResetTime } from "../../../../hooks/useFormattedResetTime"; +import { useMonthlyLimitBlockNow } from "../../../../hooks/useMonthlyLimitBlockNow"; +import { isMonthlyLimitBlockActive } from "../../../../lib/monthlyLimitBlock"; import { isUsageItemVisible } from "../../../../lib/usageItemVisibility"; +import { resetDescriptionFallback, windowDetailText } from "../../../../lib/usageWindows"; interface Props { provider: ProviderDetail; @@ -32,14 +38,14 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { if (provider.session && isUsageItemVisible(provider.hiddenUsageItemIds, "primary")) { bars.push({ key: "session", - label: t("ProviderSessionLabel"), + label: provider.primaryLabel || t("ProviderSessionLabel"), rate: provider.session, }); } if (provider.weekly && isUsageItemVisible(provider.hiddenUsageItemIds, "secondary")) { bars.push({ key: "weekly", - label: t("ProviderWeeklyLabel"), + label: provider.secondaryLabel || t("ProviderWeeklyLabel"), rate: provider.weekly, }); } @@ -68,8 +74,13 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { }); } + // Cached snapshots outlive the pool reset; re-check blocks on the clock. + const monthlyLimitBlockNow = useMonthlyLimitBlockNow( + bars.map((bar) => bar.rate.monthlyLimitBlock), + ); const inventory = provider.inventory ?? []; const displayDetails = provider.displayDetails ?? []; + const displayDetailGroups = groupProviderDisplayDetails(displayDetails); if (bars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { return null; } @@ -82,6 +93,7 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { key={b.key} label={b.label} rate={b.rate} + blocked={isMonthlyLimitBlockActive(b.rate.monthlyLimitBlock, monthlyLimitBlockNow)} resetTimeRelative={resetTimeRelative} t={t} /> @@ -94,14 +106,27 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { lineClassName="provider-usage-inventory" /> ))} - {displayDetails.map((detail) => ( - + {displayDetailGroups.map((group) => ( +
+ {group.title && ( +
+ {group.title} +
+ )} + {group.rows.map((detail) => ( + + ))} +
))} ); @@ -110,22 +135,36 @@ export function UsageSection({ provider, resetTimeRelative, t }: Props) { function UsageBar({ label, rate, + blocked, resetTimeRelative, t, }: { label: string; rate: RateWindowSnapshot; + blocked: boolean; resetTimeRelative: boolean; t: (key: LocaleKey) => string; }) { const usedPct = Number.isFinite(rate.usedPercent) ? Math.max(0, rate.usedPercent) : 0; const pct = Math.min(100, usedPct); const isInformational = rate.isInformational === true; + const detailText = windowDetailText(rate); const formattedReset = useFormattedResetTime( - rate.resetsAt, - rate.resetDescription, + blocked ? null : rate.resetsAt, + blocked ? null : resetDescriptionFallback(rate), resetTimeRelative, ); + if (blocked) { + // Upstream 0.69.0 #4091 status row: title and status, no bar or reset. + return ( +
+
+ {label} + {t("PanelBlockedByMonthlyLimit")} +
+
+ ); + } const resetHint = formattedReset ? resetTimeRelative ? formattedReset @@ -161,6 +200,7 @@ function UsageBar({ {!isInformational && resetHint && ( {resetHint} )} + {detailText && {detailText}}
); -} \ No newline at end of file +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.test.tsx index 77087ce037..76b32b2691 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.test.tsx @@ -32,6 +32,12 @@ describe("usage source policy", () => { "oauth", "web", ]); + expect(usageSourcePolicy("zed")?.options.map((option) => option.value)).toEqual([ + "auto", + "oauth", + "web", + ]); + expect(usageSourcePolicy("zed")?.options[0].description).toContain("editor credential"); expect(usageSourcePolicy("antigravity")?.options[0].description).toContain( "skips agy reports without account identity", ); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.tsx index d8acbb4a28..eccd7bf0a2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSourceSection.tsx @@ -57,12 +57,16 @@ export function UsageSourceSection({ className={`provider-detail-segmented__option${isActive ? " is-active" : ""}`} onClick={() => void handleSelect(option.value)} > - {option.label} + {option.labelKey ? t(option.labelKey) : option.label} ); })}
-

{selectedOption.description}

+

+ {selectedOption.descriptionKey + ? t(selectedOption.descriptionKey) + : selectedOption.description} +

{error &&

{error}

} ); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx new file mode 100644 index 0000000000..1b95267446 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + isGatewayProviderId, + WayfinderGatewaySection, + type GatewayProviderId, +} from "./WayfinderGatewaySection"; + +function renderSection(providerId: GatewayProviderId, onSave = vi.fn()) { + render( + key} + />, + ); + return onSave; +} + +describe("WayfinderGatewaySection", () => { + it.each([ + ["wayfinder", "WayfinderGatewayTitle", "WayfinderGatewayLabel", "WayfinderGatewayHelp"], + ["bifrost", "BifrostGatewayTitle", "WayfinderGatewayLabel", "BifrostGatewayHelp"], + ["aixy", "AixyGatewayTitle", "AixyGatewayLabel", "AixyGatewayHelp"], + ] as const)("uses localized %s copy", (providerId, title, label, help) => { + renderSection(providerId); + + expect(screen.getByRole("heading", { name: title })).toBeInTheDocument(); + expect(screen.getByLabelText(label)).toHaveValue("https://gateway.example.com"); + expect(screen.getByText(help)).toBeInTheDocument(); + }); + + it("saves through the shared button", () => { + const onSave = renderSection("aixy"); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it("recognizes only gateway providers", () => { + expect(isGatewayProviderId("aixy")).toBe(true); + expect(isGatewayProviderId("bifrost")).toBe(true); + expect(isGatewayProviderId("wayfinder")).toBe(true); + expect(isGatewayProviderId("codex")).toBe(false); + expect(isGatewayProviderId("toString")).toBe(false); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx index 1f0f28f0e6..9c454f0ae1 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx @@ -1,6 +1,37 @@ import type { LocaleKey } from "../../../../i18n/keys"; +export type GatewayProviderId = "wayfinder" | "bifrost" | "aixy"; + +interface GatewayCopy { + title: LocaleKey; + label: LocaleKey; + help: LocaleKey; +} + +const GATEWAY_COPY: Record = { + wayfinder: { + title: "WayfinderGatewayTitle", + label: "WayfinderGatewayLabel", + help: "WayfinderGatewayHelp", + }, + bifrost: { + title: "BifrostGatewayTitle", + label: "WayfinderGatewayLabel", + help: "BifrostGatewayHelp", + }, + aixy: { + title: "AixyGatewayTitle", + label: "AixyGatewayLabel", + help: "AixyGatewayHelp", + }, +}; + +export function isGatewayProviderId(id: string): id is GatewayProviderId { + return Object.prototype.hasOwnProperty.call(GATEWAY_COPY, id); +} + interface Props { + providerId: GatewayProviderId; draft: string; error: string | null; busy: boolean; @@ -11,6 +42,7 @@ interface Props { } export function WayfinderGatewaySection({ + providerId, draft, error, busy, @@ -19,11 +51,12 @@ export function WayfinderGatewaySection({ onSave, t, }: Props) { + const copy = GATEWAY_COPY[providerId]; return (
-

{t("WayfinderGatewayTitle")}

+

{t(copy.title)}

-

{t("WayfinderGatewayHelp")}

+

{t(copy.help)}

{error &&

{error}

}