diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index c1003316784..9ada44041ae 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -94,6 +94,8 @@ describe('fetchAntigravityRateLimits', () => { expect(result.buckets).toEqual([ { name: 'Gemini Models', + group: 'Gemini Models', + windowName: 'Weekly Limit Remaining', usedPercent: 60, windowMinutes: 10_080, resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), diff --git a/src/main/rate-limits/antigravity-usage-response.test.ts b/src/main/rate-limits/antigravity-usage-response.test.ts index be32522a319..d38bea36534 100644 --- a/src/main/rate-limits/antigravity-usage-response.test.ts +++ b/src/main/rate-limits/antigravity-usage-response.test.ts @@ -26,6 +26,8 @@ describe('parseAntigravityUsageStdout', () => { { id: 'gemini-weekly', name: 'Gemini Models', + group: 'Gemini Models', + windowName: 'Weekly Limit Remaining', usedPercent: 0, windowMinutes: 10_080, resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), @@ -34,6 +36,8 @@ describe('parseAntigravityUsageStdout', () => { { id: '3p-weekly', name: 'Claude and GPT models', + group: 'Claude and GPT models', + windowName: 'Weekly Limit Remaining', usedPercent: 0, windowMinutes: 10_080, resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), @@ -120,6 +124,45 @@ describe('parseAntigravityUsageEnvelope', () => { ]) }) + it('fills the structured group and window name the renderer labels pools from', () => { + const reading = parseAntigravityUsageEnvelope( + envelope([ + { + name: 'Gemini Models', + buckets: [ + { id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 }, + { id: 'gemini-weekly', window: 'weekly', remaining_fraction: 1 } + ] + } + ]) + ) + + expect(reading?.buckets[0]).toMatchObject({ + group: 'Gemini Models', + windowName: '5h Limit Remaining' + }) + expect(reading?.buckets[1]).toMatchObject({ group: 'Gemini Models' }) + expect(reading?.buckets[1]).not.toHaveProperty('windowName') + }) + + it('keeps only the four window fields on the session and weekly summaries', () => { + const reading = parseAntigravityUsageEnvelope( + envelope([ + { + name: 'Gemini Models', + buckets: [ + { id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 }, + { id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 } + ] + } + ]) + ) + + const window = { usedPercent: 0, resetsAt: null, resetDescription: null } + expect(reading?.session).toEqual({ ...window, windowMinutes: 300 }) + expect(reading?.weekly).toEqual({ ...window, windowMinutes: 10_080 }) + }) + it('drops a disabled bucket instead of drawing it as unused', () => { const reading = parseAntigravityUsageEnvelope( envelope([ @@ -171,12 +214,23 @@ describe('parseAntigravityUsageEnvelope', () => { envelope([ { name: 'Gemini Models', - buckets: [{ id: 'gemini-monthly', window: 'monthly', remaining_fraction: 0.4 }] + buckets: [ + { + id: 'gemini-monthly', + name: 'Monthly Limit Remaining', + window: 'monthly', + remaining_fraction: 0.4 + } + ] } ]) ) - expect(reading?.buckets[0]).toMatchObject({ usedPercent: 60, windowMinutes: 0 }) + expect(reading?.buckets[0]).toMatchObject({ + usedPercent: 60, + windowMinutes: 0, + windowName: 'Monthly Limit Remaining' + }) expect(reading?.session).toBeNull() expect(reading?.weekly).toBeNull() }) diff --git a/src/main/rate-limits/antigravity-usage-response.ts b/src/main/rate-limits/antigravity-usage-response.ts index 3e4077ed35f..c932a18f01f 100644 --- a/src/main/rate-limits/antigravity-usage-response.ts +++ b/src/main/rate-limits/antigravity-usage-response.ts @@ -111,9 +111,13 @@ function parseBucket( return null } const usedPercent = Math.min(100, Math.max(0, Math.round((1 - fraction) * 100))) + const windowName = readString(raw.name) return { id, - name: formatBucketName(groupName, readString(raw.name), siblings), + name: formatBucketName(groupName, windowName, siblings), + // Why structured fields: the renderer builds short labels without splitting `name`. + group: groupName, + ...(windowName ? { windowName } : {}), usedPercent, // Why 0 and not null: RateLimitWindow requires a number, and an unrecognised agy window still // carries a real remaining fraction worth showing as a named bucket. @@ -181,12 +185,8 @@ export function parseAntigravityUsageEnvelope(value: unknown): AntigravityUsageR if (buckets.length === 0) { return null } - // Why drop the id first: the summary is a RateLimitWindow, and the summariser only strips `name`, - // so an id left on the bucket would ride into the published window. - const windowsOf = (minutes: number): RateLimitBucket[] => - buckets - .filter((bucket) => bucket.windowMinutes === minutes) - .map(({ id: _id, ...bucket }) => bucket) + const windowsOf = (minutes: number): AntigravityUsageBucket[] => + buckets.filter((bucket) => bucket.windowMinutes === minutes) return { session: deriveMostConstrainedWindow(windowsOf(SESSION_WINDOW_MINUTES)), weekly: deriveMostConstrainedWindow(windowsOf(WEEKLY_WINDOW_MINUTES)), diff --git a/src/main/rate-limits/rate-limit-bucket-summary.ts b/src/main/rate-limits/rate-limit-bucket-summary.ts index 585c24a95aa..674c750521c 100644 --- a/src/main/rate-limits/rate-limit-bucket-summary.ts +++ b/src/main/rate-limits/rate-limit-bucket-summary.ts @@ -14,6 +14,6 @@ export function deriveMostConstrainedWindow(buckets: RateLimitBucket[]): RateLim const mostConstrained = buckets.reduce((worst, bucket) => bucket.usedPercent > worst.usedPercent ? bucket : worst ) - const { name: _name, ...window } = mostConstrained - return window + const { usedPercent, windowMinutes, resetsAt, resetDescription } = mostConstrained + return { usedPercent, windowMinutes, resetsAt, resetDescription } } diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 386463f67bb..1dbe3ac6c89 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -27,6 +27,9 @@ import { getTightestUsageSection, getUsageHeadlineSection } from './UsageRosterP import { formatBareUsagePercentage, formatUsagePercentageLabel } from './usage-percentage-label' import { translate } from '@/i18n/i18n' import { getStatusBarUsageSections } from './status-bar-usage-sections' +import { GroupedPoolUsage } from './usage-grouped-pools' +import { formatStatusBarSectionLabel, isGroupedUsagePool } from './usage-pool-label' +import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' function MiniBar({ usedPct, @@ -251,6 +254,21 @@ export function ProviderSegment({ const statusLabel = p ? getProviderUsageStatusLabel(p) : '' // Why: not a problem; Claude updates its own login the next time it runs. const calm = p ? isClaudeUsageWaitingForClaude(p) : false + const tightest = p + ? mode === 'compact' + ? getUsageHeadlineSection(p) + : getTightestUsageSection(p) + : null + const sections = getStatusBarUsageSections(p, mode) + const groupedBuckets = sections.map((section) => section.window).filter(isGroupedUsagePool) + const showLabels = mode === 'verbose' || !compact + const now = useResetCountdownClock( + showLabels + ? groupedBuckets + .filter((bucket) => clampUsedPercent(bucket.usedPercent) >= USAGE_URGENT_PERCENT) + .map((bucket) => bucket.resetsAt) + : [] + ) // Idle / initial load if (!p || p.status === 'idle') { @@ -262,8 +280,6 @@ export function ProviderSegment({ ) } - const tightest = mode === 'compact' ? getUsageHeadlineSection(p) : getTightestUsageSection(p) - // Fetching with no prior data if (p.status === 'fetching' && !tightest) { return ( @@ -311,7 +327,8 @@ export function ProviderSegment({ // Has data (ok, fetching with stale data, or error with stale data) const isStale = p.status === 'error' && !calm const showBalance = isExtraUsageActive(p) - const sections = getStatusBarUsageSections(p, mode) + const showGroupedPools = + mode === 'verbose' && groupedBuckets.length > 0 && groupedBuckets.length === sections.length return ( @@ -319,18 +336,27 @@ export function ProviderSegment({ {mode === 'verbose' && tightest && !compact ? ( ) : null} - {sections.map((section, index) => ( - - {index > 0 ? · : null} - - - ))} + {showGroupedPools ? ( + + ) : ( + sections.map((section, index) => ( + + {index > 0 ? · : null} + + + )) + )} {showBalance && p.extraUsage ? ( <> · diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx index 2c9ba83b4b1..80890457282 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -155,6 +155,38 @@ describe('UsageRow', () => { expect(markup).toContain('10%') }) + it('keeps full Antigravity pool names in the click panel', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Gemini Models · Weekly Limit Remaining') + }) + it('renders the tightest window inline in compact mode', () => { const markup = renderToStaticMarkup( = {}): Provide } } +const FIVE_HOUR_MINUTES = 300 + +/** A tier with a 5h and a weekly pool per group, from a host that publishes `group`/`windowName`. */ +function groupedLimits(): ProviderRateLimits { + const pool = (group: string, windowMinutes: number, usedPercent: number) => { + const windowName = + windowMinutes === FIVE_HOUR_MINUTES ? '5h Limit Remaining' : 'Weekly Limit Remaining' + return { + name: `${group} · ${windowName}`, + group, + windowName, + ...weeklyWindow(usedPercent), + windowMinutes + } + } + return antigravityLimits({ + weekly: weeklyWindow(4), + buckets: [ + pool('Gemini Models', WEEKLY_MINUTES, 4), + pool('Gemini Models', FIVE_HOUR_MINUTES, 0), + pool('Claude and GPT models', WEEKLY_MINUTES, 0), + pool('Claude and GPT models', FIVE_HOUR_MINUTES, 0) + ] + }) +} + describe('Antigravity status-bar segment', () => { it('renders both model-group pools by name', async () => { // Why: the verbose bucket allowlist was written for Gemini's experimental models, so @@ -63,6 +89,44 @@ describe('Antigravity status-bar segment', () => { expect(markup).toContain('100%') }) + it('labels grouped pools by short group and window length', async () => { + const { ProviderSegment } = await import('./StatusBar') + const markup = renderToStaticMarkup( + + ) + + // Why '>Gemini<': the group's `title` still holds the full name, so a bare match is vacuous. + expect(markup).toContain('>Gemini<') + expect(markup).toContain('>Claude/GPT<') + expect(markup).toContain('5h') + expect(markup).toContain('4%') + expect(markup).not.toContain('Gemini Models ·') + expect(markup).not.toContain('Limit Remaining') + // 5h sorts before wk within a group. + expect(markup.indexOf('5h')).toBeLessThan(markup.indexOf('wk')) + }) + + it('uses the short pool label in compact mode', async () => { + const { ProviderSegment } = await import('./StatusBar') + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('4%') + expect(markup).toContain('Gemini wk') + expect(markup).not.toContain('Limit Remaining') + }) + + it('renders no pool label when the compact bar hides labels', async () => { + const { ProviderSegment } = await import('./StatusBar') + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('4%') + expect(markup).not.toContain('Gemini') + }) + it('shows the weekly window when a tier reports no session pool', async () => { // Why: the verbose fallback chain was `session ?? monthly`, so a weekly-only provider with a // real limit rendered an empty segment. diff --git a/src/renderer/src/components/status-bar/status-bar-pool-countdown.test.tsx b/src/renderer/src/components/status-bar/status-bar-pool-countdown.test.tsx new file mode 100644 index 00000000000..c69004c7223 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-pool-countdown.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { ProviderSegment } from './StatusBarProviderSegment' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string, values: Record = {}) => + Object.entries(values).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) +vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) + +const START = Date.UTC(2026, 9, 8, 12) +const MINUTE = 60_000 + +function limits(usedPercent = 90): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + name: 'Gemini Models · 5h Limit Remaining', + group: 'Gemini Models', + windowName: '5h Limit Remaining', + usedPercent, + windowMinutes: 300, + resetsAt: START + 90 * MINUTE + 30_000, + resetDescription: null + } + ], + status: 'ok', + error: null, + updatedAt: START + } +} + +describe('status-bar pool countdown', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(START) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + }) + + it.each([ + ['verbose', 'used'], + ['verbose', 'remaining'], + ['compact', 'used'], + ['compact', 'remaining'] + ] as const)('ticks without new quota data in %s mode with %s percentages', (mode, display) => { + const { container } = render( + + ) + expect(container.textContent).toContain('5h (1h 30m)') + expect(container.textContent).toContain(display === 'used' ? '90%' : '10%') + + act(() => vi.advanceTimersByTime(30_001)) + expect(container.textContent).toContain('5h (1h 29m)') + + act(() => vi.advanceTimersByTime(MINUTE)) + expect(container.textContent).toContain('5h (1h 28m)') + }) + + it.each(['verbose', 'compact'] as const)( + 'identifies the limiting window when both pools reset together in %s mode', + (mode) => { + const p = limits() + p.buckets?.push({ + name: 'Gemini Models · Weekly Limit Remaining', + group: 'Gemini Models', + windowName: 'Weekly Limit Remaining', + usedPercent: 95, + windowMinutes: 10_080, + resetsAt: START + 90 * MINUTE + 30_000, + resetDescription: null + }) + const { container } = render( + + ) + + if (mode === 'verbose') { + expect(container.textContent).toContain('10% 5h (1h 30m)') + expect(container.textContent).toContain('5% wk (1h 30m)') + } else { + expect(container.textContent).toBe('5% Gemini wk (1h 30m)') + } + } + ) + + it('shows now at the reset and stops scheduling updates', () => { + vi.setSystemTime(START + 90 * MINUTE) + const { container } = render() + expect(container.textContent).toContain('5h (0m)') + act(() => vi.advanceTimersByTime(30_001)) + expect(container.textContent).toContain('5h (now)') + expect(vi.getTimerCount()).toBe(0) + }) + + it('starts the clock when an idle segment receives an urgent pool', () => { + const { container, rerender } = render( + + ) + rerender() + act(() => vi.advanceTimersByTime(30_001)) + expect(container.textContent).toContain('1h 29m') + }) + + it('does not schedule countdown updates for nonurgent pools', () => { + const { container } = render() + expect(container.textContent).toContain('5h') + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not schedule countdown updates when compact labels are hidden', () => { + const { container } = render( + + ) + expect(container.textContent).toBe('90%') + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['idle', 'unavailable'] as const)( + 'does not schedule countdown updates for a hidden %s reading', + (status) => { + const { container } = render( + + ) + expect(container.textContent).not.toContain('1h 30m') + expect(vi.getTimerCount()).toBe(0) + } + ) + + it('stops countdown updates when the labels become hidden', () => { + const { container, rerender } = render( + + ) + expect(container.textContent).toContain('1h 30m') + expect(vi.getTimerCount()).toBe(1) + + rerender() + expect(container.textContent).toBe('90%') + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-grouped-pools.tsx b/src/renderer/src/components/status-bar/usage-grouped-pools.tsx new file mode 100644 index 00000000000..2988655d8b7 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-grouped-pools.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import type { RateLimitBucket } from '../../../../shared/rate-limit-types' +import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { + formatPoolGroupLabel, + formatPoolWindowLabel, + type GroupedUsagePool +} from './usage-pool-label' +import { formatBareUsagePercentage } from './usage-percentage-label' + +// Shortest window first; an unknown window (0) sorts last. Stable, so ties keep agy's order. +function compareWindows(a: RateLimitBucket, b: RateLimitBucket): number { + if (a.windowMinutes === b.windowMinutes) { + return 0 + } + if (a.windowMinutes === 0) { + return 1 + } + if (b.windowMinutes === 0) { + return -1 + } + return a.windowMinutes - b.windowMinutes +} + +// Why not `name`: it repeats when agy omits bucket names, so key by length and its occurrence count. +function keyedWindows( + buckets: readonly RateLimitBucket[] +): { bucket: RateLimitBucket; key: string }[] { + const seen = new Map() + return [...buckets].sort(compareWindows).map((bucket) => { + const occurrence = seen.get(bucket.windowMinutes) ?? 0 + seen.set(bucket.windowMinutes, occurrence + 1) + return { bucket, key: `${bucket.windowMinutes}:${occurrence}` } + }) +} + +/** Model-group pools as "Gemini 0% 5h · 4% wk │ Claude/GPT …"; children are flex items of the segment row. */ +export function GroupedPoolUsage({ + visibleBuckets, + allBuckets, + display, + now +}: { + visibleBuckets: readonly GroupedUsagePool[] + allBuckets: readonly RateLimitBucket[] + display: UsagePercentageDisplay + now: number +}): React.JSX.Element { + const groups = new Map() + for (const bucket of visibleBuckets) { + const buckets = groups.get(bucket.group) ?? [] + buckets.push(bucket) + groups.set(bucket.group, buckets) + } + return ( + <> + {[...groups].map(([group, buckets], groupIndex) => { + const windows = keyedWindows(buckets) + return ( + + {groupIndex > 0 ? ( + + ) + })} + + ) +} diff --git a/src/renderer/src/components/status-bar/usage-pool-label.test.ts b/src/renderer/src/components/status-bar/usage-pool-label.test.ts new file mode 100644 index 00000000000..a3213d3178f --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-pool-label.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits, RateLimitBucket } from '../../../../shared/rate-limit-types' +import { + formatPoolShortLabel, + formatStatusBarSectionLabel, + formatPoolGroupLabel, + formatPoolWindowLabel +} from './usage-pool-label' +import { getTightestUsageSection } from './UsageRosterPanel' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values: Record = {}) => + Object.entries(values).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) +vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) +vi.mock('@/hooks/useResetCountdownClock', () => ({ useResetCountdownClock: () => 0 })) +vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: () => null })) +vi.mock('@/components/settings/SettingsFormControls', () => ({ + SettingsSegmentedControl: () => null +})) + +const FIVE_HOURS = 300 +const WEEK = 10_080 + +function pool( + group: string | undefined, + windowMinutes: number, + overrides: Partial = {} +): RateLimitBucket { + const windowName = windowMinutes === FIVE_HOURS ? '5h Limit Remaining' : 'Weekly Limit Remaining' + return { + name: group ? `${group} · ${windowName}` : 'Pro', + ...(group ? { group, windowName } : {}), + usedPercent: 0, + windowMinutes, + resetsAt: null, + resetDescription: null, + ...overrides + } +} + +const TWO_GROUPS = [ + pool('Gemini Models', FIVE_HOURS), + pool('Gemini Models', WEEK), + pool('Claude and GPT models', FIVE_HOURS), + pool('Claude and GPT models', WEEK) +] + +describe('pool labels', () => { + it('shortens two groups with two windows each', () => { + expect(TWO_GROUPS.map((b) => formatPoolShortLabel(b, TWO_GROUPS))).toEqual([ + 'Gemini 5h', + 'Gemini wk', + 'Claude/GPT 5h', + 'Claude/GPT wk' + ]) + expect(formatPoolGroupLabel('Claude and GPT models', TWO_GROUPS)).toBe('Claude/GPT') + }) + + it('labels a single-window group by its window length', () => { + const buckets = [pool('Gemini Models', WEEK, { name: 'Gemini Models' })] + expect(formatPoolShortLabel(buckets[0], buckets)).toBe('Gemini wk') + }) + + it('uses agy’s window name without "Limit Remaining" for an unknown window', () => { + const daily = pool('Gemini Models', 0, { windowName: 'Daily Limit Remaining' }) + expect(formatPoolShortLabel(daily, [daily])).toBe('Gemini Daily') + }) + + it('shows the group alone for an unknown window with no window name', () => { + const { windowName: _windowName, ...unnamed } = pool('Gemini Models', 0) + expect(formatPoolShortLabel(unnamed, [unnamed])).toBe('Gemini') + expect(formatPoolWindowLabel(unnamed)).toBe('') + }) + + it('keeps full group names when two groups shorten to the same text', () => { + const buckets = [pool('Gemini Models', WEEK), pool('Gemini models', WEEK)] + expect(buckets.map((b) => formatPoolShortLabel(b, buckets))).toEqual([ + 'Gemini Models wk', + 'Gemini models wk' + ]) + }) + + it('does not shorten another group to a name retained after a collision', () => { + const buckets = [ + pool('Gemini Models', WEEK), + pool('Gemini models', WEEK), + pool('Gemini Models Models', WEEK) + ] + expect(buckets.map((bucket) => formatPoolShortLabel(bucket, buckets))).toEqual([ + 'Gemini Models wk', + 'Gemini models wk', + 'Gemini Models Models wk' + ]) + }) + + it('passes a group name the rule does not match through', () => { + const buckets = [pool('Gemini Models', WEEK), pool('Gemini models (Preview)', WEEK)] + expect(buckets.map((b) => formatPoolShortLabel(b, buckets))).toEqual([ + 'Gemini wk', + 'Gemini models (Preview) wk' + ]) + }) + + it('keeps the full name for a pool with no group (an old host)', () => { + const legacy = pool(undefined, WEEK, { name: 'Gemini Models · Weekly Limit Remaining' }) + expect(formatPoolShortLabel(legacy, [legacy])).toBe('Gemini Models · Weekly Limit Remaining') + }) + + it('leaves Gemini CLI’s 60-minute "Pro" pool unchanged', () => { + const pro = pool(undefined, 60, { name: 'Pro' }) + expect(formatPoolShortLabel(pro, [pro])).toBe('Pro') + }) + + it('adds a reset countdown only once a pool is urgent and its reset is known', () => { + const now = Date.UTC(2026, 9, 1, 12) + const resetsAt = now + 130 * 60_000 + const label = (overrides: Partial): string => { + const bucket = pool('Gemini Models', FIVE_HOURS, overrides) + return formatPoolWindowLabel(bucket, now) + } + + expect(label({ usedPercent: 80, resetsAt })).toBe('5h (2h 10m)') + expect(label({ usedPercent: 79, resetsAt })).toBe('5h') + expect(label({ usedPercent: 80, resetsAt: null })).toBe('5h') + }) + + it.each([ + [WEEK, 'Weekly Limit Remaining', 'wk (2h 10m)'], + [0, 'Daily Limit Remaining', 'Daily (2h 10m)'], + [0, undefined, '2h 10m'] + ] as const)( + 'keeps the window identity for an urgent %s-minute pool', + (minutes, windowName, expected) => { + const now = Date.UTC(2026, 9, 1, 12) + const bucket = pool('Gemini Models', minutes, { + windowName, + usedPercent: 90, + resetsAt: now + 130 * 60_000 + }) + + expect(formatPoolWindowLabel(bucket, now)).toBe(expected) + } + ) +}) + +describe('formatStatusBarSectionLabel', () => { + it('labels a tie with the pool, not the derived weekly section', () => { + const buckets = [ + pool('Gemini Models', FIVE_HOURS, { usedPercent: 30 }), + pool('Gemini Models', WEEK, { usedPercent: 50 }), + pool('Claude and GPT models', WEEK, { usedPercent: 10 }) + ] + const p: ProviderRateLimits = { + provider: 'antigravity', + session: null, + // Derived from the pools, so it ties with the Gemini weekly pool. + weekly: { usedPercent: 50, windowMinutes: WEEK, resetsAt: null, resetDescription: null }, + buckets, + updatedAt: 0, + error: null, + status: 'ok' + } + + const tightest = getTightestUsageSection(p) + expect(tightest?.window).toBe(buckets[1]) + expect(formatStatusBarSectionLabel(p, tightest!)).toBe('Gemini wk') + }) + + it('returns the section label when the window is not a grouped bucket', () => { + const p: ProviderRateLimits = { + provider: 'gemini', + session: null, + weekly: null, + buckets: [pool(undefined, 60, { name: 'Pro', usedPercent: 40 })], + updatedAt: 0, + error: null, + status: 'ok' + } + expect(formatStatusBarSectionLabel(p, getTightestUsageSection(p)!)).toBe('Pro') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-pool-label.ts b/src/renderer/src/components/status-bar/usage-pool-label.ts new file mode 100644 index 00000000000..91c8e1d8530 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-pool-label.ts @@ -0,0 +1,84 @@ +import { translate } from '@/i18n/i18n' +import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter' +import type { + ProviderRateLimits, + RateLimitBucket, + RateLimitWindow +} from '../../../../shared/rate-limit-types' +import { clampUsedPercent } from '../../../../shared/usage-percentage-display' +import { USAGE_URGENT_PERCENT } from './provider-extra-usage-section' + +export type GroupedUsagePool = RateLimitBucket & { group: string } + +const WEEKLY_WINDOW_MINUTES = 10_080 + +export function isGroupedUsagePool(window: RateLimitWindow): window is GroupedUsagePool { + return ( + 'name' in window && + typeof window.name === 'string' && + 'group' in window && + typeof window.group === 'string' && + window.group.length > 0 + ) +} + +function shortenGroup(group: string): string { + const short = group + .replace(/\s+models$/i, '') + .replace(/\s+and\s+/gi, '/') + .trim() + return short || group +} + +export function formatPoolGroupLabel(group: string, buckets: readonly RateLimitBucket[]): string { + const short = shortenGroup(group) + const collides = buckets.some( + (other) => + other.group && + other.group !== group && + (other.group === short || shortenGroup(other.group) === short) + ) + return collides ? group : short +} + +function formatPoolWindowName(bucket: RateLimitBucket): string { + // An unmapped agy window has no length; formatWindowLabel(0) would invent "0m". + if (!(bucket.windowMinutes > 0)) { + return (bucket.windowName ?? '').replace(/\s*limit remaining$/i, '').trim() + } + if (bucket.windowMinutes === WEEKLY_WINDOW_MINUTES) { + return translate('auto.components.status.bar.StatusBar.5c938d39ac', 'wk') + } + return formatWindowLabel(bucket.windowMinutes) +} + +export function formatPoolWindowLabel(bucket: RateLimitBucket, now?: number): string { + const label = formatPoolWindowName(bucket) + // Urgency follows consumption even when the user displays the percentage remaining. + if (clampUsedPercent(bucket.usedPercent) >= USAGE_URGENT_PERCENT && bucket.resetsAt != null) { + const countdown = formatRateLimitWindowChipLabel(bucket, now) + return label ? `${label} (${countdown})` : countdown + } + return label +} + +export function formatPoolShortLabel( + bucket: RateLimitBucket, + buckets: readonly RateLimitBucket[], + now?: number +): string { + if (!bucket.group) { + return bucket.name + } + return `${formatPoolGroupLabel(bucket.group, buckets)} ${formatPoolWindowLabel(bucket, now)}`.trim() +} + +export function formatStatusBarSectionLabel( + p: ProviderRateLimits, + section: { label: string; window: RateLimitWindow }, + now?: number +): string { + return isGroupedUsagePool(section.window) + ? formatPoolShortLabel(section.window, p.buckets ?? [], now) + : section.label +} diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 37eab32839d..7fc1e5033d0 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -39,6 +39,10 @@ export type ExtraUsageBalance = CurrencyExtraUsageBalance | CreditExtraUsageBala export type RateLimitBucket = RateLimitWindow & { name: string + /** Model-group name the pool belongs to (Antigravity), when the provider groups pools. */ + group?: string + /** Provider's own window name for the pool, e.g. agy's "Weekly Limit Remaining". */ + windowName?: string } export type UsageRateLimitSource = 'oauth' | 'cli' | 'web' | 'live-session'