Show Kimi Code subscription usage in the status bar (#4641)

* Show Kimi Code subscription usage in the status bar

Add a read-only rate-limit fetcher for Kimi Code that reads the OAuth token from ~/.kimi-code/credentials (honoring KIMI_CODE_HOME) and queries Kimi's usages endpoint, surfacing the 5h session window and weekly quota in the existing status-bar rate-limit UI alongside Claude/Codex/Gemini. Gated on the kimi CLI being detected, with a settings toggle.

Read-only by design: the fetcher never refreshes or writes credentials — rotating Kimi's refresh token would log out the user's live CLI session — and never calls Kimi's completion endpoint. It only reads the existing token and the usages quota endpoint, mirroring how the CLI itself reads managed usage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Harden Kimi usage status handling

Preserve cached quota on transient Kimi credential/API failures and migrate the default status-bar item for existing users.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lu 2026-06-08 01:58:29 +08:00 committed by GitHub
parent 8aa5b53148
commit 129b4c755f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 531 additions and 35 deletions

View File

@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const netFetchMock = vi.hoisted(() => vi.fn())
const fsState = vi.hoisted<{ credentials: string | null; readError: Error | null }>(() => ({
credentials: null,
readError: null
}))
vi.mock('electron', () => ({
net: { fetch: netFetchMock }
}))
vi.mock('node:fs', () => ({
existsSync: () => fsState.credentials !== null,
readFileSync: () => {
if (fsState.readError) {
throw fsState.readError
}
if (fsState.credentials === null) {
throw new Error('ENOENT')
}
return fsState.credentials
},
writeFileSync: () => {},
renameSync: () => {}
}))
vi.mock('node:os', () => ({ homedir: () => '/home/test' }))
import { fetchKimiRateLimits } from './kimi-fetcher'
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body
} as Response
}
// Real shape captured from GET https://api.kimi.com/coding/v1/usages.
const USAGE_RESPONSE = {
user: { userId: 'u1', membership: { level: 'LEVEL_INTERMEDIATE' } },
usage: { limit: '1000', remaining: '1000', resetTime: '2026-06-09T07:52:41.230862Z' },
limits: [
{
window: { duration: 300, timeUnit: 'TIME_UNIT_MINUTE' },
detail: { limit: '100', remaining: '40', resetTime: '2026-06-04T08:52:41.230862Z' }
}
],
subType: 'TYPE_PURCHASE'
}
function freshCredentials(): string {
// expires_at far in the future (seconds).
return JSON.stringify({ access_token: 'tok-abc', expires_at: 99_999_999_999 })
}
describe('fetchKimiRateLimits', () => {
beforeEach(() => {
netFetchMock.mockReset()
fsState.credentials = null
fsState.readError = null
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('returns unavailable when not signed in', async () => {
const result = await fetchKimiRateLimits()
expect(result.provider).toBe('kimi')
expect(result.status).toBe('unavailable')
expect(result.session).toBeNull()
expect(result.weekly).toBeNull()
expect(netFetchMock).not.toHaveBeenCalled()
})
it('maps the usages payload to session (5h) and weekly windows', async () => {
fsState.credentials = freshCredentials()
netFetchMock.mockResolvedValueOnce(jsonResponse(USAGE_RESPONSE))
const result = await fetchKimiRateLimits()
expect(result.status).toBe('ok')
expect(result.provider).toBe('kimi')
// 5h window from limits[]: 40/100 remaining → 60% used.
expect(result.session?.windowMinutes).toBe(300)
expect(result.session?.usedPercent).toBeCloseTo(60)
// Weekly from top-level usage: 1000/1000 remaining → 0% used.
expect(result.weekly?.windowMinutes).toBe(10080)
expect(result.weekly?.usedPercent).toBeCloseTo(0)
// Bearer token from the credentials file is sent.
const [, init] = netFetchMock.mock.calls[0]
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok-abc')
})
it('surfaces an error when the usage request fails', async () => {
fsState.credentials = freshCredentials()
netFetchMock.mockResolvedValueOnce(jsonResponse({}, 500))
const result = await fetchKimiRateLimits()
expect(result.status).toBe('error')
expect(result.session).toBeNull()
})
it('surfaces an error when the credentials file cannot be parsed', async () => {
fsState.credentials = '{'
const result = await fetchKimiRateLimits()
expect(result.status).toBe('error')
expect(result.error).toMatch(/json/i)
expect(netFetchMock).not.toHaveBeenCalled()
})
it('surfaces an error when the credentials file cannot be read', async () => {
fsState.credentials = freshCredentials()
fsState.readError = new Error('EACCES')
const result = await fetchKimiRateLimits()
expect(result.status).toBe('error')
expect(result.error).toMatch(/EACCES/)
expect(netFetchMock).not.toHaveBeenCalled()
})
it('treats an empty usage payload as an error', async () => {
fsState.credentials = freshCredentials()
netFetchMock.mockResolvedValueOnce(jsonResponse({}))
const result = await fetchKimiRateLimits()
expect(result.status).toBe('error')
expect(result.error).toMatch(/quota windows/)
expect(result.session).toBeNull()
expect(result.weekly).toBeNull()
})
it('does NOT refresh or call the API when the token is expired (read-only)', async () => {
// expires_at in the past → token stale; fetcher must not hit the network.
fsState.credentials = JSON.stringify({ access_token: 'tok-old', expires_at: 1 })
const result = await fetchKimiRateLimits()
expect(result.status).toBe('error')
expect(result.error).toMatch(/expired/i)
expect(netFetchMock).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,267 @@
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { net } from 'electron'
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
// Why: Kimi Code's managed coding plan exposes subscription usage at
// `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle).
// The base URL is overridable via the same env var the CLI honours so Orca
// stays aligned with a user's self-hosted/staging config.
const KIMI_BASE_URL = process.env.KIMI_CODE_BASE_URL ?? 'https://api.kimi.com/coding/v1'
const API_TIMEOUT_MS = 10_000
const SESSION_WINDOW_MINUTES = 300 // 5h
const WEEKLY_WINDOW_MINUTES = 10080 // 7d
function getKimiHome(): string {
// Why: match the CLI's `KIMI_CODE_HOME ?? ~/.kimi-code` resolution so we read
// the same OAuth credentials the running Kimi CLI writes.
return process.env.KIMI_CODE_HOME ?? join(homedir(), '.kimi-code')
}
function getCredentialsPath(): string {
return join(getKimiHome(), 'credentials', 'kimi-code.json')
}
type KimiCredentials = {
access_token?: string
expires_at?: number
}
type CredentialsReadResult =
| { status: 'missing' }
| { status: 'error'; error: string }
| { status: 'ok'; credentials: KimiCredentials }
function parseCredentials(value: unknown): KimiCredentials | null {
if (typeof value !== 'object' || value === null) {
return null
}
const credentials: KimiCredentials = {}
if ('access_token' in value && typeof value.access_token === 'string') {
credentials.access_token = value.access_token
}
if ('expires_at' in value && typeof value.expires_at === 'number') {
credentials.expires_at = value.expires_at
}
return credentials
}
function readCredentials(): CredentialsReadResult {
const path = getCredentialsPath()
if (!existsSync(path)) {
return { status: 'missing' }
}
try {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
const credentials = parseCredentials(parsed)
return credentials
? { status: 'ok', credentials }
: { status: 'error', error: 'Kimi credentials file is invalid' }
} catch (err) {
return {
status: 'error',
error: err instanceof Error ? err.message : 'Unable to read Kimi credentials'
}
}
}
function isAccessTokenFresh(creds: KimiCredentials): boolean {
return (
typeof creds.access_token === 'string' &&
creds.access_token.length > 0 &&
typeof creds.expires_at === 'number' &&
// Why: small skew margin so we don't fire a request against a token that
// expires mid-flight. The CLI refreshes the file on its next run.
creds.expires_at - Math.floor(Date.now() / 1000) > 5
)
}
// ---------------------------------------------------------------------------
// Usage payload parsing (see packages/oauth/src/managed-usage.ts in the CLI)
// ---------------------------------------------------------------------------
type KimiUsageDetail = {
limit?: string | number
remaining?: string | number
used?: string | number
resetTime?: string
resetAt?: string
}
type KimiUsageWindow = {
duration?: number
timeUnit?: string
}
type KimiUsageLimit = {
window?: KimiUsageWindow
detail?: KimiUsageDetail
}
type KimiUsageResponse = {
usage?: KimiUsageDetail
limits?: KimiUsageLimit[]
}
function toInt(value: string | number | undefined): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function windowToMinutes(window: KimiUsageWindow | undefined): number | null {
const duration = toInt(window?.duration)
if (duration === null) {
return null
}
const unit = (window?.timeUnit ?? '').toUpperCase()
if (unit.includes('MINUTE')) {
return duration
}
if (unit.includes('HOUR')) {
return duration * 60
}
if (unit.includes('DAY')) {
return duration * 60 * 24
}
if (unit.includes('SECOND')) {
return Math.round(duration / 60)
}
return duration
}
function parseResetDescription(isoString: string | undefined): string | null {
if (!isoString) {
return null
}
const date = new Date(isoString)
if (isNaN(date.getTime())) {
return null
}
const isToday = date.toDateString() === new Date().toDateString()
return isToday
? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
: date.toLocaleDateString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })
}
function mapWindow(
detail: KimiUsageDetail | undefined,
windowMinutes: number
): RateLimitWindow | null {
if (!detail) {
return null
}
const limit = toInt(detail.limit)
let used = toInt(detail.used)
if (used === null) {
const remaining = toInt(detail.remaining)
if (remaining !== null && limit !== null) {
used = limit - remaining
}
}
if (limit === null || limit <= 0 || used === null) {
return null
}
const reset = detail.resetTime ?? detail.resetAt
return {
usedPercent: Math.min(100, Math.max(0, (used / limit) * 100)),
windowMinutes,
resetsAt: reset ? new Date(reset).getTime() || null : null,
resetDescription: parseResetDescription(reset)
}
}
function mapUsageResponse(data: KimiUsageResponse): ProviderRateLimits {
// Why: the top-level `usage` block is the weekly quota; the windowed entries
// in `limits` carry shorter rolling windows — the 5h one is the session view.
const weekly = mapWindow(data.usage, WEEKLY_WINDOW_MINUTES)
let session: RateLimitWindow | null = null
for (const limit of data.limits ?? []) {
const minutes = windowToMinutes(limit.window) ?? SESSION_WINDOW_MINUTES
const mapped = mapWindow(limit.detail, minutes)
if (!mapped) {
continue
}
// Prefer the window closest to a 5h session; otherwise keep the first seen.
if (
session === null ||
Math.abs(minutes - SESSION_WINDOW_MINUTES) <
Math.abs(session.windowMinutes - SESSION_WINDOW_MINUTES)
) {
session = mapped
}
}
return {
provider: 'kimi',
session,
weekly,
updatedAt: Date.now(),
error: session || weekly ? null : 'Kimi usage response did not include quota windows',
status: session || weekly ? 'ok' : 'error'
}
}
function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits {
return { provider: 'kimi', session: null, weekly: null, updatedAt: Date.now(), error, status }
}
/**
* Read-only subscription usage for Kimi Code.
*
* Why read-only: the access token lives in `~/.kimi-code/credentials/kimi-code.json`
* and is refreshed by the Kimi CLI itself (15-min TTL, refresh-token rotation).
* Orca must NEVER refresh or rewrite that file a rotated refresh token would
* log out a live `kimi` session. We only read the current token and call the
* same `GET /usages` endpoint, with the same headers, that the CLI's own
* `/usage` command uses. The completion endpoint (the one Moonshot gates to
* approved coding agents) is never touched here.
*/
export async function fetchKimiRateLimits(): Promise<ProviderRateLimits> {
const readResult = readCredentials()
if (readResult.status === 'missing') {
return result('unavailable', 'Not signed in to Kimi Code')
}
if (readResult.status === 'error') {
return result('error', readResult.error)
}
const creds = readResult.credentials
if (typeof creds.access_token !== 'string' || creds.access_token.length === 0) {
return result('error', 'Kimi credentials file is missing an access token')
}
if (!isAccessTokenFresh(creds)) {
// Why: don't refresh — the CLI owns the token lifecycle. Report a transient
// error so the rate-limit service keeps the last good snapshot (stale
// policy) until the user next runs Kimi and the CLI refreshes the file.
return result('error', 'Kimi token expired — open Kimi to refresh')
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
try {
const res = await net.fetch(`${KIMI_BASE_URL.replace(/\/$/, '')}/usages`, {
// Why: identical to the CLI's fetchManagedUsage — bearer token + Accept.
// No extra User-Agent: the usages endpoint authenticates by token only.
headers: { Authorization: `Bearer ${creds.access_token}`, Accept: 'application/json' },
signal: controller.signal
})
if (res.status === 401 || res.status === 403) {
return result('error', `Kimi usage request unauthorized (HTTP ${res.status})`)
}
if (!res.ok) {
return result('error', `Kimi usage request failed (HTTP ${res.status})`)
}
const data: unknown = await res.json()
return mapUsageResponse(typeof data === 'object' && data !== null ? data : {})
} catch (err) {
return result('error', err instanceof Error ? err.message : 'Kimi usage request failed')
} finally {
clearTimeout(timeout)
}
}

View File

@ -17,6 +17,7 @@ import {
type NormalizedClaudeAccountSelectionTarget
} from '../claude-accounts/runtime-selection'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
import {
normalizeCodexAccountSelectionTarget,
@ -51,6 +52,7 @@ type InternalRateLimitState = {
codex: ProviderRateLimits | null
gemini: ProviderRateLimits | null
opencodeGo: ProviderRateLimits | null
kimi: ProviderRateLimits | null
}
function normalizePollingInterval(ms: number): number {
@ -65,7 +67,8 @@ export class RateLimitService {
claude: null,
codex: null,
gemini: null,
opencodeGo: null
opencodeGo: null,
kimi: null
}
private pollInterval: number = DEFAULT_POLL_MS
private timer: ReturnType<typeof setInterval> | null = null
@ -742,7 +745,7 @@ export class RateLimitService {
private withFetchingStatus(
current: ProviderRateLimits | null,
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go'
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
): ProviderRateLimits {
if (!current) {
return {
@ -792,27 +795,30 @@ export class RateLimitService {
gemini: this.withFetchingStatus(previousState.gemini, 'gemini'),
opencodeGo: opencodeConfigChanged
? this.withFetchingStatus(null, 'opencode-go')
: this.withFetchingStatus(previousState.opencodeGo, 'opencode-go')
: this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'),
kimi: this.withFetchingStatus(previousState.kimi, 'kimi')
})
const missingWslCodexHome = codexHomePath
? null
: this.getMissingWslCodexHomeResult(codexTarget)
const [claudeResult, codexResult, geminiResult, opencodeGoResult] = await Promise.allSettled([
fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
// Why: active quota refreshes run on startup/focus/timers. They must
// never spawn hidden Claude Code, which can trigger macOS App Data TCC.
allowPtyFallback: false
}),
missingWslCodexHome ??
fetchCodexRateLimits({
codexHomePath,
allowPtyFallback: this.shouldAllowCodexPtyFallback()
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult] =
await Promise.allSettled([
fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
// Why: active quota refreshes run on startup/focus/timers. They must
// never spawn hidden Claude Code, which can trigger macOS App Data TCC.
allowPtyFallback: false
}),
fetchGeminiRateLimits(geminiCliOAuthEnabled),
fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined)
])
missingWslCodexHome ??
fetchCodexRateLimits({
codexHomePath,
allowPtyFallback: this.shouldAllowCodexPtyFallback()
}),
fetchGeminiRateLimits(geminiCliOAuthEnabled),
fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined),
fetchKimiRateLimits()
])
const claude =
claudeResult.status === 'fulfilled'
@ -869,6 +875,18 @@ export class RateLimitService {
status: 'error'
} satisfies ProviderRateLimits)
const kimi =
kimiResult.status === 'fulfilled'
? kimiResult.value
: ({
provider: 'kimi',
session: null,
weekly: null,
updatedAt: Date.now(),
error: kimiResult.reason instanceof Error ? kimiResult.reason.message : 'Unknown error',
status: 'error'
} satisfies ProviderRateLimits)
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
@ -898,7 +916,8 @@ export class RateLimitService {
? opencodeConfigChanged
? opencodeGo
: this.applyStalePolicy(opencodeGo, previousState.opencodeGo)
: this.state.opencodeGo
: this.state.opencodeGo,
kimi: this.applyStalePolicy(kimi, previousState.kimi)
})
this.lastFetchAt = Date.now()

View File

@ -37,6 +37,13 @@ export const STATUS_BAR_TOGGLES: readonly {
keywords: ['status bar', 'opencode', 'opencode-go', 'usage', 'tokens', 'cost'],
toggleDescription: 'Show OpenCode Go token and cost usage for the active workspace.'
},
{
id: 'kimi',
title: 'Kimi Usage',
description: 'Show Kimi subscription usage in the status bar.',
keywords: ['status bar', 'kimi', 'usage', 'subscription', 'moonshot'],
toggleDescription: 'Show Kimi subscription usage for the active workspace.'
},
{
id: 'ssh',
title: 'SSH Status',

View File

@ -37,6 +37,7 @@ import type {
} from '../../../../shared/rate-limit-types'
import { ProviderIcon, ProviderPanel, barColor } from './tooltip'
import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
import { AgentIcon } from '@/lib/agent-catalog'
import { formatWindowLabel } from '@/lib/window-label-formatter'
import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart'
import { SshStatusSegment } from './SshStatusSegment'
@ -1444,7 +1445,9 @@ export function ProviderDetailsMenu({
? 'G'
: provider.provider === 'opencode-go'
? 'O'
: 'X'}
: provider.provider === 'kimi'
? 'K'
: 'X'}
</span>
</span>
) : (
@ -1584,7 +1587,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
return null
}
const { claude, codex, gemini, opencodeGo } = rateLimits
const { claude, codex, gemini, opencodeGo, kimi } = rateLimits
// Why: a provider only earns a bar once it's configured (isProviderConfigured
// drops the `unavailable` state — Gemini OAuth off, OpenCode Go cookie unset,
@ -1604,6 +1607,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
isProviderConfigured(gemini) &&
statusBarItems.includes('gemini') &&
isStatusBarItemAvailable('gemini', detectedAgentIds)
const showKimi =
isProviderConfigured(kimi) &&
statusBarItems.includes('kimi') &&
isStatusBarItemAvailable('kimi', detectedAgentIds)
// Why: OpenCode Go is a web/cookie-auth provider, not a CLI on PATH, so
// detection-gating doesn't apply.
const showOpencodeGo = isProviderConfigured(opencodeGo) && statusBarItems.includes('opencode-go')
@ -1612,12 +1619,14 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
const showPorts = statusBarItems.includes('ports')
const showFloatingTerminalToggle =
floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar'
const anyVisible = showClaude || showCodex || showGemini || showOpencodeGo || showResourceUsage
const anyVisible =
showClaude || showCodex || showGemini || showOpencodeGo || showKimi || showResourceUsage
const anyFetching =
claude?.status === 'fetching' ||
codex?.status === 'fetching' ||
gemini?.status === 'fetching' ||
opencodeGo?.status === 'fetching'
opencodeGo?.status === 'fetching' ||
kimi?.status === 'fetching'
const compact = containerWidth < 900
const iconOnly = containerWidth < 500
@ -1665,6 +1674,14 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
ariaLabel="Open OpenCode Go usage details"
/>
)}
{showKimi && (
<ProviderDetailsMenu
provider={kimi}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Kimi usage details"
/>
)}
{anyVisible && (
<Tooltip>
<TooltipTrigger asChild>
@ -1774,6 +1791,18 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
<OpenCodeGoIcon size={14} />
OpenCode Go Usage
</DropdownMenuCheckboxItem>
{isStatusBarItemAvailable('kimi', detectedAgentIds) && (
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('kimi')}
onCheckedChange={() => {
recordFeatureInteraction('usage-tracking')
toggleStatusBarItem('kimi')
}}
>
<AgentIcon agent="kimi" size={14} />
Kimi Usage
</DropdownMenuCheckboxItem>
)}
<DropdownMenuCheckboxItem
checked={statusBarItems.includes('ssh')}
onCheckedChange={() => {

View File

@ -6,7 +6,7 @@ import type { StatusBarItem, TuiAgent } from '../../../../shared/types'
// PATH detection reports the agent as missing. Pre-detection (null) keeps
// the legacy behavior so the bar/toggle don't flicker on cold start, and
// re-show automatically once the agent appears on PATH.
const CLI_GATED_ITEMS: ReadonlySet<StatusBarItem> = new Set(['claude', 'codex', 'gemini'])
const CLI_GATED_ITEMS: ReadonlySet<StatusBarItem> = new Set(['claude', 'codex', 'gemini', 'kimi'])
export function isStatusBarItemAvailable(
id: StatusBarItem,

View File

@ -1,4 +1,5 @@
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
import { AgentIcon } from '@/lib/agent-catalog'
import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
// ---------------------------------------------------------------------------
@ -55,6 +56,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem
if (provider === 'opencode-go') {
return <OpenCodeGoIcon size={13} />
}
if (provider === 'kimi') {
return <AgentIcon agent="kimi" size={13} />
}
return <ClaudeIcon size={13} />
}
@ -151,7 +155,9 @@ export function ProviderPanel({
? 'Gemini'
: p.provider === 'opencode-go'
? 'OpenCode Go'
: p.provider
: p.provider === 'kimi'
? 'Kimi'
: p.provider
if (p.status === 'unavailable') {
return (

View File

@ -19,6 +19,7 @@ export const createRateLimitSlice: StateCreator<AppState, [], [], RateLimitSlice
codex: null,
gemini: null,
opencodeGo: null,
kimi: null,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],

View File

@ -798,7 +798,7 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().worktreeCardProperties).toEqual(['status', 'unread', 'inline-agents'])
})
it('adds the default-on Ports status item once for older persisted UI', () => {
it('adds default-on status items once for older persisted UI', () => {
const setUI = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
const store = createUIStore()
@ -810,14 +810,15 @@ describe('createUISlice hydratePersistedUI', () => {
})
)
expect(store.getState().statusBarItems).toEqual(['claude', 'resource-usage', 'ports'])
expect(store.getState().statusBarItems).toEqual(['claude', 'resource-usage', 'ports', 'kimi'])
expect(setUI).toHaveBeenCalledWith({
statusBarItems: ['claude', 'resource-usage', 'ports'],
_portsStatusBarDefaultAdded: true
statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi'],
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true
})
})
it('preserves a user-hidden Ports status item after the one-shot migration ran', () => {
it('preserves user-hidden default-on status items after one-shot migrations ran', () => {
const setUI = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
const store = createUIStore()
@ -825,7 +826,8 @@ describe('createUISlice hydratePersistedUI', () => {
store.getState().hydratePersistedUI(
makePersistedUI({
statusBarItems: ['claude', 'resource-usage'],
_portsStatusBarDefaultAdded: true
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true
})
)

View File

@ -237,6 +237,9 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI
return out as StatusBarItem[]
}
const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports'
const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi'
function normalizePersistedRightSidebarTab(
tab: PersistedUIState['rightSidebarTab'] | unknown
): PersistedUIState['rightSidebarTab'] {
@ -1856,13 +1859,24 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
// 'recent' sort keep it across restarts.
const sortBy = ui.sortBy
const migratedStatusBarItems = migrateStatusBarItems(ui.statusBarItems)
const statusBarItems =
const statusBarItemsWithPorts =
ui._portsStatusBarDefaultAdded || migratedStatusBarItems.includes('ports')
? migratedStatusBarItems
: [...migratedStatusBarItems, 'ports' as const]
if (!ui._portsStatusBarDefaultAdded && typeof window !== 'undefined') {
: [...migratedStatusBarItems, DEFAULT_ON_PORTS_STATUS_BAR_ITEM]
const statusBarItems =
ui._kimiStatusBarDefaultAdded || statusBarItemsWithPorts.includes('kimi')
? statusBarItemsWithPorts
: [...statusBarItemsWithPorts, DEFAULT_ON_KIMI_STATUS_BAR_ITEM]
if (
(!ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded) &&
typeof window !== 'undefined'
) {
window.api.ui
.set({ statusBarItems, _portsStatusBarDefaultAdded: true })
.set({
statusBarItems,
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true
})
.catch(console.error)
}
return {

View File

@ -2070,6 +2070,7 @@ function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimits']> {
codex: null,
gemini: null,
opencodeGo: null,
kimi: null,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],

View File

@ -16,7 +16,7 @@ export type RateLimitBucket = RateLimitWindow & {
}
export type ProviderRateLimits = {
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go'
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
/** 5-hour session window, null if not available. */
session: RateLimitWindow | null
/** 7-day weekly window, null if not available. */
@ -49,6 +49,7 @@ export type RateLimitState = {
codex: ProviderRateLimits | null
gemini: ProviderRateLimits | null
opencodeGo: ProviderRateLimits | null
kimi: ProviderRateLimits | null
claudeTarget: RateLimitRuntimeTarget
codexTarget: RateLimitRuntimeTarget
inactiveClaudeAccounts: InactiveAccountUsage[]

View File

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

View File

@ -2507,6 +2507,7 @@ export type StatusBarItem =
| 'codex'
| 'gemini'
| 'opencode-go'
| 'kimi'
| 'ssh'
| 'resource-usage'
| 'ports'
@ -2578,6 +2579,8 @@ export type PersistedUIState = {
_workspaceStatusesDefaultVisualsMigrated?: boolean
/** One-shot migration flag for adding the default-on Ports status item. */
_portsStatusBarDefaultAdded?: boolean
/** One-shot migration flag for adding the default-on Kimi status item. */
_kimiStatusBarDefaultAdded?: boolean
statusBarItems: StatusBarItem[]
statusBarVisible: boolean
dismissedUpdateVersion: string | null