Show short Antigravity pool labels in status bar (#31007)

* Show short Antigravity pool labels in the status bar

Antigravity pools rendered their full "<group> · <agy window name>" in the
status bar. The host now publishes structured group/windowName fields on each
bucket, and the renderer labels pools as short group + window length
("Gemini 5h", "Claude/GPT wk"), with a reset countdown once a pool is urgent.
Old hosts without the fields keep today's full names; the click panel and
tooltips are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* Keep Antigravity status bar reset countdowns current

* Preserve rate-limit pool identities in status labels

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinjing
2026-10-19 22:32:04 -07:00
committed by GitHub
co-authored by Claude
parent 937d2d3806
commit 7b1e73f3ea
12 changed files with 712 additions and 26 deletions
@@ -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(),
@@ -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()
})
@@ -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)),
@@ -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 }
}
@@ -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 (
<span className="inline-flex items-center gap-1.5">
@@ -319,18 +336,27 @@ export function ProviderSegment({
{mode === 'verbose' && tightest && !compact ? (
<MiniBar usedPct={clampUsedPercent(tightest.window.usedPercent)} display={display} />
) : null}
{sections.map((section, index) => (
<React.Fragment key={section.key}>
{index > 0 ? <span className="text-muted-foreground">·</span> : null}
<WindowLabel
w={section.window}
label={section.label}
display={display}
showLabel={mode === 'verbose' || !compact}
labelFirst={section.labelFirst}
/>
</React.Fragment>
))}
{showGroupedPools ? (
<GroupedPoolUsage
visibleBuckets={groupedBuckets}
allBuckets={p.buckets ?? []}
display={display}
now={now}
/>
) : (
sections.map((section, index) => (
<React.Fragment key={section.key}>
{index > 0 ? <span className="text-muted-foreground">·</span> : null}
<WindowLabel
w={section.window}
label={formatStatusBarSectionLabel(p, section, now)}
display={display}
showLabel={showLabels}
labelFirst={section.labelFirst}
/>
</React.Fragment>
))
)}
{showBalance && p.extraUsage ? (
<>
<span className="text-muted-foreground">·</span>
@@ -155,6 +155,38 @@ describe('UsageRow', () => {
expect(markup).toContain('10%')
})
it('keeps full Antigravity pool names in the click panel', () => {
const markup = renderToStaticMarkup(
<UsageRow
p={{
provider: 'antigravity',
session: null,
weekly: null,
buckets: [
{
name: 'Gemini Models · Weekly Limit Remaining',
group: 'Gemini Models',
windowName: 'Weekly Limit Remaining',
usedPercent: 4,
windowMinutes: 10_080,
resetsAt: null,
resetDescription: null
}
],
updatedAt: 0,
status: 'ok',
error: null
}}
display="used"
state={{ kind: 'usage', statusLabel: null }}
showSignInAction={false}
now={mocks.now}
/>
)
expect(markup).toContain('Gemini Models · Weekly Limit Remaining')
})
it('renders the tightest window inline in compact mode', () => {
const markup = renderToStaticMarkup(
<UsageRow
@@ -48,6 +48,32 @@ function antigravityLimits(overrides: Partial<ProviderRateLimits> = {}): 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(
<ProviderSegment p={groupedLimits()} compact={false} display="used" mode="verbose" />
)
// 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(
<ProviderSegment p={groupedLimits()} compact={false} display="used" mode="compact" />
)
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(
<ProviderSegment p={groupedLimits()} compact display="used" mode="compact" />
)
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.
@@ -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<string, string> = {}) =>
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(
<ProviderSegment p={limits()} compact={false} mode={mode} display={display} />
)
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(
<ProviderSegment p={p} compact={false} mode={mode} display="remaining" />
)
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(<ProviderSegment p={limits()} compact={false} display="used" />)
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(
<ProviderSegment p={null} compact={false} display="used" />
)
rerender(<ProviderSegment p={limits()} compact={false} display="used" />)
act(() => vi.advanceTimersByTime(30_001))
expect(container.textContent).toContain('1h 29m')
})
it('does not schedule countdown updates for nonurgent pools', () => {
const { container } = render(<ProviderSegment p={limits(79)} compact={false} display="used" />)
expect(container.textContent).toContain('5h')
expect(vi.getTimerCount()).toBe(0)
})
it('does not schedule countdown updates when compact labels are hidden', () => {
const { container } = render(
<ProviderSegment p={limits()} compact mode="compact" display="used" />
)
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(
<ProviderSegment p={{ ...limits(), status }} compact={false} display="used" />
)
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(
<ProviderSegment p={limits()} compact={false} mode="compact" display="used" />
)
expect(container.textContent).toContain('1h 30m')
expect(vi.getTimerCount()).toBe(1)
rerender(<ProviderSegment p={limits()} compact mode="compact" display="used" />)
expect(container.textContent).toBe('90%')
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -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<number, number>()
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<string, RateLimitBucket[]>()
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 (
<React.Fragment key={group}>
{groupIndex > 0 ? (
<span aria-hidden="true" className="h-3 w-px shrink-0 bg-border" />
) : null}
<span className="max-w-[120px] truncate" title={group}>
{formatPoolGroupLabel(group, allBuckets)}
</span>
{windows.map(({ bucket, key }, index) => {
const percentage = formatBareUsagePercentage(bucket.usedPercent, display)
const window = formatPoolWindowLabel(bucket, now)
return (
<React.Fragment key={key}>
{index > 0 ? <span className="text-muted-foreground">·</span> : null}
<span className="tabular-nums">{`${percentage} ${window}`.trim()}</span>
</React.Fragment>
)
})}
</React.Fragment>
)
})}
</>
)
}
@@ -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<string, string> = {}) =>
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> = {}
): 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<RateLimitBucket>): 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')
})
})
@@ -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
}
+4
View File
@@ -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'