From fa2b228ad333cc8a6fc2c41e4ee68ace8b04a614 Mon Sep 17 00:00:00 2001 From: Doan Bac Tam Date: Fri, 10 Jul 2026 14:11:27 +0700 Subject: [PATCH] feat(rate-limits): Grok CLI OAuth weekly credit usage (#7869) * feat(rate-limits): Grok CLI OAuth weekly credit usage in status bar Read ~/.grok/auth.json (read-only), fetch billing credits via cli-chat-proxy, and surface Grok in Settings, status bar toggles, and rate-limit polling alongside other usage providers. * fix(grok): clarify comments and address CodeRabbit review - Shorten Why comments per AGENTS.md; fix billing period end fallback. - Share GrokAccountStatus type; hash-based locale keys; reload why in Settings. * docs(grok): plain-language comments and Settings copy * feat(stats): subscription usage section with Grok in Stats & Usage Surface rate-limit weekly credits in Settings > Stats & Usage and link to Accounts for setup. * feat(stats): Grok tab in Usage Analytics dropdown * refactor(stats): drop Subscription usage block; align Grok pane with Codex * fix(grok): align settings copy and visibility tests * fix(grok): add localization catalog entries * fix(grok): avoid eager usage refresh fanout * fix(grok): harden usage refresh visibility * test(grok): cover account status privacy boundary * fix(grok): target refreshes and redact auth errors * fix(grok): hide usage UI for signed-out users and align empty states - Treat a token-less auth.json (e.g. after grok logout) as signed out instead of surfacing a permanent status-bar error. - Map billing responses without credit usage to 'unavailable' so plans with no weekly credits hide the bar like Claude API-key billing. - Gate the grok status-bar item and toggle on CLI PATH detection, matching claude/codex/gemini/kimi. - Guard an empty GROK_CLI_CHAT_PROXY_BASE_URL from producing a relative billing URL. - Drop dead minimax/kimi Stats & Usage search keywords left from the removed subscription section. - Add missing grok search keyword catalog entries and translate the English-stubbed stats keywords in es/ja/ko/zh. * test(ipc): mock grok account registrar in register-core-handlers test --------- Co-authored-by: Brennan Benson --- src/main/grok-accounts/status.test.ts | 69 +++++++ src/main/grok-accounts/status.ts | 32 +++ src/main/ipc/grok-accounts.ts | 6 + src/main/ipc/rate-limits.test.ts | 20 +- src/main/ipc/rate-limits.ts | 1 + src/main/ipc/register-core-handlers.test.ts | 7 + src/main/ipc/register-core-handlers.ts | 2 + src/main/rate-limits/grok-auth.test.ts | 48 +++++ src/main/rate-limits/grok-auth.ts | 116 +++++++++++ src/main/rate-limits/grok-fetcher.test.ts | 164 +++++++++++++++ src/main/rate-limits/grok-fetcher.ts | 173 ++++++++++++++++ src/main/rate-limits/service.test.ts | 146 +++++++++++++- src/main/rate-limits/service.ts | 185 ++++++++++++++++- src/main/runtime/orca-runtime.ts | 4 +- .../runtime/rpc/methods/client-ui.test.ts | 6 +- src/main/runtime/rpc/methods/client-ui.ts | 2 + src/preload/api-types.ts | 5 + src/preload/index.ts | 6 + .../src/components/settings/AccountsPane.tsx | 5 + .../AppearanceWindowSidebarSection.tsx | 3 +- .../settings/GrokAccountsSection.tsx | 186 ++++++++++++++++++ .../src/components/settings/Settings.tsx | 2 +- .../components/settings/accounts-search.ts | 24 ++- ...ppearance-status-bar-grok-toggle-search.ts | 37 ++++ .../settings/appearance-status-bar-search.ts | 2 + .../components/stats/GrokUsagePane.test.tsx | 105 ++++++++++ .../src/components/stats/GrokUsagePane.tsx | 137 +++++++++++++ .../src/components/stats/StatsPane.tsx | 13 +- .../src/components/stats/stats-search.ts | 9 +- .../src/components/status-bar/StatusBar.tsx | 46 ++++- .../status-bar-agent-gating.test.ts | 3 + .../status-bar/status-bar-agent-gating.ts | 8 +- .../status-bar-provider-visibility.test.ts | 39 +++- .../status-bar-provider-visibility.ts | 19 +- .../src/components/status-bar/tooltip.tsx | 3 + .../components/status-bar/usage-error-copy.ts | 3 + .../hooks/useSettingsNavigationMetadata.ts | 4 +- src/renderer/src/i18n/locales/en.json | 65 +++++- src/renderer/src/i18n/locales/es.json | 65 +++++- src/renderer/src/i18n/locales/ja.json | 65 +++++- src/renderer/src/i18n/locales/ko.json | 65 +++++- src/renderer/src/i18n/locales/zh.json | 65 +++++- src/renderer/src/store/slices/rate-limits.ts | 12 ++ src/renderer/src/store/slices/ui.test.ts | 11 +- src/renderer/src/store/slices/ui.ts | 15 +- src/renderer/src/web/web-preload-api.ts | 17 ++ src/shared/rate-limit-types.test.ts | 2 + src/shared/rate-limit-types.ts | 13 +- src/shared/status-bar-defaults.ts | 1 + src/shared/types.ts | 3 + 50 files changed, 1938 insertions(+), 101 deletions(-) create mode 100644 src/main/grok-accounts/status.test.ts create mode 100644 src/main/grok-accounts/status.ts create mode 100644 src/main/ipc/grok-accounts.ts create mode 100644 src/main/rate-limits/grok-auth.test.ts create mode 100644 src/main/rate-limits/grok-auth.ts create mode 100644 src/main/rate-limits/grok-fetcher.test.ts create mode 100644 src/main/rate-limits/grok-fetcher.ts create mode 100644 src/renderer/src/components/settings/GrokAccountsSection.tsx create mode 100644 src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts create mode 100644 src/renderer/src/components/stats/GrokUsagePane.test.tsx create mode 100644 src/renderer/src/components/stats/GrokUsagePane.tsx diff --git a/src/main/grok-accounts/status.test.ts b/src/main/grok-accounts/status.test.ts new file mode 100644 index 000000000..fccf5dfd7 --- /dev/null +++ b/src/main/grok-accounts/status.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getGrokAccountStatus } from './status' +import { isGrokAccessTokenFresh, readGrokAuthSession } from '../rate-limits/grok-auth' + +vi.mock('../rate-limits/grok-auth', () => ({ + isGrokAccessTokenFresh: vi.fn(), + readGrokAuthSession: vi.fn() +})) + +describe('getGrokAccountStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isGrokAccessTokenFresh).mockReturnValue(true) + }) + + it('reports unsigned status when the Grok auth file is missing', () => { + vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) + + expect(getGrokAccountStatus()).toEqual({ + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: null + }) + }) + + it('reports auth read errors without exposing token fields', () => { + vi.mocked(readGrokAuthSession).mockReturnValue({ + status: 'error', + error: 'Grok auth file is invalid' + }) + + expect(getGrokAccountStatus()).toEqual({ + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: 'Grok auth file is invalid' + }) + }) + + it('returns non-secret signed-in metadata and freshness', () => { + vi.mocked(readGrokAuthSession).mockReturnValue({ + status: 'ok', + session: { + accessToken: 'secret-token', + email: 'dev@example.com', + teamId: 'team-1', + userId: 'user-1', + expiresAtMs: null, + oidcClientId: 'client-1' + } + }) + vi.mocked(isGrokAccessTokenFresh).mockReturnValue(false) + + const status = getGrokAccountStatus() + + expect(status).toEqual({ + signedIn: true, + email: 'dev@example.com', + teamId: 'team-1', + tokenFresh: false, + error: null + }) + expect(JSON.stringify(status)).not.toContain('secret-token') + expect(JSON.stringify(status)).not.toContain('client-1') + }) +}) diff --git a/src/main/grok-accounts/status.ts b/src/main/grok-accounts/status.ts new file mode 100644 index 000000000..021483e47 --- /dev/null +++ b/src/main/grok-accounts/status.ts @@ -0,0 +1,32 @@ +import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import { isGrokAccessTokenFresh, readGrokAuthSession } from '../rate-limits/grok-auth' + +export function getGrokAccountStatus(): GrokAccountStatus { + const readResult = readGrokAuthSession() + if (readResult.status === 'missing') { + return { + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: null + } + } + if (readResult.status === 'error') { + return { + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: readResult.error + } + } + const session = readResult.session + return { + signedIn: true, + email: session.email, + teamId: session.teamId, + tokenFresh: isGrokAccessTokenFresh(session), + error: null + } +} diff --git a/src/main/ipc/grok-accounts.ts b/src/main/ipc/grok-accounts.ts new file mode 100644 index 000000000..8b0aa69f2 --- /dev/null +++ b/src/main/ipc/grok-accounts.ts @@ -0,0 +1,6 @@ +import { ipcMain } from 'electron' +import { getGrokAccountStatus } from '../grok-accounts/status' + +export function registerGrokAccountHandlers(): void { + ipcMain.handle('grokAccounts:getStatus', () => getGrokAccountStatus()) +} diff --git a/src/main/ipc/rate-limits.test.ts b/src/main/ipc/rate-limits.test.ts index 3a299c5bd..b53d87d5d 100644 --- a/src/main/ipc/rate-limits.test.ts +++ b/src/main/ipc/rate-limits.test.ts @@ -16,11 +16,17 @@ import { registerRateLimitHandlers } from './rate-limits' import type { RateLimitService } from '../rate-limits/service' import type { RateLimitState } from '../../shared/rate-limit-types' -function makeService(): { service: RateLimitService; refresh: ReturnType } { +function makeService(): { + service: RateLimitService + refresh: ReturnType + refreshGrok: ReturnType +} { const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) + const refreshGrok = vi.fn(() => Promise.resolve({} as RateLimitState)) const service = { getState: vi.fn(() => ({}) as RateLimitState), refresh, + refreshGrok, refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), consumeCodexRateLimitResetCredit: vi.fn(() => @@ -30,7 +36,7 @@ function makeService(): { service: RateLimitService; refresh: ReturnType Promise.resolve()), fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve()) } - return { service: service as unknown as RateLimitService, refresh } + return { service: service as unknown as RateLimitService, refresh, refreshGrok } } describe('registerRateLimitHandlers', () => { @@ -53,5 +59,15 @@ describe('registerRateLimitHandlers', () => { expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true) expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true) expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true) + expect(ipcState.handleHandlers.has('rateLimits:refreshGrok')).toBe(true) + }) + + it('registers a refreshGrok channel that delegates to refreshGrok()', async () => { + const { service, refreshGrok } = makeService() + registerRateLimitHandlers(service) + const handler = ipcState.handleHandlers.get('rateLimits:refreshGrok') + expect(handler).toBeDefined() + await handler!({}) + expect(refreshGrok).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index a28b78102..b906ddce5 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -24,4 +24,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void { rateLimits.fetchInactiveCodexAccountsOnOpen() ) ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh()) + ipcMain.handle('rateLimits:refreshGrok', () => rateLimits.refreshGrok()) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index ad836b8e1..08af6977e 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -37,6 +37,7 @@ const { registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, + registerGrokAccountHandlersMock, registerClipboardHandlersMock, setTrustedClipboardRendererWebContentsIdMock, registerUpdaterHandlersMock, @@ -97,6 +98,7 @@ const { registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), + registerGrokAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), setTrustedClipboardRendererWebContentsIdMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -309,6 +311,10 @@ vi.mock('./minimax-credentials', () => ({ registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock })) +vi.mock('./grok-accounts', () => ({ + registerGrokAccountHandlers: registerGrokAccountHandlersMock +})) + vi.mock('../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock })) @@ -464,6 +470,7 @@ describe('registerCoreHandlers', () => { expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) + expect(registerGrokAccountHandlersMock).toHaveBeenCalled() expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 2946204f5..dc2c21902 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -56,6 +56,7 @@ import { registerAgentHookHandlers } from './agent-hooks' import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' import { registerMiniMaxCredentialsHandlers } from './minimax-credentials' +import { registerGrokAccountHandlers } from './grok-accounts' import { registerUpdaterHandlers } from '../window/attach-main-window-services' import { registerClipboardHandlers, @@ -125,6 +126,7 @@ export function registerCoreHandlers( registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) + registerGrokAccountHandlers() registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) diff --git a/src/main/rate-limits/grok-auth.test.ts b/src/main/rate-limits/grok-auth.test.ts new file mode 100644 index 000000000..793e3e7a6 --- /dev/null +++ b/src/main/rate-limits/grok-auth.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('readGrokAuthSession', () => { + afterEach(() => { + vi.resetModules() + vi.doUnmock('node:fs') + }) + + it('redacts filesystem paths from auth read failures', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => { + throw new Error( + 'EACCES: permission denied, open /Users/brennanbenson/private/.grok/auth.json' + ) + }) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'error', + error: 'Unable to read Grok auth file' + }) + }) + + it('treats a token-less auth file as signed out, not an error', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => JSON.stringify({ 'https://auth.x.ai::client': { user_id: 'u1' } })) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ status: 'missing' }) + }) + + it('reports malformed auth JSON without parser details', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => '{') + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'error', + error: 'Grok auth file is invalid' + }) + }) +}) diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts new file mode 100644 index 000000000..508e0569b --- /dev/null +++ b/src/main/rate-limits/grok-auth.ts @@ -0,0 +1,116 @@ +import { existsSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +// Why: when GROK_HOME is set, auth.json must be the same path Grok CLI uses. +export function getGrokHome(): string { + return process.env.GROK_HOME?.trim() || join(homedir(), '.grok') +} + +export function getGrokAuthPath(): string { + return join(getGrokHome(), 'auth.json') +} + +export type GrokAuthSession = { + accessToken: string + userId: string | null + email: string | null + teamId: string | null + expiresAtMs: number | null + oidcClientId: string | null +} + +type GrokAuthEntry = { + key?: string + user_id?: string + email?: string + team_id?: string + expires_at?: string + oidc_client_id?: string +} + +export type GrokAuthReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'ok'; session: GrokAuthSession } + +function getGrokAuthReadError(err: unknown): string { + if (err instanceof SyntaxError) { + return 'Grok auth file is invalid' + } + // Why: filesystem errors often include the full auth path; renderer/mobile + // account state should not expose local usernames or custom GROK_HOME values. + return 'Unable to read Grok auth file' +} + +function parseAuthEntry(value: unknown): GrokAuthEntry | null { + if (typeof value !== 'object' || value === null) { + return null + } + const entry = value as GrokAuthEntry + if (typeof entry.key !== 'string' || entry.key.length === 0) { + return null + } + return entry +} + +function parseExpiresAtMs(iso: string | undefined): number | null { + if (!iso) { + return null + } + const ms = Date.parse(iso) + return Number.isFinite(ms) ? ms : null +} + +export function readGrokAuthSession(): GrokAuthReadResult { + const path = getGrokAuthPath() + if (!existsSync(path)) { + return { status: 'missing' } + } + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) + if (typeof parsed !== 'object' || parsed === null) { + return { status: 'error', error: 'Grok auth file is invalid' } + } + for (const entry of Object.values(parsed)) { + const authEntry = parseAuthEntry(entry) + if (!authEntry?.key) { + continue + } + return { + status: 'ok', + session: { + accessToken: authEntry.key, + userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null, + email: typeof authEntry.email === 'string' ? authEntry.email : null, + teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null, + expiresAtMs: parseExpiresAtMs(authEntry.expires_at), + oidcClientId: + typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null + } + } + } + // Why: a token-less file (e.g. after grok logout) means signed out, not a + // failure — 'error' would keep a status-bar alert visible for that user. + return { status: 'missing' } + } catch (err) { + return { + status: 'error', + error: getGrokAuthReadError(err) + } + } +} + +export function hasGrokAuthSession(): boolean { + return readGrokAuthSession().status === 'ok' +} + +const TOKEN_SKEW_MS = 5 * 60 * 1000 + +export function isGrokAccessTokenFresh(session: GrokAuthSession): boolean { + if (session.expiresAtMs === null) { + // Why: auth.json may lack expiry; a bad token still surfaces as billing HTTP 401. + return true + } + return session.expiresAtMs - Date.now() > TOKEN_SKEW_MS +} diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts new file mode 100644 index 000000000..f816d5c55 --- /dev/null +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const netFetchMock = vi.hoisted(() => vi.fn()) +const authState = vi.hoisted<{ + file: string | null + readError: Error | null +}>(() => ({ file: null, readError: null })) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock } +})) + +vi.mock('node:fs', () => ({ + existsSync: () => authState.file !== null, + readFileSync: () => { + if (authState.readError) { + throw authState.readError + } + if (authState.file === null) { + throw new Error('ENOENT') + } + return authState.file + } +})) + +vi.mock('node:os', () => ({ homedir: () => '/home/test' })) + +import { fetchGrokRateLimits } from './grok-fetcher' + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body + } as Response +} + +const BILLING_RESPONSE = { + config: { + creditUsagePercent: 42, + currentPeriod: { + type: 'USAGE_PERIOD_TYPE_WEEKLY', + start: '2026-06-30T18:36:14.268512+00:00', + end: '2026-07-07T18:36:14.268512+00:00' + }, + subscriptionTier: 'SuperGrok', + isUnifiedBillingUser: true + } +} + +function freshAuthJson(): string { + return JSON.stringify({ + 'https://auth.x.ai::client': { + key: 'access-token', + user_id: 'user-1', + email: 'dev@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) +} + +describe('fetchGrokRateLimits', () => { + beforeEach(() => { + netFetchMock.mockReset() + authState.file = null + authState.readError = null + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('returns unavailable when not signed in', async () => { + const result = await fetchGrokRateLimits() + expect(result.provider).toBe('grok') + expect(result.status).toBe('unavailable') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('maps weekly credit usage from billing config', async () => { + authState.file = freshAuthJson() + netFetchMock.mockResolvedValueOnce(jsonResponse(BILLING_RESPONSE)) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('ok') + expect(result.weekly?.usedPercent).toBe(42) + expect(result.weekly?.windowMinutes).toBe(10_080) + expect(result.usageMetadata?.source).toBe('oauth') + expect(result.usageMetadata?.authProvenance).toContain('SuperGrok') + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://cli-chat-proxy.grok.com/v1/billing?format=credits', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + 'X-XAI-Token-Auth': 'xai-grok-cli', + 'x-userid': 'user-1' + }) + }) + ) + }) + + it('returns unavailable when not signed in even if a token-less auth file exists', async () => { + authState.file = JSON.stringify({}) + const result = await fetchGrokRateLimits() + expect(result.status).toBe('unavailable') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('returns unavailable when billing has no credit usage', async () => { + authState.file = freshAuthJson() + netFetchMock.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } })) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('unavailable') + expect(result.weekly).toBeNull() + }) + + it('returns unavailable when billing response has no config', async () => { + authState.file = freshAuthJson() + netFetchMock.mockResolvedValueOnce(jsonResponse({})) + + const result = await fetchGrokRateLimits() + expect(result.status).toBe('unavailable') + }) + + it('aborts the billing request when the caller aborts', async () => { + authState.file = freshAuthJson() + const controller = new AbortController() + let requestSignal: AbortSignal | undefined + netFetchMock.mockImplementationOnce((_url, init: RequestInit) => { + requestSignal = init.signal as AbortSignal + return new Promise((_resolve, reject) => { + requestSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + }) + + const resultPromise = fetchGrokRateLimits({ signal: controller.signal }) + await Promise.resolve() + + expect(requestSignal?.aborted).toBe(false) + controller.abort() + expect(requestSignal?.aborted).toBe(true) + + const result = await resultPromise + expect(result.status).toBe('error') + expect(result.error).toBe('aborted') + }) + + it('returns error when the session token is expired', async () => { + authState.file = JSON.stringify({ + 'https://auth.x.ai::client': { + key: 'stale', + expires_at: '2000-01-01T00:00:00.000Z' + } + }) + const result = await fetchGrokRateLimits() + expect(result.status).toBe('error') + expect(result.error).toMatch(/expired/i) + expect(netFetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts new file mode 100644 index 000000000..963a2fb13 --- /dev/null +++ b/src/main/rate-limits/grok-fetcher.ts @@ -0,0 +1,173 @@ +import { net } from 'electron' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { + isGrokAccessTokenFresh, + readGrokAuthSession, + type GrokAuthReadResult, + type GrokAuthSession +} from './grok-auth' + +// Why: billing URL and headers must match Grok CLI or xAI rejects the request. +const GROK_CLI_PROXY_BASE = + process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim().replace(/\/$/, '') || + 'https://cli-chat-proxy.grok.com/v1' +const BILLING_CREDITS_URL = `${GROK_CLI_PROXY_BASE}/billing?format=credits` +const API_TIMEOUT_MS = 10_000 +const WEEKLY_WINDOW_MINUTES = 10_080 + +const GROK_CLI_AUTH_HEADER = 'xai-grok-cli' + +type GrokMoneyVal = { val?: string | number } + +type GrokUsagePeriod = { + type?: string + start?: string + end?: string +} + +type GrokBillingConfig = { + creditUsagePercent?: number + currentPeriod?: GrokUsagePeriod + billingPeriodStart?: string + billingPeriodEnd?: string + subscriptionTier?: string + onDemandCap?: GrokMoneyVal + onDemandUsed?: GrokMoneyVal + prepaidBalance?: GrokMoneyVal + isUnifiedBillingUser?: boolean +} + +type GrokBillingResponse = GrokBillingConfig & { + config?: GrokBillingConfig +} + +function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits { + return { + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status + } +} + +function parseResetDescription(isoString: string | undefined): string | null { + if (!isoString) { + return null + } + const date = new Date(isoString) + if (Number.isNaN(date.getTime())) { + return null + } + const isToday = date.toDateString() === new Date().toDateString() + return isToday + ? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + : date.toLocaleDateString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' }) +} + +function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null { + const usedPercent = config.creditUsagePercent + if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) { + return null + } + const periodEnd = config.currentPeriod?.end ?? config.billingPeriodEnd + const resetsAt = periodEnd ? Date.parse(periodEnd) : null + return { + usedPercent: Math.min(100, Math.max(0, usedPercent)), + windowMinutes: WEEKLY_WINDOW_MINUTES, + resetsAt: resetsAt !== null && Number.isFinite(resetsAt) ? resetsAt : null, + resetDescription: parseResetDescription(periodEnd) + } +} + +function grokRequestHeaders(session: GrokAuthSession): Record { + const headers: Record = { + Authorization: `Bearer ${session.accessToken}`, + 'X-XAI-Token-Auth': GROK_CLI_AUTH_HEADER, + Accept: 'application/json' + } + if (session.userId) { + headers['x-userid'] = session.userId + } + return headers +} + +function resolveBillingConfig(data: GrokBillingResponse): GrokBillingConfig | null { + if (data.config) { + return data.config + } + if (typeof data.creditUsagePercent === 'number') { + return data + } + return null +} + +function mapBillingResponse( + data: GrokBillingResponse, + session: GrokAuthSession +): ProviderRateLimits { + const config = resolveBillingConfig(data) + // Why: a 200 without credit usage means the plan has no weekly credits — + // 'unavailable' hides the bar (like Claude on API-key billing); 'error' + // would paint a permanent alert for a signed-in account that has no quota. + if (!config) { + return result('unavailable', 'Grok billing response did not include config') + } + const weekly = mapWeeklyCredits(config) + const tier = config.subscriptionTier?.trim() + const authLabel = session.email?.trim() || session.userId || 'Grok account' + const provenance = tier ? `${authLabel} (${tier})` : authLabel + return { + provider: 'grok', + session: null, + weekly, + updatedAt: Date.now(), + error: weekly ? null : 'Grok billing response did not include credit usage', + status: weekly ? 'ok' : 'unavailable', + usageMetadata: { + source: 'oauth', + authProvenance: provenance + } + } +} + +// Why: Orca never runs grok login; it only reads the session file the CLI updates. +export async function fetchGrokRateLimits( + options: { signal?: AbortSignal; authReadResult?: GrokAuthReadResult } = {} +): Promise { + const readResult = options.authReadResult ?? readGrokAuthSession() + if (readResult.status === 'missing') { + return result('unavailable', 'Not signed in to Grok — run grok login') + } + if (readResult.status === 'error') { + return result('error', readResult.error) + } + const session = readResult.session + if (!isGrokAccessTokenFresh(session)) { + return result('error', 'Grok session expired — run grok login to refresh') + } + + try { + const signal = options.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + const res = await net.fetch(BILLING_CREDITS_URL, { + headers: grokRequestHeaders(session), + signal + }) + if (res.status === 401 || res.status === 403) { + return result('error', `Grok usage request unauthorized (HTTP ${res.status})`) + } + if (!res.ok) { + return result('error', `Grok usage request failed (HTTP ${res.status})`) + } + const data: unknown = await res.json() + return mapBillingResponse( + typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {}, + session + ) + } catch (err) { + return result('error', err instanceof Error ? err.message : 'Grok usage request failed') + } +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 082dcf282..166442ee0 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -9,7 +9,10 @@ import { RateLimitService } from './service' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax-fetcher' +import { fetchGrokRateLimits } from './grok-fetcher' +import { readGrokAuthSession } from './grok-auth' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' @@ -30,10 +33,22 @@ vi.mock('./opencode-go-usage-fetcher', () => ({ fetchOpenCodeGoRateLimits: vi.fn() })) +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: vi.fn() +})) + vi.mock('./minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: vi.fn() +})) + +vi.mock('./grok-auth', () => ({ + readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) @@ -51,8 +66,14 @@ function deferred(): Deferred { return { promise, resolve } } +async function flushMicrotasks(times = 4): Promise { + for (let i = 0; i < times; i += 1) { + await Promise.resolve() + } +} + function okProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax', + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok', usedPercent: number, updatedAt = Date.now() ): ProviderRateLimits { @@ -72,7 +93,7 @@ function okProvider( } function errorProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax', + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok', message: string ): ProviderRateLimits { return { @@ -126,8 +147,72 @@ describe('RateLimitService', () => { vi.clearAllMocks() vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now())) + vi.mocked(fetchKimiRateLimits).mockResolvedValue(okProvider('kimi', 0, Date.now())) vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now())) + vi.mocked(fetchGrokRateLimits).mockResolvedValue({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: null, + status: 'unavailable' + }) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) + vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) + }) + + it('does not reread Grok auth when callers read state snapshots', () => { + vi.mocked(readGrokAuthSession).mockReturnValue({ + status: 'ok', + session: { + accessToken: 'token', + userId: null, + email: null, + teamId: null, + expiresAtMs: null, + oidcClientId: null + } + }) + const service = new RateLimitService() + vi.mocked(readGrokAuthSession).mockClear() + + expect(service.getState().grokAuthConfigured).toBe(true) + service.getState() + + expect(readGrokAuthSession).not.toHaveBeenCalled() + }) + + it('refreshes Grok without refreshing other providers', async () => { + const authReadResult = { + status: 'ok' as const, + session: { + accessToken: 'token', + userId: null, + email: 'dev@example.com', + teamId: null, + expiresAtMs: null, + oidcClientId: null + } + } + vi.mocked(readGrokAuthSession).mockReturnValue(authReadResult) + vi.mocked(fetchGrokRateLimits).mockResolvedValueOnce(okProvider('grok', 42)) + const service = new RateLimitService() + + await service.refreshGrok() + + expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGrokRateLimits).toHaveBeenCalledWith({ + authReadResult, + signal: expect.any(AbortSignal) + }) + expect(fetchClaudeRateLimits).not.toHaveBeenCalled() + expect(fetchCodexRateLimits).not.toHaveBeenCalled() + expect(fetchGeminiRateLimits).not.toHaveBeenCalled() + expect(fetchOpenCodeGoRateLimits).not.toHaveBeenCalled() + expect(fetchKimiRateLimits).not.toHaveBeenCalled() + expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled() + expect(service.getState().grokAuthConfigured).toBe(true) + expect(service.getState().grok?.status).toBe('ok') }) it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => { @@ -345,9 +430,47 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) }) + it('publishes non-Grok provider results before a slow Grok fetch completes', async () => { + const service = new RateLimitService() + const grok = deferred() + let refreshResolved = false + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( + okProvider('opencode-go', 40, Date.now()) + ) + vi.mocked(fetchKimiRateLimits).mockResolvedValueOnce(okProvider('kimi', 50, Date.now())) + vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 60, Date.now())) + vi.mocked(fetchGrokRateLimits).mockReturnValueOnce(grok.promise) + + const refresh = service.refresh().then(() => { + refreshResolved = true + }) + await flushMicrotasks() + + const pendingGrokState = service.getState() + expect(pendingGrokState.claude?.status).toBe('ok') + expect(pendingGrokState.codex?.status).toBe('ok') + expect(pendingGrokState.gemini?.status).toBe('ok') + expect(pendingGrokState.opencodeGo?.status).toBe('ok') + expect(pendingGrokState.kimi?.status).toBe('ok') + expect(pendingGrokState.minimax?.status).toBe('ok') + expect(pendingGrokState.grok?.status).toBe('fetching') + expect(refreshResolved).toBe(false) + + grok.resolve(okProvider('grok', 70, Date.now())) + await refresh + + const completedState = service.getState() + expect(completedState.grok?.status).toBe('ok') + expect(refreshResolved).toBe(true) + }) + it('aborts the active fetch cycle and clears queued refreshes on stop', async () => { const service = new RateLimitService() - const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal } = {} + const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal; grok?: AbortSignal } = {} vi.mocked(fetchClaudeRateLimits).mockImplementation( (options) => @@ -371,6 +494,17 @@ describe('RateLimitService', () => { ) }) ) + vi.mocked(fetchGrokRateLimits).mockImplementation( + (options) => + new Promise((resolve) => { + capturedSignals.grok = options?.signal + options?.signal?.addEventListener( + 'abort', + () => resolve(errorProvider('grok', 'aborted')), + { once: true } + ) + }) + ) const activeFetch = serviceInternals(service).fetchAll() await Promise.resolve() @@ -383,12 +517,14 @@ describe('RateLimitService', () => { expect(capturedSignals.claude?.aborted).toBe(true) expect(capturedSignals.codex?.aborted).toBe(true) + expect(capturedSignals.grok?.aborted).toBe(true) await queuedRefresh await activeFetch expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) }) it('aborts inactive Claude preview fetches on stop', async () => { @@ -480,6 +616,10 @@ describe('RateLimitService', () => { expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith('session=abc123', undefined) + expect(fetchGrokRateLimits).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + authReadResult: { status: 'missing' } + }) const state = service.getState() expect(state.claude?.status).toBe('ok') diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index d270c6773..cbfd8289b 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -21,6 +21,8 @@ import { } from '../claude-accounts/runtime-selection' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' +import { fetchGrokRateLimits } from './grok-fetcher' +import { readGrokAuthSession } from './grok-auth' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' import { fetchMiniMaxRateLimits } from './minimax-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' @@ -78,6 +80,7 @@ type InternalRateLimitState = { opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null minimax: ProviderRateLimits | null + grok: ProviderRateLimits | null } function normalizePollingInterval(ms: number): number { @@ -110,8 +113,10 @@ export class RateLimitService { gemini: null, opencodeGo: null, kimi: null, - minimax: null + minimax: null, + grok: null } + private grokAuthConfigured = readGrokAuthSession().status === 'ok' private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null private deferredStartupRefreshTimer: ReturnType | null = null @@ -122,6 +127,7 @@ export class RateLimitService { private fullFetchQueued = false private codexOnlyFetchQueued = false private claudeOnlyFetchQueued = false + private grokOnlyFetchQueued = false private activeFetchAbortControllers = new Set() private fetchIdleResolvers: (() => void)[] = [] private codexFetchGeneration = 0 @@ -269,6 +275,7 @@ export class RateLimitService { // its presence on the pushed state so the renderer keeps the MiniMax // bar visible across reloads and between snapshot refreshes. minimaxCookieConfigured: hasMiniMaxSessionCookie(), + grokAuthConfigured: this.grokAuthConfigured, claudeTarget: this.claudeFetchTarget, codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( @@ -291,6 +298,11 @@ export class RateLimitService { return this.getState() } + async refreshGrok(): Promise { + await this.fetchGrokOnly({ force: true }) + return this.getState() + } + invalidateMiniMaxCredentialState(): void { this.minimaxFetchGeneration += 1 // Why: saving or forgetting the browser cookie can race an in-flight usage @@ -740,6 +752,15 @@ export class RateLimitService { break } } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } } } finally { this.isFetching = false @@ -790,6 +811,15 @@ export class RateLimitService { break } } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } } } finally { this.isFetching = false @@ -840,6 +870,74 @@ export class RateLimitService { break } } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + private async fetchGrokOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.grokOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + shouldContinue = false + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal) + ) + if (claudeSignal.aborted) { + break + } + } } } finally { this.isFetching = false @@ -852,7 +950,8 @@ export class RateLimitService { !this.isFetching && !this.fullFetchQueued && !this.codexOnlyFetchQueued && - !this.claudeOnlyFetchQueued + !this.claudeOnlyFetchQueued && + !this.grokOnlyFetchQueued ) { return Promise.resolve() } @@ -869,7 +968,8 @@ export class RateLimitService { this.isFetching || this.fullFetchQueued || this.codexOnlyFetchQueued || - this.claudeOnlyFetchQueued + this.claudeOnlyFetchQueued || + this.grokOnlyFetchQueued ) { return } @@ -913,6 +1013,7 @@ export class RateLimitService { this.fullFetchQueued = false this.codexOnlyFetchQueued = false this.claudeOnlyFetchQueued = false + this.grokOnlyFetchQueued = false } private resolveAndClearFetchIdleWaiters(): void { @@ -1025,7 +1126,7 @@ export class RateLimitService { private withFetchingStatus( current: ProviderRateLimits | null, - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' ): ProviderRateLimits { if (!current) { return { @@ -1064,6 +1165,10 @@ export class RateLimitService { const miniMaxGroupId = miniMaxConfigResult.config.groupId const miniMaxModels = miniMaxConfigResult.config.models const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false + // Why: getState() is used by renderer pushes and mobile snapshots; keep + // Grok's sync auth-file probe on fetch cycles instead of every state read. + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' // Detect if configuration changed — if it did, we must discard any stale // data because it belongs to a different session/workspace. @@ -1097,12 +1202,21 @@ export class RateLimitService { kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), minimax: miniMaxConfigChanged ? this.withFetchingStatus(null, 'minimax') - : this.withFetchingStatus(previousState.minimax, 'minimax') + : this.withFetchingStatus(previousState.minimax, 'minimax'), + grok: this.withFetchingStatus(previousState.grok, 'grok') }) const missingWslCodexHome = codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) + const grokResultPromise = fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = await Promise.allSettled([ fetchClaudeRateLimits({ @@ -1237,7 +1351,7 @@ export class RateLimitService { // generation still match, otherwise an old account could overwrite the // newly selected account's quota state. this.updateState({ - ...previousState, + ...this.state, claude: shouldApplyClaude ? this.applyStalePolicy(claude, previousState.claude) : this.state.claude, @@ -1258,6 +1372,26 @@ export class RateLimitService { : this.state.minimax }) + const grokResult = await grokResultPromise + if (signal.aborted) { + return + } + const grok = + grokResult.status === 'fulfilled' + ? grokResult.value + : ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + this.lastFetchAt = Date.now() } @@ -1374,6 +1508,45 @@ export class RateLimitService { this.lastFetchAt = Date.now() } + private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const previousState = this.state + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + + this.updateState({ + ...previousState, + grok: this.withFetchingStatus(previousState.grok, 'grok') + }) + + const grok = await fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).catch( + (err): ProviderRateLimits => ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + }) + ) + + if (signal.aborted) { + return + } + + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + + this.lastFetchAt = Date.now() + } + private applyStalePolicy( fresh: ProviderRateLimits, previous: ProviderRateLimits | null diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 305a15a21..662770037 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6859,11 +6859,11 @@ export class OrcaRuntimeService { // `rateLimits:update` IPC channel desktop already uses. onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { const services = this.requireAccountServices() - return services.rateLimits.onStateChange(() => { + return services.rateLimits.onStateChange((rateLimits) => { listener({ claude: services.claudeAccounts.listAccounts(), codex: services.codexAccounts.listAccounts(), - rateLimits: services.rateLimits.getState() + rateLimits }) }) } diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 4c692264c..2af71e3ab 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -182,10 +182,11 @@ describe('client UI RPC methods', () => { ...getDefaultUIState(), worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex', 'kimi', 'minimax', 'ports'], + statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', @@ -221,10 +222,11 @@ describe('client UI RPC methods', () => { const payload = { worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex', 'kimi', 'minimax', 'ports'], + statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index b5991f669..995959228 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -47,6 +47,7 @@ const StatusBarItem = z.enum([ 'opencode-go', 'kimi', 'minimax', + 'grok', 'ssh', 'resource-usage', 'ports' @@ -203,6 +204,7 @@ const UiUpdate = z _portsStatusBarDefaultAdded: z.boolean().optional(), _kimiStatusBarDefaultAdded: z.boolean().optional(), _minimaxStatusBarDefaultAdded: z.boolean().optional(), + _grokStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), dismissedUpdateVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3496e31a0..fc6ffd789 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -352,6 +352,7 @@ import type { } from '../shared/claude-usage-types' import type { CodexRateLimitResetResult, + GrokAccountStatus, RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limit-types' @@ -2876,6 +2877,7 @@ export type PreloadApi = { fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise refreshMiniMax: () => Promise + refreshGrok: () => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void } minimaxCredentials: { @@ -2883,6 +2885,9 @@ export type PreloadApi = { saveCookie: (cookie: string) => Promise<{ configured: boolean }> clearCookie: () => Promise<{ configured: boolean }> } + grokAccounts: { + getStatus: () => Promise + } ssh: { listTargets: () => Promise // Removed-target id → last known label, for showing a friendly host name on diff --git a/src/preload/index.ts b/src/preload/index.ts index 049acbb20..5263b10ef 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -79,6 +79,7 @@ import type { } from '../shared/mobile-markdown-document' import type { CodexRateLimitResetResult, + GrokAccountStatus, RateLimitRuntimeTarget, RateLimitState } from '../shared/rate-limit-types' @@ -3851,6 +3852,7 @@ const api = { fetchInactiveCodexAccounts: (): Promise => ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), + refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) ipcRenderer.on('rateLimits:update', listener) @@ -3867,6 +3869,10 @@ const api = { ipcRenderer.invoke('minimaxCredentials:clearCookie') }, + grokAccounts: { + getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') + }, + ssh: { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 7719dfd9e..dbd4b752e 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -43,10 +43,12 @@ import { getAccountsCodexSearchEntries, getAccountsGeminiSearchEntries, getAccountsLocationSearchEntries, + getAccountsGrokSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' +import { GrokAccountsSection } from './GrokAccountsSection' import { SearchableSetting } from './SearchableSetting' import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' import { matchesSettingsSearch } from './settings-search' @@ -1796,6 +1798,9 @@ export function AccountsPane({ + ) : null, + matchesSettingsSearch(searchQuery, getAccountsGrokSearchEntries()) ? ( + ) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 217319665..97d53297e 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -43,7 +43,8 @@ function recordStatusBarToggleInteraction( id === 'gemini' || id === 'opencode-go' || id === 'kimi' || - id === 'minimax' + id === 'minimax' || + id === 'grok' ) { recordFeatureInteraction('usage-tracking') } diff --git a/src/renderer/src/components/settings/GrokAccountsSection.tsx b/src/renderer/src/components/settings/GrokAccountsSection.tsx new file mode 100644 index 000000000..a5eb0ca8d --- /dev/null +++ b/src/renderer/src/components/settings/GrokAccountsSection.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useState } from 'react' +import { ExternalLink, Loader2, RefreshCw, ShieldCheck } from 'lucide-react' +import { AgentIcon } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { useAppStore } from '../../store' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import type { GrokAccountStatus } from '../../../../shared/rate-limit-types' +import { SearchableSetting } from './SearchableSetting' +const GROK_CLI_DOCS_URL = 'https://docs.x.ai/build/overview' + +export function GrokAccountsSection(): React.JSX.Element { + const refreshGrokRateLimits = useAppStore((s) => s.refreshGrokRateLimits) + const grokUsage = useAppStore((s) => s.rateLimits.grok) + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const loadStatus = useCallback(async (): Promise => { + try { + const next = await window.api.grokAccounts.getStatus() + setStatus(next) + } catch (error) { + console.error('Failed to load Grok account status:', error) + setStatus({ + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: error instanceof Error ? error.message : 'Unable to read Grok sign-in' + }) + } finally { + setLoading(false) + } + }, []) + + // Why: after a background usage fetch, sign-in state may change — reload status then. + useEffect(() => { + void loadStatus() + }, [loadStatus, grokUsage?.updatedAt]) + + const handleRefreshUsage = async (): Promise => { + setRefreshing(true) + try { + await refreshGrokRateLimits() + await loadStatus() + } finally { + setRefreshing(false) + } + } + + const signedIn = status?.signedIn === true + const tokenFresh = status?.tokenFresh === true + + return ( +
+
+
+

+ + {translate('auto.components.settings.GrokAccountsSection.a1b2c3d4e5', 'Grok (xAI)')} +

+

+ {translate( + 'auto.components.settings.GrokAccountsSection.f6e5d4c3b2', + 'Shows weekly credit usage from your Grok CLI sign-in (session file ~/.grok/auth.json).' + )} +

+
+ + {translate('auto.components.settings.GrokAccountsSection.0d8e77bc40', 'Grok CLI docs')} + + +
+ +
+ +
+ {loading ? ( +

+ {translate('auto.components.settings.GrokAccountsSection.ad47a33f72', 'Loading…')} +

+ ) : signedIn ? ( + <> +

+ {status?.email ?? + translate('auto.components.settings.GrokAccountsSection.b2c3d4e5f6', 'Signed in')} +

+

+ {tokenFresh + ? translate( + 'auto.components.settings.GrokAccountsSection.c3d4e5f6a7', + 'Signed in. Orca only reads that file on disk — run grok login again if usage fails.' + ) + : translate( + 'auto.components.settings.GrokAccountsSection.d4e5f6a7b8', + 'Session expired — run grok login in a terminal to refresh.' + )} +

+ + ) : ( + <> +

+ {translate( + 'auto.components.settings.GrokAccountsSection.e5f6a7b8c9', + 'Not signed in to Grok CLI' + )} +

+

+ {translate( + 'auto.components.settings.GrokAccountsSection.f6a7b8c9d0', + 'In a terminal, run grok login, then click Refresh usage here.' + )} +

+ + )} + {status?.error ?

{status.error}

: null} +
+ +
+ + {grokUsage?.weekly ? ( + +
+ + {Math.round(grokUsage.weekly.usedPercent)}% + + {grokUsage.weekly.resetDescription ? ( + + {translate( + 'auto.components.settings.GrokAccountsSection.c6d1a8f4e2', + 'Resets {{when}}', + { when: grokUsage.weekly.resetDescription } + )} + + ) : null} + {grokUsage.usageMetadata?.authProvenance ? ( + + {grokUsage.usageMetadata.authProvenance} + + ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index bfe82c9d7..3b2fc0663 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1490,7 +1490,7 @@ function Settings(): React.JSX.Element { title={translate('auto.components.settings.Settings.954a8f5aef', 'Stats & Usage')} description={translate( 'auto.components.settings.Settings.8acf3f22e0', - 'Orca stats plus Claude, Codex, and OpenCode usage analytics.' + 'Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.' )} searchEntries={getSectionSearchEntries('stats')} > diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 7f0d68a83..6ff07366b 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -191,11 +191,33 @@ export const getAccountsMiniMaxSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsGrokSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.f4a8c2e1b7', 'Grok (xAI) Usage'), + description: translate( + 'auto.components.settings.accounts.search.e3b7d1f9a2', + 'OAuth sign-in via Grok CLI (grok login) for weekly credit usage.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.d2c6a0e8f1', 'grok'), + ...translateSearchKeyword('auto.components.settings.accounts.search.c1b5f9d7e0', 'xai'), + ...translateSearchKeyword('auto.components.settings.accounts.search.b0a4e8c6d9', 'oauth'), + ...translateSearchKeyword('auto.components.settings.accounts.search.a9f3d7b5c8', 'login'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.e949b08ffb', + 'rate limit' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.86edc96bc9', 'status bar') + ] + } +]) + export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), ...getAccountsCodexSearchEntries(), ...getAccountsGeminiSearchEntries(), ...getAccountsOpencodeSearchEntries(), - ...getAccountsMiniMaxSearchEntries() + ...getAccountsMiniMaxSearchEntries(), + ...getAccountsGrokSearchEntries() ]) diff --git a/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts new file mode 100644 index 000000000..3ff429523 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts @@ -0,0 +1,37 @@ +import type { StatusBarItem } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getGrokStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'grok', + title: translate('auto.components.settings.appearance.search.f8e2a1c4b6', 'Grok Usage'), + description: translate( + 'auto.components.settings.appearance.search.e7d1b0f3a5', + 'Show Grok weekly credit usage from Grok CLI OAuth.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.d6c0a9e2f4', 'grok'), + ...translateSearchKeyword('auto.components.settings.appearance.search.c5b9f8d1e3', 'xai'), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ) + ], + toggleDescription: translate( + 'settings.appearance.statusBar.grokToggleDescription', + 'Show Grok subscription credit usage when signed in via Grok CLI.' + ) + } +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 673deda4b..9044c084f 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -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 { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search' export const getStatusBarToggles = createLocalizedCatalog( (): readonly { @@ -196,6 +197,7 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show MiniMax subscription usage for the active workspace.' ) }, + getGrokStatusBarToggleSearchEntry(), { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), diff --git a/src/renderer/src/components/stats/GrokUsagePane.test.tsx b/src/renderer/src/components/stats/GrokUsagePane.test.tsx new file mode 100644 index 000000000..6e9423631 --- /dev/null +++ b/src/renderer/src/components/stats/GrokUsagePane.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../../store' + +const storeMocks = vi.hoisted(() => ({ + refreshGrokRateLimits: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn(), + recordFeatureInteraction: vi.fn() +})) + +const mockStoreState = { + rateLimits: { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + minimax: null, + grok: { + provider: 'grok', + session: null, + weekly: { + usedPercent: 42, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: 'Tue' + }, + updatedAt: 1, + error: null, + status: 'ok' + }, + minimaxCookieConfigured: false, + grokAuthConfigured: true, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + }, + refreshGrokRateLimits: storeMocks.refreshGrokRateLimits, + openSettingsPage: storeMocks.openSettingsPage, + openSettingsTarget: storeMocks.openSettingsTarget, + recordFeatureInteraction: storeMocks.recordFeatureInteraction +} satisfies Partial + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: Partial) => unknown) => selector(mockStoreState), + { + getState: () => mockStoreState + } + ) +})) + +vi.mock('../ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + values + ? Object.entries(values).reduce( + (text, [token, value]) => text.replace(`{{${token}}}`, value), + fallback + ) + : fallback +})) + +import { GrokUsagePane } from './GrokUsagePane' + +describe('GrokUsagePane', () => { + beforeEach(() => { + storeMocks.refreshGrokRateLimits.mockResolvedValue(undefined) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('does not refresh all providers just from opening the Grok tab', () => { + render() + + expect(screen.getByTestId('grok-usage-pane')).toBeInTheDocument() + expect(storeMocks.refreshGrokRateLimits).not.toHaveBeenCalled() + }) + + it('refreshes usage only from the explicit refresh button', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Refresh Grok usage' })) + + expect(storeMocks.refreshGrokRateLimits).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/stats/GrokUsagePane.tsx b/src/renderer/src/components/stats/GrokUsagePane.tsx new file mode 100644 index 000000000..8e51049e3 --- /dev/null +++ b/src/renderer/src/components/stats/GrokUsagePane.tsx @@ -0,0 +1,137 @@ +import { CalendarClock, ExternalLink, RefreshCw, Sparkles } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { StatCard } from './StatCard' +import { formatUpdatedAt } from './usage-formatters' + +export function GrokUsagePane(): React.JSX.Element { + const grok = useAppStore((s) => s.rateLimits.grok) + const grokAuthConfigured = useAppStore((s) => s.rateLimits.grokAuthConfigured) + const refreshGrokRateLimits = useAppStore((s) => s.refreshGrokRateLimits) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + + const openGrokAccounts = (): void => { + openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'accounts-grok' }) + openSettingsPage() + } + + const paneTitle = translate('auto.components.stats.GrokUsagePane.g8h9i0j1k2', 'Grok usage') + + if (!grokAuthConfigured) { + return ( +
+
+
+

{paneTitle}

+

+ {translate( + 'auto.components.stats.GrokUsagePane.b2d3e4f5c6', + 'Weekly subscription credits from Grok CLI OAuth (~/.grok/auth.json). Same source as the status bar.' + )} +

+
+
+
+ +
+
+ ) + } + + const weeklyPercent = + grok?.weekly && typeof grok.weekly.usedPercent === 'number' + ? Math.round(grok.weekly.usedPercent) + : null + const isFetching = grok?.status === 'fetching' + + return ( +
+
+
+

{paneTitle}

+

+ {formatUpdatedAt(grok?.updatedAt ?? null)} + {grok?.error + ? translate('auto.components.stats.GrokUsagePane.h9i0j1k2l3', ' • {{value0}}', { + value0: grok.error + }) + : ''} +

+
+
+ + + + + + + {translate('auto.components.stats.GrokUsagePane.d4f5a6b7c8', 'Refresh')} + + + +
+
+ +
+ } + /> + } + /> +
+ + {grok?.usageMetadata?.authProvenance ? ( +

{grok.usageMetadata.authProvenance}

+ ) : null} + +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 5746d00f8..e21437bb2 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -4,6 +4,7 @@ import { useAppStore } from '../../store' import { StatCard } from './StatCard' import { ClaudeUsagePane } from './ClaudeUsagePane' import { CodexUsagePane } from './CodexUsagePane' +import { GrokUsagePane } from './GrokUsagePane' import { OpenCodeUsagePane } from './OpenCodeUsagePane' import { UsageOverviewPane } from './UsageOverviewPane' import { Button } from '../ui/button' @@ -45,7 +46,7 @@ function formatTrackingSince(timestamp: number | null): string { return `Tracking since ${date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` } -type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode' +type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode' | 'grok' const USAGE_ANALYTICS_OPTIONS = [ { @@ -71,6 +72,12 @@ const USAGE_ANALYTICS_OPTIONS = [ get label() { return translate('auto.components.stats.StatsPane.1e696db2f6', 'OpenCode') } + }, + { + id: 'grok', + get label() { + return translate('auto.components.stats.StatsPane.grokUsageTab', 'Grok') + } } ] as const satisfies readonly { id: UsageTab; label: string }[] @@ -193,8 +200,10 @@ export function StatsPane(): React.JSX.Element { ) : activeUsageTab === 'codex' ? ( - ) : ( + ) : activeUsageTab === 'opencode' ? ( + ) : ( + )} diff --git a/src/renderer/src/components/stats/stats-search.ts b/src/renderer/src/components/stats/stats-search.ts index 797c5fba3..08bb306b6 100644 --- a/src/renderer/src/components/stats/stats-search.ts +++ b/src/renderer/src/components/stats/stats-search.ts @@ -6,7 +6,7 @@ export const getStatsPaneSearchEntries = createLocalizedCatalog(() => [ title: translate('auto.components.stats.stats.search.cb2430ae6a', 'Stats & Usage'), description: translate( 'auto.components.stats.stats.search.26bb901fcd', - 'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.' + 'Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.' ), keywords: [ translate('auto.components.stats.stats.search.372debfac0', 'stats'), @@ -20,7 +20,12 @@ export const getStatsPaneSearchEntries = createLocalizedCatalog(() => [ translate('auto.components.stats.stats.search.b77826fca3', 'codex'), translate('auto.components.stats.stats.search.6953af58e6', 'opencode'), translate('auto.components.stats.stats.search.eaf251e183', 'tokens'), - translate('auto.components.stats.stats.search.cb6a9f0334', 'cache') + translate('auto.components.stats.stats.search.cb6a9f0334', 'cache'), + translate('auto.components.stats.stats.search.f8a1b2c3d4', 'grok'), + translate('auto.components.stats.stats.search.e7f0a1b2c3', 'subscription'), + translate('auto.components.stats.stats.search.d6e9f0a1b2', 'credits'), + translate('auto.components.stats.stats.search.a3b6c7d8e9', 'grok usage'), + translate('auto.components.stats.stats.search.9f2a3b4c5d', 'xai') ] } ]) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index d3dd01213..3db1b663f 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1762,7 +1762,9 @@ export function ProviderDetailsMenu({ ? 'K' : provider.provider === 'minimax' ? 'M' - : 'X'} + : provider.provider === 'grok' + ? 'R' + : 'X'} ) : ( @@ -1908,7 +1910,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele return null } - const { claude, codex, gemini, opencodeGo, kimi, minimax } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, 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 @@ -1917,12 +1919,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele // 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. - const usageSettings = { ...settings, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured } + const usageSettings = { + ...settings, + minimaxCookieConfigured: rateLimits.minimaxCookieConfigured, + grokAuthConfigured: rateLimits.grokAuthConfigured + } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) + const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings) const showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -1942,6 +1949,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele // 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') + const showGrok = + visibleGrok !== null && + statusBarItems.includes('grok') && + isStatusBarItemAvailable('grok', detectedAgentIds) // Why: OpenCode Go is a web/cookie-auth provider, not a CLI on PATH, so // detection-gating doesn't apply. const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) @@ -1958,13 +1969,14 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele showOpencodeGo || showKimi || showMiniMax || + showGrok || showResourceUsage // Why: a brand-new user with no provider configured would otherwise see an // empty left side of the status bar and wonder what's missing. Settings are // 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 }, + { claude, codex, gemini, opencodeGo, kimi, minimax, grok }, usageSettings ) // Why: the teaching CTA is a one-time nudge — once the user hides it, keep it @@ -1976,7 +1988,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || kimi?.status === 'fetching' || - minimax?.status === 'fetching' + minimax?.status === 'fetching' || + grok?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -2065,6 +2078,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele )} /> )} + {showGrok && ( + + )} )} {anyVisible && !isEmptyUsageState && ( @@ -2218,6 +2242,18 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele {translate('auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax Usage')} + {isStatusBarItemAvailable('grok', detectedAgentIds) && ( + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('grok') + }} + > + + {translate('auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok Usage')} + + )} { diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index edd6cd97c..b888a52fb 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -18,17 +18,20 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('claude', null)).toBe(true) expect(isStatusBarItemAvailable('codex', null)).toBe(true) expect(isStatusBarItemAvailable('gemini', null)).toBe(true) + expect(isStatusBarItemAvailable('grok', null)).toBe(true) }) it('hides CLI items not detected on PATH', () => { expect(isStatusBarItemAvailable('claude', [])).toBe(false) expect(isStatusBarItemAvailable('codex', ['claude'])).toBe(false) expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) + expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) }) it('shows CLI items detected on PATH', () => { expect(isStatusBarItemAvailable('claude', ['claude'])).toBe(true) expect(isStatusBarItemAvailable('codex', ['codex', 'claude'])).toBe(true) expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) + expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index fbc63b88c..805d7079c 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -6,7 +6,13 @@ import type { StatusBarItem, TuiAgent } from '../../../../shared/types' // PATH detection reports the agent as missing. Pre-detection (null) keeps // the legacy behavior so the bar/toggle don't flicker on cold start, and // re-show automatically once the agent appears on PATH. -const CLI_GATED_ITEMS: ReadonlySet = new Set(['claude', 'codex', 'gemini', 'kimi']) +const CLI_GATED_ITEMS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'gemini', + 'kimi', + 'grok' +]) export function isStatusBarItemAvailable( id: StatusBarItem, 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 23de9434c..2dbba2315 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 @@ -71,6 +71,7 @@ function usageSettings(overrides: Partial = {}): UsagePro opencodeSessionCookie: '', geminiCliOAuthEnabled: false, minimaxCookieConfigured: false, + grokAuthConfigured: false, ...overrides } } @@ -119,6 +120,7 @@ describe('hasUsageProviderSettings', () => { hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) + expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) }) it('does not treat empty or unloaded settings as configured', () => { @@ -160,6 +162,14 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('minimax', usageSettings())).toBe(false) expect(hasUsageProviderSettingsForProvider('minimax', null)).toBe(false) }) + + it('treats grokAuthConfigured as the durable signal for Grok', () => { + expect( + hasUsageProviderSettingsForProvider('grok', usageSettings({ grokAuthConfigured: true })) + ).toBe(true) + expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) + expect(hasUsageProviderSettingsForProvider('grok', null)).toBe(false) + }) }) describe('getVisibleUsageProvider', () => { @@ -235,6 +245,20 @@ describe('getVisibleUsageProvider', () => { }) }) + it('keeps Grok visible while the snapshot is pending when CLI auth is configured', () => { + const visible = getVisibleUsageProvider( + 'grok', + null, + usageSettings({ grokAuthConfigured: true }) + ) + expect(visible).toMatchObject({ + provider: 'grok', + status: 'fetching', + session: null, + weekly: null + }) + }) + it('keeps MiniMax visible when the fetch returns unavailable for a configured cookie', () => { const unavailable = provider('unavailable', { provider: 'minimax', @@ -271,7 +295,8 @@ describe('isUsageEmptyState', () => { gemini: null, opencodeGo: null, kimi: null, - minimax: null + minimax: null, + grok: null }, usageSettings() ) @@ -287,7 +312,8 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), - minimax: provider('unavailable', { provider: 'minimax' }) + minimax: provider('unavailable', { provider: 'minimax' }), + grok: provider('unavailable', { provider: 'grok' }) }, usageSettings() ) @@ -303,7 +329,8 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), - minimax: provider('unavailable', { provider: 'minimax' }) + minimax: provider('unavailable', { provider: 'minimax' }), + grok: provider('unavailable', { provider: 'grok' }) }, usageSettings({ codexManagedAccounts: [ @@ -330,7 +357,8 @@ describe('isUsageEmptyState', () => { gemini: null, opencodeGo: null, kimi: null, - minimax: null + minimax: null, + grok: null }, null ) @@ -346,7 +374,8 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), - minimax: provider('unavailable', { provider: 'minimax' }) + minimax: provider('unavailable', { provider: 'minimax' }), + grok: provider('unavailable', { provider: 'grok' }) }, usageSettings() ) 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 bbac8ba8a..54e7ad1a3 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 @@ -8,11 +8,9 @@ export type UsageProviderSettings = Pick< | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' > & { - // Why: the MiniMax cookie lives in the file system, not GlobalSettings, so - // we can't derive durability from settings alone. The renderer threads the - // flag from RateLimitState (pushed by the main process) so the bar stays - // visible across reloads and between snapshot refreshes. + // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean + grokAuthConfigured: boolean } type UsageProviderSnapshots = { @@ -22,6 +20,7 @@ type UsageProviderSnapshots = { opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null minimax: ProviderRateLimits | null + grok: ProviderRateLimits | null } type UsageProviderId = ProviderRateLimits['provider'] @@ -66,7 +65,8 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || - settings?.minimaxCookieConfigured === true + settings?.minimaxCookieConfigured === true || + settings?.grokAuthConfigured === true ) } @@ -92,6 +92,9 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true } + if (providerId === 'grok') { + return settings.grokAuthConfigured === true + } return false } @@ -140,7 +143,8 @@ export function isUsageEmptyState( isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || isProviderSnapshotPending(providers.kimi) || - isProviderSnapshotPending(providers.minimax) + isProviderSnapshotPending(providers.minimax) || + isProviderSnapshotPending(providers.grok) ) { return false } @@ -151,6 +155,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && !isProviderConfigured(providers.kimi) && - !isProviderConfigured(providers.minimax) + !isProviderConfigured(providers.minimax) && + !isProviderConfigured(providers.grok) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index a0b54f1a0..c013c1104 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -96,6 +96,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'minimax') { return } + if (provider === 'grok') { + return + } return } diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 3d33fe763..f6f9b8d69 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -20,6 +20,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'minimax') { return 'MiniMax' } + if (provider === 'grok') { + return 'Grok' + } return provider } diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 531933be4..03ba64e44 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -154,7 +154,7 @@ export function buildSettingsNavigationMetadata({ ), description: translate( 'auto.hooks.useSettingsNavigationMetadata.b1c2f8b0ac', - 'Optional account switching for Claude, Codex, Gemini, and OpenCode Go.' + 'Optional account switching and usage setup for Claude, Codex, Gemini, OpenCode Go, MiniMax, and Grok.' ), icon: UserCog, searchEntries: getAccountsPaneSearchEntries(), @@ -424,7 +424,7 @@ export function buildSettingsNavigationMetadata({ title: translate('auto.hooks.useSettingsNavigationMetadata.d72a58b5b9', 'Stats & Usage'), description: translate( 'auto.hooks.useSettingsNavigationMetadata.b351014180', - 'Orca stats plus Claude, Codex, and OpenCode usage analytics.' + 'Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.' ), icon: BarChart3, searchEntries: getStatsPaneSearchEntries(), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7c5392933..65e056f58 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "Show MiniMax subscription usage for the active workspace.", "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." + "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", + "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI." } } }, @@ -635,7 +636,7 @@ "94a5afe910": "SSH Hosts", "40d80bad8a": "Beta", "de0c2907a1": "Remote Orca Servers", - "b351014180": "Orca stats plus Claude, Codex, and OpenCode usage analytics.", + "b351014180": "Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.", "d72a58b5b9": "Stats & Usage", "dcd0d9b74f": "Keyboard shortcuts for common actions.", "94295ebfb3": "Shortcuts", @@ -676,7 +677,7 @@ "cd50cec5d7": "Coordinate multiple coding agents through Orca.", "58a868e8e4": "Orchestration", "7c79d3b7bf": "Optional", - "b1c2f8b0ac": "Optional account switching for Claude, Codex, Gemini, and OpenCode Go.", + "b1c2f8b0ac": "Optional account switching and usage setup for Claude, Codex, Gemini, OpenCode Go, MiniMax, and Grok.", "f70ac54d38": "AI Provider Accounts", "4121f7a0a2": "Manage AI agents, set a default, and customize commands.", "b49abbd2f7": "Agents", @@ -3032,7 +3033,9 @@ "c0e972d726": "Cancel", "06741a2f3d": "Open MiniMax usage details", "3bbf140864": "MiniMax Usage", - "remoteServerLabel": "Remote server" + "remoteServerLabel": "Remote server", + "grokUsageAria": "Open Grok usage details", + "grokUsageMenu": "Grok Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "Overview", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "overview" + "eee19cfade": "overview", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "sessions", @@ -3431,8 +3435,13 @@ "0bba8ca244": "statistics", "0e2a0b6431": "usage", "372debfac0": "stats", - "26bb901fcd": "Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.", - "cb2430ae6a": "Stats & Usage" + "26bb901fcd": "Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.", + "cb2430ae6a": "Stats & Usage", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "subscription", + "d6e9f0a1b2": "credits", + "a3b6c7d8e9": "grok usage", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "Input", "a8b7487ff7": "Output", "cfe2282ffa": "Unknown" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Grok usage", + "b2d3e4f5c6": "Weekly subscription credits from Grok CLI OAuth (~/.grok/auth.json). Same source as the status bar.", + "c3e4f5a6b7": "Set up in Accounts", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "Refresh Grok usage", + "d4f5a6b7c8": "Refresh", + "e5a6b7c8d9": "Weekly credits used", + "f6b7c8d9e0": "Billing period reset", + "a7b8c9d0e1": "Grok account settings" } }, "sparse": { @@ -6312,7 +6332,7 @@ "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "Connect this browser to a saved Orca server.", "bd0181eeca": "Remote Orca Servers", - "8acf3f22e0": "Orca stats plus Claude, Codex, and OpenCode usage analytics.", + "8acf3f22e0": "Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.", "954a8f5aef": "Stats & Usage", "a737a4bb22": "Keyboard shortcuts for common actions.", "23bf7a1ad4": "Shortcuts", @@ -6982,7 +7002,13 @@ "b84a5b0c8a": "Choose whether provider accounts are inspected and added on this device or in WSL.", "d09fb5ca92": "Account Location", "733f9e2a93": "MiniMax Usage", - "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching.", + "f4a8c2e1b7": "Grok (xAI) Usage", + "e3b7d1f9a2": "OAuth sign-in via Grok CLI (grok login) for weekly credit usage.", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "oauth", + "a9f3d7b5c8": "login" } }, "advanced": { @@ -7196,7 +7222,11 @@ "cardLayout": "card layout", "workspaceOptions": "workspace options", "detailed": "detailed" - } + }, + "f8e2a1c4b6": "Grok Usage", + "e7d1b0f3a5": "Show Grok weekly credit usage from Grok CLI OAuth.", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8936,6 +8966,21 @@ } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "Shows weekly credit usage from your Grok CLI sign-in (session file ~/.grok/auth.json).", + "0d8e77bc40": "Grok CLI docs", + "ad47a33f72": "Loading…", + "b2c3d4e5f6": "Signed in", + "c3d4e5f6a7": "Signed in. Orca only reads that file on disk — run grok login again if usage fails.", + "d4e5f6a7b8": "Session expired — run grok login in a terminal to refresh.", + "e5f6a7b8c9": "Not signed in to Grok CLI", + "f6a7b8c9d0": "In a terminal, run grok login, then click Refresh usage here.", + "3325d996cb": "Refresh usage", + "a8f3e2c1b4": "Weekly credits", + "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", + "c6d1a8f4e2": "Resets {{when}}" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index de1a6ee9c..7e421b530 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "Muestra el uso de la suscripción de MiniMax para el espacio de trabajo activo.", "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." + "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.", + "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." } } }, @@ -635,7 +636,7 @@ "94a5afe910": "Hosts SSH", "40d80bad8a": "Beta", "de0c2907a1": "Servidores remotos de Orca", - "b351014180": "Estadísticas de Orca más análisis de uso de Claude, Codex y OpenCode.", + "b351014180": "Estadísticas de Orca más análisis de tokens de Claude, Codex y OpenCode y uso de suscripción de Grok.", "d72a58b5b9": "Estadísticas y uso", "dcd0d9b74f": "Atajos de teclado para acciones comunes.", "94295ebfb3": "Atajos", @@ -676,7 +677,7 @@ "cd50cec5d7": "Coordina múltiples coding agents a través de Orca.", "58a868e8e4": "Orquestación", "7c79d3b7bf": "Opcional", - "b1c2f8b0ac": "Cambio de cuenta opcional para Claude, Codex, Gemini y OpenCode Go.", + "b1c2f8b0ac": "Configuración opcional de cuentas y uso para Claude, Codex, Gemini, OpenCode Go, MiniMax y Grok.", "f70ac54d38": "Cuentas de proveedores de IA", "4121f7a0a2": "Administra agentes de IA, define uno predeterminado y personaliza comandos.", "b49abbd2f7": "Agentes", @@ -3032,7 +3033,9 @@ "c0e972d726": "Cancelar", "06741a2f3d": "Abrir detalles de uso de MiniMax", "3bbf140864": "Uso de MiniMax", - "remoteServerLabel": "Servidor remoto" + "remoteServerLabel": "Servidor remoto", + "grokUsageAria": "Abrir detalles de uso de Grok", + "grokUsageMenu": "Uso de Grok" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "Resumen", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "resumen" + "eee19cfade": "resumen", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "sesiones", @@ -3431,8 +3435,13 @@ "0bba8ca244": "estadísticas", "0e2a0b6431": "uso", "372debfac0": "estadísticas", - "26bb901fcd": "Estadísticas de Orca más analíticas de uso combinadas de Claude, Codex y OpenCode: tokens, caché, modelos y sesiones.", - "cb2430ae6a": "Estadísticas y uso" + "26bb901fcd": "Estadísticas de Orca más análisis de tokens de Claude, Codex y OpenCode y uso de suscripción de Grok.", + "cb2430ae6a": "Estadísticas y uso", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "suscripción", + "d6e9f0a1b2": "créditos", + "a3b6c7d8e9": "uso de grok", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "Entrada", "a8b7487ff7": "Salida", "cfe2282ffa": "Desconocido" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Uso de Grok", + "b2d3e4f5c6": "Créditos semanales de suscripción desde OAuth de Grok CLI (~/.grok/auth.json). Es la misma fuente que la barra de estado.", + "c3e4f5a6b7": "Configurar en Cuentas", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "Actualizar uso de Grok", + "d4f5a6b7c8": "Actualizar", + "e5a6b7c8d9": "Créditos semanales usados", + "f6b7c8d9e0": "Restablecimiento del periodo de facturación", + "a7b8c9d0e1": "Configuración de la cuenta de Grok" } }, "sparse": { @@ -6275,7 +6295,7 @@ "b5ee17826b": "Empareja runtimes remotos de Orca para sesiones persistentes, estado remoto más completo y traspaso web o móvil.", "7686cb5c36": "Conecta este navegador a un servidor Orca guardado.", "bd0181eeca": "Servidores remotos de Orca", - "8acf3f22e0": "Estadísticas de Orca más análisis de uso de Claude, Codex y OpenCode.", + "8acf3f22e0": "Estadísticas de Orca más análisis de tokens de Claude, Codex y OpenCode y uso de suscripción de Grok.", "954a8f5aef": "Estadísticas y uso", "a737a4bb22": "Atajos de teclado para acciones comunes.", "23bf7a1ad4": "Atajos", @@ -6945,7 +6965,13 @@ "b84a5b0c8a": "Elija si las cuentas de proveedor se inspeccionan y agregan en este dispositivo o en WSL.", "d09fb5ca92": "Ubicación de cuentas", "733f9e2a93": "Uso de MiniMax", - "f8374c3151": "Pega tu cookie de sesión de platform.minimax.io para obtener límites de uso locales." + "f8374c3151": "Pega tu cookie de sesión de platform.minimax.io para obtener límites de uso locales.", + "f4a8c2e1b7": "Uso de Grok (xAI)", + "e3b7d1f9a2": "Inicio de sesión OAuth mediante Grok CLI (grok login) para el uso de créditos semanales.", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "OAuth", + "a9f3d7b5c8": "inicio de sesión" } }, "advanced": { @@ -7159,7 +7185,11 @@ "detailed": "detallado" }, "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." + "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.", + "f8e2a1c4b6": "Uso de Grok", + "e7d1b0f3a5": "Muestra el uso semanal de créditos de Grok desde OAuth de Grok CLI.", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8936,6 +8966,21 @@ } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "Muestra el uso semanal de créditos desde tu inicio de sesión en Grok CLI (archivo de sesión ~/.grok/auth.json).", + "0d8e77bc40": "Documentación de Grok CLI", + "ad47a33f72": "Cargando…", + "b2c3d4e5f6": "Sesión iniciada", + "c3d4e5f6a7": "Sesión iniciada. Orca solo lee ese archivo en disco; ejecuta grok login de nuevo si falla el uso.", + "d4e5f6a7b8": "Sesión vencida; ejecuta grok login en una terminal para renovarla.", + "e5f6a7b8c9": "No has iniciado sesión en Grok CLI", + "f6a7b8c9d0": "En una terminal, ejecuta grok login y luego haz clic en Actualizar uso aquí.", + "3325d996cb": "Actualizar uso", + "a8f3e2c1b4": "Créditos semanales", + "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", + "c6d1a8f4e2": "Se restablece {{when}}" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0052b0593..0afc75509 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "アクティブなワークスペースの MiniMax サブスクリプション使用状況を表示します。", "sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", - "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。" + "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", + "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" } } }, @@ -635,7 +636,7 @@ "94a5afe910": "SSH ホスト", "40d80bad8a": "ベータ", "de0c2907a1": "リモート Orca サーバー", - "b351014180": "Orca の統計と Claude、Codex、OpenCode の使用状況分析。", + "b351014180": "Orca の統計に加え、Claude、Codex、OpenCode のトークン分析と Grok サブスクリプション使用状況。", "d72a58b5b9": "統計と使用状況", "dcd0d9b74f": "よく使う操作のキーボード ショートカット。", "94295ebfb3": "ショートカット", @@ -676,7 +677,7 @@ "cd50cec5d7": "Orca を通じて複数のコーディング agents を調整します。", "58a868e8e4": "オーケストレーション", "7c79d3b7bf": "任意", - "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go のオプションのアカウント切り替え。", + "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go、MiniMax、Grok の任意のアカウント切り替えと使用量設定。", "f70ac54d38": "AI プロバイダー アカウント", "4121f7a0a2": "AI agents を管理し、デフォルトを設定し、コマンドをカスタマイズします。", "b49abbd2f7": "エージェント", @@ -3032,7 +3033,9 @@ "c0e972d726": "キャンセル", "06741a2f3d": "MiniMax の使用量詳細を開く", "3bbf140864": "MiniMax 使用量", - "remoteServerLabel": "リモートサーバー" + "remoteServerLabel": "リモートサーバー", + "grokUsageAria": "Grok の使用状況詳細を開く", + "grokUsageMenu": "Grok の使用状況" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "概要", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "概要" + "eee19cfade": "概要", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "セッション", @@ -3431,8 +3435,13 @@ "0bba8ca244": "統計", "0e2a0b6431": "使用法", "372debfac0": "統計", - "26bb901fcd": "Orca の統計に加え、Claude、Codex、OpenCode の使用状況分析、トークン、キャッシュ、モデル、セッションを組み合わせたもの。", - "cb2430ae6a": "統計と使用状況" + "26bb901fcd": "Orca の統計に加え、Claude、Codex、OpenCode のトークン分析と Grok サブスクリプション使用状況。", + "cb2430ae6a": "統計と使用状況", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "サブスクリプション", + "d6e9f0a1b2": "クレジット", + "a3b6c7d8e9": "grok の使用状況", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "入力", "a8b7487ff7": "出力", "cfe2282ffa": "不明" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Grok の使用状況", + "b2d3e4f5c6": "Grok CLI OAuth (~/.grok/auth.json) から取得した週次サブスクリプションクレジット。ステータスバーと同じ情報源です。", + "c3e4f5a6b7": "アカウントで設定", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "Grok 使用状況を更新", + "d4f5a6b7c8": "更新", + "e5a6b7c8d9": "使用済み週次クレジット", + "f6b7c8d9e0": "請求期間のリセット", + "a7b8c9d0e1": "Grok アカウント設定" } }, "sparse": { @@ -6297,7 +6317,7 @@ "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "このブラウザを保存された Orca サーバーに接続します。", "bd0181eeca": "リモート Orca サーバー", - "8acf3f22e0": "Orca の統計と Claude、Codex、OpenCode の使用状況分析。", + "8acf3f22e0": "Orca の統計に加え、Claude、Codex、OpenCode のトークン分析と Grok サブスクリプション使用状況。", "954a8f5aef": "統計と使用状況", "a737a4bb22": "よく使う操作のキーボード ショートカット。", "23bf7a1ad4": "ショートカット", @@ -6967,7 +6987,13 @@ "b84a5b0c8a": "プロバイダー アカウントをこのデバイスまたは WSL で検査および追加するかどうかを選択します。", "d09fb5ca92": "アカウントの場所", "733f9e2a93": "MiniMax 使用量", - "f8374c3151": "ローカルのレート制限取得用に platform.minimax.io のセッション Cookie を貼り付けます。" + "f8374c3151": "ローカルのレート制限取得用に platform.minimax.io のセッション Cookie を貼り付けます。", + "f4a8c2e1b7": "Grok (xAI) の使用状況", + "e3b7d1f9a2": "週次クレジット使用量のための Grok CLI (grok login) による OAuth サインイン。", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "認証", + "a9f3d7b5c8": "ログイン" } }, "advanced": { @@ -7181,7 +7207,11 @@ "detailed": "詳細" }, "9a115966d3": "閉じるときにトレイへ最小化", - "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。" + "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。", + "f8e2a1c4b6": "Grok の使用状況", + "e7d1b0f3a5": "Grok CLI OAuth から Grok の週次クレジット使用量を表示します。", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8936,6 +8966,21 @@ } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "Grok CLI サインインから週次クレジット使用量を表示します (セッションファイル ~/.grok/auth.json)。", + "0d8e77bc40": "Grok CLI ドキュメント", + "ad47a33f72": "読み込み中…", + "b2c3d4e5f6": "サインイン済み", + "c3d4e5f6a7": "サインイン済みです。Orca はディスク上のそのファイルだけを読み取ります。使用状況の取得に失敗する場合は grok login を再実行してください。", + "d4e5f6a7b8": "セッションの有効期限が切れました。ターミナルで grok login を実行して更新してください。", + "e5f6a7b8c9": "Grok CLI にサインインしていません", + "f6a7b8c9d0": "ターミナルで grok login を実行してから、ここで「使用状況を更新」をクリックしてください。", + "3325d996cb": "使用状況を更新", + "a8f3e2c1b4": "週次クレジット", + "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", + "c6d1a8f4e2": "{{when}} にリセット" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2ae98490e..eb5839afd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "활성 워크스페이스의 MiniMax 구독 사용량을 표시합니다.", "sshToggleDescription": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다.", "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다.", - "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요." + "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.", + "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다." } } }, @@ -635,7 +636,7 @@ "94a5afe910": "SSH 호스트", "40d80bad8a": "베타", "de0c2907a1": "원격 Orca 서버", - "b351014180": "Orca 통계와 Claude, Codex 및 OpenCode 사용 분석.", + "b351014180": "Orca 통계와 Claude, Codex, OpenCode 토큰 분석 및 Grok 구독 사용량.", "d72a58b5b9": "통계 및 사용량", "dcd0d9b74f": "일반 작업에 대한 키보드 단축키입니다.", "94295ebfb3": "단축키", @@ -676,7 +677,7 @@ "cd50cec5d7": "Orca를 통해 여러 코딩 agents를 조정합니다.", "58a868e8e4": "오케스트레이션", "7c79d3b7bf": "선택 사항", - "b1c2f8b0ac": "Claude, Codex, Gemini 및 OpenCode Go에 대한 선택적 계정 전환.", + "b1c2f8b0ac": "Claude, Codex, Gemini, OpenCode Go, MiniMax, Grok의 선택적 계정 전환 및 사용량 설정.", "f70ac54d38": "AI 제공업체 계정", "4121f7a0a2": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.", "b49abbd2f7": "에이전트", @@ -3032,7 +3033,9 @@ "c0e972d726": "취소", "06741a2f3d": "MiniMax 사용량 세부 정보 열기", "3bbf140864": "MiniMax 사용량", - "remoteServerLabel": "원격 서버" + "remoteServerLabel": "원격 서버", + "grokUsageAria": "Grok 사용량 세부 정보 열기", + "grokUsageMenu": "Grok 사용량" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "개요", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "개요" + "eee19cfade": "개요", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "세션", @@ -3431,8 +3435,13 @@ "0bba8ca244": "통계", "0e2a0b6431": "사용량", "372debfac0": "통계", - "26bb901fcd": "Orca 통계, Claude/Codex/OpenCode 통합 사용량 분석, 토큰, 캐시, 모델, 세션", - "cb2430ae6a": "통계 및 사용량" + "26bb901fcd": "Orca 통계와 Claude, Codex, OpenCode 토큰 분석 및 Grok 구독 사용량.", + "cb2430ae6a": "통계 및 사용량", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "구독", + "d6e9f0a1b2": "크레딧", + "a3b6c7d8e9": "grok 사용량", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "입력", "a8b7487ff7": "출력", "cfe2282ffa": "알 수 없음" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Grok 사용량", + "b2d3e4f5c6": "Grok CLI OAuth(~/.grok/auth.json)의 주간 구독 크레딧입니다. 상태 표시줄과 같은 출처입니다.", + "c3e4f5a6b7": "계정에서 설정", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "Grok 사용량 새로 고침", + "d4f5a6b7c8": "새로 고침", + "e5a6b7c8d9": "사용한 주간 크레딧", + "f6b7c8d9e0": "결제 기간 재설정", + "a7b8c9d0e1": "Grok 계정 설정" } }, "sparse": { @@ -6260,7 +6280,7 @@ "b5ee17826b": "지속 세션, 더 풍부한 원격 상태, 웹 또는 모바일 핸드오프를 위해 원격 Orca 런타임을 페어링합니다.", "7686cb5c36": "이 브라우저를 저장된 Orca 서버에 연결하세요.", "bd0181eeca": "원격 Orca 서버", - "8acf3f22e0": "Orca 통계와 Claude, Codex 및 OpenCode 사용 분석.", + "8acf3f22e0": "Orca 통계와 Claude, Codex, OpenCode 토큰 분석 및 Grok 구독 사용량.", "954a8f5aef": "통계 및 사용량", "a737a4bb22": "일반 작업에 대한 키보드 단축키입니다.", "23bf7a1ad4": "단축키", @@ -6930,7 +6950,13 @@ "b84a5b0c8a": "이 장치 또는 WSL에서 공급자 계정을 검사하고 추가할지 선택합니다.", "d09fb5ca92": "계정 위치", "733f9e2a93": "MiniMax 사용량", - "f8374c3151": "로컬 rate-limit 가져오기에 사용할 platform.minimax.io 세션 Cookie를 붙여넣으세요." + "f8374c3151": "로컬 rate-limit 가져오기에 사용할 platform.minimax.io 세션 Cookie를 붙여넣으세요.", + "f4a8c2e1b7": "Grok(xAI) 사용량", + "e3b7d1f9a2": "주간 크레딧 사용량을 위한 Grok CLI(grok login) OAuth 로그인.", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "OAuth", + "a9f3d7b5c8": "로그인" } }, "advanced": { @@ -7144,7 +7170,11 @@ "cardLayout": "카드 레이아웃", "workspaceOptions": "워크스페이스 옵션", "detailed": "상세" - } + }, + "f8e2a1c4b6": "Grok 사용량", + "e7d1b0f3a5": "Grok CLI OAuth에서 Grok 주간 크레딧 사용량을 표시합니다.", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8936,6 +8966,21 @@ } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "Grok CLI 로그인에서 주간 크레딧 사용량을 표시합니다(세션 파일 ~/.grok/auth.json).", + "0d8e77bc40": "Grok CLI 문서", + "ad47a33f72": "로드 중…", + "b2c3d4e5f6": "로그인됨", + "c3d4e5f6a7": "로그인되었습니다. Orca는 디스크의 해당 파일만 읽습니다. 사용량 조회가 실패하면 grok login을 다시 실행하세요.", + "d4e5f6a7b8": "세션이 만료되었습니다. 터미널에서 grok login을 실행해 갱신하세요.", + "e5f6a7b8c9": "Grok CLI에 로그인되어 있지 않음", + "f6a7b8c9d0": "터미널에서 grok login을 실행한 다음 여기에서 사용량 새로 고침을 클릭하세요.", + "3325d996cb": "사용량 새로 고침", + "a8f3e2c1b4": "주간 크레딧", + "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", + "c6d1a8f4e2": "{{when}}에 재설정" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 424612585..65fe5c903 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "显示当前工作区的 MiniMax 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", - "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。" + "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。", + "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。" } } }, @@ -635,7 +636,7 @@ "94a5afe910": "SSH 主机", "40d80bad8a": "测试版", "de0c2907a1": "远程 Orca 服务器", - "b351014180": "Orca 统计数据以及 Claude、Codex 和 OpenCode 使用情况分析。", + "b351014180": "Orca 统计,以及 Claude、Codex、OpenCode Token 分析和 Grok 订阅使用量。", "d72a58b5b9": "统计和使用情况", "dcd0d9b74f": "常见操作的键盘快捷键。", "94295ebfb3": "快捷键", @@ -676,7 +677,7 @@ "cd50cec5d7": "通过 Orca 协调多个编码智能体。", "58a868e8e4": "编排", "7c79d3b7bf": "可选", - "b1c2f8b0ac": "Claude、Codex、Gemini 和 OpenCode Go 的可选账户切换。", + "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go、MiniMax 和 Grok 的可选账户切换与使用量设置。", "f70ac54d38": "AI 提供商账户", "4121f7a0a2": "管理 AI 智能体、设置默认值并自定义命令。", "b49abbd2f7": "智能体", @@ -3032,7 +3033,9 @@ "c0e972d726": "取消", "06741a2f3d": "打开 MiniMax 使用量详情", "3bbf140864": "MiniMax 使用量", - "remoteServerLabel": "远程服务器" + "remoteServerLabel": "远程服务器", + "grokUsageAria": "打开 Grok 使用详情", + "grokUsageMenu": "Grok 使用量" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "概览", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "概览" + "eee19cfade": "概览", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "会话", @@ -3431,8 +3435,13 @@ "0bba8ca244": "统计数据", "0e2a0b6431": "用法", "372debfac0": "统计数据", - "26bb901fcd": "Orca 统计数据以及 Claude、Codex 和 OpenCode 的综合使用分析、Token、缓存、模型和会话。", - "cb2430ae6a": "统计和使用情况" + "26bb901fcd": "Orca 统计,以及 Claude、Codex、OpenCode Token 分析和 Grok 订阅使用量。", + "cb2430ae6a": "统计和使用情况", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "订阅", + "d6e9f0a1b2": "额度", + "a3b6c7d8e9": "grok 使用量", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "输入", "a8b7487ff7": "输出", "cfe2282ffa": "未知" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Grok 使用量", + "b2d3e4f5c6": "来自 Grok CLI OAuth(~/.grok/auth.json)的每周订阅额度。与状态栏使用同一来源。", + "c3e4f5a6b7": "在账户中设置", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "刷新 Grok 使用量", + "d4f5a6b7c8": "刷新", + "e5a6b7c8d9": "已用每周额度", + "f6b7c8d9e0": "计费周期重置", + "a7b8c9d0e1": "Grok 账户设置" } }, "sparse": { @@ -6260,7 +6280,7 @@ "b5ee17826b": "配对远程 Orca 运行时,以获得持久会话、更丰富的远程状态,以及 Web 或移动端接续。", "7686cb5c36": "将此浏览器连接到已保存的 Orca 服务器。", "bd0181eeca": "远程 Orca 服务器", - "8acf3f22e0": "Orca 统计数据以及 Claude、Codex 和 OpenCode 使用情况分析。", + "8acf3f22e0": "Orca 统计,以及 Claude、Codex、OpenCode Token 分析和 Grok 订阅使用量。", "954a8f5aef": "统计和使用情况", "a737a4bb22": "常见操作的键盘快捷键。", "23bf7a1ad4": "快捷键", @@ -6930,7 +6950,13 @@ "b84a5b0c8a": "选择是否在此设备上或 WSL 中检查和添加提供商账户。", "d09fb5ca92": "账户位置", "733f9e2a93": "MiniMax 使用量", - "f8374c3151": "粘贴 platform.minimax.io 会话 Cookie 以在本地获取速率限制。" + "f8374c3151": "粘贴 platform.minimax.io 会话 Cookie 以在本地获取速率限制。", + "f4a8c2e1b7": "Grok (xAI) 使用量", + "e3b7d1f9a2": "通过 Grok CLI(grok login)OAuth 登录以查看每周额度使用量。", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "OAuth", + "a9f3d7b5c8": "登录" } }, "advanced": { @@ -7144,7 +7170,11 @@ "detailed": "详细" }, "9a115966d3": "关闭时最小化到托盘", - "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。" + "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。", + "f8e2a1c4b6": "Grok 使用量", + "e7d1b0f3a5": "显示来自 Grok CLI OAuth 的 Grok 每周额度使用量。", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8936,6 +8966,21 @@ } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "显示来自 Grok CLI 登录的每周额度使用量(会话文件 ~/.grok/auth.json)。", + "0d8e77bc40": "Grok CLI 文档", + "ad47a33f72": "正在加载…", + "b2c3d4e5f6": "已登录", + "c3d4e5f6a7": "已登录。Orca 只读取磁盘上的该文件;如果使用量获取失败,请重新运行 grok login。", + "d4e5f6a7b8": "会话已过期,请在终端运行 grok login 以刷新。", + "e5f6a7b8c9": "未登录 Grok CLI", + "f6a7b8c9d0": "在终端运行 grok login,然后点击此处的刷新使用量。", + "3325d996cb": "刷新使用量", + "a8f3e2c1b4": "每周额度", + "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", + "c6d1a8f4e2": "{{when}} 重置" } }, "right": { diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index 4513119ab..37bd64383 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -6,6 +6,7 @@ export type RateLimitSlice = { rateLimits: RateLimitState fetchRateLimits: () => Promise refreshRateLimits: () => Promise + refreshGrokRateLimits: () => Promise refreshClaudeRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise refreshCodexRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise consumeCodexRateLimitResetCredit: () => Promise @@ -22,7 +23,9 @@ export const createRateLimitSlice: StateCreator { + try { + const state = await window.api.rateLimits.refreshGrok() + set({ rateLimits: state }) + } catch (error) { + console.error('Failed to refresh Grok usage:', error) + } + }, + refreshClaudeRateLimitsForTarget: async (target) => { const current = get().rateLimits const targetChanged = diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 202adfe5e..6c47388db 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1215,13 +1215,15 @@ describe('createUISlice hydratePersistedUI', () => { 'resource-usage', 'ports', 'kimi', - 'minimax' + 'minimax', + 'grok' ]) expect(setUI).toHaveBeenCalledWith({ - statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax'], + statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax', 'grok'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, - _minimaxStatusBarDefaultAdded: true + _minimaxStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true }) }) @@ -1235,7 +1237,8 @@ describe('createUISlice hydratePersistedUI', () => { statusBarItems: ['claude', 'resource-usage'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, - _minimaxStatusBarDefaultAdded: true + _minimaxStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 7b280b7b1..00f40ee2f 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -263,6 +263,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_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) @@ -2257,18 +2258,24 @@ export const createUISlice: StateCreator = (set, get) ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') ? statusBarItems : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] + const statusBarItemsWithGrok = + ui._grokStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('grok') + ? statusBarItemsWithMiniMax + : [...statusBarItemsWithMiniMax, DEFAULT_ON_GROK_STATUS_BAR_ITEM] if ( (!ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded || - !ui._minimaxStatusBarDefaultAdded) && + !ui._minimaxStatusBarDefaultAdded || + !ui._grokStatusBarDefaultAdded) && typeof window !== 'undefined' ) { window.api.ui .set({ - statusBarItems: statusBarItemsWithMiniMax, + statusBarItems: statusBarItemsWithGrok, _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, - _minimaxStatusBarDefaultAdded: true + _minimaxStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true }) .catch(console.error) } @@ -2333,7 +2340,7 @@ export const createUISlice: StateCreator = (set, get) workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, - statusBarItems: statusBarItemsWithMiniMax, + statusBarItems: statusBarItemsWithGrok, statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the pet the first time // they enable the experimental flag. Only an explicit Hide pet diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 487c91529..f93936ed0 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -704,6 +704,7 @@ function createWebPreloadApi(): Partial { notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), minimaxCredentials: createMiniMaxCredentialsApi(), + grokAccounts: createGrokAccountsApi(), codexAccounts: createAccountsApi(), claudeAccounts: createAccountsApi(), cli: createCliApi(), @@ -2583,7 +2584,9 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { opencodeGo: null, kimi: null, minimax: null, + grok: null, minimaxCookieConfigured: false, + grokAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], @@ -2601,6 +2604,7 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { fetchInactiveClaudeAccounts: () => Promise.resolve(), fetchInactiveCodexAccounts: () => Promise.resolve(), refreshMiniMax: () => Promise.resolve(empty), + refreshGrok: () => Promise.resolve(empty), onUpdate: () => noopUnsubscribe } } @@ -2615,6 +2619,19 @@ function createMiniMaxCredentialsApi(): NonNullable['minimax } } +function createGrokAccountsApi(): NonNullable['grokAccounts']> { + const unsigned = { + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: null + } + return { + getStatus: () => Promise.resolve(unsigned) + } +} + function createAccountsApi(): never { const empty = { accounts: [], diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index dfc8c1dad..255965ec0 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -14,7 +14,9 @@ describe('RateLimitState', () => { opencodeGo: null, kimi: null, minimax: null, + grok: null, minimaxCookieConfigured: false, + grokAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 6ed2c696f..d19699dee 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -44,7 +44,7 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -95,6 +95,14 @@ export type InactiveAccountUsage = { isFetching: boolean } +export type GrokAccountStatus = { + signedIn: boolean + email: string | null + teamId: string | null + tokenFresh: boolean + error: string | null +} + export type RateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null @@ -102,6 +110,7 @@ export type RateLimitState = { opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null minimax: ProviderRateLimits | null + grok: ProviderRateLimits | null /** * True when a MiniMax session cookie is persisted on disk. The cookie lives * outside GlobalSettings, so this flag is the durable signal that the @@ -109,6 +118,8 @@ export type RateLimitState = { * between snapshot refreshes. */ minimaxCookieConfigured: boolean + /** True when main finds a Grok CLI session file (~/.grok/auth.json or GROK_HOME). */ + grokAuthConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 5e9939c64..0d48a6364 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -7,6 +7,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'opencode-go', 'kimi', 'minimax', + 'grok', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/types.ts b/src/shared/types.ts index 82daa227a..3af1e7fdd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3154,6 +3154,7 @@ export type StatusBarItem = | 'opencode-go' | 'kimi' | 'minimax' + | 'grok' | 'ssh' | 'resource-usage' | 'ports' @@ -3269,6 +3270,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 Grok status item. */ + _grokStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean dismissedUpdateVersion: string | null