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 ({ runProcess: vi.fn() })) +vi.mock('../../shared/node-cli-command-resolution', () => ({ resolveCliCommand: vi.fn() })) + +const sample = { + command: { + data: { + groups: [ + { + name: 'Gemini Models', + 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' + } + ] + } + ] + } + } +} + +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('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?.map((bucket) => [bucket.id, bucket.groupName, bucket.windowMinutes]) + ).toEqual([ + ['gemini-5h', 'Gemini Models', 300], + ['gemini-weekly', 'Gemini Models', 10080], + ['3p-5h', 'Claude and GPT models', 300], + ['3p-weekly', 'Claude and GPT models', 10080] + ]) + expect(result.buckets?.[1]?.usedPercent).toBeCloseTo(0.6) + expect(result.buckets?.[0]?.windowLabel).toBeUndefined() + expect(result.buckets?.[3]?.resetsAt).toBeNull() + }) + + 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 }, + { id: '5h', name: 'Five', window: '5h', remaining_fraction: 1 } + ] + } + ] + } + } + }) + expect(result.buckets?.map((bucket) => bucket.id)).toEqual(['5h', 'daily']) + expect(result.buckets?.[1]).toMatchObject({ + groupName: 'New pool', + windowLabel: 'daily', + windowMinutes: 0, + usedPercent: 50 + }) + }) + + 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. + it.each([ + { name: 'missing groups', value: { command: { data: {} } } }, + { name: 'missing command', value: {} }, + { + 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' } + ] + } + ] + } + } + } + }, + { + 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', async ({ value }) => { + const result = await fetchUsage(value) + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + }) + + 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') + }) +}) + +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(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: a timed-out/aborted agy must take its descendants with it, not just the root. + expect(spec.terminationBarrier).toBe(true) + } + }) + + // 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.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toContain('1.1.11') + if (found) { + expect(result.error).toContain(`found ${found}`) + } + expect(usageInvocations()).toHaveLength(0) + }) + + it('never invokes /usage when the version probe cannot start', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(runProcess).mockRejectedValueOnce(new Error('spawn EACCES')) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('unavailable') + expect(usageInvocations()).toHaveLength(0) + }) + + 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(runProcess).not.toHaveBeenCalled() + }) + + 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') + + 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 new file mode 100644 index 000000000000..09e735af26f8 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,184 @@ +import path from 'node:path' +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 { runProcess, type ProcessResult } from '../../shared/child-process/run-process' + +// 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 antigravityFailure( + status: 'error' | 'unavailable', + error: string, + failureKind: UsageRateLimitFailureKind, + now: number +): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [], + updatedAt: now, + error, + status, + usageMetadata: { source: 'cli', failureKind } + } +} + +function quotaFormatError(now: number): ProviderRateLimits { + return antigravityFailure( + 'error', + 'Antigravity usage returned an unexpected quota format from the agy CLI.', + 'parse', + now + ) +} + +function parseBucket(value: unknown, groupName: string): RateLimitBucket | null { + if (!isRecord(value)) { + return null + } + 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 `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: 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.', + 'usage-unavailable', + now + ) + } + const buckets: RateLimitBucket[] = [] + 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', + session: null, + weekly: null, + buckets, + updatedAt: now, + error: null, + status: 'ok', + usageMetadata: { source: 'cli', lastSuccessfulSource: 'cli' } + } +} + +function runAgy( + program: string, + args: string[], + timeoutMs: number, + signal: AbortSignal | undefined +): Promise { + // 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`. */ +export async function fetchAntigravityRateLimits( + signal?: AbortSignal +): Promise { + const now = Date.now() + const agy = resolveCliCommand('agy') + if (!path.isAbsolute(agy)) { + return antigravityFailure( + 'unavailable', + 'Antigravity usage is unavailable because the agy CLI was not found.', + 'cli-unavailable', + now + ) + } + 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', + 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 { + return parseAgyUsageResponse(JSON.parse(usageRun.stdout), now) + } catch { + return quotaFormatError(now) + } +} 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/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 25f7baeb78d6..2b3dfe3172d8 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 163730246145..776e41864c41 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-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 9f5afe9423d1..ac4134e5220b 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-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 1dee94e8c991..175d69014093 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 9b9137568fb9..0f3dec294111 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 89b311827b66..94e48c72f5a0 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 9b70fa0ea51f..fb62cfac73e1 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 2b53c0919a1b..29bda048dd25 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 { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' @@ -33,6 +34,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) @@ -495,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 7104face38fa..abe371695f42 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 { @@ -34,7 +33,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + antigravityPromise } = prepared if (signal.aborted) { return @@ -79,9 +79,6 @@ 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 opencodeGo = opencodeGoResult.status === 'fulfilled' ? opencodeGoResult.value @@ -156,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) } @@ -183,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 @@ -211,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 3e4f77b906de..d316bf2a1d09 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' @@ -42,6 +43,7 @@ export type FetchAllCyclePrepared = { grokResultPromise: Promise< { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } > + antigravityPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -139,6 +141,17 @@ 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 = @@ -215,7 +228,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + antigravityPromise } } } 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/StatusBarProviderSegment.test.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx new file mode 100644 index 000000000000..47fb737be27c --- /dev/null +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx @@ -0,0 +1,193 @@ +// @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' + +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 }) => +})) + +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)( + '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) + } + 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') + } + ) + + 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)/) + }) + + 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 24de1094534f..e74e51ac7bf9 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -7,10 +7,12 @@ import { } from '../../../../shared/usage-percentage-display' import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' import { ProviderIcon, clampUsedPercent, getProviderUsageStatusLabel } from './tooltip' -import { getTightestUsageSection } from './UsageRosterPanel' +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 { useResetCountdownClock } from '@/hooks/useResetCountdownClock' function MiniBar({ usedPct, @@ -94,29 +96,54 @@ 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 GroupLabel({ groupName }: { groupName: string | undefined }): React.JSX.Element | null { + return groupName ? ( + {getUsageGroupShortLabel(groupName)} + ) : null +} + function VerboseProviderUsage({ p, - display + display, + now }: { p: ProviderRateLimits display: UsagePercentageDisplay + now: number }): React.JSX.Element { if (p.buckets && p.buckets.length > 0) { - const visibleBuckets = 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 ? ( ) : null} @@ -129,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 @@ -151,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 } => { @@ -183,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') { @@ -237,16 +273,22 @@ export function ProviderSegment({ {tightest && !compact ? ( ) : null} - + - ) : tightest ? ( - - ) : null} + ) : ( + getCompactUsageSections(p, now).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 de4f5a0e06f8..4694c640c323 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -12,7 +12,11 @@ const mocks = vi.hoisted(() => ({ })) 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,132 @@ 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-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 + } + ] + }} + display="used" + mode="compact" + state={{ kind: 'usage', statusLabel: null }} + showSignInAction={false} + now={mocks.now} + /> + ) + expect(compactMarkup).toContain('G') + expect(compactMarkup).toContain('C/G') + // 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).toContain('G 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') + }) }) 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..5b40ba6a0aae 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, @@ -16,9 +16,14 @@ import { getProviderDisplayName } from './usage-error-copy' import { formatPlanLabel, usageTextColorClass } from './usage-roster-formatting' import { getUsageRosterRowState, type UsageRosterRowState } from './usage-roster-row-state' import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { + getTightestUsageSectionFromSections, + getUsageGroupShortLabel, + groupUsageSections, + 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 @@ -39,7 +44,8 @@ function providerMaxUsed(sections: UsageSection[]): number { function shortLabel( p: ProviderRateLimits, section: UsageSection, - useRemainingDuration = false + useRemainingDuration = false, + now = Date.now() ): string { if (p.buckets?.some((b) => b.name === section.label)) { return section.label @@ -50,23 +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) - 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) } + 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,28 +156,44 @@ export function UsageRow({ ) : null} - ) : tightest ? ( - - + ) : compactSections.length > 0 ? ( + + {compactSections.map((section) => ( + + ))} ) : reset ? ( {reset} ) : null}
{hasUsage && mode === 'verbose' ? ( -
- {sections.map((section) => ( - +
+ {groupUsageSections(sections).map(({ groupName, entries }) => ( +
+ {groupName ? ( +
{groupName}
+ ) : null} +
+ {entries.map((section) => ( + + ))} +
+
))}
) : null} 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 239c3314eb35..d61c3299fb5f 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 @@ -124,11 +124,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) // Why: an OPENCODE_API_KEY or a key OpenCode saved on /connect is invisible // to the renderer, so main's presence flag is the only durable signal. expect(hasUsageProviderSettings(usageSettings({ opencodeGoApiKeyConfigured: true }))).toBe(true) @@ -174,21 +171,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', @@ -370,16 +365,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', @@ -389,7 +382,7 @@ describe('getVisibleUsageProvider', () => { }), usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(null) + ).toMatchObject({ provider: 'antigravity', status: 'unavailable' }) }) }) @@ -503,9 +496,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( { @@ -520,6 +511,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 c14f0a23a39e..a32db147a367 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 @@ -79,8 +79,9 @@ export function hasUsageProviderSettings( settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || settings?.opencodeGoApiKeyConfigured === true || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. + // Antigravity's usage now comes from its own agy CLI probe, which does not + // need the Gemini OAuth opt-in, so this is a durable signal of its own. + settings?.antigravityUsageConfigured === true || settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true @@ -110,10 +111,7 @@ export function hasUsageProviderSettingsForProvider( ) } 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..0758dffee859 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -327,6 +327,41 @@ 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', + 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: 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 +457,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 +510,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..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 @@ -18,6 +22,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 { groupUsageSections } from './usage-section-selection' // Re-exported from its shared home so status-bar callers keep a single import. export { clampUsedPercent } @@ -139,11 +145,26 @@ function ErrorMessage({ // Window section derivation // --------------------------------------------------------------------------- +// 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 }[] { +): { label: string; window: RateLimitWindow | null; groupName?: string }[] { if (p.buckets?.length) { - const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow })) + const bucketSections = p.buckets.map((b) => ({ + label: b.groupName ? getGroupedBucketLabel(b) : b.name, + window: b, + groupName: b.groupName + })) return [ ...bucketSections, { @@ -285,7 +306,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 (
@@ -340,23 +368,28 @@ export function ProviderPanel({
- {windowSections.map((s) => ( - + {groupUsageSections(windowSections.filter((s) => s.window)).map(({ groupName, entries }) => ( +
+ {groupName ?
{groupName}
: null} + {entries.map((s) => ( + + ))} +
))} {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..813a619b9eea --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-section-selection.ts @@ -0,0 +1,40 @@ +import type { RateLimitWindow } from '../../../../shared/rate-limit-types' +import { clampUsedPercent } from '../../../../shared/usage-percentage-display' + +export type UsageSection = { + label: string + window: RateLimitWindow + groupName?: string +} + +/** 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 + ) +} + +/** Groups by quota pool in first-seen order; ungrouped entries share one unnamed group. */ +export function groupUsageSections( + 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/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index d2f9a21ea18d..f8caaa6d41ff 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/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) } 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 dd89c9e8ec69..116209482557 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -7,12 +7,18 @@ 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 + /** Provider-defined quota pool; buckets sharing it render together, labelled by window. */ + groupName?: string } export type UsageRateLimitSource = 'oauth' | 'cli' | 'web' | 'live-session'