feat(status-bar): Antigravity usage status (#7996)

* fix(status-bar): keep antigravity usage visible

* feat(rate-limits): add Grok usage status

* feat(grok): add managed usage accounts

* test(rate-limits): isolate Codex PTY fallback fetches

* fix(grok): address CodeRabbit review on usage status PR

- Guard auth.json read/parse so missing files return null
- Thread AbortSignal through Grok rate-limit fetch and ACP auth
- Pass isRemote:false for floating-terminal agent launch env

* fix(grok): wire ACP abort after child listeners to avoid TDZ

* fix(status-bar): address CodeRabbit on combined Grok/Antigravity usage

- Pin WSL shell distro when resolving managed GROK_HOME (parity with Codex)
- Refresh only Grok on account change via fetchGrokOnly
- Consolidate usage status-bar toggle catalogs; add Grok locale keys
- Fix Grok aria-label, feature-interaction tracking, and test fixture

* fix(settings): drop duplicate usage status-bar toggle catalog

Remove the leftover re-export path so Appearance search has a single
source of truth for usage provider toggles (including Antigravity + Grok).

* fix(settings): restore AccountsPane and locale UTF-8 after merge

Re-do AccountsPane three-way merge with binary-safe git objects and keep
both Grok accounts + remote provider client imports. Rebuild locale JSON
merges so em dash/ellipsis/middle-dot strings are no longer mojibake.

* fix(i18n): add missing comma after merged status-bar locale keys

* test: add antigravity to GrokUsagePane rate-limit fixture

RateLimitState gained a required antigravity field; the pane fixture must
carry it to typecheck.

* fix(status-bar): gate Antigravity durable visibility on Gemini OAuth opt-in

The Antigravity snapshot mirrors the Gemini fetch, which is permanently
'unavailable' until the user opts into Gemini CLI OAuth. Without this gate
the default-on checked item plus a detected agy CLI pinned a dead 'A --'
bar (Gemini itself hides in that state) and suppressed the usage setup CTA
for users who configured nothing.

---------

Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
BingZ 2026-07-11 04:11:24 +08:00 committed by GitHub
parent 143c36e566
commit 4209889f60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 399 additions and 22 deletions

View File

@ -79,6 +79,7 @@ type InternalRateLimitState = {
gemini: ProviderRateLimits | null
opencodeGo: ProviderRateLimits | null
kimi: ProviderRateLimits | null
antigravity: ProviderRateLimits | null
minimax: ProviderRateLimits | null
grok: ProviderRateLimits | null
}
@ -113,6 +114,7 @@ export class RateLimitService {
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null
}
@ -1126,7 +1128,7 @@ export class RateLimitService {
private withFetchingStatus(
current: ProviderRateLimits | null,
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok'
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity'
): ProviderRateLimits {
if (!current) {
return {
@ -1200,6 +1202,7 @@ export class RateLimitService {
? this.withFetchingStatus(null, 'opencode-go')
: this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'),
kimi: this.withFetchingStatus(previousState.kimi, 'kimi'),
antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'),
minimax: miniMaxConfigChanged
? this.withFetchingStatus(null, 'minimax')
: this.withFetchingStatus(previousState.minimax, 'minimax'),
@ -1287,6 +1290,14 @@ export class RateLimitService {
status: 'error'
} satisfies ProviderRateLimits)
// Why: Antigravity shares Google/Gemini usage credentials today; mirror the
// Gemini snapshot under provider 'antigravity' so status-bar UI that checks
// antigravity state receives a real fetch lifecycle instead of staying null.
const antigravity: ProviderRateLimits = {
...gemini,
provider: 'antigravity'
}
const opencodeGo =
opencodeGoResult.status === 'fulfilled'
? opencodeGoResult.value
@ -1365,6 +1376,7 @@ export class RateLimitService {
: this.applyStalePolicy(opencodeGo, previousState.opencodeGo)
: this.state.opencodeGo,
kimi: this.applyStalePolicy(kimi, previousState.kimi),
antigravity: this.applyStalePolicy(antigravity, previousState.antigravity),
minimax: shouldApplyMiniMax
? miniMaxConfigChanged
? miniMax

View File

@ -182,11 +182,12 @@ describe('client UI RPC methods', () => {
...getDefaultUIState(),
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'],
statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'antigravity', 'ports'],
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_grokStatusBarDefaultAdded: true,
_antigravityStatusBarDefaultAdded: true,
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open',
@ -222,11 +223,12 @@ describe('client UI RPC methods', () => {
const payload = {
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'],
statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'antigravity', 'ports'],
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_grokStatusBarDefaultAdded: true,
_antigravityStatusBarDefaultAdded: true,
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open',

View File

@ -44,6 +44,7 @@ const StatusBarItem = z.enum([
'claude',
'codex',
'gemini',
'antigravity',
'opencode-go',
'kimi',
'minimax',
@ -204,6 +205,7 @@ const UiUpdate = z
_portsStatusBarDefaultAdded: z.boolean().optional(),
_kimiStatusBarDefaultAdded: z.boolean().optional(),
_minimaxStatusBarDefaultAdded: z.boolean().optional(),
_antigravityStatusBarDefaultAdded: z.boolean().optional(),
_grokStatusBarDefaultAdded: z.boolean().optional(),
statusBarVisible: z.boolean().optional(),
dismissedUpdateVersion: NullableString.optional(),

View File

@ -412,6 +412,31 @@ describe('AppearancePane', () => {
expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('minimax')
})
it('records Antigravity status bar toggles as usage tracking interactions', async () => {
mocks.state.availableStatusBarToggles = [
{
id: 'antigravity',
title: 'Antigravity Usage',
description: 'Show Antigravity subscription usage in the status bar.',
toggleDescription: 'Show Antigravity subscription usage for the active workspace.',
keywords: ['status bar', 'antigravity', 'usage']
}
]
mocks.state.settingsSearchQuery = 'antigravity'
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
const antigravitySwitch = container.querySelector<HTMLButtonElement>(
'button[role="switch"][aria-label="Antigravity Usage"]'
)
expect(antigravitySwitch).not.toBeNull()
await act(async () => {
antigravitySwitch?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking')
expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('antigravity')
})
it('collapses sibling sections so only the Interface section is expanded by default', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))

View File

@ -43,6 +43,7 @@ function recordStatusBarToggleInteraction(
id === 'gemini' ||
id === 'opencode-go' ||
id === 'kimi' ||
id === 'antigravity' ||
id === 'minimax' ||
id === 'grok'
) {

View File

@ -0,0 +1,43 @@
import type { StatusBarItem } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
export function getAntigravityStatusBarToggleSearchEntry(): {
id: StatusBarItem
title: string
description: string
keywords: string[]
toggleDescription: string
} {
return {
id: 'antigravity',
title: translate(
'auto.components.settings.appearance.search.antigravityUsageTitle',
'Antigravity Usage'
),
description: translate(
'auto.components.settings.appearance.search.antigravityUsageDescription',
'Show Antigravity subscription usage in the status bar.'
),
keywords: [
...translateSearchKeyword(
'auto.components.settings.appearance.search.896eb53fd4',
'status bar'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.antigravityKeyword',
'antigravity'
),
...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'),
...translateSearchKeyword(
'auto.components.settings.appearance.search.de586def95',
'subscription'
),
...translateSearchKeyword('auto.components.settings.appearance.search.51b0ccd6a2', 'google')
],
toggleDescription: translate(
'settings.appearance.statusBar.antigravityToggleDescription',
'Show Antigravity subscription usage for the active workspace.'
)
}
}

View File

@ -18,6 +18,19 @@ vi.mock('./settings-search-keywords', () => ({
import { getStatusBarToggles } from './appearance-status-bar-search'
describe('getStatusBarToggles', () => {
it('includes Antigravity usage so Appearance can toggle the default-on status item', () => {
const antigravityToggle = getStatusBarToggles().find((entry) => entry.id === 'antigravity')
expect(antigravityToggle).toMatchObject({
title: 'Antigravity Usage',
description: 'Show Antigravity subscription usage in the status bar.',
toggleDescription: 'Show Antigravity subscription usage for the active workspace.'
})
expect(antigravityToggle?.keywords).toEqual(
expect.arrayContaining(['status bar', 'antigravity', 'usage', 'subscription', 'google'])
)
})
it('includes MiniMax usage so Appearance can toggle the default-on status item', () => {
const miniMaxToggle = getStatusBarToggles().find((entry) => entry.id === 'minimax')

View File

@ -2,6 +2,7 @@ import type { StatusBarItem } from '../../../../shared/types'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { getAntigravityStatusBarToggleSearchEntry } from './appearance-status-bar-antigravity-toggle-search'
import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search'
export const getStatusBarToggles = createLocalizedCatalog(
@ -99,6 +100,7 @@ export const getStatusBarToggles = createLocalizedCatalog(
'Show Gemini token and cost usage for the active workspace.'
)
},
getAntigravityStatusBarToggleSearchEntry(),
{
id: 'opencode-go',
title: translate(

View File

@ -22,6 +22,7 @@ const mockStoreState = {
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: {
provider: 'grok',

View File

@ -1760,11 +1760,13 @@ export function ProviderDetailsMenu({
? 'O'
: provider.provider === 'kimi'
? 'K'
: provider.provider === 'minimax'
? 'M'
: provider.provider === 'grok'
? 'R'
: 'X'}
: provider.provider === 'antigravity'
? 'A'
: provider.provider === 'minimax'
? 'M'
: provider.provider === 'grok'
? 'R'
: 'X'}
</span>
</span>
) : (
@ -1910,17 +1912,26 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
return null
}
const { claude, codex, gemini, opencodeGo, kimi, minimax, grok } = rateLimits
const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits
// Why: a provider earns a bar from either a usable live snapshot or durable
// setup in Settings. The durable path keeps account switchers visible while
// usage snapshots hydrate, fail, or temporarily report unavailable.
// Detection-gating (see status-bar-agent-gating) additionally hides per-CLI
// bars when the agent isn't installed on PATH.
// Why: thread the cookie durability flag from RateLimitState so the
// MiniMax bar stays visible after a reload and between snapshot refreshes.
// Why: Antigravity usage has no separate persisted credential. A checked
// status item plus detected CLI is the durable signal that the user wants
// its usage slot visible while the first snapshot is still pending. The
// visibility module additionally requires geminiCliOAuthEnabled (already in
// settings) because the snapshot mirrors the Gemini fetch.
const antigravityUsageConfigured =
statusBarItems.includes('antigravity') &&
isStatusBarItemAvailable('antigravity', detectedAgentIds)
// Why: thread non-GlobalSettings durability flags from renderer/main state so
// bars stay visible after a reload and between snapshot refreshes.
const usageSettings = {
...settings,
antigravityUsageConfigured,
minimaxCookieConfigured: rateLimits.minimaxCookieConfigured,
grokAuthConfigured: rateLimits.grokAuthConfigured
}
@ -1928,6 +1939,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
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)
const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings)
const showClaude =
@ -1946,6 +1958,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
visibleKimi !== null &&
statusBarItems.includes('kimi') &&
isStatusBarItemAvailable('kimi', detectedAgentIds)
const showAntigravity =
visibleAntigravity !== null &&
statusBarItems.includes('antigravity') &&
isStatusBarItemAvailable('antigravity', detectedAgentIds)
// Why: MiniMax is a cookie-auth provider, not a CLI on PATH, so detection-gating
// doesn't apply (same rationale as OpenCode Go below).
const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax')
@ -1968,6 +1984,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
showGemini ||
showOpencodeGo ||
showKimi ||
showAntigravity ||
showMiniMax ||
showGrok ||
showResourceUsage
@ -1976,7 +1993,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
// included because managed accounts are durable even when live usage
// snapshots are still hydrating or unavailable after an update.
const isEmptyUsageState = isUsageEmptyState(
{ claude, codex, gemini, opencodeGo, kimi, minimax, grok },
{ claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok },
usageSettings
)
// Why: the teaching CTA is a one-time nudge — once the user hides it, keep it
@ -1988,6 +2005,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
gemini?.status === 'fetching' ||
opencodeGo?.status === 'fetching' ||
kimi?.status === 'fetching' ||
antigravity?.status === 'fetching' ||
minimax?.status === 'fetching' ||
grok?.status === 'fetching'
@ -2045,6 +2063,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
)}
/>
)}
{showAntigravity && (
<ProviderDetailsMenu
provider={visibleAntigravity}
compact={compact}
iconOnly={iconOnly}
ariaLabel={translate(
'auto.components.status.bar.StatusBar.antigravityUsageDetails',
'Open Antigravity usage details'
)}
/>
)}
{showOpencodeGo && (
<ProviderDetailsMenu
provider={visibleOpencodeGo}
@ -2210,6 +2239,21 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
{translate('auto.components.status.bar.StatusBar.c1df0d67ec', 'Gemini Usage')}
</DropdownMenuCheckboxItem>
)}
{isStatusBarItemAvailable('antigravity', detectedAgentIds) && (
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('antigravity')}
onCheckedChange={() => {
recordFeatureInteraction('usage-tracking')
toggleStatusBarItem('antigravity')
}}
>
<AgentIcon agent="antigravity" size={14} />
{translate(
'auto.components.status.bar.StatusBar.antigravityUsage',
'Antigravity Usage'
)}
</DropdownMenuCheckboxItem>
)}
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('opencode-go')}
onCheckedChange={() => {

View File

@ -18,6 +18,7 @@ describe('isStatusBarItemAvailable', () => {
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,6 +26,7 @@ describe('isStatusBarItemAvailable', () => {
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)
})
@ -32,6 +34,7 @@ describe('isStatusBarItemAvailable', () => {
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)
})
})

View File

@ -1,6 +1,6 @@
import type { StatusBarItem, TuiAgent } from '../../../../shared/types'
// Why: Claude/Codex/Gemini usage bars are surface noise when the underlying
// 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
// PATH detection reports the agent as missing. Pre-detection (null) keeps
@ -11,6 +11,7 @@ const CLI_GATED_ITEMS: ReadonlySet<StatusBarItem> = new Set([
'codex',
'gemini',
'kimi',
'antigravity',
'grok'
])

View File

@ -70,6 +70,7 @@ function usageSettings(overrides: Partial<UsageProviderSettings> = {}): UsagePro
claudeManagedAccounts: [],
opencodeSessionCookie: '',
geminiCliOAuthEnabled: false,
antigravityUsageConfigured: false,
minimaxCookieConfigured: false,
grokAuthConfigured: false,
...overrides
@ -119,6 +120,11 @@ 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
)
expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true)
expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true)
})
@ -150,6 +156,32 @@ describe('hasUsageProviderSettingsForProvider', () => {
).toBe(true)
expect(hasUsageProviderSettingsForProvider('claude', usageSettings())).toBe(false)
expect(hasUsageProviderSettingsForProvider('kimi', usageSettings())).toBe(false)
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.
expect(
hasUsageProviderSettingsForProvider(
'antigravity',
usageSettings({ antigravityUsageConfigured: true })
)
).toBe(false)
expect(
hasUsageProviderSettingsForProvider(
'antigravity',
usageSettings({ geminiCliOAuthEnabled: true })
)
).toBe(false)
expect(hasUsageProviderSettingsForProvider('antigravity', usageSettings())).toBe(false)
expect(hasUsageProviderSettingsForProvider('antigravity', null)).toBe(false)
})
it('treats minimaxCookieConfigured as the durable signal for MiniMax', () => {
@ -283,6 +315,42 @@ describe('getVisibleUsageProvider', () => {
)
).toBe(null)
})
it('keeps Antigravity visible while the snapshot is pending when checked and Gemini OAuth is on', () => {
const visible = getVisibleUsageProvider(
'antigravity',
null,
usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true })
)
expect(visible).toMatchObject({
provider: 'antigravity',
status: 'fetching',
session: null,
weekly: null
})
})
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)
})
})
describe('isUsageEmptyState', () => {
@ -295,6 +363,7 @@ describe('isUsageEmptyState', () => {
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null
},
@ -312,6 +381,7 @@ describe('isUsageEmptyState', () => {
gemini: provider('unavailable'),
opencodeGo: provider('unavailable', { provider: 'opencode-go' }),
kimi: provider('unavailable', { provider: 'kimi' }),
antigravity: provider('unavailable', { provider: 'antigravity' }),
minimax: provider('unavailable', { provider: 'minimax' }),
grok: provider('unavailable', { provider: 'grok' })
},
@ -329,6 +399,7 @@ describe('isUsageEmptyState', () => {
gemini: provider('unavailable'),
opencodeGo: provider('unavailable', { provider: 'opencode-go' }),
kimi: provider('unavailable', { provider: 'kimi' }),
antigravity: provider('unavailable', { provider: 'antigravity' }),
minimax: provider('unavailable', { provider: 'minimax' }),
grok: provider('unavailable', { provider: 'grok' })
},
@ -357,6 +428,7 @@ describe('isUsageEmptyState', () => {
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null
},
@ -374,6 +446,7 @@ describe('isUsageEmptyState', () => {
gemini: provider('unavailable'),
opencodeGo: provider('unavailable', { provider: 'opencode-go' }),
kimi: provider('unavailable', { provider: 'kimi' }),
antigravity: null,
minimax: provider('unavailable', { provider: 'minimax' }),
grok: provider('unavailable', { provider: 'grok' })
},
@ -381,4 +454,42 @@ describe('isUsageEmptyState', () => {
)
).toBe(true)
})
it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => {
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,
grok: provider('unavailable', { provider: 'grok' }),
minimax: provider('unavailable', { provider: 'minimax' })
},
usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: 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.
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,
grok: provider('unavailable', { provider: 'grok' }),
minimax: provider('unavailable', { provider: 'minimax' })
},
usageSettings({ antigravityUsageConfigured: true })
)
).toBe(true)
})
})

View File

@ -8,6 +8,12 @@ export type UsageProviderSettings = Pick<
| 'opencodeSessionCookie'
| 'geminiCliOAuthEnabled'
> & {
// 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.
antigravityUsageConfigured: boolean
// Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll.
minimaxCookieConfigured: boolean
grokAuthConfigured: boolean
@ -19,6 +25,7 @@ type UsageProviderSnapshots = {
gemini: ProviderRateLimits | null
opencodeGo: ProviderRateLimits | null
kimi: ProviderRateLimits | null
antigravity: ProviderRateLimits | null
minimax: ProviderRateLimits | null
grok: ProviderRateLimits | null
}
@ -65,6 +72,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.
settings?.minimaxCookieConfigured === true ||
settings?.grokAuthConfigured === true
)
@ -89,6 +98,12 @@ export function hasUsageProviderSettingsForProvider(
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
}
if (providerId === 'minimax') {
return settings.minimaxCookieConfigured === true
}
@ -137,12 +152,16 @@ export function isUsageEmptyState(
// Why: system-default Claude/Codex accounts have no persisted account row;
// their first durable signal is the usage snapshot, so wait for snapshots to
// settle before teaching the user to connect an account.
const antigravitySnapshotPending =
hasUsageProviderSettingsForProvider('antigravity', settings) &&
isProviderSnapshotPending(providers.antigravity)
if (
isProviderSnapshotPending(providers.claude) ||
isProviderSnapshotPending(providers.codex) ||
isProviderSnapshotPending(providers.gemini) ||
isProviderSnapshotPending(providers.opencodeGo) ||
isProviderSnapshotPending(providers.kimi) ||
antigravitySnapshotPending ||
isProviderSnapshotPending(providers.minimax) ||
isProviderSnapshotPending(providers.grok)
) {
@ -155,6 +174,7 @@ export function isUsageEmptyState(
!isProviderConfigured(providers.gemini) &&
!isProviderConfigured(providers.opencodeGo) &&
!isProviderConfigured(providers.kimi) &&
!isProviderConfigured(providers.antigravity) &&
!isProviderConfigured(providers.minimax) &&
!isProviderConfigured(providers.grok)
)

View File

@ -1,10 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import type * as ReactModule from 'react'
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: () => null
}))
vi.mock('@/lib/agent-catalog', async () => {
const ReactActual = await vi.importActual<typeof ReactModule>('react')
return {
AgentIcon: ({ agent }: { agent: string }) =>
ReactActual.createElement('span', { 'data-agent-icon': agent })
}
})
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) => {
@ -448,6 +453,11 @@ describe('ProviderPanel reset rendering', () => {
})
describe('ProviderIcon', () => {
it('renders the Antigravity agent icon for the antigravity provider', () => {
const markup = renderToStaticMarkup(ProviderIcon({ provider: 'antigravity' }))
expect(markup).toContain('data-agent-icon="antigravity"')
})
it('renders the official MiniMax icon asset for the minimax provider', () => {
// Why: the icon must travel to the status bar / tooltip unchanged so the
// user recognises the brand. We pin it to an <img> with a non-empty

View File

@ -93,6 +93,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem
if (provider === 'kimi') {
return <AgentIcon agent="kimi" size={13} />
}
if (provider === 'antigravity') {
return <AgentIcon agent="antigravity" size={13} />
}
if (provider === 'minimax') {
return <MiniMaxIcon size={13} />
}

View File

@ -7,6 +7,10 @@ vi.mock('@/i18n/i18n', () => ({
import { getProviderDisplayName } from './usage-error-copy'
describe('getProviderDisplayName', () => {
it('returns the Antigravity brand name', () => {
expect(getProviderDisplayName('antigravity')).toBe('Antigravity')
})
it('returns the MiniMax brand name', () => {
expect(getProviderDisplayName('minimax')).toBe('MiniMax')
})
@ -17,6 +21,7 @@ describe('getProviderDisplayName', () => {
expect(getProviderDisplayName('gemini')).toBe('Gemini')
expect(getProviderDisplayName('opencode-go')).toBe('OpenCode Go')
expect(getProviderDisplayName('kimi')).toBe('Kimi')
expect(getProviderDisplayName('grok')).toBe('Grok')
})
it('falls back to the raw provider id when no mapping exists', () => {

View File

@ -17,6 +17,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider'])
if (provider === 'kimi') {
return 'Kimi'
}
if (provider === 'antigravity') {
return 'Antigravity'
}
if (provider === 'minimax') {
return 'MiniMax'
}

View File

@ -29,6 +29,7 @@
"sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.",
"resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.",
"portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.",
"antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.",
"grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI."
}
}
@ -3034,6 +3035,8 @@
"06741a2f3d": "Open MiniMax usage details",
"3bbf140864": "MiniMax Usage",
"remoteServerLabel": "Remote server",
"antigravityUsage": "Antigravity Usage",
"antigravityUsageDetails": "Open Antigravity usage details",
"grokUsageAria": "Open Grok usage details",
"grokUsageMenu": "Grok Usage"
},
@ -7223,6 +7226,9 @@
"workspaceOptions": "workspace options",
"detailed": "detailed"
},
"antigravityUsageTitle": "Antigravity Usage",
"antigravityUsageDescription": "Show Antigravity subscription usage in the status bar.",
"antigravityKeyword": "antigravity",
"f8e2a1c4b6": "Grok Usage",
"e7d1b0f3a5": "Show Grok weekly credit usage from Grok CLI OAuth.",
"d6c0a9e2f4": "grok",

View File

@ -29,6 +29,7 @@
"sshToggleDescription": "Muestra hosts SSH configurados y hosts remotos de Orca cuando haya alguno disponible.",
"resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del servicio en segundo plano y análisis de disco de los espacios de trabajo.",
"portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.",
"antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.",
"grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI."
}
}
@ -3034,6 +3035,8 @@
"06741a2f3d": "Abrir detalles de uso de MiniMax",
"3bbf140864": "Uso de MiniMax",
"remoteServerLabel": "Servidor remoto",
"antigravityUsage": "Uso de Antigravity",
"antigravityUsageDetails": "Abrir detalles de uso de Antigravity",
"grokUsageAria": "Abrir detalles de uso de Grok",
"grokUsageMenu": "Uso de Grok"
},
@ -7186,6 +7189,9 @@
},
"9a115966d3": "Minimizar a la bandeja al cerrar",
"4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.",
"antigravityUsageTitle": "Uso de Antigravity",
"antigravityUsageDescription": "Muestra el uso de suscripción de Antigravity en la barra de estado.",
"antigravityKeyword": "antigravity",
"f8e2a1c4b6": "Uso de Grok",
"e7d1b0f3a5": "Muestra el uso semanal de créditos de Grok desde OAuth de Grok CLI.",
"d6c0a9e2f4": "grok",

View File

@ -29,6 +29,7 @@
"sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。",
"resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。",
"portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。",
"antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。",
"grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。"
}
}
@ -3034,6 +3035,8 @@
"06741a2f3d": "MiniMax の使用量詳細を開く",
"3bbf140864": "MiniMax 使用量",
"remoteServerLabel": "リモートサーバー",
"antigravityUsage": "Antigravity の使用状況",
"antigravityUsageDetails": "Antigravity の使用状況詳細を開く",
"grokUsageAria": "Grok の使用状況詳細を開く",
"grokUsageMenu": "Grok の使用状況"
},
@ -7208,6 +7211,9 @@
},
"9a115966d3": "閉じるときにトレイへ最小化",
"4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。",
"antigravityUsageTitle": "Antigravity の使用状況",
"antigravityUsageDescription": "ステータスバーに Antigravity サブスクリプションの使用量を表示します。",
"antigravityKeyword": "antigravity",
"f8e2a1c4b6": "Grok の使用状況",
"e7d1b0f3a5": "Grok CLI OAuth から Grok の週次クレジット使用量を表示します。",
"d6c0a9e2f4": "grok",

View File

@ -29,6 +29,7 @@
"sshToggleDescription": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다.",
"resourceUsageToggleDescription": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다.",
"portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.",
"antigravityToggleDescription": "활성 워크스페이스에 대한 Antigravity 구독 사용량을 표시합니다.",
"grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다."
}
}
@ -3034,6 +3035,8 @@
"06741a2f3d": "MiniMax 사용량 세부 정보 열기",
"3bbf140864": "MiniMax 사용량",
"remoteServerLabel": "원격 서버",
"antigravityUsage": "Antigravity 사용량",
"antigravityUsageDetails": "Antigravity 사용량 세부 정보 열기",
"grokUsageAria": "Grok 사용량 세부 정보 열기",
"grokUsageMenu": "Grok 사용량"
},
@ -7171,6 +7174,9 @@
"workspaceOptions": "워크스페이스 옵션",
"detailed": "상세"
},
"antigravityUsageTitle": "Antigravity 사용량",
"antigravityUsageDescription": "상태 표시줄에 Antigravity 구독 사용량을 표시합니다.",
"antigravityKeyword": "antigravity",
"f8e2a1c4b6": "Grok 사용량",
"e7d1b0f3a5": "Grok CLI OAuth에서 Grok 주간 크레딧 사용량을 표시합니다.",
"d6c0a9e2f4": "grok",

View File

@ -29,6 +29,7 @@
"sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。",
"resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。",
"portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。",
"antigravityToggleDescription": "显示当前工作区的 Antigravity 订阅使用量。",
"grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。"
}
}
@ -3034,6 +3035,8 @@
"06741a2f3d": "打开 MiniMax 使用量详情",
"3bbf140864": "MiniMax 使用量",
"remoteServerLabel": "远程服务器",
"antigravityUsage": "Antigravity 使用量",
"antigravityUsageDetails": "打开 Antigravity 使用详情",
"grokUsageAria": "打开 Grok 使用详情",
"grokUsageMenu": "Grok 使用量"
},
@ -7171,6 +7174,9 @@
},
"9a115966d3": "关闭时最小化到托盘",
"4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。",
"antigravityUsageTitle": "Antigravity 使用量",
"antigravityUsageDescription": "在状态栏中显示 Antigravity 订阅使用量。",
"antigravityKeyword": "antigravity",
"f8e2a1c4b6": "Grok 使用量",
"e7d1b0f3a5": "显示来自 Grok CLI OAuth 的 Grok 每周额度使用量。",
"d6c0a9e2f4": "grok",

View File

@ -0,0 +1,19 @@
import { createStore, type StoreApi } from 'zustand/vanilla'
import { describe, expect, it } from 'vitest'
import { createRateLimitSlice } from './rate-limits'
import type { AppState } from '../types'
function createRateLimitStore(): StoreApi<AppState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createStore<any>()((...args: any[]) =>
createRateLimitSlice(...(args as Parameters<typeof createRateLimitSlice>))
) as unknown as StoreApi<AppState>
}
describe('createRateLimitSlice', () => {
it('initializes Antigravity usage with a stable pending key', () => {
const store = createRateLimitStore()
expect(store.getState().rateLimits.antigravity).toBeNull()
})
})

View File

@ -22,6 +22,7 @@ export const createRateLimitSlice: StateCreator<AppState, [], [], RateLimitSlice
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null,
minimaxCookieConfigured: false,

View File

@ -1219,13 +1219,23 @@ describe('createUISlice hydratePersistedUI', () => {
'ports',
'kimi',
'minimax',
'antigravity',
'grok'
])
expect(setUI).toHaveBeenCalledWith({
statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax', 'grok'],
statusBarItems: [
'claude',
'resource-usage',
'ports',
'kimi',
'minimax',
'antigravity',
'grok'
],
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_antigravityStatusBarDefaultAdded: true,
_grokStatusBarDefaultAdded: true
})
})
@ -1241,6 +1251,7 @@ describe('createUISlice hydratePersistedUI', () => {
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_antigravityStatusBarDefaultAdded: true,
_grokStatusBarDefaultAdded: true
})
)

View File

@ -265,6 +265,7 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI
const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports'
const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi'
const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax'
const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity'
const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok'
function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds {
@ -2264,14 +2265,19 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax')
? statusBarItems
: [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM]
const statusBarItemsWithGrok =
ui._grokStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('grok')
const statusBarItemsWithAntigravity =
ui._antigravityStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('antigravity')
? statusBarItemsWithMiniMax
: [...statusBarItemsWithMiniMax, DEFAULT_ON_GROK_STATUS_BAR_ITEM]
: [...statusBarItemsWithMiniMax, DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM]
const statusBarItemsWithGrok =
ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok')
? statusBarItemsWithAntigravity
: [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM]
if (
(!ui._portsStatusBarDefaultAdded ||
!ui._kimiStatusBarDefaultAdded ||
!ui._minimaxStatusBarDefaultAdded ||
!ui._antigravityStatusBarDefaultAdded ||
!ui._grokStatusBarDefaultAdded) &&
typeof window !== 'undefined'
) {
@ -2281,6 +2287,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_antigravityStatusBarDefaultAdded: true,
_grokStatusBarDefaultAdded: true
})
.catch(console.error)

View File

@ -2587,6 +2587,7 @@ function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimits']> {
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null,
minimaxCookieConfigured: false,

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { RateLimitState } from './rate-limit-types'
describe('RateLimitState', () => {
it('documents the MiniMax surface used by the AccountsPane settings UI', () => {
it('documents provider surfaces used by settings and status-bar UI', () => {
// Why: the AccountsPane and the status bar both read these fields
// from RateLimitState. The shape must stay stable so that the
// visibility check (status-bar-provider-visibility) keeps working
@ -13,6 +13,7 @@ describe('RateLimitState', () => {
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null,
minimaxCookieConfigured: false,
@ -23,6 +24,7 @@ describe('RateLimitState', () => {
inactiveCodexAccounts: []
}
expect(state.antigravity).toBeNull()
expect(state.minimax).toBeNull()
expect(state.minimaxCookieConfigured).toBe(false)
})

View File

@ -44,7 +44,7 @@ export type UsageRateLimitMetadata = {
}
export type ProviderRateLimits = {
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok'
provider: 'claude' | 'codex' | 'gemini' | '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. */
@ -109,6 +109,7 @@ export type RateLimitState = {
gemini: ProviderRateLimits | null
opencodeGo: ProviderRateLimits | null
kimi: ProviderRateLimits | null
antigravity: ProviderRateLimits | null
minimax: ProviderRateLimits | null
grok: ProviderRateLimits | null
/**

View File

@ -4,6 +4,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
'claude',
'codex',
'gemini',
'antigravity',
'opencode-go',
'kimi',
'minimax',

View File

@ -3152,6 +3152,7 @@ export type StatusBarItem =
| 'claude'
| 'codex'
| 'gemini'
| 'antigravity'
| 'opencode-go'
| 'kimi'
| 'minimax'
@ -3271,6 +3272,8 @@ export type PersistedUIState = {
_kimiStatusBarDefaultAdded?: boolean
/** One-shot migration flag for adding the default-on MiniMax status item. */
_minimaxStatusBarDefaultAdded?: boolean
/** One-shot migration flag for adding the default-on Antigravity status item. */
_antigravityStatusBarDefaultAdded?: boolean
/** One-shot migration flag for adding the default-on Grok status item. */
_grokStatusBarDefaultAdded?: boolean
statusBarItems: StatusBarItem[]