From 6f9ea16bbe7bca2116e79db300ca585f114a31b2 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 01/11] 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' From ea59a2313031c8d46fbeb0eae17c4c36fca014e8 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Mon, 21 Sep 2026 15:14:56 -0300 Subject: [PATCH 02/11] fix(rate-limits): gate agy /usage behind CLI 1.1.11 agy older than 1.1.11 answers print-mode /usage with a billable agent turn instead of a quota report. Probe --version first and return an actionable unavailable result without ever spawning the usage argv. --- .../antigravity-usage-fetcher.test.ts | 128 +++++++++++++++--- .../rate-limits/antigravity-usage-fetcher.ts | 53 +++++++- 2 files changed, 162 insertions(+), 19 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index ff8b5177588d..756f0d76749d 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -2,7 +2,10 @@ 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_MIN_USAGE_VERSION, AGY_USAGE_ARGS, + AGY_VERSION_ARGS, + extractAgyVersion, fetchAntigravityRateLimits, parseAgyUsageResponse } from './antigravity-usage-fetcher' @@ -119,14 +122,49 @@ describe('parseAgyUsageResponse', () => { }) }) +describe('extractAgyVersion', () => { + it.each([ + { output: '1.2.4', expected: '1.2.4' }, + { output: 'agy version 1.1.11\n', expected: '1.1.11' }, + { output: 'v1.1.10 (darwin arm64)', expected: '1.1.10' } + ])('reads $output as $expected', ({ output, expected }) => { + expect(extractAgyVersion(output)).toBe(expected) + }) + + it.each([{ output: '' }, { output: 'agy' }, { output: 'version unknown' }])( + 'returns null for $output', + ({ output }) => { + expect(extractAgyVersion(output)).toBeNull() + } + ) +}) + describe('fetchAntigravityRateLimits', () => { - it('resolves agy outside PATH and uses the exact argv without a shell', async () => { + function mockVersionThenUsage(versionStdout: string, usageStdout: string) { vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') - vi.mocked(execFileCaptureToTermination).mockResolvedValue({ - stdout: JSON.stringify(sample), - stderr: '' - }) + vi.mocked(execFileCaptureToTermination) + .mockResolvedValueOnce({ stdout: versionStdout, stderr: '' }) + .mockResolvedValueOnce({ stdout: usageStdout, stderr: '' }) + } + + function usageInvocations() { + return vi + .mocked(execFileCaptureToTermination) + .mock.calls.filter(([, args]) => args === AGY_USAGE_ARGS) + } + + it('resolves agy outside PATH and uses the exact argv without a shell', async () => { + mockVersionThenUsage('1.2.4', JSON.stringify(sample)) await fetchAntigravityRateLimits() + expect(execFileCaptureToTermination).toHaveBeenCalledWith( + '/mock/bin/agy', + AGY_VERSION_ARGS, + expect.objectContaining({ + timeout: 5_000, + signal: undefined, + createTimeoutError: expect.any(Function) + }) + ) expect(execFileCaptureToTermination).toHaveBeenCalledWith( '/mock/bin/agy', AGY_USAGE_ARGS, @@ -139,6 +177,55 @@ describe('fetchAntigravityRateLimits', () => { ) }) + it('proceeds on the 1.1.11 boundary version', async () => { + mockVersionThenUsage('1.1.11', JSON.stringify(sample)) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('ok') + expect(usageInvocations()).toHaveLength(1) + }) + + // Why: agy 1.1.10 answers `-p /usage` with a billable agent turn instead of a + // quota report, so the usage argv must never be spawned for older CLIs. + it('never invokes /usage on agy older than 1.1.11', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockResolvedValueOnce({ + stdout: '1.1.10', + stderr: '' + }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('unavailable') + expect(result.error).toContain(AGY_MIN_USAGE_VERSION) + expect(result.error).toContain('1.1.10') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(vi.mocked(execFileCaptureToTermination).mock.calls).toHaveLength(1) + expect(usageInvocations()).toHaveLength(0) + }) + + it.each([{ versionStdout: '' }, { versionStdout: 'version unknown' }])( + 'never invokes /usage when the version is unreadable ($versionStdout)', + async ({ versionStdout }) => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockResolvedValueOnce({ + stdout: versionStdout, + stderr: '' + }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('unavailable') + expect(result.error).toContain(AGY_MIN_USAGE_VERSION) + expect(usageInvocations()).toHaveLength(0) + } + ) + + it('never invokes /usage when the version probe fails', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockRejectedValueOnce( + Object.assign(new Error('The agy CLI timed out.'), { code: 'ETIMEDOUT' }) + ) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('unavailable') + expect(usageInvocations()).toHaveLength(0) + }) + it('distinguishes an unresolved executable', async () => { vi.mocked(resolveCliCommand).mockReturnValue('agy') const result = await fetchAntigravityRateLimits() @@ -148,8 +235,7 @@ describe('fetchAntigravityRateLimits', () => { }) it('reports malformed stdout as a parse failure', async () => { - vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') - vi.mocked(execFileCaptureToTermination).mockResolvedValue({ stdout: '{', stderr: '' }) + mockVersionThenUsage('1.2.4', '{') const result = await fetchAntigravityRateLimits() expect(result.status).toBe('error') expect(result.usageMetadata?.failureKind).toBe('parse') @@ -157,12 +243,16 @@ describe('fetchAntigravityRateLimits', () => { 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() + vi.mocked(execFileCaptureToTermination) + .mockResolvedValueOnce({ stdout: '1.2.4', stderr: '' }) + .mockResolvedValueOnce({ stdout: JSON.stringify(sample), stderr: '' }) await fetchAntigravityRateLimits(controller.signal) + expect(execFileCaptureToTermination).toHaveBeenCalledWith( + '/mock/bin/agy', + AGY_VERSION_ARGS, + expect.objectContaining({ signal: controller.signal }) + ) expect(execFileCaptureToTermination).toHaveBeenCalledWith( '/mock/bin/agy', AGY_USAGE_ARGS, @@ -172,13 +262,15 @@ describe('fetchAntigravityRateLimits', () => { 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' - }) - ) + vi.mocked(execFileCaptureToTermination) + .mockResolvedValueOnce({ stdout: '1.2.4', stderr: '' }) + .mockRejectedValueOnce( + 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') diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 3ea93bd4a266..b4d9016e9e22 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -1,11 +1,18 @@ import path from 'node:path' import type { ProviderRateLimits, RateLimitBucket } from '../../shared/rate-limit-types' +import { hasReachedAppVersion } from '../../shared/app-version' 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 +const AGY_VERSION_ARGS = ['--version'] +const AGY_VERSION_TIMEOUT_MS = 5_000 +const AGY_VERSION_MAX_BUFFER = 64 * 1024 +// Why 1.1.11: older agy answers `-p /usage` with a billable agent turn instead of +// a quota report; 1.1.11 handles read-only slash commands in print mode directly. +const AGY_MIN_USAGE_VERSION = '1.1.11' function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' @@ -164,6 +171,39 @@ export function getAntigravityUsageCommand(): string { return resolveCliCommand('agy') } +// Why a pre-check: invoking /usage on agy <1.1.11 starts an agent turn that spends +// quota on every refresh, so the version gate must run before any usage invocation. +// Unreadable versions fail closed for the same reason. +export function extractAgyVersion(output: string): string | null { + const match = output.match(/(\d+)\.(\d+)\.(\d+)/) + return match ? `${match[1]}.${match[2]}.${match[3]}` : null +} + +async function checkAgyUsageSupport( + command: string, + signal?: AbortSignal +): Promise<{ supported: boolean; version: string | null }> { + try { + const { stdout } = await execFileCaptureToTermination(command, AGY_VERSION_ARGS, { + encoding: 'utf8', + timeout: AGY_VERSION_TIMEOUT_MS, + maxBuffer: AGY_VERSION_MAX_BUFFER, + signal, + createTimeoutError: createAgyTimeoutError + }) + const version = extractAgyVersion(String(stdout)) + return { + supported: version !== null && hasReachedAppVersion(version, AGY_MIN_USAGE_VERSION), + version + } + } catch (error) { + if (signal?.aborted || (isRecord(error) && error.name === 'AbortError')) { + throw error + } + return { supported: false, version: null } + } +} + export async function fetchAntigravityRateLimits( signal?: AbortSignal ): Promise { @@ -177,6 +217,17 @@ export async function fetchAntigravityRateLimits( 'cli-unavailable' ) } + const versionCheck = await checkAgyUsageSupport(command, signal) + if (!versionCheck.supported) { + return emptyAntigravityResult( + 'unavailable', + versionCheck.version + ? `Antigravity usage needs agy ${AGY_MIN_USAGE_VERSION} or newer (found ${versionCheck.version}). Update the agy CLI to show quota in the status bar.` + : 'Antigravity usage is unavailable because the agy CLI version could not be read. Update agy to 1.1.11 or newer.', + now, + 'usage-unavailable' + ) + } try { const { stdout } = await execFileCaptureToTermination(command, AGY_USAGE_ARGS, { encoding: 'utf8', @@ -204,4 +255,4 @@ export async function fetchAntigravityRateLimits( } } -export { AGY_USAGE_ARGS, parseAgyUsageResponse } +export { AGY_MIN_USAGE_VERSION, AGY_USAGE_ARGS, AGY_VERSION_ARGS, parseAgyUsageResponse } From fb950cfdd6874acfdc2e5de293043b1fbc95754c Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Mon, 21 Sep 2026 15:27:10 -0300 Subject: [PATCH 03/11] =?UTF-8?q?fix(rate-limits):=20review=20round=20?= =?UTF-8?q?=E2=80=94=20prerelease=20gate,=20strict=20quota=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Preserve prerelease/build metadata in extractAgyVersion so 1.1.11-rc.1 stays below the stable floor and never spawns /usage. - Reject the whole quota response on any malformed group/bucket instead of returning ok with a partial pool set. - JSDoc the new fetcher surface. --- .../antigravity-usage-fetcher.test.ts | 82 ++++++++++++- .../rate-limits/antigravity-usage-fetcher.ts | 114 ++++++++++-------- 2 files changed, 143 insertions(+), 53 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index 756f0d76749d..46540f50c7c3 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -112,13 +112,66 @@ describe('parseAgyUsageResponse', () => { it.each([ { name: 'missing groups', value: {} }, - { name: 'empty groups', value: { command: { data: { groups: [] } } } }, + { name: 'empty groups', value: { command: { data: { groups: [] } } } } + ])('returns unavailable for $name', ({ value }) => { + const result = parseAgyUsageResponse(value) + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + }) + + // Why: a partial read must never present as `ok` — a dropped pool could hide + // the tightest quota while the status bar reports a successful refresh. + it.each([ { name: 'partial bucket', value: { command: { data: { groups: [{ name: 'x', buckets: [{}] }] } } } + }, + { + name: 'group without a name', + value: { + command: { + data: { + groups: [ + { + buckets: [{ name: 'x', window: '5h', remaining_fraction: 0.5 }] + } + ] + } + } + } + }, + { + name: 'bucket without a window', + value: { + command: { + data: { + groups: [{ name: 'x', buckets: [{ name: 'x', remaining_fraction: 0.5 }] }] + } + } + } + }, + { + name: 'one malformed bucket beside a valid one', + value: { + command: { + data: { + groups: [ + { + name: 'Gemini Models', + buckets: [ + { name: 'Weekly', window: 'weekly', remaining_fraction: 0.5 }, + { name: 'Broken', window: '5h' } + ] + } + ] + } + } + } } - ])('returns unavailable for $name', ({ value }) => { - expect(parseAgyUsageResponse(value).status).toBe('unavailable') + ])('returns a parse error for $name', ({ value }) => { + const result = parseAgyUsageResponse(value) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') }) }) @@ -126,7 +179,9 @@ describe('extractAgyVersion', () => { it.each([ { output: '1.2.4', expected: '1.2.4' }, { output: 'agy version 1.1.11\n', expected: '1.1.11' }, - { output: 'v1.1.10 (darwin arm64)', expected: '1.1.10' } + { output: 'v1.1.10 (darwin arm64)', expected: '1.1.10' }, + { output: '1.1.11-rc.1', expected: '1.1.11-rc.1' }, + { output: 'agy 1.2.4+build.7', expected: '1.2.4+build.7' } ])('reads $output as $expected', ({ output, expected }) => { expect(extractAgyVersion(output)).toBe(expected) }) @@ -201,6 +256,20 @@ describe('fetchAntigravityRateLimits', () => { expect(usageInvocations()).toHaveLength(0) }) + // Why: `hasReachedAppVersion` ranks prereleases below the stable floor, and the + // extractor preserves the suffix, so an unverified build can never spawn /usage. + it('never invokes /usage on a prerelease below the stable floor', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockResolvedValueOnce({ + stdout: '1.1.11-rc.1', + stderr: '' + }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('unavailable') + expect(result.error).toContain(AGY_MIN_USAGE_VERSION) + expect(usageInvocations()).toHaveLength(0) + }) + it.each([{ versionStdout: '' }, { versionStdout: 'version unknown' }])( 'never invokes /usage when the version is unreadable ($versionStdout)', async ({ versionStdout }) => { @@ -290,7 +359,7 @@ describe('fetchAntigravityRateLimits', () => { it.each([ { remaining: -0.1, label: 'below zero' }, { remaining: 1.1, label: 'above one' } - ])('rejects remaining_fraction $label', ({ remaining }) => { + ])('rejects remaining_fraction $label as a parse error', ({ remaining }) => { const result = parseAgyUsageResponse({ command: { data: { @@ -300,6 +369,7 @@ describe('fetchAntigravityRateLimits', () => { } } }) - expect(result.status).toBe('unavailable') + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') }) }) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index b4d9016e9e22..9890c1d3650d 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -58,53 +58,74 @@ function parseResetAt(value: unknown): number | null { return Number.isFinite(parsed) ? parsed : null } +function agyQuotaFormatError(now: number): ProviderRateLimits { + return emptyAntigravityResult( + 'error', + 'Antigravity usage returned an unexpected quota format from the agy CLI.', + now, + 'parse' + ) +} + +/** + * Maps the `agy -p /usage` quota groups onto rate-limit buckets. Well-formed but + * unknown groups/windows are preserved; any malformed entry rejects the whole + * response so a partial read can never hide the tightest quota pool as `ok`. + */ 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 + if (!Array.isArray(groups) || groups.length === 0) { + return emptyAntigravityResult( + 'unavailable', + 'Antigravity usage is not available. The agy CLI returned no quota buckets.', + now, + 'usage-unavailable' + ) + } 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 + for (const groupValue of groups) { + if (!isRecord(groupValue)) { + return agyQuotaFormatError(now) + } + const groupName = typeof groupValue.name === 'string' ? groupValue.name.trim() : '' + if (!groupName || !Array.isArray(groupValue.buckets) || groupValue.buckets.length === 0) { + return agyQuotaFormatError(now) + } + const groupDescription = + typeof groupValue.description === 'string' ? groupValue.description.trim() || null : null + for (const bucketValue of groupValue.buckets) { + if (!isRecord(bucketValue)) { + return agyQuotaFormatError(now) } - 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 - }) + const name = typeof bucketValue.name === 'string' ? bucketValue.name.trim() : '' + const remaining = bucketValue.remaining_fraction + const sourceWindow = typeof bucketValue.window === 'string' ? bucketValue.window.trim() : '' + if ( + !name || + !sourceWindow || + typeof remaining !== 'number' || + !Number.isFinite(remaining) || + remaining < 0 || + remaining > 1 + ) { + return agyQuotaFormatError(now) } + buckets.push({ + ...(typeof bucketValue.id === 'string' && bucketValue.id.trim() + ? { id: bucketValue.id.trim() } + : {}), + name, + groupName, + groupDescription, + windowMinutes: parseWindowMinutes(sourceWindow), + windowLabel: sourceWindow, + usedPercent: (1 - remaining) * 100, + resetsAt: parseResetAt(bucketValue.reset_time), + resetDescription: null + }) } } if (buckets.length === 0) { @@ -167,6 +188,7 @@ function classifyAgyFailure(error: unknown): { } } +/** Resolves the `agy` executable off PATH (bare name when absent). */ export function getAntigravityUsageCommand(): string { return resolveCliCommand('agy') } @@ -174,11 +196,13 @@ export function getAntigravityUsageCommand(): string { // Why a pre-check: invoking /usage on agy <1.1.11 starts an agent turn that spends // quota on every refresh, so the version gate must run before any usage invocation. // Unreadable versions fail closed for the same reason. +/** Extracts the first semver triple (preserving prerelease/build) from `agy --version` output. */ export function extractAgyVersion(output: string): string | null { - const match = output.match(/(\d+)\.(\d+)\.(\d+)/) - return match ? `${match[1]}.${match[2]}.${match[3]}` : null + const match = output.match(/(\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?)/) + return match ? match[1] : null } +/** Probes `agy --version`; aborts propagate, every other failure reads as unsupported. */ async function checkAgyUsageSupport( command: string, signal?: AbortSignal @@ -204,6 +228,7 @@ async function checkAgyUsageSupport( } } +/** Reads native Antigravity quota: version-gated `agy -p /usage`, else an actionable `unavailable`. */ export async function fetchAntigravityRateLimits( signal?: AbortSignal ): Promise { @@ -239,12 +264,7 @@ export async function fetchAntigravityRateLimits( try { return parseAgyUsageResponse(JSON.parse(String(stdout)), now) } catch { - return emptyAntigravityResult( - 'error', - 'Antigravity usage returned malformed JSON from the agy CLI.', - now, - 'parse' - ) + return agyQuotaFormatError(now) } } catch (error) { if (signal?.aborted || (isRecord(error) && error.name === 'AbortError')) { From e5023282cb66f955939e661e9d881dd10ecf5661 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Mon, 21 Sep 2026 15:47:15 -0300 Subject: [PATCH 04/11] fix(rate-limits): drop unreachable empty-buckets branch --- src/main/rate-limits/antigravity-usage-fetcher.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 9890c1d3650d..941740f04458 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -128,14 +128,6 @@ function parseAgyUsageResponse(value: unknown, now = Date.now()): ProviderRateLi }) } } - 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, From 71e600b62056200b53b695eb35ac032bbaaa22da Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 23 Sep 2026 17:15:00 -0400 Subject: [PATCH 05/11] test(rate-limits): stub the agy fetcher in shared service suite mocks Service suites spawned the real agy CLI on machines that have it installed, failing nine activation and orchestration tests. Co-Authored-By: Claude --- src/main/rate-limits/rate-limit-service-test-harness.ts | 6 ++++++ .../rate-limits/service-account-target-selection.test.ts | 4 ++++ .../rate-limits/service-inactive-account-previews.test.ts | 4 ++++ src/main/rate-limits/service-live-claude-usage.test.ts | 4 ++++ src/main/rate-limits/service-minimax-usage.test.ts | 4 ++++ src/main/rate-limits/service-refresh-orchestration.test.ts | 4 ++++ src/main/rate-limits/service-window-activation.test.ts | 4 ++++ 7 files changed, 30 insertions(+) diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 041ede99b628..8fb0678147b1 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -4,6 +4,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import type { RateLimitService } from './service' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -89,6 +90,9 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) + vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => + unavailableProvider('antigravity') + ) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -106,6 +110,8 @@ export function resetRateLimitProviderMocks(): void { error: null, status: 'unavailable' }) + // Why: the real fetcher spawns agy, which is on PATH on developer machines. + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) } diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 7f9174f5d1f3..026eceffb4a2 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -24,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() })) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 3c6293513826..517ece536dbe 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -31,6 +31,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 668cfc113b9a..fdff98df95f1 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -28,6 +28,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 819b4c93f5d2..4ff0eace4c38 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -25,6 +25,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 44f4b05b3bac..4aa03e8b2f6e 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -32,6 +32,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index ec813a53d94a..8be43130df15 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -33,6 +33,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) From 2670a9e28f5efd86f886e57709b34789837bf66b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 23 Sep 2026 17:18:41 -0400 Subject: [PATCH 06/11] refactor(rate-limits): read agy usage through runProcess and a shared version parser - Spawn agy with runProcess and branch on its exit code and timedOut flag, dropping the git runner and the error-field guessing it required. - Move the semver extraction shared by the Claude and agy probes to parseCliVersion in shared/app-version. - A response missing command.data.groups is now a parse error; only an empty group list means no quota. - Sort buckets within each group at parse time and set windowLabel only for windows without a known duration. - Drop the never-rendered groupDescription field and the test-only exports; tests exercise fetchAntigravityRateLimits end to end. Co-Authored-By: Claude --- .../managed-hook-detection-commands.ts | 6 +- ...claude-session-end-hook-capability.test.ts | 7 +- .../claude-session-end-hook-capability.ts | 11 +- .../antigravity-usage-fetcher.test.ts | 340 ++++++------------ .../rate-limits/antigravity-usage-fetcher.ts | 312 ++++++---------- src/relay/managed-hook-installer.ts | 4 +- .../src/components/status-bar/tooltip.test.ts | 2 - .../src/components/status-bar/tooltip.tsx | 4 +- .../status-bar/usage-section-selection.ts | 1 - src/shared/app-version.test.ts | 19 +- src/shared/app-version.ts | 6 + src/shared/rate-limit-types.ts | 3 +- 12 files changed, 260 insertions(+), 455 deletions(-) diff --git a/src/main/agent-hooks/managed-hook-detection-commands.ts b/src/main/agent-hooks/managed-hook-detection-commands.ts index f9507160e276..6ec72725b159 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.ts @@ -7,7 +7,7 @@ import { MANAGED_AGENT_HOOK_TARGETS } from '../../shared/managed-agent-hook-targ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands' -import { parseClaudeCliVersion } from '../claude/claude-session-end-hook-capability' +import { parseCliVersion } from '../../shared/app-version' export type ManagedHookDetectionSettings = Partial< Pick @@ -61,8 +61,6 @@ export function readManagedHookDetectionResult(value: unknown): { : null return { agents, - claudeVersion: parseClaudeCliVersion( - typeof rawClaudeVersion === 'string' ? rawClaudeVersion : null - ) + claudeVersion: parseCliVersion(typeof rawClaudeVersion === 'string' ? rawClaudeVersion : null) } } diff --git a/src/main/claude/claude-session-end-hook-capability.test.ts b/src/main/claude/claude-session-end-hook-capability.test.ts index 755cdcbe4987..c1aa48e72701 100644 --- a/src/main/claude/claude-session-end-hook-capability.test.ts +++ b/src/main/claude/claude-session-end-hook-capability.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_SESSION_END_CAPABILITY_FLOOR, - claudeVersionSupportsSessionEnd, - parseClaudeCliVersion + claudeVersionSupportsSessionEnd } from './claude-session-end-hook-capability' describe('Claude SessionEnd hook version capability', () => { @@ -10,10 +9,6 @@ describe('Claude SessionEnd hook version capability', () => { expect(CLAUDE_SESSION_END_CAPABILITY_FLOOR).toBe('2.1.261') }) - it('extracts Claude Code version output', () => { - expect(parseClaudeCliVersion('2.1.261 (Claude Code)')).toBe('2.1.261') - }) - it.each([ ['2.1.260', false], ['2.1.261', true], diff --git a/src/main/claude/claude-session-end-hook-capability.ts b/src/main/claude/claude-session-end-hook-capability.ts index 1587c154e7bd..b6699167f5fc 100644 --- a/src/main/claude/claude-session-end-hook-capability.ts +++ b/src/main/claude/claude-session-end-hook-capability.ts @@ -1,17 +1,12 @@ -import { hasReachedAppVersion, isValidAppVersion } from '../../shared/app-version' +import { hasReachedAppVersion, parseCliVersion } from '../../shared/app-version' import { runProcess } from '../../shared/child-process/run-process' import path from 'node:path' // 2.1.261 is the only version measured, not an established minimum. export const CLAUDE_SESSION_END_CAPABILITY_FLOOR = '2.1.261' -export function parseClaudeCliVersion(output: string | null | undefined): string | null { - const version = output?.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/)?.[0] - return version && isValidAppVersion(version) ? version : null -} - export function claudeVersionSupportsSessionEnd(version: string | null | undefined): boolean { - const parsed = parseClaudeCliVersion(version) + const parsed = parseCliVersion(version) return parsed !== null && hasReachedAppVersion(parsed, CLAUDE_SESSION_END_CAPABILITY_FLOOR) } @@ -34,7 +29,7 @@ export async function probeClaudeCliVersion(executablePath: string): Promise ({ - execFileCaptureToTermination: vi.fn() -})) - -vi.mock('../../shared/node-cli-command-resolution', () => ({ - resolveCliCommand: vi.fn() -})) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('../../shared/node-cli-command-resolution', () => ({ resolveCliCommand: vi.fn() })) const sample = { command: { @@ -24,7 +13,6 @@ const sample = { groups: [ { name: 'Gemini Models', - description: 'Gemini quota', buckets: [ { id: 'gemini-weekly', @@ -66,43 +54,66 @@ const sample = { } } +function exited(stdout: string, overrides: Partial = {}): ProcessResult { + return { code: 0, signal: null, stdout, stderr: '', timedOut: false, ...overrides } +} + +function mockAgy(version: ProcessResult, usage?: ProcessResult): void { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(runProcess).mockResolvedValueOnce(version) + if (usage) { + vi.mocked(runProcess).mockResolvedValueOnce(usage) + } +} + +function usageInvocations(): unknown[] { + return vi.mocked(runProcess).mock.calls.filter(([spec]) => spec.args?.includes('/usage')) +} + +async function fetchUsage(value: unknown) { + mockAgy(exited('1.2.9'), exited(JSON.stringify(value))) + return fetchAntigravityRateLimits() +} + beforeEach(() => { vi.clearAllMocks() }) -describe('parseAgyUsageResponse', () => { - it('preserves two groups and two windows with independent values', () => { - const result = parseAgyUsageResponse(sample, 1_700_000_000_000) +describe('fetchAntigravityRateLimits parsing', () => { + it('keeps each group and orders its buckets shortest window first', async () => { + const result = await fetchUsage(sample) 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] + ['gemini-weekly', 'Gemini Models', 10080], + ['3p-5h', 'Claude and GPT models', 300], + ['3p-weekly', 'Claude and GPT models', 10080] ]) - expect(result.buckets?.[0]?.usedPercent).toBeCloseTo(0.6) - expect(result.buckets?.[1]?.usedPercent).toBe(0) - expect(result.buckets?.[2]?.resetsAt).toBeNull() + expect(result.buckets?.[1]?.usedPercent).toBeCloseTo(0.6) + expect(result.buckets?.[0]?.windowLabel).toBeUndefined() + expect(result.buckets?.[3]?.resetsAt).toBeNull() }) - it('keeps unknown groups and windows instead of dropping them', () => { - const result = parseAgyUsageResponse({ + it('keeps unknown windows, labelled by their source name and sorted last', async () => { + const result = await fetchUsage({ command: { data: { groups: [ { name: 'New pool', - buckets: [{ id: 'daily', name: 'Daily', window: 'daily', remaining_fraction: 0.5 }] + buckets: [ + { id: 'daily', name: 'Daily', window: 'daily', remaining_fraction: 0.5 }, + { id: '5h', name: 'Five', window: '5h', remaining_fraction: 1 } + ] } ] } } }) - expect(result.buckets?.[0]).toMatchObject({ - id: 'daily', + expect(result.buckets?.map((bucket) => bucket.id)).toEqual(['5h', 'daily']) + expect(result.buckets?.[1]).toMatchObject({ groupName: 'New pool', windowLabel: 'daily', windowMinutes: 0, @@ -110,18 +121,16 @@ describe('parseAgyUsageResponse', () => { }) }) - it.each([ - { name: 'missing groups', value: {} }, - { name: 'empty groups', value: { command: { data: { groups: [] } } } } - ])('returns unavailable for $name', ({ value }) => { - const result = parseAgyUsageResponse(value) + it('reports an empty group list as unavailable', async () => { + const result = await fetchUsage({ command: { data: { groups: [] } } }) expect(result.status).toBe('unavailable') expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') }) - // Why: a partial read must never present as `ok` — a dropped pool could hide - // the tightest quota while the status bar reports a successful refresh. + // Why: a partial read must never present as `ok` — a dropped pool could hide the tightest quota. it.each([ + { name: 'missing groups', value: { command: { data: {} } } }, + { name: 'missing command', value: {} }, { name: 'partial bucket', value: { command: { data: { groups: [{ name: 'x', buckets: [{}] }] } } } @@ -130,13 +139,7 @@ describe('parseAgyUsageResponse', () => { name: 'group without a name', value: { command: { - data: { - groups: [ - { - buckets: [{ name: 'x', window: '5h', remaining_fraction: 0.5 }] - } - ] - } + data: { groups: [{ buckets: [{ name: 'x', window: '5h', remaining_fraction: 0.5 }] }] } } } }, @@ -144,9 +147,7 @@ describe('parseAgyUsageResponse', () => { name: 'bucket without a window', value: { command: { - data: { - groups: [{ name: 'x', buckets: [{ name: 'x', remaining_fraction: 0.5 }] }] - } + data: { groups: [{ name: 'x', buckets: [{ name: 'x', remaining_fraction: 0.5 }] }] } } } }, @@ -167,209 +168,102 @@ describe('parseAgyUsageResponse', () => { } } } + }, + { + name: 'remaining_fraction below zero', + value: { + command: { + data: { + groups: [ + { name: 'x', buckets: [{ name: 'x', window: '5h', remaining_fraction: -0.1 }] } + ] + } + } + } + }, + { + name: 'remaining_fraction above one', + value: { + command: { + data: { + groups: [{ name: 'x', buckets: [{ name: 'x', window: '5h', remaining_fraction: 1.1 }] }] + } + } + } } - ])('returns a parse error for $name', ({ value }) => { - const result = parseAgyUsageResponse(value) + ])('returns a parse error for $name', async ({ value }) => { + const result = await fetchUsage(value) expect(result.status).toBe('error') expect(result.usageMetadata?.failureKind).toBe('parse') }) -}) -describe('extractAgyVersion', () => { - it.each([ - { output: '1.2.4', expected: '1.2.4' }, - { output: 'agy version 1.1.11\n', expected: '1.1.11' }, - { output: 'v1.1.10 (darwin arm64)', expected: '1.1.10' }, - { output: '1.1.11-rc.1', expected: '1.1.11-rc.1' }, - { output: 'agy 1.2.4+build.7', expected: '1.2.4+build.7' } - ])('reads $output as $expected', ({ output, expected }) => { - expect(extractAgyVersion(output)).toBe(expected) + it('reports malformed stdout as a parse failure', async () => { + mockAgy(exited('1.2.9'), exited('{')) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') }) - - it.each([{ output: '' }, { output: 'agy' }, { output: 'version unknown' }])( - 'returns null for $output', - ({ output }) => { - expect(extractAgyVersion(output)).toBeNull() - } - ) }) -describe('fetchAntigravityRateLimits', () => { - function mockVersionThenUsage(versionStdout: string, usageStdout: string) { - vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') - vi.mocked(execFileCaptureToTermination) - .mockResolvedValueOnce({ stdout: versionStdout, stderr: '' }) - .mockResolvedValueOnce({ stdout: usageStdout, stderr: '' }) - } - - function usageInvocations() { - return vi - .mocked(execFileCaptureToTermination) - .mock.calls.filter(([, args]) => args === AGY_USAGE_ARGS) - } - - it('resolves agy outside PATH and uses the exact argv without a shell', async () => { - mockVersionThenUsage('1.2.4', JSON.stringify(sample)) - await fetchAntigravityRateLimits() - expect(execFileCaptureToTermination).toHaveBeenCalledWith( - '/mock/bin/agy', - AGY_VERSION_ARGS, - expect.objectContaining({ - timeout: 5_000, - signal: undefined, - createTimeoutError: expect.any(Function) - }) - ) - expect(execFileCaptureToTermination).toHaveBeenCalledWith( - '/mock/bin/agy', - AGY_USAGE_ARGS, - expect.objectContaining({ - timeout: 10_000, - maxBuffer: 1024 * 1024, - signal: undefined, - createTimeoutError: expect.any(Function) - }) - ) - }) - - it('proceeds on the 1.1.11 boundary version', async () => { - mockVersionThenUsage('1.1.11', JSON.stringify(sample)) - const result = await fetchAntigravityRateLimits() +describe('fetchAntigravityRateLimits version gate', () => { + it('probes the resolved agy, then reads usage with the refresh signal', async () => { + const controller = new AbortController() + mockAgy(exited('1.1.11'), exited(JSON.stringify(sample))) + const result = await fetchAntigravityRateLimits(controller.signal) expect(result.status).toBe('ok') - expect(usageInvocations()).toHaveLength(1) + expect(vi.mocked(runProcess).mock.calls.map(([spec]) => [spec.program, spec.args])).toEqual([ + ['/mock/bin/agy', ['--version']], + ['/mock/bin/agy', ['--print', '/usage', '--output-format', 'json']] + ]) + for (const [spec] of vi.mocked(runProcess).mock.calls) { + expect(spec.signal).toBe(controller.signal) + } }) - // Why: agy 1.1.10 answers `-p /usage` with a billable agent turn instead of a - // quota report, so the usage argv must never be spawned for older CLIs. - it('never invokes /usage on agy older than 1.1.11', async () => { - vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') - vi.mocked(execFileCaptureToTermination).mockResolvedValueOnce({ - stdout: '1.1.10', - stderr: '' - }) + // Why: agy 1.1.10 answers `/usage` with a billable agent turn, so it must never be spawned. + it.each([ + { name: 'an older release', version: exited('1.1.10'), found: '1.1.10' }, + { name: 'a prerelease below the floor', version: exited('1.1.11-rc.1'), found: '1.1.11-rc.1' }, + { name: 'unreadable output', version: exited('version unknown'), found: null }, + { name: 'a failed probe', version: exited('1.2.9', { code: 1 }), found: null }, + { name: 'a timed-out probe', version: exited('', { code: null, timedOut: true }), found: null } + ])('never invokes /usage for $name', async ({ version, found }) => { + mockAgy(version) const result = await fetchAntigravityRateLimits() expect(result.status).toBe('unavailable') - expect(result.error).toContain(AGY_MIN_USAGE_VERSION) - expect(result.error).toContain('1.1.10') expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') - expect(vi.mocked(execFileCaptureToTermination).mock.calls).toHaveLength(1) - expect(usageInvocations()).toHaveLength(0) - }) - - // Why: `hasReachedAppVersion` ranks prereleases below the stable floor, and the - // extractor preserves the suffix, so an unverified build can never spawn /usage. - it('never invokes /usage on a prerelease below the stable floor', async () => { - vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') - vi.mocked(execFileCaptureToTermination).mockResolvedValueOnce({ - stdout: '1.1.11-rc.1', - stderr: '' - }) - const result = await fetchAntigravityRateLimits() - expect(result.status).toBe('unavailable') - expect(result.error).toContain(AGY_MIN_USAGE_VERSION) + expect(result.error).toContain('1.1.11') + if (found) { + expect(result.error).toContain(`found ${found}`) + } expect(usageInvocations()).toHaveLength(0) }) - it.each([{ versionStdout: '' }, { versionStdout: 'version unknown' }])( - 'never invokes /usage when the version is unreadable ($versionStdout)', - async ({ versionStdout }) => { - vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') - vi.mocked(execFileCaptureToTermination).mockResolvedValueOnce({ - stdout: versionStdout, - stderr: '' - }) - const result = await fetchAntigravityRateLimits() - expect(result.status).toBe('unavailable') - expect(result.error).toContain(AGY_MIN_USAGE_VERSION) - expect(usageInvocations()).toHaveLength(0) - } - ) - - it('never invokes /usage when the version probe fails', async () => { + it('never invokes /usage when the version probe cannot start', async () => { vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') - vi.mocked(execFileCaptureToTermination).mockRejectedValueOnce( - Object.assign(new Error('The agy CLI timed out.'), { code: 'ETIMEDOUT' }) - ) + vi.mocked(runProcess).mockRejectedValueOnce(new Error('spawn EACCES')) const result = await fetchAntigravityRateLimits() expect(result.status).toBe('unavailable') expect(usageInvocations()).toHaveLength(0) }) - it('distinguishes an unresolved executable', async () => { + it('reports an unresolved executable without spawning', 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() + expect(runProcess).not.toHaveBeenCalled() }) - it('reports malformed stdout as a parse failure', async () => { - mockVersionThenUsage('1.2.4', '{') - 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') - const controller = new AbortController() - vi.mocked(execFileCaptureToTermination) - .mockResolvedValueOnce({ stdout: '1.2.4', stderr: '' }) - .mockResolvedValueOnce({ stdout: JSON.stringify(sample), stderr: '' }) - await fetchAntigravityRateLimits(controller.signal) - expect(execFileCaptureToTermination).toHaveBeenCalledWith( - '/mock/bin/agy', - AGY_VERSION_ARGS, - expect.objectContaining({ signal: 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) - .mockResolvedValueOnce({ stdout: '1.2.4', stderr: '' }) - .mockRejectedValueOnce( - 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('distinguishes a usage timeout from a failed read', async () => { + mockAgy(exited('1.2.9'), exited('', { code: null, timedOut: true })) + const timedOut = await fetchAntigravityRateLimits() + expect(timedOut.status).toBe('error') + expect(timedOut.error).toContain('timed out') - it.each([ - { remaining: -0.1, label: 'below zero' }, - { remaining: 1.1, label: 'above one' } - ])('rejects remaining_fraction $label as a parse error', ({ remaining }) => { - const result = parseAgyUsageResponse({ - command: { - data: { - groups: [ - { name: 'x', buckets: [{ name: 'x', window: '5h', remaining_fraction: remaining }] } - ] - } - } - }) - expect(result.status).toBe('error') - expect(result.usageMetadata?.failureKind).toBe('parse') + mockAgy(exited('1.2.9'), exited('', { code: 2 })) + const failed = await fetchAntigravityRateLimits() + expect(failed.status).toBe('error') + expect(failed.error).not.toContain('timed out') }) }) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 941740f04458..e990bacfd254 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -1,28 +1,27 @@ import path from 'node:path' -import type { ProviderRateLimits, RateLimitBucket } from '../../shared/rate-limit-types' -import { hasReachedAppVersion } from '../../shared/app-version' +import type { + ProviderRateLimits, + RateLimitBucket, + UsageRateLimitFailureKind +} from '../../shared/rate-limit-types' +import { hasReachedAppVersion, parseCliVersion } from '../../shared/app-version' import { resolveCliCommand } from '../../shared/node-cli-command-resolution' -import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture' +import { runProcess, type ProcessResult } from '../../shared/child-process/run-process' -const AGY_USAGE_ARGS = ['--print', '/usage', '--output-format', 'json'] -const AGY_USAGE_TIMEOUT_MS = 10_000 -const AGY_USAGE_MAX_BUFFER = 1024 * 1024 -const AGY_VERSION_ARGS = ['--version'] -const AGY_VERSION_TIMEOUT_MS = 5_000 -const AGY_VERSION_MAX_BUFFER = 64 * 1024 -// Why 1.1.11: older agy answers `-p /usage` with a billable agent turn instead of -// a quota report; 1.1.11 handles read-only slash commands in print mode directly. +// Why 1.1.11: older agy answers `--print /usage` with a billable agent turn instead of +// a quota report, so the version is re-checked before every usage invocation. const AGY_MIN_USAGE_VERSION = '1.1.11' +const WINDOW_MINUTES: Readonly> = { '5h': 300, weekly: 10080 } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' } -function emptyAntigravityResult( +function antigravityFailure( status: 'error' | 'unavailable', error: string, - now: number, - failureKind: 'cli-unavailable' | 'usage-unavailable' | 'parse' | 'unknown' + failureKind: UsageRateLimitFailureKind, + now: number ): ProviderRateLimits { return { provider: 'antigravity', @@ -36,97 +35,73 @@ function emptyAntigravityResult( } } -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 quotaFormatError(now: number): ProviderRateLimits { + return antigravityFailure( + 'error', + 'Antigravity usage returned an unexpected quota format from the agy CLI.', + 'parse', + now + ) } -function parseResetAt(value: unknown): number | null { - if (typeof value !== 'string') { +function parseBucket(value: unknown, groupName: string): RateLimitBucket | null { + if (!isRecord(value)) { return null } - const parsed = Date.parse(value) - return Number.isFinite(parsed) ? parsed : null -} - -function agyQuotaFormatError(now: number): ProviderRateLimits { - return emptyAntigravityResult( - 'error', - 'Antigravity usage returned an unexpected quota format from the agy CLI.', - now, - 'parse' - ) + const name = typeof value.name === 'string' ? value.name.trim() : '' + const window = typeof value.window === 'string' ? value.window.trim() : '' + const remaining = value.remaining_fraction + if (!name || !window || typeof remaining !== 'number' || !(remaining >= 0 && remaining <= 1)) { + return null + } + const id = typeof value.id === 'string' ? value.id.trim() : '' + const resetsAt = typeof value.reset_time === 'string' ? Date.parse(value.reset_time) : Number.NaN + const windowMinutes = WINDOW_MINUTES[window] ?? 0 + return { + ...(id ? { id } : {}), + name, + groupName, + windowMinutes, + ...(windowMinutes === 0 ? { windowLabel: window } : {}), + usedPercent: (1 - remaining) * 100, + resetsAt: Number.isFinite(resetsAt) ? resetsAt : null, + resetDescription: null + } } /** - * Maps the `agy -p /usage` quota groups onto rate-limit buckets. Well-formed but - * unknown groups/windows are preserved; any malformed entry rejects the whole - * response so a partial read can never hide the tightest quota pool as `ok`. + * Maps `agy /usage` quota groups onto buckets, shortest known window first per group. + * Any malformed entry rejects the whole response so a partial read never hides a pool. */ -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 - if (!Array.isArray(groups) || groups.length === 0) { - return emptyAntigravityResult( +function parseAgyUsageResponse(value: unknown, now: number): ProviderRateLimits { + const data = isRecord(value) && isRecord(value.command) ? value.command.data : null + const groups = isRecord(data) ? data.groups : null + if (!Array.isArray(groups)) { + return quotaFormatError(now) + } + if (groups.length === 0) { + return antigravityFailure( 'unavailable', 'Antigravity usage is not available. The agy CLI returned no quota buckets.', - now, - 'usage-unavailable' + 'usage-unavailable', + now ) } const buckets: RateLimitBucket[] = [] - for (const groupValue of groups) { - if (!isRecord(groupValue)) { - return agyQuotaFormatError(now) - } - const groupName = typeof groupValue.name === 'string' ? groupValue.name.trim() : '' - if (!groupName || !Array.isArray(groupValue.buckets) || groupValue.buckets.length === 0) { - return agyQuotaFormatError(now) - } - const groupDescription = - typeof groupValue.description === 'string' ? groupValue.description.trim() || null : null - for (const bucketValue of groupValue.buckets) { - if (!isRecord(bucketValue)) { - return agyQuotaFormatError(now) - } - const name = typeof bucketValue.name === 'string' ? bucketValue.name.trim() : '' - const remaining = bucketValue.remaining_fraction - const sourceWindow = typeof bucketValue.window === 'string' ? bucketValue.window.trim() : '' - if ( - !name || - !sourceWindow || - typeof remaining !== 'number' || - !Number.isFinite(remaining) || - remaining < 0 || - remaining > 1 - ) { - return agyQuotaFormatError(now) - } - buckets.push({ - ...(typeof bucketValue.id === 'string' && bucketValue.id.trim() - ? { id: bucketValue.id.trim() } - : {}), - name, - groupName, - groupDescription, - windowMinutes: parseWindowMinutes(sourceWindow), - windowLabel: sourceWindow, - usedPercent: (1 - remaining) * 100, - resetsAt: parseResetAt(bucketValue.reset_time), - resetDescription: null - }) + for (const group of groups) { + const groupName = isRecord(group) && typeof group.name === 'string' ? group.name.trim() : '' + const rawBuckets = isRecord(group) && Array.isArray(group.buckets) ? group.buckets : [] + const parsed = rawBuckets.map((bucket) => parseBucket(bucket, groupName)) + if (!groupName || parsed.length === 0 || parsed.some((bucket) => bucket === null)) { + return quotaFormatError(now) } + // Why: unknown windows (0 minutes) sort last; `Infinity - Infinity` is NaN, so ties fall to the name. + const byWindow = (bucket: RateLimitBucket): number => bucket.windowMinutes || Infinity + buckets.push( + ...parsed + .filter((bucket): bucket is RateLimitBucket => bucket !== null) + .sort((a, b) => byWindow(a) - byWindow(b) || a.name.localeCompare(b.name)) + ) } return { provider: 'antigravity', @@ -140,131 +115,62 @@ function parseAgyUsageResponse(value: unknown, now = Date.now()): ProviderRateLi } } -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' - } -} - -/** Resolves the `agy` executable off PATH (bare name when absent). */ -export function getAntigravityUsageCommand(): string { - return resolveCliCommand('agy') -} - -// Why a pre-check: invoking /usage on agy <1.1.11 starts an agent turn that spends -// quota on every refresh, so the version gate must run before any usage invocation. -// Unreadable versions fail closed for the same reason. -/** Extracts the first semver triple (preserving prerelease/build) from `agy --version` output. */ -export function extractAgyVersion(output: string): string | null { - const match = output.match(/(\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?)/) - return match ? match[1] : null -} - -/** Probes `agy --version`; aborts propagate, every other failure reads as unsupported. */ -async function checkAgyUsageSupport( - command: string, - signal?: AbortSignal -): Promise<{ supported: boolean; version: string | null }> { - try { - const { stdout } = await execFileCaptureToTermination(command, AGY_VERSION_ARGS, { - encoding: 'utf8', - timeout: AGY_VERSION_TIMEOUT_MS, - maxBuffer: AGY_VERSION_MAX_BUFFER, - signal, - createTimeoutError: createAgyTimeoutError - }) - const version = extractAgyVersion(String(stdout)) - return { - supported: version !== null && hasReachedAppVersion(version, AGY_MIN_USAGE_VERSION), - version - } - } catch (error) { - if (signal?.aborted || (isRecord(error) && error.name === 'AbortError')) { - throw error - } - return { supported: false, version: null } - } +function runAgy( + program: string, + args: string[], + timeoutMs: number, + signal: AbortSignal | undefined +): Promise { + return runProcess({ program, args, timeoutMs, maxOutputBytes: 1024 * 1024, signal }).catch( + () => null + ) } -/** Reads native Antigravity quota: version-gated `agy -p /usage`, else an actionable `unavailable`. */ +/** Reads native Antigravity quota via version-gated `agy --print /usage`. */ export async function fetchAntigravityRateLimits( signal?: AbortSignal ): Promise { const now = Date.now() - const command = getAntigravityUsageCommand() - if (!path.isAbsolute(command)) { - return emptyAntigravityResult( + const agy = resolveCliCommand('agy') + if (!path.isAbsolute(agy)) { + return antigravityFailure( 'unavailable', 'Antigravity usage is unavailable because the agy CLI was not found.', - now, - 'cli-unavailable' + 'cli-unavailable', + now ) } - const versionCheck = await checkAgyUsageSupport(command, signal) - if (!versionCheck.supported) { - return emptyAntigravityResult( + const versionRun = await runAgy(agy, ['--version'], 5_000, signal) + const version = versionRun?.code === 0 ? parseCliVersion(versionRun.stdout) : null + if (!version || !hasReachedAppVersion(version, AGY_MIN_USAGE_VERSION)) { + return antigravityFailure( 'unavailable', - versionCheck.version - ? `Antigravity usage needs agy ${AGY_MIN_USAGE_VERSION} or newer (found ${versionCheck.version}). Update the agy CLI to show quota in the status bar.` - : 'Antigravity usage is unavailable because the agy CLI version could not be read. Update agy to 1.1.11 or newer.', - now, - 'usage-unavailable' + version + ? `Antigravity usage needs agy ${AGY_MIN_USAGE_VERSION} or newer (found ${version}). Update the agy CLI to show quota in the status bar.` + : `Antigravity usage is unavailable because the agy CLI version could not be read. Update agy to ${AGY_MIN_USAGE_VERSION} or newer.`, + 'usage-unavailable', + now + ) + } + const usageRun = await runAgy( + agy, + ['--print', '/usage', '--output-format', 'json'], + 10_000, + signal + ) + if (usageRun?.code !== 0) { + return antigravityFailure( + 'error', + usageRun?.timedOut + ? 'Antigravity usage could not be refreshed before the agy CLI timed out.' + : 'Antigravity usage could not be read from the agy CLI.', + 'unknown', + now ) } 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 agyQuotaFormatError(now) - } - } 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) + return parseAgyUsageResponse(JSON.parse(usageRun.stdout), now) + } catch { + return quotaFormatError(now) } } - -export { AGY_MIN_USAGE_VERSION, AGY_USAGE_ARGS, AGY_VERSION_ARGS, parseAgyUsageResponse } diff --git a/src/relay/managed-hook-installer.ts b/src/relay/managed-hook-installer.ts index 3fa57dd5ed84..22cf5450e9dc 100644 --- a/src/relay/managed-hook-installer.ts +++ b/src/relay/managed-hook-installer.ts @@ -6,7 +6,7 @@ import { import type { RelayDispatcher, RequestContext } from './dispatcher' import type { AgentHookTarget } from '../shared/agent-hook-types' import { isManagedAgentHookTarget } from '../shared/managed-agent-hook-targets' -import { parseClaudeCliVersion } from '../main/claude/claude-session-end-hook-capability' +import { parseCliVersion } from '../shared/app-version' export type ManagedHookInstallSummary = { installers: number @@ -48,7 +48,7 @@ function readClaudeVersion(params: unknown): string | undefined { params !== null && typeof params === 'object' && 'claudeVersion' in params ? params.claudeVersion : null - return parseClaudeCliVersion(typeof raw === 'string' ? raw : null) ?? undefined + return parseCliVersion(typeof raw === 'string' ? raw : null) ?? undefined } let managedHookRuntime: ManagedHookRuntime | null = null diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index abab43fcb2d0..0758dffee859 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -337,7 +337,6 @@ describe('getWindowSections', () => { id: 'gemini-5h', name: 'Five Hour Limit Remaining', groupName: 'Gemini Models', - groupDescription: 'Gemini quota', usedPercent: 0, windowMinutes: 300, resetsAt: null, @@ -347,7 +346,6 @@ describe('getWindowSections', () => { id: '3p-weekly', name: 'Weekly Limit Remaining', groupName: 'Claude and GPT models', - groupDescription: null, usedPercent: 0, windowMinutes: 10080, resetsAt: null, diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 51b398279144..dd4adce9f5f3 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -145,7 +145,6 @@ 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) => ({ @@ -158,8 +157,7 @@ export function getWindowSections(p: ProviderRateLimits): { : b.windowLabel || formatWindowLabel(b.windowMinutes) : b.name, window: b, - ...(b.groupName ? { groupName: b.groupName } : {}), - ...(b.groupDescription !== undefined ? { groupDescription: b.groupDescription } : {}) + ...(b.groupName ? { groupName: b.groupName } : {}) })) return [ ...bucketSections, diff --git a/src/renderer/src/components/status-bar/usage-section-selection.ts b/src/renderer/src/components/status-bar/usage-section-selection.ts index 321f1f21b5e0..0257ce3d2f6f 100644 --- a/src/renderer/src/components/status-bar/usage-section-selection.ts +++ b/src/renderer/src/components/status-bar/usage-section-selection.ts @@ -5,7 +5,6 @@ export type UsageSection = { label: string window: RateLimitWindow groupName?: string - groupDescription?: string | null } /** Choose the most consumed window, preserving input order for ties. */ diff --git a/src/shared/app-version.test.ts b/src/shared/app-version.test.ts index df3bff84c4cf..cc12125ed999 100644 --- a/src/shared/app-version.test.ts +++ b/src/shared/app-version.test.ts @@ -3,7 +3,8 @@ import { compareAppVersions, isPerfPrereleaseAppVersion, isPrereleaseAppVersion, - isValidAppVersion + isValidAppVersion, + parseCliVersion } from './app-version' describe('app version comparison', () => { @@ -23,3 +24,19 @@ describe('app version comparison', () => { expect(isPerfPrereleaseAppVersion('1.5.0-rc.1')).toBe(false) }) }) + +describe('parseCliVersion', () => { + it.each([ + ['2.1.261 (Claude Code)', '2.1.261'], + ['agy version 1.1.11\n', '1.1.11'], + ['v1.1.10 (darwin arm64)', '1.1.10'], + ['1.1.11-rc.1', '1.1.11-rc.1'], + ['agy 1.2.4+build.7', '1.2.4+build.7'] + ])('reads %j as %s', (output, expected) => { + expect(parseCliVersion(output)).toBe(expected) + }) + + it.each(['', 'agy', 'version unknown', null, undefined])('returns null for %j', (output) => { + expect(parseCliVersion(output)).toBeNull() + }) +}) diff --git a/src/shared/app-version.ts b/src/shared/app-version.ts index e3c537f4534a..383b69bff42f 100644 --- a/src/shared/app-version.ts +++ b/src/shared/app-version.ts @@ -22,6 +22,12 @@ export function isValidAppVersion(value: string): boolean { return parseVersion(value) !== null } +/** Extracts the first valid semver (with prerelease/build) from CLI `--version` output. */ +export function parseCliVersion(output: string | null | undefined): string | null { + const version = output?.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/)?.[0] + return version && isValidAppVersion(version) ? version : null +} + export function isPrereleaseAppVersion(value: string): boolean { const parsed = parseVersion(value) return parsed !== null && parsed.prerelease.length > 0 diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index c2889ec4e03c..f5a012c45ea8 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -17,9 +17,8 @@ export type RateLimitBucket = RateLimitWindow & { name: string /** Stable provider/source identifier when available. */ id?: string - /** Optional provider-defined quota group metadata. */ + /** Provider-defined quota pool; buckets sharing it render together, labelled by window. */ groupName?: string - groupDescription?: string | null } export type UsageRateLimitSource = 'oauth' | 'cli' | 'web' | 'live-session' From 8e346a594fdf811423f35e689a8020d528ec8660 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 23 Sep 2026 17:20:19 -0400 Subject: [PATCH 07/11] fix(rate-limits): apply Antigravity usage outside the shared provider batch A slow agy CLI (up to 15s across the version probe and the usage read) held every other provider's result back inside Promise.allSettled. It now runs as its own promise and applies after the batch, the same way Grok does. Antigravity has no dedicated fetch cycle, so a persistent agy error keeps the 5-minute full-fetch retry cadence Kimi already uses; the activation test now covers both providers. Co-Authored-By: Claude --- .../service-window-activation.test.ts | 92 ++++++++++-------- .../service/service-full-cycle-application.ts | 32 +++--- .../service/service-full-cycle-preparation.ts | 97 ++++++++++--------- 3 files changed, 111 insertions(+), 110 deletions(-) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 8be43130df15..c30fe9d6523f 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -5,6 +5,7 @@ import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' @@ -499,50 +500,55 @@ describe('RateLimitService', () => { } }) - it('keeps a full-fetch retry on the 5-minute cadence for a provider without a dedicated fetch cycle', async () => { - vi.useFakeTimers() - try { - // Kimi has no individual fetch cycle, so recovering it re-runs fetchAll - // (which hits Claude's tight-budget endpoint). A durable Kimi error must - // not drive that full fetch every 30s — it stays on the 5-minute cadence. - vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) - vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) - vi.mocked(fetchKimiRateLimits).mockResolvedValue(errorProvider('kimi', 'token expired')) - - const service = new RateLimitService() - const window = new FakeRateLimitWindow() - service.attach(asRateLimitWindow(window)) - service.start({ fetchImmediately: false }) - - await vi.advanceTimersByTimeAsync(1000) - expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) - expect(service.getState().kimi?.status).toBe('error') - - // First activation recovers immediately (retry timestamps start at 0). - window.emit('focus') - await vi.advanceTimersByTimeAsync(0) - expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) - expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) - - // Well past the 30s failure throttle but inside the 5-minute window: the - // full fetch (and the Claude read it entails) must not fire again. - await vi.advanceTimersByTimeAsync(2 * 60 * 1000) - window.emit('show') - await vi.advanceTimersByTimeAsync(0) - expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) - expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) - - // After the full 5-minute window the retry fires again. - await vi.advanceTimersByTimeAsync(4 * 60 * 1000) - window.emit('restore') - await vi.advanceTimersByTimeAsync(0) - expect(fetchKimiRateLimits).toHaveBeenCalledTimes(3) - - service.stop() - } finally { - vi.useRealTimers() + it.each([ + { provider: 'kimi', fetcher: fetchKimiRateLimits }, + { provider: 'antigravity', fetcher: fetchAntigravityRateLimits } + ] as const)( + 'keeps a full-fetch retry on the 5-minute cadence for $provider, which has no dedicated fetch cycle', + async ({ provider, fetcher }) => { + vi.useFakeTimers() + try { + // Without an individual fetch cycle, recovery re-runs fetchAll (which hits + // Claude's tight-budget endpoint), so a durable error stays on the 5-minute cadence. + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + vi.mocked(fetcher).mockResolvedValue(errorProvider(provider, 'token expired')) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(service.getState()[provider]?.status).toBe('error') + + // First activation recovers immediately (retry timestamps start at 0). + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetcher).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // Well past the 30s failure throttle but inside the 5-minute window: the + // full fetch (and the Claude read it entails) must not fire again. + await vi.advanceTimersByTimeAsync(2 * 60 * 1000) + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + expect(fetcher).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // After the full 5-minute window the retry fires again. + await vi.advanceTimersByTimeAsync(4 * 60 * 1000) + window.emit('restore') + await vi.advanceTimersByTimeAsync(0) + expect(fetcher).toHaveBeenCalledTimes(3) + + service.stop() + } finally { + vi.useRealTimers() + } } - }) + ) it('debounces unavailable providers on active window events', async () => { vi.useFakeTimers() 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 a0815fcab31c..abe371695f42 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -29,12 +29,12 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ claudeResult, codexResult, geminiResult, - antigravityResult, opencodeGoResult, kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + antigravityPromise } = prepared if (signal.aborted) { return @@ -79,22 +79,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ status: 'error' } satisfies ProviderRateLimits) - 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' ? opencodeGoResult.value @@ -169,7 +153,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ this.trackActiveFailureStreak('codex', codex) } this.trackActiveFailureStreak('gemini', gemini) - this.trackActiveFailureStreak('antigravity', antigravity) if (shouldApplyOpencode) { this.trackActiveFailureStreak('opencode-go', opencodeGo) } @@ -196,7 +179,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) : this.state.opencodeGo, kimi: this.applyStalePolicy(kimi, previousState.kimi), - antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), minimax: shouldApplyMiniMax ? miniMaxConfigChanged ? miniMax @@ -224,5 +206,15 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) }) + + const antigravity = await antigravityPromise + if (signal.aborted) { + return + } + this.trackActiveFailureStreak('antigravity', antigravity) + this.updateState({ + ...this.state, + antigravity: this.applyStalePolicy(antigravity, previousState.antigravity) + }) } } 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 4b625e84576f..a1fd921d4055 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -37,12 +37,12 @@ export type FetchAllCyclePrepared = { PromiseSettledResult, PromiseSettledResult, PromiseSettledResult, - PromiseSettledResult, PromiseSettledResult ] grokResultPromise: Promise< { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } > + antigravityPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -135,55 +135,58 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (value) => ({ status: 'fulfilled', value }) as const, (reason) => ({ status: 'rejected', reason }) as const ) + // Why: a slow agy CLI must not hold back the other providers, so it applies on its own like Grok. + const antigravityPromise = fetchAntigravityRateLimits(signal).catch( + (error): ProviderRateLimits => ({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: error instanceof Error ? error.message : 'Unknown error', + status: 'error' + }) + ) // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. const claudeFetchGated = !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - 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 - }) - ]) + 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 + }) + ]) if (signal.aborted) { return null @@ -208,12 +211,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ claudeResult, codexResult, geminiResult, - antigravityResult, opencodeGoResult, kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + antigravityPromise } } } From ce258bea114d6951cdc9e10b3c9f56569155081c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 23 Sep 2026 17:24:54 -0400 Subject: [PATCH 08/11] refactor(status-bar): render grouped usage buckets from data, not provider checks Buckets that carry a groupName now render the same way for any provider: - one groupUsageSections helper replaces five ad hoc group-by loops; - tooltips head each pool with its name and label buckets by window; - the roster and status bar compact modes show the tightest window per pool via getCompactUsageSections, which collapses to today's single figure for ungrouped providers; - the verbose status bar shows each pool's windows with their reset chips. Main now orders buckets, so the renderer's Antigravity sorter goes, along with antigravity-compact-metrics.tsx, antigravity-usage-format.ts, the non-null assertions they needed, and the unreachable bucket branch. Pool short labels come from word initials ("Claude and GPT models" -> "C/G") instead of hardcoded model names, and chips fall back to a window's source name when its duration is unknown. The roster's compact chip now uses the row's shared countdown clock. Co-Authored-By: Claude --- .../StatusBarProviderSegment.test.tsx | 28 +-- .../status-bar/StatusBarProviderSegment.tsx | 174 +++++------------- .../status-bar/UsageRosterPanel.test.tsx | 68 +------ .../status-bar/UsageRosterPanel.tsx | 89 ++++----- .../antigravity-compact-metrics.tsx | 62 ------- .../status-bar/antigravity-usage-format.ts | 32 ---- .../src/components/status-bar/tooltip.tsx | 91 ++++----- .../status-bar/usage-section-selection.ts | 20 ++ .../src/lib/window-label-formatter.test.ts | 6 + .../src/lib/window-label-formatter.ts | 4 +- 10 files changed, 177 insertions(+), 397 deletions(-) delete mode 100644 src/renderer/src/components/status-bar/antigravity-compact-metrics.tsx delete mode 100644 src/renderer/src/components/status-bar/antigravity-usage-format.ts diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx index 4e3ae3ccbbc9..517b3a59c81c 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx @@ -69,20 +69,24 @@ 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( - - ) + ] as const)( + 'labels each pool and falls back to window length in %s mode', + (display, expected) => { + const markup = renderToStaticMarkup( + + ) - for (const value of expected) { - expect(markup).toContain(value) + for (const value of expected) { + expect(markup).toContain(value) + } + expect(markup).toContain('G') + expect(markup).toContain('C/G') + // Why: without a reset time the chip names the window, as it does for Claude and Codex. + expect(markup).toContain('5h') + expect(markup).toContain('wk') + expect(markup).not.toContain('Limit Remaining') } - 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( diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index bbaf2eede321..60b87fa62b07 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -6,15 +6,12 @@ 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 { getCompactUsageSections, getTightestUsageSection } from './UsageRosterPanel' +import { getUsageGroupShortLabel, groupUsageSections } 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, @@ -55,58 +52,6 @@ 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. @@ -150,72 +95,46 @@ 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 GroupLabel({ groupName }: { groupName: string | undefined }): React.JSX.Element | null { + return groupName ? ( + {getUsageGroupShortLabel(groupName)} + ) : null } function VerboseProviderUsage({ p, - display, - now + display }: { 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.provider === 'antigravity' - ? p.buckets - : p.buckets.filter((bucket) => STATUS_BAR_BUCKET_NAMES.has(bucket.name)) + // Why: every grouped quota pool matters; only Gemini's ungrouped extras are filtered. + const visibleBuckets = p.buckets.filter( + (bucket) => bucket.groupName || STATUS_BAR_BUCKET_NAMES.has(bucket.name) + ) return ( <> - {visibleBuckets.map((bucket, index) => ( - - {index > 0 ? · : null} - - {bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)} - + {groupUsageSections(visibleBuckets).map(({ groupName, entries }, groupIndex) => ( + + {groupIndex > 0 ? | : null} + + {entries.map((bucket, index) => ( + + {index > 0 ? · : null} + {groupName ? ( + + ) : ( + + {bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)} + + )} + + ))} ))} {visibleBuckets.length === 0 && p.session ? ( @@ -288,7 +207,6 @@ 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') { @@ -343,22 +261,22 @@ export function ProviderSegment({ {tightest && !compact ? ( ) : null} - + - ) : p.provider === 'antigravity' ? ( - - ) : tightest ? ( - - ) : null} + ) : ( + getCompactUsageSections(p).map((section, index) => ( + + {index > 0 ? | : null} + + + + )) + )} {isStale && } ) diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx index 0a795b9f3886..4694c640c323 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -411,20 +411,20 @@ describe('UsageRow', () => { resetsAt: mocks.now + 2 * 60 * 60_000 })), { - id: '3p-weekly', - name: 'Weekly Limit Remaining', + id: '3p-5h', + name: 'Five Hour Limit Remaining', groupName: 'Claude and GPT models', usedPercent: 0, - windowMinutes: 10080, + windowMinutes: 300, resetsAt: null, resetDescription: null }, { - id: '3p-5h', - name: 'Five Hour Limit Remaining', + id: '3p-weekly', + name: 'Weekly Limit Remaining', groupName: 'Claude and GPT models', usedPercent: 0, - windowMinutes: 300, + windowMinutes: 10080, resetsAt: null, resetDescription: null } @@ -439,11 +439,12 @@ describe('UsageRow', () => { ) expect(compactMarkup).toContain('G') expect(compactMarkup).toContain('C/G') - expect(compactMarkup).not.toContain('5h') + // Why: a pool without a reset time names its window, as ungrouped chips do. + expect(compactMarkup).toContain('C/G 5h') expect(compactMarkup).not.toContain('wk') expect(compactMarkup).toContain('1%') expect(compactMarkup).not.toContain('1% used') - expect(compactMarkup.indexOf('1%')).toBeLessThan(compactMarkup.indexOf('2h')) + expect(compactMarkup).toContain('G 2h') const remainingCompactMarkup = renderToStaticMarkup( { ) 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')) }) }) diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index 8adc3612c247..5b40ba6a0aae 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -14,11 +14,14 @@ 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' +import { + getTightestUsageSectionFromSections, + getUsageGroupShortLabel, + groupUsageSections, + type UsageSection +} from './usage-section-selection' type ProviderId = ProviderRateLimits['provider'] @@ -41,15 +44,9 @@ function providerMaxUsed(sections: UsageSection[]): number { function shortLabel( p: ProviderRateLimits, section: UsageSection, - useRemainingDuration = false + useRemainingDuration = false, + now = Date.now() ): 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 } @@ -59,14 +56,20 @@ function shortLabel( return 'Fable' } return useRemainingDuration - ? formatRateLimitWindowChipLabel(section.window) - : formatWindowLabel(section.window.windowMinutes) + ? formatRateLimitWindowChipLabel(section.window, now) + : (section.window.windowLabel ?? formatWindowLabel(section.window.windowMinutes)) +} + +// Why: the compact summary promises one quiet figure per quota pool (one for ungrouped providers). +export function getCompactUsageSections(p: ProviderRateLimits, now?: number): UsageSection[] { + return groupUsageSections(usedSections(p)).flatMap(({ entries }) => { + const tightest = getTightestUsageSectionFromSections(entries) + return tightest ? [{ ...tightest, label: shortLabel(p, tightest, true, now) }] : [] + }) } export function getTightestUsageSection(p: ProviderRateLimits): UsageSection | null { - const sections = usedSections(p) - const tightest = getTightestUsageSectionFromSections(sections) - return tightest ? { ...tightest, label: shortLabel(p, tightest, true) } : null + return getTightestUsageSectionFromSections(getCompactUsageSections(p)) } // The soonest-resetting window summarizes the agent's next reset in one line. @@ -130,7 +133,7 @@ export function UsageRow({ const name = getProviderDisplayName(p.provider) const plan = formatPlanLabel(p.planType) const reset = hasUsage ? soonestResetLabel(sections, now) : null - const tightest = mode === 'compact' ? getTightestUsageSection(p) : null + const compactSections = mode === 'compact' ? getCompactUsageSections(p, now) : [] return (
@@ -153,16 +156,21 @@ export function UsageRow({ ) : null} - ) : p.provider === 'antigravity' && hasUsage && mode === 'compact' ? ( - - ) : tightest ? ( - - + ) : compactSections.length > 0 ? ( + + {compactSections.map((section) => ( + + ))} ) : reset ? ( {reset} @@ -170,34 +178,15 @@ export function UsageRow({
{hasUsage && mode === 'verbose' ? (
- {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]) => ( -
+ {groupUsageSections(sections).map(({ groupName, entries }) => ( +
{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) => ( + {entries.map((section) => ( () - 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 deleted file mode 100644 index 78e2fe38ad0b..000000000000 --- a/src/renderer/src/components/status-bar/antigravity-usage-format.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index dd4adce9f5f3..e0c49d008fe5 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -1,4 +1,8 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitBucket, + RateLimitWindow +} from '../../../../shared/rate-limit-types' import { formatResetCountdown, formatResetDuration @@ -19,7 +23,7 @@ import { import { formatUsagePercentageLabel } from './usage-percentage-label' import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' import { formatWindowLabel } from '@/lib/window-label-formatter' -import { sortAntigravityBuckets } from './antigravity-usage-format' +import { groupUsageSections } from './usage-section-selection' // Re-exported from its shared home so status-bar callers keep a single import. export { clampUsedPercent } @@ -141,23 +145,25 @@ function ErrorMessage({ // Window section derivation // --------------------------------------------------------------------------- -export function getWindowSections(p: ProviderRateLimits): { - label: string - window: RateLimitWindow | null - groupName?: string -}[] { +// Why: a grouped bucket sits under its pool heading, so it is named by its window instead. +function getGroupedBucketLabel(bucket: RateLimitBucket): string { + if (bucket.windowMinutes === 300) { + return translate('auto.components.status.bar.tooltip.94038ad2fa', 'Session') + } + if (bucket.windowMinutes === 10080) { + return translate('auto.components.status.bar.tooltip.252c096536', 'Weekly') + } + return bucket.windowLabel ?? formatWindowLabel(bucket.windowMinutes) +} + +export function getWindowSections( + p: ProviderRateLimits +): { label: string; window: RateLimitWindow | null; groupName?: string }[] { if (p.buckets?.length) { 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, + label: b.groupName ? getGroupedBucketLabel(b) : b.name, window: b, - ...(b.groupName ? { groupName: b.groupName } : {}) + groupName: b.groupName })) return [ ...bucketSections, @@ -334,34 +340,6 @@ 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 (
@@ -390,14 +368,23 @@ export function ProviderPanel({
- {p.provider === 'antigravity' - ? groupedAntigravitySections.map(({ groupName, sections }) => ( -
- {groupName ?
{groupName}
: null} - {sections.map(renderWindowSection)} -
- )) - : windowSections.map(renderWindowSection)} + {groupUsageSections(windowSections.filter((s) => s.window)).map(({ groupName, entries }) => ( +
+ {groupName ?
{groupName}
: null} + {entries.map((s) => ( + + ))} +
+ ))} {p.error ? ( ( + entries: readonly T[] +): { groupName: string | undefined; entries: T[] }[] { + const groups = new Map() + for (const entry of entries) { + groups.set(entry.groupName, [...(groups.get(entry.groupName) ?? []), entry]) + } + return [...groups].map(([groupName, grouped]) => ({ groupName, entries: grouped })) +} + +/** Initials of a pool name for tight status-bar space, e.g. "Claude and GPT models" -> "C/G". */ +export function getUsageGroupShortLabel(groupName: string): string { + const initials = groupName + .split(/\s+/) + .filter((word) => word && !/^(and|models?)$/i.test(word)) + .map((word) => word.charAt(0).toUpperCase()) + return initials.length > 0 ? initials.join('/') : groupName +} diff --git a/src/renderer/src/lib/window-label-formatter.test.ts b/src/renderer/src/lib/window-label-formatter.test.ts index 0390f7e84a0f..d94669d9643f 100644 --- a/src/renderer/src/lib/window-label-formatter.test.ts +++ b/src/renderer/src/lib/window-label-formatter.test.ts @@ -79,6 +79,12 @@ describe('formatRateLimitWindowChipLabel', () => { expect(formatRateLimitWindowChipLabel({ windowMinutes: 10080, resetsAt: null })).toBe('wk') }) + it('falls back to the source window name when the duration is unknown', () => { + expect( + formatRateLimitWindowChipLabel({ windowMinutes: 0, resetsAt: null, windowLabel: 'daily' }) + ).toBe('daily') + }) + it('reports "now" when the reset timestamp has already passed', () => { const now = 1_700_000_000_000 expect(formatRateLimitWindowChipLabel({ windowMinutes: 300, resetsAt: now - MIN }, now)).toBe( diff --git a/src/renderer/src/lib/window-label-formatter.ts b/src/renderer/src/lib/window-label-formatter.ts index b14e13aa3a9e..2a6120a833b7 100644 --- a/src/renderer/src/lib/window-label-formatter.ts +++ b/src/renderer/src/lib/window-label-formatter.ts @@ -41,11 +41,11 @@ export function formatWindowLabel(windowMinutes: number): string { * when no reset timestamp is available. */ export function formatRateLimitWindowChipLabel( - window: { windowMinutes: number; resetsAt: number | null }, + window: { windowMinutes: number; resetsAt: number | null; windowLabel?: string }, now: number = Date.now() ): string { if (window.resetsAt != null) { return formatResetDuration(window.resetsAt - now) } - return formatWindowLabel(window.windowMinutes) + return window.windowLabel ?? formatWindowLabel(window.windowMinutes) } From 14728d89d5c18b16d802437d06f511ec332b5c25 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Wed, 23 Sep 2026 23:39:57 -0300 Subject: [PATCH 09/11] fix(rate-limits): terminate the agy process tree on timeout or abort execFileCaptureToTermination defaulted terminationBarrier to true; the runProcess migration dropped it, so a timed-out or aborted agy killed only the root and left descendants behind (cmd.exe behind a .cmd shim on Windows). Pin it with a regression assertion. --- .../rate-limits/antigravity-usage-fetcher.test.ts | 2 ++ src/main/rate-limits/antigravity-usage-fetcher.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index 06f1e557b2ef..800ddb8c1e3d 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -217,6 +217,8 @@ describe('fetchAntigravityRateLimits version gate', () => { ]) for (const [spec] of vi.mocked(runProcess).mock.calls) { expect(spec.signal).toBe(controller.signal) + // Why: a timed-out/aborted agy must take its descendants with it, not just the root. + expect(spec.terminationBarrier).toBe(true) } }) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index e990bacfd254..09e735af26f8 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -121,9 +121,17 @@ function runAgy( timeoutMs: number, signal: AbortSignal | undefined ): Promise { - return runProcess({ program, args, timeoutMs, maxOutputBytes: 1024 * 1024, signal }).catch( - () => null - ) + // Why terminationBarrier: the old execFileCaptureToTermination default killed the whole + // tree on timeout/abort; without it only the root dies, orphaning agy descendants (Windows + // cmd.exe behind a .cmd shim, POSIX groups). + return runProcess({ + program, + args, + timeoutMs, + maxOutputBytes: 1024 * 1024, + signal, + terminationBarrier: true + }).catch(() => null) } /** Reads native Antigravity quota via version-gated `agy --print /usage`. */ From 354f5938298b8fac7f1b44c3d06fe895b13af47c Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Wed, 23 Sep 2026 23:40:00 -0300 Subject: [PATCH 10/11] fix(status-bar): drive reset labels from the countdown clock formatRateLimitWindowChipLabel defaulted to render-time Date.now() and ProviderSegment scheduled no boundary render, so remaining-time chips went stale until an unrelated state update. Feed useResetCountdownClock through the verbose and compact label paths. --- .../StatusBarProviderSegment.test.tsx | 32 +++++++++++++++++++ .../status-bar/StatusBarProviderSegment.tsx | 28 +++++++++++----- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx index 517b3a59c81c..47fb737be27c 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx @@ -4,6 +4,16 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +const mocks = vi.hoisted(() => ({ + // Why Date.now default: the existing label tests build resetsAt from the wall clock, so the + // stub must track it; individual tests override with mockReturnValueOnce to pin the wiring. + useResetCountdownClock: vi.fn(() => Date.now()) +})) + +vi.mock('@/hooks/useResetCountdownClock', () => ({ + useResetCountdownClock: mocks.useResetCountdownClock +})) + vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: ({ agent }: { agent: string }) => })) @@ -158,4 +168,26 @@ describe('Antigravity status summary', () => { expect(used).toMatch(/80% used.*(?:1h|2h)/) expect(remaining).toMatch(/20% left.*(?:1h|2h)/) }) + + it('renders reset labels from the countdown clock, not render-time Date.now()', () => { + mocks.useResetCountdownClock.mockClear() + const clockNow = 1_000_000_000 + mocks.useResetCountdownClock.mockReturnValueOnce(clockNow) + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('80% used 2h') + expect(mocks.useResetCountdownClock).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 60b87fa62b07..e74e51ac7bf9 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -12,6 +12,7 @@ import { getUsageGroupShortLabel, groupUsageSections } from './usage-section-sel import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter' import { formatUsagePercentageLabel } from './usage-percentage-label' import { translate } from '@/i18n/i18n' +import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' function MiniBar({ usedPct, @@ -103,10 +104,12 @@ function GroupLabel({ groupName }: { groupName: string | undefined }): React.JSX function VerboseProviderUsage({ p, - display + display, + now }: { p: ProviderRateLimits display: UsagePercentageDisplay + now: number }): React.JSX.Element { if (p.buckets && p.buckets.length > 0) { // Why: every grouped quota pool matters; only Gemini's ungrouped extras are filtered. @@ -125,7 +128,7 @@ function VerboseProviderUsage({ {groupName ? ( ) : ( @@ -140,7 +143,7 @@ function VerboseProviderUsage({ {visibleBuckets.length === 0 && p.session ? ( ) : null} @@ -153,14 +156,14 @@ function VerboseProviderUsage({ ? { key: 'session', window: p.session, - label: formatRateLimitWindowChipLabel(p.session) + label: formatRateLimitWindowChipLabel(p.session, now) } : null, p.weekly ? { key: 'weekly', window: p.weekly, - label: formatRateLimitWindowChipLabel(p.weekly) + label: formatRateLimitWindowChipLabel(p.weekly, now) } : null, p.fableWeekly @@ -175,7 +178,7 @@ function VerboseProviderUsage({ ? { key: 'monthly', window: p.monthly, - label: formatRateLimitWindowChipLabel(p.monthly) + label: formatRateLimitWindowChipLabel(p.monthly, now) } : null ].filter((window): window is { key: string; window: RateLimitWindow; label: string } => { @@ -207,6 +210,15 @@ export function ProviderSegment({ }): React.JSX.Element { const provider = p?.provider ?? 'claude' const statusLabel = p ? getProviderUsageStatusLabel(p) : '' + // Why: labels below are remaining-time text, so they must re-render on the countdown + // boundary instead of waiting for an unrelated state update. + const now = useResetCountdownClock([ + p?.session?.resetsAt, + p?.weekly?.resetsAt, + p?.fableWeekly?.resetsAt, + p?.monthly?.resetsAt, + ...(p?.buckets ?? []).map((bucket) => bucket.resetsAt) + ]) // Idle / initial load if (!p || p.status === 'idle') { @@ -261,10 +273,10 @@ export function ProviderSegment({ {tightest && !compact ? ( ) : null} - + ) : ( - getCompactUsageSections(p).map((section, index) => ( + getCompactUsageSections(p, now).map((section, index) => ( {index > 0 ? | : null} From faa0069d4aafea5155f3b120197c6f62a1a55b08 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Thu, 24 Sep 2026 02:08:04 -0300 Subject: [PATCH 11/11] style: drop the incidental reformat of an upstream-only test file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge commit's lint-staged oxfmt pass rewrote line wrapping in a file this PR does not otherwise touch, adding unrelated diff noise against main. Restored to main's exact content (hook skipped — oxfmt would reapply it). --- .../slices/agent-status-main-agent-fact.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts b/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts index 8ceb45488e8d..bd00eaa50158 100644 --- a/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts +++ b/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts @@ -51,12 +51,14 @@ describe('the main agent fact on a renderer status entry', () => { }) expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toEqual(mainAgent) - store.getState().setAgentStatus(PANE, { - state: 'done', - prompt: 'go', - agentType: 'claude', - observation: osc(2) - }) + store + .getState() + .setAgentStatus(PANE, { + state: 'done', + prompt: 'go', + agentType: 'claude', + observation: osc(2) + }) expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toBeUndefined() })