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/.gitignore b/.gitignore index e1961644d0..c24ff2f978 100755 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ nul /.local/ # Docs children ignored generally; ADRs are product docs and stay trackable /docs/* +!/docs/prometheus/ +!/docs/grafana/ !/docs/adr/ /docs/adr/* !/docs/adr/*.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c054768f2c..7f28c2434f 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,112 @@ # Changelog -## [Windows] 0.61.0 - Unreleased +## [Windows] 0.70.0 - 2026-10-03 + +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, plus six community pull requests and fixes for open issues. This is also the first release to ship the changes in the unreleased 0.61.0 changelog section. + +### 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)). +- Codex: persist and record quota reset observations so reset history survives restarts and refreshes ([#743](https://github.com/nesszer/Win-CodexBar/pull/743)). +- Providers: new llmman, DevPass (plan credits and premium weekly), xKiro (daily free tokens), Raycast (monthly AI credits), Vercel AI Gateway (credit balance) and Sakana (pay-as-you-go balance) providers ([#650](https://github.com/nesszer/Win-CodexBar/pull/650), [#658](https://github.com/nesszer/Win-CodexBar/pull/658), [#663](https://github.com/nesszer/Win-CodexBar/pull/663), [#668](https://github.com/nesszer/Win-CodexBar/pull/668), [#678](https://github.com/nesszer/Win-CodexBar/pull/678), [#685](https://github.com/nesszer/Win-CodexBar/pull/685)). +- Providers: Alibaba Token Plan and Qwen Cloud monthly windows, Doubao Agent Plan tray icon fallback, Chutes quota classification, Perplexity session retries and credit lanes, Command Code granted monthly credits, and Abacus concurrent credits and billing ([#662](https://github.com/nesszer/Win-CodexBar/pull/662), [#637](https://github.com/nesszer/Win-CodexBar/pull/637), [#639](https://github.com/nesszer/Win-CodexBar/pull/639), [#643](https://github.com/nesszer/Win-CodexBar/pull/643), [#632](https://github.com/nesszer/Win-CodexBar/pull/632), [#628](https://github.com/nesszer/Win-CodexBar/pull/628)). +- Providers: Grok reads bearer credits from the CLI credits proxy and shows a product usage breakdown, also on the grok.com billing fallback; the Grok local session scan covers exact local calendar days ([#652](https://github.com/nesszer/Win-CodexBar/pull/652), [#656](https://github.com/nesszer/Win-CodexBar/pull/656), [#692](https://github.com/nesszer/Win-CodexBar/pull/692), [#687](https://github.com/nesszer/Win-CodexBar/pull/687)). +- Providers: LiteLLM spend-report fallback, identity and team/user budgets, and opt-in model activity with Claude workspace spend; LLM Proxy base URL setting with private-network HTTP and tolerant quota groups ([#630](https://github.com/nesszer/Win-CodexBar/pull/630), [#702](https://github.com/nesszer/Win-CodexBar/pull/702), [#664](https://github.com/nesszer/Win-CodexBar/pull/664), [#704](https://github.com/nesszer/Win-CodexBar/pull/704)). +- Providers: Mistral counts plan-covered tokens, shows plan amounts as detail lines, offers a Monthly Plan metric, and prices usage by event type, API zone and service tier ([#647](https://github.com/nesszer/Win-CodexBar/pull/647), [#657](https://github.com/nesszer/Win-CodexBar/pull/657), [#717](https://github.com/nesszer/Win-CodexBar/pull/717), [#718](https://github.com/nesszer/Win-CodexBar/pull/718)). +- Providers: OpenAI Admin pagination, token totals and retry, plus per-day usage history with a daily usage chart ([#645](https://github.com/nesszer/Win-CodexBar/pull/645), [#699](https://github.com/nesszer/Win-CodexBar/pull/699), [#701](https://github.com/nesszer/Win-CodexBar/pull/701)). +- Providers: Muse reads the selected browser team quota, ClinePass reuses the Cline auth session, OpenCode Go counts local tokens, Zed gets opt-in browser billing, Bifrost parity polish, and MiniMax browser storage discovery over a shared Chromium LevelDB reader ([#655](https://github.com/nesszer/Win-CodexBar/pull/655), [#681](https://github.com/nesszer/Win-CodexBar/pull/681), [#671](https://github.com/nesszer/Win-CodexBar/pull/671), [#674](https://github.com/nesszer/Win-CodexBar/pull/674), [#675](https://github.com/nesszer/Win-CodexBar/pull/675), [#651](https://github.com/nesszer/Win-CodexBar/pull/651), [#646](https://github.com/nesszer/Win-CodexBar/pull/646)). +- Providers: Devin manual paste auth guidance, and the saved Kimi API region is honored in CLI and serve fetches ([#736](https://github.com/nesszer/Win-CodexBar/pull/736), [#737](https://github.com/nesszer/Win-CodexBar/pull/737)). +- Settings: portable preferences export and import (Settings and `codexbar config preferences`), credential expiry alerts, and Stay Awake while local agent sessions are live ([#654](https://github.com/nesszer/Win-CodexBar/pull/654), [#660](https://github.com/nesszer/Win-CodexBar/pull/660), [#659](https://github.com/nesszer/Win-CodexBar/pull/659)). +- Settings: provider switcher shortcut model, default navigation keys and a shortcut editor ([#693](https://github.com/nesszer/Win-CodexBar/pull/693), [#700](https://github.com/nesszer/Win-CodexBar/pull/700)). +- Usage & Spend: provider grouping, a consolidated chart selector and clear-selected-day; Codex sessions are named and ranked by cost; Claude incomplete requests are marked ([#705](https://github.com/nesszer/Win-CodexBar/pull/705), [#673](https://github.com/nesszer/Win-CodexBar/pull/673), [#707](https://github.com/nesszer/Win-CodexBar/pull/707)). +- Serve: dashboard usage display control (Follow server, Used, Remaining) ([#680](https://github.com/nesszer/Win-CodexBar/pull/680)). +- Tray: stacked provider tray mode, and the reviewed provider, history and tray ports batch ([#603](https://github.com/nesszer/Win-CodexBar/pull/603), [#610](https://github.com/nesszer/Win-CodexBar/pull/610)). +- Language: Brazilian Portuguese (pt-BR) UI, contributed by @mdnbras ([#617](https://github.com/nesszer/Win-CodexBar/pull/617)). +- Browser cookies: Chrome Beta, Dev, Canary, Chrome for Testing and Chromium profiles as cookie sources, contributed by @ciltocruz ([#614](https://github.com/nesszer/Win-CodexBar/pull/614)). +- Metrics: Codex reset-credit count and next expiry in `serve --metrics`, with Grafana dashboard, scrape and alert examples, contributed by @le-shi ([#623](https://github.com/nesszer/Win-CodexBar/pull/623)). +- CLI: `usage` and serve `/usage` follow the enabled providers; `config providers --json`; `-p/--provider` on config subcommands; `config claude-code-credentials allow|deny|status`; `serve --request-timeout`; `CODEXBAR_CONFIG`; and an `errorKind` on JSON error rows ([#640](https://github.com/nesszer/Win-CodexBar/issues/640)). + +### 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)). +- Usage: local usage token totals include cache tokens per provider rule (30-day, latest and daily rows), contributed by @Pyaoya ([#611](https://github.com/nesszer/Win-CodexBar/pull/611)). +- Claude: the Windows `/usage` PTY probe retries, takes turns across processes with a lock, shares a fresh result, and kills its child tree on close, contributed by @marcus7989 ([#641](https://github.com/nesszer/Win-CodexBar/pull/641)). +- Gemini: read OAuth client constants from the bundled Gemini CLI layout, contributed by @marcus7989 ([#642](https://github.com/nesszer/Win-CodexBar/pull/642)). +- Codex: keep users distinct in a shared Team workspace, contributed by @gleaming9 ([#593](https://github.com/nesszer/Win-CodexBar/pull/593)); exclude inherited fork baselines ([#597](https://github.com/nesszer/Win-CodexBar/pull/597)); redact every Codex System Account label under Hide Personal Info ([#741](https://github.com/nesszer/Win-CodexBar/pull/741)). +- Codex: cost history also scans archived and flat legacy session rollouts (refs [#616](https://github.com/nesszer/Win-CodexBar/issues/616); this may not be the cause of that report, which is still open). +- Groq: use the correct metrics URL, rewrite a `GROQ_API_URL` still set to the old `https://api.groq.com/openai/v1` default, and explain a 404 as an Enterprise-plan limit ([#606](https://github.com/nesszer/Win-CodexBar/issues/606)). +- Float bar: moves made by Windows (display sleep, resolution or DPI changes) no longer overwrite the saved placement, and the bar returns once the monitor layout is back ([#625](https://github.com/nesszer/Win-CodexBar/issues/625)). +- Kimi: reconcile zero ratio placeholders, honor the manual cookie policy, and route through the selected region ([#587](https://github.com/nesszer/Win-CodexBar/pull/587), [#598](https://github.com/nesszer/Win-CodexBar/pull/598), [#599](https://github.com/nesszer/Win-CodexBar/pull/599)). +- Providers: Neuralwatt retry and endpoint parity, ZenMux expiry and PAYG handling, Manus iterates browser sessions, Cursor backs off forbidden cost requests for six hours, Ollama explains an empty manual cookie, Qoder regional cookie routing, DeepInfra retries transient billing requests, and z.ai shows unavailable quota instead of a fabricated 0% ([#624](https://github.com/nesszer/Win-CodexBar/pull/624), [#634](https://github.com/nesszer/Win-CodexBar/pull/634), [#635](https://github.com/nesszer/Win-CodexBar/pull/635), [#636](https://github.com/nesszer/Win-CodexBar/pull/636), [#638](https://github.com/nesszer/Win-CodexBar/pull/638), [#644](https://github.com/nesszer/Win-CodexBar/pull/644), [#683](https://github.com/nesszer/Win-CodexBar/pull/683), [#696](https://github.com/nesszer/Win-CodexBar/pull/696)). +- Antigravity: estimate local history costs; Claude: contain oversized history values ([#602](https://github.com/nesszer/Win-CodexBar/pull/602), [#605](https://github.com/nesszer/Win-CodexBar/pull/605)). +- Credentials: stage credential writes and publish them atomically ([#669](https://github.com/nesszer/Win-CodexBar/pull/669)). + +### 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`). +- Overview: show every quota row in the compact Overview, keep provider detail sections visible and deduplicated, and default the layout to detailed ([#744](https://github.com/nesszer/Win-CodexBar/pull/744)). +- Overview: per-section visibility for provider details via `detailSection:` IDs ([#745](https://github.com/nesszer/Win-CodexBar/pull/745)). +- Settings: show the running app version on the General tab ([#746](https://github.com/nesszer/Win-CodexBar/pull/746)). +- Settings: animate the budget disclosure chevron ([#608](https://github.com/nesszer/Win-CodexBar/pull/608)). +- Providers: retire the Crof provider safely ([#596](https://github.com/nesszer/Win-CodexBar/pull/596)). + +### Issues fixed + +- Fixed in this release: [#606](https://github.com/nesszer/Win-CodexBar/issues/606) Groq metrics URL; [#612](https://github.com/nesszer/Win-CodexBar/issues/612) Vercel AI Gateway provider (via #678); [#613](https://github.com/nesszer/Win-CodexBar/issues/613) Chrome channels and Chromium (via #614); [#622](https://github.com/nesszer/Win-CodexBar/issues/622) Codex reset credits in metrics (via #623); [#625](https://github.com/nesszer/Win-CodexBar/issues/625) float bar placement; [#640](https://github.com/nesszer/Win-CodexBar/issues/640) CLI parity for embedding apps (with #641 and #642). +- Already fixed on this release line, now pinned by regression tests: [#591](https://github.com/nesszer/Win-CodexBar/issues/591) OpenCode Go console URL; [#592](https://github.com/nesszer/Win-CodexBar/issues/592) distinct users in a shared Team workspace (via #593). +- Related, not confirmed fixed: [#616](https://github.com/nesszer/Win-CodexBar/issues/616) Codex cost history shows $0.00. Archived and flat legacy rollouts are now scanned, but the reporter's root cause is not confirmed. + +### Community contributions + +Thanks to @Pyaoya ([#611](https://github.com/nesszer/Win-CodexBar/pull/611)), @ciltocruz ([#614](https://github.com/nesszer/Win-CodexBar/pull/614)), @mdnbras ([#617](https://github.com/nesszer/Win-CodexBar/pull/617)), @le-shi ([#623](https://github.com/nesszer/Win-CodexBar/pull/623)), @marcus7989 ([#641](https://github.com/nesszer/Win-CodexBar/pull/641), [#642](https://github.com/nesszer/Win-CodexBar/pull/642)) and @gleaming9 ([#593](https://github.com/nesszer/Win-CodexBar/pull/593)). + +> 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 @@ -12,10 +118,18 @@ menu-bar layout. - Provider charts: show recent Codex and Claude quota-window history with explicit account scope and estimated-boundary markers; historical data remains display-only. - 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. +- Raycast: read monthly AI credits from the signed-in `www.raycast.com` session (Chrome auto-import or a pasted Cookie header), showing credits left, plan, and renewal date. Off makes no request and a manual header never falls back to a browser. - 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..0091ef2506 100644 --- a/README.es-MX.md +++ b/README.es-MX.md @@ -1,6 +1,6 @@ # Win-CodexBar -[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md) +[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md) Win-CodexBar es una aplicación de bandeja del sistema para Windows que mantiene visible el uso de herramientas de codificación con IA sin necesidad de abrir una docena de paneles. Traslada el espíritu de [CodexBar](https://github.com/steipete/CodexBar) a un entorno de escritorio Tauri + React respaldado por lógica compartida de proveedores en Rust. @@ -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.ja-JP.md b/README.ja-JP.md index df131b12e1..0990f379bf 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -1,6 +1,6 @@ # Win-CodexBar -[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md) +[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md) Win-CodexBar は、複数の AI コーディングツールの使用量を Windows のシステムトレイから確認できるデスクトップアプリです。[CodexBar](https://github.com/steipete/CodexBar) の考え方を、Tauri + React のデスクトップシェルと共有 Rust バックエンドで Windows 向けに移植しています。 diff --git a/README.ko-KR.md b/README.ko-KR.md index 1037de8619..73fcb6a6e8 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -1,6 +1,6 @@ # Win-CodexBar -[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md) +[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md) Win-CodexBar는 여러 AI 코딩 도구의 사용량을 Windows 시스템 트레이에서 바로 확인할 수 있게 해 주는 데스크톱 앱입니다. [CodexBar](https://github.com/steipete/CodexBar)의 아이디어를 Tauri + React 데스크톱 셸과 공유 Rust 백엔드로 Windows에 맞게 포팅했습니다. diff --git a/README.md b/README.md index 2e052ad2f9..8c7a35e0f8 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Win-CodexBar -[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md) +[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md) Win-CodexBar is a Windows system-tray app for keeping AI coding-tool usage visible without opening a dozen dashboards. It ports the spirit of [CodexBar](https://github.com/steipete/CodexBar) to a Tauri + React desktop shell backed by shared Rust provider logic. @@ -21,7 +21,7 @@ Win-CodexBar is a Windows system-tray app for keeping AI coding-tool usage visib - **Tray-first workflow** with a compact provider grid, usage cards, refresh action, settings shortcut, and quit control. - **Provider settings** for source selection, credentials, cookie import, token accounts, API keys, regions, and tray-display preferences. - **Windows credential protection** for app-managed API keys, manual cookies, and token accounts, using user-scoped DPAPI where available. -- **Browser cookie import** for Chrome, Edge, Brave, and Firefox, kept opt-in per provider. +- **Browser cookie import** for Chrome Stable, Beta, Dev, Canary, Chrome for Testing, Chromium, Edge, Brave, and Firefox, kept opt-in per provider. - **Installed local CLI** for scripting usage, cost, config, diagnostics, and loopback integrations. - **Installer + portable builds** with WebView2 runtime bootstrap, VC++ runtime bootstrap, and SHA-256 checksum files. @@ -81,6 +81,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | Kiro | Cookies / CLI | Monthly Credits, Overage | | Vertex AI | gcloud OAuth | Cost | | v0 | API Key | Billing quota, API rate limits, on-demand balance | +| Vercel AI Gateway | API Key | Team credit balance, lifetime spend | | Augment | Cookies | Credits | | OpenCode | Local Config | Usage | | Kimi | Cookies | 5h Rate, Weekly | @@ -109,17 +110,21 @@ 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 | +| Raycast | Cookies (Chrome auto or manual) | Monthly AI credits, plan, renewal | | 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 | | LLM Proxy | API Key | Quota Stats | +| llmman | Local daemon / optional API Key | Memory in use, loaded and stored models | +| DevPass | API Key | Plan credits, Premium weekly, API-key spend | +| xKiro | API Key | Daily free tokens | @@ -133,6 +138,7 @@ The UI and contributor reporting currently support: - 日本語 - 한국어 - Español mexicano +- Português (Brasil) - Türkçe ## Build From Source diff --git a/README.pt-BR.md b/README.pt-BR.md new file mode 100644 index 0000000000..1c454dff7a --- /dev/null +++ b/README.pt-BR.md @@ -0,0 +1,205 @@ +# Win-CodexBar + +[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md) + +O Win-CodexBar é um aplicativo para a bandeja do sistema do Windows que mantém visível o uso das suas ferramentas de programação com IA, sem precisar abrir vários painéis. Ele traz a proposta do [CodexBar](https://github.com/steipete/CodexBar) para uma aplicação desktop em Tauri + React, apoiada por uma lógica compartilhada de provedores escrita em Rust. + + + + + + +
+ Painel da bandeja do Win-CodexBar mostrando cartões de uso dos provedores + + Página de configurações de provedores do Win-CodexBar +
+ +## Destaques + +- **51 provedores**, incluindo Codex, Claude, Copilot, OpenRouter, Cursor, Gemini, DeepSeek, MiniMax, Kiro, Antigravity, Groq e muitos outros. +- **Fluxo centrado na bandeja**, com uma grade compacta de provedores, cartões de uso, ação de atualização, atalho para as configurações e controle para sair. +- **Configurações por provedor** para selecionar fontes de dados, credenciais, importação de cookies, contas de token, chaves de API, regiões e preferências de exibição na bandeja. +- **Proteção de credenciais no Windows** para chaves de API, cookies manuais e contas de token gerenciados pelo aplicativo, usando DPAPI no escopo do usuário quando disponível. +- **Importação de cookies do navegador** para Chrome, Edge, Brave e Firefox, ativada individualmente para cada provedor. +- **CLI local instalada** para automatizar consultas de uso, custos, configurações, diagnósticos e integrações via loopback. +- **Versões instalável e portátil**, com inicialização do runtime do WebView2 e do runtime do VC++, além de arquivos de verificação SHA-256. + +## Instalação + +Instale com o Gerenciador de Pacotes do Windows: + +```powershell +winget install Finesssee.Win-CodexBar +``` + +Ou baixe a versão mais recente do instalador ou do executável portátil em [GitHub Releases](https://github.com/nesszer/Win-CodexBar/releases). + +- Instalador: `CodexBar--Setup.exe` +- Portátil: `CodexBar--portable.exe` +- Checksums: cada versão inclui arquivos `.sha256` + +A distribuição pelo Winget foi aprovada no [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs/tree/master/manifests/f/Finesssee/Win-CodexBar). Novas versões podem demorar um pouco para aparecer, pois cada atualização no Winget é vinculada a uma URL de versão e a um hash específico do instalador. + +## Assinatura de código + +> **Assinatura de código:** o SignPath.io está integrado ao fluxo de lançamento do GitHub Actions, mas a assinatura em produção permanece bloqueada até a emissão do certificado Release 2026 e a validação da política `release-signing`. Consulte [docs/CODE_SIGNING.md](docs/CODE_SIGNING.md) para conhecer a política de assinatura. +> A versão v0.60.3 é imutável e não está assinada; verifique os arquivos SHA-256. A primeira versão assinada será a próxima versão normal após a integração com o SignPath. + +## Primeira execução + +1. Inicie o **CodexBar** pelo menu Iniciar ou pelo executável portátil. +2. Clique no ícone da bandeja para abrir o painel de uso. +3. Abra **Configurações -> Provedores**. +4. Ative os provedores que você utiliza. +5. Adicione o tipo de credencial correspondente: login OAuth/dispositivo, chave de API, cookies do navegador, login na CLI local ou conta de token. + +Para o Claude, cookies do navegador/sessionKey são recomendados porque correspondem ao uso exibido na página de configurações do Claude. OAuth e CLI continuam disponíveis como alternativas. Para provedores baseados em CLI, como Codex e Gemini, primeiro faça login pela CLI do provedor. + +## Histórico de versões + +Consulte todas as alterações em [CHANGELOG.md](CHANGELOG.md). + +## Provedores compatíveis + +
+Matriz de provedores + +| Provedor | Autenticação | Dados monitorados | +|---|---|---| +| Codex | OAuth / CLI | Sessão, semanal, créditos | +| Claude | Cookies / alternativa OAuth / alternativa CLI | Sessão (5h), semanal | +| Cursor | Cookies | Plano, uso, cobrança | +| Factory | Cookies | Uso | +| Gemini | OAuth do gcloud | Cota | +| Copilot | Fluxo de dispositivo do GitHub / CLI gh / token legado | Uso do plano, Chat | +| Antigravity | LSP local | Uso, cotas por modelo | +| z.ai | Token de API | Cota | +| MiniMax | API / Cookies | Uso, resumo de cobrança | +| Kiro | Cookies / CLI | Créditos mensais, excedentes | +| Vertex AI | OAuth do gcloud | Custo | +| v0 | Chave de API | Cota de cobrança, limites de requisição da API, saldo sob demanda | +| Augment | Cookies | Créditos | +| OpenCode | Configuração local | Uso | +| Kimi | Cookies | Limite de 5h, semanal | +| Kimi K2 | Chave de API | Créditos | +| Amp | Cookies | Uso | +| Warp | Configuração local | Uso | +| Ollama | Cookies / Chave de API | Uso, modelos na nuvem, janelas de ritmo | +| Azure OpenAI | Chave de API | Implantação | +| T3 Chat | Cookies / cURL | Base, excedentes | +| TypeSafe | Cookies do navegador / cabeçalho Cookie manual | Gastos do ciclo de cobrança, saldo, créditos a expirar | +| OpenRouter | Chave de API | Créditos | +| JetBrains AI | Configuração local | Uso | +| Alibaba | Cookies | Uso | +| Alibaba Token Plan | Cookies | Créditos do plano de tokens, data de renovação | +| NanoGPT | Chave de API | Créditos | +| Infini | Chave de API | Sessão, semanal, cota | +| Perplexity | Cookies | Créditos, plano | +| Abacus AI | Cookies | Créditos | +| Mistral | Cookies | Cobrança, uso | +| OpenCode Go | Cookies | Uso, saldo Zen | +| Kilo | Chave de API / CLI | Uso | +| Codebuff | Chave de API / Configuração local | Créditos, semanal | +| DeepSeek | Chave de API | Saldo, resumos de uso, custo | +| Windsurf | Cache local | Diário, semanal | +| Manus | Cookies | Créditos, créditos de atualização | +| Xiaomi MiMo | Cookies | Saldo, plano de tokens | +| Doubao | Chave de API | Limites de requisição | +| Command Code | Cookies | Créditos mensais, créditos comprados | +| StepFun | Token Oasis | 5h, semanal, atualização de token | +| Venice | Chave de API / sessão web | Saldo em USD / DIEM, créditos incluídos (a sessão web expira em cerca de 60 s) | +| OpenAI | API Admin / Chave de API | Uso, requisições, custo por projeto, saldo de créditos | +| Grok | Cookies / auth.json | Cobrança | +| Helmcode (também NaN Builders) | Cookies do navegador / cabeçalho Cookie manual | Cotas de tokens por modelo, janelas de renovação, saldo pré-pago do Helmcode | +| Replicate | Cookies / contas de token | Gastos mensais, saldo de créditos | +| Aixy | Chave de API / contas de token | Saldos de orçamento aplicáveis, uso da chave em 7 dias | +| ElevenLabs | Chave de API | Créditos da assinatura, slots de voz | +| Deepgram | Chave de API | Uso do projeto | +| Groq | Chave de API | Métricas corporativas | +| LLM Proxy | Chave de API | Estatísticas de cota | + +
+ +## Idiomas disponíveis + +A interface e os relatórios para colaboradores estão disponíveis atualmente em: + +- English +- 简体中文 +- 繁體中文(臺灣) +- 日本語 +- 한국어 +- Español mexicano +- Português (Brasil) +- Türkçe + +## Compilação a partir do código-fonte + +```powershell +# Pré-requisitos: Node.js + pnpm. Rust e MinGW são instalados pelo script quando necessário. +git clone https://github.com/nesszer/Win-CodexBar.git +cd Win-CodexBar +.\scripts\dev.ps1 +``` + +Opções úteis para desenvolvimento: + +```powershell +.\scripts\dev.ps1 -Release # compilação otimizada +.\scripts\dev.ps1 -SkipBuild # reinicia a última compilação +``` + +Exemplos da CLI: + +```bash +codexbar-cli --help +codexbar-cli diagnose --pretty +codexbar-cli usage -p claude +codexbar-cli usage -p all +codexbar-cli cost -p codex +``` + +As compilações do instalador incluem `codexbar.exe` como aplicativo da bandeja e `codexbar-cli.exe` como CLI de console. Os atalhos do menu Iniciar executam o aplicativo desktop; os comandos do terminal usam `codexbar-cli.exe`. O `codexbar-desktop.exe` ainda é instalado como um alias de compatibilidade para atalhos antigos e entradas de inicialização automática. + +## Compilações de lançamento + +Para criar versões locais no Windows, use o gerador de versões com cache: + +```powershell +.\scripts\windows-release-build.ps1 -Ref v0.33.2 -SmokeInstall +``` + +O script compila o binário de lançamento real do Tauri e a CLI de console, verifica as dependências assinadas do instalador, empacota com o Inno Setup, gera os artefatos instalável e portátil, cria os arquivos SHA-256 correspondentes e pode executar um teste rápido de instalação e desinstalação silenciosas. + +Mais informações sobre a automação de versões estão em [docs/release/ci-cd.md](docs/release/ci-cd.md). + +## Privacidade + +- **Processamento local por padrão**: os dados dos provedores são lidos de caminhos locais conhecidos ou das APIs que você configurar. +- **Cookies opcionais**: a extração de cookies do navegador só é executada para os provedores ativados por você. +- **Segredos protegidos**: chaves de API, cookies manuais e contas de token utilizam a camada de arquivo seguro; no Windows, ela usa DPAPI no escopo do usuário quando disponível. +- **Diagnósticos seguros**: os diagnósticos mostram apenas metadados de provedor, fonte e status, nunca cookies, chaves de API, bearer tokens ou valores OAuth brutos. +- **Atualizações verificadas**: downloads do instalador exigem um resumo SHA-256 do GitHub e são verificados novamente imediatamente antes da aplicação. + +## Documentação + +| Tópico | Link | +|---|---| +| Compilação a partir do código-fonte | [docs/BUILDING.md](docs/BUILDING.md) | +| Configuração do WSL e dicas de autenticação | [docs/WSL.md](docs/WSL.md) | +| Detalhes dos cookies do navegador | [docs/COOKIES.md](docs/COOKIES.md) | + +## Integrações locais + +- [AI Usage Limits](https://github.com/lenadweb/stream-deck-ai-limits) — integração com o Elgato Stream Deck que utiliza o painel/API local de `codexbar serve` para exibir métricas de provedor, conta, cota ou payload. +- [AI Monitor](https://github.com/tobymarks/esp32-ai-monitor) — monitor de mesa com ESP32 (Cheap Yellow Display) e aplicativo complementar para Windows (beta), que chama a CLI instalada (`codexbar-cli usage -p --json`) e transmite os limites de Claude, Codex, Copilot, Cursor, Gemini ou Antigravity para o monitor por uma conexão serial USB. + +## Créditos + +- Aplicativo original para macOS: [steipete/CodexBar](https://github.com/steipete/CodexBar), de Peter Steinberger +- Inspirado no [ccusage](https://github.com/ryoppippi/ccusage) para o acompanhamento de custos + +## Licença + +MIT, a mesma licença do CodexBar original. diff --git a/README.tr-TR.md b/README.tr-TR.md index 47ab7bb13b..841ac9f23c 100644 --- a/README.tr-TR.md +++ b/README.tr-TR.md @@ -1,6 +1,6 @@ # Win-CodexBar -[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md) +[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md) Win-CodexBar, bir düzine pano açmadan yapay zekâ kodlama araçlarının kullanımını görebilmenizi sağlayan bir Windows sistem tepsisi uygulamasıdır. [CodexBar](https://github.com/steipete/CodexBar) ruhunu, ortak Rust sağlayıcı mantığıyla desteklenen Tauri + React masaüstü kabuğuna taşır. @@ -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..25685118d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # Win-CodexBar -[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md) +[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md) [CodexBar](https://github.com/steipete/CodexBar) 的 Windows 移植版 —— 一个系统托盘应用,让你随时掌握各个 AI 编程工具的用量额度。 @@ -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..7fc2b438c4 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,6 +1,6 @@ # Win-CodexBar -[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md) +[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md) [CodexBar](https://github.com/steipete/CodexBar) 的 Windows 移植版 —— 一個系統系統匣應用,讓你隨時掌握各個 AI 程式設計工具的用量額度。 @@ -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..ccb9b43ce4 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 { @@ -143,6 +171,10 @@ pub struct NamedRateWindowSnapshot { /// when the provider reports no real core quota window. #[serde(default)] pub fallback_lane: bool, + /// Provider-declared tray-icon lane this window stands in for when the + /// snapshot has no real core window in that lane. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_fallback: Option, } /// Pace prediction snapshot for tray/bridge display. @@ -159,6 +191,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 +264,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 +327,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 +404,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 +412,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 +424,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 +447,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 +471,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,11 +492,14 @@ 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, + icon_fallback: extra.icon_fallback, }) .collect(), inventory: result @@ -438,6 +517,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 +572,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 +600,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 +626,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 +701,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 +795,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, @@ -659,11 +814,15 @@ pub struct SettingsSnapshot { provider_usage_thresholds: std::collections::HashMap, predictive_pace_warning_enabled: bool, + credential_expiry_notifications_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, @@ -676,8 +835,10 @@ pub struct SettingsSnapshot { auto_download_updates: bool, install_updates_on_quit: bool, global_shortcut: String, + switcher_shortcuts: std::collections::BTreeMap, codex_custom_sessions_dirs: Vec, agent_sessions_enabled: bool, + stay_awake_enabled: bool, agent_session_ssh_hosts: Vec, hooks_enabled: bool, http_proxy_enabled: bool, @@ -716,12 +877,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 +930,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, @@ -781,11 +949,16 @@ impl From for SettingsSnapshot { critical_usage_threshold: settings.critical_usage_threshold, provider_usage_thresholds: settings.provider_usage_thresholds, predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled, + credential_expiry_notifications_enabled: settings + .credential_expiry_notifications_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, @@ -797,9 +970,13 @@ impl From for SettingsSnapshot { update_channel: update_channel_label(settings.update_channel), auto_download_updates: settings.auto_download_updates, install_updates_on_quit: settings.install_updates_on_quit, + switcher_shortcuts: codexbar::switcher_shortcuts::resolve_or_default( + &settings.switcher_shortcuts, + ), global_shortcut: settings.global_shortcut, codex_custom_sessions_dirs: settings.codex_custom_sessions_dirs, agent_sessions_enabled: settings.agent_sessions_enabled, + stay_awake_enabled: settings.stay_awake_enabled, agent_session_ssh_hosts: settings.agent_session_ssh_hosts, hooks_enabled: settings.hooks_enabled, http_proxy_enabled: settings.http_proxy_enabled, @@ -842,6 +1019,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 +1054,7 @@ fn tray_icon_mode_label(mode: TrayIconMode) -> &'static str { match mode { TrayIconMode::Single => "single", TrayIconMode::PerProvider => "perProvider", + TrayIconMode::Stacked => "stacked", } } @@ -980,6 +1159,8 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + monthly_limit_block: None, + description_is_detail: false, } } @@ -1009,6 +1190,26 @@ mod tests { ); } + #[test] + fn credit_balance_detail_crosses_the_bridge_and_is_not_a_reset_phrase() { + let rw = RateWindow::with_details(25.0, None, None, Some("750 / 1000 credits left".into())) + .with_description_as_detail(); + let window = RateWindowSnapshot::from_rate_window(&rw); + + assert!(window.description_is_detail); + assert_eq!( + window.reset_description.as_deref(), + Some("750 / 1000 credits left") + ); + let json = serde_json::to_value(&window).unwrap(); + assert_eq!(json["descriptionIsDetail"], true); + assert_eq!(compact_tray_status_label(&window, Language::English), "25%"); + + let plain = RateWindowSnapshot::from_rate_window(&RateWindow::new(10.0)); + let json = serde_json::to_value(&plain).unwrap(); + assert_eq!(json["descriptionIsDetail"], false); + } + #[test] fn japanese_tray_status_label_has_no_english_reset_text() { use codexbar::settings::Language; 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/browser_import.rs b/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs index 40d8ff79a8..8ea322b7c7 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs @@ -23,7 +23,7 @@ pub fn list_detected_browsers() -> Vec { BrowserDetector::detect_all() .into_iter() .map(|b| DetectedBrowserBridge { - browser_type: browser_type_key(b.browser_type).to_string(), + browser_type: b.browser_type.key().to_string(), display_name: b.browser_type.display_name().to_string(), profile_count: b.profiles.len(), }) @@ -62,7 +62,7 @@ pub fn import_browser_cookies( let browsers = BrowserDetector::detect_all(); let browser = browsers .into_iter() - .find(|b| browser_type_key(b.browser_type) == browser_type.as_str()) + .find(|b| b.browser_type.key() == browser_type.as_str()) .ok_or_else(|| format!("Browser '{browser_type}' not found or not installed"))?; // Extract the cookie header. @@ -86,16 +86,3 @@ pub fn import_browser_cookies( Ok(get_manual_cookies()) } - -/// Map `BrowserType` to a stable lowercase string key used in the IPC bridge. -fn browser_type_key(bt: codexbar::browser::detection::BrowserType) -> &'static str { - use codexbar::browser::detection::BrowserType; - match bt { - BrowserType::Chrome => "chrome", - BrowserType::Edge => "edge", - BrowserType::Brave => "brave", - BrowserType::Arc => "arc", - BrowserType::Firefox => "firefox", - BrowserType::Chromium => "chromium", - } -} diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 4561dd9129..f40702b7e7 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, }; @@ -30,6 +31,10 @@ const LOCAL_USAGE_TTL: Duration = Duration::from_secs(30); pub struct DailyCostPoint { pub date: String, pub value: Option, + /// Claude requests that only produced a preliminary proxy usage row that + /// day (upstream 0.60.5 #3688). Omitted when zero or not applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub incomplete_request_count: Option, } /// A single (date, tokens) point for the Tokens chart mode (upstream 0.50.0 @@ -63,12 +68,26 @@ 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, pub token_cost_updated_at_ms: i64, + /// Incomplete Claude requests excluded from the selected-period totals + /// (`period_cost` / `period_tokens`). Omitted when zero or not applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub incomplete_request_count: Option, } /// One display-only quota-window history row. Completeness is tracked per @@ -186,7 +205,18 @@ fn build_provider_chart_data_with_cancel( .daily_cost .iter() .cloned() - .map(|(date, value)| DailyCostPoint { date, value }) + .map(|(date, value)| { + let incomplete_request_count = snapshot + .daily_incomplete + .iter() + .find(|(day, _)| *day == date) + .map(|(_, count)| *count); + DailyCostPoint { + date, + value, + incomplete_request_count, + } + }) .collect(); let tokens_history = snapshot .daily_tokens @@ -194,10 +224,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 +258,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, @@ -233,7 +277,11 @@ fn build_provider_chart_data_with_cancel( let raw_cost = get_daily_cost_history(&provider_id, 30); let cost_history: Vec = raw_cost .into_iter() - .map(|(date, value)| DailyCostPoint { date, value }) + .map(|(date, value)| DailyCostPoint { + date, + value, + incomplete_request_count: None, + }) .collect(); let (raw_tokens, tokens_incomplete) = get_daily_token_history(&provider_id, 30); @@ -331,20 +379,50 @@ 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(provider_id, summary); + let has_usage = summary.sessions_count > 0 + || summary.total_cost_usd > 0.0 + || thirty_tokens > 0 + || summary.incomplete_request_count > 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(provider_id, s))), + reporting_period: period.raw(), latest_tokens: None, - top_model: top_model(summary), + top_model: top_model(provider_id, summary), estimate_note: localized_estimate_note(provider_id, locale::current_language()), token_cost_updated_at_ms: current_unix_ms(), + // The note sits under the selected-period totals, so count that window. + incomplete_request_count: period_summary + .and_then(|s| non_zero_u32(s.incomplete_request_count)), }) } @@ -388,32 +466,42 @@ 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); - let has_usage = - thirty_day.sessions_count > 0 || thirty_day.total_cost_usd > 0.0 || thirty_day_tokens > 0; + let thirty_day_tokens = total_tokens(provider_id, &thirty_day); + let latest_tokens = total_tokens(provider_id, &today); + let has_usage = thirty_day.sessions_count > 0 + || thirty_day.total_cost_usd > 0.0 + || thirty_day_tokens > 0 + || thirty_day.incomplete_request_count > 0; if !has_usage { return (None, unknown_models); } @@ -424,17 +512,42 @@ 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(provider_id, s))), + reporting_period: period.raw(), latest_tokens: non_zero_u64(latest_tokens), - top_model: top_model(&thirty_day), + top_model: top_model(provider_id, &thirty_day), estimate_note: localized_estimate_note(provider_id, lang), token_cost_updated_at_ms: current_unix_ms(), + incomplete_request_count: period_summary + .as_ref() + .and_then(|s| non_zero_u32(s.incomplete_request_count)), }), 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,10 +558,17 @@ 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), token_cost_updated_at_ms: current_unix_ms(), + incomplete_request_count: None, }) } @@ -460,9 +580,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 +682,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 +705,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 +733,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 +787,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)), @@ -650,8 +800,8 @@ fn scan_local_cost( } } -fn total_tokens(summary: &CostSummary) -> u64 { - summary.input_tokens + summary.output_tokens +fn total_tokens(provider_id: &str, summary: &CostSummary) -> u64 { + summary.total_tokens_for_provider(provider_id) } fn non_zero_f64(value: f64) -> Option { @@ -662,11 +812,15 @@ fn non_zero_u64(value: u64) -> Option { (value > 0).then_some(value) } -fn top_model(summary: &CostSummary) -> Option { +fn non_zero_u32(value: u32) -> Option { + (value > 0).then_some(value) +} + +fn top_model(provider_id: &str, summary: &CostSummary) -> Option { summary .by_model_tokens .iter() - .max_by_key(|(_, counts)| counts.total()) + .max_by_key(|(_, counts)| counts.total_for_provider(provider_id)) .map(|(model, _)| model.clone()) .or_else(|| { summary @@ -712,6 +866,7 @@ fn load_openai_dashboard_chart_data( .map(|d| DailyCostPoint { date: d.day.clone(), value: Some(d.total_credits_used), + incomplete_request_count: None, }) .collect(); @@ -744,120 +899,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..3b53f45b40 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs @@ -0,0 +1,189 @@ +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, + incomplete_request_count: None, + }; + + 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(), + incomplete_request_count: None, + }; + + 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/codex_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs index eede8b8a3b..848901c28d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs @@ -5,12 +5,16 @@ use uuid::Uuid; use codexbar::codex_accounts::{ AccountStore, CodexAccount, CodexAccountApi, CodexAccountManager, CodexAccountManagerError, - CodexAccountRuntime, CodexApiError, CodexSwitchResult, SnapshotStore, display_names_by_id, - ordinals_by_id, restart_codex_desktop, + CodexAccountRuntime, CodexAccountSource, CodexApiError, CodexSwitchResult, SnapshotStore, + display_names_by_id, ordinals_by_id, restart_codex_desktop, }; +use codexbar::notifications::CredentialAlertPolicy; +use codexbar::settings::Settings; use crate::state::AppState; +use super::credential_alerts::FetchAttempt; +use super::providers::quota_notification_account_identity_for; use super::*; // ── Codex multi-account (ADR 0003, milestone 2) ────────────────────── @@ -115,10 +119,10 @@ pub(crate) async fn refresh_codex_account_lanes( return; } - let mut handles = Vec::with_capacity(accounts.len()); + let mut handles = tokio::task::JoinSet::new(); for account in accounts { let permits = Arc::clone(&fetch_permits); - handles.push(tokio::spawn(async move { + handles.spawn(async move { let Ok(_permit) = permits.acquire_owned().await else { return None; }; @@ -126,7 +130,7 @@ pub(crate) async fn refresh_codex_account_lanes( let home_path = account.codex_home_path.clone(); let email_hint = account.email_hint.clone(); let workspace_account_id = account.effective_workspace_account_id(); - match tokio::time::timeout( + let result = tokio::time::timeout( std::time::Duration::from_secs(DEFAULT_FETCH_TIMEOUT_SECONDS), api.fetch_snapshot_for_workspace( &home_path, @@ -135,36 +139,73 @@ pub(crate) async fn refresh_codex_account_lanes( true, ), ) - .await - { - Ok(Ok(snapshot)) => Some((account, snapshot)), - Ok(Err(e)) => { - tracing::debug!( - "codex account lane {} failed: {}", - account.id, - into_api_message(e) - ); - None + .await; + match result { + Ok(result) => { + if let Err(error) = &result { + tracing::debug!("codex account lane {} failed: {}", account.id, error); + } + Some((account, result)) } Err(_) => { tracing::debug!("codex account lane {} timed out", account.id); None } } - })); + }); } - let mut updates = Vec::new(); - for handle in handles { - if let Ok(Some((fetched_account, snapshot))) = handle.await { - updates.push((fetched_account, snapshot)); + let mut outcomes = Vec::new(); + while let Some(result) = handles.join_next().await { + if let Ok(Some(outcome)) = result { + outcomes.push(outcome); } } + + let current_accounts = match load_codex_accounts() { + Ok(accounts) => accounts, + Err(e) => { + tracing::warn!("codex account lanes: failed to reload accounts: {e}"); + return; + } + }; // Hold the generation owner through the read/merge/write so an invalidated // batch cannot overwrite a replacement batch's account snapshots. let state = app.state::>(); - let Ok(state) = state.lock() else { return }; - match save_codex_lane_results(&state, generation, updates) { + let Ok(mut state) = state.lock() else { return }; + if !is_current_provider_refresh_generation(&state, generation) { + return; + } + let current_by_id: HashMap = current_accounts + .iter() + .map(|account| (account.id, account)) + .collect(); + let mut updates = Vec::new(); + for (fetched_account, result) in outcomes { + let Some(current) = current_by_id.get(&fetched_account.id).copied() else { + continue; + }; + if !account_lane_is_current(&fetched_account, current) { + continue; + } + match result { + Ok(snapshot) if account_snapshot_belongs_to(current, &snapshot) => { + observe_codex_account_lane_outcome( + &mut state.notification_manager, + &fetched_account, + FetchAttempt::Succeeded, + ); + updates.push((fetched_account, snapshot)); + } + Ok(_) => {} + Err(error) => observe_codex_account_lane_outcome( + &mut state.notification_manager, + &fetched_account, + FetchAttempt::Failed(error.state_kind()), + ), + } + } + match save_codex_lane_results(&state, generation, updates, ¤t_accounts) { Ok(false) => return, Err(e) => tracing::warn!("codex account lanes: failed to persist snapshots: {e}"), Ok(true) => {} @@ -172,20 +213,48 @@ pub(crate) async fn refresh_codex_account_lanes( events::emit_codex_accounts_updated(&app); } +fn observe_codex_account_lane_outcome( + manager: &mut codexbar::notifications::NotificationManager, + account: &CodexAccount, + attempt: FetchAttempt, +) { + if account.source != CodexAccountSource::ManagedByApp { + return; + } + + let account_scope = quota_notification_account_identity_for( + ProviderId::Codex, + "", + account.email_hint.as_deref(), + None, + Some(account.id), + ); + match attempt { + FetchAttempt::Succeeded => { + manager.observe_credential_recovery(ProviderId::Codex, &account_scope); + } + FetchAttempt::Failed(kind) if kind.needs_sign_in() => { + let policy = CredentialAlertPolicy::from_settings(&Settings::load()); + manager.observe_credential_failure(policy, ProviderId::Codex, &account_scope, kind); + } + FetchAttempt::Failed(_) => {} + } +} + fn save_codex_lane_results( state: &AppState, generation: u64, updates: Vec<(CodexAccount, codexbar::codex_accounts::AccountUsageSnapshot)>, + current_accounts: &[CodexAccount], ) -> Result { if !is_current_provider_refresh_generation(state, generation) { return Ok(false); } - let current_accounts = load_codex_accounts().map_err(std::io::Error::other)?; let current_by_id: HashMap = current_accounts .iter() .map(|account| (account.id, account)) .collect(); - let mut snapshots = snapshots_for_accounts(¤t_accounts, SnapshotStore::new().load()?); + let mut snapshots = snapshots_for_accounts(current_accounts, SnapshotStore::new().load()?); for (fetched_account, snapshot) in updates { if current_by_id .get(&fetched_account.id) @@ -511,7 +580,10 @@ fn into_user_message(error: CodexAccountManagerError) -> String { fn into_api_message(error: CodexApiError) -> String { match error { - CodexApiError::Message(msg) => msg, + CodexApiError::AuthenticationRequired(msg) + | CodexApiError::SessionExpired(msg) + | CodexApiError::PermissionDenied(msg) + | CodexApiError::Message(msg) => msg, CodexApiError::Network(e) => format!("network error: {e}"), CodexApiError::Parse(e) => format!("failed to parse Codex payload: {e}"), } @@ -650,7 +722,8 @@ mod tests { save_codex_lane_results( &state, state.provider_refresh_generation, - vec![(account.clone(), snapshot.clone())] + vec![(account.clone(), snapshot.clone())], + &[account.clone()] ) .unwrap() ); @@ -659,7 +732,15 @@ mod tests { email: Some("old@example.com".into()), ..snapshot }; - assert!(!save_codex_lane_results(&state, old_generation, vec![(account, stale)]).unwrap()); + assert!( + !save_codex_lane_results( + &state, + old_generation, + vec![(account.clone(), stale)], + &[account] + ) + .unwrap() + ); assert_eq!( std::fs::read(file_locations::snapshots_file()).unwrap(), before diff --git a/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs b/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs new file mode 100644 index 0000000000..bf8637f615 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs @@ -0,0 +1,126 @@ +//! Bridges provider refresh outcomes to the core credential-expiry episode +//! tracker (`codexbar::notifications`). Classification stays typed: the +//! failure kind is captured from the fresh fetch before any last-good +//! fallback replaces the published snapshot, so cached data can neither end +//! nor restart an episode. + +use codexbar::core::{ProviderId, ProviderStateKind}; +use codexbar::notifications::{CredentialAlertPolicy, NotificationManager}; + +use super::bridge::ProviderUsageSnapshot; +use super::providers::quota_notification_account_identity; + +/// Result of the fresh fetch, before last-good preservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FetchAttempt { + Succeeded, + Failed(ProviderStateKind), +} + +impl FetchAttempt { + pub(super) fn of(fresh: &ProviderUsageSnapshot) -> Self { + if fresh.error.is_none() { + Self::Succeeded + } else { + Self::Failed(fresh.error_state) + } + } +} + +/// Report one accepted refresh outcome. `published` is the snapshot that +/// replaced the cache; `last_good` is the previous error-free snapshot, the +/// only source of account identity when the fetch itself failed. +pub(super) fn observe_attempt( + manager: &mut NotificationManager, + policy: Option, + id: ProviderId, + token_account_id: Option, + attempt: FetchAttempt, + published: &ProviderUsageSnapshot, + last_good: Option<&ProviderUsageSnapshot>, +) { + match attempt { + FetchAttempt::Succeeded => { + let account = quota_notification_account_identity(published, token_account_id); + manager.observe_credential_recovery(id, &account); + } + FetchAttempt::Failed(kind) => { + if let Some(policy) = policy { + let account = quota_notification_account_identity( + last_good.unwrap_or(published), + token_account_id, + ); + manager.observe_credential_failure(policy, id, &account, kind); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codexbar::core::{ProviderError, instantiate_provider}; + use codexbar::settings::Settings; + + fn failed(kind: ProviderStateKind) -> ProviderUsageSnapshot { + ProviderUsageSnapshot::from_error( + ProviderId::Codex, + instantiate_provider(ProviderId::Codex).metadata(), + "Authentication required".to_string(), + kind, + ) + } + + fn succeeded() -> ProviderUsageSnapshot { + let mut snapshot = failed(ProviderStateKind::Ready); + snapshot.error = None; + snapshot.account_email = Some("person@example.test".to_string()); + snapshot + } + + #[test] + fn attempt_reads_the_fresh_error_state() { + assert_eq!( + FetchAttempt::of(&failed(ProviderStateKind::ExpiredSession)), + FetchAttempt::Failed(ProviderStateKind::ExpiredSession) + ); + assert_eq!(FetchAttempt::of(&succeeded()), FetchAttempt::Succeeded); + } + + #[test] + fn typed_state_matches_provider_error_classification() { + let kind = ProviderError::AuthRequired.state_kind(); + assert_eq!( + FetchAttempt::of(&failed(kind)), + FetchAttempt::Failed(ProviderStateKind::NeedsAuthentication) + ); + } + + #[test] + fn observing_outcomes_with_alerts_off_never_delivers() { + // Default settings leave credential alerts off, so no toast can fire. + let policy = CredentialAlertPolicy::from_settings(&Settings::default()); + let mut manager = NotificationManager::new(); + let good = succeeded(); + let failure = failed(ProviderStateKind::NeedsAuthentication); + + observe_attempt( + &mut manager, + Some(policy), + ProviderId::Codex, + None, + FetchAttempt::of(&failure), + &failure, + Some(&good), + ); + observe_attempt( + &mut manager, + Some(policy), + ProviderId::Codex, + None, + FetchAttempt::Succeeded, + &good, + None, + ); + } +} 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/locale_cmd.rs b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs index a71224de8f..81c4d632e3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs @@ -164,6 +164,17 @@ mod locale_tests { assert_eq!(bundle.entries.len(), locale::LocaleKey::ALL.len()); } + #[test] + fn locale_strings_roundtrip_brazilian_portuguese() { + let bundle = locale_strings_for(Language::PortugueseBrazil); + assert_eq!(bundle.language, "portuguesebrazil"); + assert_eq!( + bundle.entries.get("TabGeneral").map(String::as_str), + Some("Geral") + ); + assert_eq!(bundle.entries.len(), locale::LocaleKey::ALL.len()); + } + #[test] fn locale_strings_contains_every_variant() { let bundle = locale_strings_for(Language::English); @@ -190,6 +201,7 @@ mod locale_tests { "japanese", "korean", "spanish", + "portuguesebrazil", "russian", "turkish" ] @@ -203,6 +215,7 @@ mod locale_tests { "日本語", "한국어", "Español", + "Português (Brasil)", "Русский", "Türkçe" ] @@ -287,6 +300,14 @@ mod locale_tests { parse_locale_language("español"), Some(Language::Spanish) )); + assert!(matches!( + parse_locale_language("pt-BR"), + Some(Language::PortugueseBrazil) + )); + assert!(matches!( + parse_locale_language("Português (Brasil)"), + Some(Language::PortugueseBrazil) + )); assert!(matches!( parse_locale_language("tr-TR"), Some(Language::Turkish) diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index fdfaeda967..f8d6033e81 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; @@ -36,11 +37,13 @@ mod claude_accounts; pub(crate) mod claude_reconciliation; mod codex_accounts; mod codex_workspaces; +mod credential_alerts; mod credential_detection; mod credentials; mod diagnostics; mod grok_accounts; mod locale_cmd; +mod preferences_transfer; mod provider_detail; mod provider_refresh; mod provider_settings; @@ -63,6 +66,7 @@ pub use credentials::*; pub use diagnostics::*; pub use grok_accounts::*; pub use locale_cmd::*; +pub use preferences_transfer::*; pub use provider_detail::*; #[cfg(test)] pub(crate) use provider_refresh::is_provider_cache_fresh; @@ -75,10 +79,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 +116,29 @@ 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(), + ), + // The llmman dashboard is the configured daemon itself. + ProviderId::LLMMan => Some(codexbar::providers::llmman::dashboard_url(Some( + settings.workspace_id(id), + ))), + _ => 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/preferences_transfer.rs b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs new file mode 100644 index 0000000000..cf1c66baf7 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs @@ -0,0 +1,109 @@ +//! Export and import of the portable preferences document. +//! +//! The document format, allowlist and validation live in +//! `codexbar::settings::PreferencesDocument`; this module only adds the +//! desktop side effects that a settings save normally triggers. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::Mutex; + +use codexbar::core::ProviderId; +use codexbar::settings::{PreferencesDocument, Settings}; +use tauri::{Emitter, Manager}; + +use super::{SettingsSnapshot, language_label}; +use crate::events; +use crate::state::AppState; + +fn checked_path(path: &str) -> Result<&Path, String> { + let path = path.trim(); + if path.is_empty() { + return Err("Preferences path must not be empty".to_string()); + } + Ok(Path::new(path)) +} + +fn enabled_provider_set_changed(before: &[ProviderId], after: &[ProviderId]) -> bool { + before.iter().copied().collect::>() != after.iter().copied().collect() +} + +/// Write the current portable preferences to `path`; returns how many were written. +#[tauri::command] +pub fn export_preferences(path: String) -> Result { + let path = checked_path(&path)?; + let document = + PreferencesDocument::from_settings(&Settings::load()).map_err(|error| error.to_string())?; + document + .write_file(path) + .map_err(|error| error.to_string())?; + tracing::info!(count = document.len(), "exported portable preferences"); + Ok(document.len()) +} + +/// Apply the preferences file at `path` to this machine's settings. +/// +/// Nothing is saved when the file is rejected. On success the same live +/// updates as `update_settings` follow: locale, float bar, tray and dependent +/// windows, a local cost cache reset when the reporting period changed, plus a +/// provider cache prune and refresh when the enabled set changed. +#[tauri::command] +pub async fn import_preferences( + app: tauri::AppHandle, + path: String, +) -> Result { + let path = checked_path(&path)?; + let document = PreferencesDocument::read_file(path).map_err(|error| error.to_string())?; + let mut settings = Settings::load(); + let previous_language = settings.ui_language; + let previous_period = settings.cost_reporting_period; + let previous_enabled = settings.get_enabled_provider_ids(); + let applied = document + .apply_to(&mut settings) + .map_err(|error| error.to_string())?; + settings.save().map_err(|error| error.to_string())?; + tracing::info!(applied, "imported portable preferences"); + if settings.cost_reporting_period != previous_period { + // Cached local cost summaries cover the old period's window. + super::clear_provider_local_usage_cache(); + } + + if settings.ui_language != previous_language { + let _ = app.emit(events::LOCALE_CHANGED, language_label(settings.ui_language)); + } + let enabled_ids = settings.get_enabled_provider_ids(); + let providers_changed = enabled_provider_set_changed(&previous_enabled, &enabled_ids); + if providers_changed { + let state = app.state::>(); + let _ = super::invalidate_provider_refresh_and_prune_disabled(&state, &enabled_ids); + } + crate::floatbar::notify_settings_changed(&app); + crate::floatbar::apply_state(&app, &settings); + crate::tray_bridge::rebuild_tray_menu(&app); + crate::tray_bridge::refresh_tray_presentation(&app); + events::emit_settings_changed(&app); + + if providers_changed { + let refresh_app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = super::do_refresh_providers(&refresh_app).await; + }); + } + Ok(SettingsSnapshot::from(settings)) +} + +#[cfg(test)] +mod tests { + use super::enabled_provider_set_changed; + use codexbar::core::ProviderId; + + #[test] + fn refresh_decision_tracks_provider_membership() { + let before = [ProviderId::Claude, ProviderId::Codex]; + assert!(!enabled_provider_set_changed( + &before, + &[ProviderId::Codex, ProviderId::Claude] + )); + assert!(enabled_provider_set_changed(&before, &[ProviderId::Claude])); + } +} 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..9a0eeb8da0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -11,6 +11,9 @@ pub struct ProviderDetail { pub enabled: bool, pub auto_resume_after_quota_reset: bool, pub auto_resume_supported: bool, + /// LiteLLM/Claude expose one opt-in extra breakdown; other providers do not. + pub optional_details_supported: bool, + pub optional_details_enabled: bool, // Identity pub email: Option, @@ -23,11 +26,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 +53,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, @@ -57,6 +76,31 @@ pub struct ProviderDetail { pub usage_source: Option, pub cookie_source: Option, pub region: Option, + /// True when the Manual cookie source has no usable header and the + /// provider opted in to failing closed (`manual_empty_cookie_policy`). + /// Drives the "No cookie header pasted." hint and the switch-to-Auto action. + pub manual_cookie_missing: bool, +} + +/// Same decision the refresh path makes in `build_fetch_context`, gated on the +/// provider's fail-closed policy so opening the pane never triggers a browser +/// cookie import. +fn manual_cookie_missing_for(id: ProviderId, settings: &Settings) -> bool { + if settings.cookie_source(id) != "manual" + || instantiate_provider(id).manual_empty_cookie_policy() + != ManualEmptyCookiePolicy::FailClosedWeb + { + return false; + } + let token_accounts = TokenAccountStore::new().load().unwrap_or_default(); + build_fetch_context( + id, + settings, + &ManualCookies::load(), + &ApiKeys::load(), + &token_accounts, + ) + .manual_cookie_missing } pub(crate) fn build_provider_detail( @@ -73,15 +117,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(), @@ -89,6 +125,8 @@ pub(crate) fn build_provider_detail( enabled, auto_resume_after_quota_reset: settings.auto_resume_after_quota_reset(id), auto_resume_supported: resume_supported, + optional_details_supported: codexbar::settings::provider_has_optional_details(id), + optional_details_enabled: settings.optional_details_enabled(id), email: None, plan: None, auth_type: None, @@ -97,9 +135,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 +149,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(), @@ -122,6 +165,7 @@ pub(crate) fn build_provider_detail( usage_source: provider_usage_source_lookup(&settings, id.cli_name()), cookie_source: provider_cookie_source_lookup(&settings, id.cli_name()), region: provider_region_lookup(&settings, id.cli_name()), + manual_cookie_missing: manual_cookie_missing_for(id, &settings), }; Ok((detail, settings, id)) @@ -154,6 +198,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 +217,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..e3d0a5bc71 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -132,6 +132,27 @@ pub fn set_provider_auto_resume_after_quota_reset( Ok(()) } +/// Persist the opt-in for a provider's optional extra breakdown (LiteLLM model +/// activity, Claude workspace spend). Takes effect on the next refresh. +#[tauri::command] +pub fn set_provider_optional_details( + app: tauri::AppHandle, + provider_id: String, + enabled: bool, +) -> Result<(), String> { + let id = parse_provider_arg(&provider_id)?; + if !codexbar::settings::provider_has_optional_details(id) { + return Err(format!( + "Provider '{provider_id}' has no optional detail breakdown" + )); + } + let mut settings = Settings::load(); + settings.set_optional_details_enabled(id, enabled); + settings.save().map_err(|e| e.to_string())?; + crate::events::emit_settings_changed(&app); + Ok(()) +} + // ── OpenRouter Management API key ──────────────────────────────────── #[tauri::command] @@ -223,9 +244,12 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::Sakana, "notion" => ProviderId::Notion, "grok" => ProviderId::Grok, + "muse" => ProviderId::Muse, "replicate" => ProviderId::Replicate, + "raycast" => ProviderId::Raycast, "helmcode" => ProviderId::Helmcode, "typesafe" => ProviderId::TypeSafe, + "hyper" => ProviderId::Hyper, _ => return None, }) } @@ -272,6 +296,7 @@ fn region_provider(provider_id: &str) -> Option { "alibabatokenplan" => ProviderId::AlibabaTokenPlan, "zai" => ProviderId::Zai, "minimax" => ProviderId::MiniMax, + "kimi" => ProviderId::Kimi, _ => return None, }) } @@ -284,6 +309,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() } @@ -323,12 +352,16 @@ fn workspace_provider(provider_id: &str) -> Option { Some(match provider_id { "openaiapi" => ProviderId::OpenAIApi, "litellm" => ProviderId::LiteLLM, + "llmman" => ProviderId::LLMMan, "devin" => ProviderId::Devin, "opencodego" => ProviderId::OpenCodeGo, "zed" => ProviderId::Zed, + "llmproxy" => ProviderId::LLMProxy, "xai" => ProviderId::Xai, "v0" => ProviderId::V0, "helmcode" => ProviderId::Helmcode, + "gitkraken" => ProviderId::GitKraken, + "muse" => ProviderId::Muse, _ => return None, }) } @@ -384,7 +417,22 @@ 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 muse_exposes_cookie_source_and_browser_team_settings() { + assert_eq!(workspace_provider("muse"), Some(ProviderId::Muse)); + assert_eq!( + super::cookie_source_provider("muse"), + Some(ProviderId::Muse) + ); + let values: Vec = + super::cookie_source_options_for("muse", codexbar::settings::Language::English) + .into_iter() + .map(|option| option.value) + .collect(); + assert_eq!(values, ["auto", "manual", "off"]); + } #[test] fn maps_opencode_go_workspace_provider() { @@ -394,6 +442,56 @@ 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 maps_llmman_workspace_provider() { + assert_eq!(workspace_provider("llmman"), Some(ProviderId::LLMMan)); + } + #[test] fn litellm_endpoint_change_requires_reentering_saved_key() { assert!( @@ -445,7 +543,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 +563,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()); @@ -733,6 +856,29 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option( + lang, + "auto", + "Reads the selected team's quota with the signed-in dev.meta.ai browser session.", + "", + None, + ), + cookie_option( + lang, + "manual", + "", + "Paste the llama_dev_sess cookie from dev.meta.ai. Nothing is read until you paste one.", + None, + ), + cookie_option( + lang, + "off", + "", + "", + Some("Browser sessions are never read for Muse Code."), + ), + ], "replicate" => vec![ cookie_option( lang, @@ -749,6 +895,32 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option( + lang, + "auto", + locale::get_text(lang, locale::LocaleKey::ProviderRaycastAutoImportHelp), + "", + None, + ), + cookie_option( + lang, + "manual", + "", + locale::get_text(lang, locale::LocaleKey::ProviderRaycastManualCookieHelp), + None, + ), + cookie_option( + lang, + "off", + "", + "", + Some( + locale::get_text(lang, locale::LocaleKey::ProviderRaycastCookiesDisabled) + .as_str(), + ), + ), + ], "helmcode" => vec![ cookie_option( lang, @@ -781,6 +953,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 +1017,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..2b6abaf22a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1,3 +1,4 @@ +use super::credential_alerts::{self, FetchAttempt}; use super::provider_refresh::{ ProviderRefreshCompletion, ProviderRefreshReservation, complete_provider_refresh, reserve_provider_refresh, @@ -6,9 +7,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 +43,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 +88,64 @@ 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 + // Providers whose cookies only enrich an API result keep the configured + // usage source. Off and the default manual-without-cookie state never read + // a browser; only Automatic imports one (`browser_cookie_import`). + let cookies_only_enrich_usage = provider.cookies_only_enrich_usage(); + let browser_cookie_import = + cookies_only_enrich_usage && matches!(cookie_source, "auto" | "browser" | "web"); + 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 +159,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,8 +191,10 @@ 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 fails_closed_without_cookie = cookie_header.is_none() + let cookie_header = active_token_cookie.clone().or(stored_cookie); + let fails_closed_without_cookie = cookie_header + .as_deref() + .is_none_or(|header| header.trim().is_empty()) && provider.manual_empty_cookie_policy() == ManualEmptyCookiePolicy::FailClosedWeb; let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key) @@ -159,28 +225,144 @@ 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 if cookies_only_enrich_usage { + let cookie_header = (cookie_source == "manual") + .then(|| active_token_cookie.clone().or(stored_cookie)) + .flatten(); + (usage_source, cookie_header, 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 + .as_deref() + .is_none_or(|header| header.trim().is_empty()) + && 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) or only enriches an + // API result (Muse browser team quota) 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() + && !cookies_only_enrich_usage + { if cookie_header .as_deref() .map(str::trim) @@ -213,22 +395,62 @@ 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)), + browser_cookie_import, + optional_details_enabled: settings.optional_details_enabled(id), ..FetchContext::default() } } @@ -279,11 +501,13 @@ pub(crate) fn provider_cookie_domain(id: ProviderId, settings: &Settings) -> Opt const DEFAULT_PROVIDER_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(35); const SLOW_PROVIDER_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75); +const OPTIONAL_LITELLM_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(40); const MAX_CONTEXT_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(65); pub(crate) fn provider_fetch_timeout(id: ProviderId, ctx: &FetchContext) -> std::time::Duration { let provider_timeout = match id { ProviderId::Claude | ProviderId::Codex | ProviderId::Copilot => SLOW_PROVIDER_FETCH_TIMEOUT, + ProviderId::LiteLLM if ctx.optional_details_enabled => OPTIONAL_LITELLM_FETCH_TIMEOUT, _ => DEFAULT_PROVIDER_FETCH_TIMEOUT, }; let context_timeout = std::time::Duration::from_secs(ctx.web_timeout.saturating_add(5)); @@ -334,6 +558,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)); @@ -386,6 +613,11 @@ async fn do_refresh_providers_with_policy( let settings = Settings::load(); let enabled_ids = settings.get_enabled_provider_ids(); let refresh_ids = scope.provider_ids(&settings, &enabled_ids); + if let Ok(mut guard) = state.lock() { + guard + .notification_manager + .retire_credential_episodes_except(&enabled_ids); + } if refresh_ids.is_empty() { return Ok(ProviderRefreshOutcome::Skipped { reason: ProviderRefreshSkipReason::NoEnabledProviders, @@ -565,9 +797,11 @@ 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(); + // Captured before last-good preservation can swap in a cached snapshot. + let fetch_attempt = FetchAttempt::of(&snapshot); let state = app.state::>(); let published = if let Ok(mut guard) = state.lock() { @@ -584,8 +818,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,13 +832,30 @@ 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 .provider_cache_updated_at_by_provider .insert(id, std::time::Instant::now()); } + // Read consent after the fetch completes, so a toggle change made + // while the request was in flight takes effect for this outcome. + let credential_alerts = match fetch_attempt { + FetchAttempt::Failed(kind) if kind.needs_sign_in() => Some( + codexbar::notifications::CredentialAlertPolicy::from_settings(&Settings::load()), + ), + _ => None, + }; + credential_alerts::observe_attempt( + &mut guard.notification_manager, + credential_alerts, + id, + token_account_id, + fetch_attempt, + &snapshot, + cached.as_ref(), + ); Some(snapshot) } } else { @@ -678,82 +933,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 +941,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 +972,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 +984,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 +1050,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 +1085,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 +1101,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 +1119,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 +1204,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 +1237,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 +1272,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, @@ -1053,24 +1329,40 @@ fn dispatch_quota_hooks( /// Stable account discriminator for threshold/session toast dedupe. /// Prefer token-account id, then email, org, plan; empty for single-account lanes. -fn quota_notification_account_identity( +pub(super) fn quota_notification_account_identity( snapshot: &ProviderUsageSnapshot, token_account_id: Option, ) -> String { ProviderId::from_cli_name(&snapshot.provider_id) .map(|provider| { - WarningIdentity::new( + quota_notification_account_identity_for( provider, &snapshot.source_label, snapshot.account_email.as_deref(), snapshot.account_organization.as_deref(), token_account_id, ) - .threshold_key() }) .unwrap_or_default() } +pub(super) fn quota_notification_account_identity_for( + provider: ProviderId, + source_label: &str, + account_email: Option<&str>, + account_organization: Option<&str>, + token_account_id: Option, +) -> String { + WarningIdentity::new( + provider, + source_label, + account_email, + account_organization, + token_account_id, + ) + .threshold_key() +} + fn notify_predictive_pace( manager: &mut codexbar::notifications::NotificationManager, provider: ProviderId, @@ -1301,12 +1593,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 +1610,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 +1642,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 +1677,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 +1687,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 +1702,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..141c06e931 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, @@ -24,11 +25,15 @@ pub struct SettingsUpdate { pub provider_usage_thresholds: Option>, pub predictive_pace_warning_enabled: Option, + pub credential_expiry_notifications_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, @@ -41,8 +46,11 @@ pub struct SettingsUpdate { pub auto_download_updates: Option, pub install_updates_on_quit: Option, pub global_shortcut: Option, + /// Provider-switcher shortcut overrides; replaces the stored overrides. + pub switcher_shortcuts: Option>, pub codex_custom_sessions_dirs: Option>, pub agent_sessions_enabled: Option, + pub stay_awake_enabled: Option, pub agent_session_ssh_hosts: Option>, pub hooks_enabled: Option, pub http_proxy_enabled: Option, @@ -85,6 +93,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 +113,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 +132,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 +183,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 +213,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 +229,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 +294,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; } @@ -298,6 +337,9 @@ impl SettingsUpdate { if let Some(v) = self.predictive_pace_warning_enabled { settings.predictive_pace_warning_enabled = v; } + if let Some(v) = self.credential_expiry_notifications_enabled { + settings.credential_expiry_notifications_enabled = v; + } if let Some(v) = self.show_pace { settings.show_pace = v; } @@ -327,6 +369,9 @@ impl SettingsUpdate { if let Some(v) = self.agent_sessions_enabled { settings.agent_sessions_enabled = v; } + if let Some(v) = self.stay_awake_enabled { + settings.stay_awake_enabled = v; + } if let Some(v) = self.agent_session_ssh_hosts.clone() { settings.agent_session_ssh_hosts = codexbar::agent_sessions::RemoteSessionFetcher::sanitized_hosts(&v); @@ -417,6 +462,16 @@ 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(overrides) = &self.switcher_shortcuts { + settings.switcher_shortcuts = + codexbar::switcher_shortcuts::normalize_overrides(overrides) + .map_err(|error| error.to_string())?; + } if let Some(value) = self.copilot_seat_credit_entitlement { settings.set_seat_credit_entitlement(codexbar::core::ProviderId::Copilot, value)?; } @@ -482,10 +537,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,9 +567,11 @@ 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 stay_awake_changed = patch.stay_awake_enabled.is_some(); let tray_promotion_changed = patch.changes_tray_promotion(); let tray_panel_always_on_top_changed = patch.tray_panel_always_on_top.is_some(); let previous_promoted = settings.promote_tray_icon; @@ -536,6 +599,9 @@ pub async fn update_settings( } crate::floatbar::after_settings_saved(&app, &float_bar_patch, &settings, notify_float_bar); + if stay_awake_changed { + crate::stay_awake::settings_changed(&app, settings.stay_awake_enabled); + } if rebuild_tray_menu { crate::tray_bridge::rebuild_tray_menu(&app); } @@ -572,6 +638,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 +769,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 +806,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] @@ -806,4 +969,87 @@ mod tests { codexbar::settings::NotificationSoundPaths::default() ); } + + fn switcher_patch(json: &str) -> SettingsUpdate { + serde_json::from_str(&format!(r#"{{"switcherShortcuts":{json}}}"#)).unwrap() + } + + #[test] + fn switcher_shortcuts_patch_stores_normalized_non_default_overrides() { + let mut settings = Settings::default(); + switcher_patch(r#"{"select2":"Alt+Cmd+2","previous":"left","next":"none"}"#) + .apply_to(&mut settings) + .expect("valid overrides are stored"); + + assert_eq!( + settings.switcher_shortcuts, + std::collections::BTreeMap::from([ + ("select2".to_string(), "ctrl+alt+2".to_string()), + ("next".to_string(), "none".to_string()), + ]) + ); + } + + #[test] + fn switcher_shortcuts_empty_patch_restores_defaults() { + let mut settings = Settings::default(); + switcher_patch(r#"{"next":"shift+right"}"#) + .apply_to(&mut settings) + .unwrap(); + switcher_patch("{}").apply_to(&mut settings).unwrap(); + + assert!(settings.switcher_shortcuts.is_empty()); + } + + #[test] + fn switcher_shortcuts_patch_rejects_invalid_maps_and_keeps_stored_value() { + let mut settings = Settings::default(); + switcher_patch(r#"{"next":"shift+right"}"#) + .apply_to(&mut settings) + .unwrap(); + + for (json, message) in [ + ( + r#"{"bogus":"ctrl+1"}"#, + "Unknown switcher shortcut action: bogus", + ), + ( + r#"{"next":"left"}"#, + "Each switcher shortcut can be assigned to only one action", + ), + ( + r#"{"next":"ctrl+r"}"#, + "ctrl+r is reserved and cannot be used as a switcher shortcut", + ), + (r#"{"next":"f1"}"#, "f1 is not a valid switcher shortcut"), + ] { + let error = switcher_patch(json) + .apply_to(&mut settings) + .expect_err(json); + assert_eq!(error, message); + } + assert_eq!( + settings.switcher_shortcuts.get("next").map(String::as_str), + Some("shift+right") + ); + } + + #[test] + fn switcher_shortcuts_snapshot_exposes_the_fully_resolved_map() { + let settings = Settings { + switcher_shortcuts: std::collections::BTreeMap::from([( + "next".to_string(), + "none".to_string(), + )]), + ..Settings::default() + }; + let value = + serde_json::to_value(super::super::bridge::SettingsSnapshot::from(settings)).unwrap(); + let map = &value["switcherShortcuts"]; + + assert_eq!(map["next"], "none"); + assert_eq!(map["previous"], "left"); + assert_eq!(map["select9"], "ctrl+9"); + assert_eq!(map.as_object().unwrap().len(), 11); + } } 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..5ee348c07d 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(); @@ -305,6 +319,20 @@ fn replicate_cookie_source_and_domain_are_exposed() { ); } +#[test] +fn raycast_cookie_source_and_domain_are_exposed() { + let mut settings = Settings::default(); + super::provider_cookie_source_set(&mut settings, "raycast", "manual".to_string()).unwrap(); + assert_eq!( + provider_cookie_source_lookup(&settings, "raycast").as_deref(), + Some("manual") + ); + assert_eq!( + super::provider_cookie_domain(ProviderId::Raycast, &settings), + Some("www.raycast.com") + ); +} + #[test] fn provider_cookie_source_set_rejects_unknown_provider() { let mut s = Settings::default(); @@ -331,6 +359,24 @@ fn fetch_context_defaults_to_manual_cookies_without_browser_import() { assert_eq!(ctx.source_mode, SourceMode::Web); } +#[test] +fn fetch_context_carries_the_optional_details_opt_in_for_its_own_provider() { + let mut settings = Settings::default(); + settings.set_optional_details_enabled(ProviderId::LiteLLM, true); + let build = |id| { + super::build_fetch_context( + id, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ) + }; + + assert!(build(ProviderId::LiteLLM).optional_details_enabled); + assert!(!build(ProviderId::Codex).optional_details_enabled); +} + #[test] fn fetch_context_cursor_cookie_off_stays_cli() { let mut settings = Settings::default(); @@ -464,6 +510,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 +577,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(); @@ -498,6 +726,115 @@ fn fetch_context_replicate_empty_manual_fails_closed_without_browser_import() { assert!(ctx.manual_cookie_missing); } +#[test] +fn fetch_context_raycast_cookie_sources_never_import_in_the_shell() { + let build = |source: &str, stored: Option<&str>| { + let mut settings = Settings::default(); + settings.set_cookie_source(ProviderId::Raycast, source); + let mut cookies = ManualCookies::default(); + if let Some(stored) = stored { + cookies.set(ProviderId::Raycast.cli_name(), stored); + } + super::build_fetch_context( + ProviderId::Raycast, + &settings, + &cookies, + &ApiKeys::default(), + &HashMap::new(), + ) + }; + + // Manual with no stored header fails closed instead of using a browser. + let empty_manual = build("manual", None); + assert_eq!(empty_manual.source_mode, SourceMode::Web); + assert!(empty_manual.manual_cookie_header.is_none()); + assert!(empty_manual.manual_cookie_missing); + + // Off maps to the source the provider refuses, and never carries a header. + let off = build("off", Some("__raycast_session=stored")); + assert_eq!(off.source_mode, SourceMode::Cli); + assert!(off.manual_cookie_header.is_none()); + + // Auto leaves browser resolution to the provider (Chrome only). + let auto = build("auto", None); + assert_eq!(auto.source_mode, SourceMode::Auto); + assert!(auto.manual_cookie_header.is_none()); + assert!(!auto.manual_cookie_missing); + + let manual = build("manual", Some("__raycast_session=stored")); + assert_eq!(manual.source_mode, SourceMode::Web); + assert_eq!( + manual.manual_cookie_header.as_deref(), + Some("__raycast_session=stored") + ); +} + +#[test] +fn fetch_context_ollama_empty_manual_fails_closed_without_browser_import() { + let settings = Settings::default(); + let ctx = super::build_fetch_context( + ProviderId::Ollama, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); +} + +#[test] +fn fetch_context_ollama_blank_manual_header_counts_as_missing() { + let mut cookies = ManualCookies::default(); + cookies.set(ProviderId::Ollama.cli_name(), " "); + let ctx = super::build_fetch_context( + ProviderId::Ollama, + &Settings::default(), + &cookies, + &ApiKeys::default(), + &HashMap::new(), + ); + + assert!(ctx.manual_cookie_missing); +} + +#[test] +fn fetch_context_ollama_pasted_header_is_not_reported_missing() { + let mut cookies = ManualCookies::default(); + cookies.set(ProviderId::Ollama.cli_name(), "__Secure-session=abc"); + let ctx = super::build_fetch_context( + ProviderId::Ollama, + &Settings::default(), + &cookies, + &ApiKeys::default(), + &HashMap::new(), + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("__Secure-session=abc") + ); + assert!(!ctx.manual_cookie_missing); +} + +#[test] +fn fetch_context_ollama_auto_source_never_reports_manual_cookie_missing() { + let mut settings = Settings::default(); + settings.set_cookie_source(ProviderId::Ollama, "auto"); + let ctx = super::build_fetch_context( + ProviderId::Ollama, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + + assert!(!ctx.manual_cookie_missing); +} + #[test] fn fetch_context_codex_manual_cookie_never_forces_unsupported_web() { // Default cookie source is "manual". Pasting a chatgpt.com cookie used to flip @@ -703,6 +1040,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 +1319,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 +1343,15 @@ 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(), + reset_credits: None, 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); @@ -970,6 +1361,7 @@ fn usage_item_descriptors_keep_raw_ids_and_redact_titles() { title: "Credits owner@example.com".to_string(), window: snapshot.primary.clone(), fallback_lane: false, + icon_fallback: None, }]; let mut settings = Settings { @@ -988,6 +1380,63 @@ 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(), + reset_credits: None, + 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 +1529,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 +1544,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") @@ -1149,6 +1608,16 @@ fn provider_fetch_timeout_allows_slower_authenticated_providers() { super::provider_fetch_timeout(ProviderId::DeepSeek, &ctx), std::time::Duration::from_secs(35) ); + + let optional_litellm_ctx = FetchContext { + web_timeout: 30, + optional_details_enabled: true, + ..FetchContext::default() + }; + assert_eq!( + super::provider_fetch_timeout(ProviderId::LiteLLM, &optional_litellm_ctx), + std::time::Duration::from_secs(40) + ); } #[test] @@ -1179,12 +1648,15 @@ 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(), + reset_credits: None, 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 +1679,15 @@ 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(), + reset_credits: None, 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 +1718,15 @@ 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(), + reset_credits: None, 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 +1758,15 @@ 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(), + reset_credits: None, 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 +1797,15 @@ 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(), + reset_credits: None, 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 +1843,15 @@ 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(), + reset_credits: None, 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 +1900,15 @@ 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(), + reset_credits: None, 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 +1954,15 @@ 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(), + reset_credits: None, 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 +2004,15 @@ 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(), + reset_credits: None, 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); @@ -1608,15 +2104,18 @@ fn chart_data_serde_roundtrip_preserves_fields() { DailyCostPoint { date: "2025-01-01".into(), value: Some(1.25), + incomplete_request_count: None, }, DailyCostPoint { date: "2025-01-02".into(), value: Some(0.0), + incomplete_request_count: None, }, ], credits_history: vec![DailyCostPoint { date: "2025-01-01".into(), value: Some(42.0), + incomplete_request_count: None, }], usage_breakdown: vec![DailyUsageBreakdown { day: "2025-01-01".into(), @@ -1715,12 +2214,15 @@ fn japanese_provider_snapshot_localizes_weekly_label() { usage, cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), + reset_credits: None, 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 +2251,15 @@ fn japanese_provider_snapshot_localizes_pace_reserve_description() { usage, cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), + reset_credits: None, 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 = @@ -1792,6 +2297,13 @@ fn replicate_cookie_options_allow_automatic_and_manual_sessions() { assert_eq!(values, vec!["auto", "manual"]); } +#[test] +fn raycast_cookie_options_include_off_and_a_pinned_manual_session() { + let opts = super::cookie_source_options_for("raycast", Language::English); + let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); +} + #[test] fn cookie_options_empty_for_providers_without_picker() { assert!(super::cookie_source_options_for("anthropic", Language::English).is_empty()); @@ -1837,6 +2349,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 +2437,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 +2487,114 @@ 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" + ); +} + +fn muse_fetch_context(cookie_source: Option<&str>, stored_cookie: Option<&str>) -> FetchContext { + let mut settings = Settings::default(); + if let Some(source) = cookie_source { + settings.set_cookie_source(ProviderId::Muse, source); + } + let mut cookies = ManualCookies::default(); + if let Some(header) = stored_cookie { + cookies.set(ProviderId::Muse.cli_name(), header); + } + super::build_fetch_context( + ProviderId::Muse, + &settings, + &cookies, + &ApiKeys::default(), + &HashMap::new(), + ) +} + +#[test] +fn muse_default_cookie_source_reads_no_browser_and_keeps_the_login_source() { + let ctx = muse_fetch_context(None, None); + + assert!(!ctx.browser_cookie_import); + assert!(ctx.manual_cookie_header.is_none()); + assert_eq!(ctx.source_mode, SourceMode::Auto); +} + +#[test] +fn muse_cookie_source_off_never_reads_or_forwards_a_cookie() { + let ctx = muse_fetch_context(Some("off"), Some("llama_dev_sess=abc")); + + assert!(!ctx.browser_cookie_import); + assert!(ctx.manual_cookie_header.is_none()); +} + +#[test] +fn muse_automatic_cookie_source_requests_the_browser_import() { + let ctx = muse_fetch_context(Some("auto"), Some("llama_dev_sess=abc")); + + assert!(ctx.browser_cookie_import); + assert!(ctx.manual_cookie_header.is_none()); + assert_eq!(ctx.source_mode, SourceMode::Auto); +} + +#[test] +fn muse_manual_cookie_source_forwards_only_the_pasted_header() { + let ctx = muse_fetch_context(Some("manual"), Some("llama_dev_sess=abc")); + + assert!(!ctx.browser_cookie_import); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("llama_dev_sess=abc") + ); +} + +#[test] +fn other_providers_never_request_the_muse_browser_import() { + let ctx = super::build_fetch_context( + ProviderId::Cursor, + &Settings::default(), + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ); + + assert!(!ctx.browser_cookie_import); +} 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..2ade811da4 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) - .with_options(codex_scan_options) - .scan_codex(), - CostScanner::new(30) + 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(), - ) + .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), + seven_day_tokens: codex_7_contract.token_total, + thirty_day_tokens: codex_30_contract.token_total, + period_cost: codex_period_contract.known_cost_usd, + period_tokens: codex_period_contract.token_total, source: if include_opencodex && !codex_30_contract.imports.is_empty() { "local logs + OpenCodex".to_string() } else { @@ -513,16 +596,10 @@ fn build_usage_spend_summary( "claude" => SpendValues { seven_day: Some(claude_7_summary.total_cost_usd), thirty_day: Some(claude_30_summary.total_cost_usd), - seven_day_tokens: Some( - claude_7_summary - .input_tokens - .saturating_add(claude_7_summary.output_tokens), - ), - thirty_day_tokens: Some( - claude_30_summary - .input_tokens - .saturating_add(claude_30_summary.output_tokens), - ), + seven_day_tokens: Some(claude_7_summary.total_tokens_for_provider("claude")), + thirty_day_tokens: Some(claude_30_summary.total_tokens_for_provider("claude")), + period_cost: Some(claude_period_summary.total_cost_usd), + period_tokens: Some(claude_period_summary.total_tokens_for_provider("claude")), source: "local logs".to_string(), refreshing: false, stale_updated_at: None, @@ -530,21 +607,27 @@ fn build_usage_spend_summary( "pi" => SpendValues { seven_day: pi_7_contract.known_cost_usd, 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), + seven_day_tokens: pi_7_contract.token_total, + thirty_day_tokens: pi_30_contract.token_total, + period_cost: pi_period_contract.known_cost_usd, + period_tokens: pi_period_contract.token_total, 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), + seven_day_tokens: seven.token_total, + thirty_day_tokens: thirty.token_total, + period_cost: selected.known_cost_usd, + period_tokens: selected.token_total, source: if provider_id == "opencodego" { "local logs + OpenCodex".to_string() } else { @@ -554,52 +637,88 @@ fn build_usage_spend_summary( stale_updated_at: None, } } else { - cached_spend(cached_snapshot) + cached_spend(cached_snapshot, period, now) } } + "opencodego" => { + // Cost stays the provider's; recorded local tokens fill the token columns. + let mut spend = cached_spend(cached_snapshot, period, now); + let seven = CostScanner::new(7).scan_opencodego_usage_tokens_with_cancel(None); + let thirty = CostScanner::new(30).scan_opencodego_usage_tokens_with_cancel(None); + spend.period_tokens = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period).scan_opencodego_usage_tokens_with_cancel(None) + }); + spend.seven_day_tokens = seven; + spend.thirty_day_tokens = thirty; + spend + } "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. @@ -686,28 +810,64 @@ fn last_included_reporting_day(contract: &SpendContract) -> String { .to_string() } -fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix) -> Option { - let values = [ - mix.input_tokens, - mix.output_tokens, - mix.cache_creation_tokens, - ]; +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; - let mut total = 0u64; - for value in values.into_iter().flatten() { - saw = true; - total = total.saturating_add(value); + 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>) -> SpendValues { +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 +879,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 +901,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 +910,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 +924,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 +953,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 +1018,56 @@ 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(), + token_total: None, + 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 +1078,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/events.rs b/apps/desktop-tauri/src-tauri/src/events.rs index b65e5eb629..a9f9025371 100644 --- a/apps/desktop-tauri/src-tauri/src/events.rs +++ b/apps/desktop-tauri/src-tauri/src/events.rs @@ -20,6 +20,7 @@ pub const REFRESH_COMPLETE: &str = "refresh-complete"; pub const UPDATE_STATE_CHANGED: &str = "update-state-changed"; pub const LOCALE_CHANGED: &str = "locale-changed"; pub const SETTINGS_CHANGED: &str = "settings-changed"; +pub const STAY_AWAKE_CHANGED: &str = "stay-awake-changed"; pub const CODEX_ACCOUNTS_UPDATED: &str = "codex-accounts-updated"; pub const LOGIN_PHASE: &str = "login-phase"; @@ -112,6 +113,10 @@ pub fn emit_settings_changed(app: &AppHandle) { let _emit_settings = app.emit(SETTINGS_CHANGED, ()); } +pub fn emit_stay_awake_changed(app: &AppHandle, held: bool) { + let _emit_stay_awake = app.emit(STAY_AWAKE_CHANGED, held); +} + pub fn emit_login_phase(app: &AppHandle, provider_id: &str, phase: &str, auth_link: Option<&str>) { let _emit_login_phase = app.emit( LOGIN_PHASE, diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs index 9277bc026a..5e2c1f7908 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs @@ -6,6 +6,7 @@ //! shell only needs to call into the small public API exported here. mod commands; +mod placement; mod topmost_guard; mod window; @@ -53,7 +54,7 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - let style = Settings::load().float_bar_style; window::recover_onto_primary(window, &style) } else { - window::remember_geometry(window); + window::track_move(window); false }; if let Some(floatbar) = window.app_handle().get_webview_window(FLOATBAR_LABEL) { @@ -63,9 +64,10 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - window::apply_always_on_top(&floatbar); } } - tauri::WindowEvent::CloseRequested { .. } => window::remember_geometry(window), + tauri::WindowEvent::CloseRequested { .. } => window::remember_user_geometry(window), tauri::WindowEvent::Destroyed => { topmost_guard::set_active(false); + placement::with_tracker(placement::PlacementTracker::reset); } _ => {} } diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/placement.rs b/apps/desktop-tauri/src-tauri/src/floatbar/placement.rs new file mode 100644 index 0000000000..fe3f1a8dc2 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/floatbar/placement.rs @@ -0,0 +1,353 @@ +//! Keep the FloatBar where the user put it (issue #625). +//! +//! Windows moves top-level windows on its own when the display layout +//! changes: sleep/wake, a monitor that drops off and comes back, a DPI or +//! resolution change, or an Explorer restart that briefly changes the work +//! area. A bar parked over the taskbar then lands above it. The old event +//! handler saved every `Moved` position, so the system's move overwrote the +//! user's placement for good. +//! +//! The tracker separates the two kinds of move. A move made while a mouse +//! button is held (or right after one) is the user dragging the bar and +//! becomes the user placement. Any other move is a system relocation: it is +//! never persisted, and once the monitor layout is back to the layout the user +//! placed the bar on, and has been quiet for [`SETTLE`], the bar is moved back. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use tauri::{PhysicalPosition, PhysicalSize}; + +/// How long the layout and the bar must stay still before a displaced bar is +/// moved back. Long enough to let Windows finish a display transition. +pub(super) const SETTLE: Duration = Duration::from_secs(3); + +/// Moves this soon after a user drag move still belong to that drag (the +/// last `Moved` of a drag can be delivered after the button is released). +const DRAG_TAIL: Duration = Duration::from_millis(500); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct MonitorRect { + pub position: PhysicalPosition, + pub size: PhysicalSize, + pub work_position: PhysicalPosition, + pub work_size: PhysicalSize, + /// Scale factor in thousandths so the layout compares exactly. + pub scale_milli: u32, +} + +/// Bounds, work area and scale of every connected display, sorted so the +/// enumeration order does not matter. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct MonitorLayout(Vec); + +impl MonitorLayout { + pub(super) fn new(mut monitors: Vec) -> Self { + monitors.sort_by_key(|m| (m.position.x, m.position.y, m.size.width, m.size.height)); + Self(monitors) + } + + pub(super) fn from_monitors(monitors: &[tauri::Monitor]) -> Self { + Self::new( + monitors + .iter() + .map(|monitor| MonitorRect { + position: *monitor.position(), + size: *monitor.size(), + work_position: monitor.work_area().position, + work_size: monitor.work_area().size, + scale_milli: scale_milli(monitor.scale_factor()), + }) + .collect(), + ) + } +} + +fn scale_milli(scale_factor: f64) -> u32 { + if scale_factor.is_finite() && scale_factor > 0.0 { + (scale_factor * 1000.0).round() as u32 + } else { + 1000 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum MoveKind { + /// The user dragged the bar: remember and persist this position. + User, + /// Windows (or a recovery) moved the bar: keep the user placement. + System, +} + +#[derive(Debug, Default)] +pub(super) struct PlacementTracker { + layout: Option, + layout_changed_at: Option, + last_move_at: Option, + last_user_move_at: Option, + user: Option<(MonitorLayout, PhysicalPosition)>, +} + +impl PlacementTracker { + fn observe_layout(&mut self, layout: &MonitorLayout, now: Instant) { + match &self.layout { + Some(previous) if previous == layout => {} + Some(_) => { + self.layout = Some(layout.clone()); + self.layout_changed_at = Some(now); + } + None => self.layout = Some(layout.clone()), + } + } + + fn within(since: Option, now: Instant, window: Duration) -> bool { + since.is_some_and(|at| now.saturating_duration_since(at) < window) + } + + /// The bar was placed at `position` deliberately (shown at its stored or + /// default spot): that is the user placement for `layout`. + pub(super) fn record_user( + &mut self, + layout: MonitorLayout, + position: PhysicalPosition, + now: Instant, + ) { + self.observe_layout(&layout, now); + self.user = Some((layout, position)); + } + + /// Classify a `Moved`/`Resized` event at `position`. + pub(super) fn classify_move( + &mut self, + layout: MonitorLayout, + position: PhysicalPosition, + pointer_down: bool, + now: Instant, + ) -> MoveKind { + self.observe_layout(&layout, now); + self.last_move_at = Some(now); + if self + .user + .as_ref() + .is_some_and(|(_, user)| *user == position) + { + // A resize in place, or the bar moved back to the placement. + return MoveKind::User; + } + if pointer_down || Self::within(self.last_user_move_at, now, DRAG_TAIL) { + self.last_user_move_at = Some(now); + self.user = Some((layout, position)); + MoveKind::User + } else { + MoveKind::System + } + } + + /// True when `position` is the user placement (or none is known), so + /// persisting it cannot overwrite the user's choice with a system move. + pub(super) fn is_user_position(&self, position: PhysicalPosition) -> bool { + self.user.as_ref().is_none_or(|(_, user)| *user == position) + } + + /// Where to move a displaced bar back to, if anywhere: only when the + /// layout equals the one the user placed it on and both the layout and + /// the bar have been still for [`SETTLE`]. + pub(super) fn restore_target( + &mut self, + layout: MonitorLayout, + current: PhysicalPosition, + now: Instant, + ) -> Option> { + self.observe_layout(&layout, now); + if Self::within(self.layout_changed_at, now, SETTLE) + || Self::within(self.last_move_at, now, SETTLE) + { + return None; + } + let (user_layout, user_position) = self.user.as_ref()?; + (*user_layout == layout && *user_position != current).then_some(*user_position) + } + + /// The bar was closed: forget the session state but keep nothing that + /// could outlive the window. + pub(super) fn reset(&mut self) { + *self = Self::default(); + } +} + +static TRACKER: Mutex> = Mutex::new(None); + +pub(super) fn with_tracker(f: impl FnOnce(&mut PlacementTracker) -> T) -> T { + let mut guard = TRACKER + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(guard.get_or_insert_with(PlacementTracker::default)) +} + +/// True while a physical mouse button is held. A FloatBar drag always holds +/// one (left, or right with swapped buttons); a system relocation does not. +#[cfg(windows)] +pub(super) fn pointer_button_down() -> bool { + const VK_LBUTTON: i32 = 0x01; + const VK_RBUTTON: i32 = 0x02; + // SAFETY: GetAsyncKeyState takes a virtual-key code and reads global + // input state; it has no pointer arguments. + unsafe { + (GetAsyncKeyState(VK_LBUTTON) as u16 & 0x8000) != 0 + || (GetAsyncKeyState(VK_RBUTTON) as u16 & 0x8000) != 0 + } +} + +/// Elsewhere every move counts as a user move (the previous behaviour). +#[cfg(not(windows))] +pub(super) fn pointer_button_down() -> bool { + true +} + +#[cfg(windows)] +#[link(name = "user32")] +unsafe extern "system" { + fn GetAsyncKeyState(virtual_key: i32) -> i16; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn monitor(height: u32, work_height: u32) -> MonitorRect { + MonitorRect { + position: PhysicalPosition::new(0, 0), + size: PhysicalSize::new(1_920, height), + work_position: PhysicalPosition::new(0, 0), + work_size: PhysicalSize::new(1_920, work_height), + scale_milli: 1_000, + } + } + + fn desk() -> MonitorLayout { + MonitorLayout::new(vec![monitor(1_080, 1_032)]) + } + + /// The display shrinks while the screen sleeps or reconnects. + fn transient() -> MonitorLayout { + MonitorLayout::new(vec![monitor(768, 720)]) + } + + const OVER_TASKBAR: PhysicalPosition = PhysicalPosition::new(1_200, 1_044); + const ABOVE_TASKBAR: PhysicalPosition = PhysicalPosition::new(1_200, 676); + + #[test] + fn system_move_during_a_display_change_keeps_and_restores_the_user_placement() { + let start = Instant::now(); + let mut tracker = PlacementTracker::default(); + tracker.record_user(desk(), OVER_TASKBAR, start); + + // Windows pushes the bar into the shrunken work area: no button held. + let at = start + Duration::from_secs(10); + assert_eq!( + tracker.classify_move(transient(), ABOVE_TASKBAR, false, at), + MoveKind::System + ); + assert!(!tracker.is_user_position(ABOVE_TASKBAR)); + + // Still on the transient layout: nothing to restore. + assert_eq!( + tracker.restore_target(transient(), ABOVE_TASKBAR, at + SETTLE * 2), + None + ); + + // The display comes back; wait for it to settle before moving. + let back = at + SETTLE * 3; + assert_eq!(tracker.restore_target(desk(), ABOVE_TASKBAR, back), None); + assert_eq!( + tracker.restore_target(desk(), ABOVE_TASKBAR, back + SETTLE), + Some(OVER_TASKBAR) + ); + } + + #[test] + fn system_move_without_a_layout_change_is_also_undone() { + let start = Instant::now(); + let mut tracker = PlacementTracker::default(); + tracker.record_user(desk(), OVER_TASKBAR, start); + + let at = start + Duration::from_secs(10); + assert_eq!( + tracker.classify_move(desk(), ABOVE_TASKBAR, false, at), + MoveKind::System + ); + assert_eq!(tracker.restore_target(desk(), ABOVE_TASKBAR, at), None); + assert_eq!( + tracker.restore_target(desk(), ABOVE_TASKBAR, at + SETTLE), + Some(OVER_TASKBAR) + ); + } + + #[test] + fn user_drag_becomes_the_new_placement_including_its_tail() { + let start = Instant::now(); + let mut tracker = PlacementTracker::default(); + tracker.record_user(desk(), OVER_TASKBAR, start); + + let at = start + Duration::from_secs(10); + let mid = PhysicalPosition::new(900, 500); + let end = PhysicalPosition::new(600, 300); + assert_eq!(tracker.classify_move(desk(), mid, true, at), MoveKind::User); + // The final Moved arrives just after the button is released. + assert_eq!( + tracker.classify_move(desk(), end, false, at + Duration::from_millis(100)), + MoveKind::User + ); + assert!(tracker.is_user_position(end)); + assert_eq!(tracker.restore_target(desk(), end, at + SETTLE * 2), None); + } + + #[test] + fn restore_waits_for_the_bar_to_stop_moving_and_needs_a_known_placement() { + let start = Instant::now(); + let mut tracker = PlacementTracker::default(); + assert_eq!(tracker.restore_target(desk(), ABOVE_TASKBAR, start), None); + + tracker.record_user(desk(), OVER_TASKBAR, start); + let at = start + Duration::from_secs(10); + tracker.classify_move(desk(), ABOVE_TASKBAR, false, at); + let later = at + Duration::from_secs(1); + tracker.classify_move(desk(), ABOVE_TASKBAR, false, later); + assert_eq!( + tracker.restore_target(desk(), ABOVE_TASKBAR, at + SETTLE), + None + ); + assert_eq!( + tracker.restore_target(desk(), ABOVE_TASKBAR, later + SETTLE), + Some(OVER_TASKBAR) + ); + // Resetting (bar closed) forgets the placement. + tracker.reset(); + assert_eq!( + tracker.restore_target(desk(), ABOVE_TASKBAR, later + SETTLE * 4), + None + ); + } + + #[test] + fn layout_ignores_enumeration_order_and_sees_scale_changes() { + let left = MonitorRect { + position: PhysicalPosition::new(-1_920, 0), + ..monitor(1_080, 1_032) + }; + let main = monitor(1_080, 1_032); + assert_eq!( + MonitorLayout::new(vec![left, main]), + MonitorLayout::new(vec![main, left]) + ); + let scaled = MonitorRect { + scale_milli: scale_milli(1.25), + ..main + }; + assert_ne!( + MonitorLayout::new(vec![main]), + MonitorLayout::new(vec![scaled]) + ); + assert_eq!(scale_milli(f64::NAN), 1_000); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/topmost_guard.rs b/apps/desktop-tauri/src-tauri/src/floatbar/topmost_guard.rs index 9b37858b3e..28e34214bd 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/topmost_guard.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/topmost_guard.rs @@ -97,6 +97,13 @@ mod platform { if !floatbar.is_visible().unwrap_or(false) { return; } + // Undo a move Windows made on its own once the display settles + // (issue #625); the moved bar then needs its native flags again. + if window::restore_user_placement(&floatbar) { + window::apply_no_activate(&floatbar); + window::apply_always_on_top(&floatbar); + return; + } if overlaps_taskbar(&floatbar) { window::apply_always_on_top(&floatbar); } diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/window.rs b/apps/desktop-tauri/src-tauri/src/floatbar/window.rs index aabf23f042..1c3289f6e1 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/window.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/window.rs @@ -5,6 +5,9 @@ use tauri::{LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize, WebviewUrl}; +use std::time::Instant; + +use super::placement::{self, MonitorLayout, MoveKind}; use crate::geometry_store; pub const FLOATBAR_LABEL: &str = "floatbar"; @@ -139,8 +142,8 @@ pub(super) fn is_off_all_monitors>(windo !intersects_any_monitor(position, size, &monitors) } -/// Recover: unminimize if needed, place on the primary work area using -/// `style` for top/bottom, and persist the recovered geometry. Callers must +/// Recover: unminimize if needed and place on the primary work area using +/// `style` for top/bottom. The position is not persisted. Callers must /// supply style (settings stay out of this module). pub(super) fn recover_onto_primary>( window: &M, @@ -179,15 +182,12 @@ pub(super) fn recover_onto_primary>( if window.is_minimized().unwrap_or(false) || is_windows_minimized_position(position.x, position.y) { - let _ = window.unminimize(); - } - if window.set_physical_position(target).is_err() { - return false; + let _ = window.restore_without_activation(); } - // Atomic: persist recovered geometry here so event handlers never need - // a synthetic Moved to re-save, and never persist the old off-screen pos. - remember_geometry(window); - true + // Not persisted: the recovery spot is a stand-in. The stored geometry + // keeps the user's placement so it comes back when its display returns + // (issue #625); the next user drag or close stores whatever is current. + window.set_physical_position(target).is_ok() } /// Move a FloatBar that no longer intersects an active monitor onto the @@ -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 @@ -307,6 +310,12 @@ pub fn show( } let _ = ensure_visible_on_active_monitor(&win, style); + // Wherever the bar opened is the placement to keep across display + // changes until the user drags it. + if let (Ok(position), Ok(monitors)) = (win.outer_position(), win.available_monitors()) { + let layout = MonitorLayout::from_monitors(&monitors); + placement::with_tracker(|tracker| tracker.record_user(layout, position, Instant::now())); + } apply_opacity(&win, opacity); apply_click_through(&win, click_through); @@ -323,7 +332,8 @@ pub fn hide(app: &tauri::AppHandle) -> Result<(), String> { super::topmost_guard::set_active(false); if let Some(window) = app.get_webview_window(FLOATBAR_LABEL) { // Persist position before closing so it reopens in place. - remember_geometry(&window); + remember_user_geometry(&window); + placement::with_tracker(placement::PlacementTracker::reset); if let Err(error) = window.close() { super::topmost_guard::set_active(true); return Err(error.to_string()); @@ -369,6 +379,66 @@ pub fn remember_geometry>(window: &M) { ); } +/// Handle a `Moved`/`Resized` event for a bar that is on screen: persist the +/// position only when the user dragged the bar there. A move Windows made on +/// its own (display change, sleep/wake, Explorer restart) is not persisted, +/// so the user placement survives it (issue #625). +pub(super) fn track_move>(window: &M) { + let Ok(position) = window.outer_position() else { + return; + }; + let Ok(monitors) = window.available_monitors() else { + remember_geometry(window); + return; + }; + let layout = MonitorLayout::from_monitors(&monitors); + let pointer_down = placement::pointer_button_down(); + let kind = placement::with_tracker(|tracker| { + tracker.classify_move(layout, position, pointer_down, Instant::now()) + }); + if kind == MoveKind::User { + remember_geometry(window); + } +} + +/// Persist the current geometry unless the bar sits where Windows moved it +/// rather than where the user placed it. +pub(super) fn remember_user_geometry>(window: &M) { + let Ok(position) = window.outer_position() else { + return; + }; + if placement::with_tracker(|tracker| tracker.is_user_position(position)) { + remember_geometry(window); + } +} + +/// Move a bar that Windows displaced back to the user placement once the +/// display layout is the one it was placed on and has settled. Returns `true` +/// when the bar was moved. +#[cfg_attr( + not(windows), + allow( + dead_code, + reason = "the restore runs from the Windows-only topmost guard poll" + ) +)] +pub(super) fn restore_user_placement>(window: &M) -> bool { + if window.is_minimized().unwrap_or(false) { + return false; + } + let (Ok(position), Ok(monitors)) = (window.outer_position(), window.available_monitors()) + else { + return false; + }; + if monitors.is_empty() { + return false; + } + let layout = MonitorLayout::from_monitors(&monitors); + let target = + placement::with_tracker(|tracker| tracker.restore_target(layout, position, Instant::now())); + target.is_some_and(|target| window.set_physical_position(target).is_ok()) +} + /// Subset of `tauri::WebviewWindow` / `tauri::Window` used by /// [`remember_geometry`]. Both types implement the underlying methods, but /// they don't share a public trait — this private trait bridges them so we @@ -381,7 +451,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 +477,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 +511,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..2a2caf309e 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -14,11 +14,13 @@ mod proof_harness; mod shell; mod shortcut_bridge; mod state; +mod stay_awake; mod surface; 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 +53,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 +150,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 +197,8 @@ fn main() { commands::get_bootstrap_state, commands::get_provider_catalog, commands::get_settings_snapshot, + commands::get_currency_rates, + stay_awake::get_stay_awake_status, commands::list_agent_sessions, commands::focus_agent_session, commands::update_settings, @@ -237,12 +270,15 @@ fn main() { commands::get_provider_local_usage_summary, commands::get_usage_spend_summary, commands::write_usage_spend_export, + commands::export_preferences, + commands::import_preferences, commands::get_spend_contract, commands::get_codex_workspaces_snapshot, commands::reorder_providers, commands::set_provider_cookie_source, commands::set_provider_usage_source, commands::set_provider_auto_resume_after_quota_reset, + commands::set_provider_optional_details, commands::has_openrouter_management_api_key, commands::set_openrouter_management_api_key, commands::remove_openrouter_management_api_key, @@ -253,6 +289,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, @@ -295,6 +332,7 @@ fn main() { shortcut_bridge::register(app.handle()); floatbar::install(app.handle()); auto_refresh::install(app.handle().clone()); + stay_awake::install(app.handle().clone()); if settings.powertoys_status_pipe_enabled { powertoys::install(app.handle().clone()); } @@ -415,8 +453,13 @@ fn main() { _ => {} } }) - .run(tauri::generate_context!()) - .expect("failed to run CodexBar desktop shell"); + .build(context) + .expect("failed to build CodexBar desktop shell") + .run(|_app, event| { + if matches!(event, tauri::RunEvent::Exit) { + stay_awake::shutdown(); + } + }); } #[cfg(test)] diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 99012ae7c3..305abe3dd9 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,10 +234,15 @@ 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(), token_cost_updated_at_ms: 1234, + incomplete_request_count: None, }), ); @@ -261,7 +273,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/stay_awake.rs b/apps/desktop-tauri/src-tauri/src/stay_awake.rs new file mode 100644 index 0000000000..898dd7b4c8 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/stay_awake.rs @@ -0,0 +1,101 @@ +//! Stay Awake scheduler: keeps Windows from idle-sleeping while a local agent +//! (Codex, Claude, Pi/OMP) process is running. +//! +//! While `stay_awake_enabled` is on, a local-only process scan runs every 30 +//! seconds, independent of `agent_sessions_enabled`. It never touches SSH or +//! Tailscale hosts and never reads transcript files, so file-only rollouts and +//! remote sessions cannot hold the machine awake. The policy itself lives in +//! `codexbar::power_assertion`. The setting reaches the controller through +//! [`settings_changed`] (every save goes through `update_settings`) and once at +//! startup; the scan loop never re-reads the file, so it cannot re-enable a +//! toggle the user just turned off. + +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::Duration; + +use codexbar::agent_sessions::LocalAgentSessionScanner; +use codexbar::power_assertion::{StayAwake, SystemPowerAssertion}; +use codexbar::settings::Settings; +use tokio::sync::Notify; + +const SCAN_INTERVAL: Duration = Duration::from_secs(30); + +type Controller = StayAwake; + +static CONTROLLER: OnceLock> = OnceLock::new(); +static WAKE: Notify = Notify::const_new(); + +fn controller() -> MutexGuard<'static, Controller> { + CONTROLLER + .get_or_init(|| Mutex::new(StayAwake::new(SystemPowerAssertion::new()))) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Whether an idle-sleep prevention is currently held (tray status line). +pub fn is_held() -> bool { + controller().is_held() +} + +#[tauri::command] +pub fn get_stay_awake_status() -> bool { + is_held() +} + +fn publish_status_change(app: &tauri::AppHandle) { + crate::tray_bridge::rebuild_tray_menu(app); + crate::events::emit_stay_awake_changed(app, is_held()); +} + +/// Apply a saved settings change: turning Stay Awake off releases right away, +/// turning it on triggers a scan without waiting for the next interval. +pub fn settings_changed(app: &tauri::AppHandle, enabled: bool) { + let changed = controller().set_enabled(enabled); + if changed { + publish_status_change(app); + } + if enabled { + WAKE.notify_one(); + } +} + +/// Release for good on app exit. +pub fn shutdown() { + controller().shutdown(); +} + +pub fn install(app: tauri::AppHandle) { + controller().set_enabled(Settings::load().stay_awake_enabled); + tauri::async_runtime::spawn(async move { + loop { + scan_once(&app).await; + // Sleep one interval, or wake early when the setting is turned on. + tokio::time::timeout(SCAN_INTERVAL, WAKE.notified()) + .await + .ok(); + } + }); +} + +async fn scan_once(app: &tauri::AppHandle) { + if !controller().is_enabled() { + return; + } + let changed = + match tokio::task::spawn_blocking(LocalAgentSessionScanner::has_live_agent_process).await { + // The controller drops this result if the setting was turned off or + // the app shut down while the scan was running. + Ok(Ok(live)) => controller().observe(live), + Ok(Err(error)) => { + tracing::warn!("Stay Awake process scan failed: {error}"); + false + } + Err(error) => { + tracing::warn!("Stay Awake scan task failed: {error}"); + false + } + }; + if changed { + publish_status_change(app); + } +} 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..ce7e8bf1b2 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_menu::{MenuState, TrayMenuEntry, build_tray_menu_with}; +use crate::tray_presentation::{TrayPresentationPlan, headline_window}; #[derive(Debug, Clone, Copy)] struct MonitorScaleInfo { @@ -132,7 +128,10 @@ fn build_native_tray_menu( providers, status_labels, &enabled, - settings.float_bar_enabled, + MenuState { + float_bar_enabled: settings.float_bar_enabled, + stay_awake_held: crate::stay_awake::is_held(), + }, settings.ui_language, ); crate::tray_accounts::prepend_account_menus(&mut spec, &settings); @@ -294,7 +293,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 +338,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 +362,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 +404,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 +534,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 +696,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 +712,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 +729,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 +766,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, } } @@ -969,6 +785,7 @@ mod tests { id: "additional_budget".to_string(), title: "Additional Budget".to_string(), fallback_lane: false, + icon_fallback: None, window: crate::commands::RateWindowSnapshot { used_percent: percent, remaining_percent: 100.0 - percent, @@ -981,114 +798,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 +904,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 +929,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 +966,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 +989,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 +1014,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 +1028,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 +1099,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 +1139,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 +1159,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 +1167,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_menu.rs b/apps/desktop-tauri/src-tauri/src/tray_menu.rs index 3474c92d6b..3498739af8 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_menu.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_menu.rs @@ -82,6 +82,14 @@ impl TrayMenuEntry { } } +/// Live app state the menu mirrors beyond the provider catalog. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct MenuState { + pub(crate) float_bar_enabled: bool, + /// Stay Awake currently holds an idle-sleep prevention. + pub(crate) stay_awake_held: bool, +} + #[cfg(test)] pub(crate) fn build_tray_menu( providers: &[ProviderCatalogEntry], @@ -92,7 +100,7 @@ pub(crate) fn build_tray_menu( providers, status_labels, enabled_providers, - false, + MenuState::default(), Language::English, ) } @@ -101,7 +109,7 @@ pub(crate) fn build_tray_menu_with( providers: &[ProviderCatalogEntry], status_labels: &[(String, String)], enabled_providers: &HashSet, - float_bar_enabled: bool, + state: MenuState, lang: Language, ) -> Vec { let mut menu: Vec = Vec::new(); @@ -111,7 +119,13 @@ pub(crate) fn build_tray_menu_with( for (id, label) in status_labels { menu.push(TrayMenuEntry::status_row(format!("status_{id}"), label)); } - if !status_labels.is_empty() { + if state.stay_awake_held { + menu.push(TrayMenuEntry::status_row( + "stay_awake_active", + text(LocaleKey::TrayStayAwakeActive), + )); + } + if !status_labels.is_empty() || state.stay_awake_held { menu.push(TrayMenuEntry::separator()); } @@ -126,7 +140,7 @@ pub(crate) fn build_tray_menu_with( menu.push(TrayMenuEntry::check_item( "toggle_float_bar", text(LocaleKey::TrayShowFloatBar), - float_bar_enabled, + state.float_bar_enabled, )); menu.push(TrayMenuEntry::separator()); @@ -245,7 +259,10 @@ mod tests { &sample_provider_catalog(), &[], &both_enabled(), - /* float_bar_enabled = */ true, + MenuState { + float_bar_enabled: true, + ..MenuState::default() + }, Language::English, ); let toggle = menu_on @@ -259,7 +276,7 @@ mod tests { &sample_provider_catalog(), &[], &both_enabled(), - /* float_bar_enabled = */ false, + MenuState::default(), Language::English, ); let toggle = menu_off @@ -275,7 +292,7 @@ mod tests { &sample_provider_catalog(), &[], &both_enabled(), - false, + MenuState::default(), Language::Japanese, ); fn label_for<'a>(menu: &'a [TrayMenuEntry], id: &'a str) -> &'a str { @@ -317,4 +334,27 @@ mod tests { // Third item should be a separator. assert!(menu[2].is_separator); } + + #[test] + fn stay_awake_row_appears_only_while_held() { + let build = |stay_awake_held| { + build_tray_menu_with( + &sample_provider_catalog(), + &[], + &both_enabled(), + MenuState { + stay_awake_held, + ..MenuState::default() + }, + Language::English, + ) + }; + assert!(!menu_contains(&build(false), "stay_awake_active")); + + let held = build(true); + assert_eq!(held[0].id.as_deref(), Some("stay_awake_active")); + assert_eq!(held[0].label, "Stay Awake: local agent session is live"); + assert!(held[0].disabled); + assert!(held[1].is_separator); + } } 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..2dadce0734 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -0,0 +1,415 @@ +//! 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 if snapshot.primary.is_informational { + // Provider-declared icon lanes (Doubao Agent Plan) stand in for an + // absent core session, matching the tray icon. + crate::usage_metric::icon_fallback_window(snapshot, codexbar::core::IconLane::Primary) + .unwrap_or(&snapshot.primary) + } 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..e443735870 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -0,0 +1,792 @@ +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, + icon_fallback: None, + 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..cab5254a13 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -1,8 +1,9 @@ //! Canonical single-metric selection shared by native and webview surfaces. +use std::borrow::Cow; use std::cmp::Ordering; -use codexbar::core::ProviderId; +use codexbar::core::{IconLane, ProviderId}; use codexbar::settings::{MetricPreference, Settings}; use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; @@ -11,6 +12,10 @@ pub(crate) fn selected_usage_window( snapshot: &ProviderUsageSnapshot, settings: &Settings, ) -> RateWindowSnapshot { + select_window(&with_icon_fallbacks(snapshot), settings) +} + +fn select_window(snapshot: &ProviderUsageSnapshot, settings: &Settings) -> RateWindowSnapshot { let provider = ProviderId::from_cli_name(&snapshot.provider_id); let preference = provider .map(|id| settings.get_provider_metric(id)) @@ -30,6 +35,58 @@ pub(crate) fn selected_usage_window( automatic_window(snapshot, provider).unwrap_or_else(|| snapshot.primary.clone()) } +/// Let provider-declared extra windows stand in for an absent core lane. +/// +/// A lane is absent when the primary is informational or the secondary is +/// missing; the provider's own `icon_fallback` hint names the extra window +/// that fills it. Only this selection view changes: the snapshot the UI +/// renders keeps its lanes as reported, and a lane with no hint stays absent. +fn with_icon_fallbacks(snapshot: &ProviderUsageSnapshot) -> Cow<'_, ProviderUsageSnapshot> { + let primary = snapshot + .primary + .is_informational + .then(|| icon_fallback_window(snapshot, IconLane::Primary)) + .flatten(); + let secondary = snapshot + .secondary + .is_none() + .then(|| icon_fallback_window(snapshot, IconLane::Secondary)) + .flatten(); + if primary.is_none() && secondary.is_none() { + return Cow::Borrowed(snapshot); + } + let mut resolved = snapshot.clone(); + if let Some(primary) = primary { + resolved.primary = primary.clone(); + } + if let Some(secondary) = secondary { + resolved.secondary = Some(secondary.clone()); + } + Cow::Owned(resolved) +} + +/// Provider-declared extra window that stands in for an absent core lane. +pub(crate) fn icon_fallback_window( + snapshot: &ProviderUsageSnapshot, + lane: IconLane, +) -> Option<&RateWindowSnapshot> { + snapshot + .extra_rate_windows + .iter() + .find(|extra| extra.icon_fallback == Some(lane) && !extra.window.is_informational) + .map(|extra| &extra.window) +} + +/// Whether the provider maps extra windows onto the tray icon lanes. Such a +/// provider's icon is its two lanes; its other extra windows (team, monthly) +/// never take part in Automatic selection. +fn declares_icon_lanes(snapshot: &ProviderUsageSnapshot) -> bool { + snapshot + .extra_rate_windows + .iter() + .any(|extra| extra.icon_fallback.is_some()) +} + /// Select the primary tray metric and, when there are multiple meaningful core /// quotas, one distinct companion lane. Keeping this policy beside canonical /// metric selection prevents tray rendering from duplicating the selected lane. @@ -37,7 +94,17 @@ pub(crate) fn selected_usage_icon_windows( snapshot: &ProviderUsageSnapshot, settings: &Settings, ) -> (RateWindowSnapshot, Option) { - let selected = selected_usage_window(snapshot, settings); + let snapshot = with_icon_fallbacks(snapshot); + if declares_icon_lanes(&snapshot) && icon_metric_is_automatic(&snapshot, settings) { + return ( + snapshot.primary.clone(), + snapshot + .secondary + .clone() + .filter(|window| !window.is_informational), + ); + } + let selected = select_window(&snapshot, settings); let meaningful_count = std::iter::once(&snapshot.primary) .chain(snapshot.secondary.iter()) .chain(snapshot.tertiary.iter()) @@ -58,11 +125,19 @@ pub(crate) fn selected_usage_icon_windows( (selected, companion) } +fn icon_metric_is_automatic(snapshot: &ProviderUsageSnapshot, settings: &Settings) -> bool { + ProviderId::from_cli_name(&snapshot.provider_id) + .map(|id| settings.get_provider_metric(id)) + .unwrap_or_default() + == MetricPreference::Automatic +} + fn same_window(left: &RateWindowSnapshot, right: &RateWindowSnapshot) -> bool { left.used_percent.to_bits() == right.used_percent.to_bits() && 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 +169,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 +246,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()); @@ -146,7 +270,7 @@ fn automatic_window( .chain(snapshot.model_specific.iter()) .chain(snapshot.tertiary.iter()) .any(|window| !window.is_informational); - if policy.uses_extra_windows { + if policy.uses_extra_windows && !declares_icon_lanes(snapshot) { windows.extend( snapshot .extra_rate_windows @@ -180,6 +304,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 +316,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 +335,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 +401,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 +488,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, } } @@ -406,6 +544,7 @@ mod tests { title: "Credits used".to_string(), window: window(35.0), fallback_lane: true, + icon_fallback: None, }]; assert_eq!( @@ -425,6 +564,7 @@ mod tests { title: "Credits used".to_string(), window: window(90.0), fallback_lane: true, + icon_fallback: None, }]; assert_eq!( @@ -447,6 +587,7 @@ mod tests { title: "Credits used".to_string(), window: window(35.0), fallback_lane: true, + icon_fallback: None, }]; let mut settings = Settings::default(); let provider = ProviderId::from_cli_name(&snapshot.provider_id).expect("copilot provider"); @@ -468,6 +609,7 @@ mod tests { title: "Grok Bot".to_string(), window: window(95.0), fallback_lane: false, + icon_fallback: None, }]; assert_eq!( @@ -483,6 +625,39 @@ 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, + icon_fallback: None, + }]; + + 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(); @@ -550,6 +725,7 @@ mod tests { title: "Claude/GPT weekly".to_string(), window: window(100.0), fallback_lane: false, + icon_fallback: None, }]; let selected = selected_usage_window(&snapshot, &Settings::default()); @@ -570,6 +746,7 @@ mod tests { title: "Other".to_string(), window: window(100.0), fallback_lane: false, + icon_fallback: None, }]; let selected = selected_usage_window(&snapshot, &Settings::default()); @@ -604,6 +781,121 @@ 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, + icon_fallback: None, + }) + .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, + icon_fallback: None, + }]; + 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( @@ -647,6 +939,7 @@ mod tests { title: "Daily Routines".to_string(), window: window(95.0), fallback_lane: false, + icon_fallback: None, }]; let baseline = crate::commands::ProviderUsagePresentationSnapshot::new( @@ -680,4 +973,256 @@ 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 + } + + const AGENT_RESET: &str = "2026-08-16T05:00:00Z"; + + fn agent_window(used_percent: f64, minutes: u32) -> RateWindowSnapshot { + RateWindowSnapshot { + window_minutes: Some(minutes), + resets_at: Some(AGENT_RESET.to_string()), + ..window(used_percent) + } + } + + fn agent_extra( + id: &str, + window: RateWindowSnapshot, + icon_fallback: Option, + ) -> crate::commands::NamedRateWindowSnapshot { + crate::commands::NamedRateWindowSnapshot { + id: id.to_string(), + title: id.to_string(), + window, + fallback_lane: false, + icon_fallback, + } + } + + /// Bridge shape of a Doubao Coding Plan snapshot that also reports Agent + /// Plan lanes (`doubao-agent-*`), with optional Coding Plan session/weekly. + fn doubao_snapshot( + coding_session: Option, + coding_weekly: Option, + ) -> ProviderUsageSnapshot { + let mut snapshot = snapshot(); + snapshot.provider_id = "doubao".to_string(); + snapshot.primary = coding_session.map_or_else( + || RateWindowSnapshot { + window_minutes: Some(300), + is_informational: true, + ..window(0.0) + }, + window, + ); + snapshot.secondary = coding_weekly.map(window); + snapshot.extra_rate_windows = vec![ + agent_extra( + "doubao-agent-session", + agent_window(0.0, 300), + Some(IconLane::Primary), + ), + agent_extra( + "doubao-agent-weekly", + agent_window(31.0, 10_080), + Some(IconLane::Secondary), + ), + agent_extra("doubao-agent-monthly", agent_window(72.0, 43_200), None), + ]; + 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 + ); + } + + #[test] + fn doubao_icon_windows_fall_back_to_agent_plan_lanes() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Doubao, MetricPreference::Session); + + for (has_session, has_weekly) in + [(false, false), (false, true), (true, false), (true, true)] + { + let snapshot = doubao_snapshot(has_session.then_some(10.0), has_weekly.then_some(20.0)); + let (primary, secondary) = selected_usage_icon_windows(&snapshot, &settings); + let secondary = secondary.expect("secondary icon lane"); + + assert_eq!(primary.used_percent, if has_session { 10.0 } else { 0.0 }); + assert_eq!(secondary.used_percent, if has_weekly { 20.0 } else { 31.0 }); + assert!(!primary.is_informational); + assert_eq!( + primary.window_minutes, + if has_session { None } else { Some(300) } + ); + assert_eq!( + secondary.window_minutes, + if has_weekly { None } else { Some(10_080) } + ); + assert_eq!( + primary.resets_at.as_deref(), + (!has_session).then_some(AGENT_RESET) + ); + assert_eq!( + secondary.resets_at.as_deref(), + (!has_weekly).then_some(AGENT_RESET) + ); + } + } + + #[test] + fn doubao_icon_fallback_does_not_change_the_reported_snapshot() { + let raw = doubao_snapshot(None, None); + let presentation = crate::commands::ProviderUsagePresentationSnapshot::new( + raw.clone(), + &Settings::default(), + ); + + assert!(presentation.snapshot.primary.is_informational); + assert!(presentation.snapshot.secondary.is_none()); + assert!(presentation.snapshot.tertiary.is_none()); + assert_eq!( + presentation + .snapshot + .extra_rate_windows + .iter() + .map(|extra| extra.id.as_str()) + .collect::>(), + [ + "doubao-agent-session", + "doubao-agent-weekly", + "doubao-agent-monthly" + ] + ); + assert!(!presentation.selected_metric.is_informational); + } + + #[test] + fn doubao_icon_windows_preserve_missing_lanes() { + let no_hints = |levels: &[(&str, f64, u32)]| { + let mut snapshot = doubao_snapshot(None, None); + snapshot.extra_rate_windows = levels + .iter() + .map(|(id, percent, minutes)| { + agent_extra(id, agent_window(*percent, *minutes), None) + }) + .collect(); + snapshot + }; + // Monthly and team buckets never stand in for the session/weekly lanes. + for id in ["doubao-agent-monthly", "doubao-agent-team-session"] { + let snapshot = no_hints(&[(id, 25.0, 300)]); + let resolved = with_icon_fallbacks(&snapshot); + assert!(matches!(resolved, Cow::Borrowed(_)), "{id}"); + assert!(resolved.primary.is_informational, "{id}"); + assert!(resolved.secondary.is_none(), "{id}"); + } + + // Agent weekly alone fills only the weekly lane; no session is invented. + let mut snapshot = doubao_snapshot(None, None); + snapshot + .extra_rate_windows + .retain(|extra| extra.id == "doubao-agent-weekly"); + let resolved = with_icon_fallbacks(&snapshot); + assert!(resolved.primary.is_informational); + assert_eq!(resolved.secondary.as_ref().unwrap().used_percent, 31.0); + + // Coding session alone (agent lanes absent): unchanged, nothing invented. + let mut snapshot = doubao_snapshot(Some(25.0), None); + snapshot.extra_rate_windows.clear(); + let resolved = with_icon_fallbacks(&snapshot); + assert!(matches!(resolved, Cow::Borrowed(_))); + let (primary, companion) = selected_usage_icon_windows(&snapshot, &Settings::default()); + assert_eq!(primary.used_percent, 25.0); + assert!(companion.is_none()); + } + + #[test] + fn doubao_automatic_icon_uses_agent_lanes_not_team_windows() { + let mut snapshot = doubao_snapshot(None, None); + snapshot.extra_rate_windows = vec![ + agent_extra( + "doubao-agent-session", + agent_window(42.0, 300), + Some(IconLane::Primary), + ), + agent_extra( + "doubao-agent-weekly", + agent_window(67.0, 10_080), + Some(IconLane::Secondary), + ), + agent_extra("doubao-agent-team-session", agent_window(91.0, 300), None), + agent_extra("doubao-agent-team-weekly", agent_window(88.0, 10_080), None), + ]; + let settings = Settings::default(); + + let (top, bottom) = selected_usage_icon_windows(&snapshot, &settings); + assert_eq!(top.used_percent, 42.0); + assert_eq!(bottom.expect("bottom lane").used_percent, 67.0); + assert_eq!( + selected_usage_window(&snapshot, &settings).used_percent, + 67.0 + ); + assert_eq!( + crate::tray_presentation::headline_window(&snapshot).used_percent, + 42.0 + ); + } + + #[test] + fn icon_fallback_hints_are_ignored_while_the_core_lane_is_present() { + let snapshot = doubao_snapshot(Some(10.0), Some(20.0)); + let resolved = with_icon_fallbacks(&snapshot); + + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.primary.used_percent, 10.0); + assert_eq!(resolved.secondary.as_ref().unwrap().used_percent, 20.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..05c3355627 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -85,10 +85,12 @@ function settings(overrides: Partial = {}): SettingsSnapshot { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarColorPace: false, showAsUsed: true, showAllTokenAccountsInMenu: false, enableAnimations: true, @@ -101,6 +103,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "Ctrl+Shift+U", + switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", // "dark" (not "auto") so useTheme's effect short-circuits before ever 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..140c682e56 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", @@ -264,6 +274,38 @@ describe("MenuCard", () => { expect(fill?.style.width).toBe("35%"); }); + it("shows an additional balance description below the meter and reset time", async () => { + const snapshot = provider(null, 25, { + resetDescription: "750 / 1000 credits left", + }); + snapshot.primaryLabel = "Credits"; + snapshot.primary.descriptionIsDetail = true; + snapshot.selectedMetric.descriptionIsDetail = true; + const resetsAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + snapshot.primary.resetsAt = resetsAt; + snapshot.selectedMetric.resetsAt = resetsAt; + + renderCard(snapshot); + + const description = await screen.findByText("750 / 1000 credits left"); + expect(description).toHaveClass("menu-metric__detail"); + expect(screen.getByText(/Resets in/)).toBeInTheDocument(); + }); + + it("does not repeat a reset-phrase description as an extra detail", async () => { + const snapshot = provider(null, 20, { resetDescription: "Resets in 3h" }); + const resetsAt = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString(); + snapshot.primary.resetsAt = resetsAt; + snapshot.selectedMetric.resetsAt = resetsAt; + + renderCard(snapshot); + + await screen.findByText(/Resets in/); + expect(document.querySelectorAll(".menu-metric__reset")).toHaveLength(1); + expect(document.querySelectorAll(".menu-metric__detail")).toHaveLength(0); + expect(screen.queryByText("Resets in 3h")).not.toBeInTheDocument(); + }); + it("displays over-quota usage without overflowing the bar", async () => { renderCard(provider(null, 115, { exhausted: true, resetDescription: "115% used" }), { showAsUsed: true, @@ -310,7 +352,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 +363,26 @@ 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("shows both Agent Plan lanes next to the informational placeholder in compact Overview", async () => { + const snapshot = provider(null, 0); + snapshot.primary = { ...rateWindow(0), isInformational: true, resetDescription: "No active 5h session" }; + snapshot.extraRateWindows = [ + { id: "doubao-agent-session", title: "5-hour", window: rateWindow(42, { windowMinutes: 300 }) }, + { id: "doubao-agent-weekly", title: "Weekly", window: rateWindow(67, { windowMinutes: 10080 }) }, + ]; + + renderCard(snapshot, { compactOverview: true }); + + // Compact Overview shows every quota row (upstream 0.62.0 #2616), so the + // placeholder no longer competes with the measured lanes for a row. + expect(await screen.findByText("58% left")).toBeInTheDocument(); + expect(screen.getByText("33% left")).toBeInTheDocument(); + expect(screen.getByText("No active 5h session")).toBeInTheDocument(); + expect(document.querySelectorAll(".menu-metric")).toHaveLength(3); }); it("localizes Claude scoped weekly extra-window labels", async () => { @@ -356,6 +414,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 +683,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 +841,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..4b0efb6816 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,12 +197,25 @@ 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"), + )} /> ))}
)} + {summary.incompleteRequestCount != null && summary.incompleteRequestCount > 0 && ( +
+ {t("IncompleteRequestsLabel")} + + {t("IncompleteRequestsDetail").replace("{}", String(summary.incompleteRequestCount))} + +
+ )} +
{summary.topModel && {t("PanelTopModelPrefix")}: {summary.topModel}} @@ -294,6 +338,7 @@ type MetricRowDisplay = { showAsUsed?: boolean; compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; + monthlyLimitBlockNow?: number; }; /** @@ -328,7 +373,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 +389,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 +407,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 +444,9 @@ function MetricRow({ {resetText} )}
+ {!compactOverview && detailText && ( +
{detailText}
+ )} {!compactOverview && !isInformational && snap.isExhausted && (
{exhaustedLabel}
)} @@ -437,8 +504,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 +533,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 +543,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 +564,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 +575,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 +592,10 @@ export function describeCard( hasCreditsHistory, hasUsageBreakdown, hasQuotaWindowHistory, + hasBurndown, localUsage, wayfinderUsage, + openAiApiUsage, hasDetails, }; } @@ -540,7 +622,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 +644,10 @@ export default function MenuCardDetails({ hasCreditsHistory, hasUsageBreakdown, hasQuotaWindowHistory, + hasBurndown, localUsage, wayfinderUsage, + openAiApiUsage, } = presence; return ( @@ -595,20 +688,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 +799,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 +823,7 @@ export default function MenuCardDetails({ providerId={provider.providerId} summary={localUsage} costHistory={localCostHistory} + tokensHistory={localTokensHistory} /> )} @@ -778,6 +904,9 @@ export default function MenuCardDetails({ {hasQuotaWindowHistory && ( )} + {hasBurndown && provider.quotaBurndown && ( + + )} )} @@ -786,26 +915,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/MiniBarChart.test.tsx b/apps/desktop-tauri/src/components/MiniBarChart.test.tsx index d2703a7c95..d46c973ead 100644 --- a/apps/desktop-tauri/src/components/MiniBarChart.test.tsx +++ b/apps/desktop-tauri/src/components/MiniBarChart.test.tsx @@ -29,6 +29,26 @@ describe("MiniBarChart history axes", () => { expect((labels[1] as HTMLElement).style.transform).toBe(""); }); + it("marks days that only had incomplete requests without inventing a cost", () => { + const { container } = render( + , + ); + + const titles = Array.from(container.querySelectorAll("rect title")).map( + (node) => node.textContent, + ); + expect(titles[0]).toBe("2026-09-01 \u00b7 IncompleteRequestsLabel \u00b7 IncompleteRequestsDetail"); + expect(titles[1]).toContain("2026-09-02: 1.50"); + expect(titles[1]).toContain("IncompleteRequestsLabel"); + expect(container.querySelectorAll("rect")[0]).toHaveAttribute("opacity", "0"); + }); + it("keeps full endpoint dates for usage breakdown history", () => { const { container } = render( - {p.value == null ? p.date : `${p.date}: ${fmt(p.value)}`} + {[ + p.value == null ? p.date : `${p.date}: ${fmt(p.value)}`, + incompleteRequestsTooltip(t, p.incompleteRequestCount), + ] + .filter(Boolean) + .join(" \u00b7 ")} ); 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.test.ts b/apps/desktop-tauri/src/components/ProviderDisplayRow.test.ts new file mode 100644 index 0000000000..6c6ab647ae --- /dev/null +++ b/apps/desktop-tauri/src/components/ProviderDisplayRow.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderDisplayDetail } from "../types/bridge"; +import { groupProviderDisplayDetails } from "./ProviderDisplayRow"; + +function row(id: string, sectionTitle: string | null = null): ProviderDisplayDetail { + return { id, sectionTitle, title: id, value: "1", secondaryValue: null, progress: null }; +} + +describe("groupProviderDisplayDetails", () => { + it("starts a titled group only where the section changes", () => { + const rows = [ + row("plain"), + row("a", "Model activity"), + row("b", "Model activity"), + row("c", "Workspace spend"), + row("d", null), + ]; + + const groups = groupProviderDisplayDetails(rows); + + expect(groups.map((group) => group.title)).toEqual([ + null, + "Model activity", + "Workspace spend", + null, + ]); + expect(groups.map((group) => group.rows.map((detail) => detail.id))).toEqual([ + ["plain"], + ["a", "b"], + ["c"], + ["d"], + ]); + }); +}); 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/ProviderGrid.test.tsx b/apps/desktop-tauri/src/components/ProviderGrid.test.tsx new file mode 100644 index 0000000000..764a779ed7 --- /dev/null +++ b/apps/desktop-tauri/src/components/ProviderGrid.test.tsx @@ -0,0 +1,43 @@ +import { render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ProviderGrid from "./ProviderGrid"; +import { providerPlaceholder } from "../lib/trayProviders"; + +vi.mock("../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key, language: "english" }), +})); + +const providers = [ + providerPlaceholder("codex", "Codex"), + providerPlaceholder("claude", "Claude"), +]; + +function grid(selectedProviderId: string | null) { + return ( + {}} + /> + ); +} + +afterEach(() => { + delete (Element.prototype as Partial).scrollIntoView; +}); + +describe("ProviderGrid", () => { + it("scrolls the active item into view when the selection changes", () => { + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const { rerender } = render(grid(null)); + scrollIntoView.mockClear(); + rerender(grid("claude")); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", inline: "nearest" }); + expect(scrollIntoView.mock.contexts[0]).toBe( + document.querySelector(".provider-grid__item--active"), + ); + }); +}); diff --git a/apps/desktop-tauri/src/components/ProviderGrid.tsx b/apps/desktop-tauri/src/components/ProviderGrid.tsx index 7fe353dc7e..32bbf60743 100644 --- a/apps/desktop-tauri/src/components/ProviderGrid.tsx +++ b/apps/desktop-tauri/src/components/ProviderGrid.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import type { ProviderUsageSnapshot } from "../types/bridge"; import { ProviderIcon } from "./providers/ProviderIcon"; import { getProviderIcon } from "./providers/providerIcons"; @@ -36,6 +36,15 @@ export default function ProviderGrid({ const [dragId, setDragId] = useState(null); const [overId, setOverId] = useState(null); const canReorder = typeof onReorder === "function"; + const gridRef = useRef(null); + + // Keep the selected item visible when it changes (keyboard switching in the + // scrollable dense grid); this never expands a collapsed grid. + useEffect(() => { + gridRef.current + ?.querySelector(".provider-grid__item--active") + ?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); + }, [selectedProviderId]); const applyReorder = (targetId: string) => { if (!onReorder || !dragId || dragId === targetId) return; @@ -85,6 +94,7 @@ export default function ProviderGrid({ return (
{ + 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/ShortcutCapture.test.tsx b/apps/desktop-tauri/src/components/ShortcutCapture.test.tsx index 4befba0372..28ed6732de 100644 --- a/apps/desktop-tauri/src/components/ShortcutCapture.test.tsx +++ b/apps/desktop-tauri/src/components/ShortcutCapture.test.tsx @@ -77,6 +77,27 @@ describe("ShortcutCapture", () => { expect(onCommit).toHaveBeenCalledWith("Ctrl+Shift+K"); }); + it("uses a custom composer, recording hint and empty label", async () => { + const compose = (event: KeyboardEvent) => + event.key === "ArrowLeft" ? "left" : null; + const { onCommit } = await mount({ + compose, + recordingHint: "custom hint", + emptyLabel: "disabled", + }); + expect(screen.getByText("disabled")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Record" })); + expect(screen.getByText("custom hint")).toBeInTheDocument(); + + // A bare key is not a valid accelerator, but the custom composer accepts it. + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowLeft", code: "ArrowLeft", bubbles: true }), + ); + }); + expect(onCommit).toHaveBeenCalledWith("left"); + }); + it("cancels on Escape without committing", async () => { const { onCommit } = await mount(); fireEvent.click(screen.getByRole("button", { name: "Record" })); diff --git a/apps/desktop-tauri/src/components/ShortcutCapture.tsx b/apps/desktop-tauri/src/components/ShortcutCapture.tsx index 0791b9594d..db8cbbd88d 100644 --- a/apps/desktop-tauri/src/components/ShortcutCapture.tsx +++ b/apps/desktop-tauri/src/components/ShortcutCapture.tsx @@ -6,6 +6,18 @@ interface ShortcutCaptureProps { disabled?: boolean; onCommit: (accelerator: string) => void; onClear: () => void; + /** + * Turns a key event into the committed shortcut string, or null to keep + * recording. Defaults to a Tauri accelerator (modifier + key). Must be a + * stable reference. + */ + compose?: (event: KeyboardEvent) => string | null; + /** Overrides the recording hint text. */ + recordingHint?: string; + /** Overrides the text shown when `value` is empty. */ + emptyLabel?: string; + /** Names the capture group and its action buttons for assistive technology. */ + accessibleLabel?: string; } const MODIFIER_KEYS = new Set([ @@ -65,6 +77,10 @@ export function ShortcutCapture({ disabled, onCommit, onClear, + compose = composeAccelerator, + recordingHint, + emptyLabel, + accessibleLabel, }: ShortcutCaptureProps) { const { t } = useLocale(); const [recording, setRecording] = useState(false); @@ -91,7 +107,7 @@ export function ShortcutCapture({ return; } - const accel = composeAccelerator(e); + const accel = compose(e); if (accel) { stopRecording(); onCommit(accel); @@ -102,7 +118,7 @@ export function ShortcutCapture({ return () => { window.removeEventListener("keydown", handleKey, true); }; - }, [recording, stopRecording, onCommit, onClear]); + }, [recording, stopRecording, onCommit, onClear, compose]); useEffect(() => { if (recording) { @@ -111,11 +127,15 @@ export function ShortcutCapture({ }, [recording]); const chipText = recording - ? t("ShortcutRecordingHint") - : value || t("ShortcutEmptyPlaceholder"); + ? (recordingHint ?? t("ShortcutRecordingHint")) + : value || (emptyLabel ?? t("ShortcutEmptyPlaceholder")); return ( -
+
{chipText}
@@ -133,6 +158,15 @@ export function ShortcutCapture({ type="button" className="shortcut-capture__button" disabled={disabled || recording} + aria-label={ + accessibleLabel + ? `${accessibleLabel}: ${ + recording + ? t("ShortcutRecordingLabel") + : t("ShortcutRecordButton") + }` + : undefined + } onClick={() => setRecording(true)} > {recording ? t("ShortcutRecordingLabel") : t("ShortcutRecordButton")} @@ -141,6 +175,11 @@ export function ShortcutCapture({ type="button" className="shortcut-capture__button shortcut-capture__button--ghost" disabled={disabled || recording || !value} + aria-label={ + accessibleLabel + ? `${accessibleLabel}: ${t("ShortcutClearButton")}` + : undefined + } onClick={onClear} > {t("ShortcutClearButton")} 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..726b130c24 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"; @@ -23,6 +24,18 @@ import { export interface BarChartPoint { label: string; value: number | null; + /** Localized marker for requests excluded from this slot's value. */ + incompleteNote?: string; +} + +/** + * 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 { @@ -35,6 +48,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 +64,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 +99,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 +114,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,14 +169,37 @@ 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)}`} + {[ + p.value == null ? p.label : `${p.label}: ${fmt(p.value)}`, + p.incompleteNote, + ] + .filter(Boolean) + .join(" \u00b7 ")} {isPeak && ( @@ -146,18 +218,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 && (
{data[hover.i].label} {fmt(data[hover.i].value ?? 0)} + {data[hover.i].incompleteNote && ( + {data[hover.i].incompleteNote} + )}
)}
diff --git a/apps/desktop-tauri/src/components/charts/chartGeometry.test.ts b/apps/desktop-tauri/src/components/charts/chartGeometry.test.ts index b4c867b7cd..c55d2ae9ac 100644 --- a/apps/desktop-tauri/src/components/charts/chartGeometry.test.ts +++ b/apps/desktop-tauri/src/components/charts/chartGeometry.test.ts @@ -3,6 +3,8 @@ import { DATE_EDGE_PADDING, WIDTH, getBarCenter, + getBarWidth, + getScrollableChartWidth, shouldRenderCenterMax, } from "./chartGeometry"; @@ -22,4 +24,13 @@ describe("chart axis geometry", () => { 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/icons/ProviderIcon-devpass.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-devpass.svg new file mode 100644 index 0000000000..00bcb52fe8 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-devpass.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-llmman.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-llmman.svg new file mode 100644 index 0000000000..a087c006ca --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-llmman.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-raycast.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-raycast.svg new file mode 100644 index 0000000000..851925356d --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-raycast.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-vercel.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-vercel.svg new file mode 100644 index 0000000000..b8ff338636 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-vercel.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xkiro.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xkiro.svg new file mode 100644 index 0000000000..3dd57adf54 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xkiro.svg @@ -0,0 +1 @@ + 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..082672f4d2 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,14 @@ 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 devpass from "./icons/ProviderIcon-devpass.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"; @@ -38,6 +40,7 @@ import jetbrains from "./icons/ProviderIcon-jetbrains.svg?raw"; import kilo from "./icons/ProviderIcon-kilo.svg?raw"; import kimi from "./icons/ProviderIcon-kimi.svg?raw"; import kiro from "./icons/ProviderIcon-kiro.svg?raw"; +import llmman from "./icons/ProviderIcon-llmman.svg?raw"; import llmproxy from "./icons/ProviderIcon-llmproxy.svg?raw"; import manus from "./icons/ProviderIcon-manus.svg?raw"; import meta from "./icons/ProviderIcon-meta.svg?raw"; @@ -48,18 +51,21 @@ import muse from "./icons/ProviderIcon-muse.svg?raw"; import notion from "./icons/ProviderIcon-notion.svg?raw"; import nous from "./icons/ProviderIcon-nous.svg?raw"; import xai from "./icons/ProviderIcon-xai.svg?raw"; +import xkiro from "./icons/ProviderIcon-xkiro.svg?raw"; import ollama from "./icons/ProviderIcon-ollama.svg?raw"; import opencode from "./icons/ProviderIcon-opencode.svg?raw"; import opencodego from "./icons/ProviderIcon-opencodego.svg?raw"; import openrouter from "./icons/ProviderIcon-openrouter.svg?raw"; import perplexity from "./icons/ProviderIcon-perplexity.svg?raw"; import qoder from "./icons/ProviderIcon-qoder.svg?raw"; +import raycast from "./icons/ProviderIcon-raycast.svg?raw"; import replicate from "./icons/ProviderIcon-replicate.svg?raw"; import sakana from "./icons/ProviderIcon-sakana.svg?raw"; import stepfun from "./icons/ProviderIcon-stepfun.svg?raw"; import sub2api from "./icons/ProviderIcon-sub2api.svg?raw"; import t3chat from "./icons/ProviderIcon-t3chat.svg?raw"; import venice from "./icons/ProviderIcon-venice.svg?raw"; +import vercel from "./icons/ProviderIcon-vercel.svg?raw"; import vertexai from "./icons/ProviderIcon-vertexai.svg?raw"; import warp from "./icons/ProviderIcon-warp.svg?raw"; import windsurf from "./icons/ProviderIcon-windsurf.svg?raw"; @@ -94,6 +100,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 +109,14 @@ 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), + devpass: tint(devpass), fireworks: tint(fireworks), aiand: tint(aiand), + aixy: tint(aixy), clinepass: tint(clinepass), longcat: tint(longcat), neuralwatt: tint(neuralwatt), @@ -126,6 +134,7 @@ const RAW: Record = { kilo: tint(kilo), kimi: tint(kimi), kiro: tint(kiro), + llmman: tint(llmman), llmproxy: tint(llmproxy), manus: tint(manus), meta: tint(meta), @@ -134,6 +143,7 @@ const RAW: Record = { notion: tint(notion), nous: tint(nous), xai: tint(xai), + xkiro: tint(xkiro), mistral: tint(mistral), muse: tint(muse), ollama: tint(ollama), @@ -142,12 +152,14 @@ const RAW: Record = { openrouter: tint(openrouter), perplexity: tint(perplexity), qoder: tint(qoder), + raycast: tint(raycast), replicate: tint(replicate), sakana: tint(sakana), stepfun: tint(stepfun), sub2api: tint(sub2api), t3chat: tint(t3chat), venice: tint(venice), + vercel: tint(vercel), vertexai: tint(vertexai), warp: tint(warp), windsurf: tint(windsurf), @@ -162,44 +174,50 @@ 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 }, + devpass: { id: "devpass", brandColor: "#2563eb", fallbackLetter: "D", svgPath: RAW.devpass }, 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 }, + llmman: { id: "llmman", brandColor: "#6CC5B0", fallbackLetter: "L", svgPath: RAW.llmman }, 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,30 +234,33 @@ 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 }, + raycast: { id: "raycast", brandColor: "#FF6363", fallbackLetter: "R", svgPath: RAW.raycast }, 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 }, + vercel: { id: "vercel", brandColor: "#737373", fallbackLetter: "V", svgPath: RAW.vercel }, 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 }, nous: { id: "nous", brandColor: "#D6A55C", fallbackLetter: "N", svgPath: RAW.nous }, xai: { id: "xai", brandColor: "#8e8e93", fallbackLetter: "X", svgPath: RAW.xai }, + xkiro: { id: "xkiro", brandColor: "#52c99b", fallbackLetter: "X", svgPath: RAW.xkiro }, meta: { id: "meta", brandColor: "#0467DF", fallbackLetter: "M", svgPath: RAW.meta }, }; @@ -308,6 +329,7 @@ const ALIASES: Record = { // xai is its own Management API provider (not an alias of consumer Grok). "x.ai": "xai", "x-ai": "xai", + "x-kiro": "xkiro", supergrok: "grok", "super-grok": "grok", "eleven labs": "elevenlabs", diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 88860e087d..f1535d82a2 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; @@ -149,10 +152,12 @@ function settings(overrides: Partial = {}): SettingsSnapshot { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarColorPace: false, showAsUsed: true, showAllTokenAccountsInMenu: false, enableAnimations: true, @@ -165,6 +170,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "Ctrl+Shift+U", + switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", theme: "dark", @@ -230,7 +236,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,35 +332,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\n34.07 EUR / 255.00 EUR · 220.93 EUR remaining", + ); + 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 () => { @@ -388,6 +483,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 +507,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 +528,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..d30e8f9577 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, windowDetailText } 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,24 +214,34 @@ 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 detailText = windowDetailText(rateWindow); const resetSuffix = resetText ? `\n${resetText}` : ""; + const detailSuffix = detailText ? `\n${detailText}` : ""; const inlineReset = resetText ? inlineResetTime(resetText, rateWindow.resetsAt, resetRelative) : null; @@ -237,7 +254,9 @@ function ProviderPill({ title={ state.isProblem ? `${provider.displayName}: ${stateLabel}` - : `${provider.displayName}: ${label} ${displaySuffix}${resetSuffix}` + : informational + ? `${provider.displayName}: ${infoText}${resetSuffix}` + : `${provider.displayName}: ${label} ${displaySuffix}${resetSuffix}${detailSuffix}` } data-tauri-drag-region style={{ "--brand": brand } as CSSProperties} @@ -382,7 +401,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 +423,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 +543,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/useProviderSwitcherKeys.test.tsx b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.test.tsx new file mode 100644 index 0000000000..07c57ee8ce --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useProviderSwitcherKeys } from "./useProviderSwitcherKeys"; + +const IDS = ["codex", "claude", "gemini"]; + +function setup(selectedProviderId: string | null = null) { + const onSelect = vi.fn(); + const view = renderHook( + (props: { selected: string | null }) => + useProviderSwitcherKeys({ + providerIds: IDS, + selectedProviderId: props.selected, + onSelect, + }), + { initialProps: { selected: selectedProviderId } }, + ); + return { onSelect, ...view }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("useProviderSwitcherKeys", () => { + it("moves with Right and Left, wrapping through Overview", () => { + const { onSelect, rerender } = setup(null); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + + rerender({ selected: "gemini" }); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).toHaveBeenLastCalledWith(null); + + rerender({ selected: null }); + fireEvent.keyDown(window, { key: "ArrowLeft", code: "ArrowLeft" }); + expect(onSelect).toHaveBeenLastCalledWith("gemini"); + }); + + it("selects the Nth segment with Ctrl+digit, Overview first", () => { + const { onSelect } = setup("codex"); + fireEvent.keyDown(window, { key: "1", code: "Digit1", ctrlKey: true }); + expect(onSelect).toHaveBeenLastCalledWith(null); + fireEvent.keyDown(window, { key: "3", code: "Digit3", ctrlKey: true }); + expect(onSelect).toHaveBeenLastCalledWith("claude"); + }); + + it("prevents default only when it handles the key", () => { + setup(null); + expect(fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" })).toBe(false); + expect(fireEvent.keyDown(window, { key: "9", code: "Digit9", ctrlKey: true })).toBe(true); + expect(fireEvent.keyDown(window, { key: "x", code: "KeyX" })).toBe(true); + }); + + it("ignores keys typed into inputs and sliders", () => { + const { onSelect } = setup(null); + const input = document.createElement("input"); + input.type = "range"; + document.body.append(input); + fireEvent.keyDown(input, { key: "ArrowRight", code: "ArrowRight" }); + const field = document.createElement("input"); + document.body.append(field); + fireEvent.keyDown(field, { key: "ArrowLeft", code: "ArrowLeft" }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("ignores keys while a grid drag is active", () => { + const { onSelect } = setup(null); + const dragging = document.createElement("button"); + dragging.className = "provider-grid__item provider-grid__item--dragging"; + document.body.append(dragging); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("ignores events another handler already consumed", () => { + const { onSelect } = setup(null); + const consume = (event: KeyboardEvent) => event.preventDefault(); + document.addEventListener("keydown", consume); + fireEvent.keyDown(document.body, { key: "ArrowRight", code: "ArrowRight" }); + document.removeEventListener("keydown", consume); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("follows custom shortcuts instead of the defaults", () => { + const onSelect = vi.fn(); + renderHook(() => + useProviderSwitcherKeys({ + providerIds: IDS, + selectedProviderId: null, + onSelect, + shortcuts: { next: "shift+right", previous: "none", select2: "alt+2" }, + }), + ); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + fireEvent.keyDown(window, { key: "ArrowLeft", code: "ArrowLeft" }); + fireEvent.keyDown(window, { key: "2", code: "Digit2", ctrlKey: true }); + expect(onSelect).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight", shiftKey: true }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + fireEvent.keyDown(window, { key: "2", code: "Digit2", altKey: true }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + expect(onSelect).toHaveBeenCalledTimes(2); + }); + + it("uses updated shortcuts without reinstalling the key listener", () => { + const onSelect = vi.fn(); + const { rerender } = renderHook( + ({ shortcuts }: { shortcuts: Record }) => + useProviderSwitcherKeys({ + providerIds: IDS, + selectedProviderId: null, + onSelect, + shortcuts, + }), + { initialProps: { shortcuts: { next: "shift+right" } } }, + ); + + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight", shiftKey: true }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + + rerender({ shortcuts: { next: "alt+2" } }); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight", shiftKey: true }); + fireEvent.keyDown(window, { key: "2", code: "Digit2", altKey: true }); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + }); + + it("falls back to the defaults for an invalid stored map", () => { + const onSelect = vi.fn(); + renderHook(() => + useProviderSwitcherKeys({ + providerIds: IDS, + selectedProviderId: null, + onSelect, + shortcuts: { next: "left" }, + }), + ); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + }); + + it("stops listening after unmount", () => { + const { onSelect, unmount } = setup(null); + unmount(); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts new file mode 100644 index 0000000000..8602baf1c8 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts @@ -0,0 +1,55 @@ +import { useEffect, useMemo, useRef } from "react"; +import { + matchSwitcherAction, + resolveSwitcherShortcuts, + resolveSwitcherTarget, +} from "../lib/switcherShortcuts"; + +const EDITABLE_SELECTOR = + 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="slider"]'; +const DRAGGING_SELECTOR = ".provider-grid__item--dragging"; + +/** + * Provider-switcher keyboard navigation for the tray flyout (the retired + * pop-out layout no longer has a surface). `providerIds` must be the list the + * grid displays, in display order. Keys are ignored while focus is in a text field, select or + * slider (the zoom slider uses the arrow keys) or while a grid drag is active. + */ +export function useProviderSwitcherKeys({ + providerIds, + selectedProviderId, + onSelect, + shortcuts, +}: { + providerIds: readonly string[]; + selectedProviderId: string | null; + onSelect: (providerId: string | null) => void; + /** `settings.switcherShortcuts`; defaults apply when omitted or invalid. */ + shortcuts?: Readonly>; +}) { + const mapping = useMemo(() => resolveSwitcherShortcuts(shortcuts), [shortcuts]); + const latest = useRef({ providerIds, selectedProviderId, onSelect, mapping }); + latest.current = { providerIds, selectedProviderId, onSelect, mapping }; + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const target = event.target; + if (target instanceof Element && target.closest(EDITABLE_SELECTOR)) return; + if (document.querySelector(DRAGGING_SELECTOR)) return; + const current = latest.current; + const action = matchSwitcherAction(event, current.mapping); + if (action === null) return; + const next = resolveSwitcherTarget( + action, + current.providerIds, + current.selectedProviderId, + ); + if (next === null) return; + event.preventDefault(); + current.onSelect(next.providerId); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); +} 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/useStayAwakeStatus.ts b/apps/desktop-tauri/src/hooks/useStayAwakeStatus.ts new file mode 100644 index 0000000000..c7d67e58c2 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useStayAwakeStatus.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { getStayAwakeStatus } from "../lib/tauri"; + +const STAY_AWAKE_CHANGED = "stay-awake-changed"; + +export function useStayAwakeStatus(): boolean { + const [held, setHeld] = useState(false); + + useEffect(() => { + let cancelled = false; + let updates = 0; + let unlisten = () => {}; + + const initialize = async () => { + try { + unlisten = await listen(STAY_AWAKE_CHANGED, (event) => { + updates += 1; + if (!cancelled) setHeld(event.payload); + }); + } catch { + // The status query below still provides a useful initial value. + } + if (cancelled) { + unlisten(); + return; + } + + const observedUpdates = updates; + try { + const current = await getStayAwakeStatus(); + if (!cancelled && updates === observedUpdates) setHeld(current); + } catch { + // Keep the last event value if the status query is unavailable. + } + }; + + void initialize(); + return () => { + cancelled = true; + unlisten(); + }; + }, []); + + return held; +} diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index 35d4260449..8e4784997d 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -16,6 +16,7 @@ import { useProviders } from "./useProviders"; import { useSettings } from "./useSettings"; import { useUpdateState } from "./useUpdateState"; import { useLocale } from "./useLocale"; +import { useProviderSwitcherKeys } from "./useProviderSwitcherKeys"; import { useSurfaceTarget } from "./useSurfaceMode"; import { useTrayPanelLayout } from "./useTrayPanelLayout"; import type { MenuFooterRow } from "../components/MenuSurface"; @@ -147,6 +148,10 @@ export function useTrayPanelController(state: BootstrapState) { return hydrateProviderSlots(denseProviderSlots, providersById); }, [denseProviderSlots, expectsDenseOverview, providersById, sorted]); + // What the switcher grid displays: the dense overview shows hydrated slots + // (with placeholders); everything else shows the sorted providers. + const gridProviders = expectsDenseOverview ? denseTrayProviders : sorted; + useEffect(() => { setSelectedProviderId(initialProviderId); }, [initialProviderId]); @@ -335,6 +340,16 @@ export function useTrayPanelController(state: BootstrapState) { }, [], ); + const gridProviderIds = useMemo( + () => gridProviders.map((provider) => provider.providerId), + [gridProviders], + ); + useProviderSwitcherKeys({ + providerIds: gridProviderIds, + selectedProviderId, + onSelect: handleGridClick, + shortcuts: settings.switcherShortcuts, + }); const handleReorder = useCallback((orderedIds: string[]) => { void reorderProviders(orderedIds).catch(() => {}); }, []); @@ -359,8 +374,7 @@ export function useTrayPanelController(state: BootstrapState) { trayScaleFillPercent, handleTrayScaleChange, sorted, - denseTrayProviders, - expectsDenseOverview, + gridProviders, selectedProviderId, gridExpanded, setGridExpanded, 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..e6968770f3 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -58,6 +58,10 @@ export const ALL_LOCALE_KEYS = [ "PredictivePaceWarningsHelper", "PredictivePaceWarningTitle", "PredictivePaceWarningBody", + "CredentialExpiryNotifications", + "CredentialExpiryNotificationsHelper", + "CredentialExpiryTitle", + "CredentialExpiryBody", "UsageDisplay", "ShowUsageAsUsed", "ShowUsageAsUsedHelper", @@ -76,6 +80,8 @@ export const ALL_LOCALE_KEYS = [ "ProviderDisabled", "ProviderInfo", "ProviderUsage", + "AtlasCloudAvailableBalance", + "AtlasCloudBalance", "PanelUsageDetails", "AuthType", "DataSource", @@ -96,6 +102,11 @@ export const ALL_LOCALE_KEYS = [ "WayfinderGatewayLabel", "WayfinderGatewayHelp", "WayfinderGatewayStatus", + "BifrostGatewayTitle", + "BifrostGatewayHelp", + "AixyGatewayTitle", + "AixyGatewayLabel", + "AixyGatewayHelp", "WayfinderModels", "WayfinderRequests", "WayfinderTokens", @@ -225,6 +236,7 @@ export const ALL_LOCALE_KEYS = [ "TrayOpenCodexBar", "TrayPopOutDashboard", "TrayShowFloatBar", + "TrayStayAwakeActive", "TrayRefreshAll", "TrayProviders", "TraySettings", @@ -284,6 +296,10 @@ export const ALL_LOCALE_KEYS = [ "MenuBarMetric", "MenuBarMetricHelper", "UsageSource", + "ProviderZedBrowserSession", + "ProviderZedUsageSourceAutoHelp", + "ProviderZedUsageSourceApiHelp", + "ProviderZedUsageSourceWebHelp", "ProviderNoCodexAccountsDetected", "ProviderCodexAutoImportHelp", "ProviderCodexHistoryHelp", @@ -296,6 +312,11 @@ export const ALL_LOCALE_KEYS = [ "ProviderCodexNotRunningHelp", "ProviderCookieSource", "CookieSourceManual", + "ProviderRaycastAutoImportHelp", + "ProviderRaycastManualCookieHelp", + "ProviderRaycastCookiesDisabled", + "ProviderManualCookieMissing", + "ProviderUseAutomaticCookies", "ProviderRegion", "ProviderClaudeCookies", "ProviderClaudeCookiesHelp", @@ -395,7 +416,6 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendTitle", "UsageSpendCaption", "UsageSpendModels", - "UsageSpendAllTime", "UsageSpendOpenCodexImport", "UsageSpendHideNativeCodex", "UsageSpendSpend", @@ -415,8 +435,14 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendHourlyActivity", "UsageSpendRequests", "UsageSpendTokens", + "UsageSpendKnownSubtotal", + "IncompleteRequestsLabel", + "IncompleteRequestsDetail", "UsageSpendAllTimeHistory", "UsageSpendCustomPricing", + "UsageSpendDailyLedger", + "UsageSpendDailyLedgerHelper", + "UsageSpendNoDailyData", "OverviewSpendTitle", "OverviewSpendProviderCoverage", "OverviewSpendEstimate", @@ -434,14 +460,25 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendCol30d", "UsageSpendColCurrency", "UsageSpendColSource", + "UsageSpendColDate", + "UsageSpendColCost", + "UsageSpendColTokens", "UsageSpendShareFailed", "UsageSpendShareEmpty", "UsageSpendShare", "UsageSpendCopyJson", "UsageSpendSaveJson", + "UsageSpendDailySpend", + "UsageSpendTrendDaily", + "UsageSpendTrendHourly", + "UsageSpendClearSelectedDay", + "UsageSpendSelectedDayScope", + "UsageSpendPartialModelBreakdown", "AgentSessionsTitle", "AgentSessionsEnableLabel", "AgentSessionsEnableHelper", + "AgentSessionsStayAwakeLabel", + "AgentSessionsStayAwakeHelper", "AgentSessionsSshHostsLabel", "AgentSessionsSshHostsHelper", "AgentSessionsLoading", @@ -507,6 +544,8 @@ export const ALL_LOCALE_KEYS = [ "SectionUsageRendering", "SectionTime", "SectionLanguage", + "PreferredCurrencyLabel", + "PreferredCurrencyHelper", "SectionCredentialsSecurity", "SectionDebug", "SectionApiKeys", @@ -529,23 +568,46 @@ export const ALL_LOCALE_KEYS = [ "CriticalUsageWarningHelper", "GlobalShortcutFieldLabel", "GlobalShortcutToggleHelper", + "SectionPreferencesTransfer", + "PreferencesTransferCaption", + "PreferencesExportButton", + "PreferencesImportButton", + "PreferencesExportSuccess", + "PreferencesImportSuccess", "ShortcutRecordButton", "ShortcutRecordingLabel", "ShortcutRecordingHint", "ShortcutClearButton", "ShortcutEmptyPlaceholder", + "SwitcherShortcutsTitle", + "SwitcherShortcutsHelper", + "SwitcherShortcutPrevious", + "SwitcherShortcutNext", + "SwitcherShortcutSelect", + "SwitcherShortcutNone", + "SwitcherShortcutRecordingHint", + "SwitcherShortcutReset", + "SwitcherShortcutErrorUnknown", + "SwitcherShortcutErrorDuplicate", + "SwitcherShortcutErrorReserved", + "SwitcherShortcutErrorInvalid", "NotificationTestSound", "NotificationTestSoundPlaying", "TrayIconModeLabel", "TrayIconModeHelper", "TrayIconModeSingle", "TrayIconModePerProvider", + "TrayIconModeStacked", + "StackedTrayTopProvider", + "StackedTrayBottomProvider", "ShowProviderIcons", "ShowProviderIconsHelper", "PreferHighestUsage", "PreferHighestUsageHelper", "ShowPercentInTray", "ShowPercentInTrayHelper", + "ColorPaceInTray", + "ColorPaceInTrayHelper", "DisplayModeLabel", "DisplayModeHelper", "DisplayModeDetailed", @@ -624,6 +686,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 +708,6 @@ export const ALL_LOCALE_KEYS = [ "PanelMenu", "PanelCopied", "PanelToday", - "PanelThirtyDayCost", - "PanelThirtyDayTokens", "PanelLatestTokens", "PanelThirtyDayCostHistogram", "PanelTopModelPrefix", @@ -651,10 +724,10 @@ export const ALL_LOCALE_KEYS = [ "PanelFiveHours", "PanelTodayBudget", "PanelReserveSuffix", + "PanelBlockedByMonthlyLimit", "PanelReserveLastsUntilReset", "PanelReserveRunsOutInDaysHours", "PanelReserveRunsOutInHours", - "FloatBarThirtyDayShort", "FloatBarNoProviders", "FloatBarRemainingSuffix", "FloatBarShowCost", @@ -746,6 +819,11 @@ export const ALL_LOCALE_KEYS = [ "OpenCodeGoWorkspaceTitle", "OpenCodeGoWorkspaceLabel", "OpenCodeGoWorkspaceHelp", + "MuseBrowserTeamTitle", + "MuseBrowserTeamChoose", + "MuseBrowserTeamHelp", + "MuseBrowserTeamNoTeams", + "MuseBrowserTeamUnavailable", "OpenRouterManagementKeyTitle", "OpenRouterManagementKeyLabel", "OpenRouterManagementKeyHelp", @@ -879,6 +957,14 @@ export const ALL_LOCALE_KEYS = [ "Sub2ApiBaseUrlLabel", "Sub2ApiBaseUrlPlaceholder", "Sub2ApiBaseUrlHelp", + "LlmProxyTitle", + "LlmProxyBaseUrlLabel", + "LlmProxyBaseUrlPlaceholder", + "LlmProxyBaseUrlHelp", + "LLMManApiTitle", + "LLMManBaseUrlLabel", + "LLMManBaseUrlPlaceholder", + "LLMManBaseUrlHelp", // Tray icon visibility (Windows 11 hidden-icons overflow) "PromoteTrayIconLabel", @@ -888,6 +974,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 +994,35 @@ 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", + + // Opt-in provider breakdowns (upstream 0.67.0) + "ProviderOptionalDetailsTitle", + "ProviderLiteLLMModelActivity", + "ProviderLiteLLMModelActivityHelper", + "ProviderClaudeWorkspaceSpend", + "ProviderClaudeWorkspaceSpendHelper", ] 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/incompleteRequests.test.ts b/apps/desktop-tauri/src/lib/incompleteRequests.test.ts new file mode 100644 index 0000000000..5fa0c70028 --- /dev/null +++ b/apps/desktop-tauri/src/lib/incompleteRequests.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { incompleteRequestsTooltip } from "./incompleteRequests"; + +const t = (key: string) => + key === "IncompleteRequestsDetail" ? "{} requests excluded" : "Incomplete"; + +describe("incompleteRequestsTooltip", () => { + it("returns null when nothing was excluded", () => { + expect(incompleteRequestsTooltip(t, undefined)).toBeNull(); + expect(incompleteRequestsTooltip(t, 0)).toBeNull(); + }); + + it("names the marker and the excluded request count", () => { + expect(incompleteRequestsTooltip(t, 3)).toBe("Incomplete · 3 requests excluded"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/incompleteRequests.ts b/apps/desktop-tauri/src/lib/incompleteRequests.ts new file mode 100644 index 0000000000..718a7a76b1 --- /dev/null +++ b/apps/desktop-tauri/src/lib/incompleteRequests.ts @@ -0,0 +1,16 @@ +import type { LocaleKey } from "../i18n/keys"; + +type Translate = (key: LocaleKey) => string; + +/** + * Tooltip text for Claude requests that only produced a preliminary proxy + * usage row (upstream 0.60.5 #3688). Returns null when there is nothing to + * flag so callers can skip the marker. + */ +export function incompleteRequestsTooltip( + t: Translate, + count: number | null | undefined, +): string | null { + if (count == null || count <= 0) return null; + return `${t("IncompleteRequestsLabel")} \u00b7 ${t("IncompleteRequestsDetail").replace("{}", String(count))}`; +} 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/switcherShortcuts.test.ts b/apps/desktop-tauri/src/lib/switcherShortcuts.test.ts new file mode 100644 index 0000000000..67ec2bc950 --- /dev/null +++ b/apps/desktop-tauri/src/lib/switcherShortcuts.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SWITCHER_SHORTCUTS, + SWITCHER_ACTIONS, + SwitcherShortcutError, + matchSwitcherAction, + normalizeShortcut, + resolveSwitcherShortcuts, + resolveSwitcherTarget, + shortcutFromEvent, + switcherShortcutOverrides, + validateSwitcherShortcuts, + type SwitcherKeyEvent, +} from "./switcherShortcuts"; + +function codeOf(error: unknown): string | undefined { + return error instanceof SwitcherShortcutError ? error.code : undefined; +} + +function failure(run: () => unknown): string | undefined { + try { + run(); + } catch (error) { + return codeOf(error); + } + return undefined; +} + +function keyEvent(init: Partial): SwitcherKeyEvent { + return { + key: "", + code: "", + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, + ...init, + }; +} + +describe("defaults", () => { + it("cover every action with upstream keys (cmd becomes ctrl)", () => { + expect(Object.keys(DEFAULT_SWITCHER_SHORTCUTS)).toEqual([...SWITCHER_ACTIONS]); + expect(DEFAULT_SWITCHER_SHORTCUTS.previous).toBe("left"); + expect(DEFAULT_SWITCHER_SHORTCUTS.next).toBe("right"); + expect(DEFAULT_SWITCHER_SHORTCUTS.select9).toBe("ctrl+9"); + }); + + it("are valid and unchanged by validation", () => { + expect(validateSwitcherShortcuts()).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + expect(validateSwitcherShortcuts({})).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + }); +}); + +describe("normalizeShortcut", () => { + it("orders modifiers canonically and lowercases", () => { + expect(normalizeShortcut("Shift+Ctrl+Right")).toBe("ctrl+shift+right"); + expect(normalizeShortcut(" alt + ctrl + 2 ")).toBe("ctrl+alt+2"); + expect(normalizeShortcut("Alt+,")).toBe("alt+,"); + }); + + it("aliases cmd to ctrl", () => { + expect(normalizeShortcut("alt+cmd+2")).toBe("ctrl+alt+2"); + expect(normalizeShortcut("cmd+3")).toBe("ctrl+3"); + }); + + it("accepts none", () => { + expect(normalizeShortcut("NONE")).toBe("none"); + }); + + it("rejects malformed shortcuts", () => { + for (const bad of ["", "ctrl+", "ctrl+ab", "ctrl+ctrl+1", "ctrl+cmd+1", "meta+1", "ctrl+f1", "ctrl+up", "alt+f4", "alt+tab", "ctrl+none"]) { + expect(failure(() => normalizeShortcut(bad)), bad).toBe("invalid"); + } + }); + + it("rejects reserved Windows shortcuts", () => { + for (const reserved of ["ctrl+r", "cmd+q", "ctrl+,", "ctrl+w"]) { + expect(failure(() => normalizeShortcut(reserved)), reserved).toBe("reserved"); + } + }); + + it("requires a non-shift modifier for letters, digits and comma", () => { + for (const bare of ["a", "1", ",", "shift+a", "shift+1"]) { + expect(failure(() => normalizeShortcut(bare)), bare).toBe("reserved"); + } + expect(normalizeShortcut("alt+a")).toBe("alt+a"); + expect(normalizeShortcut("ctrl+shift+1")).toBe("ctrl+shift+1"); + expect(normalizeShortcut("shift+ctrl+r")).toBe("ctrl+shift+r"); + }); + + it("allows arrows with any or no modifiers", () => { + expect(normalizeShortcut("left")).toBe("left"); + expect(normalizeShortcut("shift+right")).toBe("shift+right"); + }); +}); + +describe("validateSwitcherShortcuts", () => { + it("overlays overrides and normalizes them", () => { + const resolved = validateSwitcherShortcuts({ select2: "alt+cmd+2", next: "shift+right" }); + expect(resolved.select2).toBe("ctrl+alt+2"); + expect(resolved.next).toBe("shift+right"); + expect(resolved.previous).toBe("left"); + }); + + it("rejects unknown actions", () => { + expect(failure(() => validateSwitcherShortcuts({ select10: "ctrl+0" }))).toBe("unknown"); + }); + + it("rejects duplicates, including against untouched defaults", () => { + expect(failure(() => validateSwitcherShortcuts({ next: "left" }))).toBe("duplicate"); + expect(failure(() => validateSwitcherShortcuts({ select1: "cmd+2" }))).toBe("duplicate"); + }); + + it("lets none disable an action and free its key", () => { + const resolved = validateSwitcherShortcuts({ previous: "none", next: "none" }); + expect(resolved.previous).toBe("none"); + expect(validateSwitcherShortcuts({ previous: "none", next: "left" }).next).toBe("left"); + }); + + it("allows several disabled actions at once", () => { + expect(() => validateSwitcherShortcuts({ select1: "none", select2: "none" })).not.toThrow(); + }); +}); + +describe("shortcutFromEvent", () => { + it("maps arrows and modifiers", () => { + expect(shortcutFromEvent(keyEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe("left"); + expect( + shortcutFromEvent(keyEvent({ key: "ArrowRight", code: "ArrowRight", ctrlKey: true, shiftKey: true })), + ).toBe("ctrl+shift+right"); + }); + + it("uses the physical key so shifted digits stay digits", () => { + expect(shortcutFromEvent(keyEvent({ key: "!", code: "Digit1", ctrlKey: true, shiftKey: true }))).toBe( + "ctrl+shift+1", + ); + expect(shortcutFromEvent(keyEvent({ key: "A", code: "KeyA", altKey: true }))).toBe("alt+a"); + }); + + it("uses the logical letter reported by the active keyboard layout", () => { + const event = keyEvent({ key: "z", code: "KeyY", ctrlKey: true }); + expect(shortcutFromEvent(event)).toBe("ctrl+z"); + expect( + matchSwitcherAction(event, validateSwitcherShortcuts({ select1: "ctrl+z" })), + ).toBe("select1"); + }); + + it("maps comma and numpad digits via key", () => { + expect(shortcutFromEvent(keyEvent({ key: ",", code: "Comma", ctrlKey: true }))).toBe("ctrl+,"); + expect( + shortcutFromEvent(keyEvent({ key: "<", code: "Comma", ctrlKey: true, shiftKey: true })), + ).toBe("ctrl+shift+,"); + expect(shortcutFromEvent(keyEvent({ key: "3", code: "Numpad3", ctrlKey: true }))).toBe("ctrl+3"); + }); + + it("returns null for the Windows key and unsupported keys", () => { + expect(shortcutFromEvent(keyEvent({ key: "1", code: "Digit1", metaKey: true }))).toBeNull(); + expect(shortcutFromEvent(keyEvent({ key: "Control", code: "ControlLeft", ctrlKey: true }))).toBeNull(); + expect(shortcutFromEvent(keyEvent({ key: "ArrowUp", code: "ArrowUp" }))).toBeNull(); + expect(shortcutFromEvent(keyEvent({ key: "F4", code: "F4", altKey: true }))).toBeNull(); + }); +}); + +describe("matchSwitcherAction", () => { + it("matches defaults", () => { + expect(matchSwitcherAction(keyEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe("previous"); + expect(matchSwitcherAction(keyEvent({ key: "ArrowRight", code: "ArrowRight" }))).toBe("next"); + expect(matchSwitcherAction(keyEvent({ key: "3", code: "Digit3", ctrlKey: true }))).toBe("select3"); + }); + + it("does not match extra or missing modifiers", () => { + expect(matchSwitcherAction(keyEvent({ key: "ArrowLeft", code: "ArrowLeft", shiftKey: true }))).toBeNull(); + expect(matchSwitcherAction(keyEvent({ key: "3", code: "Digit3" }))).toBeNull(); + expect(matchSwitcherAction(keyEvent({ key: "3", code: "Digit3", ctrlKey: true, altKey: true }))).toBeNull(); + }); + + it("honors a custom mapping and disabled actions", () => { + const mapping = validateSwitcherShortcuts({ previous: "none", next: "shift+right" }); + expect(matchSwitcherAction(keyEvent({ key: "ArrowLeft", code: "ArrowLeft" }), mapping)).toBeNull(); + expect( + matchSwitcherAction(keyEvent({ key: "ArrowRight", code: "ArrowRight", shiftKey: true }), mapping), + ).toBe("next"); + }); + + it("matches shifted comma by its physical key code", () => { + const mapping = validateSwitcherShortcuts({ select1: "ctrl+shift+," }); + expect( + matchSwitcherAction( + keyEvent({ key: "<", code: "Comma", ctrlKey: true, shiftKey: true }), + mapping, + ), + ).toBe("select1"); + }); +}); + +describe("resolveSwitcherTarget", () => { + const ids = ["codex", "claude", "gemini"]; + + it("moves Overview -> providers -> Overview with wraparound", () => { + expect(resolveSwitcherTarget("next", ids, null)).toEqual({ providerId: "codex" }); + expect(resolveSwitcherTarget("next", ids, "gemini")).toEqual({ providerId: null }); + expect(resolveSwitcherTarget("previous", ids, null)).toEqual({ providerId: "gemini" }); + expect(resolveSwitcherTarget("previous", ids, "codex")).toEqual({ providerId: null }); + }); + + it("treats an unknown selection as Overview", () => { + expect(resolveSwitcherTarget("next", ids, "gone")).toEqual({ providerId: "codex" }); + }); + + it("does nothing when there are no providers", () => { + expect(resolveSwitcherTarget("next", [], null)).toBeNull(); + expect(resolveSwitcherTarget("previous", [], null)).toBeNull(); + }); + + it("selectN counts Overview as 1 and ignores positions past the end", () => { + expect(resolveSwitcherTarget("select1", ids, "codex")).toEqual({ providerId: null }); + expect(resolveSwitcherTarget("select2", ids, null)).toEqual({ providerId: "codex" }); + expect(resolveSwitcherTarget("select4", ids, null)).toEqual({ providerId: "gemini" }); + expect(resolveSwitcherTarget("select5", ids, null)).toBeNull(); + }); +}); + +describe("resolveSwitcherShortcuts", () => { + it("returns the defaults for a missing or invalid map", () => { + expect(resolveSwitcherShortcuts()).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + expect(resolveSwitcherShortcuts({})).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + expect(resolveSwitcherShortcuts({ next: "left" })).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + expect(resolveSwitcherShortcuts({ bogus: "ctrl+1" })).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + }); + + it("accepts the fully resolved map the backend sends", () => { + const stored = { ...DEFAULT_SWITCHER_SHORTCUTS, next: "none", select2: "ctrl+alt+2" }; + expect(resolveSwitcherShortcuts(stored)).toEqual(stored); + }); +}); + +describe("switcherShortcutOverrides", () => { + it("keeps only entries that differ from the defaults", () => { + expect(switcherShortcutOverrides(DEFAULT_SWITCHER_SHORTCUTS)).toEqual({}); + expect( + switcherShortcutOverrides(validateSwitcherShortcuts({ next: "none", select2: "alt+2" })), + ).toEqual({ next: "none", select2: "alt+2" }); + }); +}); diff --git a/apps/desktop-tauri/src/lib/switcherShortcuts.ts b/apps/desktop-tauri/src/lib/switcherShortcuts.ts new file mode 100644 index 0000000000..662c56f2e6 --- /dev/null +++ b/apps/desktop-tauri/src/lib/switcherShortcuts.ts @@ -0,0 +1,223 @@ +/** + * Provider-switcher keyboard shortcuts (upstream CodexBar 0.67.0, + * `ProviderSwitcherShortcuts`). Shortcuts are menu-local strings such as + * `left`, `ctrl+3` or `ctrl+shift+right`; `none` disables an action. + * + * Grammar is upstream's, with one Windows difference: `cmd` is accepted as an + * alias for `ctrl` and normalized to `ctrl`, so documents exported from macOS + * stay portable. Windows reserves `ctrl+r`, `ctrl+q`, `ctrl+,` and `ctrl+w` + * (Refresh, Quit, Settings and window close). `alt+f4` and `alt+tab` need no entry: F4 and + * Tab are outside the grammar, so they are rejected as invalid. + * + * The backend validates the stored map with the same rules + * (`rust/src/switcher_shortcuts.rs`); keep the two in step. + */ + +export const SWITCHER_ACTIONS = [ + "previous", + "next", + "select1", + "select2", + "select3", + "select4", + "select5", + "select6", + "select7", + "select8", + "select9", +] as const; + +export type SwitcherAction = (typeof SWITCHER_ACTIONS)[number]; +export type SwitcherShortcutMap = Record; + +export const SWITCHER_SHORTCUT_NONE = "none"; + +export const DEFAULT_SWITCHER_SHORTCUTS: Readonly = { + previous: "left", + next: "right", + select1: "ctrl+1", + select2: "ctrl+2", + select3: "ctrl+3", + select4: "ctrl+4", + select5: "ctrl+5", + select6: "ctrl+6", + select7: "ctrl+7", + select8: "ctrl+8", + select9: "ctrl+9", +}; + +export type SwitcherShortcutErrorCode = + | "unknown" + | "duplicate" + | "reserved" + | "invalid"; + +/** `code` selects the localized message shown by the editor. */ +export class SwitcherShortcutError extends Error { + readonly code: SwitcherShortcutErrorCode; + + constructor(code: SwitcherShortcutErrorCode) { + super(`switcher shortcut error: ${code}`); + this.name = "SwitcherShortcutError"; + this.code = code; + } +} + +const MODIFIER_ORDER = ["ctrl", "alt", "shift"] as const; +const NAMED_KEYS = new Set(["left", "right", ","]); +const RESERVED = new Set(["ctrl+r", "ctrl+q", "ctrl+,", "ctrl+w"]); + +function isSwitcherAction(value: string): value is SwitcherAction { + return (SWITCHER_ACTIONS as readonly string[]).includes(value); +} + +/** Canonicalize one shortcut string, or throw `SwitcherShortcutError`. */ +export function normalizeShortcut(shortcut: string): string { + const parts = shortcut + .toLowerCase() + .split("+") + .map((part) => part.trim()); + if (parts.length === 1 && parts[0] === SWITCHER_SHORTCUT_NONE) { + return SWITCHER_SHORTCUT_NONE; + } + const key = parts[parts.length - 1]; + const modifiers = parts + .slice(0, -1) + .map((part) => (part === "cmd" ? "ctrl" : part)); + if ( + new Set(modifiers).size !== modifiers.length || + !modifiers.every((part) => + (MODIFIER_ORDER as readonly string[]).includes(part), + ) || + !(NAMED_KEYS.has(key) || /^[a-z0-9]$/.test(key)) + ) { + throw new SwitcherShortcutError("invalid"); + } + const result = [ + ...MODIFIER_ORDER.filter((part) => modifiers.includes(part)), + key, + ].join("+"); + const isArrow = key === "left" || key === "right"; + if ( + RESERVED.has(result) || + (!isArrow && modifiers.every((part) => part === "shift")) + ) { + throw new SwitcherShortcutError("reserved"); + } + return result; +} + +/** + * Overlay `overrides` on the defaults and validate the result: unknown + * actions and duplicate assignments are rejected; omitted actions keep their + * default. Returns the fully resolved, normalized map. + */ +export function validateSwitcherShortcuts( + overrides: Readonly> = {}, +): SwitcherShortcutMap { + const result: SwitcherShortcutMap = { ...DEFAULT_SWITCHER_SHORTCUTS }; + for (const [action, shortcut] of Object.entries(overrides)) { + if (!isSwitcherAction(action)) throw new SwitcherShortcutError("unknown"); + result[action] = normalizeShortcut(shortcut); + } + const assigned = Object.values(result).filter( + (value) => value !== SWITCHER_SHORTCUT_NONE, + ); + if (new Set(assigned).size !== assigned.length) { + throw new SwitcherShortcutError("duplicate"); + } + return result; +} + +/** + * Resolve the map stored in settings; a missing or invalid map (the backend + * only stores valid ones) yields the defaults. + */ +export function resolveSwitcherShortcuts( + stored?: Readonly>, +): SwitcherShortcutMap { + try { + return validateSwitcherShortcuts(stored); + } catch { + return { ...DEFAULT_SWITCHER_SHORTCUTS }; + } +} + +/** The entries of `mapping` that differ from the defaults (the patch payload). */ +export function switcherShortcutOverrides( + mapping: Readonly, +): Record { + return Object.fromEntries( + SWITCHER_ACTIONS.filter( + (action) => mapping[action] !== DEFAULT_SWITCHER_SHORTCUTS[action], + ).map((action) => [action, mapping[action]]), + ); +} + +export type SwitcherKeyEvent = Pick< + KeyboardEvent, + "key" | "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey" +>; + +/** + * Canonical shortcut string for a key event, or null when the event cannot + * be expressed in the grammar (bare modifier, Windows key, unsupported + * punctuation, ...). Letters come from `key` so shortcuts follow the active + * keyboard layout; top-row digits and comma come from `code` so Shift+1 stays + * `1` and Shift+, stays a comma on US layouts. + */ +export function shortcutFromEvent(event: SwitcherKeyEvent): string | null { + if (event.metaKey) return null; + let key: string | undefined = /^Digit([0-9])$/.exec(event.code)?.[1]; + if (key === undefined) { + if (event.key === "ArrowLeft") key = "left"; + else if (event.key === "ArrowRight") key = "right"; + else if (event.code === "Comma" || event.code === "NumpadComma") key = ","; + else if (/^[a-z0-9,]$/i.test(event.key)) key = event.key.toLowerCase(); + } + if (key === undefined) return null; + const modifiers = [ + event.ctrlKey && "ctrl", + event.altKey && "alt", + event.shiftKey && "shift", + ].filter((part): part is string => part !== false); + return [...modifiers, key].join("+"); +} + +/** The action bound to this key event under `mapping`, or null. */ +export function matchSwitcherAction( + event: SwitcherKeyEvent, + mapping: Readonly = DEFAULT_SWITCHER_SHORTCUTS, +): SwitcherAction | null { + const shortcut = shortcutFromEvent(event); + if (shortcut === null) return null; + return SWITCHER_ACTIONS.find((action) => mapping[action] === shortcut) ?? null; +} + +/** Result of resolving an action; `providerId: null` means Overview. */ +export interface SwitcherTarget { + providerId: string | null; +} + +/** + * Resolve `action` against the switcher order (Overview first, then + * `providerIds` in display order). previous/next wrap around; `selectN` is + * the Nth segment with Overview as 1. Returns null when there is no target + * (N past the last segment, or nothing to switch to). + */ +export function resolveSwitcherTarget( + action: SwitcherAction, + providerIds: readonly string[], + selectedProviderId: string | null, +): SwitcherTarget | null { + const segments: (string | null)[] = [null, ...providerIds]; + if (action === "previous" || action === "next") { + if (segments.length < 2) return null; + const current = Math.max(0, segments.indexOf(selectedProviderId)); + const step = action === "next" ? 1 : -1; + const index = (current + step + segments.length) % segments.length; + return { providerId: segments[index] }; + } + const index = Number(action.slice("select".length)) - 1; + return index < segments.length ? { providerId: segments[index] } : null; +} diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 6ccd63c0de..b32003c367 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,14 @@ export function getSettingsSnapshot(): Promise { return invoke("get_settings_snapshot"); } +export function getCurrencyRates(preferredCurrencyCode: string): Promise { + return invoke("get_currency_rates", { preferredCurrencyCode }); +} + +export function getStayAwakeStatus(): Promise { + return invoke("get_stay_awake_status"); +} + export function updateSettings( patch: SettingsUpdate, ): Promise { @@ -284,9 +293,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, }); } @@ -295,13 +305,23 @@ export function writeUsageSpendExport(path: string, payload: string): Promise("write_usage_spend_export", { path, payload }); } +/** Write portable preferences to `path`; resolves with how many were exported. */ +export function exportPreferences(path: string): Promise { + return invoke("export_preferences", { path }); +} + +/** Apply the preferences file at `path`; the shell validates before saving anything. */ +export function importPreferences(path: string): Promise { + return invoke("import_preferences", { path }); +} + export function getSpendContract( providerId: string, - options?: { historyDays?: number; includeOpenCodex?: boolean }, + options?: { period?: string; includeOpenCodex?: boolean }, ): Promise { return invoke("get_spend_contract", { providerId, - historyDays: options?.historyDays ?? null, + period: options?.period ?? null, includeOpenCodex: options?.includeOpenCodex ?? null, }); } @@ -414,6 +434,13 @@ export function setProviderUsageSource(providerId: string, source: string): Prom return invoke("set_provider_usage_source", { providerId, source }); } +export function setProviderOptionalDetails( + providerId: string, + enabled: boolean, +): Promise { + return invoke("set_provider_optional_details", { providerId, enabled }); +} + export function setProviderAutoResumeAfterQuotaReset( providerId: string, enabled: boolean, @@ -450,6 +477,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/usageSpendBreakdown.test.ts b/apps/desktop-tauri/src/lib/usageSpendBreakdown.test.ts new file mode 100644 index 0000000000..c8ab8ba408 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageSpendBreakdown.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + buildSpendProviderGroups, + compareSpendProviderGroups, + findSpendDay, + formatSpendDay, + type SpendProviderGroup, +} from "./usageSpendBreakdown"; +import type { SpendContract, SpendModelRow } from "../types/bridge"; + +const model = (name: string, costUsd: number | null, totalTokens: number): SpendModelRow => ({ + model: name, + costUsd, + inputTokens: totalTokens, + outputTokens: 0, + cacheReadTokens: 0, + totalTokens, + customPricing: false, +}); + +const contract = (overrides: Partial = {}): SpendContract => + ({ + providerId: "codex", + knownCostUsd: 3.5, + historyCoverageEstablished: true, + priceCoverage: { priced: 2, unpriced: 0, unmetered: 0, estimated: 0 }, + models: [model("gpt-5", 3, 1000), model("gpt-5-mini", 0.5, 500)], + daily: [ + { day: "2026-09-18", costUsd: 1, totalTokens: 100 }, + { day: "2026-09-19", costUsd: null, totalTokens: null }, + ], + ...overrides, + }) as SpendContract; + +const group = (displayName: string, costUsd: number | null, totalTokens: number | null): SpendProviderGroup => ({ + providerId: displayName.toLowerCase(), + displayName, + costUsd, + totalTokens, + costIsPartial: false, + hasPartialModelHistory: false, + models: [], +}); + +describe("usage spend provider breakdown", () => { + it("groups the contract models under their provider with summed tokens", () => { + const [codex] = buildSpendProviderGroups(contract(), "Codex"); + expect(codex).toMatchObject({ + providerId: "codex", + displayName: "Codex", + costUsd: 3.5, + totalTokens: 1500, + costIsPartial: false, + hasPartialModelHistory: false, + }); + expect(codex.models.map((row) => row.model)).toEqual(["gpt-5", "gpt-5-mini"]); + }); + + it("returns no groups without model-level history", () => { + expect(buildSpendProviderGroups(contract({ models: [] }), "Codex")).toEqual([]); + }); + + it("flags unpriced models and incomplete history as partial", () => { + const [codex] = buildSpendProviderGroups( + contract({ + priceCoverage: { priced: 1, unpriced: 1, unmetered: 0, estimated: 0 }, + historyCoverageEstablished: false, + }), + "Codex", + ); + expect(codex.costIsPartial).toBe(true); + expect(codex.hasPartialModelHistory).toBe(true); + }); + + it("orders groups by cost, then tokens, then name with unknown values last", () => { + const ordered = [ + group("Zed", null, null), + group("Beta", 2, 10), + group("Alpha", 2, 10), + group("Gamma", 5, 1), + group("Tokens", null, 99), + ] + .sort(compareSpendProviderGroups) + .map((entry) => entry.displayName); + expect(ordered).toEqual(["Gamma", "Alpha", "Beta", "Tokens", "Zed"]); + }); + + it("finds a selected day only while it is inside the current range", () => { + const { daily } = contract(); + expect(findSpendDay(daily, "2026-09-18")?.costUsd).toBe(1); + expect(findSpendDay(daily, "2026-01-01")).toBeNull(); + expect(findSpendDay(daily, null)).toBeNull(); + }); + + it("formats dashboard days and falls back for invalid dates", () => { + expect(formatSpendDay("2026-09-19")).toContain("2026"); + expect(formatSpendDay("2026-02-31")).toBe("2026-02-31"); + expect(formatSpendDay("not-a-day")).toBe("not-a-day"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts b/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts new file mode 100644 index 0000000000..227ca228a9 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts @@ -0,0 +1,78 @@ +import type { SpendContract, SpendDailyPoint, SpendModelRow } from "../types/bridge"; + +/** Upstream 0.64.0 (#3353) `spendProviderModelDisplayLimit`. */ +export const PROVIDER_MODEL_DISPLAY_LIMIT = 6; + +/** One provider group in the Usage & Spend breakdown: its spend plus its models. */ +export interface SpendProviderGroup { + providerId: string; + displayName: string; + costUsd: number | null; + totalTokens: number | null; + /** True when the cost is a lower bound (unpriced models) and renders with "~". */ + costIsPartial: boolean; + /** True when model history is incomplete, so the model list is a partial view. */ + hasPartialModelHistory: boolean; + models: SpendModelRow[]; +} + +/** + * Upstream `spendDashboardProviderBreakdowns` ordering: highest known cost + * first, then highest tokens, unknown values last, then display name. + */ +export function compareSpendProviderGroups(a: SpendProviderGroup, b: SpendProviderGroup): number { + const byValue = (left: number | null, right: number | null): number => { + if (left != null && right != null) return left === right ? 0 : right - left; + if (left != null) return -1; + if (right != null) return 1; + return 0; + }; + return ( + byValue(a.costUsd, b.costUsd) || + byValue(a.totalTokens, b.totalTokens) || + a.displayName.localeCompare(b.displayName, undefined, { numeric: true, sensitivity: "base" }) + ); +} + +/** + * Group the contract's model rows by the provider that produced them. + * + * The desktop shell builds model-level history for one provider contract + * (`contract.providerId`), so that is the only group with models; providers + * without model-level history stay in the provider table. + */ +export function buildSpendProviderGroups( + contract: SpendContract, + providerName: string, +): SpendProviderGroup[] { + if (contract.models.length === 0) return []; + const totalTokens = contract.models.reduce((sum, model) => sum + model.totalTokens, 0); + const group: SpendProviderGroup = { + providerId: contract.providerId, + displayName: providerName, + costUsd: contract.knownCostUsd, + totalTokens: Number.isSafeInteger(totalTokens) ? totalTokens : null, + costIsPartial: contract.priceCoverage.unpriced > 0, + hasPartialModelHistory: !contract.historyCoverageEstablished, + models: contract.models, + }; + return [group].sort(compareSpendProviderGroups); +} + +/** The daily point for `day`, or null when that day is not in the current range. */ +export function findSpendDay(daily: SpendDailyPoint[], day: string | null): SpendDailyPoint | null { + if (!day) return null; + return daily.find((point) => point.day === day) ?? null; +} + +/** Medium local date for a `YYYY-MM-DD` dashboard day; the raw value when it does not parse. */ +export function formatSpendDay(day: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!match) return day; + const [year, month, date] = [Number(match[1]), Number(match[2]), Number(match[3])]; + const parsed = new Date(year, month - 1, date); + if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== date) { + return day; + } + return parsed.toLocaleDateString(undefined, { dateStyle: "medium" }); +} 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..937a9c0157 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); } @@ -2543,6 +2548,10 @@ body:has(.tray-panel-reveal) { color: var(--provider-row-text-secondary); } +.provider-detail-cookie-source__missing .credential-btn { + margin-top: 8px; +} + .provider-detail-select { appearance: none; background: var(--provider-row-bg); @@ -2655,6 +2664,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 +3079,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 +3717,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 +4245,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 +4278,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 +4971,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 +5049,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 +5640,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 +6013,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..b39b3a36cd 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -6,6 +6,7 @@ const tauriMocks = vi.hoisted(() => ({ refreshProviders: vi.fn(), refreshProvidersIfStale: vi.fn(), getSettingsSnapshot: vi.fn(), + getStayAwakeStatus: vi.fn().mockResolvedValue(false), updateSettings: vi.fn(), getUpdateState: vi.fn(), checkForUpdates: vi.fn(), @@ -141,10 +142,12 @@ function settings(overrides: Partial = {}): SettingsSnapshot { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarColorPace: false, showAsUsed: true, showAllTokenAccountsInMenu: false, enableAnimations: true, @@ -157,6 +160,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "Ctrl+Shift+U", + switcherShortcuts: {}, codexCustomSessionsDirs: [], uiLanguage: "english", theme: "dark", @@ -319,6 +323,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 +332,8 @@ describe("TrayPanel provider grid", () => { displayName: "Codex", sevenDay: 1, thirtyDay: 2, + periodCost: 2, + periodTokens: null, currency: "USD", source: "local", includedInOverview: true, @@ -336,17 +343,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 +453,9 @@ describe("TrayPanel provider grid", () => { PanelAllProviders: "すべてのプロバイダー", PanelAllProvidersShort: "すべて", PanelLatestTokens: "最新トークン", - PanelThirtyDayCost: "30日間のコスト", + CostPeriodShortDays: "{}日", + PanelPeriodCost: "{}間のコスト", + PanelPeriodTokens: "{}間のトークン", PanelTopModelPrefix: "トップモデル", PanelEstimatedFromLocalLogs: "ローカルログから推定", PanelZoom: "ズーム", @@ -445,6 +473,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 +630,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 +640,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..1de3c573a3 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, @@ -15,6 +18,7 @@ import { TRAY_SCALE_STEP, useTrayPanelController, } from "../hooks/useTrayPanelController"; +import { useStayAwakeStatus } from "../hooks/useStayAwakeStatus"; import MenuCard from "../components/MenuCard"; import MenuSurface, { MenuEmpty } from "../components/MenuSurface"; import UpdateBanner from "../components/UpdateBanner"; @@ -28,15 +32,16 @@ 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", "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", "t3chat", "venice", "vertexai", "warp", "windsurf", - "xai", "zai", "fireworks", "meta", "muse", "nous", + "xai", "zai", "fireworks", "meta", "muse", "nous", "llmman", "devpass", "xkiro", + "raycast", "vercel", ]); /** Provider IDs that have a status page URL in the backend */ const HAS_STATUS_PAGE = new Set([ @@ -52,6 +57,7 @@ const HAS_STATUS_PAGE = new Set([ * 2. Detail: click a provider in grid → show only that provider's card */ export default function TrayPanel({ state }: { state: BootstrapState }) { + const stayAwakeHeld = useStayAwakeStatus(); const { t, settings, @@ -63,8 +69,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { trayScaleFillPercent, handleTrayScaleChange, sorted, - denseTrayProviders, - expectsDenseOverview, + gridProviders, selectedProviderId, gridExpanded, setGridExpanded, @@ -152,6 +157,11 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { : null; const canSwitchClaudeAccount = selectedProvider !== null && hasSuccessfulClaudeCliQuota(selectedProvider); + const stayAwakeStatus = stayAwakeHeld ? ( +

+ {t("TrayStayAwakeActive")} +

+ ) : null; if (sorted.length === 0) { return ( @@ -162,6 +172,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { footerRows={footerRows} style={{ zoom: trayScale }} > + {stayAwakeStatus} {settings.agentSessionsEnabled && } + {stayAwakeStatus} {settings.agentSessionsEnabled && }
{selectedProviderId === null && ( - provider.providerId)} t={t} /> + provider.providerId)} + period={settings.costReportingPeriod} + t={t} + /> )}
{useWideColumns @@ -326,49 +342,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")}
+ + ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/CookieSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/CookieSection.tsx index ea911fca7d..85cfd0671d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/CookieSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/CookieSection.tsx @@ -16,6 +16,8 @@ import type { interface Props { providerId: string; cookieDomain: string | null; + /** Called after the stored cookie changes so the detail pane can refresh. */ + onChanged?: () => void; } function cookiePlaceholder( @@ -35,7 +37,7 @@ function cookiePlaceholder( * Per-provider browser cookie management. Renders nothing for providers * that do not have a cookieDomain (i.e. don't authenticate via web cookies). */ -export function CookieSection({ providerId, cookieDomain }: Props) { +export function CookieSection({ providerId, cookieDomain, onChanged }: Props) { const { t } = useLocale(); const [saved, setSaved] = useState(null); const [loaded, setLoaded] = useState(false); @@ -98,6 +100,7 @@ export function CookieSection({ providerId, cookieDomain }: Props) { try { const next = await removeManualCookie(providerId); setSaved(next.find((c) => c.providerId === providerId) ?? null); + onChanged?.(); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -114,6 +117,7 @@ export function CookieSection({ providerId, cookieDomain }: Props) { const next = await importBrowserCookies(providerId, browserType); setSaved(next.find((c) => c.providerId === providerId) ?? null); setImportStatus(t("BrowserCookieImportSuccess")); + onChanged?.(); } catch (err: unknown) { setImportError(err instanceof Error ? err.message : String(err)); } finally { @@ -129,6 +133,7 @@ export function CookieSection({ providerId, cookieDomain }: Props) { const next = await setManualCookie(providerId, pasteValue.trim()); setSaved(next.find((c) => c.providerId === providerId) ?? null); setPasteValue(""); + onChanged?.(); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); } finally { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 0dadfe492d..7c8b22ee6a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useReducer } from "react"; +import { useCallback, useEffect, useReducer, useState } from "react"; import type { SettingsSnapshot, SettingsUpdate } from "../../../types/bridge"; import { useLocale } from "../../../hooks/useLocale"; import { providerAllowsPace } from "../../../lib/providerPace"; @@ -6,6 +6,7 @@ import { getCredentialStorageStatus, getProviderCookieSourceOptions, getProviderDetail, + getProviderGatewayUrl, getProviderRegionOptions, getTokenAccountProviders, openProviderDashboard, @@ -26,6 +27,7 @@ import { IdentitySection } from "./sections/IdentitySection"; import { UsageSection } from "./sections/UsageSection"; import { UsageItemVisibilitySection } from "./sections/UsageItemVisibilitySection"; import { AutoResumeSection } from "./sections/AutoResumeSection"; +import { OptionalDetailsSection } from "./sections/OptionalDetailsSection"; import { PaceSection } from "./sections/PaceSection"; import { CostSection } from "./sections/CostSection"; import { QuickActionsSection } from "./sections/QuickActionsSection"; @@ -46,8 +48,12 @@ import { AccentColorSection } from "./sections/AccentColorSection"; import { ProviderIssueNotice } from "./sections/ProviderIssueNotice"; import { CredentialStorageSection } from "./sections/CredentialStorageSection"; import { CredentialsDispatcher } from "./sections/CredentialsDispatcher"; -import { WayfinderGatewaySection } from "./sections/WayfinderGatewaySection"; +import { + isGatewayProviderId, + WayfinderGatewaySection, +} from "./sections/WayfinderGatewaySection"; import { AzureApiVersionSection } from "./sections/AzureApiVersionSection"; +import { MuseBrowserTeamSection } from "./sections/MuseBrowserTeamSection"; interface Props { providerId: string | null; @@ -83,6 +89,8 @@ export function ProviderDetailPane({ onSettingsChange, }: Props) { const { t, language } = useLocale(); + const [gatewayLoadedProviderId, setGatewayLoadedProviderId] = + useState(null); const [state, dispatch] = useReducer( providerDetailPaneReducer, { wayfinderGatewayUrl, providerId }, @@ -142,11 +150,33 @@ export function ProviderDetailPane({ } }, []); + const gatewayProviderId = + providerId !== null && isGatewayProviderId(providerId) ? providerId : null; + + useEffect(() => { + setGatewayLoadedProviderId(null); + if (!gatewayProviderId) return; + let cancelled = false; + void getProviderGatewayUrl(gatewayProviderId).then((url) => { + if (!cancelled) { + dispatch({ type: "SET_GATEWAY_DRAFT", draft: url }); + setGatewayLoadedProviderId(gatewayProviderId); + } + }).catch((e) => { + if (!cancelled) { + dispatch({ type: "SAVE_GATEWAY_ERROR", error: String(e) }); + setGatewayLoadedProviderId(gatewayProviderId); + } + }); + return () => { cancelled = true; }; + }, [gatewayProviderId]); + const saveGateway = async () => { dispatch({ type: "SAVE_GATEWAY_START" }); try { - await setProviderGatewayUrl("wayfinder", gatewayDraft); - await load("wayfinder"); + if (!gatewayProviderId) return; + await setProviderGatewayUrl(gatewayProviderId, gatewayDraft); + await load(gatewayProviderId); } catch (e) { dispatch({ type: "SAVE_GATEWAY_ERROR", error: String(e) }); } finally { @@ -288,7 +318,12 @@ export function ProviderDetailPane({ return (
- + {detail.id === "codex" && ( @@ -319,8 +354,18 @@ export function ProviderDetailPane({ t={t} onChanged={reload} /> - {detail.id === "wayfinder" && ( + + {isGatewayProviderId(detail.id) && + gatewayLoadedProviderId === detail.id && ( + )} + {detail.id === "muse" && ( + @@ -404,11 +459,13 @@ export function ProviderDetailPane({ key={`cookie-${credKey}`} providerId={detail.id} cookieDomain={cookieDomain} + onChanged={reload} /> diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts index 3aa3a415b9..9166f2d211 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts @@ -15,6 +15,8 @@ function baseDetail(over: Partial = {}): ProviderDetail { enabled: true, autoResumeAfterQuotaReset: false, autoResumeSupported: true, + optionalDetailsSupported: false, + optionalDetailsEnabled: false, email: "team@example.com", plan: "Pro", authType: "oauth", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailPaneState.test.ts b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailPaneState.test.ts index 0000a32ea0..7a40854a6c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailPaneState.test.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailPaneState.test.ts @@ -22,6 +22,8 @@ function baseDetail(id = "claude"): ProviderDetail { enabled: true, autoResumeAfterQuotaReset: false, autoResumeSupported: true, + optionalDetailsSupported: false, + optionalDetailsEnabled: false, email: "team@example.com", plan: "Pro", authType: "oauth", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.test.tsx new file mode 100644 index 0000000000..6a5a385660 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.test.tsx @@ -0,0 +1,21 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AccentColorSection } from "./AccentColorSection"; + +describe("AccentColorSection", () => { + it("uses the native color input as the only color preview", () => { + const { container } = render( + key} + onChange={vi.fn()} + />, + ); + + expect(container.querySelector('input[type="color"]')).toHaveValue("#123456"); + expect(container.querySelector(".accent-color-swatch-row")).toBeNull(); + expect(container.querySelector(".accent-color-swatch")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx index 9e0f8dca41..112e09f856 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx @@ -94,16 +94,6 @@ export function AccentColorSection({ {t("ProviderAccentColorReset")}
-
- - {t("ProviderAccentColor")} - - - {effective} -
{error &&

{error}

} ); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.test.tsx new file mode 100644 index 0000000000..58bbc4bad4 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + setProviderCookieSource: vi.fn(), +})); + +vi.mock("../../../../lib/tauri", () => tauriMocks); + +import { CookieSourceSection } from "./CookieSourceSection"; + +const OPTIONS = [ + { value: "auto", label: "Automatic" }, + { value: "manual", label: "Manual" }, +]; + +function renderSection( + overrides: Partial[0]> = {}, +) { + const onChanged = vi.fn(); + render( + key} + onChanged={onChanged} + {...overrides} + />, + ); + return { onChanged }; +} + +describe("CookieSourceSection manual cookie hint", () => { + beforeEach(() => { + tauriMocks.setProviderCookieSource.mockReset(); + }); + + it("explains an empty Manual configuration and switches to Automatic on request", async () => { + tauriMocks.setProviderCookieSource.mockResolvedValue(undefined); + const { onChanged } = renderSection(); + + expect(screen.getByText("ProviderManualCookieMissing")).toBeInTheDocument(); + expect(tauriMocks.setProviderCookieSource).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "ProviderUseAutomaticCookies" }), + ); + + await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1)); + expect(tauriMocks.setProviderCookieSource).toHaveBeenCalledWith( + "ollama", + "auto", + ); + }); + + it("stays quiet when the backend reports a header is configured", () => { + renderSection({ manualCookieMissing: false }); + + expect( + screen.queryByText("ProviderManualCookieMissing"), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "ProviderUseAutomaticCookies" }), + ).not.toBeInTheDocument(); + }); + + it("hides the action when Automatic is already selected", () => { + renderSection({ currentValue: "auto" }); + + expect( + screen.queryByRole("button", { name: "ProviderUseAutomaticCookies" }), + ).not.toBeInTheDocument(); + }); + + it("hides the action for providers without an Automatic option", () => { + renderSection({ options: [{ value: "manual", label: "Manual" }] }); + + expect( + screen.queryByRole("button", { name: "ProviderUseAutomaticCookies" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.tsx index 50dcd759f7..29e70f244e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.tsx @@ -7,6 +7,8 @@ interface Props { providerId: string; currentValue: string | null; options: CookieSourceOption[]; + /** Backend flag: Manual is selected but no cookie header is configured. */ + manualCookieMissing?: boolean; t: (key: LocaleKey) => string; onChanged: () => void; } @@ -22,6 +24,7 @@ export function CookieSourceSection({ providerId, currentValue, options, + manualCookieMissing = false, t, onChanged, }: Props) { @@ -48,6 +51,11 @@ export function CookieSourceSection({ } }; + const showUseAutomatic = + manualCookieMissing && + selected === "manual" && + options.some((o) => o.value === "auto"); + return (

{t("ProviderCookieSource")}

@@ -78,6 +86,21 @@ export function CookieSourceSection({ {selectedOption?.description && (

{selectedOption.description}

)} + {showUseAutomatic && ( +
+

+ {t("ProviderManualCookieMissing")} +

+ +
+ )} {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..69036bb68a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx @@ -35,11 +35,14 @@ export function CredentialsDispatcher({ providerId, t }: Props) { case "openaiapi": return ; case "litellm": + case "llmman": case "devin": case "opencodego": case "zed": case "sub2api": + case "llmproxy": 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..c0f79ddd0d 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 @@ -10,6 +10,8 @@ function provider(extra = true): ProviderDetail { enabled: true, autoResumeAfterQuotaReset: false, autoResumeSupported: false, + optionalDetailsSupported: false, + optionalDetailsEnabled: false, email: null, plan: null, authType: null, @@ -36,6 +38,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 +143,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/MuseBrowserTeamSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MuseBrowserTeamSection.test.tsx new file mode 100644 index 0000000000..12f754b3e3 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MuseBrowserTeamSection.test.tsx @@ -0,0 +1,118 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getProviderWorkspaceId, + setProviderWorkspaceId, +} from "../../../../lib/tauri"; +import type { ProviderDisplayDetail } from "../../../../types/bridge"; +import { MuseBrowserTeamSection } from "./MuseBrowserTeamSection"; + +vi.mock("../../../../lib/tauri", () => ({ + getProviderWorkspaceId: vi.fn(), + setProviderWorkspaceId: vi.fn(), +})); + +function row(id: string, title: string, value: string): ProviderDisplayDetail { + return { id, sectionTitle: null, title, value, secondaryValue: null, progress: null }; +} + +const DETAILS = [ + row("browser-teams-status", "Browser teams", "Choose a browser team ID in Muse Code settings"), + row("browser-team-11", "Alpha", "11"), + row("browser-team-22", "Beta", "22"), +]; + +function renderSection(details: ProviderDisplayDetail[] | undefined = DETAILS) { + const onChanged = vi.fn(); + render( + key} + onChanged={onChanged} + />, + ); + return onChanged; +} + +describe("MuseBrowserTeamSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getProviderWorkspaceId).mockResolvedValue(null); + vi.mocked(setProviderWorkspaceId).mockResolvedValue(undefined); + }); + + it("lists only team rows and never preselects the first team", async () => { + renderSection(); + const select = (await screen.findByRole("combobox")) as HTMLSelectElement; + + expect(select.value).toBe(""); + expect(Array.from(select.options).map((option) => option.value)).toEqual([ + "", + "11", + "22", + ]); + expect(select.options[2].textContent).toBe("Beta (22)"); + expect(screen.getByText("MuseBrowserTeamHelp")).toBeInTheDocument(); + }); + + it("persists the chosen team as the provider workspace value", async () => { + const onChanged = renderSection(); + const select = await screen.findByRole("combobox"); + await vi.waitFor(() => expect(select).toBeEnabled()); + + await act(async () => { + fireEvent.change(select, { target: { value: "22" } }); + }); + + await vi.waitFor(() => + expect(setProviderWorkspaceId).toHaveBeenCalledWith("muse", "22"), + ); + expect(onChanged).toHaveBeenCalledOnce(); + }); + + it("keeps a saved team visible when the session no longer lists it", async () => { + vi.mocked(getProviderWorkspaceId).mockResolvedValue("99"); + renderSection([]); + + const select = (await screen.findByRole("combobox")) as HTMLSelectElement; + await vi.waitFor(() => expect(select.value).toBe("99")); + expect(Array.from(select.options).map((option) => option.textContent)).toContain( + "99 (MuseBrowserTeamUnavailable)", + ); + expect(screen.getByText("MuseBrowserTeamNoTeams")).toBeInTheDocument(); + }); + + it("waits for the saved team to load before allowing a change", async () => { + let resolveWorkspaceId!: (value: string | null) => void; + vi.mocked(getProviderWorkspaceId).mockReturnValue( + new Promise((resolve) => { + resolveWorkspaceId = resolve; + }), + ); + renderSection(); + + const select = (await screen.findByRole("combobox")) as HTMLSelectElement; + expect(select).toBeDisabled(); + await act(async () => resolveWorkspaceId("22")); + + await vi.waitFor(() => expect(select).toBeEnabled()); + expect(select.value).toBe("22"); + }); + + it("reports a failed save without changing the selection", async () => { + vi.mocked(setProviderWorkspaceId).mockRejectedValue("Muse browser team ID is invalid"); + const onChanged = renderSection(); + const select = (await screen.findByRole("combobox")) as HTMLSelectElement; + await vi.waitFor(() => expect(select).toBeEnabled()); + + await act(async () => { + fireEvent.change(select, { target: { value: "11" } }); + }); + + expect(await screen.findByText("Muse browser team ID is invalid")).toBeInTheDocument(); + expect(onChanged).not.toHaveBeenCalled(); + expect(select.value).toBe(""); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MuseBrowserTeamSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MuseBrowserTeamSection.tsx new file mode 100644 index 0000000000..6d6b0a64c7 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MuseBrowserTeamSection.tsx @@ -0,0 +1,119 @@ +import { useEffect, useMemo, useState } from "react"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { + getProviderWorkspaceId, + setProviderWorkspaceId, +} from "../../../../lib/tauri"; +import type { ProviderDisplayDetail } from "../../../../types/bridge"; + +/** Detail-row id prefix the Muse provider uses for each listed browser team. */ +export const MUSE_BROWSER_TEAM_ROW_PREFIX = "browser-team-"; + +interface Props { + providerId: string; + details: ProviderDisplayDetail[] | undefined; + disabled: boolean; + t: (key: LocaleKey) => string; + onChanged: () => void; +} + +interface TeamOption { + id: string; + label: string; +} + +function listedTeams(details: ProviderDisplayDetail[] | undefined): TeamOption[] { + return (details ?? []) + .filter((row) => row.id.startsWith(MUSE_BROWSER_TEAM_ROW_PREFIX)) + .map((row) => ({ id: row.value, label: `${row.title} (${row.value})` })); +} + +/** + * Picker for the dev.meta.ai team whose quota Muse Code reads when the + * device login reports none. The choice is stored as the provider workspace + * value; an empty choice never falls back to the first listed team. + */ +export function MuseBrowserTeamSection({ + providerId, + details, + disabled, + t, + onChanged, +}: Props) { + const [selected, setSelected] = useState(""); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let stale = false; + setBusy(true); + setError(null); + void getProviderWorkspaceId(providerId) + .then((value) => { + if (!stale) setSelected(value ?? ""); + }) + .catch((reason: unknown) => { + if (!stale) setError(String(reason)); + }) + .finally(() => { + if (!stale) setBusy(false); + }); + return () => { + stale = true; + }; + }, [providerId]); + + const teams = useMemo(() => listedTeams(details), [details]); + const options = useMemo( + () => + !selected || teams.some((team) => team.id === selected) + ? teams + : [ + ...teams, + { + id: selected, + label: `${selected} (${t("MuseBrowserTeamUnavailable")})`, + }, + ], + [teams, selected, t], + ); + + const handleChange = async (next: string) => { + if (next === selected || busy || disabled) return; + setBusy(true); + setError(null); + try { + await setProviderWorkspaceId(providerId, next); + setSelected(next); + onChanged(); + } catch (reason: unknown) { + setError(String(reason)); + } finally { + setBusy(false); + } + }; + + return ( +
+

{t("MuseBrowserTeamTitle")}

+ +

+ {t(teams.length === 0 ? "MuseBrowserTeamNoTeams" : "MuseBrowserTeamHelp")} +

+ {error &&

{error}

} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.test.tsx new file mode 100644 index 0000000000..156ff76a9f --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.test.tsx @@ -0,0 +1,75 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setProviderOptionalDetails } from "../../../../lib/tauri"; +import { OptionalDetailsSection } from "./OptionalDetailsSection"; + +vi.mock("../../../../lib/tauri", () => ({ + setProviderOptionalDetails: vi.fn(), +})); + +function renderSection( + overrides: Partial[0]> = {}, +) { + const onChanged = vi.fn(); + const view = render( + key} + onChanged={onChanged} + {...overrides} + />, + ); + return { ...view, onChanged }; +} + +describe("OptionalDetailsSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(setProviderOptionalDetails).mockResolvedValue(undefined); + }); + + it("shows the LiteLLM model activity opt-in and persists a change", async () => { + const { onChanged } = renderSection(); + + expect(screen.getByText("ProviderLiteLLMModelActivity")).toBeInTheDocument(); + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).not.toBeChecked(); + await act(async () => { + fireEvent.click(checkbox); + }); + + await vi.waitFor(() => + expect(setProviderOptionalDetails).toHaveBeenCalledWith("litellm", true), + ); + expect(onChanged).toHaveBeenCalledOnce(); + }); + + it("uses the workspace spend copy for Claude", () => { + renderSection({ providerId: "claude", enabled: true }); + + expect(screen.getByText("ProviderClaudeWorkspaceSpend")).toBeInTheDocument(); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("does not render for providers without an optional breakdown", () => { + const { container } = renderSection({ providerId: "codex", available: false }); + + expect(container).toBeEmptyDOMElement(); + }); + + it("surfaces a failed save and keeps the section usable", async () => { + vi.mocked(setProviderOptionalDetails).mockRejectedValue("save failed"); + const { onChanged } = renderSection(); + + await act(async () => { + fireEvent.click(screen.getByRole("checkbox")); + }); + + expect(await screen.findByText("save failed")).toBeInTheDocument(); + expect(onChanged).not.toHaveBeenCalled(); + expect(screen.getByRole("checkbox")).not.toBeDisabled(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.tsx new file mode 100644 index 0000000000..8234c30f44 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionalDetailsSection.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { setProviderOptionalDetails } from "../../../../lib/tauri"; + +interface Props { + providerId: string; + enabled: boolean; + available: boolean; + disabled: boolean; + t: (key: LocaleKey) => string; + onChanged: () => void; +} + +interface Copy { + label: LocaleKey; + helper: LocaleKey; +} + +/** Copy for the one optional breakdown each supporting provider offers. */ +const OPTIONAL_DETAILS_COPY: Record = { + litellm: { + label: "ProviderLiteLLMModelActivity", + helper: "ProviderLiteLLMModelActivityHelper", + }, + claude: { + label: "ProviderClaudeWorkspaceSpend", + helper: "ProviderClaudeWorkspaceSpendHelper", + }, +}; + +/** Opt-in for a provider's extra breakdown (LiteLLM model activity, Claude workspace spend). */ +export function OptionalDetailsSection({ + providerId, + enabled, + available, + disabled, + t, + onChanged, +}: Props) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const copy = OPTIONAL_DETAILS_COPY[providerId]; + if (!available || !copy) return null; + + const handleChange = async (next: boolean) => { + if (busy) return; + setBusy(true); + setError(null); + try { + await setProviderOptionalDetails(providerId, next); + onChanged(); + } catch (cause) { + setError(String(cause)); + } finally { + setBusy(false); + } + }; + + return ( +
+

{t("ProviderOptionalDetailsTitle")}

+ + {error &&
{error}
} +
+ ); +} 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/UsageItemVisibilitySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx index 10b4556290..b91693ff0b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageItemVisibilitySection.test.tsx @@ -10,6 +10,8 @@ function provider(): ProviderDetail { enabled: true, autoResumeAfterQuotaReset: false, autoResumeSupported: false, + optionalDetailsSupported: false, + optionalDetailsEnabled: false, email: null, plan: null, authType: null, 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..35460d4a9f 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 @@ -40,6 +40,8 @@ function provider(): ProviderDetail { enabled: true, autoResumeAfterQuotaReset: false, autoResumeSupported: false, + optionalDetailsSupported: false, + optionalDetailsEnabled: false, email: null, plan: null, authType: null, @@ -86,6 +88,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 +171,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 +257,78 @@ 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(); + }); + + it("prints one heading per display detail section", async () => { + const detail = provider(); + detail.session = null; + detail.extraRateWindows = []; + detail.displayDetails = [ + { + id: "plain", + sectionTitle: null, + title: "Plain", + value: "1", + secondaryValue: null, + progress: null, + }, + { + id: "a", + sectionTitle: "Model activity", + title: "fixture-alpha", + value: "60 tokens", + secondaryValue: null, + progress: null, + }, + { + id: "b", + sectionTitle: "Model activity", + title: "fixture-beta", + value: "30 tokens", + secondaryValue: null, + progress: null, + }, + ]; + + render( + + key} /> + , + ); + + expect(await screen.findByText(/fixture-alpha: 60 tokens/)).toBeInTheDocument(); + expect(screen.getAllByRole("heading", { name: "Model activity" })).toHaveLength(1); + expect(screen.getByText(/Plain: 1/)).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}

}
+ + {/* ── Diagnostics ──────────────────────────────────────────── */}

diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx new file mode 100644 index 0000000000..5e0c517a2d --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; +import type { LocaleKey } from "../../../i18n/keys"; +import { + MAX_ROLLING_DAYS, + PRESET_COST_PERIODS, + costPeriodLabel, + customPeriodRaw, + isPresetCostPeriod, + rollingDays, +} from "../../../lib/costPeriod"; + +const CUSTOM_OPTION = "custom"; + +/** + * History window picker: month to date, all, 1/7/30/90/365 days, or a custom + * day count in 1..=365. `value` is the persisted form (`rolling:N`, + * `month-to-date`, `all`). A custom count is committed on blur or Enter, once + * it is valid, so typing a multi-digit value triggers one rescan. + */ +export default function CostPeriodControl({ + value, + onChange, + disabled = false, + t, +}: { + value: string; + onChange: (raw: string) => void; + disabled?: boolean; + t: (key: LocaleKey) => string; +}) { + const [customMode, setCustomMode] = useState(() => !isPresetCostPeriod(value)); + const [customText, setCustomText] = useState(() => String(rollingDays(value) ?? "")); + // Follow external changes (settings loaded after mount, another window). + useEffect(() => { + if (isPresetCostPeriod(value)) { + setCustomMode(false); + } else { + setCustomMode(true); + setCustomText(String(rollingDays(value) ?? "")); + } + }, [value]); + + const customInvalid = customMode && customText !== "" && customPeriodRaw(customText) === null; + const commitCustom = () => { + const raw = customPeriodRaw(customText); + if (raw && raw !== value) onChange(raw); + }; + + return ( +
+ +

{t("CostPeriodHistoryWindowHelper")}

+
+ + {customMode && ( + setCustomText(event.target.value)} + onBlur={commitCustom} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitCustom(); + } + }} + /> + )} +
+
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx index bff42a55b3..8c57e3a31a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx @@ -14,11 +14,15 @@ import DisplayTab from "./DisplayTab"; import type { SettingsSnapshot } from "../../../types/bridge"; const baseSettings = { + enabledProviders: ["codex", "claude"], trayIconMode: "single", + stackedTrayTopProvider: null, + stackedTrayBottomProvider: null, trayPanelAlwaysOnTop: false, switcherShowsIcons: false, menuBarShowsHighestUsage: false, menuBarShowsPercent: false, + menuBarColorPace: false, menuBarDisplayMode: "detailed", overviewLayout: "detailed", windowScalePercent: 100, @@ -29,9 +33,17 @@ const baseSettings = { showPace: false, } as unknown as SettingsSnapshot; -function renderTab(set: (patch: Record) => void) { +function renderTab( + set: (patch: Record) => void, + mode: "menuBar" | "menu" = "menu", +) { return render( - , + , ); } @@ -83,4 +95,38 @@ describe("DisplayTab menu settings", () => { expect(set).toHaveBeenCalledWith({ trayPanelAlwaysOnTop: true }); }); + + it("updates the tray pace color preference", () => { + const set = vi.fn(); + renderTab(set, "menuBar"); + + fireEvent.click(screen.getByRole("checkbox", { name: "ColorPaceInTray" })); + + expect(set).toHaveBeenCalledWith({ menuBarColorPace: true }); + }); +}); + +describe("DisplayTab stacked tray providers", () => { + it("persists explicit top and bottom provider choices", () => { + const set = vi.fn(); + render( + , + ); + const selects = screen.getAllByRole("combobox"); + + fireEvent.change(selects[1], { target: { value: "claude" } }); + fireEvent.change(selects[2], { target: { value: "codex" } }); + + expect(set).toHaveBeenCalledWith({ stackedTrayTopProvider: "claude" }); + expect(set).toHaveBeenCalledWith({ stackedTrayBottomProvider: "codex" }); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx index 52588deb84..51f2607e42 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx @@ -4,11 +4,13 @@ import { Field, Select, Toggle } from "../../../components/FormControls"; import type { MenuBarDisplayMode, OverviewLayout, + ProviderCatalogEntry, TrayIconMode, TrayVisibilityStatusDto, } from "../../../types/bridge"; import type { TabProps } from "../settingsTabs"; import FloatBarSettingsSection from "../../../floatbar/SettingsSection"; +import SwitcherShortcutsSection from "../SwitcherShortcutsSection"; import { getTrayVisibilityStatus } from "../../../lib/tauri"; export default function DisplayTab({ @@ -16,7 +18,11 @@ export default function DisplayTab({ settings, set, saving, -}: TabProps & { mode?: "menuBar" | "menu" }) { + providers = [], +}: TabProps & { + mode?: "menuBar" | "menu"; + providers?: ProviderCatalogEntry[]; +}) { const { t } = useLocale(); const [trayVisibility, setTrayVisibility] = useState(null); @@ -26,6 +32,13 @@ export default function DisplayTab({ .catch(() => setTrayVisibility(null)); }, []); + const providerName = new Map( + providers.map((provider) => [provider.id, provider.displayName]), + ); + const stackedProviderOptions = settings.enabledProviders.map((providerId) => ({ + value: providerId, + label: providerName.get(providerId) ?? providerId, + })); return ( <> {/* ── Menu bar ─────────────────────────────────────────────── */} @@ -42,10 +55,47 @@ export default function DisplayTab({ options={[ { value: "single", label: t("TrayIconModeSingle") }, { value: "perProvider", label: t("TrayIconModePerProvider") }, + { value: "stacked", label: t("TrayIconModeStacked") }, ]} onChange={(v) => set({ trayIconMode: v as TrayIconMode })} /> + {settings.trayIconMode === "stacked" && ( + <> + + + provider.value !== settings.stackedTrayTopProvider, + ), + ]} + onChange={(provider) => + set({ stackedTrayBottomProvider: provider }) + } + /> + + + )} set({ menuBarShowsHighestUsage: v })} /> @@ -75,10 +125,22 @@ export default function DisplayTab({ > set({ menuBarShowsPercent: v })} /> + + set({ menuBarColorPace: v })} + /> +

} + {mode === "menu" && ( + + )} + {mode === "menu" && ( )} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 33a78a00e9..45c0798789 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -7,16 +7,27 @@ vi.mock("../../../hooks/useLocale", () => ({ // Mock Tauri invoke for get_available_languages vi.mock("@tauri-apps/api/core", () => ({ - invoke: vi.fn().mockResolvedValue([ - { value: "english", display: "English" }, - { value: "chinese", display: "中文" }, - { value: "chinesetraditional", display: "繁體中文" }, - { value: "japanese", display: "日本語" }, - { value: "korean", display: "한국어" }, - { value: "spanish", display: "Español" }, - { value: "russian", display: "Русский" }, - { value: "turkish", display: "Türkçe" }, - ]), + invoke: vi.fn(async (command: string) => { + if (command === "get_app_info") { + return { + name: "CodexBar", + version: "0.60.3", + buildNumber: "42", + updateChannel: "stable", + tagline: "usage", + }; + } + return [ + { value: "english", display: "English" }, + { value: "chinese", display: "中文" }, + { value: "chinesetraditional", display: "繁體中文" }, + { value: "japanese", display: "日本語" }, + { value: "korean", display: "한국어" }, + { value: "spanish", display: "Español" }, + { value: "russian", display: "Русский" }, + { value: "turkish", display: "Türkçe" }, + ]; + }), })); vi.mock("@tauri-apps/plugin-dialog", () => ({ @@ -30,6 +41,7 @@ import { open } from "@tauri-apps/plugin-dialog"; const settings: SettingsSnapshot = { enabledProviders: [], + preferredCurrencyCode: "AUTO", refreshIntervalSecs: 300, adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, @@ -51,10 +63,12 @@ const settings: SettingsSnapshot = { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: true, menuBarShowsPercent: true, + menuBarColorPace: false, showAsUsed: false, showAllTokenAccountsInMenu: true, enableAnimations: true, @@ -69,6 +83,7 @@ const settings: SettingsSnapshot = { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "", + switcherShortcuts: {}, codexCustomSessionsDirs: [], updateChannel: "stable", uiLanguage: "english", @@ -96,6 +111,14 @@ const settings: SettingsSnapshot = { showResetWhenExhausted: false, }; +describe("GeneralTab running version", () => { + it("shows the running app version on the general tab", async () => { + render(); + + expect(await screen.findByText("0.60.3 (42)")).toBeInTheDocument(); + }); +}); + describe("GeneralTab language picker", () => { it("renders all supported language options", () => { render(); @@ -157,6 +180,28 @@ describe("GeneralTab language picker", () => { expect(set).toHaveBeenCalledWith({ predictivePaceWarningEnabled: true }); }); + it("updates the credential expiry alert preference", () => { + const set = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole("checkbox", { + name: "CredentialExpiryNotifications", + }); + expect(toggle).not.toBeChecked(); + fireEvent.click(toggle); + + expect(set).toHaveBeenCalledWith({ + credentialExpiryNotificationsEnabled: true, + }); + }); + it("updates the low power mode preference", () => { const set = vi.fn(); render(); @@ -307,6 +352,27 @@ describe("GeneralTab language picker", () => { }); }); +describe("GeneralTab preferred currency picker", () => { + it("offers AUTO then every catalog currency as CODE (symbol) in catalog order", () => { + render(); + const select = screen.getByLabelText("PreferredCurrencyLabel"); + const options = Array.from(select.querySelectorAll("option"), (option) => option.textContent); + expect(options).toEqual([ + "AUTO", "USD ($)", "GBP (£)", "EUR (€)", "CZK (Kč)", "CNY (¥)", "JPY (¥)", "KRW (₩)", + "CAD ($)", "AUD ($)", "HKD ($)", "TWD (NT$)", "SGD ($)", "INR (₹)", "CHF (Fr.)", + "AED (د.إ)", "TRY (₺)", "NZD ($)", "SEK (kr)", "NOK (kr)", "DKK (kr)", "PLN (zł)", + "BRL (R$)", "MXN ($)", "ZAR (R)", "THB (฿)", "IDR (Rp)", "VND (₫)", "UAH (₴)", + ]); + }); + + it("persists an explicitly selected preferred currency", () => { + const set = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText("PreferredCurrencyLabel"), { target: { value: "TRY" } }); + expect(set).toHaveBeenCalledWith({ preferredCurrencyCode: "TRY" }); + }); +}); + it("renders the theme picker with auto/light/dark options in general mode", () => { render(); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index f2e700a5e3..39df127858 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { useLocale } from "../../../hooks/useLocale"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; -import { playNotificationSound, quitApp } from "../../../lib/tauri"; +import { getAppInfo, playNotificationSound, quitApp } from "../../../lib/tauri"; +import type { AppInfoBridge } from "../../../types/bridge"; import { Field, NumberInput, Select, Toggle } from "../../../components/FormControls"; import type { Language, @@ -14,6 +15,7 @@ import type { UsageThresholdOverride, } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; +import { CURRENCY_PICKER_OPTIONS } from "../../../lib/currency"; import type { TabProps } from "../settingsTabs"; const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ @@ -23,6 +25,7 @@ const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ { value: "japanese", display: "日本語" }, { value: "korean", display: "한국어" }, { value: "spanish", display: "Español" }, + { value: "portuguesebrazil", display: "Português (Brasil)" }, { value: "russian", display: "Русский" }, { value: "turkish", display: "Türkçe" }, ]; @@ -199,6 +202,14 @@ export default function GeneralTab({ .catch(() => {}); // graceful fallback to static default }, []); + const [appVersion, setAppVersion] = useState(null); + + useEffect(() => { + getAppInfo() + .then(setAppVersion) + .catch(() => {}); // version row degrades to absent, never blocks the tab + }, []); + const handleTestSound = useCallback((event: NotificationSoundEvent) => { setSoundError(null); setPlayingSound(event); @@ -266,6 +277,18 @@ export default function GeneralTab({ onChange={(v) => set({ uiLanguage: v as Language })} /> + + + {periodError &&

{periodError}

} {error &&

{error}

} {shareError &&

{shareError}

} @@ -224,6 +257,7 @@ export default function UsageSpendTab(_props: TabProps) { {t("UsageSpendColProvider")} + {periodTitle} {t("UsageSpendCol7d")} {t("UsageSpendCol30d")} {t("UsageSpendColCurrency")} @@ -234,8 +268,36 @@ export default function UsageSpendTab(_props: TabProps) { {(summary?.rows ?? []).map((row) => ( {row.displayName} - {formatSpendMetric(row.sevenDay, row.sevenDayTokens, row.currency, t("UsageSpendTokens"))} - {formatSpendMetric(row.thirtyDay, row.thirtyDayTokens, row.currency, t("UsageSpendTokens"))} + + {formatSpendMetric( + row.periodCost, + row.periodTokens, + row.currency, + t("UsageSpendTokens"), + )} + + + {formatSpendMetric( + row.sevenDay, + row.sevenDayTokens, + row.currency, + t("UsageSpendTokens"), + row.sevenDayEstimate?.knownSubtotalUsd, + t("UsageSpendKnownSubtotal"), + row.sevenDayTokensLowerBound, + )} + + + {formatSpendMetric( + row.thirtyDay, + row.thirtyDayTokens, + row.currency, + t("UsageSpendTokens"), + row.thirtyDayEstimate?.knownSubtotalUsd, + t("UsageSpendKnownSubtotal"), + row.thirtyDayTokensLowerBound, + )} + {row.currency || "USD"} {row.source} @@ -249,17 +311,31 @@ export default function UsageSpendTab(_props: TabProps) { ))} {!loading && (summary?.rows?.length ?? 0) === 0 && ( - {t("UsageSpendEmpty")} + {t("UsageSpendEmpty")} )} )} - {!error && summary && } + {!error && summary && } + + {!error && summary && ( + + )} + + {!error && summary && } {!error && summary && ( - setShowAllModels((value) => !value)} t={t} /> + row.providerId === summary.contract.providerId)?.displayName + ?? summary.contract.providerId, + )} + caption={t("UsageSpendModelsPeriodCaption").replace("{}", costPeriodLabel(summary.contract.reportingPeriod, t))} + t={t} + /> )} {!error && summary && ( @@ -285,7 +361,8 @@ export default function UsageSpendTab(_props: TabProps) { -function SpendContractOverview({ contract, t }: { contract: SpendContract; t: (key: LocaleKey) => string }) { +function SpendContractOverview({ contract, selectedDay, t }: { contract: SpendContract; selectedDay: string | null; t: (key: LocaleKey) => string }) { + const day = findSpendDay(contract.daily, selectedDay); const coverage = contract.priceCoverageRatio == null ? t("UsageSpendUnknown") : `${Math.round(contract.priceCoverageRatio * 100)}%`; @@ -308,12 +385,19 @@ function SpendContractOverview({ contract, t }: { contract: SpendContract; t: (k return (
- + {day ? ( + + ) : ( + + )}
- {contract.imports.map((source) => (

{source.displayName}: {source.requestCount.toLocaleString()} {t("UsageSpendRequests")} · {source.conversationCount.toLocaleString()} {t("UsageSpendConversations").toLowerCase()} @@ -333,70 +417,6 @@ function MetricCard({ label, value, detail }: { label: string; value: string; de ); } -function ActivityHeatmap({ cells, t }: { cells: SpendContract["hourlyActivity"]; t: (key: LocaleKey) => string }) { - const lookup = new Map(cells.map((cell) => [`${cell.weekday}:${cell.hour}`, cell.conversations])); - const max = Math.max(1, ...cells.map((cell) => cell.conversations)); - return ( -

-

{t("UsageSpendHourlyActivity")}

-
- {Array.from({ length: 7 * 24 }, (_, index) => { - const weekday = Math.floor(index / 24); - const hour = index % 24; - const value = lookup.get(`${weekday}:${hour}`) ?? 0; - const alpha = value === 0 ? 0.08 : 0.18 + (value / max) * 0.72; - return ( - - ); - })} -
-
- ); -} - -function ContractModelsPanel({ contract, showAll, onToggleAll, t }: { contract: SpendContract; showAll: boolean; onToggleAll: () => void; t: (key: LocaleKey) => string }) { - const visible = showAll ? contract.models : contract.models.slice(0, 8); - return ( -
-
-
-

{t("UsageSpendModels")}

-

{t("UsageSpendAllTimeHistory")} {contract.historyDays} days of local history.

-
- {contract.models.length > 8 && ( - - )} -
- {visible.length === 0 ? ( -

{t("UsageSpendNoModels")}

- ) : ( -
- {visible.map((model) => ( -
- - {model.model} - - {model.totalTokens.toLocaleString()} {t("UsageSpendTokens")}{model.customPricing ? " · " + t("UsageSpendCustomPricing") : ""} - - - {model.costUsd == null ? t("UsageSpendUnpriced") : formatUsd(model.costUsd, "USD")} -
- ))} -
- )} -
- ); -} - function ProjectsPanel({ contract, showAll, @@ -421,7 +441,7 @@ function ProjectsPanel({

{t("UsageSpendProjects")}

- Ranked Codex local project spend for the last {contract.historyDays} days + {t("UsageSpendProjectsPeriodCaption").replace("{}", costPeriodLabel(contract.reportingPeriod, t))} {partial ? ` · ${t("UsageSpendPartialHistory")}` : ""}.

@@ -471,24 +491,10 @@ function ProjectsPanel({ {isExpanded && ( -
- {contract.conversations - .filter((session) => session.projectId === project.id) - .map((session) => ( -
- - {session.displayTitle} - - - {session.costEstimate.unknownTokens > 0 ? "~" : ""} - ${session.costEstimate.knownUsd.toFixed(2)} - -
- ))} -
+ session.projectId === project.id)} + t={t} + /> )}
); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTrendPanel.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTrendPanel.tsx new file mode 100644 index 0000000000..1ece78aac4 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTrendPanel.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import { formatSpendMetric } from "../../../lib/usageSpendSharing"; +import { findSpendDay, formatSpendDay } from "../../../lib/usageSpendBreakdown"; +import type { SpendContract } from "../../../types/bridge"; +import type { LocaleKey } from "../../../i18n/keys"; + +type TrendSection = "daily" | "hourly"; + +interface Props { + contract: SpendContract; + selectedDay: string | null; + onSelectDay: (day: string | null) => void; + t: (key: LocaleKey) => string; +} + +/** + * Upstream 0.64.0 (#3353): one panel with a compact Day/Hour selector in place + * of separate chart sections, plus a control that clears the selected day. + */ +export function UsageSpendTrendPanel({ contract, selectedDay, onSelectDay, t }: Props) { + const [section, setSection] = useState("daily"); + const hasDaily = contract.daily.length > 0; + const hasHourly = contract.hourlyActivity.length > 0; + const sections: TrendSection[] = [...(hasDaily ? (["daily"] as const) : []), ...(hasHourly ? (["hourly"] as const) : [])]; + if (sections.length === 0) return null; + const active = sections.includes(section) ? section : sections[0]; + const day = findSpendDay(contract.daily, selectedDay); + + return ( +
+
+

+ {active === "daily" ? t("UsageSpendDailySpend") : t("UsageSpendHourlyActivity")} +

+
+ {day && ( + + {formatSpendDay(day.day)} + + + )} + {sections.length > 1 && ( +
+ {sections.map((value) => ( + + ))} +
+ )} +
+
+ {active === "daily" ? ( + + ) : ( + + )} +
+ ); +} + +function DailySpendBars({ + contract, + selectedDay, + onSelectDay, + t, +}: { + contract: SpendContract; + selectedDay: string | null; + onSelectDay: (day: string | null) => void; + t: (key: LocaleKey) => string; +}) { + const max = Math.max(0, ...contract.daily.map((point) => point.costUsd ?? 0)); + return ( +
+
+ {contract.daily.map((point) => { + const selected = point.day === selectedDay; + const height = point.costUsd == null || max === 0 ? 2 : Math.max(2, (point.costUsd / max) * 72); + return ( +
+ {selectedDay && ( +

+ {t("UsageSpendSelectedDayScope")} +

+ )} +
+ ); +} + +export function ActivityHeatmap({ cells, t }: { cells: SpendContract["hourlyActivity"]; t: (key: LocaleKey) => string }) { + const lookup = new Map(cells.map((cell) => [`${cell.weekday}:${cell.hour}`, cell.conversations])); + const max = Math.max(1, ...cells.map((cell) => cell.conversations)); + return ( +
+
+ {Array.from({ length: 7 * 24 }, (_, index) => { + const weekday = Math.floor(index / 24); + const hour = index % 24; + const value = lookup.get(`${weekday}:${hour}`) ?? 0; + const alpha = value === 0 ? 0.08 : 0.18 + (value / max) * 0.72; + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index dda4c24441..c2d33f3719 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -37,6 +37,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["deepseek", "DeepSeek"], ["deepinfra", "DeepInfra"], ["fireworks", "Fireworks"], + ["atlascloud", "Atlas Cloud"], ["aiand", "ai&"], ["zenmux", "ZenMux"], ["clinepass", "ClinePass"], @@ -48,9 +49,9 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["mimo", "Xiaomi MiMo"], ["doubao", "Doubao"], ["commandcode", "Command Code"], - ["crof", "Crof"], ["stepfun", "StepFun"], ["venice", "Venice"], + ["vercel", "Vercel AI Gateway"], ["openaiapi", "OpenAI API"], ["grok", "Grok"], ["elevenlabs", "ElevenLabs"], @@ -77,4 +78,12 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["meta", "Meta"], ["muse", "Muse Code"], ["nous", "Nous Portal"], + ["hyper", "Charm Hyper"], + ["gitkraken", "GitKraken AI"], + ["bifrost", "Bifrost"], + ["aixy", "Aixy"], + ["llmman", "llmman"], + ["devpass", "DevPass"], + ["xkiro", "xKiro"], + ["raycast", "Raycast"], ]; diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index 5075276d82..38a2479cee 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -15,7 +15,9 @@ describe("Language type", () => { const langRu: Language = "russian"; expect(langRu).toBe("russian"); const langTr: Language = "turkish"; + const langPtBr: Language = "portuguesebrazil"; expect(langTr).toBe("turkish"); + expect(langPtBr).toBe("portuguesebrazil"); }); it("allows 'spanish' in LocaleStrings payload", () => { @@ -79,10 +81,12 @@ describe("Language type", () => { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: true, menuBarShowsPercent: true, + menuBarColorPace: false, showAsUsed: false, showAllTokenAccountsInMenu: true, enableAnimations: true, @@ -98,6 +102,7 @@ describe("Language type", () => { autoDownloadUpdates: false, installUpdatesOnQuit: false, globalShortcut: "", + switcherShortcuts: {}, codexCustomSessionsDirs: [], updateChannel: "stable", uiLanguage: "spanish", diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 88754baa7d..d26ebbe551 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -12,7 +12,7 @@ export type SettingsTabId = // ── Narrowed string-literal unions (persisted settings enums) ───────── -export type TrayIconMode = "single" | "perProvider"; +export type TrayIconMode = "single" | "perProvider" | "stacked"; export type NotificationSoundTheme = "windows" | "codexBar"; @@ -53,6 +53,7 @@ export type Language = | "japanese" | "korean" | "spanish" + | "portuguesebrazil" | "russian" | "turkish"; @@ -171,6 +172,7 @@ export interface ProviderSummary { } export interface SettingsSnapshot { + preferredCurrencyCode?: string; enabledProviders: string[]; providerOrder?: string[]; refreshIntervalSecs: number; @@ -188,11 +190,15 @@ export interface SettingsSnapshot { criticalUsageThreshold: number; providerUsageThresholds?: Record; predictivePaceWarningEnabled: boolean; + credentialExpiryNotificationsEnabled: boolean; showPace?: boolean; trayIconMode: TrayIconMode; + stackedTrayTopProvider?: string | null; + stackedTrayBottomProvider?: string | null; switcherShowsIcons: boolean; menuBarShowsHighestUsage: boolean; menuBarShowsPercent: boolean; + menuBarColorPace: boolean; showAsUsed: boolean; showAllTokenAccountsInMenu: boolean; enableAnimations: boolean; @@ -205,9 +211,13 @@ export interface SettingsSnapshot { autoDownloadUpdates: boolean; installUpdatesOnQuit: boolean; globalShortcut: string; + /** Fully resolved action -> shortcut map (defaults overlaid by overrides). */ + switcherShortcuts: Record; /** Extra Codex home or sessions directories scanned for local cost estimates. */ codexCustomSessionsDirs: string[]; agentSessionsEnabled?: boolean; + /** Hold system awake while a local agent session is live. */ + stayAwakeEnabled?: boolean; agentSessionSshHosts?: string[]; /** Master switch for external hooks (hooks.json next to settings). */ hooksEnabled?: boolean; @@ -275,12 +285,26 @@ export interface SettingsSnapshot { /** Opt-in read-only OpenCodex usage.jsonl import. */ openCodexUsageLogsEnabled?: boolean; hideNativeCodexCostWhenOpenCodexPresent?: boolean; + /** + * History window for local cost surfaces: `rolling:N` (1..=365), + * `month-to-date`, or `all`. Absent from older backends. + */ + costReportingPeriod?: string; /** Per-provider accent color overrides (CLI name → hex color, #2972). */ providerAccentColors: Record; } +export interface CurrencyRatesSnapshot { + rates: Record; + /** Codes the converter supports (Rust-owned list). */ + supportedCodes: string[]; + /** Normalized preference this snapshot was built for ("AUTO" or a code). */ + preferredCode: string; +} + /** Partial settings object — only include fields you want to change. */ export interface SettingsUpdate { + preferredCurrencyCode?: string; enabledProviders?: string[]; refreshIntervalSecs?: number; adaptiveRefresh?: boolean; @@ -297,11 +321,15 @@ export interface SettingsUpdate { criticalUsageThreshold?: number; providerUsageThresholds?: Record; predictivePaceWarningEnabled?: boolean; + credentialExpiryNotificationsEnabled?: boolean; showPace?: boolean; trayIconMode?: TrayIconMode; + stackedTrayTopProvider?: string; + stackedTrayBottomProvider?: string; switcherShowsIcons?: boolean; menuBarShowsHighestUsage?: boolean; menuBarShowsPercent?: boolean; + menuBarColorPace?: boolean; showAsUsed?: boolean; showAllTokenAccountsInMenu?: boolean; enableAnimations?: boolean; @@ -314,8 +342,11 @@ export interface SettingsUpdate { autoDownloadUpdates?: boolean; installUpdatesOnQuit?: boolean; globalShortcut?: string; + /** Overrides only; replaces the stored map. `{}` restores the defaults. */ + switcherShortcuts?: Record; codexCustomSessionsDirs?: string[]; agentSessionsEnabled?: boolean; + stayAwakeEnabled?: boolean; agentSessionSshHosts?: string[]; hooksEnabled?: boolean; httpProxyEnabled?: boolean; @@ -360,6 +391,8 @@ export interface SettingsUpdate { costSummaryDisplayStyle?: CostSummaryDisplayStyle; openCodexUsageLogsEnabled?: boolean; hideNativeCodexCostWhenOpenCodexPresent?: boolean; + /** `rolling:N` (1..=365), `month-to-date`, or `all`; the backend rejects other values. */ + costReportingPeriod?: string; providerAccentColors?: Record; } @@ -379,8 +412,16 @@ export interface UsageSpendRow { displayName: string; sevenDay: number | null; thirtyDay: number | null; + sevenDayEstimate?: LocalCostEstimate; + thirtyDayEstimate?: LocalCostEstimate; sevenDayTokens?: number | null; thirtyDayTokens?: number | null; + /** The token figure is a floor from an incomplete scan ("at least N"). */ + sevenDayTokensLowerBound?: boolean; + thirtyDayTokensLowerBound?: boolean; + /** Cost over the selected History window (`UsageSpendSummary.reportingPeriod`). */ + periodCost: number | null; + periodTokens: number | null; currency: string; source: string; includedInOverview: boolean; @@ -391,9 +432,16 @@ export interface UsageSpendRow { staleUpdatedAt?: string; } +export interface LocalCostEstimate { + knownSubtotalUsd: number | null; + coverage: CostCoverageCounts; +} + export interface UsageSpendSummary { rows: UsageSpendRow[]; contract: SpendContract; + /** Raw History window the `period*` columns were built for. */ + reportingPeriod: string; reportingDay: string; dashboardTimezone: string; } @@ -517,6 +565,8 @@ export interface CodexLocalProjectUsageSnapshot { export interface SpendContract { providerId: string; historyDays: number; + /** Raw History window this contract was built for. */ + reportingPeriod: string; knownCostUsd: number | null; knownZero: boolean; provenance: CostProvenance; @@ -544,6 +594,15 @@ export interface BootstrapState { // ── Provider usage snapshot types ──────────────────────────────────── +/** + * A longer exhausted pool (Kimi monthly, upstream 0.69.0 #4091) blocks the + * window until `resetsAt`; `null` means the reset is unknown and the block + * holds. Surfaces re-check it at render time because snapshots are cached. + */ +export interface MonthlyLimitBlock { + resetsAt: string | null; +} + export interface RateWindowSnapshot { usedPercent: number; remainingPercent: number; @@ -552,10 +611,14 @@ export interface RateWindowSnapshot { resetDescription: string | null; isExhausted: boolean; isInformational?: boolean; + /** `resetDescription` is a detail line (for example spend amounts), not reset wording. */ + descriptionIsDetail?: boolean; reservePercent: number | null; reserveDescription: string | null; reserveWillLastToReset?: boolean; reserveEtaSeconds?: number | null; + /** Set while a longer exhausted pool blocks this window; raw percentages stay the provider data. */ + monthlyLimitBlock?: MonthlyLimitBlock | null; } export interface CostDailyPoint { @@ -592,6 +655,24 @@ export interface PaceSnapshot { etaSeconds: number | null; expectedUsedPercent: number; actualUsedPercent: number; + /** Block of the window this pace comes from; no pace is shown while it is active. */ + monthlyLimitBlock?: MonthlyLimitBlock | null; +} + +/** One burndown chart point (RFC 3339 capture time + remaining percent). */ +export interface QuotaBurndownPoint { + capturedAt: string; + remainingPercent: number; +} + +/** Recorded remaining-quota burndown for one series (upstream 0.70.0 #4085). */ +export interface QuotaBurndownSnapshot { + series: "session" | "weekly"; + windowMinutes: number; + start: string; + reset: string; + samples: QuotaBurndownPoint[]; + ideal: [QuotaBurndownPoint, QuotaBurndownPoint]; } export interface SessionEquivalentForecastSnapshot { @@ -624,6 +705,7 @@ export interface ProviderDisplayProgress { /** Transient provider detail row; it is display-only and never quota math. */ export interface ProviderDisplayDetail { id: string; + sectionTitle: string | null; title: string; value: string; secondaryValue: string | null; @@ -663,6 +745,8 @@ export interface ProviderUsageSnapshot { window: RateWindowSnapshot; /** Provider-declared fallback lane; only fills in without a core quota window. */ fallbackLane?: boolean; + /** Provider-declared tray-icon lane this window stands in for when that core lane is absent. */ + iconFallback?: "primary" | "secondary"; }>; /** Display-only discrete provider inventory; never used as quota math. */ inventory?: ProviderInventoryItem[]; @@ -685,7 +769,11 @@ export interface ProviderUsageSnapshot { trayStatusLabel: string | null; fetchDurationMs?: number | null; wayfinderUsage?: WayfinderUsageSnapshot | null; + /** Per-UTC-day OpenAI Admin API history; only the `openaiapi` Admin path sets it. */ + openAiApiUsage?: OpenAiApiUsageSnapshot | null; sessionEquivalentForecast?: SessionEquivalentForecastSnapshot | null; + /** Recorded remaining-quota burndown; Codex and Claude only. */ + quotaBurndown?: QuotaBurndownSnapshot | null; } export interface WayfinderRouteSummary { @@ -717,6 +805,49 @@ export interface WayfinderUsageSnapshot { routes: WayfinderRouteSummary[]; } +/** Line item cost for one UTC day; descending by cost, then name. */ +export interface OpenAiApiLineItemSnapshot { + name: string; + costUsd: number; +} + +/** Model usage for one UTC day; descending by total tokens, then name. */ +export interface OpenAiApiModelUsageSnapshot { + name: string; + requests: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + totalTokens: number; +} + +/** + * One UTC-day bucket. Input and output include audio tokens, cached input is a + * subset of input, and `totalTokens === inputTokens + outputTokens`. + */ +export interface OpenAiApiDailyUsageSnapshot { + /** Bucket start, epoch seconds. */ + startTime: number; + /** Bucket end, epoch seconds; always after `startTime`. */ + endTime: number; + costUsd: number; + requests: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + totalTokens: number; + lineItems: OpenAiApiLineItemSnapshot[]; + models: OpenAiApiModelUsageSnapshot[]; +} + +export interface OpenAiApiUsageSnapshot { + /** Requested window in days (1-365). */ + historyDays: number; + projectId: string | null; + /** Ascending by `startTime`; empty when the window had no data. */ + daily: OpenAiApiDailyUsageSnapshot[]; +} + export interface RefreshCompletePayload { providerCount: number; errorCount: number; @@ -798,6 +929,8 @@ export interface AppInfoBridge { export interface DailyCostPoint { date: string; value: number | null; + /** Claude requests excluded from this day's totals (upstream 0.60.5 #3688). */ + incompleteRequestCount?: number; } /** Exact local token totals per day (upstream 0.50.0 #2930). */ @@ -819,12 +952,19 @@ export interface DailyUsageBreakdown { export interface ProviderLocalUsageSummary { todayCost: number | null; + /** Always the trailing 30 days. */ thirtyDayCost: number | null; thirtyDayTokens: number | null; + /** Selected History window totals. */ + periodCost: number | null; + periodTokens: number | null; + reportingPeriod: string; latestTokens: number | null; topModel: string | null; estimateNote: string; tokenCostUpdatedAtMs: number; + /** Claude requests excluded from the selected-period totals (upstream 0.60.5 #3688). */ + incompleteRequestCount?: number; } export interface QuotaWindowHistoryPoint { @@ -945,6 +1085,9 @@ export interface ProviderDetail { autoResumeAfterQuotaReset: boolean; /** Whether the active credential lane can be correlated to a local CLI session. */ autoResumeSupported: boolean; + /** LiteLLM and Claude expose one opt-in extra breakdown; other providers do not. */ + optionalDetailsSupported: boolean; + optionalDetailsEnabled: boolean; // Identity email: string | null; @@ -957,16 +1100,26 @@ export interface ProviderDetail { // Usage windows — mirror RateWindowSnapshot. session: RateWindowSnapshot | null; weekly: RateWindowSnapshot | null; + /** Provider-declared label for the session (primary) lane, e.g. "Personal budget". */ + primaryLabel?: string | null; + /** Provider-declared label for the weekly (secondary) lane, e.g. "Team budget". */ + secondaryLabel?: string | null; modelSpecific: RateWindowSnapshot | null; tertiary: RateWindowSnapshot | null; /** Locale key for the tertiary metric lane when it carries a semantic label (upstream F5). */ tertiaryLabelKey?: string | null; + /** Extra rate window id holding the monthly plan allowance; offers the Monthly Plan metric (upstream 0.70.0). */ + monthlyPlanWindowId?: string | null; + /** Metric picker label for the primary lane when it is not a session window. */ + primaryMetricLabel?: string | null; extraRateWindows: Array<{ id: string; title: string; window: RateWindowSnapshot; /** Provider-declared fallback lane; only fills in without a core quota window. */ fallbackLane?: boolean; + /** Provider-declared tray-icon lane this window stands in for when that core lane is absent. */ + iconFallback?: "primary" | "secondary"; }>; /** Metric and extra rows exposed by the current provider snapshot. */ usageItems?: ProviderUsageItem[]; @@ -979,6 +1132,8 @@ export interface ProviderDetail { cost: CostSnapshotBridge | null; pace: PaceSnapshot | null; + /** Per-UTC-day OpenAI Admin API history; only the `openaiapi` Admin path sets it. */ + openAiApiUsage?: OpenAiApiUsageSnapshot | null; lastError: string | null; errorState: ProviderStateKind | null; @@ -994,6 +1149,9 @@ export interface ProviderDetail { /** Phase 6c — currently-persisted cookie source value ("auto" | "manual" | "off" | …). * `null` for providers that do not expose a cookie-source picker. */ cookieSource: string | null; + /** Manual cookie source is selected with no usable header, and the provider + * fails closed instead of importing browser cookies. */ + manualCookieMissing?: boolean; /** Phase 6c — currently-persisted region value. `null` for non-regional providers. */ region: string | null; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e2f292802f..30d03b291c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,7 +9,7 @@ Upstream `docs/architecture.md` describes Swift modules (`CodexBarCore`, menu ba |------|------|------| | Shared backend + CLI | `rust/` (`codexbar` crate) | Providers, settings, browser cookies, tray icon pixels, CLI | | Desktop shell | `apps/desktop-tauri/src-tauri/` (`codexbar-desktop-tauri`) | Tauri 2 host: tray, windows, IPC commands, float bar, proof harness | -| Frontend | `apps/desktop-tauri/src/` | React 18 + Vite surfaces (tray panel, pop-out, settings, float bar) | +| Frontend | `apps/desktop-tauri/src/` | React 18 + Vite surfaces (tray panel, settings, float bar) | Cargo workspace (root `Cargo.toml`): members `rust`, `apps/desktop-tauri/src-tauri`; **default-member** is the Tauri crate. Shell depends on `codexbar = { path = "../../../rust" }`. @@ -39,7 +39,9 @@ Cargo workspace (root `Cargo.toml`): members `rust`, `apps/desktop-tauri/src-tau ## Surfaces (desktop) - **Tray panel** — left-click tray; blur-dismiss (suppressed in proof mode). -- **Pop-out / flyout** — larger dashboard window. +- **Tray panel flyout** — larger dashboard window opened from the tray + menu ("Pop Out Dashboard"); the tray panel itself is the only + dashboard layout. - **Settings** — detached window; tabs: `general`, `providers`, `notifications`, `menuBar`, `menu`, `usageSpend`, `advanced`, `about`. - **Float bar** — optional capacity strip. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index abed8e384c..04c4fd4f39 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -150,3 +150,27 @@ cd apps/desktop-tauri && pnpm exec tsc --noEmit cargo clippy --all-targets -- -D warnings cargo fmt --all --check ``` + +## Stored process environments + +A process environment carries API keys, tokens and cookies. Every struct, union or enum-variant +field that stores one uses `codexbar::process_environment::ProcessEnvironment`, including +optional maps and `(OsString, OsString)` pair lists. Its `Debug` output is +`ProcessEnvironment(N entries; redacted)`, so `{:?}`, `dbg!`, `tracing` field captures and +`assert_eq!` failures never print variable names or values. Wrapping an `Option` keeps `None` +distinct from an empty map, and equality still compares the original contents. + +Deref, iteration, `get` and `insert` still return the original values for the child process, +so never log what they return. Function parameters and locals are transient and stay unwrapped. + +The `process_environment::storage_guard` test scans `rust/src` and +`apps/desktop-tauri/src-tauri/src` for environment-named fields whose type is a string map or a +string pair list, through `Option`, `Box`, `Arc`, references, slices and local `type` aliases, +and fails on any that is not wrapped. Each entry in its `REVIEWED_EXCEPTIONS` list must match +exactly one declaration, so stale or duplicate exceptions fail too. The guard is a lexical +tripwire, not a type checker: generic parameters, differently named fields and explicit logging +of an unwrapped map still need code review. + +```bash +cargo test -p codexbar process_environment +``` diff --git a/docs/CLI.md b/docs/CLI.md index a9c25bcdcc..184cb6e6a8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -28,22 +28,26 @@ codexbar config validate codexbar config dump ``` +Set `CODEXBAR_CONFIG` to run the CLI against another settings file; see [Separate config file](#separate-config-file-codexbar_config). + ## Commands (current) Top-level (from `codexbar --help`): | Command | Purpose | |---------|---------| -| `usage` | Print usage from enabled providers (default-style workflow; also global `-p` / `-f`) | +| `usage` | Print usage from the enabled providers, or the ones passed with `-p` | | `cost` | Local token cost usage (Claude + Codex session scans; no web required for those) | | `guard` | Gate automation on remaining quota for one provider | | `diagnose` | Export safe provider diagnostics as JSON | | `sessions` | List or focus local / SSH agent sessions | | `serve` | HTTP JSON/dashboard server with optional Prometheus metrics | +| `dashboard` | Emit a one-shot dashboard snapshot (JSON to stdout or `--output` file) | | `autostart` | Manage Windows boot auto-start | | `account` | Token accounts for providers | -| `config` | validate / dump / providers / enable / disable / set-api-key / path | +| `config` | validate / dump / providers / enable / disable / set-api-key / path / claude-code-credentials | | `hooks` | List, enable, disable, test, or watch external hooks | +| `workspaces` | List local Codex project/workspace usage | ### Usage @@ -55,7 +59,31 @@ codexbar usage --source auto # auto | web | cli | oauth codexbar usage --brief ``` -Global-style flags (also on root help): `-p/--provider`, `-f/--format`, `--json`, `--pretty`, `--status`, `--all-accounts`, `--account`, `--no-credits`, `--source`, `--web-timeout`, `--brief`. +These flags belong to `usage`: `-p/--provider`, `-f/--format` (`text`, `json` or `toon`), `--json`, `--pretty`, `--status`, `--all-accounts`, `--account`, `--no-credits`, `--no-color`, `--source`, `--web-timeout`, `--brief`. The root command takes only `-v/--verbose`, `--json-output` (JSON logs on stderr) and `--no-color`; `codexbar` without a subcommand prints a hint and exits with a usage error. + +Without `-p`, `usage` follows the providers enabled in settings, like upstream: exactly Codex and Claude are fetched as `both`, one enabled provider is a single fetch, and any other set keeps the display order. With nothing enabled, JSON and TOON print an empty list, and text explains how to enable a provider (`codexbar config enable `) or pass `--provider`. An explicit `-p` (a provider, `both` or `all`) never reads the enabled list. `--account` needs exactly one provider, including when the enabled list resolves to several. `cost` keeps its Claude default. + +**Error rows.** In JSON and TOON output a provider that fails becomes a row with `provider`, the human-readable `error`, and `errorKind`, so scripts can react without parsing the text: + +| `errorKind` | Meaning | +|---|---| +| `needsAuthentication` | Credentials are missing or were rejected; sign in again or add a key. | +| `expiredSession` | The credentials worked but the session or token has expired. | +| `localRuntimeOffline` | A local runtime the provider reads is not running, for example the Claude CLI is not installed. | +| `browserSignInRequired` | Only a browser sign-in can bring usage back. The row adds `signInUrl`, the page to open. | +| `timeout` | The fetch ran out of time. | +| `unknown` | Anything else, including failures before the provider runs (account selection, stored keys). | + +The first three use the same names as the desktop's provider states. Claude reports `browserSignInRequired` (with `signInUrl` `https://claude.ai/login`) when its OAuth usage endpoint is rate limited, no claude.ai browser session was found, and the CLI probe failed too. Until the rate limit lifts, signing in to claude.ai in a browser is the only way back, and the `error` text says so. + +```json +{ + "provider": "claude", + "error": "Claude usage failed from all configured sources. ... Sign in at https://claude.ai/login in your browser, then refresh.", + "errorKind": "browserSignInRequired", + "signInUrl": "https://claude.ai/login" +} +``` ### Cost @@ -64,8 +92,20 @@ codexbar cost codexbar cost -p codex -f json --pretty codexbar cost -p codex --remote user@mac-host codexbar cost -p codex --format json --summary-only --provider-native-only --days 30 +codexbar cost --period month-to-date --json +codexbar cost --period all --json +codexbar cost --days 7 --json ``` +Claude/Codex costs come from local session logs. Antigravity reads supported local token history; known models receive API list-price estimates from the bundled price table or the models.dev pricing catalog, and unknown models stay unpriced rather than becoming a false `$0`. Antigravity's Gemini 3.1 Pro aliases (`gemini-pro-default`, `gemini-pro-agent`, and the `gemini-3.1-pro` effort tiers) price as `gemini-3.1-pro-preview`, the only catalogued Gemini 3.1 Pro row; the alias is provider-local and recorded model names stay unchanged. These estimates are not Antigravity charges or credit deductions. Other providers may differ; do not assume upstream Cursor dashboard cost behavior unless implemented in this tree. + +Antigravity history that stopped short is never shown as exact. A scan whose decoded rows are trustworthy reports `tokensAreLowerBound` / `costIsLowerBound` (text: "at least N"); a scan cut off by a hard limit, or one whose sources contradict each other, is withheld and publishes no total. A later partial read does not replace an earlier complete read of the same window and roots within one process. Reading the history never waits on the network. When a recorded model has no known public price, the desktop Usage & Spend view and `serve /cost` start one bounded models.dev pricing refresh in the background and a later read picks up the new prices; `codexbar cost --provider antigravity --refresh` waits for that refresh and rescans, while a plain `codexbar cost` starts no download. The request carries no account identity or usage data, and a failure leaves the affected models unpriced. Empty or absent history never starts a pricing download. +**Reporting period.** `--period month-to-date|all` selects the calendar month so far (midnight on the first of the month, in the machine's local zone, through today) or all available local history. `--days N` (1..=365) always selects a rolling window and wins over `--period`. With neither flag the saved `cost_reporting_period` in `settings.json` applies (`rolling:30`, `month-to-date` or `all`; 30 days when unset). Text headings show the period label (`Last 30 days`, `Month to date`, `All`, `Today`). JSON payloads add `reportingPeriod` (the raw value), `historyLabel`, and a `totals` object (`inputTokens`, `outputTokens`, `cachedTokens`, `reasoningTokens`, `totalTokens`, `totalCost`) for the selected window, and `days_scanned` is the resolved day count; the existing `cost`, `tokens`, and `sessions_count` fields keep their meaning. `totalTokens` follows each provider's token rule: Claude and Pi add cache reads and writes to input + output, while Codex input already includes cached input, so its total is input + output. `totals` is `null` when a scan found nothing without establishing a known zero. + +`--remote` and `--summary-only` keep the 1..=365 day protocol: month to date is sent as its current day count, and All is rejected unless you pass an explicit `--days N`. `--group-by session` lists the most recent 365 days at most when All is selected. All reads every available local log, including logs older than a year; missing or deleted logs cannot be recovered and incomplete scans stay marked incomplete. + +`serve` `/cost` reads the saved period on every request, so `days_scanned`, `totals`, the scanner windows and the `daily` chart rows follow it (`daily` covers at most the latest 365 days for All). The dashboard cost line, the dashboard snapshot, and the Prometheus gauge `codexbar_cost_last_30_days_usd` keep their 30-day meaning. + Claude/Codex costs come from local session logs. Antigravity exposes local **token history only** through `cost`; dollar cost remains unknown rather than becoming a false `$0`. Other providers may differ; do not assume upstream Cursor dashboard cost behavior unless implemented in this tree. `--remote` adds one separate native Codex report fetched through non-interactive SSH; overlapping local and remote histories are never combined. `--summary-only` emits the versioned, path-free JSON contract used by the remote comparison and accepts only `--provider codex --format json`. Both modes reject session grouping and other provider selections. @@ -91,6 +131,16 @@ codexbar serve --port 8080 Typical endpoints: `/health`, `/usage`, `/cost`, and `/dashboard/v1/snapshot`. Loopback default keeps local use simple; treat non-loopback as a threat-model choice because the token for protected requests crosses the network over HTTP. +The dashboard page at `/` has a "Usage display" control (Follow server, Used, Remaining). Follow server uses `host.usageBarsShowUsed` from the snapshot; the other two override it for this browser only, stored in `localStorage` under `codexbar.dashboard.usageDisplay`. Changing it re-renders the cached snapshot without refetching. Bar colour always follows consumption (70% used warns, 90% used is full), whichever value is shown. + +`/usage` without a `provider` query (or with an empty one) follows the enabled providers, like a plain `codexbar usage`; `/cost` still defaults to Claude. `/usage` fetches the selected providers concurrently, and its error rows carry the same `errorKind` (and `signInUrl`) as `usage --json`. + +```powershell +codexbar serve --request-timeout 30 +``` + +`--request-timeout ` bounds `/usage` and `/cost`. A provider still running after 0.8 of the timeout becomes a row such as `{"provider":"claude","error":"claude usage timed out","errorKind":"timeout"}` (`/cost`: `codex cost refresh timed out`), and a request that outlives the whole timeout answers `504` with `{"error":"request timed out"}`. The fetch behind a timed-out row keeps running: the next request for that provider joins it instead of starting another, and at most eight fetches or scans run at once. Finished results are not cached, so the first request after a fetch completes starts a new one. The default `0` waits for every provider. Upstream defaults to 30 seconds and serves the late result from a response cache; this port has no such cache, so the timeout is opt-in. Values are capped at 86400, and negative values are rejected with `--request-timeout must be zero or greater.` + Pass `--metrics` to enable the Prometheus text endpoint at `/metrics`; it returns `404` when the flag is absent. The endpoint uses the same Host allowlist, Bearer token, snapshot cache, and single-flight collection as the dashboard snapshot. A scrape never waits for provider I/O: it returns the last successful snapshot while an expired value refreshes in the background, or `codexbar_up 0` until the first collection succeeds. ```powershell @@ -101,19 +151,43 @@ curl.exe -H "Authorization: Bearer $env:CODEXBAR_DASHBOARD_TOKEN" http://127.0.0 The metrics contract exports collection health for every enabled, known provider. The only provider label is its bounded canonical CLI slug, for example `codexbar_provider_up{provider="claude"}`; disabled providers are absent, and an ordinary provider fetch failure does not suppress healthy provider series. Quota semantics are currently exported only for Codex through fixed `session`, `weekly`, `monthly`, and `code_review` metric families. Used and remaining values are ratios from `0` to `1`, with no dynamic window label. Available local Codex cost estimates are also exported. +Codex OAuth reset credits are exposed as `codexbar_reset_credits_available{provider="codex"}` (`0` exhausted, `-1` unavailable or PAT) and, when present, `codexbar_reset_credits_next_expiry_timestamp_seconds{provider="codex"}`. Successful and unavailable reset-credit observations are cached for ten minutes. See [Prometheus examples](prometheus/README.zh-CN.md) for a Chinese Grafana dashboard, scrape settings, and alert rules. + Unknown, informational, non-finite, and dynamic additional-limit values are omitted instead of being inferred or replaced with sentinels. Account identity, display labels, source names, free-form provider errors, and version strings are not exposed. Consumers should alert on provider health, snapshot staleness, and quota values together. ### Config ```powershell codexbar config providers +codexbar config providers --json --pretty codexbar config enable -p cursor -codexbar config disable -p cursor +codexbar config disable cursor --json printf '%s' $env:OPENROUTER_API_KEY | codexbar config set-api-key -p openrouter --stdin +codexbar config claude-code-credentials status --json codexbar config validate ``` -`enable` / `disable` persist settings. `usage -p ` is a one-shot override and does not by itself toggle enabled state the same way. +`enable` / `disable` persist settings. `usage -p ` is a one-shot override and does not by itself toggle enabled state the same way. `enable`, `disable` and `set-api-key` take the provider either as a positional name or as `-p/--provider`. + +`config providers`, `enable`, `disable` and `claude-code-credentials` accept `--format text|json`, `--json` and `--pretty`. Their JSON follows upstream: + +- `config providers`: one `{provider, displayName, enabled, defaultEnabled}` object per provider. The text lines are unchanged. +- `config enable` / `config disable`: `{provider, displayName, enabled, configPath}`. +- `config claude-code-credentials allow|deny|status`: `{allowed, configPath}`. + +`claude-code-credentials` controls whether CodexBar may read, and refresh, Claude Code's own OAuth credentials (`~/.claude/.credentials.json` or Windows Credential Manager). It is the same choice as the desktop setting and is off by default. While it is off, Claude Auto falls back to reduced-fidelity CLI usage. `allow` and `deny` save the choice, and `status` only reports it. + +#### Separate config file (`CODEXBAR_CONFIG`) + +```powershell +$env:CODEXBAR_CONFIG = 'D:\isolated\codexbar\settings.json' +codexbar config path +codexbar usage --json +``` + +`CODEXBAR_CONFIG` names the settings file this CLI process uses instead of `%AppData%\Roaming\CodexBar\settings.json`. Surrounding whitespace is trimmed, an empty value is ignored, a leading `~` becomes your home directory, and a relative path is resolved from the current directory. Upstream keeps provider keys, cookies and token accounts in that one file; this port keeps them in separate stores, so `api_keys.json`, `manual_cookies.json` and `token-accounts.json` are read from and written to the same folder as the chosen file. Logs, caches and other app state stay in their default locations. + +Only the `codexbar` CLI reads the variable; the desktop app ignores it. A CLI run with the variable set also skips the desktop integrations that loading settings normally performs: it does not sync the start-at-login registry entry or run the tray-default migration. `config path` lists the resulting paths and notes when they come from `CODEXBAR_CONFIG`. ### Hooks diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 99e470487b..a549120db9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -24,6 +24,8 @@ Implementation: `dirs::config_dir()/CodexBar/...` via `Settings::settings_path() Desktop UI and CLI share these stores. Prefer the Settings window for day-to-day toggles; use `codexbar config` for scripts/CI. +A CLI process can use another settings file through `CODEXBAR_CONFIG`; the API key, manual cookie and token account stores then sit beside that file. The desktop app ignores the variable. See [CLI.md](./CLI.md#separate-config-file-codexbar_config). + ## What lives where (conceptual) Aligned with upstream *ideas*, mapped to this port: @@ -32,7 +34,7 @@ Aligned with upstream *ideas*, mapped to this port: - **Manual cookie headers** → `manual_cookies.json` (and/or settings fields depending on provider path) - **API keys** → `api_keys.json` / keyring helpers where used - **Token accounts** → `token-accounts.json` -- **Browser auto cookies** → extracted at runtime from Chrome/Edge/Brave/Firefox profiles (see [COOKIES.md](./COOKIES.md)); not a substitute for committing secrets into git +- **Browser auto cookies** → extracted at runtime from Chrome (any channel)/Chromium/Edge/Brave/Firefox profiles (see [COOKIES.md](./COOKIES.md)); not a substitute for committing secrets into git Do not commit real `settings.json` / key files into the repo. @@ -49,8 +51,21 @@ codexbar config path # API key via stdin (example) printf '%s' $env:OPENROUTER_API_KEY | codexbar config set-api-key -p openrouter --stdin + +# Portable preferences (also under Settings > Advanced) +codexbar config preferences export --file prefs.json # omit --file to print to stdout +codexbar config preferences import --file prefs.json ``` +Portable preferences are a versioned JSON document (`{"version": 1, "preferences": {...}}`) +limited to display, refresh, notification, provider-order, float-bar, cost reporting period, +preferred currency and switcher-shortcut choices. API keys, cookies, token accounts, folders, SSH +hosts, proxy settings, update settings, Stay Awake and the pinned cost time zone are never +exported, and an import that contains any other key, or any invalid value, is rejected without +changing settings. +The CLI import writes `settings.json` only; restart a running CodexBar to pick it up. The desktop +Import button applies the file live. + Notes: - `enable` / `disable` are **persistent** (same idea as upstream). @@ -72,6 +87,35 @@ $env:CODEXBAR_PROOF_MODE = "settings:menu" # then launch the desktop binary ``` +## Provider switcher keys + +The tray flyout switches providers from the keyboard (ported from upstream 0.67.0; the shortcuts are menu-local, not global hotkeys): + +| Action | Default key | +|--------|-------------| +| Previous / next | `Left` / `Right` (wraps through Overview) | +| Overview, then providers in display order | `Ctrl+1` ... `Ctrl+9` | + +Keys are ignored while a text field, select or slider (the zoom slider) has focus, or while a grid drag is active. + +### Customizing the keys + +**Settings → Menu → Provider switcher shortcuts** lists all 11 actions. Choose **Record**, then press the key or combination; **Backspace** while recording (or **Clear**) disables the action, **Reset to defaults** restores every default. The editor rejects duplicates and reserved keys with an inline message and saves nothing in that case. + +Only the actions you changed are stored, in `settings.json` under `switcher_shortcuts` (the key is omitted when everything is default): + +```json +{ "switcher_shortcuts": { "previous": "shift+left", "select2": "ctrl+alt+2", "next": "none" } } +``` + +Rules (upstream grammar, validated by `rust/src/switcher_shortcuts.rs` and mirrored in `apps/desktop-tauri/src/lib/switcherShortcuts.ts`): + +- Actions: `previous`, `next`, `select1` ... `select9`. Unknown actions are rejected. +- A shortcut is optional `ctrl`, `alt`, `shift` modifiers plus one key: `left`, `right`, `,`, a letter or a digit. `cmd` is accepted as an alias for `ctrl`, so shortcuts copied from macOS stay valid. +- Letters, digits and `,` need `ctrl` or `alt`. `ctrl+r`, `ctrl+q`, `ctrl+,` and `ctrl+w` are reserved (Refresh, Quit, Settings and window close). +- Two actions cannot share a shortcut. `none` disables an action and frees its key. +- An invalid stored map is ignored on load (defaults apply) and logged as a warning; it never blocks the rest of the settings. + ## Claude Code accounts In **Settings → Providers → Claude → Claude Code accounts**, use **Save current @@ -127,6 +171,23 @@ skills, and other Grok home files stay in place. `XAI_API_KEY` and `GROK_OAUTH_TOKEN` are unset for the isolated sign-in so the browser OAuth flow is used. +## Antigravity CLI path + +When neither the Antigravity desktop app nor a signed-in `agy` session is +running, the Antigravity provider can use the `agy` CLI. Win-CodexBar looks +for it on `PATH`, then at `%LOCALAPPDATA%\agy\bin\agy.exe`, then at +`%USERPROFILE%\.local\bin\agy.exe`. Set `ANTIGRAVITY_CLI_PATH` to the full +path of `agy.exe` when it is installed somewhere else. + +A set `ANTIGRAVITY_CLI_PATH` is authoritative. If it is empty or does not +point to a file, Win-CodexBar skips the CLI source and names the variable in +the provider error instead of discovering another `agy` through `PATH` or the +install directories, so a background refresh never starts a different CLI +that could ask for an interactive sign-in. A running desktop app still +answers first, and offline conversation history is still shown when it +exists. Unset the variable to restore automatic discovery. Other providers +keep their own override behavior. + ## Source mode CLI `--source` values on this port (see `codexbar usage --help`): `auto`, `web`, `cli`, `oauth`. diff --git a/docs/COOKIES.md b/docs/COOKIES.md index 804562d077..1a33031228 100644 --- a/docs/COOKIES.md +++ b/docs/COOKIES.md @@ -9,9 +9,10 @@ Win-CodexBar can extract browser cookies for providers that use web authenticati | Chrome | DPAPI + AES-256-GCM; modern profiles may use Chromium ABE (`v20`) | ⚠️ Automatic only when the needed cookies are not App-Bound | | Edge | DPAPI + AES-256-GCM; modern profiles may use Chromium ABE (`v20`) | ⚠️ Automatic only when the needed cookies are not App-Bound | | Brave | DPAPI + AES-256-GCM; modern profiles may use Chromium ABE (`v20`) | ⚠️ Automatic only when the needed cookies are not App-Bound | +| Chrome Beta, Dev, Canary, Chrome for Testing, Chromium | DPAPI + AES-256-GCM; modern profiles may use Chromium ABE (`v20`) | ⚠️ Automatic only when the needed cookies are not App-Bound | | Firefox | Unencrypted SQLite | ✅ Automatic | -Chromium App-Bound Encryption (ABE) binds protected cookie keys to the browser installation. Win-CodexBar does not bypass that protection. If the selected Chromium profile stores the provider cookies as App-Bound `v20` values, automatic import cannot decrypt them with the normal user DPAPI key. Use a manual Cookie header or Firefox instead. Chromium browser choices remain available because older or unmigrated profiles can still contain readable DPAPI/AES-GCM cookies. +Chromium App-Bound Encryption (ABE) binds protected cookie keys to the browser installation. Win-CodexBar does not bypass that protection. If the selected Chromium profile stores the provider cookies as App-Bound `v20` values, automatic import cannot decrypt them with the normal user DPAPI key. Use a manual Cookie header or Firefox instead. Chromium browser choices remain available because older or unmigrated profiles can still contain readable DPAPI/AES-GCM cookies. Each Chrome channel (Stable, Beta, Dev, Canary, Chrome for Testing) and Chromium is a separate import choice that reads only its own `User Data` profiles. ## How It Works @@ -37,6 +38,10 @@ If automatic extraction fails (for example, Chromium App-Bound Encryption is act 4. Copy the `Cookie` header value from **Request Headers** 5. In CodexBar Settings → provider detail → **Browser Cookies**, paste the value +## Kimi local-storage tokens + +With the Kimi cookie source set to automatic, CodexBar also reads `access_token` from Chromium browsers' `Local Storage` for the selected Kimi region (`www.kimi.com` or `www.kimi.ai`), after the Kimi Desktop session and browser cookies. Local storage is not App-Bound encrypted, so this can work when cookie decryption is blocked. Only unexpired three-segment JWTs are used; refresh tokens are never read. A manual Cookie header always wins, and Cookie source Off or Manual skips this step. Open Kimi in the browser to renew an expired session. Firefox and Safari local storage are not read, and only the `Default` and `Profile N` profiles are scanned. + ## Troubleshooting - **"Chromium App-Bound Encryption"**: Modern Chrome, Edge, Brave, and other Chromium-based profiles can protect cookies with ABE. Closing the browser does not remove ABE; use a manual Cookie header or Firefox for the same login diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 3dce00e1aa..ba42f52068 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -64,10 +64,11 @@ Deleting these folders removes every trace the app wrote, including secrets. ### Browser cookies If — and only if — you opt in per provider, the app reads cookies for that -provider's domain from your local Chrome, Edge, Brave, or Firefox profile -(Chromium cookies are decrypted locally with your user DPAPI key; Firefox -cookies are read from its SQLite store). Cookies are used only to call that -provider's own web endpoints for usage data. See [COOKIES.md](COOKIES.md). +provider's domain from your local Chrome (any channel), Chromium, Edge, Brave, +or Firefox profile (Chromium cookies are decrypted locally with your user +DPAPI key; Firefox cookies are read from its SQLite store). Cookies are used +only to call that provider's own web endpoints for usage data. See +[COOKIES.md](COOKIES.md). ## Network endpoints the app contacts diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 237f7dab3d..0cf80274a7 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -38,13 +38,54 @@ CLI: `codexbar usage --source auto|web|cli|oauth`. Auth resolution helpers in `rust/src/providers/` commonly try: explicit settings → keyring/entry → environment variables (exact order is provider-specific). +### Devin manual authentication + +On Windows, Devin uses a manually pasted Bearer token; Chrome-session import is +not available. In Devin, open Developer Tools → Network, reload **Usage & +Limits**, then copy the `Authorization` value from a successful +billing/quota/usage request. In Settings → Providers → Devin, paste a bare +token, a `Bearer ...` value, or the full `Authorization: Bearer ...` line into +the token field. Never share this token. + +Set **Organization** to the internal `org-...` or `org_...` ID, an organization +slug, or a `devin.ai` organization URL. The internal ID from the +`x-cog-org-id` header on a successful quota request is the most direct choice. +Environment variables are `DEVIN_BEARER_TOKEN`, `DEVIN_AUTHORIZATION`, or +`DEVIN_API_KEY` for the token, and `DEVIN_ORGANIZATION` or `DEVIN_ORG` for the +organization. + ## Cookie-backed providers -Windows browser import: Chrome, Edge, Brave (DPAPI + AES-GCM), Firefox (SQLite). +Windows browser import: Chrome Stable, Beta, Dev, Canary, Chrome for Testing, Chromium, Edge, Brave (DPAPI + AES-GCM), and Firefox (SQLite). Chrome channels are detected separately and keep distinct import choices. Settings → **Providers** → provider detail → choose browser → Import. Manual cookie header paste is the fallback (required under WSL for Chromium DPAPI). Details: [COOKIES.md](./COOKIES.md). +### Venice web session + +Venice normally uses an API key (USD / DIEM balance). The optional web mode +reads the signed-in `venice.ai` Clerk session instead and reports the +bundled-credit quota from the session token. + +- Web (automatic) mode needs a signed-in `venice.ai` tab in a supported + browser, because the session token comes from the `__session` cookie. +- Clerk sessions last only about 60 seconds, so Web mode does not refresh + unattended. When the session has expired, open `venice.ai` in the browser + again and refresh. +- Manual mode needs a freshly pasted Cookie header from a signed-in + `venice.ai` request; an old header fails with the expired-session message. +### Raycast credits + +Raycast reads the unofficial `frontend_api/current_user/ai_credits` route of +`www.raycast.com` with the `__raycast_session` and `csrf_token` cookies only. +Auto imports those cookies from Chrome (all profiles, exact `www.raycast.com` +host first); Windows Chrome App-Bound Encryption can block automatic import, in +which case paste a Cookie header under Manual. A manual header is pinned and +never falls back to a browser, and Off makes no request. When the response has +a positive allowance and a known balance the provider shows one "credits left" +meter with the renewal date; otherwise it shows the balance and total as detail +rows. Top-up packages and credit details are not fetched. + ### Replicate billing Replicate uses the signed-in `replicate.com` session cookie for its billing @@ -55,6 +96,95 @@ spend and, when the optional balance request succeeds, prepaid credit balance. It keeps those values in the cost/detail surfaces and does not invent a quota percentage or use a Replicate API token as a website credential. +### Charm Hyper balance + +Charm Hyper is disabled by default. It reads +`GET https://hyper.charm.land/v1/credits` and shows one **Hypercredits** detail +row in native HC units; it never infers a quota percentage, plan, reset or +spend. The cookie source only picks the session: Automatic imports the +`hyper.charm.land` session from the selected browser, Manual uses a pasted +Cookie header, and Disabled uses only the API key. The usage source keeps +routing. Auto prefers the session and falls back to the API key (the saved key, +then `HYPER_API_KEY`) when there is no session, the session request fails, or +the session is rejected (401, 403, a redirect or an HTML sign-in page). +Browser session never uses the key, and API never reads cookies. The cookie and +the bearer key are never sent together. Rate limits, server errors and +malformed balances are final and do not fall back. Upstream's multiple API-key +token accounts are not ported yet. + +### llmman daemon + +llmman reads a local (or LAN) llmman daemon: `GET {base}/llmman/node` for the +memory budget plus loaded and stored models, and a best-effort +`GET {base}/api/version`. The base URL comes from provider extras or +`LLMMAN_HOST` and defaults to `http://127.0.0.1:17434`; a trailing `/v1` is +accepted and dropped per request. `LLMMAN_API_KEY` is optional, because a +daemon without configured keys is open, and is sent as a Bearer token only when +set. Plain HTTP is accepted only for localhost, loopback, private-network +(10/8, 172.16/12, 192.168/16, 169.254/16, fc00::/7, fe80::/10) and `.local` +hosts; any public host must use HTTPS. Embedded credentials, queries, and +fragments are rejected. + +### DevPass + +DevPass reads `GET https://api.llmgateway.io/v1/key` with a regular LLM Gateway +API key (`DEVPASS_API_KEY` or the Settings key field). Publishable keys and +end-user sessions get HTTP 403. Plan credits are the primary lane and the +premium weekly allowance is the secondary lane (seven-day window; the reset +comes only from the response, and an inactive window has no reset). A +pay-as-you-go key (`devPlan: none`) shows only key-scoped all-time spend. +Remaining plan credits are an allowance, not a wallet balance, so no balance is +reported. Amounts must be plain decimal strings; anything else fails the +refresh instead of showing as zero. The key is only sent to the fixed HTTPS +origin, redirects are not followed, and response bodies never appear in errors. + +### Muse Code browser team quota + +Muse Code reads its quota from the device-code login. When that response has no +`subs_usage`, CodexBar can optionally read the quota of one dev.meta.ai team you +select in Settings → Providers → Muse Code → **Browser team**. + +- The cookie source is **Off** by default. The Windows default ("Manual" with no + pasted cookie) also reads nothing, and never touches a browser. +- **Automatic** imports the `llama_dev_sess` cookie from the selected browser. + **Manual** accepts a pasted Cookie header or cURL capture. +- Requests, in order: `GET /api/auth/me` (its email must match the login email, + case-insensitively), `GET /api/portal/teams`, then + `GET /api/portal/teams/{id}/subscription-quota` for the selected team only. The + team's `tier` must equal the login plan. At most 5 requests, 8 seconds each. +- The team list appears as the picker options and starts at "Choose a team..."; + the first team is never selected for you. The team ID is stored as the provider + workspace value and accepts digits only. +- The source label becomes `oauth+web`. This port has no `estimated` data + confidence, so the pace shown for this reading is marked non-authoritative + instead. The device-code token is only sent to `api.meta.ai`; dev.meta.ai + requests carry only the browser cookie. +- The CLI does not read browser cookies for Muse Code. + +## API-key gateway providers + +### Aixy + +Aixy reports the API key's own usage and the budgets that apply to it +(`GET {base}/v1/usage`, `Authorization: Bearer `). Configure the key in +Settings → Providers → Aixy (or a token account, or `AIXY_API_KEY`). Leave the +Base URL empty for `https://api.aixy-gateway.com`; set it (or `AIXY_BASE_URL`) +for a self-hosted gateway. A trailing `/v1` and path prefix are accepted; HTTPS +is required except for localhost, private-network and `.local` hosts, and a +Base URL with embedded credentials, a query or a fragment is rejected. Redirects +are never followed and response bodies are never echoed in errors. + +- Hard budgets come first; the two most-utilised known budgets fill the primary + and secondary lanes and every other budget is a named window. Overlapping + budgets are never summed. Unknown balances show as Unavailable. +- Details show the key, project and observation time, each applicable budget, + and the last seven days of requests, tokens, attributed spend and coverage. + Attributed spend is the provider cost; it is not an invoice. +- Only the Automatic menu-bar metric is offered. +- A response that fails the `key.usage` contract (wrong currency, another key's + budget, inconsistent coverage, malformed amounts) is a parse error, not a + partial balance. + ## Listing what is enabled ```powershell @@ -71,7 +201,7 @@ Optional status polling (provider status pages) is available via CLI `--status` ## Usage & Spend -Desktop tab id: `usageSpend`. The desktop and Overview consume one shared spend catalog. Codex and Claude local logs are first-class; routed OpenCodex usage enriches the matching Codex, OpenCode Go, Kimi, or DeepSeek subscription instead of appearing as a second fake provider. xAI and OpenRouter can publish exact provider-metered daily USD spend when their management credentials are configured, while Grok local sessions contribute tokens only. Missing spend sources remain unknown rather than becoming a false `$0`. Do not invent cross-currency totals. +Desktop tab id: `usageSpend`. The desktop and Overview consume one shared spend catalog. Codex and Claude local logs are first-class; routed OpenCodex usage enriches the matching Codex, OpenCode Go, Kimi, or DeepSeek subscription instead of appearing as a second fake provider. xAI and OpenRouter can publish exact provider-metered daily USD spend when their management credentials are configured, while Grok local sessions contribute tokens only (the scan covers exactly the requested number of local calendar days, including today). Missing spend sources remain unknown rather than becoming a false `$0`. Do not invent cross-currency totals. ### AWS Bedrock monitoring @@ -85,14 +215,30 @@ Custom pricing overlays are exact-match overrides used only where the local spen ### OpenCode, Codex quota, and local cost boundaries -OpenCode-held OpenAI/Codex OAuth can be reused for **remote Codex account quota** only when the Codex provider's `External OAuth sources` setting is explicitly enabled. Native Codex credentials still take precedence, an explicit `CODEX_HOME` stays isolated, and external credentials remain read-only. This does **not** import ordinary OpenCode sessions into Codex token or spend totals. OpenCode Go's local SQLite reader remains scoped to its own `opencode-go` assistant records; OpenAI API-platform usage is a separate provider. +OpenCode-held OpenAI/Codex OAuth can be reused for **remote Codex account quota** only when the Codex provider's `External OAuth sources` setting is explicitly enabled. Native Codex credentials still take precedence, an explicit `CODEX_HOME` stays isolated, and external credentials remain read-only. This does **not** import ordinary OpenCode sessions into Codex token or spend totals. OpenCode Go's local SQLite reader remains scoped to its own `opencode-go` assistant records; OpenAI API-platform usage is a separate provider. Its recorded local token counts (`tokens` on assistant messages and step-finish parts; step-finish parts win over their parent message) fill Usage & Spend token columns and per-model history; costs stay the recorded `cost` field, and rows with absent, malformed, negative, or overflowing tokens leave that window's totals incomplete instead of counting as zero. + +Codex local cost prices **Priority (Fast) turns** at the Fast rate. A turn counts as Priority when `/logs_2.sqlite` (Codex's trace database) holds a `response.create` websocket request with `service_tier == "priority"` for that turn's id. The database is opened read-only, scanned incrementally with a persisted cursor in the cost cache, and only turn ids, model names, and timestamps are kept; row bodies contain prompts and are never stored or logged. A missing or unreadable database keeps Standard pricing, and turn evidence never crosses `CODEX_HOME` scopes. Models without a Fast lane stay Standard. Older cost caches rebuild once (Codex cache schema v4 records each row's turn id). + +### OpenRouter keys + +The **API key** field (or `OPENROUTER_API_KEY`) is required and accepts either a regular API key or a Management API key. A Management key entered there also enables account Activity on the official OpenRouter API. The separate **Management API key** field (or `OPENROUTER_MANAGEMENT_API_KEY`) is an optional additional key for account Activity, only needed to use a Management key different from the one in the required field. Filling only the Management field does not select an account for quota or balance; with no primary key, the provider reports the missing-key message instead. ### z.ai Coding Plan quotas -z.ai Coding Plans accept both `TOKENS_LIMIT` and `CREDIT_LIMIT` rows. The shortest known Coding Plan window becomes primary and the longest becomes secondary; `TIME_LIMIT` is the separate MCP lane. When absolute usage/remaining counts are available they determine the used percentage, otherwise the provider percentage is used, always clamped to 0–100%. This behavior is shared by the tray, provider detail, CLI, and other Windows surfaces. +z.ai Coding Plans accept both `TOKENS_LIMIT` and `CREDIT_LIMIT` rows. The shortest known Coding Plan window becomes primary and the longest becomes secondary; `TIME_LIMIT` is the separate MCP lane. When absolute usage/remaining counts are available they determine the used percentage, otherwise the provider percentage is used, always clamped to 0–100%. This behavior is shared by the tray, provider detail, CLI, and other Windows surfaces. Empty or wholly unrecognized limits never fabricate a 0% window; they show a "Coding Plan usage: Unavailable" detail row that points to the Usage Dashboard, and mixed responses keep the recognized windows and add "Additional quota: Unavailable". Unknown string limit types are skipped, while malformed entries and unsupported envelopes fail with Usage Dashboard guidance. + +### xKiro daily free tokens + +xKiro reads `GET https://api.xkiro.com/v1/usage` with `XKIRO_API_KEY` (or the key saved in Preferences) as a bearer token. Only the `free_tokens` counters are used: the primary lane is `used_today / limit_per_day`, resetting at the next 00:00 UTC, and a limit of `0` reads as fully used. The paid `wallet` balance is never treated as headroom and is not shown. Counters that are missing stay missing rather than becoming zero, a payload with no usable counter is reported as unrecognized, and an explicit `plan: null` is labelled "Pay as you go". There are no token accounts. The wire shape follows upstream's documented example; live account behavior has not been verified. Upstream's independent **WidgetKit** provider-widget configuration has no Windows analogue in this repository. Win-CodexBar has no WidgetKit extension; provider cards and tray entries are already independent Windows/Tauri surfaces. +### LiteLLM budgets + +LiteLLM reads a virtual key's own budgets from the proxy's management routes. Set the base URL and key in Settings → Providers → LiteLLM, or use `LITELLM_BASE_URL` and `LITELLM_API_KEY`. A trailing `/v1` is dropped. The base URL must use HTTPS unless it names `localhost`, a `.local` host, or a loopback, RFC 1918, link-local or IPv6 unique-local address, and it must not embed credentials, because the key is sent as a bearer token. + +The provider calls `GET /key/info`, then `GET /user/info?user_id=…` for a user-bound key or `GET /team/info?team_id=…` for a team-only key, and rejects a response whose user or team ID differs from the key's. The personal budget fills the primary lane and the key's matching team budget fills the secondary lane. When only one budget exists, it takes the primary lane under its own label, and a key without any budget shows "No budget set". Amounts such as `$25.00 / $100.00` are detail lines, shown apart from a real reset date. The Automatic tray and float bar metric shows the team budget unless a budget is exhausted. Spend without a budget stays visible as API spend, and no pace is derived from budget resets. + ## Upstream doc warning Upstream `docs/providers.md` is a large auto-strategy matrix (60+ providers) for the macOS app. Use it as **inspiration** when porting a provider. For runtime truth on Windows: diff --git a/docs/WINDOWS_PROOF.md b/docs/WINDOWS_PROOF.md index ec7f8d055d..9de894acd8 100644 --- a/docs/WINDOWS_PROOF.md +++ b/docs/WINDOWS_PROOF.md @@ -90,12 +90,12 @@ the list below. Check each item off in §4. 1. **Tray icon visible** in the Windows notification area (may require "Show all icons" in the taskbar settings on first run). -2. **Left-click the tray icon** → borderless pop-out panel anchored +2. **Left-click the tray icon** → borderless tray panel anchored near the tray. On multi-monitor setups it should appear on the display hosting the tray. 3. **Right-click the tray icon** → native Windows context menu with - Pop Out / Refresh / Settings / Check for updates / Quit entries. -4. **Preferences window** opens from the tray menu or from the pop-out + Pop Out Dashboard / Refresh / Settings / Check for updates / Quit entries. +4. **Preferences window** opens from the tray menu or from the tray panel settings gear; all tabs render (General, Providers, Display, API keys, Cookies, Token accounts, Advanced, About). 5. **Theme toggle** — Appearance → Theme cycles `Auto ↔ Light ↔ Dark` @@ -105,15 +105,15 @@ the list below. Check each item off in §4. e.g. `Ctrl+Shift+K`. Chip shows the combo, `Saved` appears, and the shortcut toggles the tray panel globally. 7. **Provider rows drag-reorder** in Preferences → Providers: grab the - handle, drop above/below another provider, refresh the pop-out, and + handle, drop above/below another provider, refresh the tray panel, and confirm the new order is persisted across app restarts. 8. **Chart tooltip** — open a provider detail → Cost/Credits chart. Hovering a bar/point shows a tooltip with value + date. -9. **Reset countdown** — pop-out card shows `Resets in Xh Ym` and +9. **Reset countdown** — tray panel card shows `Resets in Xh Ym` and re-renders at least once during a 1-minute stare (internal tick is 30 s). 10. **Update banner** — Advanced → Check for updates; when an update is - offered the banner appears at the top of the pop-out and dismiss / + offered the banner appears at the top of the tray panel and dismiss / download / install-and-restart buttons all respond. --- @@ -160,7 +160,7 @@ Tick each entry as it is verified on the Windows target. [ ] release.doctor .\scripts\release-doctor.ps1 -Version [ ] runtime.tray-icon-visible -[ ] runtime.tray-left-click-popout +[ ] runtime.tray-left-click-opens-panel [ ] runtime.tray-context-menu [ ] runtime.preferences-opens [ ] runtime.preferences-all-tabs diff --git a/docs/grafana/codexbar-codex-zh.json b/docs/grafana/codexbar-codex-zh.json new file mode 100644 index 0000000000..1f63bcde23 --- /dev/null +++ b/docs/grafana/codexbar-codex-zh.json @@ -0,0 +1,504 @@ +{ + "__inputs": [], + "__requires": [ + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + } + ], + "uid": null, + "id": null, + "title": "CodexBar · Codex 监控", + "tags": [ + "codexbar", + "codex" + ], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "editable": true, + "refresh": "1m", + "time": { + "from": "now-24h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "datasource", + "label": "Prometheus 数据源", + "type": "datasource", + "query": "prometheus", + "refresh": 1, + "current": {} + }, + { + "name": "job", + "label": "采集任务", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "query": { + "query": "label_values(codexbar_provider_up{provider=\"codex\"},job)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "includeAll": true, + "multi": true, + "allValue": ".*", + "current": { + "selected": true, + "text": "全部", + "value": "$__all" + } + }, + { + "name": "instance", + "label": "节点", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "query": { + "query": "label_values(codexbar_provider_up{provider=\"codex\",job=~\"${job:regex}\"},instance)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "includeAll": true, + "multi": true, + "allValue": ".*", + "current": { + "selected": true, + "text": "全部", + "value": "$__all" + } + }, + { + "name": "window", + "label": "额度窗口", + "type": "custom", + "query": "weekly,session,monthly,code_review", + "current": { + "selected": true, + "text": "weekly", + "value": "weekly" + }, + "includeAll": false, + "multi": false, + "options": [ + { + "selected": true, + "text": "weekly", + "value": "weekly" + }, + { + "selected": false, + "text": "session", + "value": "session" + }, + { + "selected": false, + "text": "monthly", + "value": "monthly" + }, + { + "selected": false, + "text": "code_review", + "value": "code_review" + } + ] + } + ] + }, + "panels": [ + { + "id": 1, + "title": "节点采集状态", + "type": "stat", + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "codexbar_provider_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": {}, + "description": "1 为正常,0 为查询失败" + }, + { + "id": 2, + "title": "剩余额度", + "type": "gauge", + "gridPos": { + "h": 5, + "w": 8, + "x": 8, + "y": 0 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "codexbar_quota_${window:raw}_remaining_ratio{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": {}, + "description": "" + }, + { + "id": 3, + "title": "重置卡可用张数", + "type": "stat", + "gridPos": { + "h": 5, + "w": 8, + "x": 16, + "y": 0 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "codexbar_reset_credits_available{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "type": "value", + "options": { + "-1": { + "text": "无法获取", + "color": "orange" + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": {}, + "description": "-1 表示 PAT 模式或查询不可用;0 表示用完" + }, + { + "id": 4, + "title": "最早到期重置卡", + "type": "stat", + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 5 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "codexbar_reset_credits_next_expiry_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"} * 1000", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "dateTimeAsLocal", + "color": { + "mode": "palette-classic" + }, + "noValue": "无有效期数据" + }, + "overrides": [] + }, + "options": {}, + "description": "仅在有可用卡且接口返回有效期时显示" + }, + { + "id": 5, + "title": "剩余额度历史", + "type": "timeseries", + "gridPos": { + "h": 9, + "w": 16, + "x": 8, + "y": 5 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "codexbar_quota_${window:raw}_remaining_ratio{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": {}, + "description": "" + }, + { + "id": 6, + "title": "额度重置时间", + "type": "stat", + "gridPos": { + "h": 4, + "w": 8, + "x": 0, + "y": 10 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "codexbar_quota_${window:raw}_reset_timestamp_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"} * 1000", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "dateTimeAsLocal", + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": {}, + "description": "" + }, + { + "id": 7, + "type": "row", + "title": "辅助信息", + "collapsed": true, + "gridPos": { + "x": 0, + "y": 14, + "w": 24, + "h": 1 + }, + "panels": [ + { + "id": 8, + "title": "Codex 本地估算成本(今日)", + "type": "stat", + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 15 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "codexbar_cost_today_usd{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": {}, + "description": "" + }, + { + "id": 9, + "title": "采集数据年龄", + "type": "stat", + "gridPos": { + "h": 5, + "w": 8, + "x": 8, + "y": 15 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "codexbar_provider_data_age_seconds{job=~\"${job:regex}\",instance=~\"${instance:regex}\",provider=\"codex\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": {}, + "description": "" + }, + { + "id": 10, + "title": "导出器状态", + "type": "stat", + "gridPos": { + "h": 5, + "w": 8, + "x": 16, + "y": 15 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "codexbar_up{job=~\"${job:regex}\",instance=~\"${instance:regex}\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": {}, + "description": "" + } + ] + } + ], + "annotations": { + "list": [] + } +} diff --git a/docs/prometheus/README.zh-CN.md b/docs/prometheus/README.zh-CN.md new file mode 100644 index 0000000000..6c315f1af7 --- /dev/null +++ b/docs/prometheus/README.zh-CN.md @@ -0,0 +1,18 @@ +# Codex Prometheus 监控示例 + +用同一 Windows 用户运行 Codex CLI 和 `codexbar`,确保能读取该用户的 OAuth 凭据。将 `YOUR_RANDOM_TOKEN` 替换为长随机令牌、`YOUR_LAN_IP` 替换为节点实际局域网 IP 后启动: + +```cmd +set CODEXBAR_DASHBOARD_TOKEN=YOUR_RANDOM_TOKEN +codexbar.exe serve --host YOUR_LAN_IP --port 8081 --metrics --allow-plain-http +``` + +仅允许 Prometheus 主机访问该端口,并在可信局域网中传输令牌。`/metrics` 默认关闭,启用后与 `/usage` 共用 Bearer 认证。`--host` 应设为节点自身的局域网 IP,Prometheus target 应指向该 IP。验证: + +```cmd +curl.exe -H "Authorization: Bearer %CODEXBAR_DASHBOARD_TOKEN%" http://YOUR_LAN_IP:8081/metrics +``` + +将 [抓取示例](codexbar-scrape-example.yml)中的示例地址替换成实际节点 IP,并把 `scrape_configs` 和 [告警规则](codexbar-alerts.yml)的路径加入现有 `prometheus.yml`;示例规则默认匹配 `job="codexbar"`。多台节点共用一个 job 时必须使用同一个 Bearer Token;若令牌不同,则为每台节点建一个单独 job,并同步调整告警规则中的 job 选择器。运行 `promtool check config` 和 `promtool check rules` 后重载 Prometheus。导入 [Grafana 中文面板](../grafana/codexbar-codex-zh.json),选择 Prometheus 数据源;额度窗口默认 weekly,节点可选单台或全部。 + +`codexbar_reset_credits_available{provider="codex"}` 中,`0` 为已用完,`-1` 为 PAT 模式或接口不可用。`codexbar_reset_credits_next_expiry_timestamp_seconds` 只在有可用卡且存在未来有效期时输出,不暴露卡 ID。重置卡接口查询成功或失败均缓存 10 分钟;异常的周额度重置确认会获取独立观察。配额指标以 0 到 1 的比例表示,Grafana 面板会按百分比显示。成本来自本地日志估算,不是订阅账单。 diff --git a/docs/prometheus/codexbar-alerts.yml b/docs/prometheus/codexbar-alerts.yml new file mode 100644 index 0000000000..2c1e13bd6d --- /dev/null +++ b/docs/prometheus/codexbar-alerts.yml @@ -0,0 +1,101 @@ +groups: + - name: win-codexbar-codex + rules: + - alert: CodexBarExporterDown + expr: up{job="codexbar"} == 0 + for: 10m + labels: + severity: critical + annotations: + summary: "CodexBar 节点无法抓取" + description: "{{ $labels.instance }} 连续 10 分钟无法抓取,请检查服务、网络和 Bearer Token。" + + - alert: CodexBarCollectionStale + expr: codexbar_snapshot_stale{job="codexbar"} == 1 + for: 10m + labels: + severity: warning + annotations: + summary: "CodexBar 采集数据过期" + description: "{{ $labels.instance }} 的最近一次采集已超过过期阈值。" + + - alert: CodexBarCollectionUnavailable + expr: codexbar_up{job="codexbar"} == 0 + for: 10m + labels: + severity: critical + annotations: + summary: "CodexBar 尚无可导出的采集数据" + description: "{{ $labels.instance }} 的 exporter 可抓取,但持续没有成功采集数据。" + + - alert: CodexBarCodexFetchFailed + expr: codexbar_provider_up{job="codexbar",provider="codex"} == 0 + for: 10m + labels: + severity: critical + annotations: + summary: "Codex 用量查询失败" + description: "{{ $labels.instance }} 连续 10 分钟无法取得 Codex 用量。" + + - alert: CodexBarCodexQuotaMissing + expr: | + (codexbar_provider_up{job="codexbar",provider="codex"} == 1) + unless on (job, instance, provider) + (codexbar_quota_weekly_remaining_ratio{job="codexbar",provider="codex"}) + for: 15m + labels: + severity: warning + annotations: + summary: "Codex 周额度数据缺失" + description: "{{ $labels.instance }} 的用量查询成功,但周额度指标持续缺失。" + + - alert: CodexBarCodexWeeklyLow + expr: | + (codexbar_quota_weekly_remaining_ratio{job="codexbar",provider="codex"} <= 0.2) + and on (job, instance, provider) + (codexbar_quota_weekly_remaining_ratio{job="codexbar",provider="codex"} > 0.05) + for: 10m + labels: + severity: warning + annotations: + summary: "Codex 周剩余额度低于 20%" + description: "{{ $labels.instance }} 当前周剩余额度为 {{ $value | humanizePercentage }}。" + + - alert: CodexBarCodexWeeklyCritical + expr: codexbar_quota_weekly_remaining_ratio{job="codexbar",provider="codex"} <= 0.05 + for: 5m + labels: + severity: critical + annotations: + summary: "Codex 周剩余额度低于 5%" + description: "{{ $labels.instance }} 当前周额度即将耗尽。" + + - alert: CodexBarResetCreditsExhausted + expr: codexbar_reset_credits_available{job="codexbar",provider="codex"} == 0 + for: 5m + labels: + severity: warning + annotations: + summary: "Codex 重置卡已用完" + description: "{{ $labels.instance }} 当前没有可用重置卡。" + + - alert: CodexBarResetCreditsUnavailable + expr: codexbar_reset_credits_available{job="codexbar",provider="codex"} == -1 + for: 15m + labels: + severity: warning + annotations: + summary: "Codex 重置卡数量无法获取" + description: "{{ $labels.instance }} 处于 PAT 模式,或重置卡接口暂不可用。" + + - alert: CodexBarResetCreditExpiringSoon + expr: | + (codexbar_reset_credits_next_expiry_timestamp_seconds{job="codexbar",provider="codex"} - time() < 86400) + and on (job, instance, provider) + (codexbar_reset_credits_available{job="codexbar",provider="codex"} > 0) + for: 5m + labels: + severity: warning + annotations: + summary: "Codex 重置卡即将到期" + description: "{{ $labels.instance }} 最早到期的可用重置卡将在 24 小时内到期。" diff --git a/docs/prometheus/codexbar-scrape-example.yml b/docs/prometheus/codexbar-scrape-example.yml new file mode 100644 index 0000000000..ca9dfc7f90 --- /dev/null +++ b/docs/prometheus/codexbar-scrape-example.yml @@ -0,0 +1,12 @@ +scrape_configs: + - job_name: codexbar + scrape_interval: 2m + metrics_path: /metrics + authorization: + type: Bearer + credentials_file: /etc/prometheus/secrets/codexbar.token + static_configs: + - targets: + - 192.0.2.11:8081 + - 192.0.2.12:8081 + - 192.0.2.13:8081 diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 55722a3f27..1e70f8f654 100755 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codexbar" -version = "0.60.3" +version = "0.70.0" edition = "2024" authors = ["CodexBar Contributors"] description = "Windows and WSL system tray app for monitoring AI provider usage limits" @@ -95,6 +95,7 @@ windows = { version = "0.58", features = [ "Win32_System_LibraryLoader", "Win32_System_JobObjects", "Win32_System_Pipes", + "Win32_System_Power", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_UI_WindowsAndMessaging", diff --git a/rust/README.md b/rust/README.md index 6cfa465f28..b0522079f6 100755 --- a/rust/README.md +++ b/rust/README.md @@ -12,7 +12,7 @@ A Windows port of [CodexBar](https://github.com/steipete/CodexBar) - a system tr - MiniMax, Kiro, Vertex AI, Augment, OpenCode, OpenCode Go, Kimi, Kimi K2 - Amp, Warp, Ollama, OpenRouter, JetBrains AI, Alibaba - NanoGPT, Infini, Perplexity, Abacus AI, Mistral, Kilo, AWS Bedrock, Codebuff, DeepSeek, Windsurf - - Manus, Xiaomi MiMo, Doubao, Command Code, Crof, StepFun, Venice, OpenAI API + - Manus, Xiaomi MiMo, Doubao, Command Code, StepFun, Venice, OpenAI API - **Usage Notifications** - Windows toast alerts when usage hits thresholds - **Settings Panel** - Enable/disable providers, configure refresh intervals - **Manual Cookie Input** - Fallback for when automatic cookie extraction fails diff --git a/rust/assets/icons/ProviderIcon-crof.svg b/rust/assets/icons/ProviderIcon-crof.svg deleted file mode 100644 index fdde018b8f..0000000000 --- a/rust/assets/icons/ProviderIcon-crof.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/rust/src/agent_sessions.rs b/rust/src/agent_sessions.rs index 1780c4893e..ebd940056e 100644 --- a/rust/src/agent_sessions.rs +++ b/rust/src/agent_sessions.rs @@ -340,10 +340,11 @@ pub struct AgentSessionDiscovery { mod claude_desktop; mod focus; +mod native_processes; mod parsers; pub mod pi_family; mod remote; -pub use focus::focus_session; +pub use focus::{focus_session, request_session_attention}; struct CodexRollout { path: PathBuf, @@ -543,7 +544,7 @@ impl LocalAgentSessionScanner { let pi_input = pi_family::PiFamilyScanInput { processes: &pi_processes, cwd_by_pid: std::collections::HashMap::new(), - environment: pi_family::PiFamilySessionScanner::scan_environment(), + environment: pi_family::PiFamilySessionScanner::scan_environment().into(), now, host: host.to_string(), config: self.config, diff --git a/rust/src/agent_sessions/focus.rs b/rust/src/agent_sessions/focus.rs index 2b4f728685..77ea8e3db6 100644 --- a/rust/src/agent_sessions/focus.rs +++ b/rust/src/agent_sessions/focus.rs @@ -1,21 +1,44 @@ use super::*; pub fn focus_session(session: &AgentSession) -> SessionFocusResult { + match local_process(session) { + Ok(pid) => focus_process(pid), + Err(result) => result, + } +} + +/// Flash the session's taskbar button without restoring or activating its +/// window. +/// +/// For callers that are not answering a user action, such as auto-resume +/// after a quota reset. Windows does not let a background process activate +/// another app's window, and [`focus_session`]'s `ShowWindow(SW_RESTORE)` +/// would still pop a minimized terminal up over the app the user is working +/// in. The flash stops once the user switches to the window. +/// +/// Returns `Ok(())` once the window was found and is flashing; otherwise the +/// same reason [`focus_session`] would report. +pub fn request_session_attention(session: &AgentSession) -> Result<(), SessionFocusResult> { + flash_process(local_process(session)?) +} + +/// The local process that owns `session`'s window, or why there is none. +fn local_process(session: &AgentSession) -> Result { match session.focus_target { - AgentSessionFocusTarget::Transcript { .. } => SessionFocusResult::unsupported( + AgentSessionFocusTarget::Transcript { .. } => Err(SessionFocusResult::unsupported( "This file-only session has no focusable Windows window.", - ), - AgentSessionFocusTarget::None => { - SessionFocusResult::unsupported("This session has no focus target on Windows.") - } + )), + AgentSessionFocusTarget::None => Err(SessionFocusResult::unsupported( + "This session has no focus target on Windows.", + )), AgentSessionFocusTarget::Process { pid } => { - if !is_local_host(&session.host) { - return SessionFocusResult::unsupported( + if is_local_host(&session.host) { + Ok(pid) + } else { + Err(SessionFocusResult::unsupported( "Remote session focus is not supported from this Windows desktop.", - ); + )) } - - focus_process(pid) } } } @@ -34,12 +57,12 @@ fn is_local_host(host: &str) -> bool { .unwrap_or(false) } +/// The first visible top-level window owned by `pid`. #[cfg(windows)] -fn focus_process(pid: u32) -> SessionFocusResult { +fn find_process_window(pid: u32) -> Result { use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; use windows::Win32::UI::WindowsAndMessaging::{ - EnumWindows, GetWindowThreadProcessId, IsWindowVisible, SW_RESTORE, SetForegroundWindow, - ShowWindow, + EnumWindows, GetWindowThreadProcessId, IsWindowVisible, }; struct Search { @@ -74,11 +97,23 @@ fn focus_process(pid: u32) -> SessionFocusResult { // `search` lives on the stack; the LPARAM encodes a valid pointer to it. let result = unsafe { EnumWindows(Some(find_window), LPARAM(&mut search as *mut _ as isize)) }; if result.is_err() { - return SessionFocusResult::failed("Windows could not enumerate application windows."); + return Err(SessionFocusResult::failed( + "Windows could not enumerate application windows.", + )); } - let Some(window) = search.window else { - return SessionFocusResult::failed("No focusable window was found for this session."); + search.window.ok_or_else(|| { + SessionFocusResult::failed("No focusable window was found for this session.") + }) +} + +#[cfg(windows)] +fn focus_process(pid: u32) -> SessionFocusResult { + use windows::Win32::UI::WindowsAndMessaging::{SW_RESTORE, SetForegroundWindow, ShowWindow}; + + let window = match find_process_window(pid) { + Ok(window) => window, + Err(result) => return result, }; // SAFETY: `window` is an HWND returned by EnumWindows, so it refers to a // live window; ShowWindow/SetForegroundWindow only read the handle and @@ -93,7 +128,38 @@ fn focus_process(pid: u32) -> SessionFocusResult { } } +#[cfg(windows)] +fn flash_process(pid: u32) -> Result<(), SessionFocusResult> { + use windows::Win32::UI::WindowsAndMessaging::{ + FLASHW_TIMERNOFG, FLASHW_TRAY, FLASHWINFO, FlashWindowEx, + }; + + let window = find_process_window(pid)?; + let size = u32::try_from(std::mem::size_of::()) + .map_err(|_| SessionFocusResult::failed("Windows could not flash this session."))?; + let info = FLASHWINFO { + cbSize: size, + hwnd: window, + // Flash the taskbar button until the window reaches the foreground. + dwFlags: FLASHW_TRAY | FLASHW_TIMERNOFG, + uCount: 0, + dwTimeout: 0, + }; + // SAFETY: `info` is a fully initialized FLASHWINFO that outlives the call + // and `window` is a live HWND from EnumWindows. The return value is the + // window's previous caption state, not an error code. + let _was_active = unsafe { FlashWindowEx(&info) }; + Ok(()) +} + #[cfg(not(windows))] fn focus_process(_pid: u32) -> SessionFocusResult { SessionFocusResult::unsupported("Process focus requires the Windows desktop shell.") } + +#[cfg(not(windows))] +fn flash_process(_pid: u32) -> Result<(), SessionFocusResult> { + Err(SessionFocusResult::unsupported( + "Process focus requires the Windows desktop shell.", + )) +} diff --git a/rust/src/agent_sessions/native_processes.rs b/rust/src/agent_sessions/native_processes.rs new file mode 100644 index 0000000000..4e846ee2f8 --- /dev/null +++ b/rust/src/agent_sessions/native_processes.rs @@ -0,0 +1,128 @@ +//! Native Windows process snapshot for the Stay Awake scan. +//! +//! The Agent Sessions list shells out to PowerShell (`Get-CimInstance +//! Win32_Process`), which is too heavy to run every 30 seconds. Stay Awake only +//! needs to know whether a live agent process exists, so it reads the +//! ToolHelp snapshot instead. Records go through the same classifier as the +//! CIM path; like that path, no command lines are collected. + +use super::*; + +impl LocalAgentSessionScanner { + /// Whether at least one live local agent process (Codex, Claude, Pi/OMP) + /// exists right now. Idle processes waiting for a prompt count. File-only + /// rollouts and remote hosts are never consulted, so they cannot count. + pub fn has_live_agent_process() -> Result { + Ok(Self::holds_system_awake(&snapshot_processes()?)) + } + + /// Pure predicate behind [`Self::has_live_agent_process`]. + pub(super) fn holds_system_awake(processes: &[AgentProcessRecord]) -> bool { + processes + .iter() + .any(|process| process.pid > 0 && process.is_agent()) + } +} + +#[cfg(windows)] +fn snapshot_processes() -> Result, String> { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows::Win32::Foundation::{ERROR_NO_MORE_FILES, HANDLE}; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, + TH32CS_SNAPPROCESS, + }; + + // SAFETY: a successful call returns a unique snapshot handle owned below. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) } + .map_err(|error| format!("process snapshot failed: {error}"))?; + // SAFETY: `snapshot` is a valid, unique handle returned just above; the + // `OwnedHandle` closes it when dropped. + let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot.0) }; + let handle = HANDLE(snapshot.as_raw_handle()); + + let mut entry = PROCESSENTRY32W { + dwSize: u32::try_from(std::mem::size_of::()) + .map_err(|error| format!("invalid process entry size: {error}"))?, + ..Default::default() + }; + let mut records = Vec::new(); + // SAFETY: the snapshot handle is live and `entry.dwSize` is correct. + match unsafe { Process32FirstW(handle, &mut entry) } { + Ok(()) => {} + Err(error) if error.code() == ERROR_NO_MORE_FILES.to_hresult() => return Ok(records), + Err(error) => return Err(format!("process enumeration failed: {error}")), + } + loop { + let len = entry + .szExeFile + .iter() + .position(|&unit| unit == 0) + .unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..len]); + records.push(WindowsProcessOutputParser::record( + entry.th32ProcessID, + entry.th32ParentProcessID, + None, + Some(name), + None, + )); + // SAFETY: `entry` is valid for the next snapshot entry. + match unsafe { Process32NextW(handle, &mut entry) } { + Ok(()) => {} + Err(error) if error.code() == ERROR_NO_MORE_FILES.to_hresult() => break, + Err(error) => return Err(format!("process enumeration failed: {error}")), + } + } + Ok(records) +} + +#[cfg(not(windows))] +fn snapshot_processes() -> Result, String> { + Err("native process snapshots are only available on Windows".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(pid: u32, name: &str) -> AgentProcessRecord { + WindowsProcessOutputParser::record(pid, 1, None, Some(name.to_string()), None) + } + + #[test] + fn agent_processes_hold_the_system_awake() { + for name in ["claude.exe", "codex.exe"] { + let processes = [record(10, "explorer.exe"), record(11, name)]; + assert!( + LocalAgentSessionScanner::holds_system_awake(&processes), + "{name} should count" + ); + } + } + + #[test] + fn helpers_and_unrelated_processes_do_not_hold_the_system_awake() { + let processes = [ + record(10, "explorer.exe"), + record(11, "Codex (Renderer)"), + record(12, "codex app-server"), + record(13, "node.exe"), + ]; + assert!(!LocalAgentSessionScanner::holds_system_awake(&processes)); + assert!(!LocalAgentSessionScanner::holds_system_awake(&[])); + } + + #[test] + fn pid_zero_never_counts() { + let processes = [record(0, "claude.exe")]; + assert!(!LocalAgentSessionScanner::holds_system_awake(&processes)); + } + + #[cfg(windows)] + #[test] + fn native_snapshot_lists_the_current_process() { + let processes = snapshot_processes().expect("snapshot"); + assert!(processes.iter().any(|p| p.pid == std::process::id())); + } +} diff --git a/rust/src/agent_sessions/parsers.rs b/rust/src/agent_sessions/parsers.rs index 49e236e8e6..b29b4ea598 100644 --- a/rust/src/agent_sessions/parsers.rs +++ b/rust/src/agent_sessions/parsers.rs @@ -139,32 +139,49 @@ impl WindowsProcessOutputParser { .filter_map(|value| serde_json::from_value::(value).ok()) .filter(|process| process.process_id > 0 && seen.insert(process.process_id)) .map(|process| { - let display_name = process - .name - .as_deref() - .filter(|name| !name.trim().is_empty()) - .or(process.executable_path.as_deref()) - .unwrap_or_default(); - let classification = classify_process_command(display_name); - AgentProcessRecord { - pid: process.process_id, - ppid: process.parent_process_id, - started_at: process + Self::record( + process.process_id, + process.parent_process_id, + process .creation_date .as_deref() .and_then(parse_windows_creation_date), - provider: classification.provider, - source: classification.source, - executable: process.name.unwrap_or(classification.executable), - kind: classification.kind, - // Windows deliberately does not harvest other processes' - // command lines (privacy); ps-based remote scans carry - // them, and tests inject them. - command: None, - } + process.name, + process.executable_path.as_deref(), + ) }) .collect()) } + + /// Classify one Windows process. The image name is preferred over the + /// executable path, matching the CIM query and the native snapshot. + pub(super) fn record( + pid: u32, + ppid: u32, + started_at: Option>, + name: Option, + executable_path: Option<&str>, + ) -> AgentProcessRecord { + let display_name = name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .or(executable_path) + .unwrap_or_default(); + let classification = classify_process_command(display_name); + AgentProcessRecord { + pid, + ppid, + started_at, + provider: classification.provider, + source: classification.source, + executable: name.unwrap_or(classification.executable), + kind: classification.kind, + // Windows deliberately does not harvest other processes' + // command lines (privacy); ps-based remote scans carry + // them, and tests inject them. + command: None, + } + } } fn parse_windows_creation_date(value: &str) -> Option> { diff --git a/rust/src/agent_sessions/pi_family/mod.rs b/rust/src/agent_sessions/pi_family/mod.rs index 49092987ea..954007a585 100644 --- a/rust/src/agent_sessions/pi_family/mod.rs +++ b/rust/src/agent_sessions/pi_family/mod.rs @@ -35,6 +35,7 @@ use super::{ AgentSessionProvider, AgentSessionSource, AgentSessionState, AgentSessionWorkspace, PiSessionDialect, SessionScanConfig, }; +use crate::process_environment::ProcessEnvironment; /// Bounded transcript prefix used for header/first-title parsing (upstream /// `maximumReadSize`). @@ -132,7 +133,7 @@ pub struct PiFamilyScanInput<'a> { /// rows result until cwd capture lands). pub cwd_by_pid: CwdByPid, /// Environment slice (HOME/USERPROFILE/PI_*/OMP_*). - pub environment: EnvMap, + pub environment: ProcessEnvironment, pub now: DateTime, pub host: String, pub config: SessionScanConfig, diff --git a/rust/src/agent_sessions/pi_family_tests.rs b/rust/src/agent_sessions/pi_family_tests.rs index 8acdc84bc0..513a10926e 100644 --- a/rust/src/agent_sessions/pi_family_tests.rs +++ b/rust/src/agent_sessions/pi_family_tests.rs @@ -173,7 +173,7 @@ mod pi_family_tests { let input = PiFamilyScanInput { processes, cwd_by_pid, - environment, + environment: environment.into(), now, host: "fixture-host".to_string(), config: SessionScanConfig::default(), diff --git a/rust/src/agent_sessions/tests.rs b/rust/src/agent_sessions/tests.rs index 2c2da7b174..c9342d4db9 100644 --- a/rust/src/agent_sessions/tests.rs +++ b/rust/src/agent_sessions/tests.rs @@ -546,6 +546,14 @@ bad line focus_session(&file_only), SessionFocusResult::Unsupported { .. } )); + assert!(matches!( + request_session_attention(&remote), + Err(SessionFocusResult::Unsupported { .. }) + )); + assert!(matches!( + request_session_attention(&file_only), + Err(SessionFocusResult::Unsupported { .. }) + )); } #[test] diff --git a/rust/src/atomic_file.rs b/rust/src/atomic_file.rs index 8d7cb1e1b7..ac779714a2 100644 --- a/rust/src/atomic_file.rs +++ b/rust/src/atomic_file.rs @@ -8,6 +8,22 @@ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); /// failure, truncate the owned temp sibling instead of deleting it so callers /// never need destructive cleanup. pub fn write_atomic(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { + write_atomic_inner(path, bytes, false) +} + +/// Atomic write for credential-bearing files. +/// +/// Same publish contract as [`write_atomic`], but the staged sibling is created +/// owner-only (mode `0600` on unix, set at creation and re-applied before any +/// byte is written) so a secret is never readable by other users, neither in +/// the staged file nor in the published one. On Windows the staged file +/// inherits the protected per-user directory ACL, matching the previous +/// behavior of `secure_file`. +pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { + write_atomic_inner(path, bytes, true) +} + +fn write_atomic_inner(path: &Path, bytes: &[u8], private: bool) -> anyhow::Result<()> { let parent = path .parent() .filter(|p| !p.as_os_str().is_empty()) @@ -26,10 +42,23 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { )); let temp = PathBuf::from(temp_name); let result = (|| -> anyhow::Result<()> { - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp)?; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if private { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temp)?; + #[cfg(unix)] + if private { + use std::os::unix::fs::PermissionsExt; + // Restore owner access even under a restrictive umask, before + // writing any bytes. + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + #[cfg(not(unix))] + let _ = private; file.write_all(bytes)?; file.sync_all()?; drop(file); diff --git a/rust/src/browser/cookie_cache.rs b/rust/src/browser/cookie_cache.rs index 0e9d89b356..d943f2acbb 100755 --- a/rust/src/browser/cookie_cache.rs +++ b/rust/src/browser/cookie_cache.rs @@ -1,7 +1,9 @@ //! Cookie Header Cache //! //! Caches cookie headers for providers to avoid repeated browser cookie extraction. -//! Stores normalized cookie headers with timestamps and source labels. +//! Stores normalized cookie headers with timestamps and source labels. Entries +//! are session secrets, so they are written through `secure_file` (DPAPI on +//! Windows, staged and published atomically) rather than as plaintext. // The cache API is currently unused by the crate (reserved for provider // cookie reuse), so every public item would trip dead_code. @@ -11,10 +13,11 @@ )] use crate::core::ProviderId; +use crate::secure_file; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Cached cookie header entry #[derive(Debug, Clone, Serialize, Deserialize)] @@ -49,8 +52,11 @@ pub struct CookieHeaderCache; impl CookieHeaderCache { /// Load cached cookie header for a provider pub fn load(provider: ProviderId) -> Option { - let path = Self::cache_path(provider)?; - let data = fs::read_to_string(&path).ok()?; + Self::load_from(&Self::cache_path(provider)?) + } + + fn load_from(path: &Path) -> Option { + let data = secure_file::read_string(path).ok()?; serde_json::from_str(&data).ok() } @@ -73,14 +79,7 @@ impl CookieHeaderCache { let entry = CookieHeaderEntry::new(normalized, source_label); let path = Self::cache_path(provider).ok_or(CookieHeaderCacheError::PathNotAvailable)?; - - // Create parent directory - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let json = serde_json::to_string_pretty(&entry)?; - fs::write(&path, json)?; + Self::store_to(&path, &entry)?; tracing::debug!( provider = %provider.cli_name(), @@ -91,6 +90,17 @@ impl CookieHeaderCache { Ok(()) } + /// Persist `entry` to `path`, creating the parent directory. The write is + /// staged and published atomically, so a failure keeps the previous entry. + fn store_to(path: &Path, entry: &CookieHeaderEntry) -> Result<(), CookieHeaderCacheError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(entry)?; + secure_file::write_string(path, &json)?; + Ok(()) + } + /// Clear cached cookie header for a provider pub fn clear(provider: ProviderId) { if let Some(path) = Self::cache_path(provider) @@ -195,4 +205,57 @@ mod tests { let whitespace = CookieHeaderCache::normalize_cookie_header(" ; ; "); assert!(whitespace.is_empty()); } + + #[test] + fn store_round_trips_and_replaces_the_previous_entry() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("codex-cookie.json"); + + CookieHeaderCache::store_to(&path, &CookieHeaderEntry::new("a=1", "Chrome")).unwrap(); + CookieHeaderCache::store_to(&path, &CookieHeaderEntry::new("a=2", "Edge")).unwrap(); + + let loaded = CookieHeaderCache::load_from(&path).expect("entry"); + assert_eq!(loaded.cookie_header, "a=2"); + assert_eq!(loaded.source_label, "Edge"); + let names: Vec<_> = fs::read_dir(path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(names, vec![std::ffi::OsString::from("codex-cookie.json")]); + } + + #[test] + fn failed_store_leaves_no_cookie_bytes_behind() { + let dir = tempfile::tempdir().unwrap(); + // A directory at the destination makes publishing fail after the + // staged sibling was fully written. + let blocked = dir.path().join("codex-cookie.json"); + fs::create_dir(&blocked).unwrap(); + let entry = CookieHeaderEntry::new("secret=leaked-cookie", "Chrome"); + + assert!(CookieHeaderCache::store_to(&blocked, &entry).is_err()); + + assert!(blocked.is_dir()); + for sibling in fs::read_dir(dir.path()).unwrap() { + let sibling = sibling.unwrap().path(); + if sibling.is_file() { + assert_eq!( + fs::metadata(&sibling).unwrap().len(), + 0, + "failed store must truncate the staged cookie secret" + ); + } + } + } + + #[test] + fn load_still_reads_entries_written_as_plaintext_json() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("codex-cookie.json"); + let entry = CookieHeaderEntry::new("legacy=1", "Chrome"); + fs::write(&path, serde_json::to_string_pretty(&entry).unwrap()).unwrap(); + + let loaded = CookieHeaderCache::load_from(&path).expect("legacy entry"); + assert_eq!(loaded.cookie_header, "legacy=1"); + } } diff --git a/rust/src/browser/cookies.rs b/rust/src/browser/cookies.rs index 25be5f29dd..3233d702a5 100755 --- a/rust/src/browser/cookies.rs +++ b/rust/src/browser/cookies.rs @@ -770,6 +770,22 @@ pub fn get_cookie_headers_for_domain( Ok(headers) } +/// Get cookie candidates from every detected browser that has readable cookies +/// for a domain, preserving browser order and cookie metadata for provider-side +/// host filtering. Reports App-Bound Encryption when it is the only blocker. +pub fn get_cookies_by_browser_for_domain( + domain: &str, +) -> Result)>, CookieError> { + let scan = extract_domain_candidates(domain)?; + if scan.candidates.is_empty() && scan.abe_error_seen { + return Err(CookieError::AppBoundEncryption); + } + if scan.candidates.is_empty() { + return Err(CookieError::NotFound(domain.to_string())); + } + Ok(scan.candidates) +} + /// Get a cookie header string for a domain pub fn get_cookie_header(domain: &str) -> Result { let cookies = get_cookies_for_domain(domain)?; diff --git a/rust/src/browser/detection.rs b/rust/src/browser/detection.rs index 0dc39f27d7..bc93219621 100755 --- a/rust/src/browser/detection.rs +++ b/rust/src/browser/detection.rs @@ -16,6 +16,10 @@ use crate::wsl; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum BrowserType { Chrome, + ChromeBeta, + ChromeDev, + ChromeCanary, + ChromeForTesting, Edge, Brave, Arc, @@ -28,6 +32,10 @@ impl BrowserType { pub fn all() -> &'static [BrowserType] { &[ BrowserType::Chrome, + BrowserType::ChromeBeta, + BrowserType::ChromeDev, + BrowserType::ChromeCanary, + BrowserType::ChromeForTesting, BrowserType::Edge, BrowserType::Brave, BrowserType::Arc, @@ -45,6 +53,10 @@ impl BrowserType { pub fn display_name(&self) -> &'static str { match self { BrowserType::Chrome => "Google Chrome", + BrowserType::ChromeBeta => "Google Chrome Beta", + BrowserType::ChromeDev => "Google Chrome Dev", + BrowserType::ChromeCanary => "Google Chrome Canary", + BrowserType::ChromeForTesting => "Chrome for Testing", BrowserType::Edge => "Microsoft Edge", BrowserType::Brave => "Brave", BrowserType::Arc => "Arc", @@ -52,6 +64,41 @@ impl BrowserType { BrowserType::Chromium => "Chromium", } } + + /// Stable browser identifier shared by the core and desktop IPC bridge. + pub fn key(&self) -> &'static str { + match self { + BrowserType::Chrome => "chrome", + BrowserType::ChromeBeta => "chrome-beta", + BrowserType::ChromeDev => "chrome-dev", + BrowserType::ChromeCanary => "chrome-canary", + BrowserType::ChromeForTesting => "chrome-for-testing", + BrowserType::Edge => "edge", + BrowserType::Brave => "brave", + BrowserType::Arc => "arc", + BrowserType::Firefox => "firefox", + BrowserType::Chromium => "chromium", + } + } + + /// Resolve a profile root under Windows AppData. Firefox requires Roaming; + /// local-root browsers remain resolvable when Roaming is unavailable. + pub fn user_data_dir(&self, local: &Path, roaming: Option<&Path>) -> Option { + match self { + BrowserType::Chrome => Some(local.join("Google/Chrome/User Data")), + BrowserType::ChromeBeta => Some(local.join("Google/Chrome Beta/User Data")), + BrowserType::ChromeDev => Some(local.join("Google/Chrome Dev/User Data")), + BrowserType::ChromeCanary => Some(local.join("Google/Chrome SxS/User Data")), + BrowserType::ChromeForTesting => { + Some(local.join("Google/Chrome for Testing/User Data")) + } + BrowserType::Edge => Some(local.join("Microsoft/Edge/User Data")), + BrowserType::Brave => Some(local.join("BraveSoftware/Brave-Browser/User Data")), + BrowserType::Arc => Some(local.join("Arc/User Data")), + BrowserType::Firefox => roaming.map(|root| root.join("Mozilla/Firefox/Profiles")), + BrowserType::Chromium => Some(local.join("Chromium/User Data")), + } + } } /// A detected browser installation @@ -117,7 +164,23 @@ impl BrowserDetector { /// Detect a specific browser pub fn detect(browser_type: BrowserType) -> Option { let user_data_dir = Self::get_user_data_dir(browser_type)?; + Self::detect_at_path(browser_type, user_data_dir) + } + + /// Detect from explicit AppData roots, including WSL-mounted Windows roots. + pub(super) fn detect_in_roots( + browser_type: BrowserType, + local: &Path, + roaming: Option<&Path>, + ) -> Option { + let user_data_dir = browser_type.user_data_dir(local, roaming)?; + Self::detect_at_path(browser_type, user_data_dir) + } + fn detect_at_path( + browser_type: BrowserType, + user_data_dir: PathBuf, + ) -> Option { if !user_data_dir.exists() { return None; } @@ -141,59 +204,17 @@ impl BrowserDetector { if wsl::is_wsl() && let Some(appdata_local) = wsl::windows_appdata_local() { - let path = match browser_type { - BrowserType::Chrome => Some( - appdata_local - .join("Google") - .join("Chrome") - .join("User Data"), - ), - BrowserType::Edge => Some( - appdata_local - .join("Microsoft") - .join("Edge") - .join("User Data"), - ), - BrowserType::Brave => Some( - appdata_local - .join("BraveSoftware") - .join("Brave-Browser") - .join("User Data"), - ), - BrowserType::Arc => Some(appdata_local.join("Arc").join("User Data")), - BrowserType::Chromium => Some(appdata_local.join("Chromium").join("User Data")), - BrowserType::Firefox => wsl::windows_appdata_roaming() - .map(|roaming| roaming.join("Mozilla").join("Firefox").join("Profiles")), - }; - if let Some(ref p) = path - && p.exists() + let roaming = wsl::windows_appdata_roaming(); + if let Some(path) = browser_type.user_data_dir(&appdata_local, roaming.as_deref()) + && path.exists() { - return path; + return Some(path); } } let local_app_data = dirs::data_local_dir()?; - let app_data = dirs::data_dir()?; - - let path = match browser_type { - BrowserType::Chrome => local_app_data - .join("Google") - .join("Chrome") - .join("User Data"), - BrowserType::Edge => local_app_data - .join("Microsoft") - .join("Edge") - .join("User Data"), - BrowserType::Brave => local_app_data - .join("BraveSoftware") - .join("Brave-Browser") - .join("User Data"), - BrowserType::Arc => local_app_data.join("Arc").join("User Data"), - BrowserType::Chromium => local_app_data.join("Chromium").join("User Data"), - BrowserType::Firefox => app_data.join("Mozilla").join("Firefox").join("Profiles"), - }; - - Some(path) + let app_data = dirs::data_dir(); + browser_type.user_data_dir(&local_app_data, app_data.as_deref()) } /// Detect profiles within a browser's user data directory @@ -206,36 +227,26 @@ impl BrowserDetector { } /// Detect Chromium-based browser profiles - fn detect_chromium_profiles(user_data_dir: &PathBuf) -> Vec { - let mut profiles = Vec::new(); - - // Default profile - let default_path = user_data_dir.join("Default"); - if default_path.exists() { - profiles.push(BrowserProfile { - name: "Default".to_string(), - path: default_path, - is_default: true, - }); - } - - // Additional profiles (Profile 1, Profile 2, etc.) - if let Ok(entries) = std::fs::read_dir(user_data_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("Profile ") { - let path = entry.path(); - if path.is_dir() { - profiles.push(BrowserProfile { - name, - path, - is_default: false, - }); - } - } - } - } + pub(super) fn detect_chromium_profiles(user_data_dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(user_data_dir) else { + return Vec::new(); + }; + let mut profiles: Vec<_> = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + let path = entry.path(); + let is_profile = + name == "Default" || name.starts_with("Profile ") || name.starts_with("user-"); + (is_profile && path.is_dir()).then(|| BrowserProfile { + is_default: name == "Default", + name, + path, + }) + }) + .collect(); + profiles.sort_by(|left, right| left.name.cmp(&right.name)); profiles } @@ -268,6 +279,92 @@ impl BrowserDetector { mod tests { use super::*; + #[test] + fn browser_paths_and_keys_cover_every_type() { + let local = Path::new("C:/Users/test/AppData/Local"); + let roaming = Path::new("C:/Users/test/AppData/Roaming"); + let expected = [ + ( + BrowserType::Chrome, + "Google/Chrome/User Data", + "Google Chrome", + "chrome", + ), + ( + BrowserType::ChromeBeta, + "Google/Chrome Beta/User Data", + "Google Chrome Beta", + "chrome-beta", + ), + ( + BrowserType::ChromeDev, + "Google/Chrome Dev/User Data", + "Google Chrome Dev", + "chrome-dev", + ), + ( + BrowserType::ChromeCanary, + "Google/Chrome SxS/User Data", + "Google Chrome Canary", + "chrome-canary", + ), + ( + BrowserType::ChromeForTesting, + "Google/Chrome for Testing/User Data", + "Chrome for Testing", + "chrome-for-testing", + ), + ( + BrowserType::Edge, + "Microsoft/Edge/User Data", + "Microsoft Edge", + "edge", + ), + ( + BrowserType::Brave, + "BraveSoftware/Brave-Browser/User Data", + "Brave", + "brave", + ), + (BrowserType::Arc, "Arc/User Data", "Arc", "arc"), + ( + BrowserType::Firefox, + "Mozilla/Firefox/Profiles", + "Firefox", + "firefox", + ), + ( + BrowserType::Chromium, + "Chromium/User Data", + "Chromium", + "chromium", + ), + ]; + + assert_eq!(BrowserType::all().len(), expected.len()); + let mut keys = std::collections::HashSet::new(); + for (browser, relative_path, display_name, key) in expected { + let root = if browser == BrowserType::Firefox { + roaming + } else { + local + }; + assert_eq!( + browser.user_data_dir(local, Some(roaming)), + Some(root.join(relative_path)), + "wrong profile root for {display_name}" + ); + assert_eq!(browser.display_name(), display_name); + assert_eq!(browser.key(), key); + assert!(keys.insert(browser.key()), "duplicate IPC key: {key}"); + assert!(BrowserType::all().contains(&browser)); + assert_eq!( + browser.user_data_dir(local, None), + (browser != BrowserType::Firefox).then(|| local.join(relative_path)) + ); + } + } + #[test] fn test_browser_detection() { let browsers = BrowserDetector::detect_all(); diff --git a/rust/src/browser/leveldb/local_storage.rs b/rust/src/browser/leveldb/local_storage.rs new file mode 100644 index 0000000000..0491a4278f --- /dev/null +++ b/rust/src/browser/leveldb/local_storage.rs @@ -0,0 +1,109 @@ +//! Chromium `Local Storage` layer on top of the LevelDB reader. +//! +//! Chromium stores each origin's `localStorage` items under the key +//! `_\0` where `` is the serialized origin (`https://www.kimi.ai`) and +//! `` starts with a format byte: `0x00` for UTF-16LE text, `0x01` for Latin-1. Values use +//! the same format byte. Other keys in the database (`VERSION`, `META:`, +//! `METAACCESS:`) are bookkeeping and are ignored. + +use super::{Entry, LevelDbError, read_entries_with_budget}; +use std::path::{Path, PathBuf}; + +const KEY_PREFIX: u8 = b'_'; +const ORIGIN_TERMINATOR: u8 = 0; +const FORMAT_UTF16LE: u8 = 0; +const FORMAT_LATIN1: u8 = 1; + +/// One decoded `localStorage` item. +#[derive(Clone, PartialEq, Eq)] +pub struct LocalStorageEntry { + pub key: String, + pub value: String, +} + +impl std::fmt::Debug for LocalStorageEntry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LocalStorageEntry") + .field("key", &self.key) + .field("value", &"[REDACTED]") + .finish() + } +} + +/// Directory holding Local Storage for a Chromium profile directory (`Default`, `Profile 1`, ...). +pub fn local_storage_dir(profile_dir: &Path) -> PathBuf { + profile_dir.join("Local Storage").join("leveldb") +} + +/// Read the `localStorage` items stored for `origin` (for example `https://www.kimi.ai`) in the +/// LevelDB directory `dir`, sorted by key. A trailing slash on `origin` is ignored. +pub fn read_local_storage_entries( + dir: &Path, + origin: &str, +) -> Result, LevelDbError> { + read_local_storage_entries_for_origins(dir, &[origin]) +} + +/// Read `localStorage` items for any of `origins` with one LevelDB scan, sorted by key within +/// each origin. A trailing slash on an origin is ignored. +pub fn read_local_storage_entries_for_origins( + dir: &Path, + origins: &[&str], +) -> Result, LevelDbError> { + let entries = super::read_entries(dir)?; + Ok(origins + .iter() + .flat_map(|origin| decode_origin_entries(&entries, origin)) + .collect()) +} + +pub(crate) fn read_local_storage_entries_with_budget( + dir: &Path, + origin: &str, + max_total_bytes: u64, +) -> Result<(Vec, u64), LevelDbError> { + let prefix = origin_key_prefix(origin); + let (entries, scanned_bytes) = + read_entries_with_budget(dir, max_total_bytes, Some(prefix.as_slice()))?; + Ok((decode_origin_entries(&entries, origin), scanned_bytes)) +} + +pub(super) fn decode_origin_entries(entries: &[Entry], origin: &str) -> Vec { + let prefix = origin_key_prefix(origin); + + entries + .iter() + .filter_map(|entry| { + let key = decode_text(entry.key.strip_prefix(prefix.as_slice())?)?; + let value = decode_text(&entry.value)?; + Some(LocalStorageEntry { key, value }) + }) + .collect() +} + +fn origin_key_prefix(origin: &str) -> Vec { + let mut prefix = Vec::with_capacity(origin.len() + 2); + prefix.push(KEY_PREFIX); + prefix.extend_from_slice(origin.trim_end_matches('/').as_bytes()); + prefix.push(ORIGIN_TERMINATOR); + prefix +} + +/// Decode a format-byte-prefixed Chromium string; `None` for an unknown format or bad UTF-16. +fn decode_text(bytes: &[u8]) -> Option { + let (&format, data) = bytes.split_first()?; + match format { + FORMAT_LATIN1 => Some(data.iter().map(|&byte| char::from(byte)).collect()), + FORMAT_UTF16LE if data.len() % 2 == 0 => { + let units: Vec = data + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + String::from_utf16(&units).ok() + } + _ => None, + } +} diff --git a/rust/src/browser/leveldb/log.rs b/rust/src/browser/leveldb/log.rs new file mode 100644 index 0000000000..d8e01b4a14 --- /dev/null +++ b/rust/src/browser/leveldb/log.rs @@ -0,0 +1,176 @@ +//! LevelDB write-ahead log (`*.log`) reader. +//! +//! A log is a sequence of 32 KiB blocks holding physical records (7 byte header: masked CRC32C, +//! length, type) that are reassembled into logical records. Each logical record is a write batch: +//! `sequence: u64le`, `count: u32le`, then `count` operations (`1 key value` puts and `0 key` +//! deletes, with varint-length-prefixed byte strings). Operation `i` has sequence `sequence + i`. +//! +//! The live log of a running browser routinely ends in a half-written record, so a truncated or +//! malformed tail ends the scan quietly and everything before it is kept. Record checksums are +//! not verified: this is a best-effort read of another program's cache, not a database recovery. + +use super::varint::{read_length_prefixed, read_u32_le, read_u64_le}; +use super::{MAX_RECORDS_PER_DIRECTORY, Record}; + +const BLOCK_SIZE: usize = 32 * 1024; +const HEADER_SIZE: usize = 7; +const BATCH_HEADER_SIZE: usize = 12; +const MAX_LOG_RECORD_BYTES: usize = 16 * 1024 * 1024; + +const TYPE_ZERO: u8 = 0; +const TYPE_FULL: u8 = 1; +const TYPE_FIRST: u8 = 2; +const TYPE_MIDDLE: u8 = 3; +const TYPE_LAST: u8 = 4; + +const OP_DELETE: u8 = 0; +const OP_PUT: u8 = 1; +const MAX_SEQUENCE: u64 = (1 << 56) - 1; + +/// Feed every put/delete found in `data` to `emit`. +#[cfg(test)] +pub(super) fn read_log(data: &[u8], emit: &mut impl FnMut(Record)) { + let _ = read_log_until(data, &mut |record| { + emit(record); + true + }); +} + +/// Read a log until it is malformed or `emit` asks the scan to stop. +pub(super) fn read_log_until(data: &[u8], emit: &mut impl FnMut(Record) -> bool) -> bool { + let mut assembled: Vec = Vec::new(); + let mut in_fragmented = false; + + let mut offset = 0usize; + while offset < data.len() { + let block_remaining = BLOCK_SIZE - (offset % BLOCK_SIZE); + if block_remaining < HEADER_SIZE { + offset += block_remaining; + continue; + } + let Some(header) = data.get(offset..offset + HEADER_SIZE) else { + return false; + }; + let length = usize::from(u16::from_le_bytes([header[4], header[5]])); + let kind = header[6]; + if kind == TYPE_ZERO && length == 0 { + // Zero padding: the rest of this block is unused. + offset += block_remaining; + continue; + } + let payload_start = offset + HEADER_SIZE; + if HEADER_SIZE + length > block_remaining { + return false; + } + let Some(payload) = data.get(payload_start..payload_start + length) else { + return false; + }; + offset = payload_start + length; + + match kind { + TYPE_FULL => { + assembled.clear(); + in_fragmented = false; + if !read_batch(payload, emit) { + return false; + } + } + TYPE_FIRST => { + assembled.clear(); + if !append_fragment(&mut assembled, payload) { + return false; + } + in_fragmented = true; + } + TYPE_MIDDLE if in_fragmented => { + if !append_fragment(&mut assembled, payload) { + return false; + } + } + TYPE_LAST if in_fragmented => { + if !append_fragment(&mut assembled, payload) { + return false; + } + in_fragmented = false; + if !read_batch(&assembled, emit) { + return false; + } + assembled.clear(); + } + _ => return false, + } + } + true +} + +fn append_fragment(assembled: &mut Vec, payload: &[u8]) -> bool { + let Some(new_len) = assembled.len().checked_add(payload.len()) else { + return false; + }; + if new_len > MAX_LOG_RECORD_BYTES || assembled.try_reserve(payload.len()).is_err() { + return false; + } + assembled.extend_from_slice(payload); + true +} + +fn read_batch(batch: &[u8], emit: &mut impl FnMut(Record) -> bool) -> bool { + if !visit_batch(batch, &mut |_, _, _| true) { + return true; + } + visit_batch(batch, &mut |sequence, key, value| { + emit(Record { + key: key.to_vec(), + sequence, + value: value.map(|value| value.to_vec()), + }) + }) +} + +/// Validate the whole batch before applying any of its operations. A torn or malformed write +/// batch must not leave a valid-looking prefix in the returned database state. +fn visit_batch<'a>( + batch: &'a [u8], + emit: &mut impl FnMut(u64, &'a [u8], Option<&'a [u8]>) -> bool, +) -> bool { + let (Some(sequence), Some(count)) = (read_u64_le(batch, 0), read_u32_le(batch, 8)) else { + return false; + }; + if sequence > MAX_SEQUENCE || u64::from(count) > MAX_RECORDS_PER_DIRECTORY as u64 { + return false; + } + let mut rest = match batch.get(BATCH_HEADER_SIZE..) { + Some(rest) => rest, + None => return false, + }; + for index in 0..u64::from(count) { + let Some((&op, after_op)) = rest.split_first() else { + return false; + }; + let Some((key, after_key)) = read_length_prefixed(after_op) else { + return false; + }; + let value = match op { + OP_PUT => { + let Some((value, after_value)) = read_length_prefixed(after_key) else { + return false; + }; + rest = after_value; + Some(value) + } + OP_DELETE => { + rest = after_key; + None + } + _ => return false, + }; + let Some(record_sequence) = sequence.checked_add(index).filter(|s| *s <= MAX_SEQUENCE) + else { + return false; + }; + if !emit(record_sequence, key, value) { + return false; + } + } + rest.is_empty() +} diff --git a/rust/src/browser/leveldb/mod.rs b/rust/src/browser/leveldb/mod.rs new file mode 100644 index 0000000000..ea5bbefd13 --- /dev/null +++ b/rust/src/browser/leveldb/mod.rs @@ -0,0 +1,279 @@ +//! Read-only LevelDB reader for Chromium browser storage (Local Storage, and other LevelDB +//! directories a browser keeps under a profile). +//! +//! This is a small hand-written reader rather than a dependency: it understands the write-ahead +//! log and sorted-table formats, including Snappy-compressed table blocks, and resolves each user +//! key to its newest value by sequence number. It does not open the database, take its `LOCK`, or +//! write anything, so it can be pointed at a profile of a running browser. +//! +//! Known limits, all acceptable for a best-effort credential import: +//! - The `MANIFEST` is not consulted, so a table that compaction already obsoleted but the +//! browser has not yet removed can still be read. Newer sequence numbers win, so this only +//! matters for a deleted key whose tombstone was compacted away. +//! - Checksums are not verified and only the bytewise comparator is assumed (which is what +//! `Local Storage` uses; a full scan does not depend on ordering anyway). +//! - Blocks using a compression type other than none/Snappy are skipped. +//! - Files larger than [`MAX_FILE_BYTES`], scans larger than [`MAX_TOTAL_SCAN_BYTES`], and blocks +//! that decode beyond [`MAX_BLOCK_BYTES`] are skipped so a corrupt profile cannot exhaust +//! memory or monopolize a refresh. + +pub mod local_storage; +mod log; +pub mod snappy; +mod table; +mod varint; + +#[cfg(test)] +mod tests; + +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::io::Read; +use std::path::Path; + +/// Largest single log or table file that will be read into memory. +pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; +/// Largest total number of bytes scanned across a LevelDB directory. +pub const MAX_TOTAL_SCAN_BYTES: u64 = 256 * 1024 * 1024; +/// Largest decompressed table block accepted. +pub const MAX_BLOCK_BYTES: usize = 16 * 1024 * 1024; +const MAX_DIRECTORY_ENTRIES: usize = 8192; +const MAX_RECORDS_PER_DIRECTORY: usize = 250_000; +const MAX_LIVE_ENTRIES: usize = 100_000; +const MAX_LIVE_ENTRY_BYTES: usize = 64 * 1024 * 1024; + +/// One live key/value pair of the database. +#[derive(Clone, PartialEq, Eq)] +pub struct Entry { + pub key: Vec, + pub value: Vec, +} + +impl std::fmt::Debug for Entry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Entry") + .field("key", &"[REDACTED]") + .field("value", &"[REDACTED]") + .finish() + } +} + +/// Failure to read a LevelDB directory at all (per-file problems are skipped, not reported). +#[derive(Debug, thiserror::Error)] +pub enum LevelDbError { + #[error("cannot read LevelDB directory: {0}")] + Io(#[from] std::io::Error), + #[error("LevelDB directory exceeds safe scan limits")] + ResourceLimit, +} + +/// A put (`value: Some`) or delete (`value: None`) with the sequence number it was written at. +pub(crate) struct Record { + pub(crate) key: Vec, + pub(crate) sequence: u64, + pub(crate) value: Option>, +} + +#[derive(Default)] +struct EntryAccumulator<'a> { + key_prefix: Option<&'a [u8]>, + newest: BTreeMap, (u64, Option>)>, + record_count: usize, + retained_bytes: usize, + resource_limit_exceeded: bool, +} + +impl<'a> EntryAccumulator<'a> { + fn absorb(&mut self, record: Record) -> bool { + if self.record_count == MAX_RECORDS_PER_DIRECTORY { + self.resource_limit_exceeded = true; + return false; + } + self.record_count += 1; + if self + .key_prefix + .is_some_and(|prefix| !record.key.starts_with(prefix)) + { + return true; + } + + let current_entry_count = self.newest.len(); + match self.newest.entry(record.key) { + std::collections::btree_map::Entry::Occupied(mut entry) => { + let existing = entry.get_mut(); + if existing.0 >= record.sequence { + return true; + } + let old_value_bytes = existing.1.as_ref().map_or(0, Vec::len); + let new_value_bytes = record.value.as_ref().map_or(0, Vec::len); + let next_retained_bytes = self + .retained_bytes + .checked_sub(old_value_bytes) + .and_then(|bytes| bytes.checked_add(new_value_bytes)); + let Some(next_retained_bytes) = next_retained_bytes else { + self.resource_limit_exceeded = true; + return false; + }; + if next_retained_bytes > MAX_LIVE_ENTRY_BYTES { + self.resource_limit_exceeded = true; + return false; + } + self.retained_bytes = next_retained_bytes; + *existing = (record.sequence, record.value); + true + } + std::collections::btree_map::Entry::Vacant(entry) => { + let Some(entry_bytes) = entry + .key() + .len() + .checked_add(record.value.as_ref().map_or(0, Vec::len)) + else { + self.resource_limit_exceeded = true; + return false; + }; + let Some(next_retained_bytes) = self.retained_bytes.checked_add(entry_bytes) else { + self.resource_limit_exceeded = true; + return false; + }; + if current_entry_count == MAX_LIVE_ENTRIES + || next_retained_bytes > MAX_LIVE_ENTRY_BYTES + { + self.resource_limit_exceeded = true; + return false; + } + self.retained_bytes = next_retained_bytes; + entry.insert((record.sequence, record.value)); + true + } + } + } + + fn into_entries(self) -> Vec { + self.newest + .into_iter() + .filter_map(|(key, (_, value))| value.map(|value| Entry { key, value })) + .collect() + } +} + +/// Read every live entry of the LevelDB directory `dir`, sorted by key. +/// +/// Keys that were deleted (or whose newest record is a delete) are omitted. Unreadable or corrupt +/// files are skipped with a debug log; scans over the configured resource limits fail as a whole. +pub fn read_entries(dir: &Path) -> Result, LevelDbError> { + read_entries_with_budget(dir, MAX_TOTAL_SCAN_BYTES, None).map(|(entries, _)| entries) +} + +pub(crate) fn read_entries_with_budget( + dir: &Path, + max_total_bytes: u64, + key_prefix: Option<&[u8]>, +) -> Result<(Vec, u64), LevelDbError> { + let mut entries = EntryAccumulator { + key_prefix, + ..EntryAccumulator::default() + }; + let mut scanned_bytes = 0u64; + + for (directory_entry_count, dir_entry) in std::fs::read_dir(dir)?.enumerate() { + if directory_entry_count == MAX_DIRECTORY_ENTRIES { + return Err(LevelDbError::ResourceLimit); + } + let Ok(dir_entry) = dir_entry else { continue }; + let path = dir_entry.path(); + let Some(kind) = FileKind::of(&path) else { + continue; + }; + let remaining_bytes = max_total_bytes.saturating_sub(scanned_bytes); + let data = match read_bounded_file(&path, remaining_bytes) { + Ok(data) => data, + Err(BoundedReadError::FileTooLarge) => continue, + Err(BoundedReadError::BudgetExceeded) => return Err(LevelDbError::ResourceLimit), + Err(BoundedReadError::Io(error)) => { + tracing::debug!(file = ?path.file_name(), %error, "skipping unreadable LevelDB file"); + continue; + } + }; + let file_bytes = u64::try_from(data.len()).map_err(|_| LevelDbError::ResourceLimit)?; + scanned_bytes = scanned_bytes + .checked_add(file_bytes) + .ok_or(LevelDbError::ResourceLimit)?; + match kind { + FileKind::Log => { + let _ = log::read_log_until(&data, &mut |record| entries.absorb(record)); + } + FileKind::Table => { + match table::read_table_until(&data, &mut |record| entries.absorb(record)) { + Ok((0, _)) => {} + Ok((skipped, _)) => tracing::debug!( + file = ?path.file_name(), + skipped, + "skipped undecodable LevelDB table blocks" + ), + Err(error) => { + tracing::debug!(file = ?path.file_name(), %error, "skipping malformed LevelDB table"); + } + } + } + } + if entries.resource_limit_exceeded { + return Err(LevelDbError::ResourceLimit); + } + } + + Ok((entries.into_entries(), scanned_bytes)) +} + +#[derive(Clone, Copy)] +enum FileKind { + Log, + Table, +} + +impl FileKind { + fn of(path: &Path) -> Option { + let extension = path.extension().and_then(OsStr::to_str)?; + if extension.eq_ignore_ascii_case("log") { + Some(Self::Log) + } else if extension.eq_ignore_ascii_case("ldb") || extension.eq_ignore_ascii_case("sst") { + Some(Self::Table) + } else { + None + } + } +} + +enum BoundedReadError { + Io(std::io::Error), + FileTooLarge, + BudgetExceeded, +} + +fn read_bounded_file(path: &Path, remaining_bytes: u64) -> Result, BoundedReadError> { + let file = std::fs::File::open(path).map_err(BoundedReadError::Io)?; + let size = file.metadata().map_err(BoundedReadError::Io)?.len(); + if size > MAX_FILE_BYTES { + return Err(BoundedReadError::FileTooLarge); + } + if size > remaining_bytes { + return Err(BoundedReadError::BudgetExceeded); + } + + let read_limit = MAX_FILE_BYTES.min(remaining_bytes); + let reserve = usize::try_from(size).map_err(|_| BoundedReadError::FileTooLarge)?; + let mut data = Vec::new(); + data.try_reserve_exact(reserve) + .map_err(|error| BoundedReadError::Io(std::io::Error::other(error)))?; + file.take(read_limit + 1) + .read_to_end(&mut data) + .map_err(BoundedReadError::Io)?; + let data_len = u64::try_from(data.len()).map_err(|_| BoundedReadError::FileTooLarge)?; + if data_len > MAX_FILE_BYTES { + return Err(BoundedReadError::FileTooLarge); + } + if data_len > remaining_bytes { + return Err(BoundedReadError::BudgetExceeded); + } + Ok(data) +} diff --git a/rust/src/browser/leveldb/snappy.rs b/rust/src/browser/leveldb/snappy.rs new file mode 100644 index 0000000000..d61542e015 --- /dev/null +++ b/rust/src/browser/leveldb/snappy.rs @@ -0,0 +1,126 @@ +//! Raw Snappy block decompression (the format LevelDB uses for compressed table blocks). +//! +//! Format reference: . +//! The stream is a varint uncompressed length followed by literal and copy elements. +//! Output size is bounded by the caller so a hostile length preamble cannot exhaust memory. + +use super::varint::read_varint32; + +/// Why a Snappy block could not be decoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum SnappyError { + #[error("snappy length preamble is missing or malformed")] + BadPreamble, + #[error("snappy output of {0} bytes exceeds the {1} byte limit")] + TooLarge(usize, usize), + #[error("snappy stream is truncated")] + Truncated, + #[error("snappy copy references data before the start of the output")] + BadOffset, + #[error("snappy output buffer could not be allocated")] + AllocationFailed, + #[error("snappy stream length does not match its preamble")] + LengthMismatch, +} + +/// Decompress one raw Snappy block, refusing to produce more than `max_len` bytes. +pub fn decompress(input: &[u8], max_len: usize) -> Result, SnappyError> { + let (expected, mut pos) = read_varint32(input).ok_or(SnappyError::BadPreamble)?; + let expected = expected as usize; + if expected > max_len { + return Err(SnappyError::TooLarge(expected, max_len)); + } + + let mut out = Vec::new(); + out.try_reserve_exact(expected) + .map_err(|_| SnappyError::AllocationFailed)?; + while pos < input.len() { + let tag = input[pos]; + pos += 1; + match tag & 0b11 { + 0b00 => { + let mut len = usize::from(tag >> 2); + if len >= 60 { + let extra = len - 59; + let bytes = input.get(pos..pos + extra).ok_or(SnappyError::Truncated)?; + pos += extra; + len = bytes + .iter() + .rev() + .fold(0usize, |acc, b| (acc << 8) | usize::from(*b)); + } + len = len.checked_add(1).ok_or(SnappyError::Truncated)?; + let literal = pos + .checked_add(len) + .and_then(|end| input.get(pos..end)) + .ok_or(SnappyError::Truncated)?; + let Some(new_len) = out.len().checked_add(literal.len()) else { + return Err(SnappyError::LengthMismatch); + }; + if new_len > expected { + return Err(SnappyError::LengthMismatch); + } + out.extend_from_slice(literal); + pos += len; + } + kind => { + let (len, offset) = match kind { + 0b01 => { + let low = *input.get(pos).ok_or(SnappyError::Truncated)?; + pos += 1; + ( + 4 + usize::from((tag >> 2) & 0b111), + (usize::from(tag >> 5) << 8) | usize::from(low), + ) + } + 0b10 => { + let bytes = input.get(pos..pos + 2).ok_or(SnappyError::Truncated)?; + pos += 2; + ( + 1 + usize::from(tag >> 2), + usize::from(u16::from_le_bytes([bytes[0], bytes[1]])), + ) + } + _ => { + let bytes = input.get(pos..pos + 4).ok_or(SnappyError::Truncated)?; + pos += 4; + ( + 1 + usize::from(tag >> 2), + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize, + ) + } + }; + copy_within_output(&mut out, offset, len, expected)?; + } + } + } + + if out.len() == expected { + Ok(out) + } else { + Err(SnappyError::LengthMismatch) + } +} + +/// Append `len` bytes copied from `offset` bytes back; the ranges may overlap (run-length style). +fn copy_within_output( + out: &mut Vec, + offset: usize, + len: usize, + expected: usize, +) -> Result<(), SnappyError> { + if offset == 0 || offset > out.len() { + return Err(SnappyError::BadOffset); + } + let Some(new_len) = out.len().checked_add(len) else { + return Err(SnappyError::LengthMismatch); + }; + if new_len > expected { + return Err(SnappyError::LengthMismatch); + } + let start = out.len() - offset; + for i in 0..len { + out.push(out[start + i]); + } + Ok(()) +} diff --git a/rust/src/browser/leveldb/table.rs b/rust/src/browser/leveldb/table.rs new file mode 100644 index 0000000000..314cdee15c --- /dev/null +++ b/rust/src/browser/leveldb/table.rs @@ -0,0 +1,311 @@ +//! LevelDB sorted-table (`*.ldb` / `*.sst`) reader. +//! +//! Layout: data blocks, a metaindex block, an index block, and a 48 byte footer that ends with a +//! magic number. The footer holds block handles (varint offset, varint size) for the metaindex and +//! index blocks; index entries map a separator key to the handle of a data block. Every block is +//! followed by a 5 byte trailer (compression type, CRC32C). Entries inside a block share key +//! prefixes with the previous entry, and a restart array closes the block. +//! +//! Only the index and data blocks are read; the filter/metaindex blocks are irrelevant to a full +//! scan. Data-block keys are internal keys: the user key followed by `sequence << 8 | kind`. +//! Checksums are not verified (see `log.rs`); structural checks keep malformed input from +//! panicking or over-allocating. + +use super::snappy; +use super::varint::{read_u32_le, read_u64_le, read_varint32, read_varint64}; +use super::{MAX_BLOCK_BYTES, MAX_RECORDS_PER_DIRECTORY, Record}; + +const FOOTER_SIZE: usize = 48; +const TABLE_MAGIC: u64 = 0xdb47_7524_8b80_fb57; +const BLOCK_TRAILER_SIZE: usize = 5; + +const COMPRESSION_NONE: u8 = 0; +const COMPRESSION_SNAPPY: u8 = 1; + +const KIND_DELETE: u64 = 0; +const KIND_VALUE: u64 = 1; + +#[derive(Debug, thiserror::Error)] +pub(super) enum TableError { + #[error("file is too short or has a bad table footer")] + BadFooter, + #[error("block handle points outside the file")] + BadHandle, + #[error("unsupported block compression type {0}")] + UnsupportedCompression(u8), + #[error("block is malformed")] + BadBlock, + #[error("block exceeds the configured size limit")] + BlockTooLarge, + #[error("block output buffer could not be allocated")] + AllocationFailed, + #[error("snappy block failed to decode: {0}")] + Snappy(#[from] snappy::SnappyError), +} + +#[derive(Clone, Copy)] +struct BlockHandle { + offset: usize, + size: usize, +} + +impl BlockHandle { + /// Parse a handle from the front of `input`; returns it with the bytes consumed. + fn parse(input: &[u8]) -> Option<(Self, usize)> { + let (offset, first) = read_varint64(input)?; + let (size, second) = read_varint64(input.get(first..)?)?; + Some(( + Self { + offset: usize::try_from(offset).ok()?, + size: usize::try_from(size).ok()?, + }, + first + second, + )) + } +} + +/// Feed every put/delete found in the table `data` to `emit`. +/// +/// A data block that cannot be decoded is skipped (and counted in the returned failure count) so +/// one bad block does not hide the rest of the table; a bad footer or index block is an error. +#[cfg(test)] +pub(super) fn read_table(data: &[u8], emit: &mut impl FnMut(Record)) -> Result { + read_table_until(data, &mut |record| { + emit(record); + true + }) + .map(|(skipped, _)| skipped) +} + +pub(super) fn read_table_until( + data: &[u8], + emit: &mut impl FnMut(Record) -> bool, +) -> Result<(usize, bool), TableError> { + let footer_start = data + .len() + .checked_sub(FOOTER_SIZE) + .ok_or(TableError::BadFooter)?; + let footer = &data[footer_start..]; + if read_u64_le(footer, FOOTER_SIZE - 8) != Some(TABLE_MAGIC) { + return Err(TableError::BadFooter); + } + let (_metaindex, used) = BlockHandle::parse(footer).ok_or(TableError::BadFooter)?; + let (index, _) = BlockHandle::parse(&footer[used..]).ok_or(TableError::BadFooter)?; + + let index_block = read_block(data, index)?; + validate_index_block(&index_block)?; + let mut skipped_blocks = 0usize; + for (_separator, handle_bytes) in BlockEntries::new(&index_block)? { + let (handle, _) = BlockHandle::parse(handle_bytes).ok_or(TableError::BadBlock)?; + let block = read_block(data, handle); + let Ok(block) = block else { + skipped_blocks += 1; + continue; + }; + match validate_data_block(&block).and_then(|_| emit_block_records(&block, emit)) { + Ok(true) => {} + Ok(false) => return Ok((skipped_blocks, false)), + Err(_) => skipped_blocks += 1, + } + } + Ok((skipped_blocks, true)) +} + +fn validate_index_block(block: &[u8]) -> Result<(), TableError> { + let mut entries = BlockEntries::new(block)?; + let mut entry_count = 0usize; + for (_separator, handle_bytes) in entries.by_ref() { + entry_count += 1; + if entry_count > MAX_RECORDS_PER_DIRECTORY { + return Err(TableError::BadBlock); + } + let (_, used) = BlockHandle::parse(handle_bytes).ok_or(TableError::BadBlock)?; + if used != handle_bytes.len() { + return Err(TableError::BadBlock); + } + } + if entries.failed { + return Err(TableError::BadBlock); + } + Ok(()) +} + +fn validate_data_block(block: &[u8]) -> Result<(), TableError> { + let mut entries = BlockEntries::new(block)?; + let mut entry_count = 0usize; + for (internal_key, _value) in entries.by_ref() { + entry_count += 1; + if entry_count > MAX_RECORDS_PER_DIRECTORY { + return Err(TableError::BadBlock); + } + if internal_key.len() < 8 { + return Err(TableError::BadBlock); + } + } + if entries.failed { + return Err(TableError::BadBlock); + } + Ok(()) +} + +fn emit_block_records( + block: &[u8], + emit: &mut impl FnMut(Record) -> bool, +) -> Result { + let mut entries = BlockEntries::new(block)?; + for (internal_key, value) in entries.by_ref() { + let Some(split) = internal_key.len().checked_sub(8) else { + return Err(TableError::BadBlock); + }; + let (user_key, trailer) = internal_key.split_at(split); + let packed = read_u64_le(trailer, 0).ok_or(TableError::BadBlock)?; + let value = match packed & 0xff { + KIND_VALUE => Some(copy_bytes(value)?), + KIND_DELETE => None, + _ => continue, + }; + if !emit(Record { + key: copy_bytes(user_key)?, + sequence: packed >> 8, + value, + }) { + return Ok(false); + } + } + if entries.failed { + return Err(TableError::BadBlock); + } + Ok(true) +} + +/// Return the decompressed contents of the block at `handle` (without its trailer). +fn read_block(data: &[u8], handle: BlockHandle) -> Result, TableError> { + let table_data_end = data + .len() + .checked_sub(FOOTER_SIZE) + .ok_or(TableError::BadHandle)?; + let end = handle + .offset + .checked_add(handle.size) + .and_then(|end| end.checked_add(BLOCK_TRAILER_SIZE)) + .ok_or(TableError::BadHandle)?; + if end > table_data_end { + return Err(TableError::BadHandle); + } + let contents_end = end - BLOCK_TRAILER_SIZE; + let raw = data + .get(handle.offset..contents_end) + .ok_or(TableError::BadHandle)?; + let compression = data[contents_end]; + match compression { + COMPRESSION_NONE if raw.len() <= MAX_BLOCK_BYTES => copy_bytes(raw), + COMPRESSION_NONE => Err(TableError::BlockTooLarge), + COMPRESSION_SNAPPY => Ok(snappy::decompress(raw, MAX_BLOCK_BYTES)?), + other => Err(TableError::UnsupportedCompression(other)), + } +} + +fn copy_bytes(bytes: &[u8]) -> Result, TableError> { + let mut output = Vec::new(); + output + .try_reserve_exact(bytes.len()) + .map_err(|_| TableError::AllocationFailed)?; + output.extend_from_slice(bytes); + Ok(output) +} + +/// Iterator over the `(key, value)` entries of a decoded block, undoing prefix compression. +struct BlockEntries<'a> { + entries: &'a [u8], + key: Vec, + failed: bool, +} + +impl<'a> BlockEntries<'a> { + fn new(block: &'a [u8]) -> Result { + let restart_count = + read_u32_le(block, block.len().saturating_sub(4)).ok_or(TableError::BadBlock)? as usize; + if restart_count == 0 || restart_count > MAX_RECORDS_PER_DIRECTORY { + return Err(TableError::BadBlock); + } + let restarts_len = restart_count + .checked_mul(4) + .and_then(|len| len.checked_add(4)) + .ok_or(TableError::BadBlock)?; + let entries_end = block + .len() + .checked_sub(restarts_len) + .ok_or(TableError::BadBlock)?; + let mut restart_position = entries_end; + let mut previous_restart = 0usize; + for index in 0..restart_count { + let restart = + usize::try_from(read_u32_le(block, restart_position).ok_or(TableError::BadBlock)?) + .map_err(|_| TableError::BadBlock)?; + if (index == 0 && restart != 0) + || restart < previous_restart + || restart > entries_end + || (entries_end > 0 && restart == entries_end) + { + return Err(TableError::BadBlock); + } + previous_restart = restart; + restart_position += 4; + } + Ok(Self { + entries: &block[..entries_end], + key: Vec::new(), + failed: false, + }) + } +} + +impl<'a> Iterator for BlockEntries<'a> { + type Item = (Vec, &'a [u8]); + + fn next(&mut self) -> Option { + if self.failed || self.entries.is_empty() { + return None; + } + let parsed = parse_entry(self.entries, &self.key); + match parsed { + Some((key, value, rest)) => { + self.key.clear(); + if self.key.try_reserve(key.len()).is_err() { + self.failed = true; + return None; + } + self.key.extend_from_slice(&key); + self.entries = rest; + Some((key, value)) + } + None => { + self.failed = true; + None + } + } + } +} + +/// Decode one entry: `shared`, `non_shared`, `value_len` varints, key delta, value. +fn parse_entry<'a>( + entries: &'a [u8], + previous_key: &[u8], +) -> Option<(Vec, &'a [u8], &'a [u8])> { + let (shared, a) = read_varint32(entries)?; + let (non_shared, b) = read_varint32(entries.get(a..)?)?; + let (value_len, c) = read_varint32(entries.get(a + b..)?)?; + let body = entries.get(a + b + c..)?; + let (shared, non_shared, value_len) = + (shared as usize, non_shared as usize, value_len as usize); + let payload_len = non_shared.checked_add(value_len)?; + if shared > previous_key.len() || body.len() < payload_len { + return None; + } + let key_len = shared.checked_add(non_shared)?; + let mut key = Vec::new(); + key.try_reserve_exact(key_len).ok()?; + key.extend_from_slice(&previous_key[..shared]); + key.extend_from_slice(&body[..non_shared]); + Some((key, &body[non_shared..payload_len], &body[payload_len..])) +} diff --git a/rust/src/browser/leveldb/tests.rs b/rust/src/browser/leveldb/tests.rs new file mode 100644 index 0000000000..7110e9d215 --- /dev/null +++ b/rust/src/browser/leveldb/tests.rs @@ -0,0 +1,625 @@ +//! Tests for the LevelDB reader. Fixtures are built in-memory from the documented on-disk +//! formats (log block/record framing, table block/footer layout, Snappy elements). + +#![allow( + clippy::cast_possible_truncation, + reason = "fixture builders encode small in-memory sizes into fixed-width fields" +)] + +use super::local_storage::{ + LocalStorageEntry, decode_origin_entries, local_storage_dir, read_local_storage_entries, +}; +use super::snappy::{self, SnappyError}; +use super::table::{TableError, read_table}; +use super::{Entry, Record, log, read_entries}; + +// --------------------------------------------------------------------------------------------- +// Fixture builders +// --------------------------------------------------------------------------------------------- + +fn varint(mut value: u64) -> Vec { + let mut out = Vec::new(); + while value >= 0x80 { + out.push((value as u8 & 0x7f) | 0x80); + value >>= 7; + } + out.push(value as u8); + out +} + +/// A valid Snappy stream made only of literal elements (no back-references). +fn snappy_literal(data: &[u8]) -> Vec { + let mut out = varint(data.len() as u64); + for chunk in data.chunks(60) { + out.push(((chunk.len() - 1) as u8) << 2); + out.extend_from_slice(chunk); + } + out +} + +enum Op<'a> { + Put(&'a [u8], &'a [u8]), + Delete(&'a [u8]), +} + +fn write_batch(sequence: u64, ops: &[Op]) -> Vec { + let mut out = sequence.to_le_bytes().to_vec(); + out.extend_from_slice(&(ops.len() as u32).to_le_bytes()); + for op in ops { + match op { + Op::Put(key, value) => { + out.push(1); + out.extend(varint(key.len() as u64)); + out.extend_from_slice(key); + out.extend(varint(value.len() as u64)); + out.extend_from_slice(value); + } + Op::Delete(key) => { + out.push(0); + out.extend(varint(key.len() as u64)); + out.extend_from_slice(key); + } + } + } + out +} + +/// Frame batches as a LevelDB log: 32 KiB blocks, fragmenting records that cross a block edge. +fn write_log(batches: &[Vec]) -> Vec { + const BLOCK: usize = 32 * 1024; + let mut out = Vec::new(); + for batch in batches { + let mut remaining = batch.as_slice(); + let mut first = true; + loop { + let left_in_block = BLOCK - out.len() % BLOCK; + if left_in_block < 7 { + out.extend(std::iter::repeat_n(0u8, left_in_block)); + continue; + } + let chunk_len = remaining.len().min(left_in_block - 7); + let last = chunk_len == remaining.len(); + let kind: u8 = match (first, last) { + (true, true) => 1, + (true, false) => 2, + (false, false) => 3, + (false, true) => 4, + }; + out.extend_from_slice(&[0, 0, 0, 0]); // checksum (not verified by the reader) + out.extend_from_slice(&(chunk_len as u16).to_le_bytes()); + out.push(kind); + out.extend_from_slice(&remaining[..chunk_len]); + remaining = &remaining[chunk_len..]; + first = false; + if last { + break; + } + } + } + out +} + +fn internal_key(user_key: &[u8], sequence: u64, kind: u8) -> Vec { + let mut key = user_key.to_vec(); + key.extend_from_slice(&((sequence << 8) | u64::from(kind)).to_le_bytes()); + key +} + +/// Encode a block with prefix compression and a restart every `restart_interval` entries. +fn write_block(entries: &[(Vec, Vec)], restart_interval: usize) -> Vec { + let mut out = Vec::new(); + let mut restarts = vec![0u32]; + let mut previous: &[u8] = &[]; + for (index, (key, value)) in entries.iter().enumerate() { + let shared = if index % restart_interval == 0 { + if index > 0 { + restarts.push(out.len() as u32); + } + 0 + } else { + previous.iter().zip(key).take_while(|(a, b)| a == b).count() + }; + out.extend(varint(shared as u64)); + out.extend(varint((key.len() - shared) as u64)); + out.extend(varint(value.len() as u64)); + out.extend_from_slice(&key[shared..]); + out.extend_from_slice(value); + previous = key; + } + for restart in &restarts { + out.extend_from_slice(&restart.to_le_bytes()); + } + out.extend_from_slice(&(restarts.len() as u32).to_le_bytes()); + out +} + +#[derive(Clone, Copy, PartialEq)] +enum Compression { + None, + Snappy, +} + +/// A table record: user key, sequence, kind (1 = value, 0 = delete), value. +type TableRecord<'a> = (&'a [u8], u64, u8, &'a [u8]); + +/// Build a table with one data block per slice of `blocks`. +fn write_table(blocks: &[(&[TableRecord], Compression)]) -> Vec { + let mut file = Vec::new(); + let mut index_entries = Vec::new(); + for (records, compression) in blocks { + let entries: Vec<_> = records + .iter() + .map(|(key, sequence, kind, value)| { + (internal_key(key, *sequence, *kind), value.to_vec()) + }) + .collect(); + let block = write_block(&entries, 2); + let (contents, tag) = match compression { + Compression::None => (block, 0u8), + Compression::Snappy => (snappy_literal(&block), 1u8), + }; + let mut handle = varint(file.len() as u64); + handle.extend(varint(contents.len() as u64)); + file.extend_from_slice(&contents); + file.push(tag); + file.extend_from_slice(&[0, 0, 0, 0]); + index_entries.push((entries.last().unwrap().0.clone(), handle)); + } + + let index = write_block(&index_entries, 1); + let mut index_handle = varint(file.len() as u64); + index_handle.extend(varint(index.len() as u64)); + file.extend_from_slice(&index); + file.extend_from_slice(&[0, 0, 0, 0, 0]); + + let mut footer = varint(0); // metaindex handle: offset 0, size 0 (never read) + footer.extend(varint(0)); + footer.extend_from_slice(&index_handle); + footer.resize(40, 0); + footer.extend_from_slice(&0xdb47_7524_8b80_fb57u64.to_le_bytes()); + file.extend_from_slice(&footer); + file +} + +type Collected = Vec<(Vec, u64, Option>)>; + +fn collect_log(data: &[u8]) -> Collected { + let mut records = Vec::new(); + log::read_log(data, &mut |r: Record| { + records.push((r.key, r.sequence, r.value)); + }); + records +} + +fn collect_table(data: &[u8]) -> Result { + let mut records = Vec::new(); + read_table(data, &mut |r: Record| { + records.push((r.key, r.sequence, r.value)); + })?; + Ok(records) +} + +// --------------------------------------------------------------------------------------------- +// Snappy +// --------------------------------------------------------------------------------------------- + +#[test] +fn snappy_decodes_literal() { + let mut stream = vec![5, 4 << 2]; + stream.extend_from_slice(b"hello"); + assert_eq!(snappy::decompress(&stream, 1024).unwrap(), b"hello"); +} + +#[test] +fn snappy_decodes_long_literal_with_extra_length_byte() { + let data: Vec = (0..100u8).collect(); + let mut stream = varint(100); + stream.extend_from_slice(&[60 << 2, 99]); + stream.extend_from_slice(&data); + assert_eq!(snappy::decompress(&stream, 1024).unwrap(), data); +} + +#[test] +fn snappy_decodes_overlapping_copy_with_one_byte_offset() { + // "abc" literal, then copy of length 9 at offset 3 (copy-1 tag: len-4 in bits 2..5). + let stream = [12, 2 << 2, b'a', b'b', b'c', 0b01 | (5 << 2), 3]; + assert_eq!(snappy::decompress(&stream, 1024).unwrap(), b"abcabcabcabc"); +} + +#[test] +fn snappy_decodes_copy_with_two_byte_offset() { + let mut stream = vec![15, 9 << 2]; + stream.extend_from_slice(b"0123456789"); + stream.extend_from_slice(&[0b10 | (4 << 2), 10, 0]); + assert_eq!( + snappy::decompress(&stream, 1024).unwrap(), + b"012345678901234" + ); +} + +#[test] +fn snappy_decodes_copy_with_four_byte_offset() { + let mut stream = vec![14, 9 << 2]; + stream.extend_from_slice(b"0123456789"); + stream.extend_from_slice(&[0b11 | (3 << 2), 10, 0, 0, 0]); + assert_eq!( + snappy::decompress(&stream, 1024).unwrap(), + b"01234567890123" + ); +} + +#[test] +fn snappy_rejects_malformed_streams() { + assert_eq!(snappy::decompress(&[], 16), Err(SnappyError::BadPreamble)); + // Declared length larger than the limit is refused before allocating. + assert_eq!( + snappy::decompress(&varint(u64::from(u32::MAX)), 1024), + Err(SnappyError::TooLarge(u32::MAX as usize, 1024)) + ); + // Literal runs past the end of input. + assert_eq!( + snappy::decompress(&[5, 4 << 2, b'h'], 16), + Err(SnappyError::Truncated) + ); + // Copy before any output exists. + assert_eq!( + snappy::decompress(&[4, 0b01, 1], 16), + Err(SnappyError::BadOffset) + ); + // Zero offset is invalid. + assert_eq!( + snappy::decompress(&[5, 0, b'a', 0b01, 0], 16), + Err(SnappyError::BadOffset) + ); + // Output shorter than the preamble promised. + assert_eq!( + snappy::decompress(&[5, 0, b'a'], 16), + Err(SnappyError::LengthMismatch) + ); + // Output longer than the preamble promised. + assert_eq!( + snappy::decompress(&[1, 1 << 2, b'a', b'b'], 16), + Err(SnappyError::LengthMismatch) + ); +} + +// --------------------------------------------------------------------------------------------- +// Write-ahead log +// --------------------------------------------------------------------------------------------- + +#[test] +fn log_yields_puts_and_deletes_with_incrementing_sequences() { + let data = write_log(&[write_batch( + 10, + &[Op::Put(b"a", b"1"), Op::Delete(b"b"), Op::Put(b"c", b"3")], + )]); + assert_eq!( + collect_log(&data), + vec![ + (b"a".to_vec(), 10, Some(b"1".to_vec())), + (b"b".to_vec(), 11, None), + (b"c".to_vec(), 12, Some(b"3".to_vec())), + ] + ); +} + +#[test] +fn log_reassembles_records_that_span_blocks() { + let big = vec![0xabu8; 80 * 1024]; + let data = write_log(&[ + write_batch(1, &[Op::Put(b"small", b"x")]), + write_batch(2, &[Op::Put(b"big", &big)]), + write_batch(3, &[Op::Put(b"after", b"y")]), + ]); + assert!(data.len() > 64 * 1024); + let records = collect_log(&data); + assert_eq!(records.len(), 3); + assert_eq!(records[1].2.as_deref(), Some(big.as_slice())); + assert_eq!(records[2].0, b"after"); +} + +#[test] +fn log_skips_block_trailer_padding() { + // Leave fewer than 7 bytes at the end of the first block so the writer pads it. + let filler = vec![1u8; 32 * 1024 - 7 - 12 - 1 - 2 - 1 - 1 - 4]; + let data = write_log(&[ + write_batch(1, &[Op::Put(b"k", &filler)]), + write_batch(2, &[Op::Put(b"z", b"after-padding")]), + ]); + let records = collect_log(&data); + assert_eq!(records.len(), 2); + assert_eq!(records[1].2.as_deref(), Some(b"after-padding".as_slice())); +} + +#[test] +fn log_keeps_records_before_a_truncated_tail() { + let mut data = write_log(&[ + write_batch(1, &[Op::Put(b"kept", b"1")]), + write_batch(2, &[Op::Put(b"lost", b"2")]), + ]); + data.truncate(data.len() - 3); + let records = collect_log(&data); + assert_eq!(records.len(), 1); + assert_eq!(records[0].0, b"kept"); +} + +#[test] +fn log_ignores_garbage_without_panicking() { + assert!(collect_log(&[0xff; 100]).is_empty()); + assert!(collect_log(&[]).is_empty()); + // Middle fragment with no preceding first fragment. + let orphan = [0, 0, 0, 0, 1, 0, 3, 9]; + assert!(collect_log(&orphan).is_empty()); +} + +// --------------------------------------------------------------------------------------------- +// Tables +// --------------------------------------------------------------------------------------------- + +#[test] +fn table_reads_prefix_compressed_multi_block_data() { + let first: &[TableRecord] = &[ + (b"app:alpha", 5, 1, b"one"), + (b"app:alpha2", 6, 1, b"two"), + (b"app:beta", 7, 1, b"three"), + ]; + let second: &[TableRecord] = &[(b"zeta", 8, 1, b"four"), (b"zeta-gone", 9, 0, b"")]; + let data = write_table(&[(first, Compression::None), (second, Compression::None)]); + + let records = collect_table(&data).unwrap(); + assert_eq!(records.len(), 5); + assert_eq!( + records[1], + (b"app:alpha2".to_vec(), 6, Some(b"two".to_vec())) + ); + assert_eq!(records[4], (b"zeta-gone".to_vec(), 9, None)); +} + +#[test] +fn table_reads_snappy_compressed_blocks() { + let long_value = vec![b'v'; 500]; + let records: &[TableRecord] = &[(b"k1", 1, 1, &long_value), (b"k2", 2, 1, b"short")]; + let data = write_table(&[(records, Compression::Snappy)]); + + let decoded = collect_table(&data).unwrap(); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].2.as_deref(), Some(long_value.as_slice())); +} + +#[test] +fn table_with_bad_magic_or_length_is_rejected() { + let records: &[TableRecord] = &[(b"k", 1, 1, b"v")]; + let mut data = write_table(&[(records, Compression::None)]); + assert!(matches!( + collect_table(&data[..20]), + Err(TableError::BadFooter) + )); + let last = data.len() - 1; + data[last] ^= 0xff; + assert!(matches!(collect_table(&data), Err(TableError::BadFooter))); +} + +#[test] +fn table_skips_an_undecodable_block_but_keeps_the_rest() { + let good: &[TableRecord] = &[(b"good", 1, 1, b"ok")]; + let bad: &[TableRecord] = &[(b"bad", 2, 1, b"nope")]; + let mut data = write_table(&[(bad, Compression::Snappy), (good, Compression::None)]); + // Corrupt the first block's compression tag to an unsupported type (2). + let bad_block_len = snappy_literal(&write_block( + &[(internal_key(b"bad", 2, 1), b"nope".to_vec())], + 2, + )) + .len(); + data[bad_block_len] = 2; + + let mut records = Vec::new(); + let skipped = read_table(&data, &mut |r: Record| records.push(r.key)).unwrap(); + assert_eq!(skipped, 1); + assert_eq!(records, vec![b"good".to_vec()]); +} + +#[test] +fn table_with_out_of_range_handle_does_not_panic() { + let records: &[TableRecord] = &[(b"k", 1, 1, b"v")]; + let mut data = write_table(&[(records, Compression::None)]); + // Point the index handle far past the end of the file. + let footer = data.len() - 48; + data[footer..footer + 40].fill(0); + data[footer + 2..footer + 6].copy_from_slice(&[0xff, 0xff, 0xff, 0x0f]); + assert!(collect_table(&data).is_err()); +} + +// --------------------------------------------------------------------------------------------- +// Directory reader +// --------------------------------------------------------------------------------------------- + +#[test] +fn read_entries_resolves_newest_sequence_across_log_and_table() { + let dir = tempfile::tempdir().unwrap(); + let table_records: &[TableRecord] = &[ + (b"keep", 1, 1, b"table-keep"), + (b"stale", 2, 1, b"table-old"), + (b"removed", 3, 1, b"table-removed"), + ]; + std::fs::write( + dir.path().join("000005.ldb"), + write_table(&[(table_records, Compression::None)]), + ) + .unwrap(); + std::fs::write( + dir.path().join("000006.log"), + write_log(&[write_batch( + 10, + &[ + Op::Put(b"stale", b"log-new"), + Op::Delete(b"removed"), + Op::Put(b"fresh", b"log-fresh"), + ], + )]), + ) + .unwrap(); + // Bookkeeping files and unrelated data are ignored. + std::fs::write(dir.path().join("LOCK"), b"").unwrap(); + std::fs::write(dir.path().join("CURRENT"), b"MANIFEST-000001\n").unwrap(); + std::fs::write(dir.path().join("notes.txt"), b"not a database").unwrap(); + + let entries = read_entries(dir.path()).unwrap(); + let pairs: Vec<(&[u8], &[u8])> = entries + .iter() + .map(|e| (e.key.as_slice(), e.value.as_slice())) + .collect(); + assert_eq!( + pairs, + vec![ + (b"fresh".as_slice(), b"log-fresh".as_slice()), + (b"keep".as_slice(), b"table-keep".as_slice()), + (b"stale".as_slice(), b"log-new".as_slice()), + ] + ); +} + +#[test] +fn read_entries_lets_a_newer_table_delete_beat_an_older_log_put() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("000001.log"), + write_log(&[write_batch(1, &[Op::Put(b"gone", b"old")])]), + ) + .unwrap(); + let deletes: &[TableRecord] = &[(b"gone", 2, 0, b"")]; + std::fs::write( + dir.path().join("000002.ldb"), + write_table(&[(deletes, Compression::None)]), + ) + .unwrap(); + assert!(read_entries(dir.path()).unwrap().is_empty()); +} + +#[test] +fn read_entries_skips_corrupt_files_and_keeps_good_ones() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("000001.ldb"), b"definitely not a table").unwrap(); + std::fs::write( + dir.path().join("000002.log"), + write_log(&[write_batch(1, &[Op::Put(b"ok", b"1")])]), + ) + .unwrap(); + assert_eq!( + read_entries(dir.path()).unwrap(), + vec![Entry { + key: b"ok".to_vec(), + value: b"1".to_vec() + }] + ); +} + +#[test] +fn read_entries_reports_a_missing_directory() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_entries(&dir.path().join("absent")).is_err()); +} + +// --------------------------------------------------------------------------------------------- +// Chromium Local Storage layer +// --------------------------------------------------------------------------------------------- + +fn latin1(text: &str) -> Vec { + let mut out = vec![1u8]; + out.extend(text.chars().map(|c| c as u8)); + out +} + +fn utf16(text: &str) -> Vec { + let mut out = vec![0u8]; + out.extend(text.encode_utf16().flat_map(u16::to_le_bytes)); + out +} + +fn storage_key(origin: &str, encoded_key: &[u8]) -> Vec { + let mut key = format!("_{origin}").into_bytes(); + key.push(0); + key.extend_from_slice(encoded_key); + key +} + +#[test] +fn local_storage_decodes_latin1_and_utf16_items_for_one_origin() { + let entries = vec![ + Entry { + key: b"META:https://www.kimi.ai".to_vec(), + value: b"\x08\x01".to_vec(), + }, + Entry { + key: storage_key("https://www.kimi.ai", &latin1("access_token")), + value: latin1("eyJ.body.sig"), + }, + Entry { + key: storage_key("https://www.kimi.ai", &utf16("name")), + value: utf16("Zoë \u{1F600}"), + }, + Entry { + key: storage_key("https://www.kimi.com", &latin1("access_token")), + value: latin1("other-origin"), + }, + Entry { + key: storage_key("https://www.kimi.ai", &latin1("bad_format")), + value: vec![7, b'x'], + }, + ]; + + let decoded = decode_origin_entries(&entries, "https://www.kimi.ai/"); + assert_eq!( + decoded, + vec![ + LocalStorageEntry { + key: "access_token".into(), + value: "eyJ.body.sig".into() + }, + LocalStorageEntry { + key: "name".into(), + value: "Zoë \u{1F600}".into() + }, + ] + ); +} + +#[test] +fn local_storage_origin_match_is_exact_not_a_prefix() { + let entries = vec![Entry { + key: storage_key("https://www.kimi.ai.evil.example", &latin1("access_token")), + value: latin1("nope"), + }]; + assert!(decode_origin_entries(&entries, "https://www.kimi.ai").is_empty()); +} + +#[test] +fn local_storage_rejects_odd_length_utf16_values() { + let entries = vec![Entry { + key: storage_key("https://a.example", &latin1("k")), + value: vec![0, b'x'], + }]; + assert!(decode_origin_entries(&entries, "https://a.example").is_empty()); +} + +#[test] +fn read_local_storage_entries_reads_a_profile_directory() { + let profile = tempfile::tempdir().unwrap(); + let dir = local_storage_dir(profile.path()); + std::fs::create_dir_all(&dir).unwrap(); + + let key = storage_key("https://www.kimi.ai", &latin1("access_token")); + let value = latin1("a.b.c"); + std::fs::write( + dir.join("000003.log"), + write_log(&[write_batch(4, &[Op::Put(&key, &value)])]), + ) + .unwrap(); + + assert_eq!( + read_local_storage_entries(&dir, "https://www.kimi.ai").unwrap(), + vec![LocalStorageEntry { + key: "access_token".into(), + value: "a.b.c".into() + }] + ); +} diff --git a/rust/src/browser/leveldb/varint.rs b/rust/src/browser/leveldb/varint.rs new file mode 100644 index 0000000000..c51511a711 --- /dev/null +++ b/rust/src/browser/leveldb/varint.rs @@ -0,0 +1,41 @@ +//! LevelDB varint and fixed-width helpers. + +/// Decode a little-endian base-128 varint of at most 32 bits; returns the value and bytes used. +pub(super) fn read_varint32(input: &[u8]) -> Option<(u32, usize)> { + let (value, used) = read_varint64(input)?; + Some((u32::try_from(value).ok()?, used)) +} + +/// Decode a little-endian base-128 varint of at most 64 bits; returns the value and bytes used. +pub(super) fn read_varint64(input: &[u8]) -> Option<(u64, usize)> { + let mut value = 0u64; + for (index, byte) in input.iter().take(10).enumerate() { + let bits = u64::from(byte & 0x7f); + if index == 9 && bits > 1 { + return None; + } + value |= bits << (7 * index); + if byte & 0x80 == 0 { + return Some((value, index + 1)); + } + } + None +} + +/// Split a varint-length-prefixed slice off the front of `input`, returning `(slice, rest)`. +pub(super) fn read_length_prefixed(input: &[u8]) -> Option<(&[u8], &[u8])> { + let (len, used) = read_varint32(input)?; + let rest = &input[used..]; + let len = len as usize; + (rest.len() >= len).then(|| rest.split_at(len)) +} + +pub(super) fn read_u32_le(input: &[u8], at: usize) -> Option { + let bytes = input.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +pub(super) fn read_u64_le(input: &[u8], at: usize) -> Option { + let bytes = input.get(at..at.checked_add(8)?)?; + Some(u64::from_le_bytes(bytes.try_into().ok()?)) +} diff --git a/rust/src/browser/mod.rs b/rust/src/browser/mod.rs index f428e1b41a..b15cbdf3c5 100755 --- a/rust/src/browser/mod.rs +++ b/rust/src/browser/mod.rs @@ -3,6 +3,8 @@ pub mod cookie_cache; pub mod cookies; pub mod detection; +pub mod leveldb; +pub mod storage_discovery; pub mod watchdog; pub mod wsl_paths; diff --git a/rust/src/browser/storage_discovery.rs b/rust/src/browser/storage_discovery.rs new file mode 100644 index 0000000000..ab6214e43b --- /dev/null +++ b/rust/src/browser/storage_discovery.rs @@ -0,0 +1,141 @@ +//! Locates raw Chromium profile stores (Local Storage, Session Storage, IndexedDB) across the +//! installed Chromium-family browsers. +//! +//! Each consumer supplies its own decoder; this module only answers "which directories could hold +//! the data". It never opens, locks, or decrypts anything, so it is safe to run while the browser +//! is open and needs no credential-store access. +//! +//! The browser catalog is [`super::detection::BrowserType`] (via [`BrowserDetector`]); a +//! Chromium-family browser that is not in that catalog is not visited. + +use std::path::{Path, PathBuf}; + +use super::detection::BrowserDetector; +use super::leveldb::local_storage::local_storage_dir; + +const INDEXED_DB_DIR: &str = "IndexedDB"; +const SESSION_STORAGE_DIR: &str = "Session Storage"; +const INDEXED_DB_SUFFIX: &str = ".indexeddb.leveldb"; + +/// Which per-profile store to locate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StorageKind { + /// `/Local Storage/leveldb` + LocalStorage, + /// `/Session Storage` + SessionStorage, + /// `/IndexedDB/.indexeddb.leveldb` for every database whose directory name + /// starts with one of `origin_prefixes` (for example `https_platform.minimax.io_`). + IndexedDb { + origin_prefixes: &'static [&'static str], + }, +} + +impl StorageKind { + fn label_suffix(self) -> &'static str { + match self { + Self::LocalStorage => "", + Self::SessionStorage => " (Session Storage)", + Self::IndexedDb { .. } => " (IndexedDB)", + } + } +} + +/// One directory that may hold data of the requested [`StorageKind`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StorageCandidate { + /// Human-readable source, such as `Google Chrome Default (Session Storage)`. + pub label: String, + pub path: PathBuf, +} + +/// Candidates for `kind` in every installed Chromium-family browser, in catalog order. +pub fn discover(kind: StorageKind) -> Vec { + BrowserDetector::detect_all() + .iter() + .filter(|browser| browser.browser_type.is_chromium_based()) + .flat_map(|browser| { + candidates_in_profiles(&browser.profiles, browser.browser_type.display_name(), kind) + }) + .collect() +} + +/// Candidates for `kind` in one browser `User Data` directory, profiles sorted by name. +/// +/// Only `Default`, `Profile *`, and `user-*` directories count as profiles; guest and system +/// profiles are ignored. +#[cfg(test)] +fn candidates_in_user_data_dir( + user_data_dir: &Path, + label_prefix: &str, + kind: StorageKind, +) -> Vec { + let profiles = BrowserDetector::detect_chromium_profiles(user_data_dir); + candidates_in_profiles(&profiles, label_prefix, kind) +} + +fn candidates_in_profiles( + profiles: &[super::detection::BrowserProfile], + label_prefix: &str, + kind: StorageKind, +) -> Vec { + profiles + .iter() + .flat_map(|profile| { + let label = format!("{label_prefix} {}{}", profile.name, kind.label_suffix()); + profile_store_paths(&profile.path, kind) + .into_iter() + .map(move |path| StorageCandidate { + label: label.clone(), + path, + }) + }) + .collect() +} + +fn profile_store_paths(profile_dir: &Path, kind: StorageKind) -> Vec { + match kind { + StorageKind::LocalStorage => existing(local_storage_dir(profile_dir)), + StorageKind::SessionStorage => existing(profile_dir.join(SESSION_STORAGE_DIR)), + StorageKind::IndexedDb { origin_prefixes } => { + sorted_child_dirs(&profile_dir.join(INDEXED_DB_DIR), |name| { + name.ends_with(INDEXED_DB_SUFFIX) + && origin_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) + }) + .into_iter() + .map(|(_, path)| path) + .collect() + } + } +} + +fn existing(path: PathBuf) -> Vec { + if path.is_dir() { + vec![path] + } else { + Vec::new() + } +} + +/// Child directories of `dir` whose (non-hidden, valid UTF-8) name passes `keep`, sorted by name. +/// A missing or unreadable `dir` yields nothing. +fn sorted_child_dirs(dir: &Path, keep: impl Fn(&str) -> bool) -> Vec<(String, PathBuf)> { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut children: Vec<(String, PathBuf)> = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + let is_dir = entry.file_type().is_ok_and(|kind| kind.is_dir()); + (is_dir && !name.starts_with('.') && keep(&name)).then(|| (name, entry.path())) + }) + .collect(); + children.sort_by(|left, right| left.0.cmp(&right.0)); + children +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/browser/storage_discovery/tests.rs b/rust/src/browser/storage_discovery/tests.rs new file mode 100644 index 0000000000..e6f2239024 --- /dev/null +++ b/rust/src/browser/storage_discovery/tests.rs @@ -0,0 +1,158 @@ +use super::*; +use std::fs; + +const PREFIXES: &[&str] = &[ + "https_platform.minimax.io_", + "https_www.minimax.io_", + "https_minimax.io_", + "https_platform.minimaxi.com_", + "https_minimaxi.com_", + "https_www.minimaxi.com_", +]; + +fn indexed_db() -> StorageKind { + StorageKind::IndexedDb { + origin_prefixes: PREFIXES, + } +} + +fn mkdir(root: &Path, relative: &str) { + fs::create_dir_all(root.join(relative)).unwrap(); +} + +fn paths(root: &Path, candidates: &[StorageCandidate]) -> Vec { + candidates + .iter() + .map(|candidate| { + candidate + .path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) + .collect() +} + +#[test] +fn each_storage_kind_resolves_to_its_profile_directory() { + let root = tempfile::tempdir().unwrap(); + let database = "IndexedDB/https_platform.minimax.io_0.indexeddb.leveldb"; + for path in ["Local Storage/leveldb", "Session Storage", database] { + mkdir(root.path(), &format!("Default/{path}")); + } + + let cases = [ + ( + StorageKind::LocalStorage, + "Default/Local Storage/leveldb", + "", + ), + ( + StorageKind::SessionStorage, + "Default/Session Storage", + " (Session Storage)", + ), + (indexed_db(), &format!("Default/{database}"), " (IndexedDB)"), + ]; + for (kind, expected_path, suffix) in cases { + let found = candidates_in_user_data_dir(root.path(), "Google Chrome", kind); + assert_eq!(paths(root.path(), &found), [expected_path]); + assert_eq!(found[0].label, format!("Google Chrome Default{suffix}")); + } +} + +#[test] +fn missing_stores_and_missing_user_data_dir_yield_nothing() { + let root = tempfile::tempdir().unwrap(); + mkdir(root.path(), "Default"); + for kind in [ + StorageKind::LocalStorage, + StorageKind::SessionStorage, + indexed_db(), + ] { + assert!(candidates_in_user_data_dir(root.path(), "Chrome", kind).is_empty()); + assert!( + candidates_in_user_data_dir(&root.path().join("absent"), "Chrome", kind).is_empty() + ); + } +} + +#[test] +fn only_default_profile_and_user_profiles_are_visited_in_name_order() { + let root = tempfile::tempdir().unwrap(); + for profile in [ + "user-work", + "Profile 2", + "Default", + "Guest Profile", + "System Profile", + ".hidden", + "Profile1", + ] { + mkdir(root.path(), &format!("{profile}/Local Storage/leveldb")); + } + // A file named like a profile is not a profile. + fs::write(root.path().join("Profile 9"), b"x").unwrap(); + + let found = candidates_in_user_data_dir(root.path(), "Edge", StorageKind::LocalStorage); + assert_eq!( + found + .iter() + .map(|candidate| candidate.label.as_str()) + .collect::>(), + ["Edge Default", "Edge Profile 2", "Edge user-work"] + ); +} + +#[test] +fn indexed_db_keeps_only_allowed_origin_databases_that_are_directories() { + let root = tempfile::tempdir().unwrap(); + let allowed = [ + "https_platform.minimax.io_0", + "https_www.minimax.io_0", + "https_minimax.io_0", + "https_platform.minimaxi.com_0", + "https_www.minimaxi.com_0", + "https_minimaxi.com_0", + ] + .map(|origin| format!("{origin}.indexeddb.leveldb")); + let excluded = [ + "https_other.example_0.indexeddb.leveldb", + "https_minimax.io.evil_0.indexeddb.leveldb", + "https_minimax.io_0.indexeddb.blob", + ".https_minimax.io_0.indexeddb.leveldb", + ]; + for profile in ["Default", "Profile 2", "user-work", "Guest Profile"] { + for database in allowed.iter().map(String::as_str).chain(excluded) { + mkdir(root.path(), &format!("{profile}/IndexedDB/{database}")); + } + } + fs::write( + root.path() + .join("Default/IndexedDB/https_minimax.io_1.indexeddb.leveldb"), + b"x", + ) + .unwrap(); + + let found = candidates_in_user_data_dir(root.path(), "Fixture", indexed_db()); + + assert_eq!(found.len(), 3 * allowed.len()); + let mut expected_names: Vec<&str> = allowed.iter().map(String::as_str).collect(); + expected_names.sort_unstable(); + for (profile, group) in ["Default", "Profile 2", "user-work"] + .iter() + .zip(found.chunks(allowed.len())) + { + let names: Vec<_> = group + .iter() + .map(|candidate| candidate.path.file_name().unwrap().to_str().unwrap()) + .collect(); + assert_eq!(names, expected_names); + assert!( + group + .iter() + .all(|candidate| candidate.label == format!("Fixture {profile} (IndexedDB)")) + ); + } +} diff --git a/rust/src/browser/wsl_paths.rs b/rust/src/browser/wsl_paths.rs index 42d93f17d4..df59249d48 100644 --- a/rust/src/browser/wsl_paths.rs +++ b/rust/src/browser/wsl_paths.rs @@ -4,11 +4,9 @@ //! This module provides path resolvers that detect WSL and map //! browser profile paths to their Windows host equivalents. -use std::path::PathBuf; - use crate::wsl; -use super::detection::{BrowserProfile, BrowserType, DetectedBrowser}; +use super::detection::{BrowserDetector, BrowserType, DetectedBrowser}; /// WSL-aware browser detector. /// @@ -17,6 +15,7 @@ use super::detection::{BrowserProfile, BrowserType, DetectedBrowser}; pub struct WslBrowserDetector; impl WslBrowserDetector { + /// Detect Windows browsers visible from WSL via /mnt/c/ paths. pub fn detect_all() -> Vec { if !wsl::is_wsl() { return Vec::new(); @@ -27,130 +26,82 @@ impl WslBrowserDetector { None => return Vec::new(), }; - let mut browsers = Vec::new(); - - let candidates: &[(BrowserType, PathBuf)] = &[ - ( - BrowserType::Chrome, - appdata_local - .join("Google") - .join("Chrome") - .join("User Data"), - ), - ( - BrowserType::Edge, - appdata_local - .join("Microsoft") - .join("Edge") - .join("User Data"), - ), - ( - BrowserType::Brave, - appdata_local - .join("BraveSoftware") - .join("Brave-Browser") - .join("User Data"), - ), - ( - BrowserType::Arc, - appdata_local.join("Arc").join("User Data"), - ), - ( - BrowserType::Chromium, - appdata_local.join("Chromium").join("User Data"), - ), - ]; - - for (browser_type, user_data_dir) in candidates { - if user_data_dir.exists() { - let profiles = detect_chromium_profiles(user_data_dir); - if !profiles.is_empty() { - browsers.push(DetectedBrowser { - browser_type: *browser_type, - user_data_dir: user_data_dir.clone(), - profiles, - }); - } - } - } - - if let Some(appdata_roaming) = wsl::windows_appdata_roaming() { - let ff_dir = appdata_roaming - .join("Mozilla") - .join("Firefox") - .join("Profiles"); - if ff_dir.exists() { - let profiles = detect_firefox_profiles(&ff_dir); - if !profiles.is_empty() { - browsers.push(DetectedBrowser { - browser_type: BrowserType::Firefox, - user_data_dir: ff_dir, - profiles, - }); - } - } - } - - browsers + let appdata_roaming = wsl::windows_appdata_roaming(); + BrowserType::all() + .iter() + .copied() + .filter_map(|browser_type| { + BrowserDetector::detect_in_roots( + browser_type, + &appdata_local, + appdata_roaming.as_deref(), + ) + }) + .collect() } } -fn detect_chromium_profiles(user_data_dir: &PathBuf) -> Vec { - let mut profiles = Vec::new(); - - let default_path = user_data_dir.join("Default"); - if default_path.exists() { - profiles.push(BrowserProfile { - name: "Default".to_string(), - path: default_path, - is_default: true, - }); - } +#[cfg(test)] +mod tests { + use super::*; - if let Ok(entries) = std::fs::read_dir(user_data_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("Profile ") { - let path = entry.path(); - if path.is_dir() { - profiles.push(BrowserProfile { - name, - path, - is_default: false, - }); - } - } - } + #[test] + fn shared_detector_reads_wsl_shaped_roots_without_roaming() { + let temp = tempfile::tempdir().unwrap(); + let local = temp.path().join("mnt/c/Users/test/AppData/Local"); + let chrome = local.join("Google/Chrome Beta/User Data"); + std::fs::create_dir_all(chrome.join("Default")).unwrap(); + std::fs::create_dir_all(chrome.join("Profile 2")).unwrap(); + std::fs::create_dir_all(chrome.join("Profile 1")).unwrap(); + std::fs::create_dir_all(chrome.join("Other")).unwrap(); + + let detected = + BrowserDetector::detect_in_roots(BrowserType::ChromeBeta, &local, None).unwrap(); + assert_eq!(detected.user_data_dir, chrome); + assert_eq!(detected.profiles[0].name, "Default"); + assert!(detected.profiles[0].is_default); + let mut other_names: Vec<_> = detected.profiles[1..] + .iter() + .map(|profile| profile.name.as_str()) + .collect(); + other_names.sort_unstable(); + assert_eq!(other_names, ["Profile 1", "Profile 2"]); + assert!( + detected.profiles[1..] + .iter() + .all(|profile| !profile.is_default) + ); + assert!(BrowserDetector::detect_in_roots(BrowserType::Firefox, &local, None).is_none()); } - profiles -} - -fn detect_firefox_profiles(profiles_dir: &PathBuf) -> Vec { - let mut profiles = Vec::new(); - - if let Ok(entries) = std::fs::read_dir(profiles_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - let path = entry.path(); - if path.is_dir() && name.contains('.') { - let is_default = name.contains("default"); - profiles.push(BrowserProfile { - name, - path, - is_default, - }); - } - } + #[test] + fn shared_detector_reads_firefox_from_wsl_roaming_root() { + let temp = tempfile::tempdir().unwrap(); + let local = temp.path().join("mnt/c/Users/test/AppData/Local"); + let roaming = temp.path().join("mnt/c/Users/test/AppData/Roaming"); + let firefox = roaming.join("Mozilla/Firefox/Profiles"); + std::fs::create_dir_all(firefox.join("abc.default-release")).unwrap(); + std::fs::create_dir_all(firefox.join("def.other")).unwrap(); + std::fs::create_dir_all(local.join("Mozilla/Firefox/Profiles/fake.default")).unwrap(); + + let detected = + BrowserDetector::detect_in_roots(BrowserType::Firefox, &local, Some(&roaming)).unwrap(); + assert_eq!(detected.user_data_dir, firefox); + assert_eq!(detected.profiles.len(), 2); + assert!( + detected + .profiles + .iter() + .any(|profile| profile.name == "abc.default-release" && profile.is_default) + ); + assert!( + detected + .profiles + .iter() + .any(|profile| profile.name == "def.other" && !profile.is_default) + ); } - profiles -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] fn test_wsl_browser_detection() { let browsers = WslBrowserDetector::detect_all(); diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index b49be7ae2c..df9b26350d 100755 --- a/rust/src/cli/config.rs +++ b/rust/src/cli/config.rs @@ -3,11 +3,13 @@ //! Utilities for validating and inspecting configuration. use clap::{Parser, Subcommand}; +use serde::Serialize; use serde::de::DeserializeOwned; use std::path::Path; +use super::usage::OutputFormat; use crate::core::{ProviderId, TokenAccountStore, instantiate_provider}; -use crate::settings::{ApiKeys, ManualCookies, Settings}; +use crate::settings::{ApiKeys, ManualCookies, PreferencesDocument, Settings}; /// Arguments for the config command #[derive(Parser, Debug)] @@ -30,21 +32,28 @@ pub enum ConfigCommand { show_secrets: bool, }, /// List providers and enabled state - Providers, + Providers { + #[command(flatten)] + output: ConfigOutputArgs, + }, /// Enable a provider Enable { - /// Provider CLI name or alias - provider: String, + #[command(flatten)] + target: ConfigProviderArg, + #[command(flatten)] + output: ConfigOutputArgs, }, /// Disable a provider Disable { - /// Provider CLI name or alias - provider: String, + #[command(flatten)] + target: ConfigProviderArg, + #[command(flatten)] + output: ConfigOutputArgs, }, /// Store an API key for a provider SetApiKey { - /// Provider CLI name or alias - provider: String, + #[command(flatten)] + target: ConfigProviderArg, /// API key to store #[arg(long = "api-key")] api_key: Option, @@ -57,6 +66,110 @@ pub enum ConfigCommand { }, /// Show configuration file paths Path, + /// Export or import portable preferences (no secrets, no machine state) + Preferences { + #[command(subcommand)] + action: PreferencesAction, + }, + /// Allow or deny reading (and refreshing) Claude Code's own credentials + /// (~/.claude/.credentials.json or Credential Manager), or show the + /// current choice. Off by default; without it Claude Auto falls back to + /// reduced-fidelity CLI usage. + ClaudeCodeCredentials { + /// allow, deny, or status + #[arg(value_enum)] + action: ConsentAction, + #[command(flatten)] + output: ConfigOutputArgs, + }, +} + +#[derive(Subcommand, Debug)] +pub enum PreferencesAction { + /// Write portable preferences as JSON (to stdout unless --file is given) + Export { + /// Destination file + #[arg(long)] + file: Option, + }, + /// Apply a preferences file; restart a running CodexBar afterwards + Import { + /// Preferences file to read + #[arg(long)] + file: std::path::PathBuf, + }, +} + +/// `config claude-code-credentials` action. +#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentAction { + /// Let CodexBar read and refresh Claude Code's credentials + Allow, + /// Keep Claude Code's credentials closed (the default) + Deny, + /// Show the current choice without changing it + Status, +} + +/// `--format`, `--json`, and `--pretty` for the config subcommands that +/// report a result (upstream `CLICommonOptions`). +#[derive(clap::Args, Debug, Clone, Copy, Default)] +pub struct ConfigOutputArgs { + /// Output format: text or json + #[arg(short, long, default_value = "text")] + pub format: OutputFormat, + + /// Shorthand for --format json + #[arg(long)] + pub json: bool, + + /// Pretty-print JSON output + #[arg(long)] + pub pretty: bool, +} + +impl ConfigOutputArgs { + fn is_json(self) -> bool { + self.json || self.format == OutputFormat::Json + } + + fn print_json(self, value: &T) -> anyhow::Result<()> { + let json = if self.pretty { + serde_json::to_string_pretty(value)? + } else { + serde_json::to_string(value)? + }; + println!("{json}"); + Ok(()) + } +} + +/// The provider a config subcommand acts on: positional, or `-p/--provider` +/// as upstream spells it. +#[derive(clap::Args, Debug, Clone, Default)] +pub struct ConfigProviderArg { + /// Provider CLI name or alias + #[arg(value_name = "PROVIDER", required_unless_present = "provider_option")] + pub provider: Option, + + /// Provider CLI name or alias (same as the positional PROVIDER) + #[arg( + id = "provider_option", + short = 'p', + long = "provider", + value_name = "PROVIDER", + conflicts_with = "provider" + )] + pub provider_option: Option, +} + +impl ConfigProviderArg { + fn name(&self) -> &str { + self.provider_option + .as_deref() + .or(self.provider.as_deref()) + .unwrap_or_default() + } } /// Run the config command @@ -67,17 +180,56 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> { format, show_secrets, } => dump_config(&format, show_secrets).await, - ConfigCommand::Providers => list_providers().await, - ConfigCommand::Enable { provider } => set_provider_enabled(&provider, true).await, - ConfigCommand::Disable { provider } => set_provider_enabled(&provider, false).await, + ConfigCommand::Providers { output } => list_providers(output).await, + ConfigCommand::Enable { target, output } => { + set_provider_enabled(target.name(), true, output).await + } + ConfigCommand::Disable { target, output } => { + set_provider_enabled(target.name(), false, output).await + } ConfigCommand::SetApiKey { - provider, + target, api_key, stdin, no_enable, - } => set_api_key(&provider, api_key.as_deref(), stdin, !no_enable).await, + } => set_api_key(target.name(), api_key.as_deref(), stdin, !no_enable).await, ConfigCommand::Path => show_paths().await, + ConfigCommand::Preferences { action } => transfer_preferences(action), + ConfigCommand::ClaudeCodeCredentials { action, output } => { + claude_code_credentials(action, output).await + } + } +} + +/// Export or import the portable preferences document. +fn transfer_preferences(action: PreferencesAction) -> anyhow::Result<()> { + match action { + PreferencesAction::Export { file } => { + let document = PreferencesDocument::from_settings(&Settings::load())?; + match file { + Some(path) => { + document.write_file(&path)?; + println!( + "Config: exported {} preferences to {}", + document.len(), + path.display() + ); + } + None => print!("{}", document.to_json()), + } + } + PreferencesAction::Import { file: path } => { + let document = PreferencesDocument::read_file(&path)?; + let mut settings = Settings::load(); + let applied = document.apply_to(&mut settings)?; + settings.save()?; + println!( + "Config: imported {applied} preferences from {}. Restart CodexBar to apply them to a running app.", + path.display() + ); + } } + Ok(()) } /// Validate configuration files @@ -322,33 +474,68 @@ fn redact_secrets_value(value: serde_json::Value) -> serde_json::Value { } } +/// One `config providers` row; the JSON shape matches upstream's +/// `ConfigProviderStatusResult`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigProviderStatus { + provider: String, + display_name: String, + enabled: bool, + default_enabled: bool, +} + +impl ConfigProviderStatus { + fn text_line(&self) -> String { + let state = if self.enabled { "enabled" } else { "disabled" }; + let default_marker = if self.default_enabled { " default" } else { "" }; + format!( + "{}: {state}{default_marker} ({})", + self.provider, self.display_name + ) + } +} + +fn provider_statuses(settings: &Settings) -> Vec { + ProviderId::all() + .iter() + .map(|id| ConfigProviderStatus { + provider: id.cli_name().to_string(), + display_name: id.display_name().to_string(), + enabled: settings.is_provider_enabled(*id), + default_enabled: instantiate_provider(*id).metadata().default_enabled, + }) + .collect() +} + /// List provider enabled state. -async fn list_providers() -> anyhow::Result<()> { - let settings = Settings::load(); - for id in ProviderId::all() { - let state = if settings.is_provider_enabled(*id) { - "enabled" - } else { - "disabled" - }; - let default_marker = if instantiate_provider(*id).metadata().default_enabled { - " default" - } else { - "" - }; - println!( - "{}: {}{} ({})", - id.cli_name(), - state, - default_marker, - id.display_name() - ); +async fn list_providers(output: ConfigOutputArgs) -> anyhow::Result<()> { + let statuses = provider_statuses(&Settings::load()); + if output.is_json() { + return output.print_json(&statuses); + } + for status in &statuses { + println!("{}", status.text_line()); } Ok(()) } +/// `config enable|disable` JSON result (upstream `ConfigProviderToggleResult`). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConfigProviderToggle { + provider: String, + display_name: String, + enabled: bool, + config_path: Option, +} + /// Enable or disable a provider by CLI name. -async fn set_provider_enabled(provider: &str, enabled: bool) -> anyhow::Result<()> { +async fn set_provider_enabled( + provider: &str, + enabled: bool, + output: ConfigOutputArgs, +) -> anyhow::Result<()> { let id = parse_provider(provider)?; let mut settings = Settings::load(); if enabled { @@ -357,11 +544,68 @@ async fn set_provider_enabled(provider: &str, enabled: bool) -> anyhow::Result<( settings.disable_provider(id); } settings.save()?; + if output.is_json() { + return output.print_json(&ConfigProviderToggle { + provider: id.cli_name().to_string(), + display_name: id.display_name().to_string(), + enabled, + config_path: Settings::settings_path().map(|path| path.display().to_string()), + }); + } let state = if enabled { "enabled" } else { "disabled" }; println!("Config: {state} {}", id.display_name()); Ok(()) } +/// `config claude-code-credentials` JSON result. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClaudeCodeCredentialsConsent { + allowed: bool, + config_path: Option, +} + +/// Apply a consent action to the settings `claude_allow_reading_claude_code_credentials` +/// flag; returns whether it changed (and so needs saving). +fn apply_consent_action(settings: &mut Settings, action: ConsentAction) -> bool { + let allowed = match action { + ConsentAction::Allow => true, + ConsentAction::Deny => false, + ConsentAction::Status => return false, + }; + let changed = settings.claude_allow_reading_claude_code_credentials != allowed; + settings.claude_allow_reading_claude_code_credentials = allowed; + changed +} + +fn consent_text(allowed: bool) -> &'static str { + if allowed { + "Claude Code credentials: allowed (CodexBar may read and refresh Claude Code's OAuth credentials)" + } else { + "Claude Code credentials: not allowed (Claude Auto falls back to reduced-fidelity CLI usage)" + } +} + +/// Allow, deny, or show consent to read Claude Code's own credentials. +async fn claude_code_credentials( + action: ConsentAction, + output: ConfigOutputArgs, +) -> anyhow::Result<()> { + let mut settings = Settings::load(); + if apply_consent_action(&mut settings, action) { + settings.save()?; + } + let allowed = settings.claude_allow_reading_claude_code_credentials; + if output.is_json() { + return output.print_json(&ClaudeCodeCredentialsConsent { + allowed, + config_path: Settings::settings_path().map(|path| path.display().to_string()), + }); + } + println!("{}", consent_text(allowed)); + Ok(()) +} + /// Store an API key and optionally enable the provider. async fn set_api_key( provider: &str, @@ -466,9 +710,16 @@ async fn show_paths() -> anyhow::Result<()> { }; println!(" Token accounts: {}{}", token_path.display(), exists); + if crate::settings::settings_file_override().is_some() { + println!(); + println!( + "Settings file from {}; the stores above sit beside it.", + crate::settings::CONFIG_PATH_ENV + ); + } + // Show config directory - if let Some(config_dir) = dirs::config_dir() { - let codexbar_dir = config_dir.join("CodexBar"); + if let Some(codexbar_dir) = crate::settings::config_store_dir() { println!(); println!("Config directory: {}", codexbar_dir.display()); } @@ -478,12 +729,235 @@ async fn show_paths() -> anyhow::Result<()> { #[cfg(test)] mod tests { - use super::{ConfigFileError, read_json_config, sanitize_settings_for_dump}; + use super::{ + ClaudeCodeCredentialsConsent, ConfigCommand, ConfigFileError, ConfigOutputArgs, + ConsentAction, apply_consent_action, consent_text, provider_statuses, read_json_config, + sanitize_settings_for_dump, + }; + use crate::cli::{Cli, Commands}; + use crate::core::ProviderId; #[cfg(windows)] use crate::secure_file; - use crate::settings::ManualCookies; + use crate::settings::{ManualCookies, Settings}; + use clap::Parser; use serde_json::json; + #[test] + fn preferences_subcommands_parse_a_path() { + use super::{ConfigArgs, ConfigCommand, PreferencesAction}; + use clap::Parser; + + let export = + ConfigArgs::try_parse_from(["config", "preferences", "export", "--file", "p.json"]) + .expect("export parses"); + assert!(matches!( + export.command, + ConfigCommand::Preferences { + action: PreferencesAction::Export { file: Some(_) } + } + )); + let stdout = ConfigArgs::try_parse_from(["config", "preferences", "export"]) + .expect("export without --file parses"); + assert!(matches!( + stdout.command, + ConfigCommand::Preferences { + action: PreferencesAction::Export { file: None } + } + )); + let import = + ConfigArgs::try_parse_from(["config", "preferences", "import", "--file", "p.json"]) + .expect("import parses"); + assert!(matches!( + import.command, + ConfigCommand::Preferences { + action: PreferencesAction::Import { .. } + } + )); + assert!(ConfigArgs::try_parse_from(["config", "preferences", "import"]).is_err()); + } + + fn parse_config(argv: &[&str]) -> Result { + let cli = Cli::try_parse_from(["codexbar", "config"].iter().chain(argv.iter()))?; + match cli.command { + Some(Commands::Config(args)) => Ok(args.command), + other => panic!("expected the config command, got {other:?}"), + } + } + + #[test] + fn config_providers_accepts_json_output_flags() { + let ConfigCommand::Providers { output } = parse_config(&["providers"]).unwrap() else { + panic!("expected providers"); + }; + assert!(!output.is_json()); + for argv in [ + &["providers", "--json"][..], + &["providers", "--format", "json"][..], + &["providers", "-f", "json", "--pretty"][..], + ] { + let ConfigCommand::Providers { output } = parse_config(argv).unwrap() else { + panic!("expected providers for {argv:?}"); + }; + assert!(output.is_json(), "{argv:?}"); + } + assert!(parse_config(&["providers", "--format", "toml"]).is_err()); + } + + #[test] + fn provider_status_json_matches_the_upstream_shape() { + let mut settings = Settings::default(); + settings.enabled_providers.clear(); + settings.enable_provider(ProviderId::Cursor); + + let statuses = provider_statuses(&settings); + + assert_eq!(statuses.len(), ProviderId::all().len()); + let cursor = statuses + .iter() + .find(|status| status.provider == "cursor") + .expect("cursor row"); + assert!(cursor.enabled); + let codex = statuses + .iter() + .find(|status| status.provider == "codex") + .expect("codex row"); + assert!(!codex.enabled); + + let value = serde_json::to_value(codex).unwrap(); + let mut keys: Vec<&str> = value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + ["defaultEnabled", "displayName", "enabled", "provider"] + ); + assert_eq!(value["displayName"], ProviderId::Codex.display_name()); + assert_eq!(value["enabled"], false); + assert_eq!(value["defaultEnabled"], codex.default_enabled); + } + + #[test] + fn provider_status_text_lines_keep_the_existing_format() { + let mut settings = Settings::default(); + settings.enabled_providers.clear(); + settings.enable_provider(ProviderId::Codex); + let statuses = provider_statuses(&settings); + let codex = statuses.iter().find(|s| s.provider == "codex").unwrap(); + let claude = statuses.iter().find(|s| s.provider == "claude").unwrap(); + + let marker = |default: bool| if default { " default" } else { "" }; + assert_eq!( + codex.text_line(), + format!("codex: enabled{} (Codex)", marker(codex.default_enabled)) + ); + assert_eq!( + claude.text_line(), + format!( + "claude: disabled{} (Claude)", + marker(claude.default_enabled) + ) + ); + } + + #[test] + fn provider_subcommands_accept_the_positional_or_provider_option() { + for argv in [ + &["enable", "cursor"][..], + &["enable", "-p", "cursor"][..], + &["enable", "--provider", "cursor", "--json"][..], + ] { + let ConfigCommand::Enable { target, .. } = parse_config(argv).unwrap() else { + panic!("expected enable for {argv:?}"); + }; + assert_eq!(target.name(), "cursor", "{argv:?}"); + } + let ConfigCommand::Disable { target, output } = + parse_config(&["disable", "--provider", "cursor", "--format", "json"]).unwrap() + else { + panic!("expected disable"); + }; + assert_eq!(target.name(), "cursor"); + assert!(output.is_json()); + let ConfigCommand::SetApiKey { target, stdin, .. } = + parse_config(&["set-api-key", "-p", "openrouter", "--stdin"]).unwrap() + else { + panic!("expected set-api-key"); + }; + assert_eq!(target.name(), "openrouter"); + assert!(stdin); + + assert!(parse_config(&["enable"]).is_err(), "provider is required"); + assert!( + parse_config(&["enable", "cursor", "--provider", "codex"]).is_err(), + "positional and --provider conflict" + ); + } + + #[test] + fn default_output_args_are_text() { + assert!(!ConfigOutputArgs::default().is_json()); + } + + #[test] + fn claude_code_credentials_parses_each_action() { + for (word, expected) in [ + ("allow", ConsentAction::Allow), + ("deny", ConsentAction::Deny), + ("status", ConsentAction::Status), + ] { + let ConfigCommand::ClaudeCodeCredentials { action, output } = + parse_config(&["claude-code-credentials", word, "--json"]).unwrap() + else { + panic!("expected claude-code-credentials for {word}"); + }; + assert_eq!(action, expected); + assert!(output.is_json()); + } + assert!(parse_config(&["claude-code-credentials"]).is_err()); + assert!(parse_config(&["claude-code-credentials", "maybe"]).is_err()); + } + + #[test] + fn consent_actions_toggle_only_the_claude_code_flag() { + let mut settings = Settings::default(); + assert!(!settings.claude_allow_reading_claude_code_credentials); + let before = serde_json::to_value(&settings).unwrap(); + + assert!(!apply_consent_action(&mut settings, ConsentAction::Status)); + assert!(!apply_consent_action(&mut settings, ConsentAction::Deny)); + assert!(apply_consent_action(&mut settings, ConsentAction::Allow)); + assert!(settings.claude_allow_reading_claude_code_credentials); + assert!(!apply_consent_action(&mut settings, ConsentAction::Allow)); + assert!(!apply_consent_action(&mut settings, ConsentAction::Status)); + assert!(settings.claude_allow_reading_claude_code_credentials); + + let mut after = serde_json::to_value(&settings).unwrap(); + after["claude_allow_reading_claude_code_credentials"] = json!(false); + assert_eq!(after, before, "no other setting changes"); + + assert!(apply_consent_action(&mut settings, ConsentAction::Deny)); + assert!(!settings.claude_allow_reading_claude_code_credentials); + } + + #[test] + fn consent_output_reports_the_choice() { + let value = serde_json::to_value(ClaudeCodeCredentialsConsent { + allowed: true, + config_path: Some("settings.json".to_string()), + }) + .unwrap(); + assert_eq!( + value, + json!({ "allowed": true, "configPath": "settings.json" }) + ); + assert!(consent_text(true).contains("allowed")); + assert!(consent_text(false).contains("not allowed")); + } + #[cfg(windows)] #[test] fn validates_dpapi_protected_manual_cookies_without_plaintext() { diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 8e9be16dc2..efaef2dbf3 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -4,11 +4,16 @@ use clap::Args; +use super::cost_period::{ + cost_totals_json, resolve_period, rolling_window_days, stamp_period, window_days, +}; use super::usage::{OutputFormat, ProviderSelection}; use crate::codex_costs::{ CodexHostCostReport, CodexHostCostsArgs, CodexHostOutcome, run_codex_host_costs, }; +use crate::codex_workspaces::short_session_id; use crate::core::{CostScanOptions, ProviderId}; +use crate::cost_reporting_period::CostReportingPeriod; use crate::cost_scanner::{CostScanner, CostSummary}; use crate::settings::Settings; use crate::spend_contract::build_local_spend_contract_from_summary; @@ -36,9 +41,14 @@ pub struct CostArgs { #[arg(long)] pub pretty: bool, - /// Number of days to scan (default: 30) - #[arg(short, long, default_value = "30")] - pub days: u32, + /// Cost history window in days (1..=365); always rolling and overrides --period + #[arg(short, long)] + pub days: Option, + + /// Cost period: month-to-date or all. Without --days or --period the + /// saved cost period applies (30 days by default) + #[arg(long)] + pub period: Option, /// A16 (upstream 0.48.0): exclude pi/OMP-compatible agent session mirrors, /// reporting only the provider-native local JSONL logs. When omitted @@ -62,6 +72,12 @@ pub struct CostArgs { /// Emit the versioned native Codex summary contract as JSON. #[arg(long = "summary-only")] pub summary_only: bool, + + /// Antigravity only: when a recorded model has no known public price, wait + /// for one bounded models.dev pricing refresh and rescan. Without it the + /// CLI starts no pricing download. + #[arg(long)] + pub refresh: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -91,9 +107,19 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { let group_by = CostGroupBy::from_arg(args.group_by.as_deref()); let use_color = !args.no_color && is_terminal(); - if args.remote.is_some() || args.summary_only { + let settings = Settings::load(); + let host_summary = args.remote.is_some() || args.summary_only; + let period = resolve_period( + args.days, + args.period.as_deref(), + settings.cost_reporting_period, + host_summary, + )?; + let days = window_days(period); + + if host_summary { return run_codex_host_costs(&CodexHostCostsArgs { - days: args.days, + days, remote: args.remote.clone(), summary_only: args.summary_only, pretty: args.pretty, @@ -115,13 +141,14 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { // owns its mirrored Codex/Claude events. A single native-provider request // keeps the historical inclusive behavior unless explicitly narrowed. scan_options.include_pi_sessions = !args.provider_native_only && !pi_selected; - let scanner = CostScanner::new(args.days).with_options(scan_options); + let scanner = CostScanner::for_period(period).with_options(scan_options); tracing::debug!( - "Running cost command: providers={:?}, format={:?}, days={}", + "Running cost command: providers={:?}, format={:?}, period={}, days={}", providers.as_list(), format, - args.days + period, + days ); // Collect cost data for requested providers @@ -169,13 +196,17 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { display_name: provider.display_name().to_string(), summary: CostSummary::default(), supported: true, - token_history: Some(crate::providers::antigravity::local_sessions::summarize( - args.days, - )), + token_history: Some( + crate::providers::antigravity::local_sessions::summarize_with_pricing_refresh( + days, + args.refresh, + ) + .await, + ), }); } ProviderId::Muse => { - let report = crate::providers::muse::local_usage::scan(args.days, None); + let report = crate::providers::muse::local_usage::scan(days, None); results.push(CostResult { provider: provider.cli_name().to_string(), display_name: provider.display_name().to_string(), @@ -199,10 +230,17 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { match format { OutputFormat::Text => { - print_text_output(&results, use_color, args.days, group_by); + print_text_output(&results, use_color, period, group_by); } OutputFormat::Json => { - print_json_output(&results, args.pretty, args.days)?; + // Upstream 0.60.4 refreshes OpenCodex prices before the JSON + // payload; on Windows the import lives in the Codex contract. + if results.iter().any(|result| result.provider == "codex") + && settings.open_codex_usage_logs_enabled + { + crate::spend_contract::refresh_opencodex_pricing_if_needed().await; + } + print_json_output(&results, args.pretty, period, days, &settings)?; } } @@ -273,12 +311,18 @@ struct CostResult { } /// Print text output -fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_by: CostGroupBy) { +fn print_text_output( + results: &[CostResult], + use_color: bool, + period: CostReportingPeriod, + group_by: CostGroupBy, +) { + let label = period.label(); for (i, result) in results.iter().enumerate() { let title = if result.token_history.is_some() { - format!("{} Token History (last {} days)", result.display_name, days) + format!("{} Token History ({label})", result.display_name) } else { - format!("{} Cost (last {} days)", result.display_name, days) + format!("{} Cost ({label})", result.display_name) }; if use_color { println!("\x1b[1m{title}\x1b[0m"); @@ -286,31 +330,39 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b println!("{title}"); } - if let Some(history) = result.token_history { - print_local_token_history(history, days); + if let Some(history) = result.token_history.as_ref() { + print_local_token_history(history); } else if group_by == CostGroupBy::Session && result.provider == "codex" { - print_codex_session_output(result, days); + print_codex_session_output(result, period); } else if group_by == CostGroupBy::Session { println!(" Session grouping is only available for Codex local conversations"); } else if !result.supported { println!(" Local cost scanning not available for this provider"); println!(" (Only Codex and Claude have local logs)"); } else if result.summary.sessions_count == 0 { - if result.summary.known_zero { - println!(" No usage in the last {} days (scan complete)", days); + if result.summary.incomplete_request_count > 0 { + // Only preliminary proxy rows exist: usage is unavailable, not $0. + println!(" No completed usage data found"); + print_incomplete_note(result.summary.incomplete_request_count); + } else if result.summary.known_zero { + println!(" No usage in the selected period (scan complete)"); } else { println!(" No usage data found"); println!(" Check that you have used {} locally", result.display_name); } } else { // Total cost + let incomplete_suffix = incomplete_suffix(result.summary.incomplete_request_count); if use_color { println!( - " Total: \x1b[32m{}\x1b[0m", + " Total: \x1b[32m{}\x1b[0m{incomplete_suffix}", result.summary.format_total() ); } else { - println!(" Total: {}", result.summary.format_total()); + println!( + " Total: {}{incomplete_suffix}", + result.summary.format_total() + ); } // Token breakdown @@ -323,6 +375,7 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b // Sessions println!(" Sessions: {}", result.summary.sessions_count); + print_incomplete_note(result.summary.incomplete_request_count); // Cost by model if !result.summary.by_model.is_empty() { @@ -372,11 +425,32 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b } } -fn print_local_token_history(history: crate::spend_contract::LocalTokenHistorySummary, days: u32) { +/// " · Incomplete" marker for totals that exclude preliminary Claude proxy +/// rows (upstream 0.60.5 #3688). +fn incomplete_suffix(count: u32) -> &'static str { + if count > 0 { " · Incomplete" } else { "" } +} + +fn print_incomplete_note(count: u32) { + if count > 0 { + println!( + " Incomplete: {count} requests lacked final usage and were excluded from tokens and cost." + ); + } +} + +fn print_local_token_history(history: &crate::spend_contract::LocalTokenHistorySummary) { use crate::spend_contract::LocalHistoryCoverage; match history.coverage { + LocalHistoryCoverage::Partial if history.lower_bound => { + println!( + " Tokens: at least {} (lower bound; scan stopped short)", + format_number(history.total_tokens) + ); + println!(" Sessions: at least {}", history.session_count); + } LocalHistoryCoverage::Complete if history.total_tokens == 0 => { - println!(" No token usage in the last {days} days (scan complete)"); + println!(" No token usage in the selected period (scan complete)"); } LocalHistoryCoverage::Complete => { println!(" Tokens: {} total", format_number(history.total_tokens)); @@ -386,11 +460,26 @@ fn print_local_token_history(history: crate::spend_contract::LocalTokenHistorySu println!(" Local token history is unavailable or incomplete"); } } - println!(" Local token history; dollar costs unavailable"); + if let Some(cost) = history.total_usd() { + println!(" API list-price estimate: ${cost:.2} (not billed spend)"); + } else if let Some(cost) = history.cost_estimate.known_subtotal_usd { + if history.coverage == LocalHistoryCoverage::Complete { + println!( + " Known API list-price subtotal: ${cost:.2} ({} unpriced requests)", + history.cost_estimate.coverage.unpriced + ); + } else { + println!(" Known API list-price subtotal: at least ${cost:.2} (history incomplete)"); + } + } else { + println!(" Local token history; dollar costs unavailable"); + } } -fn print_codex_session_output(result: &CostResult, days: u32) { - let index = crate::codex_workspaces::CodexWorkspacesIndex::new(days); +fn print_codex_session_output(result: &CostResult, period: CostReportingPeriod) { + // The conversation index keeps rolling 1..=365 day windows, so All lists + // the most recent year; the heading below reports the window it used. + let index = crate::codex_workspaces::CodexWorkspacesIndex::new(rolling_window_days(period)); let snapshot = match index.load_snapshot(false, |_| {}) { Ok(snapshot) => snapshot, Err(err) => { @@ -436,31 +525,21 @@ fn print_codex_session_output(result: &CostResult, days: u32) { println!(" Not a subscription bill or plan value · local usage × public API prices"); } -fn short_session_id(value: &str) -> String { - let trimmed = value.trim(); - if trimmed.chars().count() <= 12 { - return trimmed.to_string(); - } - let prefix: String = trimmed.chars().take(4).collect(); - let suffix: String = trimmed - .chars() - .rev() - .take(8) - .collect::>() - .into_iter() - .rev() - .collect(); - format!("{prefix}...{suffix}") -} - /// Print JSON output -fn build_json_payloads(results: &[CostResult], days: u32) -> Vec { - let settings = Settings::load(); +fn build_json_payloads( + results: &[CostResult], + period: CostReportingPeriod, + days: u32, + settings: &Settings, +) -> Vec { results .iter() .map(|r| { - if let Some(history) = r.token_history { - return crate::spend_contract::local_token_history_json(&r.provider, history, days); + if let Some(history) = r.token_history.as_ref() { + let mut payload = + crate::spend_contract::local_token_history_json(&r.provider, history, days); + stamp_period(&mut payload, period); + return payload; } if !r.supported { serde_json::json!({ @@ -478,10 +557,11 @@ fn build_json_payloads(results: &[CostResult], days: u32) -> Vec Vec>(), "period": {"start": r.summary.period_start.map(|d| d.to_string()), "end": r.summary.period_end.map(|d| d.to_string())}, "spendContract": spend_contract - }) + }); + // Only emitted when a scan excluded preliminary Claude proxy rows. + if r.summary.incomplete_request_count > 0 + && let Some(object) = payload.as_object_mut() + { + object.insert( + "incompleteRequestCount".to_string(), + serde_json::json!(r.summary.incomplete_request_count), + ); + } + stamp_period(&mut payload, period); + payload } }) .collect() } -fn print_json_output(results: &[CostResult], pretty: bool, days: u32) -> anyhow::Result<()> { - let payloads = build_json_payloads(results, days); +fn print_json_output( + results: &[CostResult], + pretty: bool, + period: CostReportingPeriod, + days: u32, + settings: &Settings, +) -> anyhow::Result<()> { + let payloads = build_json_payloads(results, period, days, settings); let output = if pretty { serde_json::to_string_pretty(&payloads)? @@ -616,15 +713,45 @@ mod tests { assert!(s.contains("null"), "non-codex A16 is null"); } + #[test] + fn json_output_emits_incomplete_request_count_only_when_positive() { + let make = |incomplete_request_count| CostResult { + provider: "claude".to_string(), + display_name: "Claude".to_string(), + summary: CostSummary { + incomplete_request_count, + ..CostSummary::default() + }, + supported: true, + token_history: None, + }; + let payloads = build_json_payloads( + &[make(3), make(0)], + CostReportingPeriod::Rolling(30), + 30, + &Settings::default(), + ); + assert_eq!(payloads[0]["incompleteRequestCount"], 3); + assert!(payloads[1].get("incompleteRequestCount").is_none()); + } + + #[test] + fn incomplete_suffix_marks_only_positive_counts() { + assert_eq!(incomplete_suffix(0), ""); + assert_eq!(incomplete_suffix(2), " · Incomplete"); + } + #[test] fn antigravity_json_keeps_unknown_cost_distinct_from_zero() { use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; let payload = crate::spend_contract::local_token_history_json( "antigravity", - LocalTokenHistorySummary { + &LocalTokenHistorySummary { total_tokens: 12_345, session_count: 2, coverage: LocalHistoryCoverage::Complete, + cost_estimate: Default::default(), + ..Default::default() }, 30, ); @@ -635,10 +762,12 @@ mod tests { let partial = crate::spend_contract::local_token_history_json( "antigravity", - LocalTokenHistorySummary { + &LocalTokenHistorySummary { total_tokens: 999, session_count: 1, coverage: LocalHistoryCoverage::Partial, + cost_estimate: Default::default(), + ..Default::default() }, 30, ); @@ -646,6 +775,201 @@ mod tests { assert!(partial["tokens"]["total"].is_null()); assert_eq!(partial["historyCoverage"], "partial"); } + + #[test] + fn antigravity_json_labels_public_price_estimates() { + use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; + let payload = crate::spend_contract::local_token_history_json( + "antigravity", + &LocalTokenHistorySummary { + total_tokens: 1_000, + session_count: 1, + coverage: LocalHistoryCoverage::Complete, + cost_estimate: crate::spend_contract::LocalCostEstimate { + known_subtotal_usd: Some(0.0125), + coverage: crate::spend_contract::CostCoverageCounts { + estimated: 1, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + 30, + ); + assert_eq!(payload["cost"]["total_usd"], 0.0125); + assert_eq!(payload["cost"]["known_subtotal_usd"], 0.0125); + assert_eq!(payload["cost"]["currency"], "USD"); + assert!(payload["note"].as_str().unwrap().contains("not billed")); + } + + #[test] + fn antigravity_json_keeps_partial_scan_cost_as_a_subtotal() { + use crate::spend_contract::{ + CostCoverageCounts, LocalCostEstimate, LocalHistoryCoverage, LocalTokenHistorySummary, + }; + let payload = crate::spend_contract::local_token_history_json( + "antigravity", + &LocalTokenHistorySummary { + total_tokens: 1_000, + session_count: 1, + coverage: LocalHistoryCoverage::Partial, + cost_estimate: LocalCostEstimate { + known_subtotal_usd: Some(0.0125), + coverage: CostCoverageCounts { + estimated: 1, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + 30, + ); + assert!(payload["cost"]["total_usd"].is_null()); + assert_eq!(payload["cost"]["known_subtotal_usd"], 0.0125); + assert_eq!(payload["historyCoverage"], "partial"); + assert!( + payload["note"] + .as_str() + .unwrap() + .contains("history is incomplete") + ); + } + + #[test] + fn antigravity_json_emits_zero_for_complete_empty_history() { + use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; + let payload = crate::spend_contract::local_token_history_json( + "antigravity", + &LocalTokenHistorySummary { + coverage: LocalHistoryCoverage::Complete, + ..Default::default() + }, + 30, + ); + assert_eq!(payload["cost"]["total_usd"], 0.0); + assert!(payload["cost"]["known_subtotal_usd"].is_null()); + assert_eq!(payload["cost"]["currency"], "USD"); + assert_eq!(payload["knownZero"], true); + } + + #[test] + fn antigravity_json_keeps_mixed_pricing_as_a_known_subtotal() { + use crate::spend_contract::{ + CostCoverageCounts, LocalCostEstimate, LocalHistoryCoverage, LocalTokenHistorySummary, + }; + let payload = crate::spend_contract::local_token_history_json( + "antigravity", + &LocalTokenHistorySummary { + total_tokens: 1_500, + session_count: 2, + coverage: LocalHistoryCoverage::Complete, + cost_estimate: LocalCostEstimate { + known_subtotal_usd: Some(0.0125), + coverage: CostCoverageCounts { + estimated: 1, + unpriced: 1, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + 30, + ); + assert!(payload["cost"]["total_usd"].is_null()); + assert_eq!(payload["cost"]["known_subtotal_usd"], 0.0125); + assert_eq!(payload["cost"]["pricingCoverage"]["estimated"], 1); + assert_eq!(payload["cost"]["pricingCoverage"]["unpriced"], 1); + assert!(payload["note"].as_str().unwrap().contains("subtotal")); + } + + #[derive(clap::Parser)] + struct Wrapper { + #[command(flatten)] + args: CostArgs, + } + + fn parse_args(argv: &[&str]) -> CostArgs { + use clap::Parser; + Wrapper::try_parse_from(std::iter::once("cost").chain(argv.iter().copied())) + .expect("cost args parse") + .args + } + + #[test] + fn days_and_period_are_detectable_when_absent() { + let args = parse_args(&[]); + assert_eq!(args.days, None); + assert_eq!(args.period, None); + let args = parse_args(&["--period", "month-to-date", "--days", "7"]); + assert_eq!(args.days, Some(7)); + assert_eq!(args.period.as_deref(), Some("month-to-date")); + } + + #[test] + fn json_payload_carries_period_identity_and_totals() { + let summary = CostSummary { + total_cost_usd: 2.5, + input_tokens: 10, + output_tokens: 5, + sessions_count: 1, + ..Default::default() + }; + let results = [CostResult { + provider: "claude".to_string(), + display_name: "Claude".to_string(), + summary, + supported: true, + token_history: None, + }]; + let settings = Settings::default(); + let payloads = + build_json_payloads(&results, CostReportingPeriod::MonthToDate, 12, &settings); + let payload = &payloads[0]; + assert_eq!(payload["reportingPeriod"], "month-to-date"); + assert_eq!(payload["historyLabel"], "Month to date"); + assert_eq!(payload["days_scanned"], 12); + assert_eq!(payload["totals"]["totalCost"], 2.5); + assert_eq!(payload["totals"]["totalTokens"], 15); + // Existing fields keep their meaning. + assert_eq!(payload["cost"]["total_usd"], 2.5); + assert_eq!(payload["tokens"]["input"], 10); + + let rolling = + build_json_payloads(&results, CostReportingPeriod::Rolling(30), 30, &settings); + assert_eq!(rolling[0]["reportingPeriod"], "rolling:30"); + assert_eq!(rolling[0]["historyLabel"], "Last 30 days"); + } + + #[test] + fn token_history_payload_is_stamped_with_the_period() { + use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; + let results = [CostResult { + provider: "antigravity".to_string(), + display_name: "Antigravity".to_string(), + summary: CostSummary::default(), + supported: true, + token_history: Some(LocalTokenHistorySummary { + total_tokens: 3, + session_count: 1, + coverage: LocalHistoryCoverage::Complete, + ..Default::default() + }), + }]; + let settings = Settings::default(); + let payloads = build_json_payloads( + &results, + CostReportingPeriod::AllAvailable, + 20_000, + &settings, + ); + assert_eq!(payloads[0]["reportingPeriod"], "all"); + assert_eq!(payloads[0]["historyLabel"], "All"); + assert_eq!(payloads[0]["days_scanned"], 20_000); + } + #[test] fn provider_native_only_flag_default_false() { // Default CostArgs has provider_native_only = false (backward compat). @@ -664,12 +988,6 @@ mod tests { assert_eq!(CostGroupBy::from_arg(Some("session")), CostGroupBy::Session); } - #[test] - fn short_session_id_is_privacy_conscious() { - assert_eq!(short_session_id("abc"), "abc"); - assert_eq!(short_session_id("1234567890abcdef"), "1234...90abcdef"); - } - #[test] fn remote_failure_retains_local_report() { let local_summary = CodexCostSummary::from_summaries_at( diff --git a/rust/src/cli/cost_period.rs b/rust/src/cli/cost_period.rs new file mode 100644 index 0000000000..84622e978f --- /dev/null +++ b/rust/src/cli/cost_period.rs @@ -0,0 +1,211 @@ +//! Reporting-period selection shared by `codexbar cost` and `serve` `/cost` +//! (upstream 0.67.0 `decodeCostReportingPeriod`, `costTotals`). + +use chrono::Utc; +use serde_json::{Value, json}; + +use crate::cost_reporting_period::{CostReportingPeriod, CostTimeZone, MAX_ROLLING_DAYS}; +use crate::cost_scanner::CostSummary; + +/// Pick the period for one `cost` invocation. +/// +/// `--days N` always selects a rolling window and wins over `--period`; +/// without either flag the saved selection applies. Host summaries +/// (`--remote`, `--summary-only`) speak a 1..=365 day protocol, so All needs an +/// explicit `--days`. +pub(super) fn resolve_period( + days: Option, + period: Option<&str>, + saved: CostReportingPeriod, + host_summary: bool, +) -> anyhow::Result { + if let Some(days) = days { + return Ok(CostReportingPeriod::rolling(days)); + } + let period = match period { + Some(raw) => match CostReportingPeriod::parse(raw) { + Some(CostReportingPeriod::MonthToDate) => CostReportingPeriod::MonthToDate, + Some(CostReportingPeriod::AllAvailable) => CostReportingPeriod::AllAvailable, + _ => anyhow::bail!("--period must be month-to-date or all"), + }, + None => saved, + }; + if host_summary && period == CostReportingPeriod::AllAvailable { + anyhow::bail!("host summaries support 1...365 days; override All with --days N"); + } + Ok(period) +} + +/// Days covered by `period` today, in the zone the local scanners bucket by. +pub(crate) fn window_days(period: CostReportingPeriod) -> u32 { + period.days(Utc::now(), CostTimeZone::Local, None) +} + +/// Days for consumers that only understand a rolling 1..=365 window (daily +/// chart rows, conversation grouping). Month to date fits; All is capped. +pub(crate) fn rolling_window_days(period: CostReportingPeriod) -> u32 { + window_days(period).min(MAX_ROLLING_DAYS) +} + +/// Add `reportingPeriod` (raw) and `historyLabel` to one provider payload. +pub(crate) fn stamp_period(payload: &mut Value, period: CostReportingPeriod) { + if let Some(object) = payload.as_object_mut() { + object.insert("reportingPeriod".to_string(), json!(period.raw())); + object.insert("historyLabel".to_string(), json!(period.label())); + } +} + +/// Totals for the selected window. `null` when the scan found nothing and did +/// not establish a known zero, so a missing scan is never shown as `$0`. +/// `totalTokens` uses the provider's own rule, the same total the desktop +/// spend rows show (Claude and Pi add cache tokens; Codex input already +/// includes them). +pub(crate) fn cost_totals_json(provider: &str, summary: &CostSummary) -> Value { + if summary.sessions_count == 0 && !summary.known_zero { + return Value::Null; + } + json!({ + "inputTokens": summary.input_tokens, + "outputTokens": summary.output_tokens, + "cachedTokens": summary.cached_tokens, + "reasoningTokens": summary.reasoning_tokens, + "totalTokens": summary.total_tokens_for_provider(provider), + "totalCost": summary.total_cost_usd, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAVED_MTD: CostReportingPeriod = CostReportingPeriod::MonthToDate; + + #[test] + fn days_beats_period_and_clamps() { + let resolved = resolve_period(Some(7), Some("all"), SAVED_MTD, false).unwrap(); + assert_eq!(resolved, CostReportingPeriod::Rolling(7)); + let clamped = resolve_period(Some(900), None, SAVED_MTD, false).unwrap(); + assert_eq!(clamped, CostReportingPeriod::Rolling(365)); + let floor = resolve_period(Some(0), None, SAVED_MTD, false).unwrap(); + assert_eq!(floor, CostReportingPeriod::Rolling(1)); + } + + #[test] + fn days_wins_even_over_an_invalid_period() { + let resolved = resolve_period(Some(7), Some("nonsense"), SAVED_MTD, false).unwrap(); + assert_eq!(resolved, CostReportingPeriod::Rolling(7)); + } + + #[test] + fn absent_flags_use_the_saved_period() { + let default = CostReportingPeriod::default(); + assert_eq!( + resolve_period(None, None, default, false).unwrap(), + CostReportingPeriod::Rolling(30) + ); + assert_eq!( + resolve_period(None, None, SAVED_MTD, false).unwrap(), + SAVED_MTD + ); + } + + #[test] + fn explicit_period_overrides_saved() { + let saved = CostReportingPeriod::Rolling(7); + assert_eq!( + resolve_period(None, Some("all"), saved, false).unwrap(), + CostReportingPeriod::AllAvailable + ); + assert_eq!( + resolve_period(None, Some("month-to-date"), saved, false).unwrap(), + CostReportingPeriod::MonthToDate + ); + } + + #[test] + fn invalid_period_is_rejected() { + let error = resolve_period(None, Some("weekly"), SAVED_MTD, false).unwrap_err(); + assert_eq!(error.to_string(), "--period must be month-to-date or all"); + assert!(resolve_period(None, Some("rolling:0"), SAVED_MTD, false).is_err()); + } + + #[test] + fn host_summaries_reject_all_without_explicit_days() { + let error = resolve_period(None, Some("all"), SAVED_MTD, true).unwrap_err(); + assert!(error.to_string().contains("override All with --days N")); + let saved_all = resolve_period(None, None, CostReportingPeriod::AllAvailable, true); + assert!(saved_all.is_err()); + assert_eq!( + resolve_period(Some(14), Some("all"), SAVED_MTD, true).unwrap(), + CostReportingPeriod::Rolling(14) + ); + assert_eq!( + resolve_period(None, None, SAVED_MTD, true).unwrap(), + SAVED_MTD + ); + } + + #[test] + fn rolling_window_is_capped_at_a_year_for_all() { + assert_eq!(rolling_window_days(CostReportingPeriod::AllAvailable), 365); + assert_eq!(rolling_window_days(CostReportingPeriod::Rolling(30)), 30); + assert!(window_days(CostReportingPeriod::AllAvailable) > 365); + assert!((1..=31).contains(&window_days(CostReportingPeriod::MonthToDate))); + } + + #[test] + fn stamp_adds_raw_period_and_label() { + let mut payload = json!({"provider": "codex"}); + stamp_period(&mut payload, CostReportingPeriod::MonthToDate); + assert_eq!(payload["reportingPeriod"], "month-to-date"); + assert_eq!(payload["historyLabel"], "Month to date"); + assert_eq!(payload["provider"], "codex"); + + let mut rolling = json!({}); + stamp_period(&mut rolling, CostReportingPeriod::Rolling(30)); + assert_eq!(rolling["reportingPeriod"], "rolling:30"); + assert_eq!(rolling["historyLabel"], "Last 30 days"); + } + + #[test] + fn totals_sum_tokens_and_keep_unknown_distinct_from_zero() { + let summary = CostSummary { + total_cost_usd: 1.5, + input_tokens: 100, + output_tokens: 20, + cached_tokens: 7, + sessions_count: 2, + ..CostSummary::default() + }; + let totals = cost_totals_json("codex", &summary); + assert_eq!(totals["inputTokens"], 100); + assert_eq!(totals["outputTokens"], 20); + assert_eq!(totals["cachedTokens"], 7); + assert_eq!(totals["totalTokens"], 120); + assert_eq!(totals["totalCost"], 1.5); + assert!(totals["reasoningTokens"].is_null()); + + assert!(cost_totals_json("codex", &CostSummary::default()).is_null()); + let known_zero = CostSummary { + known_zero: true, + ..CostSummary::default() + }; + assert_eq!(cost_totals_json("codex", &known_zero)["totalCost"], 0.0); + } + + #[test] + fn total_tokens_follow_each_provider_cache_rule() { + let summary = CostSummary { + input_tokens: 100, + output_tokens: 20, + cached_tokens: 7, + sessions_count: 1, + ..CostSummary::default() + }; + // Codex input already includes cached input; Claude and Pi report + // cache reads and writes outside input. + assert_eq!(cost_totals_json("codex", &summary)["totalTokens"], 120); + assert_eq!(cost_totals_json("claude", &summary)["totalTokens"], 127); + assert_eq!(cost_totals_json("pi", &summary)["totalTokens"], 127); + } +} diff --git a/rust/src/cli/diagnose.rs b/rust/src/cli/diagnose.rs index 64d0b54d59..623389af5c 100644 --- a/rust/src/cli/diagnose.rs +++ b/rust/src/cli/diagnose.rs @@ -177,6 +177,8 @@ async fn collect_provider_diagnostic( .map(ToOwned::to_owned), manual_cookie_missing: false, api_key: api_keys.get(provider_id.cli_name()).map(ToOwned::to_owned), + token_account_kind: None, + token_account_isolated: false, workspace_id: settings .provider_config(provider_id) .and_then(|config| config.workspace_id.clone()), @@ -188,8 +190,10 @@ async fn collect_provider_diagnostic( .provider_config(provider_id) .and_then(|config| config.gateway_url.clone()), auto_prefer_web: false, + browser_cookie_import: false, // Diagnostics keep the short optional-join grace (upstream #2583 gate). requires_optional_usage_completeness: false, + optional_details_enabled: settings.optional_details_enabled(provider_id), }; let fetch_result = provider.fetch_usage(&ctx).await; @@ -397,9 +401,11 @@ fn error_category(err: &ProviderError) -> &'static str { | ProviderError::OAuth(_) | ProviderError::OAuthExpired(_) | ProviderError::OAuthRevoked(_) - | ProviderError::NoCookies => "auth", + | ProviderError::NoCookies + | ProviderError::BrowserSignInRequired { .. } => "auth", ProviderError::OAuthTransient(_) => "api", ProviderError::Network(_) | ProviderError::Timeout => "network", + ProviderError::OwnedTransport { source, .. } => error_category(source), ProviderError::NotInstalled(_) | ProviderError::UnsupportedSource(_) => "config", ProviderError::Parse(_) => "parse", ProviderError::Other(message) => { @@ -427,6 +433,15 @@ mod tests { use super::*; use crate::core::{RateWindow, UsageSnapshot}; + #[test] + fn browser_sign_in_failures_count_as_auth() { + let error = ProviderError::BrowserSignInRequired { + message: "Sign in at the provider page in your browser.".to_string(), + sign_in_url: "https://example.test/login".to_string(), + }; + assert_eq!(error_category(&error), "auth"); + } + #[test] fn source_mode_names_are_stable() { assert_eq!(source_mode_name(SourceMode::Auto), "auto"); diff --git a/rust/src/cli/error_kind.rs b/rust/src/cli/error_kind.rs new file mode 100644 index 0000000000..8b76ba7730 --- /dev/null +++ b/rust/src/cli/error_kind.rs @@ -0,0 +1,197 @@ +//! `errorKind` on the provider error rows of `usage --json` (and `--format +//! toon`) and `serve` `/usage` (Issue #640 item 8). +//! +//! The `error` string stays for people. `errorKind` lets a caller act on a +//! failure without parsing English text: it reuses the desktop's provider +//! state names (`needsAuthentication`, `expiredSession`, +//! `localRuntimeOffline`), adds `timeout` and `browserSignInRequired`, and +//! falls back to `unknown`. A `browserSignInRequired` row also carries +//! `signInUrl`, the page that restores the provider's browser session. + +use serde_json::{Value, json}; + +use crate::core::{Provider, ProviderError, ProviderId, ProviderStateKind, instantiate_provider}; + +/// `errorKind` of a fetch that ran out of time. +pub(crate) const ERROR_KIND_TIMEOUT: &str = "timeout"; +/// `errorKind` when nothing more specific is known. +pub(crate) const ERROR_KIND_UNKNOWN: &str = "unknown"; +/// `errorKind` of a failure that only a browser sign-in can fix. +const ERROR_KIND_BROWSER_SIGN_IN: &str = "browserSignInRequired"; + +/// A JSON error row: `{provider, error, errorKind}`, plus `signInUrl` when +/// given. +pub(crate) fn error_row( + provider_id: ProviderId, + error: &str, + kind: &str, + sign_in_url: Option<&str>, +) -> Value { + let mut row = json!({ + "provider": provider_id.cli_name(), + "error": error, + "errorKind": kind, + }); + if let Some(url) = sign_in_url { + row["signInUrl"] = json!(url); + } + row +} + +/// The error row for a failed provider fetch, classified the way the desktop +/// classifies it (`Provider::error_state_kind`). +pub(crate) fn provider_error_row(provider: &dyn Provider, error: &ProviderError) -> Value { + classified_error_row(provider.id(), error, |error| { + provider.error_state_kind(error) + }) +} + +/// The error row for a failed `usage` fetch. Failures before the provider +/// runs (account selection, stored keys) are not provider errors and stay +/// `unknown`. +pub(crate) fn usage_error_row(provider_id: ProviderId, error: &anyhow::Error) -> Value { + match error.downcast_ref::() { + Some(provider_error) => { + provider_error_row(instantiate_provider(provider_id).as_ref(), provider_error) + } + None => error_row(provider_id, &error.to_string(), ERROR_KIND_UNKNOWN, None), + } +} + +fn classified_error_row( + provider_id: ProviderId, + error: &ProviderError, + state_kind: impl FnOnce(&ProviderError) -> ProviderStateKind, +) -> Value { + let (kind, sign_in_url) = match error.without_failure_owner() { + ProviderError::BrowserSignInRequired { sign_in_url, .. } => { + (ERROR_KIND_BROWSER_SIGN_IN, Some(sign_in_url.as_str())) + } + ProviderError::Timeout => (ERROR_KIND_TIMEOUT, None), + ProviderError::Network(network) if network.is_timeout() => (ERROR_KIND_TIMEOUT, None), + other => (state_kind_name(state_kind(other)), None), + }; + error_row(provider_id, &error.to_string(), kind, sign_in_url) +} + +fn state_kind_name(kind: ProviderStateKind) -> &'static str { + match kind { + ProviderStateKind::NeedsAuthentication => "needsAuthentication", + ProviderStateKind::ExpiredSession => "expiredSession", + ProviderStateKind::LocalRuntimeOffline => "localRuntimeOffline", + // A failed fetch is never `ready`. + ProviderStateKind::Ready | ProviderStateKind::Unknown => ERROR_KIND_UNKNOWN, + } +} + +#[cfg(test)] +mod tests { + use super::{classified_error_row, error_row, state_kind_name, usage_error_row}; + use crate::core::{ProviderError, ProviderId, ProviderStateKind}; + use serde_json::{Value, json}; + + fn kind_of(error: ProviderError) -> Value { + classified_error_row(ProviderId::Codex, &error, ProviderError::state_kind)["errorKind"] + .clone() + } + + #[test] + fn kinds_reuse_the_desktop_state_names() { + for kind in [ + ProviderStateKind::NeedsAuthentication, + ProviderStateKind::ExpiredSession, + ProviderStateKind::LocalRuntimeOffline, + ProviderStateKind::Unknown, + ] { + assert_eq!(json!(state_kind_name(kind)), json!(kind)); + } + assert_eq!(state_kind_name(ProviderStateKind::Ready), "unknown"); + } + + #[test] + fn provider_errors_get_the_kind_the_desktop_shows() { + assert_eq!(kind_of(ProviderError::AuthRequired), "needsAuthentication"); + assert_eq!(kind_of(ProviderError::NoCookies), "needsAuthentication"); + assert_eq!( + kind_of(ProviderError::OAuthExpired("Token expired.".to_string())), + "expiredSession" + ); + assert_eq!( + kind_of(ProviderError::Parse("bad body".to_string())), + "unknown" + ); + assert_eq!( + kind_of(ProviderError::Other("API error 500".to_string())), + "unknown" + ); + assert_eq!(kind_of(ProviderError::Timeout), "timeout"); + assert_eq!( + kind_of(ProviderError::OwnedTransport { + owner: None, + source: Box::new(ProviderError::Timeout), + }), + "timeout" + ); + } + + #[test] + fn the_provider_classification_wins_over_the_default() { + let row = classified_error_row( + ProviderId::Claude, + &ProviderError::NotInstalled("Claude CLI not found.".to_string()), + |_| ProviderStateKind::LocalRuntimeOffline, + ); + assert_eq!( + row, + json!({ + "provider": "claude", + "error": "Provider not installed: Claude CLI not found.", + "errorKind": "localRuntimeOffline", + }) + ); + } + + #[test] + fn browser_sign_in_rows_name_the_page_to_open() { + let row = classified_error_row( + ProviderId::Claude, + &ProviderError::BrowserSignInRequired { + message: "Claude usage failed from all configured sources. Sign in.".to_string(), + sign_in_url: "https://claude.ai/login".to_string(), + }, + ProviderError::state_kind, + ); + assert_eq!( + row, + json!({ + "provider": "claude", + "error": "Claude usage failed from all configured sources. Sign in.", + "errorKind": "browserSignInRequired", + "signInUrl": "https://claude.ai/login", + }) + ); + assert!( + kind_of(ProviderError::AuthRequired) != "browserSignInRequired", + "only the typed error asks for a browser" + ); + } + + #[test] + fn usage_errors_outside_the_provider_stay_unknown() { + assert_eq!( + usage_error_row( + ProviderId::Kimi, + &anyhow::anyhow!("No token accounts configured for Kimi") + ), + json!({ + "provider": "kimi", + "error": "No token accounts configured for Kimi", + "errorKind": "unknown", + }) + ); + assert_eq!( + error_row(ProviderId::Pi, "pi usage timed out", "timeout", None), + json!({ "provider": "pi", "error": "pi usage timed out", "errorKind": "timeout" }) + ); + } +} diff --git a/rust/src/cli/fetch_context.rs b/rust/src/cli/fetch_context.rs new file mode 100644 index 0000000000..4e3b971953 --- /dev/null +++ b/rust/src/cli/fetch_context.rs @@ -0,0 +1,96 @@ +use crate::core::{FetchContext, ProviderId}; +use crate::settings::Settings; + +/// Fill a missing API region from the saved provider settings. +/// +/// An explicitly supplied fetch-context value takes precedence over settings. +pub(crate) fn populate_api_region_from_settings( + provider_id: ProviderId, + settings: &Settings, + ctx: &mut FetchContext, +) { + if ctx.api_region.is_none() { + let region = settings.api_region(provider_id); + ctx.api_region = (!region.is_empty()).then(|| region.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::kimi::KimiRegion; + + fn fetch_context(api_region: Option<&str>) -> FetchContext { + FetchContext { + source_mode: crate::core::SourceMode::Auto, + include_credits: true, + web_timeout: 60, + verbose: false, + manual_cookie_header: None, + manual_cookie_missing: false, + api_key: None, + token_account_kind: None, + token_account_isolated: false, + workspace_id: None, + seat_credit_entitlement: None, + api_region: api_region.map(str::to_string), + gateway_url: None, + auto_prefer_web: false, + browser_cookie_import: false, + requires_optional_usage_completeness: false, + optional_details_enabled: false, + } + } + + #[test] + fn saved_kimi_international_region_populates_fetch_context() { + let mut settings = Settings::default(); + settings.set_api_region(ProviderId::Kimi, "international"); + let mut ctx = fetch_context(None); + + populate_api_region_from_settings(ProviderId::Kimi, &settings, &mut ctx); + + assert_eq!(ctx.api_region.as_deref(), Some("international")); + } + + #[test] + fn empty_or_unset_saved_region_leaves_context_region_missing() { + for configured_region in [None, Some("")] { + let mut settings = Settings::default(); + if let Some(region) = configured_region { + settings.set_api_region(ProviderId::Kimi, region); + } + let mut ctx = fetch_context(None); + + populate_api_region_from_settings(ProviderId::Kimi, &settings, &mut ctx); + + assert_eq!(ctx.api_region, None); + } + } + + #[test] + fn explicit_fetch_context_region_is_preserved() { + let mut settings = Settings::default(); + settings.set_api_region(ProviderId::Kimi, "international"); + let mut ctx = fetch_context(Some("caller-region")); + + populate_api_region_from_settings(ProviderId::Kimi, &settings, &mut ctx); + + assert_eq!(ctx.api_region.as_deref(), Some("caller-region")); + } + + #[test] + fn saved_international_region_selects_kimi_ai_domains() { + let mut settings = Settings::default(); + settings.set_api_region(ProviderId::Kimi, "international"); + let mut ctx = fetch_context(None); + + populate_api_region_from_settings(ProviderId::Kimi, &settings, &mut ctx); + + let region = KimiRegion::from_settings(ctx.api_region.as_deref()); + assert_eq!(region, KimiRegion::International); + assert_eq!(region.code_api_base_url(), "https://api.kimi.ai"); + assert_eq!(region.web_base_url(), "https://www.kimi.ai"); + assert_eq!(region.cookie_domains(), &["www.kimi.ai", "kimi.ai"][..]); + } +} diff --git a/rust/src/cli/guard.rs b/rust/src/cli/guard.rs index b9ecf5dce8..da5e9f138b 100644 --- a/rust/src/cli/guard.rs +++ b/rust/src/cli/guard.rs @@ -200,7 +200,7 @@ pub fn parse_timeout_secs(value: f64) -> Result { pub fn resolve_guard_provider(raw: &str) -> Result { match ProviderSelection::from_arg(Some(raw)) { Ok(ProviderSelection::Single(id)) => Ok(id), - Ok(ProviderSelection::Both | ProviderSelection::All) => { + Ok(ProviderSelection::Both | ProviderSelection::All | ProviderSelection::Custom(_)) => { Err("guard requires exactly one --provider.".to_string()) } Err(e) => Err(e.to_string()), @@ -318,13 +318,17 @@ async fn fetch_guard_outcome( manual_cookie_header: None, manual_cookie_missing: false, api_key: None, + token_account_kind: None, + token_account_isolated: false, workspace_id: None, seat_credit_entitlement: None, api_region: None, gateway_url: None, auto_prefer_web: false, + browser_cookie_import: false, // Guard checks keep the short optional-join grace. requires_optional_usage_completeness: false, + optional_details_enabled: false, }; match provider.fetch_usage(&ctx).await { diff --git a/rust/src/cli/hooks.rs b/rust/src/cli/hooks.rs index 2f26498cf1..ea705e022a 100644 --- a/rust/src/cli/hooks.rs +++ b/rust/src/cli/hooks.rs @@ -296,13 +296,17 @@ async fn hooks_watch_observation( manual_cookie_header: None, manual_cookie_missing: false, api_key: None, + token_account_kind: None, + token_account_isolated: false, workspace_id: (!workspace.is_empty()).then(|| workspace.to_string()), seat_credit_entitlement: settings.seat_credit_entitlement(provider_id), api_region: (!region.is_empty()).then(|| region.to_string()), gateway_url: (!gateway.is_empty()).then(|| gateway.to_string()), auto_prefer_web: false, + browser_cookie_import: false, // Hook watches keep the short optional-join grace. requires_optional_usage_completeness: false, + optional_details_enabled: settings.optional_details_enabled(provider_id), }; if ctx.api_key.is_none() { @@ -397,7 +401,8 @@ fn hook_refresh_failure_status(error: &ProviderError) -> String { | ProviderError::NoCookies | ProviderError::OAuth(_) | ProviderError::OAuthExpired(_) - | ProviderError::OAuthRevoked(_) => "auth_required".into(), + | ProviderError::OAuthRevoked(_) + | ProviderError::BrowserSignInRequired { .. } => "auth_required".into(), ProviderError::OAuthTransient(_) => "rate_limited".into(), ProviderError::Timeout => "timeout".into(), ProviderError::Network(err) => { @@ -409,6 +414,7 @@ fn hook_refresh_failure_status(error: &ProviderError) -> String { "network_error".into() } } + ProviderError::OwnedTransport { source, .. } => hook_refresh_failure_status(source), ProviderError::NotInstalled(_) => "error".into(), ProviderError::Parse(_) | ProviderError::UnsupportedSource(_) | ProviderError::Other(_) => { "error".into() @@ -657,6 +663,15 @@ fn print_json(value: &T, pretty: bool) -> anyhow::Result<()> { mod tests { use super::*; + #[test] + fn browser_sign_in_failures_report_auth_required() { + let error = ProviderError::BrowserSignInRequired { + message: "Sign in at the provider page in your browser.".to_string(), + sign_in_url: "https://example.test/login".to_string(), + }; + assert_eq!(hook_refresh_failure_status(&error), "auth_required"); + } + #[test] fn parses_event_names() { assert!(matches!( diff --git a/rust/src/cli/mod.rs b/rust/src/cli/mod.rs index 497581222c..fd26db9719 100755 --- a/rust/src/cli/mod.rs +++ b/rust/src/cli/mod.rs @@ -15,8 +15,11 @@ pub mod account; pub mod autostart; pub mod config; pub mod cost; +mod cost_period; pub mod dashboard; pub mod diagnose; +mod error_kind; +mod fetch_context; pub mod guard; pub mod hooks; pub mod serve; diff --git a/rust/src/cli/serve/dashboard/dashboard.html b/rust/src/cli/serve/dashboard/dashboard.html index 73e117b756..16360f7e59 100644 --- a/rust/src/cli/serve/dashboard/dashboard.html +++ b/rust/src/cli/serve/dashboard/dashboard.html @@ -9,10 +9,12 @@