From 0ffb87fc18af6704bced12ab5c8f306ea00b075e Mon Sep 17 00:00:00 2001
From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
Date: Wed, 4 Nov 2026 19:29:54 +0000
Subject: [PATCH] feat(mobile): show usage for every agent desktop tracks on
the Accounts screen (#38187)
* feat(mobile): show every desktop usage provider on Accounts
* refactor(status-bar): import moved usage helpers from shared
* refactor(mobile): reuse shared usage rules for provider names and data checks
* test(mobile): type account-slot fixtures as Claude/Codex limits
---
mobile/src/accounts/MobileAccountsScreen.tsx | 97 +++++++------
mobile/src/accounts/ProviderUsageWindows.tsx | 108 ++++++++++++++
.../accounts/mobile-accounts-screen-styles.ts | 5 -
mobile/src/components/AccountUsage.tsx | 87 ++++++-----
.../components/account-usage-state.test.ts | 135 +++++++++---------
mobile/src/components/account-usage-state.ts | 55 +++----
mobile/src/components/accounts-snapshot.ts | 28 ++--
.../src/components/codex-reset-credit.test.ts | 7 +-
mobile/src/i18n/locales/de.json | 3 +
mobile/src/i18n/locales/en.json | 5 +-
mobile/src/i18n/locales/es.json | 3 +
mobile/src/i18n/locales/fr.json | 3 +
mobile/src/i18n/locales/ja.json | 3 +
mobile/src/i18n/locales/ko.json | 3 +
mobile/src/i18n/locales/pt.json | 3 +
mobile/src/i18n/locales/ru.json | 3 +
mobile/src/i18n/locales/tr.json | 3 +
mobile/src/i18n/locales/zh-Hant.json | 3 +
mobile/src/i18n/locales/zh.json | 3 +
.../status-bar/InlineProviderUsage.tsx | 2 +-
.../status-bar/ProviderIdentityCard.tsx | 3 +-
.../status-bar/StatusBarProviderSegment.tsx | 2 +-
.../status-bar/UsageRosterPanel.tsx | 7 +-
.../status-bar/owner-usage-rate-limits.ts | 2 +-
.../status-bar-provider-visibility.ts | 11 +-
.../status-bar/status-bar-usage-sections.ts | 2 +-
.../src/components/status-bar/tooltip.tsx | 82 ++++-------
.../components/status-bar/usage-error-copy.ts | 35 +----
.../components/status-bar/usage-pool-label.ts | 5 +-
.../rate-limit-window-label.ts} | 2 +-
src/shared/usage-provider-windows.ts | 83 +++++++++++
31 files changed, 488 insertions(+), 305 deletions(-)
create mode 100644 mobile/src/accounts/ProviderUsageWindows.tsx
rename src/{renderer/src/lib/window-label-formatter.ts => shared/rate-limit-window-label.ts} (93%)
create mode 100644 src/shared/usage-provider-windows.ts
diff --git a/mobile/src/accounts/MobileAccountsScreen.tsx b/mobile/src/accounts/MobileAccountsScreen.tsx
index b70b84937b9..ce838dc7aa6 100644
--- a/mobile/src/accounts/MobileAccountsScreen.tsx
+++ b/mobile/src/accounts/MobileAccountsScreen.tsx
@@ -18,11 +18,12 @@ import {
type ProviderKey,
getActiveProviderRateLimits,
getInactiveProviderUsage,
- getUsageBarState,
- getWindowResetLabel,
- hasActiveProviderUsage,
- UsageBar
+ getOtherUsageProviders,
+ hasActiveProviderUsage
} from '../components/AccountUsage'
+import { MobileAgentIcon } from '../components/MobileAgentIcon'
+import { getProviderDisplayName } from '../../../src/shared/usage-provider-windows'
+import { ProviderUsageWindows } from './ProviderUsageWindows'
import {
getActiveCodexAccountIdForRateLimitTarget,
getCodexResetCreditSummary
@@ -201,7 +202,7 @@ export function MobileAccountsScreen() {
[client, refresh, snapshot, t]
)
- const renderProviderSection = (provider: ProviderKey, title: string) => {
+ const renderProviderSection = (provider: ProviderKey) => {
if (!snapshot) {
return null
}
@@ -211,15 +212,17 @@ export function MobileAccountsScreen() {
? getActiveCodexAccountIdForRateLimitTarget(snapshot)
: state.activeAccountId
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
- const activeSessionBar = getUsageBarState(activeUsage, 'session')
- const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly')
const resetCredit = provider === 'codex' ? getCodexResetCreditSummary(activeUsage, now) : null
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
const switchBlocked = busyAccountId !== null || resettingCodex || connState !== 'connected'
const spinner =
return (
- } first={provider === 'claude'} />
+ }
+ first={provider === 'claude'}
+ />
{/* A blocked row drops its press instead of dimming, so the picked one keeps full contrast. */}
-
-
-
+
) : undefined
}
accessory={busyAccountId === `${provider}:default` ? spinner : undefined}
@@ -260,30 +248,18 @@ export function MobileAccountsScreen() {
const isFetching =
(isActive && usage?.status === 'fetching') ||
(!isActive && inactiveEntry?.isFetching === true)
- const sessionBar = getUsageBarState(usage, 'session', isFetching)
- const weeklyBar = getUsageBarState(usage, 'weekly', isFetching)
return (
-
-
-
-
+
{usage?.error ? (
{usage.error}
@@ -316,6 +292,38 @@ export function MobileAccountsScreen() {
)
}
+ // Every other provider desktop shows usage for; usage only, since accounts are managed on desktop.
+ const renderOtherProviders = () => {
+ const providers = snapshot ? getOtherUsageProviders(snapshot) : []
+ if (providers.length === 0) {
+ return null
+ }
+ return (
+
+
+
+ {providers.map((limits) => (
+ }
+ detail={
+ <>
+
+ {limits.error ? (
+
+ {limits.error}
+
+ ) : null}
+ >
+ }
+ />
+ ))}
+
+
+ )
+ }
+
return (
) : (
<>
- {renderProviderSection('claude', 'Claude')}
- {renderProviderSection('codex', 'Codex')}
+ {renderProviderSection('claude')}
+ {renderProviderSection('codex')}
+ {renderOtherProviders()}
diff --git a/mobile/src/accounts/ProviderUsageWindows.tsx b/mobile/src/accounts/ProviderUsageWindows.tsx
new file mode 100644
index 00000000000..8f9dd1e9e30
--- /dev/null
+++ b/mobile/src/accounts/ProviderUsageWindows.tsx
@@ -0,0 +1,108 @@
+import { View } from 'react-native'
+import { formatWindowLabel } from '../../../src/shared/rate-limit-window-label'
+import {
+ getUsageWindowSections,
+ type UsageWindowSection
+} from '../../../src/shared/usage-provider-windows'
+import {
+ getUsageBarState,
+ getWindowResetLabel,
+ UsageBar,
+ type ProviderRateLimits
+} from '../components/AccountUsage'
+import type { Translate } from '../i18n/mobile-i18n'
+import { useMobileTranslation } from '../i18n/use-mobile-translation'
+import { spacing } from '../theme/mobile-theme'
+import { themedStyleSheet } from '../theme/themed-styles'
+
+const SESSION_WINDOW_MINUTES = 300
+const WEEKLY_WINDOW_MINUTES = 10_080
+const EMPTY_SECTIONS: UsageWindowSection[] = [
+ { kind: 'session', window: null },
+ { kind: 'weekly', window: null }
+]
+
+// The desktop roster's labels, except 5h and 7d use mobile's translated copy (desktop shows 7d as "wk").
+function getWindowLabel(section: UsageWindowSection, t: Translate): string {
+ if (section.kind === 'bucket') {
+ return section.name
+ }
+ if (section.kind === 'fableWeekly') {
+ return t('accounts.screen.fableWindow', 'Fable')
+ }
+ if (section.kind === 'mcp') {
+ return t('accounts.screen.mcpWindow', 'MCP')
+ }
+ const minutes =
+ section.window?.windowMinutes ??
+ (section.kind === 'session' ? SESSION_WINDOW_MINUTES : WEEKLY_WINDOW_MINUTES)
+ if (minutes === SESSION_WINDOW_MINUTES) {
+ return t('accounts.screen.sessionWindow', '5h')
+ }
+ if (minutes === WEEKLY_WINDOW_MINUTES) {
+ return t('accounts.screen.weeklyWindow', '7d')
+ }
+ return formatWindowLabel(minutes)
+}
+
+/** One bar per usage window desktop shows for this provider. */
+export function ProviderUsageWindows({
+ limits,
+ now,
+ isFetching,
+ showEmptyWindows = false
+}: {
+ limits: ProviderRateLimits | null
+ now: number
+ isFetching?: boolean
+ /** Account rows keep empty session/weekly bars so a not-yet-read account still shows its windows. */
+ showEmptyWindows?: boolean
+}) {
+ const t = useMobileTranslation()
+ const sections = (limits ? getUsageWindowSections(limits) : EMPTY_SECTIONS).filter(
+ (section) => showEmptyWindows || section.window !== null
+ )
+ // Pool names can repeat when agy leaves a window unnamed, so count each name's occurrences.
+ const seen = new Map()
+ return (
+
+ {sections.map((section) => {
+ const name = section.kind === 'bucket' ? `bucket:${section.name}` : section.kind
+ const occurrence = seen.get(name) ?? 0
+ seen.set(name, occurrence + 1)
+ const bar = getUsageBarState(limits, section.window, isFetching)
+ const wide = section.kind === 'bucket'
+ return (
+
+ )
+ })}
+
+ )
+}
+
+const styles = themedStyleSheet(() => ({
+ windows: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ columnGap: spacing.md,
+ rowGap: spacing.sm,
+ marginTop: spacing.xs
+ },
+ // Two windows share a line; a third wraps to its own.
+ window: {
+ flexBasis: '40%',
+ flexGrow: 1
+ },
+ wideWindow: {
+ flexBasis: '100%'
+ }
+}))
diff --git a/mobile/src/accounts/mobile-accounts-screen-styles.ts b/mobile/src/accounts/mobile-accounts-screen-styles.ts
index 0c141e93bef..f72f5786ecc 100644
--- a/mobile/src/accounts/mobile-accounts-screen-styles.ts
+++ b/mobile/src/accounts/mobile-accounts-screen-styles.ts
@@ -15,11 +15,6 @@ export const styles = themedStyleSheet(() => ({
group: {
backgroundColor: colors.bgPanel
},
- usageRow: {
- flexDirection: 'row',
- gap: spacing.md,
- marginTop: spacing.xs
- },
errorText: {
fontSize: typography.metaSize,
color: colors.statusRed
diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx
index fa2644c45b2..bf8b3e40191 100644
--- a/mobile/src/components/AccountUsage.tsx
+++ b/mobile/src/components/AccountUsage.tsx
@@ -1,4 +1,4 @@
-import { View, Text, ActivityIndicator } from 'react-native'
+import { View, Text, ActivityIndicator, type StyleProp, type ViewStyle } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
@@ -18,6 +18,7 @@ export {
decodeAccountsSnapshot,
getActiveProviderRateLimits,
getInactiveProviderUsage,
+ getOtherUsageProviders,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
@@ -30,13 +31,18 @@ export function UsageBar({
usedPercent,
unavailable,
loading,
- resetText
+ resetText,
+ wideLabel = false,
+ style
}: {
label: string
usedPercent: number | null
unavailable: boolean
loading?: boolean
resetText?: string | null
+ /** For long pool names: the label sits on its own line above the track. */
+ wideLabel?: boolean
+ style?: StyleProp
}) {
useMobileColorScheme()
// Why: round then clamp so bar width, color, and label share one value (desktop parity).
@@ -51,44 +57,59 @@ export function UsageBar({
? colors.statusAmber
: colors.statusGreen
return (
-
-
- {label}
-
-
+
+
+ {label}
+
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+ {unavailable || used == null ? '—' : `${used}%`}
+ )}
- {loading ? (
-
- ) : (
- {unavailable || used == null ? '—' : `${used}%`}
- )}
+ {resetText ? (
+
+ {resetText}
+
+ ) : null}
- {resetText ? (
-
- {resetText}
-
- ) : null}
)
}
const styles = themedStyleSheet(() => ({
- usageBarColumn: {
+ usageBarRow: {
flex: 1,
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: spacing.xs
+ },
+ usageBarStack: {
gap: 2
},
+ usageBarBody: {
+ gap: 2
+ },
+ usageBarBodyBeside: {
+ flex: 1
+ },
usageBar: {
flexDirection: 'row',
alignItems: 'center',
@@ -97,7 +118,7 @@ const styles = themedStyleSheet(() => ({
usageLabel: {
fontSize: typography.metaSize,
color: colors.textMuted,
- width: 22
+ minWidth: 22
},
usageTrack: {
flex: 1,
@@ -119,10 +140,8 @@ const styles = themedStyleSheet(() => ({
usageSpinner: {
width: 36
},
- // Indent countdown past window label to align with track.
usageResetText: {
fontSize: typography.metaSize,
- color: colors.textMuted,
- marginLeft: 22 + spacing.xs
+ color: colors.textMuted
}
}))
diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts
index 06ce6e67082..a2db194d154 100644
--- a/mobile/src/components/account-usage-state.test.ts
+++ b/mobile/src/components/account-usage-state.test.ts
@@ -2,16 +2,18 @@ import { describe, expect, it } from 'vitest'
import {
getInactiveProviderUsage,
+ decodeAccountsSnapshot,
+ getOtherUsageProviders,
getUsageBarState,
- getWindowResetLabel,
hasActiveProviderUsage,
hasRenderableUsage,
+ type AccountProviderRateLimits,
type AccountsSnapshot,
type InactiveAccountUsage,
type ProviderRateLimits
} from './account-usage-state'
-function makeLimits(overrides: Partial = {}): ProviderRateLimits {
+function makeLimits(overrides: Partial = {}): AccountProviderRateLimits {
return {
provider: 'claude',
session: null,
@@ -26,8 +28,8 @@ function makeLimits(overrides: Partial = {}): ProviderRateLi
function makeSnapshot(
overrides: {
- claudeLimits?: ProviderRateLimits | null
- codexLimits?: ProviderRateLimits | null
+ claudeLimits?: AccountProviderRateLimits | null
+ codexLimits?: AccountProviderRateLimits | null
claudeAccounts?: AccountsSnapshot['claude']['accounts']
codexAccounts?: AccountsSnapshot['codex']['accounts']
inactiveClaudeAccounts?: InactiveAccountUsage[]
@@ -127,84 +129,77 @@ describe('getInactiveProviderUsage', () => {
})
})
-describe('getWindowResetLabel', () => {
- const now = 1_700_000_000_000
- const min = 60_000
- const hour = 60 * min
- const day = 24 * hour
-
- function makeWindow(resetsAt: number | null): ProviderRateLimits['session'] {
- return { usedPercent: 13, windowMinutes: 300, resetsAt, resetDescription: null }
- }
-
- it('is null when there are no limits or the window has no reset timestamp', () => {
- expect(getWindowResetLabel(null, 'session', now)).toBe(null)
- expect(getWindowResetLabel(makeLimits({ status: 'ok' }), 'session', now)).toBe(null)
- expect(
- getWindowResetLabel(makeLimits({ status: 'ok', session: makeWindow(null) }), 'session', now)
- ).toBe(null)
- })
-
- it('formats minutes, hours+minutes, and days+hours like the desktop tooltip', () => {
- expect(
- getWindowResetLabel(makeLimits({ session: makeWindow(now + 47 * min) }), 'session', now)
- ).toBe('Resets in 47m')
- expect(
- getWindowResetLabel(
- makeLimits({ session: makeWindow(now + 3 * hour + 54 * min) }),
- 'session',
- now
- )
- ).toBe('Resets in 3h 54m')
- expect(
- getWindowResetLabel(
- makeLimits({ weekly: makeWindow(now + 6 * day + 7 * hour) }),
- 'weekly',
- now
- )
- ).toBe('Resets in 6d 7h')
- })
-
- it('formats exact hours and exact days without a zero remainder', () => {
- expect(
- getWindowResetLabel(makeLimits({ session: makeWindow(now + 2 * hour) }), 'session', now)
- ).toBe('Resets in 2h')
- expect(
- getWindowResetLabel(makeLimits({ weekly: makeWindow(now + 7 * day) }), 'weekly', now)
- ).toBe('Resets in 7d')
- })
-
- it('reports "Resets now" for a reset timestamp in the past', () => {
- expect(
- getWindowResetLabel(makeLimits({ session: makeWindow(now - min) }), 'session', now)
- ).toBe('Resets now')
- })
-
- it('reads the requested window only', () => {
- const limits = makeLimits({ session: makeWindow(now + hour) })
- expect(getWindowResetLabel(limits, 'weekly', now)).toBe(null)
- })
-})
-
describe('getUsageBarState', () => {
it('keeps stale window data visible during a transient error', () => {
+ const session = { usedPercent: 72, windowMinutes: 300, resetsAt: null, resetDescription: null }
const bar = getUsageBarState(
- makeLimits({
- status: 'error',
- error: 'temporarily unavailable',
- session: { usedPercent: 72, windowMinutes: 300, resetsAt: null, resetDescription: null }
- }),
- 'session'
+ makeLimits({ status: 'error', error: 'temporarily unavailable', session }),
+ session
)
expect(bar).toEqual({ usedPercent: 72, unavailable: false, loading: false })
})
it('shows loading for a fetching provider without a window', () => {
- expect(getUsageBarState(makeLimits({ status: 'fetching' }), 'weekly')).toEqual({
+ expect(getUsageBarState(makeLimits({ status: 'fetching' }), null)).toEqual({
usedPercent: null,
unavailable: false,
loading: true
})
})
})
+
+function providerLimits(
+ provider: string,
+ overrides: Partial
+): ProviderRateLimits {
+ return { ...makeLimits(), ...overrides, provider }
+}
+
+describe('getOtherUsageProviders', () => {
+ it('lists every set-up provider the host sends, in host order, beside Claude and Codex', () => {
+ const week = { usedPercent: 12, windowMinutes: 10_080, resetsAt: null, resetDescription: null }
+ const host = makeSnapshot({ claudeLimits: makeLimits({ status: 'ok', weekly: week }) })
+ const snapshot = decodeAccountsSnapshot({
+ ...host,
+ rateLimits: {
+ ...host.rateLimits,
+ gemini: providerLimits('gemini', {
+ status: 'unavailable',
+ error: 'OAuth disabled'
+ }),
+ antigravity: providerLimits('antigravity', {
+ status: 'ok',
+ buckets: [
+ { name: 'Gemini Models', group: 'Gemini Models', ...week },
+ { name: 'Claude and GPT models', ...week, windowMinutes: 0 }
+ ]
+ }),
+ kimi: providerLimits('kimi', {
+ status: 'idle'
+ }),
+ grok: providerLimits('grok', {
+ status: 'ok',
+ weekly: week
+ }),
+ cursor: providerLimits('cursor', {
+ status: 'error',
+ error: 'Cursor is down'
+ }),
+ 'future-agent': providerLimits('future-agent', {
+ status: 'ok',
+ monthly: week
+ }),
+ broken: { provider: 'broken', status: 'ok', weekly: { usedPercent: 'lots' } },
+ grokAuthConfigured: true
+ }
+ })
+
+ expect(getOtherUsageProviders(snapshot).map((limits) => limits.provider)).toEqual([
+ 'antigravity',
+ 'grok',
+ 'cursor',
+ 'future-agent'
+ ])
+ })
+})
diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts
index 924cf263795..23d4c02485c 100644
--- a/mobile/src/components/account-usage-state.ts
+++ b/mobile/src/components/account-usage-state.ts
@@ -1,10 +1,13 @@
// Keep shapes aligned with shared types/rate-limit-types without pulling Electron-coupled desktop graph into mobile.
// Pure RN-free selectors stay directly testable; AccountUsage re-exports them with UsageBar.
import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format'
-import type {
- AccountsSnapshot,
- InactiveAccountUsage,
- ProviderRateLimits
+import { hasUsageData } from '../../../src/shared/usage-provider-windows'
+import {
+ ProviderRateLimitsSchema,
+ type AccountsSnapshot,
+ type InactiveAccountUsage,
+ type ProviderRateLimits,
+ type RateLimitWindow
} from './accounts-snapshot'
export {
@@ -12,6 +15,7 @@ export {
decodeAccountsSnapshot,
ProviderRateLimitsSchema,
RateLimitRuntimeTargetSchema,
+ type AccountProviderRateLimits,
type AccountsSnapshot,
type ClaudeAccountSummary,
type CodexAccountSummary,
@@ -50,27 +54,15 @@ export function getInactiveProviderUsage(
// System-default logins still fetch quota without managed accounts. Show successful provider or any window data; empty unavailable/error means no credentials.
export function hasActiveProviderUsage(limits: ProviderRateLimits | null): boolean {
- if (!limits) {
- return false
- }
- if (
- limits.session != null ||
- limits.weekly != null ||
- limits.monthly != null ||
- (limits.buckets && limits.buckets.length > 0)
- ) {
- return true
- }
- return limits.status === 'ok'
+ return limits !== null && (hasUsageData(limits) || limits.status === 'ok')
}
// Determine availability per window because transient provider errors retain successful window data.
export function getUsageBarState(
limits: ProviderRateLimits | null,
- windowKey: 'session' | 'weekly',
+ window: RateLimitWindow | null,
isFetchingOverride?: boolean
): UsageBarState {
- const window = limits?.[windowKey] ?? null
const fetching =
isFetchingOverride ?? (limits?.status === 'fetching' || limits?.status === 'idle')
return {
@@ -81,16 +73,27 @@ export function getUsageBarState(
}
// Use desktop formatResetCountdown copy with injectable now; return null without reset timestamp to retain bars-only UI.
-export function getWindowResetLabel(
- limits: ProviderRateLimits | null,
- windowKey: 'session' | 'weekly',
- now: number
-): string | null {
- const resetsAt = limits?.[windowKey]?.resetsAt
- if (resetsAt == null) {
+export function getWindowResetLabel(window: RateLimitWindow | null, now: number): string | null {
+ if (window?.resetsAt == null) {
return null
}
- return formatResetCountdown(resetsAt - now)
+ return formatResetCountdown(window.resetsAt - now)
+}
+
+/** Every other provider the host reports usage for, in the host's order; Claude and Codex have their own sections. */
+export function getOtherUsageProviders(snapshot: AccountsSnapshot): ProviderRateLimits[] {
+ const providers: ProviderRateLimits[] = []
+ for (const [slot, value] of Object.entries(snapshot.rateLimits)) {
+ if (slot === 'claude' || slot === 'codex') {
+ continue
+ }
+ const parsed = ProviderRateLimitsSchema.safeParse(value)
+ // Hidden or signed-out providers come back idle or unavailable with no windows.
+ if (parsed.success && (hasUsageData(parsed.data) || parsed.data.status === 'error')) {
+ providers.push(parsed.data)
+ }
+ }
+ return providers
}
// Show provider with managed accounts or active system-default quota; management is not required for usage UI.
diff --git a/mobile/src/components/accounts-snapshot.ts b/mobile/src/components/accounts-snapshot.ts
index 81f17bbbe0b..2be2b132e64 100644
--- a/mobile/src/components/accounts-snapshot.ts
+++ b/mobile/src/components/accounts-snapshot.ts
@@ -7,7 +7,8 @@ const AccountIdSchema = z.string().min(1)
const RateLimitWindowSchema = z
.object({
usedPercent: z.number().finite().min(0).max(100),
- windowMinutes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
+ // agy reports windows of unknown length as 0.
+ windowMinutes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
resetsAt: TimestampSchema.nullable(),
resetDescription: z.string().nullable()
})
@@ -32,17 +33,8 @@ const RateLimitResetCreditsSchema = z
export const ProviderRateLimitsSchema = z
.object({
- provider: z.enum([
- 'claude',
- 'codex',
- 'gemini',
- 'opencode-go',
- 'kimi',
- 'minimax',
- 'grok',
- 'antigravity',
- 'cursor'
- ]),
+ // Any provider the host polls, so a provider added on desktop decodes here too.
+ provider: z.string().min(1),
session: RateLimitWindowSchema.nullable(),
weekly: RateLimitWindowSchema.nullable(),
fableWeekly: RateLimitWindowSchema.nullable().optional(),
@@ -57,10 +49,15 @@ export const ProviderRateLimitsSchema = z
})
.passthrough()
+// The slots that carry accounts; their exact identity is checked against the slot below.
+const AccountProviderRateLimitsSchema = ProviderRateLimitsSchema.extend({
+ provider: z.enum(['claude', 'codex'])
+})
+
const InactiveAccountUsageSchema = z
.object({
accountId: AccountIdSchema,
- rateLimits: ProviderRateLimitsSchema.nullable(),
+ rateLimits: AccountProviderRateLimitsSchema.nullable(),
updatedAt: TimestampSchema,
isFetching: z.boolean()
})
@@ -174,8 +171,8 @@ export const AccountsSnapshotSchema = z
.passthrough(),
rateLimits: z
.object({
- claude: ProviderRateLimitsSchema.nullable(),
- codex: ProviderRateLimitsSchema.nullable(),
+ claude: AccountProviderRateLimitsSchema.nullable(),
+ codex: AccountProviderRateLimitsSchema.nullable(),
// Older compatible hosts omit runtime target fields and retain host-only selection semantics.
claudeTarget: RateLimitRuntimeTargetSchema.default(HostRateLimitRuntimeTarget),
codexTarget: RateLimitRuntimeTargetSchema.default(HostRateLimitRuntimeTarget),
@@ -222,6 +219,7 @@ export const AccountsSnapshotSchema = z
export type RateLimitWindow = z.infer
export type ProviderRateLimits = z.infer
+export type AccountProviderRateLimits = z.infer
export type InactiveAccountUsage = z.infer
export type RateLimitRuntimeTarget = z.infer
export type ClaudeAccountSummary = z.infer
diff --git a/mobile/src/components/codex-reset-credit.test.ts b/mobile/src/components/codex-reset-credit.test.ts
index d2e930fae3e..5c7c5745feb 100644
--- a/mobile/src/components/codex-reset-credit.test.ts
+++ b/mobile/src/components/codex-reset-credit.test.ts
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const uuid = vi.hoisted(() => ({ next: vi.fn<() => string>() }))
vi.mock('../platform/random-v4-uuid', () => ({ randomV4Uuid: uuid.next }))
-import type { AccountsSnapshot, ProviderRateLimits } from './accounts-snapshot'
+import type { AccountProviderRateLimits, AccountsSnapshot } from './accounts-snapshot'
import {
getActiveCodexAccountIdForRateLimitTarget,
getCodexResetCreditOutcomeCopy,
@@ -14,7 +14,10 @@ import {
const UUID = '11111111-1111-4111-8111-111111111111'
-function makeLimits(availableCount: number, nextExpiresAt: number | null): ProviderRateLimits {
+function makeLimits(
+ availableCount: number,
+ nextExpiresAt: number | null
+): AccountProviderRateLimits {
return {
provider: 'codex',
session: null,
diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json
index 75bedc4b701..92da22eb208 100644
--- a/mobile/src/i18n/locales/de.json
+++ b/mobile/src/i18n/locales/de.json
@@ -5,10 +5,13 @@
"connecting": "Verbindung zum Host wird hergestellt…",
"connectingTo": "Verbindung zu {{name}} wird hergestellt…",
"default": "Systemstandard",
+ "fableWindow": "Fable",
"hostNotFound": "Host nicht gefunden",
"invalidSnapshot": "Ungültige Momentaufnahme der Konten vom Host",
"loadingAccounts": "Konten werden geladen…",
"manageAccountsHint": "Konten auf dem Desktop unter Einstellungen → Konten hinzufügen oder erneut authentifizieren.",
+ "mcpWindow": "MCP",
+ "otherAgents": "Weitere Agenten",
"refreshAccounts": "Konten aktualisieren",
"sessionWindow": "5 h",
"switchFailed": "Konto konnte nicht gewechselt werden",
diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json
index df41881cf1e..a502903ba41 100644
--- a/mobile/src/i18n/locales/en.json
+++ b/mobile/src/i18n/locales/en.json
@@ -290,7 +290,10 @@
"loadingAccounts": "Loading accounts…",
"manageAccountsHint": "Add or re-authenticate accounts from desktop Settings → Accounts.",
"useReset": "Use reset",
- "pairingInvalidMessage": "Re-pair this phone with your desktop to see its accounts."
+ "pairingInvalidMessage": "Re-pair this phone with your desktop to see its accounts.",
+ "otherAgents": "Other agents",
+ "fableWindow": "Fable",
+ "mcpWindow": "MCP"
}
},
"agentHistory": {
diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json
index f1c00be192c..1d97f973eb7 100644
--- a/mobile/src/i18n/locales/es.json
+++ b/mobile/src/i18n/locales/es.json
@@ -5,10 +5,13 @@
"connecting": "Conectando al host…",
"connectingTo": "Conectando a {{name}}…",
"default": "Predeterminado del sistema",
+ "fableWindow": "Fable",
"hostNotFound": "Host no encontrado",
"invalidSnapshot": "Instantánea de cuentas del host no válida",
"loadingAccounts": "Cargando cuentas…",
"manageAccountsHint": "Añade cuentas o vuelve a autenticarlas desde Ajustes → Cuentas en el escritorio.",
+ "mcpWindow": "MCP",
+ "otherAgents": "Otros agentes",
"refreshAccounts": "Actualizar cuentas",
"sessionWindow": "5h",
"switchFailed": "No se pudo cambiar de cuenta",
diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json
index fcf9814f6e3..2591c4313c8 100644
--- a/mobile/src/i18n/locales/fr.json
+++ b/mobile/src/i18n/locales/fr.json
@@ -5,10 +5,13 @@
"connecting": "Connexion à l'hôte…",
"connectingTo": "Connexion à {{name}}…",
"default": "Valeur par défaut du système",
+ "fableWindow": "Fable",
"hostNotFound": "Hôte introuvable",
"invalidSnapshot": "État des comptes invalide reçu de l'hôte",
"loadingAccounts": "Chargement des comptes…",
"manageAccountsHint": "Ajoutez ou réauthentifiez des comptes dans Paramètres → Comptes sur l'ordinateur.",
+ "mcpWindow": "MCP",
+ "otherAgents": "Autres agents",
"refreshAccounts": "Actualiser les comptes",
"sessionWindow": "5 h",
"switchFailed": "Impossible de changer de compte",
diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json
index 2c94c12b6f6..37e79fc6c4b 100644
--- a/mobile/src/i18n/locales/ja.json
+++ b/mobile/src/i18n/locales/ja.json
@@ -5,10 +5,13 @@
"connecting": "ホストに接続中…",
"connectingTo": "「{{name}}」に接続中…",
"default": "システムのデフォルト",
+ "fableWindow": "Fable",
"hostNotFound": "ホストが見つかりません",
"invalidSnapshot": "ホストからのアカウントスナップショットが無効です",
"loadingAccounts": "アカウントを読み込み中…",
"manageAccountsHint": "デスクトップの「設定」→「アカウント」から、アカウントの追加や再認証を行ってください。",
+ "mcpWindow": "MCP",
+ "otherAgents": "その他の Agent",
"refreshAccounts": "アカウントを更新",
"sessionWindow": "5時間",
"switchFailed": "アカウントを切り替えられませんでした",
diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json
index 066f70af48d..2a6a1dcb338 100644
--- a/mobile/src/i18n/locales/ko.json
+++ b/mobile/src/i18n/locales/ko.json
@@ -5,10 +5,13 @@
"connecting": "호스트에 연결 중…",
"connectingTo": "{{name}}에 연결 중…",
"default": "시스템 기본값",
+ "fableWindow": "Fable",
"hostNotFound": "호스트를 찾을 수 없습니다.",
"invalidSnapshot": "호스트에서 받은 계정 스냅샷이 유효하지 않습니다.",
"loadingAccounts": "계정 로드 중…",
"manageAccountsHint": "데스크톱의 설정 → 계정에서 계정을 추가하거나 다시 인증하세요.",
+ "mcpWindow": "MCP",
+ "otherAgents": "기타 에이전트",
"refreshAccounts": "계정 새로고침",
"sessionWindow": "5시간",
"switchFailed": "계정을 전환하지 못했습니다.",
diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json
index be98d875301..5eb591acbfa 100644
--- a/mobile/src/i18n/locales/pt.json
+++ b/mobile/src/i18n/locales/pt.json
@@ -5,10 +5,13 @@
"connecting": "Conectando ao host…",
"connectingTo": "Conectando a {{name}}…",
"default": "Padrão do sistema",
+ "fableWindow": "Fable",
"hostNotFound": "Host não encontrado",
"invalidSnapshot": "Snapshot de contas inválido recebido do host",
"loadingAccounts": "Carregando contas…",
"manageAccountsHint": "Adicione contas ou autentique-as novamente em Configurações → Contas no desktop.",
+ "mcpWindow": "MCP",
+ "otherAgents": "Outros agentes",
"refreshAccounts": "Atualizar contas",
"sessionWindow": "5h",
"switchFailed": "Não foi possível trocar de conta",
diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json
index 42853815ba2..d1e2ecdb781 100644
--- a/mobile/src/i18n/locales/ru.json
+++ b/mobile/src/i18n/locales/ru.json
@@ -5,10 +5,13 @@
"connecting": "Подключение к серверу…",
"connectingTo": "Подключение к {{name}}…",
"default": "Как в системе",
+ "fableWindow": "Fable",
"hostNotFound": "Сервер не найден",
"invalidSnapshot": "Некорректные данные учётных записей от сервера",
"loadingAccounts": "Загрузка учётных записей…",
"manageAccountsHint": "Для добавления учётных записей или повторного входа откройте «Настройки → Учётные записи» в приложении на компьютере.",
+ "mcpWindow": "MCP",
+ "otherAgents": "Другие агенты",
"refreshAccounts": "Обновить учётные записи",
"sessionWindow": "5 ч",
"switchFailed": "Не удалось переключить учётную запись",
diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json
index 2dee0fe8c25..e53985fea4c 100644
--- a/mobile/src/i18n/locales/tr.json
+++ b/mobile/src/i18n/locales/tr.json
@@ -5,10 +5,13 @@
"connecting": "Sunucuya bağlanılıyor…",
"connectingTo": "{{name}} ile bağlantı kuruluyor…",
"default": "Sistem varsayılanı",
+ "fableWindow": "Fable",
"hostNotFound": "Sunucu bulunamadı",
"invalidSnapshot": "Sunucudan gelen hesap anlık görüntüsü geçersiz",
"loadingAccounts": "Hesaplar yükleniyor…",
"manageAccountsHint": "Masaüstünde Ayarlar → Hesaplar bölümünden hesap ekleyin veya hesaplarınızda yeniden oturum açın.",
+ "mcpWindow": "MCP",
+ "otherAgents": "Diğer ajanlar",
"refreshAccounts": "Hesapları yenile",
"sessionWindow": "5 sa",
"switchFailed": "Hesap değiştirilemedi",
diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json
index 8613b89eae9..75946808fe1 100644
--- a/mobile/src/i18n/locales/zh-Hant.json
+++ b/mobile/src/i18n/locales/zh-Hant.json
@@ -5,10 +5,13 @@
"connecting": "正在連線至主機…",
"connectingTo": "正在連線至 {{name}}…",
"default": "系統預設",
+ "fableWindow": "Fable",
"hostNotFound": "找不到主機",
"invalidSnapshot": "主機傳回的帳戶快照無效",
"loadingAccounts": "正在載入帳戶…",
"manageAccountsHint": "請至桌面版的「設定」→「帳戶」,新增帳戶或重新驗證。",
+ "mcpWindow": "MCP",
+ "otherAgents": "其他代理程式",
"refreshAccounts": "重新整理帳戶",
"sessionWindow": "5 小時",
"switchFailed": "無法切換帳戶",
diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json
index 6095d94bc06..931ede31f3e 100644
--- a/mobile/src/i18n/locales/zh.json
+++ b/mobile/src/i18n/locales/zh.json
@@ -5,10 +5,13 @@
"connecting": "正在连接到主机…",
"connectingTo": "正在连接到 {{name}}…",
"default": "系统默认",
+ "fableWindow": "Fable",
"hostNotFound": "未找到主机",
"invalidSnapshot": "主机返回的账户快照无效",
"loadingAccounts": "正在加载账户…",
"manageAccountsHint": "请在桌面的“设置 → 账户”中添加账户或重新进行账户身份验证。",
+ "mcpWindow": "MCP",
+ "otherAgents": "其他智能体",
"refreshAccounts": "刷新账户",
"sessionWindow": "5小时",
"switchFailed": "无法切换账户",
diff --git a/src/renderer/src/components/status-bar/InlineProviderUsage.tsx b/src/renderer/src/components/status-bar/InlineProviderUsage.tsx
index 906979f3acc..ef01ef6a2c1 100644
--- a/src/renderer/src/components/status-bar/InlineProviderUsage.tsx
+++ b/src/renderer/src/components/status-bar/InlineProviderUsage.tsx
@@ -10,7 +10,7 @@ import {
} from '../../../../shared/usage-percentage-display'
import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
import { barColor, clampUsedPercent } from './tooltip'
-import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter'
+import { formatRateLimitWindowChipLabel } from '../../../../shared/rate-limit-window-label'
import { formatUsagePercentageLabel } from './usage-percentage-label'
import { getUsageRosterRowState } from './usage-roster-row-state'
import { translate } from '@/i18n/i18n'
diff --git a/src/renderer/src/components/status-bar/ProviderIdentityCard.tsx b/src/renderer/src/components/status-bar/ProviderIdentityCard.tsx
index 97827f3ff4b..923fd9a43c3 100644
--- a/src/renderer/src/components/status-bar/ProviderIdentityCard.tsx
+++ b/src/renderer/src/components/status-bar/ProviderIdentityCard.tsx
@@ -3,7 +3,8 @@ import { Card } from '@/components/ui/card'
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
import { translate } from '@/i18n/i18n'
-import { formatTimeAgo, getProviderDisplayName, ProviderIcon, ProviderPanel } from './tooltip'
+import { getProviderDisplayName } from '../../../../shared/usage-provider-windows'
+import { formatTimeAgo, ProviderIcon, ProviderPanel } from './tooltip'
export function ProviderIdentityCard({
p,
diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx
index 3896de3e85a..df86d4acb1a 100644
--- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx
+++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx
@@ -17,10 +17,10 @@ import {
USAGE_URGENT_PERCENT,
USAGE_WARNING_PERCENT,
clampUsedPercent,
- getProviderDisplayName,
getProviderUsageErrorMessage,
getProviderUsageStatusLabel
} from './tooltip'
+import { getProviderDisplayName } from '../../../../shared/usage-provider-windows'
import { getOwnerNotSetUpLabelParts } from './owner-not-set-up-copy'
import { isClaudeUsageWaitingForClaude } from './usage-error-copy'
import { getTightestUsageSection, getUsageHeadlineSection } from './UsageRosterPanel'
diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx
index dfd0d910dfc..dc61f091a75 100644
--- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx
+++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx
@@ -4,7 +4,10 @@ import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { SettingsSegmentedControl } from '@/components/settings/form-controls/SettingsFormControls'
import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
import { translate } from '@/i18n/i18n'
-import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter'
+import {
+ formatRateLimitWindowChipLabel,
+ formatWindowLabel
+} from '../../../../shared/rate-limit-window-label'
import { CURSOR_MODELS_BUCKET_NAME } from '../../../../shared/cursor-usage-buckets'
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
import {
@@ -13,7 +16,7 @@ import {
type UsagePercentageDisplay
} from '../../../../shared/usage-percentage-display'
import { barColor, formatResetCountdown, getWindowSections, ProviderIcon } from './tooltip'
-import { getProviderDisplayName } from './usage-error-copy'
+import { getProviderDisplayName } from '../../../../shared/usage-provider-windows'
import { formatPlanLabel, usageTextColorClass } from './usage-roster-formatting'
import { getUsageRosterRowState, type UsageRosterRowState } from './usage-roster-row-state'
import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode'
diff --git a/src/renderer/src/components/status-bar/owner-usage-rate-limits.ts b/src/renderer/src/components/status-bar/owner-usage-rate-limits.ts
index d6ae47e42c2..99d0fb450cb 100644
--- a/src/renderer/src/components/status-bar/owner-usage-rate-limits.ts
+++ b/src/renderer/src/components/status-bar/owner-usage-rate-limits.ts
@@ -9,10 +9,10 @@ import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-f
import { translate } from '@/i18n/i18n'
import {
createPendingProviderSnapshot,
- hasUsageData,
hasUsageProviderSettingsForProvider,
isProviderConfigured
} from './status-bar-provider-visibility'
+import { hasUsageData } from '../../../../shared/usage-provider-windows'
import { describeOwnerNotSetUp } from './owner-not-set-up-copy'
/** Whose usage the status bar shows: this device, or the remote host that owns the accounts. */
diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
index c5ace7471ac..618aaecb785 100644
--- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
+++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
@@ -1,5 +1,6 @@
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
+import { hasUsageData } from '../../../../shared/usage-provider-windows'
export type UsageProviderSettings = Pick<
GlobalSettings,
@@ -36,16 +37,6 @@ type UsageProviderSnapshots = {
type UsageProviderId = ProviderRateLimits['provider']
-export function hasUsageData(provider: ProviderRateLimits): boolean {
- return Boolean(
- provider.session ||
- provider.weekly ||
- provider.fableWeekly ||
- provider.monthly ||
- (provider.buckets && provider.buckets.length > 0)
- )
-}
-
function isProviderSnapshotPending(provider: ProviderRateLimits | null | undefined): boolean {
return provider == null || (provider.status === 'fetching' && !hasUsageData(provider))
}
diff --git a/src/renderer/src/components/status-bar/status-bar-usage-sections.ts b/src/renderer/src/components/status-bar/status-bar-usage-sections.ts
index 8cb0a5164db..c961da35575 100644
--- a/src/renderer/src/components/status-bar/status-bar-usage-sections.ts
+++ b/src/renderer/src/components/status-bar/status-bar-usage-sections.ts
@@ -2,7 +2,7 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rat
import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode'
import { isCursorUsageBucket } from '../../../../shared/cursor-usage-buckets'
import { translate } from '@/i18n/i18n'
-import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter'
+import { formatRateLimitWindowChipLabel } from '../../../../shared/rate-limit-window-label'
import { getUsageHeadlineSection } from './UsageRosterPanel'
export type StatusBarUsageSection = {
diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx
index 5a9f4df3edb..3910f91b919 100644
--- a/src/renderer/src/components/status-bar/tooltip.tsx
+++ b/src/renderer/src/components/status-bar/tooltip.tsx
@@ -8,9 +8,10 @@ import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from
import { translate } from '@/i18n/i18n'
import {
getProviderDisplayName,
- getProviderUsageErrorMessage,
- getProviderUsageStatusLabel
-} from './usage-error-copy'
+ getUsageWindowSections,
+ type UsageWindowSection
+} from '../../../../shared/usage-provider-windows'
+import { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy'
import {
clampUsedPercent,
getDisplayedUsagePercentage,
@@ -29,11 +30,7 @@ export {
export { clampUsedPercent }
-export {
- getProviderDisplayName,
- getProviderUsageErrorMessage,
- getProviderUsageStatusLabel
-} from './usage-error-copy'
+export { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy'
export function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts
@@ -144,54 +141,29 @@ function ErrorMessage({
export function getWindowSections(
p: ProviderRateLimits
): { label: string; window: RateLimitWindow | null }[] {
- if (p.buckets?.length) {
- const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow }))
- return [
- ...bucketSections,
- // Include Cursor’s monthly plan total because pools can understate proximity to its cap.
- ...(p.monthly
- ? [
- {
- label:
- p.provider === 'cursor'
- ? translate('auto.components.status.bar.tooltip.cursor.plan', 'Plan')
- : translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'),
- window: p.monthly
- }
- ]
- : []),
- {
- label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'),
- window: p.weekly
- }
- ]
+ return getUsageWindowSections(p).map((section) => ({
+ label: getWindowSectionLabel(section),
+ window: section.window
+ }))
+}
+
+function getWindowSectionLabel(section: UsageWindowSection): string {
+ switch (section.kind) {
+ case 'bucket':
+ return section.name
+ case 'session':
+ return translate('auto.components.status.bar.tooltip.94038ad2fa', 'Session')
+ case 'weekly':
+ return translate('auto.components.status.bar.tooltip.252c096536', 'Weekly')
+ case 'fableWeekly':
+ return translate('auto.components.status.bar.tooltip.a79c64f87e', 'Fable')
+ case 'monthly':
+ return translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly')
+ case 'plan':
+ return translate('auto.components.status.bar.tooltip.cursor.plan', 'Plan')
+ case 'mcp':
+ return translate('auto.components.status.bar.tooltip.zcode.mcp', 'MCP')
}
- const sections: { label: string; window: RateLimitWindow | null }[] = [
- {
- label: translate('auto.components.status.bar.tooltip.94038ad2fa', 'Session'),
- window: p.session
- },
- {
- label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'),
- window: p.weekly
- }
- ]
- if (p.fableWeekly !== undefined && p.fableWeekly !== null) {
- sections.push({
- label: translate('auto.components.status.bar.tooltip.a79c64f87e', 'Fable'),
- window: p.fableWeekly
- })
- }
- if (p.monthly !== undefined && p.monthly !== null) {
- sections.push({
- label:
- p.provider === 'zcode'
- ? translate('auto.components.status.bar.tooltip.zcode.mcp', 'MCP')
- : translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'),
- window: p.monthly
- })
- }
- return sections
}
// Tooltips invert the theme; use background text tokens for legible primary and secondary content.
diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts
index 53a92848c44..f90a341bc31 100644
--- a/src/renderer/src/components/status-bar/usage-error-copy.ts
+++ b/src/renderer/src/components/status-bar/usage-error-copy.ts
@@ -1,41 +1,8 @@
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import { translate } from '@/i18n/i18n'
+import { getProviderDisplayName } from '../../../../shared/usage-provider-windows'
import { describeOwnerNotSetUp, getOwnerNotSetUpLabel } from './owner-not-set-up-copy'
-export function getProviderDisplayName(provider: ProviderRateLimits['provider']): string {
- if (provider === 'claude') {
- return 'Claude'
- }
- if (provider === 'codex') {
- return 'Codex'
- }
- if (provider === 'gemini') {
- return 'Gemini'
- }
- if (provider === 'opencode-go') {
- return 'OpenCode Go'
- }
- if (provider === 'kimi') {
- return 'Kimi'
- }
- if (provider === 'antigravity') {
- return 'Antigravity'
- }
- if (provider === 'minimax') {
- return 'MiniMax'
- }
- if (provider === 'grok') {
- return 'Grok'
- }
- if (provider === 'zcode') {
- return 'ZCode'
- }
- if (provider === 'cursor') {
- return 'Cursor'
- }
- return provider
-}
-
function isUsageRateLimitError(p: ProviderRateLimits): boolean {
const failureKind = p.usageMetadata?.failureKind
// Structured failure kinds outrank wording that may only name the unread rate limits.
diff --git a/src/renderer/src/components/status-bar/usage-pool-label.ts b/src/renderer/src/components/status-bar/usage-pool-label.ts
index 91c8e1d8530..b36b037faae 100644
--- a/src/renderer/src/components/status-bar/usage-pool-label.ts
+++ b/src/renderer/src/components/status-bar/usage-pool-label.ts
@@ -1,5 +1,8 @@
import { translate } from '@/i18n/i18n'
-import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter'
+import {
+ formatRateLimitWindowChipLabel,
+ formatWindowLabel
+} from '../../../../shared/rate-limit-window-label'
import type {
ProviderRateLimits,
RateLimitBucket,
diff --git a/src/renderer/src/lib/window-label-formatter.ts b/src/shared/rate-limit-window-label.ts
similarity index 93%
rename from src/renderer/src/lib/window-label-formatter.ts
rename to src/shared/rate-limit-window-label.ts
index 67907f5cf72..ff52c70bc0e 100644
--- a/src/renderer/src/lib/window-label-formatter.ts
+++ b/src/shared/rate-limit-window-label.ts
@@ -1,4 +1,4 @@
-import { formatResetDuration } from '../../../shared/rate-limit-reset-format'
+import { formatResetDuration } from './rate-limit-reset-format'
/** Format usage-window duration; retain 10080 minutes → wk compatibility with the original status bar. */
export function formatWindowLabel(windowMinutes: number): string {
diff --git a/src/shared/usage-provider-windows.ts b/src/shared/usage-provider-windows.ts
new file mode 100644
index 00000000000..d0f79b75ced
--- /dev/null
+++ b/src/shared/usage-provider-windows.ts
@@ -0,0 +1,83 @@
+import type { ProviderRateLimits, RateLimitWindow } from './rate-limit-types'
+
+// Structural so mobile's decoded snapshot, whose provider id is any string, fits too.
+type ProviderUsageWindows = {
+ provider: string
+ session: RateLimitWindow | null
+ weekly: RateLimitWindow | null
+ fableWeekly?: RateLimitWindow | null
+ monthly?: RateLimitWindow | null
+ buckets?: readonly (RateLimitWindow & { name: string })[]
+}
+
+export type UsageWindowSection =
+ | { kind: 'bucket'; name: string; window: RateLimitWindow }
+ | {
+ kind: 'session' | 'weekly' | 'fableWeekly' | 'monthly' | 'plan' | 'mcp'
+ window: RateLimitWindow | null
+ }
+
+// `satisfies` makes a provider added to ProviderRateLimits fail the typecheck until it has a name.
+const PROVIDER_DISPLAY_NAMES: Readonly> = {
+ claude: 'Claude',
+ codex: 'Codex',
+ gemini: 'Gemini',
+ 'opencode-go': 'OpenCode Go',
+ kimi: 'Kimi',
+ antigravity: 'Antigravity',
+ minimax: 'MiniMax',
+ grok: 'Grok',
+ zcode: 'ZCode',
+ cursor: 'Cursor'
+} satisfies Record
+
+// A provider id from a newer host falls back to the id itself.
+export function getProviderDisplayName(provider: string): string {
+ return Object.hasOwn(PROVIDER_DISPLAY_NAMES, provider)
+ ? PROVIDER_DISPLAY_NAMES[provider]
+ : provider
+}
+
+export function hasUsageData(provider: ProviderUsageWindows): boolean {
+ return Boolean(
+ provider.session ||
+ provider.weekly ||
+ provider.fableWeekly ||
+ provider.monthly ||
+ (provider.buckets && provider.buckets.length > 0)
+ )
+}
+
+/** The usage windows a provider shows, in order; session and weekly stay as null placeholders. */
+export function getUsageWindowSections(p: ProviderUsageWindows): UsageWindowSection[] {
+ if (p.buckets?.length) {
+ return [
+ ...p.buckets.map((bucket) => ({
+ kind: 'bucket' as const,
+ name: bucket.name,
+ window: bucket
+ })),
+ // Include Cursor’s monthly plan total because pools can understate proximity to its cap.
+ ...(p.monthly
+ ? [
+ {
+ kind: p.provider === 'cursor' ? ('plan' as const) : ('monthly' as const),
+ window: p.monthly
+ }
+ ]
+ : []),
+ { kind: 'weekly', window: p.weekly }
+ ]
+ }
+ const sections: UsageWindowSection[] = [
+ { kind: 'session', window: p.session },
+ { kind: 'weekly', window: p.weekly }
+ ]
+ if (p.fableWeekly) {
+ sections.push({ kind: 'fableWeekly', window: p.fableWeekly })
+ }
+ if (p.monthly) {
+ sections.push({ kind: p.provider === 'zcode' ? 'mcp' : 'monthly', window: p.monthly })
+ }
+ return sections
+}