From 37d29bb3333293c69cae450785b09ee437e5d7ac Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:37:22 +0700 Subject: [PATCH 1/8] Let providers declare card wording: regen countdowns, card-hidden windows, cost titles RateWindow gains reset_regenerates (the card says "Regenerates in 45m", upstream syntheticRegenDetail) and hidden_on_card (a window kept for the tray and float bar only). CostSnapshot gains a card title key (upstream ProviderCostMenuCardStyle) and the detail rows the cost block replaces while it shows (upstream visibleProviderDetails). All four cross the bridge only when set; core serialization is unchanged. Adds RegeneratesCountdown (Mac translations of "Regenerates %@") and CostTitleClawRouterSpend to every locale. --- .../src-tauri/src/auto_resume_tests.rs | 2 + .../src-tauri/src/commands/bridge.rs | 83 ++++++++++++++++ .../src-tauri/src/commands/providers.rs | 2 + .../src/commands/providers/reset_backfill.rs | 2 + apps/desktop-tauri/src-tauri/src/powertoys.rs | 2 + .../src-tauri/src/tray_bridge.rs | 14 +++ .../src-tauri/src/tray_presentation_tests.rs | 10 ++ .../src-tauri/src/usage_metric.rs | 4 + .../src/components/MenuCard.test.tsx | 96 +++++++++++++++++++ .../desktop-tauri/src/components/MenuCard.tsx | 8 +- .../src/components/card/describeCard.ts | 17 +++- apps/desktop-tauri/src/i18n/keys.ts | 2 + apps/desktop-tauri/src/lib/cardCost.test.ts | 33 +++++++ apps/desktop-tauri/src/lib/cardCost.ts | 8 +- .../src/lib/metricRowModel.test.ts | 27 ++++++ apps/desktop-tauri/src/lib/metricRowModel.ts | 20 ++++ apps/desktop-tauri/src/types/bridge.ts | 9 ++ rust/src/core/rate_window.rs | 28 ++++++ rust/src/core/usage_snapshot.rs | 27 ++++++ rust/src/locale.rs | 2 + rust/src/locale/en-US.ftl | 2 + rust/src/locale/es-MX.ftl | 2 + rust/src/locale/ja-JP.ftl | 2 + rust/src/locale/ko-KR.ftl | 2 + rust/src/locale/pt-BR.ftl | 2 + rust/src/locale/ru-RU.ftl | 2 + rust/src/locale/tr-TR.ftl | 2 + rust/src/locale/uk-UA.ftl | 2 + rust/src/locale/zh-CN.ftl | 2 + rust/src/locale/zh-TW.ftl | 2 + rust/src/providers/copilot/api.rs | 2 + 31 files changed, 409 insertions(+), 9 deletions(-) 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 37183b3a7d..63f318d099 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs @@ -45,6 +45,8 @@ mod tests { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index e4a1c82e12..5197ddcc9c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -33,6 +33,12 @@ pub struct RateWindowSnapshot { /// `reset_description` is a detail line (for example spend amounts), not reset wording. #[serde(default)] pub description_is_detail: bool, + /// The reset is the next quota regeneration; the card says "Regenerates in". + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reset_regenerates: bool, + /// The window feeds the tray and float bar only; the card draws no row for it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hidden_on_card: bool, #[serde(default)] pub reserve_percent: Option, #[serde(default)] @@ -72,6 +78,8 @@ impl RateWindowSnapshot { pace: None, monthly_limit_block: None, description_is_detail: rw.description_is_detail, + reset_regenerates: rw.reset_regenerates, + hidden_on_card: rw.hidden_on_card, } } @@ -131,6 +139,12 @@ pub struct CostSnapshotBridge { pub daily: Vec, #[serde(default)] pub always_visible: bool, + /// Provider-owned cost-block title as a locale key name; absent keeps the shared title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title_key: Option, + /// Detail row ids the cost block replaces on the card while it is shown. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub replaced_detail_ids: Vec, } fn default_currency() -> String { @@ -633,6 +647,12 @@ impl ProviderUsageSnapshot { }) .collect(), always_visible: c.always_visible, + title_key: c.card_title_key.map(|key| key.name().to_string()), + replaced_detail_ids: c + .replaced_detail_ids + .iter() + .map(|id| (*id).to_string()) + .collect(), }), plan_name: usage.login_method.clone(), account_email: usage.account_email.clone(), @@ -685,6 +705,8 @@ impl ProviderUsageSnapshot { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, }, primary_label: Some(metadata.session_label.to_string()), secondary: None, @@ -1286,6 +1308,8 @@ mod tests { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, } } @@ -1386,4 +1410,63 @@ mod tests { "{japanese}" ); } + + /// Provider-declared card wording (Synthetic's regen countdown, LithosAI's + /// card-hidden headline, cost titles and replaced rows) reaches the card. + #[test] + fn provider_card_wording_crosses_the_bridge() { + let regen = + RateWindowSnapshot::from_rate_window(&RateWindow::new(10.0).with_regenerating_reset()); + let hidden = RateWindowSnapshot::from_rate_window( + &RateWindow::informational("Balance: $1.00").hidden_on_card(), + ); + let regen_json = serde_json::to_value(®en).unwrap(); + let hidden_json = serde_json::to_value(&hidden).unwrap(); + assert_eq!(regen_json["resetRegenerates"], true); + assert!(regen_json.get("hiddenOnCard").is_none()); + assert_eq!(hidden_json["hiddenOnCard"], true); + assert!(hidden_json.get("resetRegenerates").is_none()); + + let metadata = codexbar::core::instantiate_provider(ProviderId::LithosAI) + .metadata() + .clone(); + let cost = codexbar::core::CostSnapshot::new(0.0, "USD", "Prepaid credits") + .with_balance(4.0) + .with_card_title(codexbar::locale::LocaleKey::CreditsTitle) + .replacing_detail_rows(&["balance"]); + let result = codexbar::core::ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(RateWindow::new(0.0)), + "web", + ) + .with_cost(cost); + let snapshot = ProviderUsageSnapshot::from_fetch_result( + ProviderId::LithosAI, + &metadata, + &result, + None, + None, + ); + let json = serde_json::to_value(&snapshot).unwrap(); + assert_eq!(json["cost"]["titleKey"], "CreditsTitle"); + assert_eq!( + json["cost"]["replacedDetailIds"], + serde_json::json!(["balance"]) + ); + + let plain = codexbar::core::ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(RateWindow::new(0.0)), + "web", + ) + .with_cost(codexbar::core::CostSnapshot::new(1.0, "USD", "This month")); + let json = serde_json::to_value(ProviderUsageSnapshot::from_fetch_result( + ProviderId::LithosAI, + &metadata, + &plain, + None, + None, + )) + .unwrap(); + assert!(json["cost"].get("titleKey").is_none()); + assert!(json["cost"].get("replacedDetailIds").is_none()); + } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 8ca47bde10..dea1f68872 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1691,6 +1691,8 @@ mod reset_backfill_tests { reserve_eta_seconds: None, pace: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, monthly_limit_block: 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 index 29617573b8..6318881282 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs @@ -113,6 +113,8 @@ mod tests { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, } } diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 6194ba3b0d..75bce6f085 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -184,6 +184,8 @@ mod tests { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, reserve_will_last_to_reset: false, } } diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 795ecf80e6..5f4f2b44f5 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -696,6 +696,8 @@ mod tests { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, }, primary_label: None, secondary: secondary_percent.map(|pct| crate::commands::RateWindowSnapshot { @@ -713,6 +715,8 @@ mod tests { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, }), secondary_label: None, model_specific: None, @@ -731,6 +735,8 @@ mod tests { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, }), tertiary_label: None, extra_rate_windows: Vec::new(), @@ -752,6 +758,8 @@ mod tests { account_id: None, daily: Vec::new(), always_visible: false, + title_key: None, + replaced_detail_ids: Vec::new(), }), plan_name: None, account_email: None, @@ -808,6 +816,8 @@ mod tests { reserve_eta_seconds: None, pace: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, monthly_limit_block: None, }, } @@ -1110,6 +1120,8 @@ mod tests { reserve_eta_seconds: None, pace: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, monthly_limit_block: None, }); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -1151,6 +1163,8 @@ mod tests { reserve_eta_seconds: None, pace: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, monthly_limit_block: None, }); snapshot.extra_rate_windows.push(fake_extra_window(90.0)); diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 7927ff8a56..f899c30dbc 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -28,6 +28,8 @@ fn fake_snapshot_with( pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, }; ProviderUsageSnapshot { @@ -59,6 +61,8 @@ fn fake_snapshot_with( account_id: None, daily: Vec::new(), always_visible: false, + title_key: None, + replaced_detail_ids: Vec::new(), }), plan_name: None, account_email: None, @@ -398,6 +402,8 @@ fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, }, } } @@ -622,6 +628,8 @@ fn claude_automatic_prefers_weekly_when_model_exhausted() { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, }); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -668,6 +676,8 @@ fn automatic_picks_highest_among_model_and_extra_windows() { pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, }); snapshot.extra_rate_windows.push(fake_extra_window(90.0)); diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 099fc45e33..3b20c5aa43 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -404,6 +404,8 @@ fn derived_window(used_percent: f64, resets_at: Option) -> RateWindowSna pace: None, monthly_limit_block: None, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, } } @@ -868,6 +870,8 @@ mod tests { formatted_balance: None, daily: Vec::new(), always_visible: false, + title_key: None, + replaced_detail_ids: Vec::new(), }); let settings = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index be9976b217..fb8504209d 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -1345,6 +1345,102 @@ describe("MenuCard", () => { expect(container.querySelector(".card-extra")).toBeNull(); }); + describe("LithosAI shows its balance once", () => { + function lithos(balance: number | null, shown: string): ProviderUsageSnapshot { + const snapshot = provider(null, 0, { resetDescription: `Balance: ${shown}` }); + snapshot.providerId = "lithosai"; + snapshot.displayName = "LithosAI"; + snapshot.primary = { ...snapshot.primary, isInformational: true, hiddenOnCard: true }; + snapshot.cost = + balance === null + ? null + : { + used: 0, + limit: null, + remaining: null, + currencyCode: "USD", + period: "Prepaid credits", + resetsAt: null, + formattedUsed: "$0.00", + formattedLimit: null, + balance, + formattedBalance: shown, + titleKey: "CreditsTitle", + replacedDetailIds: ["balance"], + }; + snapshot.displayDetails = [ + { id: "balance", sectionTitle: "Billing", title: "Balance", value: shown, secondaryValue: null, progress: null }, + { id: "payment-card", sectionTitle: "Billing", title: "Payment card", value: "Added", secondaryValue: null, progress: null }, + ]; + return snapshot; + } + + beforeEach(() => { + tauriMocks.getLocaleStrings.mockResolvedValue( + buildBundle({ CreditsTitle: "Credits", ExtraUsage: "Extra usage", DetailCostBalance: "Balance" }), + ); + }); + + it("in the Credits block, without the headline bar or the Billing row", async () => { + const { container } = renderCard(lithos(42.37, "$42.37")); + + expect(await screen.findByText("Credits")).toHaveClass("card-extra__title"); + expect(screen.getAllByText(/42\.37/)).toHaveLength(1); + // The headline balance stays off the card (tray and float bar only). + expect(container.querySelector(".menu-metric")).toBeNull(); + expect(container.querySelector(".card-extra__inline-value")).toHaveTextContent( + "Balance: $42.37", + ); + expect(screen.getByText("Payment card")).toBeInTheDocument(); + expect(screen.queryByText("Extra usage")).not.toBeInTheDocument(); + }); + + it("in the Billing row when the cost summary is hidden", async () => { + const { container } = renderCard(lithos(42.37, "$42.37"), { costSummaryDisplayStyle: "hidden" }); + + expect(await screen.findByText("$42.37")).toBeInTheDocument(); + expect(screen.getAllByText(/42\.37/)).toHaveLength(1); + // The headline balance stays off the card (tray and float bar only). + expect(container.querySelector(".menu-metric")).toBeNull(); + expect(container.querySelector(".card-extra")).toBeNull(); + }); + + it("in the Billing row for a debt, which has no cost block", async () => { + const { container } = renderCard(lithos(null, "-$1.00")); + + expect(await screen.findByText("-$1.00")).toBeInTheDocument(); + expect(screen.getAllByText(/1\.00/)).toHaveLength(1); + // The headline balance stays off the card (tray and float bar only). + expect(container.querySelector(".menu-metric")).toBeNull(); + expect(container.querySelector(".card-extra")).toBeNull(); + }); + }); + + it("titles ClawRouter's capped cost block Monthly budget", async () => { + tauriMocks.getLocaleStrings.mockResolvedValue( + buildBundle({ ProviderTextMonthlyBudget: "Monthly budget", ExtraUsage: "Extra usage" }), + ); + const snapshot = provider(null, 37); + snapshot.providerId = "clawrouter"; + snapshot.displayName = "ClawRouter"; + snapshot.cost = { + used: 18.42, + limit: 50, + remaining: 31.58, + currencyCode: "USD", + period: "This month", + resetsAt: null, + formattedUsed: "$18.42", + formattedLimit: "$50.00", + titleKey: "ProviderTextMonthlyBudget", + }; + + renderCard(snapshot); + + expect(await screen.findByText("Monthly budget")).toHaveClass("card-extra__title"); + expect(screen.queryByText("Extra usage")).not.toBeInTheDocument(); + }); + it("localizes the relative updated-at time in Japanese without duplicated prefix", async () => { tauriMocks.getLocaleStrings.mockResolvedValue( buildBundle({ diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 01fb2edb3b..f25caffa37 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -126,7 +126,11 @@ function cardMetrics( resetFormatMode: extra.id === "reset-credits" ? "expires" : "reset", }); } - return metrics.filter((metric) => isUsageItemVisible(provider.hiddenUsageItemIds, metric.id)); + // A window the provider keeps for the tray and float bar only (upstream LithosAI has no card bar). + return metrics.filter( + (metric) => + !metric.snap.hiddenOnCard && isUsageItemVisible(provider.hiddenUsageItemIds, metric.id), + ); } /** @@ -281,7 +285,7 @@ export default function MenuCard({ )} {showDetailSections && ( 0 || cost.alwaysVisible === true) { return false; } @@ -43,19 +48,23 @@ export function describeCard( ): MenuCardPresence { const hasError = !!provider.error; const hasMetrics = !hasError && visibleMetrics.length > 0; - const hasDetailSections = - !hasError && - ((provider.inventory?.length ?? 0) > 0 || (provider.displayDetails?.length ?? 0) > 0); const hasCost = !hasError && !!provider.cost && (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true) && !balanceRepeatsDetail(provider); + // Upstream `visibleProviderDetails`: replaced rows drop only while the cost block shows. + const replaced = hasCost && !compactOverview ? provider.cost?.replacedDetailIds ?? [] : []; + const details = (provider.displayDetails ?? []).filter( + (detail) => !replaced.includes(detail.id), + ); + const hasDetailSections = + !hasError && ((provider.inventory?.length ?? 0) > 0 || details.length > 0); const localUsage = hasError ? null : chartData?.localUsage ?? null; const wayfinderUsage = !hasError && provider.providerId === "wayfinder" ? provider.wayfinderUsage ?? null : null; const hasDetails = compactOverview ? hasMetrics || !!wayfinderUsage : hasMetrics || hasDetailSections || hasCost || !!localUsage || !!wayfinderUsage; - return { hasMetrics, hasDetailSections, hasCost, localUsage, wayfinderUsage, hasDetails }; + return { hasMetrics, hasDetailSections, details, hasCost, localUsage, wayfinderUsage, hasDetails }; } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index c105263f19..a38ef29f1f 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -1211,6 +1211,8 @@ export const ALL_LOCALE_KEYS = [ "ResetsAtLabel", "ResetsInHoursOnly", "ResetsInDaysOnly", + "RegeneratesCountdown", + "CostTitleClawRouterSpend", "ResetsAtTime", "ProviderTextRequests", "ProviderTextTokens", diff --git a/apps/desktop-tauri/src/lib/cardCost.test.ts b/apps/desktop-tauri/src/lib/cardCost.test.ts index 3547ce481c..e84e83ef45 100644 --- a/apps/desktop-tauri/src/lib/cardCost.test.ts +++ b/apps/desktop-tauri/src/lib/cardCost.test.ts @@ -15,6 +15,9 @@ const STRINGS: Partial> = { PanelToday: "Today", ProviderLabelMonthly: "Monthly", WindowLabelMonthlyCredits: "Monthly credits", + CreditsTitle: "Credits", + ProviderTextMonthlyBudget: "Monthly budget", + CostTitleClawRouterSpend: "ClawRouter spend", }; const t = (key: LocaleKey) => STRINGS[key] ?? key; @@ -172,4 +175,34 @@ describe("cardCostSection", () => { expect(section?.spendLine).toBe("Monthly: €2.00"); expect(section?.inline).toBe(true); }); + + // Upstream `ProviderCostMenuCardStyle`: the provider names its block. + it("uses the provider's card title for capped, inline and balance blocks", () => { + const budget = cardCostSection( + cost({ used: 18.42, limit: 50, period: "This month", titleKey: "ProviderTextMonthlyBudget" }), + "detailed", + t, + "clawrouter", + ); + expect(budget?.title).toBe("Monthly budget"); + expect(budget?.spendLine).toBe("This month: $18.42 / $50.00"); + + const spend = cardCostSection( + cost({ used: 18.42, period: "This month", titleKey: "CostTitleClawRouterSpend" }), + "detailed", + t, + "clawrouter", + ); + expect(spend?.title).toBe("ClawRouter spend"); + expect(spend?.spendLine).toBe("This month: $18.42"); + + const credits = cardCostSection( + cost({ used: 0, balance: 42.37, period: "Prepaid credits", titleKey: "CreditsTitle" }), + "detailed", + t, + "lithosai", + ); + expect(credits?.title).toBe("Credits"); + expect(credits?.spendLine).toBe("Balance: $42.37"); + }); }); diff --git a/apps/desktop-tauri/src/lib/cardCost.ts b/apps/desktop-tauri/src/lib/cardCost.ts index 8a443be4f3..9c8b148cc2 100644 --- a/apps/desktop-tauri/src/lib/cardCost.ts +++ b/apps/desktop-tauri/src/lib/cardCost.ts @@ -91,11 +91,13 @@ export function cardCostSection( const balanceText = (value: number) => `${t("DetailCostBalance")}: ${money(value, cost.formattedBalance)}`; const limit = cost.limit ?? 0; + // A provider-declared title (upstream `ProviderCostMenuCardStyle`) wins. + const title = (fallback: LocaleKey) => t(cost.titleKey ?? fallback); if (limit > 0) { const percentUsed = clampPercent((cost.used / limit) * 100); return { - title: isQuota ? t("QuotaUsageTitle") : t("ExtraUsage"), + title: title(isQuota ? "QuotaUsageTitle" : "ExtraUsage"), spendLine: `${periodLabel(cost.period, t("CostPeriodThisMonth"), t)}: ${money(cost.used, cost.formattedUsed)} / ${money(limit, cost.formattedLimit)}`, percentUsed, percentLine: t("UsedPercent").replace(/\{[^}]*\}/, percentUsed.toFixed(0)), @@ -107,7 +109,7 @@ export function cardCostSection( if (cost.alwaysVisible === true) { return { - title: t("ApiSpendTitle"), + title: title("ApiSpendTitle"), spendLine: `${periodLabel(cost.period, t("CostPeriodLastDays").replace("{}", "30"), t)}: ${money(cost.used, cost.formattedUsed)}`, percentUsed: null, percentLine: null, @@ -117,7 +119,7 @@ export function cardCostSection( } const inline = (spendLine: string): CardCostSection => ({ - title: t("ExtraUsage"), + title: title("ExtraUsage"), spendLine, percentUsed: null, percentLine: null, diff --git a/apps/desktop-tauri/src/lib/metricRowModel.test.ts b/apps/desktop-tauri/src/lib/metricRowModel.test.ts index 2903090795..c90d46292e 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.test.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.test.ts @@ -108,6 +108,33 @@ describe("metricResetText", () => { ).toBeNull(); expect(metricResetText(rateWindow({ resetsAt: "not a date" }), true, NOW, en)).toBeNull(); }); + + // Upstream `syntheticRegenDetail`: "Regenerates %@" around the unlocalized + // `resetCountdownDescription`, relative even under the absolute reset style. + it.each([ + [45 * MINUTE, true, "Regenerates in 45m"], + [45 * MINUTE, false, "Regenerates in 45m"], + [2 * HOUR + 5 * MINUTE, true, "Regenerates in 2h 5m"], + [DAY + 3 * HOUR, true, "Regenerates in 1d 3h"], + [2 * DAY, true, "Regenerates in 2d"], + [500, true, "Regenerates now"], + ])("counts a regen lane %d ms down to %j (relative %s)", (ms, relative, expected) => { + const snap = rateWindow({ + resetsAt: new Date(NOW + ms).toISOString(), + resetDescription: "76% after next regen · Full in ~3 regens", + descriptionIsDetail: true, + resetRegenerates: true, + }); + expect(metricResetText(snap, relative, NOW, en)).toBe(expected); + }); + + it("uses the Mac translation of Regenerates around the English countdown", () => { + const snap = rateWindow({ + resetsAt: new Date(NOW + 45 * MINUTE).toISOString(), + resetRegenerates: true, + }); + expect(metricResetText(snap, true, NOW, fromFtl("ja-JP.ftl"))).toBe("in 45m に再生成"); + }); }); describe("metricRowPresentation percent and reset", () => { diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index e017279a45..955dacf646 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -181,6 +181,23 @@ function absoluteResetDescription(target: Date, now: Date, t: Translate): string }).format(target); } +/** + * Upstream `UsageFormatter.resetCountdownDescription`: the unlocalized "in 2d 3h" + * countdown that `syntheticRegenDetail` puts inside "Regenerates %@", always relative. + */ +function regenCountdown(seconds: number): string { + const parts = countdownParts(seconds); + if (!parts) return "now"; + const { days, hours, minutes } = parts; + if (days > 0) { + if (hours > 0) return `in ${days}d ${hours}h`; + if (minutes > 0) return `in ${days}d ${minutes}m`; + return `in ${days}d`; + } + if (hours > 0) return minutes > 0 ? `in ${hours}h ${minutes}m` : `in ${hours}h`; + return `in ${minutes}m`; +} + export function metricResetText( snap: RateWindowSnapshot, relative: boolean, @@ -191,6 +208,9 @@ export function metricResetText( if (!Number.isFinite(target)) { return normalizeResetDescription(resetDescriptionFallback(snap), t); } + if (snap.resetRegenerates) { + return fill(t("RegeneratesCountdown"), regenCountdown((target - now) / 1000)); + } if (!relative) { return fill(t("ResetsAtLabel"), absoluteResetDescription(new Date(target), new Date(now), t)); } diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 6d35c423a4..d13ef31be4 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -1,3 +1,4 @@ +import type { LocaleKey } from "../i18n/keys"; export type SurfaceMode = "hidden" | "trayPanel" | "popOut" | "settings"; export type VisibleSurfaceMode = Exclude; export type SettingsTabId = @@ -675,6 +676,10 @@ export interface RateWindowSnapshot { isInformational?: boolean; /** `resetDescription` is a detail line (for example spend amounts), not reset wording. */ descriptionIsDetail?: boolean; + /** The reset is the next quota regeneration: the card says "Regenerates in" (Synthetic). */ + resetRegenerates?: boolean; + /** The window feeds the tray and float bar only; the card draws no row for it. */ + hiddenOnCard?: boolean; reservePercent: number | null; reserveDescription: string | null; reserveWillLastToReset?: boolean; @@ -710,6 +715,10 @@ export interface CostSnapshotBridge { daily?: CostDailyPoint[]; /** Provider-metered spend that is itself a primary usage signal. */ alwaysVisible?: boolean; + /** Provider-owned cost-block title (upstream `ProviderCostMenuCardStyle`); absent keeps the shared title. */ + titleKey?: LocaleKey; + /** Detail row ids the cost block replaces on the card while it is shown (upstream `replacedDetailRows`). */ + replacedDetailIds?: string[]; } export type PaceStage = diff --git a/rust/src/core/rate_window.rs b/rust/src/core/rate_window.rs index 70ecab7b41..78261a202f 100755 --- a/rust/src/core/rate_window.rs +++ b/rust/src/core/rate_window.rs @@ -99,6 +99,18 @@ pub struct RateWindow { #[serde(default, skip_serializing)] pub description_is_detail: bool, + /// The card counts down to the next quota regeneration ("Regenerates in + /// 45m", upstream `syntheticRegenDetail`) instead of a reset. + /// Provider-owned; kept out of serialized output so CLI JSON is unchanged. + #[serde(default, skip_serializing)] + pub reset_regenerates: bool, + + /// Not drawn as a card row: the window only carries a value for the tray + /// and float bar, and the card shows that value elsewhere (a provider whose + /// upstream snapshot has no window). Provider-owned; not serialized. + #[serde(default, skip_serializing)] + pub hidden_on_card: bool, + /// Whether the provider explicitly supplied the usage percentage. /// Internal-only metadata prevents a missing value normalized to zero from /// becoming an exported quota measurement. @@ -120,6 +132,8 @@ impl RateWindow { reset_description: None, is_informational: false, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, usage_known: true, } } @@ -161,6 +175,8 @@ impl RateWindow { reset_description, is_informational: false, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, usage_known: true, } } @@ -171,6 +187,18 @@ impl RateWindow { self } + /// The reset is the next quota regeneration ("Regenerates in ..."). + pub fn with_regenerating_reset(mut self) -> Self { + self.reset_regenerates = true; + self + } + + /// Keep the window for the tray and float bar but off the card. + pub fn hidden_on_card(mut self) -> Self { + self.hidden_on_card = true; + self + } + pub(crate) fn with_usage_known(mut self, usage_known: bool) -> Self { self.usage_known = usage_known; self diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index b80185a2ec..5106a57b2f 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -518,6 +518,18 @@ pub struct CostSnapshot { /// rendering only; not serialized. #[serde(skip)] pub provenance: Option, + + /// Provider-owned title for the card's cost block (upstream + /// `ProviderCostMenuCardStyle`, for example ClawRouter's "Monthly budget"). + /// `None` keeps the shared title. Presentation only; not serialized. + #[serde(skip)] + pub card_title_key: Option, + + /// Ids of provider detail rows that the card's cost block replaces while + /// it is shown (upstream `ProviderCostPresentation.replacedDetailRows`). + /// Presentation only; not serialized. + #[serde(skip)] + pub replaced_detail_ids: Vec<&'static str>, } impl CostSnapshot { @@ -538,9 +550,24 @@ impl CostSnapshot { always_visible: false, history_tokens: None, provenance: None, + card_title_key: None, + replaced_detail_ids: Vec::new(), } } + /// Builder pattern: the provider's own title for the card's cost block. + pub fn with_card_title(mut self, key: crate::locale::LocaleKey) -> Self { + self.card_title_key = Some(key); + self + } + + /// Builder pattern: provider detail rows (by id) that the card's cost + /// block stands in for while it is shown. + pub fn replacing_detail_rows(mut self, ids: &[&'static str]) -> Self { + self.replaced_detail_ids = ids.to_vec(); + self + } + /// Builder pattern: set limit pub fn with_limit(mut self, limit: f64) -> Self { self.limit = finite_amount(limit); diff --git a/rust/src/locale.rs b/rust/src/locale.rs index fb2dcf4f74..e9a50c607b 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1498,6 +1498,8 @@ locale_keys! { ResetsAtLabel, ResetsInHoursOnly, ResetsInDaysOnly, + RegeneratesCountdown, + CostTitleClawRouterSpend, ResetsAtTime, // Provider detail text localized at render time ProviderTextRequests, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 14c7fcb0ff..e5d455998f 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1171,6 +1171,8 @@ WindowLabelDays = { "{}" }-day ResetsAtLabel = Resets { "{}" } ResetsInHoursOnly = Resets in { "{}" }h ResetsInDaysOnly = Resets in { "{}" }d +RegeneratesCountdown = Regenerates { "{}" } +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = Resets at { "{}" } ProviderTextRequests = { "{}" } requests ProviderTextTokens = { "{}" } tokens diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 16a11b2c0a..d133bc7618 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1168,6 +1168,8 @@ WindowLabelDays = { "{}" } días ResetsAtLabel = Reinicia { "{}" } ResetsInHoursOnly = Reinicia en { "{}" }h ResetsInDaysOnly = Reinicia en { "{}" }d +RegeneratesCountdown = Se regenera { "{}" } +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = Reinicia a las { "{}" } ProviderTextRequests = { "{}" } solicitudes ProviderTextTokens = { "{}" } tokens diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 6ccdb78f6b..cde8ae50c1 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1169,6 +1169,8 @@ WindowLabelDays = { "{}" } 日間 ResetsAtLabel = リセット { "{}" } ResetsInHoursOnly = リセットまで { "{}" }時間 ResetsInDaysOnly = リセットまで { "{}" }日 +RegeneratesCountdown = { "{}" } に再生成 +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = { "{}" } にリセット ProviderTextRequests = { "{}" } リクエスト ProviderTextTokens = { "{}" } トークン diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 8e0070661a..ac51084ba5 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1168,6 +1168,8 @@ WindowLabelDays = { "{}" }일 ResetsAtLabel = 초기화 { "{}" } ResetsInHoursOnly = 초기화까지 { "{}" }시간 ResetsInDaysOnly = 초기화까지 { "{}" }일 +RegeneratesCountdown = { "{}" }에 재생성 +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = { "{}" }에 초기화 ProviderTextRequests = 요청 { "{}" }회 ProviderTextTokens = { "{}" } 토큰 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 1adb8d5eb3..14e779af5f 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1173,6 +1173,8 @@ WindowLabelDays = { "{}" } dias ResetsAtLabel = Renova { "{}" } ResetsInHoursOnly = Renova em { "{}" }h ResetsInDaysOnly = Renova em { "{}" }d +RegeneratesCountdown = Regenera { "{}" } +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = Renova às { "{}" } ProviderTextRequests = { "{}" } solicitações ProviderTextTokens = { "{}" } tokens diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 3078c01e09..edd83bff2f 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1169,6 +1169,8 @@ WindowLabelDays = { "{}" } дн. ResetsAtLabel = Сброс { "{}" } ResetsInHoursOnly = Сброс через { "{}" } ч ResetsInDaysOnly = Сброс через { "{}" } д +RegeneratesCountdown = Пополняется { "{}" } +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = Сброс в { "{}" } ProviderTextRequests = Запросов: { "{}" } ProviderTextTokens = Токенов: { "{}" } diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index c5377a6640..0d17ecdd91 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1169,6 +1169,8 @@ WindowLabelDays = { "{}" } gün ResetsAtLabel = Sıfırlanma: { "{}" } ResetsInHoursOnly = Sıfırlanmaya { "{}" } sa ResetsInDaysOnly = Sıfırlanmaya { "{}" } g +RegeneratesCountdown = Yenilenme: { "{}" } +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = { "{}" } saatinde sıfırlanır ProviderTextRequests = { "{}" } istek ProviderTextTokens = { "{}" } token diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index c558fcd3c2..54ef48c82c 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1171,6 +1171,8 @@ WindowLabelDays = { "{}" } дн. ResetsAtLabel = Скидання { "{}" } ResetsInHoursOnly = Скинеться через { "{}" } год ResetsInDaysOnly = Скинеться через { "{}" } дн. +RegeneratesCountdown = Регенерує { "{}" } +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = Скинеться о { "{}" } ProviderTextRequests = Запитів: { "{}" } ProviderTextTokens = Токенів: { "{}" } diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 1f1f1ca3fd..d39570917e 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1169,6 +1169,8 @@ WindowLabelDays = { "{}" } 天 ResetsAtLabel = 重置 { "{}" } ResetsInHoursOnly = { "{}" } 小时后重置 ResetsInDaysOnly = { "{}" } 天后重置 +RegeneratesCountdown = { "{}" }后恢复 +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = { "{}" } 重置 ProviderTextRequests = { "{}" } 次请求 ProviderTextTokens = { "{}" } Token diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index a6db6b823e..da8fedee6f 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1169,6 +1169,8 @@ WindowLabelDays = { "{}" } 天 ResetsAtLabel = 重置 { "{}" } ResetsInHoursOnly = { "{}" } 小時後重置 ResetsInDaysOnly = { "{}" } 天後重置 +RegeneratesCountdown = { "{}" }後恢復 +CostTitleClawRouterSpend = ClawRouter spend ResetsAtTime = { "{}" } 重置 ProviderTextRequests = { "{}" } 次請求 ProviderTextTokens = { "{}" } Token diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index 9b97b0d7b5..72b14e7a1f 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -497,6 +497,8 @@ impl UsableQuota { reset_description, is_informational: false, description_is_detail: false, + reset_regenerates: false, + hidden_on_card: false, usage_known: true, } } From 1a64de0406e11423b0c60f962ef229a252fd1e4e Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:37:28 +0700 Subject: [PATCH 2/8] Count Synthetic's regen lanes down as Regenerates, as on the Mac Upstream syntheticRegenDetail / syntheticRollingRegenDetail (MenuCardView+ModelHelpers.swift) show "Regenerates in 45m" on the five-hour and weekly regen lanes; plain lanes keep "Resets in". --- rust/src/providers/synthetic/mod.rs | 2 ++ rust/src/providers/synthetic/tests.rs | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/rust/src/providers/synthetic/mod.rs b/rust/src/providers/synthetic/mod.rs index fbe8ff76ac..52b857dc9a 100644 --- a/rust/src/providers/synthetic/mod.rs +++ b/rust/src/providers/synthetic/mod.rs @@ -663,6 +663,8 @@ impl Quota { )), ) .with_description_as_detail() + // Upstream `syntheticRegenDetail`: the card counts down to the regen. + .with_regenerating_reset() } /// Five-hour lane: regen line when the payload has a tick percent. diff --git a/rust/src/providers/synthetic/tests.rs b/rust/src/providers/synthetic/tests.rs index aa23df80de..6baa168c12 100644 --- a/rust/src/providers/synthetic/tests.rs +++ b/rust/src/providers/synthetic/tests.rs @@ -79,6 +79,8 @@ fn pack_result_matches_the_mac_card() { Some("76% after next regen · Full in ~3 regens") ); assert!(usage.primary.description_is_detail); + // The card says "Regenerates in 45m", not "Resets in 45m". + assert!(usage.primary.reset_regenerates); // No window length, so the card shows no pace line on the regen lane. assert_eq!(usage.primary.window_minutes, None); @@ -88,6 +90,7 @@ fn pack_result_matches_the_mac_card() { assert_eq!(weekly.window_minutes, Some(10_080)); assert_eq!(weekly.resets_at, at(NOW + 388_800)); assert!(!weekly.description_is_detail); + assert!(!weekly.reset_regenerates); // "Search hourly 76% left", "Resets in 25m". assert_eq!(usage.extra_rate_windows.len(), 1); @@ -96,6 +99,7 @@ fn pack_result_matches_the_mac_card() { assert_eq!(search.title, "Search hourly"); assert_eq!(search.window.remaining_percent().round(), 76.0); assert_eq!(search.window.resets_at, at(NOW + 1500)); + assert!(!search.window.reset_regenerates); assert!(usage.tertiary.is_none()); assert_eq!(usage.login_method.as_deref(), Some("Synthetic Standard")); @@ -243,6 +247,7 @@ fn weekly_cost_with_next_regen_amount_drives_the_weekly_regen_line() { Some("65% after next regen · Full in ~8 regens") ); assert!(weekly.description_is_detail); + assert!(weekly.reset_regenerates); assert_eq!(weekly.window_minutes, None); assert_eq!(result.usage.login_method.as_deref(), Some("Pro")); // The Mac card hides Synthetic's cost block, so the credits never reach From 0d5d4a7134c34cb047acd64dfc00f2cc2a5bfaa0 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:37:28 +0700 Subject: [PATCH 3/8] Title ClawRouter's cost block Monthly budget or ClawRouter spend Upstream ProviderCostMenuCardStyle.clawRouter (MenuCardView+Costs.swift v0.73.0:592-608): "Monthly budget" with a positive limit, else "ClawRouter spend". --- rust/src/providers/clawrouter/model.rs | 12 ++++++++++++ rust/src/providers/clawrouter/tests.rs | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/rust/src/providers/clawrouter/model.rs b/rust/src/providers/clawrouter/model.rs index db292a7fbe..6a820ed57f 100644 --- a/rust/src/providers/clawrouter/model.rs +++ b/rust/src/providers/clawrouter/model.rs @@ -9,6 +9,7 @@ use crate::core::{ CostSnapshot, ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot, }; +use crate::locale::LocaleKey; /// Upstream shows at most 20 routed-provider rows. const MAX_PROVIDER_ROWS: usize = 20; @@ -241,6 +242,8 @@ pub(super) fn build_result(usage: &ClawRouterUsage) -> ProviderFetchResult { None => None, }; if let Some(mut cost) = cost { + let title = cost_card_title(cost.limit); + cost = cost.with_card_title(title); if let Some(resets_at) = budget.resets_at { cost = cost.with_resets_at(resets_at); } @@ -252,6 +255,15 @@ pub(super) fn build_result(usage: &ClawRouterUsage) -> ProviderFetchResult { result } +/// Upstream `ProviderCostMenuCardStyle.clawRouter` (`MenuCardView+Costs.swift`): +/// "Monthly budget" when a positive limit is reported, else "ClawRouter spend". +fn cost_card_title(limit: Option) -> LocaleKey { + match limit { + Some(limit) if limit > 0.0 => LocaleKey::ProviderTextMonthlyBudget, + _ => LocaleKey::CostTitleClawRouterSpend, + } +} + fn usage_row(id: &str, title: &str, value: String) -> Option { ProviderDisplayDetail::new(id, title, value)?.with_section_title(USAGE_SECTION) } diff --git a/rust/src/providers/clawrouter/tests.rs b/rust/src/providers/clawrouter/tests.rs index 83823031da..e296e8f854 100644 --- a/rust/src/providers/clawrouter/tests.rs +++ b/rust/src/providers/clawrouter/tests.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use super::model::{build_result, monthly_reset, parse_usage}; use super::*; +use crate::locale::LocaleKey; /// The parity pack payload (`scenarios/providers/ClawRouter/routes.json`) /// with `{{month:+0}}` resolved to `window_key`. @@ -106,6 +107,11 @@ fn pack_result_matches_the_mac_card() { assert_eq!(cost.currency_code, "USD"); assert_eq!(cost.period, "This month"); assert_eq!(cost.resets_at, Some(reset)); + // Upstream `.clawRouter` with a positive limit titles the block "Monthly budget". + assert_eq!( + cost.card_title_key, + Some(LocaleKey::ProviderTextMonthlyBudget) + ); assert_eq!( rows(&result), @@ -231,6 +237,10 @@ fn unmetered_policy_reports_actual_cost_without_a_limit() { let cost = result.cost.as_ref().expect("actual cost"); assert!((cost.used - 18.42).abs() < 1e-9); assert_eq!(cost.limit, None); + assert_eq!( + cost.card_title_key, + Some(LocaleKey::CostTitleClawRouterSpend) + ); assert_eq!( cost.resets_at, Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).single() @@ -263,6 +273,11 @@ fn zero_limit_keeps_the_cost_but_drops_the_meter() { Some("No monthly limit reported") ); assert_eq!(result.cost.as_ref().and_then(|cost| cost.limit), Some(0.0)); + // Upstream `.clawRouter` with `limit <= 0` is "ClawRouter spend". + assert_eq!( + result.cost.as_ref().and_then(|cost| cost.card_title_key), + Some(LocaleKey::CostTitleClawRouterSpend) + ); assert!(rows(&result).contains(&row("Usage", "Monthly budget", "$18.420000 / $0.00", None))); } From e3b759c0e976ca8ad2acb503847031fb216a34e9 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:37:28 +0700 Subject: [PATCH 4/8] Show LithosAI's balance once, in the Credits block Upstream LithosAI (v0.73.0) is balance-only with a .prepaidCredits cost titled Credits and no usage window. The card now drops the Balance: headline row (kept for the tray and float bar) and the Billing Balance row while the Credits block shows. A debt or the hidden cost style keeps the Billing row instead, so the balance appears exactly once. --- rust/src/providers/lithosai/model.rs | 25 ++++++++++++++++++++----- rust/src/providers/lithosai/tests.rs | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/rust/src/providers/lithosai/model.rs b/rust/src/providers/lithosai/model.rs index 592101e3f2..1825b6d918 100644 --- a/rust/src/providers/lithosai/model.rs +++ b/rust/src/providers/lithosai/model.rs @@ -10,6 +10,7 @@ use crate::core::{ CostSnapshot, ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot, }; +use crate::locale::LocaleKey; use crate::providers::format; /// `Number.MAX_SAFE_INTEGER`. @@ -203,6 +204,9 @@ fn row(id: &str, title: &str, value: impl Into) -> Option ProviderFetchResult { let balance = usd(billing.balance_nanos); let balance_text = money(balance); - let mut usage = UsageSnapshot::new(RateWindow::informational(format!( - "Balance: {balance_text}" - ))) + // Upstream's snapshot has no window (lithosai.ts:71-79). The primary + // carries the balance for the tray and float bar only; the card shows it + // in the Credits block or the Billing row. + let mut usage = UsageSnapshot::new( + RateWindow::informational(format!("Balance: {balance_text}")).hidden_on_card(), + ) .with_login_method("Browser session"); if let Some(email) = &account.email { usage = usage.with_email(email.clone()); @@ -226,7 +233,7 @@ pub(super) fn result( } let mut rows = vec![ - row("balance", "Balance", balance_text), + row(BALANCE_ROW_ID, "Balance", balance_text), row( "payment-card", "Payment card", @@ -263,7 +270,15 @@ pub(super) fn result( // as "$0.00". Upstream keeps negative balances visible, so a debt is left // to the primary line and the Balance row. let result = if billing.balance_nanos >= 0 { - result.with_cost(CostSnapshot::new(0.0, "USD", "Prepaid credits").with_balance(balance)) + // Upstream `.prepaidCredits` (LithosAIProviderDescriptor.swift:16-19): + // a "Credits" block. While it is shown it stands in for the Billing + // "Balance" row, so the card shows the balance once. + result.with_cost( + CostSnapshot::new(0.0, "USD", "Prepaid credits") + .with_balance(balance) + .with_card_title(LocaleKey::CreditsTitle) + .replacing_detail_rows(&[BALANCE_ROW_ID]), + ) } else { result }; diff --git a/rust/src/providers/lithosai/tests.rs b/rust/src/providers/lithosai/tests.rs index 2032c910d1..ab3d571162 100644 --- a/rust/src/providers/lithosai/tests.rs +++ b/rust/src/providers/lithosai/tests.rs @@ -5,7 +5,9 @@ use std::thread::JoinHandle; use chrono::{TimeZone, Utc}; use reqwest::redirect::Policy; +use super::model::BALANCE_ROW_ID; use super::*; +use crate::locale::LocaleKey; const ME: &str = r#"{"user":{"email":"person@example.test"},"activeOrganization":{"id":"org-fixture","name":"Synthetic Organization"}}"#; const EMPTY_SPEND: &str = r#"{"start":"2027-01-01","end":"2027-01-21","days":[]}"#; @@ -129,8 +131,14 @@ async fn console_balance_and_sparse_spend_match_the_upstream_fixture() { assert_eq!(cost.limit, None); assert_eq!(cost.currency_code, "USD"); assert_eq!(cost.period, "Prepaid credits"); + // Upstream `.prepaidCredits` titles the block "Credits", and the card shows + // the balance once: in that block, not again as the Billing row or a bar. + assert_eq!(cost.card_title_key, Some(LocaleKey::CreditsTitle)); + assert_eq!(cost.replaced_detail_ids, [BALANCE_ROW_ID]); + assert_eq!(result.display_details[0].id(), BALANCE_ROW_ID); let usage = &result.usage; assert!(usage.primary.is_informational); + assert!(usage.primary.hidden_on_card); assert_eq!( usage.primary.reset_description.as_deref(), Some("Balance: $4.71") @@ -206,10 +214,20 @@ async fn zero_debt_and_subcent_balances_are_not_invented_quotas() { "{balance}" ); assert!(result.usage.primary.is_informational); + assert!(result.usage.primary.hidden_on_card, "{balance}"); assert_eq!( result.usage.primary.reset_description.as_deref(), Some(format!("Balance: {shown}").as_str()) ); + // A debt has no cost block, so the Billing row is where the card shows it. + assert_eq!( + result + .cost + .as_ref() + .map(|cost| cost.replaced_detail_ids.clone()), + cost_balance.map(|_| vec![BALANCE_ROW_ID]), + "{balance}" + ); let rows = rows(&result); assert_eq!(rows[0], ("Balance".to_owned(), shown.to_owned())); assert_eq!(rows[3], ("Today (UTC)".to_owned(), "$0.00".to_owned())); From e92d5a3342ad760a0feaace9c2034395822cde16 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:52:20 +0700 Subject: [PATCH 5/8] Box Muse's session reading so clippy's enum-size check passes with the larger rate window --- rust/src/providers/muse/web_quota/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rust/src/providers/muse/web_quota/mod.rs b/rust/src/providers/muse/web_quota/mod.rs index c5e50cedf2..5b88d9abe3 100644 --- a/rust/src/providers/muse/web_quota/mod.rs +++ b/rust/src/providers/muse/web_quota/mod.rs @@ -435,7 +435,7 @@ async fn read_team_quota( }; match read_session(&mut client, identity, team_id, now).await { Ok(SessionOutcome::Next) => {} - Ok(SessionOutcome::Done(reading)) => return reading, + Ok(SessionOutcome::Done(reading)) => return reading.map(|reading| *reading), Err(Abort) => { tracing::debug!("Muse browser team quota read stopped"); return None; @@ -448,7 +448,7 @@ async fn read_team_quota( enum SessionOutcome { /// This session was rejected or belongs to another account. Next, - Done(Option), + Done(Option>), } async fn read_session( @@ -484,11 +484,11 @@ async fn read_session( .collect(); let done = |teams: Vec, note: Option, quota: Option| { - Ok(SessionOutcome::Done(Some(WebReading { + Ok(SessionOutcome::Done(Some(Box::new(WebReading { teams, note, quota, - }))) + })))) }; if team_id.is_empty() { return done(teams, Some(TeamNote::ChooseTeam), None); From eaeeedb5ab4008b2fdb2856dbb7b44617fe96167 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:19:44 +0700 Subject: [PATCH 6/8] Keep LithosAI's balance line on the compact Overview --- .../desktop-tauri/src/components/MenuCard.test.tsx | 14 ++++++++++++++ apps/desktop-tauri/src/components/MenuCard.tsx | 8 ++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index fb8504209d..e38e6a4faa 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -1395,6 +1395,20 @@ describe("MenuCard", () => { expect(screen.queryByText("Extra usage")).not.toBeInTheDocument(); }); + it("on the compact Overview line, which drops the Credits block and Billing rows", async () => { + tauriMocks.getLocaleStrings.mockResolvedValue( + buildBundle({ ProviderSessionLabel: "Session", ProviderTextBalanceColonPrefix: "Balance: {}" }), + ); + const { container } = renderCard(lithos(42.37, "$42.37"), { compactOverview: true }); + + expect(await screen.findByText("Balance: $42.37")).toHaveClass("menu-metric__pct"); + expect(screen.getAllByText(/42\.37/)).toHaveLength(1); + expect(container.querySelectorAll(".menu-metric")).toHaveLength(1); + expect(container.querySelector(".card-extra")).toBeNull(); + expect(screen.queryByText("Payment card")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-card--header-only")).toBeNull(); + }); + it("in the Billing row when the cost summary is hidden", async () => { const { container } = renderCard(lithos(42.37, "$42.37"), { costSummaryDisplayStyle: "hidden" }); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index f25caffa37..c915cc53f1 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -78,6 +78,7 @@ function cardMetrics( provider: ProviderUsageSnapshot, t: ReturnType["t"], language: ReturnType["language"], + compactOverview: boolean, ): MetricEntry[] { const metrics: MetricEntry[] = []; if (provider.providerId !== "wayfinder") { @@ -127,9 +128,12 @@ function cardMetrics( }); } // A window the provider keeps for the tray and float bar only (upstream LithosAI has no card bar). + // The compact Overview drops the detail rows and cost block that carry it on the full card, so + // the window stays there as the card's one line. return metrics.filter( (metric) => - !metric.snap.hiddenOnCard && isUsageItemVisible(provider.hiddenUsageItemIds, metric.id), + (compactOverview || !metric.snap.hiddenOnCard) && + isUsageItemVisible(provider.hiddenUsageItemIds, metric.id), ); } @@ -200,7 +204,7 @@ export default function MenuCard({ provider.providerId === "openaiapi" && hideEmail, ); - const metrics = cardMetrics(provider, t, language); + const metrics = cardMetrics(provider, t, language, compactOverview); // 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( From 51fa6a8c39a402bc06a2d5f478bc108e375228ba Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:20:51 +0700 Subject: [PATCH 7/8] Keep ClawRouter's Monthly budget title in English, as on the Mac --- .../src/components/MenuCard.test.tsx | 4 ++-- apps/desktop-tauri/src/i18n/keys.ts | 1 + apps/desktop-tauri/src/lib/cardCost.test.ts | 4 ++-- rust/src/locale.rs | 1 + rust/src/locale/en-US.ftl | 1 + rust/src/locale/es-MX.ftl | 1 + rust/src/locale/ja-JP.ftl | 1 + rust/src/locale/ko-KR.ftl | 1 + rust/src/locale/pt-BR.ftl | 1 + rust/src/locale/ru-RU.ftl | 1 + rust/src/locale/tests.rs | 19 +++++++++++++++++++ rust/src/locale/tr-TR.ftl | 1 + rust/src/locale/uk-UA.ftl | 1 + rust/src/locale/zh-CN.ftl | 1 + rust/src/locale/zh-TW.ftl | 1 + rust/src/providers/clawrouter/model.rs | 2 +- rust/src/providers/clawrouter/tests.rs | 2 +- 17 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index e38e6a4faa..044eb7ccb7 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -1432,7 +1432,7 @@ describe("MenuCard", () => { it("titles ClawRouter's capped cost block Monthly budget", async () => { tauriMocks.getLocaleStrings.mockResolvedValue( - buildBundle({ ProviderTextMonthlyBudget: "Monthly budget", ExtraUsage: "Extra usage" }), + buildBundle({ CostTitleClawRouterMonthlyBudget: "Monthly budget", ExtraUsage: "Extra usage" }), ); const snapshot = provider(null, 37); snapshot.providerId = "clawrouter"; @@ -1446,7 +1446,7 @@ describe("MenuCard", () => { resetsAt: null, formattedUsed: "$18.42", formattedLimit: "$50.00", - titleKey: "ProviderTextMonthlyBudget", + titleKey: "CostTitleClawRouterMonthlyBudget", }; renderCard(snapshot); diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index a38ef29f1f..8e9a809401 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -1213,6 +1213,7 @@ export const ALL_LOCALE_KEYS = [ "ResetsInDaysOnly", "RegeneratesCountdown", "CostTitleClawRouterSpend", + "CostTitleClawRouterMonthlyBudget", "ResetsAtTime", "ProviderTextRequests", "ProviderTextTokens", diff --git a/apps/desktop-tauri/src/lib/cardCost.test.ts b/apps/desktop-tauri/src/lib/cardCost.test.ts index e84e83ef45..93db91de58 100644 --- a/apps/desktop-tauri/src/lib/cardCost.test.ts +++ b/apps/desktop-tauri/src/lib/cardCost.test.ts @@ -16,7 +16,7 @@ const STRINGS: Partial> = { ProviderLabelMonthly: "Monthly", WindowLabelMonthlyCredits: "Monthly credits", CreditsTitle: "Credits", - ProviderTextMonthlyBudget: "Monthly budget", + CostTitleClawRouterMonthlyBudget: "Monthly budget", CostTitleClawRouterSpend: "ClawRouter spend", }; const t = (key: LocaleKey) => STRINGS[key] ?? key; @@ -179,7 +179,7 @@ describe("cardCostSection", () => { // Upstream `ProviderCostMenuCardStyle`: the provider names its block. it("uses the provider's card title for capped, inline and balance blocks", () => { const budget = cardCostSection( - cost({ used: 18.42, limit: 50, period: "This month", titleKey: "ProviderTextMonthlyBudget" }), + cost({ used: 18.42, limit: 50, period: "This month", titleKey: "CostTitleClawRouterMonthlyBudget" }), "detailed", t, "clawrouter", diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e9a50c607b..e22c18cb88 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1500,6 +1500,7 @@ locale_keys! { ResetsInDaysOnly, RegeneratesCountdown, CostTitleClawRouterSpend, + CostTitleClawRouterMonthlyBudget, ResetsAtTime, // Provider detail text localized at render time ProviderTextRequests, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index e5d455998f..1bfb479dc6 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1173,6 +1173,7 @@ ResetsInHoursOnly = Resets in { "{}" }h ResetsInDaysOnly = Resets in { "{}" }d RegeneratesCountdown = Regenerates { "{}" } CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = Resets at { "{}" } ProviderTextRequests = { "{}" } requests ProviderTextTokens = { "{}" } tokens diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index d133bc7618..248b48b9a5 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1170,6 +1170,7 @@ ResetsInHoursOnly = Reinicia en { "{}" }h ResetsInDaysOnly = Reinicia en { "{}" }d RegeneratesCountdown = Se regenera { "{}" } CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = Reinicia a las { "{}" } ProviderTextRequests = { "{}" } solicitudes ProviderTextTokens = { "{}" } tokens diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index cde8ae50c1..09fb1b3500 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1171,6 +1171,7 @@ ResetsInHoursOnly = リセットまで { "{}" }時間 ResetsInDaysOnly = リセットまで { "{}" }日 RegeneratesCountdown = { "{}" } に再生成 CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = { "{}" } にリセット ProviderTextRequests = { "{}" } リクエスト ProviderTextTokens = { "{}" } トークン diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index ac51084ba5..1af051dcfb 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1170,6 +1170,7 @@ ResetsInHoursOnly = 초기화까지 { "{}" }시간 ResetsInDaysOnly = 초기화까지 { "{}" }일 RegeneratesCountdown = { "{}" }에 재생성 CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = { "{}" }에 초기화 ProviderTextRequests = 요청 { "{}" }회 ProviderTextTokens = { "{}" } 토큰 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 14e779af5f..094dc328a6 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1175,6 +1175,7 @@ ResetsInHoursOnly = Renova em { "{}" }h ResetsInDaysOnly = Renova em { "{}" }d RegeneratesCountdown = Regenera { "{}" } CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = Renova às { "{}" } ProviderTextRequests = { "{}" } solicitações ProviderTextTokens = { "{}" } tokens diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index edd83bff2f..f43638b003 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1171,6 +1171,7 @@ ResetsInHoursOnly = Сброс через { "{}" } ч ResetsInDaysOnly = Сброс через { "{}" } д RegeneratesCountdown = Пополняется { "{}" } CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = Сброс в { "{}" } ProviderTextRequests = Запросов: { "{}" } ProviderTextTokens = Токенов: { "{}" } diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 99bc5aff87..0830665b9d 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -692,3 +692,22 @@ fn avoid_keychain_prompts_helper_says_it_is_not_enforced_on_windows() { ); } } + +#[test] +fn clawrouter_cost_titles_stay_english_in_every_locale_as_on_the_mac() { + // Upstream `MenuCardView+Costs.swift` `.clawRouter` passes both titles as bare + // literals (no `L()`, no Localizable.strings entry), so every locale reads English. + assert_eq!(Language::all().len(), 10); + for &language in Language::all() { + assert_eq!( + get_text(language, LocaleKey::CostTitleClawRouterMonthlyBudget), + "Monthly budget", + "{language:?}" + ); + assert_eq!( + get_text(language, LocaleKey::CostTitleClawRouterSpend), + "ClawRouter spend", + "{language:?}" + ); + } +} diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 0d17ecdd91..78d3638b90 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1171,6 +1171,7 @@ ResetsInHoursOnly = Sıfırlanmaya { "{}" } sa ResetsInDaysOnly = Sıfırlanmaya { "{}" } g RegeneratesCountdown = Yenilenme: { "{}" } CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = { "{}" } saatinde sıfırlanır ProviderTextRequests = { "{}" } istek ProviderTextTokens = { "{}" } token diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 54ef48c82c..919b1edc49 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1173,6 +1173,7 @@ ResetsInHoursOnly = Скинеться через { "{}" } год ResetsInDaysOnly = Скинеться через { "{}" } дн. RegeneratesCountdown = Регенерує { "{}" } CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = Скинеться о { "{}" } ProviderTextRequests = Запитів: { "{}" } ProviderTextTokens = Токенів: { "{}" } diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index d39570917e..bb040de459 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1171,6 +1171,7 @@ ResetsInHoursOnly = { "{}" } 小时后重置 ResetsInDaysOnly = { "{}" } 天后重置 RegeneratesCountdown = { "{}" }后恢复 CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = { "{}" } 重置 ProviderTextRequests = { "{}" } 次请求 ProviderTextTokens = { "{}" } Token diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index da8fedee6f..42b3df0aec 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1171,6 +1171,7 @@ ResetsInHoursOnly = { "{}" } 小時後重置 ResetsInDaysOnly = { "{}" } 天後重置 RegeneratesCountdown = { "{}" }後恢復 CostTitleClawRouterSpend = ClawRouter spend +CostTitleClawRouterMonthlyBudget = Monthly budget ResetsAtTime = { "{}" } 重置 ProviderTextRequests = { "{}" } 次請求 ProviderTextTokens = { "{}" } Token diff --git a/rust/src/providers/clawrouter/model.rs b/rust/src/providers/clawrouter/model.rs index 6a820ed57f..46405929e7 100644 --- a/rust/src/providers/clawrouter/model.rs +++ b/rust/src/providers/clawrouter/model.rs @@ -259,7 +259,7 @@ pub(super) fn build_result(usage: &ClawRouterUsage) -> ProviderFetchResult { /// "Monthly budget" when a positive limit is reported, else "ClawRouter spend". fn cost_card_title(limit: Option) -> LocaleKey { match limit { - Some(limit) if limit > 0.0 => LocaleKey::ProviderTextMonthlyBudget, + Some(limit) if limit > 0.0 => LocaleKey::CostTitleClawRouterMonthlyBudget, _ => LocaleKey::CostTitleClawRouterSpend, } } diff --git a/rust/src/providers/clawrouter/tests.rs b/rust/src/providers/clawrouter/tests.rs index e296e8f854..3c2a5e6c98 100644 --- a/rust/src/providers/clawrouter/tests.rs +++ b/rust/src/providers/clawrouter/tests.rs @@ -110,7 +110,7 @@ fn pack_result_matches_the_mac_card() { // Upstream `.clawRouter` with a positive limit titles the block "Monthly budget". assert_eq!( cost.card_title_key, - Some(LocaleKey::ProviderTextMonthlyBudget) + Some(LocaleKey::CostTitleClawRouterMonthlyBudget) ); assert_eq!( From 0d029fb52bdb423e7e408fa0ba700ad97f446852 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:12:54 +0700 Subject: [PATCH 8/8] Show LithosAI's balance twice, as on the Mac The detailed card keeps the Billing "Balance" row (lithosai.ts:63) and the "Credits" block reading "Balance: $X" after it (lithosai.ts:72-73, StatusItemController+Menu.swift:1307-1336,1377-1391, MenuCardView+Costs.swift:511-518). A provider-titled cost block is no longer folded into a matching detail row, so the replaced-detail-rows plumbing (CostSnapshot::replacing_detail_rows, replacedDetailIds) has no user and is removed. The compact Overview shows a card's provider details when it has no metric rows, as upstream showsOverviewSupplementalContent does (MenuCardView+ModelHelpers.swift:378-380), so LithosAI shows its Billing rows there and the card-hidden headline stays off every card. --- .../src-tauri/src/commands/bridge.rs | 18 +------ .../src-tauri/src/tray_bridge.rs | 1 - .../src-tauri/src/tray_presentation_tests.rs | 1 - .../src-tauri/src/usage_metric.rs | 1 - .../src/components/MenuCard.test.tsx | 48 +++++++++++-------- .../desktop-tauri/src/components/MenuCard.tsx | 12 ++--- .../components/MenuCardOpenAIUsage.test.tsx | 16 +++++-- .../src/components/card/describeCard.ts | 35 +++++++++----- apps/desktop-tauri/src/types/bridge.ts | 2 - rust/src/core/usage_snapshot.rs | 14 ------ rust/src/providers/lithosai/model.rs | 15 +++--- rust/src/providers/lithosai/tests.rs | 19 ++++---- 12 files changed, 83 insertions(+), 99 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 584fcd7b93..44432314cb 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -142,9 +142,6 @@ pub struct CostSnapshotBridge { /// Provider-owned cost-block title as a locale key name; absent keeps the shared title. #[serde(default, skip_serializing_if = "Option::is_none")] pub title_key: Option, - /// Detail row ids the cost block replaces on the card while it is shown. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub replaced_detail_ids: Vec, } fn default_currency() -> String { @@ -671,11 +668,6 @@ impl ProviderUsageSnapshot { .collect(), always_visible: c.always_visible, title_key: c.card_title_key.map(|key| key.name().to_string()), - replaced_detail_ids: c - .replaced_detail_ids - .iter() - .map(|id| (*id).to_string()) - .collect(), }), plan_name: usage.login_method.clone(), account_email: usage.account_email.clone(), @@ -1476,7 +1468,7 @@ mod tests { } /// Provider-declared card wording (Synthetic's regen countdown, LithosAI's - /// card-hidden headline, cost titles and replaced rows) reaches the card. + /// card-hidden headline and cost titles) reaches the card. #[test] fn provider_card_wording_crosses_the_bridge() { let regen = @@ -1496,8 +1488,7 @@ mod tests { .clone(); let cost = codexbar::core::CostSnapshot::new(0.0, "USD", "Prepaid credits") .with_balance(4.0) - .with_card_title(codexbar::locale::LocaleKey::CreditsTitle) - .replacing_detail_rows(&["balance"]); + .with_card_title(codexbar::locale::LocaleKey::CreditsTitle); let result = codexbar::core::ProviderFetchResult::new( codexbar::core::UsageSnapshot::new(RateWindow::new(0.0)), "web", @@ -1512,10 +1503,6 @@ mod tests { ); let json = serde_json::to_value(&snapshot).unwrap(); assert_eq!(json["cost"]["titleKey"], "CreditsTitle"); - assert_eq!( - json["cost"]["replacedDetailIds"], - serde_json::json!(["balance"]) - ); let plain = codexbar::core::ProviderFetchResult::new( codexbar::core::UsageSnapshot::new(RateWindow::new(0.0)), @@ -1531,6 +1518,5 @@ mod tests { )) .unwrap(); assert!(json["cost"].get("titleKey").is_none()); - assert!(json["cost"].get("replacedDetailIds").is_none()); } } diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index a68359819f..f8fe62a7d9 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -760,7 +760,6 @@ mod tests { daily: Vec::new(), always_visible: false, title_key: None, - replaced_detail_ids: Vec::new(), }), plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 4ce111fc80..10ccb2921c 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -63,7 +63,6 @@ fn fake_snapshot_with( daily: Vec::new(), always_visible: false, title_key: None, - replaced_detail_ids: Vec::new(), }), plan_name: None, account_email: None, diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index c93f2478ec..51bceaa5d5 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -872,7 +872,6 @@ mod tests { daily: Vec::new(), always_visible: false, title_key: None, - replaced_detail_ids: Vec::new(), }); let settings = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 7baaf7782e..1de8d7c0be 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -1369,7 +1369,7 @@ describe("MenuCard", () => { expect(container.querySelector(".card-extra")).toBeNull(); }); - describe("LithosAI shows its balance once", () => { + describe("LithosAI shows its balance twice, as on the Mac", () => { function lithos(balance: number | null, shown: string): ProviderUsageSnapshot { const snapshot = provider(null, 0, { resetDescription: `Balance: ${shown}` }); snapshot.providerId = "lithosai"; @@ -1390,7 +1390,6 @@ describe("MenuCard", () => { balance, formattedBalance: shown, titleKey: "CreditsTitle", - replacedDetailIds: ["balance"], }; snapshot.displayDetails = [ { id: "balance", sectionTitle: "Billing", title: "Balance", value: shown, secondaryValue: null, progress: null }, @@ -1405,50 +1404,59 @@ describe("MenuCard", () => { ); }); - it("in the Credits block, without the headline bar or the Billing row", async () => { + // Upstream: the Billing "Balance" row (lithosai.ts:63), then the cost block + // (lithosai.ts:72-73, StatusItemController+Menu.swift:1307-1336,1377-1391), + // titled "Credits" and reading "Balance: $X" (MenuCardView+Costs.swift:511-518). + it("in the Billing row, then in the Credits block", async () => { const { container } = renderCard(lithos(42.37, "$42.37")); expect(await screen.findByText("Credits")).toHaveClass("card-extra__title"); - expect(screen.getAllByText(/42\.37/)).toHaveLength(1); + const shown = screen.getAllByText(/42\.37/); + expect(shown).toHaveLength(2); + expect(shown[0]).toHaveTextContent(/^\$42\.37$/); + expect(shown[0]).toHaveClass("card-kv__value"); + expect(shown[1]).toHaveTextContent(/^Balance: \$42\.37$/); + expect(shown[1]).toHaveClass("card-extra__inline-value"); + expect( + shown[0].compareDocumentPosition(shown[1]) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByText("Payment card")).toBeInTheDocument(); // The headline balance stays off the card (tray and float bar only). expect(container.querySelector(".menu-metric")).toBeNull(); - expect(container.querySelector(".card-extra__inline-value")).toHaveTextContent( - "Balance: $42.37", - ); - expect(screen.getByText("Payment card")).toBeInTheDocument(); expect(screen.queryByText("Extra usage")).not.toBeInTheDocument(); }); - it("on the compact Overview line, which drops the Credits block and Billing rows", async () => { - tauriMocks.getLocaleStrings.mockResolvedValue( - buildBundle({ ProviderSessionLabel: "Session", ProviderTextBalanceColonPrefix: "Balance: {}" }), - ); + // Upstream compact Overview rows draw the provider details when a card has no + // metrics (MenuCardView+ModelHelpers.swift:378-380) and never the cost block. + it("once on the compact Overview, in the Billing rows", async () => { const { container } = renderCard(lithos(42.37, "$42.37"), { compactOverview: true }); - expect(await screen.findByText("Balance: $42.37")).toHaveClass("menu-metric__pct"); - expect(screen.getAllByText(/42\.37/)).toHaveLength(1); - expect(container.querySelectorAll(".menu-metric")).toHaveLength(1); + expect(await screen.findByText("Payment card")).toBeInTheDocument(); + const shown = screen.getAllByText(/42\.37/); + expect(shown).toHaveLength(1); + expect(shown[0]).toHaveTextContent(/^\$42\.37$/); + expect(shown[0]).toHaveClass("card-kv__value"); + expect(container.querySelector(".menu-metric")).toBeNull(); expect(container.querySelector(".card-extra")).toBeNull(); - expect(screen.queryByText("Payment card")).not.toBeInTheDocument(); expect(container.querySelector(".menu-card--header-only")).toBeNull(); }); - it("in the Billing row when the cost summary is hidden", async () => { + // Windows deviation: the Mac `.prepaidCredits` block ignores the summary style. + it("once, in the Billing row, when the cost summary is hidden", async () => { const { container } = renderCard(lithos(42.37, "$42.37"), { costSummaryDisplayStyle: "hidden" }); expect(await screen.findByText("$42.37")).toBeInTheDocument(); expect(screen.getAllByText(/42\.37/)).toHaveLength(1); - // The headline balance stays off the card (tray and float bar only). expect(container.querySelector(".menu-metric")).toBeNull(); expect(container.querySelector(".card-extra")).toBeNull(); }); - it("in the Billing row for a debt, which has no cost block", async () => { + // Windows deviation: the shared cost block has no negative balance, so a debt has no block. + it("once, in the Billing row, for a debt", async () => { const { container } = renderCard(lithos(null, "-$1.00")); expect(await screen.findByText("-$1.00")).toBeInTheDocument(); expect(screen.getAllByText(/1\.00/)).toHaveLength(1); - // The headline balance stays off the card (tray and float bar only). expect(container.querySelector(".menu-metric")).toBeNull(); expect(container.querySelector(".card-extra")).toBeNull(); }); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 3c38d498d0..4ae38330bf 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -78,7 +78,6 @@ function cardMetrics( provider: ProviderUsageSnapshot, t: ReturnType["t"], language: ReturnType["language"], - compactOverview: boolean, ): MetricEntry[] { const metrics: MetricEntry[] = []; if (provider.providerId !== "wayfinder") { @@ -130,12 +129,9 @@ function cardMetrics( }); } // A window the provider keeps for the tray and float bar only (upstream LithosAI has no card bar). - // The compact Overview drops the detail rows and cost block that carry it on the full card, so - // the window stays there as the card's one line. return metrics.filter( (metric) => - (compactOverview || !metric.snap.hiddenOnCard) && - isUsageItemVisible(provider.hiddenUsageItemIds, metric.id), + !metric.snap.hiddenOnCard && isUsageItemVisible(provider.hiddenUsageItemIds, metric.id), ); } @@ -206,7 +202,7 @@ export default function MenuCard({ provider.providerId === "openaiapi" && hideEmail, ); - const metrics = cardMetrics(provider, t, language, compactOverview); + const metrics = cardMetrics(provider, t, language); // 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( @@ -230,7 +226,7 @@ export default function MenuCard({ t, }) : null; - const showDetailSections = !compactOverview && presence.hasDetailSections; + const showDetailSections = presence.showsDetailSections; const showCost = !compactOverview && presence.hasCost && !!provider.cost; const metricDisplay = { resetTimeRelative, @@ -291,7 +287,7 @@ export default function MenuCard({ )} {showDetailSections && ( { }); }); - it("keeps compact Overview to quota rows", () => { + // Upstream `showsOverviewSupplementalContent(compact:)` is `!compact || metrics.isEmpty` + // (MenuCardView+ModelHelpers.swift:378-380). + it("keeps compact Overview to quota rows, or details when there are none", () => { const snap = snapshot("openaiapi", usage()); snap.displayDetails = [ { id: "d", sectionTitle: null, title: "Plan", value: "Pro", secondaryValue: null, progress: null }, ]; - expect(describeCard(snap, null, [], "detailed", false).hasDetails).toBe(true); - expect(describeCard(snap, null, [], "detailed", true).hasDetails).toBe(false); + const metrics = [{ id: "primary", label: "Session", snap: snap.primary, lane: "primary" as const }]; + expect(describeCard(snap, null, metrics, "detailed", false).showsDetailSections).toBe(true); + expect(describeCard(snap, null, metrics, "detailed", true)).toMatchObject({ + hasDetails: true, + showsDetailSections: false, + }); + expect(describeCard(snap, null, [], "detailed", true)).toMatchObject({ + hasDetails: true, + showsDetailSections: true, + }); }); }); diff --git a/apps/desktop-tauri/src/components/card/describeCard.ts b/apps/desktop-tauri/src/components/card/describeCard.ts index 9da21bef1c..b2a16a83a3 100644 --- a/apps/desktop-tauri/src/components/card/describeCard.ts +++ b/apps/desktop-tauri/src/components/card/describeCard.ts @@ -1,6 +1,5 @@ import type { CostSummaryDisplayStyle, - ProviderDisplayDetail, ProviderChartData, ProviderUsageSnapshot, } from "../../types/bridge"; @@ -11,8 +10,12 @@ export interface MenuCardPresence { hasMetrics: boolean; /** Inventory rows or provider key-value details. */ hasDetailSections: boolean; - /** Provider detail rows the card shows, minus rows the cost block replaces. */ - details: ProviderDisplayDetail[]; + /** + * Draw the detail sections: always on the full card, and on the compact + * Overview only when it has no metric rows (upstream + * `showsOverviewSupplementalContent`: `!compact || metrics.isEmpty`). + */ + showsDetailSections: boolean; hasCost: boolean; localUsage: ProviderChartData["localUsage"] | null; wayfinderUsage: ProviderUsageSnapshot["wayfinderUsage"] | null; @@ -26,8 +29,9 @@ export interface MenuCardPresence { */ function balanceRepeatsDetail(provider: ProviderUsageSnapshot): boolean { const cost = provider.cost; - // A cost that names the rows it replaces keeps itself and drops those rows. - if (cost?.replacedDetailIds?.length) return false; + // A provider that styles its cost block (upstream `ProviderCostMenuCardStyle`, + // such as LithosAI's "Credits") keeps it beside the matching detail row. + if (cost?.titleKey) return false; if (!cost || cost.balance == null || (cost.limit ?? 0) > 0 || cost.alwaysVisible === true) { return false; } @@ -53,18 +57,23 @@ export function describeCard( !!provider.cost && (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true) && !balanceRepeatsDetail(provider); - // Upstream `visibleProviderDetails`: replaced rows drop only while the cost block shows. - const replaced = hasCost && !compactOverview ? provider.cost?.replacedDetailIds ?? [] : []; - const details = (provider.displayDetails ?? []).filter( - (detail) => !replaced.includes(detail.id), - ); const hasDetailSections = - !hasError && ((provider.inventory?.length ?? 0) > 0 || details.length > 0); + !hasError && + ((provider.inventory?.length ?? 0) > 0 || (provider.displayDetails?.length ?? 0) > 0); + const showsDetailSections = hasDetailSections && (!compactOverview || !hasMetrics); const localUsage = hasError ? null : chartData?.localUsage ?? null; const wayfinderUsage = !hasError && provider.providerId === "wayfinder" ? provider.wayfinderUsage ?? null : null; const hasDetails = compactOverview - ? hasMetrics || !!wayfinderUsage + ? hasMetrics || showsDetailSections || !!wayfinderUsage : hasMetrics || hasDetailSections || hasCost || !!localUsage || !!wayfinderUsage; - return { hasMetrics, hasDetailSections, details, hasCost, localUsage, wayfinderUsage, hasDetails }; + return { + hasMetrics, + hasDetailSections, + showsDetailSections, + hasCost, + localUsage, + wayfinderUsage, + hasDetails, + }; } diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index f24885c9e5..748b35090d 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -718,8 +718,6 @@ export interface CostSnapshotBridge { alwaysVisible?: boolean; /** Provider-owned cost-block title (upstream `ProviderCostMenuCardStyle`); absent keeps the shared title. */ titleKey?: LocaleKey; - /** Detail row ids the cost block replaces on the card while it is shown (upstream `replacedDetailRows`). */ - replacedDetailIds?: string[]; } export type PaceStage = diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 08fdfba816..704182ccb4 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -537,12 +537,6 @@ pub struct CostSnapshot { /// `None` keeps the shared title. Presentation only; not serialized. #[serde(skip)] pub card_title_key: Option, - - /// Ids of provider detail rows that the card's cost block replaces while - /// it is shown (upstream `ProviderCostPresentation.replacedDetailRows`). - /// Presentation only; not serialized. - #[serde(skip)] - pub replaced_detail_ids: Vec<&'static str>, } impl CostSnapshot { @@ -564,7 +558,6 @@ impl CostSnapshot { history_tokens: None, provenance: None, card_title_key: None, - replaced_detail_ids: Vec::new(), } } @@ -574,13 +567,6 @@ impl CostSnapshot { self } - /// Builder pattern: provider detail rows (by id) that the card's cost - /// block stands in for while it is shown. - pub fn replacing_detail_rows(mut self, ids: &[&'static str]) -> Self { - self.replaced_detail_ids = ids.to_vec(); - self - } - /// Builder pattern: set limit pub fn with_limit(mut self, limit: f64) -> Self { self.limit = finite_amount(limit); diff --git a/rust/src/providers/lithosai/model.rs b/rust/src/providers/lithosai/model.rs index 1825b6d918..85b6bda76d 100644 --- a/rust/src/providers/lithosai/model.rs +++ b/rust/src/providers/lithosai/model.rs @@ -204,9 +204,6 @@ fn row(id: &str, title: &str, value: impl Into) -> Option= 0 { // Upstream `.prepaidCredits` (LithosAIProviderDescriptor.swift:16-19): - // a "Credits" block. While it is shown it stands in for the Billing - // "Balance" row, so the card shows the balance once. + // a "Credits" block reading "Balance: $X" (MenuCardView+Costs.swift:511-518), + // drawn after the Billing rows, which keep their own "Balance" row + // (lithosai.ts:63). The Mac card shows the balance twice. result.with_cost( CostSnapshot::new(0.0, "USD", "Prepaid credits") .with_balance(balance) - .with_card_title(LocaleKey::CreditsTitle) - .replacing_detail_rows(&[BALANCE_ROW_ID]), + .with_card_title(LocaleKey::CreditsTitle), ) } else { result diff --git a/rust/src/providers/lithosai/tests.rs b/rust/src/providers/lithosai/tests.rs index ab3d571162..f3e474ce38 100644 --- a/rust/src/providers/lithosai/tests.rs +++ b/rust/src/providers/lithosai/tests.rs @@ -5,7 +5,6 @@ use std::thread::JoinHandle; use chrono::{TimeZone, Utc}; use reqwest::redirect::Policy; -use super::model::BALANCE_ROW_ID; use super::*; use crate::locale::LocaleKey; @@ -131,11 +130,11 @@ async fn console_balance_and_sparse_spend_match_the_upstream_fixture() { assert_eq!(cost.limit, None); assert_eq!(cost.currency_code, "USD"); assert_eq!(cost.period, "Prepaid credits"); - // Upstream `.prepaidCredits` titles the block "Credits", and the card shows - // the balance once: in that block, not again as the Billing row or a bar. + // Upstream `.prepaidCredits` titles the block "Credits". The Billing + // "Balance" row stays too, so the card shows the balance twice, as on the + // Mac; only the headline bar stays off the card. assert_eq!(cost.card_title_key, Some(LocaleKey::CreditsTitle)); - assert_eq!(cost.replaced_detail_ids, [BALANCE_ROW_ID]); - assert_eq!(result.display_details[0].id(), BALANCE_ROW_ID); + assert_eq!(result.display_details[0].id(), "balance"); let usage = &result.usage; assert!(usage.primary.is_informational); assert!(usage.primary.hidden_on_card); @@ -219,13 +218,11 @@ async fn zero_debt_and_subcent_balances_are_not_invented_quotas() { result.usage.primary.reset_description.as_deref(), Some(format!("Balance: {shown}").as_str()) ); - // A debt has no cost block, so the Billing row is where the card shows it. + // Every balance with a cost block gets the Credits title; a debt has + // no block, so the Billing row alone shows it. assert_eq!( - result - .cost - .as_ref() - .map(|cost| cost.replaced_detail_ids.clone()), - cost_balance.map(|_| vec![BALANCE_ROW_ID]), + result.cost.as_ref().and_then(|cost| cost.card_title_key), + cost_balance.map(|_| LocaleKey::CreditsTitle), "{balance}" ); let rows = rows(&result);