From 0233d4c63ac02f76682b0f849f5bae19eb060d5b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 18:23:39 +0800 Subject: [PATCH 01/21] feat(oauth): parse boosterWallet extra usage from /usages --- packages/oauth/src/managed-usage.ts | 21 +++++++++-- packages/oauth/test/managed-usage.test.ts | 43 +++++++++++++++++++++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 534104a20e..84c778198d 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -48,11 +48,25 @@ export interface UsageRow { export interface ParsedManagedUsage { readonly summary: UsageRow | null; readonly limits: UsageRow[]; + readonly extraUsage: UsageRow | null; +} + +function parseBoosterWallet(raw: unknown): UsageRow | null { + if (!isRecord(raw)) return null; + const balance = raw['balance']; + if (!isRecord(balance)) return null; + if (balance['type'] !== 'BALANCE_BOOSTER') return null; + const amount = toInt(balance['amount']); + if (amount === null || amount <= 0) return null; + const amountLeft = toInt(balance['amountLeft']) ?? 0; + const used = Math.max(0, Math.min(amount - amountLeft, amount)); + const resetHint = resetHintFrom(balance); + return { label: 'Extra Usage', used, limit: amount, resetHint }; } export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { if (typeof payload !== 'object' || payload === null) { - return { summary: null, limits: [] }; + return { summary: null, limits: [], extraUsage: null }; } const rec = payload as Record; const summary = toUsageRow(rec['usage'], 'Weekly limit'); @@ -71,7 +85,8 @@ export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { if (row !== null) limits.push(row); } } - return { summary, limits }; + const extraUsage = parseBoosterWallet(rec['boosterWallet']); + return { summary, limits, extraUsage }; } function toUsageRow(raw: unknown, defaultLabel: string): UsageRow | null { @@ -126,7 +141,7 @@ function limitLabel( } function resetHintFrom(raw: Record): string | undefined { - for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) { + for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime', 'period_end', 'periodEnd']) { const v = raw[key]; if (typeof v === 'string' && v.length > 0) { return formatResetTime(v); diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 98d33ee17a..83e1832e0b 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -25,8 +25,8 @@ describe('isManagedKimiCode', () => { describe('parseManagedUsagePayload', () => { it('returns empty when payload is not an object', () => { - expect(parseManagedUsagePayload(null)).toEqual({ summary: null, limits: [] }); - expect(parseManagedUsagePayload('nope')).toEqual({ summary: null, limits: [] }); + expect(parseManagedUsagePayload(null)).toEqual({ summary: null, limits: [], extraUsage: null }); + expect(parseManagedUsagePayload('nope')).toEqual({ summary: null, limits: [], extraUsage: null }); }); it('extracts a summary from the `usage` object', () => { @@ -74,6 +74,44 @@ describe('parseManagedUsagePayload', () => { const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, resetAt: future } }); expect(parsed.summary?.resetHint).toMatch(/resets in/); }); + + it('extracts extra usage from boosterWallet.balance', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: 40, limit: 1000, name: 'Weekly limit' }, + boosterWallet: { + id: 'wallet_1', + balance: { + type: 'BALANCE_BOOSTER', + amount: '1000', + amountLeft: '500', + unit: 'UNIT_CREDIT', + periodEnd: '2026-08-01T00:00:00Z', + }, + }, + }); + expect(parsed.extraUsage).toEqual({ + label: 'Extra Usage', + used: 500, + limit: 1000, + resetHint: expect.stringMatching(/resets in/), + }); + }); + + it('returns null extra usage when boosterWallet is missing or invalid', () => { + expect(parseManagedUsagePayload({ usage: { used: 1, limit: 10 } }).extraUsage).toBeNull(); + expect( + parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'OTHER', amount: '100', amountLeft: '50' } }, + }).extraUsage, + ).toBeNull(); + expect( + parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'BALANCE_BOOSTER', amount: '0', amountLeft: '0' } }, + }).extraUsage, + ).toBeNull(); + }); }); describe('fetchManagedUsage', () => { @@ -92,6 +130,7 @@ describe('fetchManagedUsage', () => { parsed: { summary: { label: 'Weekly limit', used: 1, limit: 10 }, limits: [], + extraUsage: null, }, }); From e051c10b7b7f4a925be5de620c11d5971303bd56 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 18:28:51 +0800 Subject: [PATCH 02/21] feat(oauth): expose extraUsage on AuthManagedUsageResult --- .changeset/expose-extrausage-toolkit.md | 5 ++++ packages/oauth/src/toolkit.ts | 2 ++ packages/oauth/test/toolkit.test.ts | 35 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .changeset/expose-extrausage-toolkit.md diff --git a/.changeset/expose-extrausage-toolkit.md b/.changeset/expose-extrausage-toolkit.md new file mode 100644 index 0000000000..24952d13b4 --- /dev/null +++ b/.changeset/expose-extrausage-toolkit.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Propagate extra usage data through the OAuth managed usage result. diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 94dca02bf9..0229db7b03 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -97,6 +97,7 @@ export type AuthManagedUsageResult = readonly kind: 'ok'; readonly summary: ParsedManagedUsage['summary']; readonly limits: ParsedManagedUsage['limits']; + readonly extraUsage: ParsedManagedUsage['extraUsage']; } | FetchManagedUsageError; @@ -291,6 +292,7 @@ export class KimiOAuthToolkit { kind: 'ok', summary: result.parsed.summary, limits: result.parsed.limits, + extraUsage: result.parsed.extraUsage, }; } catch (error) { return { diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 73e091644d..8e8fab7280 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -567,6 +567,41 @@ describe('KimiOAuthToolkit', () => { expect((await storage.load(storageName))?.accessToken).toBe('fresh-access'); }); + it('propagates extraUsage from the managed usage response', async () => { + const storage = new MemoryTokenStorage(); + storage.tokens.set('kimi-code', token('access-1')); + const fetchImpl = vi.fn(async (_input: unknown, _init?: RequestInit) => + new Response( + JSON.stringify({ + usage: { used: 10, limit: 100, name: 'Weekly limit' }, + limits: [], + boosterWallet: { + balance: { + type: 'BALANCE_BOOSTER', + amount: '50', + amountLeft: '30', + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof fetch; + vi.stubGlobal('fetch', fetchImpl); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + await expect(toolkit.getManagedUsage()).resolves.toEqual({ + kind: 'ok', + summary: { label: 'Weekly limit', used: 10, limit: 100 }, + limits: [], + extraUsage: { label: 'Extra Usage', used: 20, limit: 50 }, + }); + }); + it('removes managed config on logout when an adapter supports cleanup', async () => { const storage = new MemoryTokenStorage(); storage.tokens.set('kimi-code', token('access-1')); From 7f458f6c399a00d8813707c47e868f1867425114 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 18:34:35 +0800 Subject: [PATCH 03/21] feat(kimi-code): render Extra Usage section in /usage panel --- .../tui/components/messages/usage-panel.ts | 35 +++++++++++++++++++ .../components/messages/usage-panel.test.ts | 23 ++++++++++++ 2 files changed, 58 insertions(+) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 23cef0b27d..1d621327bc 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -33,6 +33,7 @@ export interface ManagedUsageRow { export interface ManagedUsageReport { readonly summary: ManagedUsageRow | null; readonly limits: readonly ManagedUsageRow[]; + readonly extraUsage?: ManagedUsageRow | null; } export interface UsageReportOptions { @@ -136,6 +137,29 @@ function buildManagedUsageSection( return out; } +function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' { + return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; +} + +export function buildExtraUsageSection( + extraUsage: ManagedUsageRow | undefined | null, + accent: Colorize, + value: Colorize, + muted: Colorize, +): string[] { + if (extraUsage === undefined || extraUsage === null) return []; + const usedRatio = + extraUsage.limit > 0 ? Math.max(0, Math.min(extraUsage.used / extraUsage.limit, 1)) : 0; + const bar = renderProgressBar(usedRatio, 20); + const pct = `${Math.round(usedRatio * 100)}% used`; + const barColoured = currentTheme.fg(severityColor(ratioSeverity(usedRatio)), bar); + const resetStr = extraUsage.resetHint ? ` ${muted(extraUsage.resetHint)}` : ''; + return [ + accent('Extra Usage'), + ` ${barColoured} ${value(pct.padEnd(6, ' '))}${resetStr}`, + ]; +} + export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { const accent = (text: string) => currentTheme.boldFg('primary', text); const value = (text: string) => currentTheme.fg('text', text); @@ -197,6 +221,17 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { lines.push(...managedSection); } + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index ff39cb7e6b..794de9b5d4 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -48,6 +48,29 @@ describe('UsagePanelComponent', () => { expect(lines.join('\n')).toContain('resets tomorrow'); }); + it('formats an extra usage section from booster wallet data', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + label: 'Extra Usage', + used: 500, + limit: 1000, + resetHint: 'resets in 23d', + }, + }, + }).map(strip); + + expect(lines).toContain('Extra Usage'); + expect(lines.join('\n')).toContain('50% used'); + expect(lines.join('\n')).toContain('resets in 23d'); + }); + it('wraps preformatted usage lines in a bordered panel', () => { const component = new UsagePanelComponent(() => ['Session usage'], 'primary'); const output = component.render(80).map(strip); From 947725c4f853d0e02f2f55418ab2e6a8102a52ee Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 18:37:55 +0800 Subject: [PATCH 04/21] fix(kimi-code): address Task 3 review feedback for extra usage section --- .../tui/components/messages/usage-panel.ts | 13 +- .../components/messages/usage-panel.test.ts | 114 ++++++++++++++++++ 2 files changed, 121 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 1d621327bc..2cf6bdeea0 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -122,8 +122,7 @@ function buildManagedUsageSection( r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; + const out: string[] = [accent('Plan usage')]; for (const row of rows) { const ratioUsed = usedRatio(row); @@ -148,8 +147,12 @@ export function buildExtraUsageSection( muted: Colorize, ): string[] { if (extraUsage === undefined || extraUsage === null) return []; - const usedRatio = - extraUsage.limit > 0 ? Math.max(0, Math.min(extraUsage.used / extraUsage.limit, 1)) : 0; + const used = Number(extraUsage.used); + const limit = Number(extraUsage.limit); + if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) { + return []; + } + const usedRatio = Math.max(0, Math.min(used / limit, 1)); const bar = renderProgressBar(usedRatio, 20); const pct = `${Math.round(usedRatio * 100)}% used`; const barColoured = currentTheme.fg(severityColor(ratioSeverity(usedRatio)), bar); @@ -181,8 +184,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const value = (text: string) => currentTheme.fg('text', text); const muted = (text: string) => currentTheme.fg('textDim', text); const errorStyle = (text: string) => currentTheme.fg('error', text); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const lines: string[] = [ accent('Session usage'), diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 794de9b5d4..8deebf71fd 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -71,6 +71,120 @@ describe('UsagePanelComponent', () => { expect(lines.join('\n')).toContain('resets in 23d'); }); + it('omits the extra usage section when extraUsage is omitted or null', () => { + for (const extraUsage of [undefined, null]) { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: extraUsage as never, + }, + }).map(strip); + + expect(lines).not.toContain('Extra Usage'); + } + }); + + it('omits the extra usage section when extraUsage.limit is not positive', () => { + for (const limit of [0, -10]) { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + label: 'Extra Usage', + used: 0, + limit, + }, + }, + }).map(strip); + + expect(lines).not.toContain('Extra Usage'); + } + }); + + it('coerces string used and limit values for the extra usage section', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + label: 'Extra Usage', + used: '500' as never, + limit: '1000' as never, + resetHint: 'resets in 23d', + }, + }, + }).map(strip); + + expect(lines).toContain('Extra Usage'); + expect(lines.join('\n')).toContain('50% used'); + expect(lines.join('\n')).toContain('resets in 23d'); + }); + + it('clamps the extra usage ratio to the [0, 1] range', () => { + for (const [used, expectedPct] of [ + [-100, '0% used'], + [2000, '100% used'], + ] as const) { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + label: 'Extra Usage', + used, + limit: 1000, + }, + }, + }).map(strip); + + expect(lines).toContain('Extra Usage'); + expect(lines.join('\n')).toContain(expectedPct); + } + }); + + it('omits the extra usage section when used or limit cannot be coerced to a finite number', () => { + const cases = [ + { used: Number.NaN, limit: 1000 }, + { used: 100, limit: Number.NaN }, + { used: 'not-a-number' as never, limit: 1000 }, + { used: 100, limit: 'not-a-number' as never }, + ]; + + for (const extraUsage of cases) { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { label: 'Extra Usage', ...extraUsage }, + }, + }).map(strip); + + expect(lines).not.toContain('Extra Usage'); + } + }); + it('wraps preformatted usage lines in a bordered panel', () => { const component = new UsagePanelComponent(() => ['Session usage'], 'primary'); const output = component.render(80).map(strip); From 9a4a20a32457cfe67c6ab60d115cd94f79e1e9bf Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 18:41:31 +0800 Subject: [PATCH 05/21] feat(kimi-code): render Extra Usage section in /status panel --- .../tui/components/messages/status-panel.ts | 13 +++++++- .../components/messages/status-panel.test.ts | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index f53f1a2d0e..de67ef85ce 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -22,7 +22,7 @@ import { safeUsageRatio, } from '#/utils/usage/usage-format'; -import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel'; +import { buildExtraUsageSection, buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel'; interface FieldRow { readonly label: string; @@ -145,5 +145,16 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { lines.push(...managedSection); } + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 7d27be8e2c..7b3fef3a7c 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -68,6 +68,38 @@ describe('status panel report lines', () => { expect(output).not.toContain('Runtime'); }); + it('formats extra usage section in status report', () => { + const lines = buildStatusReportLines({ + version: '1.2.3', + model: 'k2', + workDir: '/tmp/project', + sessionId: 'ses-1', + sessionTitle: null, + thinkingEffort: 'off', + permissionMode: 'manual', + planMode: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + availableModels: {}, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + label: 'Extra Usage', + used: 250, + limit: 1000, + resetHint: 'resets in 10d', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Extra Usage'); + expect(output).toContain('25% used'); + expect(output).toContain('resets in 10d'); + }); + it('falls back to app state and shows status load errors as warnings', () => { const lines = buildStatusReportLines({ version: '1.2.3', From de3bf434967c921ce2c4ad35570bfa133fbe1a4c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 18:45:09 +0800 Subject: [PATCH 06/21] feat(kimi-code): wire extraUsage into /usage and /status commands --- apps/kimi-code/src/tui/commands/info.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 51ccd3fc17..fd5d397f4b 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -213,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise Date: Wed, 8 Jul 2026 18:56:03 +0800 Subject: [PATCH 07/21] chore(extra-usage): address final review findings for fuel pack feature - Update changeset to cover both kimi-code and kimi-code-sdk packages - Add parser clamp tests and toolkit null-case test - Replace 'as never' casts in usage-panel tests - Wrap long import line in status-panel --- .changeset/expose-extrausage-toolkit.md | 3 +- .../tui/components/messages/status-panel.ts | 6 +++- .../components/messages/usage-panel.test.ts | 12 ++++---- packages/oauth/test/managed-usage.test.ts | 14 +++++++++ packages/oauth/test/toolkit.test.ts | 30 ++++++++++++++++++- 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/.changeset/expose-extrausage-toolkit.md b/.changeset/expose-extrausage-toolkit.md index 24952d13b4..d31c53ee5d 100644 --- a/.changeset/expose-extrausage-toolkit.md +++ b/.changeset/expose-extrausage-toolkit.md @@ -1,5 +1,6 @@ --- "@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch --- -Propagate extra usage data through the OAuth managed usage result. +Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands. diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index de67ef85ce..1d788d53b5 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -22,7 +22,11 @@ import { safeUsageRatio, } from '#/utils/usage/usage-format'; -import { buildExtraUsageSection, buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel'; +import { + buildExtraUsageSection, + buildManagedUsageReportLines, + type ManagedUsageReport, +} from './usage-panel'; interface FieldRow { readonly label: string; diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 8deebf71fd..36256bd3da 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -24,7 +24,7 @@ describe('UsagePanelComponent', () => { output: 250, }, }, - } as never, + }, contextUsage: 0.25, contextTokens: 2500, maxContextTokens: 10000, @@ -81,7 +81,7 @@ describe('UsagePanelComponent', () => { managedUsage: { summary: null, limits: [], - extraUsage: extraUsage as never, + extraUsage: extraUsage, }, }).map(strip); @@ -122,8 +122,8 @@ describe('UsagePanelComponent', () => { limits: [], extraUsage: { label: 'Extra Usage', - used: '500' as never, - limit: '1000' as never, + used: '500' as unknown as number, + limit: '1000' as unknown as number, resetHint: 'resets in 23d', }, }, @@ -164,8 +164,8 @@ describe('UsagePanelComponent', () => { const cases = [ { used: Number.NaN, limit: 1000 }, { used: 100, limit: Number.NaN }, - { used: 'not-a-number' as never, limit: 1000 }, - { used: 100, limit: 'not-a-number' as never }, + { used: 'not-a-number' as unknown as number, limit: 1000 }, + { used: 100, limit: 'not-a-number' as unknown as number }, ]; for (const extraUsage of cases) { diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 83e1832e0b..04afc02b59 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -97,6 +97,20 @@ describe('parseManagedUsagePayload', () => { }); }); + it('clamps extra usage used to [0, amount]', () => { + const overused = parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'BALANCE_BOOSTER', amount: '100', amountLeft: '200' } }, + }); + expect(overused.extraUsage).toEqual({ label: 'Extra Usage', used: 0, limit: 100 }); + + const negativeLeft = parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'BALANCE_BOOSTER', amount: '100', amountLeft: '-50' } }, + }); + expect(negativeLeft.extraUsage).toEqual({ label: 'Extra Usage', used: 100, limit: 100 }); + }); + it('returns null extra usage when boosterWallet is missing or invalid', () => { expect(parseManagedUsagePayload({ usage: { used: 1, limit: 10 } }).extraUsage).toBeNull(); expect( diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 8e8fab7280..1aa6e46859 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -594,7 +594,7 @@ describe('KimiOAuthToolkit', () => { now: () => 100, }); - await expect(toolkit.getManagedUsage()).resolves.toEqual({ + await expect(toolkit.getManagedUsage()).resolves.toMatchObject({ kind: 'ok', summary: { label: 'Weekly limit', used: 10, limit: 100 }, limits: [], @@ -602,6 +602,34 @@ describe('KimiOAuthToolkit', () => { }); }); + it('returns null extraUsage when the payload has no boosterWallet', async () => { + const storage = new MemoryTokenStorage(); + storage.tokens.set('kimi-code', token('access-1')); + const fetchImpl = vi.fn(async (_input: unknown, _init?: RequestInit) => + new Response( + JSON.stringify({ + usage: { used: 10, limit: 100, name: 'Weekly limit' }, + limits: [], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof fetch; + vi.stubGlobal('fetch', fetchImpl); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + await expect(toolkit.getManagedUsage()).resolves.toMatchObject({ + kind: 'ok', + summary: { label: 'Weekly limit', used: 10, limit: 100 }, + limits: [], + extraUsage: null, + }); + }); + it('removes managed config on logout when an adapter supports cleanup', async () => { const storage = new MemoryTokenStorage(); storage.tokens.set('kimi-code', token('access-1')); From f7c29fd45d98395cd00b14daac799af6ce8b4a27 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 20:43:31 +0800 Subject: [PATCH 08/21] chore: temporarily log /usages raw response for debugging --- packages/oauth/src/managed-usage.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 84c778198d..745faa1965 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -239,6 +239,7 @@ export async function fetchManagedUsage( return { kind: 'error', status, message: await readApiErrorMessage(res, hint) }; } const json: unknown = await res.json(); + console.error('[managed-usage] /usages response:', JSON.stringify(json, null, 2)); return { kind: 'ok', parsed: parseManagedUsagePayload(json) }; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { From 99103f17a3abf2746886e33e5b80be9f5a2d6472 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 12:41:11 +0800 Subject: [PATCH 09/21] fix(oauth): accept BOOSTER balance type and drop debug log --- packages/oauth/src/managed-usage.ts | 3 +-- packages/oauth/test/managed-usage.test.ts | 8 ++++---- packages/oauth/test/toolkit.test.ts | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 745faa1965..317f57ae2f 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -55,7 +55,7 @@ function parseBoosterWallet(raw: unknown): UsageRow | null { if (!isRecord(raw)) return null; const balance = raw['balance']; if (!isRecord(balance)) return null; - if (balance['type'] !== 'BALANCE_BOOSTER') return null; + if (balance['type'] !== 'BOOSTER') return null; const amount = toInt(balance['amount']); if (amount === null || amount <= 0) return null; const amountLeft = toInt(balance['amountLeft']) ?? 0; @@ -239,7 +239,6 @@ export async function fetchManagedUsage( return { kind: 'error', status, message: await readApiErrorMessage(res, hint) }; } const json: unknown = await res.json(); - console.error('[managed-usage] /usages response:', JSON.stringify(json, null, 2)); return { kind: 'ok', parsed: parseManagedUsagePayload(json) }; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 04afc02b59..92f48f7edb 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -81,7 +81,7 @@ describe('parseManagedUsagePayload', () => { boosterWallet: { id: 'wallet_1', balance: { - type: 'BALANCE_BOOSTER', + type: 'BOOSTER', amount: '1000', amountLeft: '500', unit: 'UNIT_CREDIT', @@ -100,13 +100,13 @@ describe('parseManagedUsagePayload', () => { it('clamps extra usage used to [0, amount]', () => { const overused = parseManagedUsagePayload({ usage: { used: 1, limit: 10 }, - boosterWallet: { balance: { type: 'BALANCE_BOOSTER', amount: '100', amountLeft: '200' } }, + boosterWallet: { balance: { type: 'BOOSTER', amount: '100', amountLeft: '200' } }, }); expect(overused.extraUsage).toEqual({ label: 'Extra Usage', used: 0, limit: 100 }); const negativeLeft = parseManagedUsagePayload({ usage: { used: 1, limit: 10 }, - boosterWallet: { balance: { type: 'BALANCE_BOOSTER', amount: '100', amountLeft: '-50' } }, + boosterWallet: { balance: { type: 'BOOSTER', amount: '100', amountLeft: '-50' } }, }); expect(negativeLeft.extraUsage).toEqual({ label: 'Extra Usage', used: 100, limit: 100 }); }); @@ -122,7 +122,7 @@ describe('parseManagedUsagePayload', () => { expect( parseManagedUsagePayload({ usage: { used: 1, limit: 10 }, - boosterWallet: { balance: { type: 'BALANCE_BOOSTER', amount: '0', amountLeft: '0' } }, + boosterWallet: { balance: { type: 'BOOSTER', amount: '0', amountLeft: '0' } }, }).extraUsage, ).toBeNull(); }); diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 1aa6e46859..7fa83b5b7d 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -577,7 +577,7 @@ describe('KimiOAuthToolkit', () => { limits: [], boosterWallet: { balance: { - type: 'BALANCE_BOOSTER', + type: 'BOOSTER', amount: '50', amountLeft: '30', }, From facd2b93f1c7f9ad538a49306636547b0be26255 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 12:56:19 +0800 Subject: [PATCH 10/21] fix(oauth): drop reset hint from Extra Usage and revert periodEnd parsing --- .../test/tui/components/messages/status-panel.test.ts | 2 -- .../test/tui/components/messages/usage-panel.test.ts | 4 ---- packages/oauth/src/managed-usage.ts | 5 ++--- packages/oauth/test/managed-usage.test.ts | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 7b3fef3a7c..18b44a1b4d 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -89,7 +89,6 @@ describe('status panel report lines', () => { label: 'Extra Usage', used: 250, limit: 1000, - resetHint: 'resets in 10d', }, }, }).map(strip); @@ -97,7 +96,6 @@ describe('status panel report lines', () => { const output = lines.join('\n'); expect(output).toContain('Extra Usage'); expect(output).toContain('25% used'); - expect(output).toContain('resets in 10d'); }); it('falls back to app state and shows status load errors as warnings', () => { diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 36256bd3da..ec5926b3b4 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -61,14 +61,12 @@ describe('UsagePanelComponent', () => { label: 'Extra Usage', used: 500, limit: 1000, - resetHint: 'resets in 23d', }, }, }).map(strip); expect(lines).toContain('Extra Usage'); expect(lines.join('\n')).toContain('50% used'); - expect(lines.join('\n')).toContain('resets in 23d'); }); it('omits the extra usage section when extraUsage is omitted or null', () => { @@ -124,14 +122,12 @@ describe('UsagePanelComponent', () => { label: 'Extra Usage', used: '500' as unknown as number, limit: '1000' as unknown as number, - resetHint: 'resets in 23d', }, }, }).map(strip); expect(lines).toContain('Extra Usage'); expect(lines.join('\n')).toContain('50% used'); - expect(lines.join('\n')).toContain('resets in 23d'); }); it('clamps the extra usage ratio to the [0, 1] range', () => { diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 317f57ae2f..0fa0b1ef6f 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -60,8 +60,7 @@ function parseBoosterWallet(raw: unknown): UsageRow | null { if (amount === null || amount <= 0) return null; const amountLeft = toInt(balance['amountLeft']) ?? 0; const used = Math.max(0, Math.min(amount - amountLeft, amount)); - const resetHint = resetHintFrom(balance); - return { label: 'Extra Usage', used, limit: amount, resetHint }; + return { label: 'Extra Usage', used, limit: amount }; } export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { @@ -141,7 +140,7 @@ function limitLabel( } function resetHintFrom(raw: Record): string | undefined { - for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime', 'period_end', 'periodEnd']) { + for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) { const v = raw[key]; if (typeof v === 'string' && v.length > 0) { return formatResetTime(v); diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 92f48f7edb..7f27e22282 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -93,7 +93,6 @@ describe('parseManagedUsagePayload', () => { label: 'Extra Usage', used: 500, limit: 1000, - resetHint: expect.stringMatching(/resets in/), }); }); From ff8e409a5b8ab0e82fc8e3747864126bb1007728 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 14:34:10 +0800 Subject: [PATCH 11/21] fix(oauth): treat missing amountLeft as zero extra usage and drop debug log --- packages/oauth/src/managed-usage.ts | 2 +- packages/oauth/test/managed-usage.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 0fa0b1ef6f..3243562f01 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -58,7 +58,7 @@ function parseBoosterWallet(raw: unknown): UsageRow | null { if (balance['type'] !== 'BOOSTER') return null; const amount = toInt(balance['amount']); if (amount === null || amount <= 0) return null; - const amountLeft = toInt(balance['amountLeft']) ?? 0; + const amountLeft = toInt(balance['amountLeft']) ?? amount; const used = Math.max(0, Math.min(amount - amountLeft, amount)); return { label: 'Extra Usage', used, limit: amount }; } diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 7f27e22282..f2e5b3d03c 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -110,6 +110,14 @@ describe('parseManagedUsagePayload', () => { expect(negativeLeft.extraUsage).toEqual({ label: 'Extra Usage', used: 100, limit: 100 }); }); + it('treats missing amountLeft as zero usage', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'BOOSTER', amount: '100' } }, + }); + expect(parsed.extraUsage).toEqual({ label: 'Extra Usage', used: 0, limit: 100 }); + }); + it('returns null extra usage when boosterWallet is missing or invalid', () => { expect(parseManagedUsagePayload({ usage: { used: 1, limit: 10 } }).extraUsage).toBeNull(); expect( From 41642b6e74940ffb6ef9e1866c0f1c1c10619c6d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 14:36:38 +0800 Subject: [PATCH 12/21] revert: keep missing amountLeft defaulting to 0 (fully used) --- packages/oauth/src/managed-usage.ts | 2 +- packages/oauth/test/managed-usage.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 3243562f01..0fa0b1ef6f 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -58,7 +58,7 @@ function parseBoosterWallet(raw: unknown): UsageRow | null { if (balance['type'] !== 'BOOSTER') return null; const amount = toInt(balance['amount']); if (amount === null || amount <= 0) return null; - const amountLeft = toInt(balance['amountLeft']) ?? amount; + const amountLeft = toInt(balance['amountLeft']) ?? 0; const used = Math.max(0, Math.min(amount - amountLeft, amount)); return { label: 'Extra Usage', used, limit: amount }; } diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index f2e5b3d03c..d861f55674 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -110,12 +110,12 @@ describe('parseManagedUsagePayload', () => { expect(negativeLeft.extraUsage).toEqual({ label: 'Extra Usage', used: 100, limit: 100 }); }); - it('treats missing amountLeft as zero usage', () => { + it('treats missing amountLeft as fully used', () => { const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10 }, boosterWallet: { balance: { type: 'BOOSTER', amount: '100' } }, }); - expect(parsed.extraUsage).toEqual({ label: 'Extra Usage', used: 0, limit: 100 }); + expect(parsed.extraUsage).toEqual({ label: 'Extra Usage', used: 100, limit: 100 }); }); it('returns null extra usage when boosterWallet is missing or invalid', () => { From 5df58b33c8470cec5929cd2863a6273ef89f3893 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 15:25:37 +0800 Subject: [PATCH 13/21] feat(extra-usage): show monthly cap usage bar and balance in /usage and /status --- .../tui/components/messages/usage-panel.ts | 67 +++++++-- .../components/messages/status-panel.test.ts | 12 +- .../components/messages/usage-panel.test.ts | 132 ++++++------------ packages/oauth/src/managed-usage.ts | 65 ++++++++- packages/oauth/test/managed-usage.test.ts | 48 ++++--- packages/oauth/test/toolkit.test.ts | 16 ++- 6 files changed, 204 insertions(+), 136 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 2cf6bdeea0..0e214744ae 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -30,10 +30,19 @@ export interface ManagedUsageRow { readonly resetHint?: string; } +export interface BoosterWalletInfo { + readonly balanceCents: number; + readonly totalCents: number; + readonly monthlyChargeLimitEnabled: boolean; + readonly monthlyChargeLimitCents: number; + readonly monthlyUsedCents: number; + readonly currency: string; +} + export interface ManagedUsageReport { readonly summary: ManagedUsageRow | null; readonly limits: readonly ManagedUsageRow[]; - readonly extraUsage?: ManagedUsageRow | null; + readonly extraUsage?: BoosterWalletInfo | null; } export interface UsageReportOptions { @@ -140,27 +149,55 @@ function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | ' return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; } +const DOTTED_BAR = '·'.repeat(20); + +function currencySymbol(currency: string): string { + switch (currency.toUpperCase()) { + case 'CNY': + return '¥'; + case 'USD': + return '$'; + default: + return ''; + } +} + +function formatCurrency(cents: number, currency: string): string { + const symbol = currencySymbol(currency); + const main = cents / 100; + const formatted = cents % 100 === 0 ? String(main) : main.toFixed(2); + return symbol.length > 0 ? `${symbol}${formatted}` : `${formatted} ${currency}`; +} + export function buildExtraUsageSection( - extraUsage: ManagedUsageRow | undefined | null, + extraUsage: BoosterWalletInfo | undefined | null, accent: Colorize, value: Colorize, muted: Colorize, ): string[] { if (extraUsage === undefined || extraUsage === null) return []; - const used = Number(extraUsage.used); - const limit = Number(extraUsage.limit); - if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) { - return []; + + const hasMonthlyLimit = + extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0; + + if (hasMonthlyLimit) { + const ratio = Math.max( + 0, + Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1), + ); + const bar = renderProgressBar(ratio, 20); + const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); + const used = formatCurrency(extraUsage.monthlyUsedCents, extraUsage.currency); + const limit = formatCurrency(extraUsage.monthlyChargeLimitCents, extraUsage.currency); + const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); + return [ + accent('Extra Usage'), + ` ${barColoured} ${value(`${used} / ${limit}`)} ${muted(`· Balance ${balance}`)}`, + ]; } - const usedRatio = Math.max(0, Math.min(used / limit, 1)); - const bar = renderProgressBar(usedRatio, 20); - const pct = `${Math.round(usedRatio * 100)}% used`; - const barColoured = currentTheme.fg(severityColor(ratioSeverity(usedRatio)), bar); - const resetStr = extraUsage.resetHint ? ` ${muted(extraUsage.resetHint)}` : ''; - return [ - accent('Extra Usage'), - ` ${barColoured} ${value(pct.padEnd(6, ' '))}${resetStr}`, - ]; + + const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); + return [accent('Extra Usage'), ` ${muted(DOTTED_BAR)} ${value(`Balance ${balance}`)}`]; } export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 18b44a1b4d..6adb6d95ef 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -86,16 +86,20 @@ describe('status panel report lines', () => { summary: null, limits: [], extraUsage: { - label: 'Extra Usage', - used: 250, - limit: 1000, + balanceCents: 15000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', }, }, }).map(strip); const output = lines.join('\n'); expect(output).toContain('Extra Usage'); - expect(output).toContain('25% used'); + expect(output).toContain('$50 / $200'); + expect(output).toContain('Balance $150'); }); it('falls back to app state and shows status load errors as warnings', () => { diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index ec5926b3b4..498382b336 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -48,7 +48,7 @@ describe('UsagePanelComponent', () => { expect(lines.join('\n')).toContain('resets tomorrow'); }); - it('formats an extra usage section from booster wallet data', () => { + it('formats extra usage with a monthly limit', () => { const lines = buildUsageReportLines({ sessionUsage: { byModel: {} }, contextUsage: 0, @@ -58,58 +58,23 @@ describe('UsagePanelComponent', () => { summary: null, limits: [], extraUsage: { - label: 'Extra Usage', - used: 500, - limit: 1000, + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', }, }, }).map(strip); + const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); - expect(lines.join('\n')).toContain('50% used'); + expect(output).toContain('$50 / $200'); + expect(output).toContain('Balance $100'); }); - it('omits the extra usage section when extraUsage is omitted or null', () => { - for (const extraUsage of [undefined, null]) { - const lines = buildUsageReportLines({ - sessionUsage: { byModel: {} }, - contextUsage: 0, - contextTokens: 0, - maxContextTokens: 0, - managedUsage: { - summary: null, - limits: [], - extraUsage: extraUsage, - }, - }).map(strip); - - expect(lines).not.toContain('Extra Usage'); - } - }); - - it('omits the extra usage section when extraUsage.limit is not positive', () => { - for (const limit of [0, -10]) { - const lines = buildUsageReportLines({ - sessionUsage: { byModel: {} }, - contextUsage: 0, - contextTokens: 0, - maxContextTokens: 0, - managedUsage: { - summary: null, - limits: [], - extraUsage: { - label: 'Extra Usage', - used: 0, - limit, - }, - }, - }).map(strip); - - expect(lines).not.toContain('Extra Usage'); - } - }); - - it('coerces string used and limit values for the extra usage section', () => { + it('formats extra usage without a monthly limit using a dotted bar', () => { const lines = buildUsageReportLines({ sessionUsage: { byModel: {} }, contextUsage: 0, @@ -119,66 +84,59 @@ describe('UsagePanelComponent', () => { summary: null, limits: [], extraUsage: { - label: 'Extra Usage', - used: '500' as unknown as number, - limit: '1000' as unknown as number, + balanceCents: 20000, + totalCents: 20000, + monthlyChargeLimitEnabled: false, + monthlyChargeLimitCents: 0, + monthlyUsedCents: 0, + currency: 'USD', }, }, }).map(strip); + const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); - expect(lines.join('\n')).toContain('50% used'); + expect(output).toContain('Balance $200'); + expect(output).toContain('····················'); }); - it('clamps the extra usage ratio to the [0, 1] range', () => { - for (const [used, expectedPct] of [ - [-100, '0% used'], - [2000, '100% used'], - ] as const) { + it('omits the extra usage section when extraUsage is omitted or null', () => { + for (const extraUsage of [undefined, null]) { const lines = buildUsageReportLines({ sessionUsage: { byModel: {} }, contextUsage: 0, contextTokens: 0, maxContextTokens: 0, - managedUsage: { - summary: null, - limits: [], - extraUsage: { - label: 'Extra Usage', - used, - limit: 1000, - }, - }, + managedUsage: { summary: null, limits: [], extraUsage }, }).map(strip); - expect(lines).toContain('Extra Usage'); - expect(lines.join('\n')).toContain(expectedPct); + expect(lines).not.toContain('Extra Usage'); } }); - it('omits the extra usage section when used or limit cannot be coerced to a finite number', () => { - const cases = [ - { used: Number.NaN, limit: 1000 }, - { used: 100, limit: Number.NaN }, - { used: 'not-a-number' as unknown as number, limit: 1000 }, - { used: 100, limit: 'not-a-number' as unknown as number }, - ]; - - for (const extraUsage of cases) { - const lines = buildUsageReportLines({ - sessionUsage: { byModel: {} }, - contextUsage: 0, - contextTokens: 0, - maxContextTokens: 0, - managedUsage: { - summary: null, - limits: [], - extraUsage: { label: 'Extra Usage', ...extraUsage }, + it('formats extra usage with CNY currency', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'CNY', }, - }).map(strip); + }, + }).map(strip); - expect(lines).not.toContain('Extra Usage'); - } + const output = lines.join('\n'); + expect(output).toContain('¥50 / ¥200'); + expect(output).toContain('Balance ¥100'); }); it('wraps preformatted usage lines in a bordered panel', () => { diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 0fa0b1ef6f..928d9008c2 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -45,22 +45,73 @@ export interface UsageRow { readonly resetHint?: string | undefined; } +export interface BoosterWalletInfo { + /** Remaining balance in whole cents (from balance.amountLeft). */ + readonly balanceCents: number; + /** Total balance in whole cents (from balance.amount). */ + readonly totalCents: number; + /** Whether the user enabled a monthly spending cap. */ + readonly monthlyChargeLimitEnabled: boolean; + /** Monthly spending cap in whole cents; 0 means unlimited. */ + readonly monthlyChargeLimitCents: number; + /** Monthly spend so far in whole cents. */ + readonly monthlyUsedCents: number; + /** ISO currency code, e.g. USD / CNY. */ + readonly currency: string; +} + export interface ParsedManagedUsage { readonly summary: UsageRow | null; readonly limits: UsageRow[]; - readonly extraUsage: UsageRow | null; + readonly extraUsage: BoosterWalletInfo | null; } -function parseBoosterWallet(raw: unknown): UsageRow | null { +const FIXED_POINT_CENTS = 1_000_000; + +function fixedPointToCents(value: number): number { + const cents = value / FIXED_POINT_CENTS; + if (cents > 0 && cents < 1) return 1; + return Math.round(cents); +} + +function parseMoney(raw: unknown): { cents: number; currency: string } | null { + if (!isRecord(raw)) return null; + const cents = toInt(raw['priceInCents']); + if (cents === null) return null; + const currency = typeof raw['currency'] === 'string' ? raw['currency'] : ''; + return { cents, currency }; +} + +function parseBoosterWallet(raw: unknown): BoosterWalletInfo | null { if (!isRecord(raw)) return null; const balance = raw['balance']; if (!isRecord(balance)) return null; if (balance['type'] !== 'BOOSTER') return null; - const amount = toInt(balance['amount']); - if (amount === null || amount <= 0) return null; - const amountLeft = toInt(balance['amountLeft']) ?? 0; - const used = Math.max(0, Math.min(amount - amountLeft, amount)); - return { label: 'Extra Usage', used, limit: amount }; + const amountRaw = toInt(balance['amount']); + if (amountRaw === null || amountRaw <= 0) return null; + const totalCents = fixedPointToCents(amountRaw); + const amountLeftRaw = toInt(balance['amountLeft']); + const balanceCents = amountLeftRaw !== null ? fixedPointToCents(amountLeftRaw) : 0; + + const monthlyLimit = parseMoney(raw['monthlyChargeLimit']); + const monthlyUsed = parseMoney(raw['monthlyUsed']); + const monthlyChargeLimitEnabled = raw['monthlyChargeLimitEnabled'] === true; + + const currency = + monthlyLimit && monthlyLimit.currency.length > 0 + ? monthlyLimit.currency + : monthlyUsed && monthlyUsed.currency.length > 0 + ? monthlyUsed.currency + : 'USD'; + + return { + balanceCents, + totalCents, + monthlyChargeLimitEnabled, + monthlyChargeLimitCents: monthlyLimit?.cents ?? 0, + monthlyUsedCents: monthlyUsed?.cents ?? 0, + currency, + }; } export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index d861f55674..f390653bc5 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -82,40 +82,48 @@ describe('parseManagedUsagePayload', () => { id: 'wallet_1', balance: { type: 'BOOSTER', - amount: '1000', - amountLeft: '500', - unit: 'UNIT_CREDIT', - periodEnd: '2026-08-01T00:00:00Z', + amount: '20000000000', + amountLeft: '10000000000', + unit: 'UNIT_CURRENCY', }, + monthlyChargeLimitEnabled: true, + monthlyChargeLimit: { currency: 'USD', priceInCents: '20000' }, + monthlyUsed: { currency: 'USD', priceInCents: '5000' }, }, }); expect(parsed.extraUsage).toEqual({ - label: 'Extra Usage', - used: 500, - limit: 1000, + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', }); }); - it('clamps extra usage used to [0, amount]', () => { - const overused = parseManagedUsagePayload({ - usage: { used: 1, limit: 10 }, - boosterWallet: { balance: { type: 'BOOSTER', amount: '100', amountLeft: '200' } }, - }); - expect(overused.extraUsage).toEqual({ label: 'Extra Usage', used: 0, limit: 100 }); - - const negativeLeft = parseManagedUsagePayload({ + it('treats missing amountLeft as zero balance', () => { + const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10 }, - boosterWallet: { balance: { type: 'BOOSTER', amount: '100', amountLeft: '-50' } }, + boosterWallet: { balance: { type: 'BOOSTER', amount: '20000000000' } }, }); - expect(negativeLeft.extraUsage).toEqual({ label: 'Extra Usage', used: 100, limit: 100 }); + expect(parsed.extraUsage).toMatchObject({ totalCents: 20000, balanceCents: 0 }); }); - it('treats missing amountLeft as fully used', () => { + it('defaults monthly limit fields when absent', () => { const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10 }, - boosterWallet: { balance: { type: 'BOOSTER', amount: '100' } }, + boosterWallet: { + balance: { type: 'BOOSTER', amount: '20000000000', amountLeft: '20000000000' }, + }, + }); + expect(parsed.extraUsage).toEqual({ + balanceCents: 20000, + totalCents: 20000, + monthlyChargeLimitEnabled: false, + monthlyChargeLimitCents: 0, + monthlyUsedCents: 0, + currency: 'USD', }); - expect(parsed.extraUsage).toEqual({ label: 'Extra Usage', used: 100, limit: 100 }); }); it('returns null extra usage when boosterWallet is missing or invalid', () => { diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 7fa83b5b7d..6ceaae4170 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -578,9 +578,12 @@ describe('KimiOAuthToolkit', () => { boosterWallet: { balance: { type: 'BOOSTER', - amount: '50', - amountLeft: '30', + amount: '20000000000', + amountLeft: '10000000000', }, + monthlyChargeLimitEnabled: true, + monthlyChargeLimit: { currency: 'USD', priceInCents: '20000' }, + monthlyUsed: { currency: 'USD', priceInCents: '5000' }, }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, @@ -598,7 +601,14 @@ describe('KimiOAuthToolkit', () => { kind: 'ok', summary: { label: 'Weekly limit', used: 10, limit: 100 }, limits: [], - extraUsage: { label: 'Extra Usage', used: 20, limit: 50 }, + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, }); }); From bfeb5851665a32e4f4fef35370c88cd497ff4de3 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 15:30:03 +0800 Subject: [PATCH 14/21] fix(extra-usage): show balance and unlimited marker when no monthly cap --- apps/kimi-code/src/tui/components/messages/usage-panel.ts | 2 +- apps/kimi-code/test/tui/components/messages/usage-panel.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 0e214744ae..68ef0c9169 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -197,7 +197,7 @@ export function buildExtraUsageSection( } const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); - return [accent('Extra Usage'), ` ${muted(DOTTED_BAR)} ${value(`Balance ${balance}`)}`]; + return [accent('Extra Usage'), ` ${muted(DOTTED_BAR)} ${value(`${balance} / 无限制`)}`]; } export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 498382b336..a00ed69242 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -96,7 +96,7 @@ describe('UsagePanelComponent', () => { const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); - expect(output).toContain('Balance $200'); + expect(output).toContain('$200 / 无限制'); expect(output).toContain('····················'); }); From 0b35f6d945281c1f1b355f764a0e5b967fce3be4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 15:34:31 +0800 Subject: [PATCH 15/21] fix(extra-usage): show monthly used with unlimited marker and balance --- .../src/tui/components/messages/usage-panel.ts | 6 +++++- .../test/tui/components/messages/usage-panel.test.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 68ef0c9169..6b43ce9907 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -196,8 +196,12 @@ export function buildExtraUsageSection( ]; } + const used = formatCurrency(extraUsage.monthlyUsedCents, extraUsage.currency); const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); - return [accent('Extra Usage'), ` ${muted(DOTTED_BAR)} ${value(`${balance} / 无限制`)}`]; + return [ + accent('Extra Usage'), + ` ${muted(DOTTED_BAR)} ${value(`${used} / 无限制`)} ${muted(`Balance ${balance}`)}`, + ]; } export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index a00ed69242..1fc70fe7f9 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -84,19 +84,20 @@ describe('UsagePanelComponent', () => { summary: null, limits: [], extraUsage: { - balanceCents: 20000, - totalCents: 20000, + balanceCents: 18208, + totalCents: 40000, monthlyChargeLimitEnabled: false, monthlyChargeLimitCents: 0, - monthlyUsedCents: 0, - currency: 'USD', + monthlyUsedCents: 21792, + currency: 'CNY', }, }, }).map(strip); const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); - expect(output).toContain('$200 / 无限制'); + expect(output).toContain('¥217.92 / 无限制'); + expect(output).toContain('Balance ¥182.08'); expect(output).toContain('····················'); }); From 9a83a2e726550972abdbc69f9af17cf91a46af66 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 15:37:38 +0800 Subject: [PATCH 16/21] fix(extra-usage): label Used and use English Unlimited --- apps/kimi-code/src/tui/components/messages/usage-panel.ts | 4 ++-- .../test/tui/components/messages/status-panel.test.ts | 1 + .../test/tui/components/messages/usage-panel.test.ts | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 6b43ce9907..de72581e78 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -192,7 +192,7 @@ export function buildExtraUsageSection( const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); return [ accent('Extra Usage'), - ` ${barColoured} ${value(`${used} / ${limit}`)} ${muted(`· Balance ${balance}`)}`, + ` ${barColoured} ${muted('Used')} ${value(`${used} / ${limit}`)} ${muted(`· Balance ${balance}`)}`, ]; } @@ -200,7 +200,7 @@ export function buildExtraUsageSection( const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); return [ accent('Extra Usage'), - ` ${muted(DOTTED_BAR)} ${value(`${used} / 无限制`)} ${muted(`Balance ${balance}`)}`, + ` ${muted(DOTTED_BAR)} ${muted('Used')} ${value(`${used} / Unlimited`)} ${muted(`Balance ${balance}`)}`, ]; } diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 6adb6d95ef..f4aa867762 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -98,6 +98,7 @@ describe('status panel report lines', () => { const output = lines.join('\n'); expect(output).toContain('Extra Usage'); + expect(output).toContain('Used'); expect(output).toContain('$50 / $200'); expect(output).toContain('Balance $150'); }); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 1fc70fe7f9..2016fd5f9e 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -70,6 +70,7 @@ describe('UsagePanelComponent', () => { const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); + expect(output).toContain('Used'); expect(output).toContain('$50 / $200'); expect(output).toContain('Balance $100'); }); @@ -96,7 +97,8 @@ describe('UsagePanelComponent', () => { const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); - expect(output).toContain('¥217.92 / 无限制'); + expect(output).toContain('Used'); + expect(output).toContain('¥217.92 / Unlimited'); expect(output).toContain('Balance ¥182.08'); expect(output).toContain('····················'); }); From 09e157631b90b226bb6f9eb78692366a031b5dc6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 15:49:38 +0800 Subject: [PATCH 17/21] feat(extra-usage): render balance, monthly used and monthly limit as labeled rows --- .../tui/components/messages/usage-panel.ts | 31 +++++++++-------- .../components/messages/status-panel.test.ts | 9 +++-- .../components/messages/usage-panel.test.ts | 33 +++++++++++++------ 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index de72581e78..3d81316ab9 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -149,8 +149,6 @@ function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | ' return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; } -const DOTTED_BAR = '·'.repeat(20); - function currencySymbol(currency: string): string { switch (currency.toUpperCase()) { case 'CNY': @@ -180,6 +178,14 @@ export function buildExtraUsageSection( const hasMonthlyLimit = extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0; + const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); + const used = formatCurrency(extraUsage.monthlyUsedCents, extraUsage.currency); + const labelWidth = 14; + const row = (label: string, val: string): string => + ` ${muted(label.padEnd(labelWidth, ' '))} ${value(val)}`; + + const lines: string[] = [accent('Extra Usage')]; + if (hasMonthlyLimit) { const ratio = Math.max( 0, @@ -187,21 +193,18 @@ export function buildExtraUsageSection( ); const bar = renderProgressBar(ratio, 20); const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); - const used = formatCurrency(extraUsage.monthlyUsedCents, extraUsage.currency); const limit = formatCurrency(extraUsage.monthlyChargeLimitCents, extraUsage.currency); - const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); - return [ - accent('Extra Usage'), - ` ${barColoured} ${muted('Used')} ${value(`${used} / ${limit}`)} ${muted(`· Balance ${balance}`)}`, - ]; + lines.push(` ${barColoured}`); + lines.push(row('Balance', balance)); + lines.push(row('Used this month', used)); + lines.push(row('Monthly limit', limit)); + } else { + lines.push(row('Balance', balance)); + lines.push(row('Used this month', used)); + lines.push(row('Monthly limit', 'Unlimited')); } - const used = formatCurrency(extraUsage.monthlyUsedCents, extraUsage.currency); - const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); - return [ - accent('Extra Usage'), - ` ${muted(DOTTED_BAR)} ${muted('Used')} ${value(`${used} / Unlimited`)} ${muted(`Balance ${balance}`)}`, - ]; + return lines; } export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index f4aa867762..7bc0fb618a 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -98,9 +98,12 @@ describe('status panel report lines', () => { const output = lines.join('\n'); expect(output).toContain('Extra Usage'); - expect(output).toContain('Used'); - expect(output).toContain('$50 / $200'); - expect(output).toContain('Balance $150'); + expect(output).toContain('Balance'); + expect(output).toContain('$150'); + expect(output).toContain('Used this month'); + expect(output).toContain('$50'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('$200'); }); it('falls back to app state and shows status load errors as warnings', () => { diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 2016fd5f9e..214feb1e58 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -70,12 +70,17 @@ describe('UsagePanelComponent', () => { const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); - expect(output).toContain('Used'); - expect(output).toContain('$50 / $200'); - expect(output).toContain('Balance $100'); + expect(output).toContain('Balance'); + expect(output).toContain('$100'); + expect(output).toContain('Used this month'); + expect(output).toContain('$50'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('$200'); + // bar row contains block glyphs but no percentage text + expect(output).toContain('░'); }); - it('formats extra usage without a monthly limit using a dotted bar', () => { + it('formats extra usage without a monthly limit and omits the progress bar', () => { const lines = buildUsageReportLines({ sessionUsage: { byModel: {} }, contextUsage: 0, @@ -97,10 +102,14 @@ describe('UsagePanelComponent', () => { const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); - expect(output).toContain('Used'); - expect(output).toContain('¥217.92 / Unlimited'); - expect(output).toContain('Balance ¥182.08'); - expect(output).toContain('····················'); + expect(output).toContain('Balance'); + expect(output).toContain('¥182.08'); + expect(output).toContain('Used this month'); + expect(output).toContain('¥217.92'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('Unlimited'); + expect(output).not.toContain('░'); + expect(output).not.toContain('█'); }); it('omits the extra usage section when extraUsage is omitted or null', () => { @@ -138,8 +147,12 @@ describe('UsagePanelComponent', () => { }).map(strip); const output = lines.join('\n'); - expect(output).toContain('¥50 / ¥200'); - expect(output).toContain('Balance ¥100'); + expect(output).toContain('Balance'); + expect(output).toContain('¥100'); + expect(output).toContain('Used this month'); + expect(output).toContain('¥50'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('¥200'); }); it('wraps preformatted usage lines in a bordered panel', () => { From 0a4adb5e786bbef3a18ff838ca2776b6272da404 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 16:06:41 +0800 Subject: [PATCH 18/21] fix(extra-usage): move Balance row to the bottom --- apps/kimi-code/src/tui/components/messages/usage-panel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 3d81316ab9..6dc8f1c614 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -195,13 +195,13 @@ export function buildExtraUsageSection( const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); const limit = formatCurrency(extraUsage.monthlyChargeLimitCents, extraUsage.currency); lines.push(` ${barColoured}`); - lines.push(row('Balance', balance)); lines.push(row('Used this month', used)); lines.push(row('Monthly limit', limit)); - } else { lines.push(row('Balance', balance)); + } else { lines.push(row('Used this month', used)); lines.push(row('Monthly limit', 'Unlimited')); + lines.push(row('Balance', balance)); } return lines; From 7927e50a3fcd650f34884b41fe52c46239c798d6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 16:40:08 +0800 Subject: [PATCH 19/21] fix(extra-usage): format currency values with two decimals for column alignment --- .../src/tui/components/messages/usage-panel.ts | 2 +- .../tui/components/messages/status-panel.test.ts | 6 +++--- .../test/tui/components/messages/usage-panel.test.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 6dc8f1c614..2576f1805b 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -163,7 +163,7 @@ function currencySymbol(currency: string): string { function formatCurrency(cents: number, currency: string): string { const symbol = currencySymbol(currency); const main = cents / 100; - const formatted = cents % 100 === 0 ? String(main) : main.toFixed(2); + const formatted = main.toFixed(2); return symbol.length > 0 ? `${symbol}${formatted}` : `${formatted} ${currency}`; } diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 7bc0fb618a..407c6b88c9 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -99,11 +99,11 @@ describe('status panel report lines', () => { const output = lines.join('\n'); expect(output).toContain('Extra Usage'); expect(output).toContain('Balance'); - expect(output).toContain('$150'); + expect(output).toContain('$150.00'); expect(output).toContain('Used this month'); - expect(output).toContain('$50'); + expect(output).toContain('$50.00'); expect(output).toContain('Monthly limit'); - expect(output).toContain('$200'); + expect(output).toContain('$200.00'); }); it('falls back to app state and shows status load errors as warnings', () => { diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 214feb1e58..c8a6aeced0 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -71,11 +71,11 @@ describe('UsagePanelComponent', () => { const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); expect(output).toContain('Balance'); - expect(output).toContain('$100'); + expect(output).toContain('$100.00'); expect(output).toContain('Used this month'); - expect(output).toContain('$50'); + expect(output).toContain('$50.00'); expect(output).toContain('Monthly limit'); - expect(output).toContain('$200'); + expect(output).toContain('$200.00'); // bar row contains block glyphs but no percentage text expect(output).toContain('░'); }); @@ -148,11 +148,11 @@ describe('UsagePanelComponent', () => { const output = lines.join('\n'); expect(output).toContain('Balance'); - expect(output).toContain('¥100'); + expect(output).toContain('¥100.00'); expect(output).toContain('Used this month'); - expect(output).toContain('¥50'); + expect(output).toContain('¥50.00'); expect(output).toContain('Monthly limit'); - expect(output).toContain('¥200'); + expect(output).toContain('¥200.00'); }); it('wraps preformatted usage lines in a bordered panel', () => { From 512383bcccf1e5524463b7269c010f281a72eb61 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 16:45:30 +0800 Subject: [PATCH 20/21] fix(extra-usage): right-align currency values so numbers line up --- .../tui/components/messages/usage-panel.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 2576f1805b..867397d20f 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -181,10 +181,9 @@ export function buildExtraUsageSection( const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); const used = formatCurrency(extraUsage.monthlyUsedCents, extraUsage.currency); const labelWidth = 14; - const row = (label: string, val: string): string => - ` ${muted(label.padEnd(labelWidth, ' '))} ${value(val)}`; - const lines: string[] = [accent('Extra Usage')]; + const rows: Array<{ label: string; val: string }> = []; + let barLine: string | null = null; if (hasMonthlyLimit) { const ratio = Math.max( @@ -192,18 +191,27 @@ export function buildExtraUsageSection( Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1), ); const bar = renderProgressBar(ratio, 20); - const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); + barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`; const limit = formatCurrency(extraUsage.monthlyChargeLimitCents, extraUsage.currency); - lines.push(` ${barColoured}`); - lines.push(row('Used this month', used)); - lines.push(row('Monthly limit', limit)); - lines.push(row('Balance', balance)); + rows.push({ label: 'Used this month', val: used }); + rows.push({ label: 'Monthly limit', val: limit }); + rows.push({ label: 'Balance', val: balance }); } else { - lines.push(row('Used this month', used)); - lines.push(row('Monthly limit', 'Unlimited')); - lines.push(row('Balance', balance)); + rows.push({ label: 'Used this month', val: used }); + rows.push({ label: 'Monthly limit', val: 'Unlimited' }); + rows.push({ label: 'Balance', val: balance }); } + const valueWidth = Math.max(...rows.map((r) => visibleWidth(r.val))); + const row = (label: string, val: string): string => { + const pad = Math.max(0, valueWidth - visibleWidth(val)); + return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(' '.repeat(pad) + val)}`; + }; + + const lines: string[] = [accent('Extra Usage')]; + if (barLine !== null) lines.push(barLine); + for (const r of rows) lines.push(row(r.label, r.val)); + return lines; } From ce260d66f4c5dc8ee56e52244dca02626bed98b8 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 17:03:06 +0800 Subject: [PATCH 21/21] fix(extra-usage): align currency symbol and decimal point in usage rows --- .../tui/components/messages/usage-panel.ts | 53 ++++++++++++------- .../components/messages/status-panel.test.ts | 6 +-- .../components/messages/usage-panel.test.ts | 41 +++++++++++--- 3 files changed, 72 insertions(+), 28 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 867397d20f..195860bc39 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -160,11 +160,18 @@ function currencySymbol(currency: string): string { } } -function formatCurrency(cents: number, currency: string): string { +interface CurrencyParts { + readonly symbol: string; + readonly number: string; +} + +function formatCurrencyParts(cents: number, currency: string): CurrencyParts { const symbol = currencySymbol(currency); const main = cents / 100; const formatted = main.toFixed(2); - return symbol.length > 0 ? `${symbol}${formatted}` : `${formatted} ${currency}`; + return symbol.length > 0 + ? { symbol, number: formatted } + : { symbol: '', number: `${formatted} ${currency}` }; } export function buildExtraUsageSection( @@ -178,11 +185,9 @@ export function buildExtraUsageSection( const hasMonthlyLimit = extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0; - const balance = formatCurrency(extraUsage.balanceCents, extraUsage.currency); - const used = formatCurrency(extraUsage.monthlyUsedCents, extraUsage.currency); - const labelWidth = 14; - - const rows: Array<{ label: string; val: string }> = []; + const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency); + const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency); + const rows: Array<{ label: string; symbol: string; number: string }> = []; let barLine: string | null = null; if (hasMonthlyLimit) { @@ -192,25 +197,35 @@ export function buildExtraUsageSection( ); const bar = renderProgressBar(ratio, 20); barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`; - const limit = formatCurrency(extraUsage.monthlyChargeLimitCents, extraUsage.currency); - rows.push({ label: 'Used this month', val: used }); - rows.push({ label: 'Monthly limit', val: limit }); - rows.push({ label: 'Balance', val: balance }); + const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency); + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', ...limit }); + rows.push({ label: 'Balance', ...balance }); } else { - rows.push({ label: 'Used this month', val: used }); - rows.push({ label: 'Monthly limit', val: 'Unlimited' }); - rows.push({ label: 'Balance', val: balance }); + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' }); + rows.push({ label: 'Balance', ...balance }); } - const valueWidth = Math.max(...rows.map((r) => visibleWidth(r.val))); - const row = (label: string, val: string): string => { - const pad = Math.max(0, valueWidth - visibleWidth(val)); - return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(' '.repeat(pad) + val)}`; + // `Used this month` is the longest label; size the column to the widest label + // so the currency symbol starts in the same column on every row. + const labelWidth = Math.max(...rows.map((r) => r.label.length)); + // Right-align the numeric part of currency rows against each other so the + // decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as + // `Unlimited` carry no currency symbol, so they must not widen the numeric + // column — otherwise money values get padded with stray spaces. + const numberWidth = Math.max( + 0, + ...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)), + ); + const row = (label: string, symbol: string, number: string): string => { + const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number; + return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`; }; const lines: string[] = [accent('Extra Usage')]; if (barLine !== null) lines.push(barLine); - for (const r of rows) lines.push(row(r.label, r.val)); + for (const r of rows) lines.push(row(r.label, r.symbol, r.number)); return lines; } diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 407c6b88c9..041860896a 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -99,11 +99,11 @@ describe('status panel report lines', () => { const output = lines.join('\n'); expect(output).toContain('Extra Usage'); expect(output).toContain('Balance'); - expect(output).toContain('$150.00'); + expect(output).toContain('150.00'); expect(output).toContain('Used this month'); - expect(output).toContain('$50.00'); + expect(output).toContain('50.00'); expect(output).toContain('Monthly limit'); - expect(output).toContain('$200.00'); + expect(output).toContain('200.00'); }); it('falls back to app state and shows status load errors as warnings', () => { diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index c8a6aeced0..cf2598f829 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -71,11 +71,11 @@ describe('UsagePanelComponent', () => { const output = lines.join('\n'); expect(lines).toContain('Extra Usage'); expect(output).toContain('Balance'); - expect(output).toContain('$100.00'); + expect(output).toContain('100.00'); expect(output).toContain('Used this month'); - expect(output).toContain('$50.00'); + expect(output).toContain('50.00'); expect(output).toContain('Monthly limit'); - expect(output).toContain('$200.00'); + expect(output).toContain('200.00'); // bar row contains block glyphs but no percentage text expect(output).toContain('░'); }); @@ -148,11 +148,40 @@ describe('UsagePanelComponent', () => { const output = lines.join('\n'); expect(output).toContain('Balance'); - expect(output).toContain('¥100.00'); + expect(output).toContain('100.00'); expect(output).toContain('Used this month'); - expect(output).toContain('¥50.00'); + expect(output).toContain('50.00'); expect(output).toContain('Monthly limit'); - expect(output).toContain('¥200.00'); + expect(output).toContain('200.00'); + }); + + it('aligns the currency symbol and decimal point across extra usage rows', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15901, + totalCents: 300000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 300000, + monthlyUsedCents: 24099, + currency: 'CNY', + }, + }, + }).map(strip); + + const extraRows = lines.filter((line) => line.includes('¥')); + expect(extraRows).toHaveLength(3); + // The currency symbol stays in one column... + expect(new Set(extraRows.map((line) => line.indexOf('¥'))).size).toBe(1); + // ...and the right-aligned numeric parts end in the same column, so the + // decimal points line up across rows. + expect(new Set(extraRows.map((line) => line.length)).size).toBe(1); }); it('wraps preformatted usage lines in a bordered panel', () => {