From d23955704346b96f9ebee651c1fcd46c930c3c79 Mon Sep 17 00:00:00 2001 From: yearthmain Date: Wed, 8 Jul 2026 15:30:11 +0800 Subject: [PATCH 1/2] feat(tui): support external status line command --- .changeset/status-line-command.md | 5 + apps/kimi-code/src/tui/commands/config.ts | 1 + apps/kimi-code/src/tui/commands/reload.ts | 1 + .../src/tui/components/chrome/footer.ts | 120 +++++++++++++++-- apps/kimi-code/src/tui/config.ts | 31 +++++ apps/kimi-code/src/tui/kimi-tui.ts | 1 + apps/kimi-code/src/tui/types.ts | 3 +- .../src/tui/utils/status-line-command.ts | 69 ++++++++++ .../test/cli/update/preflight.test.ts | 1 + apps/kimi-code/test/tui/activity-pane.test.ts | 1 + .../test/tui/components/chrome/footer.test.ts | 126 +++++++++++++++++- .../tui/components/chrome/welcome.test.ts | 1 + apps/kimi-code/test/tui/config.test.ts | 16 +++ .../test/tui/create-tui-state.test.ts | 1 + .../test/tui/kimi-tui-message-flow.test.ts | 1 + .../test/tui/kimi-tui-startup.test.ts | 1 + .../kimi-code/test/tui/message-replay.test.ts | 1 + .../test/tui/signal-handlers.test.ts | 1 + .../tui/utils/status-line-command.test.ts | 51 +++++++ docs/en/configuration/config-files.md | 29 ++++ docs/zh/configuration/config-files.md | 29 ++++ 21 files changed, 476 insertions(+), 14 deletions(-) create mode 100644 .changeset/status-line-command.md create mode 100644 apps/kimi-code/src/tui/utils/status-line-command.ts create mode 100644 apps/kimi-code/test/tui/utils/status-line-command.test.ts diff --git a/.changeset/status-line-command.md b/.changeset/status-line-command.md new file mode 100644 index 0000000000..8c6896cdc3 --- /dev/null +++ b/.changeset/status-line-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a TUI status line command for custom footer output. Set `[status_line].command` in `tui.toml` to use it. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9d95974193..16c54bc406 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -42,6 +42,7 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig { disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, + statusLine: host.state.appState.statusLine, }; } diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 6f28a16da5..200125ba0d 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -48,6 +48,7 @@ export async function applyReloadedTuiConfig( disablePasteBurst: config.disablePasteBurst, notifications: config.notifications, upgrade: config.upgrade, + statusLine: config.statusLine, }); host.state.editor.setDisablePasteBurst(config.disablePasteBurst); } diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index b91193b530..8fe4d99fd2 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -16,6 +16,10 @@ import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/danc import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; +import { + runStatusLineCommand, + type StatusLineCommandPayload, +} from '#/tui/utils/status-line-command'; import { createGitStatusCache, formatGitBadgeBase, @@ -27,6 +31,7 @@ import { safeUsageRatio } from '#/utils/usage/usage-format'; const MAX_CWD_SEGMENTS = 3; const GOAL_TIMER_INTERVAL_MS = 1_000; +const STATUS_LINE_REFRESH_INTERVAL_MS = 1_000; // Toolbar tips — rotates every 10s. Most tips are short and pair up (two // joined by " | ") when space allows; tips flagged `solo` are long or @@ -188,6 +193,12 @@ export class FooterComponent implements Component { private gitCache: GitStatusCache; private gitCacheWorkDir: string; private transientHint: string | null = null; + private statusLineText: string | null = null; + private statusLineInFlight = false; + private statusLineTimer: ReturnType | null = null; + private statusLineCommand: string | null = null; + private statusLineGeneration = 0; + private disposed = false; private goalSnapshotKey: string | null = null; private goalObservedAtMs = Date.now(); private goalTimer: ReturnType | null = null; @@ -208,6 +219,7 @@ export class FooterComponent implements Component { this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); + this.syncStatusLineTimer(state.statusLine.command); } setState(state: AppState): void { @@ -218,6 +230,7 @@ export class FooterComponent implements Component { this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); this.state = state; + this.syncStatusLineTimer(state.statusLine.command); } /** @@ -332,29 +345,30 @@ export class FooterComponent implements Component { line1 = truncateToWidth(leftLine, width, '…'); } - // ── Line 2: transient hint (bottom-left) + context (right) ── - const contextText = formatContextStatus( - state.contextUsage, - state.contextTokens, - state.maxContextTokens, - ); - const contextWidth = visibleWidth(contextText); + // ── Line 2: transient hint (bottom-left) + status line (right) ── + const statusLineText = + this.statusLineText ?? + formatContextStatus(state.contextUsage, state.contextTokens, state.maxContextTokens); + const renderedStatusLine = + this.statusLineText === null ? chalk.hex(colors.text)(statusLineText) : statusLineText; let line2: string; if (this.transientHint) { - const maxHintWidth = Math.max(0, width - contextWidth - 1); + const statusLineWidth = visibleWidth(statusLineText); + const maxHintWidth = Math.max(0, width - statusLineWidth - 1); const shownHint = visibleWidth(this.transientHint) <= maxHintWidth ? this.transientHint : truncateToWidth(this.transientHint, maxHintWidth, '…'); const hintWidth = visibleWidth(shownHint); - const pad = Math.max(0, width - hintWidth - contextWidth); + const pad = Math.max(0, width - hintWidth - statusLineWidth); line2 = chalk.hex(colors.warning).bold(shownHint) + ' '.repeat(pad) + - chalk.hex(colors.text)(contextText); + renderedStatusLine; } else { - const leftPad = Math.max(0, width - contextWidth); - line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + const statusLineWidth = visibleWidth(statusLineText); + const leftPad = Math.max(0, width - statusLineWidth); + line2 = ' '.repeat(leftPad) + renderedStatusLine; } return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; @@ -383,11 +397,93 @@ export class FooterComponent implements Component { } } + private syncStatusLineTimer(command: string | null): void { + if (command !== this.statusLineCommand) { + this.statusLineCommand = command; + this.statusLineGeneration += 1; + this.statusLineText = null; + } + + if (command !== null) { + if (this.statusLineTimer === null) { + this.statusLineTimer = setInterval(() => { + void this.refreshStatusLine(); + }, STATUS_LINE_REFRESH_INTERVAL_MS); + this.statusLineTimer.unref?.(); + } + void this.refreshStatusLine(); + return; + } + + if (this.statusLineTimer !== null) { + clearInterval(this.statusLineTimer); + this.statusLineTimer = null; + } + } + + private async refreshStatusLine(): Promise { + const { command, timeoutMs } = this.state.statusLine; + if (command === null || this.statusLineInFlight) return; + const generation = this.statusLineGeneration; + this.statusLineInFlight = true; + let text: string | null = null; + try { + text = await runStatusLineCommand({ + command, + timeoutMs, + payload: this.createStatusLinePayload(), + }); + } catch { + text = null; + } finally { + this.statusLineInFlight = false; + } + + if ( + this.disposed || + generation !== this.statusLineGeneration || + this.state.statusLine.command !== command + ) { + if (!this.disposed && this.state.statusLine.command !== null) { + void this.refreshStatusLine(); + } + return; + } + + this.statusLineText = text; + this.onRefresh(); + } + + private createStatusLinePayload(): StatusLineCommandPayload { + const state = this.state; + return { + session_id: state.sessionId, + model: state.model, + display_model: modelDisplayName(state), + cwd: state.workDir, + permission_mode: state.permissionMode, + plan_mode: state.planMode, + input_mode: state.inputMode, + swarm_mode: state.swarmMode, + thinking_effort: state.thinkingEffort, + context: { + usage: safeUsage(state.contextUsage), + tokens: state.contextTokens, + max_tokens: state.maxContextTokens, + }, + }; + } + dispose(): void { + this.disposed = true; if (this.goalTimer !== null) { clearInterval(this.goalTimer); this.goalTimer = null; } + if (this.statusLineTimer !== null) { + clearInterval(this.statusLineTimer); + this.statusLineTimer = null; + } } private goalWallClockMs(goal: AppState['goal']): number | undefined { diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index cd3329b552..f167f77c56 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -30,6 +30,11 @@ export const UpgradePreferencesSchema = z.object({ autoInstall: z.boolean(), }); +export const StatusLineConfigSchema = z.object({ + command: z.string().nullable(), + timeoutMs: z.number().int().positive(), +}); + export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), disable_paste_burst: z.boolean().optional(), @@ -49,6 +54,12 @@ export const TuiConfigFileSchema = z.object({ auto_install: z.boolean().optional(), }) .optional(), + status_line: z + .object({ + command: z.string().optional(), + timeout_ms: z.number().int().positive().optional(), + }) + .optional(), }); export const TuiConfigSchema = z.object({ @@ -57,12 +68,14 @@ export const TuiConfigSchema = z.object({ editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, + statusLine: StatusLineConfigSchema, }); export type TuiConfigFileShape = z.infer; export type TuiConfig = z.infer; export type NotificationsConfig = z.infer; export type UpgradePreferences = z.infer; +export type StatusLineConfig = z.infer; export const DEFAULT_NOTIFICATIONS_CONFIG: NotificationsConfig = { enabled: true, @@ -73,12 +86,18 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { autoInstall: true, }; +export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { + command: null, + timeoutMs: 200, +}; + export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', disablePasteBurst: false, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, + statusLine: DEFAULT_STATUS_LINE_CONFIG, }); /** @@ -133,6 +152,7 @@ export async function saveTuiConfig( export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { const command = config.editor?.command?.trim(); + const statusLineCommand = config.status_line?.command?.trim(); return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, @@ -145,6 +165,13 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { upgrade: { autoInstall: config.upgrade?.auto_install ?? DEFAULT_UPGRADE_PREFERENCES.autoInstall, }, + statusLine: { + command: + statusLineCommand === undefined || statusLineCommand.length === 0 + ? null + : statusLineCommand, + timeoutMs: config.status_line?.timeout_ms ?? DEFAULT_STATUS_LINE_CONFIG.timeoutMs, + }, }); } @@ -165,6 +192,10 @@ notification_condition = "${config.notifications.condition}" # "unfocused" | "al [upgrade] auto_install = ${String(config.upgrade.autoInstall)} # true | false + +[status_line] +command = "${escapeTomlBasicString(config.statusLine.command ?? '')}" # External command; empty disables custom status line +timeout_ms = ${String(config.statusLine.timeoutMs)} # Command timeout in milliseconds `; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 62d1b2370b..91ad2e6747 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -223,6 +223,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { disablePasteBurst: input.tuiConfig.disablePasteBurst, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, + statusLine: input.tuiConfig.statusLine, availableModels: {}, availableProviders: {}, sessionTitle: null, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6dcdccdd1a..b87924e699 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -9,7 +9,7 @@ import type { ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; -import type { NotificationsConfig, UpgradePreferences } from './config'; +import type { NotificationsConfig, StatusLineConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -52,6 +52,7 @@ export interface AppState { disablePasteBurst?: boolean; notifications: NotificationsConfig; upgrade: UpgradePreferences; + statusLine: StatusLineConfig; availableModels: Record; availableProviders: Record; sessionTitle: string | null; diff --git a/apps/kimi-code/src/tui/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts new file mode 100644 index 0000000000..34fa8e194a --- /dev/null +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -0,0 +1,69 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; + +export interface StatusLineCommandPayload { + readonly session_id: string; + readonly model: string; + readonly display_model: string; + readonly cwd: string; + readonly permission_mode: string; + readonly plan_mode: boolean; + readonly input_mode: string; + readonly swarm_mode: boolean; + readonly thinking_effort: string; + readonly context: { + readonly usage: number; + readonly tokens: number; + readonly max_tokens: number; + }; +} + +export interface RunStatusLineCommandOptions { + readonly command: string; + readonly timeoutMs: number; + readonly payload: StatusLineCommandPayload; +} + +export async function runStatusLineCommand({ + command, + timeoutMs, + payload, +}: RunStatusLineCommandOptions): Promise { + const child = spawn(command, { + shell: true, + stdio: ['pipe', 'pipe', 'ignore'], + }); + + let stdout = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + + child.stdin.on('error', () => {}); + child.stdin.end(`${JSON.stringify(payload)}\n`); + + const timeout = new Promise<'timeout'>((resolve) => { + const timer = setTimeout(() => { + resolve('timeout'); + }, timeoutMs); + timer.unref?.(); + }); + const closed = once(child, 'close').then(([code]) => ({ + kind: 'close' as const, + code: typeof code === 'number' ? code : null, + })); + const errored = once(child, 'error').then(() => ({ kind: 'error' as const })); + const result = await Promise.race([closed, errored, timeout]); + + if (result === 'timeout') { + child.kill(); + return null; + } + if (result.kind === 'error' || result.code !== 0) { + return null; + } + + const line = stdout.trimEnd().split(/\r?\n/, 1)[0]?.trimEnd() ?? ''; + return line.length > 0 ? line : null; +} diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 4bc79289b2..d1c18cc9f1 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -165,6 +165,7 @@ function tuiConfig(overrides: Partial = {}): TuiConfig { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, ...overrides, }; } diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index 0bcae749a5..cfb1bf59ea 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -33,6 +33,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 2fe6f3e52e..a16bef5188 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; @@ -7,6 +7,12 @@ import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; +const statusLineMocks = vi.hoisted(() => ({ + runStatusLineCommand: vi.fn(), +})); + +vi.mock('#/tui/utils/status-line-command', () => statusLineMocks); + const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; function truecolorCodes(text: string): Set { @@ -17,6 +23,14 @@ function truecolorCodes(text: string): Set { return codes; } +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + // Dark dance colors the footer never uses outside of /dance. const RAINBOW_CYAN = '91,192,190'; const RAINBOW_GREEN = '78,200,126'; @@ -55,6 +69,7 @@ const appState: AppState = { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, availableModels: {}, availableProviders: {}, mcpServersSummary: null, @@ -65,6 +80,7 @@ describe('FooterComponent', () => { beforeEach(() => { chalk.level = 3; + statusLineMocks.runStatusLineCommand.mockReset(); }); afterEach(() => { @@ -187,3 +203,111 @@ describe('FooterComponent displayName override', () => { expect(footer.render(120).join('\n')).not.toContain('Remote Name'); }); }); + +describe('FooterComponent status line command', () => { + it('uses the command output on the second line when configured', async () => { + statusLineMocks.runStatusLineCommand.mockResolvedValue('model: kimi-k2 | dir: project'); + const onRefresh = vi.fn(); + const footer = new FooterComponent( + { + ...appState, + statusLine: { command: 'kimi-hud', timeoutMs: 250 }, + }, + onRefresh, + ); + + await vi.waitFor(() => { + expect(onRefresh).toHaveBeenCalled(); + }); + + const rendered = footer.render(120).join('\n'); + expect(rendered).toContain('model: kimi-k2 | dir: project'); + expect(rendered).not.toContain('context: 0.0%'); + expect(statusLineMocks.runStatusLineCommand).toHaveBeenCalledWith({ + command: 'kimi-hud', + timeoutMs: 250, + payload: expect.objectContaining({ + session_id: 'ses-1', + model: 'kimi-k2', + display_model: 'kimi-k2', + cwd: '/tmp/project', + permission_mode: 'manual', + }), + }); + + footer.dispose(); + }); + + it('falls back to the built-in context line when the command returns no output', async () => { + statusLineMocks.runStatusLineCommand.mockResolvedValue(null); + const onRefresh = vi.fn(); + const footer = new FooterComponent( + { + ...appState, + statusLine: { command: 'kimi-hud', timeoutMs: 250 }, + }, + onRefresh, + ); + + await vi.waitFor(() => { + expect(onRefresh).toHaveBeenCalled(); + }); + + expect(footer.render(120).join('\n')).toContain('context: 0.0%'); + + footer.dispose(); + }); + + it('ignores stale command output after the status line is disabled', async () => { + const first = deferred(); + statusLineMocks.runStatusLineCommand.mockReturnValue(first.promise); + const footer = new FooterComponent({ + ...appState, + statusLine: { command: 'kimi-hud', timeoutMs: 250 }, + }); + + footer.setState({ ...appState, statusLine: { command: null, timeoutMs: 250 } }); + first.resolve('stale hud'); + await first.promise; + await Promise.resolve(); + + const rendered = footer.render(120).join('\n'); + expect(rendered).toContain('context: 0.0%'); + expect(rendered).not.toContain('stale hud'); + + footer.dispose(); + }); + + it('ignores stale output after switching to another status line command', async () => { + const first = deferred(); + const second = deferred(); + statusLineMocks.runStatusLineCommand.mockImplementation( + ({ command }: { command: string }) => + command === 'kimi-hud-a' ? first.promise : second.promise, + ); + const onRefresh = vi.fn(); + const footer = new FooterComponent( + { + ...appState, + statusLine: { command: 'kimi-hud-a', timeoutMs: 250 }, + }, + onRefresh, + ); + + footer.setState({ ...appState, statusLine: { command: 'kimi-hud-b', timeoutMs: 250 } }); + first.resolve('old hud'); + await first.promise; + await Promise.resolve(); + await Promise.resolve(); + second.resolve('new hud'); + await second.promise; + await Promise.resolve(); + await Promise.resolve(); + + const rendered = footer.render(120).join('\n'); + expect(rendered).toContain('new hud'); + expect(rendered).not.toContain('old hud'); + + footer.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index bc1b754fb6..c03344c362 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -32,6 +32,7 @@ const appState: AppState = { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, availableModels: {}, availableProviders: {}, mcpServersSummary: null, diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index c4c5035cdb..7086e6035e 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -37,6 +37,8 @@ describe('TUI config', () => { expect(text).toContain('command = ""'); expect(text).toContain('[upgrade]'); expect(text).toContain('auto_install = true'); + expect(text).toContain('[status_line]'); + expect(text).toContain('timeout_ms = 200'); expect(text).toContain('[notifications]'); expect(text).toContain('enabled = true'); expect(text).toContain('notification_condition = "unfocused"'); @@ -55,6 +57,10 @@ notification_condition = "always" [upgrade] auto_install = false + +[status_line] +command = "kimi-hud" +timeout_ms = 500 `); expect(config).toEqual({ @@ -63,6 +69,7 @@ auto_install = false editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { command: 'kimi-hud', timeoutMs: 500 }, }); }); @@ -87,6 +94,7 @@ command = " " editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, }); }); @@ -95,6 +103,7 @@ command = " " expect(config.notifications).toEqual({ enabled: true, condition: 'unfocused' }); expect(config.upgrade).toEqual({ autoInstall: true }); + expect(config.statusLine).toEqual({ command: null, timeoutMs: 200 }); }); it('throws TuiConfigParseError with fallback when parsing fails, leaving the file untouched', async () => { @@ -119,6 +128,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { command: 'kimi-hud', timeoutMs: 300 }, }, filePath, ); @@ -129,6 +139,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { command: 'kimi-hud', timeoutMs: 300 }, }); }); @@ -141,10 +152,15 @@ command = " " editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, + statusLine: { + command: 'status"line\\cmd', + timeoutMs: DEFAULT_TUI_CONFIG.statusLine.timeoutMs, + }, }, filePath, ); expect((await loadTuiConfig(filePath)).theme).toBe(theme); + expect((await loadTuiConfig(filePath)).statusLine.command).toBe('status"line\\cmd'); }); }); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 0899cf0702..f3cca303fc 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -27,6 +27,7 @@ function fakeInitialAppState(): AppState { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, availableModels: {}, availableProviders: {}, sessionTitle: null, 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 a014a45860..f0f178de1d 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 @@ -132,6 +132,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 79a36d70f2..b2ebbf4bea 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -92,6 +92,7 @@ function makeStartupInput( editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, ...tuiConfig, }, version: '0.0.0-test', diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 5e4e11670f..eaf9f91977 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -56,6 +56,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 92a91e0633..ab52400177 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -29,6 +29,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { command: null, timeoutMs: 200 }, }, version: '0.0.0-test', workDir: '/tmp/proj-signals', diff --git a/apps/kimi-code/test/tui/utils/status-line-command.test.ts b/apps/kimi-code/test/tui/utils/status-line-command.test.ts new file mode 100644 index 0000000000..48cee802d9 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/status-line-command.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { + runStatusLineCommand, + type StatusLineCommandPayload, +} from '#/tui/utils/status-line-command'; + +const payload: StatusLineCommandPayload = { + session_id: 'ses-1', + model: 'kimi-k2', + display_model: 'Kimi K2', + cwd: '/tmp/project', + permission_mode: 'manual', + plan_mode: false, + input_mode: 'prompt', + swarm_mode: false, + thinking_effort: 'off', + context: { + usage: 0.25, + tokens: 1000, + max_tokens: 4000, + }, +}; + +function nodeCommand(script: string): string { + return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; +} + +describe('runStatusLineCommand', () => { + it('passes the payload on stdin and returns the first stdout line', async () => { + const result = await runStatusLineCommand({ + command: nodeCommand( + "let input='';process.stdin.on('data',(chunk)=>input+=chunk);process.stdin.on('end',()=>{const payload=JSON.parse(input);console.log(payload.model+' @ '+payload.cwd);console.log('ignored');});", + ), + timeoutMs: 500, + payload, + }); + + expect(result).toBe('kimi-k2 @ /tmp/project'); + }); + + it('returns null when the command times out', async () => { + const result = await runStatusLineCommand({ + command: nodeCommand('setTimeout(() => {}, 1000);'), + timeoutMs: 10, + payload, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 303886e360..b95f9ffb52 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -292,6 +292,8 @@ 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].command` | `string` | `""` | External command used to render the second footer line; empty uses the built-in context indicator | +| `[status_line].timeout_ms` | `integer` | `200` | Maximum time to wait for the status-line command | ```toml # ~/.kimi-code/tui.toml @@ -307,6 +309,33 @@ notification_condition = "unfocused" # "unfocused" | "always" [upgrade] auto_install = true + +[status_line] +command = "" # External command; empty uses the built-in context indicator +timeout_ms = 200 +``` + +When `[status_line].command` is set, Kimi Code runs the command about once per second, sends a JSON payload on stdin, and uses the first stdout line as the second footer line. If the command exits non-zero, times out, or prints no output, the built-in context indicator is shown instead. + +Example payload: + +```json +{ + "session_id": "ses_123", + "model": "kimi-k2", + "display_model": "Kimi K2", + "cwd": "/path/to/project", + "permission_mode": "manual", + "plan_mode": false, + "input_mode": "prompt", + "swarm_mode": false, + "thinking_effort": "off", + "context": { + "usage": 0.12, + "tokens": 31457, + "max_tokens": 262144 + } +} ``` Changes apply on the next start, or immediately with `/reload-tui` (which reloads only `tui.toml`); `/reload` reloads both `config.toml` and `tui.toml`. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 9cc6ace34f..6a0b3c24ba 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -292,6 +292,8 @@ 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].command` | `string` | `""` | 用来渲染第二行 footer 的外部命令;留空则使用内置上下文用量指示 | +| `[status_line].timeout_ms` | `integer` | `200` | 等待 status line 命令的最长时间 | ```toml # ~/.kimi-code/tui.toml @@ -307,6 +309,33 @@ notification_condition = "unfocused" # "unfocused" | "always" [upgrade] auto_install = true + +[status_line] +command = "" # 外部命令;留空则使用内置上下文用量指示 +timeout_ms = 200 +``` + +设置 `[status_line].command` 后,Kimi Code 会约每秒运行一次该命令,通过 stdin 传入 JSON,并把 stdout 的第一行作为第二行 footer 显示。命令非零退出、超时或没有输出时,会回退到内置上下文用量指示。 + +示例 payload: + +```json +{ + "session_id": "ses_123", + "model": "kimi-k2", + "display_model": "Kimi K2", + "cwd": "/path/to/project", + "permission_mode": "manual", + "plan_mode": false, + "input_mode": "prompt", + "swarm_mode": false, + "thinking_effort": "off", + "context": { + "usage": 0.12, + "tokens": 31457, + "max_tokens": 262144 + } +} ``` 修改在下次启动时生效,或用 `/reload-tui` 立即生效(只重载 `tui.toml`);`/reload` 会同时重载 `config.toml` 和 `tui.toml`。 From c5b01cfaf0b255da417d4fc14bcefc0650718406 Mon Sep 17 00:00:00 2001 From: yearthmain Date: Fri, 10 Jul 2026 10:17:49 +0800 Subject: [PATCH 2/2] fix(tui): throttle status line refresh --- .../src/tui/components/chrome/footer.ts | 21 +++++++++---- .../test/tui/components/chrome/footer.test.ts | 31 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 8fe4d99fd2..0d5a41e074 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -16,6 +16,7 @@ import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/danc import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; +import { DEFAULT_TUI_CONFIG, type StatusLineConfig } from '#/tui/config'; import { runStatusLineCommand, type StatusLineCommandPayload, @@ -219,7 +220,7 @@ export class FooterComponent implements Component { this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); - this.syncStatusLineTimer(state.statusLine.command); + this.syncStatusLineTimer(statusLineConfig(state).command); } setState(state: AppState): void { @@ -230,7 +231,7 @@ export class FooterComponent implements Component { this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); this.state = state; - this.syncStatusLineTimer(state.statusLine.command); + this.syncStatusLineTimer(statusLineConfig(state).command); } /** @@ -398,6 +399,7 @@ export class FooterComponent implements Component { } private syncStatusLineTimer(command: string | null): void { + const commandChanged = command !== this.statusLineCommand; if (command !== this.statusLineCommand) { this.statusLineCommand = command; this.statusLineGeneration += 1; @@ -405,13 +407,16 @@ export class FooterComponent implements Component { } if (command !== null) { + const timerCreated = this.statusLineTimer === null; if (this.statusLineTimer === null) { this.statusLineTimer = setInterval(() => { void this.refreshStatusLine(); }, STATUS_LINE_REFRESH_INTERVAL_MS); this.statusLineTimer.unref?.(); } - void this.refreshStatusLine(); + if (commandChanged || timerCreated) { + void this.refreshStatusLine(); + } return; } @@ -422,7 +427,7 @@ export class FooterComponent implements Component { } private async refreshStatusLine(): Promise { - const { command, timeoutMs } = this.state.statusLine; + const { command, timeoutMs } = statusLineConfig(this.state); if (command === null || this.statusLineInFlight) return; const generation = this.statusLineGeneration; this.statusLineInFlight = true; @@ -442,9 +447,9 @@ export class FooterComponent implements Component { if ( this.disposed || generation !== this.statusLineGeneration || - this.state.statusLine.command !== command + statusLineConfig(this.state).command !== command ) { - if (!this.disposed && this.state.statusLine.command !== null) { + if (!this.disposed && statusLineConfig(this.state).command !== null) { void this.refreshStatusLine(); } return; @@ -507,3 +512,7 @@ function goalSnapshotKey(goal: AppState['goal']): string | null { String(goal.budget.wallClockBudgetMs), ].join('\u0000'); } + +function statusLineConfig(state: AppState): StatusLineConfig { + return (state as Partial).statusLine ?? DEFAULT_TUI_CONFIG.statusLine; +} diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index a16bef5188..9ca4439e1e 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -205,6 +205,15 @@ describe('FooterComponent displayName override', () => { }); describe('FooterComponent status line command', () => { + it('falls back when older app state fixtures omit statusLine', () => { + const { statusLine: _statusLine, ...legacyState } = appState; + + const footer = new FooterComponent(legacyState as AppState); + + expect(footer.render(120).join('\n')).toContain('context: 0.0%'); + footer.dispose(); + }); + it('uses the command output on the second line when configured', async () => { statusLineMocks.runStatusLineCommand.mockResolvedValue('model: kimi-k2 | dir: project'); const onRefresh = vi.fn(); @@ -238,6 +247,28 @@ describe('FooterComponent status line command', () => { footer.dispose(); }); + it('does not refresh the external command on unrelated state updates', async () => { + statusLineMocks.runStatusLineCommand.mockResolvedValue('model: kimi-k2 | dir: project'); + const footer = new FooterComponent({ + ...appState, + statusLine: { command: 'kimi-hud', timeoutMs: 250 }, + }); + + await vi.waitFor(() => { + expect(statusLineMocks.runStatusLineCommand).toHaveBeenCalled(); + }); + statusLineMocks.runStatusLineCommand.mockClear(); + + footer.setState({ + ...appState, + contextUsage: 0.5, + statusLine: { command: 'kimi-hud', timeoutMs: 250 }, + }); + + expect(statusLineMocks.runStatusLineCommand).not.toHaveBeenCalled(); + footer.dispose(); + }); + it('falls back to the built-in context line when the command returns no output', async () => { statusLineMocks.runStatusLineCommand.mockResolvedValue(null); const onRefresh = vi.fn();