Skip to content
Open
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-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add the /context slash command to show a Claude Code-style context usage breakdown with estimated tokens per category (system prompt, tools, MCP tools, memory files, skills, messages). Run /context to see it.
7 changes: 5 additions & 2 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
showSettingsSelector,
} from './config';
import { handleGoalCommand } from './goal';
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
import { handleFeedbackCommand, showContextReport, showMcpServers, showStatusReport, showUsage } from './info';
import { handleAddDirCommand } from './add-dir';
import { parseSlashInput } from './parse';
import { handlePluginsCommand } from './plugins';
Expand Down Expand Up @@ -79,7 +79,7 @@ export {
showSettingsSelector,
} from './config';
export { handleSwarmCommand } from './swarm';
export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
export { handleFeedbackCommand, showContextReport, showMcpServers, showStatusReport, showUsage } from './info';
export { handlePluginsCommand } from './plugins';
export { handleReloadCommand, handleReloadTuiCommand } from './reload';
export { handleGoalCommand } from './goal';
Expand Down Expand Up @@ -318,6 +318,9 @@ async function handleBuiltInSlashCommand(
case 'usage':
void showUsage(host);
return;
case 'context':
void showContextReport(host);
return;
case 'status':
void showStatusReport(host);
return;
Expand Down
38 changes: 37 additions & 1 deletion apps/kimi-code/src/tui/commands/info.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { release as osRelease, type as osType } from 'node:os';

import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk';
import type { ContextBreakdownData, McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk';

import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel';
import { buildContextReportLines } from '../components/messages/context-panel';
import { buildStatusReportLines } from '../components/messages/status-panel';
import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel';
import {
Expand Down Expand Up @@ -183,6 +184,41 @@ export async function showMcpServers(host: SlashCommandHost): Promise<void> {
host.state.ui.requestRender();
}

export async function showContextReport(host: SlashCommandHost): Promise<void> {
const session = host.requireSession();
// The MCP status list is decorative — a failure there must not sink the report.
const [breakdownResult, mcpServers] = await Promise.all([
loadContextBreakdown(host),
session.listMcpServers().catch(() => undefined),
]);
const panel = new UsagePanelComponent(
() =>
buildContextReportLines({
model: host.state.appState.model,
breakdown: breakdownResult.breakdown,
mcpServers,
error: breakdownResult.error,
}),
'primary',
' Context ',
);
host.state.transcriptContainer.addChild(panel);
host.state.ui.requestRender();
}

interface ContextBreakdownResult {
readonly breakdown?: ContextBreakdownData;
readonly error?: string;
}

async function loadContextBreakdown(host: SlashCommandHost): Promise<ContextBreakdownResult> {
try {
return { breakdown: await host.requireSession().getContextBreakdown() };
} catch (error) {
return { error: formatErrorMessage(error) };
}
}

async function loadSessionUsageReport(host: SlashCommandHost): Promise<SessionUsageResult> {
try {
return { usage: await host.requireSession().getUsage() };
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,13 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 60,
availability: 'always',
},
{
name: 'context',
aliases: [],
description: 'Show context usage breakdown',
priority: 65,
availability: 'always',
},
{
name: 'status',
aliases: [],
Expand Down
188 changes: 188 additions & 0 deletions apps/kimi-code/src/tui/components/messages/context-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* ContextPanelComponent — renders the `/context` report: a Claude Code-style
* context-usage panel with a block-grid visualization, model + context-window
* summary, and the estimated per-category token cost of the context
* (system prompt, tool schemas, MCP tools, memory files, skills, messages),
* with per-server / per-file / per-skill token detail under each section.
*/

import type {
ContextBreakdownData,
McpServerInfo,
} from '@moonshot-ai/kimi-code-sdk';

import { formatTokenCount, ratioSeverity, safeUsageRatio } from '#/utils/usage/usage-format';
import { currentTheme } from '#/tui/theme';
import type { ColorToken } from '#/tui/theme';

const BLOCK_FILLED = '⛁';
const BLOCK_EMPTY = '⛶';
const GRID_COLS = 10;
const GRID_ROWS = 4;
const GRID_BLOCKS = GRID_COLS * GRID_ROWS;

export interface ContextReportOptions {
readonly model: string;
readonly breakdown?: ContextBreakdownData;
readonly mcpServers?: readonly McpServerInfo[];
readonly error?: string;
}

function severityColor(sev: 'ok' | 'warn' | 'danger'): ColorToken {
return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success';
}

function renderBlockGrid(used: number, max: number): string[] {
const emptyCell = currentTheme.fg('textDim', BLOCK_EMPTY);
if (max <= 0) {
return Array.from({ length: GRID_ROWS }, () => emptyCell.repeat(GRID_COLS));
}
const ratio = safeUsageRatio(used / max);
const usedBlocks = Math.round(ratio * GRID_BLOCKS);
const filledCell = currentTheme.fg(severityColor(ratioSeverity(ratio)), BLOCK_FILLED);
const lines: string[] = [];
for (let row = 0; row < GRID_ROWS; row += 1) {
const cells: string[] = [];
for (let col = 0; col < GRID_COLS; col += 1) {
const index = row * GRID_COLS + col;
cells.push(index < usedBlocks ? filledCell : emptyCell);
}
lines.push(cells.join(' '));
}
return lines;
}

/** Percentage of the context window with one decimal ("1.2"), like Claude Code. */
function percentOf(tokens: number, max: number): string {
return ((tokens / max) * 100).toFixed(1);
}

function categoryLine(icon: string, color: ColorToken, label: string, value: string): string {
return ` ${currentTheme.fg(color, icon)} ${currentTheme.boldFg('text', label)}: ${value}`;
}

/** "2.4k tokens (1.2%)" — percent of the context window, or plain tokens when unknown. */
function tokenValue(tokens: number, max: number): string {
const muted = (text: string) => currentTheme.fg('textDim', text);
const base = `${currentTheme.fg('text', formatTokenCount(tokens))} ${muted('tokens')}`;
return max > 0 ? `${base} ${muted(`(${percentOf(tokens, max)}%)`)}` : base;
}

/** "~555 tokens" for detail lines, where the value is always an estimate. */
function estimateValue(tokens: number): string {
return currentTheme.fg('textDim', `~${formatTokenCount(tokens)} tokens`);
}

function buildMcpServerLines(
servers: readonly McpServerInfo[] | undefined,
breakdown: ContextBreakdownData,
): string[] {
if (servers === undefined || servers.length === 0) return [];
const muted = (text: string) => currentTheme.fg('textDim', text);
const value = (text: string) => currentTheme.fg('text', text);
const tokensByServer = new Map(breakdown.mcpServers.map((server) => [server.name, server.tokens]));
const lines: string[] = ['', currentTheme.boldFg('primary', `MCP servers · /mcp`)];
const sorted = servers.toSorted((a, b) => a.name.localeCompare(b.name));
for (let i = 0; i < sorted.length; i += 1) {
const server = sorted[i]!;
const isLast = i === sorted.length - 1;
const branch = isLast ? '└' : '├';
const statusBadge = server.status === 'connected' ? currentTheme.fg('success', '●') : muted('○');
const tokens = tokensByServer.get(server.name);
lines.push(
` ${branch} ${statusBadge} ${value(server.name)} ${muted(
`(${server.toolCount} tool${server.toolCount === 1 ? '' : 's'})`,
)}${tokens === undefined ? '' : ` ${estimateValue(tokens)}`}`,
);
}
return lines;
}

function buildMemoryFileLines(breakdown: ContextBreakdownData): string[] {
const files = breakdown.memoryFileEntries;
if (files.length === 0) return [];
const value = (text: string) => currentTheme.fg('text', text);
const lines: string[] = ['', currentTheme.boldFg('roleUser', `Memory files`)];
for (let i = 0; i < files.length; i += 1) {
const file = files[i]!;
const branch = i === files.length - 1 ? '└' : '├';
lines.push(` ${branch} ${value(file.path)} ${estimateValue(file.tokens)}`);
}
return lines;
}

function buildSkillLines(breakdown: ContextBreakdownData): string[] {
const skills = breakdown.skillEntries;
if (skills.length === 0) return [];
const muted = (text: string) => currentTheme.fg('textDim', text);
const value = (text: string) => currentTheme.fg('text', text);
const lines: string[] = ['', currentTheme.boldFg('shellMode', `Skills · /skills`)];
const sorted = skills.toSorted((a, b) => a.name.localeCompare(b.name));
for (let i = 0; i < sorted.length; i += 1) {
const skill = sorted[i]!;
const branch = i === sorted.length - 1 ? '└' : '├';
lines.push(
` ${branch} ${value(skill.name)} ${muted(`[${skill.source}]`)} ${estimateValue(skill.tokens)}`,
);
}
return lines;
}

export function buildContextReportLines(options: ContextReportOptions): string[] {
const accent = (text: string) => currentTheme.boldFg('primary', text);
const muted = (text: string) => currentTheme.fg('textDim', text);
const value = (text: string) => currentTheme.fg('text', text);
const errorStyle = (text: string) => currentTheme.fg('error', text);

const lines: string[] = [accent('Context Usage')];

if (options.error !== undefined || options.breakdown === undefined) {
lines.push(errorStyle(` ${options.error ?? 'Context breakdown unavailable.'}`));
return lines;
}
const breakdown = options.breakdown;

const modelName = options.model.length > 0 ? options.model : muted('No model selected');
const grid = renderBlockGrid(breakdown.usedTokens, breakdown.maxContextTokens);
const hasWindow = breakdown.maxContextTokens > 0;
const tokenLine = hasWindow
? `${value(formatTokenCount(breakdown.usedTokens))}/${value(
formatTokenCount(breakdown.maxContextTokens),
)} ${muted('tokens')} ${muted(`(${percentOf(breakdown.usedTokens, breakdown.maxContextTokens)}%)`)}`
: `${value(formatTokenCount(breakdown.usedTokens))} ${muted('tokens')}`;

// Right-align the summary text next to the first two grid rows.
lines.push(` ${grid[0]} ${modelName}`);
lines.push(` ${grid[1]} ${tokenLine}`);
lines.push(` ${grid[2]}`);
lines.push(` ${grid[3]}`);

lines.push('');
lines.push(muted(' Estimated usage by category'));

const max = breakdown.maxContextTokens;
const freeTokens = hasWindow ? Math.max(0, max - breakdown.usedTokens) : 0;

lines.push(categoryLine('⛁', 'warning', 'System prompt', tokenValue(breakdown.systemPrompt, max)));
lines.push(categoryLine('⛁', 'accent', 'System tools', tokenValue(breakdown.systemTools, max)));

const mcpToolCount = options.mcpServers?.reduce((sum, server) => sum + server.toolCount, 0) ?? 0;
const mcpSuffix =
mcpToolCount > 0
? muted(` · ${mcpToolCount} tool${mcpToolCount === 1 ? '' : 's'}`)
: '';
lines.push(
categoryLine('⛁', 'primary', 'MCP tools', tokenValue(breakdown.mcpTools, max) + mcpSuffix),
);

lines.push(categoryLine('⛁', 'roleUser', 'Memory files', tokenValue(breakdown.memoryFiles, max)));
lines.push(categoryLine('⛁', 'shellMode', 'Skills', tokenValue(breakdown.skills, max)));
lines.push(categoryLine('⛁', 'text', 'Messages', tokenValue(breakdown.messages, max)));
lines.push(categoryLine('⛶', 'textDim', 'Free space', hasWindow ? tokenValue(freeTokens, max) : muted('unknown')));

lines.push(...buildMcpServerLines(options.mcpServers, breakdown));
lines.push(...buildMemoryFileLines(breakdown));
lines.push(...buildSkillLines(breakdown));

return lines;
}
Loading