From be0805dab74774faaf7edaab3d1643c5ce909dbc Mon Sep 17 00:00:00 2001 From: yichensunjoe <163158633+yichensunjoe@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:07:48 +0800 Subject: [PATCH] feat(tui): add scriptable statusline command to the footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in [statusline] section to tui.toml (command / interval_ms / timeout_ms). When configured, the TUI runs the command periodically with the session context (session id, version, model, working directory, permission mode, plan mode, context token usage) as JSON on stdin, and renders the first stdout line — ANSI SGR colors passed through — as a third footer line. An empty command disables the feature (the default); failed or timed-out runs silently keep the last successful output, and nothing is rendered before the first success. /reload-tui picks up config changes. Refs #1954 --- .changeset/tui-statusline-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 | 73 +++++++- apps/kimi-code/src/tui/config.ts | 47 +++++ apps/kimi-code/src/tui/kimi-tui.ts | 1 + apps/kimi-code/src/tui/types.ts | 4 +- .../utils/statusline/status-line-runner.ts | 162 ++++++++++++++++++ .../test/cli/update/preflight.test.ts | 23 ++- apps/kimi-code/test/tui/activity-pane.test.ts | 2 + .../tui/commands/update-preferences.test.ts | 3 + .../test/tui/components/chrome/footer.test.ts | 2 + .../tui/components/chrome/welcome.test.ts | 2 + .../panels/footer-bg-agents.test.ts | 2 + .../components/panels/footer-context.test.ts | 2 + .../panels/footer-goal-badge.test.ts | 2 + .../panels/footer-statusline.test.ts | 139 +++++++++++++++ apps/kimi-code/test/tui/config.test.ts | 68 ++++++++ .../test/tui/create-tui-state.test.ts | 2 + .../test/tui/kimi-tui-message-flow.test.ts | 2 + .../test/tui/kimi-tui-startup.test.ts | 2 + .../kimi-code/test/tui/message-replay.test.ts | 2 + .../test/tui/signal-handlers.test.ts | 2 + .../statusline/status-line-runner.test.ts | 113 ++++++++++++ docs/en/configuration/config-files.md | 10 ++ docs/zh/configuration/config-files.md | 10 ++ 26 files changed, 668 insertions(+), 14 deletions(-) create mode 100644 .changeset/tui-statusline-command.md create mode 100644 apps/kimi-code/src/utils/statusline/status-line-runner.ts create mode 100644 apps/kimi-code/test/tui/components/panels/footer-statusline.test.ts create mode 100644 apps/kimi-code/test/utils/statusline/status-line-runner.test.ts diff --git a/.changeset/tui-statusline-command.md b/.changeset/tui-statusline-command.md new file mode 100644 index 0000000000..f78274550d --- /dev/null +++ b/.changeset/tui-statusline-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a scriptable statusline to the TUI footer. Set `[statusline] command` in `tui.toml` to a shell command and the CLI runs it periodically (`interval_ms`, `timeout_ms` are configurable), passing the session context as JSON on stdin — session id, model, working directory, permission mode, plan mode, and context token usage. The first stdout line (ANSI SGR colors included) is rendered as a third footer line. An empty command disables the feature; failed runs silently keep the last successful output. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 58f1c27acb..8a17b41b0b 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -57,6 +57,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 a2e8d7adee..2a1dad3a28 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -4,6 +4,8 @@ * Layout: * Line 1: [yolo] [plan] * Line 2: context: N% (tokens/max) + * Line 3: optional statusline — first stdout line of the user-configured + * `[statusline] command`, ANSI SGR passed through */ import type { Component } from '@moonshot-ai/pi-tui'; @@ -15,6 +17,7 @@ import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; +import type { StatusLineConfig } from '#/tui/config'; import type { AppState } from '#/tui/types'; import { createGitStatusCache, @@ -23,6 +26,7 @@ import { type GitStatus, type GitStatusCache, } from '#/utils/git/git-status'; +import { StatusLineRunner } from '#/utils/statusline/status-line-runner'; import { formatTokenCount, usagePercent, @@ -141,6 +145,26 @@ function modelDisplayName(state: AppState): string { return effective?.displayName ?? effective?.model ?? state.model; } +/** + * Session context passed to the statusline command on stdin. Field names + * follow Claude Code's statusline contract so community scripts port over. + */ +function buildStatusLineInput(state: AppState): Record { + return { + session_id: state.sessionId, + version: state.version, + model: { id: state.model, display_name: modelDisplayName(state) }, + workspace: { current_dir: state.workDir }, + permission_mode: state.permissionMode, + plan_mode: state.planMode, + context: { + used_tokens: state.contextTokens, + max_tokens: state.maxContextTokens, + percent: usagePercent(state.contextTokens, state.maxContextTokens), + }, + }; +} + function shortenCwd(path: string): string { if (!path) return path; const home = process.env['HOME'] ?? ''; @@ -200,6 +224,8 @@ export class FooterComponent implements Component { */ private backgroundBashTaskCount = 0; private backgroundAgentCount = 0; + private statusLineRunner: StatusLineRunner | null = null; + private statusLineRunnerKey: string | null = null; constructor(state: AppState, onRefresh: () => void = () => {}) { this.state = state; @@ -208,6 +234,7 @@ export class FooterComponent implements Component { this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); + this.syncStatusLineRunner(state.statusLine); } setState(state: AppState): void { @@ -218,6 +245,10 @@ export class FooterComponent implements Component { this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); this.state = state; + // Sync after `this.state` is updated: the runner's getInput closure + // reads it for the stdin payload. Restarts only on config change + // (e.g. /reload-tui), leaving a running interval untouched otherwise. + this.syncStatusLineRunner(state.statusLine); } /** @@ -357,7 +388,13 @@ export class FooterComponent implements Component { line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); } - return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + // ── Line 3 (optional): statusline command output, ANSI passed through ── + const lines = [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + const statusLineOutput = this.statusLineRunner?.getOutput(); + if (statusLineOutput) { + lines.push(truncateToWidth(statusLineOutput, width)); + } + return lines; } private syncGoalClock(goal: AppState['goal']): void { @@ -388,6 +425,40 @@ export class FooterComponent implements Component { clearInterval(this.goalTimer); this.goalTimer = null; } + if (this.statusLineRunner !== null) { + this.statusLineRunner.stop(); + this.statusLineRunner = null; + this.statusLineRunnerKey = null; + } + } + + /** + * Keeps the statusline runner in sync with the configured command. + * A null/empty command stops the runner; any field change restarts it. + */ + private syncStatusLineRunner(config: StatusLineConfig): void { + const command = config.command; + if (command === null || command.length === 0) { + if (this.statusLineRunner !== null) { + this.statusLineRunner.stop(); + this.statusLineRunner = null; + this.statusLineRunnerKey = null; + } + return; + } + + const key = [command, String(config.intervalMs), String(config.timeoutMs)].join('\u0000'); + if (key === this.statusLineRunnerKey) return; + this.statusLineRunner?.stop(); + this.statusLineRunnerKey = key; + this.statusLineRunner = new StatusLineRunner({ + command, + intervalMs: config.intervalMs, + timeoutMs: config.timeoutMs, + getInput: () => buildStatusLineInput(this.state), + onChange: this.onRefresh, + }); + this.statusLineRunner.start(); } 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..2f8160d9ba 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -30,6 +30,15 @@ export const UpgradePreferencesSchema = z.object({ autoInstall: z.boolean(), }); +/** Lower bound for the statusline refresh period; tighter loops just fork spam. */ +export const STATUSLINE_MIN_INTERVAL_MS = 300; + +export const StatusLineConfigSchema = z.object({ + command: z.string().nullable(), + intervalMs: z.number().int().min(STATUSLINE_MIN_INTERVAL_MS), + timeoutMs: z.number().int().positive(), +}); + export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), disable_paste_burst: z.boolean().optional(), @@ -49,6 +58,13 @@ export const TuiConfigFileSchema = z.object({ auto_install: z.boolean().optional(), }) .optional(), + statusline: z + .object({ + command: z.string().optional(), + interval_ms: z.number().int().optional(), + timeout_ms: z.number().int().optional(), + }) + .optional(), }); export const TuiConfigSchema = z.object({ @@ -57,12 +73,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 +91,19 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { autoInstall: true, }; +export const DEFAULT_STATUSLINE_CONFIG: StatusLineConfig = { + command: null, + intervalMs: 2_000, + timeoutMs: 5_000, +}; + export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', disablePasteBurst: false, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, + statusLine: DEFAULT_STATUSLINE_CONFIG, }); /** @@ -133,6 +158,7 @@ export async function saveTuiConfig( export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { const command = config.editor?.command?.trim(); + const statusLineCommand = config.statusline?.command?.trim(); return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, @@ -145,6 +171,22 @@ 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, + // Clamp instead of rejecting: a typo in one preference should not + // discard the whole config file. + intervalMs: Math.max( + STATUSLINE_MIN_INTERVAL_MS, + config.statusline?.interval_ms ?? DEFAULT_STATUSLINE_CONFIG.intervalMs, + ), + timeoutMs: Math.max( + 1, + config.statusline?.timeout_ms ?? DEFAULT_STATUSLINE_CONFIG.timeoutMs, + ), + }, }); } @@ -165,6 +207,11 @@ notification_condition = "${config.notifications.condition}" # "unfocused" | "al [upgrade] auto_install = ${String(config.upgrade.autoInstall)} # true | false + +[statusline] +command = "${escapeTomlBasicString(config.statusLine.command ?? '')}" # Empty disables the statusline +interval_ms = ${String(config.statusLine.intervalMs)} # Refresh period; clamped to >= ${String(STATUSLINE_MIN_INTERVAL_MS)} +timeout_ms = ${String(config.statusLine.timeoutMs)} # Per-run timeout; the process is killed past it `; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 464c7237aa..2e7af0fa78 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -230,6 +230,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 d895b11e12..bf47e47ffb 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,8 @@ export interface AppState { disablePasteBurst?: boolean; notifications: NotificationsConfig; upgrade: UpgradePreferences; + /** External statusline command config; `command: null` disables the feature. */ + statusLine: StatusLineConfig; availableModels: Record; availableProviders: Record; sessionTitle: string | null; diff --git a/apps/kimi-code/src/utils/statusline/status-line-runner.ts b/apps/kimi-code/src/utils/statusline/status-line-runner.ts new file mode 100644 index 0000000000..f90038ac69 --- /dev/null +++ b/apps/kimi-code/src/utils/statusline/status-line-runner.ts @@ -0,0 +1,162 @@ +/** + * Periodic runner for the user-configured statusline command. + * + * Mirrors the footer-side conventions of `utils/git/git-status.ts` + * (`setInterval` + `unref()`), while the child-process handling follows + * `agent-core-v2`'s hook runner: `shell: true`, the session context JSON + * is written to stdin, a timeout escalates SIGTERM → SIGKILL, and stdout + * is capped so a runaway script cannot grow memory unboundedly. + * + * Only the first stdout line is kept. A failing run preserves the last + * successful output; before the first success `getOutput()` is `null` + * and the footer renders nothing. + */ + +import { spawn } from 'node:child_process'; + +const MAX_STDOUT_BYTES = 64 * 1024; +const KILL_GRACE_MS = 100; + +export interface StatusLineRunnerOptions { + readonly command: string; + readonly intervalMs: number; + readonly timeoutMs: number; + /** Builds the stdin JSON payload; called fresh on every run. */ + readonly getInput: () => Record; + /** Called after a successful run changed (or set) the output. */ + readonly onChange?: () => void; +} + +export class StatusLineRunner { + private readonly options: StatusLineRunnerOptions; + private output: string | null = null; + private timer: ReturnType | null = null; + private inFlight = false; + private generation = 0; + + constructor(options: StatusLineRunnerOptions) { + this.options = options; + } + + /** Runs once immediately, then on every `intervalMs` tick. */ + start(): void { + if (this.timer !== null) return; + const generation = this.generation; + void this.runOnce(generation); + this.timer = setInterval(() => { + void this.runOnce(generation); + }, this.options.intervalMs); + this.timer.unref?.(); + } + + stop(): void { + this.generation += 1; + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** Last successful first-line output; `null` until the first success. */ + getOutput(): string | null { + return this.output; + } + + /** + * Single execution, also exposed for tests. Skipped while a previous + * run is still in flight (a slow script must not stack up processes). + */ + async runOnce(generation: number = this.generation): Promise { + if (this.inFlight) return; + this.inFlight = true; + try { + const firstLine = await executeStatusLineCommand( + this.options.command, + JSON.stringify(this.options.getInput()), + this.options.timeoutMs, + ); + if (firstLine === null || generation !== this.generation) return; + const changed = this.output !== firstLine; + this.output = firstLine; + if (changed) this.options.onChange?.(); + } finally { + this.inFlight = false; + } + } +} + +/** + * Runs `command` via the shell with `inputJson` on stdin. Resolves to the + * first stdout line on exit code 0 (possibly the empty string), or `null` + * on spawn error, non-zero exit, or timeout. + */ +function executeStatusLineCommand( + command: string, + inputJson: string, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + let proc: ReturnType; + try { + proc = spawn(command, { + shell: true, + stdio: 'pipe', + windowsHide: true, + }); + } catch { + resolve(null); + return; + } + // `stdio: 'pipe'` guarantees all three streams exist. + const childStdout = proc.stdout!; + const childStderr = proc.stderr!; + const childStdin = proc.stdin!; + + let stdout = ''; + let settled = false; + + const settle = (result: string | null): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + // oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it + resolve(result); + }; + + childStdout.setEncoding('utf8'); + childStdout.on('data', (chunk: string) => { + // Keep consuming (so the child never blocks on a full pipe) but + // stop retaining past the cap. + if (stdout.length < MAX_STDOUT_BYTES) { + stdout = (stdout + chunk).slice(0, MAX_STDOUT_BYTES); + } + }); + childStderr.resume(); + + proc.on('error', () => { + settle(null); + }); + proc.on('close', (code) => { + settle(code === 0 ? firstLineOf(stdout) : null); + }); + + const timeout = setTimeout(() => { + proc.kill('SIGTERM'); + const killTimer = setTimeout(() => { + proc.kill('SIGKILL'); + }, KILL_GRACE_MS); + killTimer.unref(); + settle(null); + }, timeoutMs); + timeout.unref?.(); + + childStdin.on('error', () => {}); + childStdin.end(inputJson); + }); +} + +function firstLineOf(stdout: string): string { + const end = stdout.indexOf('\n'); + const line = end === -1 ? stdout : stdout.slice(0, end); + return line.endsWith('\r') ? line.slice(0, -1) : line; +} diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 2f7439f51b..1b44521d54 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -23,7 +23,7 @@ import { type UpdateInstallState, type UpdateManifest, } from '#/cli/update/types'; -import type { TuiConfig } from '#/tui/config'; +import { DEFAULT_STATUSLINE_CONFIG, type TuiConfig } from '#/tui/config'; const mocks = vi.hoisted(() => ({ readUpdateCache: vi.fn(), @@ -57,17 +57,15 @@ vi.mock('../../../src/cli/update/install-state', () => ({ writeUpdateInstallState: mocks.writeUpdateInstallState, })); -vi.mock('../../../src/tui/config', () => ({ - loadTuiConfig: mocks.loadTuiConfig, - TuiConfigParseError: class TuiConfigParseError extends Error { - readonly fallback: TuiConfig; - - constructor(fallback: TuiConfig) { - super('Invalid client preferences in ~/.kimi-code/tui.toml; using defaults.'); - this.fallback = fallback; - } - }, -})); +vi.mock('../../../src/tui/config', async () => { + const actual = await vi.importActual( + '../../../src/tui/config.js', + ); + return { + ...actual, + loadTuiConfig: mocks.loadTuiConfig, + }; +}); vi.mock('../../../src/cli/update/source', () => ({ detectInstallSource: mocks.detectInstallSource, @@ -165,6 +163,7 @@ function tuiConfig(overrides: Partial = {}): TuiConfig { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, ...overrides, }; } diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index 3d8b2ed382..d1b6ac9942 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AgentSwarmProgressComponent } from '#/tui/components/messages/agent-swarm-progress'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; @@ -35,6 +36,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, }, version: '0.0.0-test', workDir: '/tmp/proj-a', diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index b584c33d63..6c789f5335 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { applyUpdatePreferenceChoice } from '#/tui/commands/config'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import { darkColors } from '#/tui/theme/colors'; const mocks = vi.hoisted(() => ({ @@ -29,6 +30,7 @@ describe('update preference commands', () => { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' as const }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, }, theme: { palette: darkColors }, }, @@ -45,6 +47,7 @@ describe('update preference commands', () => { disablePasteBurst: false, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, + statusLine: DEFAULT_STATUSLINE_CONFIG, }); expect(setAppState).toHaveBeenCalledWith({ upgrade: { autoInstall: false } }); expect(track).toHaveBeenCalledWith('upgrade_preference_changed', { auto_install: false }); 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..43539e7059 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; @@ -55,6 +56,7 @@ const appState: AppState = { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, availableModels: {}, availableProviders: {}, mcpServersSummary: null, 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..b6093b2cf9 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -32,6 +33,7 @@ const appState: AppState = { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, availableModels: {}, availableProviders: {}, mcpServersSummary: null, diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index 101cd19116..66ab404497 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import type { AppState } from '#/tui/types'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -28,6 +29,7 @@ function baseState(overrides: Partial = {}): AppState { version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + statusLine: DEFAULT_STATUSLINE_CONFIG, availableModels: {}, ...overrides, } as AppState; diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index 100f5f6a3b..7eb9e7191d 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import chalk from 'chalk'; import { FooterComponent, formatFooterGitBadge, buildWeightedTips } from '#/tui/components/chrome/footer'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -38,6 +39,7 @@ function baseState(overrides: Partial = {}): AppState { version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + statusLine: DEFAULT_STATUSLINE_CONFIG, availableModels: {}, ...overrides, } as AppState; diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index 982be06579..4f2aac20ad 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; @@ -29,6 +30,7 @@ function baseState(overrides: Partial = {}): AppState { version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, + statusLine: DEFAULT_STATUSLINE_CONFIG, availableModels: {}, ...overrides, } as AppState; diff --git a/apps/kimi-code/test/tui/components/panels/footer-statusline.test.ts b/apps/kimi-code/test/tui/components/panels/footer-statusline.test.ts new file mode 100644 index 0000000000..31f4209166 --- /dev/null +++ b/apps/kimi-code/test/tui/components/panels/footer-statusline.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import { FooterComponent } from '#/tui/components/chrome/footer'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; +import type { AppState } from '#/tui/types'; + +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +const NODE = JSON.stringify(process.execPath); +function nodeCommand(script: string): string { + return `${NODE} -e ${JSON.stringify(script)}`; +} + +const ECHO_CONTEXT_SCRIPT = `let d='';process.stdin.on('data',(c)=>{d+=c}).on('end',()=>{const j=JSON.parse(d);process.stdout.write([j.session_id,j.model.id,j.workspace.current_dir,j.permission_mode,String(j.context.percent)].join('|'))})`; + +function baseState(overrides: Partial = {}): AppState { + return { + model: 'k2', + workDir: '/tmp/proj', + additionalDirs: [], + sessionId: 'sess_1', + permissionMode: 'manual', + planMode: false, + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: 'dark', + version: 'test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + statusLine: DEFAULT_STATUSLINE_CONFIG, + availableModels: {}, + ...overrides, + } as AppState; +} + +function statusLineConfig(command: string): AppState['statusLine'] { + return { command, intervalMs: 60_000, timeoutMs: 5_000 }; +} + +async function waitForLines(footer: FooterComponent, count: number): Promise { + const deadline = Date.now() + 5_000; + let lines = footer.render(120); + while (lines.length < count && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + lines = footer.render(120); + } + return lines; +} + +async function settle(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('FooterComponent — statusline', () => { + it('renders only two lines when no statusline command is configured', () => { + const footer = new FooterComponent(baseState()); + try { + expect(footer.render(120)).toHaveLength(2); + } finally { + footer.dispose(); + } + }); + + it('appends the command output as a third line with ANSI passed through', async () => { + const footer = new FooterComponent( + baseState({ + statusLine: statusLineConfig( + nodeCommand(`process.stdout.write('\\u001B[31mred-status\\u001B[0m')`), + ), + }), + ); + try { + const lines = await waitForLines(footer, 3); + + expect(lines).toHaveLength(3); + // Raw SGR from the script survives; the footer adds no colouring. + expect(lines[2]).toContain(''); + expect(strip(lines[2] ?? '')).toContain('red-status'); + } finally { + footer.dispose(); + } + }); + + it('sends the session context as stdin JSON on every run', async () => { + const footer = new FooterComponent( + baseState({ + permissionMode: 'yolo', + contextTokens: 50, + maxContextTokens: 100, + statusLine: statusLineConfig(nodeCommand(ECHO_CONTEXT_SCRIPT)), + }), + ); + try { + const lines = await waitForLines(footer, 3); + + expect(strip(lines[2] ?? '')).toBe('sess_1|k2|/tmp/proj|yolo|50'); + } finally { + footer.dispose(); + } + }); + + it('keeps two lines when the command never succeeds', async () => { + const footer = new FooterComponent( + baseState({ statusLine: statusLineConfig(nodeCommand(`process.exit(1)`)) }), + ); + try { + await settle(500); + + expect(footer.render(120)).toHaveLength(2); + } finally { + footer.dispose(); + } + }); + + it('starts the runner when setState introduces a statusline config', async () => { + const footer = new FooterComponent(baseState()); + try { + expect(footer.render(120)).toHaveLength(2); + + footer.setState( + baseState({ statusLine: statusLineConfig(nodeCommand(`process.stdout.write('hot')`)) }), + ); + + const lines = await waitForLines(footer, 3); + expect(strip(lines[2] ?? '')).toBe('hot'); + } finally { + footer.dispose(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index c4c5035cdb..9d0fe42117 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + DEFAULT_STATUSLINE_CONFIG, DEFAULT_TUI_CONFIG, INVALID_TUI_CONFIG_MESSAGE, loadTuiConfig, @@ -40,6 +41,8 @@ describe('TUI config', () => { expect(text).toContain('[notifications]'); expect(text).toContain('enabled = true'); expect(text).toContain('notification_condition = "unfocused"'); + expect(text).toContain('[statusline]'); + expect(text).toContain('interval_ms = 2000'); }); it('parses valid TOML', () => { @@ -63,6 +66,7 @@ auto_install = false editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: DEFAULT_STATUSLINE_CONFIG, }); }); @@ -87,6 +91,7 @@ command = " " editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, }); }); @@ -119,6 +124,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { command: '/tmp/statusline.sh', intervalMs: 1_000, timeoutMs: 3_000 }, }, filePath, ); @@ -129,6 +135,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { command: '/tmp/statusline.sh', intervalMs: 1_000, timeoutMs: 3_000 }, }); }); @@ -141,10 +148,71 @@ command = " " editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, + statusLine: DEFAULT_TUI_CONFIG.statusLine, }, filePath, ); expect((await loadTuiConfig(filePath)).theme).toBe(theme); }); + + it('parses the [statusline] section', () => { + const config = parseTuiConfig(` +[statusline] +command = "/path/to/statusline.sh" +interval_ms = 1500 +timeout_ms = 8000 +`); + + expect(config.statusLine).toEqual({ + command: '/path/to/statusline.sh', + intervalMs: 1500, + timeoutMs: 8000, + }); + }); + + it('defaults to a disabled statusline when the section is omitted', () => { + const config = parseTuiConfig(`theme = "dark"`); + + expect(config.statusLine).toEqual(DEFAULT_STATUSLINE_CONFIG); + expect(config.statusLine.command).toBeNull(); + }); + + it('normalizes an empty statusline command to disabled', () => { + const config = parseTuiConfig(` +[statusline] +command = " " +`); + + expect(config.statusLine.command).toBeNull(); + }); + + it('clamps a too-small statusline interval instead of rejecting the config', () => { + const config = parseTuiConfig(` +[statusline] +command = "x" +interval_ms = 50 +`); + + expect(config.statusLine.intervalMs).toBe(300); + }); + + it('round-trips the [statusline] section through save and load', async () => { + await saveTuiConfig( + { + ...DEFAULT_TUI_CONFIG, + statusLine: { command: 'echo hi', intervalMs: 2_500, timeoutMs: 4_000 }, + }, + filePath, + ); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('[statusline]'); + expect(text).toContain('interval_ms = 2500'); + expect((await loadTuiConfig(filePath)).statusLine).toEqual({ + command: 'echo hi', + intervalMs: 2_500, + timeoutMs: 4_000, + }); + }); }); 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..df2e29b4fe 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { createTUIState, type KimiTUIOptions } from '#/tui/kimi-tui'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import type { AppState } from '#/tui/types'; function fakeInitialAppState(): AppState { @@ -27,6 +28,7 @@ function fakeInitialAppState(): AppState { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, 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 9fe501d9e0..fb36a85122 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 @@ -14,6 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering'; import { AgentSwarmProgressComponent, @@ -144,6 +145,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, }, 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 79f8cfb552..e2a44c2684 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -12,6 +12,7 @@ import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import { REPLAY_TURN_LIMIT } from '#/tui/utils/message-replay'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; @@ -95,6 +96,7 @@ function makeStartupInput( editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, ...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 20d825ce04..986dc66bdf 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -14,6 +14,7 @@ import type { import { describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; @@ -65,6 +66,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, }, 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 91dc5beacf..0c44b78591 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { DEFAULT_STATUSLINE_CONFIG } from '#/tui/config'; interface SignalDriver { state: TUIState; @@ -31,6 +32,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: DEFAULT_STATUSLINE_CONFIG, }, version: '0.0.0-test', workDir: '/tmp/proj-signals', diff --git a/apps/kimi-code/test/utils/statusline/status-line-runner.test.ts b/apps/kimi-code/test/utils/statusline/status-line-runner.test.ts new file mode 100644 index 0000000000..f8e19dc3c9 --- /dev/null +++ b/apps/kimi-code/test/utils/statusline/status-line-runner.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { StatusLineRunner } from '#/utils/statusline/status-line-runner'; + +const NODE = JSON.stringify(process.execPath); + +function nodeCommand(script: string): string { + return `${NODE} -e ${JSON.stringify(script)}`; +} + +function makeRunner(command: string, onChange?: () => void): StatusLineRunner { + return new StatusLineRunner({ + command, + intervalMs: 60_000, + timeoutMs: 5_000, + getInput: () => ({ session_id: 'sess_1' }), + onChange, + }); +} + +describe('StatusLineRunner', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-statusline-runner-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('captures the first stdout line of a successful run', async () => { + const runner = makeRunner(nodeCommand(`process.stdout.write('hello statusline')`)); + + await runner.runOnce(); + + expect(runner.getOutput()).toBe('hello statusline'); + }); + + it('keeps only the first line of multi-line output', async () => { + const runner = makeRunner(nodeCommand(`process.stdout.write('first\\nsecond\\n')`)); + + await runner.runOnce(); + + expect(runner.getOutput()).toBe('first'); + }); + + it('passes the getInput payload as stdin JSON', async () => { + const runner = makeRunner( + nodeCommand( + `let d='';process.stdin.on('data',(c)=>{d+=c}).on('end',()=>process.stdout.write(JSON.parse(d).session_id))`, + ), + ); + + await runner.runOnce(); + + expect(runner.getOutput()).toBe('sess_1'); + }); + + it('kills the command on timeout and reports no output', async () => { + const runner = new StatusLineRunner({ + command: nodeCommand(`setTimeout(() => {}, 60_000)`), + intervalMs: 60_000, + timeoutMs: 200, + getInput: () => ({}), + }); + const startedAt = Date.now(); + + await runner.runOnce(); + + expect(runner.getOutput()).toBeNull(); + expect(Date.now() - startedAt).toBeLessThan(5_000); + }); + + it('preserves the last successful output after a failing run', async () => { + const marker = join(dir, 'marker'); + // First run succeeds and creates the marker; later runs exit non-zero. + const runner = makeRunner( + nodeCommand( + `const fs=require('fs');if(fs.existsSync(${JSON.stringify(marker)})){process.exit(1)}fs.writeFileSync(${JSON.stringify(marker)},'1');process.stdout.write('good')`, + ), + ); + + await runner.runOnce(); + expect(runner.getOutput()).toBe('good'); + await runner.runOnce(); + expect(runner.getOutput()).toBe('good'); + }); + + it('stays silent when the command never succeeds', async () => { + const runner = makeRunner(nodeCommand(`process.exit(1)`)); + + await runner.runOnce(); + + expect(runner.getOutput()).toBeNull(); + }); + + it('notifies onChange only when the output changes', async () => { + let notifications = 0; + const runner = makeRunner(nodeCommand(`process.stdout.write('same')`), () => { + notifications += 1; + }); + + await runner.runOnce(); + await runner.runOnce(); + + expect(notifications).toBe(1); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index dd2d4b0ba5..d6fd19cbda 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -350,6 +350,9 @@ 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 | +| `[statusline].command` | `string` | `""` | Shell command for a scriptable statusline: its first stdout line (ANSI SGR colors included) is rendered as a third footer line; empty disables the feature | +| `[statusline].interval_ms` | `integer` | `2000` | How often the statusline command runs; clamped to at least 300 | +| `[statusline].timeout_ms` | `integer` | `5000` | Per-run timeout for the statusline command; the process is killed past it and the last successful output is kept | ```toml # ~/.kimi-code/tui.toml @@ -365,8 +368,15 @@ notification_condition = "unfocused" # "unfocused" | "always" [upgrade] auto_install = true + +[statusline] +command = "" # empty disables the statusline +interval_ms = 2000 +timeout_ms = 5000 ``` +On every run the statusline command receives the current session context as JSON on stdin — `session_id`, `version`, `model` (`id` and `display_name`), `workspace.current_dir`, `permission_mode`, `plan_mode`, and `context` token usage (`used_tokens`, `max_tokens`, `percent`) — so a script can render any of it without further calls. Only the first stdout line is used; a failing or timed-out run keeps the previous output, and before the first successful run no extra line is shown. + Changes apply on the next start, or immediately with `/reload-tui` (which reloads only `tui.toml`); `/reload` reloads both `config.toml` and `tui.toml`. ## Project-local configuration diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 0a71711ace..a7dd9da9bf 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -350,6 +350,9 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | | `[upgrade].auto_install` | `boolean` | `true` | 是否自动安装新版本 | +| `[statusline].command` | `string` | `""` | 可脚本化的状态栏命令:其 stdout 第一行(支持 ANSI SGR 颜色)渲染为 footer 第三行;留空关闭 | +| `[statusline].interval_ms` | `integer` | `2000` | 状态栏命令的执行周期;最小钳制到 300 | +| `[statusline].timeout_ms` | `integer` | `5000` | 状态栏命令单次执行超时;超时进程会被杀掉,并保留上一次成功的输出 | ```toml # ~/.kimi-code/tui.toml @@ -365,8 +368,15 @@ notification_condition = "unfocused" # "unfocused" | "always" [upgrade] auto_install = true + +[statusline] +command = "" # 留空关闭状态栏 +interval_ms = 2000 +timeout_ms = 5000 ``` +每次执行时,状态栏命令会通过 stdin 收到当前会话上下文的 JSON——`session_id`、`version`、`model`(`id` 与 `display_name`)、`workspace.current_dir`、`permission_mode`、`plan_mode`,以及 `context` token 用量(`used_tokens`、`max_tokens`、`percent`)——脚本无需额外调用即可渲染这些信息。输出只取第一行;执行失败或超时保留上一次的输出,首次成功之前不显示额外行。 + 修改在下次启动时生效,或用 `/reload-tui` 立即生效(只重载 `tui.toml`);`/reload` 会同时重载 `config.toml` 和 `tui.toml`。 ## 项目级本地配置