diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index d2ac1d35d756..f0c779bd7e26 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -32,7 +32,7 @@ "anti-slop/no-module-mocking": "off", "anti-slop/no-object-parameters": "off", "anti-slop/no-reduce-accumulator-copy": "error", - "anti-slop/no-reflect-apply": "off", + "anti-slop/no-reflect-apply": "error", "anti-slop/no-reflect-get": "off", "anti-slop/no-runtime-typeof": "off", "anti-slop/no-shape-in-symbol-names": "off", diff --git a/config/scripts/main-blocking-probe.mjs b/config/scripts/main-blocking-probe.mjs index 93a5372a7653..e8b038abcaad 100644 --- a/config/scripts/main-blocking-probe.mjs +++ b/config/scripts/main-blocking-probe.mjs @@ -12,7 +12,7 @@ export function installMainBlockingProbe() { const epoch = Date.now() let result try { - result = Reflect.apply(original, this, args) + result = original.call(this, ...args) return result } finally { const durationMs = performance.now() - start diff --git a/config/scripts/persistence-call-probe.mjs b/config/scripts/persistence-call-probe.mjs index d62df17cc0b2..dc15462d0cff 100644 --- a/config/scripts/persistence-call-probe.mjs +++ b/config/scripts/persistence-call-probe.mjs @@ -21,7 +21,7 @@ export function installPersistenceCallProbe() { const epoch = Date.now() let result try { - result = Reflect.apply(original, this, args) + result = original.call(this, ...args) return result } finally { const durationMs = performance.now() - start diff --git a/config/scripts/terminal-stream-byte-length-benchmark.mjs b/config/scripts/terminal-stream-byte-length-benchmark.mjs index 4ea2548b3933..7ad53e74dceb 100644 --- a/config/scripts/terminal-stream-byte-length-benchmark.mjs +++ b/config/scripts/terminal-stream-byte-length-benchmark.mjs @@ -88,7 +88,7 @@ function runWithNativeCallCount(fn) { let calls = 0 Buffer.byteLength = (...args) => { calls += 1 - return Reflect.apply(nativeByteLength, Buffer, args) + return nativeByteLength.call(Buffer, ...args) } try { return { output: fn(), calls } diff --git a/src/main/git/command-runner/command-exec-file.ts b/src/main/git/command-runner/command-exec-file.ts index aa3e18a3a70e..d67fa24f2497 100644 --- a/src/main/git/command-runner/command-exec-file.ts +++ b/src/main/git/command-runner/command-exec-file.ts @@ -1,14 +1,9 @@ import { isWindowsBatchScript, resolveWindowsCommand } from '../../win32-utils' +import { isMissingCommandBinaryError } from '../exec-error' import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' import { execFileCapture } from './exec-file-capture' import { spawnCommandCapture, type CommandExecOptions } from './spawn-command-capture' -function isMissingCommandError(error: unknown): boolean { - return Boolean( - error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT' - ) -} - function hasPathSeparator(command: string): boolean { return command.includes('/') || command.includes('\\') } @@ -17,7 +12,7 @@ function shouldRetryWindowsCommandShim(error: unknown, resolved: ResolvedCommand return ( process.platform === 'win32' && resolved.wsl === null && - isMissingCommandError(error) && + isMissingCommandBinaryError(error) && !hasPathSeparator(resolved.binary) && !/\.[A-Za-z0-9]+$/.test(resolved.binary) ) diff --git a/src/main/git/exec-error.ts b/src/main/git/exec-error.ts index fb44e54917af..6e0385091d5e 100644 --- a/src/main/git/exec-error.ts +++ b/src/main/git/exec-error.ts @@ -40,6 +40,19 @@ export function extractExecError(err: unknown): { stderr: string; stdout: string return { stderr: String(err), stdout: '' } } +/** Recognizes spawn ENOENT; callers must separately rule out a missing cwd. */ +export function isMissingCommandBinaryError(err: unknown): boolean { + return Boolean( + err && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' && + 'syscall' in err && + typeof err.syscall === 'string' && + err.syscall.startsWith('spawn ') + ) +} + /** * Detect a Retry-After hint in gh stderr and return the suggested delay in ms, * or null when the response includes no Retry-After. diff --git a/src/main/git/git-availability.ts b/src/main/git/git-availability.ts new file mode 100644 index 000000000000..0fda3933f7ad --- /dev/null +++ b/src/main/git/git-availability.ts @@ -0,0 +1,31 @@ +import { access } from 'node:fs/promises' +import { isMissingCommandBinaryError } from './exec-error' + +type GitVersionExec = ( + args: string[], + options: { cwd: string; timeout: number } +) => Promise + +/** + * Resolves `false` only when the spawn proved Git absent; every other failure rejects so callers + * keep an unknown answer instead of reporting a host with no Git. + */ +export async function probeGitAvailability( + exec: GitVersionExec, + options: { cwd: string; timeout: number } +): Promise { + try { + await exec(['--version'], options) + return true + } catch (err) { + if (isMissingCommandBinaryError(err)) { + try { + await access(options.cwd) + return false + } catch { + // Node reports the same spawn ENOENT for a missing binary and a missing cwd. + } + } + throw err + } +} diff --git a/src/main/gitlab/client-mr-auth-rate-limit.test.ts b/src/main/gitlab/client-mr-auth-rate-limit.test.ts index abf928a7c9fa..6d76bea52a5b 100644 --- a/src/main/gitlab/client-mr-auth-rate-limit.test.ts +++ b/src/main/gitlab/client-mr-auth-rate-limit.test.ts @@ -95,7 +95,7 @@ describe('gitlab client — MR operations', () => { if (this[0] === 'gitlab.com' && this.every((value) => typeof value === 'string')) { knownHostCacheScans += 1 } - return Reflect.apply(originalMap, this, [callback, thisArg]) + return originalMap.call(this, callback, thisArg) }) try { diff --git a/src/main/ipc/repos/repo-creation-git-availability.test.ts b/src/main/ipc/repos/repo-creation-git-availability.test.ts new file mode 100644 index 000000000000..8ffbbdc033e4 --- /dev/null +++ b/src/main/ipc/repos/repo-creation-git-availability.test.ts @@ -0,0 +1,82 @@ +/** + * `repos:isGitAvailable` gates the create dialog's Git option. Only a spawn that never started may + * answer `false`; everything else rejects so the renderer's existing `unknown` branch is reachable. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } })) +vi.mock('../../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('../../repo-icon-autodetect', () => ({ + detectRepoIconAndUpstream: vi.fn(async () => ({})) +})) +vi.mock('../../worktree-root-preparation', () => ({ + prepareLocalWorktreeRootForRepo: vi.fn(async () => {}) +})) +vi.mock('../registered-worktree-roots-cache', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) +vi.mock('./repo-added-telemetry', () => ({ emitRepoAdded: vi.fn() })) +vi.mock('./repos-changed-notification', () => ({ notifyReposChanged: vi.fn() })) +vi.mock('./local-repo-registration', () => ({ addLocalRepoFromPath: vi.fn() })) +vi.mock('./remote-repo-registration', () => ({ addRemoteRepoFromPath: vi.fn() })) +vi.mock('./remote-repo-creation', () => ({ createRemoteRepo: vi.fn() })) + +import { probeLocalGitAvailability } from './repo-creation-handlers' + +describe('repos:isGitAvailable', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('answers true when git reports its version', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git version 2.25.1\n', stderr: '' }) + await expect(probeLocalGitAvailability()).resolves.toBe(true) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['--version'], { + cwd: process.cwd(), + timeout: 1500 + }) + }) + + it('answers false only when the spawn itself found no binary', async () => { + gitExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git' }) + ) + await expect(probeLocalGitAvailability()).resolves.toBe(false) + }) + + it('rejects an ENOENT when the working directory disappeared', async () => { + const missingCwd = `${process.cwd()}-missing` + vi.spyOn(process, 'cwd').mockReturnValue(missingCwd) + gitExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git' }) + ) + + await expect(probeLocalGitAvailability()).rejects.toThrow('spawn git ENOENT') + }) + + it('rejects a non-spawn ENOENT rather than reporting no Git', async () => { + gitExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('open config ENOENT'), { code: 'ENOENT', syscall: 'open' }) + ) + + await expect(probeLocalGitAvailability()).rejects.toThrow('open config ENOENT') + }) + + it('rejects on the timeout rather than reporting no git', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('git --version timed out after 1500ms')) + await expect(probeLocalGitAvailability()).rejects.toThrow('timed out') + }) + + it('rejects when git runs and fails', async () => { + gitExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('fatal: detected dubious ownership'), { code: 128 }) + ) + await expect(probeLocalGitAvailability()).rejects.toThrow('dubious ownership') + }) +}) diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index 908948942531..57bfcf66c79b 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -10,6 +10,7 @@ import { DEFAULT_REPO_BADGE_COLOR, getDefaultWorkspaceDir } from '../../../share import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { getEffectiveHostSetting } from '../../../shared/host-setting-overrides' +import { probeGitAvailability } from '../../git/git-availability' import { gitExecFileAsync } from '../../git/runner' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' @@ -22,16 +23,12 @@ import { createRemoteRepo } from './remote-repo-creation' const GIT_AVAILABILITY_TIMEOUT_MS = 1500 -async function isGitAvailable(): Promise { - try { - await gitExecFileAsync(['--version'], { - cwd: process.cwd(), - timeout: GIT_AVAILABILITY_TIMEOUT_MS - }) - return true - } catch { - return false - } +// Only ENOENT proves Git absent; rejecting other failures preserves the renderer's unknown state. +export async function probeLocalGitAvailability(): Promise { + return probeGitAvailability(gitExecFileAsync, { + cwd: process.cwd(), + timeout: GIT_AVAILABILITY_TIMEOUT_MS + }) } /** @@ -63,7 +60,7 @@ function getDefaultCreateProjectParent(store: Store): string { } export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: Store): void { - ipcMain.handle('repos:isGitAvailable', () => isGitAvailable()) + ipcMain.handle('repos:isGitAvailable', () => probeLocalGitAvailability()) ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent(store)) ipcMain.handle( diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts index 757e7e440847..17bdf7104905 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts @@ -69,13 +69,17 @@ it.each([1, 100, 200])('serializes each of %i unchanged forward page items once' await appendItems(count, 'x'.repeat(8_000)) const snapshot = journal.snapshot() const stringify = JSON.stringify + // Method-shaped type: the JSON.stringify overloads split on replacer shape and reject a forwarded one. + const forwardStringify: { + stringify(value: unknown, replacer?: unknown, space?: unknown): string + }['stringify'] = stringify let itemSerializations = 0 - JSON.stringify = ((value: unknown, ...args: unknown[]) => { + JSON.stringify = (value: unknown, replacer?: unknown, space?: unknown): string => { if (value && typeof value === 'object' && 'itemId' in value && 'body' in value) { itemSerializations++ } - return Reflect.apply(stringify, JSON, [value, ...args]) - }) as typeof JSON.stringify + return forwardStringify(value, replacer, space) + } try { const result = readAgentSessionHistory( journal, diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts new file mode 100644 index 000000000000..ff8b5177588d --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { + AGY_USAGE_ARGS, + fetchAntigravityRateLimits, + parseAgyUsageResponse +} from './antigravity-usage-fetcher' + +vi.mock('../git/command-runner/exec-file-capture', () => ({ + execFileCaptureToTermination: vi.fn() +})) + +vi.mock('../../shared/node-cli-command-resolution', () => ({ + resolveCliCommand: vi.fn() +})) + +const sample = { + command: { + data: { + groups: [ + { + name: 'Gemini Models', + description: 'Gemini quota', + buckets: [ + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + window: 'weekly', + remaining_fraction: 0.994, + reset_time: '2026-09-18T20:00:00Z' + }, + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + window: '5h', + remaining_fraction: 1, + reset_time: '2026-09-14T18:08:00Z' + } + ] + }, + { + name: 'Claude and GPT models', + buckets: [ + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + window: 'weekly', + remaining_fraction: 1, + reset_time: null + }, + { + id: '3p-5h', + name: 'Five Hour Limit Remaining', + window: '5h', + remaining_fraction: 1, + reset_time: '2026-09-14T18:08:00Z' + } + ] + } + ] + } + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('parseAgyUsageResponse', () => { + it('preserves two groups and two windows with independent values', () => { + const result = parseAgyUsageResponse(sample, 1_700_000_000_000) + expect(result.status).toBe('ok') + expect(result.buckets).toHaveLength(4) + expect( + result.buckets?.map((bucket) => [bucket.id, bucket.groupName, bucket.windowMinutes]) + ).toEqual([ + ['gemini-weekly', 'Gemini Models', 10080], + ['gemini-5h', 'Gemini Models', 300], + ['3p-weekly', 'Claude and GPT models', 10080], + ['3p-5h', 'Claude and GPT models', 300] + ]) + expect(result.buckets?.[0]?.usedPercent).toBeCloseTo(0.6) + expect(result.buckets?.[1]?.usedPercent).toBe(0) + expect(result.buckets?.[2]?.resetsAt).toBeNull() + }) + + it('keeps unknown groups and windows instead of dropping them', () => { + const result = parseAgyUsageResponse({ + command: { + data: { + groups: [ + { + name: 'New pool', + buckets: [{ id: 'daily', name: 'Daily', window: 'daily', remaining_fraction: 0.5 }] + } + ] + } + } + }) + expect(result.buckets?.[0]).toMatchObject({ + id: 'daily', + groupName: 'New pool', + windowLabel: 'daily', + windowMinutes: 0, + usedPercent: 50 + }) + }) + + it.each([ + { name: 'missing groups', value: {} }, + { name: 'empty groups', value: { command: { data: { groups: [] } } } }, + { + name: 'partial bucket', + value: { command: { data: { groups: [{ name: 'x', buckets: [{}] }] } } } + } + ])('returns unavailable for $name', ({ value }) => { + expect(parseAgyUsageResponse(value).status).toBe('unavailable') + }) +}) + +describe('fetchAntigravityRateLimits', () => { + it('resolves agy outside PATH and uses the exact argv without a shell', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockResolvedValue({ + stdout: JSON.stringify(sample), + stderr: '' + }) + await fetchAntigravityRateLimits() + expect(execFileCaptureToTermination).toHaveBeenCalledWith( + '/mock/bin/agy', + AGY_USAGE_ARGS, + expect.objectContaining({ + timeout: 10_000, + maxBuffer: 1024 * 1024, + signal: undefined, + createTimeoutError: expect.any(Function) + }) + ) + }) + + it('distinguishes an unresolved executable', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('agy') + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('cli-unavailable') + expect(execFileCaptureToTermination).not.toHaveBeenCalled() + }) + + it('reports malformed stdout as a parse failure', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockResolvedValue({ stdout: '{', stderr: '' }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + }) + + it('passes the refresh AbortSignal to the agy process', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockResolvedValue({ + stdout: JSON.stringify(sample), + stderr: '' + }) + const controller = new AbortController() + await fetchAntigravityRateLimits(controller.signal) + expect(execFileCaptureToTermination).toHaveBeenCalledWith( + '/mock/bin/agy', + AGY_USAGE_ARGS, + expect.objectContaining({ signal: controller.signal }) + ) + }) + + it('classifies the shared runner timeout separately from a generic read failure', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + vi.mocked(execFileCaptureToTermination).mockRejectedValue( + Object.assign(new Error('The agy CLI timed out.'), { + code: null, + killed: true, + signal: 'SIGTERM' + }) + ) + const result = await fetchAntigravityRateLimits() + expect(result.error).toContain('timed out') + expect(result.usageMetadata?.failureKind).toBe('unknown') + }) + + it('propagates refresh cancellation instead of converting it to a provider error', async () => { + vi.mocked(resolveCliCommand).mockReturnValue('/mock/bin/agy') + const abortError = Object.assign(new Error('The operation was aborted.'), { + name: 'AbortError' + }) + vi.mocked(execFileCaptureToTermination).mockRejectedValue(abortError) + await expect(fetchAntigravityRateLimits(new AbortController().signal)).rejects.toMatchObject({ + name: 'AbortError' + }) + }) + + it.each([ + { remaining: -0.1, label: 'below zero' }, + { remaining: 1.1, label: 'above one' } + ])('rejects remaining_fraction $label', ({ remaining }) => { + const result = parseAgyUsageResponse({ + command: { + data: { + groups: [ + { name: 'x', buckets: [{ name: 'x', window: '5h', remaining_fraction: remaining }] } + ] + } + } + }) + expect(result.status).toBe('unavailable') + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts new file mode 100644 index 000000000000..3ea93bd4a266 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,207 @@ +import path from 'node:path' +import type { ProviderRateLimits, RateLimitBucket } from '../../shared/rate-limit-types' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture' + +const AGY_USAGE_ARGS = ['--print', '/usage', '--output-format', 'json'] +const AGY_USAGE_TIMEOUT_MS = 10_000 +const AGY_USAGE_MAX_BUFFER = 1024 * 1024 + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' +} + +function emptyAntigravityResult( + status: 'error' | 'unavailable', + error: string, + now: number, + failureKind: 'cli-unavailable' | 'usage-unavailable' | 'parse' | 'unknown' +): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [], + updatedAt: now, + error, + status, + usageMetadata: { source: 'cli', failureKind } + } +} + +function parseWindowMinutes(window: unknown): number { + if (window === '5h') { + return 300 + } + if (window === 'weekly') { + return 10080 + } + return 0 +} + +function createAgyTimeoutError(): Error { + return Object.assign(new Error('The agy CLI timed out.'), { code: 'ETIMEDOUT' }) +} + +function parseResetAt(value: unknown): number | null { + if (typeof value !== 'string') { + return null + } + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null +} + +function parseAgyUsageResponse(value: unknown, now = Date.now()): ProviderRateLimits { + const root = isRecord(value) ? value : {} + const command = isRecord(root.command) ? root.command : {} + const data = isRecord(command.data) ? command.data : {} + const groups = data.groups + const buckets: RateLimitBucket[] = [] + if (Array.isArray(groups)) { + for (const groupValue of groups) { + if (!groupValue || typeof groupValue !== 'object') { + continue + } + const group = groupValue + const groupName = typeof group.name === 'string' ? group.name.trim() : '' + if (!groupName || !Array.isArray(group.buckets)) { + continue + } + const groupDescription = + typeof group.description === 'string' ? group.description.trim() || null : null + for (const bucketValue of group.buckets) { + if (!bucketValue || typeof bucketValue !== 'object') { + continue + } + const bucket = bucketValue + const name = typeof bucket.name === 'string' ? bucket.name.trim() : '' + const remaining = bucket.remaining_fraction + if ( + !name || + typeof remaining !== 'number' || + !Number.isFinite(remaining) || + remaining < 0 || + remaining > 1 + ) { + continue + } + const sourceWindow = typeof bucket.window === 'string' ? bucket.window.trim() : '' + buckets.push({ + ...(typeof bucket.id === 'string' && bucket.id.trim() ? { id: bucket.id.trim() } : {}), + name, + groupName, + groupDescription, + windowMinutes: parseWindowMinutes(sourceWindow), + ...(sourceWindow ? { windowLabel: sourceWindow } : {}), + usedPercent: (1 - remaining) * 100, + resetsAt: parseResetAt(bucket.reset_time), + resetDescription: null + }) + } + } + } + if (buckets.length === 0) { + return emptyAntigravityResult( + 'unavailable', + 'Antigravity usage is not available. The agy CLI returned no quota buckets.', + now, + 'usage-unavailable' + ) + } + return { + provider: 'antigravity', + session: null, + weekly: null, + buckets, + updatedAt: now, + error: null, + status: 'ok', + usageMetadata: { source: 'cli', lastSuccessfulSource: 'cli' } + } +} + +function classifyAgyFailure(error: unknown): { + message: string + status: 'error' | 'unavailable' + failureKind: 'cli-unavailable' | 'usage-unavailable' | 'parse' | 'unknown' +} { + const record = isRecord(error) ? error : {} + const stderr = typeof record.stderr === 'string' ? record.stderr.trim() : '' + if (record.code === 'ENOENT') { + return { + message: 'Antigravity usage is unavailable because the agy CLI was not found.', + status: 'unavailable', + failureKind: 'cli-unavailable' + } + } + if ( + record.code === 'ETIMEDOUT' || + record.code === 'ERR_CHILD_PROCESS_TIMEOUT' || + record.killed === true || + record.signal === 'SIGTERM' + ) { + return { + message: 'Antigravity usage could not be refreshed before the agy CLI timed out.', + status: 'error', + failureKind: 'unknown' + } + } + if (/auth|login|sign.?in|credential/i.test(stderr)) { + return { + message: 'Antigravity usage is unavailable because the agy CLI is not authenticated.', + status: 'unavailable', + failureKind: 'usage-unavailable' + } + } + return { + message: 'Antigravity usage could not be read from the agy CLI.', + status: 'error', + failureKind: 'unknown' + } +} + +export function getAntigravityUsageCommand(): string { + return resolveCliCommand('agy') +} + +export async function fetchAntigravityRateLimits( + signal?: AbortSignal +): Promise { + const now = Date.now() + const command = getAntigravityUsageCommand() + if (!path.isAbsolute(command)) { + return emptyAntigravityResult( + 'unavailable', + 'Antigravity usage is unavailable because the agy CLI was not found.', + now, + 'cli-unavailable' + ) + } + try { + const { stdout } = await execFileCaptureToTermination(command, AGY_USAGE_ARGS, { + encoding: 'utf8', + timeout: AGY_USAGE_TIMEOUT_MS, + maxBuffer: AGY_USAGE_MAX_BUFFER, + signal, + createTimeoutError: createAgyTimeoutError + }) + try { + return parseAgyUsageResponse(JSON.parse(String(stdout)), now) + } catch { + return emptyAntigravityResult( + 'error', + 'Antigravity usage returned malformed JSON from the agy CLI.', + now, + 'parse' + ) + } + } catch (error) { + if (signal?.aborted || (isRecord(error) && error.name === 'AbortError')) { + throw error + } + const failure = classifyAgyFailure(error) + return emptyAntigravityResult(failure.status, failure.message, now, failure.failureKind) + } +} + +export { AGY_USAGE_ARGS, parseAgyUsageResponse } diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts deleted file mode 100644 index 84c92e1ad22f..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' - -function geminiSnapshot( - status: ProviderRateLimitStatus, - error: string | null, - usedPercent: number | null = null -): ProviderRateLimits { - return { - provider: 'gemini', - session: - usedPercent === null - ? null - : { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1_700_000_000_000, - error, - status - } -} - -describe('deriveAntigravityRateLimits', () => { - it('mirrors a successful Gemini read as shared Code Assist quota', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42)) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('ok') - expect(antigravity.session?.usedPercent).toBe(42) - expect(antigravity.error).toBeNull() - }) - - it('reports unavailable without quoting the Gemini failure', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('error', 'Gemini project ID not found') - ) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini project ID not found') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.session).toBeNull() - expect(antigravity.weekly).toBeNull() - }) - - it('does not blame a missing sign-in when the quota read itself failed', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - // Why: the reported symptom is a connected sign-in whose Code Assist read failed. - expect(antigravity.error).toContain('could not be read right now') - expect(antigravity.error).not.toContain('sign-in is connected') - }) - - it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - expect(antigravity.updatedAt).toBe(1_700_000_000_000) - }) - - it('points at the missing sign-in when the Gemini opt-in is off', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings') - ) - - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.error).toContain('Gemini CLI sign-in is connected') - }) -}) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts deleted file mode 100644 index 3cea6decc111..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' - -// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini -// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read -// describes shared Google Code Assist quota; republishing a Gemini failure under the -// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. -const ANTIGRAVITY_NO_SIGN_IN_REASON = - 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' -// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. -const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = - 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' - -export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { - if (gemini.status === 'ok') { - return { ...gemini, provider: 'antigravity' } - } - return { - provider: 'antigravity', - session: null, - weekly: null, - // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. - updatedAt: gemini.updatedAt, - error: - gemini.status === 'unavailable' - ? ANTIGRAVITY_NO_SIGN_IN_REASON - : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, - status: 'unavailable' - } -} diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index e0975af0db07..7c49c85652e6 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -3,6 +3,7 @@ import { RateLimitService } from './service' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { errorProvider, okProvider, @@ -23,6 +24,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) @@ -52,6 +57,9 @@ describe('RateLimitService Antigravity usage', () => { resetRateLimitProviderMocks() vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + errorProvider('antigravity', 'agy unavailable') + ) }) it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { @@ -63,7 +71,7 @@ describe('RateLimitService Antigravity usage', () => { await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('unavailable') + expect(state.antigravity?.status).toBe('error') expect(state.antigravity?.error).not.toContain('Gemini project ID not found') expect(state.antigravity?.session).toBeNull() // Why: the real Gemini failure must still surface under its own provider. @@ -71,30 +79,40 @@ describe('RateLimitService Antigravity usage', () => { expect(state.gemini?.error).toBe('Gemini project ID not found') }) - it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => { + it('keeps Gemini and Antigravity results independent', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 8, Date.now()) + ) const service = new RateLimitService() await service.refresh() const state = service.getState() + expect(state.gemini?.session?.usedPercent).toBe(42) expect(state.antigravity?.status).toBe('ok') expect(state.antigravity?.provider).toBe('antigravity') - expect(state.antigravity?.session?.usedPercent).toBe(42) + expect(state.antigravity?.session?.usedPercent).toBe(8) }) it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce( + okProvider('antigravity', 18, Date.now()) + ) const service = new RateLimitService() await service.refresh() vi.mocked(fetchGeminiRateLimits).mockResolvedValue( errorProvider('gemini', 'Token refresh failed') ) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + errorProvider('antigravity', 'agy temporary failure') + ) await service.refresh() - // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. - expect(service.getState().antigravity?.status).toBe('unavailable') - expect(service.getState().antigravity?.session).toBeNull() + // Why: stale-retention must not substitute Gemini numbers for Antigravity. + expect(service.getState().antigravity?.status).toBe('error') + expect(service.getState().antigravity?.session?.usedPercent).toBe(18) }) }) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 7104face38fa..a0815fcab31c 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,4 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' -import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { @@ -30,6 +29,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ claudeResult, codexResult, geminiResult, + antigravityResult, opencodeGoResult, kimiResult, miniMaxResult @@ -79,8 +79,21 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value + : ({ + provider: 'antigravity', + session: null, + weekly: null, + buckets: [], + updatedAt: Date.now(), + error: + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Antigravity usage could not be read.', + status: 'error' + } satisfies ProviderRateLimits) const opencodeGo = opencodeGoResult.status === 'fulfilled' diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 7551652bca1d..4b625e84576f 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -1,5 +1,6 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' @@ -36,6 +37,7 @@ export type FetchAllCyclePrepared = { PromiseSettledResult, PromiseSettledResult, PromiseSettledResult, + PromiseSettledResult, PromiseSettledResult ] grokResultPromise: Promise< @@ -138,42 +140,50 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const claudeFetchGated = !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels, - endpointMode: miniMaxEndpoint, - apiKey: miniMaxApiKey - }) - ]) + const [ + claudeResult, + codexResult, + geminiResult, + antigravityResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ] = await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchAntigravityRateLimits(signal), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels, + endpointMode: miniMaxEndpoint, + apiKey: miniMaxApiKey + }) + ]) if (signal.aborted) { return null @@ -198,6 +208,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ claudeResult, codexResult, geminiResult, + antigravityResult, opencodeGoResult, kimiResult, miniMaxResult diff --git a/src/main/runtime/orca-runtime-browser-client-hosted.test.ts b/src/main/runtime/orca-runtime-browser-client-hosted.test.ts index e0a901a0f96e..f9f69834463e 100644 --- a/src/main/runtime/orca-runtime-browser-client-hosted.test.ts +++ b/src/main/runtime/orca-runtime-browser-client-hosted.test.ts @@ -312,9 +312,7 @@ describe('RuntimeBrowserCommands client-hosted routing', () => { .spyOn(registry, 'publishClientPage') .mockImplementation((input) => { order.push('publish') - return Reflect.apply(RuntimeBrowserPageRegistry.prototype.publishClientPage, registry, [ - input - ]) + return RuntimeBrowserPageRegistry.prototype.publishClientPage.call(registry, input) }) const notifyHeadlessBrowserSessionTabsChanged = vi.fn(() => order.push('notify')) const issueClientPageCommand = vi.fn(() => { diff --git a/src/main/runtime/runtime-linear-command-surface.ts b/src/main/runtime/runtime-linear-command-surface.ts index 63b053f45df1..0f2347682038 100644 --- a/src/main/runtime/runtime-linear-command-surface.ts +++ b/src/main/runtime/runtime-linear-command-surface.ts @@ -66,7 +66,7 @@ export function installRuntimeLinearCommandSurface(target: object): void { const method = { [name](this: LinearFacadeInstance, ...args: unknown[]): unknown { const commands = this.linearCommands as unknown as LinearMethodBag - return Reflect.apply(commands[name], overrideAwareReceiver(this, commands, names), args) + return commands[name].call(overrideAwareReceiver(this, commands, names), ...args) } }[name] delegators.add(method) diff --git a/src/main/runtime/runtime-search-line-fragments.test.ts b/src/main/runtime/runtime-search-line-fragments.test.ts index 17c873efdd88..701abd1327e0 100644 --- a/src/main/runtime/runtime-search-line-fragments.test.ts +++ b/src/main/runtime/runtime-search-line-fragments.test.ts @@ -90,7 +90,9 @@ describe('RuntimeFileCommands', () => { submatches: [{ start: 0, end: 6 }] } }) - const originalSplit = String.prototype.split + // Method-shaped type: a call-signature capture would reject `split`'s splitter-object overload. + const originalSplit: { split(separator: unknown, limit?: number): string[] }['split'] = + String.prototype.split let scanned = 0 const spy = vi.spyOn(String.prototype, 'split').mockImplementation(function ( this: string, @@ -100,7 +102,7 @@ describe('RuntimeFileCommands', () => { if (separator === '\n') { scanned += this.length } - return Reflect.apply(originalSplit, this, [separator, limit]) + return originalSplit.call(this, separator, limit) }) try { for (let offset = 0; offset < line.length; offset += 1024) { diff --git a/src/main/runtime/runtime-server-environment-commands.ts b/src/main/runtime/runtime-server-environment-commands.ts index 54f08d29f0c8..7671481b1e59 100644 --- a/src/main/runtime/runtime-server-environment-commands.ts +++ b/src/main/runtime/runtime-server-environment-commands.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { isAbsolute, resolve } from 'node:path' import type { DirEntry, FilesystemPathFlavor } from '../../shared/filesystem-entry-types' import { sortDirEntries } from '../../shared/file-name-sort' +import { probeGitAvailability } from '../git/git-availability' import { gitExecFileAsync } from '../git/runner' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' @@ -54,11 +55,6 @@ export class RuntimeServerEnvironmentCommands { } async isGitAvailable(): Promise { - try { - await gitExecFileAsync(['--version'], { cwd: process.cwd(), timeout: 3000 }) - return true - } catch { - return false - } + return probeGitAvailability(gitExecFileAsync, { cwd: process.cwd(), timeout: 3000 }) } } diff --git a/src/main/runtime/runtime-server-git-availability.test.ts b/src/main/runtime/runtime-server-git-availability.test.ts new file mode 100644 index 000000000000..2259e1a563ac --- /dev/null +++ b/src/main/runtime/runtime-server-git-availability.test.ts @@ -0,0 +1,56 @@ +/** + * `repo.gitAvailable` gates the create dialog's Git option on a runtime/remote host. Only a spawn + * that never started may answer `false`; everything else rejects so the renderer's existing + * `unknown` branch stays reachable instead of collapsing to a false "no Git here". + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { RuntimeServerEnvironmentCommands } from './runtime-server-environment-commands' + +function spawnEnoent(): Error { + return Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git' }) +} + +describe('RuntimeServerEnvironmentCommands.isGitAvailable', () => { + const commands = new RuntimeServerEnvironmentCommands() + + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('answers true when git reports its version', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git version 2.25.1\n', stderr: '' }) + await expect(commands.isGitAvailable()).resolves.toBe(true) + }) + + it('answers false only when the spawn itself found no binary', async () => { + gitExecFileAsyncMock.mockRejectedValue(spawnEnoent()) + await expect(commands.isGitAvailable()).resolves.toBe(false) + }) + + it('rejects an ENOENT when the working directory disappeared', async () => { + vi.spyOn(process, 'cwd').mockReturnValue(`${process.cwd()}-missing`) + gitExecFileAsyncMock.mockRejectedValue(spawnEnoent()) + await expect(commands.isGitAvailable()).rejects.toThrow('spawn git ENOENT') + }) + + it('rejects a slow host rather than reporting no Git', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('git --version timed out after 3000ms')) + await expect(commands.isGitAvailable()).rejects.toThrow('timed out') + }) + + it('rejects a repository-level git failure rather than reporting no Git', async () => { + gitExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('detected dubious ownership'), { code: 128 }) + ) + await expect(commands.isGitAvailable()).rejects.toThrow('dubious ownership') + }) +}) diff --git a/src/relay/fs-search-line-fragments.test.ts b/src/relay/fs-search-line-fragments.test.ts index 53b8902ddfd3..74d17c428fec 100644 --- a/src/relay/fs-search-line-fragments.test.ts +++ b/src/relay/fs-search-line-fragments.test.ts @@ -83,7 +83,9 @@ describe.each(searchCases)('relay $name line fragments', ({ search, encode }) => for (let offset = 0; offset < wire.length; offset += 4096) { chunks.push(wire.slice(offset, offset + 4096)) } - const originalSplit = String.prototype.split + // Method-shaped type: a call-signature capture would reject `split`'s splitter-object overload. + const originalSplit: { split(separator: unknown, limit?: number): string[] }['split'] = + String.prototype.split let scannedCharacters = 0 const spy = vi.spyOn(String.prototype, 'split').mockImplementation(function ( this: string, @@ -93,7 +95,7 @@ describe.each(searchCases)('relay $name line fragments', ({ search, encode }) => if (separator === '\n') { scannedCharacters += this.length } - return Reflect.apply(originalSplit, this, [separator, limit]) + return originalSplit.call(this, separator, limit) }) let fragmented try { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts index 8bfb9fc28d4e..3025aecad774 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts @@ -43,8 +43,8 @@ function layoutWithNumericMapSetCount(worktrees: ReturnType) if (typeof key === 'number') { numericMapSets += 1 } - return Reflect.apply(set, this, [key, value]) - } as typeof Map.prototype.set + return set.call(this, key, value) + } try { return { layout: layoutAgentMapWorktreeLineage(worktrees), numericMapSets } } finally { @@ -64,7 +64,7 @@ function layoutWithWorktreePushCount(count: number) { typeof item.id === 'string' && item.id.startsWith('worktree-') ).length - return Reflect.apply(push, this, items) + return push.call(this, ...items) } try { return { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts index a07490dab928..d074c30b2adb 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts @@ -145,8 +145,8 @@ describe('packAgentMapWorktrees', () => { if (typeof key === 'number') { numericMapSets += 1 } - return Reflect.apply(set, this, [key, value]) - } as typeof Map.prototype.set + return set.call(this, key, value) + } try { const packed = packAgentMapWorktrees( Array.from({ length: 5 }, (_, index) => ({ diff --git a/src/renderer/src/components/editor/diff-section-layout.test.ts b/src/renderer/src/components/editor/diff-section-layout.test.ts index c1b100a2f33a..16a8783ff494 100644 --- a/src/renderer/src/components/editor/diff-section-layout.test.ts +++ b/src/renderer/src/components/editor/diff-section-layout.test.ts @@ -110,8 +110,10 @@ describe('diff section layout', () => { }) it('estimates line-count height without allocating split arrays', () => { - const originalSplit = String.prototype.split - const patchedSplit = function patchedSplit( + // Method-shaped type: a call-signature capture would reject `split`'s splitter-object overload. + const originalSplit: { split(separator: unknown, limit?: number): string[] }['split'] = + String.prototype.split + const patchedSplit: typeof String.prototype.split = function patchedSplit( this: string, separator?: unknown, limit?: number @@ -119,9 +121,8 @@ describe('diff section layout', () => { if (String(this).startsWith('line 0')) { throw new Error('layout should not split full diff content') } - const args = limit === undefined ? [separator] : [separator, limit] - return Reflect.apply(originalSplit, this, args) as string[] - } as typeof String.prototype.split + return originalSplit.call(this, separator, limit) + } String.prototype.split = patchedSplit try { diff --git a/src/renderer/src/components/editor/tiptap-marked-facade.ts b/src/renderer/src/components/editor/tiptap-marked-facade.ts index e34f67555664..823217be6ef5 100644 --- a/src/renderer/src/components/editor/tiptap-marked-facade.ts +++ b/src/renderer/src/components/editor/tiptap-marked-facade.ts @@ -32,7 +32,8 @@ export function createTiptapMarkedFacade(): typeof marked { const lexer = (src: string, options?: MarkedOptions): TokensList => new RegistryLexer(options).lex(src) const facade = new Proxy(marked, { - apply: (_target, _thisArg, args) => Reflect.apply(registry.parse, registry, args), + apply: (_target, _thisArg, args: [src: string, options?: MarkedOptions | null]) => + registry.parse(...args), get: (target, property, receiver) => { switch (property) { case 'defaults': diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx new file mode 100644 index 000000000000..4e3ae3ccbbc9 --- /dev/null +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: ({ agent }: { agent: string }) => +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (result, [key, value]) => result.replace(`{{${key}}}`, value), + fallback + ) +})) + +import { ProviderSegment } from './StatusBarProviderSegment' + +const antigravity: ProviderRateLimits = { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 1, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: 1, + error: null, + status: 'ok' +} + +describe('Antigravity status summary', () => { + it.each([ + ['used', ['0% used', '1% used']], + ['remaining', ['100% left', '99% left']] + ] as const)('uses native concise labels in %s mode', (display, expected) => { + const markup = renderToStaticMarkup( + + ) + + for (const value of expected) { + expect(markup).toContain(value) + } + expect(markup).toContain('G') + expect(markup).toContain('C/G') + expect(markup).not.toContain('5h') + expect(markup).not.toContain('wk') + expect(markup).not.toContain('Limit Remaining') + }) + + it('shows reset durations after detailed menu-bar percentages', () => { + const markup = renderToStaticMarkup( + ({ + ...bucket, + resetsAt: + Date.now() + (bucket.windowMinutes === 300 ? 5 * 60 * 60_000 : 4 * 24 * 60 * 60_000) + })) + }} + compact={false} + display="used" + mode="verbose" + /> + ) + + expect(markup).toMatch(/0% used \d+h/) + expect(markup).toMatch(/1% used \d+d/) + expect(markup).not.toContain('Session') + expect(markup).not.toContain('Weekly') + }) + + it('selects the tightest window independently for each compact group', () => { + const now = Date.now() + const markup = renderToStaticMarkup( + + ) + + expect(markup).toMatch(/G.*80% used.*(?:1h|2h)/) + expect(markup).toMatch(/C\/G.*60% used.*(?:5d|6d)/) + expect(markup.indexOf('80% used')).toBeLessThan(markup.search(/(?:1h|2h)/)) + expect(markup).not.toContain('wk') + expect(markup).not.toContain('5h') + }) + + it('keeps the selected window in Used and Remaining modes', () => { + const p = { + ...antigravity, + buckets: antigravity.buckets!.map((bucket) => ({ + ...bucket, + usedPercent: bucket.windowMinutes === 300 ? 80 : 20, + resetsAt: + Date.now() + (bucket.windowMinutes === 300 ? 2 * 60 * 60_000 : 5 * 24 * 60 * 60_000) + })) + } + const used = renderToStaticMarkup( + + ) + const remaining = renderToStaticMarkup( + + ) + expect(used).toContain('80% used') + expect(remaining).toContain('20% left') + expect(used).toMatch(/80% used.*(?:1h|2h)/) + expect(remaining).toMatch(/20% left.*(?:1h|2h)/) + }) +}) diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 24de1094534f..bbaf2eede321 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -6,11 +6,15 @@ import { type UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { formatResetDuration } from '../../../../shared/rate-limit-reset-format' +import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' import { ProviderIcon, clampUsedPercent, getProviderUsageStatusLabel } from './tooltip' import { getTightestUsageSection } from './UsageRosterPanel' +import { getTightestUsageSectionFromSections, type UsageSection } from './usage-section-selection' import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter' import { formatUsagePercentageLabel } from './usage-percentage-label' import { translate } from '@/i18n/i18n' +import { getAntigravityGroupShortLabel, sortAntigravityBuckets } from './antigravity-usage-format' function MiniBar({ usedPct, @@ -51,6 +55,58 @@ function WindowLabel({ ) } +function AntigravityCompactSummary({ + sections, + display, + now +}: { + sections: UsageSection[] + display: UsagePercentageDisplay + now: number +}): React.JSX.Element { + return ( + <> + {sections.map((section, index) => { + const reset = section.window.resetsAt + ? formatResetDuration(section.window.resetsAt - now) + : null + return ( + + {index > 0 ? | : null} + + + {getAntigravityGroupShortLabel(section.groupName)} + + + {formatUsagePercentageLabel(section.window.usedPercent, display)} + {reset ? ` ${reset}` : ''} + + + + ) + })} + + ) +} + +function getAntigravityCompactSections(p: ProviderRateLimits): UsageSection[] { + const groups = new Map() + for (const bucket of p.buckets ?? []) { + const key = bucket.groupName ?? '' + const sections = groups.get(key) ?? [] + sections.push({ label: bucket.name, window: bucket, groupName: bucket.groupName }) + groups.set(key, sections) + } + return [...groups.values()] + .map((sections) => + sortAntigravityBuckets(sections.map((section) => section.window)).map((window) => + sections.find((section) => section.window === window)! + ) + ) + .map((sections) => getTightestUsageSectionFromSections(sections)) + .filter((section): section is UsageSection => section !== null) +} + // Single-letter provider badge for the icon-only (narrow) status bar. Shared by // the roster trigger and ProviderDetailsMenu so the dot's has-data condition // and markup can't drift between the two. @@ -94,15 +150,64 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { // Why: Gemini exposes extra experimental buckets that made the pre-existing verbose footer noisy. const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) +function AntigravitySummary({ + p, + display, + now +}: { + p: ProviderRateLimits + display: UsagePercentageDisplay + now: number +}): React.JSX.Element { + const groups = new Map>() + for (const bucket of p.buckets ?? []) { + const key = bucket.groupName ?? '' + const current = groups.get(key) ?? [] + current.push(bucket) + groups.set(key, current) + } + return ( + <> + {[...groups.entries()].map(([groupName, buckets], groupIndex) => ( + + {groupIndex > 0 ? | : null} + + + {getAntigravityGroupShortLabel(groupName)} + + {sortAntigravityBuckets(buckets).map((bucket, index) => ( + + {index > 0 ? · : null} + + {formatUsagePercentageLabel(bucket.usedPercent, display)} + {bucket.resetsAt ? ` ${formatResetDuration(bucket.resetsAt - now)}` : ''} + + + ))} + + + ))} + + ) +} + function VerboseProviderUsage({ p, - display + display, + now }: { p: ProviderRateLimits display: UsagePercentageDisplay + now: number }): React.JSX.Element { + if (p.provider === 'antigravity' && p.buckets?.length) { + return + } if (p.buckets && p.buckets.length > 0) { - const visibleBuckets = p.buckets.filter((bucket) => STATUS_BAR_BUCKET_NAMES.has(bucket.name)) + const visibleBuckets = + p.provider === 'antigravity' + ? p.buckets + : p.buckets.filter((bucket) => STATUS_BAR_BUCKET_NAMES.has(bucket.name)) return ( <> {visibleBuckets.map((bucket, index) => ( @@ -183,6 +288,7 @@ export function ProviderSegment({ }): React.JSX.Element { const provider = p?.provider ?? 'claude' const statusLabel = p ? getProviderUsageStatusLabel(p) : '' + const now = useResetCountdownClock((p?.buckets ?? []).map((bucket) => bucket.resetsAt)) // Idle / initial load if (!p || p.status === 'idle') { @@ -237,8 +343,14 @@ export function ProviderSegment({ {tightest && !compact ? ( ) : null} - + + ) : p.provider === 'antigravity' ? ( + ) : tightest ? ( ({ })) vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (result, [key, value]) => result.replace(`{{${key}}}`, value), + fallback + ) })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: ({ agent }: { agent: string }) => @@ -87,6 +91,94 @@ describe('UsageRow', () => { expect(markup).not.toContain('width:25%') }) + it.each([ + ['5h', 70, 20, '2h'], + ['weekly', 20, 70, '5d'] + ] as const)( + 'compact Antigravity chooses the %s window by consumption', + (expectedWindow, fiveHour, weekly, reset) => { + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain(`${Math.max(fiveHour, weekly)}%`) + expect(markup).toContain(reset) + expect(markup).not.toContain(`${expectedWindow === '5h' ? weekly : fiveHour}%`) + expect(markup).not.toContain(' used') + } + ) + + it('uses session-first Codex tie semantics for compact Antigravity', () => { + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain('2h') + expect(markup).not.toContain('5d') + }) + it('uses one shared clock for live reset labels across the roster', () => { const sessionReset = mocks.now + 2 * 60_000 const weeklyReset = mocks.now + 7 * 24 * 60 * 60_000 @@ -247,6 +339,182 @@ describe('UsageRow', () => { expect(markup).toContain('25%') expect(markup).toContain('60%') }) + + it('renders Antigravity windows with native labels and selected semantics', () => { + const provider: ProviderRateLimits = { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 1, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: mocks.now, + error: null, + status: 'ok' + } + + const usedMarkup = renderToStaticMarkup( + + ) + expect(usedMarkup).toContain('5h') + expect(usedMarkup).toContain('wk') + expect(usedMarkup).toContain('0%') + expect(usedMarkup).toContain('1%') + expect(usedMarkup).not.toContain('0% used') + expect(usedMarkup).not.toContain('1% used') + expect(usedMarkup).not.toContain('Limit Remaining') + + const remainingMarkup = renderToStaticMarkup( + + ) + expect(remainingMarkup).toContain('100%') + expect(remainingMarkup).toContain('99%') + expect(remainingMarkup).not.toContain('100% left') + expect(remainingMarkup).not.toContain('99% left') + + const compactMarkup = renderToStaticMarkup( + ({ + ...bucket, + resetsAt: mocks.now + 2 * 60 * 60_000 + })), + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + ] + }} + display="used" + mode="compact" + state={{ kind: 'usage', statusLabel: null }} + showSignInAction={false} + now={mocks.now} + /> + ) + expect(compactMarkup).toContain('G') + expect(compactMarkup).toContain('C/G') + expect(compactMarkup).not.toContain('5h') + expect(compactMarkup).not.toContain('wk') + expect(compactMarkup).toContain('1%') + expect(compactMarkup).not.toContain('1% used') + expect(compactMarkup.indexOf('1%')).toBeLessThan(compactMarkup.indexOf('2h')) + + const remainingCompactMarkup = renderToStaticMarkup( + ({ + ...bucket, + groupName: bucket.groupName ?? 'Gemini Models' + })) + }} + display="remaining" + mode="compact" + state={{ kind: 'usage', statusLabel: null }} + showSignInAction={false} + now={mocks.now} + /> + ) + expect(remainingCompactMarkup).toContain('99%') + expect(remainingCompactMarkup).not.toContain('99% left') + expect(remainingCompactMarkup).not.toContain('5h') + expect(remainingCompactMarkup).not.toContain('wk') + }) + + it('renders detailed Antigravity windows with unknown windows after known windows', () => { + const markup = renderToStaticMarkup( + + ) + expect(markup.indexOf('5h')).toBeLessThan(markup.indexOf('wk')) + expect(markup.indexOf('wk')).toBeLessThan(markup.indexOf('daily')) + }) }) describe('UsageRosterPanel density picker', () => { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index feb066fadca4..8adc3612c247 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -5,7 +5,7 @@ import { SettingsSegmentedControl } from '@/components/settings/SettingsFormCont import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' import { translate } from '@/i18n/i18n' import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter' -import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' import { clampUsedPercent, getDisplayedUsagePercentage, @@ -14,11 +14,13 @@ import { import { barColor, formatResetCountdown, getWindowSections, ProviderIcon } from './tooltip' import { getProviderDisplayName } from './usage-error-copy' import { formatPlanLabel, usageTextColorClass } from './usage-roster-formatting' +import { AntigravityCompactMetrics } from './antigravity-compact-metrics' +import { sortAntigravityBuckets } from './antigravity-usage-format' import { getUsageRosterRowState, type UsageRosterRowState } from './usage-roster-row-state' import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { getTightestUsageSectionFromSections, type UsageSection } from './usage-section-selection' type ProviderId = ProviderRateLimits['provider'] -export type UsageSection = { label: string; window: RateLimitWindow } // Windows/buckets that actually carry data — absent limits arrive as null, but a // partial/rehydrated provider can also carry an undefined window; both must be @@ -41,6 +43,13 @@ function shortLabel( section: UsageSection, useRemainingDuration = false ): string { + if (p.provider === 'antigravity') { + return section.window.windowMinutes === 300 + ? '5h' + : section.window.windowMinutes === 10080 + ? 'wk' + : (section.window.windowLabel ?? formatWindowLabel(section.window.windowMinutes)) + } if (p.buckets?.some((b) => b.name === section.label)) { return section.label } @@ -56,17 +65,8 @@ function shortLabel( export function getTightestUsageSection(p: ProviderRateLimits): UsageSection | null { const sections = usedSections(p) - if (sections.length === 0) { - return null - } - // Why: the footer promises one quiet summary per provider; choose urgency by - // consumption even when the user displays the complementary “% left” value. - const tightest = sections.reduce((current, candidate) => - clampUsedPercent(candidate.window.usedPercent) > clampUsedPercent(current.window.usedPercent) - ? candidate - : current - ) - return { ...tightest, label: shortLabel(p, tightest, true) } + const tightest = getTightestUsageSectionFromSections(sections) + return tightest ? { ...tightest, label: shortLabel(p, tightest, true) } : null } // The soonest-resetting window summarizes the agent's next reset in one line. @@ -153,6 +153,8 @@ export function UsageRow({ ) : null} + ) : p.provider === 'antigravity' && hasUsage && mode === 'compact' ? ( + ) : tightest ? ( {hasUsage && mode === 'verbose' ? ( -
- {sections.map((section) => ( - +
+ {Array.from( + sections.reduce((groups, section) => { + const key = section.groupName ?? '' + const current = groups.get(key) ?? [] + current.push(section) + groups.set(key, current) + return groups + }, new Map()) + ).map(([groupName, groupSections]) => ( +
+ {groupName ? ( +
{groupName}
+ ) : null} +
+ {(p.provider === 'antigravity' + ? sortAntigravityBuckets( + groupSections.map((section) => ({ + name: section.label, + windowMinutes: section.window.windowMinutes, + section + })) + ).map(({ section }) => section) + : groupSections + .slice() + .sort((a, b) => a.window.windowMinutes - b.window.windowMinutes) + ).map((section) => ( + + ))} +
+
))}
) : null} diff --git a/src/renderer/src/components/status-bar/antigravity-compact-metrics.tsx b/src/renderer/src/components/status-bar/antigravity-compact-metrics.tsx new file mode 100644 index 000000000000..8ca3c3aef4f2 --- /dev/null +++ b/src/renderer/src/components/status-bar/antigravity-compact-metrics.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { formatResetDuration } from '../../../../shared/rate-limit-reset-format' +import { + clampUsedPercent, + getDisplayedUsagePercentage, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' +import { getAntigravityGroupShortLabel, sortAntigravityBuckets } from './antigravity-usage-format' +import { getTightestUsageSectionFromSections, type UsageSection } from './usage-section-selection' +import { usageTextColorClass } from './usage-roster-formatting' + +export function AntigravityCompactMetrics({ + sections, + display, + now +}: { + sections: UsageSection[] + display: UsagePercentageDisplay + now: number +}): React.JSX.Element { + const groups = new Map() + for (const section of sections) { + const key = section.groupName ?? '' + groups.set(key, [...(groups.get(key) ?? []), section]) + } + return ( + + {[...groups.entries()].map(([groupName, groupSections], groupIndex) => { + const orderedSections = sortAntigravityBuckets( + groupSections.map((section) => section.window) + ).map((window) => groupSections.find((section) => section.window === window)!) + const tightest = getTightestUsageSectionFromSections(orderedSections) + if (!tightest) { + return null + } + const reset = tightest.window.resetsAt + ? formatResetDuration(tightest.window.resetsAt - now) + : null + const used = clampUsedPercent(tightest.window.usedPercent) + const shown = getDisplayedUsagePercentage(tightest.window.usedPercent, display) + return ( + + {groupIndex > 0 ? | : null} + + + {getAntigravityGroupShortLabel(groupName)} + + + + {shown}% + + + {reset ? ( + {reset} + ) : null} + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/status-bar/antigravity-usage-format.ts b/src/renderer/src/components/status-bar/antigravity-usage-format.ts new file mode 100644 index 000000000000..78e2fe38ad0b --- /dev/null +++ b/src/renderer/src/components/status-bar/antigravity-usage-format.ts @@ -0,0 +1,32 @@ +import type { RateLimitWindow } from '../../../../shared/rate-limit-types' + +export function getAntigravityGroupShortLabel(groupName?: string): string { + if (!groupName) { + return 'Other' + } + if (/^gemini/i.test(groupName)) { + return 'G' + } + if (/claude|gpt/i.test(groupName)) { + return 'C/G' + } + return groupName + .split(/\s+/) + .map((part) => part[0]) + .join('') + .slice(0, 3) + .toUpperCase() +} + +export function sortAntigravityBuckets< + T extends Pick & { name?: string } +>(buckets: T[]): T[] { + return buckets.slice().sort((a, b) => { + const aKnown = a.windowMinutes > 0 + const bKnown = b.windowMinutes > 0 + if (aKnown !== bKnown) { + return aKnown ? -1 : 1 + } + return a.windowMinutes - b.windowMinutes || (a.name ?? '').localeCompare(b.name ?? '') + }) +} diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index ab2a0eca23f1..b82db6d7e4c3 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -123,11 +123,8 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) - // Why: antigravity durability requires the Gemini OAuth opt-in; the - // checked item alone must not suppress the usage setup CTA. - expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( - false - ) + // Antigravity uses its own CLI-backed usage source. + expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxApiKeyConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) @@ -163,21 +160,19 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { + it('requires a checked Antigravity item as the durable Antigravity signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) ) ).toBe(true) - // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it - // is permanently unavailable, so the checked item alone is not durable. expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(false) + ).toBe(true) expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -359,16 +354,14 @@ describe('getVisibleUsageProvider', () => { }) }) - it('hides Antigravity while Gemini OAuth is off even when its status item is checked', () => { - // Why: without the OAuth opt-in the mirrored snapshot is permanently - // 'unavailable'; the default-on item must not pin a dead bar. + it('keeps Antigravity visible while Gemini OAuth is off when its status item is checked', () => { expect( getVisibleUsageProvider( 'antigravity', null, usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(null) + ).toMatchObject({ provider: 'antigravity', status: 'fetching' }) expect( getVisibleUsageProvider( 'antigravity', @@ -378,7 +371,7 @@ describe('getVisibleUsageProvider', () => { }), usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(null) + ).toMatchObject({ provider: 'antigravity', status: 'unavailable' }) }) }) @@ -492,9 +485,7 @@ describe('isUsageEmptyState', () => { ).toBe(false) }) - it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { - // Why: the default-on Antigravity item is not configured usage on its own; - // it must not hide the teaching CTA from users who set nothing up. + it('does not show the setup CTA when Antigravity is checked and independently configured', () => { expect( isUsageEmptyState( { @@ -509,6 +500,6 @@ describe('isUsageEmptyState', () => { }, usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(true) + ).toBe(false) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 19258592f1fd..26bac50b387c 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -75,8 +75,8 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. + // Antigravity has its own CLI-backed usage source. + settings?.antigravityUsageConfigured === true || settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true @@ -103,10 +103,7 @@ export function hasUsageProviderSettingsForProvider( return Boolean(settings.opencodeSessionCookie?.trim()) } if (providerId === 'antigravity') { - // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays - // 'unavailable' until the user opts into Gemini CLI OAuth. Without that - // gate the default-on checked item would pin a permanently dead bar. - return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + return settings.antigravityUsageConfigured === true } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true || settings.minimaxApiKeyConfigured === true diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index fd7561e250cd..abab43fcb2d0 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -327,6 +327,43 @@ describe('getWindowSections', () => { ]) }) + it('preserves Antigravity bucket group metadata for grouped rendering', () => { + const p: ProviderRateLimits = { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Gemini Models', + groupDescription: 'Gemini quota', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Claude and GPT models', + groupDescription: null, + usedPercent: 0, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + expect(getWindowSections(p).slice(0, 2)).toMatchObject([ + { label: 'Session', groupName: 'Gemini Models' }, + { label: 'Weekly', groupName: 'Claude and GPT models' } + ]) + }) + it('returns session and weekly when buckets are absent', () => { const p: ProviderRateLimits = { provider: 'claude', @@ -422,6 +459,32 @@ describe('getWindowSections', () => { expect(labels).not.toContain('Session') }) + it('keeps unknown Antigravity source window labels instead of rendering 0m', () => { + const p: ProviderRateLimits = { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + id: 'daily', + name: 'Daily', + groupName: 'New pool', + usedPercent: 50, + windowMinutes: 0, + windowLabel: 'daily', + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const sections = getWindowSections(p) + expect(sections[0]).toMatchObject({ label: 'daily' }) + expect(sections[0]?.label).not.toBe('0m') + }) + it('preserves reset metadata inside bucket windows', () => { const p: ProviderRateLimits = { provider: 'gemini', @@ -449,6 +512,101 @@ describe('getWindowSections', () => { }) describe('ProviderPanel reset rendering', () => { + it('keeps non-Antigravity window ordering unchanged', () => { + const markup = renderToStaticMarkup( + createElement(ProviderPanel, { + p: provider({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 10, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 20, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + } + }) + }) + ) + expect(markup.indexOf('Session')).toBeLessThan(markup.indexOf('Weekly')) + }) + + it('groups Antigravity windows and uses native labels in both display modes', () => { + const p = provider({ + provider: 'antigravity', + status: 'ok', + buckets: [ + { + id: 'gemini-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 1, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-5h', + name: 'Five Hour Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + { + id: '3p-weekly', + name: 'Weekly Limit Remaining', + groupName: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + { + id: 'gemini-daily', + name: 'Daily Limit Remaining', + groupName: 'Gemini Models', + usedPercent: 2, + windowMinutes: 0, + windowLabel: 'daily', + resetsAt: null, + resetDescription: null + } + ] + }) + + const usedMarkup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + expect(usedMarkup).toContain('Gemini Models') + expect(usedMarkup).toContain('Claude and GPT models') + expect(usedMarkup).toContain('Session') + expect(usedMarkup).toContain('Weekly') + expect(usedMarkup).toContain('1% used') + expect(usedMarkup).not.toContain('Limit Remaining') + expect(usedMarkup.indexOf('Session')).toBeLessThan(usedMarkup.indexOf('Weekly')) + expect(usedMarkup.indexOf('Weekly')).toBeLessThan(usedMarkup.indexOf('daily')) + + const remainingMarkup = renderToStaticMarkup( + createElement(ProviderPanel, { p, usagePercentageDisplay: 'remaining' }) + ) + expect(remainingMarkup).toContain('100% left') + expect(remainingMarkup).toContain('99% left') + }) + it('renders the Fable reset countdown when Claude reports a reset timestamp', () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 2a82f7318dd9..51b398279144 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -18,6 +18,8 @@ import { } from '../../../../shared/usage-percentage-display' import { formatUsagePercentageLabel } from './usage-percentage-label' import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' +import { formatWindowLabel } from '@/lib/window-label-formatter' +import { sortAntigravityBuckets } from './antigravity-usage-format' // Re-exported from its shared home so status-bar callers keep a single import. export { clampUsedPercent } @@ -139,11 +141,26 @@ function ErrorMessage({ // Window section derivation // --------------------------------------------------------------------------- -export function getWindowSections( - p: ProviderRateLimits -): { label: string; window: RateLimitWindow | null }[] { +export function getWindowSections(p: ProviderRateLimits): { + label: string + window: RateLimitWindow | null + groupName?: string + groupDescription?: string | null +}[] { if (p.buckets?.length) { - const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow })) + const bucketSections = p.buckets.map((b) => ({ + label: + p.provider === 'antigravity' + ? b.windowMinutes === 300 + ? translate('auto.components.status.bar.tooltip.94038ad2fa', 'Session') + : b.windowMinutes === 10080 + ? translate('auto.components.status.bar.tooltip.252c096536', 'Weekly') + : b.windowLabel || formatWindowLabel(b.windowMinutes) + : b.name, + window: b, + ...(b.groupName ? { groupName: b.groupName } : {}), + ...(b.groupDescription !== undefined ? { groupDescription: b.groupDescription } : {}) + })) return [ ...bucketSections, { @@ -285,7 +302,14 @@ export function ProviderPanel({ ) } - if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { + if ( + p.status === 'error' && + !p.session && + !p.weekly && + !p.fableWeekly && + !p.monthly && + !p.buckets?.length + ) { return (
@@ -312,6 +336,34 @@ export function ProviderPanel({ resetCreditCount != null ? formatResetCreditExpiry(p.rateLimitResetCredits?.nextExpiresAt, resetCreditCount) : null + const groupedAntigravitySections = + p.provider === 'antigravity' + ? [...new Set(windowSections.map((section) => section.groupName ?? ''))].map((groupName) => ({ + groupName, + sections: sortAntigravityBuckets( + windowSections + .filter((section) => (section.groupName ?? '') === groupName) + .map((section) => ({ + name: section.label, + windowMinutes: section.window?.windowMinutes ?? 0, + section + })) + ).map(({ section }) => section) + })) + : [] + + const renderWindowSection = (s: (typeof windowSections)[number]) => ( + + ) return (
@@ -340,23 +392,19 @@ export function ProviderPanel({
- {windowSections.map((s) => ( - - ))} + {p.provider === 'antigravity' + ? groupedAntigravitySections.map(({ groupName, sections }) => ( +
+ {groupName ?
{groupName}
: null} + {sections.map(renderWindowSection)} +
+ )) + : windowSections.map(renderWindowSection)} {p.error ? ( ) : null} diff --git a/src/renderer/src/components/status-bar/usage-section-selection.ts b/src/renderer/src/components/status-bar/usage-section-selection.ts new file mode 100644 index 000000000000..321f1f21b5e0 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-section-selection.ts @@ -0,0 +1,21 @@ +import type { RateLimitWindow } from '../../../../shared/rate-limit-types' +import { clampUsedPercent } from '../../../../shared/usage-percentage-display' + +export type UsageSection = { + label: string + window: RateLimitWindow + groupName?: string + groupDescription?: string | null +} + +/** Choose the most consumed window, preserving input order for ties. */ +export function getTightestUsageSectionFromSections(sections: UsageSection[]): UsageSection | null { + if (sections.length === 0) { + return null + } + return sections.reduce((current, candidate) => + clampUsedPercent(candidate.window.usedPercent) > clampUsedPercent(current.window.usedPercent) + ? candidate + : current + ) +} diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 33db1976b0da..8fe39d3d6d9a 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -103,7 +103,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. - // Why: Antigravity visibility also requires geminiCliOAuthEnabled because its usage snapshot mirrors the Gemini fetch. + // Why: Antigravity visibility is based on its own detected CLI and snapshot. const antigravityUsageConfigured = statusBarItems.includes('antigravity') && isStatusBarItemAvailable('antigravity', detectedAgentIds) diff --git a/src/renderer/src/components/task-page-mutation-page-allocation.test.ts b/src/renderer/src/components/task-page-mutation-page-allocation.test.ts index 08f18926a42a..8e1f272afaf4 100644 --- a/src/renderer/src/components/task-page-mutation-page-allocation.test.ts +++ b/src/renderer/src/components/task-page-mutation-page-allocation.test.ts @@ -33,7 +33,12 @@ it('avoids allocating copies of unaffected pages during an item mutation', () => if (inputs.has(this)) { allocations++ } - return Reflect.apply(map, this, [callback, thisArg]) as U[] + // Explicit `call` type arguments: inference through `call` erases `map`'s own `U` to `unknown`. + return map.call U, unknown], U[]>( + this, + callback, + thisArg + ) } Array.prototype.slice = function (this: unknown[], ...args: Parameters) { if (inputs.has(this)) { diff --git a/src/shared/git-history-message-allocation.test.ts b/src/shared/git-history-message-allocation.test.ts index cfd8561b0c5c..a3bad7d59b3c 100644 --- a/src/shared/git-history-message-allocation.test.ts +++ b/src/shared/git-history-message-allocation.test.ts @@ -14,14 +14,16 @@ it('keeps a multiline commit body intact without materializing every message lin '', message ].join('\n') - const original = String.prototype.split + // Method-shaped type: a call-signature capture would reject `split`'s splitter-object overload. + const original: { split(separator: unknown, limit?: number): string[] }['split'] = + String.prototype.split let allocatedFields = 0 const spy = vi.spyOn(String.prototype, 'split').mockImplementation(function ( this: string, - separator: string | RegExp | { [Symbol.split](value: string, limit?: number): string[] }, + separator: unknown, limit?: number ) { - const result = Reflect.apply(original, this, [separator, limit]) as string[] + const result = original.call(this, separator, limit) if (separator === '\n' && String(this).includes('body line')) { allocatedFields += result.length } diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 5744e3e67497..c2889ec4e03c 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -7,12 +7,19 @@ export type RateLimitWindow = { resetsAt: number | null /** Human-readable reset description, e.g. "2:30 PM" or "Thu". */ resetDescription: string | null + /** Provider-specific source window name when no standard duration exists. */ + windowLabel?: string } export type ProviderRateLimitStatus = 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' export type RateLimitBucket = RateLimitWindow & { name: string + /** Stable provider/source identifier when available. */ + id?: string + /** Optional provider-defined quota group metadata. */ + groupName?: string + groupDescription?: string | null } export type UsageRateLimitSource = 'oauth' | 'cli' | 'web' | 'live-session' diff --git a/src/shared/workspace-space-compaction.test.ts b/src/shared/workspace-space-compaction.test.ts index bf774645fbb0..7bae49bd73fc 100644 --- a/src/shared/workspace-space-compaction.test.ts +++ b/src/shared/workspace-space-compaction.test.ts @@ -19,7 +19,7 @@ it('sums omitted sizes without constructing a replacement object per omitted ite if (initial && typeof initial === 'object' && 'name' in initial && initial.name === 'Other') { objectAccumulators += this.length } - return Reflect.apply(original, this, [callback, initial]) + return original.call(this, callback, initial) }) let result: ReturnType try {