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 <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
Doan Bac Tam 2026-07-10 14:11:27 +07:00 committed by GitHub
parent 770a9d5dc3
commit fa2b228ad3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1938 additions and 101 deletions

View File

@ -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')
})
})

View File

@ -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
}
}

View File

@ -0,0 +1,6 @@
import { ipcMain } from 'electron'
import { getGrokAccountStatus } from '../grok-accounts/status'
export function registerGrokAccountHandlers(): void {
ipcMain.handle('grokAccounts:getStatus', () => getGrokAccountStatus())
}

View File

@ -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<typeof vi.fn> } {
function makeService(): {
service: RateLimitService
refresh: ReturnType<typeof vi.fn>
refreshGrok: ReturnType<typeof vi.fn>
} {
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<typeof
fetchInactiveClaudeAccountsOnOpen: vi.fn(() => 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)
})
})

View File

@ -24,4 +24,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void {
rateLimits.fetchInactiveCodexAccountsOnOpen()
)
ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh())
ipcMain.handle('rateLimits:refreshGrok', () => rateLimits.refreshGrok())
}

View File

@ -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()

View File

@ -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)

View File

@ -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'
})
})
})

View File

@ -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
}

View File

@ -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()
})
})

View File

@ -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<string, string> {
const headers: Record<string, string> = {
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<ProviderRateLimits> {
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')
}
}

View File

@ -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<T>(): Deferred<T> {
return { promise, resolve }
}
async function flushMicrotasks(times = 4): Promise<void> {
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<ProviderRateLimits>()
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')

View File

@ -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<typeof setInterval> | null = null
private deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | 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<AbortController>()
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<RateLimitState> {
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<void> {
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<void> {
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

View File

@ -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
})
})
}

View File

@ -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',

View File

@ -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(),

View File

@ -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<void>
fetchInactiveCodexAccounts: () => Promise<void>
refreshMiniMax: () => Promise<RateLimitState>
refreshGrok: () => Promise<RateLimitState>
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<GrokAccountStatus>
}
ssh: {
listTargets: () => Promise<SshTarget[]>
// Removed-target id → last known label, for showing a friendly host name on

View File

@ -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<void> =>
ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'),
refreshMiniMax: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshMiniMax'),
refreshGrok: (): Promise<RateLimitState> => 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<GrokAccountStatus> => ipcRenderer.invoke('grokAccounts:getStatus')
},
ssh: {
listTargets: (): Promise<SshTarget[]> => ipcRenderer.invoke('ssh:listTargets'),

View File

@ -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({
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getAccountsGrokSearchEntries()) ? (
<GrokAccountsSection key="grok" />
) : null
].filter(Boolean)

View File

@ -43,7 +43,8 @@ function recordStatusBarToggleInteraction(
id === 'gemini' ||
id === 'opencode-go' ||
id === 'kimi' ||
id === 'minimax'
id === 'minimax' ||
id === 'grok'
) {
recordFeatureInteraction('usage-tracking')
}

View File

@ -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<GrokAccountStatus | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const loadStatus = useCallback(async (): Promise<void> => {
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<void> => {
setRefreshing(true)
try {
await refreshGrokRateLimits()
await loadStatus()
} finally {
setRefreshing(false)
}
}
const signedIn = status?.signedIn === true
const tokenFresh = status?.tokenFresh === true
return (
<section id="accounts-grok" className="space-y-4 scroll-mt-6">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<AgentIcon agent="grok" size={16} />
{translate('auto.components.settings.GrokAccountsSection.a1b2c3d4e5', 'Grok (xAI)')}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.GrokAccountsSection.f6e5d4c3b2',
'Shows weekly credit usage from your Grok CLI sign-in (session file ~/.grok/auth.json).'
)}
</p>
</div>
<a
href={GROK_CLI_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{translate('auto.components.settings.GrokAccountsSection.0d8e77bc40', 'Grok CLI docs')}
<ExternalLink className="size-3" />
</a>
</div>
<div
className={cn(
'flex items-start gap-3 rounded-lg border bg-muted/20 p-3',
signedIn && tokenFresh ? 'border-border/60' : 'border-border/40'
)}
>
<ShieldCheck
className={cn(
'mt-0.5 size-4 shrink-0',
signedIn && tokenFresh ? 'text-foreground' : 'text-muted-foreground'
)}
/>
<div className="min-w-0 flex-1 space-y-1">
{loading ? (
<p className="text-xs text-muted-foreground">
{translate('auto.components.settings.GrokAccountsSection.ad47a33f72', 'Loading…')}
</p>
) : signedIn ? (
<>
<p className="truncate text-xs font-medium">
{status?.email ??
translate('auto.components.settings.GrokAccountsSection.b2c3d4e5f6', 'Signed in')}
</p>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</>
) : (
<>
<p className="text-xs font-medium">
{translate(
'auto.components.settings.GrokAccountsSection.e5f6a7b8c9',
'Not signed in to Grok CLI'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.GrokAccountsSection.f6a7b8c9d0',
'In a terminal, run grok login, then click Refresh usage here.'
)}
</p>
</>
)}
{status?.error ? <p className="text-xs text-destructive">{status.error}</p> : null}
</div>
<Button
variant="outline"
size="xs"
disabled={refreshing}
onClick={() => void handleRefreshUsage()}
className="shrink-0 gap-1"
>
{refreshing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{translate('auto.components.settings.GrokAccountsSection.3325d996cb', 'Refresh usage')}
</Button>
</div>
{grokUsage?.weekly ? (
<SearchableSetting
title={translate(
'auto.components.settings.GrokAccountsSection.a8f3e2c1b4',
'Weekly credits'
)}
description={translate(
'auto.components.settings.GrokAccountsSection.b7e2d9f0a3',
'Same weekly credit % as the grok /usage screen in the terminal.'
)}
keywords={['grok', 'xai', 'usage', 'credits', 'oauth']}
>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="tabular-nums">
{Math.round(grokUsage.weekly.usedPercent)}%
</Badge>
{grokUsage.weekly.resetDescription ? (
<span className="text-muted-foreground">
{translate(
'auto.components.settings.GrokAccountsSection.c6d1a8f4e2',
'Resets {{when}}',
{ when: grokUsage.weekly.resetDescription }
)}
</span>
) : null}
{grokUsage.usageMetadata?.authProvenance ? (
<span className="truncate text-muted-foreground">
{grokUsage.usageMetadata.authProvenance}
</span>
) : null}
</div>
</SearchableSetting>
) : null}
</section>
)
}

View File

@ -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')}
>

View File

@ -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()
])

View File

@ -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.'
)
}
}

View File

@ -2,6 +2,7 @@ import type { StatusBarItem } from '../../../../shared/types'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { 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'),

View File

@ -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<AppState>
vi.mock('../../store', () => ({
useAppStore: Object.assign(
(selector: (state: Partial<AppState>) => 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<string, string>) =>
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(<GrokUsagePane />)
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(<GrokUsagePane />)
await user.click(screen.getByRole('button', { name: 'Refresh Grok usage' }))
expect(storeMocks.refreshGrokRateLimits).toHaveBeenCalledTimes(1)
})
})

View File

@ -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 (
<div
className="rounded-lg border border-border/60 bg-card/40 p-4"
data-testid="grok-usage-pane"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold text-foreground">{paneTitle}</h3>
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.stats.GrokUsagePane.b2d3e4f5c6',
'Weekly subscription credits from Grok CLI OAuth (~/.grok/auth.json). Same source as the status bar.'
)}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
onClick={() => {
recordFeatureInteraction('usage-tracking')
openGrokAccounts()
}}
>
{translate('auto.components.stats.GrokUsagePane.c3e4f5a6b7', 'Set up in Accounts')}
</Button>
</div>
</div>
)
}
const weeklyPercent =
grok?.weekly && typeof grok.weekly.usedPercent === 'number'
? Math.round(grok.weekly.usedPercent)
: null
const isFetching = grok?.status === 'fetching'
return (
<div
className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4"
data-testid="grok-usage-pane"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground">{paneTitle}</h3>
<p className="mt-1 text-xs text-muted-foreground">
{formatUpdatedAt(grok?.updatedAt ?? null)}
{grok?.error
? translate('auto.components.stats.GrokUsagePane.h9i0j1k2l3', ' • {{value0}}', {
value0: grok.error
})
: ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void refreshGrokRateLimits()}
disabled={isFetching}
aria-label={translate(
'auto.components.stats.GrokUsagePane.i0j1k2l3m4',
'Refresh Grok usage'
)}
>
<RefreshCw className={`size-3.5 ${isFetching ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.stats.GrokUsagePane.d4f5a6b7c8', 'Refresh')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<StatCard
label={translate('auto.components.stats.GrokUsagePane.e5a6b7c8d9', 'Weekly credits used')}
value={weeklyPercent !== null ? `${weeklyPercent}%` : '—'}
icon={<Sparkles className="size-4" />}
/>
<StatCard
label={translate(
'auto.components.stats.GrokUsagePane.f6b7c8d9e0',
'Billing period reset'
)}
value={grok?.weekly?.resetDescription ?? '—'}
icon={<CalendarClock className="size-4" />}
/>
</div>
{grok?.usageMetadata?.authProvenance ? (
<p className="px-1 text-xs text-muted-foreground">{grok.usageMetadata.authProvenance}</p>
) : null}
<div className="flex flex-wrap items-center gap-2 px-1">
<Button
variant="ghost"
size="sm"
className="h-auto gap-1 px-0 text-xs"
onClick={openGrokAccounts}
>
{translate('auto.components.stats.GrokUsagePane.a7b8c9d0e1', 'Grok account settings')}
<ExternalLink className="size-3" />
</Button>
</div>
</div>
)
}

View File

@ -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 {
<ClaudeUsagePane />
) : activeUsageTab === 'codex' ? (
<CodexUsagePane />
) : (
) : activeUsageTab === 'opencode' ? (
<OpenCodeUsagePane />
) : (
<GrokUsagePane />
)}
</div>
</div>

View File

@ -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')
]
}
])

View File

@ -1762,7 +1762,9 @@ export function ProviderDetailsMenu({
? 'K'
: provider.provider === 'minimax'
? 'M'
: 'X'}
: provider.provider === 'grok'
? 'R'
: 'X'}
</span>
</span>
) : (
@ -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 && (
<ProviderDetailsMenu
provider={visibleGrok}
compact={compact}
iconOnly={iconOnly}
ariaLabel={translate(
'auto.components.status.bar.StatusBar.grokUsageAria',
'Open Grok usage details'
)}
/>
)}
</>
)}
{anyVisible && !isEmptyUsageState && (
@ -2218,6 +2242,18 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
<MiniMaxIcon size={14} />
{translate('auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax Usage')}
</DropdownMenuCheckboxItem>
{isStatusBarItemAvailable('grok', detectedAgentIds) && (
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('grok')}
onCheckedChange={() => {
recordFeatureInteraction('usage-tracking')
toggleStatusBarItem('grok')
}}
>
<AgentIcon agent="grok" size={14} />
{translate('auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok Usage')}
</DropdownMenuCheckboxItem>
)}
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('ssh')}
onCheckedChange={() => {

View File

@ -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)
})
})

View File

@ -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<StatusBarItem> = new Set(['claude', 'codex', 'gemini', 'kimi'])
const CLI_GATED_ITEMS: ReadonlySet<StatusBarItem> = new Set([
'claude',
'codex',
'gemini',
'kimi',
'grok'
])
export function isStatusBarItemAvailable(
id: StatusBarItem,

View File

@ -71,6 +71,7 @@ function usageSettings(overrides: Partial<UsageProviderSettings> = {}): 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()
)

View File

@ -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)
)
}

View File

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

View File

@ -20,6 +20,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider'])
if (provider === 'minimax') {
return 'MiniMax'
}
if (provider === 'grok') {
return 'Grok'
}
return provider
}

View File

@ -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(),

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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 CLIgrok loginOAuth 登录以查看每周额度使用量。",
"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": {

View File

@ -6,6 +6,7 @@ export type RateLimitSlice = {
rateLimits: RateLimitState
fetchRateLimits: () => Promise<void>
refreshRateLimits: () => Promise<void>
refreshGrokRateLimits: () => Promise<void>
refreshClaudeRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise<void>
refreshCodexRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise<void>
consumeCodexRateLimitResetCredit: () => Promise<void>
@ -22,7 +23,9 @@ export const createRateLimitSlice: StateCreator<AppState, [], [], RateLimitSlice
opencodeGo: null,
kimi: null,
minimax: null,
grok: null,
minimaxCookieConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
@ -47,6 +50,15 @@ export const createRateLimitSlice: StateCreator<AppState, [], [], RateLimitSlice
}
},
refreshGrokRateLimits: async () => {
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 =

View File

@ -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
})
)

View File

@ -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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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

View File

@ -704,6 +704,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
notifications: createNotificationsApi(),
rateLimits: createRateLimitsApi(),
minimaxCredentials: createMiniMaxCredentialsApi(),
grokAccounts: createGrokAccountsApi(),
codexAccounts: createAccountsApi(),
claudeAccounts: createAccountsApi(),
cli: createCliApi(),
@ -2583,7 +2584,9 @@ function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['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<Partial<PreloadApi>['rateLimits']> {
fetchInactiveClaudeAccounts: () => Promise.resolve(),
fetchInactiveCodexAccounts: () => Promise.resolve(),
refreshMiniMax: () => Promise.resolve(empty),
refreshGrok: () => Promise.resolve(empty),
onUpdate: () => noopUnsubscribe
}
}
@ -2615,6 +2619,19 @@ function createMiniMaxCredentialsApi(): NonNullable<Partial<PreloadApi>['minimax
}
}
function createGrokAccountsApi(): NonNullable<Partial<PreloadApi>['grokAccounts']> {
const unsigned = {
signedIn: false,
email: null,
teamId: null,
tokenFresh: false,
error: null
}
return {
getStatus: () => Promise.resolve(unsigned)
}
}
function createAccountsApi(): never {
const empty = {
accounts: [],

View File

@ -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: [],

View File

@ -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[]

View File

@ -7,6 +7,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
'opencode-go',
'kimi',
'minimax',
'grok',
'ssh',
'resource-usage',
'ports'

View File

@ -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