From 03bce76ef0dca7f7309f1598953a4f45a92c595d Mon Sep 17 00:00:00 2001 From: Abhishek Tiwari <69499+abti@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:05:09 -0700 Subject: [PATCH] fix(rate-limits): read native Antigravity usage --- .../antigravity-usage-fetcher.test.ts | 213 ++++++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 207 ++++++++++++++ .../antigravity-usage-mirror.test.ts | 70 ----- .../rate-limits/antigravity-usage-mirror.ts | 29 -- .../service-antigravity-usage.test.ts | 30 +- .../service/service-full-cycle-application.ts | 19 +- .../service/service-full-cycle-preparation.ts | 83 +++--- .../StatusBarProviderSegment.test.tsx | 157 ++++++++++ .../status-bar/StatusBarProviderSegment.tsx | 118 +++++++- .../status-bar/UsageRosterPanel.test.tsx | 270 +++++++++++++++++- .../status-bar/UsageRosterPanel.tsx | 72 +++-- .../antigravity-compact-metrics.tsx | 62 ++++ .../status-bar/antigravity-usage-format.ts | 32 +++ .../status-bar-provider-visibility.test.ts | 27 +- .../status-bar-provider-visibility.ts | 9 +- .../src/components/status-bar/tooltip.test.ts | 158 ++++++++++ .../src/components/status-bar/tooltip.tsx | 84 ++++-- .../status-bar/usage-section-selection.ts | 21 ++ .../status-bar/use-status-bar-controller.ts | 2 +- src/shared/rate-limit-types.ts | 7 + 20 files changed, 1458 insertions(+), 212 deletions(-) create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.test.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.ts create mode 100644 src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx create mode 100644 src/renderer/src/components/status-bar/antigravity-compact-metrics.tsx create mode 100644 src/renderer/src/components/status-bar/antigravity-usage-format.ts create mode 100644 src/renderer/src/components/status-bar/usage-section-selection.ts diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts new file mode 100644 index 000000000000..ff8b5177588d --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { + AGY_USAGE_ARGS, + fetchAntigravityRateLimits, + parseAgyUsageResponse +} from './antigravity-usage-fetcher' + +vi.mock('../git/command-runner/exec-file-capture', () => ({ + execFileCaptureToTermination: vi.fn() +})) + +vi.mock('../../shared/node-cli-command-resolution', () => ({ + resolveCliCommand: vi.fn() +})) + +const sample = { + command: { + data: { + groups: [ + { + name: 'Gemini Models', + description: 'Gemini quota', + buckets: [ + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + window: 'weekly', + remaining_fraction: 0.994, + reset_time: '2026-09-18T20:00:00Z' + }, + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + window: '5h', + remaining_fraction: 1, + reset_time: '2026-09-14T18:08:00Z' + } + ] + }, + { + name: 'Claude and GPT models', + buckets: [ + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + window: 'weekly', + remaining_fraction: 1, + reset_time: null + }, + { + id: '3p-5h', + name: 'Five Hour Limit Remaining', + window: '5h', + remaining_fraction: 1, + reset_time: '2026-09-14T18:08:00Z' + } + ] + } + ] + } + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('parseAgyUsageResponse', () => { + it('preserves two groups and two windows with independent values', () => { + const result = parseAgyUsageResponse(sample, 1_700_000_000_000) + expect(result.status).toBe('ok') + expect(result.buckets).toHaveLength(4) + expect( + result.buckets?.map((bucket) => [bucket.id, bucket.groupName, bucket.windowMinutes]) + ).toEqual([ + ['gemini-weekly', 'Gemini Models', 10080], + ['gemini-5h', 'Gemini Models', 300], + ['3p-weekly', 'Claude and GPT models', 10080], + ['3p-5h', 'Claude and GPT models', 300] + ]) + expect(result.buckets?.[0]?.usedPercent).toBeCloseTo(0.6) + expect(result.buckets?.[1]?.usedPercent).toBe(0) + expect(result.buckets?.[2]?.resetsAt).toBeNull() + }) + + it('keeps unknown groups and windows instead of dropping them', () => { + const result = parseAgyUsageResponse({ + command: { + data: { + groups: [ + { + name: 'New pool', + buckets: [{ id: 'daily', name: 'Daily', window: 'daily', remaining_fraction: 0.5 }] + } + ] + } + } + }) + expect(result.buckets?.[0]).toMatchObject({ + id: 'daily', + groupName: 'New pool', + windowLabel: 'daily', + windowMinutes: 0, + usedPercent: 50 + }) + }) + + it.each([ + { name: 'missing groups', value: {} }, + { name: 'empty groups', value: { command: { data: { groups: [] } } } }, + { + name: 'partial bucket', + value: { command: { data: { groups: [{ name: 'x', buckets: [{}] }] } } } + } + ])('returns unavailable for $name', ({ value }) => { + expect(parseAgyUsageResponse(value).status).toBe('unavailable') + }) +}) + +describe('fetchAntigravityRateLimits', () => { + it('resolves agy outside PATH and uses the exact argv without a shell', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockResolvedValue({ + stdout: JSON.stringify(sample), + stderr: '' + }) + await fetchAntigravityRateLimits() + expect(execFileCaptureToTermination).toHaveBeenCalledWith( + '/mock/bin/agy', + AGY_USAGE_ARGS, + expect.objectContaining({ + timeout: 10_000, + maxBuffer: 1024 * 1024, + signal: undefined, + createTimeoutError: expect.any(Function) + }) + ) + }) + + it('distinguishes an unresolved executable', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('agy') + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('cli-unavailable') + expect(execFileCaptureToTermination).not.toHaveBeenCalled() + }) + + it('reports malformed stdout as a parse failure', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockResolvedValue({ stdout: '{', stderr: '' }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + }) + + it('passes the refresh AbortSignal to the agy process', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockResolvedValue({ + stdout: JSON.stringify(sample), + stderr: '' + }) + const controller = new AbortController() + await fetchAntigravityRateLimits(controller.signal) + expect(execFileCaptureToTermination).toHaveBeenCalledWith( + '/mock/bin/agy', + AGY_USAGE_ARGS, + expect.objectContaining({ signal: controller.signal }) + ) + }) + + it('classifies the shared runner timeout separately from a generic read failure', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockRejectedValue( + Object.assign(new Error('The agy CLI timed out.'), { + code: null, + killed: true, + signal: 'SIGTERM' + }) + ) + const result = await fetchAntigravityRateLimits() + expect(result.error).toContain('timed out') + expect(result.usageMetadata?.failureKind).toBe('unknown') + }) + + it('propagates refresh cancellation instead of converting it to a provider error', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + const abortError = Object.assign(new Error('The operation was aborted.'), { + name: 'AbortError' + }) + vi.mocked(execFileCaptureToTermination).mockRejectedValue(abortError) + await expect(fetchAntigravityRateLimits(new AbortController().signal)).rejects.toMatchObject({ + name: 'AbortError' + }) + }) + + it.each([ + { remaining: -0.1, label: 'below zero' }, + { remaining: 1.1, label: 'above one' } + ])('rejects remaining_fraction $label', ({ remaining }) => { + const result = parseAgyUsageResponse({ + command: { + data: { + groups: [ + { name: 'x', buckets: [{ name: 'x', window: '5h', remaining_fraction: remaining }] } + ] + } + } + }) + expect(result.status).toBe('unavailable') + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts new file mode 100644 index 000000000000..3ea93bd4a266 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,207 @@ +import path from 'node:path' +import type { ProviderRateLimits, RateLimitBucket } from '../../shared/rate-limit-types' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture' + +const AGY_USAGE_ARGS = ['--print', '/usage', '--output-format', 'json'] +const AGY_USAGE_TIMEOUT_MS = 10_000 +const AGY_USAGE_MAX_BUFFER = 1024 * 1024 + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' +} + +function emptyAntigravityResult( + status: 'error' | 'unavailable', + error: string, + now: number, + failureKind: 'cli-unavailable' | 'usage-unavailable' | 'parse' | 'unknown' +): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [], + updatedAt: now, + error, + status, + usageMetadata: { source: 'cli', failureKind } + } +} + +function parseWindowMinutes(window: unknown): number { + if (window === '5h') { + return 300 + } + if (window === 'weekly') { + return 10080 + } + return 0 +} + +function createAgyTimeoutError(): Error { + return Object.assign(new Error('The agy CLI timed out.'), { code: 'ETIMEDOUT' }) +} + +function parseResetAt(value: unknown): number | null { + if (typeof value !== 'string') { + return null + } + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null +} + +function parseAgyUsageResponse(value: unknown, now = Date.now()): ProviderRateLimits { + const root = isRecord(value) ? value : {} + const command = isRecord(root.command) ? root.command : {} + const data = isRecord(command.data) ? command.data : {} + const groups = data.groups + const buckets: RateLimitBucket[] = [] + if (Array.isArray(groups)) { + for (const groupValue of groups) { + if (!groupValue || typeof groupValue !== 'object') { + continue + } + const group = groupValue + const groupName = typeof group.name === 'string' ? group.name.trim() : '' + if (!groupName || !Array.isArray(group.buckets)) { + continue + } + const groupDescription = + typeof group.description === 'string' ? group.description.trim() || null : null + for (const bucketValue of group.buckets) { + if (!bucketValue || typeof bucketValue !== 'object') { + continue + } + const bucket = bucketValue + const name = typeof bucket.name === 'string' ? bucket.name.trim() : '' + const remaining = bucket.remaining_fraction + if ( + !name || + typeof remaining !== 'number' || + !Number.isFinite(remaining) || + remaining < 0 || + remaining > 1 + ) { + continue + } + const sourceWindow = typeof bucket.window === 'string' ? bucket.window.trim() : '' + buckets.push({ + ...(typeof bucket.id === 'string' && bucket.id.trim() ? { id: bucket.id.trim() } : {}), + name, + groupName, + groupDescription, + windowMinutes: parseWindowMinutes(sourceWindow), + ...(sourceWindow ? { windowLabel: sourceWindow } : {}), + usedPercent: (1 - remaining) * 100, + resetsAt: parseResetAt(bucket.reset_time), + resetDescription: null + }) + } + } + } + if (buckets.length === 0) { + return emptyAntigravityResult( + 'unavailable', + 'Antigravity usage is not available. The agy CLI returned no quota buckets.', + now, + 'usage-unavailable' + ) + } + return { + provider: 'antigravity', + session: null, + weekly: null, + buckets, + updatedAt: now, + error: null, + status: 'ok', + usageMetadata: { source: 'cli', lastSuccessfulSource: 'cli' } + } +} + +function classifyAgyFailure(error: unknown): { + message: string + status: 'error' | 'unavailable' + failureKind: 'cli-unavailable' | 'usage-unavailable' | 'parse' | 'unknown' +} { + const record = isRecord(error) ? error : {} + const stderr = typeof record.stderr === 'string' ? record.stderr.trim() : '' + if (record.code === 'ENOENT') { + return { + message: 'Antigravity usage is unavailable because the agy CLI was not found.', + status: 'unavailable', + failureKind: 'cli-unavailable' + } + } + if ( + record.code === 'ETIMEDOUT' || + record.code === 'ERR_CHILD_PROCESS_TIMEOUT' || + record.killed === true || + record.signal === 'SIGTERM' + ) { + return { + message: 'Antigravity usage could not be refreshed before the agy CLI timed out.', + status: 'error', + failureKind: 'unknown' + } + } + if (/auth|login|sign.?in|credential/i.test(stderr)) { + return { + message: 'Antigravity usage is unavailable because the agy CLI is not authenticated.', + status: 'unavailable', + failureKind: 'usage-unavailable' + } + } + return { + message: 'Antigravity usage could not be read from the agy CLI.', + status: 'error', + failureKind: 'unknown' + } +} + +export function getAntigravityUsageCommand(): string { + return resolveCliCommand('agy') +} + +export async function fetchAntigravityRateLimits( + signal?: AbortSignal +): Promise { + const now = Date.now() + const command = getAntigravityUsageCommand() + if (!path.isAbsolute(command)) { + return emptyAntigravityResult( + 'unavailable', + 'Antigravity usage is unavailable because the agy CLI was not found.', + now, + 'cli-unavailable' + ) + } + try { + const { stdout } = await execFileCaptureToTermination(command, AGY_USAGE_ARGS, { + encoding: 'utf8', + timeout: AGY_USAGE_TIMEOUT_MS, + maxBuffer: AGY_USAGE_MAX_BUFFER, + signal, + createTimeoutError: createAgyTimeoutError + }) + try { + return parseAgyUsageResponse(JSON.parse(String(stdout)), now) + } catch { + return emptyAntigravityResult( + 'error', + 'Antigravity usage returned malformed JSON from the agy CLI.', + now, + 'parse' + ) + } + } catch (error) { + if (signal?.aborted || (isRecord(error) && error.name === 'AbortError')) { + throw error + } + const failure = classifyAgyFailure(error) + return emptyAntigravityResult(failure.status, failure.message, now, failure.failureKind) + } +} + +export { AGY_USAGE_ARGS, parseAgyUsageResponse } diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts deleted file mode 100644 index 84c92e1ad22f..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' - -function geminiSnapshot( - status: ProviderRateLimitStatus, - error: string | null, - usedPercent: number | null = null -): ProviderRateLimits { - return { - provider: 'gemini', - session: - usedPercent === null - ? null - : { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1_700_000_000_000, - error, - status - } -} - -describe('deriveAntigravityRateLimits', () => { - it('mirrors a successful Gemini read as shared Code Assist quota', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42)) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('ok') - expect(antigravity.session?.usedPercent).toBe(42) - expect(antigravity.error).toBeNull() - }) - - it('reports unavailable without quoting the Gemini failure', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('error', 'Gemini project ID not found') - ) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini project ID not found') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.session).toBeNull() - expect(antigravity.weekly).toBeNull() - }) - - it('does not blame a missing sign-in when the quota read itself failed', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - // Why: the reported symptom is a connected sign-in whose Code Assist read failed. - expect(antigravity.error).toContain('could not be read right now') - expect(antigravity.error).not.toContain('sign-in is connected') - }) - - it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - expect(antigravity.updatedAt).toBe(1_700_000_000_000) - }) - - it('points at the missing sign-in when the Gemini opt-in is off', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings') - ) - - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.error).toContain('Gemini CLI sign-in is connected') - }) -}) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts deleted file mode 100644 index 3cea6decc111..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' - -// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini -// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read -// describes shared Google Code Assist quota; republishing a Gemini failure under the -// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. -const ANTIGRAVITY_NO_SIGN_IN_REASON = - 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' -// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. -const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = - 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' - -export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { - if (gemini.status === 'ok') { - return { ...gemini, provider: 'antigravity' } - } - return { - provider: 'antigravity', - session: null, - weekly: null, - // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. - updatedAt: gemini.updatedAt, - error: - gemini.status === 'unavailable' - ? ANTIGRAVITY_NO_SIGN_IN_REASON - : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, - status: 'unavailable' - } -} diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index e0975af0db07..7c49c85652e6 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -3,6 +3,7 @@ import { RateLimitService } from './service' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { errorProvider, okProvider, @@ -23,6 +24,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) @@ -52,6 +57,9 @@ describe('RateLimitService Antigravity usage', () => { resetRateLimitProviderMocks() vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + errorProvider('antigravity', 'agy unavailable') + ) }) it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { @@ -63,7 +71,7 @@ describe('RateLimitService Antigravity usage', () => { await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('unavailable') + expect(state.antigravity?.status).toBe('error') expect(state.antigravity?.error).not.toContain('Gemini project ID not found') expect(state.antigravity?.session).toBeNull() // Why: the real Gemini failure must still surface under its own provider. @@ -71,30 +79,40 @@ describe('RateLimitService Antigravity usage', () => { expect(state.gemini?.error).toBe('Gemini project ID not found') }) - it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => { + it('keeps Gemini and Antigravity results independent', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 8, Date.now()) + ) const service = new RateLimitService() await service.refresh() const state = service.getState() + expect(state.gemini?.session?.usedPercent).toBe(42) expect(state.antigravity?.status).toBe('ok') expect(state.antigravity?.provider).toBe('antigravity') - expect(state.antigravity?.session?.usedPercent).toBe(42) + expect(state.antigravity?.session?.usedPercent).toBe(8) }) it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce( + okProvider('antigravity', 18, Date.now()) + ) const service = new RateLimitService() await service.refresh() vi.mocked(fetchGeminiRateLimits).mockResolvedValue( errorProvider('gemini', 'Token refresh failed') ) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + errorProvider('antigravity', 'agy temporary failure') + ) await service.refresh() - // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. - expect(service.getState().antigravity?.status).toBe('unavailable') - expect(service.getState().antigravity?.session).toBeNull() + // Why: stale-retention must not substitute Gemini numbers for Antigravity. + expect(service.getState().antigravity?.status).toBe('error') + expect(service.getState().antigravity?.session?.usedPercent).toBe(18) }) }) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 7104face38fa..a0815fcab31c 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,4 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' -import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { @@ -30,6 +29,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ claudeResult, codexResult, geminiResult, + antigravityResult, opencodeGoResult, kimiResult, miniMaxResult @@ -79,8 +79,21 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value + : ({ + provider: 'antigravity', + session: null, + weekly: null, + buckets: [], + updatedAt: Date.now(), + error: + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Antigravity usage could not be read.', + status: 'error' + } satisfies ProviderRateLimits) const opencodeGo = opencodeGoResult.status === 'fulfilled' diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 7551652bca1d..4b625e84576f 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -1,5 +1,6 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' @@ -36,6 +37,7 @@ export type FetchAllCyclePrepared = { PromiseSettledResult, PromiseSettledResult, PromiseSettledResult, + PromiseSettledResult, PromiseSettledResult ] grokResultPromise: Promise< @@ -138,42 +140,50 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const claudeFetchGated = !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels, - endpointMode: miniMaxEndpoint, - apiKey: miniMaxApiKey - }) - ]) + const [ + claudeResult, + codexResult, + geminiResult, + antigravityResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ] = await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchAntigravityRateLimits(signal), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels, + endpointMode: miniMaxEndpoint, + apiKey: miniMaxApiKey + }) + ]) if (signal.aborted) { return null @@ -198,6 +208,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ claudeResult, codexResult, geminiResult, + antigravityResult, opencodeGoResult, kimiResult, miniMaxResult diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx new file mode 100644 index 000000000000..4e3ae3ccbbc9 --- /dev/null +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: ({ agent }: { agent: string }) => +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (result, [key, value]) => result.replace(`{{${key}}}`, value), + fallback + ) +})) + +import { ProviderSegment } from './StatusBarProviderSegment' + +const antigravity: ProviderRateLimits = { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 1, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: 1, + error: null, + status: 'ok' +} + +describe('Antigravity status summary', () => { + it.each([ + ['used', ['0% used', '1% used']], + ['remaining', ['100% left', '99% left']] + ] as const)('uses native concise labels in %s mode', (display, expected) => { + const markup = renderToStaticMarkup( + + ) + + for (const value of expected) { + expect(markup).toContain(value) + } + expect(markup).toContain('G') + expect(markup).toContain('C/G') + expect(markup).not.toContain('5h') + expect(markup).not.toContain('wk') + expect(markup).not.toContain('Limit Remaining') + }) + + it('shows reset durations after detailed menu-bar percentages', () => { + const markup = renderToStaticMarkup( + ({ + ...bucket, + resetsAt: + Date.now() + (bucket.windowMinutes === 300 ? 5 * 60 * 60_000 : 4 * 24 * 60 * 60_000) + })) + }} + compact={false} + display="used" + mode="verbose" + /> + ) + + expect(markup).toMatch(/0% used \d+h/) + expect(markup).toMatch(/1% used \d+d/) + expect(markup).not.toContain('Session') + expect(markup).not.toContain('Weekly') + }) + + it('selects the tightest window independently for each compact group', () => { + const now = Date.now() + const markup = renderToStaticMarkup( + + ) + + expect(markup).toMatch(/G.*80% used.*(?:1h|2h)/) + expect(markup).toMatch(/C\/G.*60% used.*(?:5d|6d)/) + expect(markup.indexOf('80% used')).toBeLessThan(markup.search(/(?:1h|2h)/)) + expect(markup).not.toContain('wk') + expect(markup).not.toContain('5h') + }) + + it('keeps the selected window in Used and Remaining modes', () => { + const p = { + ...antigravity, + buckets: antigravity.buckets!.map((bucket) => ({ + ...bucket, + usedPercent: bucket.windowMinutes === 300 ? 80 : 20, + resetsAt: + Date.now() + (bucket.windowMinutes === 300 ? 2 * 60 * 60_000 : 5 * 24 * 60 * 60_000) + })) + } + const used = renderToStaticMarkup( + + ) + const remaining = renderToStaticMarkup( + + ) + expect(used).toContain('80% used') + expect(remaining).toContain('20% left') + expect(used).toMatch(/80% used.*(?:1h|2h)/) + expect(remaining).toMatch(/20% left.*(?:1h|2h)/) + }) +}) diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 24de1094534f..bbaf2eede321 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -6,11 +6,15 @@ import { type UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { formatResetDuration } from '../../../../shared/rate-limit-reset-format' +import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' import { ProviderIcon, clampUsedPercent, getProviderUsageStatusLabel } from './tooltip' import { getTightestUsageSection } from './UsageRosterPanel' +import { getTightestUsageSectionFromSections, type UsageSection } from './usage-section-selection' import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter' import { formatUsagePercentageLabel } from './usage-percentage-label' import { translate } from '@/i18n/i18n' +import { getAntigravityGroupShortLabel, sortAntigravityBuckets } from './antigravity-usage-format' function MiniBar({ usedPct, @@ -51,6 +55,58 @@ function WindowLabel({ ) } +function AntigravityCompactSummary({ + sections, + display, + now +}: { + sections: UsageSection[] + display: UsagePercentageDisplay + now: number +}): React.JSX.Element { + return ( + <> + {sections.map((section, index) => { + const reset = section.window.resetsAt + ? formatResetDuration(section.window.resetsAt - now) + : null + return ( + + {index > 0 ? | : null} + + + {getAntigravityGroupShortLabel(section.groupName)} + + + {formatUsagePercentageLabel(section.window.usedPercent, display)} + {reset ? ` ${reset}` : ''} + + + + ) + })} + + ) +} + +function getAntigravityCompactSections(p: ProviderRateLimits): UsageSection[] { + const groups = new Map() + for (const bucket of p.buckets ?? []) { + const key = bucket.groupName ?? '' + const sections = groups.get(key) ?? [] + sections.push({ label: bucket.name, window: bucket, groupName: bucket.groupName }) + groups.set(key, sections) + } + return [...groups.values()] + .map((sections) => + sortAntigravityBuckets(sections.map((section) => section.window)).map((window) => + sections.find((section) => section.window === window)! + ) + ) + .map((sections) => getTightestUsageSectionFromSections(sections)) + .filter((section): section is UsageSection => section !== null) +} + // Single-letter provider badge for the icon-only (narrow) status bar. Shared by // the roster trigger and ProviderDetailsMenu so the dot's has-data condition // and markup can't drift between the two. @@ -94,15 +150,64 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { // Why: Gemini exposes extra experimental buckets that made the pre-existing verbose footer noisy. const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) +function AntigravitySummary({ + p, + display, + now +}: { + p: ProviderRateLimits + display: UsagePercentageDisplay + now: number +}): React.JSX.Element { + const groups = new Map>() + for (const bucket of p.buckets ?? []) { + const key = bucket.groupName ?? '' + const current = groups.get(key) ?? [] + current.push(bucket) + groups.set(key, current) + } + return ( + <> + {[...groups.entries()].map(([groupName, buckets], groupIndex) => ( + + {groupIndex > 0 ? | : null} + + + {getAntigravityGroupShortLabel(groupName)} + + {sortAntigravityBuckets(buckets).map((bucket, index) => ( + + {index > 0 ? · : null} + + {formatUsagePercentageLabel(bucket.usedPercent, display)} + {bucket.resetsAt ? ` ${formatResetDuration(bucket.resetsAt - now)}` : ''} + + + ))} + + + ))} + + ) +} + function VerboseProviderUsage({ p, - display + display, + now }: { p: ProviderRateLimits display: UsagePercentageDisplay + now: number }): React.JSX.Element { + if (p.provider === 'antigravity' && p.buckets?.length) { + return + } if (p.buckets && p.buckets.length > 0) { - const visibleBuckets = p.buckets.filter((bucket) => STATUS_BAR_BUCKET_NAMES.has(bucket.name)) + const visibleBuckets = + p.provider === 'antigravity' + ? p.buckets + : p.buckets.filter((bucket) => STATUS_BAR_BUCKET_NAMES.has(bucket.name)) return ( <> {visibleBuckets.map((bucket, index) => ( @@ -183,6 +288,7 @@ export function ProviderSegment({ }): React.JSX.Element { const provider = p?.provider ?? 'claude' const statusLabel = p ? getProviderUsageStatusLabel(p) : '' + const now = useResetCountdownClock((p?.buckets ?? []).map((bucket) => bucket.resetsAt)) // Idle / initial load if (!p || p.status === 'idle') { @@ -237,8 +343,14 @@ export function ProviderSegment({ {tightest && !compact ? ( ) : null} - + + ) : p.provider === 'antigravity' ? ( + ) : tightest ? ( ({ })) vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (result, [key, value]) => result.replace(`{{${key}}}`, value), + fallback + ) })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: ({ agent }: { agent: string }) => @@ -87,6 +91,94 @@ describe('UsageRow', () => { expect(markup).not.toContain('width:25%') }) + it.each([ + ['5h', 70, 20, '2h'], + ['weekly', 20, 70, '5d'] + ] as const)( + 'compact Antigravity chooses the %s window by consumption', + (expectedWindow, fiveHour, weekly, reset) => { + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain(`${Math.max(fiveHour, weekly)}%`) + expect(markup).toContain(reset) + expect(markup).not.toContain(`${expectedWindow === '5h' ? weekly : fiveHour}%`) + expect(markup).not.toContain(' used') + } + ) + + it('uses session-first Codex tie semantics for compact Antigravity', () => { + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain('2h') + expect(markup).not.toContain('5d') + }) + it('uses one shared clock for live reset labels across the roster', () => { const sessionReset = mocks.now + 2 * 60_000 const weeklyReset = mocks.now + 7 * 24 * 60 * 60_000 @@ -247,6 +339,182 @@ describe('UsageRow', () => { expect(markup).toContain('25%') expect(markup).toContain('60%') }) + + it('renders Antigravity windows with native labels and selected semantics', () => { + const provider: ProviderRateLimits = { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 1, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: mocks.now, + error: null, + status: 'ok' + } + + const usedMarkup = renderToStaticMarkup( + + ) + expect(usedMarkup).toContain('5h') + expect(usedMarkup).toContain('wk') + expect(usedMarkup).toContain('0%') + expect(usedMarkup).toContain('1%') + expect(usedMarkup).not.toContain('0% used') + expect(usedMarkup).not.toContain('1% used') + expect(usedMarkup).not.toContain('Limit Remaining') + + const remainingMarkup = renderToStaticMarkup( + + ) + expect(remainingMarkup).toContain('100%') + expect(remainingMarkup).toContain('99%') + expect(remainingMarkup).not.toContain('100% left') + expect(remainingMarkup).not.toContain('99% left') + + const compactMarkup = renderToStaticMarkup( + ({ + ...bucket, + resetsAt: mocks.now + 2 * 60 * 60_000 + })), + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + ] + }} + display="used" + mode="compact" + state={{ kind: 'usage', statusLabel: null }} + showSignInAction={false} + now={mocks.now} + /> + ) + expect(compactMarkup).toContain('G') + expect(compactMarkup).toContain('C/G') + expect(compactMarkup).not.toContain('5h') + expect(compactMarkup).not.toContain('wk') + expect(compactMarkup).toContain('1%') + expect(compactMarkup).not.toContain('1% used') + expect(compactMarkup.indexOf('1%')).toBeLessThan(compactMarkup.indexOf('2h')) + + const remainingCompactMarkup = renderToStaticMarkup( + ({ + ...bucket, + groupName: bucket.groupName ?? 'Gemini Models' + })) + }} + display="remaining" + mode="compact" + state={{ kind: 'usage', statusLabel: null }} + showSignInAction={false} + now={mocks.now} + /> + ) + expect(remainingCompactMarkup).toContain('99%') + expect(remainingCompactMarkup).not.toContain('99% left') + expect(remainingCompactMarkup).not.toContain('5h') + expect(remainingCompactMarkup).not.toContain('wk') + }) + + it('renders detailed Antigravity windows with unknown windows after known windows', () => { + const markup = renderToStaticMarkup( + + ) + expect(markup.indexOf('5h')).toBeLessThan(markup.indexOf('wk')) + expect(markup.indexOf('wk')).toBeLessThan(markup.indexOf('daily')) + }) }) describe('UsageRosterPanel density picker', () => { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index feb066fadca4..8adc3612c247 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -5,7 +5,7 @@ import { SettingsSegmentedControl } from '@/components/settings/SettingsFormCont import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' import { translate } from '@/i18n/i18n' import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter' -import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' import { clampUsedPercent, getDisplayedUsagePercentage, @@ -14,11 +14,13 @@ import { import { barColor, formatResetCountdown, getWindowSections, ProviderIcon } from './tooltip' import { getProviderDisplayName } from './usage-error-copy' import { formatPlanLabel, usageTextColorClass } from './usage-roster-formatting' +import { AntigravityCompactMetrics } from './antigravity-compact-metrics' +import { sortAntigravityBuckets } from './antigravity-usage-format' import { getUsageRosterRowState, type UsageRosterRowState } from './usage-roster-row-state' import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { getTightestUsageSectionFromSections, type UsageSection } from './usage-section-selection' type ProviderId = ProviderRateLimits['provider'] -export type UsageSection = { label: string; window: RateLimitWindow } // Windows/buckets that actually carry data — absent limits arrive as null, but a // partial/rehydrated provider can also carry an undefined window; both must be @@ -41,6 +43,13 @@ function shortLabel( section: UsageSection, useRemainingDuration = false ): string { + if (p.provider === 'antigravity') { + return section.window.windowMinutes === 300 + ? '5h' + : section.window.windowMinutes === 10080 + ? 'wk' + : (section.window.windowLabel ?? formatWindowLabel(section.window.windowMinutes)) + } if (p.buckets?.some((b) => b.name === section.label)) { return section.label } @@ -56,17 +65,8 @@ function shortLabel( export function getTightestUsageSection(p: ProviderRateLimits): UsageSection | null { const sections = usedSections(p) - if (sections.length === 0) { - return null - } - // Why: the footer promises one quiet summary per provider; choose urgency by - // consumption even when the user displays the complementary “% left” value. - const tightest = sections.reduce((current, candidate) => - clampUsedPercent(candidate.window.usedPercent) > clampUsedPercent(current.window.usedPercent) - ? candidate - : current - ) - return { ...tightest, label: shortLabel(p, tightest, true) } + const tightest = getTightestUsageSectionFromSections(sections) + return tightest ? { ...tightest, label: shortLabel(p, tightest, true) } : null } // The soonest-resetting window summarizes the agent's next reset in one line. @@ -153,6 +153,8 @@ export function UsageRow({ ) : null} + ) : p.provider === 'antigravity' && hasUsage && mode === 'compact' ? ( + ) : tightest ? ( {hasUsage && mode === 'verbose' ? ( -
- {sections.map((section) => ( - +
+ {Array.from( + sections.reduce((groups, section) => { + const key = section.groupName ?? '' + const current = groups.get(key) ?? [] + current.push(section) + groups.set(key, current) + return groups + }, new Map()) + ).map(([groupName, groupSections]) => ( +
+ {groupName ? ( +
{groupName}
+ ) : null} +
+ {(p.provider === 'antigravity' + ? sortAntigravityBuckets( + groupSections.map((section) => ({ + name: section.label, + windowMinutes: section.window.windowMinutes, + section + })) + ).map(({ section }) => section) + : groupSections + .slice() + .sort((a, b) => a.window.windowMinutes - b.window.windowMinutes) + ).map((section) => ( + + ))} +
+
))}
) : null} diff --git a/src/renderer/src/components/status-bar/antigravity-compact-metrics.tsx b/src/renderer/src/components/status-bar/antigravity-compact-metrics.tsx new file mode 100644 index 000000000000..8ca3c3aef4f2 --- /dev/null +++ b/src/renderer/src/components/status-bar/antigravity-compact-metrics.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { formatResetDuration } from '../../../../shared/rate-limit-reset-format' +import { + clampUsedPercent, + getDisplayedUsagePercentage, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' +import { getAntigravityGroupShortLabel, sortAntigravityBuckets } from './antigravity-usage-format' +import { getTightestUsageSectionFromSections, type UsageSection } from './usage-section-selection' +import { usageTextColorClass } from './usage-roster-formatting' + +export function AntigravityCompactMetrics({ + sections, + display, + now +}: { + sections: UsageSection[] + display: UsagePercentageDisplay + now: number +}): React.JSX.Element { + const groups = new Map() + for (const section of sections) { + const key = section.groupName ?? '' + groups.set(key, [...(groups.get(key) ?? []), section]) + } + return ( + + {[...groups.entries()].map(([groupName, groupSections], groupIndex) => { + const orderedSections = sortAntigravityBuckets( + groupSections.map((section) => section.window) + ).map((window) => groupSections.find((section) => section.window === window)!) + const tightest = getTightestUsageSectionFromSections(orderedSections) + if (!tightest) { + return null + } + const reset = tightest.window.resetsAt + ? formatResetDuration(tightest.window.resetsAt - now) + : null + const used = clampUsedPercent(tightest.window.usedPercent) + const shown = getDisplayedUsagePercentage(tightest.window.usedPercent, display) + return ( + + {groupIndex > 0 ? | : null} + + + {getAntigravityGroupShortLabel(groupName)} + + + + {shown}% + + + {reset ? ( + {reset} + ) : null} + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/status-bar/antigravity-usage-format.ts b/src/renderer/src/components/status-bar/antigravity-usage-format.ts new file mode 100644 index 000000000000..78e2fe38ad0b --- /dev/null +++ b/src/renderer/src/components/status-bar/antigravity-usage-format.ts @@ -0,0 +1,32 @@ +import type { RateLimitWindow } from '../../../../shared/rate-limit-types' + +export function getAntigravityGroupShortLabel(groupName?: string): string { + if (!groupName) { + return 'Other' + } + if (/^gemini/i.test(groupName)) { + return 'G' + } + if (/claude|gpt/i.test(groupName)) { + return 'C/G' + } + return groupName + .split(/\s+/) + .map((part) => part[0]) + .join('') + .slice(0, 3) + .toUpperCase() +} + +export function sortAntigravityBuckets< + T extends Pick & { name?: string } +>(buckets: T[]): T[] { + return buckets.slice().sort((a, b) => { + const aKnown = a.windowMinutes > 0 + const bKnown = b.windowMinutes > 0 + if (aKnown !== bKnown) { + return aKnown ? -1 : 1 + } + return a.windowMinutes - b.windowMinutes || (a.name ?? '').localeCompare(b.name ?? '') + }) +} diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index ab2a0eca23f1..b82db6d7e4c3 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -123,11 +123,8 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) - // Why: antigravity durability requires the Gemini OAuth opt-in; the - // checked item alone must not suppress the usage setup CTA. - expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( - false - ) + // Antigravity uses its own CLI-backed usage source. + expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxApiKeyConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) @@ -163,21 +160,19 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { + it('requires a checked Antigravity item as the durable Antigravity signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) ) ).toBe(true) - // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it - // is permanently unavailable, so the checked item alone is not durable. expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(false) + ).toBe(true) expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -359,16 +354,14 @@ describe('getVisibleUsageProvider', () => { }) }) - it('hides Antigravity while Gemini OAuth is off even when its status item is checked', () => { - // Why: without the OAuth opt-in the mirrored snapshot is permanently - // 'unavailable'; the default-on item must not pin a dead bar. + it('keeps Antigravity visible while Gemini OAuth is off when its status item is checked', () => { expect( getVisibleUsageProvider( 'antigravity', null, usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(null) + ).toMatchObject({ provider: 'antigravity', status: 'fetching' }) expect( getVisibleUsageProvider( 'antigravity', @@ -378,7 +371,7 @@ describe('getVisibleUsageProvider', () => { }), usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(null) + ).toMatchObject({ provider: 'antigravity', status: 'unavailable' }) }) }) @@ -492,9 +485,7 @@ describe('isUsageEmptyState', () => { ).toBe(false) }) - it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { - // Why: the default-on Antigravity item is not configured usage on its own; - // it must not hide the teaching CTA from users who set nothing up. + it('does not show the setup CTA when Antigravity is checked and independently configured', () => { expect( isUsageEmptyState( { @@ -509,6 +500,6 @@ describe('isUsageEmptyState', () => { }, usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(true) + ).toBe(false) }) }) 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 19258592f1fd..26bac50b387c 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 @@ -75,8 +75,8 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. + // Antigravity has its own CLI-backed usage source. + settings?.antigravityUsageConfigured === true || settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true @@ -103,10 +103,7 @@ export function hasUsageProviderSettingsForProvider( return Boolean(settings.opencodeSessionCookie?.trim()) } if (providerId === 'antigravity') { - // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays - // 'unavailable' until the user opts into Gemini CLI OAuth. Without that - // gate the default-on checked item would pin a permanently dead bar. - return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + return settings.antigravityUsageConfigured === true } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true || settings.minimaxApiKeyConfigured === true diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index fd7561e250cd..abab43fcb2d0 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -327,6 +327,43 @@ describe('getWindowSections', () => { ]) }) + it('preserves Antigravity bucket group metadata for grouped rendering', () => { + const p: ProviderRateLimits = { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Gemini Models', + groupDescription: 'Gemini quota', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Claude and GPT models', + groupDescription: null, + usedPercent: 0, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + expect(getWindowSections(p).slice(0, 2)).toMatchObject([ + { label: 'Session', groupName: 'Gemini Models' }, + { label: 'Weekly', groupName: 'Claude and GPT models' } + ]) + }) + it('returns session and weekly when buckets are absent', () => { const p: ProviderRateLimits = { provider: 'claude', @@ -422,6 +459,32 @@ describe('getWindowSections', () => { expect(labels).not.toContain('Session') }) + it('keeps unknown Antigravity source window labels instead of rendering 0m', () => { + const p: ProviderRateLimits = { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + id: 'daily', + name: 'Daily', + groupName: 'New pool', + usedPercent: 50, + windowMinutes: 0, + windowLabel: 'daily', + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const sections = getWindowSections(p) + expect(sections[0]).toMatchObject({ label: 'daily' }) + expect(sections[0]?.label).not.toBe('0m') + }) + it('preserves reset metadata inside bucket windows', () => { const p: ProviderRateLimits = { provider: 'gemini', @@ -449,6 +512,101 @@ describe('getWindowSections', () => { }) describe('ProviderPanel reset rendering', () => { + it('keeps non-Antigravity window ordering unchanged', () => { + const markup = renderToStaticMarkup( + createElement(ProviderPanel, { + p: provider({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 10, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 20, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + } + }) + }) + ) + expect(markup.indexOf('Session')).toBeLessThan(markup.indexOf('Weekly')) + }) + + it('groups Antigravity windows and uses native labels in both display modes', () => { + const p = provider({ + provider: 'antigravity', + status: 'ok', + buckets: [ + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 1, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + { + id: 'gemini-daily', + name: 'Daily Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 2, + windowMinutes: 0, + windowLabel: 'daily', + resetsAt: null, + resetDescription: null + } + ] + }) + + const usedMarkup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + expect(usedMarkup).toContain('Gemini Models') + expect(usedMarkup).toContain('Claude and GPT models') + expect(usedMarkup).toContain('Session') + expect(usedMarkup).toContain('Weekly') + expect(usedMarkup).toContain('1% used') + expect(usedMarkup).not.toContain('Limit Remaining') + expect(usedMarkup.indexOf('Session')).toBeLessThan(usedMarkup.indexOf('Weekly')) + expect(usedMarkup.indexOf('Weekly')).toBeLessThan(usedMarkup.indexOf('daily')) + + const remainingMarkup = renderToStaticMarkup( + createElement(ProviderPanel, { p, usagePercentageDisplay: 'remaining' }) + ) + expect(remainingMarkup).toContain('100% left') + expect(remainingMarkup).toContain('99% left') + }) + it('renders the Fable reset countdown when Claude reports a reset timestamp', () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 2a82f7318dd9..51b398279144 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -18,6 +18,8 @@ import { } from '../../../../shared/usage-percentage-display' import { formatUsagePercentageLabel } from './usage-percentage-label' import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' +import { formatWindowLabel } from '@/lib/window-label-formatter' +import { sortAntigravityBuckets } from './antigravity-usage-format' // Re-exported from its shared home so status-bar callers keep a single import. export { clampUsedPercent } @@ -139,11 +141,26 @@ function ErrorMessage({ // Window section derivation // --------------------------------------------------------------------------- -export function getWindowSections( - p: ProviderRateLimits -): { label: string; window: RateLimitWindow | null }[] { +export function getWindowSections(p: ProviderRateLimits): { + label: string + window: RateLimitWindow | null + groupName?: string + groupDescription?: string | null +}[] { if (p.buckets?.length) { - const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow })) + const bucketSections = p.buckets.map((b) => ({ + label: + p.provider === 'antigravity' + ? b.windowMinutes === 300 + ? translate('auto.components.status.bar.tooltip.94038ad2fa', 'Session') + : b.windowMinutes === 10080 + ? translate('auto.components.status.bar.tooltip.252c096536', 'Weekly') + : b.windowLabel || formatWindowLabel(b.windowMinutes) + : b.name, + window: b, + ...(b.groupName ? { groupName: b.groupName } : {}), + ...(b.groupDescription !== undefined ? { groupDescription: b.groupDescription } : {}) + })) return [ ...bucketSections, { @@ -285,7 +302,14 @@ export function ProviderPanel({ ) } - if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { + if ( + p.status === 'error' && + !p.session && + !p.weekly && + !p.fableWeekly && + !p.monthly && + !p.buckets?.length + ) { return (
@@ -312,6 +336,34 @@ export function ProviderPanel({ resetCreditCount != null ? formatResetCreditExpiry(p.rateLimitResetCredits?.nextExpiresAt, resetCreditCount) : null + const groupedAntigravitySections = + p.provider === 'antigravity' + ? [...new Set(windowSections.map((section) => section.groupName ?? ''))].map((groupName) => ({ + groupName, + sections: sortAntigravityBuckets( + windowSections + .filter((section) => (section.groupName ?? '') === groupName) + .map((section) => ({ + name: section.label, + windowMinutes: section.window?.windowMinutes ?? 0, + section + })) + ).map(({ section }) => section) + })) + : [] + + const renderWindowSection = (s: (typeof windowSections)[number]) => ( + + ) return (
@@ -340,23 +392,19 @@ export function ProviderPanel({
- {windowSections.map((s) => ( - - ))} + {p.provider === 'antigravity' + ? groupedAntigravitySections.map(({ groupName, sections }) => ( +
+ {groupName ?
{groupName}
: null} + {sections.map(renderWindowSection)} +
+ )) + : windowSections.map(renderWindowSection)} {p.error ? ( ) : null} diff --git a/src/renderer/src/components/status-bar/usage-section-selection.ts b/src/renderer/src/components/status-bar/usage-section-selection.ts new file mode 100644 index 000000000000..321f1f21b5e0 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-section-selection.ts @@ -0,0 +1,21 @@ +import type { RateLimitWindow } from '../../../../shared/rate-limit-types' +import { clampUsedPercent } from '../../../../shared/usage-percentage-display' + +export type UsageSection = { + label: string + window: RateLimitWindow + groupName?: string + groupDescription?: string | null +} + +/** Choose the most consumed window, preserving input order for ties. */ +export function getTightestUsageSectionFromSections(sections: UsageSection[]): UsageSection | null { + if (sections.length === 0) { + return null + } + return sections.reduce((current, candidate) => + clampUsedPercent(candidate.window.usedPercent) > clampUsedPercent(current.window.usedPercent) + ? candidate + : current + ) +} diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 33db1976b0da..8fe39d3d6d9a 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -103,7 +103,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. - // Why: Antigravity visibility also requires geminiCliOAuthEnabled because its usage snapshot mirrors the Gemini fetch. + // Why: Antigravity visibility is based on its own detected CLI and snapshot. const antigravityUsageConfigured = statusBarItems.includes('antigravity') && isStatusBarItemAvailable('antigravity', detectedAgentIds) diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 5744e3e67497..c2889ec4e03c 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -7,12 +7,19 @@ export type RateLimitWindow = { resetsAt: number | null /** Human-readable reset description, e.g. "2:30 PM" or "Thu". */ resetDescription: string | null + /** Provider-specific source window name when no standard duration exists. */ + windowLabel?: string } export type ProviderRateLimitStatus = 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' export type RateLimitBucket = RateLimitWindow & { name: string + /** Stable provider/source identifier when available. */ + id?: string + /** Optional provider-defined quota group metadata. */ + groupName?: string + groupDescription?: string | null } export type UsageRateLimitSource = 'oauth' | 'cli' | 'web' | 'live-session'