diff --git a/.changeset/status-line-tps-slot.md b/.changeset/status-line-tps-slot.md new file mode 100644 index 00000000000..7de0b932427 --- /dev/null +++ b/.changeset/status-line-tps-slot.md @@ -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`. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index d2dd2ea2bbf..8998a0161cc 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -2,7 +2,7 @@ * Footer/status bar — multi-line status display at the bottom of the TUI. * * Layout: - * Line 1: [Ask When Needed] [plan] + * Line 1: [Ask When Needed] [plan] * Line 2: context: N% (tokens/max) */ @@ -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; @@ -428,6 +428,7 @@ export class FooterComponent implements Component { model: [], tasks: [], cwd: [], + tps: [], git: [], tips: [], }; @@ -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)]; @@ -510,6 +518,7 @@ export class FooterComponent implements Component { contextUsage: state.contextUsage, contextTokens: state.contextTokens, maxContextTokens: state.maxContextTokens, + decodeTps: state.decodeTps ?? null, sessionId: state.sessionId, version: state.version, }; diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index e09a8fe28e6..39974a8ab54 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -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({ diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 355f5a537b9..3093ef45219 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -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'; @@ -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') { @@ -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 }); + } + private maybeShowDebugTiming(event: TurnStepCompletedEvent): void { if (process.env['KIMI_CODE_DEBUG'] !== '1') return; const text = formatStepDebugTiming(event); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 3caa7876f78..0fbc7516cf8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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(); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 555acbc3000..2bb81cd7a35 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -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; diff --git a/apps/kimi-code/src/tui/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts index 6482a4ac7cf..1d32b0ba37f 100644 --- a/apps/kimi-code/src/tui/utils/status-line-command.ts +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -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; } diff --git a/apps/kimi-code/src/utils/usage/debug-timing.ts b/apps/kimi-code/src/utils/usage/debug-timing.ts index 15de825703c..d9748768706 100644 --- a/apps/kimi-code/src/utils/usage/debug-timing.ts +++ b/apps/kimi-code/src/utils/usage/debug-timing.ts @@ -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; @@ -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( diff --git a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts index 08883ebd3f5..6c00ca117f5 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -52,6 +52,7 @@ const payload: StatusLinePayload = { contextUsage: 12, contextTokens: 1024, maxContextTokens: 8192, + decodeTps: 42.3, sessionId: 'ses-1', version: '1.2.3', }; @@ -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 }); @@ -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 () => { @@ -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, 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 fe0aeb4428d..c2a8ff82773 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 @@ -139,6 +139,7 @@ interface MessageDriver { closeSession(reason: string): Promise; setSession(session: unknown): Promise; syncRuntimeState(session?: unknown): Promise; + resetSessionRuntime(): void; getCurrentSessionId(): string; } @@ -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(); 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 bea3bc50462..a598356839c 100644 --- a/apps/kimi-code/test/utils/usage/debug-timing.test.ts +++ b/apps/kimi-code/test/utils/usage/debug-timing.test.ts @@ -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', () => { @@ -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); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index ad8095d6fc1..5c305734e7f 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -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 |
Fields in the stdin JSON snapshot -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.
@@ -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" ``` diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index fc141273f87..467ecbdf911 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -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、每秒一次,失败回退内置布局 |
command 的 stdin 输入 -model、cwd、git 分支、permission 模式、plan 模式、上下文用量、session id、版本。 +model、cwd、git 分支、permission 模式、plan 模式、上下文用量、解码速率(`decodeTps`,尚无可测量的 step 时为 null)、session id、版本。
@@ -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" ```