From adc7455df2fabb6083d83546d66b6b2464de0366 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Wed, 12 Aug 2026 15:58:21 +0800 Subject: [PATCH 01/15] feat(usage): add Kiro subscription credits --- .../rate-limits/kiro-usage-fetcher.test.ts | 62 ++++++++ src/main/rate-limits/kiro-usage-fetcher.ts | 135 ++++++++++++++++++ .../rate-limit-service-test-harness.ts | 3 + .../service-account-target-selection.test.ts | 4 + .../service-inactive-account-previews.test.ts | 4 + .../service-live-claude-usage.test.ts | 4 + .../rate-limits/service-minimax-usage.test.ts | 4 + .../service-refresh-orchestration.test.ts | 4 + .../service-window-activation.test.ts | 4 + ...ppearance-status-bar-kiro-toggle-search.ts | 37 +++++ .../settings/appearance-status-bar-search.ts | 2 + .../status-bar-agent-gating.test.ts | 3 + .../status-bar/status-bar-agent-gating.ts | 3 +- .../status-bar-provider-visibility.ts | 8 +- .../src/components/status-bar/tooltip.tsx | 2 + .../components/status-bar/usage-error-copy.ts | 2 + .../usage-provider-settings-target.ts | 2 + src/renderer/src/i18n/locales/en.json | 10 +- ...ui-hydration-workspace-preferences.test.ts | 12 +- src/shared/rate-limit-state-factory.ts | 1 + src/shared/rate-limit-types.test.ts | 1 + src/shared/rate-limit-types.ts | 2 + src/shared/rpc-contract/client-ui-params.ts | 2 + src/shared/status-bar-defaults.ts | 1 + 24 files changed, 303 insertions(+), 9 deletions(-) create mode 100644 src/main/rate-limits/kiro-usage-fetcher.test.ts create mode 100644 src/main/rate-limits/kiro-usage-fetcher.ts create mode 100644 src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts 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..a28dd0496a45 --- /dev/null +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import { fetchKiroRateLimits, parseKiroUsageOutput } from './kiro-usage-fetcher' + +describe('Kiro usage fetcher', () => { + 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?.resetsAt).toBe(Date.parse('2026-09-01T00:00:00.000Z')) + }) + + it('runs the local non-model usage command', async () => { + const runner = vi.fn().mockResolvedValue({ + stdout: + 'Estimated Usage | resets on 2026-09-01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%', + 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('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..eec5b2c44332 --- /dev/null +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -0,0 +1,135 @@ +import { execFile } from 'node:child_process' +import { accessSync, constants } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +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 } : {}) + } + } +} + +function resolveKiroCommand(): string { + const configured = process.env.KIRO_CLI_PATH?.trim() + const candidates = [ + configured, + join(homedir(), 'bin', 'kiro-cli'), + join(homedir(), '.local', 'bin', 'kiro-cli') + ] + for (const candidate of candidates) { + if (!candidate) { + continue + } + try { + accessSync(candidate, constants.X_OK) + return candidate + } catch { + // Continue to the next known install location. + } + } + return process.platform === 'win32' ? 'kiro-cli.exe' : 'kiro-cli' +} + +const runCommand: CommandRunner = (command, args, signal) => + new Promise((resolve, reject) => { + execFile( + command, + args, + { encoding: 'utf8', timeout: CLI_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal }, + (error, stdout, stderr) => { + if (error) { + reject(error) + return + } + resolve({ stdout, stderr }) + } + ) + }) + +function parseResetDate(value: string): number | null { + const timestamp = Date.parse(`${value}T00:00:00.000Z`) + return Number.isFinite(timestamp) ? timestamp : null +} + +export function parseKiroUsageOutput(output: string): ProviderRateLimits { + const readable = stripAnsiEscapeSequences(output).replace(/\r/g, '') + const header = readable.match( + /Estimated Usage\s*\|\s*resets on (\d{4}-\d{2}-\d{2})\s*\|\s*([^\n]+)/i + ) + 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 || 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, + resetsAt: parseResetDate(header[1]), + resetDescription: header[1] + }, + 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 as NodeJS.ErrnoException | null)?.code + 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 021de01108d3..f539daad57e3 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 { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' export type Deferred = { @@ -92,6 +93,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')) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -110,6 +112,7 @@ export function resetRateLimitProviderMocks(): void { status: 'unavailable' }) vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) + vi.mocked(fetchKiroRateLimits).mockResolvedValue(unavailableProvider('kiro')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) 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 2deeadfcdaad..3222b888b9d9 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('./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 300292efcc21..564d0fc651c9 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('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: 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 3c51a09aa5ff..adad11c18608 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -36,6 +36,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: 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 8b2253364723..45096d298f15 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('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: 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 b184d51eda6a..2c66951cabeb 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -40,6 +40,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 7c83bd7d0260..547c509b2a5f 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('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) 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..0e597eaf7e78 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts @@ -0,0 +1,37 @@ +import type { StatusBarItem } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function 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 8a4d65f21fd1..f9990cb14eb6 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' export const getStatusBarToggles = createLocalizedCatalog( (): readonly { @@ -169,6 +170,7 @@ export const getStatusBarToggles = createLocalizedCatalog( getMiniMaxStatusBarToggleSearchEntry(), getGrokStatusBarToggleSearchEntry(), getCursorStatusBarToggleSearchEntry(), + getKiroStatusBarToggleSearchEntry(), { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index cd98315df505..eb3d51c05bc6 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -20,6 +20,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', null)).toBe(true) expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) + expect(isStatusBarItemAvailable('kiro', null)).toBe(true) }) it('hides CLI items not detected on PATH', () => { @@ -28,6 +29,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) }) it('shows CLI items detected on PATH', () => { @@ -36,5 +38,6 @@ 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) }) }) 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 145abef3a900..9ae7b5fa5a0b 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 @@ -13,7 +13,8 @@ const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'gemini', 'kimi', 'antigravity', - 'grok' + 'grok', + 'kiro' ]) export function isStatusBarItemAvailable( 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 086540781d71..b4ea99c7201e 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 @@ -34,6 +34,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 } type UsageProviderId = ProviderRateLimits['provider'] @@ -181,7 +183,8 @@ export function isUsageEmptyState( antigravitySnapshotPending || isProviderSnapshotPending(providers.minimax) || isProviderSnapshotPending(providers.grok) || - isProviderSnapshotPending(providers.cursor) + isProviderSnapshotPending(providers.cursor) || + (providers.kiro !== undefined && isProviderSnapshotPending(providers.kiro)) ) { return false } @@ -195,6 +198,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.antigravity) && !isProviderConfigured(providers.minimax) && !isProviderConfigured(providers.grok) && - !isProviderConfigured(providers.cursor) + !isProviderConfigured(providers.cursor) && + !isProviderConfigured(providers.kiro) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index be15ae18c02f..9e2b79934d7d 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -99,6 +99,8 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem } if (provider === 'cursor') { return + if (provider === 'kiro') { + return } return } diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index a0a6cabf1a9a..2114c1d896d4 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -28,6 +28,8 @@ 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.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 39380e135950..2ae101650f68 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,7 +21,9 @@ export function getUsageProviderAccountsSectionId( case 'cursor': return 'accounts-cursor' case 'kimi': + case 'kiro': // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + // Kiro likewise owns its CLI credential lifecycle. return null } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 14a780e7612c..53c177db0306 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -151,7 +151,8 @@ "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI.", - "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE." + "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE.", + "kiroToggleDescription": "Show Kiro subscription credit usage reported by Kiro CLI." }, "menuBarIcon": { "title": "Show Menu Bar Icon", @@ -3820,7 +3821,8 @@ "floatingTerminalNewActivity": "{{label}}, new activity", "codexSignInSuccess": "Signed in to Codex", "codexSignInError": "Codex sign-in failed. Please try again.", - "cursorUsageMenu": "Cursor Usage" + "cursorUsageMenu": "Cursor Usage", + "kiroUsageMenu": "Kiro Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -9357,6 +9359,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", "tray": { "tray": "tray", "system": "system tray", 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 0a380a0625a1..b8e4a6a5974e 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 @@ -140,7 +140,8 @@ describe('createUISlice hydratePersistedUI', () => { 'minimax', 'antigravity', 'grok', - 'cursor' + 'cursor', + 'kiro' ]) expect(setUI).toHaveBeenCalledWith({ statusBarItems: [ @@ -151,14 +152,16 @@ describe('createUISlice hydratePersistedUI', () => { 'minimax', 'antigravity', 'grok', - 'cursor' + 'cursor', + 'kiro' ], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, - _cursorStatusBarDefaultAdded: true + _cursorStatusBarDefaultAdded: true, + _kiroStatusBarDefaultAdded: true }) }) @@ -175,7 +178,8 @@ describe('createUISlice hydratePersistedUI', () => { _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, - _cursorStatusBarDefaultAdded: true + _cursorStatusBarDefaultAdded: true, + _kiroStatusBarDefaultAdded: true }) ) diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index 3dade988f0b9..4f645968b3ea 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, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index b24e40385794..1b034b4ea580 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -17,6 +17,7 @@ describe('RateLimitState', () => { minimax: null, grok: null, cursor: null, + kiro: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 48ff7c7abe35..d307560c92aa 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -58,6 +58,7 @@ export type ProviderRateLimits = { | 'grok' | 'antigravity' | 'cursor' + | 'kiro' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -139,6 +140,7 @@ export type RateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + kiro: 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 diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index b78a984889e4..0d63f9bb21c8 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', 'ssh', 'resource-usage', 'ports' @@ -184,6 +185,7 @@ export const UiUpdateFields = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), _cursorStatusBarDefaultAdded: z.boolean().optional(), + _kiroStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 2eb855d9dd5c..61b638ed7e9d 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', 'ssh', 'resource-usage', 'ports' From 1bc0fa1a84c8aa30c5594fa34c95635a1796de1d Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Wed, 12 Aug 2026 16:26:17 +0800 Subject: [PATCH 02/15] fix(usage): distinguish compact Kiro label --- .../status-bar/status-bar-provider-letter.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/renderer/src/components/status-bar/status-bar-provider-letter.test.ts 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..e24646f91f44 --- /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('KI') + }) +}) From 04eb6913b571007a67059d06662f1905a5f38a0e Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Wed, 12 Aug 2026 23:33:52 +0800 Subject: [PATCH 03/15] fix(usage): use compact Kiro Q label --- .../components/status-bar/status-bar-provider-letter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e24646f91f44..4e36e37e2974 100644 --- 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 @@ -4,6 +4,6 @@ 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('KI') + expect(getProviderLetter('kiro')).toBe('Q') }) }) From bba016d840e4a5e17bc7f56778108d1a577af44f Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Fri, 14 Aug 2026 11:24:19 +0800 Subject: [PATCH 04/15] fix(usage): avoid inventing Kiro reset time --- src/main/rate-limits/kiro-usage-fetcher.test.ts | 5 ++++- src/main/rate-limits/kiro-usage-fetcher.ts | 9 +++------ .../src/components/status-bar/tooltip.test.ts | 14 ++++++++++++++ src/renderer/src/components/status-bar/tooltip.tsx | 14 +++++++++++++- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts index a28dd0496a45..3ac9ade8f091 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -11,7 +11,10 @@ Credits (1233.74 of 2000 covered in plan) expect(result).toMatchObject({ provider: 'kiro', status: 'ok', planType: 'KIRO PRO+' }) expect(result.monthly?.usedPercent).toBeCloseTo(61.687) - expect(result.monthly?.resetsAt).toBe(Date.parse('2026-09-01T00:00:00.000Z')) + expect(result.monthly).toMatchObject({ + resetsAt: null, + resetDescription: '2026-09-01' + }) }) it('runs the local non-model usage command', async () => { diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index eec5b2c44332..83327dbf1cd5 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -75,11 +75,6 @@ const runCommand: CommandRunner = (command, args, signal) => ) }) -function parseResetDate(value: string): number | null { - const timestamp = Date.parse(`${value}T00:00:00.000Z`) - return Number.isFinite(timestamp) ? timestamp : null -} - export function parseKiroUsageOutput(output: string): ProviderRateLimits { const readable = stripAnsiEscapeSequences(output).replace(/\r/g, '') const header = readable.match( @@ -104,7 +99,9 @@ export function parseKiroUsageOutput(output: string): ProviderRateLimits { { usedPercent, windowMinutes: MONTHLY_WINDOW_MINUTES, - resetsAt: parseResetDate(header[1]), + // Kiro reports a calendar date without a time zone or time of day. Keep it as + // display metadata instead of inventing a UTC-midnight countdown. + resetsAt: null, resetDescription: header[1] }, header[2].trim() diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index fd7561e250cd..332926906f96 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -27,6 +27,7 @@ import { clampUsedPercent, formatResetCreditExpiry, formatResetCountdown, + formatRateLimitResetLabel, getProviderUsageErrorMessage, getProviderUsageStatusLabel, getWindowSections, @@ -72,6 +73,19 @@ describe('formatResetCountdown', () => { }) }) +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') + }) +}) + describe('formatResetCreditExpiry', () => { it('shows singular and plural expiry countdowns', () => { vi.useFakeTimers() diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 9e2b79934d7d..69d2e6292b1a 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -45,6 +45,18 @@ export function formatTimeAgo(ts: number): string { return `${hours}h ago` } +export function formatRateLimitResetLabel(window: RateLimitWindow): string | null { + if (window.resetsAt) { + return formatResetCountdown(window.resetsAt - Date.now()) + } + if (window.resetDescription && /^\d{4}-\d{2}-\d{2}$/.test(window.resetDescription)) { + return translate('auto.components.status.bar.tooltip.resetOnDate', 'Resets on {{value0}}', { + value0: 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 } @@ -238,7 +250,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) return (
From 10affdc46852c41f6bc41490fa5a6890e0b22bfc Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Fri, 14 Aug 2026 11:45:42 +0800 Subject: [PATCH 05/15] fix(usage): follow split shared UI types --- src/shared/persisted-ui-state-types.ts | 2 ++ src/shared/ui-chrome-types.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 6b1f20b58b2a..c873bcb39f7d 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -120,6 +120,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 statusBarItems: StatusBarItem[] statusBarVisible: boolean /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index d9aee820949e..6161dcbc49e8 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -63,6 +63,7 @@ export type StatusBarItem = | 'minimax' | 'grok' | 'cursor' + | 'kiro' | 'ssh' | 'resource-usage' | 'ports' From 136ae269a3891f7778e9f6ff097568ea16d7ff2a Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Fri, 14 Aug 2026 16:51:27 +0800 Subject: [PATCH 06/15] fix(usage): keep non-date Kiro reset descriptions formatRateLimitResetLabel returned null for human-readable resetDescription values such as "Thu" or "2:30 PM" because only ISO dates were recognized. Return non-date descriptions unchanged so the status bar keeps a valid reset label. --- .../src/components/status-bar/tooltip.test.ts | 30 +++++++++++++++++++ .../src/components/status-bar/tooltip.tsx | 12 +++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 332926906f96..97693614c92a 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -84,6 +84,36 @@ describe('formatRateLimitResetLabel', () => { }) ).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() + }) }) describe('formatResetCreditExpiry', () => { diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 69d2e6292b1a..7d023ebd773e 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -49,10 +49,14 @@ export function formatRateLimitResetLabel(window: RateLimitWindow): string | nul if (window.resetsAt) { return formatResetCountdown(window.resetsAt - Date.now()) } - if (window.resetDescription && /^\d{4}-\d{2}-\d{2}$/.test(window.resetDescription)) { - return translate('auto.components.status.bar.tooltip.resetOnDate', 'Resets on {{value0}}', { - value0: window.resetDescription - }) + 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 } From 3fcd012aef87d134f1a02035156b797c63a1bae5 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Fri, 14 Aug 2026 16:51:27 +0800 Subject: [PATCH 07/15] fix(usage): launch Kiro .cmd shims through cmd.exe execFile cannot launch configured .cmd/.bat paths directly on Windows. Route the Kiro usage command through getSpawnArgsForWindows like the other usage fetchers; native binaries keep the direct execFile path. --- .../rate-limits/kiro-usage-fetcher.test.ts | 41 ++++++++++++++++++- src/main/rate-limits/kiro-usage-fetcher.ts | 8 +++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts index 3ac9ade8f091..dfc8aade22ed 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it, vi } from 'vitest' +import { execFile } from 'node:child_process' +import { getSpawnArgsForWindows } from '../win32-utils' import { fetchKiroRateLimits, parseKiroUsageOutput } from './kiro-usage-fetcher' +vi.mock('node:child_process', () => ({ execFile: vi.fn() })) +vi.mock('../win32-utils', () => ({ getSpawnArgsForWindows: 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('parses the official CLI usage command output', () => { const result = parseKiroUsageOutput(` @@ -19,8 +27,7 @@ Credits (1233.74 of 2000 covered in plan) it('runs the local non-model usage command', async () => { const runner = vi.fn().mockResolvedValue({ - stdout: - 'Estimated Usage | resets on 2026-09-01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%', + stdout: KIRO_USAGE_OUTPUT, stderr: '' }) @@ -34,6 +41,36 @@ Credits (1233.74 of 2000 covered in plan) expect(result.status).toBe('ok') }) + it('launches configured .cmd shims through the shared Windows batch launcher', async () => { + vi.mocked(getSpawnArgsForWindows).mockReturnValue({ + spawnCmd: 'C:\\Windows\\System32\\cmd.exe', + spawnArgs: ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'] + }) + vi.mocked(execFile).mockImplementation( + ((_command, _args, _options, callback) => { + callback(null, KIRO_USAGE_OUTPUT, '') + return undefined as never + }) as unknown as typeof execFile + ) + + const result = await fetchKiroRateLimits({ command: 'C:\\Users\\me\\bin\\kiro-cli.cmd' }) + + expect(getSpawnArgsForWindows).toHaveBeenCalledWith('C:\\Users\\me\\bin\\kiro-cli.cmd', [ + 'chat', + '/usage', + '--no-interactive', + '--wrap', + 'never' + ]) + expect(execFile).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'], + expect.anything(), + expect.any(Function) + ) + expect(result.status).toBe('ok') + }) + it('fails closed on unexpected output', () => { expect(parseKiroUsageOutput('Please sign in first')).toMatchObject({ provider: 'kiro', diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index 83327dbf1cd5..88fae9191902 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -4,6 +4,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { getSpawnArgsForWindows } from '../win32-utils' const CLI_TIMEOUT_MS = 20_000 const MONTHLY_WINDOW_MINUTES = 43_200 @@ -61,9 +62,12 @@ function resolveKiroCommand(): string { const runCommand: CommandRunner = (command, args, signal) => new Promise((resolve, reject) => { + // Why: execFile cannot launch configured .cmd/.bat shims directly on + // Windows; route them through cmd.exe like the other usage fetchers. + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) execFile( - command, - args, + spawnCmd, + spawnArgs, { encoding: 'utf8', timeout: CLI_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal }, (error, stdout, stderr) => { if (error) { From 99960448d277899ccda16ac2f25dbf51332855f5 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Sat, 15 Aug 2026 08:56:17 +0800 Subject: [PATCH 08/15] fix(usage): align provider search with current lint gates --- .../settings/appearance-status-bar-kiro-toggle-search.ts | 2 +- src/renderer/src/i18n/locales/en.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 index 0e597eaf7e78..e826f93b50e5 100644 --- 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 @@ -1,4 +1,4 @@ -import type { StatusBarItem } from '../../../../shared/types' +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 53c177db0306..eec0eac404d6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4020,7 +4020,8 @@ "message": "Run cursor-agent login in a terminal on the computer running Orca, then retry usage." }, "plan": "Plan" - } + }, + "resetOnDate": "Resets on {{value0}}" }, "SshTargetStatusRow": { "sshHost": "SSH Host" From 2118b790c478d3c5bb34dc48172b358a54edfb24 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 24 Aug 2026 10:35:41 +0800 Subject: [PATCH 09/15] test(usage): mock the Kiro fetcher in the Antigravity service test The Antigravity coverage landed upstream after this branch last rebased. Its test file predates the Kiro provider, so the shared resetRateLimitProviderMocks harness now fails on the unmocked fetchKiroRateLimits import. Declare the same vi.mock the other service test files use. --- src/main/rate-limits/service-antigravity-usage.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 9f2fc2c8244c..bb90456d6047 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -47,6 +47,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' })) })) From e685ce3f187a7491a35d1825f09ecb2977b56212 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 31 Aug 2026 11:08:22 +0800 Subject: [PATCH 10/15] fix(usage): thread the reset-countdown clock into the Kiro reset label Upstream now feeds a ticking `now` into ProviderRateLimitWindowSection; `formatRateLimitResetLabel` gains an optional `now` (default Date.now()) so the label keeps re-rendering on the clock without inventing reset times. --- src/renderer/src/components/status-bar/tooltip.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 7d023ebd773e..0e0a1e0f490f 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -45,9 +45,12 @@ export function formatTimeAgo(ts: number): string { return `${hours}h ago` } -export function formatRateLimitResetLabel(window: RateLimitWindow): string | null { +export function formatRateLimitResetLabel( + window: RateLimitWindow, + now: number = Date.now() +): string | null { if (window.resetsAt) { - return formatResetCountdown(window.resetsAt - Date.now()) + return formatResetCountdown(window.resetsAt - now) } if (window.resetDescription) { if (/^\d{4}-\d{2}-\d{2}$/.test(window.resetDescription)) { @@ -254,7 +257,7 @@ function ProviderRateLimitWindowSection({ } const usedPct = clampUsedPercent(window.usedPercent) const displayedPct = getDisplayedUsagePercentage(usedPct, usagePercentageDisplay) - const resetLabel = formatRateLimitResetLabel(window) + const resetLabel = formatRateLimitResetLabel(window, now) return (
From 651b4a6d4367ba6f9397afb01a5b0d19ee81c9a3 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Wed, 2 Sep 2026 14:55:27 +0800 Subject: [PATCH 11/15] fix(usage): resolve Kiro review findings Signed-off-by: guanbear <123guan@gmail.com> --- .../rate-limits/kiro-usage-fetcher.test.ts | 30 +++++++++---- src/main/rate-limits/kiro-usage-fetcher.ts | 34 ++++++--------- .../service-refresh-orchestration.test.ts | 38 +++++++++++++++++ .../service/service-full-cycle-application.ts | 42 +++++++++++++++---- .../service/service-full-cycle-preparation.ts | 14 ++++++- .../rate-limits/service/service-polling.ts | 3 +- src/main/rate-limits/service/service-state.ts | 9 ++-- src/main/rate-limits/service/service-types.ts | 3 +- .../src/components/status-bar/StatusBar.tsx | 2 +- .../status-bar/StatusBarProviderSegment.tsx | 4 +- .../status-bar/StatusBarVisibilityMenu.tsx | 12 ++++++ .../status-bar/use-status-bar-controller.ts | 19 ++++++--- .../ui/ui-slice-hydration-status-bar-items.ts | 4 +- 13 files changed, 163 insertions(+), 51 deletions(-) diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts index dfc8aade22ed..7cdc482aad80 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { execFile } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { getSpawnArgsForWindows } from '../win32-utils' -import { fetchKiroRateLimits, parseKiroUsageOutput } from './kiro-usage-fetcher' +import { fetchKiroRateLimits, parseKiroUsageOutput, resolveKiroCommand } from './kiro-usage-fetcher' vi.mock('node:child_process', () => ({ execFile: vi.fn() })) vi.mock('../win32-utils', () => ({ getSpawnArgsForWindows: vi.fn() })) @@ -10,6 +13,21 @@ 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 @@ -46,12 +64,10 @@ Credits (1233.74 of 2000 covered in plan) spawnCmd: 'C:\\Windows\\System32\\cmd.exe', spawnArgs: ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'] }) - vi.mocked(execFile).mockImplementation( - ((_command, _args, _options, callback) => { - callback(null, KIRO_USAGE_OUTPUT, '') - return undefined as never - }) as unknown as typeof execFile - ) + vi.mocked(execFile).mockImplementation(((_command, _args, _options, callback) => { + callback(null, KIRO_USAGE_OUTPUT, '') + return undefined as never + }) as unknown as typeof execFile) const result = await fetchKiroRateLimits({ command: 'C:\\Users\\me\\bin\\kiro-cli.cmd' }) diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index 88fae9191902..0e6f8ad4018f 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -1,8 +1,6 @@ import { execFile } from 'node:child_process' -import { accessSync, constants } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { getSpawnArgsForWindows } from '../win32-utils' @@ -39,25 +37,18 @@ function result( } } -function resolveKiroCommand(): string { +export function resolveKiroCommand( + options: { + pathEnv?: string | null + platform?: NodeJS.Platform + homePath?: string + } = {} +): string { const configured = process.env.KIRO_CLI_PATH?.trim() - const candidates = [ - configured, - join(homedir(), 'bin', 'kiro-cli'), - join(homedir(), '.local', 'bin', 'kiro-cli') - ] - for (const candidate of candidates) { - if (!candidate) { - continue - } - try { - accessSync(candidate, constants.X_OK) - return candidate - } catch { - // Continue to the next known install location. - } + if (configured) { + return configured } - return process.platform === 'win32' ? 'kiro-cli.exe' : 'kiro-cli' + return resolveCliCommand('kiro-cli', options) } const runCommand: CommandRunner = (command, args, signal) => @@ -65,10 +56,11 @@ const runCommand: CommandRunner = (command, args, signal) => // Why: execFile cannot launch configured .cmd/.bat shims directly on // Windows; route them through cmd.exe like the other usage fetchers. const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) + const env = withCliRuntimeOnPath(command, { ...process.env }) execFile( spawnCmd, spawnArgs, - { encoding: 'utf8', timeout: CLI_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal }, + { encoding: 'utf8', timeout: CLI_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal, env }, (error, stdout, stderr) => { if (error) { reject(error) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 2c66951cabeb..5f4d561854f0 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 { readGrokAuthSession } from './grok-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { @@ -312,6 +313,43 @@ 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(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(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 } = {} 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 9e9d9506a051..1ac0907758c8 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,7 +1,7 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' -import { settleSiblingProviderResult } from './service-sibling-provider-result' -import type { ProviderRateLimits } from './service-types' +import { settleSiblingProviderResult, type SettledProviderResult } from './service-sibling-provider-result' +import type { InternalRateLimitState, ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { protected async runFetchAllCycle( @@ -36,7 +36,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + kiroResultPromise } = prepared if (signal.aborted) { return @@ -193,17 +194,25 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) + await Promise.all([ + this.applyGrokAndCursorResults(grokResultPromise, cursorResultPromise, previousState, signal), + this.applyKiroResult(kiroResultPromise, previousState.kiro, signal) + ]) + } + + private async applyGrokAndCursorResults( + grokResultPromise: Promise, + cursorResultPromise: Promise, + previousState: Pick, + signal: AbortSignal + ): Promise { const [grokSettled, cursorSettled] = await Promise.all([grokResultPromise, cursorResultPromise]) if (signal.aborted) { return } const grok = settleSiblingProviderResult('grok', grokSettled) const cursor = settleSiblingProviderResult('cursor', cursorSettled) - // 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 = @@ -218,4 +227,21 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor) }) } + + 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 df36011ab312..63fab594f94d 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' @@ -44,6 +45,7 @@ export type FetchAllCyclePrepared = { ] grokResultPromise: Promise cursorResultPromise: Promise + kiroResultPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -130,7 +132,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), grok: this.withFetchingStatus(previousState.grok, 'grok'), - cursor: this.withFetchingStatus(previousState.cursor, 'cursor') + cursor: this.withFetchingStatus(previousState.cursor, 'cursor'), + kiro: this.withFetchingStatus(previousState.kiro, 'kiro') }) // Why: the Cursor probe reads the macOS Keychain, so it is awaited inside the @@ -154,6 +157,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 = @@ -231,7 +240,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + kiroResultPromise } } } diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index e49c003cbb1d..973fe6f035a4 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -79,7 +79,8 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue minimax: this.state.minimax, grok: this.state.grok, antigravity: this.state.antigravity, - cursor: this.state.cursor + cursor: this.state.cursor, + kiro: this.state.kiro } return Object.entries(byProvider).map(([provider, limits]) => ({ provider: provider as ActiveRateLimitProvider, diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index a10bfa3f3c00..f295c603b02e 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -32,7 +32,8 @@ export abstract class RateLimitServiceState { antigravity: null, minimax: null, grok: null, - cursor: null + cursor: null, + kiro: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' // Why: the Cursor probe reads the macOS Keychain, so it cannot run synchronously @@ -52,7 +53,8 @@ export abstract class RateLimitServiceState { minimax: 0, grok: 0, antigravity: 0, - cursor: 0 + cursor: 0, + kiro: 0 } // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. protected activeFailureStreakByProvider: Record = { @@ -64,7 +66,8 @@ export abstract class RateLimitServiceState { minimax: 0, grok: 0, antigravity: 0, - cursor: 0 + cursor: 0, + kiro: 0 } protected mainWindow: BrowserWindow | null = null protected detachWindowListeners: (() => void) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 9783f37f77df..f46840be2134 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -109,7 +109,8 @@ export type InternalRateLimitState = { antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null - cursor: ProviderRateLimits | null + cursor: ProviderRateLimits | null, + kiro: ProviderRateLimits | null } export function normalizePollingInterval(ms: number): number { 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 3a9bc0cba485..7d7e07a6f9bc 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -67,7 +67,7 @@ export function ProviderLetterBadge({ p }: { p: ProviderRateLimits }): React.JSX ) } -function getProviderLetter(provider: ProviderRateLimits['provider']): string { +export function getProviderLetter(provider: ProviderRateLimits['provider']): string { switch (provider) { case 'claude': return 'C' @@ -85,6 +85,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { return 'R' case 'cursor': return 'U' + case 'kiro': + return 'Q' case 'codex': return 'X' } diff --git a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx index a2dab03d9c09..c5541b0b9e7e 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')} + + )} { 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 b7f57c7f1e7b..d4a57586160d 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 @@ -99,7 +99,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, kiro } = + 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. @@ -125,6 +126,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 showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -154,6 +156,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: OpenCode Go is web/cookie-auth, not a CLI on PATH, so detection-gating doesn't apply. const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go') @@ -172,11 +178,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showAntigravity || showMiniMax || showGrok || - showCursor + showCursor || + showKiro 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 }, + { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, kiro }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -190,7 +197,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { antigravity?.status === 'fetching' || minimax?.status === 'fetching' || grok?.status === 'fetching' || - cursor?.status === 'fetching' + cursor?.status === 'fetching' || + kiro?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -210,7 +218,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showKimi ? visibleKimi : null, showMiniMax ? visibleMiniMax : null, showGrok ? visibleGrok : null, - showCursor ? visibleCursor : null + showCursor ? visibleCursor : null, + showKiro ? visibleKiro : null ].filter((p): p is ProviderRateLimits => p !== null) const handleManageAccounts = (): void => { 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 47c770804c23..3b3037501edb 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' export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { let items = migrateStatusBarItems(ui.statusBarItems) @@ -17,7 +18,8 @@ export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { ['_minimaxStatusBarDefaultAdded', DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM], ['_antigravityStatusBarDefaultAdded', DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM], ['_grokStatusBarDefaultAdded', DEFAULT_ON_GROK_STATUS_BAR_ITEM], - ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM] + ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM], + ['_kiroStatusBarDefaultAdded', DEFAULT_ON_KIRO_STATUS_BAR_ITEM] ] as const for (const [flag, item] of defaults) { if (!ui[flag] && !items.includes(item)) { From b28b50ed4b347fb7082e12f68639af67ddf60e60 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 10:13:34 +0800 Subject: [PATCH 12/15] fix(usage): complete Kiro rebase integration --- src/main/rate-limits/service-refresh-orchestration.test.ts | 4 +--- .../rate-limits/service/service-full-cycle-application.ts | 5 ++++- src/main/rate-limits/service/service-types.ts | 2 +- src/renderer/src/components/status-bar/tooltip.tsx | 1 + src/renderer/src/components/status-bar/usage-error-copy.ts | 1 + 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 5f4d561854f0..a75ae90053c4 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -321,9 +321,7 @@ describe('RateLimitService', () => { 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(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) 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 1ac0907758c8..590acf4e5ef7 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,6 +1,9 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' -import { settleSiblingProviderResult, type SettledProviderResult } from './service-sibling-provider-result' +import { + settleSiblingProviderResult, + type SettledProviderResult +} from './service-sibling-provider-result' import type { InternalRateLimitState, ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index f46840be2134..fcf987cd5419 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -109,7 +109,7 @@ export type InternalRateLimitState = { antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null - cursor: ProviderRateLimits | null, + cursor: ProviderRateLimits | null kiro: ProviderRateLimits | null } diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 0e0a1e0f490f..52dcc2eba75a 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -118,6 +118,7 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem } if (provider === 'cursor') { return + } if (provider === 'kiro') { 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 2114c1d896d4..939200ab557c 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -28,6 +28,7 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) } if (provider === 'cursor') { return 'Cursor' + } if (provider === 'kiro') { return 'Kiro' } From 2a1ab9d1fb217f55eab2b4a20a19b360d852ce7d Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 17:09:06 +0800 Subject: [PATCH 13/15] fix(usage): narrow Kiro CLI errors without assertions --- .../rate-limits/kiro-usage-fetcher.test.ts | 18 ++++++++++++------ src/main/rate-limits/kiro-usage-fetcher.ts | 8 +++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts index 7cdc482aad80..9a061dd25b79 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it, vi } from 'vitest' -import { execFile } from 'node:child_process' +import * as childProcess from 'node:child_process' import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getSpawnArgsForWindows } from '../win32-utils' import { fetchKiroRateLimits, parseKiroUsageOutput, resolveKiroCommand } from './kiro-usage-fetcher' -vi.mock('node:child_process', () => ({ execFile: vi.fn() })) +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: vi.fn() +})) vi.mock('../win32-utils', () => ({ getSpawnArgsForWindows: vi.fn() })) const KIRO_USAGE_OUTPUT = @@ -64,10 +67,13 @@ Credits (1233.74 of 2000 covered in plan) spawnCmd: 'C:\\Windows\\System32\\cmd.exe', spawnArgs: ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'] }) - vi.mocked(execFile).mockImplementation(((_command, _args, _options, callback) => { + vi.mocked(childProcess.execFile).mockImplementation((_command, _args, _options, callback) => { + if (!callback) { + throw new Error('Expected execFile callback') + } callback(null, KIRO_USAGE_OUTPUT, '') - return undefined as never - }) as unknown as typeof execFile) + return new childProcess.ChildProcess() + }) const result = await fetchKiroRateLimits({ command: 'C:\\Users\\me\\bin\\kiro-cli.cmd' }) @@ -78,7 +84,7 @@ Credits (1233.74 of 2000 covered in plan) '--wrap', 'never' ]) - expect(execFile).toHaveBeenCalledWith( + expect(childProcess.execFile).toHaveBeenCalledWith( 'C:\\Windows\\System32\\cmd.exe', ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'], expect.anything(), diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index 0e6f8ad4018f..9fa1ab1b4b7c 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -115,7 +115,13 @@ export async function fetchKiroRateLimits( ) return parseKiroUsageOutput(`${stdout}\n${stderr}`) } catch (error) { - const code = (error as NodeJS.ErrnoException | null)?.code + 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', From 1d8d23718a12f41e0cd9b0b1ca452c4305c0b76a Mon Sep 17 00:00:00 2001 From: Willian Felipe Date: Thu, 8 Oct 2026 09:42:16 -0300 Subject: [PATCH 14/15] fix(usage): detect configured Kiro CLI and use shared process runner --- .../ipc/preflight-agent-detection.test.ts | 42 +++++++++++++ src/main/preflight/agent-detection.ts | 15 ++++- .../rate-limits/kiro-usage-fetcher.test.ts | 62 ++++++++++--------- src/main/rate-limits/kiro-usage-fetcher.ts | 35 +++++------ 4 files changed, 101 insertions(+), 53 deletions(-) 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 index 9a061dd25b79..8f0774e8b7f7 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -1,16 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import * as childProcess from 'node:child_process' import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { getSpawnArgsForWindows } from '../win32-utils' +import { runProcess } from '../../shared/child-process/run-process' import { fetchKiroRateLimits, parseKiroUsageOutput, resolveKiroCommand } from './kiro-usage-fetcher' -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - execFile: vi.fn() -})) -vi.mock('../win32-utils', () => ({ getSpawnArgsForWindows: vi.fn() })) +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%' @@ -62,37 +57,44 @@ Credits (1233.74 of 2000 covered in plan) expect(result.status).toBe('ok') }) - it('launches configured .cmd shims through the shared Windows batch launcher', async () => { - vi.mocked(getSpawnArgsForWindows).mockReturnValue({ - spawnCmd: 'C:\\Windows\\System32\\cmd.exe', - spawnArgs: ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'] - }) - vi.mocked(childProcess.execFile).mockImplementation((_command, _args, _options, callback) => { - if (!callback) { - throw new Error('Expected execFile callback') - } - callback(null, KIRO_USAGE_OUTPUT, '') - return new childProcess.ChildProcess() + 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(getSpawnArgsForWindows).toHaveBeenCalledWith('C:\\Users\\me\\bin\\kiro-cli.cmd', [ - 'chat', - '/usage', - '--no-interactive', - '--wrap', - 'never' - ]) - expect(childProcess.execFile).toHaveBeenCalledWith( - 'C:\\Windows\\System32\\cmd.exe', - ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'], - expect.anything(), - expect.any(Function) + 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', diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index 9fa1ab1b4b7c..459c61ff0f87 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -1,8 +1,7 @@ -import { execFile } from 'node:child_process' 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' -import { getSpawnArgsForWindows } from '../win32-utils' const CLI_TIMEOUT_MS = 20_000 const MONTHLY_WINDOW_MINUTES = 43_200 @@ -51,25 +50,21 @@ export function resolveKiroCommand( return resolveCliCommand('kiro-cli', options) } -const runCommand: CommandRunner = (command, args, signal) => - new Promise((resolve, reject) => { - // Why: execFile cannot launch configured .cmd/.bat shims directly on - // Windows; route them through cmd.exe like the other usage fetchers. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) - const env = withCliRuntimeOnPath(command, { ...process.env }) - execFile( - spawnCmd, - spawnArgs, - { encoding: 'utf8', timeout: CLI_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal, env }, - (error, stdout, stderr) => { - if (error) { - reject(error) - return - } - resolve({ stdout, stderr }) - } - ) +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, '') From b8509007363b774d269eb1de1518f3c7b76b515d Mon Sep 17 00:00:00 2001 From: Willian Felipe Date: Thu, 8 Oct 2026 10:10:25 -0300 Subject: [PATCH 15/15] fix(usage): accept Kiro reset descriptions without ISO dates --- src/main/rate-limits/kiro-usage-fetcher.test.ts | 11 +++++++++++ src/main/rate-limits/kiro-usage-fetcher.ts | 12 +++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts index 8f0774e8b7f7..011c7530b9f8 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -41,6 +41,17 @@ Credits (1233.74 of 2000 covered in plan) }) }) + 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, diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index 459c61ff0f87..d43939c1003c 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -68,9 +68,8 @@ const runCommand: CommandRunner = async (command, args, signal) => { export function parseKiroUsageOutput(output: string): ProviderRateLimits { const readable = stripAnsiEscapeSequences(output).replace(/\r/g, '') - const header = readable.match( - /Estimated Usage\s*\|\s*resets on (\d{4}-\d{2}-\d{2})\s*\|\s*([^\n]+)/i - ) + 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 @@ -81,7 +80,7 @@ export function parseKiroUsageOutput(output: string): ProviderRateLimits { : percent ? Math.min(100, Math.max(0, Number(percent[1]))) : null - if (!header || usedPercent === null || !Number.isFinite(usedPercent)) { + if (!header || !resetDescription || usedPercent === null || !Number.isFinite(usedPercent)) { return result('error', 'Could not parse Kiro usage output', null, null, 'parse') } return result( @@ -90,10 +89,9 @@ export function parseKiroUsageOutput(output: string): ProviderRateLimits { { usedPercent, windowMinutes: MONTHLY_WINDOW_MINUTES, - // Kiro reports a calendar date without a time zone or time of day. Keep it as - // display metadata instead of inventing a UTC-midnight countdown. + // Kiro supplies no exact reset time or zone, so keep its text as display metadata. resetsAt: null, - resetDescription: header[1] + resetDescription }, header[2].trim() )