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:
parent
8aa5b53148
commit
129b4c755f
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
|
|||
'codex',
|
||||
'gemini',
|
||||
'opencode-go',
|
||||
'kimi',
|
||||
'ssh',
|
||||
'resource-usage',
|
||||
'ports'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue