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/status-line-tps-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show the output token rate in the status line. Reorder or hide it with the `tps` slot in `[status_line].items`.
13 changes: 11 additions & 2 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Footer/status bar — multi-line status display at the bottom of the TUI.
*
* Layout:
* Line 1: [Ask When Needed] [plan] <model> <cwd> <git-badge> <shortcut hints>
* Line 1: [Ask When Needed] [plan] <goal> <model> <tasks> <cwd> <tps> <git-badge> <shortcut hints>
* Line 2: context: N% (tokens/max)
*/

Expand Down Expand Up @@ -37,7 +37,7 @@ import {
/** What the footer's fixed ctrl+o hint offers: expand collapsed tool output, or collapse it again. */
export type ToolOutputExpandHint = 'expand' | 'collapse';

const DEFAULT_STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git'] as const;
const DEFAULT_STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'tps', 'git'] as const;

const MAX_CWD_SEGMENTS = 3;
const GOAL_TIMER_INTERVAL_MS = 1_000;
Expand Down Expand Up @@ -428,6 +428,7 @@ export class FooterComponent implements Component {
model: [],
tasks: [],
cwd: [],
tps: [],
git: [],
tips: [],
};
Expand Down Expand Up @@ -493,6 +494,13 @@ export class FooterComponent implements Component {
const cwd = shortenCwd(state.workDir);
if (cwd) slots['cwd'] = [chalk.hex(colors.textDim)(cwd)];

// Decode TPS of the last measurable step. Absent until the first step long
// enough to time completes, so the slot simply stays empty early on.
const tps = state.decodeTps;
if (tps !== undefined) {
slots['tps'] = [chalk.hex(colors.textDim)(`${tps.toFixed(1)} tok/s`)];
}

const git = this.gitCache.getStatus();
if (git !== null) slots['git'] = [formatFooterGitBadge(git, colors)];

Expand All @@ -510,6 +518,7 @@ export class FooterComponent implements Component {
contextUsage: state.contextUsage,
contextTokens: state.contextTokens,
maxContextTokens: state.maxContextTokens,
decodeTps: state.decodeTps ?? null,
Comment thread
zhi1ong marked this conversation as resolved.
sessionId: state.sessionId,
version: state.version,
};
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const UpgradePreferencesSchema = z.object({
autoInstall: z.boolean(),
});

export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git', 'tips'] as const;
export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'tps', 'git', 'tips'] as const;
export type StatusLineItem = (typeof STATUS_LINE_ITEMS)[number];

export const StatusLineFileConfigSchema = z.object({
Expand Down
12 changes: 11 additions & 1 deletion apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import { openUrl } from '#/utils/open-url';
import { currentTheme } from '#/tui/theme';
import type { ColorToken } from '#/tui/theme';
import { errorReportHintLine } from '../constant/feedback';
import { formatStepDebugTiming } from '#/utils/usage/debug-timing';
import { formatStepDebugTiming, stepDecodeTps } from '#/utils/usage/debug-timing';
import { nextTranscriptId } from '../utils/transcript-id';
import type { BtwPanelController } from './btw-panel';
import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier';
Expand Down Expand Up @@ -432,6 +432,7 @@ export class SessionEventHandler {
this.host.streamingUI.flushNow();
this.clearStepRetry();
this.host.noteStepUsage(event.usage);
this.noteStepDecodeTps(event);
this.maybeShowDebugTiming(event);

if (event.providerFinishReason === 'filtered') {
Expand Down Expand Up @@ -501,6 +502,15 @@ export class SessionEventHandler {
}
}

// Feed the footer's `tps` slot from the step that just finished. Steps that
// drained too fast to time yield null and are skipped, leaving the previous
// reading in place rather than clearing the slot.
private noteStepDecodeTps(event: TurnStepCompletedEvent): void {
const tps = stepDecodeTps(event.usage?.output, event.llmStreamDurationMs);
if (tps === null) return;
this.host.setAppState({ decodeTps: tps });
Comment thread
zhi1ong marked this conversation as resolved.
}

private maybeShowDebugTiming(event: TurnStepCompletedEvent): void {
if (process.env['KIMI_CODE_DEBUG'] !== '1') return;
const text = formatStepDebugTiming(event);
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2609,7 +2609,7 @@ export class KimiTUI {
this.streamingUI.setTodoList([]);
this.sessionEventHandler.notifications.clear();
this.streamingUI.setTurnId(undefined);
this.setAppState({ mcpServersSummary: null });
this.setAppState({ mcpServersSummary: null, decodeTps: undefined });
this.streamingUI.setStep(0);
this.streamingUI.resetLiveText();
this.updateQueueDisplay();
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ export interface AppState {
contextUsage: number;
contextTokens: number;
maxContextTokens: number;
/**
* Decode TPS of the most recent step that streamed long enough to measure,
* feeding the footer's `tps` slot. Steps too short to time leave the previous
* reading in place, so the slot holds the last real number instead of
* blinking empty through a run of quick tool calls.
*/
decodeTps?: number;
cumulativeTokens?: number;
isCompacting: boolean;
isReplaying: boolean;
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/utils/status-line-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface StatusLinePayload {
contextUsage: number;
contextTokens: number;
maxContextTokens: number;
/** Decode TPS of the last measurable step; null until one has completed. */
decodeTps: number | null;
sessionId: string;
version: string;
}
Expand Down
21 changes: 18 additions & 3 deletions apps/kimi-code/src/utils/usage/debug-timing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ export interface StepTimingInput {
// instead of a meaningless ratio.
const MIN_STREAM_MS_FOR_TPS = 50;

/**
* Decode TPS for a single step: output tokens over the decode window. Null when
* the step reported no output or drained too fast to measure (see
* `MIN_STREAM_MS_FOR_TPS`). Shared by the debug timing line and the footer's
* `tps` slot so both report the same number from the same window.
*/
export function stepDecodeTps(
outputTokens: number | undefined,
streamMs: number | undefined,
): number | null {
if (outputTokens === undefined || outputTokens <= 0) return null;
if (streamMs === undefined || streamMs < MIN_STREAM_MS_FOR_TPS) return null;
return outputTokens / (streamMs / 1000);
}

export function formatStepDebugTiming(input: StepTimingInput): string | undefined {
const latency = input.llmFirstTokenLatencyMs;
const streamMs = input.llmStreamDurationMs;
Expand All @@ -44,10 +59,10 @@ export function formatStepDebugTiming(input: StepTimingInput): string | undefine
const parts: string[] = [`TTFT: ${formatTtft(input)}`];
const outputTokens = input.usage?.output;
if (outputTokens !== undefined && outputTokens > 0) {
if (streamMs >= MIN_STREAM_MS_FOR_TPS) {
const tps = (outputTokens / (streamMs / 1000)).toFixed(1);
const tps = stepDecodeTps(outputTokens, streamMs);
if (tps !== null) {
parts.push(
`TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`,
`TPS: ${tps.toFixed(1)} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`,
);
} else {
parts.push(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const payload: StatusLinePayload = {
contextUsage: 12,
contextTokens: 1024,
maxContextTokens: 8192,
decodeTps: 42.3,
sessionId: 'ses-1',
version: '1.2.3',
};
Expand All @@ -77,6 +78,21 @@ describe('FooterComponent status_line items', () => {
expect(line1).not.toContain('goal');
});

it('renders the tps slot after cwd in the default layout', () => {
const footer = new FooterComponent({ ...baseState, decodeTps: 42.34 });

const line1 = plain(footer.render(120)[0]!);
const cwdAt = line1.indexOf('/tmp/project');
const tpsAt = line1.indexOf('42.3 tok/s');
expect(cwdAt).toBeGreaterThanOrEqual(0);
expect(tpsAt).toBeGreaterThan(cwdAt);
});

it('omits the tps slot until a step reports a measurable rate', () => {
const line1 = plain(new FooterComponent({ ...baseState }).render(120)[0]!);
expect(line1).not.toContain('tok/s');
});

it('keeps the default layout when statusLine is unset', () => {
const footer = new FooterComponent({ ...baseState });

Expand Down Expand Up @@ -144,6 +160,7 @@ describe('runStatusLineCommand', () => {
expect(parsed.model).toBe('kimi-k2');
expect(parsed.gitBranch).toBe('main');
expect(parsed.cwd).toBe('/tmp/project');
expect(parsed.decodeTps).toBe(42.3);
});

it('returns null on a nonzero exit', async () => {
Expand Down Expand Up @@ -192,6 +209,33 @@ describe('FooterComponent status_line command', () => {
expect(plain(footer.render(120)[0]!)).toContain('my-custom-status');
});

it('hands the decode rate to the command so it can render its own', async () => {
const state: AppState = {
...baseState,
decodeTps: 42.34,
statusLine: { items: null, command: 'cat' },
};
const footer = new FooterComponent(state);

// The first render is what kicks the command off; its output lands later.
footer.render(2000);
await new Promise((resolve) => setTimeout(resolve, 200));

expect(plain(footer.render(2000)[0]!)).toContain('"decodeTps":42.34');
});

it('reports a null decode rate before any step has been measured', async () => {
const footer = new FooterComponent({
...baseState,
statusLine: { items: null, command: 'cat' },
});

footer.render(2000);
await new Promise((resolve) => setTimeout(resolve, 200));

expect(plain(footer.render(2000)[0]!)).toContain('"decodeTps":null');
});

it('keeps the built-in layout when the command fails', async () => {
const state: AppState = {
...baseState,
Expand Down
14 changes: 14 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ interface MessageDriver {
closeSession(reason: string): Promise<void>;
setSession(session: unknown): Promise<void>;
syncRuntimeState(session?: unknown): Promise<void>;
resetSessionRuntime(): void;
getCurrentSessionId(): string;
}

Expand Down Expand Up @@ -8896,6 +8897,19 @@ describe('footer ctrl+o hint', () => {
});
});

describe('footer tps slot session boundary', () => {
it('clears the decode rate when the session runtime resets', async () => {
const { driver } = await makeDriver();

driver.state.appState.decodeTps = 42.3;
driver.resetSessionRuntime();

// TPS is not persisted, so a resumed session cannot restore it — leaving
// it set would show the previous session's rate in the new one.
expect(driver.state.appState.decodeTps).toBeUndefined();
});
});

describe('KimiTUI session rating survey', () => {
it('runs the end-to-end rating flow after five user turns', async () => {
vi.useFakeTimers();
Expand Down
19 changes: 18 additions & 1 deletion apps/kimi-code/test/utils/usage/debug-timing.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { formatStepDebugTiming } from '#/utils/usage/debug-timing';
import { formatStepDebugTiming, stepDecodeTps } from '#/utils/usage/debug-timing';

describe('formatStepDebugTiming', () => {
it('returns undefined when timing fields are missing', () => {
Expand Down Expand Up @@ -159,3 +159,20 @@ describe('formatStepDebugTiming', () => {
expect(result).toContain('10.0s');
});
});

describe('stepDecodeTps', () => {
it('divides output tokens by the decode window', () => {
expect(stepDecodeTps(200, 5000)).toBeCloseTo(40);
});

it('returns null without usable output or timing', () => {
expect(stepDecodeTps(undefined, 5000)).toBeNull();
expect(stepDecodeTps(0, 5000)).toBeNull();
expect(stepDecodeTps(200, undefined)).toBeNull();
});

it('returns null for a window too short to measure', () => {
expect(stepDecodeTps(200, 49)).toBeNull();
expect(stepDecodeTps(200, 50)).toBeCloseTo(4000);
});
});
6 changes: 3 additions & 3 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,13 +555,13 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c
| `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent |
| `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` |
| `[upgrade].auto_install` | `boolean` | `true` | Whether new versions are installed automatically |
| `[status_line].items` | `string[]` | `[]` | Built-in slots on the first footer line and their order: `mode`, `goal`, `model`, `tasks`, `cwd`, `git`, `tips`; unknown ids are skipped with a warning |
| `[status_line].items` | `string[]` | `[]` | Built-in slots on the first footer line and their order: `mode`, `goal`, `model`, `tasks`, `cwd`, `tps`, `git`, `tips`; unknown ids are skipped with a warning |
| `[status_line].command` | `string` | `""` | Custom status line command: its first stdout line replaces the footer, and a JSON snapshot is passed on stdin; capped at 300ms, throttled to once per second, failures fall back to the built-in layout |

<details>
<summary>Fields in the stdin JSON snapshot</summary>

Model, cwd, git branch, permission mode, plan mode, context usage, session id, version.
Model, cwd, git branch, permission mode, plan mode, context usage, decode rate (`decodeTps`, null until a step has been measured), session id, version.

</details>

Expand All @@ -584,7 +584,7 @@ notification_condition = "unfocused" # "unfocused" | "always"
auto_install = true

# [status_line]
# items = ["mode", "goal", "model", "tasks", "cwd", "git", "tips"]
# items = ["mode", "goal", "model", "tasks", "cwd", "tps", "git", "tips"]
# command = "~/.kimi-code/statusline.sh"
```

Expand Down
6 changes: 3 additions & 3 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,13 +554,13 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod
| `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 |
| `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) |
| `[upgrade].auto_install` | `boolean` | `true` | 是否自动安装新版本 |
| `[status_line].items` | `string[]` | `[]` | 底部状态栏第一行的内置槽位及顺序:`mode`、`goal`、`model`、`tasks`、`cwd`、`git`、`tips`,未知 id 跳过并告警 |
| `[status_line].items` | `string[]` | `[]` | 底部状态栏第一行的内置槽位及顺序:`mode`、`goal`、`model`、`tasks`、`cwd`、`tps`、`git`、`tips`,未知 id 跳过并告警 |
| `[status_line].command` | `string` | `""` | 自定义状态栏命令:stdout 首行替换状态栏,stdin 收 JSON 快照;上限 300ms、每秒一次,失败回退内置布局 |

<details>
<summary>command 的 stdin 输入</summary>

model、cwd、git 分支、permission 模式、plan 模式、上下文用量、session id、版本。
model、cwd、git 分支、permission 模式、plan 模式、上下文用量、解码速率(`decodeTps`,尚无可测量的 step 时为 null)、session id、版本。

</details>

Expand All @@ -583,7 +583,7 @@ notification_condition = "unfocused" # "unfocused" | "always"
auto_install = true

# [status_line]
# items = ["mode", "goal", "model", "tasks", "cwd", "git", "tips"]
# items = ["mode", "goal", "model", "tasks", "cwd", "tps", "git", "tips"]
# command = "~/.kimi-code/statusline.sh"
```

Expand Down