mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-16 12:15:29 +00:00
* 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
* 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%"
* fix(web): clamp the status panel context percent to [0,100]
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.
* chore: merge the context usage changesets into one
* chore: reword the context usage changeset in English
179 lines
6.2 KiB
TypeScript
179 lines
6.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { ChatTurn, ToolCall, TurnBlock } from '../src/types';
|
|
import {
|
|
assistantRenderBlocks,
|
|
formatDuration,
|
|
formatTokens,
|
|
rendersToolCard,
|
|
renderBlockKey,
|
|
toolStackPosition,
|
|
turnBlocks,
|
|
turnFinalText,
|
|
turnToMarkdown,
|
|
} from '../src/components/chatTurnRendering';
|
|
|
|
function tool(id: string, over: Partial<ToolCall> = {}): ToolCall {
|
|
return { id, name: 'read', arg: `· ${id}.ts`, status: 'ok', ...over };
|
|
}
|
|
|
|
function toolBlock(id: string, over: Partial<ToolCall> = {}): Extract<TurnBlock, { kind: 'tool' }> {
|
|
return { kind: 'tool', tool: tool(id, over) };
|
|
}
|
|
|
|
function assistantTurn(blocks: TurnBlock[], over: Partial<ChatTurn> = {}): ChatTurn {
|
|
return { id: 't1', role: 'assistant', no: 1, text: '', blocks, ...over };
|
|
}
|
|
|
|
describe('formatTokens', () => {
|
|
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('1000');
|
|
expect(formatTokens(1500)).toBe('1.5k');
|
|
expect(formatTokens(1_000_000)).toBe('977k');
|
|
expect(formatTokens(2_500_000)).toBe('2.4M');
|
|
});
|
|
});
|
|
|
|
describe('formatDuration', () => {
|
|
it('switches units at the 1s and 1m boundaries', () => {
|
|
expect(formatDuration(999)).toBe('999ms');
|
|
expect(formatDuration(1000)).toBe('1.0s');
|
|
expect(formatDuration(59_999)).toBe('60.0s');
|
|
expect(formatDuration(60_000)).toBe('1m0.0s');
|
|
expect(formatDuration(90_500)).toBe('1m30.5s');
|
|
});
|
|
});
|
|
|
|
describe('turnBlocks', () => {
|
|
it('returns the ordered blocks as-is when present', () => {
|
|
const blocks: TurnBlock[] = [{ kind: 'text', text: 'hi' }];
|
|
expect(turnBlocks(assistantTurn(blocks))).toBe(blocks);
|
|
});
|
|
|
|
it('falls back to thinking -> text -> tools order when blocks are absent', () => {
|
|
const turn: ChatTurn = {
|
|
id: 't1',
|
|
role: 'assistant',
|
|
no: 1,
|
|
text: 'answer',
|
|
thinking: 'plan',
|
|
tools: [tool('a')],
|
|
};
|
|
expect(turnBlocks(turn)).toEqual([
|
|
{ kind: 'thinking', thinking: 'plan' },
|
|
{ kind: 'text', text: 'answer' },
|
|
{ kind: 'tool', tool: tool('a') },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('rendersToolCard', () => {
|
|
it('hides the card only for a successful tool that carries inline media', () => {
|
|
expect(rendersToolCard(toolBlock('a'))).toBe(true);
|
|
expect(rendersToolCard(toolBlock('r', { status: 'running' }))).toBe(true);
|
|
expect(
|
|
rendersToolCard(toolBlock('m', { status: 'ok', media: { kind: 'image', url: 'x' } })),
|
|
).toBe(false);
|
|
// media but errored -> still rendered as a card
|
|
expect(
|
|
rendersToolCard(toolBlock('e', { status: 'error', media: { kind: 'image', url: 'x' } })),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('toolStackPosition', () => {
|
|
it('marks a lone tool single and otherwise reports first/middle/last', () => {
|
|
expect(toolStackPosition(0, 1)).toBe('single');
|
|
expect(toolStackPosition(0, 0)).toBe('single');
|
|
expect(toolStackPosition(0, 3)).toBe('first');
|
|
expect(toolStackPosition(1, 3)).toBe('middle');
|
|
expect(toolStackPosition(2, 3)).toBe('last');
|
|
});
|
|
});
|
|
|
|
describe('assistantRenderBlocks', () => {
|
|
it('groups consecutive renderable tools into one tool-stack', () => {
|
|
const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a'), toolBlock('b')]));
|
|
expect(rendered).toHaveLength(1);
|
|
expect(rendered[0]).toMatchObject({ kind: 'tool-stack' });
|
|
if (rendered[0]?.kind === 'tool-stack') {
|
|
expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']);
|
|
expect(rendered[0].tools.map((t) => t.sourceIndex)).toEqual([0, 1]);
|
|
}
|
|
});
|
|
|
|
it('renders a lone tool as a standalone tool, not a stack', () => {
|
|
const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a')]));
|
|
expect(rendered).toEqual([{ kind: 'tool', tool: tool('a'), sourceIndex: 0 }]);
|
|
});
|
|
|
|
it('breaks the stack when a non-tool block interrupts the run', () => {
|
|
const rendered = assistantRenderBlocks(
|
|
assistantTurn([toolBlock('a'), { kind: 'text', text: 'x' }, toolBlock('b')]),
|
|
);
|
|
expect(rendered.map((b) => b.kind)).toEqual(['tool', 'text', 'tool']);
|
|
});
|
|
|
|
it('breaks the stack when a media tool (no card) interrupts the run', () => {
|
|
const rendered = assistantRenderBlocks(
|
|
assistantTurn([
|
|
toolBlock('a'),
|
|
toolBlock('b'),
|
|
toolBlock('c', { status: 'ok', media: { kind: 'image', url: 'x' } }),
|
|
]),
|
|
);
|
|
expect(rendered.map((b) => b.kind)).toEqual(['tool-stack', 'tool']);
|
|
if (rendered[0]?.kind === 'tool-stack') {
|
|
expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']);
|
|
}
|
|
});
|
|
|
|
it('preserves thinking/text order with their source indexes', () => {
|
|
const rendered = assistantRenderBlocks(
|
|
assistantTurn([
|
|
{ kind: 'thinking', thinking: 'plan' },
|
|
{ kind: 'text', text: 'answer' },
|
|
]),
|
|
);
|
|
expect(rendered).toEqual([
|
|
{ kind: 'thinking', thinking: 'plan', sourceIndex: 0 },
|
|
{ kind: 'text', text: 'answer', sourceIndex: 1 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('turnFinalText', () => {
|
|
it('joins only the text blocks, dropping thinking and tools', () => {
|
|
const turn = assistantTurn([
|
|
{ kind: 'thinking', thinking: 'plan' },
|
|
{ kind: 'text', text: 'first' },
|
|
toolBlock('a'),
|
|
{ kind: 'text', text: 'second' },
|
|
]);
|
|
expect(turnFinalText(turn)).toBe('first\n\nsecond');
|
|
});
|
|
});
|
|
|
|
describe('turnToMarkdown', () => {
|
|
it('renders thinking as a quote, text verbatim, and tool output as a fenced block', () => {
|
|
const turn = assistantTurn([
|
|
{ kind: 'thinking', thinking: 'line1\nline2' },
|
|
{ kind: 'text', text: 'hello' },
|
|
toolBlock('a', { name: 'bash', output: ['out1', 'out2'] }),
|
|
]);
|
|
expect(turnToMarkdown(turn)).toBe(
|
|
['> **Thinking**\n> line1\n> line2', 'hello', '```\n[bash]\nout1\nout2\n```'].join('\n\n'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('renderBlockKey', () => {
|
|
it('derives stable keys per block kind', () => {
|
|
expect(renderBlockKey({ kind: 'text', text: 'x', sourceIndex: 2 }, 0)).toBe('text-2');
|
|
expect(renderBlockKey({ kind: 'tool', tool: tool('a'), sourceIndex: 3 }, 0)).toBe('a');
|
|
expect(
|
|
renderBlockKey({ kind: 'tool-stack', tools: [{ tool: tool('a'), sourceIndex: 5 }] }, 0),
|
|
).toBe('tool-stack-5');
|
|
});
|
|
});
|