From 10474b7ba2f00522a3a2bf6523c02ecd54c21eef Mon Sep 17 00:00:00 2001 From: Seongjun Kim Date: Thu, 30 Jul 2026 18:03:14 +0900 Subject: [PATCH 1/6] fix(usage): avoid misleading Gemini quota reporting Read quota windows from the active Agy LanguageServer so Orca mirrors Antigravity's five-hour and weekly usage, and retire the deprecated Gemini CLI OAuth path. Constraint: Agy quota is exposed by a host-local LanguageServer, not a supported remote account API Rejected: Keep the deprecated Gemini OAuth fallback | it preserves ambiguous and divergent quota semantics Confidence: high Scope-risk: moderate Directive: Keep Gemini agent support separate from Gemini usage-provider state Tested: 934 targeted tests; pnpm typecheck; pnpm build; changed-file oxlint and oxfmt; localization checks; live used and remaining UI validation Not-tested: Full pnpm test and pnpm lint remain blocked by pre-existing local relay environment and daemon max-lines failures --- mobile/src/components/accounts-snapshot.ts | 11 +- .../runtime-home-service.test.ts | 1 - src/main/codex-accounts/service.test.ts | 2 - src/main/index.ts | 1 - src/main/persistence.test.ts | 15 + src/main/persistence.ts | 23 +- .../antigravity-usage-fetcher.test.ts | 138 ++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 314 ++++++++++++++++++ .../gemini-bucket-formatting.test.ts | 65 ---- .../rate-limits/gemini-bucket-formatting.ts | 84 ----- .../rate-limits/gemini-cli-oauth-extractor.ts | 262 --------------- src/main/rate-limits/gemini-oauth-sources.ts | 163 --------- .../gemini-usage-fetcher.fallback.test.ts | 187 ----------- .../gemini-usage-fetcher.test-fixtures.ts | 43 --- .../rate-limits/gemini-usage-fetcher.test.ts | 217 ------------ src/main/rate-limits/gemini-usage-fetcher.ts | 248 -------------- src/main/rate-limits/service.test.ts | 69 ++-- src/main/rate-limits/service.ts | 111 +++---- .../runtime/rpc/methods/client-ui-schemas.ts | 1 - .../components/settings/AccountsPane.test.tsx | 1 - .../src/components/settings/AccountsPane.tsx | 87 +---- .../AppearanceWindowSidebarSection.tsx | 1 - .../components/settings/accounts-search.ts | 32 -- .../settings/appearance-status-bar-search.ts | 29 -- .../components/stats/GrokUsagePane.test.tsx | 1 - .../src/components/status-bar/StatusBar.tsx | 33 +- .../status-bar/StatusBarUsageEmptyCta.tsx | 4 +- .../status-bar/UsageRosterPanel.tsx | 2 +- .../provider-segment-monthly-window.test.tsx | 23 +- .../status-bar-agent-gating.test.ts | 3 - .../status-bar/status-bar-agent-gating.ts | 4 +- .../status-bar-provider-visibility.test.ts | 91 ++--- .../status-bar-provider-visibility.ts | 31 +- .../src/components/status-bar/tooltip.test.ts | 39 +-- .../src/components/status-bar/tooltip.tsx | 21 +- .../status-bar/usage-error-copy.test.ts | 1 - .../components/status-bar/usage-error-copy.ts | 3 - .../usage-provider-settings-target.test.ts | 3 +- .../usage-provider-settings-target.ts | 4 +- .../hooks/useSettingsNavigationMetadata.ts | 2 +- src/renderer/src/i18n/locales/en.json | 16 +- src/renderer/src/i18n/locales/es.json | 16 +- src/renderer/src/i18n/locales/ja.json | 16 +- src/renderer/src/i18n/locales/ko.json | 16 +- src/renderer/src/i18n/locales/zh.json | 16 +- src/renderer/src/store/slices/rate-limits.ts | 1 - src/renderer/src/store/slices/ui.test.ts | 26 ++ src/renderer/src/store/slices/ui.ts | 12 +- src/renderer/src/web/web-preload-api.ts | 1 - src/shared/constants.ts | 1 - src/shared/rate-limit-types.test.ts | 1 - src/shared/rate-limit-types.ts | 13 +- src/shared/status-bar-defaults.ts | 1 - src/shared/telemetry-events.ts | 1 - src/shared/types.ts | 3 - 55 files changed, 712 insertions(+), 1798 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/gemini-bucket-formatting.test.ts delete mode 100644 src/main/rate-limits/gemini-bucket-formatting.ts delete mode 100644 src/main/rate-limits/gemini-cli-oauth-extractor.ts delete mode 100644 src/main/rate-limits/gemini-oauth-sources.ts delete mode 100644 src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts delete mode 100644 src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts delete mode 100644 src/main/rate-limits/gemini-usage-fetcher.test.ts delete mode 100644 src/main/rate-limits/gemini-usage-fetcher.ts diff --git a/mobile/src/components/accounts-snapshot.ts b/mobile/src/components/accounts-snapshot.ts index 8baf02136235..1cc5077a263f 100644 --- a/mobile/src/components/accounts-snapshot.ts +++ b/mobile/src/components/accounts-snapshot.ts @@ -31,16 +31,7 @@ const RateLimitResetCreditsSchema = z export const ProviderRateLimitsSchema = z .object({ - provider: z.enum([ - 'claude', - 'codex', - 'gemini', - 'opencode-go', - 'kimi', - 'minimax', - 'grok', - 'antigravity' - ]), + provider: z.enum(['claude', 'codex', 'opencode-go', 'kimi', 'minimax', 'grok', 'antigravity']), session: RateLimitWindowSchema.nullable(), weekly: RateLimitWindowSchema.nullable(), fableWeekly: RateLimitWindowSchema.nullable().optional(), diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9546c2f251f1..9c1097d37982 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -158,7 +158,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { opencodeWorkspaceId: '', minimaxGroupId: '', minimaxUsageModels: 'general', - geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, confirmClosePinnedTab: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index c2ae74b8911f..c060a119c752 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -166,7 +166,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { opencodeWorkspaceId: '', minimaxGroupId: '', minimaxUsageModels: 'general', - geminiCliOAuthEnabled: false, agentCmdOverrides: {}, keepComputerAwakeWhileAgentsRun: false, confirmClosePinnedTab: true, @@ -258,7 +257,6 @@ function createResetRateLimitState( return { claude: null, codex, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, diff --git a/src/main/index.ts b/src/main/index.ts index 5dbd92065f0b..c7bce7453c9b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2185,7 +2185,6 @@ void app.whenReady().then(async () => { models: settings.minimaxUsageModels } }) - rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled) rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) keybindings = new KeybindingService({ homePath: app.getPath('home'), diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d7a8c5dd8024..52b80e33a590 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5395,6 +5395,21 @@ describe('Store', () => { expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') }) + it('removes the deprecated Gemini CLI OAuth usage setting on load', async () => { + writeDataFile({ + settings: { + geminiCliOAuthEnabled: true + } + }) + + const store = await createStore() + + expect(store.getSettings()).not.toHaveProperty('geminiCliOAuthEnabled') + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings).not.toHaveProperty('geminiCliOAuthEnabled') + }) + it('migrates legacy terminal scrollback byte presets by intent', async () => { writeDataFile({ settings: { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 85de59a8bdaf..b4484e96ea61 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -615,6 +615,15 @@ function stripLegacyTerminalScrollbackBytes( return rest } +function stripDeprecatedGeminiCliOAuthSetting( + settings: Partial | undefined +): Partial { + const { geminiCliOAuthEnabled: _deprecatedGeminiCliOAuthEnabled, ...rest } = (settings ?? + {}) as Partial & { geminiCliOAuthEnabled?: unknown } + void _deprecatedGeminiCliOAuthEnabled + return rest +} + function migrateTerminalScrollbackRows(settings: unknown): { rows: number needsSave: boolean @@ -3129,6 +3138,12 @@ export class Store { const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( parsed.settings?.sourceControlGroupOrder ) + if ( + parsed.settings && + Object.prototype.hasOwnProperty.call(parsed.settings, 'geminiCliOAuthEnabled') + ) { + this.loadNeedsSave = true + } if ( parsed.settings?.sourceControlGroupOrder !== undefined && parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder @@ -3156,7 +3171,9 @@ export class Store { settings: { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripLegacyTerminalScrollbackBytes(parsed.settings), + ...stripDeprecatedGeminiCliOAuthSetting( + stripLegacyTerminalScrollbackBytes(parsed.settings) + ), prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), @@ -5409,7 +5426,9 @@ export class Store { updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) + const sanitizedUpdates = stripDeprecatedGeminiCliOAuthSetting( + stripLegacyTerminalScrollbackBytes(updates) + ) // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true 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..a21ee33ac036 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +import { + fetchAntigravityRateLimits, + parseAgyLogEndpoint, + parseAgyQuotaSummary, + parseCsrfToken, + parseLsofListeners +} from './antigravity-usage-fetcher' + +function response(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockResolvedValue(body) + } as unknown as Response +} + +const quotaSummary = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + window: 'weekly', + remainingFraction: 0.9873386, + resetTime: '2026-08-06T04:21:42Z' + }, + { + bucketId: 'gemini-5h', + window: '5h', + remainingFraction: 0.9240316, + resetTime: '2026-07-30T12:25:31Z' + } + ] + }, + { + displayName: 'Claude and GPT models', + buckets: [ + { bucketId: '3p-weekly', window: 'weekly', remainingFraction: 1 }, + { bucketId: '3p-5h', window: '5h', remainingFraction: 1 } + ] + } + ] + } +} + +describe('Agy endpoint discovery parsers', () => { + it('reads the live HTTP endpoint from an Agy CLI log', () => { + expect( + parseAgyLogEndpoint(` +I0730 16:55:25.550727 27262 server.go:1424] Starting language server process with pid 27262 +I0730 16:55:25.554099 27262 server.go:568] Language server listening on random port at 50590 for HTTP +`) + ).toEqual({ pid: 27262, port: 50590, csrfToken: null }) + }) + + it('reads Agy and desktop language-server listeners from lsof fields', () => { + expect( + parseLsofListeners('p27262\ncagy\nn127.0.0.1:50590\np27451\nclanguage_\nn127.0.0.1:50610\n') + ).toEqual([ + { pid: 27262, processName: 'agy', port: 50590 }, + { pid: 27451, processName: 'language_', port: 50610 } + ]) + }) + + it('reads both supported CSRF argument forms', () => { + expect(parseCsrfToken('language_server --csrf_token abc-123 --standalone')).toBe('abc-123') + expect(parseCsrfToken('language_server --csrf_token=def-456')).toBe('def-456') + }) +}) + +describe('Agy quota summary', () => { + beforeEach(() => { + netFetchMock.mockReset() + }) + + it('maps the Gemini model group to the real five-hour and weekly windows', () => { + const windows = parseAgyQuotaSummary(quotaSummary) + + expect(windows?.session.windowMinutes).toBe(300) + expect(windows?.session.usedPercent).toBeCloseTo(7.59684) + expect(windows?.session.resetsAt).toBe(Date.parse('2026-07-30T12:25:31Z')) + expect(windows?.weekly.windowMinutes).toBe(10_080) + expect(windows?.weekly.usedPercent).toBeCloseTo(1.26614) + }) + + it('does not synthesize fixed windows from unrelated model buckets', () => { + expect(parseAgyQuotaSummary({ response: { groups: [] } })).toBeNull() + }) + + it('fetches the live Agy summary with its CSRF token', async () => { + netFetchMock.mockResolvedValue(response(quotaSummary)) + + const limits = await fetchAntigravityRateLimits(async () => [ + { pid: 27451, port: 50610, csrfToken: 'csrf-token' } + ]) + + expect(limits.status).toBe('ok') + expect(limits.session?.windowMinutes).toBe(300) + expect(limits.weekly?.windowMinutes).toBe(10_080) + expect(limits.buckets).toBeUndefined() + expect(netFetchMock).toHaveBeenCalledWith( + expect.stringContaining('RetrieveUserQuotaSummary'), + expect.objectContaining({ + headers: expect.objectContaining({ 'x-codeium-csrf-token': 'csrf-token' }) + }) + ) + }) + + it('tries the next Agy listener when the adjacent HTTPS port rejects HTTP', async () => { + netFetchMock + .mockRejectedValueOnce(new Error('Client sent an HTTP request to an HTTPS server')) + .mockResolvedValueOnce(response(quotaSummary)) + + const limits = await fetchAntigravityRateLimits(async () => [ + { pid: 27262, port: 50589, csrfToken: null }, + { pid: 27262, port: 50590, csrfToken: null } + ]) + + expect(limits.status).toBe('ok') + expect(netFetchMock).toHaveBeenCalledTimes(2) + }) + + it('reports unavailable when no Agy runtime is active', async () => { + const limits = await fetchAntigravityRateLimits(async () => []) + + expect(limits.status).toBe('unavailable') + expect(limits.error).toContain('not running') + expect(netFetchMock).not.toHaveBeenCalled() + }) +}) 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..ecae737f6bff --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,314 @@ +import { execFile } from 'node:child_process' +import { open, readdir } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import { net } from 'electron' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' + +const COMMAND_TIMEOUT_MS = 4_000 +const FETCH_TIMEOUT_MS = 5_000 +const LOG_PREFIX_BYTES = 16 * 1024 +const SUMMARY_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' +const AGY_LOG_DIR = path.join(homedir(), '.gemini', 'antigravity-cli', 'log') + +export type AgyQuotaEndpoint = { + pid: number + port: number + csrfToken: string | null +} + +type AgySummaryBucket = { + bucketId?: unknown + window?: unknown + remainingFraction?: unknown + resetTime?: unknown +} + +type AgySummaryGroup = { + displayName?: unknown + buckets?: unknown +} + +type AgySummaryResponse = { + response?: { + groups?: unknown + } +} + +type LsofListener = { + pid: number + processName: string + port: number +} + +function result( + status: ProviderRateLimits['status'], + error: string | null, + windows?: { session: RateLimitWindow; weekly: RateLimitWindow } +): ProviderRateLimits { + return { + provider: 'antigravity', + session: windows?.session ?? null, + weekly: windows?.weekly ?? null, + updatedAt: Date.now(), + error, + status, + usageMetadata: { + source: 'live-session', + credentialSource: 'agy-local-service', + authProvenance: 'antigravity', + ...(status === 'unavailable' ? { failureKind: 'cli-unavailable' as const } : {}) + } + } +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + command, + args, + { + encoding: 'utf8', + timeout: COMMAND_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 2 * 1024 * 1024 + }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EPERM' + ) + } +} + +async function readFilePrefix(filePath: string): Promise { + const handle = await open(filePath, 'r') + try { + const buffer = Buffer.alloc(LOG_PREFIX_BYTES) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + return buffer.subarray(0, bytesRead).toString('utf8') + } finally { + await handle.close() + } +} + +export function parseAgyLogEndpoint(log: string): AgyQuotaEndpoint | null { + const pid = Number.parseInt( + log.match(/Starting language server process with pid\s+(\d+)/)?.[1] ?? '', + 10 + ) + const port = Number.parseInt( + log.match(/Language server listening on (?:random|fixed) port at\s+(\d+)\s+for HTTP\b/)?.[1] ?? + '', + 10 + ) + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port) || port < 1 || port > 65_535) { + return null + } + return { pid, port, csrfToken: null } +} + +export function parseLsofListeners(output: string): LsofListener[] { + const listeners: LsofListener[] = [] + let pid: number | null = null + let processName = '' + for (const line of output.split('\n')) { + if (line.startsWith('p')) { + pid = Number.parseInt(line.slice(1), 10) + processName = '' + continue + } + if (line.startsWith('c')) { + processName = line.slice(1) + continue + } + if (!line.startsWith('n') || !pid || !processName) { + continue + } + const port = Number.parseInt(line.match(/:(\d+)(?:\s+\(LISTEN\))?$/)?.[1] ?? '', 10) + if (Number.isInteger(port) && port > 0 && port <= 65_535) { + listeners.push({ pid, processName, port }) + } + } + return listeners +} + +export function parseCsrfToken(commandLine: string): string | null { + return commandLine.match(/(?:^|\s)--csrf_token(?:=|\s+)([^\s]+)/)?.[1] ?? null +} + +async function discoverLogEndpoints(): Promise { + try { + const names = (await readdir(AGY_LOG_DIR)) + .filter((name) => /^cli-\d{8}_\d{6}\.log$/.test(name)) + .sort() + .toReversed() + .slice(0, 8) + const endpoints: AgyQuotaEndpoint[] = [] + for (const name of names) { + const endpoint = parseAgyLogEndpoint(await readFilePrefix(path.join(AGY_LOG_DIR, name))) + if (endpoint && isProcessAlive(endpoint.pid)) { + endpoints.push(endpoint) + } + } + return endpoints + } catch { + return [] + } +} + +async function discoverLsofEndpoints(): Promise { + if (process.platform === 'win32') { + return [] + } + try { + const listeners = parseLsofListeners( + await runCommand('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn']) + ).filter(({ processName }) => /^(agy|language_?(?:server)?)$/i.test(processName)) + const commandLines = new Map() + for (const pid of new Set(listeners.map((listener) => listener.pid))) { + commandLines.set(pid, await runCommand('ps', ['-p', String(pid), '-o', 'command='])) + } + return listeners.flatMap(({ pid, processName, port }) => { + const commandLine = commandLines.get(pid) ?? '' + if (/^agy$/i.test(processName)) { + return [{ pid, port, csrfToken: null }] + } + if ( + !commandLine.includes('language_server') || + !commandLine.toLowerCase().includes('antigravity') + ) { + return [] + } + const csrfToken = parseCsrfToken(commandLine) + return csrfToken ? [{ pid, port, csrfToken }] : [] + }) + } catch { + return [] + } +} + +export async function discoverAgyQuotaEndpoints(): Promise { + const endpoints = [...(await discoverLogEndpoints()), ...(await discoverLsofEndpoints())] + const seen = new Set() + return endpoints.filter((endpoint) => { + const key = `${endpoint.pid}:${endpoint.port}` + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +function isSummaryBucket(value: unknown): value is AgySummaryBucket { + if (!value || typeof value !== 'object') { + return false + } + const bucket = value as AgySummaryBucket + return ( + typeof bucket.window === 'string' && + typeof bucket.remainingFraction === 'number' && + Number.isFinite(bucket.remainingFraction) && + typeof bucket.resetTime === 'string' + ) +} + +function toWindow(bucket: AgySummaryBucket, windowMinutes: number): RateLimitWindow { + const remainingFraction = Math.min(1, Math.max(0, bucket.remainingFraction as number)) + const resetsAt = Date.parse(bucket.resetTime as string) + return { + usedPercent: (1 - remainingFraction) * 100, + windowMinutes, + resetsAt: Number.isFinite(resetsAt) ? resetsAt : null, + resetDescription: null + } +} + +export function parseAgyQuotaSummary( + data: unknown +): { session: RateLimitWindow; weekly: RateLimitWindow } | null { + const groups = (data as AgySummaryResponse | null)?.response?.groups + if (!Array.isArray(groups)) { + return null + } + const geminiGroup = groups.find((group): group is AgySummaryGroup => { + if (!group || typeof group !== 'object') { + return false + } + const candidate = group as AgySummaryGroup + return ( + candidate.displayName === 'Gemini Models' || + (Array.isArray(candidate.buckets) && + candidate.buckets.some((bucket) => { + const bucketId = + bucket && typeof bucket === 'object' ? (bucket as AgySummaryBucket).bucketId : null + return typeof bucketId === 'string' && bucketId.startsWith('gemini-') + })) + ) + }) + if (!geminiGroup || !Array.isArray(geminiGroup.buckets)) { + return null + } + const buckets = geminiGroup.buckets.filter(isSummaryBucket) + const fiveHour = buckets.find((bucket) => bucket.window === '5h') + const weekly = buckets.find((bucket) => bucket.window === 'weekly') + if (!fiveHour || !weekly) { + return null + } + return { + session: toWindow(fiveHour, 300), + weekly: toWindow(weekly, 10_080) + } +} + +async function fetchSummary(endpoint: AgyQuotaEndpoint): Promise { + const response = await net.fetch(`http://127.0.0.1:${endpoint.port}${SUMMARY_PATH}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(endpoint.csrfToken ? { 'x-codeium-csrf-token': endpoint.csrfToken } : {}) + }, + body: '{}', + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + return response.ok ? await response.json() : null +} + +export async function fetchAntigravityRateLimits( + discoverEndpoints: () => Promise = discoverAgyQuotaEndpoints +): Promise { + try { + const endpoints = await discoverEndpoints() + if (endpoints.length === 0) { + return result('unavailable', 'Agy local usage service is not running') + } + for (const endpoint of endpoints) { + try { + const windows = parseAgyQuotaSummary(await fetchSummary(endpoint)) + if (windows) { + return result('ok', null, windows) + } + } catch { + // Agy exposes adjacent HTTPS and HTTP listeners; only the HTTP endpoint answers here. + } + } + return result('error', 'Agy model quota summary is unavailable') + } catch (error) { + return result('error', error instanceof Error ? error.message : 'Unknown Agy usage error') + } +} diff --git a/src/main/rate-limits/gemini-bucket-formatting.test.ts b/src/main/rate-limits/gemini-bucket-formatting.test.ts deleted file mode 100644 index b806fbef9024..000000000000 --- a/src/main/rate-limits/gemini-bucket-formatting.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { RateLimitBucket } from '../../shared/rate-limit-types' -import { getBucketName, deriveSessionSummary } from './gemini-bucket-formatting' - -describe('getBucketName', () => { - it('maps known model IDs to stable names', () => { - expect(getBucketName('gemini-2.5-pro')).toBe('Pro') - expect(getBucketName('gemini-2.5-flash')).toBe('Flash') - expect(getBucketName('gemini-2.5-flash-lite')).toBe('Flash Lite') - expect(getBucketName('gemini-2.0-flash-lite')).toBe('2.0 Flash Lite') - expect(getBucketName('gemini-2.0-flash')).toBe('2.0 Flash') - }) - - it('humanizes unknown model IDs by stripping the gemini- prefix', () => { - expect(getBucketName('gemini-3.0-ultra')).toBe('3.0 Ultra') - expect(getBucketName('gemini-experimental')).toBe('Exp') - expect(getBucketName('some-random-id')).toBe('Some Random Id') - }) -}) - -describe('deriveSessionSummary', () => { - it('returns null for empty buckets', () => { - expect(deriveSessionSummary([])).toBeNull() - }) - - it('picks the most constrained bucket (highest usedPercent) as session summary', () => { - const buckets: RateLimitBucket[] = [ - { name: 'Pro', usedPercent: 30, windowMinutes: 60, resetsAt: null, resetDescription: null }, - { name: 'Flash', usedPercent: 80, windowMinutes: 60, resetsAt: null, resetDescription: null }, - { - name: 'Flash Lite', - usedPercent: 10, - windowMinutes: 60, - resetsAt: null, - resetDescription: null - } - ] - const summary = deriveSessionSummary(buckets) - expect(summary).not.toBeNull() - expect(summary!.usedPercent).toBe(80) - expect(summary!.windowMinutes).toBe(60) - }) - - it('preserves reset metadata from the most constrained bucket', () => { - const buckets: RateLimitBucket[] = [ - { - name: 'Pro', - usedPercent: 30, - windowMinutes: 60, - resetsAt: 1000, - resetDescription: '2:00 PM' - }, - { - name: 'Flash', - usedPercent: 80, - windowMinutes: 60, - resetsAt: 2000, - resetDescription: '3:00 PM' - } - ] - const summary = deriveSessionSummary(buckets) - expect(summary!.resetsAt).toBe(2000) - expect(summary!.resetDescription).toBe('3:00 PM') - }) -}) diff --git a/src/main/rate-limits/gemini-bucket-formatting.ts b/src/main/rate-limits/gemini-bucket-formatting.ts deleted file mode 100644 index 3d58db808b73..000000000000 --- a/src/main/rate-limits/gemini-bucket-formatting.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' - -const MODEL_ID_TO_BUCKET_NAME: Record = { - 'gemini-3.1-pro': '3.1 Pro', - 'gemini-3.1-flash': '3.1 Flash', - 'gemini-3.1-flash-lite': '3.1 Flash Lite', - 'gemini-3.0-pro': '3.0 Pro', - 'gemini-3.0-flash': '3.0 Flash', - 'gemini-2.5-pro': 'Pro', - 'gemini-2.5-flash': 'Flash', - 'gemini-2.5-flash-lite': 'Flash Lite', - 'gemini-2.0-pro': '2.0 Pro', - 'gemini-2.0-flash': '2.0 Flash', - 'gemini-2.0-flash-lite': '2.0 Flash Lite', - 'gemini-1.5-pro': '1.5 Pro', - 'gemini-1.5-flash': '1.5 Flash', - 'gemini-exp': 'Exp', - 'gemini-experimental': 'Exp' -} - -function humanizeModelId(modelId: string): string { - const withoutPrefix = modelId.replace(/^gemini-/i, '') - return withoutPrefix - .split('-') - .map((part) => (part.length > 0 ? part[0]!.toUpperCase() + part.slice(1) : part)) - .join(' ') -} - -export function getBucketName(modelId: string): string { - return MODEL_ID_TO_BUCKET_NAME[modelId] ?? humanizeModelId(modelId) -} - -export function buildRateLimitBucket(b: { - remainingFraction: number - resetTime: string - modelId: string -}): RateLimitBucket { - const usedPercent = Math.min(100, Math.max(0, Math.round((1 - b.remainingFraction) * 100))) - const resetsAtTime = new Date(b.resetTime).getTime() - return { - name: getBucketName(b.modelId), - usedPercent, - windowMinutes: 60, - resetsAt: !Number.isNaN(resetsAtTime) ? resetsAtTime : null, - resetDescription: null - } -} - -export function deduplicateBuckets( - buckets: (RateLimitBucket & { modelId: string })[] -): RateLimitBucket[] { - const result: (RateLimitBucket & { modelId: string })[] = [] - const seenKeys = new Map() - for (const b of buckets) { - const key = `${b.usedPercent}-${b.resetsAt}` - const existingIndex = seenKeys.get(key) - if (existingIndex === undefined) { - seenKeys.set(key, result.length) - result.push(b) - continue - } - const existing = result[existingIndex]! - const existingInMap = existing.modelId in MODEL_ID_TO_BUCKET_NAME - const currentInMap = b.modelId in MODEL_ID_TO_BUCKET_NAME - if ( - (currentInMap && !existingInMap) || - (currentInMap === existingInMap && b.name.length < existing.name.length) - ) { - result[existingIndex] = b - } - } - return result.map(({ modelId: _id, ...rest }) => rest) -} - -export function deriveSessionSummary(buckets: RateLimitBucket[]): RateLimitWindow | null { - if (buckets.length === 0) { - return null - } - const mostConstrained = buckets.reduce((worst, bucket) => { - return bucket.usedPercent > worst.usedPercent ? bucket : worst - }) - const { name: _name, ...window } = mostConstrained - return window -} diff --git a/src/main/rate-limits/gemini-cli-oauth-extractor.ts b/src/main/rate-limits/gemini-cli-oauth-extractor.ts deleted file mode 100644 index 80e841e7adf8..000000000000 --- a/src/main/rate-limits/gemini-cli-oauth-extractor.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { exec } from 'node:child_process' -import { access, readdir, readFile, realpath } from 'node:fs/promises' -import { promisify } from 'node:util' -import { homedir } from 'node:os' -import path from 'node:path' - -const execAsync = promisify(exec) - -async function fileExists(filePath: string): Promise { - try { - await access(filePath) - return true - } catch { - return false - } -} - -// The oauth2.js relative path inside a @google/gemini-cli-core package. -const OAUTH2_SUBPATH = path.join('dist', 'src', 'code_assist', 'oauth2.js') - -async function resolveGeminiBinary(): Promise { - const whichCmd = process.platform === 'win32' ? 'where gemini' : 'which gemini' - try { - const { stdout } = await execAsync(whichCmd, { encoding: 'utf-8' }) - const fromPath = stdout.trim().split(/\r?\n/)[0] - if (fromPath && (await fileExists(fromPath))) { - return fromPath - } - } catch { - // ignore which/where failure - } - - // Why: on macOS/Linux GUI apps, the PATH might not include the binary. - // Checking common installation prefixes as fallbacks. - if (process.platform !== 'win32') { - const fallbacks = [ - '/usr/local/bin/gemini', - '/opt/homebrew/bin/gemini', - path.join(homedir(), '.local', 'bin', 'gemini'), - path.join(homedir(), 'bin', 'gemini') - ] - for (const candidate of fallbacks) { - if (await fileExists(candidate)) { - return candidate - } - } - } - - return null -} - -// Why: on all platforms the gemini binary may be a symlink (e.g. Homebrew's bin/ -// symlinks into Cellar). We must resolve it before deriving sibling paths — otherwise -// dirname points to the symlink directory, not the real installation root. -async function resolveSymlink(filePath: string): Promise { - try { - return await realpath(filePath) - } catch { - return filePath - } -} - -function parseOAuthCredentials(content: string): { clientId: string; clientSecret: string } | null { - const idMatch = content.match(/OAUTH_CLIENT_ID\s*=\s*['"]([^'"]+)['"]/)?.[1] - const secretMatch = content.match(/OAUTH_CLIENT_SECRET\s*=\s*['"]([^'"]+)['"]/)?.[1] - if (idMatch && secretMatch) { - return { clientId: idMatch, clientSecret: secretMatch } - } - return null -} - -async function tryReadCredentials( - filePath: string -): Promise<{ clientId: string; clientSecret: string } | null> { - try { - const content = await readFile(filePath, 'utf-8') - return parseOAuthCredentials(content) - } catch { - return null - } -} - -// Why: these are the known stable layouts for every major Gemini CLI install method. -// Checking explicit paths is fast and avoids walking the entire directory tree. -async function extractFromKnownPaths( - realGeminiPath: string -): Promise<{ clientId: string; clientSecret: string } | null> { - const binDir = path.dirname(realGeminiPath) - const baseDir = path.dirname(binDir) - - const candidates = [ - // Homebrew: bin -> Cellar//bin, real files live under libexec/lib - path.join( - baseDir, - 'libexec', - 'lib', - 'node_modules', - '@google', - 'gemini-cli', - 'node_modules', - '@google', - 'gemini-cli-core', - OAUTH2_SUBPATH - ), - // Homebrew alternate (some versions skip the extra nesting) - path.join( - baseDir, - 'lib', - 'node_modules', - '@google', - 'gemini-cli', - 'node_modules', - '@google', - 'gemini-cli-core', - OAUTH2_SUBPATH - ), - // Nix package layout - path.join( - baseDir, - 'share', - 'gemini-cli', - 'node_modules', - '@google', - 'gemini-cli-core', - OAUTH2_SUBPATH - ), - // npm/bun global install: gemini-cli-core is a sibling of gemini-cli - path.join(baseDir, '..', 'gemini-cli-core', OAUTH2_SUBPATH), - // npm nested inside gemini-cli - path.join(baseDir, 'node_modules', '@google', 'gemini-cli-core', OAUTH2_SUBPATH) - ] - - for (const candidate of candidates) { - const creds = await tryReadCredentials(path.normalize(candidate)) - if (creds) { - return creds - } - } - - return null -} - -// Why: newer Gemini CLI versions (>=0.38) ship everything bundled into hash-named -// chunks with no oauth2.js source file. Scanning the bundle dir for the credential -// constants is the only reliable fallback for those installs. -async function extractFromBundleDir( - geminiCliPackageRoot: string -): Promise<{ clientId: string; clientSecret: string } | null> { - const bundleDir = path.join(geminiCliPackageRoot, 'bundle') - if (!(await fileExists(bundleDir))) { - return null - } - - let entries: string[] - try { - entries = (await readdir(bundleDir)).filter((f) => f.endsWith('.js')) - } catch { - return null - } - - for (const entry of entries) { - const creds = await tryReadCredentials(path.join(bundleDir, entry)) - if (creds) { - return creds - } - } - - return null -} - -// Resolves the gemini-cli package root directory by walking up the directory -// tree from the real binary path, looking for package.json with the right name, -// or the global Node layout under lib/node_modules. -async function findGeminiPackageRoot(realGeminiPath: string): Promise { - const MAX_ASCENTS = 8 - let current = path.dirname(realGeminiPath) - - for (let i = 0; i <= MAX_ASCENTS; i++) { - const pkgJson = path.join(current, 'package.json') - if (await fileExists(pkgJson)) { - try { - const raw = await readFile(pkgJson, 'utf-8') - const pkg = JSON.parse(raw) as { name?: string } - if (pkg.name === '@google/gemini-cli') { - return current - } - } catch { - // malformed package.json — keep walking - } - } - - // Global Node layout: /lib/node_modules/@google/gemini-cli - const globalPkg = path.join( - current, - 'lib', - 'node_modules', - '@google', - 'gemini-cli', - 'package.json' - ) - if (await fileExists(globalPkg)) { - return path.join(current, 'lib', 'node_modules', '@google', 'gemini-cli') - } - - // Windows global install layout: /node_modules/@google/gemini-cli - const windowsGlobalPkg = path.join( - current, - 'node_modules', - '@google', - 'gemini-cli', - 'package.json' - ) - if (await fileExists(windowsGlobalPkg)) { - return path.join(current, 'node_modules', '@google', 'gemini-cli') - } - - const parent = path.dirname(current) - if (parent === current) { - break - } - current = parent - } - - return null -} - -export async function extractOAuthClientCredentials(): Promise<{ - clientId: string - clientSecret: string -} | null> { - const geminiPath = await resolveGeminiBinary() - if (!geminiPath) { - return null - } - - const realPath = await resolveSymlink(geminiPath) - - // 1. Known static paths (fast, covers most installs with source layout) - const fromKnown = await extractFromKnownPaths(realPath) - if (fromKnown) { - return fromKnown - } - - // 2. Walk up to find the package root, then try source layout + bundle dir - const packageRoot = await findGeminiPackageRoot(realPath) - if (packageRoot) { - const fromSource = - (await tryReadCredentials( - path.join(packageRoot, 'node_modules', '@google', 'gemini-cli-core', OAUTH2_SUBPATH) - )) ?? (await tryReadCredentials(path.join(packageRoot, OAUTH2_SUBPATH))) - if (fromSource) { - return fromSource - } - - const fromBundle = await extractFromBundleDir(packageRoot) - if (fromBundle) { - return fromBundle - } - } - - return null -} diff --git a/src/main/rate-limits/gemini-oauth-sources.ts b/src/main/rate-limits/gemini-oauth-sources.ts deleted file mode 100644 index 386c8b281f3a..000000000000 --- a/src/main/rate-limits/gemini-oauth-sources.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { readFile, writeFile, rename } from 'node:fs/promises' -import { homedir } from 'node:os' -import path from 'node:path' -import { net } from 'electron' -import { extractOAuthClientCredentials } from './gemini-cli-oauth-extractor' - -const API_TIMEOUT_MS = 10_000 -const OAUTH_CREDS_PATH = path.join(homedir(), '.gemini', 'oauth_creds.json') -const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' -const LOAD_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist' - -export type GeminiCredentials = { - access_token: string - refresh_token: string - expiry_date: number -} - -export type GoogleAuthEntry = { - type: 'oauth' - access: string - expires: number - refresh: string -} - -type AuthJson = { - google?: GoogleAuthEntry - 'opencode-go'?: { type: 'api'; key: string } -} - -export async function readAuthJson(): Promise { - const candidates = [ - process.env.APPDATA ? path.join(process.env.APPDATA, 'opencode', 'auth.json') : null, - process.env.XDG_DATA_HOME - ? path.join(process.env.XDG_DATA_HOME, 'opencode', 'auth.json') - : null, - path.join(homedir(), '.local', 'share', 'opencode', 'auth.json'), - path.join(homedir(), 'Library', 'Application Support', 'opencode', 'auth.json') - ].filter((candidate): candidate is string => candidate !== null) - - for (const candidate of candidates) { - try { - const raw = await readFile(candidate, 'utf-8') - return JSON.parse(raw) as AuthJson - } catch (err) { - if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { - continue - } - throw err - } - } - - return null -} - -export async function readGeminiCredentials(): Promise { - try { - const raw = await readFile(OAUTH_CREDS_PATH, 'utf-8') - const parsed = JSON.parse(raw) as unknown - if ( - parsed && - typeof parsed === 'object' && - 'access_token' in parsed && - typeof parsed.access_token === 'string' && - 'refresh_token' in parsed && - typeof parsed.refresh_token === 'string' && - 'expiry_date' in parsed && - typeof parsed.expiry_date === 'number' - ) { - return parsed as GeminiCredentials - } - return null - } catch (err) { - if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { - return null - } - throw err - } -} - -export async function saveGeminiCredentials(creds: GeminiCredentials): Promise { - const tmpPath = `${OAUTH_CREDS_PATH}.${process.pid}.tmp` - await writeFile(tmpPath, JSON.stringify(creds, null, 2), 'utf-8') - await rename(tmpPath, OAUTH_CREDS_PATH) -} - -export type RefreshTokenResult = { - accessToken: string | null - newRefreshToken: string | null - expiresIn?: number -} - -export async function refreshAccessToken( - refreshToken: string, - clientId: string, - clientSecret: string -): Promise { - const res = await net.fetch(GOOGLE_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token' - }).toString(), - signal: AbortSignal.timeout(API_TIMEOUT_MS) - }) - - if (!res.ok) { - return { accessToken: null, newRefreshToken: null } - } - - const data = (await res.json()) as { - access_token?: string - refresh_token?: string - expires_in?: number - } - return { - accessToken: typeof data.access_token === 'string' ? data.access_token : null, - newRefreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : null, - expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined - } -} - -export async function loadProjectId(accessToken: string): Promise { - const res = await net.fetch(LOAD_CODE_ASSIST_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}` - }, - body: JSON.stringify({ metadata: { ideType: 'GEMINI_CLI', pluginType: 'GEMINI' } }), - signal: AbortSignal.timeout(API_TIMEOUT_MS) - }) - - if (!res.ok) { - throw new Error(`Failed to load Gemini project ID (HTTP ${res.status})`) - } - - const data = (await res.json()) as { cloudaicompanionProject?: string } - if (typeof data.cloudaicompanionProject !== 'string') { - throw new Error('Gemini project ID not found in API response') - } - return data.cloudaicompanionProject -} - -// Why: accepts a plain refresh token string so both the oauth_creds.json path -// (GeminiCredentials) and the auth.json path (pipe-split string) can share -// the same bundle credential extraction without coupling to either struct. -export async function tryRefreshTokenFromBundle( - refreshToken: string, - allowCliOAuth = true -): Promise { - if (!allowCliOAuth) { - return null - } - const clientCreds = await extractOAuthClientCredentials() - if (!clientCreds) { - return null - } - - return refreshAccessToken(refreshToken, clientCreds.clientId, clientCreds.clientSecret) -} diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts deleted file mode 100644 index fddd0a77d59f..000000000000 --- a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - expiredCreds, - makeResponse, - quotaResponse, - validCreds -} from './gemini-usage-fetcher.test-fixtures' - -const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ - readFileMock: vi.fn(), - extractCredsMock: vi.fn(), - netFetchMock: vi.fn() -})) - -// Why: mock the CLI-credential extractor at the module boundary. The extractor -// is a self-contained dependency with a simple async contract (returns a -// { clientId, clientSecret } record or null). Mocking it here keeps these -// tests focused on the oauth_creds.json refresh → loadCodeAssist → quota -// flow rather than on filesystem plumbing, and avoids having to keep pace -// with extractor internals when they change. -vi.mock('./gemini-cli-oauth-extractor', () => ({ - extractOAuthClientCredentials: extractCredsMock -})) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock, - // Why: saveGeminiCredentials is exercised on the refresh path. The atomic - // tmp+rename write has no observable side effect in these tests, so the - // stubs just resolve. - writeFile: vi.fn().mockResolvedValue(undefined), - rename: vi.fn().mockResolvedValue(undefined) -})) - -vi.mock('electron', () => ({ - net: { fetch: netFetchMock } -})) - -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' - -describe('fetchGeminiRateLimits fallback oauth creds', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z')) - readFileMock.mockReset() - extractCredsMock.mockReset() - netFetchMock.mockReset() - extractCredsMock.mockResolvedValue({ - clientId: 'client-id-123', - clientSecret: 'client-secret-456' - }) - }) - - it('falls back to oauth_creds.json when auth.json has no google key', async () => { - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({ 'opencode-go': { type: 'api', key: 'k' } }) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(validCreds) - } - throw { code: 'ENOENT' } - }) - netFetchMock - .mockResolvedValueOnce(makeResponse({ cloudaicompanionProject: 'proj-123' })) - .mockResolvedValueOnce(makeResponse(quotaResponse)) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('ok') - expect(result.error).toBeNull() - expect(result.session).not.toBeNull() - }) - - it('falls back to oauth_creds.json and resolves project via loadCodeAssist', async () => { - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({}) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(validCreds) - } - throw { code: 'ENOENT' } - }) - netFetchMock - .mockResolvedValueOnce(makeResponse({ cloudaicompanionProject: 'cli-proj-456' })) - .mockResolvedValueOnce(makeResponse(quotaResponse)) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('ok') - expect(result.error).toBeNull() - expect(result.session).not.toBeNull() - - const quotaCall = netFetchMock.mock.calls.find( - (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') - ) - expect(quotaCall).toBeDefined() - const quotaBody = JSON.parse((quotaCall![1] as RequestInit).body as string) - expect(quotaBody.project).toBe('cli-proj-456') - }) - - it('refreshes via bundled client credentials when expiry passed', async () => { - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({}) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(expiredCreds) - } - throw { code: 'ENOENT' } - }) - netFetchMock - .mockResolvedValueOnce( - makeResponse({ access_token: 'bundle-refreshed-token', expires_in: 3600 }) - ) - .mockResolvedValueOnce(makeResponse({ cloudaicompanionProject: 'cli-proj-456' })) - .mockResolvedValueOnce(makeResponse(quotaResponse)) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('ok') - expect(result.error).toBeNull() - expect(result.session).not.toBeNull() - - const refreshCall = netFetchMock.mock.calls.find( - (call) => typeof call[0] === 'string' && call[0].includes('oauth2.googleapis.com') - ) - expect(refreshCall).toBeDefined() - const refreshBody = new URLSearchParams((refreshCall![1] as RequestInit).body as string) - expect(refreshBody.get('client_id')).toBe('client-id-123') - expect(refreshBody.get('client_secret')).toBe('client-secret-456') - }) - - it('returns error when oauth_creds.json token expired and bundle refresh fails', async () => { - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({}) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(expiredCreds) - } - throw { code: 'ENOENT' } - }) - // Simulate: no Gemini CLI installed, so the extractor returns null and - // tryRefreshTokenFromBundle can't obtain client credentials to refresh. - extractCredsMock.mockResolvedValue(null) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('error') - expect(result.error).toContain('Token refresh failed') - expect(result.session).toBeNull() - expect(result.weekly).toBeNull() - }) - - it('returns error when loadCodeAssist cannot resolve a project for oauth_creds path', async () => { - // Why: when the fallback (oauth_creds.json) path has no project embedded - // and loadCodeAssist fails, we surface a clear "project ID not found" - // error rather than silently posting an empty project to the quota API — - // an empty project causes a 400 that looks like an auth failure. - readFileMock.mockImplementation(async (filePath: string) => { - if (filePath.includes('auth.json')) { - return JSON.stringify({}) - } - if (filePath.includes('oauth_creds.json')) { - return JSON.stringify(validCreds) - } - throw { code: 'ENOENT' } - }) - netFetchMock.mockResolvedValueOnce(makeResponse('Internal Server Error', 500)) - - const result = await fetchGeminiRateLimits(true) - - expect(result.status).toBe('error') - expect(result.error).toContain('Gemini project ID not found') - }) - - it('returns unavailable without reading OAuth files when geminiCliOAuthEnabled=false', async () => { - const result = await fetchGeminiRateLimits(false) - - expect(result.status).toBe('unavailable') - expect(result.error).toContain('disabled') - expect(readFileMock).not.toHaveBeenCalled() - // No network calls should have been made. - expect(netFetchMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts b/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts deleted file mode 100644 index 9e21d7c81f2a..000000000000 --- a/src/main/rate-limits/gemini-usage-fetcher.test-fixtures.ts +++ /dev/null @@ -1,43 +0,0 @@ -export function makeResponse(body: unknown, status = 200): Response { - return { - ok: status >= 200 && status < 300, - status, - json: async () => body, - text: async () => (typeof body === 'string' ? body : JSON.stringify(body)) - } as Response -} - -export const authJsonGoogle = { - google: { - type: 'oauth', - access: 'auth-json-access-token', - expires: new Date('2026-04-24T13:00:00.000Z').getTime(), - refresh: 'refresh-token-abc|proj-123|managed-456' - } -} as const - -export const authJsonGoogleExpired = { - google: { - type: 'oauth', - access: 'expired-access-token', - expires: new Date('2026-04-24T11:00:00.000Z').getTime(), - refresh: 'refresh-token-abc|proj-123|managed-456' - } -} as const - -export const validCreds = { - access_token: 'valid-token', - refresh_token: 'refresh-token', - expiry_date: new Date('2026-04-24T13:00:00.000Z').getTime() -} - -export const expiredCreds = { - access_token: 'expired-token', - refresh_token: 'refresh-token', - expiry_date: new Date('2026-04-24T11:00:00.000Z').getTime() -} - -export const quotaResponse = [ - { remainingFraction: 0.75, resetTime: '2026-04-24T13:00:00.000Z', modelId: 'gemini-2.5-pro' }, - { remainingFraction: 0.9, resetTime: '2026-04-24T14:00:00.000Z', modelId: 'gemini-2.5-flash' } -] diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts deleted file mode 100644 index e8415c2e07e9..000000000000 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - authJsonGoogle, - authJsonGoogleExpired, - makeResponse, - quotaResponse -} from './gemini-usage-fetcher.test-fixtures' - -const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ - readFileMock: vi.fn(), - extractCredsMock: vi.fn(), - netFetchMock: vi.fn() -})) - -// Why: mock the extractor at the module boundary rather than re-routing every -// child_process/fs call. The extractor is a self-contained dependency with a -// simple async contract; mocking it directly keeps tests focused on the -// fetcher's refresh/quota logic rather than on filesystem plumbing that has -// already been integration-tested elsewhere. -vi.mock('./gemini-cli-oauth-extractor', () => ({ - extractOAuthClientCredentials: extractCredsMock -})) - -vi.mock('node:fs/promises', () => ({ - readFile: readFileMock, - writeFile: vi.fn().mockResolvedValue(undefined), - rename: vi.fn().mockResolvedValue(undefined) -})) -vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) - -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' - -describe('fetchGeminiRateLimits', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z')) - readFileMock.mockReset() - extractCredsMock.mockReset() - netFetchMock.mockReset() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - if (url.includes('token')) { - return Promise.resolve(makeResponse({ access_token: 'new-token', expires_in: 3600 })) - } - return Promise.resolve(makeResponse({ error: `Unhandled fetch to ${url}` }, 500)) - }) - // Default: no CLI installed, refresh path cannot find client credentials. - extractCredsMock.mockResolvedValue(null) - readFileMock.mockRejectedValue({ code: 'ENOENT' }) - }) - - const setupAuthJsonValid = () => { - readFileMock.mockImplementation(async (p: string) => { - if (p.includes('auth.json')) { - return JSON.stringify(authJsonGoogle) - } - throw { code: 'ENOENT' } - }) - } - const setupAuthJsonExpired = () => { - readFileMock.mockImplementation(async (p: string) => { - if (p.includes('auth.json')) { - return JSON.stringify(authJsonGoogleExpired) - } - throw { code: 'ENOENT' } - }) - } - - it('returns unavailable when no credentials exist', async () => { - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('unavailable') - }) - - it('returns quota via auth.json', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve(makeResponse(quotaResponse)) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.buckets).toHaveLength(2) - }) - - it('deduplicates buckets', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve( - makeResponse([ - { - remainingFraction: 0.82, - resetTime: '2026-04-24T13:00:00.000Z', - modelId: 'gemini-1.5-flash' - }, - { - remainingFraction: 0.82, - resetTime: '2026-04-24T13:00:00.000Z', - modelId: 'gemini-3-flash-preview' - } - ]) - ) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.buckets).toHaveLength(1) - expect(result.buckets![0].name).toBe('1.5 Flash') - }) - - it('handles empty bucket list', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve(makeResponse([])) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.buckets).toEqual([]) - }) - - it('returns error when token refresh fails', async () => { - vi.useRealTimers() - setupAuthJsonExpired() - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('error') - expect(result.error).toContain('Token refresh failed') - vi.useFakeTimers() - }) - - it('handles wrapped buckets response', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve(makeResponse({ buckets: quotaResponse })) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.session?.usedPercent).toBe(25) - }) - - it('filters out NaN buckets', async () => { - setupAuthJsonValid() - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - return Promise.resolve( - makeResponse([ - { - remainingFraction: Number.NaN, - resetTime: '2026-04-24T13:00:00.000Z', - modelId: 'gemini-1.5-pro' - }, - { - remainingFraction: 0.9, - resetTime: '2026-04-24T13:00:00.000Z', - modelId: 'gemini-1.5-flash' - } - ]) - ) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - expect(result.buckets).toHaveLength(1) - }) - - it('retries refresh on 401', async () => { - setupAuthJsonValid() - extractCredsMock.mockResolvedValue({ clientId: 'cid', clientSecret: 'csec' }) - let quotaCallCount = 0 - netFetchMock.mockImplementation((url: string) => { - if (url.includes('retrieveUserQuota')) { - quotaCallCount += 1 - if (quotaCallCount === 1) { - return Promise.resolve(makeResponse({ error: 'Unauthenticated' }, 401)) - } - return Promise.resolve(makeResponse(quotaResponse)) - } - if (url.includes('token')) { - return Promise.resolve(makeResponse({ access_token: 'retried-token', expires_in: 3600 })) - } - if (url.includes('loadCodeAssist')) { - return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) - } - return Promise.resolve(makeResponse({}, 404)) - }) - const result = await fetchGeminiRateLimits(true) - expect(result.status).toBe('ok') - // The second quota call should have been made with the refreshed token. - expect(quotaCallCount).toBe(2) - }) -}) diff --git a/src/main/rate-limits/gemini-usage-fetcher.ts b/src/main/rate-limits/gemini-usage-fetcher.ts deleted file mode 100644 index 1f214b4b5eb5..000000000000 --- a/src/main/rate-limits/gemini-usage-fetcher.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { net } from 'electron' -import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { - loadProjectId, - readAuthJson, - readGeminiCredentials, - saveGeminiCredentials, - tryRefreshTokenFromBundle, - type GeminiCredentials, - type GoogleAuthEntry -} from './gemini-oauth-sources' -import { - buildRateLimitBucket, - deduplicateBuckets, - deriveSessionSummary -} from './gemini-bucket-formatting' - -const API_TIMEOUT_MS = 10_000 -const RETRIEVE_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota' - -type QuotaBucket = { remainingFraction: number; resetTime: string; modelId: string } - -function isQuotaBucket(o: unknown): o is QuotaBucket { - return ( - typeof o === 'object' && - o !== null && - typeof (o as QuotaBucket).remainingFraction === 'number' && - Number.isFinite((o as QuotaBucket).remainingFraction) && - typeof (o as QuotaBucket).resetTime === 'string' && - typeof (o as QuotaBucket).modelId === 'string' - ) -} - -function parseQuotaResponse(data: unknown): QuotaBucket[] { - let rawBuckets: unknown[] = [] - if (Array.isArray(data)) { - rawBuckets = data - } else if (data && typeof data === 'object' && 'buckets' in data && Array.isArray(data.buckets)) { - rawBuckets = data.buckets - } - return rawBuckets.filter((b) => isQuotaBucket(b)) -} - -async function fetchQuota(accessToken: string, projectId: string): Promise { - const controller = new AbortController() - const timeout = setTimeout(() => { - controller.abort() - }, API_TIMEOUT_MS) - try { - const res = await net.fetch(RETRIEVE_QUOTA_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, - body: JSON.stringify({ project: projectId }), - signal: controller.signal - }) - if (!res.ok) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: `Quota fetch failed (${res.status})`, - status: 'error' - } - } - const data = (await res.json()) as unknown - const buckets = deduplicateBuckets( - parseQuotaResponse(data).map((b) => ({ ...buildRateLimitBucket(b), modelId: b.modelId })) - ) - return { - provider: 'gemini', - session: deriveSessionSummary(buckets), - weekly: null, - buckets, - updatedAt: Date.now(), - error: null, - status: 'ok' - } - } finally { - clearTimeout(timeout) - } -} - -async function fetchViaAuthJson( - auth: GoogleAuthEntry, - geminiCliOAuthEnabled = false -): Promise { - let accessToken = auth.access - const refreshToken = (auth.refresh || '').split('|')[0] ?? '' - if (auth.expires < Date.now() || !accessToken) { - const refreshResult = await tryRefreshTokenFromBundle(refreshToken, geminiCliOAuthEnabled) - if (!refreshResult?.accessToken) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Token refresh failed', - status: 'error' - } - } - accessToken = refreshResult.accessToken - } - let effectiveProjectId = '' - try { - effectiveProjectId = await loadProjectId(accessToken) - } catch { - effectiveProjectId = - (auth.refresh || '').split('|')[1] || (auth.refresh || '').split('|')[2] || '' - } - if (!effectiveProjectId) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Gemini project ID not found', - status: 'error' - } - } - const result = await fetchQuota(accessToken, effectiveProjectId) - if (result.status === 'error' && result.error?.includes('401')) { - const refreshResult = await tryRefreshTokenFromBundle(refreshToken, geminiCliOAuthEnabled) - if (refreshResult?.accessToken) { - const newProjectId = await loadProjectId(refreshResult.accessToken).catch(() => { - return effectiveProjectId - }) - return fetchQuota(refreshResult.accessToken, newProjectId) - } - } - return result -} - -async function fetchViaOauthCreds( - creds: GeminiCredentials, - geminiCliOAuthEnabled = false -): Promise { - let accessToken = creds.access_token - let currentCreds = creds - if (creds.expiry_date < Date.now()) { - const refreshResult = await tryRefreshTokenFromBundle( - creds.refresh_token, - geminiCliOAuthEnabled - ) - if (!refreshResult?.accessToken) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Token refresh failed', - status: 'error' - } - } - accessToken = refreshResult.accessToken - currentCreds = { - ...creds, - access_token: accessToken, - expiry_date: refreshResult.expiresIn - ? Date.now() + refreshResult.expiresIn * 1000 - : creds.expiry_date - } - await saveGeminiCredentials(currentCreds) - } - const projectId = await loadProjectId(accessToken).catch(() => { - return '' - }) - if (!projectId) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Gemini project ID not found', - status: 'error' - } - } - const result = await fetchQuota(accessToken, projectId) - if (result.status === 'error' && result.error?.includes('401')) { - const refreshResult = await tryRefreshTokenFromBundle( - currentCreds.refresh_token, - geminiCliOAuthEnabled - ) - if (refreshResult?.accessToken) { - const newProjectId = await loadProjectId(refreshResult.accessToken).catch(() => { - return '' - }) - if (newProjectId) { - await saveGeminiCredentials({ - ...currentCreds, - access_token: refreshResult.accessToken, - expiry_date: refreshResult.expiresIn - ? Date.now() + refreshResult.expiresIn * 1000 - : currentCreds.expiry_date - }) - return fetchQuota(refreshResult.accessToken, newProjectId) - } - } - } - return result -} - -export async function fetchGeminiRateLimits( - geminiCliOAuthEnabled = false -): Promise { - if (!geminiCliOAuthEnabled) { - // Why: the OAuth sources include other apps' data folders on macOS. - // Do not touch them during background polling unless the user opts in. - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Gemini CLI OAuth is disabled in settings', - status: 'unavailable' - } - } - - try { - const authJson = await readAuthJson() - const result = - authJson?.google?.type === 'oauth' - ? await fetchViaAuthJson(authJson.google, geminiCliOAuthEnabled) - : await (async () => { - const creds = await readGeminiCredentials() - return !creds - ? ({ - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Gemini CLI credentials not found', - status: 'unavailable' - } as ProviderRateLimits) - : await fetchViaOauthCreds(creds, geminiCliOAuthEnabled) - })() - return result - } catch (err) { - return { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - } - } -} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 245ebed42d2f..f695b81dadb9 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -8,7 +8,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { RateLimitService } from './service' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import { consumeCodexRateLimitResetCredit, 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-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -26,8 +26,8 @@ vi.mock('./codex-fetcher', () => ({ fetchCodexRateLimits: vi.fn() })) -vi.mock('./gemini-usage-fetcher', () => ({ - fetchGeminiRateLimits: vi.fn() +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() })) vi.mock('./kimi-fetcher', () => ({ @@ -127,7 +127,7 @@ function unavailableProvider( // individual retry lane need healthy providers minted fresh at fetch time. function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 24)) - vi.mocked(fetchGeminiRateLimits).mockImplementation(async () => okProvider('gemini', 0)) + vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => okProvider('antigravity', 0)) vi.mocked(fetchOpenCodeGoRateLimits).mockImplementation(async () => okProvider('opencode-go', 0)) vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) @@ -173,7 +173,9 @@ function asRateLimitWindow(window: FakeRateLimitWindow): RateLimitWindow { describe('RateLimitService', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 0, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now())) vi.mocked(fetchKimiRateLimits).mockResolvedValue(okProvider('kimi', 0, Date.now())) vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now())) @@ -235,7 +237,7 @@ describe('RateLimitService', () => { }) expect(fetchClaudeRateLimits).not.toHaveBeenCalled() expect(fetchCodexRateLimits).not.toHaveBeenCalled() - expect(fetchGeminiRateLimits).not.toHaveBeenCalled() + expect(fetchAntigravityRateLimits).not.toHaveBeenCalled() expect(fetchOpenCodeGoRateLimits).not.toHaveBeenCalled() expect(fetchKimiRateLimits).not.toHaveBeenCalled() expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled() @@ -394,7 +396,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1) @@ -1039,7 +1041,7 @@ describe('RateLimitService', () => { try { vi.mocked(fetchClaudeRateLimits).mockResolvedValue(unavailableProvider('claude')) vi.mocked(fetchCodexRateLimits).mockResolvedValue(unavailableProvider('codex')) - vi.mocked(fetchGeminiRateLimits).mockResolvedValue(unavailableProvider('gemini')) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(unavailableProvider('opencode-go')) vi.mocked(fetchKimiRateLimits).mockResolvedValue(unavailableProvider('kimi')) @@ -1056,7 +1058,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) @@ -1066,7 +1068,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(2) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(2) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) @@ -1228,7 +1230,9 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce( + okProvider('antigravity', 30, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -1244,7 +1248,7 @@ describe('RateLimitService', () => { const pendingGrokState = service.getState() expect(pendingGrokState.claude?.status).toBe('ok') expect(pendingGrokState.codex?.status).toBe('ok') - expect(pendingGrokState.gemini?.status).toBe('ok') + expect(pendingGrokState.antigravity?.status).toBe('ok') expect(pendingGrokState.opencodeGo?.status).toBe('ok') expect(pendingGrokState.kimi?.status).toBe('ok') expect(pendingGrokState.minimax?.status).toBe('ok') @@ -1376,7 +1380,7 @@ describe('RateLimitService', () => { expect(service.getState().inactiveCodexAccounts).toEqual([]) }) - it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => { + it('fetches Antigravity and OpenCode Go alongside Claude and Codex', async () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=abc123', @@ -1387,11 +1391,12 @@ describe('RateLimitService', () => { httpProxyBypassRules: 'localhost' } service.setNetworkProxySettingsResolver(() => networkProxySettings) - service.setGeminiCliOAuthEnabledResolver(() => true) vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce( + okProvider('antigravity', 30, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -1408,8 +1413,8 @@ describe('RateLimitService', () => { }) ) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) - expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledWith() expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith( 'session=abc123', @@ -1426,8 +1431,8 @@ describe('RateLimitService', () => { expect(state.claude?.session?.usedPercent).toBe(10) expect(state.codex?.status).toBe('ok') expect(state.codex?.session?.usedPercent).toBe(20) - expect(state.gemini?.status).toBe('ok') - expect(state.gemini?.session?.usedPercent).toBe(30) + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.session?.usedPercent).toBe(30) expect(state.opencodeGo?.status).toBe('ok') expect(state.opencodeGo?.session?.usedPercent).toBe(40) }) @@ -1987,11 +1992,11 @@ describe('RateLimitService', () => { expect(service.getState().inactiveCodexAccounts).toEqual([]) }) - it('preserves Gemini buckets through getState after fetch', async () => { + it('preserves Antigravity buckets through getState after fetch', async () => { const service = new RateLimitService() - const geminiWithBuckets: ProviderRateLimits = { - provider: 'gemini', + const antigravityWithBuckets: ProviderRateLimits = { + provider: 'antigravity', session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, weekly: null, buckets: [ @@ -2017,7 +2022,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(geminiWithBuckets) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce(antigravityWithBuckets) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 0, Date.now()) ) @@ -2025,11 +2030,11 @@ describe('RateLimitService', () => { await service.refresh() const state = service.getState() - expect(state.gemini?.buckets).toHaveLength(2) - expect(state.gemini?.buckets![0].name).toBe('Pro') - expect(state.gemini?.buckets![1].name).toBe('Flash') + expect(state.antigravity?.buckets).toHaveLength(2) + expect(state.antigravity?.buckets![0].name).toBe('Pro') + expect(state.antigravity?.buckets![1].name).toBe('Flash') // Why: session summary is derived from bucket data and must match the most constrained bucket. - expect(state.gemini?.session?.usedPercent).toBe(80) + expect(state.antigravity?.session?.usedPercent).toBe(80) }) it('isolates provider failures so one error does not block others', async () => { @@ -2041,7 +2046,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down')) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockRejectedValueOnce(new Error('gemini down')) + vi.mocked(fetchAntigravityRateLimits).mockRejectedValueOnce(new Error('antigravity down')) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( okProvider('opencode-go', 40, Date.now()) ) @@ -2052,8 +2057,8 @@ describe('RateLimitService', () => { expect(state.claude?.status).toBe('error') expect(state.claude?.error).toBe('claude down') expect(state.codex?.status).toBe('ok') - expect(state.gemini?.status).toBe('error') - expect(state.gemini?.error).toBe('gemini down') + expect(state.antigravity?.status).toBe('error') + expect(state.antigravity?.error).toBe('antigravity down') expect(state.opencodeGo?.status).toBe('ok') }) @@ -2068,7 +2073,9 @@ describe('RateLimitService', () => { // 1. Success fetch vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20, Date.now())) - vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 30, Date.now()) + ) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( okProvider('opencode-go', 40, Date.now()) ) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 4b0a5e266aff..f30caa389601 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -19,7 +19,7 @@ import { type ClaudeAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' import { readGrokAuthSession } from './grok-auth' @@ -58,7 +58,6 @@ type MiniMaxResolvedConfig = { error: string | null } -type GeminiCliOAuthEnabledResolver = () => boolean type ActiveRateLimitProvider = ProviderRateLimits['provider'] type ActiveProviderState = { provider: ActiveRateLimitProvider @@ -96,7 +95,6 @@ const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 type InternalRateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null - gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null antigravity: ProviderRateLimits | null @@ -146,7 +144,6 @@ export class RateLimitService { private state: InternalRateLimitState = { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, @@ -161,7 +158,6 @@ export class RateLimitService { private lastActiveFailureRetryAtByProvider: Record = { claude: 0, codex: 0, - gemini: 0, 'opencode-go': 0, kimi: 0, minimax: 0, @@ -172,7 +168,6 @@ export class RateLimitService { private activeFailureStreakByProvider: Record = { claude: 0, codex: 0, - gemini: 0, 'opencode-go': 0, kimi: 0, minimax: 0, @@ -208,7 +203,6 @@ export class RateLimitService { } private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null - private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null private networkProxySettingsResolver: (() => NetworkProxySettings) | null = null @@ -255,10 +249,6 @@ export class RateLimitService { this.miniMaxConfigResolver = resolver } - setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { - this.geminiCliOAuthEnabledResolver = resolver - } - setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { this.networkProxySettingsResolver = resolver } @@ -787,7 +777,6 @@ export class RateLimitService { const byProvider: Record = { claude: this.state.claude, codex: this.state.codex, - gemini: this.state.gemini, 'opencode-go': this.state.opencodeGo, kimi: this.state.kimi, minimax: this.state.minimax, @@ -1487,15 +1476,7 @@ export class RateLimitService { private withFetchingStatus( current: ProviderRateLimits | null, - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' + provider: 'claude' | 'codex' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' ): ProviderRateLimits { if (!current) { return { @@ -1542,7 +1523,6 @@ export class RateLimitService { const miniMaxCookie = miniMaxConfigResult.config.sessionCookie const miniMaxGroupId = miniMaxConfigResult.config.groupId const miniMaxModels = miniMaxConfigResult.config.models - const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. const grokAuthReadResult = readGrokAuthSession() this.grokAuthConfigured = grokAuthReadResult.status === 'ok' @@ -1569,7 +1549,6 @@ export class RateLimitService { ...previousState, claude: this.withFetchingStatus(previousState.claude, 'claude'), codex: this.withFetchingStatus(previousState.codex, 'codex'), - gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), opencodeGo: opencodeConfigChanged ? this.withFetchingStatus(null, 'opencode-go') : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), @@ -1596,38 +1575,44 @@ export class RateLimitService { 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 - }), - missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), + const [ + claudeResult, + codexResult, + 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 }), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - fetchKimiRateLimits(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels - }) - ]) + missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + }), + fetchAntigravityRateLimits(), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + fetchKimiRateLimits(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) + ]) if (signal.aborted) { return @@ -1659,25 +1644,21 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - const gemini = - geminiResult.status === 'fulfilled' - ? geminiResult.value + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value : ({ - provider: 'gemini', + provider: 'antigravity', session: null, weekly: null, updatedAt: Date.now(), error: - geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Unknown error', status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity shares Gemini credentials today; mirror the Gemini snapshot so its status-bar UI gets a real lifecycle instead of null. - const antigravity: ProviderRateLimits = { - ...gemini, - provider: 'antigravity' - } - const opencodeGo = opencodeGoResult.status === 'fulfilled' ? opencodeGoResult.value @@ -1745,7 +1726,6 @@ export class RateLimitService { if (shouldApplyCodex) { this.trackActiveFailureStreak('codex', codex) } - this.trackActiveFailureStreak('gemini', gemini) this.trackActiveFailureStreak('antigravity', antigravity) if (shouldApplyOpencode) { this.trackActiveFailureStreak('opencode-go', opencodeGo) @@ -1764,7 +1744,6 @@ export class RateLimitService { codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex, - gemini: this.applyStalePolicy(gemini, previousState.gemini), opencodeGo: shouldApplyOpencode ? opencodeConfigChanged ? opencodeGo diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 21a6fa8f47cf..fa947ff47382 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -59,7 +59,6 @@ const AgentActivityDisplayMode = z.enum(['compact', 'full']) const StatusBarItem = z.enum([ 'claude', 'codex', - 'gemini', 'antigravity', 'opencode-go', 'kimi', diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index d9b5cd182c04..832148218af3 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -85,7 +85,6 @@ describe('AccountsPane', () => { const markup = renderPane(getDefaultSettings('/tmp')) expect(markup).toContain('Showing accounts for this device. New accounts are added there.') - expect(markup).toContain('authenticate with Google for this device. This uses credentials') expect(markup).not.toContain('ShowingThis device') expect(markup).not.toContain('forThis device') }) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 7578112895a6..617a5d35c79f 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: AccountsPane owns all per-provider account UI - (Claude, Codex, Gemini, OpenCode Go, and future providers). Each provider's + (Claude, Codex, OpenCode Go, and future providers). Each provider's add/select/reauth/remove flow is tightly coupled to the provider-specific error handling and restart prompts below; splitting them into separate files would scatter those flows without a meaningful abstraction boundary. */ @@ -33,18 +33,11 @@ import { X } from 'lucide-react' import { useAppStore } from '../../store' -import { - ClaudeIcon, - GeminiIcon, - MiniMaxIcon, - OpenAIIcon, - OpenCodeGoIcon -} from '../status-bar/icons' +import { ClaudeIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons' import { toast } from 'sonner' import { getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, - getAccountsGeminiSearchEntries, getAccountsLocationSearchEntries, getAccountsGrokSearchEntries, getAccountsMiniMaxSearchEntries, @@ -366,10 +359,6 @@ export function AccountsPane({ !navigator.userAgent.includes('Windows') ? `${accountRuntime.label.charAt(0).toLocaleLowerCase()}${accountRuntime.label.slice(1)}` : accountRuntime.label - const localAccountRuntimeSentenceLabel = - localAccountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows') - ? `${localAccountRuntime.label.charAt(0).toLocaleLowerCase()}${localAccountRuntime.label.slice(1)}` - : localAccountRuntime.label // Why: users read the remote-scoped list as their desktop accounts being // deleted (#8186); say they are intact and link the default-runtime control. // The web client has no desktop-owned accounts and cannot select Local @@ -1513,78 +1502,6 @@ export function AccountsPane({ ) : null, - matchesSettingsSearch(searchQuery, getAccountsGeminiSearchEntries()) ? ( -
-
-

- - {translate('auto.components.settings.AccountsPane.0c64dc2a64', 'Gemini')} -

-

- {translate( - 'auto.components.settings.AccountsPane.973741a871', - 'Configure Gemini provider settings.' - )} -

-
- - -
- -

- {translate( - 'auto.components.settings.AccountsPane.c2aee76420', - 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.', - { value0: localAccountRuntimeSentenceLabel } - )} -

-
- -
-
- ) : null, matchesSettingsSearch(searchQuery, getAccountsOpencodeSearchEntries()) ? (
diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 1d7e5d8ec2cd..845b94348c9b 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -47,7 +47,6 @@ function recordStatusBarToggleInteraction( } else if ( id === 'claude' || id === 'codex' || - id === 'gemini' || id === 'opencode-go' || id === 'kimi' || id === 'antigravity' || diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 6ff07366bc9a..a00373414b08 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -96,37 +96,6 @@ export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [ } ]) -export const getAccountsGeminiSearchEntries = createLocalizedCatalog(() => [ - { - title: translate( - 'auto.components.settings.accounts.search.d819755b02', - 'Use Gemini CLI credentials' - ), - description: translate( - 'auto.components.settings.accounts.search.bada4a3218', - 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.accounts.search.e8e1ff3887', 'gemini'), - ...translateSearchKeyword('auto.components.settings.accounts.search.8630464352', 'cli'), - ...translateSearchKeyword('auto.components.settings.accounts.search.933deaf732', 'oauth'), - ...translateSearchKeyword( - 'auto.components.settings.accounts.search.7118d2f908', - 'credentials' - ), - ...translateSearchKeyword( - 'auto.components.settings.accounts.search.b7c2cee442', - 'experimental' - ), - ...translateSearchKeyword( - 'auto.components.settings.accounts.search.e949b08ffb', - 'rate limit' - ), - ...translateSearchKeyword('auto.components.settings.accounts.search.86edc96bc9', 'status bar') - ] - } -]) - export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [ { title: translate( @@ -216,7 +185,6 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), ...getAccountsCodexSearchEntries(), - ...getAccountsGeminiSearchEntries(), ...getAccountsOpencodeSearchEntries(), ...getAccountsMiniMaxSearchEntries(), ...getAccountsGrokSearchEntries() diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 02403c24f4b1..9bbe202c4445 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -71,35 +71,6 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show Codex token and cost usage for the active workspace.' ) }, - { - id: 'gemini', - title: translate('auto.components.settings.appearance.search.5bfb874d05', 'Gemini Usage'), - description: translate( - 'auto.components.settings.appearance.search.9660c5b2f1', - 'Show Gemini token and cost usage in the status bar.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.896eb53fd4', - 'status bar' - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.2804a920ad', - 'gemini' - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.afbb6a3767', - 'tokens' - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.edbf0f63a0', 'cost'), - ...translateSearchKeyword('auto.components.settings.appearance.search.51b0ccd6a2', 'google') - ], - toggleDescription: translate( - 'settings.appearance.statusBar.geminiToggleDescription', - 'Show Gemini token and cost usage for the active workspace.' - ) - }, getAntigravityStatusBarToggleSearchEntry(), { id: 'opencode-go', diff --git a/src/renderer/src/components/stats/GrokUsagePane.test.tsx b/src/renderer/src/components/stats/GrokUsagePane.test.tsx index d1ef3700f834..b55fcf282a3c 100644 --- a/src/renderer/src/components/stats/GrokUsagePane.test.tsx +++ b/src/renderer/src/components/stats/GrokUsagePane.test.tsx @@ -19,7 +19,6 @@ const mockStoreState = { rateLimits: { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 02978a47912d..19805aa12f9c 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -59,7 +59,7 @@ import { getProviderDisplayName, getProviderUsageStatusLabel } from './tooltip' -import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { ClaudeIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { AgentIcon } from '@/lib/agent-catalog' import { UsageRosterPanel, getTightestUsageSection } from './UsageRosterPanel' import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' @@ -1142,8 +1142,6 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { switch (provider) { case 'claude': return 'C' - case 'gemini': - return 'G' case 'opencode-go': return 'O' case 'kimi': @@ -1163,7 +1161,7 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { // Provider segment // --------------------------------------------------------------------------- -// Why: Gemini exposes extra experimental buckets that made the pre-existing verbose footer noisy. +// Why: providers can expose several model buckets; keep the persistent footer to primary tiers. const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) function VerboseProviderUsage({ @@ -2071,11 +2069,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits + const { claude, codex, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits // 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: Agy persists auth in the OS keyring, so a checked status item + detected CLI is the durable "show its slot" signal. const antigravityUsageConfigured = statusBarItems.includes('antigravity') && isStatusBarItemAvailable('antigravity', detectedAgentIds) @@ -2088,7 +2085,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) - const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) const visibleAntigravity = getVisibleUsageProvider('antigravity', antigravity, usageSettings) const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) @@ -2101,10 +2097,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele visibleCodex !== null && statusBarItems.includes('codex') && isStatusBarItemAvailable('codex', detectedAgentIds) - const showGemini = - visibleGemini !== null && - statusBarItems.includes('gemini') && - isStatusBarItemAvailable('gemini', detectedAgentIds) const showKimi = visibleKimi !== null && statusBarItems.includes('kimi') && @@ -2131,7 +2123,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const hasVisibleUsageMeters = showClaude || showCodex || - showGemini || showOpencodeGo || showKimi || showAntigravity || @@ -2140,7 +2131,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: include Settings so durable managed accounts count — a configured user isn't shown the empty state while snapshots hydrate. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok }, + { claude, codex, opencodeGo, kimi, antigravity, minimax, grok }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -2148,7 +2139,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const anyFetching = claude?.status === 'fetching' || codex?.status === 'fetching' || - gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || kimi?.status === 'fetching' || antigravity?.status === 'fetching' || @@ -2167,7 +2157,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const rosterProviders = [ showClaude ? visibleClaude : null, showCodex ? visibleCodex : null, - showGemini ? visibleGemini : null, showAntigravity ? visibleAntigravity : null, showOpencodeGo ? visibleOpencodeGo : null, showKimi ? visibleKimi : null, @@ -2435,18 +2424,6 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele {translate('auto.components.status.bar.StatusBar.c0909c686e', 'Codex Usage')} )} - {isStatusBarItemAvailable('gemini', detectedAgentIds) && ( - { - recordFeatureInteraction('usage-tracking') - toggleStatusBarItem('gemini') - }} - > - - {translate('auto.components.status.bar.StatusBar.c1df0d67ec', 'Gemini Usage')} - - )} {isStatusBarItemAvailable('antigravity', detectedAgentIds) && ( - +
diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index 1e675232228e..587d75a741cb 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -35,7 +35,7 @@ function providerMaxUsed(sections: UsageSection[]): number { : 0 } -// Buckets (Gemini Flash/Pro) keep their model name; windows use their duration. +// Named model buckets keep their model name; fixed provider windows use their duration. function shortLabel( p: ProviderRateLimits, section: UsageSection, diff --git a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx index 6e69a482d959..d0791a3407ee 100644 --- a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx +++ b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx @@ -44,6 +44,27 @@ function grokMonthlyLimits(status: ProviderRateLimits['status']): ProviderRateLi } describe('ProviderSegment monthly window', () => { + it.each([ + ['used', '8% used 5h'], + ['remaining', '92% left 5h'] + ] as const)('shows Antigravity usage using the global %s option', async (display, expected) => { + const { ProviderSegment } = await import('./StatusBar') + const limits: ProviderRateLimits = { + provider: 'antigravity', + session: windowOf(7.59684, 300), + weekly: windowOf(1.26614, 10080), + updatedAt: Date.now(), + error: null, + status: 'ok' + } + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(expected) + }) + it('renders a monthly-only snapshot in the chip instead of a bare icon', async () => { const { ProviderSegment } = await import('./StatusBar') @@ -94,7 +115,7 @@ describe('ProviderSegment monthly window', () => { it('selects a named bucket as the tightest provider window', async () => { const { ProviderSegment } = await import('./StatusBar') const limits: ProviderRateLimits = { - provider: 'gemini', + provider: 'opencode-go', session: null, weekly: null, buckets: [ diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index cd98315df505..e05177180448 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -17,7 +17,6 @@ describe('isStatusBarItemAvailable', () => { // don't want a flash of empty status bar on cold start. expect(isStatusBarItemAvailable('claude', null)).toBe(true) expect(isStatusBarItemAvailable('codex', null)).toBe(true) - expect(isStatusBarItemAvailable('gemini', null)).toBe(true) expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) }) @@ -25,7 +24,6 @@ describe('isStatusBarItemAvailable', () => { it('hides CLI items not detected on PATH', () => { expect(isStatusBarItemAvailable('claude', [])).toBe(false) expect(isStatusBarItemAvailable('codex', ['claude'])).toBe(false) - expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) }) @@ -33,7 +31,6 @@ describe('isStatusBarItemAvailable', () => { it('shows CLI items detected on PATH', () => { expect(isStatusBarItemAvailable('claude', ['claude'])).toBe(true) expect(isStatusBarItemAvailable('codex', ['codex', 'claude'])).toBe(true) - expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true) expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 4ded63147dba..0cf8637f5067 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -1,15 +1,13 @@ import type { StatusBarItem, TuiAgent } from '../../../../shared/types' // Why: CLI-backed usage bars are surface noise when the underlying -// CLI isn't installed (e.g. a fresh Ubuntu install showing "Gemini Usage" -// when no Gemini CLI is on PATH). We hide both the bar and its toggle when +// CLI isn't installed. We hide both the bar and its toggle when // PATH detection reports the agent as missing. Pre-detection (null) keeps // the legacy behavior so the bar/toggle don't flicker on cold start, and // re-show automatically once the agent appears on PATH. const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'claude', 'codex', - 'gemini', 'kimi', 'antigravity', 'grok' 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 e833bce7fa4c..6db06acab73c 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 @@ -17,7 +17,7 @@ function provider( overrides: Partial = {} ): ProviderRateLimits { return { - provider: 'gemini', + provider: 'opencode-go', session: null, weekly: null, updatedAt: 0, @@ -34,15 +34,14 @@ describe('isProviderConfigured', () => { }) it('hides an unconfigured (unavailable) provider', () => { - // The bug: Gemini OAuth off / OpenCode Go cookie unset returns a non-null - // `unavailable` object, which previously slipped past the `!== null` gate - // and rendered a "--" bar for a provider the user never configured. + // An unset provider can return a non-null `unavailable` object, which must + // not render a "--" bar for a provider the user never configured. expect(isProviderConfigured(provider('unavailable'))).toBe(false) }) it('hides a first-load fetching provider until it has proven usage data', () => { // The initial fetch marks every provider as `fetching`; without prior data - // that state is not proof the user configured Gemini or OpenCode Go. + // that state is not proof the user configured the provider. expect(isProviderConfigured(provider('fetching'))).toBe(false) }) @@ -70,7 +69,6 @@ function usageSettings(overrides: Partial = {}): UsagePro codexManagedAccounts: [], claudeManagedAccounts: [], opencodeSessionCookie: '', - geminiCliOAuthEnabled: false, antigravityUsageConfigured: false, minimaxCookieConfigured: false, grokAuthConfigured: false, @@ -117,15 +115,10 @@ describe('hasUsageProviderSettings', () => { }) it('treats explicit non-managed provider settings as configured usage providers', () => { - expect(hasUsageProviderSettings(usageSettings({ geminiCliOAuthEnabled: true }))).toBe(true) 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 - ) + expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) }) @@ -160,27 +153,13 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth 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. + it('uses the checked and detected Agy item as the durable Antigravity signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(false) - expect( - hasUsageProviderSettingsForProvider( - 'antigravity', - usageSettings({ geminiCliOAuthEnabled: true }) - ) - ).toBe(false) + ).toBe(true) expect(hasUsageProviderSettingsForProvider('antigravity', usageSettings())).toBe(false) expect(hasUsageProviderSettingsForProvider('antigravity', null)).toBe(false) }) @@ -262,7 +241,7 @@ describe('getVisibleUsageProvider', () => { it('hides providers with no live data or durable configuration', () => { expect(getVisibleUsageProvider('codex', null, usageSettings())).toBe(null) expect(getVisibleUsageProvider('grok', undefined, usageSettings())).toBe(null) - expect(getVisibleUsageProvider('gemini', provider('fetching'), usageSettings())).toBe(null) + expect(getVisibleUsageProvider('opencode-go', provider('fetching'), usageSettings())).toBe(null) }) it('creates a pending snapshot when an older main process omits a configured provider', () => { @@ -324,11 +303,11 @@ describe('getVisibleUsageProvider', () => { ).toBe(null) }) - it('keeps Antigravity visible while the snapshot is pending when checked and Gemini OAuth is on', () => { + it('keeps Antigravity visible while its Agy snapshot is pending', () => { const visible = getVisibleUsageProvider( 'antigravity', null, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true }) ) expect(visible).toMatchObject({ provider: 'antigravity', @@ -338,26 +317,20 @@ 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. - expect( - getVisibleUsageProvider( - 'antigravity', - null, - usageSettings({ antigravityUsageConfigured: true }) - ) - ).toBe(null) - expect( - getVisibleUsageProvider( - 'antigravity', - provider('unavailable', { - provider: 'antigravity', - error: 'Gemini CLI OAuth is disabled in settings' - }), - usageSettings({ antigravityUsageConfigured: true }) - ) - ).toBe(null) + it('keeps the checked Agy slot visible when local credentials are missing', () => { + const visible = getVisibleUsageProvider( + 'antigravity', + provider('unavailable', { + provider: 'antigravity', + error: 'Agy credentials not found' + }), + usageSettings({ antigravityUsageConfigured: true }) + ) + expect(visible).toMatchObject({ + provider: 'antigravity', + status: 'unavailable', + error: 'Agy credentials not found' + }) }) }) @@ -368,7 +341,6 @@ describe('isUsageEmptyState', () => { { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, @@ -386,7 +358,6 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: undefined, @@ -404,7 +375,6 @@ describe('isUsageEmptyState', () => { { claude: provider('fetching', { provider: 'claude' }), codex: provider('fetching', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: provider('unavailable', { provider: 'antigravity' }), @@ -422,7 +392,6 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: provider('unavailable', { provider: 'antigravity' }), @@ -451,7 +420,6 @@ describe('isUsageEmptyState', () => { { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, @@ -469,7 +437,6 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, @@ -487,27 +454,23 @@ describe('isUsageEmptyState', () => { { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, grok: provider('unavailable', { provider: 'grok' }), minimax: provider('unavailable', { provider: 'minimax' }) }, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true }) ) ).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 while checked Agy usage is awaiting credentials', () => { expect( isUsageEmptyState( { claude: provider('unavailable', { provider: 'claude' }), codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, @@ -516,6 +479,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 e96fa09550a2..c1141e930095 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 @@ -3,16 +3,10 @@ import type { GlobalSettings } from '../../../../shared/types' export type UsageProviderSettings = Pick< GlobalSettings, - | 'codexManagedAccounts' - | 'claudeManagedAccounts' - | 'opencodeSessionCookie' - | 'geminiCliOAuthEnabled' + 'codexManagedAccounts' | 'claudeManagedAccounts' | 'opencodeSessionCookie' > & { - // Why: Antigravity has no separate persisted usage credential in Orca. The - // checked status-bar item is the durable user signal; StatusBar only sets - // this after PATH detection says the agent is available. Durability further - // requires geminiCliOAuthEnabled — the snapshot mirrors the Gemini fetch, - // which never yields data while that opt-in is off. + // Why: Agy owns its persisted keyring login; the checked status-bar item plus + // PATH detection is the renderer's durable signal while main reads that login. antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean @@ -22,7 +16,6 @@ export type UsageProviderSettings = Pick< type UsageProviderSnapshots = { claude: ProviderRateLimits | null | undefined codex: ProviderRateLimits | null | undefined - gemini: ProviderRateLimits | null | undefined opencodeGo: ProviderRateLimits | null | undefined kimi: ProviderRateLimits | null | undefined antigravity: ProviderRateLimits | null | undefined @@ -47,7 +40,7 @@ function isProviderSnapshotPending(provider: ProviderRateLimits | null | undefin } // Why: a provider that returns `unavailable` is explicitly not configured -// (Gemini OAuth off, OpenCode Go cookie unset, Claude on API-key billing). Its +// (OpenCode Go cookie unset, Claude on API-key billing). Its // fetch object is non-null, so a bare `!== null` check still renders a "--" // bar for a provider the user never set up. `error` is kept visible on purpose // — that's a *configured* provider failing transiently, and hiding it would @@ -72,10 +65,8 @@ export function hasUsageProviderSettings( return Boolean( (settings?.codexManagedAccounts?.length ?? 0) > 0 || (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. + settings?.antigravityUsageConfigured === true || settings?.minimaxCookieConfigured === true || settings?.grokAuthConfigured === true ) @@ -94,17 +85,11 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'codex') { return (settings.codexManagedAccounts?.length ?? 0) > 0 } - if (providerId === 'gemini') { - return settings.geminiCliOAuthEnabled === true - } if (providerId === 'opencode-go') { 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 @@ -121,7 +106,7 @@ function createPendingProviderSnapshot(providerId: UsageProviderId): ProviderRat session: null, weekly: null, ...(providerId === 'opencode-go' ? { monthly: null } : {}), - ...(providerId === 'gemini' ? { buckets: [] } : {}), + ...(providerId === 'antigravity' ? { buckets: [] } : {}), updatedAt: 0, error: null, status: 'fetching' @@ -160,7 +145,6 @@ export function isUsageEmptyState( if ( isProviderSnapshotPending(providers.claude) || isProviderSnapshotPending(providers.codex) || - isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || isProviderSnapshotPending(providers.kimi) || antigravitySnapshotPending || @@ -173,7 +157,6 @@ export function isUsageEmptyState( !hasUsageProviderSettings(settings) && !isProviderConfigured(providers.claude) && !isProviderConfigured(providers.codex) && - !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && !isProviderConfigured(providers.kimi) && !isProviderConfigured(providers.antigravity) && diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 2dc23cf100a5..014806b0978a 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -48,7 +48,6 @@ function provider(overrides: Partial = {}): ProviderRateLimi const PROVIDER_IDS: ProviderRateLimits['provider'][] = [ 'claude', 'codex', - 'gemini', 'antigravity', 'opencode-go', 'kimi', @@ -103,17 +102,17 @@ describe('provider usage error copy', () => { error: 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.' }) - const gemini = provider({ - provider: 'gemini', - error: 'Gemini CLI credentials not found' + const antigravity = provider({ + provider: 'antigravity', + error: 'Agy credentials not found' }) expect(getProviderUsageStatusLabel(codex)).toBe('Refresh failed') expect(getProviderUsageErrorMessage(codex)).toBe( 'Codex usage could not be refreshed. Agent sessions may still be signed in.' ) - expect(getProviderUsageErrorMessage(gemini)).toBe( - 'Gemini usage could not be refreshed. Agent sessions may still be signed in.' + expect(getProviderUsageErrorMessage(antigravity)).toBe( + 'Antigravity usage could not be refreshed. Agent sessions may still be signed in.' ) }) @@ -295,8 +294,8 @@ describe('provider usage error copy', () => { describe('getWindowSections', () => { it('returns buckets as sections when present', () => { const p: ProviderRateLimits = { - provider: 'gemini', - session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, + provider: 'antigravity', + session: null, weekly: null, buckets: [ { @@ -321,8 +320,7 @@ describe('getWindowSections', () => { const sections = getWindowSections(p) expect(sections).toEqual([ { label: 'Pro', window: p.buckets![0] }, - { label: 'Flash', window: p.buckets![1] }, - { label: 'Weekly', window: null } + { label: 'Flash', window: p.buckets![1] } ]) }) @@ -367,7 +365,7 @@ describe('getWindowSections', () => { it('returns session and weekly for empty buckets array', () => { const p: ProviderRateLimits = { - provider: 'gemini', + provider: 'antigravity', session: { usedPercent: 50, windowMinutes: 300, resetsAt: null, resetDescription: null }, weekly: null, buckets: [], @@ -382,14 +380,10 @@ describe('getWindowSections', () => { ]) }) - it('does not expose bucket names via session window in compact rendering path', () => { - // Why: ProviderSegment (compact mode) reads only p.session — never p.buckets. - // This test locks the contract: getWindowSections returns buckets for detail - // views, while the plain session value remains independently available for - // compact rendering without bucket names bleeding through. + it('keeps Agy model quota separate from fixed provider windows', () => { const p: ProviderRateLimits = { - provider: 'gemini', - session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null }, + provider: 'antigravity', + session: null, weekly: null, buckets: [ { @@ -411,9 +405,8 @@ describe('getWindowSections', () => { error: null, status: 'ok' } - // Compact path uses p.session directly — independent of getWindowSections. - expect(p.session?.usedPercent).toBe(80) - // getWindowSections (detail path) returns bucket rows, not session label. + expect(p.session).toBeNull() + expect(p.weekly).toBeNull() const sections = getWindowSections(p) const labels = sections.map((s) => s.label) expect(labels).toContain('Pro') @@ -423,7 +416,7 @@ describe('getWindowSections', () => { it('preserves reset metadata inside bucket windows', () => { const p: ProviderRateLimits = { - provider: 'gemini', + provider: 'antigravity', session: null, weekly: null, buckets: [ @@ -440,7 +433,7 @@ describe('getWindowSections', () => { status: 'ok' } const sections = getWindowSections(p) - expect(sections).toHaveLength(2) + expect(sections).toHaveLength(1) expect(sections[0].label).toBe('Pro') expect(sections[0].window!.resetsAt).toBe(18000000) expect(sections[0].window!.resetDescription).toBe('5:00 PM') diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index db585ab64f7f..0abe90973101 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -4,7 +4,7 @@ import { formatResetDuration } from '../../../../shared/rate-limit-reset-format' import { AgentIcon } from '@/lib/agent-catalog' -import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { ClaudeIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { translate } from '@/i18n/i18n' import { getProviderDisplayName, @@ -78,9 +78,6 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'codex') { return } - if (provider === 'gemini') { - return - } if (provider === 'opencode-go') { return } @@ -143,13 +140,15 @@ export function getWindowSections( ): { label: string; window: RateLimitWindow | null }[] { if (p.buckets?.length) { const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow })) - return [ - ...bucketSections, - { - label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'), - window: p.weekly - } - ] + return p.weekly + ? [ + ...bucketSections, + { + label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'), + window: p.weekly + } + ] + : bucketSections } const sections: { label: string; window: RateLimitWindow | null }[] = [ { diff --git a/src/renderer/src/components/status-bar/usage-error-copy.test.ts b/src/renderer/src/components/status-bar/usage-error-copy.test.ts index c91d0704ace3..b16742e2a610 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.test.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.test.ts @@ -18,7 +18,6 @@ describe('getProviderDisplayName', () => { it('returns the existing provider brand names', () => { expect(getProviderDisplayName('claude')).toBe('Claude') expect(getProviderDisplayName('codex')).toBe('Codex') - expect(getProviderDisplayName('gemini')).toBe('Gemini') expect(getProviderDisplayName('opencode-go')).toBe('OpenCode Go') expect(getProviderDisplayName('kimi')).toBe('Kimi') expect(getProviderDisplayName('grok')).toBe('Grok') diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 39032ab5e2c3..81b721e7bf80 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -8,9 +8,6 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'codex') { return 'Codex' } - if (provider === 'gemini') { - return 'Gemini' - } if (provider === 'opencode-go') { return 'OpenCode Go' } diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts index cbcfcda04929..22ac8a29dda4 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -5,8 +5,7 @@ describe('getUsageProviderAccountsSectionId', () => { it('routes providers only to settings sections that exist', () => { expect(getUsageProviderAccountsSectionId('claude')).toBe('accounts-claude') expect(getUsageProviderAccountsSectionId('codex')).toBe('accounts-codex') - expect(getUsageProviderAccountsSectionId('gemini')).toBe('accounts-gemini') - expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('antigravity')).toBeNull() expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go') expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 59ea71042490..477d863936be 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -8,10 +8,8 @@ export function getUsageProviderAccountsSectionId( return 'accounts-claude' case 'codex': return 'accounts-codex' - case 'gemini': case 'antigravity': - // Why: Antigravity usage currently shares Gemini's OAuth configuration. - return 'accounts-gemini' + return null case 'opencode-go': return 'accounts-opencode-go' case 'minimax': diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index df84c7ef0c7c..aaaf2755cc4e 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -170,7 +170,7 @@ export function buildSettingsNavigationMetadata({ ), description: translate( 'auto.hooks.useSettingsNavigationMetadata.b1c2f8b0ac', - 'Optional account switching and usage setup for Claude, Codex, Gemini, OpenCode Go, MiniMax, and Grok.' + 'Optional account switching and usage setup for Claude, Codex, OpenCode Go, MiniMax, and Grok.' ), icon: UserCog, searchEntries: getAccountsPaneSearchEntries(), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e6f913198713..501c46329e0d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -45,7 +45,6 @@ "statusBar": { "claudeToggleDescription": "Show Claude token and cost usage for the active workspace.", "codexToggleDescription": "Show Codex token and cost usage for the active workspace.", - "geminiToggleDescription": "Show Gemini token and cost usage for the active workspace.", "opencodeGoToggleDescription": "Show OpenCode Go token and cost usage for the active workspace.", "kimiToggleDescription": "Show Kimi subscription usage for the active workspace.", "minimaxToggleDescription": "Show MiniMax subscription usage for the active workspace.", @@ -782,7 +781,7 @@ "cd50cec5d7": "Coordinate multiple coding agents through Orca.", "58a868e8e4": "Orchestration", "7c79d3b7bf": "Optional", - "b1c2f8b0ac": "Optional account switching and usage setup for Claude, Codex, Gemini, OpenCode Go, MiniMax, and Grok.", + "b1c2f8b0ac": "Optional account switching and usage setup for Claude, Codex, OpenCode Go, MiniMax, and Grok.", "f70ac54d38": "AI Provider Accounts", "4121f7a0a2": "Manage AI agents, set a default, and customize commands.", "b49abbd2f7": "Agents", @@ -3171,7 +3170,6 @@ "24ac89df1a": "Remote Hosts", "5e59007df4": "Kimi Usage", "8c86cd77b0": "OpenCode Go Usage", - "c1df0d67ec": "Gemini Usage", "c0909c686e": "Codex Usage", "3885eb74d8": "Claude Usage", "c8857b40f7": "Refresh usage data", @@ -5154,12 +5152,6 @@ "36223200ac": "OpenCode Go Session Cookie", "ea631977b5": "Configure OpenCode Go provider settings.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.", - "96f3649526": "Use Gemini CLI credentials (experimental)", - "d676c41fc6": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.", - "0c7f915b01": "Use Gemini CLI credentials", - "973741a871": "Configure Gemini provider settings.", - "0c64dc2a64": "Gemini", "db209ee572": "Remove", "8a0f870153": "Re-authenticate", "3d245ef7d9": "Codex reported this sign-in is out of date", @@ -7442,9 +7434,6 @@ "7118d2f908": "credentials", "933deaf732": "oauth", "8630464352": "cli", - "e8e1ff3887": "gemini", - "bada4a3218": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.", - "d819755b02": "Use Gemini CLI credentials", "35b461d817": "sign in", "f2d666a886": "optional", "8b06729e0f": "active", @@ -7661,9 +7650,6 @@ "7f72de7cbe": "Show OpenCode Go token and cost usage in the status bar.", "bc046e7899": "OpenCode Go Usage", "51b0ccd6a2": "google", - "2804a920ad": "gemini", - "9660c5b2f1": "Show Gemini token and cost usage in the status bar.", - "5bfb874d05": "Gemini Usage", "97957e374e": "openai", "8dfd676c28": "codex", "e9e4412545": "Show Codex token and cost usage in the status bar.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b925b8c47c35..20a32730c4f0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -22,7 +22,6 @@ "statusBar": { "claudeToggleDescription": "Muestra el consumo de tokens y el costo de Claude para el espacio de trabajo activo.", "codexToggleDescription": "Muestra el consumo de tokens y el costo de Codex para el espacio de trabajo activo.", - "geminiToggleDescription": "Muestra el consumo de tokens y el costo de Gemini para el espacio de trabajo activo.", "opencodeGoToggleDescription": "Muestra el consumo de tokens y el costo de OpenCode Go para el espacio de trabajo activo.", "kimiToggleDescription": "Muestra el uso de la suscripción de Kimi para el espacio de trabajo activo.", "minimaxToggleDescription": "Muestra el uso de la suscripción de MiniMax para el espacio de trabajo activo.", @@ -759,7 +758,7 @@ "cd50cec5d7": "Coordina múltiples coding agents a través de Orca.", "58a868e8e4": "Orquestación", "7c79d3b7bf": "Opcional", - "b1c2f8b0ac": "Configuración opcional de cuentas y uso para Claude, Codex, Gemini, OpenCode Go, MiniMax y Grok.", + "b1c2f8b0ac": "Configuración opcional de cuentas y uso para Claude, Codex, OpenCode Go, MiniMax y Grok.", "f70ac54d38": "Cuentas de proveedores de IA", "4121f7a0a2": "Administra agentes de IA, define uno predeterminado y personaliza comandos.", "b49abbd2f7": "Agentes", @@ -3148,7 +3147,6 @@ "24ac89df1a": "Hosts remotos", "5e59007df4": "Uso de Kimi", "8c86cd77b0": "Uso de OpenCode Go", - "c1df0d67ec": "Uso de Gemini", "c0909c686e": "Uso del Codex", "3885eb74d8": "Uso de Claude", "c8857b40f7": "Actualizar datos de uso", @@ -5127,12 +5125,6 @@ "36223200ac": "Cookie de sesión de OpenCode Go", "ea631977b5": "Configura los ajustes del proveedor OpenCode Go.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Extrae credenciales OAuth de tu instalación local de Gemini CLI para autenticarse con Google para {{value0}}. Usa credenciales emitidas para la app Gemini CLI, no para Orca. Puede romperse si Google actualiza la CLI. Úsalo bajo tu propio riesgo.", - "96f3649526": "Usar credenciales de Gemini CLI (experimental)", - "d676c41fc6": "Extrae credenciales OAuth de tu instalación local de Gemini CLI para autenticarse con Google. Usa credenciales emitidas para la app Gemini CLI, no para Orca. Puede romperse si Google actualiza la CLI. Úsalo bajo tu propio riesgo.", - "0c7f915b01": "Usar credenciales de Gemini CLI", - "973741a871": "Configura los ajustes del proveedor Gemini.", - "0c64dc2a64": "Gemini", "db209ee572": "Eliminar", "8a0f870153": "Volver a autenticar", "3d245ef7d9": "Codex informó que este inicio de sesión no está actualizado", @@ -7378,9 +7370,6 @@ "7118d2f908": "credenciales", "933deaf732": "OAuth", "8630464352": "CLI", - "e8e1ff3887": "Gemini", - "bada4a3218": "Extrae las credenciales de OAuth de tu instalación local de la CLI de Gemini para autenticarte con Google.", - "d819755b02": "Usar credenciales de la CLI de Gemini", "35b461d817": "iniciar sesión", "f2d666a886": "opcional", "8b06729e0f": "activo", @@ -7595,9 +7584,6 @@ "7f72de7cbe": "Muestra el consumo de tokens y el costo de OpenCode Go en la barra de estado.", "bc046e7899": "Uso de OpenCode Go", "51b0ccd6a2": "Google", - "2804a920ad": "gemini", - "9660c5b2f1": "Muestra el consumo de tokens y el costo de Gemini en la barra de estado.", - "5bfb874d05": "Uso de Gemini", "97957e374e": "openai", "8dfd676c28": "codex", "e9e4412545": "Muestra el consumo de tokens y el costo de Codex en la barra de estado.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3e3711874198..a169c2494412 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -22,7 +22,6 @@ "statusBar": { "claudeToggleDescription": "アクティブなワークスペースの Claude トークンとコストの使用状況を表示します。", "codexToggleDescription": "アクティブなワークスペースの Codex トークンとコストの使用状況を表示します。", - "geminiToggleDescription": "アクティブなワークスペースの Gemini トークンとコストの使用状況を表示します。", "opencodeGoToggleDescription": "アクティブなワークスペースの OpenCode Go トークンと使用コストを表示します。", "kimiToggleDescription": "Kimi サブスクリプション", "minimaxToggleDescription": "アクティブなワークスペースの MiniMax サブスクリプション使用状況を表示します。", @@ -759,7 +758,7 @@ "cd50cec5d7": "Orca を通じて複数のコーディング agents を調整します。", "58a868e8e4": "オーケストレーション", "7c79d3b7bf": "任意", - "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go、MiniMax、Grok の任意のアカウント切り替えと使用量設定。", + "b1c2f8b0ac": "Claude、Codex、OpenCode Go、MiniMax、Grok の任意のアカウント切り替えと使用量設定。", "f70ac54d38": "AI プロバイダー アカウント", "4121f7a0a2": "AI agents を管理し、デフォルトを設定し、コマンドをカスタマイズします。", "b49abbd2f7": "エージェント", @@ -3148,7 +3147,6 @@ "24ac89df1a": "リモートホスト", "5e59007df4": "Kimi 使用量", "8c86cd77b0": "OpenCode Go の使用量", - "c1df0d67ec": "Gemini 使用量", "c0909c686e": "Codex 使用量", "3885eb74d8": "Claude 使用量", "c8857b40f7": "使用状況データを更新する", @@ -5112,12 +5110,6 @@ "36223200ac": "OpenCode Go セッション Cookie", "ea631977b5": "OpenCode Go プロバイダー設定を構成します。", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "ローカルの Gemini CLI インストールから OAuth 認証情報を抽出し、{{value0}} の Google 認証に使用します。これは、Orca ではなく、Gemini CLI アプリに発行された認証情報を使用します。Google が CLI を更新すると壊れる可能性があります。ご自身の責任でご使用ください。", - "96f3649526": "Gemini CLI 認証情報を使用する (実験的機能)", - "d676c41fc6": "ローカルの Gemini CLI インストールから OAuth 認証情報を抽出して、Google で認証します。これは、Orca ではなく、Gemini CLI アプリに発行された認証情報を使用します。 Google が CLI を更新すると壊れる可能性があります。ご自身の責任でご使用ください。", - "0c7f915b01": "Gemini CLI 認証情報を使用する", - "973741a871": "Gemini プロバイダー設定を構成します。", - "0c64dc2a64": "Gemini", "db209ee572": "削除", "8a0f870153": "再認証", "3d245ef7d9": "Codex はこのサインインが古いと報告しました", @@ -7400,9 +7392,6 @@ "7118d2f908": "資格", "933deaf732": "認証", "8630464352": "CLI", - "e8e1ff3887": "gemini", - "bada4a3218": "ローカルの Gemini CLI インストールから OAuth 認証情報を抽出して、Google で認証します。", - "d819755b02": "Gemini CLI 認証情報を使用する", "35b461d817": "サインイン", "f2d666a886": "オプション", "8b06729e0f": "アクティブ", @@ -7617,9 +7606,6 @@ "7f72de7cbe": "OpenCode Go トークンとコストの使用状況をステータス バーに表示します。", "bc046e7899": "OpenCode Go の使用法", "51b0ccd6a2": "グーグル", - "2804a920ad": "gemini", - "9660c5b2f1": "Gemini トークンとコストの使用状況をステータス バーに表示します。", - "5bfb874d05": "Gemini 使用量", "97957e374e": "openai", "8dfd676c28": "codex", "e9e4412545": "Codex トークンとコストの使用状況をステータス バーに表示します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5310100e4bed..115b57672d35 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -22,7 +22,6 @@ "statusBar": { "claudeToggleDescription": "활성 워크스페이스에 대한 Claude 토큰 및 비용 사용량을 표시합니다.", "codexToggleDescription": "활성 워크스페이스에 대한 Codex 토큰 및 비용 사용량을 표시합니다.", - "geminiToggleDescription": "활성 워크스페이스에 대한 Gemini 토큰 및 비용 사용량을 표시합니다.", "opencodeGoToggleDescription": "활성 워크스페이스에 대한 OpenCode Go 토큰 및 비용 사용량을 표시합니다.", "kimiToggleDescription": "활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.", "minimaxToggleDescription": "활성 워크스페이스의 MiniMax 구독 사용량을 표시합니다.", @@ -759,7 +758,7 @@ "cd50cec5d7": "Orca를 통해 여러 코딩 agents를 조정합니다.", "58a868e8e4": "오케스트레이션", "7c79d3b7bf": "선택 사항", - "b1c2f8b0ac": "Claude, Codex, Gemini, OpenCode Go, MiniMax, Grok의 선택적 계정 전환 및 사용량 설정.", + "b1c2f8b0ac": "Claude, Codex, OpenCode Go, MiniMax, Grok의 선택적 계정 전환 및 사용량 설정.", "f70ac54d38": "AI 제공자 계정", "4121f7a0a2": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.", "b49abbd2f7": "에이전트", @@ -3148,7 +3147,6 @@ "24ac89df1a": "원격 호스트", "5e59007df4": "Kimi 사용량", "8c86cd77b0": "OpenCode Go 사용량", - "c1df0d67ec": "Gemini 사용량", "c0909c686e": "Codex 사용량", "3885eb74d8": "Claude 사용량", "c8857b40f7": "사용량 데이터 새로 고침", @@ -5112,12 +5110,6 @@ "36223200ac": "OpenCode Go 세션 쿠키", "ea631977b5": "OpenCode Go 공급자 설정을 구성합니다.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "{{value0}}에서 Google에 인증하기 위해 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 추출합니다. 이는 Orca가 아닌 Gemini CLI 앱에 발급된 자격 증명을 사용합니다. Google이 CLI를 업데이트하면 중단될 수 있습니다. 자신의 책임하에 사용하십시오.", - "96f3649526": "Gemini CLI 자격 증명 사용(실험적)", - "d676c41fc6": "Google 인증을 위해 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 추출합니다. 이는 Orca가 아닌 Gemini CLI 앱에 발급된 자격 증명을 사용합니다. Google이 CLI를 업데이트하면 중단될 수 있습니다. 자신의 책임하에 사용하십시오.", - "0c7f915b01": "Gemini CLI 자격 증명 사용", - "973741a871": "Gemini 공급자 설정을 구성합니다.", - "0c64dc2a64": "Gemini", "db209ee572": "제거", "8a0f870153": "재인증", "3d245ef7d9": "Codex에서 이 로그인이 오래되었다고 보고했습니다.", @@ -7363,9 +7355,6 @@ "7118d2f908": "자격 증명", "933deaf732": "OAuth", "8630464352": "CLI", - "e8e1ff3887": "gemini", - "bada4a3218": "Google 인증을 위해 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 추출합니다.", - "d819755b02": "Gemini CLI 자격 증명 사용", "35b461d817": "로그인", "f2d666a886": "선택 사항", "8b06729e0f": "활성", @@ -7580,9 +7569,6 @@ "7f72de7cbe": "상태 표시줄에 OpenCode Go 토큰 및 비용 사용량을 표시합니다.", "bc046e7899": "OpenCode Go 사용량", "51b0ccd6a2": "Google", - "2804a920ad": "gemini", - "9660c5b2f1": "상태 표시줄에 Gemini 토큰과 비용 사용량을 표시합니다.", - "5bfb874d05": "Gemini 사용량", "97957e374e": "OpenAI", "8dfd676c28": "codex", "e9e4412545": "상태 표시줄에 Codex 토큰 및 비용 사용량을 표시합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3a948e98e79d..74d2f76063d2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -22,7 +22,6 @@ "statusBar": { "claudeToggleDescription": "显示当前工作区的 Claude Token 与费用消耗情况。", "codexToggleDescription": "显示当前工作区的 Codex Token 与费用消耗情况。", - "geminiToggleDescription": "显示当前工作区的 Gemini Token 与费用消耗情况。", "opencodeGoToggleDescription": "显示当前工作区的 OpenCode Go Token 与费用消耗情况。", "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", "minimaxToggleDescription": "显示当前工作区的 MiniMax 订阅使用情况。", @@ -759,7 +758,7 @@ "cd50cec5d7": "通过 Orca 协调多个编码智能体。", "58a868e8e4": "编排", "7c79d3b7bf": "可选", - "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go、MiniMax 和 Grok 的可选账户切换与使用量设置。", + "b1c2f8b0ac": "Claude、Codex、OpenCode Go、MiniMax 和 Grok 的可选账户切换与使用量设置。", "f70ac54d38": "AI 提供商账户", "4121f7a0a2": "管理 AI 智能体、设置默认值并自定义命令。", "b49abbd2f7": "智能体", @@ -3148,7 +3147,6 @@ "24ac89df1a": "远程主机", "5e59007df4": "Kimi 使用量", "8c86cd77b0": "OpenCode Go 使用量", - "c1df0d67ec": "Gemini 使用情况", "c0909c686e": "Codex 使用情况", "3885eb74d8": "Claude 使用情况", "c8857b40f7": "刷新使用数据", @@ -5112,12 +5110,6 @@ "36223200ac": "OpenCode Go 会话 Cookie", "ea631977b5": "配置 OpenCode Go 提供商设置。", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "从本地 Gemini CLI 安装中提取 OAuth 凭据,用于在 {{value0}} 通过 Google 进行身份验证。这将使用颁发给 Gemini CLI 应用的凭据,而非 Orca。若 Google 更新 CLI,可能会失效。请自行承担风险。", - "96f3649526": "使用 Gemini CLI 凭据(实验)", - "d676c41fc6": "从本地 Gemini CLI 安装中提取 OAuth 凭据以通过 Google 认证。这将使用颁发给 Gemini CLI 应用的凭据,而非 Orca。若 Google 更新 CLI,可能会失效。请自行承担风险。", - "0c7f915b01": "使用 Gemini CLI 凭据", - "973741a871": "配置 Gemini 提供商设置。", - "0c64dc2a64": "Gemini", "db209ee572": "移除", "8a0f870153": "重新验证", "3d245ef7d9": "Codex 报告此登录已过时", @@ -7363,9 +7355,6 @@ "7118d2f908": "证书", "933deaf732": "OAuth", "8630464352": "CLI", - "e8e1ff3887": "gemini", - "bada4a3218": "从本地 Gemini CLI 安装中提取 OAuth 凭据以通过 Google 进行身份验证。", - "d819755b02": "使用 Gemini CLI 凭据", "35b461d817": "登入", "f2d666a886": "可选", "8b06729e0f": "活跃", @@ -7580,9 +7569,6 @@ "7f72de7cbe": "在状态栏中显示 OpenCode Go Token 和成本使用情况。", "bc046e7899": "OpenCode Go 用法", "51b0ccd6a2": "谷歌", - "2804a920ad": "gemini", - "9660c5b2f1": "在状态栏中显示 Gemini Token 和成本使用情况。", - "5bfb874d05": "Gemini 使用情况", "97957e374e": "开放伊", "8dfd676c28": "codex", "e9e4412545": "在状态栏中显示 Codex Token 和成本使用情况。", diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index 3df495cf32d6..d81c90cf85cb 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -19,7 +19,6 @@ export const createRateLimitSlice: StateCreator { expect(setUI).not.toHaveBeenCalled() }) + it('removes and persists the deprecated Gemini usage item', () => { + const setUI = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + statusBarItems: [ + 'claude', + 'gemini', + 'antigravity' + ] as unknown as PersistedUIState['statusBarItems'], + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true + }) + ) + + expect(store.getState().statusBarItems).toEqual(['claude', 'antigravity']) + expect(setUI).toHaveBeenCalledWith( + expect.objectContaining({ statusBarItems: ['claude', 'antigravity'] }) + ) + }) + it('persists and hydrates the usage percentage display preference', () => { const setUI = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('window', { api: { ui: { set: setUI } } }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 024027403169..8c1c8bacdd8f 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -266,11 +266,16 @@ function presetToQuery(presetId: TaskViewPresetId | null): string { } } +const DEPRECATED_STATUS_BAR_ITEMS: ReadonlySet = new Set(['gemini']) + // Why: migrate legacy memory+sessions ids → resource-usage; keep unknown ids so downgrade→upgrade can't strip a newer build's ids. function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarItem[] { const source = items ?? DEFAULT_STATUS_BAR_ITEMS const out: string[] = [] for (const id of source) { + if (DEPRECATED_STATUS_BAR_ITEMS.has(id)) { + continue + } const mapped = id === 'memory' || id === 'sessions' ? 'resource-usage' : id if (!out.includes(mapped)) { out.push(mapped) @@ -2389,8 +2394,13 @@ export const createUISlice: StateCreator = (set, get) ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok') ? statusBarItemsWithAntigravity : [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM] + const statusBarItemsMigrated = + !Array.isArray(ui.statusBarItems) || + ui.statusBarItems.length !== migratedStatusBarItems.length || + ui.statusBarItems.some((item, index) => item !== migratedStatusBarItems[index]) if ( - (!ui._portsStatusBarDefaultAdded || + (statusBarItemsMigrated || + !ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded || !ui._minimaxStatusBarDefaultAdded || !ui._antigravityStatusBarDefaultAdded || diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d8e3ef432e24..b9694e266189 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2881,7 +2881,6 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { const empty: RateLimitState = { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 541444f6d23c..1986a7173247 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -329,7 +329,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { opencodeWorkspaceId: '', minimaxGroupId: '', minimaxUsageModels: 'general', - geminiCliOAuthEnabled: false, agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index 6d35c2d22fc8..e7665593fc45 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -10,7 +10,6 @@ describe('RateLimitState', () => { const state: RateLimitState = { claude: null, codex: null, - gemini: null, opencodeGo: null, kimi: null, antigravity: null, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 83210fba2ccb..5a96afc9b8af 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -46,15 +46,7 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' + provider: 'claude' | 'codex' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -63,7 +55,7 @@ export type ProviderRateLimits = { fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go, Grok unified billing), null if not available. */ monthly?: RateLimitWindow | null - /** Named per-model buckets (Gemini only). */ + /** Named per-model buckets (providers that expose quota by model). */ buckets?: RateLimitBucket[] /** Available earned Codex rate-limit reset credits, if reported. */ rateLimitResetCredits?: { @@ -118,7 +110,6 @@ export type GrokAccountStatus = { export type RateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null - gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null antigravity: ProviderRateLimits | null diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index b1884262d7d7..f69277f1c2a9 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -3,7 +3,6 @@ import type { StatusBarItem } from './types' export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'claude', 'codex', - 'gemini', 'antigravity', 'opencode-go', 'kimi', diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 007e48cc7cf9..39ec35a39d91 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -251,7 +251,6 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'experimentalTerminalAttention', 'experimentalAgentHibernation', 'experimentalEphemeralVms', - 'geminiCliOAuthEnabled', 'openAgentTabsInChatByDefault' ] as const satisfies readonly BooleanGlobalSettingsKey[] export const settingsChangedKeySchema = z.enum(SETTINGS_CHANGED_WHITELIST) diff --git a/src/shared/types.ts b/src/shared/types.ts index cbbfa0d8776b..d676da25e7af 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2932,8 +2932,6 @@ export type GlobalSettings = { minimaxGroupId: string /** Comma-separated MiniMax model names to show in the status bar usage window. */ minimaxUsageModels: string - /** Extract OAuth credentials from the local Gemini CLI for rate-limit fetching. Off by default (explicit opt-in). */ - geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> /** Custom CODEX_HOME for Codex session-history discovery (defaults to ~/.codex). @@ -3226,7 +3224,6 @@ export type AgentActivityDisplayMode = 'compact' | 'full' export type StatusBarItem = | 'claude' | 'codex' - | 'gemini' | 'antigravity' | 'opencode-go' | 'kimi' From ebd65eafddcdc713bdcf5bf242e71d62e1e0f8ed Mon Sep 17 00:00:00 2001 From: Seongjun Kim Date: Fri, 31 Jul 2026 07:34:03 +0900 Subject: [PATCH 2/6] Keep Antigravity quota polling responsive Parallelize process inspection, document the host-local settings boundary, and remove unreachable Gemini usage translations raised during review. Constraint: Preserve lsof discovery when Agy is running without a usable log directory. Rejected: Skip lsof when the Agy log directory is absent | that would remove the runtime discovery fallback. Confidence: high Scope-risk: narrow Directive: Keep Antigravity discovery independent of Orca-managed account settings. Tested: targeted Vitest, typecheck, oxfmt, oxlint, and localization catalog/extraction/coverage checks. Not-tested: full repository test suite was not rerun for this review-only follow-up. --- src/main/rate-limits/antigravity-usage-fetcher.ts | 12 ++++++++---- .../status-bar/usage-provider-settings-target.ts | 1 + src/renderer/src/i18n/locales/en.json | 2 -- src/renderer/src/i18n/locales/es.json | 2 -- src/renderer/src/i18n/locales/ja.json | 2 -- src/renderer/src/i18n/locales/ko.json | 2 -- src/renderer/src/i18n/locales/zh.json | 2 -- 7 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index ecae737f6bff..342d604d3f32 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -179,10 +179,14 @@ async function discoverLsofEndpoints(): Promise { const listeners = parseLsofListeners( await runCommand('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn']) ).filter(({ processName }) => /^(agy|language_?(?:server)?)$/i.test(processName)) - const commandLines = new Map() - for (const pid of new Set(listeners.map((listener) => listener.pid))) { - commandLines.set(pid, await runCommand('ps', ['-p', String(pid), '-o', 'command='])) - } + const commandLines = new Map( + await Promise.all( + [...new Set(listeners.map((listener) => listener.pid))].map( + async (pid) => + [pid, await runCommand('ps', ['-p', String(pid), '-o', 'command='])] as const + ) + ) + ) return listeners.flatMap(({ pid, processName, port }) => { const commandLine = commandLines.get(pid) ?? '' if (/^agy$/i.test(processName)) { diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 477d863936be..31a66ce515a6 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -9,6 +9,7 @@ export function getUsageProviderAccountsSectionId( case 'codex': return 'accounts-codex' case 'antigravity': + // Why: Agy quota comes from a host-local process, not an Orca-managed account. return null case 'opencode-go': return 'accounts-opencode-go' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 501c46329e0d..e1a3b46bc0d4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3197,10 +3197,8 @@ "3325d996cb": "Refresh rate limits", "fda8146810": "Open Kimi usage details", "629251f4b6": "Open OpenCode Go usage details", - "d2375976eb": "Open Gemini usage details", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "error", "cd9d7b40ff": "Restart {{value0}} Sessions", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 20a32730c4f0..c69dea1ec439 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3174,10 +3174,8 @@ "3325d996cb": "Actualizar rate limits", "fda8146810": "Abrir detalles de uso de Kimi", "629251f4b6": "Abrir detalles de uso de OpenCode Go", - "d2375976eb": "Abrir detalles de uso de Gemini", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "error", "cd9d7b40ff": "Reiniciar {{value0}} sesiones", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a169c2494412..0f8da3d0453c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3174,10 +3174,8 @@ "3325d996cb": "レート制限をリセット", "fda8146810": "Kimi の使用状況の詳細を開く", "629251f4b6": "OpenCode Go の使用状況の詳細を開く", - "d2375976eb": "Gemini の使用状況の詳細を開く", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "エラー", "cd9d7b40ff": "{{value0}} セッションを再起動します", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 115b57672d35..0987ffb26aac 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3174,10 +3174,8 @@ "3325d996cb": "rate-limit 새로고침", "fda8146810": "Kimi 사용 세부정보 열기", "629251f4b6": "OpenCode Go 사용 세부정보 열기", - "d2375976eb": "Gemini 사용 세부정보 열기", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "오류", "cd9d7b40ff": "{{value0}} 세션 다시 시작", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 74d2f76063d2..e6dfa4745945 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3174,10 +3174,8 @@ "3325d996cb": "刷新速率限制", "fda8146810": "打开 Kimi 使用详情", "629251f4b6": "打开 OpenCode Go 使用详情", - "d2375976eb": "打开 Gemini 使用详情", "3d79122c3f": "kimi", "d7a0668acc": "opencode-go", - "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "错误", "cd9d7b40ff": "重新启动 {{value0}} 会话", From 7fa8b2a3e22b0a6ad4a7592b86d8000802be3dcd Mon Sep 17 00:00:00 2001 From: Seongjun Kim Date: Fri, 31 Jul 2026 07:55:36 +0900 Subject: [PATCH 3/6] Preserve valid Antigravity quota endpoints Keep per-process inspection failures isolated and prefer authenticated duplicates so transient process exits or log-first discovery cannot hide a usable Agy endpoint. Constraint: Endpoint discovery must tolerate races between lsof and ps while retaining CSRF authentication. Rejected: Reverse discovery order only | it would encode preference indirectly and leave duplicate selection fragile. Confidence: high Scope-risk: narrow Directive: Select duplicate Agy endpoints by authentication quality, not discovery order. Tested: targeted Vitest, typecheck, oxfmt, oxlint, and max-lines ratchet. Not-tested: full repository test suite was not rerun for this focused review fix. --- .../antigravity-endpoint-selection.ts | 33 +++++++++++++++++++ .../antigravity-usage-fetcher.test.ts | 26 +++++++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 31 +++++------------ 3 files changed, 68 insertions(+), 22 deletions(-) create mode 100644 src/main/rate-limits/antigravity-endpoint-selection.ts diff --git a/src/main/rate-limits/antigravity-endpoint-selection.ts b/src/main/rate-limits/antigravity-endpoint-selection.ts new file mode 100644 index 000000000000..f8248c172f03 --- /dev/null +++ b/src/main/rate-limits/antigravity-endpoint-selection.ts @@ -0,0 +1,33 @@ +export type AgyQuotaEndpoint = { + pid: number + port: number + csrfToken: string | null +} + +export async function inspectAgyProcessCommands( + pids: Iterable, + inspect: (pid: number) => Promise +): Promise> { + const entries = await Promise.all( + [...pids].map(async (pid) => { + try { + return [pid, await inspect(pid)] as const + } catch { + return null + } + }) + ) + return new Map(entries.filter((entry): entry is readonly [number, string] => entry !== null)) +} + +export function deduplicateAgyQuotaEndpoints(endpoints: AgyQuotaEndpoint[]): AgyQuotaEndpoint[] { + const unique = new Map() + for (const endpoint of endpoints) { + const key = `${endpoint.pid}:${endpoint.port}` + const current = unique.get(key) + if (!current || (!current.csrfToken && endpoint.csrfToken)) { + unique.set(key, endpoint) + } + } + return [...unique.values()] +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index a21ee33ac036..8876e0561709 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -11,6 +11,10 @@ import { parseCsrfToken, parseLsofListeners } from './antigravity-usage-fetcher' +import { + deduplicateAgyQuotaEndpoints, + inspectAgyProcessCommands +} from './antigravity-endpoint-selection' function response(body: unknown, status = 200): Response { return { @@ -74,6 +78,28 @@ I0730 16:55:25.554099 27262 server.go:568] Language server listening on random p expect(parseCsrfToken('language_server --csrf_token abc-123 --standalone')).toBe('abc-123') expect(parseCsrfToken('language_server --csrf_token=def-456')).toBe('def-456') }) + + it('keeps valid process commands when another process exits during inspection', async () => { + const inspect = vi.fn(async (pid: number) => { + if (pid === 27451) { + throw new Error('process exited') + } + return `agy --pid ${pid}` + }) + + await expect(inspectAgyProcessCommands([27262, 27451], inspect)).resolves.toEqual( + new Map([[27262, 'agy --pid 27262']]) + ) + }) + + it('prefers a CSRF-authenticated duplicate endpoint', () => { + expect( + deduplicateAgyQuotaEndpoints([ + { pid: 27451, port: 50610, csrfToken: null }, + { pid: 27451, port: 50610, csrfToken: 'csrf-token' } + ]) + ).toEqual([{ pid: 27451, port: 50610, csrfToken: 'csrf-token' }]) + }) }) describe('Agy quota summary', () => { diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 342d604d3f32..623a40831051 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -4,6 +4,11 @@ import { homedir } from 'node:os' import path from 'node:path' import { net } from 'electron' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { + deduplicateAgyQuotaEndpoints, + inspectAgyProcessCommands, + type AgyQuotaEndpoint +} from './antigravity-endpoint-selection' const COMMAND_TIMEOUT_MS = 4_000 const FETCH_TIMEOUT_MS = 5_000 @@ -11,12 +16,6 @@ const LOG_PREFIX_BYTES = 16 * 1024 const SUMMARY_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' const AGY_LOG_DIR = path.join(homedir(), '.gemini', 'antigravity-cli', 'log') -export type AgyQuotaEndpoint = { - pid: number - port: number - csrfToken: string | null -} - type AgySummaryBucket = { bucketId?: unknown window?: unknown @@ -179,13 +178,9 @@ async function discoverLsofEndpoints(): Promise { const listeners = parseLsofListeners( await runCommand('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn']) ).filter(({ processName }) => /^(agy|language_?(?:server)?)$/i.test(processName)) - const commandLines = new Map( - await Promise.all( - [...new Set(listeners.map((listener) => listener.pid))].map( - async (pid) => - [pid, await runCommand('ps', ['-p', String(pid), '-o', 'command='])] as const - ) - ) + const commandLines = await inspectAgyProcessCommands( + new Set(listeners.map((listener) => listener.pid)), + (pid) => runCommand('ps', ['-p', String(pid), '-o', 'command=']) ) return listeners.flatMap(({ pid, processName, port }) => { const commandLine = commandLines.get(pid) ?? '' @@ -208,15 +203,7 @@ async function discoverLsofEndpoints(): Promise { export async function discoverAgyQuotaEndpoints(): Promise { const endpoints = [...(await discoverLogEndpoints()), ...(await discoverLsofEndpoints())] - const seen = new Set() - return endpoints.filter((endpoint) => { - const key = `${endpoint.pid}:${endpoint.port}` - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) + return deduplicateAgyQuotaEndpoints(endpoints) } function isSummaryBucket(value: unknown): value is AgySummaryBucket { From f73ea5af07db766c9e7c41e36bb8863d6c672b70 Mon Sep 17 00:00:00 2001 From: Seongjun Kim Date: Sat, 15 Aug 2026 18:49:27 +0900 Subject: [PATCH 4/6] Keep Agy discovery resilient to rotating logs Treat each selected log as an independent discovery attempt so one removed file cannot erase valid endpoints found on Windows or other log-only hosts. Constraint: Agy rotates log files between directory listing and prefix reads. Rejected: Catch the whole discovery loop | it discards already recoverable endpoints after one transient ENOENT. Confidence: high Scope-risk: narrow Directive: Keep endpoint discovery failures isolated per candidate. Tested: Antigravity fetcher 11 tests; Node 24 typecheck; targeted oxlint; diff check. Not-tested: Full repository lint remains blocked by the unrelated origin/main warning documented in the PR body. --- .../antigravity-usage-fetcher.test.ts | 17 +++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 28 +++++++++++++------ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index 9a8107171ced..f68e859c7547 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -5,6 +5,7 @@ const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) import { + collectLiveAgyLogEndpoints, fetchAntigravityRateLimits, parseAgyLogEndpoint, parseAgyQuotaSummary, @@ -100,6 +101,22 @@ I0730 16:55:25.554099 27262 server.go:568] Language server listening on random p ]) ).toEqual([{ pid: 27451, port: 50610, csrfToken: 'csrf-token' }]) }) + + it('keeps valid log endpoints when another selected log disappears', async () => { + const readLog = vi.fn(async (name: string) => { + if (name === 'removed.log') { + throw new Error('ENOENT') + } + return ` +I0730 16:55:25.550727 27262 server.go:1424] Starting language server process with pid 27262 +I0730 16:55:25.554099 27262 server.go:568] Language server listening on random port at 50590 for HTTP +` + }) + + await expect( + collectLiveAgyLogEndpoints(['removed.log', 'active.log'], readLog, () => true) + ).resolves.toEqual([{ pid: 27262, port: 50590, csrfToken: null }]) + }) }) describe('Agy quota summary', () => { diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 3c8058dd58b6..46cd6ae44b52 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -136,6 +136,25 @@ export function parseCsrfToken(commandLine: string): string | null { return commandLine.match(/(?:^|\s)--csrf_token(?:=|\s+)([^\s]+)/)?.[1] ?? null } +export async function collectLiveAgyLogEndpoints( + names: string[], + readLog: (name: string) => Promise, + processAlive: (pid: number) => boolean = isProcessAlive +): Promise { + const endpoints: AgyQuotaEndpoint[] = [] + for (const name of names) { + try { + const endpoint = parseAgyLogEndpoint(await readLog(name)) + if (endpoint && processAlive(endpoint.pid)) { + endpoints.push(endpoint) + } + } catch { + continue + } + } + return endpoints +} + async function discoverLogEndpoints(): Promise { try { const names = (await readdir(AGY_LOG_DIR)) @@ -143,14 +162,7 @@ async function discoverLogEndpoints(): Promise { .sort() .toReversed() .slice(0, 8) - const endpoints: AgyQuotaEndpoint[] = [] - for (const name of names) { - const endpoint = parseAgyLogEndpoint(await readFilePrefix(path.join(AGY_LOG_DIR, name))) - if (endpoint && isProcessAlive(endpoint.pid)) { - endpoints.push(endpoint) - } - } - return endpoints + return collectLiveAgyLogEndpoints(names, (name) => readFilePrefix(path.join(AGY_LOG_DIR, name))) } catch { return [] } From e401ad4fc941ef6de42ca65591e73a69008b031d Mon Sep 17 00:00:00 2001 From: Seongjun Kim Date: Tue, 18 Aug 2026 09:39:37 +0900 Subject: [PATCH 5/6] Keep refreshed validation honest on current main Update search fixtures for the current palette document contract and stop localization regression tests from requiring removed Gemini usage strings. Constraint: Full validation must run after merging current main Rejected: Mark baseline failures as passed | The fixtures can be corrected without changing product behavior Confidence: high Scope-risk: narrow Directive: Keep retired-provider localization tests aligned with catalog removal Tested: pnpm test (5,911 files; 55,565 tests); pnpm lint; pnpm typecheck; pnpm build; changed-code quality Not-tested: None --- .../TabBarCreateEntry.tab-results.test.tsx | 9 +++++++ .../tab-bar/open-tab-search-retention.test.ts | 24 +++++++++++++++++-- ...-technical-literal-mistranslations.test.ts | 3 --- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx index a7e599b0ec1b..ae737ebbb32e 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx @@ -4,6 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' +import { buildPaletteTabDocument } from '@/lib/palette-match/tab-document' import type { OpenTabSearchResult } from './open-tab-search' import type { TabAgentLaunchOption } from './tab-agent-launch-options' import type { TabCreateMenuOption } from './tab-create-menu-options' @@ -70,6 +71,14 @@ const tabSearchMock = vi.hoisted(() => { secondaryText: row.relativePath ?? '', titleSearchText: row.title, secondarySearchTexts: row.relativePath ? [row.relativePath] : [], + document: buildPaletteTabDocument({ + id: row.tabId ?? row.title, + title: row.title, + secondaryTexts: row.relativePath ? [row.relativePath] : [], + worktreeName: worktree.displayName, + branch: '', + repoName: 'octo/rocket' + }), agentMetadata: [], isCurrentTab: false, isCurrentWorktree: true diff --git a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts index bac8a16eb991..8bc08812cb73 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { Tab, TabContentType } from '../../../../shared/tab-types' import type { Worktree } from '../../../../shared/worktree/types' -import type { SearchableBrowserPage } from '@/lib/browser-palette-search' +import { + buildSearchableBrowserPageDocument, + type SearchableBrowserPage +} from '@/lib/browser-palette-search' +import { buildPaletteTabDocument } from '@/lib/palette-match/tab-document' import type { SearchableWorkspaceTab } from '@/lib/workspace-tab-palette-search' import { TERMINAL_TYPE_SEARCH_ALIASES } from '@/lib/workspace-tab-palette-search' import { searchOpenTabs } from './open-tab-search' @@ -72,6 +76,16 @@ function makeWorkspaceTab({ secondarySearchTexts, typeSearchAliases: typeSearchAliases ?? (contentType === 'terminal' ? TERMINAL_TYPE_SEARCH_ALIASES : undefined), + document: buildPaletteTabDocument({ + id, + title, + secondaryTexts: secondarySearchTexts, + worktreeName: worktree.displayName, + branch: 'main', + repoName: 'octo/rocket', + typeAliases: + typeSearchAliases ?? (contentType === 'terminal' ? TERMINAL_TYPE_SEARCH_ALIASES : undefined) + }), agentMetadata: agentSnippets.length ? [{ paneKey: `${id}-pane`, textParts: [], snippetCandidates: agentSnippets }] : [], @@ -125,7 +139,13 @@ function makeBrowserPage({ repoName: 'octo/rocket', worktreeSortIndex: 0, isCurrentPage: false, - isCurrentWorktree: true + isCurrentWorktree: true, + document: buildSearchableBrowserPageDocument({ + page, + workspace, + worktree, + repoName: 'octo/rocket' + }) } } diff --git a/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts b/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts index feea4e56a623..802410066ce1 100644 --- a/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts +++ b/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts @@ -71,7 +71,6 @@ describe('zh provider usage wording (#12881)', () => { // status bar item menu ['3885eb74d8', 'Claude'], ['c0909c686e', 'Codex'], - ['c1df0d67ec', 'Gemini'], ['antigravityUsage', 'Antigravity'], ['8c86cd77b0', 'OpenCode Go'], ['5e59007df4', 'Kimi'], @@ -80,7 +79,6 @@ describe('zh provider usage wording (#12881)', () => { // Settings > Appearance search index — mirrors the menu labels ['9dc15020d7', 'Claude'], ['54b1acf24f', 'Codex'], - ['5bfb874d05', 'Gemini'], ['antigravityUsageTitle', 'Antigravity'], ['bc046e7899', 'OpenCode Go'], ['3a6c028ea8', 'Kimi'], @@ -116,7 +114,6 @@ describe('zh provider usage wording (#12881)', () => { for (const [key, brand] of [ ['fda8146810', 'Kimi'], ['629251f4b6', 'OpenCode Go'], - ['d2375976eb', 'Gemini'], ['06741a2f3d', 'MiniMax'], ['antigravityUsageDetails', 'Antigravity'], ['grokUsageAria', 'Grok'] From 5a715635349a2926f77bdf3b5a58b2ae349f2f0d Mon Sep 17 00:00:00 2001 From: Seongjun Kim Date: Mon, 24 Aug 2026 08:37:39 +0900 Subject: [PATCH 6/6] chore: drop merge-only formatting changes --- src/main/ipc/preflight-agent-refresh.test.ts | 24 +++---------------- src/main/pty/wsl-orca-env.test.ts | 6 ++++- src/main/wsl/wsl-guest-environment.test.ts | 2 +- ...ow-shortcut-policy-agent-dashboard.test.ts | 5 +++- 4 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/main/ipc/preflight-agent-refresh.test.ts b/src/main/ipc/preflight-agent-refresh.test.ts index cc64e2beecac..fb2a8f0c99e8 100644 --- a/src/main/ipc/preflight-agent-refresh.test.ts +++ b/src/main/ipc/preflight-agent-refresh.test.ts @@ -130,13 +130,7 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes("'claude'")) { - return { - environmentResolved: true, - code: 0, - stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', - stderr: '', - timedOut: false - } + return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } } throw new Error('not found') }) @@ -189,13 +183,7 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'opencode') { - return { - environmentResolved: true, - code: 0, - stdout: '/Users/test/.opencode/bin/opencode\n', - stderr: '', - timedOut: false - } + return { environmentResolved: true, code: 0, stdout: '/Users/test/.opencode/bin/opencode\n', stderr: '', timedOut: false } } throw new Error('not found') }) @@ -231,13 +219,7 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { - environmentResolved: true, - code: 0, - stdout: '/Users/test/.local/bin/claude\n', - stderr: '', - timedOut: false - } + return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } } throw new Error('not found') }) diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index f12754030013..1d6e11367725 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -5,7 +5,10 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV } from '../../shared/setup-agent-sequencing' -import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from './wsl-orca-env' +import { + addOrcaWslInteropEnv, + stampWslOrchestrationCompatibilityHost +} from './wsl-orca-env' describe('addOrcaWslInteropEnv', () => { it('marks the Orca terminal handle for Windows to WSL env import', () => { @@ -243,3 +246,4 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).not.toContain('ORCA_OPENCODE_CONFIG_DIR') }) }) + diff --git a/src/main/wsl/wsl-guest-environment.test.ts b/src/main/wsl/wsl-guest-environment.test.ts index 0d14d9252b71..b9a67d488fd7 100644 --- a/src/main/wsl/wsl-guest-environment.test.ts +++ b/src/main/wsl/wsl-guest-environment.test.ts @@ -156,7 +156,7 @@ describe('a failed verdict does not outlive its usefulness', () => { expect(await getWslGuestEnvironment('Ubuntu', 9_000)).not.toBeNull() }) - it("bounds a joiner by its own budget, not the starter's", async () => { + it('bounds a joiner by its own budget, not the starter\'s', async () => { // Joining used to mean waiting out the starter's probe, so a joiner could // reach its own command with 1ms left. let release: (v: unknown) => void = () => {} diff --git a/src/shared/window-shortcut-policy-agent-dashboard.test.ts b/src/shared/window-shortcut-policy-agent-dashboard.test.ts index 09810b9e58b8..24bfe99c04f3 100644 --- a/src/shared/window-shortcut-policy-agent-dashboard.test.ts +++ b/src/shared/window-shortcut-policy-agent-dashboard.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getWindowShortcutActionId, resolveWindowShortcutAction } from './window-shortcut-policy' +import { + getWindowShortcutActionId, + resolveWindowShortcutAction +} from './window-shortcut-policy' const input = { code: 'KeyD',