Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/context-usage-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Optimize the unit formatting of the context usage display.
32 changes: 16 additions & 16 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* Layout:
* Line 1: [yolo] [plan] <model> <cwd> <git-badge> <shortcut hints>
* Line 2: context: XX.X% (tokens/max)
* Line 2: context: N% (tokens/max)
*/

import type { Component } from '@moonshot-ai/pi-tui';
Expand All @@ -23,7 +23,11 @@ import {
type GitStatus,
type GitStatusCache,
} from '#/utils/git/git-status';
import { safeUsageRatio } from '#/utils/usage/usage-format';
import {
formatTokenCount,
usagePercent,
usagePercentFromRatio,
} from '#/utils/usage/usage-format';

const MAX_CWD_SEGMENTS = 3;
const GOAL_TIMER_INTERVAL_MS = 1_000;
Expand Down Expand Up @@ -154,22 +158,18 @@ function shortenCwd(path: string): string {
return `…/${tail}`;
}

function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}

function safeUsage(usage: number): number {
return safeUsageRatio(usage);
}

/**
* Footer context readout. Percent comes from the exact token counts when
* both are known (the ratio can lag a step behind); otherwise it falls
* back to the precomputed ratio. Counts use the shared 1024-based
* formatter.
*/
function formatContextStatus(usage: number, tokens?: number, maxTokens?: number): string {
const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`;
if (maxTokens && maxTokens > 0 && tokens !== undefined) {
return `context: ${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`;
if (maxTokens !== undefined && maxTokens > 0 && tokens !== undefined) {
const pct = String(usagePercent(tokens, maxTokens));
return `context: ${pct}% (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`;
}
return `context: ${pct}`;
return `context: ${String(usagePercentFromRatio(usage))}%`;
}

export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string {
Expand Down
5 changes: 2 additions & 3 deletions apps/kimi-code/src/tui/components/messages/agent-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Container, Spacer, Text } from '@moonshot-ai/pi-tui';

import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { formatTokenCount } from '#/utils/usage/usage-format';

import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call';

Expand Down Expand Up @@ -371,7 +372,5 @@ function formatElapsed(seconds: number): string {
}

function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`;
return `${String(n)} tok`;
return `${formatTokenCount(n)} tok`;
}
3 changes: 2 additions & 1 deletion apps/kimi-code/src/tui/components/messages/status-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ratioSeverity,
renderProgressBar,
safeUsageRatio,
usagePercent,
} from '#/utils/usage/usage-format';

import {
Expand Down Expand Up @@ -133,7 +134,7 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] {
const bar = renderProgressBar(safeRatio, 20);
const barColoured = currentTheme.fg(severityToken(ratioSeverity(safeRatio)), bar);
lines.push(
` ${barColoured} ${value(`${(safeRatio * 100).toFixed(1)}%`.padStart(6, ' '))} ` +
` ${barColoured} ${value(`${String(usagePercent(tokens, maxTokens))}%`.padStart(6, ' '))} ` +
muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`),
);
} else {
Expand Down
11 changes: 4 additions & 7 deletions apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk';
import { appendStreamingArgsPreview } from '#/tui/utils/event-payload';
import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';
import { formatTokenCount } from '#/utils/usage/usage-format';

import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress';
import { PlanBoxComponent } from './plan-box';
Expand Down Expand Up @@ -138,8 +139,7 @@ function str(v: unknown): string {

function formatSubagentContextTokens(contextTokens: number | undefined): string | undefined {
if (contextTokens === undefined || contextTokens <= 0) return undefined;
const formatted = contextTokens >= 1000 ? `${(contextTokens / 1000).toFixed(1)}k` : String(contextTokens);
return `${formatted} tok`;
return `${formatTokenCount(contextTokens)} tok`;
}

function usageInputTotal(usage: TokenUsage): number {
Expand All @@ -154,8 +154,7 @@ function usageTotal(usage: TokenUsage | undefined): number {
function formatSubagentTokens(usage: TokenUsage | undefined): string | undefined {
const total = usageTotal(usage);
if (total <= 0) return undefined;
const formatted = total >= 1000 ? `${(total / 1000).toFixed(1)}k` : String(total);
return `${formatted} tok`;
return `${formatTokenCount(total)} tok`;
}

function formatByteSize(bytes: number): string {
Expand Down Expand Up @@ -2318,9 +2317,7 @@ function computeLatestActivity(
}

function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`;
return `${String(n)} tok`;
return `${formatTokenCount(n)} tok`;
}

function formatActivityLine(
Expand Down
3 changes: 2 additions & 1 deletion apps/kimi-code/src/tui/components/messages/usage-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ratioSeverity,
renderProgressBar,
safeUsageRatio,
usagePercent,
} from '#/utils/usage/usage-format';
import { currentTheme } from '#/tui/theme';
import type { ColorToken } from '#/tui/theme';
Expand Down Expand Up @@ -266,7 +267,7 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] {
if (options.maxContextTokens > 0) {
const ratio = safeUsageRatio(options.contextUsage);
const bar = renderProgressBar(ratio, 20);
const pct = `${(ratio * 100).toFixed(1)}%`;
const pct = `${String(usagePercent(options.contextTokens, options.maxContextTokens))}%`;
const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar);
lines.push('');
lines.push(accent('Context window'));
Expand Down
10 changes: 3 additions & 7 deletions apps/kimi-code/src/tui/utils/goal-completion.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk';

import { formatTokenCount } from '#/utils/usage/usage-format';

interface GoalCompletionStats {
readonly terminalReason?: string | undefined;
readonly turnsUsed: number;
Expand All @@ -19,7 +21,7 @@ export function buildGoalCompletionMessage(goal: GoalSnapshot): string {
export function buildGoalCompletionMessageFromStats(goal: GoalCompletionStats): string {
const head = `✓ Goal complete${goal.terminalReason ? ` — ${goal.terminalReason}` : ''}.`;
const turns = `${goal.turnsUsed} turn${goal.turnsUsed === 1 ? '' : 's'}`;
const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokens(goal.tokensUsed)} tokens.`;
const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokenCount(goal.tokensUsed)} tokens.`;
return `${head}\n${stats}`;
}

Expand All @@ -32,9 +34,3 @@ function formatElapsed(ms: number): string {
const hours = Math.floor(minutes / 60);
return `${hours}h${(minutes % 60).toString().padStart(2, '0')}m`;
}

function formatTokens(tokens: number): string {
if (tokens < 1000) return String(tokens);
if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}k`;
return `${(tokens / 1_000_000).toFixed(1)}M`;
}
35 changes: 32 additions & 3 deletions apps/kimi-code/src/utils/usage/usage-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,40 @@
* command itself chalks the colour afterwards.
*/

/**
* Format a token count in 1024-based units: context sizes are powers of
* two, so 262144 reads as "256k", not "262.1k". k values at or above
* 100 are rounded to whole numbers ("977k").
*/
export function formatTokenCount(n: number): string {
if (!Number.isFinite(n) || n < 0) return '0';
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(Math.round(n));
if (n >= 1024 * 1024) return `${trimDecimal(n / (1024 * 1024))}M`;
if (n >= 1024) {
const k = n / 1024;
return `${k >= 100 ? Math.round(k) : trimDecimal(k)}k`;
}
return String(n);
}

/** One decimal place, dropping a redundant ".0" ("1.0" → "1", "1.5" stays). */
function trimDecimal(v: number): string {
const s = v.toFixed(1);
return s.endsWith('.0') ? s.slice(0, -2) : s;
}

/**
* Usage as a whole-number percentage of `max`, ceiled so any non-zero
* usage shows at least 1%, clamped to [0, 100]. A non-positive or
* non-finite `max` reports 0.
*/
export function usagePercent(used: number, max: number): number {
if (!Number.isFinite(max) || max <= 0) return 0;
return Math.min(100, Math.max(0, Math.ceil((used / max) * 100)));
}

/** `usagePercent` for callers that only know the ratio (NaN-safe). */
export function usagePercentFromRatio(ratio: number): number {
return Math.min(100, Math.max(0, Math.ceil(safeUsageRatio(ratio) * 100)));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('buildGoalReportLines', () => {
expect(out).toContain('Running');
expect(out).toContain('4m 12s');
expect(out).toContain('Turns');
expect(out).toContain('128.4k'); // formatTokenCount
expect(out).toContain('125k'); // formatTokenCount
});

it('shows a no-stop-condition note for an unbounded active goal', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ describe('status panel report lines', () => {
expect(output).toContain('Session ses-1');
expect(output).toContain('Title Implement status');
expect(output).toContain('Context window');
expect(output).toContain('25.0%');
expect(output).toContain('(3.0k / 12.0k)');
expect(output).toContain('25%');
expect(output).toContain('(2.9k / 11.7k)');
expect(output).toContain('Plan usage');
expect(output).toContain('8% used');
expect(output).not.toContain('Account');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ describe('UsagePanelComponent', () => {
}).map(strip);

expect(lines).toContain('Session usage');
expect(lines).toContain(' kimi input 2.0k output 250 total 2.3k');
expect(lines).toContain(' kimi input 2k output 250 total 2.2k');
expect(lines).toContain('Context window');
expect(lines.join('\n')).toContain('25.0%');
expect(lines.join('\n')).toContain('25%');
expect(lines).toContain('Plan usage');
expect(lines.join('\n')).toContain('20% used');
expect(lines.join('\n')).toContain('resets tomorrow');
Expand Down
40 changes: 29 additions & 11 deletions apps/kimi-code/test/tui/components/panels/footer-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,32 +44,50 @@ function baseState(overrides: Partial<AppState> = {}): AppState {
}

describe('FooterComponent — context NaN resilience', () => {
it('NaN usage → renders 0.0% (never literal "NaN%")', () => {
it('NaN usage → renders 0% (never literal "NaN%")', () => {
const fc = new FooterComponent(baseState({ contextUsage: Number.NaN }));
const out = strip(fc.render(120).join(''));
expect(out).not.toMatch(/NaN/);
expect(out).toMatch(/context: 0\.0%/);
expect(out).toMatch(/context: 0%/);
});

it('undefined-ish (coerced) usage → renders 0.0%', () => {
it('undefined-ish (coerced) usage → renders 0%', () => {
const fc = new FooterComponent(
baseState({ contextUsage: undefined as unknown as number }),
);
const out = strip(fc.render(120).join(''));
expect(out).not.toMatch(/NaN/);
expect(out).toMatch(/context: 0\.0%/);
expect(out).toMatch(/context: 0%/);
});

it('clamps ratios above 1.0 → renders 100.0%', () => {
it('clamps ratios above 1.0 → renders 100%', () => {
const fc = new FooterComponent(baseState({ contextUsage: 1.5 }));
const out = strip(fc.render(120).join(''));
expect(out).toMatch(/context: 100\.0%/);
expect(out).toMatch(/context: 100%/);
});

it('ratio 0.427 → renders 42.7%', () => {
it('ratio 0.427 → renders 43% (ceiled whole percent)', () => {
const fc = new FooterComponent(baseState({ contextUsage: 0.427 }));
const out = strip(fc.render(200).join(''));
expect(out).toMatch(/context: 42\.7%/);
expect(out).toMatch(/context: 43%/);
});

it('tiny non-zero usage → renders 1% (ceil floor)', () => {
const fc = new FooterComponent(baseState({ contextUsage: 0.0004 }));
const out = strip(fc.render(200).join(''));
expect(out).toMatch(/context: 1%/);
});

it('valid tokens/maxTokens → percent from tokens, counts in 1024 units', () => {
const fc = new FooterComponent(
baseState({
contextUsage: 0.427,
contextTokens: 430_080,
maxContextTokens: 1_048_576,
}),
);
const out = strip(fc.render(200).join(''));
expect(out).toMatch(/context: 42% \(420k\/1M\)/);
});

it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => {
Expand All @@ -78,7 +96,7 @@ describe('FooterComponent — context NaN resilience', () => {
);
const out = strip(fc.render(200).join(''));
expect(out).not.toMatch(/Infinity|NaN/);
expect(out).toMatch(/context: 0\.0%/);
expect(out).toMatch(/context: 0%/);
// With maxTokens=0, token-count annotation is suppressed.
expect(out).not.toMatch(/\(500\//);
});
Expand All @@ -91,7 +109,7 @@ describe('FooterComponent — context NaN resilience', () => {
const out = strip(footer.render(200).join(''));
expect(out).toContain('kimi-k2-5');
expect(out).not.toContain(' k2 ');
expect(out).toMatch(/context: 50\.0%/);
expect(out).toMatch(/context: 50%/);
});

it('shows "thinking" label when thinking is enabled, hides it when disabled', () => {
Expand All @@ -109,7 +127,7 @@ describe('FooterComponent — context NaN resilience', () => {

const [, line2] = footer.render(120);
expect(strip(line2 ?? '')).toContain('Press Ctrl-C again to exit');
expect(strip(line2 ?? '')).toContain('context: 0.0%');
expect(strip(line2 ?? '')).toContain('context: 0%');
});

it('highlights the pull request badge separately from git status text', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3870,7 +3870,7 @@ command = "vim"
expect(output).toContain('Permissions auto');
expect(output).toContain('Plan mode on');
expect(output).toContain('Context window');
expect(output).toContain('25.0%');
expect(output).toContain('25%');
});
});

Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/test/tui/message-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ describe('KimiTUI resume message replay', () => {
expect(transcript).toContain('Goal resumed');
expect(transcript).toContain('Goal blocked');
expect(transcript).toContain('Goal complete — done');
expect(transcript).toContain('Worked 1 turn over 7m15s, using 4.3k tokens.');
expect(transcript).toContain('Worked 1 turn over 7m15s, using 4.2k tokens.');
});

it('filters resume-normalization goal pause markers in TUI replay', async () => {
Expand Down Expand Up @@ -459,7 +459,7 @@ describe('KimiTUI resume message replay', () => {
expect(entry).toMatchObject({
kind: 'assistant',
renderMode: 'markdown',
content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.3M tokens.',
content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.1M tokens.',
});
});

Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/test/tui/utils/goal-completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('buildGoalCompletionMessage', () => {
const text = buildGoalCompletionMessage(snapshot());
expect(text).toContain('Goal complete — all tests pass.');
expect(text).toContain('3 turns');
expect(text).toContain('12.5k tokens');
expect(text).toContain('12.2k tokens');
expect(text).toContain('4m20s');
});

Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/test/utils/usage/debug-timing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('formatStepDebugTiming', () => {
},
});
expect(result).toBe(
'[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2.0k | cache read 1.2k (60%) / write 100',
'[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2k | cache read 1.2k (60%) / write 100',
);
});

Expand All @@ -54,7 +54,7 @@ describe('formatStepDebugTiming', () => {
output: 200,
},
});
expect(result).toContain('tokens in 1.0k');
expect(result).toContain('tokens in 1000');
expect(result).toContain('cache read 0 (0%)');
expect(result).not.toContain('/ write 0');
});
Expand Down
Loading
Loading