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 +}