From 3778559c668151a9085e62129ebff72d4c447caa Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:54:32 +0800 Subject: [PATCH 1/4] feat(tui): customizable footer status line via status_line config The bottom status bar was a fixed layout. Add a [status_line] section to tui.toml covering the two established models: - items: codex-style composition. Pick and order the built-in slots (mode, goal, model, tasks, cwd, git, tips); unset keeps today's layout, unknown ids are skipped with a warning, and an empty list blanks line 1. - command: claude-code-style custom line. The footer runs the command with a JSON snapshot on stdin (model, cwd, git branch, permission and plan mode, context usage, session id, version) and renders the first stdout line. Runs are throttled to one per second and capped at 300ms; nonzero exit, empty output, or a timeout falls back to the built-in layout. Line 2 (context readout) stays built-in in every mode. Resolve #2116. --- .changeset/status-line-config.md | 5 + .../src/tui/components/chrome/footer.ts | 206 ++++++++++++------ apps/kimi-code/src/tui/config.ts | 53 +++++ apps/kimi-code/src/tui/kimi-tui.ts | 1 + apps/kimi-code/src/tui/types.ts | 4 +- .../src/tui/utils/status-line-command.ts | 120 ++++++++++ .../chrome/footer-status-line.test.ts | 173 +++++++++++++++ apps/kimi-code/test/tui/config.test.ts | 53 +++++ .../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 + 12 files changed, 554 insertions(+), 65 deletions(-) create mode 100644 .changeset/status-line-config.md create mode 100644 apps/kimi-code/src/tui/utils/status-line-command.ts create mode 100644 apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts diff --git a/.changeset/status-line-config.md b/.changeset/status-line-config.md new file mode 100644 index 0000000000..ad1cbc1524 --- /dev/null +++ b/.changeset/status-line-config.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': minor +--- + +Customizable footer status line via `[status_line]` in `tui.toml`: pick and order the built-in slots with `items` (`mode`, `goal`, `model`, `tasks`, `cwd`, `git`, `tips`), or set `command` to render the first stdout line of a user script that receives a JSON session snapshot on stdin. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a2e8d7adee..983ce52b60 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 { + StatusLineCommandRunner, + type StatusLinePayload, +} from '#/tui/utils/status-line-command'; import { createGitStatusCache, formatGitBadgeBase, @@ -29,6 +33,8 @@ import { usagePercentFromRatio, } from '#/utils/usage/usage-format'; +const DEFAULT_STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git'] as const; + const MAX_CWD_SEGMENTS = 3; const GOAL_TIMER_INTERVAL_MS = 1_000; @@ -191,6 +197,7 @@ export class FooterComponent implements Component { private goalSnapshotKey: string | null = null; private goalObservedAtMs = Date.now(); private goalTimer: ReturnType | null = null; + private statusLineRunner: StatusLineCommandRunner | null = null; /** * Non-terminal background-task counts split by kind so the footer can * render two distinct badges. `bashTasks` covers `bash-*` BPM tasks @@ -208,6 +215,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); } setState(state: AppState): void { @@ -217,9 +225,21 @@ export class FooterComponent implements Component { } this.syncGoalClock(state.goal); this.syncGoalTimer(state.goal); + this.syncStatusLineRunner(state); this.state = state; } + private syncStatusLineRunner(state: AppState): void { + const command = state.statusLine?.command ?? null; + if (command === null) { + this.statusLineRunner = null; + return; + } + if (this.statusLineRunner === null) { + this.statusLineRunner = new StatusLineCommandRunner(command, this.onRefresh); + } + } + /** * Short-lived hint that replaces the rotating toolbar tips on line 1. * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C @@ -250,23 +270,114 @@ export class FooterComponent implements Component { const colors = currentTheme.palette; const state = this.state; - // ── Line 1: mode badges + model + [N task(s) running] + [N agent(s) running] + cwd + git + hints ── - const left: string[] = []; + // ── Line 1: slots composed per status_line.items, or a user command ── + let line1: string; + let customLine: string | null = null; + if (this.statusLineRunner !== null) { + this.statusLineRunner.maybeRefresh(this.statusLinePayload()); + customLine = this.statusLineRunner.current(); + } + + if (customLine !== null) { + // status_line.command: the first stdout line takes over line 1. + line1 = chalk.hex(colors.text)(customLine); + } else { + const slots = this.buildSlots(colors); + const configured = this.state.statusLine?.items ?? null; + const order: readonly string[] = configured ?? DEFAULT_STATUS_LINE_ITEMS; + const left: string[] = []; + for (const slot of order) { + const pieces = slots[slot as keyof typeof slots]; + if (pieces !== undefined) left.push(...pieces); + } + + const leftLine = left.join(' '); + const leftWidth = visibleWidth(leftLine); + + // Rotating hint tips stay on the right unless the user dropped 'tips' + // from items (or a command took the line, handled above). + let tipText = ''; + const showTips = configured === null || configured.includes('tips'); + if (showTips) { + const { primary, pair } = tipsForIndex(currentTipIndex()); + const gap = 2; + const remaining = Math.max(0, width - leftWidth - gap); + if (pair && visibleWidth(pair) <= remaining) { + tipText = pair; + } else if (primary && visibleWidth(primary) <= remaining) { + tipText = primary; + } + } + + if (tipText) { + const pad = width - leftWidth - visibleWidth(tipText); + line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText); + } else if (leftWidth <= width) { + line1 = leftLine; + } else { + 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); + let line2: string; + if (this.transientHint) { + const maxHintWidth = Math.max(0, width - contextWidth - 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); + line2 = + chalk.hex(colors.warning).bold(shownHint) + + ' '.repeat(pad) + + chalk.hex(colors.text)(contextText); + } else { + const leftPad = Math.max(0, width - contextWidth); + line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + } + + return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + } + + /** + * Rendered pieces per status-line slot. Empty-content slots (e.g. no goal, + * outside a git repo) yield an empty list so composition just skips them. + */ + private buildSlots(colors: ColorPalette): Record { + const state = this.state; + const slots: Record = { + mode: [], + goal: [], + model: [], + tasks: [], + cwd: [], + git: [], + }; + const modes: string[] = []; if (state.permissionMode === 'auto') modes.push(chalk.hex(colors.warning).bold('auto')); if (state.permissionMode === 'yolo') modes.push(chalk.hex(colors.warning).bold('yolo')); if (state.planMode) modes.push(chalk.hex(colors.primary).bold('plan')); if (state.swarmMode) modes.push(chalk.hex(colors.accent).bold('swarm')); - if (modes.length > 0) left.push(modes.join(' ')); + if (modes.length > 0) slots['mode'] = [modes.join(' ')]; const goalBadge = formatGoalBadge(state.goal, colors, this.goalWallClockMs(state.goal)); - if (goalBadge !== null) left.push(goalBadge); + if (goalBadge !== null) slots['goal'] = [goalBadge]; const model = modelDisplayName(state); if (model) { const effort = state.thinkingEffort; const rawCurrentModel = state.availableModels[state.model]; - const currentModel = rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel); + const currentModel = + rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel); // Only effort-capable models (those declaring support_efforts) show the // concrete effort; legacy boolean models keep the plain "thinking" suffix. const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0; @@ -281,83 +392,50 @@ export class FooterComponent implements Component { if (isRainbowDancing()) { renderedModelLabel = renderDanceFooterModel(modelLabel); } - left.push(renderedModelLabel); + slots['model'] = [renderedModelLabel]; } - // Background-task badges sit immediately before cwd. `bash-*` tasks - // (shell processes) and `agent-*` tasks (background subagents) get - // separate badges so the user can distinguish them at a glance. + // Background-task badges. `bash-*` tasks (shell processes) and `agent-*` + // tasks (background subagents) stay separate so the user can tell them + // apart at a glance. + const taskBadges: string[] = []; if (this.backgroundBashTaskCount > 0) { const noun = this.backgroundBashTaskCount === 1 ? 'task' : 'tasks'; - left.push( + taskBadges.push( chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)} ${noun} running]`), ); } if (this.backgroundAgentCount > 0) { const noun = this.backgroundAgentCount === 1 ? 'agent' : 'agents'; - left.push( + taskBadges.push( chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)} ${noun} running]`), ); } + slots['tasks'] = taskBadges; const cwd = shortenCwd(state.workDir); - if (cwd) left.push(chalk.hex(colors.textDim)(cwd)); + if (cwd) slots['cwd'] = [chalk.hex(colors.textDim)(cwd)]; const git = this.gitCache.getStatus(); - if (git !== null) { - left.push(formatFooterGitBadge(git, colors)); - } - - const leftLine = left.join(' '); - const leftWidth = visibleWidth(leftLine); - - // Rotating hint tips, fill remaining space on line 1. - const { primary, pair } = tipsForIndex(currentTipIndex()); - const gap = 2; - const remaining = Math.max(0, width - leftWidth - gap); - let tipText = ''; - if (pair && visibleWidth(pair) <= remaining) { - tipText = pair; - } else if (primary && visibleWidth(primary) <= remaining) { - tipText = primary; - } + if (git !== null) slots['git'] = [formatFooterGitBadge(git, colors)]; - let line1: string; - if (tipText) { - const pad = width - leftWidth - visibleWidth(tipText); - line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText); - } else if (leftWidth <= width) { - line1 = leftLine; - } else { - 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); - let line2: string; - if (this.transientHint) { - const maxHintWidth = Math.max(0, width - contextWidth - 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); - line2 = - chalk.hex(colors.warning).bold(shownHint) + - ' '.repeat(pad) + - chalk.hex(colors.text)(contextText); - } else { - const leftPad = Math.max(0, width - contextWidth); - line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); - } + return slots; + } - return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + private statusLinePayload(): StatusLinePayload { + const state = this.state; + return { + model: modelDisplayName(state), + cwd: state.workDir, + gitBranch: this.gitCache.getStatus()?.branch ?? null, + permissionMode: state.permissionMode, + planMode: state.planMode, + contextUsage: state.contextUsage, + contextTokens: state.contextTokens, + maxContextTokens: state.maxContextTokens, + sessionId: state.sessionId, + version: state.version, + }; } private syncGoalClock(goal: AppState['goal']): void { diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index cd3329b552..8c09b91421 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -30,6 +30,27 @@ export const UpgradePreferencesSchema = z.object({ autoInstall: z.boolean(), }); +export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git', 'tips'] as const; +export type StatusLineItem = (typeof STATUS_LINE_ITEMS)[number]; + +export const StatusLineFileConfigSchema = z.object({ + items: z.array(z.string()).optional(), + command: z.string().optional(), +}); + +export const StatusLineConfigSchema = z.object({ + /** Ordered built-in slots for footer line 1; null means the default layout. */ + items: z.array(z.enum(STATUS_LINE_ITEMS)).nullable(), + /** User command whose first stdout line replaces footer line 1; null disables. */ + command: z.string().nullable(), +}); +export type StatusLineConfig = z.infer; + +export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { + items: null, + command: null, +}; + export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), disable_paste_burst: z.boolean().optional(), @@ -49,6 +70,7 @@ export const TuiConfigFileSchema = z.object({ auto_install: z.boolean().optional(), }) .optional(), + status_line: StatusLineFileConfigSchema.optional(), }); export const TuiConfigSchema = z.object({ @@ -57,6 +79,9 @@ export const TuiConfigSchema = z.object({ editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, + /** Present in every normalized config; optional only so hand-built test + * fixtures from before this field existed still typecheck. */ + statusLine: StatusLineConfigSchema.optional(), }); export type TuiConfigFileShape = z.infer; @@ -79,6 +104,7 @@ export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, + statusLine: DEFAULT_STATUS_LINE_CONFIG, }); /** @@ -133,6 +159,19 @@ export async function saveTuiConfig( export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { const command = config.editor?.command?.trim(); + const statusLineCommand = config.status_line?.command?.trim(); + const knownItems = new Set(STATUS_LINE_ITEMS); + const statusLineItems = + config.status_line?.items + ?.filter((item) => { + const known = knownItems.has(item); + if (!known) { + // oxlint-disable-next-line no-console + console.warn(`[tui.toml] ignoring unknown status_line item: ${item}`); + } + return known; + }) + .map((item) => item as StatusLineItem) ?? null; return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, @@ -145,6 +184,13 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { upgrade: { autoInstall: config.upgrade?.auto_install ?? DEFAULT_UPGRADE_PREFERENCES.autoInstall, }, + statusLine: { + items: statusLineItems, + command: + statusLineCommand === undefined || statusLineCommand.length === 0 + ? null + : statusLineCommand, + }, }); } @@ -165,6 +211,13 @@ notification_condition = "${config.notifications.condition}" # "unfocused" | "al [upgrade] auto_install = ${String(config.upgrade.autoInstall)} # true | false + +# [status_line] +# Pick and order the built-in footer slots: ${STATUS_LINE_ITEMS.join(', ')} +# items = ${JSON.stringify([...STATUS_LINE_ITEMS])} +# Or render your own: a command whose first stdout line replaces footer line 1. +# It receives a JSON snapshot (model, cwd, git, usage, mode) on stdin. +# command = "~/.kimi-code/statusline.sh" `; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f0764c82b9..cb931c5a33 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..34ea098ef2 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; + /** Footer status line customization from tui.toml; absent means the default layout. */ + 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..4b73047e49 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -0,0 +1,120 @@ +/** + * User-provided status line command (`status_line.command` in tui.toml). + * + * The footer spawns the command with a JSON snapshot on stdin and renders the + * first stdout line. Runs are throttled and time-boxed; any failure (spawn + * error, nonzero exit, timeout) yields null so the caller falls back to the + * built-in layout. Mirrors Claude Code's statusLine contract at the seam: + * JSON in, first line out, 300ms ceiling. + */ + +import { spawn } from 'node:child_process'; + +export const STATUS_LINE_COMMAND_TIMEOUT_MS = 300; +export const STATUS_LINE_RERUN_INTERVAL_MS = 1_000; + +export interface StatusLinePayload { + model: string; + cwd: string; + gitBranch: string | null; + permissionMode: string; + planMode: boolean; + contextUsage: number; + contextTokens: number; + maxContextTokens: number; + sessionId: string; + version: string; +} + +export function runStatusLineCommand( + command: string, + payload: StatusLinePayload, + timeoutMs: number = STATUS_LINE_COMMAND_TIMEOUT_MS, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (value: string | null): void => { + if (settled) return; + settled = true; + resolve(value); + }; + + let child; + try { + child = spawn('sh', ['-c', command], { + stdio: ['pipe', 'pipe', 'ignore'], + env: { ...process.env, KIMI_CODE_STATUS_LINE: '1' }, + }); + } catch { + finish(null); + return; + } + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(null); + }, timeoutMs); + timer.unref?.(); + + let stdout = ''; + child.stdout?.setEncoding('utf-8'); + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + child.on('error', () => { + clearTimeout(timer); + finish(null); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { + finish(null); + return; + } + const firstLine = (stdout.split('\n')[0] ?? '').trimEnd(); + finish(firstLine.length > 0 ? firstLine : null); + }); + + child.stdin?.on('error', () => { + // The command closed stdin early (e.g. `true`); nothing more to send. + }); + child.stdin?.end(JSON.stringify(payload)); + }); +} + +/** + * Throttled cache around `runStatusLineCommand` for a sync render path: + * `current()` returns the last good line, and a refresh is kicked off in the + * background at most once per interval. `onUpdate` fires when a fresh line + * lands so the footer can repaint. + */ +export class StatusLineCommandRunner { + private lastRunAt = 0; + private cached: string | null = null; + private inFlight = false; + + constructor( + private readonly command: string, + private readonly onUpdate: () => void, + ) {} + + current(): string | null { + return this.cached; + } + + maybeRefresh(payload: StatusLinePayload): void { + const now = Date.now(); + if (this.inFlight || now - this.lastRunAt < STATUS_LINE_RERUN_INTERVAL_MS) { + return; + } + this.inFlight = true; + this.lastRunAt = now; + void runStatusLineCommand(this.command, payload).then((line) => { + this.inFlight = false; + if (line !== null) { + this.cached = line; + this.onUpdate(); + } + }); + } +} diff --git a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts new file mode 100644 index 0000000000..1e3aabcd66 --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import { FooterComponent } from '#/tui/components/chrome/footer'; +import { + runStatusLineCommand, + StatusLineCommandRunner, + type StatusLinePayload, +} from '#/tui/utils/status-line-command'; +import type { AppState } from '#/tui/types'; + +const baseState: AppState = { + version: '1.2.3', + workDir: '/tmp/project', + additionalDirs: [], + sessionId: 'ses-1', + sessionTitle: null, + model: 'kimi-k2', + permissionMode: 'manual', + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + planMode: false, + inputMode: 'prompt', + swarmMode: false, + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: {}, + availableProviders: {}, + mcpServersSummary: null, +}; + +const payload: StatusLinePayload = { + model: 'kimi-k2', + cwd: '/tmp/project', + gitBranch: 'main', + permissionMode: 'manual', + planMode: false, + contextUsage: 12, + contextTokens: 1024, + maxContextTokens: 8192, + sessionId: 'ses-1', + version: '1.2.3', +}; + +function plain(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('FooterComponent status_line items', () => { + it('renders only the chosen slots in the given order', () => { + const state: AppState = { + ...baseState, + statusLine: { items: ['cwd', 'model'], command: null }, + }; + const footer = new FooterComponent(state); + + const line1 = plain(footer.render(120)[0]!); + const cwdAt = line1.indexOf('/tmp/project'); + const modelAt = line1.indexOf('kimi-k2'); + expect(cwdAt).toBeGreaterThanOrEqual(0); + expect(modelAt).toBeGreaterThan(cwdAt); + expect(line1).not.toContain('goal'); + }); + + it('keeps the default layout when statusLine is unset', () => { + const footer = new FooterComponent({ ...baseState }); + + const line1 = plain(footer.render(120)[0]!); + expect(line1).toContain('kimi-k2'); + expect(line1).toContain('/tmp/project'); + }); + + it('drops the rotating tips when tips is not in items', () => { + const withTips = plain(new FooterComponent(baseState).render(200)[0]!); + const state: AppState = { + ...baseState, + statusLine: { items: ['model', 'cwd'], command: null }, + }; + const withoutTips = plain(new FooterComponent(state).render(200)[0]!); + + expect(withoutTips.length).toBeLessThan(withTips.length); + expect(withoutTips.trimEnd()).toMatch(/kimi-k2 {2}\/tmp\/project$/); + }); + + it('renders nothing on line 1 for an empty items list', () => { + const state: AppState = { + ...baseState, + statusLine: { items: [], command: null }, + }; + const footer = new FooterComponent(state); + + expect(plain(footer.render(120)[0]!).trim()).toBe(''); + }); +}); + +describe('runStatusLineCommand', () => { + it('passes the payload as JSON on stdin and returns the first stdout line', async () => { + const line = await runStatusLineCommand('cat', payload); + + expect(line).not.toBeNull(); + const parsed = JSON.parse(line!); + expect(parsed.model).toBe('kimi-k2'); + expect(parsed.gitBranch).toBe('main'); + expect(parsed.cwd).toBe('/tmp/project'); + }); + + it('returns null on a nonzero exit', async () => { + expect(await runStatusLineCommand('exit 3', payload)).toBeNull(); + }); + + it('returns null on empty output', async () => { + expect(await runStatusLineCommand('true', payload)).toBeNull(); + }); + + it('returns null when the command overruns the timeout', async () => { + expect(await runStatusLineCommand('sleep 2', payload, 100)).toBeNull(); + }); + + it('trims the line and ignores later lines', async () => { + const line = await runStatusLineCommand('printf "first\\nsecond\\n"', payload); + + expect(line).toBe('first'); + }); +}); + +describe('FooterComponent status_line command', () => { + it('swaps line 1 to the command output once it lands', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'printf "my-custom-status"' }, + }; + const footer = new FooterComponent(state); + + // Before the first run completes the built-in layout is still shown. + expect(plain(footer.render(120)[0]!)).toContain('kimi-k2'); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(plain(footer.render(120)[0]!)).toContain('my-custom-status'); + }); + + it('keeps the built-in layout when the command fails', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'exit 1' }, + }; + const footer = new FooterComponent(state); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(plain(footer.render(120)[0]!)).toContain('kimi-k2'); + }); +}); + +describe('StatusLineCommandRunner', () => { + it('caches the last good line and coalesces refreshes in the same interval', async () => { + const runner = new StatusLineCommandRunner('printf "x"', () => {}); + + runner.maybeRefresh(payload); + runner.maybeRefresh(payload); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(runner.current()).toBe('x'); + }); +}); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index c4c5035cdb..7bb3b56e88 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -63,6 +63,7 @@ auto_install = false editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { items: null, command: null }, }); }); @@ -87,6 +88,7 @@ command = " " editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, }); }); @@ -119,6 +121,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { items: null, command: null }, }, filePath, ); @@ -129,6 +132,7 @@ command = " " editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, + statusLine: { items: null, command: null }, }); }); @@ -141,6 +145,7 @@ command = " " editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, + statusLine: DEFAULT_TUI_CONFIG.statusLine, }, filePath, ); @@ -148,3 +153,51 @@ command = " " expect((await loadTuiConfig(filePath)).theme).toBe(theme); }); }); + +describe('TUI config status_line', () => { + it('defaults to null when the section is omitted', () => { + const config = parseTuiConfig(`theme = "dark"`); + + expect(config.statusLine).toEqual({ items: null, command: null }); + }); + + it('parses items and command', () => { + const config = parseTuiConfig(` +[status_line] +items = ["model", "git", "cwd"] +command = "~/.kimi-code/statusline.sh" +`); + + expect(config.statusLine).toEqual({ + items: ['model', 'git', 'cwd'], + command: '~/.kimi-code/statusline.sh', + }); + }); + + it('skips unknown items with a warning instead of failing the whole file', () => { + const config = parseTuiConfig(` +[status_line] +items = ["model", "wat", "git"] +`); + + expect(config.statusLine?.items).toEqual(['model', 'git']); + }); + + it('normalizes an empty command to null', () => { + const config = parseTuiConfig(` +[status_line] +command = " " +`); + + expect(config.statusLine?.command).toBeNull(); + }); + + it('documents status_line in the rendered template', async () => { + await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('[status_line]'); + expect(text).toContain('items'); + expect(text).toContain('command'); + }); +}); 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 7918d998f5..a20a315940 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 @@ -149,6 +149,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, }, 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..8f9549b855 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -95,6 +95,7 @@ function makeStartupInput( editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, ...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..d944a485f8 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -65,6 +65,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, }, 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..0cf9c76c81 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -31,6 +31,7 @@ function makeStartupInput(): KimiTUIStartupInput { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, }, version: '0.0.0-test', workDir: '/tmp/proj-signals', From 02d31ce327e6b908f11e92059c10cc97d25e4c7f Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:58:24 +0800 Subject: [PATCH 2/4] feat(tui): apply status_line on /reload-tui and document it The reload command pushes reloaded tui.toml fields into AppState; the new statusLine field joins that list so edits go live without a restart. The config files reference (EN/ZH) documents the new section. --- apps/kimi-code/src/tui/commands/reload.ts | 1 + docs/en/configuration/config-files.md | 6 ++++++ docs/zh/configuration/config-files.md | 6 ++++++ 3 files changed, 13 insertions(+) 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/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index e8df6e99cf..3fd0958be3 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -388,6 +388,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].items` | `string[]` | `[]` | Built-in slots to show on the first footer line and their order: `mode`, `goal`, `model`, `tasks`, `cwd`, `git`, `tips`. Unset keeps the default layout; unknown ids are skipped with a warning | +| `[status_line].command` | `string` | `""` | Custom status line command. Its first stdout line replaces the first footer line, with a JSON snapshot (model, cwd, git branch, permission mode, plan mode, context usage, session id, version) passed on stdin. Runs are capped at 300ms and throttled to once per second; failures fall back to the built-in layout | ```toml # ~/.kimi-code/tui.toml @@ -403,6 +405,10 @@ notification_condition = "unfocused" # "unfocused" | "always" [upgrade] auto_install = true + +# [status_line] +# items = ["mode", "goal", "model", "tasks", "cwd", "git", "tips"] +# command = "~/.kimi-code/statusline.sh" ``` 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 be8757e99b..447708f326 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -388,6 +388,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].items` | `string[]` | `[]` | 底部状态栏第一行展示哪些内置槽位及其顺序:`mode`、`goal`、`model`、`tasks`、`cwd`、`git`、`tips`。缺省保持默认布局;未知 id 跳过并告警 | +| `[status_line].command` | `string` | `""` | 自定义状态栏命令。其 stdout 第一行替换状态栏第一行,stdin 会收到 JSON 快照(model、cwd、git 分支、permission 模式、plan 模式、上下文用量、session id、版本)。运行上限 300ms、每秒最多一次;失败时回退内置布局 | ```toml # ~/.kimi-code/tui.toml @@ -403,6 +405,10 @@ notification_condition = "unfocused" # "unfocused" | "always" [upgrade] auto_install = true + +# [status_line] +# items = ["mode", "goal", "model", "tasks", "cwd", "git", "tips"] +# command = "~/.kimi-code/statusline.sh" ``` 修改在下次启动时生效,或用 `/reload-tui` 立即生效(只重载 `tui.toml`);`/reload` 会同时重载 `config.toml` 和 `tui.toml`。 From 21a0c7e5c7e46d1262d0b35dc0a85e2a71f6643d Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:16:20 +0800 Subject: [PATCH 3/4] fix(tui): round-trip active status_line on save and harden the runner Codex review on #2255 caught a real one: saveTuiConfig rewrites the whole tui.toml, so changing any other preference dropped an active [status_line] section. Render it live when set (items and command), commented-out guide when unset. Also: spawn the command through ComSpec/cmd.exe on Windows instead of assuming sh.exe, and take the whole process tree down on timeout (process-group kill on POSIX, taskkill /T on Windows) so a script that spawned children cannot leak them. --- apps/kimi-code/src/tui/config.ts | 30 ++++++++++++++----- .../src/tui/utils/status-line-command.ts | 25 ++++++++++++++-- apps/kimi-code/test/tui/config.test.ts | 27 +++++++++++++++++ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 8c09b91421..9e37616f81 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -195,6 +195,28 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { } export function renderTuiConfig(config: TuiConfig): string { + // An active status_line must round-trip: any preference save rewrites the + // whole file, so the section is emitted live when set and left as a + // commented-out guide when unset. + const statusItems = config.statusLine?.items; + const statusCommand = config.statusLine?.command; + const statusLines: string[] = []; + if (statusItems !== null && statusItems !== undefined) { + statusLines.push(`items = ${JSON.stringify(statusItems)}`); + } + if (statusCommand) { + statusLines.push(`command = "${escapeTomlBasicString(statusCommand)}"`); + } + const statusSection = + statusLines.length > 0 + ? `[status_line]\n${statusLines.join('\n')}\n` + : `# [status_line] +# Pick and order the built-in footer slots: ${STATUS_LINE_ITEMS.join(', ')} +# items = ${JSON.stringify([...STATUS_LINE_ITEMS])} +# Or render your own: a command whose first stdout line replaces footer line 1. +# It receives a JSON snapshot (model, cwd, git, usage, mode) on stdin. +# command = "~/.kimi-code/statusline.sh" +`; return `# ~/.kimi-code/tui.toml # Client preferences for kimi-code. # Agent/runtime settings stay in ~/.kimi-code/config.toml. @@ -212,13 +234,7 @@ notification_condition = "${config.notifications.condition}" # "unfocused" | "al [upgrade] auto_install = ${String(config.upgrade.autoInstall)} # true | false -# [status_line] -# Pick and order the built-in footer slots: ${STATUS_LINE_ITEMS.join(', ')} -# items = ${JSON.stringify([...STATUS_LINE_ITEMS])} -# Or render your own: a command whose first stdout line replaces footer line 1. -# It receives a JSON snapshot (model, cwd, git, usage, mode) on stdin. -# command = "~/.kimi-code/statusline.sh" -`; +${statusSection}`; } function escapeTomlBasicString(value: string): string { diff --git a/apps/kimi-code/src/tui/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts index 4b73047e49..349eb73d87 100644 --- a/apps/kimi-code/src/tui/utils/status-line-command.ts +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -39,19 +39,40 @@ export function runStatusLineCommand( resolve(value); }; + const isWin = process.platform === 'win32'; let child; try { - child = spawn('sh', ['-c', command], { + child = spawn(isWin ? (process.env['ComSpec'] ?? 'cmd.exe') : 'sh', isWin ? ['/d', '/s', '/c', command] : ['-c', command], { stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, KIMI_CODE_STATUS_LINE: '1' }, + // Own process group on POSIX so a timeout can drop the whole tree, + // not just the shell wrapper. + detached: !isWin, }); } catch { finish(null); return; } + const killTree = (): void => { + if (child.pid === undefined) return; + if (isWin) { + try { + spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); + } catch { + // best effort + } + } else { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + } + }; + const timer = setTimeout(() => { - child.kill('SIGKILL'); + killTree(); finish(null); }, timeoutMs); timer.unref?.(); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 7bb3b56e88..d4d09eceed 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -201,3 +201,30 @@ command = " " expect(text).toContain('command'); }); }); + +describe('TUI config status_line round-trip', () => { + it('preserves an active status_line across save and reload', async () => { + await saveTuiConfig( + { + ...DEFAULT_TUI_CONFIG, + statusLine: { items: ['model', 'git'], command: '~/.kimi-code/statusline.sh' }, + }, + filePath, + ); + + const reloaded = await loadTuiConfig(filePath); + expect(reloaded.statusLine).toEqual({ + items: ['model', 'git'], + command: '~/.kimi-code/statusline.sh', + }); + }); + + it('keeps the status_line section commented out when unset', async () => { + await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('# [status_line]'); + expect(text).toContain('# items ='); + expect(text).toContain('# command ='); + }); +}); From 178ddb4fc636abfbc4f2cdc3510d5380aaeecd0e Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:12:09 +0800 Subject: [PATCH 4/4] fix(tui): address status_line review: runner lifecycle, capture cap, tips slot - recreate the command runner when a reload swaps status_line.command; the old runner kept executing the previous script until restart - schedule a trailing refresh instead of dropping updates that arrive inside the throttle window, so the last state change always lands - stop accumulating stdout once the first line is complete (and cap a missing-newline stream at 64KB); only the first line is ever rendered - honor the configured position of the tips slot in items instead of always pinning tips to the far right - route unknown status_line.items warnings through the TUI status area on reload instead of raw stderr, which could corrupt the display Changeset text tightened per maintainer note. --- .changeset/status-line-config.md | 2 +- apps/kimi-code/src/tui/commands/reload.ts | 8 +- .../src/tui/components/chrome/footer.ts | 21 ++++- apps/kimi-code/src/tui/config.ts | 25 ++++-- .../src/tui/utils/status-line-command.ts | 46 +++++++++- .../chrome/footer-status-line.test.ts | 89 +++++++++++++++++++ apps/kimi-code/test/tui/config.test.ts | 14 +++ 7 files changed, 190 insertions(+), 15 deletions(-) diff --git a/.changeset/status-line-config.md b/.changeset/status-line-config.md index ad1cbc1524..6aa1f81702 100644 --- a/.changeset/status-line-config.md +++ b/.changeset/status-line-config.md @@ -2,4 +2,4 @@ '@moonshot-ai/kimi-code': minor --- -Customizable footer status line via `[status_line]` in `tui.toml`: pick and order the built-in slots with `items` (`mode`, `goal`, `model`, `tasks`, `cwd`, `git`, `tips`), or set `command` to render the first stdout line of a user script that receives a JSON session snapshot on stdin. +Customizable footer status line: compose built-in slots via `[status_line] items` in `tui.toml`, or render the first stdout line of a user script via `[status_line] command`. diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 200125ba0d..2c0010334b 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -6,13 +6,17 @@ import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; export async function handleReloadTuiCommand(host: SlashCommandHost): Promise { - const tuiConfig = await loadTuiConfig(); + const tuiConfig = await loadTuiConfig(undefined, (message) => + host.showStatus(message, 'warning'), + ); await applyReloadedTuiConfig(host, tuiConfig); host.showStatus('TUI config reloaded.', 'success'); } export async function handleReloadCommand(host: SlashCommandHost): Promise { - const tuiConfig = await loadTuiConfig(); + const tuiConfig = await loadTuiConfig(undefined, (message) => + host.showStatus(message, 'warning'), + ); const session = host.session; if (session !== undefined) { diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 983ce52b60..4bb8a75f9b 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -232,10 +232,14 @@ export class FooterComponent implements Component { private syncStatusLineRunner(state: AppState): void { const command = state.statusLine?.command ?? null; if (command === null) { + this.statusLineRunner?.dispose(); this.statusLineRunner = null; return; } - if (this.statusLineRunner === null) { + if (this.statusLineRunner?.command !== command) { + // A reload can swap one command for another; the old runner would + // otherwise keep executing the previous script until restart. + this.statusLineRunner?.dispose(); this.statusLineRunner = new StatusLineCommandRunner(command, this.onRefresh); } } @@ -294,10 +298,12 @@ export class FooterComponent implements Component { const leftLine = left.join(' '); const leftWidth = visibleWidth(leftLine); - // Rotating hint tips stay on the right unless the user dropped 'tips' - // from items (or a command took the line, handled above). + // Rotating hint tips stay on the right unless they were given an + // inline slot in items (rendered above at their configured position) + // or the user dropped 'tips' from items. let tipText = ''; - const showTips = configured === null || configured.includes('tips'); + const tipsInline = order.includes('tips'); + const showTips = !tipsInline && (configured === null || configured.includes('tips')); if (showTips) { const { primary, pair } = tipsForIndex(currentTipIndex()); const gap = 2; @@ -360,8 +366,15 @@ export class FooterComponent implements Component { tasks: [], cwd: [], git: [], + tips: [], }; + { + const { primary, pair } = tipsForIndex(currentTipIndex()); + const tip = pair ?? primary; + if (tip) slots['tips'] = [chalk.hex(colors.textMuted)(tip)]; + } + const modes: string[] = []; if (state.permissionMode === 'auto') modes.push(chalk.hex(colors.warning).bold('auto')); if (state.permissionMode === 'yolo') modes.push(chalk.hex(colors.warning).bold('yolo')); diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 9e37616f81..36f43ba07b 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -126,7 +126,10 @@ export function getTuiConfigPath(): string { return join(getDataDir(), 'tui.toml'); } -export async function loadTuiConfig(filePath: string = getTuiConfigPath()): Promise { +export async function loadTuiConfig( + filePath: string = getTuiConfigPath(), + warn?: (message: string) => void, +): Promise { if (!existsSync(filePath)) { await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); return DEFAULT_TUI_CONFIG; @@ -134,19 +137,22 @@ export async function loadTuiConfig(filePath: string = getTuiConfigPath()): Prom try { const text = await readFile(filePath, 'utf-8'); - return parseTuiConfig(text); + return parseTuiConfig(text, warn); } catch { throw new TuiConfigParseError(DEFAULT_TUI_CONFIG); } } -export function parseTuiConfig(tomlText: string): TuiConfig { +export function parseTuiConfig( + tomlText: string, + warn?: (message: string) => void, +): TuiConfig { if (tomlText.trim().length === 0) { return DEFAULT_TUI_CONFIG; } const raw = parseToml(tomlText) as Record; const parsed = TuiConfigFileSchema.parse(raw); - return normalizeTuiConfig(parsed); + return normalizeTuiConfig(parsed, warn); } export async function saveTuiConfig( @@ -157,7 +163,13 @@ export async function saveTuiConfig( await writeFile(filePath, renderTuiConfig(config), 'utf-8'); } -export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { +export function normalizeTuiConfig( + config: TuiConfigFileShape, + warn: (message: string) => void = (message) => { + // oxlint-disable-next-line no-console + console.warn(message); + }, +): TuiConfig { const command = config.editor?.command?.trim(); const statusLineCommand = config.status_line?.command?.trim(); const knownItems = new Set(STATUS_LINE_ITEMS); @@ -166,8 +178,7 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { ?.filter((item) => { const known = knownItems.has(item); if (!known) { - // oxlint-disable-next-line no-console - console.warn(`[tui.toml] ignoring unknown status_line item: ${item}`); + warn(`[tui.toml] ignoring unknown status_line item: ${item}`); } return known; }) diff --git a/apps/kimi-code/src/tui/utils/status-line-command.ts b/apps/kimi-code/src/tui/utils/status-line-command.ts index 349eb73d87..6482a4ac7c 100644 --- a/apps/kimi-code/src/tui/utils/status-line-command.ts +++ b/apps/kimi-code/src/tui/utils/status-line-command.ts @@ -12,6 +12,7 @@ import { spawn } from 'node:child_process'; export const STATUS_LINE_COMMAND_TIMEOUT_MS = 300; export const STATUS_LINE_RERUN_INTERVAL_MS = 1_000; +export const STATUS_LINE_MAX_CAPTURE_BYTES = 65_536; export interface StatusLinePayload { model: string; @@ -80,7 +81,17 @@ export function runStatusLineCommand( let stdout = ''; child.stdout?.setEncoding('utf-8'); child.stdout?.on('data', (chunk: string) => { + if (stdout.includes('\n')) return; // first line is complete stdout += chunk; + // Only the first line is ever used; stop accumulating past it (and cap + // a missing-newline stream) so a chatty command can't grow memory + // unboundedly before the timeout lands. + const cut = stdout.indexOf('\n'); + if (cut >= 0) { + stdout = stdout.slice(0, cut + 1); + } else if (stdout.length > STATUS_LINE_MAX_CAPTURE_BYTES) { + stdout = stdout.slice(0, STATUS_LINE_MAX_CAPTURE_BYTES); + } }); child.on('error', () => { clearTimeout(timer); @@ -113,9 +124,11 @@ export class StatusLineCommandRunner { private lastRunAt = 0; private cached: string | null = null; private inFlight = false; + private pendingPayload: StatusLinePayload | null = null; + private trailingTimer: ReturnType | null = null; constructor( - private readonly command: string, + readonly command: string, private readonly onUpdate: () => void, ) {} @@ -126,8 +139,36 @@ export class StatusLineCommandRunner { maybeRefresh(payload: StatusLinePayload): void { const now = Date.now(); if (this.inFlight || now - this.lastRunAt < STATUS_LINE_RERUN_INTERVAL_MS) { + // Don't drop the update: land it as soon as the current gap expires, + // so a final state change is never lost to throttling. + this.pendingPayload = payload; + this.scheduleTrailing(now); return; } + this.startRun(payload, now); + } + + dispose(): void { + if (this.trailingTimer !== null) { + clearTimeout(this.trailingTimer); + this.trailingTimer = null; + } + this.pendingPayload = null; + } + + private scheduleTrailing(now: number): void { + if (this.trailingTimer !== null) return; + const waitMs = Math.max(0, STATUS_LINE_RERUN_INTERVAL_MS - (now - this.lastRunAt)); + this.trailingTimer = setTimeout(() => { + this.trailingTimer = null; + const pending = this.pendingPayload; + this.pendingPayload = null; + if (pending !== null) this.maybeRefresh(pending); + }, waitMs); + this.trailingTimer.unref?.(); + } + + private startRun(payload: StatusLinePayload, now: number): void { this.inFlight = true; this.lastRunAt = now; void runStatusLineCommand(this.command, payload).then((line) => { @@ -136,6 +177,9 @@ export class StatusLineCommandRunner { this.cached = line; this.onUpdate(); } + const pending = this.pendingPayload; + this.pendingPayload = null; + if (pending !== null) this.maybeRefresh(pending); }); } } diff --git a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts index 1e3aabcd66..a6be39adfb 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -1,8 +1,13 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; import { runStatusLineCommand, + STATUS_LINE_MAX_CAPTURE_BYTES, StatusLineCommandRunner, type StatusLinePayload, } from '#/tui/utils/status-line-command'; @@ -90,6 +95,33 @@ describe('FooterComponent status_line items', () => { expect(withoutTips.trimEnd()).toMatch(/kimi-k2 {2}\/tmp\/project$/); }); + it('honors the configured position of the tips slot', () => { + // The tip content itself rotates; locate it via a tips-only render. + const tipsOnly = plain( + new FooterComponent({ + ...baseState, + statusLine: { items: ['tips'], command: null }, + }).render(200)[0]!, + ).trim(); + + const tipsFirst = plain( + new FooterComponent({ + ...baseState, + statusLine: { items: ['tips', 'model'], command: null }, + }).render(200)[0]!, + ); + const tipsLast = plain( + new FooterComponent({ + ...baseState, + statusLine: { items: ['model', 'tips'], command: null }, + }).render(200)[0]!, + ); + + expect(tipsOnly.length).toBeGreaterThan(0); + expect(tipsFirst.indexOf(tipsOnly)).toBeLessThan(tipsFirst.indexOf('kimi-k2')); + expect(tipsLast.indexOf('kimi-k2')).toBeLessThan(tipsLast.indexOf(tipsOnly)); + }); + it('renders nothing on line 1 for an empty items list', () => { const state: AppState = { ...baseState, @@ -129,6 +161,17 @@ describe('runStatusLineCommand', () => { expect(line).toBe('first'); }); + + it('caps the captured output instead of accumulating an unending stream', async () => { + // 200 KB on a single line, then exit: only the capped prefix is kept. + const line = await runStatusLineCommand( + 'head -c 200000 /dev/zero | tr "\\0" "a"', + payload, + ); + + expect(line).not.toBeNull(); + expect(line!.length).toBeLessThanOrEqual(STATUS_LINE_MAX_CAPTURE_BYTES); + }); }); describe('FooterComponent status_line command', () => { @@ -170,4 +213,50 @@ describe('StatusLineCommandRunner', () => { expect(runner.current()).toBe('x'); }); + + it('runs a deferred refresh after the throttle interval instead of dropping it', async () => { + const dir = join(tmpdir(), `sl-trailing-${process.pid}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + try { + const counterFile = join(dir, 'count'); + const scriptFile = join(dir, 'count.sh'); + writeFileSync(counterFile, '0'); + writeFileSync( + scriptFile, + '#!/bin/sh\nn=$(cat "$1")\necho $((n+1)) > "$1"\nprintf "run-%s" "$n"\n', + ); + const runner = new StatusLineCommandRunner(`sh ${scriptFile} ${counterFile}`, () => {}); + + runner.maybeRefresh(payload); + await new Promise((resolve) => setTimeout(resolve, 250)); + runner.maybeRefresh(payload); // throttled: must defer, not drop + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(readFileSync(counterFile, 'utf-8').trim()).toBe('1'); + + await new Promise((resolve) => setTimeout(resolve, 800)); + expect(readFileSync(counterFile, 'utf-8').trim()).toBe('2'); + runner.dispose(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('recreates the runner when the command changes', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'printf "aaa"' }, + }; + const footer = new FooterComponent(state); + footer.render(120); // kicks the first run + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(plain(footer.render(120)[0]!)).toContain('aaa'); + + footer.setState({ ...state, statusLine: { items: null, command: 'printf "bbb"' } }); + footer.render(120); // kicks the replacement run + await new Promise((resolve) => setTimeout(resolve, 450)); + + const line1 = plain(footer.render(120)[0]!); + expect(line1).toContain('bbb'); + expect(line1).not.toContain('aaa'); + }); }); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index d4d09eceed..664fda616f 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -183,6 +183,20 @@ items = ["model", "wat", "git"] expect(config.statusLine?.items).toEqual(['model', 'git']); }); + it('routes unknown-item warnings through the provided callback instead of stderr', () => { + const warnings: string[] = []; + const config = parseTuiConfig( + ` +[status_line] +items = ["model", "wat", "git"] +`, + (message) => warnings.push(message), + ); + + expect(config.statusLine?.items).toEqual(['model', 'git']); + expect(warnings).toEqual(['[tui.toml] ignoring unknown status_line item: wat']); + }); + it('normalizes an empty command to null', () => { const config = parseTuiConfig(` [status_line]