diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index ec13423fcb6b..b4f039fe70a7 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -127,12 +127,54 @@ describe('preflight', () => { }) afterEach(() => { + vi.unstubAllEnvs() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) }) + it('detects Kiro through its configured local command outside PATH', async () => { + const command = '/custom/bin/kiro-cli' + vi.stubEnv('KIRO_CLI_PATH', command) + execFileAsyncMock.mockImplementation(async (program) => { + if (program !== command) { + throw new Error('not found') + } + return { stdout: 'kiro-cli 1.0', stderr: '' } + }) + + await expect(detectInstalledAgents()).resolves.toContain('kiro') + expect(execFileAsyncMock).toHaveBeenCalledWith(command, ['--version'], expect.anything()) + }) + + it('does not detect Kiro when its configured command fails, even if PATH has another copy', async () => { + const command = '/custom/bin/broken-kiro-cli' + vi.stubEnv('KIRO_CLI_PATH', command) + execFileAsyncMock.mockImplementation(async (program, args) => { + if (program === 'which' && args[0] === 'kiro-cli') { + return { stdout: '/usr/bin/kiro-cli\n', stderr: '' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.not.toContain('kiro') + }) + + it('does not apply the local Kiro command to WSL detection', async () => { + vi.stubEnv('KIRO_CLI_PATH', 'C:\\custom\\kiro-cli.cmd') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + runWslProcessMock.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + + await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.not.toContain('kiro') + expect(execFileAsyncMock).not.toHaveBeenCalled() + }) + it('only reports agents when which/where resolves to a real executable path', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index 92484812c3bf..b04e4a56ac43 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -43,6 +43,7 @@ import { import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { prunePreflightWslCache } from '../preflight-wsl-cache' import { detectAgentCommandsOnHost } from './agent-command-detection' +import { resolveKiroCommand } from '../rate-limits/kiro-usage-fetcher' export { detectAgentCommandsOnHost } from './agent-command-detection' export type PreflightStatus = { @@ -145,15 +146,23 @@ async function detectCommandRuntime( } export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { + const wslTarget = getPreflightWslTarget(context) const commands = getTuiAgentDetectionProbeCommands( KNOWN_TUI_AGENT_DETECTION_COMMANDS, - getPreflightWslTarget(context) ? 'wsl' : process.platform + wslTarget ? 'wsl' : process.platform ) - return resolveDetectedTuiAgentIds( + const detected = resolveDetectedTuiAgentIds( KNOWN_TUI_AGENT_DETECTION_COMMANDS, await detectAgentCommandsOnHost(commands, { context }), - getPreflightWslTarget(context) ? 'wsl' : process.platform + wslTarget ? 'wsl' : process.platform ) + if (!wslTarget && process.env.KIRO_CLI_PATH?.trim()) { + const kiro = await findRunnableLocalCommand(resolveKiroCommand()) + return kiro.status === 'available' + ? [...new Set([...detected, 'kiro'])] + : detected.filter((id) => id !== 'kiro') + } + return detected } export async function detectInstalledAgentsWithShellPathHydration( diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts new file mode 100644 index 000000000000..011c7530b9f8 --- /dev/null +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { fetchKiroRateLimits, parseKiroUsageOutput, resolveKiroCommand } from './kiro-usage-fetcher' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) + +const KIRO_USAGE_OUTPUT = + 'Estimated Usage | resets on 2026-09-01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%' + +describe('Kiro usage fetcher', () => { + it('uses the shared CLI resolver for version-manager installs', () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-kiro-command-')) + const command = join(homePath, '.volta', 'bin', 'kiro-cli') + mkdirSync(join(homePath, '.volta', 'bin'), { recursive: true }) + writeFileSync(command, '#!/bin/sh\n') + chmodSync(command, 0o755) + vi.stubEnv('KIRO_CLI_PATH', '') + try { + expect(resolveKiroCommand({ platform: 'darwin', pathEnv: '', homePath })).toBe(command) + } finally { + vi.unstubAllEnvs() + rmSync(homePath, { recursive: true, force: true }) + } + }) + + it('parses the official CLI usage command output', () => { + const result = parseKiroUsageOutput(` +\u001b[1mEstimated Usage | resets on 2026-09-01 | KIRO PRO+\u001b[0m +Credits (1233.74 of 2000 covered in plan) +\u001b[32m████ 61%\u001b[0m +`) + + expect(result).toMatchObject({ provider: 'kiro', status: 'ok', planType: 'KIRO PRO+' }) + expect(result.monthly?.usedPercent).toBeCloseTo(61.687) + expect(result.monthly).toMatchObject({ + resetsAt: null, + resetDescription: '2026-09-01' + }) + }) + + it('accepts a Kiro reset description without an ISO date', () => { + const output = + 'Estimated Usage | resets on 01/01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%' + + expect(parseKiroUsageOutput(output)).toMatchObject({ + status: 'ok', + planType: 'KIRO PRO', + monthly: { usedPercent: 10, resetsAt: null, resetDescription: '01/01' } + }) + }) + + it('runs the local non-model usage command', async () => { + const runner = vi.fn().mockResolvedValue({ + stdout: KIRO_USAGE_OUTPUT, + stderr: '' + }) + + const result = await fetchKiroRateLimits({ command: '/tmp/kiro-cli', runner }) + + expect(runner).toHaveBeenCalledWith( + '/tmp/kiro-cli', + ['chat', '/usage', '--no-interactive', '--wrap', 'never'], + undefined + ) + expect(result.status).toBe('ok') + }) + + it('passes configured .cmd shims to the shared process runner', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: 0, + signal: null, + stdout: KIRO_USAGE_OUTPUT, + stderr: '', + timedOut: false + }) + + const result = await fetchKiroRateLimits({ command: 'C:\\Users\\me\\bin\\kiro-cli.cmd' }) + + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ + program: 'C:\\Users\\me\\bin\\kiro-cli.cmd', + args: ['chat', '/usage', '--no-interactive', '--wrap', 'never'], + timeoutMs: 20_000, + maxOutputBytes: 1024 * 1024, + killOnOutputLimit: true + }) + ) + expect(result.status).toBe('ok') + }) + + it('rejects partial usage output from a failed command', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: 1, + signal: null, + stdout: KIRO_USAGE_OUTPUT, + stderr: '', + timedOut: false + }) + + await expect(fetchKiroRateLimits({ command: '/tmp/kiro-cli' })).resolves.toMatchObject({ + status: 'error', + usageMetadata: { failureKind: 'usage-unavailable' } + }) + }) + + it('fails closed on unexpected output', () => { + expect(parseKiroUsageOutput('Please sign in first')).toMatchObject({ + provider: 'kiro', + status: 'error', + usageMetadata: { failureKind: 'parse' } + }) + }) + + it('reports a missing CLI without exposing process details', async () => { + const error = Object.assign(new Error('spawn /secret/path ENOENT'), { code: 'ENOENT' }) + const result = await fetchKiroRateLimits({ runner: vi.fn().mockRejectedValue(error) }) + + expect(result).toMatchObject({ status: 'unavailable', error: 'Kiro CLI is not installed' }) + expect(JSON.stringify(result)).not.toContain('/secret/path') + }) + + it('distinguishes a failed usage command from a missing CLI', async () => { + const result = await fetchKiroRateLimits({ + runner: vi.fn().mockRejectedValue(new Error('command timed out')) + }) + + expect(result).toMatchObject({ + status: 'error', + error: 'Kiro usage command failed', + usageMetadata: { failureKind: 'usage-unavailable' } + }) + expect(JSON.stringify(result)).not.toContain('command timed out') + }) +}) diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts new file mode 100644 index 000000000000..d43939c1003c --- /dev/null +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -0,0 +1,127 @@ +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { runProcess } from '../../shared/child-process/run-process' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' + +const CLI_TIMEOUT_MS = 20_000 +const MONTHLY_WINDOW_MINUTES = 43_200 + +type CommandResult = { stdout: string; stderr: string } +type CommandRunner = ( + command: string, + args: string[], + signal?: AbortSignal +) => Promise + +function result( + status: ProviderRateLimits['status'], + error: string | null, + monthly: RateLimitWindow | null = null, + planType: string | null = null, + failureKind?: 'cli-unavailable' | 'parse' | 'usage-unavailable' +): ProviderRateLimits { + return { + provider: 'kiro', + session: null, + weekly: null, + monthly, + planType, + updatedAt: Date.now(), + error, + status, + usageMetadata: { + source: 'cli', + ...(failureKind ? { failureKind } : {}) + } + } +} + +export function resolveKiroCommand( + options: { + pathEnv?: string | null + platform?: NodeJS.Platform + homePath?: string + } = {} +): string { + const configured = process.env.KIRO_CLI_PATH?.trim() + if (configured) { + return configured + } + return resolveCliCommand('kiro-cli', options) +} + +const runCommand: CommandRunner = async (command, args, signal) => { + const result = await runProcess({ + program: command, + args, + env: withCliRuntimeOnPath(command, { ...process.env }), + timeoutMs: CLI_TIMEOUT_MS, + maxOutputBytes: 1024 * 1024, + killOnOutputLimit: true, + signal + }) + if (result.code !== 0 || result.timedOut || result.signal || result.outputTruncated) { + throw new Error('Kiro usage command failed') + } + return { stdout: result.stdout, stderr: result.stderr } +} + +export function parseKiroUsageOutput(output: string): ProviderRateLimits { + const readable = stripAnsiEscapeSequences(output).replace(/\r/g, '') + const header = readable.match(/Estimated Usage\s*\|\s*resets on\s+([^|\n]+?)\s*\|\s*([^\n]+)/i) + const resetDescription = header?.[1].trim() + const credits = readable.match(/Credits\s*\(([\d,.]+)\s+of\s+([\d,.]+)\s+covered in plan\)/i) + const percent = readable.match(/(?:^|\s)(\d+(?:\.\d+)?)%\s*$/m) + const used = credits ? Number(credits[1].replaceAll(',', '')) : Number.NaN + const limit = credits ? Number(credits[2].replaceAll(',', '')) : Number.NaN + const usedPercent = + Number.isFinite(used) && Number.isFinite(limit) && limit > 0 + ? Math.min(100, Math.max(0, (used / limit) * 100)) + : percent + ? Math.min(100, Math.max(0, Number(percent[1]))) + : null + if (!header || !resetDescription || usedPercent === null || !Number.isFinite(usedPercent)) { + return result('error', 'Could not parse Kiro usage output', null, null, 'parse') + } + return result( + 'ok', + null, + { + usedPercent, + windowMinutes: MONTHLY_WINDOW_MINUTES, + // Kiro supplies no exact reset time or zone, so keep its text as display metadata. + resetsAt: null, + resetDescription + }, + header[2].trim() + ) +} + +export async function fetchKiroRateLimits( + options: { signal?: AbortSignal; runner?: CommandRunner; command?: string } = {} +): Promise { + try { + const { stdout, stderr } = await (options.runner ?? runCommand)( + options.command ?? resolveKiroCommand(), + ['chat', '/usage', '--no-interactive', '--wrap', 'never'], + options.signal + ) + return parseKiroUsageOutput(`${stdout}\n${stderr}`) + } catch (error) { + const code = + error !== null && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' + ? error.code + : null + const unavailable = code === 'ENOENT' + return result( + unavailable ? 'unavailable' : 'error', + unavailable ? 'Kiro CLI is not installed' : 'Kiro usage command failed', + null, + null, + unavailable ? 'cli-unavailable' : 'usage-unavailable' + ) + } +} diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index caf5ed780a54..ec35f0b7aba2 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -11,6 +11,7 @@ import { readGrokAuthSession } from './grok-auth' import { fetchCursorRateLimits } from './cursor-fetcher' import { readCursorAuthSession } from './cursor-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' +import { fetchKiroRateLimits } from './kiro-usage-fetcher' import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' @@ -94,6 +95,7 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor')) + vi.mocked(fetchKiroRateLimits).mockImplementation(async () => unavailableProvider('kiro')) vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode')) vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => unavailableProvider('antigravity') @@ -116,6 +118,7 @@ export function resetRateLimitProviderMocks(): void { status: 'unavailable' }) vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) + vi.mocked(fetchKiroRateLimits).mockResolvedValue(unavailableProvider('kiro')) vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode')) vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 4366417dc3e7..96f405d37307 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -32,6 +32,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 8e65e53740be..0e76581e5cc0 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -54,6 +54,10 @@ vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-cursor-usage.test.ts b/src/main/rate-limits/service-cursor-usage.test.ts index 03f5c1f4a168..fad5e41a80b3 100644 --- a/src/main/rate-limits/service-cursor-usage.test.ts +++ b/src/main/rate-limits/service-cursor-usage.test.ts @@ -18,6 +18,10 @@ vi.mock('./codex-fetcher', () => ({ })) vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 469b51e4cf1c..84c554cc9e29 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -39,6 +39,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index f6cbc7006200..56e531ec55df 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -42,6 +42,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 8f31e9a6da7a..426ae7204c21 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -33,6 +33,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-opencode-go-credentials.test.ts b/src/main/rate-limits/service-opencode-go-credentials.test.ts index 5e98ce4a1d62..4b0a741b1dd4 100644 --- a/src/main/rate-limits/service-opencode-go-credentials.test.ts +++ b/src/main/rate-limits/service-opencode-go-credentials.test.ts @@ -27,6 +27,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 2cef94ba2b3e..8708a8da3e5c 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -7,6 +7,7 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' +import { fetchKiroRateLimits } from './kiro-usage-fetcher' import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { readGrokAuthSession } from './grok-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' @@ -41,6 +42,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) @@ -368,6 +373,41 @@ describe('RateLimitService', () => { expect(refreshResolved).toBe(true) }) + it('publishes other provider results before a slow Kiro CLI fetch completes', async () => { + const service = new RateLimitService() + const kiro = deferred() + let refreshResolved = false + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchOpenCodeGoUsage).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(fetchKiroRateLimits).mockReturnValueOnce(kiro.promise) + + const refresh = service.refresh().then(() => { + refreshResolved = true + }) + await flushMicrotasks() + + const pendingKiroState = service.getState() + expect(pendingKiroState.claude?.status).toBe('ok') + expect(pendingKiroState.codex?.status).toBe('ok') + expect(pendingKiroState.gemini?.status).toBe('ok') + expect(pendingKiroState.opencodeGo?.status).toBe('ok') + expect(pendingKiroState.kimi?.status).toBe('ok') + expect(pendingKiroState.minimax?.status).toBe('ok') + expect(pendingKiroState.kiro?.status).toBe('fetching') + expect(refreshResolved).toBe(false) + + kiro.resolve(okProvider('kiro', 70, Date.now())) + await refresh + + expect(service.getState().kiro?.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; grok?: AbortSignal } = {} @@ -452,7 +492,7 @@ describe('RateLimitService', () => { expect.objectContaining({ authPreparation: undefined, allowPtyFallback: false, - allowUsagePanelSupplement: true, + allowUsagePanelSupplement: process.platform !== 'win32', signal: expect.any(AbortSignal) }) ) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 8cbb25915037..af9d47828f8a 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -41,6 +41,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-zcode-usage.test.ts b/src/main/rate-limits/service-zcode-usage.test.ts index 7aa9e911c602..a234b9db76e4 100644 --- a/src/main/rate-limits/service-zcode-usage.test.ts +++ b/src/main/rate-limits/service-zcode-usage.test.ts @@ -29,6 +29,10 @@ vi.mock('./antigravity-usage-fetcher', () => ({ fetchAntigravityRateLimits: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 2731f882be5a..c996ada985d7 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,11 @@ -import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' -import { settleSiblingProviderResult } from './service-sibling-provider-result' +import { + RateLimitServiceFullCyclePreparation, + type FetchAllCyclePrepared +} from './service-full-cycle-preparation' +import { + settleSiblingProviderResult, + type SettledProviderResult +} from './service-sibling-provider-result' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { @@ -25,8 +31,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ opencodeGeneration, miniMaxConfigChanged, miniMaxGeneration, - zcodeConfigChanged, - zcodeGeneration, claudeFetchGated, results: [ claudeResult, @@ -36,10 +40,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ kimiResult, miniMaxResult ], - grokResultPromise, - cursorResultPromise, - zcodeResultPromise, - antigravityResultPromise + kiroResultPromise } = prepared if (signal.aborted) { return @@ -191,6 +192,25 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) + await Promise.all([ + this.applySiblingProviderResults(prepared, signal), + this.applyKiroResult(kiroResultPromise, previousState.kiro, signal) + ]) + } + + private async applySiblingProviderResults( + prepared: FetchAllCyclePrepared, + signal: AbortSignal + ): Promise { + const { + grokResultPromise, + cursorResultPromise, + zcodeResultPromise, + antigravityResultPromise, + previousState, + zcodeGeneration, + zcodeConfigChanged + } = prepared const [grokSettled, cursorSettled, zcodeSettled, antigravitySettled] = await Promise.all([ grokResultPromise, cursorResultPromise, @@ -205,11 +225,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ const zcode = settleSiblingProviderResult('zcode', zcodeSettled) const shouldApplyZcode = zcodeGeneration === this.zcodeFetchGeneration const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled) - // Why: the stale policy keeps a recent snapshot through a failed refresh, but - // a snapshot belonging to a different Cursor account must not survive the - // switch — the Accounts pane would name the new account beside the old - // account's figures. Only a known-and-changed identity clears it, so an - // errored refresh that reports no account still keeps its own last reading. + // A changed Cursor identity must not inherit another account's stale figures. const previousCursorAccount = previousState.cursor?.usageMetadata?.authProvenance const cursorAccount = cursor.usageMetadata?.authProvenance const cursorAccountChanged = @@ -242,4 +258,21 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ antigravity: this.applyStalePolicy(antigravity, previousState.antigravity) }) } + + private async applyKiroResult( + resultPromise: Promise, + previousKiro: ProviderRateLimits | null, + signal: AbortSignal + ): Promise { + const settled = await resultPromise + if (signal.aborted) { + return + } + const kiro = settleSiblingProviderResult('kiro', settled) + this.trackActiveFailureStreak('kiro', kiro) + this.updateState({ + ...this.state, + kiro: this.applyStalePolicy(kiro, previousKiro) + }) + } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index f847cbf15097..9ecd5c67ef4c 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -2,6 +2,7 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' +import { fetchKiroRateLimits } from '../kiro-usage-fetcher' import { readGrokAuthSession } from '../grok-auth' import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' @@ -50,6 +51,7 @@ export type FetchAllCyclePrepared = { ] grokResultPromise: Promise cursorResultPromise: Promise + kiroResultPromise: Promise zcodeResultPromise: Promise antigravityResultPromise: Promise } @@ -164,6 +166,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ : this.withFetchingStatus(previousState.minimax, 'minimax'), grok: this.withFetchingStatus(previousState.grok, 'grok'), cursor: this.withFetchingStatus(previousState.cursor, 'cursor'), + kiro: this.withFetchingStatus(previousState.kiro, 'kiro'), zcode: zcodeConfigChanged ? this.withFetchingStatus(null, 'zcode') : this.withFetchingStatus(previousState.zcode, 'zcode') @@ -209,6 +212,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (value) => ({ status: 'fulfilled', value }) as const, (reason) => ({ status: 'rejected', reason }) as const ) + // Why: Kiro invokes a CLI with a long timeout; keep it outside the shared + // allSettled batch so healthy provider snapshots can publish immediately. + const kiroResultPromise = fetchKiroRateLimits({ signal }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. const claudeFetchGated = @@ -308,6 +317,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ], grokResultPromise, cursorResultPromise, + kiroResultPromise, zcodeResultPromise, antigravityResultPromise } diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index 402495080c27..d0340f31a9f6 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -80,6 +80,7 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue grok: this.state.grok, antigravity: this.state.antigravity, cursor: this.state.cursor, + kiro: this.state.kiro, zcode: this.state.zcode } return Object.entries(byProvider).map(([provider, limits]) => ({ diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 0ec13020a6e9..037716c738a2 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -35,6 +35,7 @@ export abstract class RateLimitServiceState { minimax: null, grok: null, cursor: null, + kiro: null, zcode: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' @@ -56,6 +57,7 @@ export abstract class RateLimitServiceState { grok: 0, antigravity: 0, cursor: 0, + kiro: 0, zcode: 0 } // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. @@ -69,6 +71,7 @@ export abstract class RateLimitServiceState { grok: 0, antigravity: 0, cursor: 0, + kiro: 0, zcode: 0 } protected mainWindow: BrowserWindow | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 5bae83b1449c..cf11df209447 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -131,6 +131,7 @@ export type InternalRateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + kiro: ProviderRateLimits | null zcode: ProviderRateLimits | null } diff --git a/src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts new file mode 100644 index 000000000000..e826f93b50e5 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts @@ -0,0 +1,37 @@ +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getKiroStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'kiro', + title: translate('auto.components.settings.appearance.search.kiroUsageTitle', 'Kiro Usage'), + description: translate( + 'auto.components.settings.appearance.search.kiroUsageDescription', + 'Show Kiro subscription credit usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.kiro', 'kiro'), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword('auto.components.settings.appearance.search.kiroAws', 'aws'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ) + ], + toggleDescription: translate( + 'settings.appearance.statusBar.kiroToggleDescription', + 'Show Kiro subscription credit usage reported by Kiro CLI.' + ) + } +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 16749f4ab311..a5244cd5d92b 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -6,6 +6,7 @@ import { getAntigravityStatusBarToggleSearchEntry } from './appearance-status-ba import { getMiniMaxStatusBarToggleSearchEntry } from './appearance-status-bar-minimax-toggle-search' import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search' import { getCursorStatusBarToggleSearchEntry } from './appearance-status-bar-cursor-toggle-search' +import { getKiroStatusBarToggleSearchEntry } from './appearance-status-bar-kiro-toggle-search' import { getZcodeStatusBarToggleSearchEntry } from './appearance-status-bar-zcode-toggle-search' export const getStatusBarToggles = createLocalizedCatalog( @@ -170,6 +171,7 @@ export const getStatusBarToggles = createLocalizedCatalog( getMiniMaxStatusBarToggleSearchEntry(), getGrokStatusBarToggleSearchEntry(), getCursorStatusBarToggleSearchEntry(), + getKiroStatusBarToggleSearchEntry(), getZcodeStatusBarToggleSearchEntry(), { id: 'ssh', diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 0dd665c939c0..03c46d6890a9 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -25,6 +25,6 @@ export { ClaudeSwitcherMenu } from './ClaudeSwitcherMenu' export { CodexSwitcherMenu } from './CodexSwitcherMenu' export { InlineUsageBars } from './InlineProviderUsage' export { ProviderDetailsMenu } from './ProviderDetailsMenu' -export { ProviderSegment } from './StatusBarProviderSegment' +export { getProviderLetter, ProviderSegment } from './StatusBarProviderSegment' export const StatusBar = React.memo(StatusBarSurface) diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 1a3cd5d9e0da..0504fdb45c0a 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -136,7 +136,7 @@ export function UsageOverflowChip({ ) } -function getProviderLetter(provider: ProviderRateLimits['provider']): string { +export function getProviderLetter(provider: ProviderRateLimits['provider']): string { switch (provider) { case 'claude': return 'C' @@ -154,6 +154,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { return 'R' case 'cursor': return 'U' + case 'kiro': + return 'Q' case 'zcode': return 'Z' case 'codex': diff --git a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx index c98f91c673ec..1452a2c6e7d5 100644 --- a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx +++ b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx @@ -133,6 +133,18 @@ export function StatusBarVisibilityMenu({ {translate('auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok Usage')} )} + {isStatusBarItemAvailable('kiro', detectedAgentIds) && ( + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('kiro') + }} + > + + {translate('auto.components.status.bar.StatusBar.kiroUsageMenu', 'Kiro Usage')} + + )} {isStatusBarItemAvailable('zcode', detectedAgentIds) && ( { expect(isStatusBarItemAvailable('gemini', null)).toBe(true) expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) + expect(isStatusBarItemAvailable('kiro', null)).toBe(true) expect(isStatusBarItemAvailable('zcode', null)).toBe(true) }) @@ -29,6 +30,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) + expect(isStatusBarItemAvailable('kiro', ['claude', 'kimi'])).toBe(false) expect(isStatusBarItemAvailable('zcode', ['claude', 'kimi'])).toBe(false) }) @@ -38,6 +40,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true) expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) + expect(isStatusBarItemAvailable('kiro', ['kiro'])).toBe(true) expect(isStatusBarItemAvailable('zcode', ['zcode'])).toBe(true) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 85f641b96d2d..ce818239671f 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -14,6 +14,7 @@ const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'kimi', 'antigravity', 'grok', + 'kiro', 'zcode' ]) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-letter.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-letter.test.ts new file mode 100644 index 000000000000..4e36e37e2974 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-provider-letter.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { getProviderLetter } from './StatusBar' + +describe('status bar provider labels', () => { + it('distinguishes Kiro from Kimi in the narrow status bar', () => { + expect(getProviderLetter('kimi')).toBe('K') + expect(getProviderLetter('kiro')).toBe('Q') + }) +}) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 4ade7294abf8..aa4eda252bac 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -36,6 +36,8 @@ type UsageProviderSnapshots = { minimax: ProviderRateLimits | null | undefined grok: ProviderRateLimits | null | undefined cursor: ProviderRateLimits | null | undefined + // Optional for renderer HMR against an older main process and persisted test fixtures. + kiro?: ProviderRateLimits | null zcode?: ProviderRateLimits | null } @@ -191,6 +193,7 @@ export function isUsageEmptyState( isProviderSnapshotPending(providers.minimax) || isProviderSnapshotPending(providers.grok) || isProviderSnapshotPending(providers.cursor) || + (providers.kiro !== undefined && isProviderSnapshotPending(providers.kiro)) || (providers.zcode !== undefined && isProviderSnapshotPending(providers.zcode)) ) { return false @@ -206,6 +209,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.minimax) && !isProviderConfigured(providers.grok) && !isProviderConfigured(providers.cursor) && + !isProviderConfigured(providers.kiro) && !isProviderConfigured(providers.zcode) ) } diff --git a/src/renderer/src/components/status-bar/tooltip-reset-label.test.ts b/src/renderer/src/components/status-bar/tooltip-reset-label.test.ts new file mode 100644 index 000000000000..4dad809dbad2 --- /dev/null +++ b/src/renderer/src/components/status-bar/tooltip-reset-label.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' + +vi.mock('@/lib/agent-catalog', async () => { + const ReactActual = await vi.importActual('react') + return { + AgentIcon: ({ agent }: { agent: string }) => + ReactActual.createElement('span', { 'data-agent-icon': agent }) + } +}) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +import { formatRateLimitResetLabel } from './tooltip' + +describe('formatRateLimitResetLabel', () => { + it('shows a date-only reset without inventing an exact countdown', () => { + expect( + formatRateLimitResetLabel({ + usedPercent: 62, + windowMinutes: 43_200, + resetsAt: null, + resetDescription: '2026-09-01' + }) + ).toBe('Resets on 2026-09-01') + }) + + it('keeps non-date reset descriptions instead of dropping the label', () => { + expect( + formatRateLimitResetLabel({ + usedPercent: 62, + windowMinutes: 43_200, + resetsAt: null, + resetDescription: 'Thu' + }) + ).toBe('Thu') + expect( + formatRateLimitResetLabel({ + usedPercent: 62, + windowMinutes: 43_200, + resetsAt: null, + resetDescription: '2:30 PM' + }) + ).toBe('2:30 PM') + }) + + it('omits the reset label when the window has no reset metadata', () => { + expect( + formatRateLimitResetLabel({ + usedPercent: 62, + windowMinutes: 43_200, + resetsAt: null, + resetDescription: null + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index d871f8943eb4..92a2cc9ad6fe 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -53,6 +53,25 @@ export function formatTimeAgo(ts: number): string { return `${hours}h ago` } +export function formatRateLimitResetLabel( + window: RateLimitWindow, + now: number = Date.now() +): string | null { + if (window.resetsAt) { + return formatResetCountdown(window.resetsAt - now) + } + if (window.resetDescription) { + if (/^\d{4}-\d{2}-\d{2}$/.test(window.resetDescription)) { + return translate('auto.components.status.bar.tooltip.resetOnDate', 'Resets on {{value0}}', { + value0: window.resetDescription + }) + } + // Producers may already send human-readable values ("Thu", "2:30 PM"). + return window.resetDescription + } + return null +} + // Re-export so existing tooltip consumers/tests keep their import path; the // implementation is shared with mobile in src/shared/rate-limit-reset-format. export { formatResetCountdown } @@ -111,6 +130,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'cursor') { return } + if (provider === 'kiro') { + return + } return } @@ -238,7 +260,7 @@ function ProviderRateLimitWindowSection({ } const usedPct = clampUsedPercent(window.usedPercent) const displayedPct = getDisplayedUsagePercentage(usedPct, usagePercentageDisplay) - const resetLabel = window.resetsAt ? formatResetCountdown(window.resetsAt - now) : null + const resetLabel = formatRateLimitResetLabel(window, now) return (
diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index be278fc9e9fc..6d1c93652aed 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -32,6 +32,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'cursor') { return 'Cursor' } + if (provider === 'kiro') { + return 'Kiro' + } return provider } diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts index 85e602794498..6e96adf567a4 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -20,6 +20,7 @@ describe('getUsageProviderAccountsSectionId', () => { it('does not invent an Accounts section for CLI-owned credentials', () => { expect(getUsageProviderAccountsSectionId('antigravity')).toBeNull() expect(getUsageProviderAccountsSectionId('kimi')).toBeNull() + expect(getUsageProviderAccountsSectionId('kiro')).toBeNull() }) }) diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index dbc81f665c62..c77a6d650843 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -21,8 +21,8 @@ export function getUsageProviderAccountsSectionId( return 'accounts-cursor' case 'antigravity': case 'kimi': - // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. - // Antigravity credentials live in the agy CLI; quota is fetched directly via agy. + case 'kiro': + // These CLIs own their credential lifecycles. return null case 'zcode': return 'accounts-zcode' diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 7ed84300dc9c..5891e369177d 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -95,8 +95,19 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, zcode } = - rateLimits + const { + claude, + codex, + gemini, + opencodeGo, + kimi, + antigravity, + minimax, + grok, + cursor, + kiro, + zcode + } = rateLimits // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. @@ -122,6 +133,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings) const visibleCursor = getVisibleUsageProvider('cursor', cursor, usageSettings) + const visibleKiro = getVisibleUsageProvider('kiro', kiro, usageSettings) const visibleZcode = getVisibleUsageProvider('zcode', zcode, usageSettings) const showClaude = visibleClaude !== null && @@ -152,6 +164,10 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // Why: a Cursor session can come from the IDE alone, so PATH detection of // cursor-agent would hide a real meter from IDE-only users. const showCursor = visibleCursor !== null && statusBarItems.includes('cursor') + const showKiro = + visibleKiro !== null && + statusBarItems.includes('kiro') && + isStatusBarItemAvailable('kiro', detectedAgentIds) // Why: a saved Coding Plan key is site-auth, not a CLI on PATH — a subscriber // without the ZCode CLI must still earn the meter (same exemption as MiniMax/Cursor). const showZcode = @@ -177,11 +193,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showMiniMax || showGrok || showCursor || + showKiro || showZcode const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: include Settings so durable managed accounts count — a configured user isn't shown the empty state while snapshots hydrate. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, zcode }, + { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, kiro, zcode }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -196,6 +213,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { minimax?.status === 'fetching' || grok?.status === 'fetching' || cursor?.status === 'fetching' || + kiro?.status === 'fetching' || zcode?.status === 'fetching' const floatingTerminalActionLabel = floatingTerminalOpen @@ -215,6 +233,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showMiniMax ? visibleMiniMax : null, showGrok ? visibleGrok : null, showCursor ? visibleCursor : null, + showKiro ? visibleKiro : null, showZcode ? visibleZcode : null ].filter((p): p is ProviderRateLimits => p !== null) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d1bfaf4285e1..3dc5d4fb5104 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -221,6 +221,7 @@ "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI.", "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE.", + "kiroToggleDescription": "Show Kiro subscription credit usage reported by Kiro CLI.", "zcodeToggleDescription": "Show ZCode Coding Plan quota usage." }, "menuBarIcon": { @@ -3985,6 +3986,7 @@ "codexSignInSuccess": "Signed in to Codex", "codexSignInError": "Codex sign-in failed. Please try again.", "cursorUsageMenu": "Cursor Usage", + "kiroUsageMenu": "Kiro Usage", "hiddenUsageProviders": "Also: {{value0}}", "zcodeUsageMenu": "ZCode Usage" }, @@ -4206,6 +4208,7 @@ }, "plan": "Plan" }, + "resetOnDate": "Resets on {{value0}}", "claude": { "waiting": { "label": "Updates when Claude runs", @@ -9608,6 +9611,10 @@ "c5b9f8d1e3": "xai", "usagePercentageDisplayTitle": "Usage percentages", "usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining.", + "kiroUsageTitle": "Kiro Usage", + "kiroUsageDescription": "Show Kiro subscription credit usage in the status bar.", + "kiro": "kiro", + "kiroAws": "aws", "zcodeUsageTitle": "ZCode Usage", "zcodeUsageDescription": "Show ZCode Coding Plan quota usage in the status bar.", "zcode": "zcode", diff --git a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts index 8a9e627788a9..36c32cc9cdd2 100644 --- a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts @@ -141,6 +141,7 @@ describe('createUISlice hydratePersistedUI', () => { 'antigravity', 'grok', 'cursor', + 'kiro', 'zcode' ]) expect(setUI).toHaveBeenCalledWith({ @@ -153,6 +154,7 @@ describe('createUISlice hydratePersistedUI', () => { 'antigravity', 'grok', 'cursor', + 'kiro', 'zcode' ], _portsStatusBarDefaultAdded: true, @@ -161,6 +163,7 @@ describe('createUISlice hydratePersistedUI', () => { _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, _cursorStatusBarDefaultAdded: true, + _kiroStatusBarDefaultAdded: true, _zcodeStatusBarDefaultAdded: true }) }) @@ -179,6 +182,7 @@ describe('createUISlice hydratePersistedUI', () => { _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, _cursorStatusBarDefaultAdded: true, + _kiroStatusBarDefaultAdded: true, _zcodeStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts index 26b6071d64dd..b410eb6960cb 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts @@ -8,6 +8,7 @@ const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' const DEFAULT_ON_CURSOR_STATUS_BAR_ITEM: StatusBarItem = 'cursor' +const DEFAULT_ON_KIRO_STATUS_BAR_ITEM: StatusBarItem = 'kiro' const DEFAULT_ON_ZCODE_STATUS_BAR_ITEM: StatusBarItem = 'zcode' export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { @@ -19,6 +20,7 @@ export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { ['_antigravityStatusBarDefaultAdded', DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM], ['_grokStatusBarDefaultAdded', DEFAULT_ON_GROK_STATUS_BAR_ITEM], ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM], + ['_kiroStatusBarDefaultAdded', DEFAULT_ON_KIRO_STATUS_BAR_ITEM], ['_zcodeStatusBarDefaultAdded', DEFAULT_ON_ZCODE_STATUS_BAR_ITEM] ] as const for (const [flag, item] of defaults) { diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 12e27144908d..76475d0af75c 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -127,6 +127,8 @@ export type PersistedUIState = { _grokStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Cursor status item. */ _cursorStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Kiro status item. */ + _kiroStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on ZCode status item. */ _zcodeStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index b9af7fe1b68c..b17fd7b14577 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -12,6 +12,7 @@ export function createEmptyRateLimitState(overrides: Partial = { minimax: null, grok: null, cursor: null, + kiro: null, zcode: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 7b30956f47c4..2064f1b02dd7 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -83,6 +83,7 @@ export type ProviderRateLimits = { | 'grok' | 'antigravity' | 'cursor' + | 'kiro' | 'zcode' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null @@ -167,6 +168,7 @@ export type RateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + kiro: ProviderRateLimits | null zcode: ProviderRateLimits | null /** * True when a MiniMax session cookie is persisted on disk. The cookie lives diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index b787d523768b..c9d1eaa2e330 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -70,6 +70,7 @@ export const StatusBarItem = z.enum([ 'minimax', 'grok', 'cursor', + 'kiro', 'zcode', 'ssh', 'resource-usage', @@ -190,6 +191,7 @@ export const UiUpdateFields = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), _cursorStatusBarDefaultAdded: z.boolean().optional(), + _kiroStatusBarDefaultAdded: z.boolean().optional(), _zcodeStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index c32958ee9491..617f47a2fdd5 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -10,6 +10,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'minimax', 'grok', 'cursor', + 'kiro', 'zcode', 'ssh', 'resource-usage', diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index 2e7557bb5920..2aa9166ad592 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -65,6 +65,7 @@ export type StatusBarItem = | 'minimax' | 'grok' | 'cursor' + | 'kiro' | 'zcode' | 'ssh' | 'resource-usage'