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

Show recent model output speed in the footer after each completed response.
46 changes: 31 additions & 15 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type GitStatus,
type GitStatusCache,
} from '#/utils/git/git-status';
import { safeUsageRatio } from '#/utils/usage/usage-format';
import { formatTokenCount, safeUsageRatio } from '#/utils/usage/usage-format';

const MAX_CWD_SEGMENTS = 3;

Expand Down Expand Up @@ -83,12 +83,6 @@ 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);
}
Expand All @@ -101,6 +95,24 @@ function formatContextStatus(usage: number, tokens?: number, maxTokens?: number)
return `context: ${pct}`;
}

function formatTokenThroughput(tokensPerSecond: number | null): string | undefined {
if (
tokensPerSecond === null ||
!Number.isFinite(tokensPerSecond) ||
tokensPerSecond <= 0
) {
return undefined;
}

const value =
tokensPerSecond >= 100
? String(Math.round(tokensPerSecond))
: tokensPerSecond >= 10
? tokensPerSecond.toFixed(1)
: tokensPerSecond.toFixed(2);
return `speed: ${value} tok/s`;
}

export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string {
const base = chalk.hex(colors.status)(formatGitBadgeBase(status));
if (status.pullRequest === null) return base;
Expand Down Expand Up @@ -236,26 +248,30 @@ export class FooterComponent implements Component {
line1 = truncateToWidth(leftLine, width, '…');
}

// ── Line 2: transient hint (bottom-left) + context (right) ──
// ── Line 2: transient hint / token speed (bottom-left) + context (right) ──
const contextText = formatContextStatus(
state.contextUsage,
state.contextTokens,
state.maxContextTokens,
);
const contextWidth = visibleWidth(contextText);
let line2: string;
if (this.transientHint) {
const throughputText = formatTokenThroughput(state.outputTokensPerSecond);
const leftStatus = this.transientHint ?? throughputText;
if (leftStatus) {
const maxHintWidth = Math.max(0, width - contextWidth - 1);
const shownHint =
visibleWidth(this.transientHint) <= maxHintWidth
? this.transientHint
: truncateToWidth(this.transientHint, maxHintWidth, '…');
visibleWidth(leftStatus) <= maxHintWidth
? leftStatus
: truncateToWidth(leftStatus, maxHintWidth, '…');
const hintWidth = visibleWidth(shownHint);
const pad = Math.max(0, width - hintWidth - contextWidth);
const statusStyle =
this.transientHint === null
? chalk.hex(colors.textMuted)
: chalk.hex(colors.warning).bold;
line2 =
chalk.hex(colors.warning).bold(shownHint) +
' '.repeat(pad) +
chalk.hex(colors.text)(contextText);
statusStyle(shownHint) + ' '.repeat(pad) + chalk.hex(colors.text)(contextText);
} else {
const leftPad = Math.max(0, width - contextWidth);
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
Expand Down
51 changes: 51 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ import { installTerminalFocusTracking } from './utils/terminal-focus';
import { notifyTerminalOnce } from './utils/terminal-notification';
import { createTerminalState, type TerminalState } from './utils/terminal-state';
import { installTerminalThemeTracking } from './utils/terminal-theme';
import { outputTokensPerSecond } from './utils/token-throughput';
import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard';
import { nextTranscriptId } from './utils/transcript-id';

Expand Down Expand Up @@ -380,6 +381,8 @@ export interface TUIState {
externalEditorRunning: boolean;
currentTurnId: string | undefined;
currentStep: number;
currentStepFirstDeltaAtMs: number | undefined;
currentStepLastDeltaAtMs: number | undefined;
assistantDraft: string;
assistantStreamActive: boolean;
thinkingDraft: string;
Expand All @@ -405,6 +408,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
outputTokensPerSecond: null,
isStreaming: false,
isCompacting: false,
isReplaying: false,
Expand Down Expand Up @@ -491,6 +495,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
externalEditorRunning: false,
currentTurnId: undefined,
currentStep: 0,
currentStepFirstDeltaAtMs: undefined,
currentStepLastDeltaAtMs: undefined,
assistantDraft: '',
assistantStreamActive: false,
thinkingDraft: '',
Expand Down Expand Up @@ -947,6 +953,7 @@ export class KimiTUI {
contextTokens: 0,
maxContextTokens: 0,
contextUsage: 0,
outputTokensPerSecond: null,
sessionTitle: null,
});
this.state.startupNotice = combineStartupNotice(
Expand Down Expand Up @@ -994,6 +1001,7 @@ export class KimiTUI {
sessionId: '',
model: '',
sessionTitle: null,
outputTokensPerSecond: null,
});
await this.refreshSkillCommands();
}
Expand Down Expand Up @@ -1037,6 +1045,7 @@ export class KimiTUI {
maxContextTokens: 0,
contextUsage: 0,
contextTokens: 0,
outputTokensPerSecond: null,
});
}

Expand Down Expand Up @@ -1545,6 +1554,7 @@ export class KimiTUI {
// Resets request-scoped state before submitting work to the active session.
private beginSessionRequest(): void {
this.state.currentTurnId = undefined;
this.resetCurrentStepModelTiming();
this.resetLiveTextRuntime();
this.resetLiveToolUiState();
this.resetToolCallState();
Expand All @@ -1558,6 +1568,7 @@ export class KimiTUI {
isStreaming: true,
streamingPhase: 'waiting',
streamingStartTime: Date.now(),
outputTokensPerSecond: null,
});
}

Expand Down Expand Up @@ -1938,6 +1949,7 @@ export class KimiTUI {
contextTokens: status.contextTokens,
maxContextTokens: status.maxContextTokens,
contextUsage: status.contextUsage,
outputTokensPerSecond: null,
sessionTitle: session.summary?.title ?? null,
});
}
Expand Down Expand Up @@ -2033,6 +2045,7 @@ export class KimiTUI {
this.setTodoList([]);
this.state.currentTurnId = undefined;
this.state.currentStep = 0;
this.resetCurrentStepModelTiming();
this.resetLiveTextRuntime();
this.updateQueueDisplay();
}
Expand Down Expand Up @@ -2355,6 +2368,7 @@ export class KimiTUI {
void _event;
this.resetLiveToolUiState();
this.state.currentStep = 0;
this.resetCurrentStepModelTiming();
this.patchLivePane({
mode: 'waiting',
pendingApproval: null,
Expand All @@ -2364,6 +2378,7 @@ export class KimiTUI {
isStreaming: true,
streamingPhase: 'waiting',
streamingStartTime: Date.now(),
outputTokensPerSecond: null,
});
}

Expand All @@ -2383,6 +2398,7 @@ export class KimiTUI {
private handleStepBegin(event: TurnStepStartedEvent): void {
this.flushStreamingUiUpdatesNow();
this.state.currentStep = event.step;
this.resetCurrentStepModelTiming();
this.resetLiveToolUiState();
this.finalizeLiveTextBuffers('waiting');
this.patchLivePane({
Expand All @@ -2393,6 +2409,7 @@ export class KimiTUI {
this.setAppState({
streamingPhase: 'waiting',
streamingStartTime: Date.now(),
outputTokensPerSecond: null,
});
}

Expand All @@ -2406,6 +2423,7 @@ export class KimiTUI {
// notice pointing at the config knob.
private handleStepCompleted(event: TurnStepCompletedEvent): void {
this.flushStreamingUiUpdatesNow();
this.updateOutputTokenThroughput(event.usage);
if (event.finishReason !== 'max_tokens') return;

// Scope the truncation marking to tool calls that belong to the
Expand Down Expand Up @@ -2443,6 +2461,30 @@ export class KimiTUI {
this.showNotice(title, detail);
}

private updateOutputTokenThroughput(usage: TurnStepCompletedEvent['usage']): void {
const startedAtMs = this.state.currentStepFirstDeltaAtMs;
const endedAtMs = this.state.currentStepLastDeltaAtMs;
if (startedAtMs === undefined || endedAtMs === undefined) return;
Comment on lines +2465 to +2467
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use step-end time when only one delta is observed

This throughput path uses currentStepLastDeltaAtMs as the end timestamp, so when a step emits exactly one model delta (a common pattern in this repo’s agent-core event snapshots), startedAtMs and endedAtMs are identical and outputTokensPerSecond returns null. In that case the new footer speed metric silently disappears even though turn.step.completed.usage.output is positive, so short or single-chunk responses never show a speed value.

Useful? React with 👍 / 👎.

const tokensPerSecond = outputTokensPerSecond(
usage,
startedAtMs,
endedAtMs,
Comment on lines +2468 to +2471
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude retry backoff time from throughput denominator

updateOutputTokenThroughput always divides by elapsed wall-clock time since turn.step.started, but turn.step.completed.usage reflects only the successful LLM attempt. When a step retries (network/provider retryable errors), this elapsed window includes retry sleeps and failed attempts, which can drastically underreport tok/s even though model generation speed was normal. The metric therefore becomes misleading in any retried step.

Useful? React with 👍 / 👎.

);
if (tokensPerSecond === null) return;
this.setAppState({ outputTokensPerSecond: tokensPerSecond });
}

private resetCurrentStepModelTiming(): void {
this.state.currentStepFirstDeltaAtMs = undefined;
this.state.currentStepLastDeltaAtMs = undefined;
}

private recordCurrentStepModelDelta(): void {
const now = Date.now();
this.state.currentStepFirstDeltaAtMs ??= now;
this.state.currentStepLastDeltaAtMs = now;
}

private isAnthropicSessionActive(): boolean {
const providerKey = this.state.appState.availableModels[this.state.appState.model]?.provider;
if (providerKey === undefined) return false;
Expand All @@ -2469,6 +2511,9 @@ export class KimiTUI {

// Appends a thinking delta to the live thinking block.
private handleThinkingDelta(event: ThinkingDeltaEvent): void {
if (event.delta.length > 0) {
this.recordCurrentStepModelDelta();
}
this.state.thinkingDraft += event.delta;
this.pendingThinkingFlush = true;
this.patchLivePane({ mode: 'idle' });
Expand All @@ -2480,6 +2525,9 @@ export class KimiTUI {

// Appends an assistant text delta to the live assistant block.
private handleAssistantDelta(event: AssistantDeltaEvent): void {
if (event.delta.length > 0) {
this.recordCurrentStepModelDelta();
}
if (this.state.thinkingDraft.length > 0) {
this.flushThinkingToTranscript('idle');
}
Expand Down Expand Up @@ -2558,6 +2606,9 @@ export class KimiTUI {
// Accumulates streaming tool-call arguments and updates the rendered call.
private handleToolCallDelta(event: ToolCallDeltaEvent): void {
if (event.toolCallId.length === 0) return;
if ((event.argumentsPart?.length ?? 0) > 0 || (event.name?.length ?? 0) > 0) {
this.recordCurrentStepModelDelta();
}
const id = event.toolCallId;
const existing = this.state.streamingToolCallArguments.get(id);
const argumentsText = appendStreamingArgsPreview(
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface AppState {
contextUsage: number;
contextTokens: number;
maxContextTokens: number;
outputTokensPerSecond: number | null;
isStreaming: boolean;
isCompacting: boolean;
isReplaying: boolean;
Expand Down
18 changes: 18 additions & 0 deletions apps/kimi-code/src/tui/utils/token-throughput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk';

export function outputTokensPerSecond(
usage: TokenUsage | undefined,
startedAtMs: number,
endedAtMs: number,
): number | null {
const outputTokens = usage?.output;
if (typeof outputTokens !== 'number' || !Number.isFinite(outputTokens) || outputTokens <= 0) {
return null;
}
if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) return null;

const elapsedMs = endedAtMs - startedAtMs;
if (elapsedMs <= 0) return null;

return outputTokens / (elapsedMs / 1000);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function makeAppState(): AppState {
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 100,
outputTokensPerSecond: null,
isStreaming: false,
isCompacting: false,
isReplaying: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState {
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 200_000,
outputTokensPerSecond: null,
isStreaming: false,
isCompacting: false,
isReplaying: false,
Expand Down
22 changes: 22 additions & 0 deletions apps/kimi-code/test/tui/components/panels/footer-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState {
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
outputTokensPerSecond: null,
isStreaming: false,
isCompacting: false,
isReplaying: false,
Expand Down Expand Up @@ -115,6 +116,27 @@ describe('FooterComponent — context NaN resilience', () => {
expect(strip(line2 ?? '')).toContain('context: 0.0%');
});

it('renders recent output token throughput on the context line', () => {
const footer = new FooterComponent(
baseState({ outputTokensPerSecond: 24.62, contextUsage: 0.125 }),
darkColors,
);

const [, line2] = footer.render(120);
expect(strip(line2 ?? '')).toContain('speed: 24.6 tok/s');
expect(strip(line2 ?? '')).toContain('context: 12.5%');
});

it('lets transient hints temporarily replace token throughput', () => {
const footer = new FooterComponent(baseState({ outputTokensPerSecond: 24.62 }), darkColors);

footer.setTransientHint('Press Ctrl-C again to exit');

const [, line2] = footer.render(120);
expect(strip(line2 ?? '')).toContain('Press Ctrl-C again to exit');
expect(strip(line2 ?? '')).not.toContain('tok/s');
});

it('highlights the pull request badge separately from git status text', () => {
const previousLevel = chalk.level;
chalk.level = 3;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/create-tui-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function fakeInitialAppState(): AppState {
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
outputTokensPerSecond: null,
isStreaming: false,
isCompacting: false,
isReplaying: false,
Expand Down
Loading