From 663965e466c723b1983497b8b49323c1d78fb096 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 16 Jul 2026 16:02:45 +0800 Subject: [PATCH 1/5] fix(web): align context usage display with 1024-based units and ring-only meter - simplify the composer context meter to the ring only; the full used/max/pct numbers live in the tooltip - format token counts with 1024-based k/M units via a shared formatTokens helper (256k context reads "256k", not "262k"), applied to the composer tooltip, status panel, mobile settings sheet, model picker, goal strip, and turn rendering - ceil the usage percent so sub-0.5% usage still shows a sliver instead of an empty meter --- .changeset/web-context-usage-display.md | 5 ++ .../kimi-web/src/components/chat/Composer.vue | 57 +++++++------------ .../src/components/chat/GoalStrip.vue | 3 +- .../src/components/chat/StatusPanel.vue | 8 ++- .../src/components/chatTurnRendering.ts | 8 +-- .../components/mobile/MobileSettingsSheet.vue | 9 +-- .../src/components/settings/ModelPicker.vue | 3 +- apps/kimi-web/src/i18n/locales/en/model.ts | 2 +- apps/kimi-web/src/i18n/locales/zh/model.ts | 2 +- apps/kimi-web/src/lib/formatTokens.test.ts | 33 +++++++++++ apps/kimi-web/src/lib/formatTokens.ts | 20 +++++++ .../kimi-web/test/chat-turn-rendering.test.ts | 8 +-- 12 files changed, 101 insertions(+), 57 deletions(-) create mode 100644 .changeset/web-context-usage-display.md create mode 100644 apps/kimi-web/src/lib/formatTokens.test.ts create mode 100644 apps/kimi-web/src/lib/formatTokens.ts diff --git a/.changeset/web-context-usage-display.md b/.changeset/web-context-usage-display.md new file mode 100644 index 0000000000..49ac135b31 --- /dev/null +++ b/.changeset/web-context-usage-display.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Simplify the composer context meter to the ring only with the full numbers in the tooltip, format token counts with 1024-based units ("256k ctx", not "262k"), and ceil the usage percent so tiny non-zero usage stays visible. diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 79dfc1e249..fc21e665be 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'; import SlashMenu from './SlashMenu.vue'; import MentionMenu from './MentionMenu.vue'; import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands'; +import { formatTokens } from '../../lib/formatTokens'; import type { FileItem } from './MentionMenu.vue'; import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types'; import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types'; @@ -607,19 +608,19 @@ onUnmounted(() => { document.removeEventListener('click', onDocClick, true); }); -// Context formatting -const kFmt = (n: number) => `${Math.round(n / 1000)}k`; // Clamped to 0–100: ctxUsed can momentarily exceed ctxMax (estimates), and -// ctxMax can be 0 before the first status fetch — both broke the ring. +// ctxMax can be 0 before the first status fetch — both broke the ring. ceil +// (not round) so a session under 0.5% usage still shows a sliver of arc — +// Math.round floored it to an empty, "no data"-looking ring. const pct = computed(() => { const max = props.status?.ctxMax ?? 0; if (max <= 0) return 0; - return Math.min(100, Math.max(0, Math.round(((props.status?.ctxUsed ?? 0) / max) * 100))); + return Math.min(100, Math.max(0, Math.ceil(((props.status?.ctxUsed ?? 0) / max) * 100))); }); const ctxTooltip = computed(() => { - const used = (props.status?.ctxUsed ?? 0).toLocaleString(); - const max = (props.status?.ctxMax ?? 0).toLocaleString(); + const used = formatTokens(props.status?.ctxUsed ?? 0); + const max = formatTokens(props.status?.ctxMax ?? 0); return t('status.ctxTooltip', { used, max, pct: pct.value }); }); @@ -1085,12 +1086,10 @@ function selectModel(modelId: string): void { - + - {{ kFmt(status.ctxUsed) }}/{{ kFmt(status.ctxMax) }} @@ -1592,8 +1590,8 @@ function selectModel(modelId: string): void { color: var(--color-danger); } -/* Context group — circular ring + num. Focusable for keyboard / switch access - to its aria-label and tooltip (see template), so it needs a focus ring. */ +/* Context group — circular ring. Focusable for keyboard / switch access to its + aria-label and tooltip (see template), so it needs a focus ring. */ .ctx-group { display: flex; align-items: center; @@ -1607,16 +1605,6 @@ function selectModel(modelId: string): void { outline-offset: 2px; } -.ctx-num { - font-size: var(--ui-font-size); - color: var(--muted); - font-family: var(--font-ui); - font-variant-numeric: tabular-nums; - font-feature-settings: "tnum"; - letter-spacing: 0; - line-height: 16px; -} - /* Model pill */ .model-pill { display: inline-flex; @@ -2100,16 +2088,11 @@ function selectModel(modelId: string): void { toolbar shows every control on one row and toolbar-left / toolbar-right are overflow:hidden, so without shedding ink the row clips its own content. The context ring stays visible at every width (it is the live context-pressure - signal) but the "12k/256k" readout moves into the ring's tooltip, the model - name truncates earlier, and the permission label is capped so the ring and - the send button are never squeezed out. Mobile (≤640px) additionally hides - perm / modes via the rules below (those live in MobileSettingsSheet there). */ + signal; the exact numbers live in its tooltip), the model name truncates + earlier, and the permission label is capped so the ring and the send button + are never squeezed out. Mobile (≤640px) additionally hides perm / modes via + the rules below (those live in MobileSettingsSheet there). */ @media (max-width: 980px) { - /* The ring already conveys context pressure; the "12k/256k" readout lives in - the tooltip and returns at wider widths. */ - .ctx-num { - display: none; - } /* Model name was budgeted for a wide card (280px); trim it so the ring and send button are not squeezed out on a narrow column. */ .model-pill b { @@ -2189,9 +2172,9 @@ function selectModel(modelId: string): void { /* Mobile toolbar: hide secondary controls; attach / context ring / model / send stay visible. Permission + plan move into the MobileSettingsSheet. The context ring stays at every width by design — it is the live - context-pressure signal on a phone (the "12k/256k" readout is hidden here - by the ≤980px rule above and remains in the ring's tooltip). The /compact - chip also stays so compaction is one tap away at ≥80% usage. */ + context-pressure signal on a phone (the exact numbers live in the ring's + tooltip). The /compact chip also stays so compaction is one tap away at + ≥80% usage. */ .perm-pill, .modes { display: none; diff --git a/apps/kimi-web/src/components/chat/GoalStrip.vue b/apps/kimi-web/src/components/chat/GoalStrip.vue index c0fddfe365..9689f2a441 100644 --- a/apps/kimi-web/src/components/chat/GoalStrip.vue +++ b/apps/kimi-web/src/components/chat/GoalStrip.vue @@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import type { AppGoal } from '../../api/types'; import { useConfirmDialog } from '../../composables/useConfirmDialog'; +import { formatTokens } from '../../lib/formatTokens'; import Card from '../ui/Card.vue'; import Badge from '../ui/Badge.vue'; import Button from '../ui/Button.vue'; @@ -95,7 +96,7 @@ async function onCancel(): Promise { >
{{ goal.turnsUsed }} turns - {{ goal.tokensUsed.toLocaleString() }} tokens + {{ formatTokens(goal.tokensUsed) }} tokens {{ formatMs(goal.wallClockMs) }} {{ tokenPct }}% token budget
diff --git a/apps/kimi-web/src/components/chat/StatusPanel.vue b/apps/kimi-web/src/components/chat/StatusPanel.vue index cc95774c14..f9422082b3 100644 --- a/apps/kimi-web/src/components/chat/StatusPanel.vue +++ b/apps/kimi-web/src/components/chat/StatusPanel.vue @@ -6,6 +6,7 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import type { ConversationStatus, PermissionMode } from '../../types'; import type { ThinkingLevel } from '../../api/types'; +import { formatTokens } from '../../lib/formatTokens'; import Dialog from '../ui/Dialog.vue'; const { t } = useI18n(); @@ -28,15 +29,16 @@ const emit = defineEmits<{ // button, which we forward to the parent. const open = ref(true); +// ceil (not round) so sub-0.5% usage still renders a visible bar sliver. const pct = computed(() => - props.status.ctxMax > 0 ? Math.round((props.status.ctxUsed / props.status.ctxMax) * 100) : 0, + props.status.ctxMax > 0 ? Math.ceil((props.status.ctxUsed / props.status.ctxMax) * 100) : 0, ); const contextValue = computed(() => props.status.ctxMax > 0 ? t('status.statusContextValue', { - used: props.status.ctxUsed.toLocaleString(), - max: props.status.ctxMax.toLocaleString(), + used: formatTokens(props.status.ctxUsed), + max: formatTokens(props.status.ctxMax), pct: pct.value, }) : t('status.statusNone'), diff --git a/apps/kimi-web/src/components/chatTurnRendering.ts b/apps/kimi-web/src/components/chatTurnRendering.ts index f928adf2de..be57896bd7 100644 --- a/apps/kimi-web/src/components/chatTurnRendering.ts +++ b/apps/kimi-web/src/components/chatTurnRendering.ts @@ -4,11 +4,9 @@ // stateful copy/edit helpers. import type { ChatTurn, TurnBlock } from '../types'; -export function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; - return String(n); -} +// Shared 1024-based token formatter (lib/formatTokens); re-exported so the +// existing ChatPane import keeps working. +export { formatTokens } from '../lib/formatTokens'; export function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; diff --git a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue index 488e651d5b..6e80a74fe1 100644 --- a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue @@ -21,6 +21,7 @@ import { } from '../../lib/modelThinking'; import BottomSheet from '../dialogs/BottomSheet.vue'; import LanguageSwitcher from '../settings/LanguageSwitcher.vue'; +import { formatTokens } from '../../lib/formatTokens'; import Button from '../ui/Button.vue'; import Input from '../ui/Input.vue'; import SegmentedControl from '../ui/SegmentedControl.vue'; @@ -106,15 +107,15 @@ const permSub = computed(() => { return `${p} · ${desc}`; }); -const kFmt = (n: number): string => `${Math.round(n / 1000)}k`; const ctxPct = computed(() => + // ceil (not round) so sub-0.5% usage still renders a visible bar sliver. props.status.ctxMax > 0 - ? Math.min(100, Math.max(0, Math.round((props.status.ctxUsed / props.status.ctxMax) * 100))) + ? Math.min(100, Math.max(0, Math.ceil((props.status.ctxUsed / props.status.ctxMax) * 100))) : 0, ); -// Same "12k/256k" format as the desktop toolbar ring. +// Shared 1024-based formatter, same as the desktop tooltip / status panel. const ctxValue = computed(() => - props.status.ctxMax > 0 ? `${kFmt(props.status.ctxUsed)}/${kFmt(props.status.ctxMax)}` : t('status.statusNone'), + props.status.ctxMax > 0 ? `${formatTokens(props.status.ctxUsed)}/${formatTokens(props.status.ctxMax)}` : t('status.statusNone'), ); function setThinkingSegment(value: string): void { diff --git a/apps/kimi-web/src/components/settings/ModelPicker.vue b/apps/kimi-web/src/components/settings/ModelPicker.vue index d705511c07..16541ef647 100644 --- a/apps/kimi-web/src/components/settings/ModelPicker.vue +++ b/apps/kimi-web/src/components/settings/ModelPicker.vue @@ -5,6 +5,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import type { AppModel } from '../../api/types'; import { useDialogFocus } from '../../composables/useDialogFocus'; +import { formatTokens } from '../../lib/formatTokens'; import Dialog from '../ui/Dialog.vue'; import Button from '../ui/Button.vue'; import IconButton from '../ui/IconButton.vue'; @@ -197,7 +198,7 @@ function selectTab(tabId: string): void { {{ m.provider }} - {{ t('model.contextSuffix', { size: Math.round(m.maxContextSize / 1000) }) }} + {{ t('model.contextSuffix', { size: formatTokens(m.maxContextSize) }) }} { + it('renders sub-k counts as-is', () => { + expect(formatTokens(0)).toBe('0'); + expect(formatTokens(512)).toBe('512'); + expect(formatTokens(1023)).toBe('1023'); + }); + + it('uses 1024-based k units', () => { + expect(formatTokens(1024)).toBe('1k'); + expect(formatTokens(2048)).toBe('2k'); + expect(formatTokens(262144)).toBe('256k'); + }); + + it('keeps one decimal under 100k', () => { + expect(formatTokens(50552)).toBe('49.4k'); + expect(formatTokens(1536)).toBe('1.5k'); + }); + + it('rounds at and above 100k', () => { + expect(formatTokens(102400)).toBe('100k'); + expect(formatTokens(999999)).toBe('977k'); + }); + + it('uses 1024-based M units, dropping a trailing ".0"', () => { + expect(formatTokens(1048576)).toBe('1M'); + expect(formatTokens(1572864)).toBe('1.5M'); + expect(formatTokens(10485760)).toBe('10M'); + }); +}); diff --git a/apps/kimi-web/src/lib/formatTokens.ts b/apps/kimi-web/src/lib/formatTokens.ts new file mode 100644 index 0000000000..5f588a15c6 --- /dev/null +++ b/apps/kimi-web/src/lib/formatTokens.ts @@ -0,0 +1,20 @@ +// Format a token count for compact display. Context sizes are powers of two +// (262144 = 256×1024, 1048576 = 1024×1024), so the k/M units are 1024-based — +// a 256k context must render as "256k", never "262k". +// - < 1024: as-is ("512") +// - k range: one decimal under 100k, rounded above ("49.4k", "256k") +// - M range: one decimal, trailing ".0" dropped ("1M", "1.5M") +export function formatTokens(n: number): string { + 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; +} diff --git a/apps/kimi-web/test/chat-turn-rendering.test.ts b/apps/kimi-web/test/chat-turn-rendering.test.ts index 77916465ac..6538aa842f 100644 --- a/apps/kimi-web/test/chat-turn-rendering.test.ts +++ b/apps/kimi-web/test/chat-turn-rendering.test.ts @@ -25,13 +25,13 @@ function assistantTurn(blocks: TurnBlock[], over: Partial = {}): ChatT } describe('formatTokens', () => { - it('keeps small counts verbatim and abbreviates at the k / M thresholds', () => { + it('keeps counts under 1024 verbatim and uses 1024-based k / M units', () => { expect(formatTokens(0)).toBe('0'); expect(formatTokens(999)).toBe('999'); - expect(formatTokens(1000)).toBe('1.0k'); + expect(formatTokens(1000)).toBe('1000'); expect(formatTokens(1500)).toBe('1.5k'); - expect(formatTokens(1_000_000)).toBe('1.0M'); - expect(formatTokens(2_500_000)).toBe('2.5M'); + expect(formatTokens(1_000_000)).toBe('977k'); + expect(formatTokens(2_500_000)).toBe('2.4M'); }); }); From 8f3dea059e5b83b39de3a814d9c208a17131924a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 16 Jul 2026 16:02:52 +0800 Subject: [PATCH 2/5] fix(tui): render context usage with 1024-based units and ceil percent - formatTokenCount is now 1024-based ("256k", not "262.1k"); the footer, /status and /usage panels, subagent cards, and goal stats all share it, replacing five local 1000-based copies - the footer and panel percents use an integer ceil (new usagePercent helper) so any non-zero usage shows at least 1% instead of "0.0%" --- .changeset/tui-context-usage-display.md | 5 ++ .../src/tui/components/chrome/footer.ts | 32 ++++---- .../tui/components/messages/agent-group.ts | 5 +- .../tui/components/messages/status-panel.ts | 3 +- .../src/tui/components/messages/tool-call.ts | 11 +-- .../tui/components/messages/usage-panel.ts | 3 +- .../src/tui/utils/goal-completion.ts | 10 +-- .../kimi-code/src/utils/usage/usage-format.ts | 35 ++++++++- .../components/messages/goal-panel.test.ts | 2 +- .../components/messages/status-panel.test.ts | 4 +- .../components/messages/usage-panel.test.ts | 4 +- .../components/panels/footer-context.test.ts | 40 +++++++--- .../test/tui/kimi-tui-message-flow.test.ts | 2 +- .../kimi-code/test/tui/message-replay.test.ts | 4 +- .../test/tui/utils/goal-completion.test.ts | 2 +- .../test/utils/usage/debug-timing.test.ts | 4 +- .../test/utils/usage/usage-format.test.ts | 73 +++++++++++++++++-- 17 files changed, 172 insertions(+), 67 deletions(-) create mode 100644 .changeset/tui-context-usage-display.md diff --git a/.changeset/tui-context-usage-display.md b/.changeset/tui-context-usage-display.md new file mode 100644 index 0000000000..d3e7612e44 --- /dev/null +++ b/.changeset/tui-context-usage-display.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +tui: Render context usage with 1024-based token units ("256k", not "262.1k") and an integer ceiled percent (at least 1% for non-zero usage) in the footer and the /status and /usage panels. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index b91193b530..a2e8d7adee 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -3,7 +3,7 @@ * * Layout: * Line 1: [yolo] [plan] - * Line 2: context: XX.X% (tokens/max) + * Line 2: context: N% (tokens/max) */ import type { Component } from '@moonshot-ai/pi-tui'; @@ -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; @@ -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 { diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 6fd1624d5b..7e47529456 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -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'; @@ -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`; } 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 1d788d53b5..4c6799e03e 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -20,6 +20,7 @@ import { ratioSeverity, renderProgressBar, safeUsageRatio, + usagePercent, } from '#/utils/usage/usage-format'; import { @@ -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 { diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index e5303111a9..9ffed46b4e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -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'; @@ -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 { @@ -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 { @@ -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( 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 195860bc39..71d6e1b5b9 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -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'; @@ -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')); diff --git a/apps/kimi-code/src/tui/utils/goal-completion.ts b/apps/kimi-code/src/tui/utils/goal-completion.ts index f4c802b4a8..1e59b27f93 100644 --- a/apps/kimi-code/src/tui/utils/goal-completion.ts +++ b/apps/kimi-code/src/tui/utils/goal-completion.ts @@ -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; @@ -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}`; } @@ -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`; -} diff --git a/apps/kimi-code/src/utils/usage/usage-format.ts b/apps/kimi-code/src/utils/usage/usage-format.ts index 34db897a26..b44adb3f04 100644 --- a/apps/kimi-code/src/utils/usage/usage-format.ts +++ b/apps/kimi-code/src/utils/usage/usage-format.ts @@ -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))); } /** diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index ed69d3a856..43e1aa8d11 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -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', () => { 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 041860896a..34eaaed7dd 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 @@ -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'); 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 cf2598f829..199031896c 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 @@ -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'); diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index 2eebee5e3f..100f5f6a3b 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -44,32 +44,50 @@ function baseState(overrides: Partial = {}): 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', () => { @@ -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\//); }); @@ -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', () => { @@ -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', () => { diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 028738cac2..ce8e9c4070 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -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%'); }); }); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 5e4e11670f..f3e5faa253 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -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 () => { @@ -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.', }); }); diff --git a/apps/kimi-code/test/tui/utils/goal-completion.test.ts b/apps/kimi-code/test/tui/utils/goal-completion.test.ts index 0ef499e19a..ac09feb73f 100644 --- a/apps/kimi-code/test/tui/utils/goal-completion.test.ts +++ b/apps/kimi-code/test/tui/utils/goal-completion.test.ts @@ -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'); }); diff --git a/apps/kimi-code/test/utils/usage/debug-timing.test.ts b/apps/kimi-code/test/utils/usage/debug-timing.test.ts index 353b10ee53..5cd04ee242 100644 --- a/apps/kimi-code/test/utils/usage/debug-timing.test.ts +++ b/apps/kimi-code/test/utils/usage/debug-timing.test.ts @@ -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', ); }); @@ -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'); }); diff --git a/apps/kimi-code/test/utils/usage/usage-format.test.ts b/apps/kimi-code/test/utils/usage/usage-format.test.ts index 264bb5c55d..f220782855 100644 --- a/apps/kimi-code/test/utils/usage/usage-format.test.ts +++ b/apps/kimi-code/test/utils/usage/usage-format.test.ts @@ -5,6 +5,8 @@ import { renderProgressBar, ratioSeverity, safeUsageRatio, + usagePercent, + usagePercentFromRatio, } from '#/utils/usage/usage-format'; describe('formatTokenCount', () => { @@ -14,15 +16,27 @@ describe('formatTokenCount', () => { expect(formatTokenCount(999)).toBe('999'); }); - it('rounds integers over 1k to 1 decimal', () => { - expect(formatTokenCount(1_000)).toBe('1.0k'); - expect(formatTokenCount(1_234)).toBe('1.2k'); - expect(formatTokenCount(9_876)).toBe('9.9k'); + it('switches to k at 1024 and trims a redundant ".0"', () => { + expect(formatTokenCount(1_000)).toBe('1000'); + expect(formatTokenCount(1_024)).toBe('1k'); + expect(formatTokenCount(1_536)).toBe('1.5k'); + expect(formatTokenCount(2_048)).toBe('2k'); }); - it('switches to M above a million', () => { - expect(formatTokenCount(1_000_000)).toBe('1.0M'); - expect(formatTokenCount(2_500_000)).toBe('2.5M'); + it('rounds k values to 1 decimal', () => { + expect(formatTokenCount(50_552)).toBe('49.4k'); + expect(formatTokenCount(262_144)).toBe('256k'); + }); + + it('rounds k values at or above 100k to whole k', () => { + expect(formatTokenCount(102_400)).toBe('100k'); + expect(formatTokenCount(999_999)).toBe('977k'); + }); + + it('switches to M at 1024*1024', () => { + expect(formatTokenCount(1_048_576)).toBe('1M'); + expect(formatTokenCount(1_572_864)).toBe('1.5M'); + expect(formatTokenCount(10_485_760)).toBe('10M'); }); it('clamps negatives and NaN to 0', () => { @@ -32,6 +46,51 @@ describe('formatTokenCount', () => { }); }); +describe('usagePercent', () => { + it('returns 0 for zero usage', () => { + expect(usagePercent(0, 1000)).toBe(0); + }); + + it('ceil-guarantees at least 1% for any non-zero usage', () => { + expect(usagePercent(4, 10_000)).toBe(1); + }); + + it('ceils fractional percentages', () => { + expect(usagePercent(427, 1000)).toBe(43); + expect(usagePercent(992, 1000)).toBe(100); + }); + + it('clamps to 100 when used meets or exceeds max', () => { + expect(usagePercent(1000, 1000)).toBe(100); + expect(usagePercent(1200, 1000)).toBe(100); + }); + + it('returns 0 for a non-positive or non-finite max', () => { + expect(usagePercent(500, 0)).toBe(0); + expect(usagePercent(500, -1)).toBe(0); + expect(usagePercent(500, Number.NaN)).toBe(0); + }); +}); + +describe('usagePercentFromRatio', () => { + it('coerces NaN to 0', () => { + expect(usagePercentFromRatio(Number.NaN)).toBe(0); + }); + + it('returns 0 for zero usage', () => { + expect(usagePercentFromRatio(0)).toBe(0); + }); + + it('ceil-guarantees at least 1% for any non-zero ratio', () => { + expect(usagePercentFromRatio(0.004)).toBe(1); + }); + + it('ceils fractional percentages and clamps above 100', () => { + expect(usagePercentFromRatio(0.427)).toBe(43); + expect(usagePercentFromRatio(1.5)).toBe(100); + }); +}); + describe('renderProgressBar', () => { it('empty bar at ratio 0', () => { expect(renderProgressBar(0, 10)).toBe('░'.repeat(10)); From d063e5d0bf3585c2b73e81a97643c64f4c673213 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 16 Jul 2026 16:20:27 +0800 Subject: [PATCH 3/5] fix(web): clamp the status panel context percent to [0,100] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctxUsed can momentarily exceed ctxMax (estimates), which could flash a "101%" readout — the composer and mobile sheet already clamp the same ConversationStatus data, so apply the same clamp around the ceiled percentage here. --- apps/kimi-web/src/components/chat/StatusPanel.vue | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/kimi-web/src/components/chat/StatusPanel.vue b/apps/kimi-web/src/components/chat/StatusPanel.vue index f9422082b3..6d51e61485 100644 --- a/apps/kimi-web/src/components/chat/StatusPanel.vue +++ b/apps/kimi-web/src/components/chat/StatusPanel.vue @@ -29,10 +29,12 @@ const emit = defineEmits<{ // button, which we forward to the parent. const open = ref(true); -// ceil (not round) so sub-0.5% usage still renders a visible bar sliver. -const pct = computed(() => - props.status.ctxMax > 0 ? Math.ceil((props.status.ctxUsed / props.status.ctxMax) * 100) : 0, -); +// ceil (not round) so sub-0.5% usage still renders a visible bar sliver; +// clamped to 0–100 — ctxUsed can momentarily exceed ctxMax (estimates). +const pct = computed(() => { + if (props.status.ctxMax <= 0) return 0; + return Math.min(100, Math.max(0, Math.ceil((props.status.ctxUsed / props.status.ctxMax) * 100))); +}); const contextValue = computed(() => props.status.ctxMax > 0 From ccc5e9a83c8d1c70357f586cad377dc4264b2eec Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 16 Jul 2026 16:34:48 +0800 Subject: [PATCH 4/5] chore: merge the context usage changesets into one --- .changeset/context-usage-display.md | 5 +++++ .changeset/tui-context-usage-display.md | 5 ----- .changeset/web-context-usage-display.md | 5 ----- 3 files changed, 5 insertions(+), 10 deletions(-) create mode 100644 .changeset/context-usage-display.md delete mode 100644 .changeset/tui-context-usage-display.md delete mode 100644 .changeset/web-context-usage-display.md diff --git a/.changeset/context-usage-display.md b/.changeset/context-usage-display.md new file mode 100644 index 0000000000..540b2bf9aa --- /dev/null +++ b/.changeset/context-usage-display.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +优化上下文用量的进制显示 diff --git a/.changeset/tui-context-usage-display.md b/.changeset/tui-context-usage-display.md deleted file mode 100644 index d3e7612e44..0000000000 --- a/.changeset/tui-context-usage-display.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -tui: Render context usage with 1024-based token units ("256k", not "262.1k") and an integer ceiled percent (at least 1% for non-zero usage) in the footer and the /status and /usage panels. diff --git a/.changeset/web-context-usage-display.md b/.changeset/web-context-usage-display.md deleted file mode 100644 index 49ac135b31..0000000000 --- a/.changeset/web-context-usage-display.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Simplify the composer context meter to the ring only with the full numbers in the tooltip, format token counts with 1024-based units ("256k ctx", not "262k"), and ceil the usage percent so tiny non-zero usage stays visible. From ba280b736be7af2c5a14f7ec5ddff47640faa5ae Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 16 Jul 2026 16:35:39 +0800 Subject: [PATCH 5/5] chore: reword the context usage changeset in English --- .changeset/context-usage-display.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/context-usage-display.md b/.changeset/context-usage-display.md index 540b2bf9aa..3b13fe7a27 100644 --- a/.changeset/context-usage-display.md +++ b/.changeset/context-usage-display.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -优化上下文用量的进制显示 +Optimize the unit formatting of the context usage display.