diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 2b63e4abfe..4a42777cce 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -453,6 +453,7 @@ pub struct SettingsSnapshot { float_bar_provider_ids: Vec, float_bar_dark_text: bool, float_bar_show_reset_inline: bool, + float_bar_show_cost: bool, } #[tauri::command] @@ -545,6 +546,7 @@ impl From for SettingsSnapshot { float_bar_provider_ids: settings.float_bar_provider_ids, float_bar_dark_text: settings.float_bar_dark_text, float_bar_show_reset_inline: settings.float_bar_show_reset_inline, + float_bar_show_cost: settings.float_bar_show_cost, } } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 15d4e558e1..cf4fc3ef27 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -57,6 +57,7 @@ pub struct SettingsUpdate { pub float_bar_provider_ids: Option>, pub float_bar_dark_text: Option, pub float_bar_show_reset_inline: Option, + pub float_bar_show_cost: Option, } impl SettingsUpdate { @@ -273,6 +274,7 @@ impl SettingsUpdate { provider_ids: self.float_bar_provider_ids.clone(), dark_text: self.float_bar_dark_text, show_reset_inline: self.float_bar_show_reset_inline, + show_cost: self.float_bar_show_cost, } } diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs index d8288a9029..6d431cd0dc 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs @@ -105,6 +105,7 @@ pub struct SettingsPatch { pub provider_ids: Option>, pub dark_text: Option, pub show_reset_inline: Option, + pub show_cost: Option, } impl SettingsPatch { @@ -150,6 +151,9 @@ impl SettingsPatch { if let Some(v) = self.show_reset_inline { settings.float_bar_show_reset_inline = v; } + if let Some(v) = self.show_cost { + settings.float_bar_show_cost = v; + } } } diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 63943167d2..14230f1519 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -108,6 +108,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { floatBarProviderIds: [], floatBarDarkText: false, floatBarShowResetInline: false, + floatBarShowCost: false, ...overrides, }; } diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 4da5bddc90..5ed18b1451 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -116,6 +116,7 @@ describe("MenuCard", () => { PanelTodayBudget: "today", PanelUsedSuffix: "used", ResetsInHoursMinutes: "Resets in {}h {}m", + ResetsInMinutes: "Resets in {}m", WayfinderGatewayStatus: "Gateway", WayfinderModels: "Models", WayfinderRequests: "Requests", @@ -190,7 +191,7 @@ describe("MenuCard", () => { renderCard(snapshot, { showResetWhenExhausted: true }); - expect(await screen.findByText(/Resets in 0h/)).toBeInTheDocument(); + expect(await screen.findByText(/Resets in \d+m/)).toBeInTheDocument(); expect(screen.queryByText("0% left")).not.toBeInTheDocument(); }); diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 165207e87f..67dc7871c8 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -135,6 +135,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { floatBarProviderIds: [], floatBarDarkText: false, floatBarShowResetInline: false, + floatBarShowCost: false, ...overrides, }; } @@ -181,7 +182,9 @@ describe("FloatBar", () => { snapshot("claude", "Claude", 20), snapshot("codex", "Codex", 75), ]); - tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarShowCost: true }), + ); const { container } = renderFloatBar(bootstrap()); await waitFor(() => { @@ -212,7 +215,7 @@ describe("FloatBar", () => { tokenCostUpdatedAtMs: 1234, }); - renderFloatBar(bootstrap()); + renderFloatBar(bootstrap({ floatBarShowCost: true })); await waitFor(() => { expect(tauriMocks.getProviderLocalUsageSummary).toHaveBeenCalledWith("codex"); @@ -220,6 +223,20 @@ describe("FloatBar", () => { expect(tauriMocks.getProviderChartData).not.toHaveBeenCalled(); }); + it("does not scan local costs by default", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("codex", "Codex", 75), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); + + renderFloatBar(bootstrap()); + + await waitFor(() => { + expect(tauriMocks.getCachedProviders).toHaveBeenCalled(); + }); + expect(tauriMocks.getProviderLocalUsageSummary).not.toHaveBeenCalled(); + }); + it("can show remaining percentages when configured", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("claude", "Claude", 20), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index 6af1d8c9a8..91a9098555 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -292,6 +292,7 @@ export default function FloatBar({ state }: { state: BootstrapState }) { const filterIds = settings.floatBarProviderIds; const scale = Math.max(0.75, Math.min(2, settings.floatBarScale / 100)); const showResetInline = settings.floatBarShowResetInline; + const showCost = settings.floatBarShowCost; const visible = useMemo(() => { const enabled = new Set(settings.enabledProviders); let list = providers.filter((p) => enabled.has(p.providerId)); @@ -307,12 +308,14 @@ export default function FloatBar({ state }: { state: BootstrapState }) { .join("|"); const visibleCostTargets = useMemo( () => - visible.map((provider) => ({ - key: providerCostKey(provider), - providerId: provider.providerId, - displayName: provider.displayName, - })), - [visibleCostTargetKey], + showCost + ? visible.map((provider) => ({ + key: providerCostKey(provider), + providerId: provider.providerId, + displayName: provider.displayName, + })) + : [], + [showCost, visibleCostTargetKey], ); useEffect(() => { diff --git a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx index 97e2e46308..f4b04d86df 100644 --- a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx +++ b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Field, Select, Toggle } from "../components/FormControls"; +import { useLocale } from "../hooks/useLocale"; import type { FloatBarOrientation, FloatBarStyle, @@ -42,6 +43,7 @@ function useDraftNumber(value: number) { * imports a single component. */ export default function FloatBarSettingsSection({ settings, saving, set }: Props) { + const { t } = useLocale(); const opacity = useDraftNumber(settings.floatBarOpacity); const scale = useDraftNumber(settings.floatBarScale); const commitOpacity = () => { @@ -132,6 +134,17 @@ export default function FloatBarSettingsSection({ settings, saving, set }: Props aria-label="Floating bar size" /> + + set({ floatBarShowCost: v })} + /> + { expect(screen.getByTestId("reset")).toHaveTextContent("Resets in 3h 42m"); }); + it("omits zero hours for sub-hour resets", async () => { + const target = new Date("2024-06-01T00:40:00Z").toISOString(); + await mountWithLocale( + , + ); + expect(screen.getByTestId("reset")).toHaveTextContent("Resets in 40m"); + }); + it("leaves fallback text unlabelled in relative mode", async () => { await mountWithLocale( , diff --git a/apps/desktop-tauri/src/hooks/useFormattedResetTime.ts b/apps/desktop-tauri/src/hooks/useFormattedResetTime.ts index 7c19869a98..5e1a1c681f 100644 --- a/apps/desktop-tauri/src/hooks/useFormattedResetTime.ts +++ b/apps/desktop-tauri/src/hooks/useFormattedResetTime.ts @@ -50,6 +50,9 @@ export function useFormattedResetTime( .replace("{}", String(days)) .replace("{}", String(hours)); } + if (hours === 0) { + return t("ResetsInMinutes").replace("{}", String(minutes)); + } return t("ResetsInHoursMinutes") .replace("{}", String(hours)) .replace("{}", String(minutes)); diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index b6e448b86f..51e029cf5b 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -231,6 +231,7 @@ export const ALL_LOCALE_KEYS = [ "ResetsInShort", "ResetsInDaysHours", "ResetsInHoursMinutes", + "ResetsInMinutes", "TrayDisplayTitle", "ShowInTray", "CreditsLabel", @@ -489,6 +490,8 @@ export const ALL_LOCALE_KEYS = [ "FloatBarThirtyDayShort", "FloatBarNoProviders", "FloatBarRemainingSuffix", + "FloatBarShowCost", + "FloatBarShowCostDescription", "BannerCheckingForUpdates", "BannerUpdateAvailablePrefix", "BannerDownloadButton", diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index 62820a65a6..d908988c85 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -161,6 +161,7 @@ function settings(): SettingsSnapshot { floatBarProviderIds: [], floatBarDarkText: false, floatBarShowResetInline: false, + floatBarShowCost: false, }; } diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index cbbdaf3eb5..6df1796c8b 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -147,6 +147,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { floatBarProviderIds: [], floatBarDarkText: false, floatBarShowResetInline: false, + floatBarShowCost: false, ...overrides, }; } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 7de93b5f97..7203d84e00 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -83,6 +83,7 @@ const settings: SettingsSnapshot = { floatBarProviderIds: [], floatBarDarkText: false, floatBarShowResetInline: false, + floatBarShowCost: false, }; describe("AboutTab", () => { 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 ad7c657486..f553e76b5d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -65,6 +65,7 @@ const settings: SettingsSnapshot = { floatBarProviderIds: [], floatBarDarkText: false, floatBarShowResetInline: false, + floatBarShowCost: false, showResetWhenExhausted: false, }; diff --git a/apps/desktop-tauri/src/surfaces/tray/useResetCountdown.test.tsx b/apps/desktop-tauri/src/surfaces/tray/useResetCountdown.test.tsx index 2cad35da23..480d614a56 100644 --- a/apps/desktop-tauri/src/surfaces/tray/useResetCountdown.test.tsx +++ b/apps/desktop-tauri/src/surfaces/tray/useResetCountdown.test.tsx @@ -31,6 +31,7 @@ async function mountWithLocale(ui: React.ReactNode) { (tauri.getLocaleStrings as ReturnType).mockResolvedValue( buildBundle({ ResetsInHoursMinutes: "{}h {}m", + ResetsInMinutes: "{}m", ResetsInDaysHours: "{}d {}h", TrayResetsDueNow: "resetting", }), @@ -66,6 +67,12 @@ describe("useResetCountdown", () => { expect(screen.getByTestId("cd")).toHaveTextContent("3h 42m"); }); + it("omits zero hours for sub-hour deltas", async () => { + const target = new Date("2024-06-01T00:40:00Z").toISOString(); + await mountWithLocale(); + expect(screen.getByTestId("cd")).toHaveTextContent(/^40m$/); + }); + it("renders days+hours for multi-day deltas", async () => { const target = new Date("2024-06-03T05:00:00Z").toISOString(); await mountWithLocale(); diff --git a/apps/desktop-tauri/src/surfaces/tray/useResetCountdown.ts b/apps/desktop-tauri/src/surfaces/tray/useResetCountdown.ts index e07fde96b8..88a93242f2 100644 --- a/apps/desktop-tauri/src/surfaces/tray/useResetCountdown.ts +++ b/apps/desktop-tauri/src/surfaces/tray/useResetCountdown.ts @@ -41,6 +41,9 @@ export function useResetCountdown( .replace("{}", String(days)) .replace("{}", String(hours)); } + if (hours === 0) { + return t("ResetsInMinutes").replace("{}", String(minutes)); + } return t("ResetsInHoursMinutes") .replace("{}", String(hours)) .replace("{}", String(minutes)); diff --git a/apps/desktop-tauri/src/types/bridge.test.ts b/apps/desktop-tauri/src/types/bridge.test.ts index 4e0a3bf2ad..b869ffeaf2 100644 --- a/apps/desktop-tauri/src/types/bridge.test.ts +++ b/apps/desktop-tauri/src/types/bridge.test.ts @@ -84,6 +84,7 @@ describe("Language type", () => { floatBarProviderIds: [], floatBarDarkText: false, floatBarShowResetInline: false, + floatBarShowCost: false, }; expect(snap.uiLanguage).toBe("spanish"); diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index b7d9e8064d..ef7efba335 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -262,6 +262,8 @@ export interface SettingsSnapshot { floatBarDarkText: boolean; /** When true, render the next primary reset inline in each provider pill. */ floatBarShowResetInline: boolean; + /** When true, scan and render local cost summaries. */ + floatBarShowCost: boolean; } /** Partial settings object — only include fields you want to change. */ @@ -315,6 +317,7 @@ export interface SettingsUpdate { floatBarProviderIds?: string[]; floatBarDarkText?: boolean; floatBarShowResetInline?: boolean; + floatBarShowCost?: boolean; } export interface UsageThresholdOverride { diff --git a/rust/src/locale.rs b/rust/src/locale.rs index d0252b2bb6..179330e8e4 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -437,6 +437,7 @@ locale_keys! { ResetsInShort, ResetsInDaysHours, ResetsInHoursMinutes, + ResetsInMinutes, // Provider detail - Tray Display TrayDisplayTitle, @@ -735,6 +736,8 @@ locale_keys! { FloatBarThirtyDayShort, FloatBarNoProviders, FloatBarRemainingSuffix, + FloatBarShowCost, + FloatBarShowCostDescription, // Tauri desktop shell — update banner BannerCheckingForUpdates, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 237ce874b7..54cc41cb3b 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -224,6 +224,9 @@ ProviderCodeReviewLabel = Code review ResetsInShort = Resets in ResetsInDaysHours = Resets in { "{}" }d { "{}" }h ResetsInHoursMinutes = Resets in { "{}" }h { "{}" }m +ResetsInMinutes = Resets in { "{}" }m +FloatBarShowCost = Show Local Cost +FloatBarShowCostDescription = Shows estimated local usage cost in the floating bar. TrayDisplayTitle = Tray Display ShowInTray = Show in tray CreditsLabel = Credits diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index f19f2d770a..8b062ab342 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -218,6 +218,9 @@ ProviderCodeReviewLabel = Revisión de código ResetsInShort = Reinicia en ResetsInDaysHours = Reinicia en { "{}" }d { "{}" }h ResetsInHoursMinutes = Reinicia en { "{}" }h { "{}" }m +ResetsInMinutes = Reinicia en { "{}" }m +FloatBarShowCost = Mostrar costo local +FloatBarShowCostDescription = Muestra el costo estimado del uso local en la barra flotante. TrayDisplayTitle = Pantalla de bandeja ShowInTray = Mostrar en bandeja CreditsLabel = Créditos diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index b4314ddcfc..47e0551aa1 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -218,6 +218,9 @@ ProviderCodeReviewLabel = Code review ResetsInShort = リセットまで ResetsInDaysHours = リセットまで { "{}" }日 { "{}" }時間 ResetsInHoursMinutes = リセットまで { "{}" }時間 { "{}" }分 +ResetsInMinutes = リセットまで { "{}" }分 +FloatBarShowCost = ローカルコストを表示 +FloatBarShowCostDescription = フローティングバーにローカル使用量の推定コストを表示します。 TrayDisplayTitle = Tray Display ShowInTray = Show in tray CreditsLabel = Credits diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 5f456ae1ec..42223154a3 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -218,6 +218,9 @@ ProviderCodeReviewLabel = 코드 리뷰 ResetsInShort = 초기화까지 ResetsInDaysHours = 초기화까지 { "{}" }일 { "{}" }시간 ResetsInHoursMinutes = 초기화까지 { "{}" }시간 { "{}" }분 +ResetsInMinutes = 초기화까지 { "{}" }분 +FloatBarShowCost = 로컬 비용 표시 +FloatBarShowCostDescription = 플로팅 바에 로컬 사용량의 예상 비용을 표시합니다. TrayDisplayTitle = 트레이 표시 ShowInTray = 트레이에 표시 CreditsLabel = 크레딧 diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 5740f151da..bfcf2e70c1 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -218,6 +218,9 @@ ProviderCodeReviewLabel = 代码审查 ResetsInShort = 重置于 ResetsInDaysHours = { "{}" } 天 { "{}" } 小时后重置 ResetsInHoursMinutes = { "{}" } 小时 { "{}" } 分钟后重置 +ResetsInMinutes = { "{}" } 分钟后重置 +FloatBarShowCost = 显示本地费用 +FloatBarShowCostDescription = 在浮动栏中显示本地使用量的估算费用。 TrayDisplayTitle = 托盘显示 ShowInTray = 在托盘中显示 CreditsLabel = 额度 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 8ae2962a4e..5f4dbeb1ca 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -218,6 +218,9 @@ ProviderCodeReviewLabel = 程式碼審查 ResetsInShort = 重置於 ResetsInDaysHours = { "{}" } 天 { "{}" } 小時後重置 ResetsInHoursMinutes = { "{}" } 小時 { "{}" } 分鐘後重置 +ResetsInMinutes = { "{}" } 分鐘後重置 +FloatBarShowCost = 顯示本機費用 +FloatBarShowCostDescription = 在浮動列中顯示本機使用量的估算費用。 TrayDisplayTitle = 系統匣顯示 ShowInTray = 在系統匣中顯示 CreditsLabel = 額度 diff --git a/rust/src/providers/claude/cli_reset.rs b/rust/src/providers/claude/cli_reset.rs index 4026265434..68f4bc99b9 100644 --- a/rust/src/providers/claude/cli_reset.rs +++ b/rust/src/providers/claude/cli_reset.rs @@ -96,7 +96,7 @@ pub(super) fn extract_cli_scoped_weekly_limits( limits } -fn slug_claude_model(label: &str) -> String { +pub(super) fn slug_claude_model(label: &str) -> String { let mut slug = String::new(); let mut previous_dash = false; for character in label.chars() { diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 83e25f0a03..62ed245965 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -3,6 +3,7 @@ mod admin_api; mod cli_reset; mod oauth; +mod scoped_weekly; mod web_api; use async_trait::async_trait; diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index 3bb62dec92..5d35a6742e 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -73,6 +73,9 @@ pub struct OAuthUsageResponse { #[serde(rename = "extraUsage", alias = "extra_usage")] pub extra_usage: Option, + + #[serde(default)] + limits: Vec, } /// A usage window from the OAuth API @@ -397,6 +400,11 @@ impl ClaudeOAuthFetcher { .extra_rate_windows .push(NamedRateWindow::new(id, title, window)); } + usage + .extra_rate_windows + .extend(super::scoped_weekly::scoped_weekly_windows( + &response.limits, + )); } // Login method from rate limit tier or default @@ -499,6 +507,14 @@ mod tests { "five_hour": {"utilization": 1.0, "resets_at": "2026-05-22T22:10:00Z"}, "seven_day": {"utilization": 0.14, "resets_at": "2026-05-29T10:00:00Z"}, "seven_day_oauth_apps": {"utilization": 0.0}, + "limits": [{ + "kind": "weekly_scoped", + "group": "weekly", + "percent": 7, + "resets_at": "2026-05-29T10:00:00Z", + "scope": {"model": {"id": null, "display_name": "Fable"}}, + "is_active": false + }], "extra_usage": {"is_enabled": true, "used_credits": 0, "monthly_limit": 1000, "currency": "USD"} }"#, ) @@ -515,7 +531,13 @@ mod tests { assert_eq!(usage.primary.used_percent, 100.0); assert!((usage.secondary.expect("weekly").used_percent - 14.0).abs() < 0.001); - assert!(usage.extra_rate_windows.is_empty()); + let scoped = usage + .extra_rate_windows + .iter() + .find(|window| window.id == "claude-weekly-scoped-fable") + .expect("Fable scoped weekly limit"); + assert_eq!(scoped.title, "Fable only"); + assert_eq!(scoped.window.used_percent, 7.0); } #[test] diff --git a/rust/src/providers/claude/scoped_weekly.rs b/rust/src/providers/claude/scoped_weekly.rs new file mode 100644 index 0000000000..0a9a023e83 --- /dev/null +++ b/rust/src/providers/claude/scoped_weekly.rs @@ -0,0 +1,105 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use std::collections::HashSet; + +use crate::core::{NamedRateWindow, RateWindow}; + +use super::cli_reset::slug_claude_model; + +#[derive(Debug, Deserialize)] +pub(super) struct ScopedWeeklyLimit { + kind: Option, + group: Option, + percent: Option, + #[serde(alias = "resetsAt")] + resets_at: Option, + scope: Option, +} + +#[derive(Debug, Deserialize)] +struct ScopedWeeklyScope { + model: Option, +} + +#[derive(Debug, Deserialize)] +struct ScopedWeeklyModel { + id: Option, + #[serde(alias = "displayName")] + display_name: Option, +} + +pub(super) fn scoped_weekly_windows(limits: &[ScopedWeeklyLimit]) -> Vec { + let mut seen = HashSet::new(); + limits + .iter() + .filter_map(|limit| { + if limit.kind.as_deref() != Some("weekly_scoped") + || limit.group.as_deref() != Some("weekly") + { + return None; + } + let percent = limit.percent.filter(|value| value.is_finite())?; + let model = limit.scope.as_ref()?.model.as_ref()?; + let title = model.display_name.as_deref()?.trim(); + if title.is_empty() { + return None; + } + let identity = model + .id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(title); + let slug = slug_claude_model(identity); + if slug.is_empty() || !seen.insert(slug.clone()) { + return None; + } + let resets_at = limit + .resets_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)); + Some(NamedRateWindow::new( + format!("claude-weekly-scoped-{slug}"), + format!("{title} only"), + RateWindow::with_details(percent, Some(7 * 24 * 60), resets_at, None), + )) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_valid_limits_and_deduplicates_stable_model_ids() { + let limits: Vec = serde_json::from_str( + r#"[ + {"kind":"weekly_scoped","group":"weekly","percent":7,"scope":{"model":{"id":"claude/fable.5:promo","display_name":"Fable"}}}, + {"kind":"weekly_scoped","group":"weekly","percent":8,"scope":{"model":{"id":"claude/fable.5:promo","display_name":"Renamed"}}} + ]"#, + ) + .unwrap(); + + let windows = scoped_weekly_windows(&limits); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].id, "claude-weekly-scoped-claude-fable-5-promo"); + assert_eq!(windows[0].title, "Fable only"); + } + + #[test] + fn ignores_unrelated_malformed_and_unnamed_limits() { + let limits: Vec = serde_json::from_str( + r#"[ + {"kind":"session","group":"weekly","percent":7,"scope":{"model":{"display_name":"Fable"}}}, + {"kind":"weekly_scoped","group":"monthly","percent":7,"scope":{"model":{"display_name":"Fable"}}}, + {"kind":"weekly_scoped","group":"weekly","percent":7,"scope":{"model":null}}, + {"kind":"weekly_scoped","group":"weekly","percent":7,"scope":{"model":{"display_name":" "}}} + ]"#, + ) + .unwrap(); + + assert!(scoped_weekly_windows(&limits).is_empty()); + } +} diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index d5261169dd..f5f1b10dce 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -95,6 +95,7 @@ struct UsageResponse { seven_day_design: Option, seven_day_routines: Option, extra_usage: Option, + limits: Vec, } impl<'de> Deserialize<'de> for UsageResponse { @@ -156,6 +157,14 @@ impl<'de> Deserialize<'de> for UsageResponse { "cowork", ], )?, + limits: map + .get("limits") + .filter(|value| !value.is_null()) + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(), extra_usage: map .remove("extra_usage") .filter(|value| !value.is_null()) @@ -351,6 +360,9 @@ impl ClaudeWebApiFetcher { .extra_rate_windows .push(NamedRateWindow::new(id, title, window)); } + snapshot + .extra_rate_windows + .extend(super::scoped_weekly::scoped_weekly_windows(&usage.limits)); } if let Some(ref acc) = account { @@ -805,6 +817,28 @@ mod tests { assert!((routines.used_percent - 11.0).abs() < f64::EPSILON); } + #[test] + fn maps_scoped_weekly_limits_even_when_inactive() { + let usage: super::UsageResponse = serde_json::from_str( + r#"{ + "limits": [{ + "kind": "weekly_scoped", + "group": "weekly", + "percent": 7, + "resets_at": "2026-07-16T10:00:00Z", + "scope": {"model": {"id": null, "display_name": "Fable"}}, + "is_active": false + }] + }"#, + ) + .unwrap(); + + let windows = super::super::scoped_weekly::scoped_weekly_windows(&usage.limits); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].id, "claude-weekly-scoped-fable"); + assert_eq!(windows[0].title, "Fable only"); + } + #[test] fn parses_duplicate_design_and_routines_aliases_with_preferred_key() { let usage: super::UsageResponse = serde_json::from_str( diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 30472ad7e3..cf14c2aa16 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -223,6 +223,10 @@ pub struct Settings { /// When true, show the primary window's next reset inline in each pill. #[serde(default)] pub float_bar_show_reset_inline: bool, + + /// When true, show local cost summaries in the floating bar. + #[serde(default)] + pub float_bar_show_cost: bool, } fn default_window_scale_percent() -> u16 { @@ -406,6 +410,7 @@ impl Default for Settings { float_bar_provider_ids: Vec::new(), float_bar_dark_text: false, float_bar_show_reset_inline: false, + float_bar_show_cost: false, } } } diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 8feefb288f..cde8f79d9b 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -139,6 +139,8 @@ pub(super) struct RawSettings { float_bar_dark_text: bool, #[serde(default)] float_bar_show_reset_inline: bool, + #[serde(default)] + float_bar_show_cost: bool, } impl Default for RawSettings { @@ -222,6 +224,7 @@ impl Default for RawSettings { float_bar_provider_ids: s.float_bar_provider_ids, float_bar_dark_text: s.float_bar_dark_text, float_bar_show_reset_inline: s.float_bar_show_reset_inline, + float_bar_show_cost: s.float_bar_show_cost, } } } @@ -488,6 +491,7 @@ impl From for Settings { float_bar_provider_ids: raw.float_bar_provider_ids, float_bar_dark_text: raw.float_bar_dark_text, float_bar_show_reset_inline: raw.float_bar_show_reset_inline, + float_bar_show_cost: raw.float_bar_show_cost, } } } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index f4e79243a8..06f6dc4cdf 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -11,6 +11,7 @@ fn test_settings_default() { assert_eq!(settings.critical_usage_threshold, 90.0); assert!(!settings.show_reset_when_exhausted); assert!(!settings.predictive_pace_warning_enabled); + assert!(!settings.float_bar_show_cost); } #[test]