Skip to content

Commit b91099e

Browse files
authored
feat: display Extra Usage fuel pack balance in /usage and /status (#1501)
* feat(oauth): parse boosterWallet extra usage from /usages * feat(oauth): expose extraUsage on AuthManagedUsageResult * feat(kimi-code): render Extra Usage section in /usage panel * fix(kimi-code): address Task 3 review feedback for extra usage section * feat(kimi-code): render Extra Usage section in /status panel * feat(kimi-code): wire extraUsage into /usage and /status commands * 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 * chore: temporarily log /usages raw response for debugging * fix(oauth): accept BOOSTER balance type and drop debug log * fix(oauth): drop reset hint from Extra Usage and revert periodEnd parsing * fix(oauth): treat missing amountLeft as zero extra usage and drop debug log * revert: keep missing amountLeft defaulting to 0 (fully used) * feat(extra-usage): show monthly cap usage bar and balance in /usage and /status * fix(extra-usage): show balance and unlimited marker when no monthly cap * fix(extra-usage): show monthly used with unlimited marker and balance * fix(extra-usage): label Used and use English Unlimited * feat(extra-usage): render balance, monthly used and monthly limit as labeled rows * fix(extra-usage): move Balance row to the bottom * fix(extra-usage): format currency values with two decimals for column alignment * fix(extra-usage): right-align currency values so numbers line up * fix(extra-usage): align currency symbol and decimal point in usage rows
1 parent fe9479d commit b91099e

10 files changed

Lines changed: 517 additions & 11 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
"@moonshot-ai/kimi-code-sdk": patch
4+
---
5+
6+
Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands.

apps/kimi-code/src/tui/commands/info.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUs
213213
if (res.kind === 'error') {
214214
return { error: res.message };
215215
}
216-
return { usage: { summary: res.summary, limits: res.limits } };
216+
return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } };
217217
}

apps/kimi-code/src/tui/components/messages/status-panel.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import {
2222
safeUsageRatio,
2323
} from '#/utils/usage/usage-format';
2424

25-
import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel';
25+
import {
26+
buildExtraUsageSection,
27+
buildManagedUsageReportLines,
28+
type ManagedUsageReport,
29+
} from './usage-panel';
2630

2731
interface FieldRow {
2832
readonly label: string;
@@ -145,5 +149,16 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] {
145149
lines.push(...managedSection);
146150
}
147151

152+
const extraSection = buildExtraUsageSection(
153+
options.managedUsage?.extraUsage,
154+
accent,
155+
value,
156+
muted,
157+
);
158+
if (extraSection.length > 0) {
159+
lines.push('');
160+
lines.push(...extraSection);
161+
}
162+
148163
return lines;
149164
}

apps/kimi-code/src/tui/components/messages/usage-panel.ts

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,19 @@ export interface ManagedUsageRow {
3030
readonly resetHint?: string;
3131
}
3232

33+
export interface BoosterWalletInfo {
34+
readonly balanceCents: number;
35+
readonly totalCents: number;
36+
readonly monthlyChargeLimitEnabled: boolean;
37+
readonly monthlyChargeLimitCents: number;
38+
readonly monthlyUsedCents: number;
39+
readonly currency: string;
40+
}
41+
3342
export interface ManagedUsageReport {
3443
readonly summary: ManagedUsageRow | null;
3544
readonly limits: readonly ManagedUsageRow[];
45+
readonly extraUsage?: BoosterWalletInfo | null;
3646
}
3747

3848
export interface UsageReportOptions {
@@ -121,8 +131,7 @@ function buildManagedUsageSection(
121131
r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0;
122132
const labelWidth = Math.max(10, ...rows.map((r) => r.label.length));
123133
const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length));
124-
const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' =>
125-
sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
134+
126135
const out: string[] = [accent('Plan usage')];
127136
for (const row of rows) {
128137
const ratioUsed = usedRatio(row);
@@ -136,6 +145,91 @@ function buildManagedUsageSection(
136145
return out;
137146
}
138147

148+
function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' {
149+
return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
150+
}
151+
152+
function currencySymbol(currency: string): string {
153+
switch (currency.toUpperCase()) {
154+
case 'CNY':
155+
return '¥';
156+
case 'USD':
157+
return '$';
158+
default:
159+
return '';
160+
}
161+
}
162+
163+
interface CurrencyParts {
164+
readonly symbol: string;
165+
readonly number: string;
166+
}
167+
168+
function formatCurrencyParts(cents: number, currency: string): CurrencyParts {
169+
const symbol = currencySymbol(currency);
170+
const main = cents / 100;
171+
const formatted = main.toFixed(2);
172+
return symbol.length > 0
173+
? { symbol, number: formatted }
174+
: { symbol: '', number: `${formatted} ${currency}` };
175+
}
176+
177+
export function buildExtraUsageSection(
178+
extraUsage: BoosterWalletInfo | undefined | null,
179+
accent: Colorize,
180+
value: Colorize,
181+
muted: Colorize,
182+
): string[] {
183+
if (extraUsage === undefined || extraUsage === null) return [];
184+
185+
const hasMonthlyLimit =
186+
extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0;
187+
188+
const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency);
189+
const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency);
190+
const rows: Array<{ label: string; symbol: string; number: string }> = [];
191+
let barLine: string | null = null;
192+
193+
if (hasMonthlyLimit) {
194+
const ratio = Math.max(
195+
0,
196+
Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1),
197+
);
198+
const bar = renderProgressBar(ratio, 20);
199+
barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`;
200+
const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency);
201+
rows.push({ label: 'Used this month', ...used });
202+
rows.push({ label: 'Monthly limit', ...limit });
203+
rows.push({ label: 'Balance', ...balance });
204+
} else {
205+
rows.push({ label: 'Used this month', ...used });
206+
rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' });
207+
rows.push({ label: 'Balance', ...balance });
208+
}
209+
210+
// `Used this month` is the longest label; size the column to the widest label
211+
// so the currency symbol starts in the same column on every row.
212+
const labelWidth = Math.max(...rows.map((r) => r.label.length));
213+
// Right-align the numeric part of currency rows against each other so the
214+
// decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as
215+
// `Unlimited` carry no currency symbol, so they must not widen the numeric
216+
// column — otherwise money values get padded with stray spaces.
217+
const numberWidth = Math.max(
218+
0,
219+
...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)),
220+
);
221+
const row = (label: string, symbol: string, number: string): string => {
222+
const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number;
223+
return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`;
224+
};
225+
226+
const lines: string[] = [accent('Extra Usage')];
227+
if (barLine !== null) lines.push(barLine);
228+
for (const r of rows) lines.push(row(r.label, r.symbol, r.number));
229+
230+
return lines;
231+
}
232+
139233
export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] {
140234
const accent = (text: string) => currentTheme.boldFg('primary', text);
141235
const value = (text: string) => currentTheme.fg('text', text);
@@ -157,8 +251,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
157251
const value = (text: string) => currentTheme.fg('text', text);
158252
const muted = (text: string) => currentTheme.fg('textDim', text);
159253
const errorStyle = (text: string) => currentTheme.fg('error', text);
160-
const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' =>
161-
sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
162254

163255
const lines: string[] = [
164256
accent('Session usage'),
@@ -197,6 +289,17 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
197289
lines.push(...managedSection);
198290
}
199291

292+
const extraSection = buildExtraUsageSection(
293+
options.managedUsage?.extraUsage,
294+
accent,
295+
value,
296+
muted,
297+
);
298+
if (extraSection.length > 0) {
299+
lines.push('');
300+
lines.push(...extraSection);
301+
}
302+
200303
return lines;
201304
}
202305

apps/kimi-code/test/tui/components/messages/status-panel.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,44 @@ describe('status panel report lines', () => {
6868
expect(output).not.toContain('Runtime');
6969
});
7070

71+
it('formats extra usage section in status report', () => {
72+
const lines = buildStatusReportLines({
73+
version: '1.2.3',
74+
model: 'k2',
75+
workDir: '/tmp/project',
76+
sessionId: 'ses-1',
77+
sessionTitle: null,
78+
thinkingEffort: 'off',
79+
permissionMode: 'manual',
80+
planMode: false,
81+
contextUsage: 0,
82+
contextTokens: 0,
83+
maxContextTokens: 0,
84+
availableModels: {},
85+
managedUsage: {
86+
summary: null,
87+
limits: [],
88+
extraUsage: {
89+
balanceCents: 15000,
90+
totalCents: 20000,
91+
monthlyChargeLimitEnabled: true,
92+
monthlyChargeLimitCents: 20000,
93+
monthlyUsedCents: 5000,
94+
currency: 'USD',
95+
},
96+
},
97+
}).map(strip);
98+
99+
const output = lines.join('\n');
100+
expect(output).toContain('Extra Usage');
101+
expect(output).toContain('Balance');
102+
expect(output).toContain('150.00');
103+
expect(output).toContain('Used this month');
104+
expect(output).toContain('50.00');
105+
expect(output).toContain('Monthly limit');
106+
expect(output).toContain('200.00');
107+
});
108+
71109
it('falls back to app state and shows status load errors as warnings', () => {
72110
const lines = buildStatusReportLines({
73111
version: '1.2.3',

apps/kimi-code/test/tui/components/messages/usage-panel.test.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('UsagePanelComponent', () => {
2424
output: 250,
2525
},
2626
},
27-
} as never,
27+
},
2828
contextUsage: 0.25,
2929
contextTokens: 2500,
3030
maxContextTokens: 10000,
@@ -48,6 +48,142 @@ describe('UsagePanelComponent', () => {
4848
expect(lines.join('\n')).toContain('resets tomorrow');
4949
});
5050

51+
it('formats extra usage with a monthly limit', () => {
52+
const lines = buildUsageReportLines({
53+
sessionUsage: { byModel: {} },
54+
contextUsage: 0,
55+
contextTokens: 0,
56+
maxContextTokens: 0,
57+
managedUsage: {
58+
summary: null,
59+
limits: [],
60+
extraUsage: {
61+
balanceCents: 10000,
62+
totalCents: 20000,
63+
monthlyChargeLimitEnabled: true,
64+
monthlyChargeLimitCents: 20000,
65+
monthlyUsedCents: 5000,
66+
currency: 'USD',
67+
},
68+
},
69+
}).map(strip);
70+
71+
const output = lines.join('\n');
72+
expect(lines).toContain('Extra Usage');
73+
expect(output).toContain('Balance');
74+
expect(output).toContain('100.00');
75+
expect(output).toContain('Used this month');
76+
expect(output).toContain('50.00');
77+
expect(output).toContain('Monthly limit');
78+
expect(output).toContain('200.00');
79+
// bar row contains block glyphs but no percentage text
80+
expect(output).toContain('░');
81+
});
82+
83+
it('formats extra usage without a monthly limit and omits the progress bar', () => {
84+
const lines = buildUsageReportLines({
85+
sessionUsage: { byModel: {} },
86+
contextUsage: 0,
87+
contextTokens: 0,
88+
maxContextTokens: 0,
89+
managedUsage: {
90+
summary: null,
91+
limits: [],
92+
extraUsage: {
93+
balanceCents: 18208,
94+
totalCents: 40000,
95+
monthlyChargeLimitEnabled: false,
96+
monthlyChargeLimitCents: 0,
97+
monthlyUsedCents: 21792,
98+
currency: 'CNY',
99+
},
100+
},
101+
}).map(strip);
102+
103+
const output = lines.join('\n');
104+
expect(lines).toContain('Extra Usage');
105+
expect(output).toContain('Balance');
106+
expect(output).toContain('¥182.08');
107+
expect(output).toContain('Used this month');
108+
expect(output).toContain('¥217.92');
109+
expect(output).toContain('Monthly limit');
110+
expect(output).toContain('Unlimited');
111+
expect(output).not.toContain('░');
112+
expect(output).not.toContain('█');
113+
});
114+
115+
it('omits the extra usage section when extraUsage is omitted or null', () => {
116+
for (const extraUsage of [undefined, null]) {
117+
const lines = buildUsageReportLines({
118+
sessionUsage: { byModel: {} },
119+
contextUsage: 0,
120+
contextTokens: 0,
121+
maxContextTokens: 0,
122+
managedUsage: { summary: null, limits: [], extraUsage },
123+
}).map(strip);
124+
125+
expect(lines).not.toContain('Extra Usage');
126+
}
127+
});
128+
129+
it('formats extra usage with CNY currency', () => {
130+
const lines = buildUsageReportLines({
131+
sessionUsage: { byModel: {} },
132+
contextUsage: 0,
133+
contextTokens: 0,
134+
maxContextTokens: 0,
135+
managedUsage: {
136+
summary: null,
137+
limits: [],
138+
extraUsage: {
139+
balanceCents: 10000,
140+
totalCents: 20000,
141+
monthlyChargeLimitEnabled: true,
142+
monthlyChargeLimitCents: 20000,
143+
monthlyUsedCents: 5000,
144+
currency: 'CNY',
145+
},
146+
},
147+
}).map(strip);
148+
149+
const output = lines.join('\n');
150+
expect(output).toContain('Balance');
151+
expect(output).toContain('100.00');
152+
expect(output).toContain('Used this month');
153+
expect(output).toContain('50.00');
154+
expect(output).toContain('Monthly limit');
155+
expect(output).toContain('200.00');
156+
});
157+
158+
it('aligns the currency symbol and decimal point across extra usage rows', () => {
159+
const lines = buildUsageReportLines({
160+
sessionUsage: { byModel: {} },
161+
contextUsage: 0,
162+
contextTokens: 0,
163+
maxContextTokens: 0,
164+
managedUsage: {
165+
summary: null,
166+
limits: [],
167+
extraUsage: {
168+
balanceCents: 15901,
169+
totalCents: 300000,
170+
monthlyChargeLimitEnabled: true,
171+
monthlyChargeLimitCents: 300000,
172+
monthlyUsedCents: 24099,
173+
currency: 'CNY',
174+
},
175+
},
176+
}).map(strip);
177+
178+
const extraRows = lines.filter((line) => line.includes('¥'));
179+
expect(extraRows).toHaveLength(3);
180+
// The currency symbol stays in one column...
181+
expect(new Set(extraRows.map((line) => line.indexOf('¥'))).size).toBe(1);
182+
// ...and the right-aligned numeric parts end in the same column, so the
183+
// decimal points line up across rows.
184+
expect(new Set(extraRows.map((line) => line.length)).size).toBe(1);
185+
});
186+
51187
it('wraps preformatted usage lines in a bordered panel', () => {
52188
const component = new UsagePanelComponent(() => ['Session usage'], 'primary');
53189
const output = component.render(80).map(strip);

0 commit comments

Comments
 (0)