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`。 ## 项目级本地配置